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,