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;
second: Deferred;
@@ -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;
@@ -62,6 +82,12 @@ export const ServerSideFilter: Story = {
key: getTemplatesQueryKey({ q: "" }),
data: [MockTemplate, mockSecondTemplate],
},
+ {
+ key: organizationsPermissions([MockDefaultOrganization.id]).queryKey,
+ data: {
+ [MockDefaultOrganization.id]: MockOrganizationPermissions,
+ },
+ },
],
},
beforeEach: () => {
@@ -97,6 +123,12 @@ export const ConcurrentToggles: Story = {
key: getTemplatesQueryKey({ q: "" }),
data: [MockTemplate, mockSecondTemplate],
},
+ {
+ key: organizationsPermissions([MockDefaultOrganization.id]).queryKey,
+ data: {
+ [MockDefaultOrganization.id]: MockOrganizationPermissions,
+ },
+ },
],
},
beforeEach: () => {
@@ -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]);
@@ -291,8 +375,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 a34fa79e09b52..e4b8ed8356440 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,15 @@ 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 refetch = organizationPermissionsQuery.error
+ ? organizationPermissionsQuery.refetch
+ : templatesQuery.refetch;
+ const error = organizationPermissionsQuery.error ?? templatesQuery.error;
const updateTemplateMutation = useMutation(updateTemplateMeta(queryClient));
const [pendingTemplateIDs, setPendingTemplateIDs] = useState<
ReadonlySet
@@ -64,10 +87,12 @@ const TemplatesPage: FC = () => {
void templatesQuery.refetch()}
+ templates={authorizedTemplates}
+ isLoading={
+ organizationPermissionsQuery.isLoading || templatesQuery.isLoading
+ }
+ error={error}
+ onRetry={() => void refetch()}
onToggleAgentsAllowed={toggleAgentsAllowed}
pendingTemplateIDs={pendingTemplateIDs}
/>
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts
index 4ae390bca1c0a..3cade7c241ebf 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,