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..ac3a6ef23f6 --- /dev/null +++ b/.server-changes/side-menu-project-and-org-menus.md @@ -0,0 +1,36 @@ +--- +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). + +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/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index 63b5fddf715..0ba1f65c209 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -1,8 +1,8 @@ import { KeyboardIcon } from "~/assets/icons/KeyboardIcon"; import { useState } from "react"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; -import { Button } from "./primitives/Buttons"; import { Header3 } from "./primitives/Headers"; +import { SideMenuItemButton } from "./navigation/SideMenuItem"; import { Paragraph } from "./primitives/Paragraph"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./primitives/SheetV3"; import { ShortcutKey } from "./primitives/ShortcutKey"; @@ -11,19 +11,7 @@ export function Shortcuts() { return ( - + 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..d05d5a0bc1f 100644 --- a/apps/webapp/app/components/navigation/AccountSideMenu.tsx +++ b/apps/webapp/app/components/navigation/AccountSideMenu.tsx @@ -34,7 +34,7 @@ export function AccountSideMenu({ user }: { user: User }) { Back to app -
+
} - content={environmentFullTitle(environment)} + content={`${environmentFullTitle(environment)} environment`} side="right" sideOffset={8} - hidden={!isCollapsed} + delayDuration={isCollapsed ? 0 : 500} buttonClassName="!h-8" asChild disableHoverableContent @@ -135,7 +141,13 @@ export function EnvironmentSelector({ } + title={ + + } isSelected={env.id === environment.id} /> ); @@ -153,8 +165,12 @@ export function EnvironmentSelector({ )} title={
- - Upgrade + + Upgrade
} isSelected={false} @@ -167,8 +183,12 @@ export function EnvironmentSelector({ )} title={
- - Upgrade + + Upgrade
} isSelected={false} @@ -190,10 +210,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 +241,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 +253,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..91a0a8ddb93 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -13,12 +13,11 @@ import { useRecentChangelogs } from "~/routes/resources.platform-changelogs"; import { cn } from "~/utils/cn"; 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, @@ -63,21 +62,13 @@ export function HelpAndFeedback({ Help & Feedback - } content={ @@ -88,7 +79,7 @@ export function HelpAndFeedback({ } side="right" sideOffset={8} - hidden={!isCollapsed} + delayDuration={isCollapsed ? 0 : 500} buttonClassName="!h-8 w-full" asChild disableHoverableContent @@ -133,17 +124,11 @@ export function HelpAndFeedback({ - Contact us… - + /> } />
diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index ada3aac5e5a..409e9b50e53 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"; @@ -79,11 +80,19 @@ export function OrganizationSettingsSideMenu({ Back to app
-
+
+ {isManagedCloud && ( <> )} -
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 395932f39e2..c94b952c33a 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -2,12 +2,10 @@ import { ArrowTopRightOnSquareIcon, ChevronRightIcon, ExclamationTriangleIcon, - PencilSquareIcon, } from "@heroicons/react/24/outline"; -import { Link, useFetcher, useNavigation } from "@remix-run/react"; +import { useFetcher, useNavigate, useNavigation, useSubmit } from "@remix-run/react"; import { LayoutGroup, motion } from "framer-motion"; import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; -import simplur from "simplur"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; import { AIPenIcon } from "~/assets/icons/AIPenIcon"; import { ArrowLeftRightIcon } from "~/assets/icons/ArrowLeftRightIcon"; @@ -17,6 +15,7 @@ import { BatchesIcon } from "~/assets/icons/BatchesIcon"; import { BellIcon } from "~/assets/icons/BellIcon"; import { Box3DIcon } from "~/assets/icons/Box3DIcon"; import { BugIcon } from "~/assets/icons/BugIcon"; +import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; import { ChartBarIcon } from "~/assets/icons/ChartBarIcon"; import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; @@ -31,30 +30,48 @@ import { HomeIcon } from "~/assets/icons/HomeIcon"; import { IDIcon } from "~/assets/icons/IDIcon"; import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon"; import { KeyIcon } from "~/assets/icons/KeyIcon"; +import { LeftSideMenuCollapsedIcon } from "~/assets/icons/LeftSideMenuCollapsedIcon"; +import { LeftSideMenuIcon } from "~/assets/icons/LeftSideMenuIcon"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { LogsIcon } from "~/assets/icons/LogsIcon"; import { PlusIcon } from "~/assets/icons/PlusIcon"; import { QueuesIcon } from "~/assets/icons/QueuesIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { ShieldIcon } from "~/assets/icons/ShieldIcon"; import { SlidersIcon } from "~/assets/icons/SlidersIcon"; import { TasksIcon } from "~/assets/icons/TasksIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; +import { UserCrossIcon } from "~/assets/icons/UserCrossIcon"; +import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; +import { RolesIcon } from "~/assets/icons/RolesIcon"; +import { PadlockIcon } from "~/assets/icons/PadlockIcon"; +import { SlackIcon } from "~/assets/icons/SlackIcon"; +import { VercelLogo } from "~/components/integrations/VercelLogo"; import { Avatar } from "~/components/primitives/Avatar"; +import { UserProfilePhoto } from "~/components/UserProfilePhoto"; import { type MatchedEnvironment } from "~/hooks/useEnvironment"; import { useFeatureFlags } from "~/hooks/useFeatureFlags"; import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { type MatchedProject } from "~/hooks/useProject"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useHasAdminAccess } from "~/hooks/useUser"; import { type UserWithDashboardPreferences } from "~/models/user.server"; -import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; +import { + useCurrentPlan, + useIsUsingRbacPlugin, + useIsUsingSsoPlugin, +} from "~/routes/_app.orgs.$organizationSlug/route"; import { type FeedbackType } from "~/routes/resources.feedback"; import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incidents"; import { cn } from "~/utils/cn"; import { accountPath, + accountSecurityPath, + personalAccessTokensPath, adminPath, branchesPath, concurrencyPath, @@ -63,13 +80,19 @@ import { newOrganizationPath, newProjectPath, organizationPath, + organizationRolesPath, organizationSettingsPath, + organizationSlackIntegrationPath, + organizationSsoPath, organizationTeamPath, + organizationVercelIntegrationPath, queryPath, regionsPath, v3ApiKeysPath, v3BatchesPath, + v3BillingLimitsPath, v3BillingPath, + v3PrivateConnectionsPath, v3BulkActionsPath, v3DashboardsLandingPath, v3DeploymentsPath, @@ -89,17 +112,15 @@ import { v3UsagePath, v3WaitpointTokensPath, } from "~/utils/pathBuilder"; -import { AskAI } from "../AskAI"; import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { AlphaBadge, NewBadge } from "../FeatureBadges"; -import { ImpersonationBanner } from "../ImpersonationBanner"; import { Button, ButtonContent, LinkButton } from "../primitives/Buttons"; import { Dialog, DialogTrigger } from "../primitives/Dialog"; import { Paragraph } from "../primitives/Paragraph"; +import { Badge } from "../primitives/Badge"; import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover"; import { ShortcutKey } from "../primitives/ShortcutKey"; -import { TextLink } from "../primitives/TextLink"; import { SimpleTooltip, Tooltip, @@ -126,6 +147,23 @@ function getSectionCollapsed( return sideMenu?.collapsedSections?.[sectionId] ?? false; } +// Size the side menu popover items (org menu + project picker) to match the side +// menu items: a 20px leading icon and a 0.90625rem label (vs the smaller +// small-menu-item defaults). The icon class overrides the variant icon size; the +// label class lands on the button element and overrides its text-2sm via +// tailwind-merge. The icon constant also carries the default dimmed color; items +// that need a different icon color (e.g. the indigo project folders) set their own. +const SIDE_MENU_POPOVER_ITEM_ICON = "h-5 w-5 text-text-dimmed"; +const SIDE_MENU_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]"; + +// Accent used to signal impersonation mode across the UI (the side menu border and the +// "Stop impersonating" action). Full class strings per Tailwind's static scanning — change +// the shade here to update everywhere. +const IMPERSONATION_ACCENT = { + border: "border-yellow-500/80", + text: "text-yellow-500/80", +}; + type SideMenuUser = Pick< UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences" @@ -155,8 +193,6 @@ export function SideMenu({ organization, organizations, }: SideMenuProps) { - const borderRef = useRef(null); - const [showHeaderDivider, setShowHeaderDivider] = useState(false); const [isCollapsed, setIsCollapsed] = useState( user.dashboardPreferences.sideMenu?.isCollapsed ?? false ); @@ -265,95 +301,53 @@ export function SideMenu({ action: handleToggleCollapsed, }); - useEffect(() => { - const handleScroll = () => { - if (borderRef.current) { - const shouldShowHeaderDivider = borderRef.current.scrollTop > 1; - if (showHeaderDivider !== shouldShowHeaderDivider) { - setShowHeaderDivider(shouldShowHeaderDivider); - } - } - }; - - borderRef.current?.addEventListener("scroll", handleScroll); - return () => borderRef.current?.removeEventListener("scroll", handleScroll); - }, [showHeaderDivider]); - return (
- -
-
+
+
-
- {isAdmin && !user.isImpersonating ? ( - - - - - - - - Admin dashboard - - - - - ) : isAdmin && user.isImpersonating ? ( - - - - ) : null} + + +
-
-
- + +
+
{environment.type === "DEVELOPMENT" && project.engine === "V2" && ( - + @@ -383,7 +377,22 @@ export function SideMenu({ )}
- +
+
+
+
{isFreeUser && ( @@ -809,30 +819,26 @@ function V3DeprecationContent() { ); } -function ProjectSelector({ - project, +function OrgSelector({ organization, organizations, - user, isCollapsed = false, }: { - project: SideMenuProject; organization: MatchedOrganization; organizations: MatchedOrganization[]; - user: SideMenuUser; isCollapsed?: boolean; }) { const currentPlan = useCurrentPlan(); const [isOrgMenuOpen, setOrgMenuOpen] = useState(false); const navigation = useNavigation(); const { isManagedCloud } = useFeatures(); + const featureFlags = useFeatureFlags(); + const showSelfServe = useShowSelfServe(); + const isUsingRbacPlugin = useIsUsingRbacPlugin(); + const isUsingSsoPlugin = useIsUsingSsoPlugin(); - let plan: string | undefined = undefined; - if (currentPlan?.v3Subscription?.isPaying === false) { - plan = "Free"; - } else if (currentPlan?.v3Subscription?.isPaying === true) { - plan = currentPlan.v3Subscription.plan?.title; - } + const isPaying = currentPlan?.v3Subscription?.isPaying === true; + const planTitle = currentPlan?.v3Subscription?.plan?.title; useEffect(() => { setOrgMenuOpen(false); @@ -856,23 +862,22 @@ function ProjectSelector({ isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100" )} > - - {project.name ?? "Select a project"} + {organization.title} } - content={`${organization.title} / ${project.name ?? "Select a project"}`} + content={organization.title} side="right" sideOffset={8} hidden={!isCollapsed} @@ -887,81 +892,81 @@ function ProjectSelector({ align="start" style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }} > -
-
- - -
- -
- -
- {organization.title} -
- {plan && ( - - {plan} plan - - )} - {simplur`${organization.membersCount} member[|s]`} -
-
-
-
- - - Settings - - {isManagedCloud && ( - - - Usage - - )} -
-
- {organization.projects.map((p) => { - const isSelected = p.id === project.id; - return ( - - {p.name} -
- } - isSelected={isSelected} - icon={isSelected ? FolderOpenIcon : FolderClosedIcon} - leadingIconClassName="text-indigo-500" - /> - ); - })} - + + {isManagedCloud && ( + + )} + {isManagedCloud && ( + + Billing + {isPaying && planTitle ? {planTitle} : null} +
+ } + icon={CreditCardIcon} + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} + /> + )} + {isManagedCloud && showSelfServe && ( + + )} + + {featureFlags.hasPrivateConnections && ( + + )} + {isUsingRbacPlugin && ( + + )} + {isUsingSsoPlugin && ( + + )} +
{organizations.length > 1 ? ( @@ -971,16 +976,120 @@ function ProjectSelector({ to={newOrganizationPath()} title="New organization" icon={PlusIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> )}
-
+ + + ); +} + +function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImpersonating: boolean }) { + const [isOpen, setIsOpen] = useState(false); + const navigation = useNavigation(); + const navigate = useNavigate(); + const submit = useSubmit(); + + useEffect(() => { + setIsOpen(false); + }, [navigation.location?.pathname]); + + const stopImpersonating = () => + submit(null, { action: "/resources/impersonation", method: "delete" }); + + useShortcutKeys({ + shortcut: isAdmin + ? { modifiers: ["mod"], key: "esc", enabledOnInputElements: true } + : undefined, + action: () => { + if (isImpersonating) { + stopImpersonating(); + } else { + navigate(adminPath()); + } + }, + }); + + return ( + setIsOpen(open)} open={isOpen}> + + + + } + content="Account" + side="bottom" + sideOffset={8} + disableHoverableContent + /> + + {isAdmin && ( +
+ {isImpersonating ? ( + + Stop impersonating + + + + +
+ } + icon={UserCrossIcon} + onClick={stopImpersonating} + leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)} + className={SIDE_MENU_POPOVER_ITEM_LABEL} + /> + ) : ( + + Admin dashboard + + + + +
+ } + icon={HomeIcon} + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} + /> + )} +
+ )} +
+ +
@@ -988,7 +1097,8 @@ function ProjectSelector({ to={logoutPath()} title="Logout" icon={ArrowRightSquareIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} danger />
@@ -997,6 +1107,118 @@ function ProjectSelector({ ); } +function ProjectSelector({ + project, + organization, + environment, + isCollapsed = false, + className, +}: { + project: SideMenuProject; + organization: MatchedOrganization; + environment: SideMenuEnvironment; + isCollapsed?: boolean; + className?: string; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const navigation = useNavigation(); + + useEffect(() => { + setIsMenuOpen(false); + }, [navigation.location?.pathname]); + + return ( + setIsMenuOpen(open)} open={isMenuOpen}> + + + + + + {project.name ?? "Select a project"} + + + + + + + + } + content={project.name ?? "Select a project"} + side="right" + sideOffset={8} + hidden={!isCollapsed} + buttonClassName="!h-8" + asChild + disableHoverableContent + /> + +
+ + +
+
+ {organization.projects.map((p) => { + const isSelected = p.id === project.id; + return ( + + {p.name} +
+ } + isSelected={isSelected} + icon={isSelected ? FolderOpenIcon : FolderClosedIcon} + leadingIconClassName="h-5 w-5 text-indigo-500" + className={SIDE_MENU_POPOVER_ITEM_LABEL} + /> + ); + })} +
+ + + ); +} + function SwitchOrganizations({ organizations, organization, @@ -1041,9 +1263,9 @@ function SwitchOrganizations({ } + icon={} leadingIconClassName="text-text-dimmed" + className={SIDE_MENU_POPOVER_ITEM_LABEL} isSelected={org.id === organization.id} /> ))} @@ -1079,7 +1302,8 @@ function SwitchOrganizations({ to={newOrganizationPath()} title="New organization" icon={PlusIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} />
@@ -1088,18 +1312,84 @@ function SwitchOrganizations({ ); } -function SelectorDivider() { +function Integrations({ organization }: { organization: MatchedOrganization }) { + const navigation = useNavigation(); + const [isMenuOpen, setMenuOpen] = useState(false); + const timeoutRef = useRef(null); + + // Clear timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + useEffect(() => { + setMenuOpen(false); + }, [navigation.location?.pathname]); + + const handleMouseEnter = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + setMenuOpen(true); + }; + + const handleMouseLeave = () => { + // Small delay before closing to allow moving to the content + timeoutRef.current = setTimeout(() => { + setMenuOpen(false); + }, 150); + }; + return ( - - - + setMenuOpen(open)} open={isMenuOpen}> +
+ + + Integrations + + + +
+ + +
+
+
+
); } @@ -1153,10 +1443,12 @@ function HelpAndAI({ isCollapsed, organizationId, projectId, + onToggleCollapsed, }: { isCollapsed: boolean; organizationId: string; projectId: string; + onToggleCollapsed: () => void; }) { return ( @@ -1172,126 +1464,58 @@ function HelpAndAI({ organizationId={organizationId} projectId={projectId} /> - +
); } -function AnimatedChevron({ - isHovering, +function CollapseMenuButton({ isCollapsed, + onToggle, }: { - isHovering: boolean; isCollapsed: boolean; + onToggle: () => void; }) { - // When hovering and expanded: left chevron (pointing left to collapse) - // When hovering and collapsed: right chevron (pointing right to expand) - // When not hovering: straight vertical line - - const getRotation = () => { - if (!isHovering) return { top: 0, bottom: 0 }; - if (isCollapsed) { - // Right chevron - return { top: -17, bottom: 17 }; - } else { - // Left chevron - return { top: 17, bottom: -17 }; - } - }; - - const { top, bottom } = getRotation(); - - // Calculate horizontal offset to keep chevron centered when rotated - // Left chevron: translate left (-1.5px) - // Right chevron: translate right (+1.5px) - const getTranslateX = () => { - if (!isHovering) return 0; - return isCollapsed ? 1.5 : -1.5; - }; - - return ( - - {/* Top segment */} - - {/* Bottom segment */} - - - ); -} - -function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) { const [isHovering, setIsHovering] = useState(false); return ( -
- {/* Vertical line to mask the side menu border */} -
+
- + - + + - + {isCollapsed ? "Expand" : "Collapse"} diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index f2349fc1e6e..4dc955d2607 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -1,4 +1,9 @@ -import { type AnchorHTMLAttributes, type ReactNode } from "react"; +import { + type AnchorHTMLAttributes, + type ButtonHTMLAttributes, + forwardRef, + type ReactNode, +} from "react"; import { Link } from "@remix-run/react"; import { motion } from "framer-motion"; import { usePathName } from "~/hooks/usePathName"; @@ -14,6 +19,7 @@ export function SideMenuItem({ trailingIcon, trailingIconClassName, name, + nameClassName, to, badge, target, @@ -30,6 +36,7 @@ export function SideMenuItem({ trailingIcon?: RenderIcon; trailingIconClassName?: string; name: string; + nameClassName?: string; to: string; badge?: ReactNode; target?: AnchorHTMLAttributes["target"]; @@ -83,7 +90,12 @@ export function SideMenuItem({ }} transition={{ duration: 0.2, ease: "easeOut" }} > - + {name} {badge && !isCollapsed && ( @@ -154,3 +166,33 @@ export function SideMenuItem({ /> ); } + +/** + * A button styled to match {@link SideMenuItem}, for menu entries that open a + * dialog/sheet rather than navigate. Forwards its ref and props so it can be + * used as a Radix `asChild` trigger. + */ +export const SideMenuItemButton = forwardRef< + HTMLButtonElement, + { icon: RenderIcon; name: string } & ButtonHTMLAttributes +>(function SideMenuItemButton({ icon, name, className, type, ...props }, ref) { + return ( + + ); +}); 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/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) },