diff --git a/web/oss/src/components/Layout/AuthUpgradeHost.tsx b/web/oss/src/components/Layout/AuthUpgradeHost.tsx new file mode 100644 index 0000000000..0e8d69b41b --- /dev/null +++ b/web/oss/src/components/Layout/AuthUpgradeHost.tsx @@ -0,0 +1,59 @@ +import {useCallback, useEffect} from "react" + +import {useAtom, useAtomValue, useSetAtom} from "jotai" + +import { + AUTH_UPGRADE_IDENTITIES_KEY, + AUTH_UPGRADE_ORG_KEY, + authUpgradeAtom, + resetAuthUpgradeState, +} from "@/oss/state/org/authUpgrade" +import {orgsAtom, selectedOrgIdAtom} from "@/oss/state/org/selectors/org" +import {authFlowAtom} from "@/oss/state/session" + +import AuthUpgradeModal from "../Sidebar/components/AuthUpgradeModal" + +/** + * App-level host for the org auth-upgrade prompt. The org switcher (or any future entry point) + * only sets `authUpgradeAtom`; this component owns rendering and teardown so the flow no longer + * lives inside the sidebar selector. + */ +const AuthUpgradeHost = () => { + const [{open, orgId, detail}, setAuthUpgrade] = useAtom(authUpgradeAtom) + const selectedOrgId = useAtomValue(selectedOrgIdAtom) + const orgs = useAtomValue(orgsAtom) + const setAuthFlow = useSetAtom(authFlowAtom) + + const organizationName = Array.isArray(orgs) + ? orgs.find((org) => org.id === orgId)?.name + : undefined + + const close = useCallback(() => { + setAuthUpgrade(resetAuthUpgradeState()) + setAuthFlow("authed") + if (typeof window !== "undefined") { + window.localStorage.removeItem(AUTH_UPGRADE_ORG_KEY) + window.localStorage.removeItem(AUTH_UPGRADE_IDENTITIES_KEY) + } + }, [setAuthFlow, setAuthUpgrade]) + + // The upgrade succeeded once the selected org becomes the org we prompted for. + useEffect(() => { + if (open && orgId && selectedOrgId && orgId === selectedOrgId) { + close() + } + }, [close, open, orgId, selectedOrgId]) + + if (!open) return null + + return ( + + ) +} + +export default AuthUpgradeHost diff --git a/web/oss/src/components/Layout/Layout.tsx b/web/oss/src/components/Layout/Layout.tsx index 034e6fd16e..d066092e00 100644 --- a/web/oss/src/components/Layout/Layout.tsx +++ b/web/oss/src/components/Layout/Layout.tsx @@ -30,6 +30,7 @@ import type {SidebarView} from "../Sidebar/types" import BreadcrumbContainer from "./assets/Breadcrumbs" import {useStyles} from "./assets/styles" +import AuthUpgradeHost from "./AuthUpgradeHost" import ErrorFallback from "./ErrorFallback" import PostHogThemeCapture from "./PostHogThemeCapture" import {SidebarIsland} from "./SidebarIsland" @@ -306,6 +307,7 @@ const AppWithVariants = memo( return.

+ {project?.is_demo && ( <>
diff --git a/web/oss/src/components/Sidebar/components/AuthUpgradeModal.tsx b/web/oss/src/components/Sidebar/components/AuthUpgradeModal.tsx index 1b47d02ab3..17b6b9f7d8 100644 --- a/web/oss/src/components/Sidebar/components/AuthUpgradeModal.tsx +++ b/web/oss/src/components/Sidebar/components/AuthUpgradeModal.tsx @@ -21,21 +21,12 @@ import {getAgentaWebUrl} from "@/oss/lib/helpers/api" import {getEffectiveAuthConfig} from "@/oss/lib/helpers/dynamicEnv" import {isBackendAvailabilityIssue} from "@/oss/lib/helpers/errorHandler" import {AuthErrorMsgType} from "@/oss/lib/Types" +import type {AuthUpgradeDetail} from "@/oss/state/org/authUpgrade" import {useProfileData} from "@/oss/state/profile" -const {Text} = Typography +export type {AuthUpgradeDetail} -export interface AuthUpgradeDetail { - message?: string - required_methods?: string[] - session_identities?: string[] - user_identities?: string[] - sso_providers?: { - id: string - slug: string - third_party_id?: string - }[] -} +const {Text} = Typography interface AuthUpgradeModalProps { open: boolean diff --git a/web/oss/src/components/Sidebar/components/ProjectOrgSwitcher/index.tsx b/web/oss/src/components/Sidebar/components/ProjectOrgSwitcher/index.tsx new file mode 100644 index 0000000000..d02ffa9f3f --- /dev/null +++ b/web/oss/src/components/Sidebar/components/ProjectOrgSwitcher/index.tsx @@ -0,0 +1,335 @@ +import {memo, useCallback, useMemo, useState} from "react" + +import {InitialsAvatar} from "@agenta/ui" +import {EnhancedModal} from "@agenta/ui/components/modal" +import { + ArrowLeft, + ArrowsLeftRight, + CaretUpDown, + Check, + GearSix, + Plus, + SignOut, + X, +} from "@phosphor-icons/react" +import {Dropdown, Form, Input} from "antd" +import clsx from "clsx" + +import {useProjectOrgSwitcher} from "../../hooks/useProjectOrgSwitcher" + +interface ProjectOrgSwitcherProps { + collapsed: boolean +} + +type Panel = "projects" | "orgs" + +const ROW_CLASS = + "flex w-full items-center gap-2 h-8 px-2 rounded-md text-[13.5px] leading-none text-left cursor-pointer border-0 bg-transparent [font:inherit] text-[var(--ag-colorText)] hover:bg-[var(--ag-colorFillTertiary)] transition-colors" + +/** shrink-0 stops the capped scroll list from compressing rows instead of scrolling. */ +const ITEM_ROW_CLASS = "shrink-0" + +const CAPTION_CLASS = + "px-2 pt-1.5 pb-1 text-[11.5px] font-medium text-[var(--ag-colorTextTertiary)] truncate" + +const Row = ({ + onClick, + className, + children, + title, +}: { + onClick?: () => void + className?: string + children: React.ReactNode + title?: string +}) => ( + +) + +const ProjectOrgSwitcher = ({collapsed}: ProjectOrgSwitcherProps) => { + const { + currentOrg, + currentProject, + orgOptions, + projectsForOrg, + switchProject, + switchOrg, + goToOrgSettings, + confirmLogout, + createProject, + createOrg, + } = useProjectOrgSwitcher() + + const [open, setOpen] = useState(false) + const [panel, setPanel] = useState("projects") + + const projectLabel = currentProject?.project_name || "Select project" + const orgLabel = currentOrg?.name || "Organization" + + const handleOpenChange = useCallback((next: boolean) => { + setOpen(next) + if (!next) setPanel("projects") + }, []) + + const close = useCallback(() => { + setOpen(false) + setPanel("projects") + }, []) + + const projectPanel = useMemo( + () => ( +
+
Projects in {orgLabel}
+ {/* Cap the list at 3 item rows (h-8 each); the rest scrolls. */} +
+ {projectsForOrg.map((proj) => { + const isActive = + proj.project_id === currentProject?.project_id && + proj.workspace_id === currentProject?.workspace_id + return ( + { + close() + if (!isActive) switchProject(proj) + }} + > + + {proj.project_name} + {isActive && ( + + )} + + ) + })} +
+
+ setPanel("orgs")}> + + Switch organization + + { + close() + createProject.setOpen(true) + }} + > + + New project + + { + close() + confirmLogout() + }} + > + + Logout + +
+ ), + [ + close, + confirmLogout, + createProject, + currentProject?.project_id, + currentProject?.workspace_id, + orgLabel, + projectsForOrg, + switchProject, + ], + ) + + const orgPanel = useMemo( + () => ( +
+
+ setPanel("projects")} + > + + Projects + + +
+
Organizations
+ {/* Cap the list at 3 item rows (h-8 each); the rest scrolls. */} +
+ {orgOptions.map((org) => { + const isActive = org.id === currentOrg?.id + return ( + { + close() + if (!isActive) void switchOrg(org.id) + }} + > + + {org.name} + {isActive && ( + + )} + + ) + })} +
+
+ { + close() + createOrg.setOpen(true) + }} + > + + Create organization + + { + close() + goToOrgSettings() + }} + > + + Organization settings + +
+ ), + [close, createOrg, currentOrg?.id, goToOrgSettings, orgOptions, switchOrg], + ) + + return ( +
+ ( + // Fixed width matching the expanded trigger (sidebar 236px − 14px wrapper padding). +
+ {panel === "projects" ? projectPanel : orgPanel} +
+ )} + > + +
+ + { + createProject.setOpen(false) + createProject.form.resetFields() + }} + onOk={() => createProject.form.submit()} + confirmLoading={createProject.isPending} + destroyOnHidden + centered + > +
createProject.submit(values)} + > + + + +
+
+ + { + createOrg.setOpen(false) + createOrg.form.resetFields() + }} + onOk={() => createOrg.form.submit()} + confirmLoading={createOrg.isPending} + destroyOnHidden + centered + > +
createOrg.submit(values)} + > + + + +
+
+
+ ) +} + +export default memo(ProjectOrgSwitcher) diff --git a/web/oss/src/components/Sidebar/components/SidebarLogo.tsx b/web/oss/src/components/Sidebar/components/SidebarLogo.tsx new file mode 100644 index 0000000000..1358ba05aa --- /dev/null +++ b/web/oss/src/components/Sidebar/components/SidebarLogo.tsx @@ -0,0 +1,38 @@ +import Image from "next/image" + +import {useAppTheme} from "@/oss/components/Layout/ThemeContextProvider" + +interface SidebarLogoProps { + collapsed: boolean +} + +/** Brand header pinned at the top of the main sidebar: full wordmark expanded, symbol collapsed. */ +const SidebarLogo = ({collapsed}: SidebarLogoProps) => { + const {appTheme} = useAppTheme() + const isDark = appTheme === "dark" + + const fullSrc = isDark + ? "/assets/logos/Agenta-logo-full-dark-accent.svg" + : "/assets/logos/Agenta-logo-full-light.svg" + const symbolSrc = isDark + ? "/assets/logos/Agenta-symbol-dark-accent.svg" + : "/assets/logos/Agenta-symbol-light.svg" + + return ( +
+ {/* unoptimized: SVGs skip /_next/image, which rejects SVG without dangerouslyAllowSVG. */} + {collapsed ? ( + Agenta + ) : ( + Agenta + )} +
+ ) +} + +export default SidebarLogo diff --git a/web/oss/src/components/Sidebar/engine/SidebarShell.tsx b/web/oss/src/components/Sidebar/engine/SidebarShell.tsx index 2d5502731c..93721b6c0b 100644 --- a/web/oss/src/components/Sidebar/engine/SidebarShell.tsx +++ b/web/oss/src/components/Sidebar/engine/SidebarShell.tsx @@ -317,6 +317,7 @@ const SidebarShell: React.FC = ({
{renderSlot(scope.footer, collapsed, scope.lastPath)} {bottomSections.map(renderSection)} + {renderSlot(scope.afterBottom, collapsed, scope.lastPath)}
diff --git a/web/oss/src/components/Sidebar/engine/types.ts b/web/oss/src/components/Sidebar/engine/types.ts index 9341e1ad81..6d6099863c 100644 --- a/web/oss/src/components/Sidebar/engine/types.ts +++ b/web/oss/src/components/Sidebar/engine/types.ts @@ -67,6 +67,8 @@ export interface SidebarScope { useSections: () => SidebarSection[] header?: SidebarSlot footer?: SidebarSlot + /** Pinned slot rendered below the bottom section — the very last element in the rail. */ + afterBottom?: SidebarSlot } export interface SidebarShellProps { diff --git a/web/oss/src/components/Sidebar/hooks/useProjectOrgSwitcher.ts b/web/oss/src/components/Sidebar/hooks/useProjectOrgSwitcher.ts new file mode 100644 index 0000000000..ebfc294b4a --- /dev/null +++ b/web/oss/src/components/Sidebar/hooks/useProjectOrgSwitcher.ts @@ -0,0 +1,300 @@ +import {useCallback, useMemo, useRef, useState} from "react" + +import {useMutation} from "@tanstack/react-query" +import {App, Form} from "antd" +import {useAtomValue, useSetAtom} from "jotai" +import {useRouter} from "next/router" +import Session from "supertokens-auth-react/recipe/session" + +import AlertPopup from "@/oss/components/AlertPopup/AlertPopup" +import {useSession} from "@/oss/hooks/useSession" +import useURL from "@/oss/hooks/useURL" +import {buildProjectSwitchHref} from "@/oss/lib/navigation/projectSwitchHref" +import type {OrgDetails} from "@/oss/lib/Types" +import {checkOrganizationAccess} from "@/oss/services/organization/api" +import type {ProjectsResponse} from "@/oss/services/project/types" +import {appIdentifiersAtom} from "@/oss/state/appState" +import {useOrgData} from "@/oss/state/org" +import { + AUTH_UPGRADE_IDENTITIES_KEY, + AUTH_UPGRADE_ORG_KEY, + authUpgradeAtom, +} from "@/oss/state/org/authUpgrade" +import { + cacheWorkspaceOrgPair, + orgsAtom as organizationsAtom, + selectedOrgIdAtom, +} from "@/oss/state/org/selectors/org" +import {cacheLastUsedProjectId, useProjectData} from "@/oss/state/project" +import {authFlowAtom} from "@/oss/state/session" +import {settingsTabAtom} from "@/oss/state/settings" + +const formatErrorMessage = (detail: any, fallback: string) => { + if (typeof detail === "string") return detail + if (detail && typeof detail.message === "string") return detail.message + return fallback +} + +export interface SwitcherOrg { + id: string + name: string +} + +/** + * Data + actions for the combined project/org switcher. Consolidates the view / switch / create / + * logout logic that previously lived across `ListOfOrgs` and `ListOfProjects`, minus the management + * actions that now live in Settings. + */ +export const useProjectOrgSwitcher = () => { + const router = useRouter() + const {message} = App.useApp() + const {logout} = useSession() + const {projectURL} = useURL() + + const {selectedOrg, orgs, changeSelectedOrg, refetch} = useOrgData() + const selectedOrgId = useAtomValue(selectedOrgIdAtom) + const organizationList = useAtomValue(organizationsAtom) + const {project, projects, refetch: refetchProjects} = useProjectData() + const settingsTab = useAtomValue(settingsTabAtom) + const {workspaceId: currentWorkspaceId} = useAtomValue(appIdentifiersAtom) + const setAuthFlow = useSetAtom(authFlowAtom) + const setAuthUpgrade = useSetAtom(authUpgradeAtom) + + const effectiveSelectedId = selectedOrg?.id || selectedOrgId || orgs?.[0]?.id || null + + const safeOrganizationList = useMemo( + () => (Array.isArray(organizationList) ? organizationList : []), + [organizationList], + ) + const currentOrg = useMemo(() => { + const match = + safeOrganizationList.find((org) => org.id === effectiveSelectedId) || + orgs.find((org) => org.id === effectiveSelectedId) + if (!match?.id) return null + return {id: match.id, name: (match.name as string) || "Organization"} + }, [safeOrganizationList, orgs, effectiveSelectedId]) + + const orgOptions = useMemo( + () => orgs.filter((org) => org.id).map((org) => ({id: org.id, name: org.name as string})), + [orgs], + ) + + const projectsByOrganization = useMemo(() => { + const map = new Map() + orgs.forEach((org) => map.set(org.id, [])) + projects.forEach((proj) => { + if (!proj) return + const organizationId = + proj.organization_id || + orgs.find( + (org) => + (org as Partial).default_workspace?.id === proj.workspace_id, + )?.id + if (!organizationId) return + if (!map.has(organizationId)) map.set(organizationId, []) + map.get(organizationId)?.push(proj) + }) + return map + }, [orgs, projects]) + + const projectsForOrg = useMemo( + () => (effectiveSelectedId ? (projectsByOrganization.get(effectiveSelectedId) ?? []) : []), + [projectsByOrganization, effectiveSelectedId], + ) + + // ── Create project ───────────────────────────────────────────────────── + const [createProjectOpen, setCreateProjectOpen] = useState(false) + const [createProjectForm] = Form.useForm<{name: string}>() + + const navigateToProject = useCallback( + (workspaceId: string, projectId: string, organizationId?: string | null) => { + if (!workspaceId || !projectId) return + cacheLastUsedProjectId(workspaceId, projectId) + if (organizationId) cacheWorkspaceOrgPair(workspaceId, organizationId) + const href = buildProjectSwitchHref({ + workspaceId, + projectId, + currentAsPath: router.asPath, + settingsTab, + queryTab: router.query.tab, + }) + void router.push(href) + }, + [router, settingsTab], + ) + + const createProjectMutation = useMutation({ + mutationFn: async ({name}: {name: string}) => { + const {createProject} = await import("@/oss/services/project") + return createProject({name: name.trim()}, currentWorkspaceId ?? undefined) + }, + onSuccess: (createdProject) => { + message.success("Project created") + createProjectForm.resetFields() + setCreateProjectOpen(false) + // Only a real workspace id routes correctly; org id would build /w//... . + const workspaceKey = createdProject?.workspace_id || currentWorkspaceId || "" + if (workspaceKey && createdProject?.project_id) { + navigateToProject( + workspaceKey, + createdProject.project_id, + createdProject.organization_id ?? effectiveSelectedId, + ) + } + void refetchProjects() + }, + onError: (error: any) => { + const detail = error?.response?.data?.detail || error?.message + message.error(formatErrorMessage(detail, "Unable to create project")) + }, + }) + + // ── Create organization ──────────────────────────────────────────────── + const [createOrgOpen, setCreateOrgOpen] = useState(false) + const [createOrgForm] = Form.useForm<{name: string}>() + + const createOrgMutation = useMutation({ + mutationFn: async (values: {name: string}) => { + const {createOrganization} = await import("@/oss/services/organization/api") + return createOrganization({name: values.name.trim()}) + }, + onSuccess: async (createdOrg) => { + message.success("Organization created") + createOrgForm.resetFields() + setCreateOrgOpen(false) + await refetch() + if (createdOrg?.id) await changeSelectedOrg(createdOrg.id) + }, + onError: (error: any) => { + const detail = error?.response?.data?.detail || error?.message + message.error(formatErrorMessage(detail, "Unable to create organization")) + }, + }) + + // ── Switch project / org ─────────────────────────────────────────────── + const switchProject = useCallback( + (proj: ProjectsResponse) => { + navigateToProject( + proj.workspace_id || "", + proj.project_id, + proj.organization_id ?? effectiveSelectedId, + ) + }, + [navigateToProject, effectiveSelectedId], + ) + + const lastDomainDeniedOrgIdRef = useRef(null) + const lastDomainDeniedAtRef = useRef(0) + + const switchOrg = useCallback( + async (organizationId: string) => { + if (!organizationId || organizationId === effectiveSelectedId) return + try { + const result = await checkOrganizationAccess(organizationId) + if (result.ok) { + await changeSelectedOrg(organizationId) + return + } + const detail = result.response?.data?.detail + if ( + detail?.error === "AUTH_UPGRADE_REQUIRED" || + detail?.error === "AUTH_SSO_DENIED" + ) { + setAuthFlow("authing") + // Write both keys before opening the modal so the SSO redirect can't + // fire ahead of the identities write. + if (typeof window !== "undefined") { + window.localStorage.setItem(AUTH_UPGRADE_ORG_KEY, organizationId) + try { + const payload = await Session.getAccessTokenPayloadSecurely() + const sessionIdentities = + payload?.session_identities || payload?.sessionIdentities || [] + window.localStorage.setItem( + AUTH_UPGRADE_IDENTITIES_KEY, + JSON.stringify(sessionIdentities), + ) + } catch { + // identities are optional for the redirect + } + } + setAuthUpgrade({open: true, orgId: organizationId, detail}) + return + } + if (detail?.error === "AUTH_DOMAIN_DENIED") { + const content = + typeof detail?.message === "string" + ? detail.message + : "Your email domain is not allowed for this organization." + const now = Date.now() + const recentlyNotified = + lastDomainDeniedOrgIdRef.current === organizationId && + now - lastDomainDeniedAtRef.current < 2000 + if (!recentlyNotified) { + lastDomainDeniedOrgIdRef.current = organizationId + lastDomainDeniedAtRef.current = now + message.error({content, key: "domain-denied"}) + } + return + } + message.error( + formatErrorMessage( + result.response?.data?.detail || result.response?.statusText, + "Unable to switch organization", + ), + ) + } catch (error) { + message.error("Unable to switch organization") + } + }, + [changeSelectedOrg, effectiveSelectedId, message, setAuthFlow, setAuthUpgrade], + ) + + const goToOrgSettings = useCallback(() => { + if (!projectURL) return + void router.push(`${projectURL}/settings?tab=organizationGeneral`) + }, [projectURL, router]) + + const confirmLogout = useCallback(() => { + AlertPopup({ + title: "Logout", + message: "Are you sure you want to logout?", + centered: true, + onOk: logout, + }) + }, [logout]) + + const createProject = useMemo( + () => ({ + open: createProjectOpen, + setOpen: setCreateProjectOpen, + form: createProjectForm, + submit: (values: {name: string}) => createProjectMutation.mutate(values), + isPending: createProjectMutation.isPending, + }), + [createProjectOpen, createProjectForm, createProjectMutation], + ) + + const createOrg = useMemo( + () => ({ + open: createOrgOpen, + setOpen: setCreateOrgOpen, + form: createOrgForm, + submit: (values: {name: string}) => createOrgMutation.mutate(values), + isPending: createOrgMutation.isPending, + }), + [createOrgOpen, createOrgForm, createOrgMutation], + ) + + return { + currentOrg, + currentProject: project ?? null, + orgOptions, + projectsForOrg, + switchProject, + switchOrg, + goToOrgSettings, + confirmLogout, + createProject, + createOrg, + } +} diff --git a/web/oss/src/components/Sidebar/scopes/bottomSection.tsx b/web/oss/src/components/Sidebar/scopes/bottomSection.tsx index e0d6a26f05..cd09ae4e8f 100644 --- a/web/oss/src/components/Sidebar/scopes/bottomSection.tsx +++ b/web/oss/src/components/Sidebar/scopes/bottomSection.tsx @@ -69,17 +69,21 @@ export const useSidebarBottomSection = ({ [hasProjectURL, projectURL], ) + const inviteItem = useMemo( + () => ({ + key: "invite-teammate-link", + title: "Invite Teammate", + link: `${projectURL}/settings?tab=workspace&inviteModal=open`, + icon: , + tooltip: "Invite Teammate", + isHidden: !doesSessionExist || !selectedOrg || !canInviteMembers, + disabled: !hasProjectURL, + }), + [canInviteMembers, doesSessionExist, hasProjectURL, projectURL, selectedOrg], + ) + const sharedItems = useMemo( () => [ - { - key: "invite-teammate-link", - title: "Invite Teammate", - link: `${projectURL}/settings?tab=workspace&inviteModal=open`, - icon: , - tooltip: "Invite Teammate", - isHidden: !doesSessionExist || !selectedOrg || !canInviteMembers, - disabled: !hasProjectURL, - }, { key: "get-started-guide-link", title: "Get Started Guide", @@ -92,13 +96,6 @@ export const useSidebarBottomSection = ({ isHidden: !SHOW_GET_STARTED_GUIDE || !doesSessionExist, onClick: handleOpenWidget, }, - { - key: "support-chat-link", - title: `Live Chat Support: ${isVisible ? "On" : "Off"}`, - icon: , - isHidden: !isDemo() || !isCrispEnabled, - onClick: handleToggleSupport, - }, { key: "help-docs-link", title: "Help & Docs", @@ -122,37 +119,38 @@ export const useSidebarBottomSection = ({ title: "Slack Support", link: "https://join.slack.com/t/agenta-hq/shared_invite/zt-37pnbp5s6-mbBrPL863d_oLB61GSNFjw", icon: , - divider: true, }, { key: "book-call", title: "Book a call", link: "https://cal.com/mahmoud-mabrouk-ogzgey/demo", icon: , + // Live Chat relocates here from a standalone row; keep the divider only + // when it will actually render (demo + Crisp), else it dangles. + divider: isDemo() && isCrispEnabled, + }, + { + key: "support-chat-link", + title: `Live Chat Support: ${isVisible ? "On" : "Off"}`, + icon: , + isHidden: !isDemo() || !isCrispEnabled, + onClick: handleToggleSupport, }, ], }, ], - [ - canInviteMembers, - doesSessionExist, - handleOpenWidget, - handleToggleSupport, - hasProjectURL, - isCrispEnabled, - isVisible, - projectURL, - selectedOrg, - ], + [doesSessionExist, handleOpenWidget, handleToggleSupport, isCrispEnabled, isVisible], ) return useMemo( () => ({ key: "bottom", - items: includeSettingsLink ? [settingsLink, ...sharedItems] : sharedItems, + items: includeSettingsLink + ? [settingsLink, inviteItem, ...sharedItems] + : [inviteItem, ...sharedItems], placement: "bottom", mode: "vertical", }), - [includeSettingsLink, settingsLink, sharedItems], + [includeSettingsLink, settingsLink, inviteItem, sharedItems], ) } diff --git a/web/oss/src/components/Sidebar/scopes/mainScope.tsx b/web/oss/src/components/Sidebar/scopes/mainScope.tsx index b539ebe947..81378ff18e 100644 --- a/web/oss/src/components/Sidebar/scopes/mainScope.tsx +++ b/web/oss/src/components/Sidebar/scopes/mainScope.tsx @@ -1,12 +1,12 @@ import {useMemo} from "react" -import {Divider} from "antd" import {useAtomValue} from "jotai" import SidePanelSubscriptionInfo from "@/oss/components/SidePanel/Subscription" import {homeNavHighlightedAtom} from "@/oss/state/onboarding" -import ListOfOrgs from "../components/ListOfOrgs" +import ProjectOrgSwitcher from "../components/ProjectOrgSwitcher" +import SidebarLogo from "../components/SidebarLogo" import type { SidebarScope, SidebarSection, @@ -18,12 +18,7 @@ import {useSidebarConfig} from "../hooks/useSidebarConfig" import {useSidebarBottomSection} from "./bottomSection" import {HOME_SIDEBAR_KEY, MAIN_SIDEBAR_SCOPE_ID} from "./constants" -const MainSidebarHeader = ({collapsed}: SidebarSlotContext) => ( - <> - - - -) +const MainSidebarHeader = ({collapsed}: SidebarSlotContext) => const MainSidebarFooter = ({collapsed}: SidebarSlotContext) => collapsed ? null : ( @@ -32,6 +27,10 @@ const MainSidebarFooter = ({collapsed}: SidebarSlotContext) =>
) +const MainSidebarAfterBottom = ({collapsed}: SidebarSlotContext) => ( + +) + // During onboarding the route is the ephemeral playground, but Home IS the surface — pin it selected. const useMainSidebarSelection = (): SidebarSelection => { const highlightHome = useAtomValue(homeNavHighlightedAtom) @@ -66,4 +65,5 @@ export const mainSidebarScope: SidebarScope = { useSections: useMainSidebarSections, header: MainSidebarHeader, footer: MainSidebarFooter, + afterBottom: MainSidebarAfterBottom, } diff --git a/web/oss/src/components/Sidebar/scopes/settingsScope.tsx b/web/oss/src/components/Sidebar/scopes/settingsScope.tsx index 930ac298db..02043de024 100644 --- a/web/oss/src/components/Sidebar/scopes/settingsScope.tsx +++ b/web/oss/src/components/Sidebar/scopes/settingsScope.tsx @@ -16,7 +16,6 @@ import { Vault, Wrench, } from "@phosphor-icons/react" -import {Divider} from "antd" import {useAtom} from "jotai" import { @@ -31,7 +30,7 @@ import {useSettingsAccess} from "@/oss/components/pages/settings/hooks/useSettin import {useQueryParam} from "@/oss/hooks/useQuery" import {settingsTabAtom} from "@/oss/state/settings" -import ListOfOrgs from "../components/ListOfOrgs" +import ProjectOrgSwitcher from "../components/ProjectOrgSwitcher" import SidebarBackButton from "../components/SidebarBackButton" import type { SidebarConfig, @@ -160,19 +159,18 @@ const useSettingsSidebarSections = (): SidebarSection[] => { } const SettingsSidebarHeader = ({collapsed, lastPath}: SidebarSlotContext) => ( - <> -
- -
- - - - +
+ +
+) + +const SettingsSidebarAfterBottom = ({collapsed}: SidebarSlotContext) => ( + ) export const createSettingsSidebarScope = ({lastPath}: SettingsScopeOptions): SidebarScope => ({ @@ -181,4 +179,5 @@ export const createSettingsSidebarScope = ({lastPath}: SettingsScopeOptions): Si useSelection: useSettingsSidebarSelection, useSections: useSettingsSidebarSections, header: SettingsSidebarHeader, + afterBottom: SettingsSidebarAfterBottom, }) diff --git a/web/oss/src/components/pages/settings/Organization/General.tsx b/web/oss/src/components/pages/settings/Organization/General.tsx index d14fb6b762..52775053da 100644 --- a/web/oss/src/components/pages/settings/Organization/General.tsx +++ b/web/oss/src/components/pages/settings/Organization/General.tsx @@ -2,7 +2,7 @@ import {useCallback, useMemo, useState} from "react" import {InitialsAvatar} from "@agenta/ui" import {EnhancedModal} from "@agenta/ui/components/modal" -import {ArrowsLeftRight, Trash} from "@phosphor-icons/react" +import {ArrowsLeftRight, CopyIcon, Trash} from "@phosphor-icons/react" import {useMutation} from "@tanstack/react-query" import {App, Button, Form, Input, Select, Typography} from "antd" import clsx from "clsx" @@ -131,6 +131,20 @@ const OrganizationGeneral = () => { }, }) + const copyOrganizationId = useCallback(async () => { + if (!organizationId) return + if (typeof navigator === "undefined" || !navigator?.clipboard) { + message.error("Clipboard not supported") + return + } + try { + await navigator.clipboard.writeText(organizationId) + message.success("Organization ID copied") + } catch { + message.error("Failed to copy organization ID") + } + }, [message, organizationId]) + const handleDelete = useCallback(async () => { if (!organizationId || !isDeleteNameMatch) return @@ -199,6 +213,22 @@ const OrganizationGeneral = () => { + +
+ + +
+
+ { await queryClient.invalidateQueries({queryKey: ["projects"]}) }, [queryClient]) + const copyProjectId = useCallback( + async (projectId: string) => { + if (typeof navigator === "undefined" || !navigator?.clipboard) { + message.error("Clipboard not supported") + return + } + try { + await navigator.clipboard.writeText(projectId) + message.success("Project ID copied") + } catch { + message.error("Failed to copy project ID") + } + }, + [message], + ) + const createMutation = useMutation({ mutationFn: (payload: ProjectFormValues) => createProject(payload), onSuccess: () => { @@ -186,9 +202,20 @@ const ProjectsSettings = () => { Default )}
- - {record.project_id} - +
+ + {record.project_id} + + +
), }, @@ -256,6 +283,7 @@ const ProjectsSettings = () => { ], [ canDeleteProjects, + copyProjectId, defaultMutation.isPending, defaultMutation.variables, deleteMutation.isPending, diff --git a/web/oss/src/state/org/authUpgrade.ts b/web/oss/src/state/org/authUpgrade.ts new file mode 100644 index 0000000000..a89e61b268 --- /dev/null +++ b/web/oss/src/state/org/authUpgrade.ts @@ -0,0 +1,36 @@ +import {atom} from "jotai" + +/** Auth-upgrade payload from the org-access check. Lives here (state, not a component) so + * consumers of `authUpgradeAtom` don't pull a Sidebar component into their type graph. */ +export interface AuthUpgradeDetail { + message?: string + required_methods?: string[] + session_identities?: string[] + user_identities?: string[] + sso_providers?: { + id: string + slug: string + third_party_id?: string + }[] +} + +/** localStorage keys shared by the switcher (writes) and the auth-redirect flow (reads). */ +export const AUTH_UPGRADE_ORG_KEY = "authUpgradeOrgId" +export const AUTH_UPGRADE_IDENTITIES_KEY = "authUpgradeSessionIdentities" + +export interface AuthUpgradeState { + open: boolean + orgId: string | null + detail: AuthUpgradeDetail | null +} + +const INITIAL_AUTH_UPGRADE_STATE: AuthUpgradeState = {open: false, orgId: null, detail: null} + +/** + * App-level auth-upgrade prompt state. Any entry point that switches organizations sets this + * atom on an `AUTH_UPGRADE_REQUIRED` / `AUTH_SSO_DENIED` response; a single app-level host + * (`AuthUpgradeHost`) renders the modal. Keeps the flow out of the sidebar selector. + */ +export const authUpgradeAtom = atom(INITIAL_AUTH_UPGRADE_STATE) + +export const resetAuthUpgradeState = (): AuthUpgradeState => ({...INITIAL_AUTH_UPGRADE_STATE})