diff --git a/.server-changes/account-profile-page-layout.md b/.server-changes/account-profile-page-layout.md new file mode 100644 index 00000000000..8c3c32c1772 --- /dev/null +++ b/.server-changes/account-profile-page-layout.md @@ -0,0 +1,11 @@ +--- +area: webapp +type: improvement +--- + +Redesign the account Profile page (`/account`) to use the same row-and-divider +layout as the Security page: each setting is a full-width row with the title on +the left and its control on the right, separated by divider lines. Profile +picture, Full name, Email address, and a "Receive onboarding emails" toggle +(replacing the old checkbox) each sit on equal-height rows, and the Update +button is now a primary button. 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..d64f0fd95e0 --- /dev/null +++ b/.server-changes/side-menu-project-and-org-menus.md @@ -0,0 +1,20 @@ +--- +area: webapp +type: improvement +--- + +Restructure the side menu's top-left and project/organization navigation: + +- 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, then Account and Logout) using the same icons and links as the + organization settings side menu. + +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/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/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 58c3aae5a0a..a021c5b9895 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -87,17 +87,17 @@ export function EnvironmentSelector({ } - content={environmentFullTitle(environment)} + content={`${environmentFullTitle(environment)} environment`} side="right" sideOffset={8} - hidden={!isCollapsed} + delayDuration={isCollapsed ? 0 : 500} buttonClassName="!h-8" asChild disableHoverableContent @@ -190,10 +190,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 +221,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}>
@@ -267,88 +246,134 @@ function Branches({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > -
- {currentBranchIsArchived && ( - + + +
+ + ); +} + +/** + * 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 && ( + + + {environment.branchName} + + Archived + + } + icon={ + + } + isSelected={environment.id === currentEnvironment.id} + /> + )} + {state === "has-branches" ? ( + <> + {branchEnvironments + .filter((env) => env.archivedAt === null) + .map((env) => ( + - {environment.branchName} + {env.branchName ?? DEFAULT_DEV_BRANCH} - Archived - - } - 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} + } + icon={ + - ))} - - ) : 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. -
- )} + } + 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. +
-
- {parentEnvironment.type === "DEVELOPMENT" ? ( - } - leadingIconClassName="text-text-dimmed" - /> - ) : ( - } - leadingIconClassName="text-text-dimmed" - /> - )} + ) : ( +
+ All branches are archived.
- + )}
- +
+ {parentEnvironment.type === "DEVELOPMENT" ? ( + } + leadingIconClassName="text-text-dimmed" + /> + ) : ( + } + leadingIconClassName="text-text-dimmed" + /> + )} +
+ ); } diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index 4264906d22d..b329f9c95e4 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -63,21 +63,13 @@ export function HelpAndFeedback({ Help & Feedback - } content={ @@ -88,7 +80,7 @@ export function HelpAndFeedback({ } side="right" sideOffset={8} - hidden={!isCollapsed} + delayDuration={isCollapsed ? 0 : 500} buttonClassName="!h-8 w-full" asChild disableHoverableContent diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index ada3aac5e5a..8e1abfa20f9 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"; @@ -130,7 +131,7 @@ export function OrganizationSettingsSideMenu({ {featureFlags.hasPrivateConnections && ( (null); - const [showHeaderDivider, setShowHeaderDivider] = useState(false); const [isCollapsed, setIsCollapsed] = useState( user.dashboardPreferences.sideMenu?.isCollapsed ?? false ); @@ -265,20 +293,6 @@ 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 +368,22 @@ export function SideMenu({ )}
- +
+
+
+
{isFreeUser && ( @@ -809,30 +810,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 +853,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 +883,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 +967,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, "text-amber-400")} + className={SIDE_MENU_POPOVER_ITEM_LABEL} + /> + ) : ( + + Admin dashboard + + + + +
+ } + icon={HomeIcon} + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} + /> + )} +
+ )} +
+ +
@@ -988,7 +1088,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 +1098,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 +1254,9 @@ function SwitchOrganizations({ } + icon={} leadingIconClassName="text-text-dimmed" + className={SIDE_MENU_POPOVER_ITEM_LABEL} isSelected={org.id === organization.id} /> ))} @@ -1079,7 +1293,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 +1303,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 +1434,12 @@ function HelpAndAI({ isCollapsed, organizationId, projectId, + onToggleCollapsed, }: { isCollapsed: boolean; organizationId: string; projectId: string; + onToggleCollapsed: () => void; }) { return ( @@ -1172,126 +1455,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/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) },