diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx
index 395932f39e2..c94b952c33a 100644
--- a/apps/webapp/app/components/navigation/SideMenu.tsx
+++ b/apps/webapp/app/components/navigation/SideMenu.tsx
@@ -2,12 +2,10 @@ import {
ArrowTopRightOnSquareIcon,
ChevronRightIcon,
ExclamationTriangleIcon,
- PencilSquareIcon,
} from "@heroicons/react/24/outline";
-import { Link, useFetcher, useNavigation } from "@remix-run/react";
+import { useFetcher, useNavigate, useNavigation, useSubmit } from "@remix-run/react";
import { LayoutGroup, motion } from "framer-motion";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
-import simplur from "simplur";
import { AIChatIcon } from "~/assets/icons/AIChatIcon";
import { AIPenIcon } from "~/assets/icons/AIPenIcon";
import { ArrowLeftRightIcon } from "~/assets/icons/ArrowLeftRightIcon";
@@ -17,6 +15,7 @@ import { BatchesIcon } from "~/assets/icons/BatchesIcon";
import { BellIcon } from "~/assets/icons/BellIcon";
import { Box3DIcon } from "~/assets/icons/Box3DIcon";
import { BugIcon } from "~/assets/icons/BugIcon";
+import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon";
import { ChartBarIcon } from "~/assets/icons/ChartBarIcon";
import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon";
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
@@ -31,30 +30,48 @@ import { HomeIcon } from "~/assets/icons/HomeIcon";
import { IDIcon } from "~/assets/icons/IDIcon";
import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon";
import { KeyIcon } from "~/assets/icons/KeyIcon";
+import { LeftSideMenuCollapsedIcon } from "~/assets/icons/LeftSideMenuCollapsedIcon";
+import { LeftSideMenuIcon } from "~/assets/icons/LeftSideMenuIcon";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
import { LogsIcon } from "~/assets/icons/LogsIcon";
import { PlusIcon } from "~/assets/icons/PlusIcon";
import { QueuesIcon } from "~/assets/icons/QueuesIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
+import { ShieldIcon } from "~/assets/icons/ShieldIcon";
import { SlidersIcon } from "~/assets/icons/SlidersIcon";
import { TasksIcon } from "~/assets/icons/TasksIcon";
import { UsageIcon } from "~/assets/icons/UsageIcon";
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
+import { CreditCardIcon } from "~/assets/icons/CreditCardIcon";
+import { UserCrossIcon } from "~/assets/icons/UserCrossIcon";
+import { UserGroupIcon } from "~/assets/icons/UserGroupIcon";
+import { RolesIcon } from "~/assets/icons/RolesIcon";
+import { PadlockIcon } from "~/assets/icons/PadlockIcon";
+import { SlackIcon } from "~/assets/icons/SlackIcon";
+import { VercelLogo } from "~/components/integrations/VercelLogo";
import { Avatar } from "~/components/primitives/Avatar";
+import { UserProfilePhoto } from "~/components/UserProfilePhoto";
import { type MatchedEnvironment } from "~/hooks/useEnvironment";
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { type MatchedProject } from "~/hooks/useProject";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
+import { useShowSelfServe } from "~/hooks/useShowSelfServe";
import { useHasAdminAccess } from "~/hooks/useUser";
import { type UserWithDashboardPreferences } from "~/models/user.server";
-import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
+import {
+ useCurrentPlan,
+ useIsUsingRbacPlugin,
+ useIsUsingSsoPlugin,
+} from "~/routes/_app.orgs.$organizationSlug/route";
import { type FeedbackType } from "~/routes/resources.feedback";
import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incidents";
import { cn } from "~/utils/cn";
import {
accountPath,
+ accountSecurityPath,
+ personalAccessTokensPath,
adminPath,
branchesPath,
concurrencyPath,
@@ -63,13 +80,19 @@ import {
newOrganizationPath,
newProjectPath,
organizationPath,
+ organizationRolesPath,
organizationSettingsPath,
+ organizationSlackIntegrationPath,
+ organizationSsoPath,
organizationTeamPath,
+ organizationVercelIntegrationPath,
queryPath,
regionsPath,
v3ApiKeysPath,
v3BatchesPath,
+ v3BillingLimitsPath,
v3BillingPath,
+ v3PrivateConnectionsPath,
v3BulkActionsPath,
v3DashboardsLandingPath,
v3DeploymentsPath,
@@ -89,17 +112,15 @@ import {
v3UsagePath,
v3WaitpointTokensPath,
} from "~/utils/pathBuilder";
-import { AskAI } from "../AskAI";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence";
import { AlphaBadge, NewBadge } from "../FeatureBadges";
-import { ImpersonationBanner } from "../ImpersonationBanner";
import { Button, ButtonContent, LinkButton } from "../primitives/Buttons";
import { Dialog, DialogTrigger } from "../primitives/Dialog";
import { Paragraph } from "../primitives/Paragraph";
+import { Badge } from "../primitives/Badge";
import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover";
import { ShortcutKey } from "../primitives/ShortcutKey";
-import { TextLink } from "../primitives/TextLink";
import {
SimpleTooltip,
Tooltip,
@@ -126,6 +147,23 @@ function getSectionCollapsed(
return sideMenu?.collapsedSections?.[sectionId] ?? false;
}
+// Size the side menu popover items (org menu + project picker) to match the side
+// menu items: a 20px leading icon and a 0.90625rem label (vs the smaller
+// small-menu-item defaults). The icon class overrides the variant icon size; the
+// label class lands on the button element and overrides its text-2sm via
+// tailwind-merge. The icon constant also carries the default dimmed color; items
+// that need a different icon color (e.g. the indigo project folders) set their own.
+const SIDE_MENU_POPOVER_ITEM_ICON = "h-5 w-5 text-text-dimmed";
+const SIDE_MENU_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]";
+
+// Accent used to signal impersonation mode across the UI (the side menu border and the
+// "Stop impersonating" action). Full class strings per Tailwind's static scanning — change
+// the shade here to update everywhere.
+const IMPERSONATION_ACCENT = {
+ border: "border-yellow-500/80",
+ text: "text-yellow-500/80",
+};
+
type SideMenuUser = Pick<
UserWithDashboardPreferences,
"email" | "admin" | "dashboardPreferences"
@@ -155,8 +193,6 @@ export function SideMenu({
organization,
organizations,
}: SideMenuProps) {
- const borderRef = useRef
(null);
- const [showHeaderDivider, setShowHeaderDivider] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(
user.dashboardPreferences.sideMenu?.isCollapsed ?? false
);
@@ -265,95 +301,53 @@ export function SideMenu({
action: handleToggleCollapsed,
});
- useEffect(() => {
- const handleScroll = () => {
- if (borderRef.current) {
- const shouldShowHeaderDivider = borderRef.current.scrollTop > 1;
- if (showHeaderDivider !== shouldShowHeaderDivider) {
- setShowHeaderDivider(shouldShowHeaderDivider);
- }
- }
- };
-
- borderRef.current?.addEventListener("scroll", handleScroll);
- return () => borderRef.current?.removeEventListener("scroll", handleScroll);
- }, [showHeaderDivider]);
-
return (
-
-
-
+
+
- {isAdmin && !user.isImpersonating ? (
-
-
-
-
-
-
-
- Admin dashboard
-
-
-
-
- ) : isAdmin && user.isImpersonating ? (
-
-
-
- ) : null}
+
+
+
-
-
-
+
+
+
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
-
+
@@ -383,7 +377,22 @@ export function SideMenu({
)}
-
+
+
+
+
{isFreeUser && (
@@ -809,30 +819,26 @@ function V3DeprecationContent() {
);
}
-function ProjectSelector({
- project,
+function OrgSelector({
organization,
organizations,
- user,
isCollapsed = false,
}: {
- project: SideMenuProject;
organization: MatchedOrganization;
organizations: MatchedOrganization[];
- user: SideMenuUser;
isCollapsed?: boolean;
}) {
const currentPlan = useCurrentPlan();
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
const navigation = useNavigation();
const { isManagedCloud } = useFeatures();
+ const featureFlags = useFeatureFlags();
+ const showSelfServe = useShowSelfServe();
+ const isUsingRbacPlugin = useIsUsingRbacPlugin();
+ const isUsingSsoPlugin = useIsUsingSsoPlugin();
- let plan: string | undefined = undefined;
- if (currentPlan?.v3Subscription?.isPaying === false) {
- plan = "Free";
- } else if (currentPlan?.v3Subscription?.isPaying === true) {
- plan = currentPlan.v3Subscription.plan?.title;
- }
+ const isPaying = currentPlan?.v3Subscription?.isPaying === true;
+ const planTitle = currentPlan?.v3Subscription?.plan?.title;
useEffect(() => {
setOrgMenuOpen(false);
@@ -856,23 +862,22 @@ function ProjectSelector({
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
)}
>
-
- {project.name ?? "Select a project"}
+ {organization.title}
}
- content={`${organization.title} / ${project.name ?? "Select a project"}`}
+ content={organization.title}
side="right"
sideOffset={8}
hidden={!isCollapsed}
@@ -887,81 +892,81 @@ function ProjectSelector({
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
>
-
-
-
-
-
-
-
-
{organization.title}
-
- {plan && (
-
- {plan} plan
-
- )}
- {simplur`${organization.membersCount} member[|s]`}
-
-
-
-
-
-
- Settings
-
- {isManagedCloud && (
-
-
- Usage
-
- )}
-
-
- {organization.projects.map((p) => {
- const isSelected = p.id === project.id;
- return (
-
- {p.name}
-
- }
- isSelected={isSelected}
- icon={isSelected ? FolderOpenIcon : FolderClosedIcon}
- leadingIconClassName="text-indigo-500"
- />
- );
- })}
-
+
+ {isManagedCloud && (
+
+ )}
+ {isManagedCloud && (
+
+ Billing
+ {isPaying && planTitle ? {planTitle} : null}
+
+ }
+ icon={CreditCardIcon}
+ leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
+ className={SIDE_MENU_POPOVER_ITEM_LABEL}
+ />
+ )}
+ {isManagedCloud && showSelfServe && (
+
+ )}
+
+ {featureFlags.hasPrivateConnections && (
+
+ )}
+ {isUsingRbacPlugin && (
+
+ )}
+ {isUsingSsoPlugin && (
+
+ )}
+
{organizations.length > 1 ? (
@@ -971,16 +976,120 @@ function ProjectSelector({
to={newOrganizationPath()}
title="New organization"
icon={PlusIcon}
- leadingIconClassName="text-text-dimmed"
+ leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
+ className={SIDE_MENU_POPOVER_ITEM_LABEL}
/>
)}
-
+
+
+ );
+}
+
+function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImpersonating: boolean }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const navigation = useNavigation();
+ const navigate = useNavigate();
+ const submit = useSubmit();
+
+ useEffect(() => {
+ setIsOpen(false);
+ }, [navigation.location?.pathname]);
+
+ const stopImpersonating = () =>
+ submit(null, { action: "/resources/impersonation", method: "delete" });
+
+ useShortcutKeys({
+ shortcut: isAdmin
+ ? { modifiers: ["mod"], key: "esc", enabledOnInputElements: true }
+ : undefined,
+ action: () => {
+ if (isImpersonating) {
+ stopImpersonating();
+ } else {
+ navigate(adminPath());
+ }
+ },
+ });
+
+ return (
+
setIsOpen(open)} open={isOpen}>
+
+
+
+ }
+ content="Account"
+ side="bottom"
+ sideOffset={8}
+ disableHoverableContent
+ />
+
+ {isAdmin && (
+
+ {isImpersonating ? (
+
+ Stop impersonating
+
+
+
+
+
+ }
+ icon={UserCrossIcon}
+ onClick={stopImpersonating}
+ leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)}
+ className={SIDE_MENU_POPOVER_ITEM_LABEL}
+ />
+ ) : (
+
+ Admin dashboard
+
+
+
+
+
+ }
+ icon={HomeIcon}
+ leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
+ className={SIDE_MENU_POPOVER_ITEM_LABEL}
+ />
+ )}
+
+ )}
+
@@ -988,7 +1097,8 @@ function ProjectSelector({
to={logoutPath()}
title="Logout"
icon={ArrowRightSquareIcon}
- leadingIconClassName="text-text-dimmed"
+ leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
+ className={SIDE_MENU_POPOVER_ITEM_LABEL}
danger
/>
@@ -997,6 +1107,118 @@ function ProjectSelector({
);
}
+function ProjectSelector({
+ project,
+ organization,
+ environment,
+ isCollapsed = false,
+ className,
+}: {
+ project: SideMenuProject;
+ organization: MatchedOrganization;
+ environment: SideMenuEnvironment;
+ isCollapsed?: boolean;
+ className?: string;
+}) {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const navigation = useNavigation();
+
+ useEffect(() => {
+ setIsMenuOpen(false);
+ }, [navigation.location?.pathname]);
+
+ return (
+
setIsMenuOpen(open)} open={isMenuOpen}>
+
+
+
+
+
+ {project.name ?? "Select a project"}
+
+
+
+
+
+
+
+ }
+ content={project.name ?? "Select a project"}
+ side="right"
+ sideOffset={8}
+ hidden={!isCollapsed}
+ buttonClassName="!h-8"
+ asChild
+ disableHoverableContent
+ />
+
+
+
+ {organization.projects.map((p) => {
+ const isSelected = p.id === project.id;
+ return (
+
+ {p.name}
+
+ }
+ isSelected={isSelected}
+ icon={isSelected ? FolderOpenIcon : FolderClosedIcon}
+ leadingIconClassName="h-5 w-5 text-indigo-500"
+ className={SIDE_MENU_POPOVER_ITEM_LABEL}
+ />
+ );
+ })}
+
+
+
+ );
+}
+
function SwitchOrganizations({
organizations,
organization,
@@ -1041,9 +1263,9 @@ function SwitchOrganizations({
}
+ icon={ }
leadingIconClassName="text-text-dimmed"
+ className={SIDE_MENU_POPOVER_ITEM_LABEL}
isSelected={org.id === organization.id}
/>
))}
@@ -1079,7 +1302,8 @@ function SwitchOrganizations({
to={newOrganizationPath()}
title="New organization"
icon={PlusIcon}
- leadingIconClassName="text-text-dimmed"
+ leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
+ className={SIDE_MENU_POPOVER_ITEM_LABEL}
/>
@@ -1088,18 +1312,84 @@ function SwitchOrganizations({
);
}
-function SelectorDivider() {
+function Integrations({ organization }: { organization: MatchedOrganization }) {
+ const navigation = useNavigation();
+ const [isMenuOpen, setMenuOpen] = useState(false);
+ const timeoutRef = useRef
(null);
+
+ // Clear timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ setMenuOpen(false);
+ }, [navigation.location?.pathname]);
+
+ const handleMouseEnter = () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ setMenuOpen(true);
+ };
+
+ const handleMouseLeave = () => {
+ // Small delay before closing to allow moving to the content
+ timeoutRef.current = setTimeout(() => {
+ setMenuOpen(false);
+ }, 150);
+ };
+
return (
-
-
-
+ setMenuOpen(open)} open={isMenuOpen}>
+
+
+
+ Integrations
+
+
+
+
+
+
+
);
}
@@ -1153,10 +1443,12 @@ function HelpAndAI({
isCollapsed,
organizationId,
projectId,
+ onToggleCollapsed,
}: {
isCollapsed: boolean;
organizationId: string;
projectId: string;
+ onToggleCollapsed: () => void;
}) {
return (
@@ -1172,126 +1464,58 @@ function HelpAndAI({
organizationId={organizationId}
projectId={projectId}
/>
-
+
);
}
-function AnimatedChevron({
- isHovering,
+function CollapseMenuButton({
isCollapsed,
+ onToggle,
}: {
- isHovering: boolean;
isCollapsed: boolean;
+ onToggle: () => void;
}) {
- // When hovering and expanded: left chevron (pointing left to collapse)
- // When hovering and collapsed: right chevron (pointing right to expand)
- // When not hovering: straight vertical line
-
- const getRotation = () => {
- if (!isHovering) return { top: 0, bottom: 0 };
- if (isCollapsed) {
- // Right chevron
- return { top: -17, bottom: 17 };
- } else {
- // Left chevron
- return { top: 17, bottom: -17 };
- }
- };
-
- const { top, bottom } = getRotation();
-
- // Calculate horizontal offset to keep chevron centered when rotated
- // Left chevron: translate left (-1.5px)
- // Right chevron: translate right (+1.5px)
- const getTranslateX = () => {
- if (!isHovering) return 0;
- return isCollapsed ? 1.5 : -1.5;
- };
-
- return (
-
- {/* Top segment */}
-
- {/* Bottom segment */}
-
-
- );
-}
-
-function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) {
const [isHovering, setIsHovering] = useState(false);
return (
-
- {/* Vertical line to mask the side menu border */}
-
+
-
+
- setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
- className={cn(
- "group flex h-12 w-6 items-center justify-center rounded-md text-text-dimmed transition-all duration-200 focus-custom",
- isHovering
- ? "border border-grid-bright bg-background-bright shadow-md hover:bg-charcoal-750 hover:text-text-bright"
- : "border border-transparent bg-transparent"
- )}
>
-
-
+
+ {isCollapsed ? (
+
+ ) : (
+
+ )}
+
+
-
+
{isCollapsed ? "Expand" : "Collapse"}
diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx
index f2349fc1e6e..4dc955d2607 100644
--- a/apps/webapp/app/components/navigation/SideMenuItem.tsx
+++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx
@@ -1,4 +1,9 @@
-import { type AnchorHTMLAttributes, type ReactNode } from "react";
+import {
+ type AnchorHTMLAttributes,
+ type ButtonHTMLAttributes,
+ forwardRef,
+ type ReactNode,
+} from "react";
import { Link } from "@remix-run/react";
import { motion } from "framer-motion";
import { usePathName } from "~/hooks/usePathName";
@@ -14,6 +19,7 @@ export function SideMenuItem({
trailingIcon,
trailingIconClassName,
name,
+ nameClassName,
to,
badge,
target,
@@ -30,6 +36,7 @@ export function SideMenuItem({
trailingIcon?: RenderIcon;
trailingIconClassName?: string;
name: string;
+ nameClassName?: string;
to: string;
badge?: ReactNode;
target?: AnchorHTMLAttributes["target"];
@@ -83,7 +90,12 @@ export function SideMenuItem({
}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
-
+
{name}
{badge && !isCollapsed && (
@@ -154,3 +166,33 @@ export function SideMenuItem({
/>
);
}
+
+/**
+ * A button styled to match {@link SideMenuItem}, for menu entries that open a
+ * dialog/sheet rather than navigate. Forwards its ref and props so it can be
+ * used as a Radix `asChild` trigger.
+ */
+export const SideMenuItemButton = forwardRef<
+ HTMLButtonElement,
+ { icon: RenderIcon; name: string } & ButtonHTMLAttributes
+>(function SideMenuItemButton({ icon, name, className, type, ...props }, ref) {
+ return (
+
+
+
+ {name}
+
+
+ );
+});
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx
index cfe74e9ccc9..deacfdf1c59 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx
@@ -11,6 +11,7 @@ import { RegionsPresenter, type Region } from "~/presenters/v3/RegionsPresenter.
import { getImpersonationId } from "~/services/impersonation.server";
import { getCachedUsage, getBillingLimit, getCurrentPlan } from "~/services/platform.v3.server";
import { rbac } from "~/services/rbac.server";
+import { ssoController } from "~/services/sso.server";
import { canManageBillingLimits } from "~/services/routeBuilders/permissions.server";
import { requireUser } from "~/services/session.server";
import { telemetry } from "~/services/telemetry.server";
@@ -33,6 +34,26 @@ export function useCurrentPlan(matches?: UIMatch[]) {
return data?.currentPlan;
}
+/** Whether the optional RBAC plugin is installed (gates the Roles UI). */
+export function useIsUsingRbacPlugin(matches?: UIMatch[]) {
+ const data = useTypedMatchesData({
+ id: "routes/_app.orgs.$organizationSlug",
+ matches,
+ });
+
+ return data?.isUsingRbacPlugin ?? false;
+}
+
+/** Whether the optional SSO plugin is installed (gates the SSO UI). */
+export function useIsUsingSsoPlugin(matches?: UIMatch[]) {
+ const data = useTypedMatchesData({
+ id: "routes/_app.orgs.$organizationSlug",
+ matches,
+ });
+
+ return data?.isUsingSsoPlugin ?? false;
+}
+
export const shouldRevalidate: ShouldRevalidateFunction = (params) => {
const { currentParams, nextParams } = params;
@@ -98,7 +119,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const shouldLoadRegions = !!projectParam && !!environment && environment.type !== "DEVELOPMENT";
- const [sessionAuth, plan, usage, billingLimit, customDashboards, regions] = await Promise.all([
+ const [
+ sessionAuth,
+ plan,
+ usage,
+ billingLimit,
+ customDashboards,
+ regions,
+ isUsingRbacPlugin,
+ isUsingSsoPlugin,
+ ] = await Promise.all([
rbac
.authenticateSession(request, {
userId: user.id,
@@ -123,6 +153,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
.then(({ regions }) => regions)
.catch(() => [] as Region[])
: Promise.resolve([] as Region[]),
+ // Resolve which optional plugins are installed so the side menu can gate the
+ // Roles (RBAC) and SSO items the same way the org settings side menu does.
+ // Both calls are cheap and cached after the first resolution.
+ rbac.isUsingPlugin().catch(() => false),
+ ssoController.isUsingPlugin().catch(() => false),
]);
const userCanManageBillingLimits = sessionAuth.ok
? canManageBillingLimits(sessionAuth.ability)
@@ -184,6 +219,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
},
widgetLimitPerDashboard,
canManageBillingLimits: userCanManageBillingLimits,
+ isUsingRbacPlugin,
+ isUsingSsoPlugin,
});
};
diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx
index b4b92c8a133..fa24fa31d7c 100644
--- a/apps/webapp/app/routes/account._index/route.tsx
+++ b/apps/webapp/app/routes/account._index/route.tsx
@@ -3,8 +3,6 @@ import { conformZodMessage, parseWithZod } from "@conform-to/zod";
import { Form, type MetaFunction, useActionData } from "@remix-run/react";
import { type ActionFunction, json } from "@remix-run/server-runtime";
import { z } from "zod";
-import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon";
-import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon";
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
import {
MainHorizontallyCenteredContainer,
@@ -12,15 +10,12 @@ import {
PageContainer,
} from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
-import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
-import { Fieldset } from "~/components/primitives/Fieldset";
-import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
-import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
+import { Switch } from "~/components/primitives/Switch";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { prisma } from "~/db.server";
import { useUser } from "~/hooks/useUser";
@@ -144,56 +139,72 @@ export default function Page() {
-
-
+
+
Profile
diff --git a/apps/webapp/app/routes/storybook.icons/route.tsx b/apps/webapp/app/routes/storybook.icons/route.tsx
index dee5aa3e25a..a465e05fb5a 100644
--- a/apps/webapp/app/routes/storybook.icons/route.tsx
+++ b/apps/webapp/app/routes/storybook.icons/route.tsx
@@ -81,6 +81,7 @@ import { KeyboardUpIcon } from "~/assets/icons/KeyboardUpIcon";
import { KeyboardWindowsIcon } from "~/assets/icons/KeyboardWindowsIcon";
import { KeyIcon } from "~/assets/icons/KeyIcon";
import { KeyValueIcon } from "~/assets/icons/KeyValueIcon";
+import { LeftSideMenuIcon } from "~/assets/icons/LeftSideMenuIcon";
import { ListBulletIcon } from "~/assets/icons/ListBulletIcon";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
import { LogsIcon } from "~/assets/icons/LogsIcon";
@@ -217,6 +218,7 @@ const icons: IconEntry[] = [
{ name: "KeyboardWindowsIcon", render: simple(KeyboardWindowsIcon) },
{ name: "KeyIcon", render: simple(KeyIcon) },
{ name: "KeyValueIcon", render: simple(KeyValueIcon) },
+ { name: "LeftSideMenuIcon", render: simple(LeftSideMenuIcon) },
{ name: "ListBulletIcon", render: simple(ListBulletIcon) },
{ name: "ListCheckedIcon", render: simple(ListCheckedIcon) },
{ name: "LlamaIcon", render: simple(LlamaIcon) },