diff --git a/.server-changes/side-menu-project-and-org-menus.md b/.server-changes/side-menu-project-and-org-menus.md
new file mode 100644
index 00000000000..d073d58ccc3
--- /dev/null
+++ b/.server-changes/side-menu-project-and-org-menus.md
@@ -0,0 +1,41 @@
+---
+area: webapp
+type: improvement
+---
+
+Refresh the side menu and account UI:
+
+- Add a new "Project" section above the "Environment" section with a popover
+ that lists the org's projects (folder icon + checkmark for the selected one)
+ and a "New project" item at the bottom.
+- The top-left menu now shows the organization (avatar + org name, no
+ project/diagonal divider) and its popover is a clean list of org-level items
+ (Settings, Usage, Billing with plan badge, Billing alerts, Team, Private
+ connections, Roles, SSO, Vercel integration, Slack integration, Switch
+ organization), with a separate account menu button (Profile, Personal Access
+ Tokens, Security, admin/impersonation, Logout) beside it.
+- Match the Environment selector popover's item sizing (icons and labels,
+ including the branch submenu and its footer) to the Project popover so the two
+ side-menu menus are visually consistent.
+- Redesign the account Profile page (/account) into the Security page's
+ row-and-divider layout: Profile picture, Full name, Email address, and a
+ "Receive onboarding emails" toggle on equal-height rows, with a primary Update
+ button.
+- Signal impersonation mode with a yellow side-menu border and a matching
+ "Stop impersonating" accent.
+- Move the Settings item to the top of the organization settings side menu.
+- Align the organization settings and account side menus' horizontal padding
+ with the main side menu, and tighten the "Personal Access Tokens" label so it
+ no longer truncates.
+- Restyle the "Shortcuts" and "Contact us…" entries in the Help & Feedback
+ popover to match the other menu items (icon size/alignment, dimmed text, text
+ size).
+- Make the main side menu resizable: drag its right edge to set a custom width
+ (remembered per user), with the labels, headers, and padding transitioning in
+ real time and a snap to open or collapsed when released below the default
+ width. Clicking the edge toggles the menu and a tooltip explains both
+ gestures.
+
+The org loader now exposes whether the RBAC and SSO plugins are installed so the
+side menu can gate the Roles and SSO items the same way the settings side menu
+does.
diff --git a/apps/webapp/app/assets/icons/ChainLinkIcon.tsx b/apps/webapp/app/assets/icons/ChainLinkIcon.tsx
new file mode 100644
index 00000000000..f2e00245479
--- /dev/null
+++ b/apps/webapp/app/assets/icons/ChainLinkIcon.tsx
@@ -0,0 +1,27 @@
+export function ChainLinkIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx b/apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx
new file mode 100644
index 00000000000..aa229af92a4
--- /dev/null
+++ b/apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx
@@ -0,0 +1,22 @@
+export function LeftSideMenuCollapsedIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx b/apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx
new file mode 100644
index 00000000000..7db45082eb3
--- /dev/null
+++ b/apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx
@@ -0,0 +1,49 @@
+import { motion } from "framer-motion";
+import { useState } from "react";
+
+export function LeftSideMenuIcon({
+ className,
+ hovered: controlledHovered,
+}: {
+ className?: string;
+ /**
+ * When provided, the shape animation is driven by this prop (e.g. from a
+ * parent button's hover). When omitted, the icon animates on its own hover.
+ */
+ hovered?: boolean;
+}) {
+ const [internalHovered, setInternalHovered] = useState(false);
+ const isControlled = controlledHovered !== undefined;
+ const hovered = isControlled ? controlledHovered : internalHovered;
+
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx
index bf76654cced..c0ea3922a07 100644
--- a/apps/webapp/app/components/AskAI.tsx
+++ b/apps/webapp/app/components/AskAI.tsx
@@ -11,11 +11,12 @@ import { useSearchParams } from "@remix-run/react";
import DOMPurify from "dompurify";
import { motion } from "framer-motion";
import { marked } from "marked";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { useTypedRouteLoaderData } from "remix-typedjson";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { useFeatures } from "~/hooks/useFeatures";
+import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type loader } from "~/root";
import { Button } from "./primitives/Buttons";
import { Callout } from "./primitives/Callout";
@@ -38,6 +39,105 @@ function useKapaWebsiteId() {
return routeMatch?.kapa.websiteId;
}
+/** Open/close state for the Ask AI dialog, including the `?aiHelp=` deep-link handling. */
+function useAskAIState() {
+ const [isOpen, setIsOpen] = useState(false);
+ const [initialQuery, setInitialQuery] = useState();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const openAskAI = useCallback((question?: string) => {
+ if (question) {
+ setInitialQuery(question);
+ } else {
+ setInitialQuery(undefined);
+ }
+ setIsOpen(true);
+ }, []);
+
+ const closeAskAI = useCallback(() => {
+ setIsOpen(false);
+ setInitialQuery(undefined);
+ }, []);
+
+ // Handle URL param functionality
+ useEffect(() => {
+ const aiHelp = searchParams.get("aiHelp");
+ if (aiHelp) {
+ // Delay to avoid hCaptcha bot detection
+ window.setTimeout(() => openAskAI(aiHelp), 1000);
+
+ // Clone instead of mutating in place
+ const next = new URLSearchParams(searchParams);
+ next.delete("aiHelp");
+ setSearchParams(next);
+ }
+ }, [searchParams, openAskAI]);
+
+ return { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI };
+}
+
+/**
+ * Hosts Ask AI for a menu that renders its own trigger (the side menu's Help & Feedback popover):
+ * the Kapa provider, the global ⌘I shortcut, and the dialog all live here, and `children`
+ * receives the open function to render a trigger with. Wrap this around the popover rather than
+ * inside its content so the dialog and shortcut survive the popover closing. `children` receives
+ * undefined when Ask AI is unavailable (self-hosted, no Kapa website id, or during SSR).
+ */
+export function AskAIRoot({
+ children,
+}: {
+ children: (openAskAI: (() => void) | undefined) => ReactNode;
+}) {
+ const { isManagedCloud } = useFeatures();
+ const websiteId = useKapaWebsiteId();
+
+ if (!isManagedCloud || !websiteId) {
+ return <>{children(undefined)}>;
+ }
+
+ return (
+ {children(undefined)}>}>
+ {() => {children}}
+
+ );
+}
+
+function AskAIRootProvider({
+ websiteId,
+ children,
+}: {
+ websiteId: string;
+ children: (openAskAI: () => void) => ReactNode;
+}) {
+ const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState();
+
+ useShortcutKeys({
+ shortcut: { modifiers: ["mod"], key: "i", enabledOnInputElements: true },
+ action: () => openAskAI(),
+ });
+
+ return (
+ openAskAI(),
+ onAnswerGenerationCompleted: () => openAskAI(),
+ },
+ }}
+ botProtectionMechanism="hcaptcha"
+ >
+ {children(() => openAskAI())}
+
+
+ );
+}
+
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { isManagedCloud } = useFeatures();
const websiteId = useKapaWebsiteId();
@@ -72,37 +172,7 @@ type AskAIProviderProps = {
};
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
- const [isOpen, setIsOpen] = useState(false);
- const [initialQuery, setInitialQuery] = useState();
- const [searchParams, setSearchParams] = useSearchParams();
-
- const openAskAI = useCallback((question?: string) => {
- if (question) {
- setInitialQuery(question);
- } else {
- setInitialQuery(undefined);
- }
- setIsOpen(true);
- }, []);
-
- const closeAskAI = useCallback(() => {
- setIsOpen(false);
- setInitialQuery(undefined);
- }, []);
-
- // Handle URL param functionality
- useEffect(() => {
- const aiHelp = searchParams.get("aiHelp");
- if (aiHelp) {
- // Delay to avoid hCaptcha bot detection
- window.setTimeout(() => openAskAI(aiHelp), 1000);
-
- // Clone instead of mutating in place
- const next = new URLSearchParams(searchParams);
- next.delete("aiHelp");
- setSearchParams(next);
- }
- }, [searchParams, openAskAI]);
+ const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState();
return (
-
+ trailing={}
+ />
diff --git a/apps/webapp/app/components/UserProfilePhoto.tsx b/apps/webapp/app/components/UserProfilePhoto.tsx
index 99febd1c240..16134174337 100644
--- a/apps/webapp/app/components/UserProfilePhoto.tsx
+++ b/apps/webapp/app/components/UserProfilePhoto.tsx
@@ -1,4 +1,4 @@
-import { UserCircleIcon } from "@heroicons/react/24/solid";
+import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon";
import { useOptionalUser } from "~/hooks/useUser";
import { cn } from "~/utils/cn";
@@ -26,6 +26,6 @@ export function UserAvatar({
/>
) : (
-
+
);
}
diff --git a/apps/webapp/app/components/navigation/AccountSideMenu.tsx b/apps/webapp/app/components/navigation/AccountSideMenu.tsx
index 10c7a886842..90f8945bda0 100644
--- a/apps/webapp/app/components/navigation/AccountSideMenu.tsx
+++ b/apps/webapp/app/components/navigation/AccountSideMenu.tsx
@@ -7,7 +7,6 @@ import {
personalAccessTokensPath,
rootPath,
} from "~/utils/pathBuilder";
-import { AskAI } from "../AskAI";
import { LinkButton } from "../primitives/Buttons";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
@@ -34,7 +33,7 @@ export function AccountSideMenu({ user }: { user: User }) {
Back to app
-
+
-
);
diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx
index 58c3aae5a0a..649056b5d17 100644
--- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx
+++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx
@@ -35,6 +35,12 @@ import { V4Badge } from "../V4Badge";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
import { Badge } from "../primitives/Badge";
+// Size this Env popover's items to match the Project popover menu items
+// (SIDE_MENU_POPOVER_ITEM_* in SideMenu.tsx). Applied only at these call sites so the
+// shared EnvironmentLabel/EnvironmentCombo defaults used elsewhere in the app stay unchanged.
+const ENV_POPOVER_ITEM_ICON = "size-5";
+const ENV_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]";
+
export function EnvironmentSelector({
organization,
project,
@@ -64,7 +70,7 @@ export function EnvironmentSelector({
button={
-
+ {/*
+ Inner opacity is driven by the resizable SideMenu's `--sm-label-opacity`
+ variable so the label fades as the menu is dragged narrower. Unset in the other
+ places this selector is used (blank-state panels, Limits page) → falls back to 1.
+ */}
+
+
+
-
+
}
- content={environmentFullTitle(environment)}
+ content={`${environmentFullTitle(environment)} environment`}
side="right"
sideOffset={8}
- hidden={!isCollapsed}
+ delayDuration={isCollapsed ? 0 : 500}
buttonClassName="!h-8"
asChild
disableHoverableContent
@@ -135,7 +148,13 @@ export function EnvironmentSelector({
}
+ title={
+
+ }
isSelected={env.id === environment.id}
/>
);
@@ -153,8 +172,12 @@ export function EnvironmentSelector({
)}
title={
-
- Branches are a way to test new features in isolation before merging them into the
- main environment.
-
-
- Branches are only available when using or above. Read our{" "}
- v4 upgrade guide to learn
- more.
-
-
+
+ Branches are a way to test new features in isolation before merging them into the main
+ environment.
+
+
+ Branches are only available when using or above. Read our{" "}
+ v4 upgrade guide to learn more.
+
+ >
);
}
diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
index 4264906d22d..4d2996ea662 100644
--- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
+++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
@@ -1,8 +1,10 @@
import { ArrowUpRightIcon } from "@heroicons/react/20/solid";
import { motion } from "framer-motion";
import { Fragment, useState } from "react";
+import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { BookIcon } from "~/assets/icons/BookIcon";
import { BulbIcon } from "~/assets/icons/BulbIcon";
+import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon";
import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
import { RadarPulseIcon } from "~/assets/icons/RadarPulseIcon";
@@ -11,14 +13,14 @@ import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { useRecentChangelogs } from "~/routes/resources.platform-changelogs";
import { cn } from "~/utils/cn";
+import { AskAIRoot } from "../AskAI";
import { Feedback } from "../Feedback";
import { Shortcuts } from "../Shortcuts";
-import { Button } from "../primitives/Buttons";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { SimpleTooltip } from "../primitives/Tooltip";
-import { SideMenuItem } from "./SideMenuItem";
+import { SideMenuItem, SideMenuItemButton } from "./SideMenuItem";
export function HelpAndFeedback({
disableShortcut = false,
@@ -48,135 +50,162 @@ export function HelpAndFeedback({
-
-
-
-
-
+ {(openAskAI) => (
+
+
+
+
+ {/*
+ Fades via the resizable side menu's `--sm-label-opacity` variable (falls back to
+ fully visible when unset) and truncates as the menu narrows, matching the nav
+ items. Only max-width transitions via CSS: the hover color snaps like the nav
+ items, and transitioning opacity would lag the per-frame variable writes.
+ */}
+
+ Help & Feedback
+
+
+ {/*
+ Up/down chevron revealed on hover, matching the Project menu button. Only
+ rendered when expanded so it can never occupy space or paint over the
+ collapsed icon (it is a hover affordance that has no meaning collapsed).
+ */}
+ {!isCollapsed && (
+
+
+
+ )}
+
+ }
+ content={
+
Help & Feedback
+
-
-
+
+
+ {openAskAI !== undefined && (
+
+
);
}
-/** Helper component that fades out but preserves width (collapses to 0 width) */
+/**
+ * Helper component that fades out but preserves width (collapses to 0 width). The fade is driven
+ * by the menu's `--sm-label-opacity` variable so it tracks a drag in real time (only max-width
+ * transitions via CSS — transitioning the opacity too would lag the per-frame variable writes).
+ */
function CollapsibleElement({
isCollapsed,
children,
@@ -1116,10 +1723,11 @@ function CollapsibleElement({
return (
);
}
+
+/**
+ * The resize affordance straddling the side menu's right border. Hovering fades in a 3px indigo
+ * line (matching the app's ResizableHandle, with soft gradient ends), dragging resizes the menu,
+ * and a plain click toggles it collapsed/expanded. The tooltip follows the pointer's vertical
+ * position and explains both gestures.
+ *
+ * The strip extends 4px past the menu's edge; the menu root deliberately has no overflow-hidden
+ * (only its inner grid does), so nothing clips the outer half.
+ */
+function ResizeHandle({
+ isCollapsed,
+ isDragging,
+ onPointerDown,
+}: {
+ isCollapsed: boolean;
+ isDragging: boolean;
+ onPointerDown: (e: ReactPointerEvent) => void;
+}) {
+ // Fully controlled so the open state never switches between controlled and uncontrolled
+ // mid-interaction; open requests made while dragging are dropped.
+ const [isTooltipOpen, setTooltipOpen] = useState(false);
+ // The pointer's Y offset within the strip — anchors the tooltip beside the cursor instead of at
+ // the strip's vertical center.
+ const [anchorY, setAnchorY] = useState(0);
+
+ return (
+
+ setTooltipOpen(open && !isDragging)}
+ >
+
+
+
+
+ Drag to resize
+
+ {isCollapsed ? "Click to expand" : "Click to collapse"}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/navigation/SideMenuHeader.tsx b/apps/webapp/app/components/navigation/SideMenuHeader.tsx
index 8d975cba436..0361c409a58 100644
--- a/apps/webapp/app/components/navigation/SideMenuHeader.tsx
+++ b/apps/webapp/app/components/navigation/SideMenuHeader.tsx
@@ -40,15 +40,9 @@ export function SideMenuHeader({
{visiblePart}
{fadingPart && (
-
- {fadingPart}
-
+ // Driven by the resizable SideMenu's `--sm-label-opacity` variable so "Project" morphs to
+ // "Proj" in real time as the menu is dragged narrower. Unset elsewhere → falls back to 1.
+ {fadingPart}
)}
{children !== undefined ? (
diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx
index f2349fc1e6e..a3878b9fe33 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"];
@@ -75,32 +82,40 @@ export function SideMenuItem({
)}
/>
-
- {name}
-
- {badge && !isCollapsed && (
-
+
- {badge}
-
- )}
- {trailingIcon && !isCollapsed && (
-
- )}
+ {name}
+
+ {badge && !isCollapsed && (
+
);
@@ -128,11 +143,14 @@ export function SideMenuItem({
disableHoverableContent
/>
{!isCollapsed && (
+ // Fades with the labels via the resizable SideMenu's `--sm-label-opacity` variable
+ // (falls back to fully visible when the variable is unset).
{action}
@@ -154,3 +172,34 @@ 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; trailing?: ReactNode } & ButtonHTMLAttributes
+>(function SideMenuItemButton({ icon, name, trailing, className, type, ...props }, ref) {
+ return (
+
+ );
+});
diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx
index 1564716512d..cd35c30a5c6 100644
--- a/apps/webapp/app/components/navigation/SideMenuSection.tsx
+++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx
@@ -38,18 +38,21 @@ export function SideMenuSection({
{/* Header container - stays in DOM to preserve height */}
- {/* Header - fades out when sidebar is collapsed */}
-
-
+
{title}
{headerAction &&
{headerAction}
}
-
- {/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
-
+ {/*
+ Divider - absolutely positioned, fades in as the header fades out (driven by the
+ `--sm-collapse` progress variable, 0 → 1). Only shown while this section is expanded.
+ */}
+
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/resources.preferences.sidemenu.tsx b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx
index 94db448b581..4e43269d0ea 100644
--- a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx
+++ b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx
@@ -15,6 +15,7 @@ const booleanFromFormData = z
const RequestSchema = z.object({
isCollapsed: booleanFromFormData,
+ width: z.coerce.number().int().positive().optional(),
sectionId: SideMenuSectionIdSchema.optional(),
sectionCollapsed: booleanFromFormData,
// Generic item order fields
@@ -66,6 +67,7 @@ export async function action({ request }: ActionFunctionArgs) {
await updateSideMenuPreferences({
user,
isCollapsed: result.data.isCollapsed,
+ width: result.data.width,
sectionCollapsed,
});
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) },
diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts
index 7af007dc381..e4ce1cd91da 100644
--- a/apps/webapp/app/services/dashboardPreferences.server.ts
+++ b/apps/webapp/app/services/dashboardPreferences.server.ts
@@ -5,6 +5,8 @@ import { type UserFromSession } from "./session.server";
const SideMenuPreferences = z.object({
isCollapsed: z.boolean().default(false),
+ /** The expanded (non-collapsed) width of the side menu in pixels, set by dragging the resize handle. */
+ width: z.number().optional(),
// Map for section collapsed states - keys are section identifiers
collapsedSections: z.record(z.string(), z.boolean()).optional(),
/** Organization-specific settings */
@@ -124,10 +126,13 @@ export async function clearCurrentProject({ user }: { user: UserFromSession }) {
export async function updateSideMenuPreferences({
user,
isCollapsed,
+ width,
sectionCollapsed,
}: {
user: UserFromSession;
isCollapsed?: boolean;
+ /** The expanded width of the side menu in pixels (from the resize handle) */
+ width?: number;
/** Update a specific section's collapsed state */
sectionCollapsed?: { sectionId: SideMenuSectionId; collapsed: boolean };
}) {
@@ -148,6 +153,7 @@ export async function updateSideMenuPreferences({
const updatedSideMenu = SideMenuPreferences.parse({
...currentSideMenu,
...(isCollapsed !== undefined && { isCollapsed }),
+ ...(width !== undefined && { width }),
collapsedSections: updatedCollapsedSections,
});
@@ -156,7 +162,11 @@ export async function updateSideMenuPreferences({
JSON.stringify(updatedSideMenu.collapsedSections) !==
JSON.stringify(currentSideMenu.collapsedSections);
- if (updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed && !hasCollapsedSectionsChanged) {
+ if (
+ updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed &&
+ updatedSideMenu.width === currentSideMenu.width &&
+ !hasCollapsedSectionsChanged
+ ) {
return;
}