diff --git a/site/permissions.json b/site/permissions.json
index 71b91a51b12c1..55614298ee351 100644
--- a/site/permissions.json
+++ b/site/permissions.json
@@ -15,6 +15,10 @@
"object": { "resource_type": "template", "any_org": true },
"action": "create"
},
+ "viewTemplates": {
+ "object": { "resource_type": "template", "any_org": true },
+ "action": "read"
+ },
"createWorkspace": {
"object": {
"resource_type": "workspace",
@@ -23,6 +27,14 @@
},
"action": "create"
},
+ "viewWorkspaces": {
+ "object": {
+ "resource_type": "workspace",
+ "any_org": true,
+ "owner_id": "me"
+ },
+ "action": "read"
+ },
"updateTemplates": {
"object": { "resource_type": "template" },
"action": "update"
diff --git a/site/src/modules/dashboard/DashboardRedirect.test.tsx b/site/src/modules/dashboard/DashboardRedirect.test.tsx
new file mode 100644
index 0000000000000..15f56c6243dc0
--- /dev/null
+++ b/site/src/modules/dashboard/DashboardRedirect.test.tsx
@@ -0,0 +1,53 @@
+import { screen } from "@testing-library/react";
+import { HttpResponse, http } from "msw";
+import { MockPermissions } from "#/testHelpers/entities";
+import { renderWithAuth } from "#/testHelpers/renderHelpers";
+import { server } from "#/testHelpers/server";
+import { DashboardRedirect } from "./DashboardRedirect";
+
+const renderDashboardRedirect = () => {
+ renderWithAuth(, {
+ path: "/",
+ route: "/",
+ extraRoutes: [
+ { path: "/workspaces", element:
Workspaces
},
+ { path: "/settings/account", element: Account
},
+ ],
+ });
+};
+
+describe("DashboardRedirect", () => {
+ it("redirects to workspaces when the user can read workspaces", async () => {
+ renderDashboardRedirect();
+
+ await screen.findByText("Workspaces");
+ });
+
+ it("redirects to the account page when the user cannot read workspaces", async () => {
+ server.use(
+ http.post("/api/v2/authcheck", () => {
+ return HttpResponse.json({
+ ...MockPermissions,
+ viewWorkspaces: false,
+ });
+ }),
+ );
+
+ renderDashboardRedirect();
+
+ await screen.findByText("Account");
+ });
+
+ it("redirects to workspaces when the check is missing from the response", async () => {
+ server.use(
+ http.post("/api/v2/authcheck", () => {
+ const { viewWorkspaces, ...rest } = MockPermissions;
+ return HttpResponse.json(rest);
+ }),
+ );
+
+ renderDashboardRedirect();
+
+ await screen.findByText("Workspaces");
+ });
+});
diff --git a/site/src/modules/dashboard/DashboardRedirect.tsx b/site/src/modules/dashboard/DashboardRedirect.tsx
new file mode 100644
index 0000000000000..87650a2354d1c
--- /dev/null
+++ b/site/src/modules/dashboard/DashboardRedirect.tsx
@@ -0,0 +1,19 @@
+import type { FC } from "react";
+import { Navigate } from "react-router";
+import { useAuthenticated } from "#/hooks/useAuthenticated";
+import { canViewWorkspaces } from "#/modules/permissions";
+
+/**
+ * Redirects the dashboard index route to the workspaces page, or to account
+ * settings when the user cannot read workspaces.
+ */
+export const DashboardRedirect: FC = () => {
+ const { permissions } = useAuthenticated();
+
+ return (
+
+ );
+};
diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx
index 00193cdcbc0b4..f8db928910146 100644
--- a/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx
+++ b/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { FC } from "react";
-import { fn, userEvent, within } from "storybook/test";
+import { expect, fn, userEvent, within } from "storybook/test";
import {
MockPrimaryWorkspaceProxy,
MockProxyLatencies,
@@ -40,6 +40,9 @@ const meta: Meta = {
supportLinks: MockSupportLinks,
onSignOut: fn(),
isDefaultOpen: true,
+ canViewWorkspaces: true,
+ canViewTemplates: true,
+ canCreateWorkspace: true,
adminPermissions: {
canViewDeployment: true,
canViewOrganizations: true,
@@ -94,6 +97,35 @@ export const Member: Story = {
},
};
+export const WithoutWorkspaceAccess: Story = {
+ args: {
+ user: MockUserMember,
+ adminPermissions: {},
+ canViewWorkspaces: false,
+ canViewTemplates: false,
+ canCreateWorkspace: false,
+ },
+ play: async ({ canvasElement }) => {
+ const body = within(canvasElement.ownerDocument.body);
+ await body.findByText("Workspaces");
+
+ expect(
+ body.queryByRole("link", { name: "Workspaces" }),
+ ).not.toBeInTheDocument();
+ expect(
+ body.queryByRole("menuitem", { name: /workspace proxy settings/i }),
+ ).not.toBeInTheDocument();
+
+ // The reason is visible without hover.
+ await body.findByText(/workspaces are not available/i);
+ const item = body.getByRole("menuitem", { name: /^Workspaces/ });
+ expect(item).toHaveAttribute("aria-disabled", "true");
+ // Radix removes items marked with its own `disabled` prop from roving
+ // focus, which puts the inline message out of keyboard reach.
+ expect(item).not.toHaveAttribute("data-disabled");
+ },
+};
+
export const ProxySettings: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.tsx
index 661341830c71b..3afd10696e9d5 100644
--- a/site/src/modules/dashboard/Navbar/MobileMenu.tsx
+++ b/site/src/modules/dashboard/Navbar/MobileMenu.tsx
@@ -32,6 +32,7 @@ import {
canViewAdminSettings,
} from "./AdminSettings";
import { sortProxiesByLatency } from "./proxyUtils";
+import { restrictedNavMessages } from "./RestrictedNavItem";
const itemStyles = {
default: "px-9 h-10 no-underline",
@@ -42,6 +43,9 @@ const itemStyles = {
type MobileMenuProps = {
proxyContextValue?: ProxyContextValue;
adminPermissions: AdminSettingsPermissions;
+ canCreateWorkspace: boolean;
+ canViewWorkspaces: boolean;
+ canViewTemplates: boolean;
user?: TypesGen.User;
supportLinks?: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
@@ -51,12 +55,35 @@ type MobileMenuProps = {
export const MobileMenu: FC = ({
adminPermissions,
proxyContextValue,
+ canCreateWorkspace,
+ canViewWorkspaces,
+ canViewTemplates,
user,
supportLinks,
onSignOut,
isDefaultOpen,
}) => {
const [open, setOpen] = useState(isDefaultOpen);
+ const navItems = [
+ {
+ label: "Workspaces",
+ to: "/workspaces",
+ enabled: canViewWorkspaces,
+ message: restrictedNavMessages.workspaces,
+ },
+ {
+ label: "Templates",
+ to: "/templates",
+ enabled: canViewTemplates,
+ message: restrictedNavMessages.templates,
+ },
+ {
+ label: "Agents",
+ to: "/agents",
+ enabled: canCreateWorkspace,
+ message: restrictedNavMessages.agents,
+ },
+ ];
return (
@@ -76,17 +103,33 @@ export const MobileMenu: FC = ({
className="w-screen border-0 border-b border-solid p-0 py-2"
sideOffset={17}
>
-
- Workspaces
-
-
- Templates
-
-
- Agents
-
-
-
+ {navItems.map(({ label, to, enabled, message }) =>
+ enabled ? (
+
+ {label}
+
+ ) : (
+ event.preventDefault()}
+ className={cn(itemStyles.default, "h-auto flex-col items-start")}
+ >
+ {label}
+ {message}
+
+ ),
+ )}
+ {canViewWorkspaces && (
+ <>
+
+
+ >
+ )}
{canViewAdminSettings(adminPermissions) && (
<>
diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx
index 75417d5e9c648..0c2dfce8cc518 100644
--- a/site/src/modules/dashboard/Navbar/Navbar.tsx
+++ b/site/src/modules/dashboard/Navbar/Navbar.tsx
@@ -5,7 +5,11 @@ import { useProxy } from "#/contexts/ProxyContext";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { useEmbeddedMetadata } from "#/hooks/useEmbeddedMetadata";
import { useDashboard } from "#/modules/dashboard/useDashboard";
-import { canViewDeploymentSettings } from "#/modules/permissions";
+import {
+ canViewDeploymentSettings,
+ canViewTemplates,
+ canViewWorkspaces,
+} from "#/modules/permissions";
import { useFeatureVisibility } from "../useFeatureVisibility";
import { NavbarView } from "./NavbarView";
@@ -31,6 +35,9 @@ export const Navbar: React.FC = () => {
permissions.viewAIGatewayKeys ||
permissions.editDeploymentConfig;
const canCreateChat = permissions.createChat;
+ const canCreateWorkspace = permissions.createWorkspace;
+ const canViewWorkspacesNav = canViewWorkspaces(permissions);
+ const canViewTemplatesNav = canViewTemplates(permissions);
const uniqueLinks = new Map();
for (const link of appearance.support_links ?? []) {
@@ -54,6 +61,9 @@ export const Navbar: React.FC = () => {
canViewHealth,
}}
canCreateChat={canCreateChat}
+ canCreateWorkspace={canCreateWorkspace}
+ canViewWorkspaces={canViewWorkspacesNav}
+ canViewTemplates={canViewTemplatesNav}
proxyContextValue={proxyContextValue}
/>
);
diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx
index dd88e6cac51a9..b2e6d36c5935c 100644
--- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx
+++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { userEvent, within } from "storybook/test";
+import { expect, userEvent, within } from "storybook/test";
import type { TasksFilter } from "#/api/typesGenerated";
import {
MockBuildInfo,
@@ -40,6 +40,9 @@ const meta: Meta = {
canViewHealth: true,
},
canCreateChat: true,
+ canCreateWorkspace: true,
+ canViewWorkspaces: true,
+ canViewTemplates: true,
supportLinks: [],
},
decorators: [withDashboardProvider],
@@ -122,6 +125,84 @@ export const ForMemberWithAgentsAccess: Story = {
},
};
+export const WithoutWorkspaceAccess: Story = {
+ parameters: { pixel: { matrix: pixelWithDesktop } },
+ args: {
+ user: MockUserMember,
+ adminPermissions: {},
+ canCreateChat: true,
+ canCreateWorkspace: false,
+ canViewWorkspaces: false,
+ canViewTemplates: false,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ for (const label of ["Workspaces", "Templates", "Tasks", "Agents"]) {
+ expect(
+ canvas.queryByRole("link", { name: label }),
+ ).not.toBeInTheDocument();
+ }
+
+ await userEvent.hover(canvas.getByText("Workspaces"));
+ const body = within(canvasElement.ownerDocument.body);
+ const tooltip = await body.findByRole("tooltip");
+ expect(tooltip).toHaveTextContent(/workspaces are not available/i);
+
+ // The message describes the label rather than naming it.
+ canvas.getByRole("button", { name: "Workspaces" });
+ },
+};
+
+// An auditor can read templates while holding no workspace permission.
+export const TemplatesOnly: Story = {
+ parameters: { pixel: { matrix: pixelWithDesktop } },
+ args: {
+ user: MockUserMember,
+ adminPermissions: { canViewAuditLog: true },
+ canCreateChat: false,
+ canCreateWorkspace: false,
+ canViewWorkspaces: false,
+ canViewTemplates: true,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByRole("link", { name: "Templates" });
+ canvas.getByRole("button", { name: "Workspaces" });
+ expect(
+ canvas.queryByRole("link", { name: "Workspaces" }),
+ ).not.toBeInTheDocument();
+ },
+};
+
+// A workspace creation ban leaves the workspace UI usable.
+export const WithoutWorkspaceCreation: Story = {
+ parameters: { pixel: { matrix: pixelWithDesktop } },
+ args: {
+ user: MockUserMember,
+ adminPermissions: {},
+ canCreateChat: true,
+ canCreateWorkspace: false,
+ canViewWorkspaces: true,
+ canViewTemplates: true,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByRole("link", { name: "Workspaces" });
+ expect(
+ canvas.queryByRole("link", { name: "Agents" }),
+ ).not.toBeInTheDocument();
+
+ await userEvent.hover(canvas.getByText("Agents"));
+ const tooltip = await within(canvasElement.ownerDocument.body).findByRole(
+ "tooltip",
+ );
+ expect(tooltip).toHaveTextContent(/permission to create workspaces/i);
+ },
+};
+
export const IdleTasks: Story = {
parameters: {
queries: [
diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx
index 6634521970173..a12ab978563c2 100644
--- a/site/src/modules/dashboard/Navbar/NavbarView.tsx
+++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx
@@ -23,6 +23,7 @@ import {
import { AdminSettingsDropdown } from "./DeploymentDropdown";
import { MobileMenu } from "./MobileMenu";
import { ProxyMenu } from "./ProxyMenu";
+import { RestrictedNavItem, restrictedNavMessages } from "./RestrictedNavItem";
import { SupportIcon } from "./SupportIcon";
import { UserDropdown } from "./UserDropdown/UserDropdown";
@@ -33,13 +34,17 @@ interface NavbarViewProps {
onSignOut: () => void;
adminPermissions: AdminSettingsPermissions;
canCreateChat: boolean;
+ canCreateWorkspace: boolean;
+ canViewWorkspaces: boolean;
+ canViewTemplates: boolean;
proxyContextValue?: ProxyContextValue;
}
const linkStyles = {
- default:
- "text-sm font-medium text-content-secondary no-underline block h-full px-2 flex items-center hover:text-content-primary transition-colors",
+ base: "text-sm font-medium no-underline block h-full px-2 flex items-center transition-colors",
+ default: "text-content-secondary hover:text-content-primary",
active: "text-content-primary",
+ disabled: "text-content-secondary",
};
export const NavbarView: FC = ({
@@ -49,6 +54,9 @@ export const NavbarView: FC = ({
onSignOut,
adminPermissions,
canCreateChat,
+ canCreateWorkspace,
+ canViewWorkspaces,
+ canViewTemplates,
proxyContextValue,
}) => {
const prerelease = getPrereleaseFlag(buildInfo);
@@ -73,7 +81,7 @@ export const NavbarView: FC = ({
: undefined,
}}
>
-
+
@@ -81,6 +89,9 @@ export const NavbarView: FC = ({
className="ml-4 hidden md:flex"
user={user}
canCreateChat={canCreateChat}
+ canCreateWorkspace={canCreateWorkspace}
+ canViewWorkspaces={canViewWorkspaces}
+ canViewTemplates={canViewTemplates}
/>
{prerelease && buildInfo?.version && (
@@ -111,7 +122,7 @@ export const NavbarView: FC = ({
))}
- {proxyContextValue && (
+ {proxyContextValue && canViewWorkspaces && (
@@ -146,6 +157,9 @@ export const NavbarView: FC = ({
= ({ className, user, canCreateChat }) => {
+const NavItems: FC = ({
+ className,
+ user,
+ canCreateChat,
+ canCreateWorkspace,
+ canViewWorkspaces,
+ canViewTemplates,
+}) => {
const location = useLocation();
return (
);
};
type TasksNavItemProps = {
user: TypesGen.User;
+ canViewWorkspaces: boolean;
};
-const TasksNavItem: FC = ({ user }) => {
+const TasksNavItem: FC = ({ user, canViewWorkspaces }) => {
const { metadata } = useEmbeddedMetadata();
const canSeeTasks = Boolean(
metadata["tasks-tab-visible"].value ||
@@ -219,7 +276,7 @@ const TasksNavItem: FC = ({ user }) => {
queryKey: ["tasks", filter],
queryFn: () => API.getTasks(filter),
refetchInterval: 1_000 * 60,
- enabled: canSeeTasks,
+ enabled: canSeeTasks && canViewWorkspaces,
refetchOnWindowFocus: true,
initialData: [],
select: (data) =>
@@ -230,11 +287,24 @@ const TasksNavItem: FC = ({ user }) => {
return null;
}
+ if (!canViewWorkspaces) {
+ return (
+
+ Tasks
+
+ );
+ }
+
return (
{
- return cn(linkStyles.default, { [linkStyles.active]: isActive });
+ return cn(linkStyles.base, linkStyles.default, {
+ [linkStyles.active]: isActive,
+ });
}}
>
Tasks
diff --git a/site/src/modules/dashboard/Navbar/RestrictedNavItem.tsx b/site/src/modules/dashboard/Navbar/RestrictedNavItem.tsx
new file mode 100644
index 0000000000000..4bcd6aa3c0445
--- /dev/null
+++ b/site/src/modules/dashboard/Navbar/RestrictedNavItem.tsx
@@ -0,0 +1,60 @@
+import type { FC, ReactNode } from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "#/components/Tooltip/Tooltip";
+import { cn } from "#/utils/cn";
+
+// TODO(PLAT-460): placeholder copy, replace once the final messages are
+// available.
+export const restrictedNavMessages = {
+ workspaces:
+ "Workspaces are not available for your account. Contact your administrator to request access.",
+ templates:
+ "Templates are not available for your account. Contact your administrator to request access.",
+ tasks:
+ "Tasks are not available for your account. Contact your administrator to request access.",
+ agents:
+ "Agents need permission to create workspaces. Contact your administrator to request access.",
+} as const;
+
+type RestrictedNavItemProps = {
+ className?: string;
+ message: string;
+ children: ReactNode;
+};
+
+/**
+ * An inert navigation label with the message in a tooltip, which opens on hover
+ * and on focus. The accessible name is the label alone; the tooltip supplies the
+ * description.
+ *
+ * The label is a button so that it is focusable and announced as unavailable. It
+ * performs no action. Only the label text is dimmed, so the focus ring renders
+ * at full strength.
+ */
+export const RestrictedNavItem: FC = ({
+ className,
+ message,
+ children,
+}) => {
+ return (
+
+
+
+
+ {message}
+
+ );
+};
diff --git a/site/src/modules/permissions/index.test.ts b/site/src/modules/permissions/index.test.ts
new file mode 100644
index 0000000000000..034c2e861fbfd
--- /dev/null
+++ b/site/src/modules/permissions/index.test.ts
@@ -0,0 +1,55 @@
+import { MockPermissions } from "#/testHelpers/entities";
+import { canViewTemplates, canViewWorkspaces, type Permissions } from ".";
+
+const permissionsWithout = (key: keyof Permissions): Permissions => {
+ const { [key]: _omitted, ...rest } = MockPermissions;
+ return rest as Permissions;
+};
+
+describe("canViewWorkspaces", () => {
+ it.each([
+ [true, true],
+ [false, false],
+ ])("returns %s when viewWorkspaces is %s", (expected, value) => {
+ expect(
+ canViewWorkspaces({ ...MockPermissions, viewWorkspaces: value }),
+ ).toBe(expected);
+ });
+
+ it("returns true when the check is absent", () => {
+ expect(canViewWorkspaces(permissionsWithout("viewWorkspaces"))).toBe(true);
+ });
+});
+
+describe("canViewTemplates", () => {
+ it.each([
+ [true, true, true],
+ [true, true, false],
+ [true, false, true],
+ [false, false, false],
+ ])("returns %s when viewTemplates is %s and viewWorkspaces is %s", (expected, viewTemplates, viewWorkspaces) => {
+ expect(
+ canViewTemplates({
+ ...MockPermissions,
+ viewTemplates,
+ viewWorkspaces,
+ }),
+ ).toBe(expected);
+ });
+
+ it("returns true when either check is absent", () => {
+ expect(canViewTemplates(permissionsWithout("viewTemplates"))).toBe(true);
+ expect(
+ canViewTemplates({
+ ...permissionsWithout("viewTemplates"),
+ viewWorkspaces: false,
+ }),
+ ).toBe(true);
+ expect(
+ canViewTemplates({
+ ...permissionsWithout("viewWorkspaces"),
+ viewTemplates: false,
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/site/src/modules/permissions/index.ts b/site/src/modules/permissions/index.ts
index 06a8d631ff278..86e3411260949 100644
--- a/site/src/modules/permissions/index.ts
+++ b/site/src/modules/permissions/index.ts
@@ -15,6 +15,14 @@ export const permissionChecks =
permissionChecksData as typeof permissionChecksData &
Record;
+export const canViewWorkspaces = (permissions: Permissions): boolean => {
+ return permissions.viewWorkspaces !== false;
+};
+
+export const canViewTemplates = (permissions: Permissions): boolean => {
+ return permissions.viewTemplates !== false || canViewWorkspaces(permissions);
+};
+
export const canViewDeploymentSettings = (
permissions: Permissions | undefined,
): permissions is Permissions => {
diff --git a/site/src/modules/roles/index.ts b/site/src/modules/roles/index.ts
index 222ecee8002c9..cb4125b4be0a6 100644
--- a/site/src/modules/roles/index.ts
+++ b/site/src/modules/roles/index.ts
@@ -19,6 +19,8 @@ export const roleDescriptions: Record = {
"Organization template admin can manage templates and workspaces within this organization.",
"organization-auditor":
"Organization auditor can access audit logs for this organization.",
+ "organization-workspace-access":
+ "Grants access to create and use workspaces within this organization.",
"organization-workspace-creation-ban":
"Prevents this user from creating new workspaces in this organization.",
member:
diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.stories.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.stories.tsx
index bffdb0f53bbdf..958405c489ad5 100644
--- a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.stories.tsx
+++ b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.stories.tsx
@@ -8,6 +8,7 @@ const meta: Meta = {
args: {
email: "test-user@org.com",
isLoading: false,
+ showTemplateNameHelperText: true,
initialValues: {
username: "test-user",
name: "Test User",
@@ -50,3 +51,9 @@ export const Editable: Story = {
editable: true,
},
};
+
+export const WithoutWorkspaceAccess: Story = {
+ args: {
+ showTemplateNameHelperText: false,
+ },
+};
diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx
index 45e4c3813115e..000210d66a39a 100644
--- a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx
+++ b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx
@@ -24,6 +24,7 @@ describe("AccountForm", () => {
email={MockUserMember.email}
initialValues={mockInitialValues}
isLoading={false}
+ showTemplateNameHelperText
onSubmit={vi.fn()}
/>,
);
@@ -53,6 +54,7 @@ describe("AccountForm", () => {
email={MockUserMember.email}
initialValues={mockInitialValues}
isLoading={false}
+ showTemplateNameHelperText
onSubmit={vi.fn()}
/>,
);
@@ -77,6 +79,7 @@ describe("AccountForm", () => {
email={MockUserMember.email}
initialValues={mockInitialValues}
isLoading={false}
+ showTemplateNameHelperText
onSubmit={vi.fn()}
/>,
);
diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx
index af88c97210e19..6f3fa6d0a11dc 100644
--- a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx
+++ b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx
@@ -22,6 +22,10 @@ interface AccountFormProps {
editable: boolean;
email: string;
isLoading: boolean;
+ /**
+ * Adds the template property reference to the name field's helper text.
+ */
+ showTemplateNameHelperText: boolean;
initialValues: UpdateUserProfileRequest;
onSubmit: (values: UpdateUserProfileRequest) => void;
updateProfileError?: unknown;
@@ -33,6 +37,7 @@ export const AccountForm: FC = ({
editable,
email,
isLoading,
+ showTemplateNameHelperText,
onSubmit,
initialValues,
updateProfileError,
@@ -71,8 +76,9 @@ export const AccountForm: FC = ({
{
;
@@ -96,19 +100,52 @@ export const ToggleNotification: Story = {
export const NonAdmin: Story = {
parameters: {
- permissions: { createTemplates: false, createUser: false },
+ permissions: {
+ createTemplates: false,
+ createUser: false,
+ viewWorkspaces: true,
+ },
},
};
export const TemplateAdmin: Story = {
parameters: {
- permissions: { createTemplates: true, createUser: false },
+ permissions: {
+ createTemplates: true,
+ createUser: false,
+ viewWorkspaces: true,
+ },
},
};
export const UserAdmin: Story = {
parameters: {
- permissions: { createTemplates: false, createUser: true },
+ permissions: {
+ createTemplates: false,
+ createUser: true,
+ viewWorkspaces: true,
+ },
+ },
+};
+
+export const WithoutWorkspaceAccess: Story = {
+ parameters: {
+ permissions: {
+ createTemplates: false,
+ createUser: false,
+ viewWorkspaces: false,
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await canvas.findByRole("switch", { name: "Chat Events" });
+
+ expect(
+ canvas.queryByRole("switch", { name: "Workspace Events" }),
+ ).not.toBeInTheDocument();
+ expect(
+ canvas.queryByRole("switch", { name: "Task Events" }),
+ ).not.toBeInTheDocument();
},
};
diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx
index 85fa0bdab34d1..d53ad7985f993 100644
--- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx
+++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx
@@ -38,7 +38,7 @@ import {
notificationIsDisabled,
selectDisabledPreferences,
} from "#/modules/notifications/utils";
-import type { Permissions } from "#/modules/permissions";
+import { canViewWorkspaces, type Permissions } from "#/modules/permissions";
import { pageTitle } from "#/utils/page";
const NotificationsPage: FC = () => {
@@ -283,6 +283,7 @@ function canSeeNotificationGroup(
return permissions.createUser;
case "Workspace Events":
case "Task Events":
+ return canViewWorkspaces(permissions);
case "Chat Events":
case "Custom Events":
case "AI Cost Control Events":
diff --git a/site/src/pages/UserSettingsPage/RequireWorkspaceAccess.test.tsx b/site/src/pages/UserSettingsPage/RequireWorkspaceAccess.test.tsx
new file mode 100644
index 0000000000000..8dcf0862fd3d9
--- /dev/null
+++ b/site/src/pages/UserSettingsPage/RequireWorkspaceAccess.test.tsx
@@ -0,0 +1,38 @@
+import { screen } from "@testing-library/react";
+import { HttpResponse, http } from "msw";
+import { MockPermissions } from "#/testHelpers/entities";
+import { renderWithAuth } from "#/testHelpers/renderHelpers";
+import { server } from "#/testHelpers/server";
+import { RequireWorkspaceAccess } from "./RequireWorkspaceAccess";
+
+const renderSchedulePage = () => {
+ renderWithAuth(, {
+ path: "/settings",
+ route: "/settings/schedule",
+ children: [{ path: "schedule", element: Schedule
}],
+ });
+};
+
+describe("RequireWorkspaceAccess", () => {
+ it("renders the route when the user can read workspaces", async () => {
+ renderSchedulePage();
+
+ await screen.findByText("Schedule");
+ });
+
+ it("blocks the route when the user cannot read workspaces", async () => {
+ server.use(
+ http.post("/api/v2/authcheck", () => {
+ return HttpResponse.json({
+ ...MockPermissions,
+ viewWorkspaces: false,
+ });
+ }),
+ );
+
+ renderSchedulePage();
+
+ await screen.findByText("You don't have permission to view this page");
+ expect(screen.queryByText("Schedule")).not.toBeInTheDocument();
+ });
+});
diff --git a/site/src/pages/UserSettingsPage/RequireWorkspaceAccess.tsx b/site/src/pages/UserSettingsPage/RequireWorkspaceAccess.tsx
new file mode 100644
index 0000000000000..825b255d9abfe
--- /dev/null
+++ b/site/src/pages/UserSettingsPage/RequireWorkspaceAccess.tsx
@@ -0,0 +1,15 @@
+import type { FC } from "react";
+import { Outlet } from "react-router";
+import { useAuthenticated } from "#/hooks/useAuthenticated";
+import { canViewWorkspaces } from "#/modules/permissions";
+import { RequirePermission } from "#/modules/permissions/RequirePermission";
+
+export const RequireWorkspaceAccess: FC = () => {
+ const { permissions } = useAuthenticated();
+
+ return (
+
+
+
+ );
+};
diff --git a/site/src/pages/UserSettingsPage/Sidebar.stories.tsx b/site/src/pages/UserSettingsPage/Sidebar.stories.tsx
new file mode 100644
index 0000000000000..e22cd6a40e436
--- /dev/null
+++ b/site/src/pages/UserSettingsPage/Sidebar.stories.tsx
@@ -0,0 +1,43 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, within } from "storybook/test";
+import { MockUserOwner } from "#/testHelpers/entities";
+import {
+ withAuthProvider,
+ withDashboardProvider,
+} from "#/testHelpers/storybook";
+import { Sidebar } from "./Sidebar";
+
+const meta: Meta = {
+ title: "pages/UserSettingsPage/Sidebar",
+ component: Sidebar,
+ parameters: {
+ user: MockUserOwner,
+ permissions: { viewWorkspaces: true },
+ features: ["advanced_template_scheduling"],
+ },
+ decorators: [withAuthProvider, withDashboardProvider],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await canvas.findByText("Schedule");
+ await canvas.findByText("SSH Keys");
+ },
+};
+
+export const WithoutWorkspaceAccess: Story = {
+ parameters: {
+ permissions: { viewWorkspaces: false },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await canvas.findByText("Account");
+
+ expect(canvas.queryByText("Schedule")).not.toBeInTheDocument();
+ expect(canvas.queryByText("SSH Keys")).not.toBeInTheDocument();
+ },
+};
diff --git a/site/src/pages/UserSettingsPage/Sidebar.tsx b/site/src/pages/UserSettingsPage/Sidebar.tsx
index 2f419bab32f5f..452c3effb7372 100644
--- a/site/src/pages/UserSettingsPage/Sidebar.tsx
+++ b/site/src/pages/UserSettingsPage/Sidebar.tsx
@@ -2,13 +2,18 @@ import {
Sidebar as BaseSidebar,
SettingsSidebarNavItem,
} from "#/components/Sidebar/Sidebar";
+import { useAuthenticated } from "#/hooks/useAuthenticated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
+import { canViewWorkspaces } from "#/modules/permissions";
import { getPrereleaseFlag } from "#/utils/buildInfo";
export const Sidebar: React.FC = () => {
const { entitlements, experiments, buildInfo } = useDashboard();
+ const { permissions } = useAuthenticated();
+ const showWorkspacePages = canViewWorkspaces(permissions);
const showSchedulePage =
- entitlements.features.advanced_template_scheduling.enabled;
+ entitlements.features.advanced_template_scheduling.enabled &&
+ showWorkspacePages;
const showOAuth2Page =
experiments.includes("oauth2") || getPrereleaseFlag(buildInfo) === "devel";
@@ -35,9 +40,11 @@ export const Sidebar: React.FC = () => {
Security
-
- SSH Keys
-
+ {showWorkspacePages && (
+
+ SSH Keys
+
+ )}
Tokens
Secrets
diff --git a/site/src/router.tsx b/site/src/router.tsx
index 6be468605ea92..f560c9edfbe1d 100644
--- a/site/src/router.tsx
+++ b/site/src/router.tsx
@@ -13,6 +13,7 @@ import { Loader } from "./components/Loader/Loader";
import { RequireAuth } from "./contexts/auth/RequireAuth";
import { useAuthenticated } from "./hooks/useAuthenticated";
import { DashboardLayout } from "./modules/dashboard/DashboardLayout";
+import { DashboardRedirect } from "./modules/dashboard/DashboardRedirect";
import AuditPage from "./pages/AuditPage/AuditPage";
import ConnectionLogPage from "./pages/ConnectionLogPage/ConnectionLogPage";
import { HealthLayout } from "./pages/HealthPage/HealthLayout";
@@ -24,6 +25,7 @@ import { TemplateRedirectController } from "./pages/TemplatePage/TemplateRedirec
import { TemplateSettingsLayout } from "./pages/TemplateSettingsPage/TemplateSettingsLayout";
import TemplatesPage from "./pages/TemplatesPage/TemplatesPage";
import UserSettingsLayout from "./pages/UserSettingsPage/Layout";
+import { RequireWorkspaceAccess } from "./pages/UserSettingsPage/RequireWorkspaceAccess";
import UsersPage from "./pages/UsersPage/UsersPage";
import { WorkspaceSettingsLayout } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
import WorkspacesPage from "./pages/WorkspacesPage/WorkspacesPage";
@@ -561,7 +563,7 @@ export const router = createBrowserRouter(
{/* Dashboard routes */}
}>
}>
- } />
+ } />
}>
} />
} />
- } />
+ }>
+ } />
+ } />
+
} />
- } />
}
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts
index 65a1ff3bbea75..fdfa78c9c5101 100644
--- a/site/src/testHelpers/entities.ts
+++ b/site/src/testHelpers/entities.ts
@@ -3320,8 +3320,10 @@ export const MockTemplateExample2: TypesGen.TemplateExample = {
export const MockPermissions: Permissions = {
createTemplates: true,
+ viewTemplates: true,
createUser: true,
createWorkspace: true,
+ viewWorkspaces: true,
deleteTemplates: true,
updateTemplates: true,
viewAllUsers: true,
@@ -3357,8 +3359,10 @@ export const MockPermissions: Permissions = {
export const MockNoPermissions: Permissions = {
createTemplates: false,
+ viewTemplates: false,
createUser: false,
createWorkspace: false,
+ viewWorkspaces: false,
deleteTemplates: false,
updateTemplates: false,
viewAllUsers: false,