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 ( + setInternalHovered(true)} + onMouseLeave={isControlled ? undefined : () => setInternalHovered(false)} + > + + {/* Animate a transform (scaleX) rather than the SVG `width` attribute: + framer snaps the first animation of an SVG geometry attribute after it + has been idle, whereas transforms animate reliably. Anchoring the origin + to the left edge collapses the panel from right to left. */} + + + ); +} 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={
- - Upgrade + + Upgrade
} isSelected={false} @@ -167,8 +190,12 @@ export function EnvironmentSelector({ )} title={
- - Upgrade + + Upgrade
} isSelected={false} @@ -190,10 +217,6 @@ function Branches({ branchEnvironments: SideMenuEnvironment[]; currentEnvironment: SideMenuEnvironment; }) { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - const { urlForEnvironment } = useEnvironmentSwitcher(); const navigation = useNavigation(); const [isMenuOpen, setMenuOpen] = useState(false); const timeoutRef = useRef(null); @@ -225,23 +248,6 @@ function Branches({ }, 150); }; - const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null); - const state = - branchEnvironments.length === 0 - ? "no-branches" - : activeBranches.length === 0 - ? "no-active-branches" - : "has-branches"; - - // Only surface the active environment's archived-branch item in the submenu it - // actually belongs to. Both Development and Preview render this component, so - // without the parent check an archived dev branch would leak into the Preview - // submenu (and vice-versa). - const currentBranchIsArchived = - environment.archivedAt !== null && environment.parentEnvironmentId === parentEnvironment.id; - - const envTextClassName = environmentTextClassName(parentEnvironment); - return ( setMenuOpen(open)} open={isMenuOpen}>
@@ -254,7 +260,11 @@ function Branches({ textAlignLeft fullWidth > - + -
- {currentBranchIsArchived && ( - - - {environment.branchName} - - Archived - - } - icon={ - - } - isSelected={environment.id === currentEnvironment.id} - /> - )} - {state === "has-branches" ? ( + + +
+ + ); +} + +/** + * The inner content of the branches popover (branch list, empty states, and the + * "Manage branches" footer). Shared by the dropdown's hover submenu (`Branches`) + * and the side-menu segmented control's Preview popover. + */ +export function BranchesPopoverContent({ + parentEnvironment, + branchEnvironments, + currentEnvironment, +}: { + parentEnvironment: SideMenuEnvironment; + branchEnvironments: SideMenuEnvironment[]; + currentEnvironment: SideMenuEnvironment; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const { urlForEnvironment } = useEnvironmentSwitcher(); + + const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null); + const state = + branchEnvironments.length === 0 + ? "no-branches" + : activeBranches.length === 0 + ? "no-active-branches" + : "has-branches"; + + // Only surface the active environment's archived-branch item in the submenu it + // actually belongs to. Both Development and Preview render this component, so + // without the parent check an archived dev branch would leak into the Preview + // submenu (and vice-versa). + const currentBranchIsArchived = + environment.archivedAt !== null && environment.parentEnvironmentId === parentEnvironment.id; + + const envTextClassName = environmentTextClassName(parentEnvironment); + + return ( + <> +
+ {currentBranchIsArchived && ( + - {branchEnvironments - .filter((env) => env.archivedAt === null) - .map((env) => ( - - {env.branchName ?? DEFAULT_DEV_BRANCH} - - } - icon={ - - } - isSelected={env.id === currentEnvironment.id} - /> - ))} + + {environment.branchName} + + Archived - ) : state === "no-branches" ? ( -
-
- - Create your first branch -
- - 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. - -
- ) : ( -
- All branches are archived. -
- )} -
-
- {parentEnvironment.type === "DEVELOPMENT" ? ( - } - leadingIconClassName="text-text-dimmed" - /> - ) : ( - } - leadingIconClassName="text-text-dimmed" + } + icon={ + - )} + } + isSelected={environment.id === currentEnvironment.id} + /> + )} + {state === "has-branches" ? ( + <> + {branchEnvironments + .filter((env) => env.archivedAt === null) + .map((env) => ( + + {env.branchName ?? DEFAULT_DEV_BRANCH} + + } + icon={ + + } + isSelected={env.id === currentEnvironment.id} + /> + ))} + + ) : state === "no-branches" ? ( +
+
+ + Create your first branch +
+ + 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. +
- + ) : ( +
+ All branches are archived. +
+ )}
- +
+ {parentEnvironment.type === "DEVELOPMENT" ? ( + } + leadingIconClassName="text-text-dimmed" + className={ENV_POPOVER_ITEM_LABEL} + /> + ) : ( + } + leadingIconClassName="text-text-dimmed" + className={ENV_POPOVER_ITEM_LABEL} + /> + )} +
+ ); } 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 && ( +
+ + } + onClick={() => { + setHelpMenuOpen(false); + openAskAI(); + }} + /> +
)} - shortcut={{ key: "h" }} - variant="medium/bright" - /> - - } - content={ - - Help & Feedback - - - } - side="right" - sideOffset={8} - hidden={!isCollapsed} - buttonClassName="!h-8 w-full" - asChild - disableHoverableContent - /> - - -
- -
-
- - - - - Contact us… - - } - /> -
-
- What's new - {changelogs.map((entry) => ( - - ))} - -
-
-
-
+
+ +
+
+ + + + + } + /> +
+
+ What's new + {changelogs.map((entry) => ( + + ))} + +
+ +
+ + )} + ); } diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index ada3aac5e5a..b2ff4b894f5 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -1,5 +1,6 @@ -import { ArrowLeftIcon, LinkIcon } from "@heroicons/react/24/solid"; +import { ArrowLeftIcon } from "@heroicons/react/24/solid"; import { BellIcon } from "~/assets/icons/BellIcon"; +import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; import { PadlockIcon } from "~/assets/icons/PadlockIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; @@ -34,7 +35,6 @@ import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { Paragraph } from "../primitives/Paragraph"; import { Badge } from "../primitives/Badge"; import { useHasAdminAccess } from "~/hooks/useUser"; -import { AskAI } from "../AskAI"; export type BuildInfo = { appVersion: string | undefined; @@ -79,11 +79,19 @@ export function OrganizationSettingsSideMenu({ Back to app
-
+
+ {isManagedCloud && ( <> )} -
@@ -241,7 +241,6 @@ export function OrganizationSettingsSideMenu({
-
); diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 395932f39e2..3c772f42a1d 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -2,12 +2,18 @@ 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 { + type CSSProperties, + type PointerEvent as ReactPointerEvent, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; import { AIPenIcon } from "~/assets/icons/AIPenIcon"; import { ArrowLeftRightIcon } from "~/assets/icons/ArrowLeftRightIcon"; @@ -17,6 +23,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 +38,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 +88,19 @@ import { newOrganizationPath, newProjectPath, organizationPath, + organizationRolesPath, organizationSettingsPath, + organizationSlackIntegrationPath, + organizationSsoPath, organizationTeamPath, + organizationVercelIntegrationPath, queryPath, regionsPath, v3ApiKeysPath, v3BatchesPath, + v3BillingLimitsPath, v3BillingPath, + v3PrivateConnectionsPath, v3BulkActionsPath, v3DashboardsLandingPath, v3DeploymentsPath, @@ -89,17 +120,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 +155,113 @@ 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", +}; + +// --- Resizable side menu ----------------------------------------------------- +// The menu can be dragged wider/narrower from a handle on its right edge. All of the +// width-driven visuals (label opacity, section headers, padding, "Project" → "Proj") are driven +// off two CSS variables set on the root during a drag/animation: +// --sm-collapse: 0 (at/above the default width) → 1 (fully collapsed) +// --sm-label-opacity: 1 (labels visible) → 0 (labels faded), a faster curve of --sm-collapse +// Keeping these on the root (rather than in React state) means a drag only writes two properties +// to one element per frame — no React re-render — so it stays smooth. + +/** Collapsed rail width in px (matches the previous `w-[2.75rem]`). */ +const COLLAPSED_WIDTH = 44; +/** The default/again-expanded width in px (matches the previous `w-56`). */ +const DEFAULT_WIDTH = 224; +/** The widest the menu can be dragged, in px. */ +const MAX_WIDTH = 400; +/** Duration of the collapse/expand/snap animation, in ms. */ +const COLLAPSE_ANIM_MS = 200; +/** + * Fraction of the collapse range (default → collapsed) over which the labels fade to 0. At 0.6 the + * labels are fully transparent once the menu is 60% of the way to collapsed, i.e. before the rail + * reaches its collapsed width. Tweak to taste. + */ +const LABEL_FADE_FRACTION = 0.6; +/** + * Where a release in the sub-default zone snaps, measured as collapse progress (0 = default + * width, 1 = collapsed): release at progress <= threshold and the menu springs open, past it and + * it collapses. Each drag direction has its own threshold so letting go early usually continues + * the gesture: dragging closed only collapses once past the first quarter of the range, while + * dragging open re-opens once pulled just a tenth of the way out. Tweak to taste. + */ +const COLLAPSE_SNAP_THRESHOLD = 0.25; +const EXPAND_SNAP_THRESHOLD = 0.9; +/** Pointer travel (px) below which a press on the handle counts as a click (toggle), not a drag. */ +const DRAG_CLICK_THRESHOLD = 4; + +/** Left/right padding of the pinned top section + scroll body, interpolated 10px → 4px by --sm-collapse. */ +const SIDE_MENU_PAD_X = `calc(0.625rem - 0.375rem * var(--sm-collapse, 0))`; +/** + * Right padding of the scroll body, interpolated 0 → 4px by --sm-collapse: expanded, the reserved + * scrollbar gutter provides the right-side space; collapsed there is no gutter, so this keeps the + * rail buttons inset symmetrically (matching the left padding) instead of touching the edge. + */ +const SIDE_MENU_SCROLL_PAD_RIGHT = `calc(0.25rem * var(--sm-collapse, 0))`; +/** Applied to every fading label so it tracks --sm-label-opacity (falls back to fully visible). */ +const SIDE_MENU_LABEL_STYLE = { opacity: "var(--sm-label-opacity, 1)" } as const; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +/** Collapse progress (0 = at/above default width, 1 = collapsed) for a given px width. */ +function widthToProgress(width: number) { + return clamp((DEFAULT_WIDTH - width) / (DEFAULT_WIDTH - COLLAPSED_WIDTH), 0, 1); +} + +/** Label opacity (1 → 0) for a given collapse progress, using the faster fade curve. */ +function progressToLabelOpacity(progress: number) { + return clamp((LABEL_FADE_FRACTION - progress) / LABEL_FADE_FRACTION, 0, 1); +} + +/** + * cubic-bezier(0.4, 0, 0.2, 1) — the standard easing, evaluated for the rAF width/progress tween so + * it matches the feel of the CSS transitions used elsewhere in the side menu. + */ +function easeStandard(t: number) { + // Solve the bezier for x = t, then return y. Control points: p1 = (0.4, 0), p2 = (0.2, 1). + const x1 = 0.4; + const y1 = 0; + const x2 = 0.2; + const y2 = 1; + const cx = 3 * x1; + const bx = 3 * (x2 - x1) - cx; + const ax = 1 - cx - bx; + const cy = 3 * y1; + const by = 3 * (y2 - y1) - cy; + const ay = 1 - cy - by; + const sampleX = (u: number) => ((ax * u + bx) * u + cx) * u; + const sampleY = (u: number) => ((ay * u + by) * u + cy) * u; + const sampleDerivativeX = (u: number) => (3 * ax * u + 2 * bx) * u + cx; + // Newton-Raphson to invert x(u) = t. + let u = t; + for (let i = 0; i < 6; i++) { + const x = sampleX(u) - t; + const dx = sampleDerivativeX(u); + if (Math.abs(x) < 1e-4 || Math.abs(dx) < 1e-6) break; + u -= x / dx; + } + return sampleY(clamp(u, 0, 1)); +} + type SideMenuUser = Pick< UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences" @@ -155,14 +291,46 @@ export function SideMenu({ organization, organizations, }: SideMenuProps) { - const borderRef = useRef(null); - const [showHeaderDivider, setShowHeaderDivider] = useState(false); const [isCollapsed, setIsCollapsed] = useState( user.dashboardPreferences.sideMenu?.isCollapsed ?? false ); + const [isDragging, setIsDragging] = useState(false); + + // --- Resize state (see the module constants above) --- + const rootRef = useRef(null); + const rafRef = useRef(null); + // Mirror of `isCollapsed` for the drag handlers, which live outside React's render cycle and + // must never act on a stale closure. + const isCollapsedRef = useRef(isCollapsed); + // The last-committed expanded width; animation targets and re-expansion read from here. + const expandedWidthRef = useRef( + clamp(user.dashboardPreferences.sideMenu?.width ?? DEFAULT_WIDTH, DEFAULT_WIDTH, MAX_WIDTH) + ); + // Frozen initial width for the first paint. It never changes across renders, so React sets it + // once and never fights the imperative width writes that drive the drag/animation. + const initialWidthRef = useRef( + (user.dashboardPreferences.sideMenu?.isCollapsed ?? false) + ? COLLAPSED_WIDTH + : expandedWidthRef.current + ); + const widthRef = useRef(initialWidthRef.current); + const progressRef = useRef((user.dashboardPreferences.sideMenu?.isCollapsed ?? false) ? 1 : 0); + // Frozen initial style, including the CSS variables, so the server-rendered HTML already carries + // the correct collapsed/expanded visuals (no expanded-state flash before hydration). The object + // identity never changes, so React never rewrites these after writeVisual takes over the DOM. + const initialStyleRef = useRef({ + width: initialWidthRef.current, + "--sm-collapse": String(progressRef.current), + "--sm-label-opacity": String(progressToLabelOpacity(progressRef.current)), + } as CSSProperties); + // Removes the window-level listeners of an in-flight drag (set on pointerdown, cleared when the + // drag finishes or the component unmounts). + const dragCleanupRef = useRef<(() => void) | null>(null); + const preferencesFetcher = useFetcher(); const pendingPreferencesRef = useRef<{ isCollapsed?: boolean; + width?: number; sectionId?: SideMenuSectionId; sectionCollapsed?: boolean; }>({}); @@ -179,6 +347,7 @@ export function SideMenu({ const persistSideMenuPreferences = useCallback( (data: { isCollapsed?: boolean; + width?: number; sectionId?: SideMenuSectionId; sectionCollapsed?: boolean; }) => { @@ -202,6 +371,9 @@ export function SideMenu({ if (pending.isCollapsed !== undefined) { formData.append("isCollapsed", String(pending.isCollapsed)); } + if (pending.width !== undefined) { + formData.append("width", String(pending.width)); + } if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) { formData.append("sectionId", pending.sectionId); formData.append("sectionCollapsed", String(pending.sectionCollapsed)); @@ -226,6 +398,7 @@ export function SideMenu({ const pending = pendingPreferencesRef.current; const hasPendingChanges = pending.isCollapsed !== undefined || + pending.width !== undefined || (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined); if (hasPendingChanges) { @@ -233,6 +406,9 @@ export function SideMenu({ if (pending.isCollapsed !== undefined) { formData.append("isCollapsed", String(pending.isCollapsed)); } + if (pending.width !== undefined) { + formData.append("width", String(pending.width)); + } if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) { formData.append("sectionId", pending.sectionId); formData.append("sectionCollapsed", String(pending.sectionCollapsed)); @@ -246,11 +422,184 @@ export function SideMenu({ }; }, [preferencesFetcher, user.isImpersonating]); - const handleToggleCollapsed = () => { - const newIsCollapsed = !isCollapsed; - setIsCollapsed(newIsCollapsed); - persistSideMenuPreferences({ isCollapsed: newIsCollapsed }); - }; + // Write the width + collapse variables straight to the DOM (no React re-render) so a drag stays + // smooth. Everything width-driven (labels, headers, padding, dividers) reads these variables. + const writeVisual = useCallback((width: number, progress: number) => { + widthRef.current = width; + progressRef.current = progress; + const el = rootRef.current; + if (!el) return; + el.style.width = `${width}px`; + el.style.setProperty("--sm-collapse", String(progress)); + el.style.setProperty("--sm-label-opacity", String(progressToLabelOpacity(progress))); + }, []); + + // Animate width + progress together over COLLAPSE_ANIM_MS with the standard easing (used for the + // toggle button, the ⌘B shortcut, and the release-snap). + const animateTo = useCallback( + (targetWidth: number, targetProgress: number) => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + const startWidth = widthRef.current; + const startProgress = progressRef.current; + if (startWidth === targetWidth && startProgress === targetProgress) return; + const startTime = performance.now(); + const step = (now: number) => { + const t = clamp((now - startTime) / COLLAPSE_ANIM_MS, 0, 1); + const eased = easeStandard(t); + writeVisual( + startWidth + (targetWidth - startWidth) * eased, + startProgress + (targetProgress - startProgress) * eased + ); + if (t < 1) { + rafRef.current = requestAnimationFrame(step); + } else { + rafRef.current = null; + writeVisual(targetWidth, targetProgress); + } + }; + rafRef.current = requestAnimationFrame(step); + }, + [writeVisual] + ); + + // Collapse/expand to a resting state and remember it. + const applyCollapsed = useCallback( + (next: boolean) => { + isCollapsedRef.current = next; + setIsCollapsed(next); + persistSideMenuPreferences({ isCollapsed: next }); + animateTo(next ? COLLAPSED_WIDTH : expandedWidthRef.current, next ? 1 : 0); + }, + [animateTo, persistSideMenuPreferences] + ); + + const handleToggleCollapsed = useCallback(() => { + applyCollapsed(!isCollapsedRef.current); + }, [applyCollapsed]); + + // The whole drag lives in window-level listeners installed here, so releasing the pointer + // anywhere — outside the handle, past the menu's min width, even outside the window — always + // finalizes the drag. (Pointer capture alone proved unreliable: if the browser drops it + // mid-drag, the release handler never fires and the menu is left stranded mid-resize.) + const onHandlePointerDown = useCallback( + (e: ReactPointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + // Capture keeps hover states elsewhere quiet while dragging; the drag itself does not + // depend on it (and must not die if capture is unavailable for this pointer). + try { + e.currentTarget.setPointerCapture(e.pointerId); + } catch {} + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + // Never allow two concurrent drags. + dragCleanupRef.current?.(); + + const drag = { + startX: e.clientX, + startWidth: rootRef.current?.getBoundingClientRect().width ?? widthRef.current, + startedCollapsed: isCollapsedRef.current, + didDrag: false, + }; + + const cleanup = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("blur", onCancel); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + dragCleanupRef.current = null; + }; + + const onMove = (ev: PointerEvent) => { + const dx = ev.clientX - drag.startX; + if (!drag.didDrag) { + // Ignore tiny movement so a click still reads as a click (toggle), not a drag. + if (Math.abs(dx) < DRAG_CLICK_THRESHOLD) return; + drag.didDrag = true; + setIsDragging(true); + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + } + const width = clamp(drag.startWidth + dx, COLLAPSED_WIDTH, MAX_WIDTH); + writeVisual(width, widthToProgress(width)); + }; + + const onUp = () => { + cleanup(); + setIsDragging(false); + + // A press with no meaningful drag toggles the menu. + if (!drag.didDrag) { + applyCollapsed(!isCollapsedRef.current); + return; + } + + const width = widthRef.current; + // A drag that started collapsed is an opening gesture, so its snap zone is flipped: + // releasing early continues opening rather than falling back to collapsed. + const snapThreshold = drag.startedCollapsed + ? EXPAND_SNAP_THRESHOLD + : COLLAPSE_SNAP_THRESHOLD; + if (width >= DEFAULT_WIDTH) { + // Rest at the dragged width. + const rounded = Math.round(width); + expandedWidthRef.current = rounded; + isCollapsedRef.current = false; + setIsCollapsed(false); + persistSideMenuPreferences({ isCollapsed: false, width: rounded }); + writeVisual(rounded, 0); + } else if (widthToProgress(width) <= snapThreshold) { + // Released near the default width — spring back open. + expandedWidthRef.current = DEFAULT_WIDTH; + isCollapsedRef.current = false; + setIsCollapsed(false); + persistSideMenuPreferences({ isCollapsed: false, width: DEFAULT_WIDTH }); + animateTo(DEFAULT_WIDTH, 0); + } else { + // Released deeper in (including over-drags past the min width) — collapse the rest of + // the way. + isCollapsedRef.current = true; + setIsCollapsed(true); + persistSideMenuPreferences({ isCollapsed: true }); + animateTo(COLLAPSED_WIDTH, 1); + } + }; + + const onCancel = () => { + cleanup(); + setIsDragging(false); + if (!drag.didDrag) return; + // Settle back to the current resting state. + animateTo( + isCollapsedRef.current ? COLLAPSED_WIDTH : expandedWidthRef.current, + isCollapsedRef.current ? 1 : 0 + ); + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + window.addEventListener("blur", onCancel); + dragCleanupRef.current = cleanup; + }, + [animateTo, applyCollapsed, persistSideMenuPreferences, writeVisual] + ); + + // Keep the drag handlers' mirror of the collapsed state in sync, and tear down any in-flight + // animation/drag listeners on unmount. + useEffect(() => { + isCollapsedRef.current = isCollapsed; + }, [isCollapsed]); + useEffect(() => { + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + dragCleanupRef.current?.(); + }; + }, []); /** Generic handler for any collapsible section - just pass the section ID */ const handleSectionToggle = useCallback( @@ -265,95 +614,57 @@ 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 +694,20 @@ export function SideMenu({ )}
- +
+
+
+
-
@@ -699,6 +1014,7 @@ export function SideMenu({ isCollapsed={isCollapsed} organizationId={organization.id} projectId={project.id} + onToggleCollapsed={handleToggleCollapsed} /> {isFreeUser && ( @@ -809,30 +1125,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); @@ -844,7 +1156,7 @@ function ProjectSelector({ button={ @@ -856,23 +1168,25 @@ 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 +1201,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 +1285,121 @@ 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} + asChild + 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 +1407,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 +1417,121 @@ 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 +1576,9 @@ function SwitchOrganizations({ } + icon={} leadingIconClassName="text-text-dimmed" + className={SIDE_MENU_POPOVER_ITEM_LABEL} isSelected={org.id === organization.id} /> ))} @@ -1079,7 +1615,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,22 +1625,92 @@ 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 + + + +
+ + +
+
+
+
); } -/** 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 (
{children}
@@ -1153,10 +1761,12 @@ function HelpAndAI({ isCollapsed, organizationId, projectId, + onToggleCollapsed, }: { isCollapsed: boolean; organizationId: string; projectId: string; + onToggleCollapsed: () => void; }) { return ( @@ -1172,126 +1782,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 */} -
+
- + - + + - + {isCollapsed ? "Expand" : "Collapse"} @@ -1303,3 +1845,76 @@ function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onTog
); } + +/** + * 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)} + > + +
{ + if (isDragging) return; + setAnchorY(Math.round(e.clientY - e.currentTarget.getBoundingClientRect().top)); + }} + className="group/resize absolute inset-y-0 -right-1 z-30 w-2 cursor-col-resize touch-none" + > +
+
+ + + 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 && ( +
{badge}
+ )} + {trailingIcon && !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
- - - - -
- - - - Your teammates will see this - {name.errors} - - - - - {email.errors} - - - - - {marketingEmails.errors} - - - - Update - - } - /> -
+
+
+ + + +
+ +
+
+
+
+
+ + + +
+ + {name.errors} +
+
+
+
+
+ + + +
+ + {email.errors} +
+
+
+
+
+ + + +
+ +
+
+
+
+ +
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; }