diff --git a/site/e2e/tests/roles.spec.ts b/site/e2e/tests/roles.spec.ts index a1d39c7c42a11..973b815991d17 100644 --- a/site/e2e/tests/roles.spec.ts +++ b/site/e2e/tests/roles.spec.ts @@ -61,25 +61,21 @@ test.describe("roles admin settings access", () => { await login(page, users.templateAdmin); await page.goto("/", { waitUntil: "domcontentloaded" }); - await hasAccessToAdminSettings(page, ["Deployment", "Organizations"]); + await hasAccessToAdminSettings(page, ["Deployment"]); }); test("user admin can see admin settings", async ({ page }) => { await login(page, users.userAdmin); await page.goto("/", { waitUntil: "domcontentloaded" }); - await hasAccessToAdminSettings(page, ["Deployment", "Organizations"]); + await hasAccessToAdminSettings(page, ["Deployment"]); }); test("auditor can see admin settings", async ({ page }) => { await login(page, users.auditor); await page.goto("/", { waitUntil: "domcontentloaded" }); - await hasAccessToAdminSettings(page, [ - "Deployment", - "Organizations", - "Audit Logs", - ]); + await hasAccessToAdminSettings(page, ["Deployment", "Audit Logs"]); }); test("owner can see admin settings", async ({ page }) => { @@ -88,7 +84,6 @@ test.describe("roles admin settings access", () => { await hasAccessToAdminSettings(page, [ "Deployment", - "Organizations", "Healthcheck", "Audit Logs", ]); @@ -103,6 +98,23 @@ test.describe("org-scoped roles admin settings access", () => { await setupApiCalls(page); }); + test("member cannot see admin settings", async ({ page }) => { + // The unlicensed member test above cannot catch all regressions here. + // Many admin settings are locked behind a license and wouldn't be shown. + const org = await createOrganization(); + const member = await createOrganizationMember({ + orgRoles: { + [org.id]: [], + }, + }); + + await login(page, member); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + // None, "Admin settings" button should not be visible + await hasAccessToAdminSettings(page, []); + }); + test("org template admin can see admin settings", async ({ page }) => { const org = await createOrganization(); const orgTemplateAdmin = await createOrganizationMember({ diff --git a/site/src/modules/dashboard/Navbar/AdminSettings.tsx b/site/src/modules/dashboard/Navbar/AdminSettings.tsx new file mode 100644 index 0000000000000..4844c302b5fdb --- /dev/null +++ b/site/src/modules/dashboard/Navbar/AdminSettings.tsx @@ -0,0 +1,80 @@ +import { Link } from "react-router"; +import { DropdownMenuItem } from "#/components/DropdownMenu/DropdownMenu"; + +/** + * Permissions that determine which items appear in the Admin settings menu. + * Shared by the desktop `DeploymentDropdown` and the mobile `MobileMenu` so + * both surfaces render the same set of items from a single source of truth. + */ +type AdminSettingsItemsProps = { + itemClassName?: string; + permissions: AdminSettingsPermissions; +}; + +export type AdminSettingsPermissions = { + canViewDeployment?: boolean; + canViewOrganizations?: boolean; + canViewAISettings?: boolean; + canViewAuditLog?: boolean; + canViewConnectionLog?: boolean; + canViewAIBridge?: boolean; + canViewHealth?: boolean; +}; + +/** + * Builds the ordered list of Admin settings menu items for the given + * permissions. Organizations is always available; the rest are gated behind + * their respective permissions. + */ +export const AdminSettingsItems: React.FC = ({ + itemClassName, + permissions, +}) => { + return ( + <> + {permissions.canViewDeployment && ( + + Deployment + + )} + {permissions.canViewOrganizations && ( + + Organizations + + )} + {permissions.canViewAISettings && ( + + AI + + )} + {permissions.canViewAuditLog && ( + + Audit logs + + )} + {permissions.canViewConnectionLog && ( + + Connection logs + + )} + {permissions.canViewAIBridge && ( + + AI sessions + + )} + {permissions.canViewHealth && ( + + Healthcheck + + )} + + ); +}; + +/** + * Whether the user has any permission that should surface the Admin settings + * menu. Organizations alone does not gate visibility, matching prior behavior. + */ +export const canViewAdminSettings = ( + permissions: AdminSettingsPermissions, +): boolean => Object.values(permissions).some((canView) => canView); diff --git a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx index d10e8f8870002..6ccaf52469979 100644 --- a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx @@ -1,30 +1,21 @@ import type { FC } from "react"; -import { Link } from "react-router"; import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown"; import { Button } from "#/components/Button/Button"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; import { + AdminSettingsItems, type AdminSettingsPermissions, - canViewAdminSettings, - getAdminSettingsItems, -} from "./adminSettings"; +} from "./AdminSettings"; -type DeploymentDropdownProps = AdminSettingsPermissions; +type AdminSettingsDropdownProps = { permissions: AdminSettingsPermissions }; -export const DeploymentDropdown: FC = ( +export const AdminSettingsDropdown: FC = ({ permissions, -) => { - if (!canViewAdminSettings(permissions)) { - return null; - } - - const items = getAdminSettingsItems(permissions); - +}) => { return ( @@ -36,11 +27,7 @@ export const DeploymentDropdown: FC = ( diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx index e6b3af2ec5812..00193cdcbc0b4 100644 --- a/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx +++ b/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx @@ -40,13 +40,15 @@ const meta: Meta = { supportLinks: MockSupportLinks, onSignOut: fn(), isDefaultOpen: true, - canViewAuditLog: true, - canViewConnectionLog: true, - canViewDeployment: true, - canViewHealth: true, - canViewOrganizations: true, - canViewAIBridge: true, - canViewAISettings: true, + adminPermissions: { + canViewDeployment: true, + canViewOrganizations: true, + canViewAISettings: true, + canViewAuditLog: true, + canViewConnectionLog: true, + canViewAIBridge: true, + canViewHealth: true, + }, }, decorators: [withNavbarMock], }; @@ -67,13 +69,9 @@ export const Admin: Story = { export const Auditor: Story = { args: { user: MockUserMember, - canViewAuditLog: true, - canViewConnectionLog: false, - canViewDeployment: false, - canViewHealth: false, - canViewOrganizations: false, - canViewAIBridge: false, - canViewAISettings: false, + adminPermissions: { + canViewAuditLog: true, + }, }, play: openAdminSettings, }; @@ -81,13 +79,10 @@ export const Auditor: Story = { export const OrgAdmin: Story = { args: { user: MockUserMember, - canViewAuditLog: true, - canViewConnectionLog: false, - canViewDeployment: false, - canViewHealth: false, - canViewOrganizations: true, - canViewAIBridge: false, - canViewAISettings: false, + adminPermissions: { + canViewAuditLog: true, + canViewOrganizations: true, + }, }, play: openAdminSettings, }; @@ -95,13 +90,7 @@ export const OrgAdmin: Story = { export const Member: Story = { args: { user: MockUserMember, - canViewAuditLog: false, - canViewConnectionLog: false, - canViewDeployment: false, - canViewHealth: false, - canViewOrganizations: false, - canViewAIBridge: false, - canViewAISettings: false, + adminPermissions: {}, }, }; diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.tsx index 906e50a4d3a34..661341830c71b 100644 --- a/site/src/modules/dashboard/Navbar/MobileMenu.tsx +++ b/site/src/modules/dashboard/Navbar/MobileMenu.tsx @@ -27,9 +27,10 @@ import { Latency } from "#/components/Latency/Latency"; import type { ProxyContextValue } from "#/contexts/ProxyContext"; import { cn } from "#/utils/cn"; import { + AdminSettingsItems, type AdminSettingsPermissions, - getAdminSettingsItems, -} from "./adminSettings"; + canViewAdminSettings, +} from "./AdminSettings"; import { sortProxiesByLatency } from "./proxyUtils"; const itemStyles = { @@ -38,8 +39,9 @@ const itemStyles = { open: "text-content-primary", }; -type MobileMenuProps = AdminSettingsPermissions & { +type MobileMenuProps = { proxyContextValue?: ProxyContextValue; + adminPermissions: AdminSettingsPermissions; user?: TypesGen.User; supportLinks?: readonly TypesGen.LinkConfig[]; onSignOut: () => void; @@ -47,15 +49,14 @@ type MobileMenuProps = AdminSettingsPermissions & { }; export const MobileMenu: FC = ({ - isDefaultOpen, + adminPermissions, proxyContextValue, user, supportLinks, onSignOut, - ...permissions + isDefaultOpen, }) => { const [open, setOpen] = useState(isDefaultOpen); - const hasSomePermission = Object.values(permissions).some((p) => p); return ( @@ -87,10 +88,10 @@ export const MobileMenu: FC = ({ - {hasSomePermission && ( + {canViewAdminSettings(adminPermissions) && ( <> - + )} @@ -123,7 +124,7 @@ const ProxySettingsSub: FC = ({ proxyContextValue }) => { { e.preventDefault(); setOpen((prev) => !prev); @@ -191,7 +192,8 @@ const ProxySettingsSub: FC = ({ proxyContextValue }) => { { + onClick={(event) => { + event.stopPropagation(); proxyContextValue.refetchProxyLatencies(); }} > @@ -202,15 +204,18 @@ const ProxySettingsSub: FC = ({ proxyContextValue }) => { ); }; -const AdminSettingsSub: FC = (permissions) => { +type AdminSettingsSubProps = { + permissions: AdminSettingsPermissions; +}; + +const AdminSettingsSub: FC = ({ permissions }) => { const [open, setOpen] = useState(false); - const items = getAdminSettingsItems(permissions); return ( { e.preventDefault(); setOpen((prev) => !prev); @@ -223,15 +228,10 @@ const AdminSettingsSub: FC = (permissions) => { - {items.map((item) => ( - - {item.label} - - ))} + ); @@ -254,7 +254,7 @@ const UserSettingsSub: FC = ({ { e.preventDefault(); setOpen((prev) => !prev); diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index ea98ebf2b2fa7..75417d5e9c648 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -1,4 +1,3 @@ -import type { FC } from "react"; import { useQuery } from "react-query"; import { buildInfo } from "#/api/queries/buildInfo"; import type { LinkConfig } from "#/api/typesGenerated"; @@ -10,7 +9,7 @@ import { canViewDeploymentSettings } from "#/modules/permissions"; import { useFeatureVisibility } from "../useFeatureVisibility"; import { NavbarView } from "./NavbarView"; -export const Navbar: FC = () => { +export const Navbar: React.FC = () => { const { metadata } = useEmbeddedMetadata(); const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); const { appearance, canViewOrganizationSettings } = useDashboard(); @@ -45,13 +44,15 @@ export const Navbar: FC = () => { buildInfo={buildInfoQuery.data} supportLinks={Array.from(uniqueLinks.values())} onSignOut={signOut} - canViewDeployment={canViewDeployment} - canViewOrganizations={canViewOrganizations} - canViewHealth={canViewHealth} - canViewAuditLog={canViewAuditLog} - canViewConnectionLog={canViewConnectionLog} - canViewAIBridge={canViewAIBridge} - canViewAISettings={canViewAISettings} + adminPermissions={{ + canViewDeployment, + canViewOrganizations, + canViewAISettings, + canViewAuditLog, + canViewConnectionLog, + canViewAIBridge, + canViewHealth, + }} canCreateChat={canCreateChat} proxyContextValue={proxyContextValue} /> diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index 5cd6a50bbb169..9ec29a4f93e4f 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -30,11 +30,15 @@ const meta: Meta = { component: NavbarView, args: { user: MockUserOwner, - canViewAuditLog: true, - canViewDeployment: true, - canViewHealth: true, - canViewAISettings: true, - canViewOrganizations: true, + adminPermissions: { + canViewDeployment: true, + canViewOrganizations: true, + canViewAISettings: true, + canViewAuditLog: true, + canViewConnectionLog: true, + canViewAIBridge: true, + canViewHealth: true, + }, canCreateChat: true, supportLinks: [], }, @@ -56,11 +60,9 @@ export const ForAdmin: Story = { export const ForAuditor: Story = { args: { user: MockUserMember, - canViewAuditLog: true, - canViewDeployment: false, - canViewHealth: false, - canViewAISettings: false, - canViewOrganizations: false, + adminPermissions: { + canViewAuditLog: true, + }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -73,11 +75,10 @@ export const ForAuditor: Story = { export const ForOrgAdmin: Story = { args: { user: MockUserMember, - canViewAuditLog: true, - canViewDeployment: false, - canViewHealth: false, - canViewAISettings: false, - canViewOrganizations: true, + adminPermissions: { + canViewAuditLog: true, + canViewOrganizations: true, + }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -89,11 +90,9 @@ export const ForOrgAdmin: Story = { export const ForSingleOrgOSSAdmin: Story = { args: { - canViewAuditLog: false, - canViewOrganizations: false, - canViewConnectionLog: false, - canViewAIBridge: false, - canViewAISettings: false, + adminPermissions: { + canViewDeployment: true, + }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -106,11 +105,7 @@ export const ForSingleOrgOSSAdmin: Story = { export const ForMember: Story = { args: { user: MockUserMember, - canViewAuditLog: false, - canViewDeployment: false, - canViewHealth: false, - canViewAISettings: false, - canViewOrganizations: false, + adminPermissions: {}, canCreateChat: false, }, }; @@ -118,11 +113,7 @@ export const ForMember: Story = { export const ForMemberWithAgentsAccess: Story = { args: { user: MockUserMember, - canViewAuditLog: false, - canViewDeployment: false, - canViewHealth: false, - canViewAISettings: false, - canViewOrganizations: false, + adminPermissions: {}, canCreateChat: true, }, }; @@ -141,11 +132,7 @@ export const IdleTasks: Story = { export const SupportLinks: Story = { args: { user: MockUserMember, - canViewAuditLog: false, - canViewDeployment: false, - canViewHealth: false, - canViewAISettings: false, - canViewOrganizations: false, + adminPermissions: {}, supportLinks: [ { name: "This is a bug", @@ -182,11 +169,7 @@ export const SupportLinks: Story = { export const DefaultSupportLinks: Story = { args: { user: MockUserMember, - canViewAuditLog: false, - canViewDeployment: false, - canViewHealth: false, - canViewAISettings: false, - canViewOrganizations: false, + adminPermissions: {}, supportLinks: [ { icon: "docs", name: "Documentation", target: "" }, { icon: "bug", name: "Report a bug", target: "" }, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 967815c25cb22..6634521970173 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -16,7 +16,11 @@ import { useEmbeddedMetadata } from "#/hooks/useEmbeddedMetadata"; import { NotificationsInbox } from "#/modules/notifications/NotificationsInbox/NotificationsInbox"; import { getPrereleaseFlag } from "#/utils/buildInfo"; import { cn } from "#/utils/cn"; -import { DeploymentDropdown } from "./DeploymentDropdown"; +import { + type AdminSettingsPermissions, + canViewAdminSettings, +} from "./AdminSettings"; +import { AdminSettingsDropdown } from "./DeploymentDropdown"; import { MobileMenu } from "./MobileMenu"; import { ProxyMenu } from "./ProxyMenu"; import { SupportIcon } from "./SupportIcon"; @@ -27,13 +31,7 @@ interface NavbarViewProps { buildInfo?: TypesGen.BuildInfoResponse; supportLinks: readonly TypesGen.LinkConfig[]; onSignOut: () => void; - canViewDeployment: boolean; - canViewOrganizations: boolean; - canViewAuditLog: boolean; - canViewConnectionLog: boolean; - canViewHealth: boolean; - canViewAIBridge: boolean; - canViewAISettings: boolean; + adminPermissions: AdminSettingsPermissions; canCreateChat: boolean; proxyContextValue?: ProxyContextValue; } @@ -49,13 +47,7 @@ export const NavbarView: FC = ({ buildInfo, supportLinks, onSignOut, - canViewDeployment, - canViewOrganizations, - canViewHealth, - canViewAuditLog, - canViewConnectionLog, - canViewAIBridge, - canViewAISettings, + adminPermissions, canCreateChat, proxyContextValue, }) => { @@ -125,17 +117,11 @@ export const NavbarView: FC = ({ )} -
- -
+ {canViewAdminSettings(adminPermissions) && ( +
+ +
+ )} = ({
@@ -207,7 +187,16 @@ const NavItems: FC = ({ className, user, canCreateChat }) => { Templates - + {canCreateChat && ( + { + return cn(linkStyles.default, { [linkStyles.active]: isActive }); + }} + to="/agents" + > + Agents + + )} ); }; @@ -272,23 +261,6 @@ function idleTasksLabel(count: number) { return `You have ${count} ${count === 1 ? "task" : "tasks"} waiting for input`; } -const AgentsNavItem: FC<{ canCreateChat: boolean }> = ({ canCreateChat }) => { - if (!canCreateChat) { - return null; - } - - return ( - { - return cn(linkStyles.default, { [linkStyles.active]: isActive }); - }} - to="/agents" - > - Agents - - ); -}; - function isNavbarLink(link: TypesGen.LinkConfig): boolean { return link.location === "navbar"; } diff --git a/site/src/modules/dashboard/Navbar/adminSettings.ts b/site/src/modules/dashboard/Navbar/adminSettings.ts deleted file mode 100644 index c22011fa96f61..0000000000000 --- a/site/src/modules/dashboard/Navbar/adminSettings.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { linkToAuditing } from "#/modules/navigation"; - -/** - * Permissions that determine which items appear in the Admin settings menu. - * Shared by the desktop `DeploymentDropdown` and the mobile `MobileMenu` so - * both surfaces render the same set of items from a single source of truth. - */ -export type AdminSettingsPermissions = { - canViewDeployment: boolean; - canViewOrganizations: boolean; - canViewAuditLog: boolean; - canViewConnectionLog: boolean; - canViewAIBridge: boolean; - canViewAISettings: boolean; - canViewHealth: boolean; -}; - -type AdminSettingsItem = { - label: string; - to: string; -}; - -/** - * Builds the ordered list of Admin settings menu items for the given - * permissions. Organizations is always available; the rest are gated behind - * their respective permissions. - */ -export const getAdminSettingsItems = ({ - canViewDeployment, - canViewAuditLog, - canViewConnectionLog, - canViewAIBridge, - canViewAISettings, - canViewHealth, -}: AdminSettingsPermissions): AdminSettingsItem[] => [ - ...(canViewDeployment ? [{ label: "Deployment", to: "/deployment" }] : []), - { label: "Organizations", to: "/organizations" }, - ...(canViewAISettings ? [{ label: "AI", to: "/ai/settings" }] : []), - ...(canViewAuditLog ? [{ label: "Audit logs", to: linkToAuditing }] : []), - ...(canViewConnectionLog - ? [{ label: "Connection logs", to: "/connectionlog" }] - : []), - ...(canViewAIBridge - ? [{ label: "AI sessions", to: "/ai-gateway/sessions" }] - : []), - ...(canViewHealth ? [{ label: "Healthcheck", to: "/health" }] : []), -]; - -/** - * Whether the user has any permission that should surface the Admin settings - * menu. Organizations alone does not gate visibility, matching prior behavior. - */ -export const canViewAdminSettings = ( - permissions: AdminSettingsPermissions, -): boolean => Object.values(permissions).some((canView) => canView); diff --git a/site/src/modules/navigation.ts b/site/src/modules/navigation.ts index 487c0e755d700..70bdb302126ad 100644 --- a/site/src/modules/navigation.ts +++ b/site/src/modules/navigation.ts @@ -17,14 +17,6 @@ export function useLinks() { return get; } -function withFilter(path: string, filter: string) { - return path + (filter ? `?filter=${encodeURIComponent(filter)}` : ""); -} - -export const linkToAuditing = "/audit"; - -const _linkToUsers = withFilter("/deployment/users", "status:active"); - export const linkToTemplate = (organizationName: string, templateName: string): LinkThunk => (dashboard) => diff --git a/site/src/modules/permissions/index.ts b/site/src/modules/permissions/index.ts index be97417f9c5a5..06a8d631ff278 100644 --- a/site/src/modules/permissions/index.ts +++ b/site/src/modules/permissions/index.ts @@ -40,8 +40,7 @@ export const canViewAnyOrganization = ( ): permissions is Permissions => { return ( permissions !== undefined && - (permissions.viewAnyMembers || - permissions.editAnyGroups || + (permissions.editAnyGroups || permissions.assignAnyRoles || permissions.viewAnyIdpSyncSettings || permissions.editAnySettings)