From e0bf1bf42a0b8cc0f8d26d34bb39b6c20b9f9439 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 11 Aug 2026 22:04:59 +0000 Subject: [PATCH] feat(site): hide workspace UI from users without workspace access Adds a site-wide `viewWorkspaces` permission check (workspace read, any org, owner me) and uses it to adapt the UI for users whose org member roles do not grant workspace access. - The dashboard index route lands these users on their account settings. - Workspaces, Templates, Tasks and Agents nav items render dimmed and non-interactive with a tooltip; the workspace proxy latency menu is hidden. - Schedule and SSH Keys are removed from the user settings sidebar and their routes redirect to the account page. - Workspace and Task notification groups are hidden. - The account name field drops the template property hint. Also adds a description for the organization-workspace-access role in the role selector. --- site/permissions.json | 8 ++ .../modules/dashboard/DashboardRedirect.tsx | 19 ++++ .../dashboard/Navbar/MobileMenu.stories.tsx | 22 ++++- .../modules/dashboard/Navbar/MobileMenu.tsx | 45 ++++++--- site/src/modules/dashboard/Navbar/Navbar.tsx | 2 + .../dashboard/Navbar/NavbarView.stories.tsx | 25 ++++- .../modules/dashboard/Navbar/NavbarView.tsx | 93 +++++++++++++------ .../dashboard/Navbar/RestrictedNavItem.tsx | 49 ++++++++++ site/src/modules/roles/index.ts | 2 + .../AccountPage/AccountForm.stories.tsx | 7 ++ .../AccountPage/AccountForm.test.tsx | 3 + .../AccountPage/AccountForm.tsx | 11 ++- .../AccountPage/AccountPage.tsx | 1 + .../NotificationsPage.stories.tsx | 45 ++++++++- .../NotificationsPage/NotificationsPage.tsx | 1 + .../RequireWorkspaceAccess.tsx | 18 ++++ .../UserSettingsPage/Sidebar.stories.tsx | 43 +++++++++ site/src/pages/UserSettingsPage/Sidebar.tsx | 14 ++- site/src/router.tsx | 10 +- site/src/testHelpers/entities.ts | 2 + 20 files changed, 366 insertions(+), 54 deletions(-) create mode 100644 site/src/modules/dashboard/DashboardRedirect.tsx create mode 100644 site/src/modules/dashboard/Navbar/RestrictedNavItem.tsx create mode 100644 site/src/pages/UserSettingsPage/RequireWorkspaceAccess.tsx create mode 100644 site/src/pages/UserSettingsPage/Sidebar.stories.tsx diff --git a/site/permissions.json b/site/permissions.json index 71b91a51b12..44d1f888b37 100644 --- a/site/permissions.json +++ b/site/permissions.json @@ -23,6 +23,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.tsx b/site/src/modules/dashboard/DashboardRedirect.tsx new file mode 100644 index 00000000000..2486ffceccc --- /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"; + +/** + * Resolves the dashboard index route to a landing page based on the signed-in + * user's permissions. Users who cannot read workspaces land on their account + * settings. + */ +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 00193cdcbc0..8bab8597c41 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,7 @@ const meta: Meta = { supportLinks: MockSupportLinks, onSignOut: fn(), isDefaultOpen: true, + canViewWorkspaces: true, adminPermissions: { canViewDeployment: true, canViewOrganizations: true, @@ -94,6 +95,25 @@ export const Member: Story = { }, }; +export const WithoutWorkspaceAccess: Story = { + args: { + user: MockUserMember, + adminPermissions: {}, + canViewWorkspaces: 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(); + }, +}; + 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 661341830c7..0de38f81563 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 { RestrictedNavItem } from "./RestrictedNavItem"; const itemStyles = { default: "px-9 h-10 no-underline", @@ -42,6 +43,7 @@ const itemStyles = { type MobileMenuProps = { proxyContextValue?: ProxyContextValue; adminPermissions: AdminSettingsPermissions; + canViewWorkspaces: boolean; user?: TypesGen.User; supportLinks?: readonly TypesGen.LinkConfig[]; onSignOut: () => void; @@ -51,6 +53,7 @@ type MobileMenuProps = { export const MobileMenu: FC = ({ adminPermissions, proxyContextValue, + canViewWorkspaces, user, supportLinks, onSignOut, @@ -76,17 +79,37 @@ export const MobileMenu: FC = ({ className="w-screen border-0 border-b border-solid p-0 py-2" sideOffset={17} > - - Workspaces - - - Templates - - - Agents - - - + {canViewWorkspaces ? ( + <> + + Workspaces + + + Templates + + + Agents + + + ) : ( + <> + {["Workspaces", "Templates", "Agents"].map((label) => ( + event.preventDefault()} + > + {label} + + ))} + + )} + {canViewWorkspaces && ( + <> + + + + )} {canViewAdminSettings(adminPermissions) && ( <> diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index 75417d5e9c6..60268e3bfdd 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -31,6 +31,7 @@ export const Navbar: React.FC = () => { permissions.viewAIGatewayKeys || permissions.editDeploymentConfig; const canCreateChat = permissions.createChat; + const canViewWorkspaces = permissions.viewWorkspaces; const uniqueLinks = new Map(); for (const link of appearance.support_links ?? []) { @@ -54,6 +55,7 @@ export const Navbar: React.FC = () => { canViewHealth, }} canCreateChat={canCreateChat} + canViewWorkspaces={canViewWorkspaces} proxyContextValue={proxyContextValue} /> ); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index dd88e6cac51..7632eef6f01 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,7 @@ const meta: Meta = { canViewHealth: true, }, canCreateChat: true, + canViewWorkspaces: true, supportLinks: [], }, decorators: [withDashboardProvider], @@ -122,6 +123,28 @@ export const ForMemberWithAgentsAccess: Story = { }, }; +export const WithoutWorkspaceAccess: Story = { + parameters: { pixel: { matrix: pixelWithDesktop } }, + args: { + user: MockUserMember, + adminPermissions: {}, + canCreateChat: true, + canViewWorkspaces: 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")); + await within(canvasElement.ownerDocument.body).findByRole("tooltip"); + }, +}; + 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 66345219701..4b923eef909 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 } from "./RestrictedNavItem"; import { SupportIcon } from "./SupportIcon"; import { UserDropdown } from "./UserDropdown/UserDropdown"; @@ -33,6 +34,7 @@ interface NavbarViewProps { onSignOut: () => void; adminPermissions: AdminSettingsPermissions; canCreateChat: boolean; + canViewWorkspaces: boolean; proxyContextValue?: ProxyContextValue; } @@ -49,6 +51,7 @@ export const NavbarView: FC = ({ onSignOut, adminPermissions, canCreateChat, + canViewWorkspaces, proxyContextValue, }) => { const prerelease = getPrereleaseFlag(buildInfo); @@ -73,7 +76,7 @@ export const NavbarView: FC = ({ : undefined, }} > - + @@ -81,6 +84,7 @@ export const NavbarView: FC = ({ className="ml-4 hidden md:flex" user={user} canCreateChat={canCreateChat} + canViewWorkspaces={canViewWorkspaces} /> {prerelease && buildInfo?.version && ( @@ -111,7 +115,7 @@ export const NavbarView: FC = ({ ))} - {proxyContextValue && ( + {proxyContextValue && canViewWorkspaces && (
@@ -146,6 +150,7 @@ export const NavbarView: FC = ({ = ({ className, user, canCreateChat }) => { +const NavItems: FC = ({ + className, + user, + canCreateChat, + canViewWorkspaces, +}) => { 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 +248,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,6 +259,14 @@ const TasksNavItem: FC = ({ user }) => { return null; } + if (!canViewWorkspaces) { + return ( + + Tasks + + ); + } + return ( = ({ + className, + tabIndex = 0, + children, +}) => { + return ( + + + + {children} + + + {restrictedNavTooltip} + + ); +}; diff --git a/site/src/modules/roles/index.ts b/site/src/modules/roles/index.ts index 222ecee8002..cb4125b4be0 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 bffdb0f53bb..958405c489a 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 45e4c381311..000210d66a3 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 af88c97210e..8544e81e8c5 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx @@ -22,6 +22,11 @@ interface AccountFormProps { editable: boolean; email: string; isLoading: boolean; + /** + * Controls whether the name field explains how templates read the value. + * The explanation only makes sense to users who can use workspaces. + */ + showTemplateNameHelperText: boolean; initialValues: UpdateUserProfileRequest; onSubmit: (values: UpdateUserProfileRequest) => void; updateProfileError?: unknown; @@ -33,6 +38,7 @@ export const AccountForm: FC = ({ editable, email, isLoading, + showTemplateNameHelperText, onSubmit, initialValues, updateProfileError, @@ -71,8 +77,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 85fa0bdab34..f9df202a135 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -283,6 +283,7 @@ function canSeeNotificationGroup( return permissions.createUser; case "Workspace Events": case "Task Events": + return permissions.viewWorkspaces; case "Chat Events": case "Custom Events": case "AI Cost Control Events": diff --git a/site/src/pages/UserSettingsPage/RequireWorkspaceAccess.tsx b/site/src/pages/UserSettingsPage/RequireWorkspaceAccess.tsx new file mode 100644 index 00000000000..b2916ea9777 --- /dev/null +++ b/site/src/pages/UserSettingsPage/RequireWorkspaceAccess.tsx @@ -0,0 +1,18 @@ +import type { FC } from "react"; +import { Navigate, Outlet } from "react-router"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; + +/** + * Guards user settings routes that only apply to workspaces. Users who cannot + * read workspaces are sent to the account page, since those settings have no + * effect for them. + */ +export const RequireWorkspaceAccess: FC = () => { + const { permissions } = useAuthenticated(); + + if (!permissions.viewWorkspaces) { + return ; + } + + return ; +}; diff --git a/site/src/pages/UserSettingsPage/Sidebar.stories.tsx b/site/src/pages/UserSettingsPage/Sidebar.stories.tsx new file mode 100644 index 00000000000..e22cd6a40e4 --- /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 2f419bab32f..2655a72b584 100644 --- a/site/src/pages/UserSettingsPage/Sidebar.tsx +++ b/site/src/pages/UserSettingsPage/Sidebar.tsx @@ -2,13 +2,17 @@ import { Sidebar as BaseSidebar, SettingsSidebarNavItem, } from "#/components/Sidebar/Sidebar"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { getPrereleaseFlag } from "#/utils/buildInfo"; export const Sidebar: React.FC = () => { const { entitlements, experiments, buildInfo } = useDashboard(); + const { permissions } = useAuthenticated(); + const canViewWorkspaces = permissions.viewWorkspaces; const showSchedulePage = - entitlements.features.advanced_template_scheduling.enabled; + entitlements.features.advanced_template_scheduling.enabled && + canViewWorkspaces; const showOAuth2Page = experiments.includes("oauth2") || getPrereleaseFlag(buildInfo) === "devel"; @@ -35,9 +39,11 @@ export const Sidebar: React.FC = () => { Security - - SSH Keys - + {canViewWorkspaces && ( + + SSH Keys + + )} Tokens Secrets diff --git a/site/src/router.tsx b/site/src/router.tsx index 6be468605ea..f560c9edfbe 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 65a1ff3bbea..f72fbf4bd79 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3322,6 +3322,7 @@ export const MockPermissions: Permissions = { createTemplates: true, createUser: true, createWorkspace: true, + viewWorkspaces: true, deleteTemplates: true, updateTemplates: true, viewAllUsers: true, @@ -3359,6 +3360,7 @@ export const MockNoPermissions: Permissions = { createTemplates: false, createUser: false, createWorkspace: false, + viewWorkspaces: false, deleteTemplates: false, updateTemplates: false, viewAllUsers: false,