From 148b5a929bb748ba0c5713b64f83717ad0c20456 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 26 Aug 2026 13:41:45 +0000 Subject: [PATCH 1/3] fix(site): let organization admins manage agent templates --- site/permissions.json | 4 ++ site/src/modules/dashboard/Navbar/Navbar.tsx | 1 + .../AISettingsSidebarView.stories.tsx | 3 +- .../management/AISettingsSidebarView.tsx | 7 +- site/src/modules/permissions/organizations.ts | 7 ++ .../AISettingsIndexRedirect.tsx | 4 ++ .../TemplatesPage/TemplatesPage.stories.tsx | 67 +++++++++++++------ .../TemplatesPage/TemplatesPage.tsx | 33 +++++++-- site/src/testHelpers/entities.ts | 4 ++ 9 files changed, 102 insertions(+), 28 deletions(-) diff --git a/site/permissions.json b/site/permissions.json index fa4e3c3d6ba..c9a0ad94382 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 cb6ceccb1a1..4a0024011f6 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/management/AISettingsSidebarView.stories.tsx b/site/src/modules/management/AISettingsSidebarView.stories.tsx index 8b024d4840b..7bbe9a666c2 100644 --- a/site/src/modules/management/AISettingsSidebarView.stories.tsx +++ b/site/src/modules/management/AISettingsSidebarView.stories.tsx @@ -190,6 +190,7 @@ export const NoDeploymentConfig: Story = { permissions: { ...MockPermissions, editDeploymentConfig: false, + updateAnyTemplate: false, }, }, play: async ({ canvasElement }) => { @@ -203,7 +204,7 @@ 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 cccec41148e..8cab67270ab 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 570a00b678d..e70bf473a54 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 c9d45e1dc71..de236cc72fd 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 1cf4f88462b..ce56afdf3e4 100644 --- a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx +++ b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx @@ -1,10 +1,13 @@ 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, + MockOrganizationPermissions, MockTemplate, MockUserOwner, mockApiError, @@ -40,14 +43,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; @@ -62,6 +70,12 @@ export const ServerSideFilter: Story = { key: getTemplatesQueryKey({ q: "" }), data: [MockTemplate, mockSecondTemplate], }, + { + key: organizationsPermissions([MockDefaultOrganization.id]).queryKey, + data: { + [MockDefaultOrganization.id]: MockOrganizationPermissions, + }, + }, ], }, beforeEach: () => { @@ -97,6 +111,12 @@ export const ConcurrentToggles: Story = { key: getTemplatesQueryKey({ q: "" }), data: [MockTemplate, mockSecondTemplate], }, + { + key: organizationsPermissions([MockDefaultOrganization.id]).queryKey, + data: { + [MockDefaultOrganization.id]: MockOrganizationPermissions, + }, + }, ], }, beforeEach: () => { @@ -240,38 +260,46 @@ export const DisplaysFallbackMutationError: Story = { }, }; -export const NoDeploymentConfigPermission: Story = { +export const OrganizationTemplateAdmin: Story = { parameters: { permissions: { - editDeploymentConfig: false, - updateTemplates: true, + updateAnyTemplate: true, viewAllUsers: true, }, - queries: [], + queries: [ + { + key: organizationsPermissions([MockDefaultOrganization.id]).queryKey, + data: { + [MockDefaultOrganization.id]: MockOrganizationPermissions, + }, + }, + ], }, beforeEach: () => { - spyOn(API, "getTemplates").mockResolvedValue([]); + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); 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(); - expect(API.getTemplates).not.toHaveBeenCalled(); - expect(API.getUsers).not.toHaveBeenCalled(); + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(await canvas.findByText("Test Template")).toBeVisible(); + 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]); @@ -291,8 +319,7 @@ export const FetchesWhenAllowed: Story = { export const NoUpdateTemplatesPermission: Story = { parameters: { permissions: { - editDeploymentConfig: true, - updateTemplates: false, + updateAnyTemplate: false, }, }, play: async () => { diff --git a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.tsx b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.tsx index a34fa79e09b..0ae4ed56157 100644 --- a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.tsx @@ -3,9 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { useSearchParams } from "react-router"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; +import { organizationsPermissions } from "#/api/queries/organizations"; import { templates, updateTemplateMeta } from "#/api/queries/templates"; import type * as TypesGen from "#/api/typesGenerated"; import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { useDashboard } from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { useTemplatesFilter } from "#/pages/TemplatesPage/TemplatesFilter"; import { pageTitle } from "#/utils/page"; @@ -13,9 +15,23 @@ import { TemplatesPageView } from "./TemplatesPageView"; const TemplatesPage: FC = () => { const { permissions } = useAuthenticated(); + const { organizations } = useDashboard(); const queryClient = useQueryClient(); - const canManageTemplates = - permissions.editDeploymentConfig && permissions.updateTemplates; + const canManageTemplates = permissions.updateAnyTemplate; + const organizationPermissionsQuery = useQuery({ + ...organizationsPermissions( + organizations.map((organization) => organization.id), + ), + enabled: canManageTemplates, + }); + const authorizedOrganizationIDs = new Set( + organizations + .filter( + (organization) => + organizationPermissionsQuery.data?.[organization.id]?.updateTemplates, + ) + .map((organization) => organization.id), + ); const [searchParams, setSearchParams] = useSearchParams(); const filterState = useTemplatesFilter({ searchParams, @@ -24,8 +40,11 @@ const TemplatesPage: FC = () => { }); const templatesQuery = useQuery({ ...templates({ q: filterState.filter.query }), - enabled: canManageTemplates, + enabled: canManageTemplates && organizationPermissionsQuery.isSuccess, }); + const authorizedTemplates = templatesQuery.data?.filter((template) => + authorizedOrganizationIDs.has(template.organization_id), + ); const updateTemplateMutation = useMutation(updateTemplateMeta(queryClient)); const [pendingTemplateIDs, setPendingTemplateIDs] = useState< ReadonlySet @@ -64,9 +83,11 @@ const TemplatesPage: FC = () => { void templatesQuery.refetch()} onToggleAgentsAllowed={toggleAgentsAllowed} pendingTemplateIDs={pendingTemplateIDs} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 4ae390bca1c..3cade7c241e 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3377,6 +3377,7 @@ export const MockPermissions: Permissions = { createWorkspace: true, deleteTemplates: true, updateTemplates: true, + updateAnyTemplate: true, viewAllUsers: true, updateUsers: true, viewAnyAuditLog: true, @@ -3423,6 +3424,7 @@ export const MockNoPermissions: Permissions = { createWorkspace: false, deleteTemplates: false, updateTemplates: false, + updateAnyTemplate: false, viewAllUsers: false, updateUsers: false, viewAnyAuditLog: false, @@ -3479,6 +3481,7 @@ export const MockOrganizationPermissions: OrganizationPermissions = { viewProvisionerJobs: true, viewIdpSyncSettings: true, editIdpSyncSettings: true, + updateTemplates: true, viewMCPServerConfigs: true, createMCPServerConfig: true, updateMCPServerConfig: true, @@ -3507,6 +3510,7 @@ export const MockNoOrganizationPermissions: OrganizationPermissions = { viewProvisionerJobs: false, viewIdpSyncSettings: false, editIdpSyncSettings: false, + updateTemplates: false, viewMCPServerConfigs: false, createMCPServerConfig: false, updateMCPServerConfig: false, From 080daaf88bde001e7bc79460b52ffceffe995000 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 26 Aug 2026 14:17:35 +0000 Subject: [PATCH 2/3] fix(site): address template admin review feedback --- .../AISettingsSidebarView.stories.tsx | 28 ++++++++- .../AISettingsIndexRedirect.test.tsx | 30 ++++++++++ .../TemplatesPage/TemplatesPage.stories.tsx | 60 ++++++++++++++++++- .../TemplatesPage/TemplatesPage.tsx | 8 ++- 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/site/src/modules/management/AISettingsSidebarView.stories.tsx b/site/src/modules/management/AISettingsSidebarView.stories.tsx index 7bbe9a666c2..05d5ccba5c1 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 { @@ -200,6 +200,32 @@ 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: { diff --git a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.test.tsx b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.test.tsx index 668bf587db8..70f23f2761d 100644 --- a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.test.tsx +++ b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.test.tsx @@ -57,3 +57,33 @@ it("redirects a deployment administrator to Coder Agents", async () => { await screen.findByText("Coder Agents"); expect(router.state.location.pathname).toBe("/ai/settings/coder-agents"); }); + +it("redirects an organization template administrator to Templates", async () => { + permissions = { ...MockNoPermissions, updateAnyTemplate: true }; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.spyOn(API.experimental, "getChatModels").mockRejectedValue({ + isAxiosError: true, + response: { status: 403 }, + }); + const router = createMemoryRouter( + [ + { path: "/ai/settings", element: }, + { + path: "/ai/settings/templates", + element:
Templates
, + }, + ], + { initialEntries: ["/ai/settings"] }, + ); + + render( + + + , + ); + + await screen.findByText("Templates"); + expect(router.state.location.pathname).toBe("/ai/settings/templates"); +}); diff --git a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx index ce56afdf3e4..f555f400e16 100644 --- a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx +++ b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx @@ -7,6 +7,8 @@ import type { Template } from "#/api/typesGenerated"; import { createDeferred, type Deferred } from "#/testHelpers/deferred"; import { MockDefaultOrganization, + MockNoOrganizationPermissions, + MockOrganization2, MockOrganizationPermissions, MockTemplate, MockUserOwner, @@ -26,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