fix: don't show admin settings dropdown to everyone (#27481)

This commit is contained in:
McKayla はな
2026-07-23 22:59:40 -06:00
committed by GitHub
parent 4f50b77ac5
commit b8727d9c23
11 changed files with 203 additions and 243 deletions
+20 -8
View File
@@ -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({
@@ -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<AdminSettingsItemsProps> = ({
itemClassName,
permissions,
}) => {
return (
<>
{permissions.canViewDeployment && (
<DropdownMenuItem asChild className={itemClassName}>
<Link to="/deployment">Deployment</Link>
</DropdownMenuItem>
)}
{permissions.canViewOrganizations && (
<DropdownMenuItem asChild className={itemClassName}>
<Link to="/organizations">Organizations</Link>
</DropdownMenuItem>
)}
{permissions.canViewAISettings && (
<DropdownMenuItem asChild className={itemClassName}>
<Link to="/ai/settings">AI</Link>
</DropdownMenuItem>
)}
{permissions.canViewAuditLog && (
<DropdownMenuItem asChild className={itemClassName}>
<Link to="/audit">Audit logs</Link>
</DropdownMenuItem>
)}
{permissions.canViewConnectionLog && (
<DropdownMenuItem asChild className={itemClassName}>
<Link to="/connectionlog">Connection logs</Link>
</DropdownMenuItem>
)}
{permissions.canViewAIBridge && (
<DropdownMenuItem asChild className={itemClassName}>
<Link to="/ai-gateway/sessions">AI sessions</Link>
</DropdownMenuItem>
)}
{permissions.canViewHealth && (
<DropdownMenuItem asChild className={itemClassName}>
<Link to="/health">Healthcheck</Link>
</DropdownMenuItem>
)}
</>
);
};
/**
* 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);
@@ -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<DeploymentDropdownProps> = (
export const AdminSettingsDropdown: FC<AdminSettingsDropdownProps> = ({
permissions,
) => {
if (!canViewAdminSettings(permissions)) {
return null;
}
const items = getAdminSettingsItems(permissions);
}) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -36,11 +27,7 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = (
<DropdownMenuContent align="end" className="w-[180px] min-w-auto">
<nav>
{items.map((item) => (
<DropdownMenuItem key={item.to} asChild>
<Link to={item.to}>{item.label}</Link>
</DropdownMenuItem>
))}
<AdminSettingsItems permissions={permissions} />
</nav>
</DropdownMenuContent>
</DropdownMenu>
@@ -40,13 +40,15 @@ const meta: Meta<typeof MobileMenu> = {
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: {},
},
};
@@ -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<MobileMenuProps> = ({
isDefaultOpen,
adminPermissions,
proxyContextValue,
user,
supportLinks,
onSignOut,
...permissions
isDefaultOpen,
}) => {
const [open, setOpen] = useState(isDefaultOpen);
const hasSomePermission = Object.values(permissions).some((p) => p);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
@@ -87,10 +88,10 @@ export const MobileMenu: FC<MobileMenuProps> = ({
<DropdownMenuSeparator />
<ProxySettingsSub proxyContextValue={proxyContextValue} />
{hasSomePermission && (
{canViewAdminSettings(adminPermissions) && (
<>
<DropdownMenuSeparator />
<AdminSettingsSub {...permissions} />
<AdminSettingsSub permissions={adminPermissions} />
</>
)}
<DropdownMenuSeparator />
@@ -123,7 +124,7 @@ const ProxySettingsSub: FC<ProxySettingsSubProps> = ({ proxyContextValue }) => {
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<DropdownMenuItem
className={cn(itemStyles.default, open ? itemStyles.open : "")}
className={cn(itemStyles.default, open && itemStyles.open)}
onClick={(e) => {
e.preventDefault();
setOpen((prev) => !prev);
@@ -191,7 +192,8 @@ const ProxySettingsSub: FC<ProxySettingsSubProps> = ({ proxyContextValue }) => {
</DropdownMenuItem>
<DropdownMenuItem
className={cn(itemStyles.default, itemStyles.sub)}
onClick={() => {
onClick={(event) => {
event.stopPropagation();
proxyContextValue.refetchProxyLatencies();
}}
>
@@ -202,15 +204,18 @@ const ProxySettingsSub: FC<ProxySettingsSubProps> = ({ proxyContextValue }) => {
);
};
const AdminSettingsSub: FC<AdminSettingsPermissions> = (permissions) => {
type AdminSettingsSubProps = {
permissions: AdminSettingsPermissions;
};
const AdminSettingsSub: FC<AdminSettingsSubProps> = ({ permissions }) => {
const [open, setOpen] = useState(false);
const items = getAdminSettingsItems(permissions);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<DropdownMenuItem
className={cn(itemStyles.default, open ? itemStyles.open : "")}
className={cn(itemStyles.default, open && itemStyles.open)}
onClick={(e) => {
e.preventDefault();
setOpen((prev) => !prev);
@@ -223,15 +228,10 @@ const AdminSettingsSub: FC<AdminSettingsPermissions> = (permissions) => {
</DropdownMenuItem>
</CollapsibleTrigger>
<CollapsibleContent>
{items.map((item) => (
<DropdownMenuItem
key={item.to}
asChild
className={cn(itemStyles.default, itemStyles.sub)}
>
<Link to={item.to}>{item.label}</Link>
</DropdownMenuItem>
))}
<AdminSettingsItems
itemClassName={cn(itemStyles.default, itemStyles.sub)}
permissions={permissions}
/>
</CollapsibleContent>
</Collapsible>
);
@@ -254,7 +254,7 @@ const UserSettingsSub: FC<UserSettingsSubProps> = ({
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<DropdownMenuItem
className={cn(itemStyles.default, open ? itemStyles.open : "")}
className={cn(itemStyles.default, open && itemStyles.open)}
onClick={(e) => {
e.preventDefault();
setOpen((prev) => !prev);
+10 -9
View File
@@ -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}
/>
@@ -30,11 +30,15 @@ const meta: Meta<typeof NavbarView> = {
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: "" },
@@ -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<NavbarViewProps> = ({
buildInfo,
supportLinks,
onSignOut,
canViewDeployment,
canViewOrganizations,
canViewHealth,
canViewAuditLog,
canViewConnectionLog,
canViewAIBridge,
canViewAISettings,
adminPermissions,
canCreateChat,
proxyContextValue,
}) => {
@@ -125,17 +117,11 @@ export const NavbarView: FC<NavbarViewProps> = ({
</div>
)}
<div className="hidden md:block">
<DeploymentDropdown
canViewAuditLog={canViewAuditLog}
canViewOrganizations={canViewOrganizations}
canViewDeployment={canViewDeployment}
canViewConnectionLog={canViewConnectionLog}
canViewAIBridge={canViewAIBridge}
canViewAISettings={canViewAISettings}
canViewHealth={canViewHealth}
/>
</div>
{canViewAdminSettings(adminPermissions) && (
<div className="hidden md:block">
<AdminSettingsDropdown permissions={adminPermissions} />
</div>
)}
<NotificationsInbox
fetchNotifications={API.getInboxNotifications}
@@ -159,16 +145,10 @@ export const NavbarView: FC<NavbarViewProps> = ({
<div className="md:hidden">
<MobileMenu
proxyContextValue={proxyContextValue}
adminPermissions={adminPermissions}
user={user}
supportLinks={supportLinks}
onSignOut={onSignOut}
canViewAuditLog={canViewAuditLog}
canViewConnectionLog={canViewConnectionLog}
canViewOrganizations={canViewOrganizations}
canViewDeployment={canViewDeployment}
canViewAIBridge={canViewAIBridge}
canViewAISettings={canViewAISettings}
canViewHealth={canViewHealth}
/>
</div>
</div>
@@ -207,7 +187,16 @@ const NavItems: FC<NavItemsProps> = ({ className, user, canCreateChat }) => {
Templates
</NavLink>
<TasksNavItem user={user} />
<AgentsNavItem canCreateChat={canCreateChat} />
{canCreateChat && (
<NavLink
className={({ isActive }) => {
return cn(linkStyles.default, { [linkStyles.active]: isActive });
}}
to="/agents"
>
Agents
</NavLink>
)}
</nav>
);
};
@@ -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 (
<NavLink
className={({ isActive }) => {
return cn(linkStyles.default, { [linkStyles.active]: isActive });
}}
to="/agents"
>
Agents
</NavLink>
);
};
function isNavbarLink(link: TypesGen.LinkConfig): boolean {
return link.location === "navbar";
}
@@ -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);
-8
View File
@@ -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) =>
+1 -2
View File
@@ -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)