diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx index 8a39e94a9fe..0d1077648d7 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx @@ -5,19 +5,27 @@ import { act, type ComponentProps } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockNavigate, mockPush } = vi.hoisted(() => ({ +const { mockNavigate, mockPush, context } = vi.hoisted(() => ({ mockNavigate: vi.fn(), mockPush: vi.fn(), + context: { + organization: { id: 'org-1' }, + }, })) vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }), + usePathname: () => '/o/org-1/home', })) vi.mock('next/link', () => ({ default: ({ onNavigate, + prefetch: _prefetch, ...props - }: ComponentProps<'a'> & { onNavigate?: (event: { preventDefault: () => void }) => void }) => ( + }: ComponentProps<'a'> & { + prefetch?: boolean + onNavigate?: (event: { preventDefault: () => void }) => void + }) => ( ({ /> ), })) +vi.mock('@/lib/auth/sign-out', () => ({ signOutAndRedirect: vi.fn() })) vi.mock('@/lib/desktop', () => ({ getDesktopUpdates: () => null })) vi.mock('@/hooks/use-desktop-update-state', () => ({ useDesktopUpdateState: () => ({ status: 'idle' }), @@ -42,12 +51,14 @@ vi.mock('@/hooks/queries/user-profile', () => ({ useUserProfile: () => ({ data: { id: 'user-1', name: 'Ada', email: 'ada@example.com' } }), })) vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ - useOrganizationContext: () => ({ organization: { id: 'org-1' } }), + useOrganizationContext: () => context, })) vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/components', () => ({ SidebarTooltip: ({ children }: { children: React.ReactNode }) => children, })) -vi.mock('@/components/icons', () => ({ SlackIcon: () => })) +vi.mock('@/components/icons', () => ({ + SlackIcon: () => , +})) import { OrganizationFooter } from '@/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' @@ -71,7 +82,7 @@ afterEach(async () => { vi.unstubAllGlobals() }) -async function selectSettings() { +async function openProfileMenu() { await act(async () => { root.render( {}} onJoinSlack={() => {}} + onContactSupport={() => {}} /> ) }) @@ -88,15 +100,27 @@ async function selectSettings() { await act(async () => { trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) }) +} + +async function selectSettings() { + await openProfileMenu() const link = document.querySelector('a[href="/o/org-1/settings/general"]') if (!link) throw new Error('Settings link is missing') await act(async () => link.click()) } describe('OrganizationFooter settings navigation', () => { + it('keeps only Settings and Sign out in the organization profile menu', async () => { + await openProfileMenu() + expect( + [...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent) + ).toEqual(['Settings', 'Sign out']) + expect(document.querySelector('[role="separator"]')).toBeNull() + }) + it('navigates immediately when settings are clean', async () => { await selectSettings() - expect(mockNavigate).toHaveBeenCalledWith('/o/org-1/settings/general') + expect(mockPush).toHaveBeenCalledWith('/o/org-1/settings/general') expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull() }) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx index ecc37a12e01..125ab4202af 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx @@ -1,242 +1,28 @@ 'use client' -import type { DesktopUpdateState } from '@sim/desktop-bridge' -import { - Chip, - chipContentLabelClass, - chipPrimaryFillTokens, - chipVariants, - cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuItemLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, - OverflowText, - Skeleton, -} from '@sim/emcn' -import { BookOpen, Download, HelpCircle, Settings } from '@sim/emcn/icons' -import { SlackIcon } from '@/components/icons' -import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link' -import { getDesktopUpdates } from '@/lib/desktop' +import type { ComponentProps } from 'react' +import { useRouter } from 'next/navigation' import { organizationRoutes } from '@/lib/navigation/paths' -import { getUserColor } from '@/lib/workspaces/colors' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' -import { - SIDEBAR_ITEM_GAP_CLASS, - SIDEBAR_RAIL_CHIP_CLASS, -} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' -import { useUserProfile } from '@/hooks/queries/user-profile' -import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state' +import { SidebarFooter } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer' -function hasAvailableDesktopUpdate(state: DesktopUpdateState): boolean { - return state.status === 'available' || state.status === 'downloading' || state.status === 'ready' -} - -function desktopUpdateActionLabel(state: DesktopUpdateState): string { - if (state.status === 'downloading') { - return state.percent === undefined - ? 'Downloading update…' - : `Downloading update ${state.percent}%` - } - return state.status === 'ready' ? 'Restart to update' : 'Update' -} - -/** Compact primary update circle using the same footprint as the surrounding sidebar icons. */ -function DesktopUpdateIcon({ className }: { className?: string }) { - return ( -
- {/* Download's default viewBox is asymmetric around its paths. Center the - artwork itself, not merely its SVG box, inside the avatar-sized circle. */} - -
- ) -} +interface OrganizationFooterProps + extends Omit< + ComponentProps, + 'accountSettingsHref' | 'onOpenAccountSettings' | 'navigationLinks' + > {} -interface OrganizationFooterProps { - /** - * True while the scroll region above still hides rows beyond its bottom edge — - * the same test the divider under the pinned nav applies at the top. The bar's - * top rule is drawn only then, so a list that fits meets the footer with no line. - */ - showDivider: boolean - isCollapsed: boolean - showCollapsedTooltips: boolean - onOpenDocs: () => void - onJoinSlack: () => void -} - -/** - * Pinned bottom bar of the organization sidebar: the viewer's avatar and name, - * which open their account settings, plus a help menu. Same two elements and the - * same layout as the workspace footer — expanded they share one row with help hard - * right, collapsed they stack as icon chips with help on top. - * - * Collapsed reverses the flex direction instead of reordering the DOM, which keeps - * both elements (and the help menu's trigger) alive across a toggle. - */ -export function OrganizationFooter({ - showDivider, - isCollapsed, - showCollapsedTooltips, - onOpenDocs, - onJoinSlack, -}: OrganizationFooterProps) { +export function OrganizationFooter(props: OrganizationFooterProps) { const { organization } = useOrganizationContext() - const { data: profile } = useUserProfile() - const updateState = useDesktopUpdateState() - - const name = profile ? profile.name?.trim() || profile.email : '' - const updateAvailable = hasAvailableDesktopUpdate(updateState) - - const handleUpdateSelect = () => { - const updates = getDesktopUpdates() - if (updateState.status === 'ready') { - updates?.install() - } else if (updateState.status === 'available') { - updates?.check() - } - } - - /** - * Plain `img`/`div` rather than the emcn `Avatar`, whose Radix root renders a - * `` — and globals fade every `span` in the collapsed rail to `opacity: 0`, - * which would blank the avatar exactly where it is the only thing left to see. - */ - const avatar = !profile ? ( - - ) : profile.image ? ( - - ) : ( -
- {name.charAt(0).toUpperCase()} -
- ) - - /** - * Expanded, the chip hugs its content (`max-w-full` so a long name truncates - * rather than overflowing); collapsed, `fullWidth` fills the narrow rail and - * `min-w-0` lets the hidden label give up its box so the chip never overflows it. - * The name is the button's accessible name — no `aria-label`, which would - * override the visible text. - */ - const profileMenu = ( - - - - - - - - - - - - - - - - ) - - /** - * One node across both states; only `fullWidth` changes, so the same Radix menu - * survives the transition. `shrink-0` keeps the chip off the avatar while the rail - * is briefly narrower than the row — the aside's clip hides it until there is room. - */ - const helpMenu = ( - - - - - - - {/* Anchored to whichever edge the trigger sits on, so the menu never overhangs the rail. */} - - {updateAvailable && ( - <> - - - {desktopUpdateActionLabel(updateState)} - - - - )} - - - Docs - - - - Join Slack - - - - ) + const router = useRouter() + const accountSettingsHref = organizationRoutes(organization.id).settingsSection('general') return ( -
- {/* Expanded, claims the row's free width so the help button lands hard right. - `flex` makes the inline-flex chip a flex item, so the wrapper is exactly the - chip's 30px rather than a line box padded by the strut's half-leading. */} -
{profileMenu}
- {helpMenu} -
+ router.push(accountSettingsHref)} + navigationLinks={[]} + /> ) } diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.test.tsx index 665eea639dc..e3d69feee22 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.test.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.test.tsx @@ -7,7 +7,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ upload: vi.fn(), refresh: vi.fn() })) +const mocks = vi.hoisted(() => ({ upload: vi.fn(), refresh: vi.fn(), invite: vi.fn() })) +vi.mock('@/app/workspace/[workspaceId]/components/invite-modal', () => ({ + InviteModal: (props: object) => { + mocks.invite(props) + return null + }, +})) vi.mock('@/lib/uploads/client/session-upload', () => ({ uploadInternalFileSession: mocks.upload, })) @@ -49,7 +55,12 @@ afterEach(async () => { vi.unstubAllGlobals() }) -async function render(canEditLogo = true, isCollapsed = false, onExpandSidebar = vi.fn()) { +async function render( + canEditLogo = true, + isCollapsed = false, + onExpandSidebar = vi.fn(), + canInviteMembers = canEditLogo +) { await act(async () => { root.render( @@ -57,6 +68,7 @@ async function render(canEditLogo = true, isCollapsed = false, onExpandSidebar = @@ -189,3 +201,35 @@ describe('OrganizationHeader logo upload', () => { expect(mocks.refresh).toHaveBeenCalledOnce() }) }) + +describe('OrganizationHeader member actions', () => { + it('opens the existing invitation flow for the current organization', async () => { + await render() + await openMenu() + const invite = [...document.querySelectorAll('[role="menuitem"]')].find( + (item) => item.textContent === 'Invite people' + )! + await act(async () => invite.click()) + expect(mocks.invite).toHaveBeenCalledWith( + expect.objectContaining({ + open: true, + organizationId: 'org-1', + isOrganizationAdmin: true, + canInvite: true, + }) + ) + }) + + it.each([false, true])( + 'keeps settings access but hides disallowed invitations (admin=%s)', + async (admin) => { + await render(admin, false, vi.fn(), false) + await openMenu() + expect(document.querySelector('a[href="/o/org-1/settings/members"]')).toHaveTextContent( + 'Settings' + ) + expect(document.querySelector('[role="menu"]')).not.toHaveTextContent('Invite people') + expect(mocks.invite).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx index 19a580ae22b..3075628dbc7 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx @@ -1,6 +1,6 @@ 'use client' -import { useRef } from 'react' +import { useRef, useState } from 'react' import { Chip, ChipChevronDown, @@ -12,13 +12,14 @@ import { Tooltip, toast, } from '@sim/emcn' -import { PanelLeft, Settings } from '@sim/emcn/icons' +import { PanelLeft, Send, Settings } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' import { IdentityTile } from '@/components/identity-tile/identity-tile' import { getOrganizationSettingsHref } from '@/components/settings/navigation' import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link' import type { OrganizationSurfaceOrganization } from '@/lib/organizations/surface' import { LOGO_ACCEPT_ATTRIBUTE } from '@/lib/uploads/client/logo-file' +import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { useUploadOrganizationLogo } from '@/hooks/queries/organization-logo' @@ -29,6 +30,7 @@ function getOrganizationInitial(name: string): string { interface OrganizationHeaderProps { organization: OrganizationSurfaceOrganization canEditLogo: boolean + canInviteMembers: boolean isCollapsed: boolean /** Expands the rail; the collapsed header is itself the expand control. */ onExpandSidebar: () => void @@ -44,6 +46,7 @@ interface OrganizationHeaderProps { export function OrganizationHeader({ organization, canEditLogo, + canInviteMembers, isCollapsed, onExpandSidebar, }: OrganizationHeaderProps) { @@ -52,6 +55,7 @@ export function OrganizationHeader({ const { mutate: uploadLogo, isPending: isUploadingLogo } = useUploadOrganizationLogo( organization.id ) + const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) const initial = getOrganizationInitial(organization.name) if (isCollapsed) { @@ -131,7 +135,7 @@ export function OrganizationHeader({ aria-label='Change organization logo' aria-busy={isUploadingLogo} textValue='Change organization logo' - className='h-auto shrink-0 p-1' + className='h-auto shrink-0 p-0 hover-hover:opacity-70 focus-visible:opacity-70' disabled={isUploadingLogo} onSelect={(event) => { event.preventDefault() @@ -161,8 +165,23 @@ export function OrganizationHeader({ Settings + {canInviteMembers && ( + setIsInviteModalOpen(true)}> + + Invite people + + )} + {isInviteModalOpen && ( + + )} ) } diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.test.tsx index e4ce1404ee2..2a08c775fb9 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.test.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.test.tsx @@ -45,6 +45,22 @@ vi.mock('@/hooks/queries/workspace', () => ({ })) import { WorkspaceList } from '@/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list' +import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu' + +function KeyboardWorkspaceFlyout() { + const hover = useHoverMenu() + return ( + (open ? hover.open() : hover.close())} + > + Workspaces + + + + + ) +} let container: HTMLDivElement let root: Root @@ -91,6 +107,18 @@ async function render() { } describe('WorkspaceList rail view', () => { + it('keeps the flyout open when opened from the keyboard without pointer hover', async () => { + workspacesState.workspaces = [{ id: 'ws-1', name: 'Design' }] + await act(async () => root.render()) + const trigger = container.querySelector('button')! + await act(async () => { + trigger.focus() + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + expect(document.querySelector('[role="menu"]')).not.toBeNull() + expect(document.querySelector('a[href="/workspace/ws-1"]')).toHaveTextContent('Design') + }) + it('lists every workspace as a link into it', async () => { workspacesState.workspaces = [ { id: 'ws-1', name: 'Design' }, diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx index c0085b6b92c..8d1bfa0b942 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx @@ -52,11 +52,13 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis }, }) const lockFlyout = flyout?.setLocked + const isInteracting = menu.isOpen || rename.editingId !== null useEffect(() => { - lockFlyout?.(menu.isOpen || rename.editingId !== null) - return () => lockFlyout?.(false) - }, [lockFlyout, menu.isOpen, rename.editingId]) + if (!lockFlyout || !isInteracting) return + lockFlyout(true) + return () => lockFlyout(false) + }, [lockFlyout, isInteracting]) const visibleWorkspaces = flyout ? workspaces : workspaces.slice(0, visibleCount) const hasMore = workspaces.length > visibleCount @@ -90,7 +92,11 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis initial={getWorkspaceInitial(workspace.name)} logoUrl={workspace.logoUrl} /> - + ) const onMoreClick = (event: React.MouseEvent) => { @@ -136,6 +142,16 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis onPointerMove={(event) => { if (menu.isOpen || rename.editingId) event.preventDefault() }} + actionIndicator={ + isPinned ? ( + + ) : undefined + } action={ openMenu(event, workspace.id)} > {label} - {isPinned && } ) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx index 363e229f6ec..c4434335f55 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx @@ -27,6 +27,7 @@ import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/works import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { + HelpModal, isNavItemActive, NavItemContextMenu, SidebarNavChip, @@ -94,6 +95,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() { const settingsPath = organizationRoutes(organization.id).settings const isSettings = pathname === settingsPath || pathname?.startsWith(`${settingsPath}/`) + const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) const [menuHref, setMenuHref] = useState(null) const { isOpen: isHrefMenuOpen, @@ -182,6 +184,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() { @@ -282,6 +285,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() { showCollapsedTooltips={showCollapsedTooltips} onOpenDocs={handleOpenDocs} onJoinSlack={handleOpenSlackCommunity} + onContactSupport={() => setIsHelpModalOpen(true)} /> + + {/* Not on the peek card: the resize hook writes an inline `--sidebar-width` that out-specifies the `[data-peek]` rule, stranding the card at a stale width. */} {!isPeeking && ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index 9ffff5c4db5..bc7e01d96b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -23,8 +23,9 @@ import dynamic from 'next/dynamic' import Image from 'next/image' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' -import { signOut, useSession } from '@/lib/auth/auth-client' +import { useSession } from '@/lib/auth/auth-client' import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' +import { signOutAndRedirect } from '@/lib/auth/sign-out' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { getBrowserTimezone, getTimezoneOptions } from '@/lib/core/utils/timezone' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -49,7 +50,6 @@ import { useUpdateUserProfile, useUserProfile, } from '@/hooks/queries/user-profile' -import { clearUserData } from '@/stores' const AuthorizedApps = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps').then( @@ -195,21 +195,6 @@ export function General() { handleUpdateName() } - const handleSignOut = async () => { - const logoutUrl = '/login?fromLogout=true' - let canNavigateInApp = false - - try { - const [, inMemoryResetSucceeded] = await Promise.all([signOut(), clearUserData()]) - canNavigateInApp = inMemoryResetSucceeded - } catch (error) { - logger.error('Error signing out:', { error }) - } - - if (canNavigateInApp) router.push(logoutUrl) - else window.location.assign(logoutUrl) - } - const handleResetPasswordConfirm = async () => { if (!profile?.email) return @@ -299,7 +284,7 @@ export function General() { : []), ...(session?.user?.id && !isAuthDisabled ? [ - { id: 'sign-out', text: 'Sign out', onSelect: handleSignOut }, + { id: 'sign-out', text: 'Sign out', onSelect: () => signOutAndRedirect(router.push) }, { id: 'reset-password', text: 'Reset password', diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx index 6e734ec5c31..635ffebb475 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx @@ -53,14 +53,14 @@ interface HelpModalProps { open: boolean onOpenChange: (open: boolean) => void workflowId?: string - workspaceId: string + workspaceId?: string } interface SubmitHelpVariables { data: FormValues images: ImageWithPreview[] workflowId?: string - workspaceId: string + workspaceId?: string } async function compressImage(file: File): Promise { @@ -93,7 +93,7 @@ async function submitHelpRequest({ data, images, workflowId, workspaceId }: Subm formData.append('subject', data.subject) formData.append('message', data.message) formData.append('type', data.type) - formData.append('workspaceId', workspaceId) + if (workspaceId) formData.append('workspaceId', workspaceId) formData.append('userAgent', navigator.userAgent) if (workflowId) { formData.append('workflowId', workflowId) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx index 22d0f09b014..c4455bac823 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx @@ -1,10 +1,10 @@ /** * @vitest-environment jsdom */ -import { act } from 'react' -import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { act, type ComponentProps } from 'react' +import { Building, Credit, Trash, Users } from '@sim/emcn/icons' import { createRoot, type Root } from 'react-dom/client' -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const desktopMocks = vi.hoisted(() => ({ getState: vi.fn(), @@ -15,15 +15,26 @@ const desktopMocks = vi.hoisted(() => ({ unsubscribe: vi.fn(), })) -const { hostContext } = vi.hoisted(() => ({ - hostContext: { - hostOrganizationId: null as string | null, - viewer: { isHostOrganizationMember: false }, - features: { - organizationSearch: undefined as boolean | undefined, - knowledgeMemberAccess: false, - }, - }, +const authMocks = vi.hoisted(() => ({ signOut: vi.fn(), userId: 'user-1' })) +vi.mock('@/lib/auth/sign-out', () => ({ signOutAndRedirect: authMocks.signOut })) +vi.mock('next/link', () => ({ + default: ({ + onNavigate, + prefetch: _prefetch, + ...props + }: ComponentProps<'a'> & { + prefetch?: boolean + onNavigate?: (event: { preventDefault: () => void }) => void + }) => ( +
{ + event.preventDefault() + onNavigate?.({ preventDefault: () => {} }) + }} + /> + ), })) vi.mock('@/lib/desktop', () => ({ @@ -35,23 +46,11 @@ vi.mock('@/lib/desktop', () => ({ }), })) vi.mock('@/hooks/queries/user-profile', () => ({ - useUserProfile: () => ({ data: { id: 'user-1', name: 'Ada', email: 'ada@sim.ai' } }), + useUserProfile: () => ({ data: { id: authMocks.userId, name: 'Ada', email: 'ada@sim.ai' } }), })) -vi.mock('@/lib/auth/auth-client', () => ({ - useSession: () => ({ data: { user: { id: 'user-1' } } }), -})) -vi.mock('@/lib/billing/workspace-permissions', () => ({ - canViewWorkspaceBillingSettings: () => true, -})) -/** Billing routes the invitations-disabled row to Subscription; read at render time. */ -beforeAll(() => setEnvFlags({ isBillingEnabled: true })) -afterAll(resetEnvFlagsMock) -vi.mock('@/lib/workspaces/colors', () => ({ getUserColor: () => '#000000' })) -vi.mock('@/hooks/use-workspace-invite-policy', () => ({ - useWorkspaceInvitePolicy: () => ({ isInvitationsDisabled: false }), -})) -vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ - useWorkspaceHostContext: () => hostContext, +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/workspace/ws-emir/home', })) vi.mock( '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/sidebar-tooltip', @@ -63,7 +62,9 @@ vi.mock('@/components/icons', () => ({ SlackIcon: ({ className }: { className?: string }) => , })) +import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' import { SidebarFooter } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer' +import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' let container: HTMLDivElement let root: Root @@ -76,12 +77,31 @@ async function renderFooter( await act(async () => { root.render( `/workspace/workspace-1/settings/${section}`} - onOpenSettings={() => {}} + accountSettingsHref='/workspace/workspace-1/settings/general' + onOpenAccountSettings={() => {}} + navigationLinks={[ + { + label: 'Subscription', + icon: Credit, + href: '/workspace/workspace-1/settings/billing', + onNavigate: () => {}, + }, + { + label: 'Teammates', + icon: Users, + href: '/workspace/workspace-1/settings/teammates', + onNavigate: () => {}, + }, + { + label: 'Recently deleted', + icon: Trash, + href: '/workspace/workspace-1/settings/recently-deleted', + onNavigate: () => {}, + }, + ]} onOpenDocs={() => {}} onJoinSlack={() => {}} onContactSupport={() => {}} @@ -129,10 +149,8 @@ function menuItem(label: string): HTMLElement { beforeEach(() => { vi.clearAllMocks() - hostContext.hostOrganizationId = null - hostContext.viewer.isHostOrganizationMember = false - hostContext.features.organizationSearch = undefined - hostContext.features.knowledgeMemberAccess = false + authMocks.userId = 'user-1' + useSettingsDirtyStore.getState().reset() desktopMocks.listener = null desktopMocks.onState.mockImplementation((listener) => { desktopMocks.listener = listener @@ -150,50 +168,64 @@ afterEach(() => { }) describe('SidebarFooter', () => { - it('links members back to the organization hosting the current workspace', async () => { - hostContext.hostOrganizationId = 'host-org' - hostContext.viewer.isHostOrganizationMember = true - hostContext.features.organizationSearch = true + it('keeps the familiar Settings entry in the profile menu', async () => { await renderFooter({ status: 'idle' }) - openProfileMenu() + expect( + [...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent) + ).toEqual(['Settings', 'Subscription', 'Teammates', 'Recently deleted', 'Sign out']) + expect(document.querySelector('[role="separator"]')).toBeNull() + expect(menuItem('Settings')).toHaveAttribute('href', '/workspace/workspace-1/settings/general') + }) - expect(menuItem('Organization')).toHaveAttribute('href', '/o/host-org') - const labels = Array.from(document.querySelectorAll('[role="menuitem"]')).map( - (item) => item.textContent + it('guards returning to the organization when settings are unsaved', async () => { + const onNavigate = vi.fn() + await renderFooter( + { status: 'idle' }, + { + navigationLinks: [{ label: 'Organization', icon: Building, href: '/o/org-1', onNavigate }], + } ) - expect(labels.indexOf('Organization')).toBe(labels.indexOf('Settings') + 1) - expect(labels.indexOf('Organization')).toBeLessThan(labels.indexOf('Teammates')) - expect(document.querySelector('[role="menu"] [role="separator"]')).toBeNull() + useSettingsDirtyStore.getState().setDirty(true) + openProfileMenu() + expect(menuItem('Organization')).toHaveAttribute('href', '/o/org-1') + act(() => menuItem('Organization').click()) + expect(onNavigate).not.toHaveBeenCalled() + act(() => useSettingsDirtyStore.getState().confirmLeave()) + expect(onNavigate).toHaveBeenCalledOnce() }) - it.each([false, undefined])( - 'keeps the workspace profile menu when org rollout is %s', - async (enabled) => { - hostContext.hostOrganizationId = 'host-org' - hostContext.viewer.isHostOrganizationMember = true - hostContext.features.organizationSearch = enabled - hostContext.features.knowledgeMemberAccess = true - await renderFooter({ status: 'idle' }) - - openProfileMenu() - - expect(document.querySelector('[role="menu"]')).not.toHaveTextContent('Organization') - expect(menuItem('Settings')).toHaveAttribute( - 'href', - '/workspace/workspace-1/settings/general' - ) - } - ) + it('uses the shared sign-out flow', async () => { + await renderFooter({ status: 'idle' }) + openProfileMenu() + await act(async () => menuItem('Sign out').click()) + expect(authMocks.signOut).toHaveBeenCalledOnce() + }) - it.each([null, 'host-org'])('hides Organization without host membership (%s)', async (orgId) => { - hostContext.hostOrganizationId = orgId - hostContext.features.organizationSearch = true + it('defers sign-out while settings are unsaved', async () => { await renderFooter({ status: 'idle' }) + useSettingsDirtyStore.getState().setDirty(true) + openProfileMenu() + await act(async () => menuItem('Sign out').click()) + expect(authMocks.signOut).not.toHaveBeenCalled() + act(() => useSettingsDirtyStore.getState().confirmLeave()) + expect(authMocks.signOut).toHaveBeenCalledOnce() + }) + it('hides sign-out for auth-disabled deployments', async () => { + authMocks.userId = ANONYMOUS_USER_ID + await renderFooter({ status: 'idle' }) openProfileMenu() + expect(document.querySelector('[role="menu"]')).not.toHaveTextContent('Sign out') + expect(document.querySelector('[role="separator"]')).toBeNull() + }) - expect(document.querySelector('[role="menu"]')).not.toHaveTextContent('Organization') + it('opens the shared support flow', async () => { + const onContactSupport = vi.fn() + await renderFooter({ status: 'idle' }, { onContactSupport }) + openHelpMenu() + act(() => menuItem('Contact support').click()) + expect(onContactSupport).toHaveBeenCalledOnce() }) it('keeps the overflow tooltip disabled while the collapsed tooltip still owns the trigger', async () => { @@ -212,18 +244,6 @@ describe('SidebarFooter', () => { expect(document.querySelector('[data-native-surface-overlay]')).toBeNull() }) - it('renders profile settings destinations with native link semantics', async () => { - await renderFooter({ status: 'idle' }) - - openProfileMenu() - - expect(menuItem('Settings')).toHaveAttribute('href', '/workspace/workspace-1/settings/general') - expect(menuItem('Subscription')).toHaveAttribute( - 'href', - '/workspace/workspace-1/settings/billing' - ) - }) - it('keeps the ordinary help treatment when no update is available', async () => { await renderFooter({ status: 'idle' }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx index 6963d707b56..1da14a9bf22 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx @@ -17,26 +17,14 @@ import { OverflowText, Skeleton, } from '@sim/emcn' -import { - BookOpen, - Building, - Credit, - Download, - HelpCircle, - Settings, - Trash, - Users, -} from '@sim/emcn/icons' +import { BookOpen, Download, HelpCircle, LogOut, Settings } from '@sim/emcn/icons' +import { useRouter } from 'next/navigation' import { SlackIcon } from '@/components/icons' import { SettingsIntentLink } from '@/components/settings/settings-intent-link' -import { useSession } from '@/lib/auth/auth-client' -import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions' -import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' +import { signOutAndRedirect } from '@/lib/auth/sign-out' import { getDesktopUpdates } from '@/lib/desktop' -import { organizationRoutes } from '@/lib/navigation/paths' import { getUserColor } from '@/lib/workspaces/colors' -import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' -import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip' import { SIDEBAR_ITEM_GAP_CLASS, @@ -44,28 +32,7 @@ import { } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { useUserProfile } from '@/hooks/queries/user-profile' import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state' -import { useWorkspaceInvitePolicy } from '@/hooks/use-workspace-invite-policy' - -/** - * Settings destinations reachable from the profile menu, in display order. Labels - * and icons mirror the settings navigation entries they open, so the menu and the - * settings sidebar never disagree about what a section is called. - * - * Which of them a given viewer actually gets is decided in {@link SidebarFooter} — - * the same gates the settings sidebar and the section route apply, so the menu - * never lists a page the server would refuse. - */ -const PROFILE_MENU_ITEMS: readonly { - section: SettingsSection | 'organization' - label: string - icon: ComponentType<{ className?: string }> -}[] = [ - { section: 'general', label: 'Settings', icon: Settings }, - { section: 'organization', label: 'Organization', icon: Building }, - { section: 'billing', label: 'Subscription', icon: Credit }, - { section: 'teammates', label: 'Teammates', icon: Users }, - { section: 'recently-deleted', label: 'Recently deleted', icon: Trash }, -] +import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' function hasAvailableDesktopUpdate(state: DesktopUpdateState): boolean { return state.status === 'available' || state.status === 'downloading' || state.status === 'ready' @@ -97,8 +64,14 @@ function DesktopUpdateIcon({ className }: { className?: string }) { ) } +interface SidebarNavigationLink { + label: string + icon: ComponentType<{ className?: string }> + href: string + onNavigate: () => void +} + interface SidebarFooterProps { - workspaceId: string /** * True while the scroll region above still hides rows beyond its bottom edge — * the same test the divider under the pinned nav applies at the top. The bar's @@ -107,16 +80,16 @@ interface SidebarFooterProps { showDivider: boolean isCollapsed: boolean showCollapsedTooltips: boolean - getSettingsHref: (section: SettingsSection) => string - onOpenSettings: (section: SettingsSection) => void + accountSettingsHref: string + onOpenAccountSettings: () => void + navigationLinks: readonly SidebarNavigationLink[] onOpenDocs: () => void onJoinSlack: () => void onContactSupport: () => void } /** - * Pinned bottom bar of the workspace sidebar: the viewer's avatar and name, which - * open a menu of their settings destinations, plus a help menu. + * Shared account and Help menus for the workspace and organization sidebars. * * Expanded, the two share one row — the profile claims the free width so the help * button lands hard right, mirroring the collapse control in the workspace header. @@ -138,31 +111,23 @@ interface SidebarFooterProps { * than remounting a trigger mid-animation. */ export function SidebarFooter({ - workspaceId, showDivider, isCollapsed, showCollapsedTooltips, - getSettingsHref, - onOpenSettings, + accountSettingsHref, + onOpenAccountSettings, + navigationLinks, onOpenDocs, onJoinSlack, onContactSupport, }: SidebarFooterProps) { const { data: profile } = useUserProfile() - const { data: session } = useSession() - const hostContext = useWorkspaceHostContext() - const { billingEnabled } = useDeploymentShape() - const { isInvitationsDisabled } = useWorkspaceInvitePolicy(workspaceId) + const router = useRouter() const updateState = useDesktopUpdateState() const name = profile ? profile.name?.trim() || profile.email : '' const updateAvailable = hasAvailableDesktopUpdate(updateState) - const organizationHref = - hostContext.hostOrganizationId && - hostContext.viewer.isHostOrganizationMember && - hostContext.features?.organizationSearch - ? organizationRoutes(hostContext.hostOrganizationId).root - : null + const canSignOut = Boolean(profile && profile.id !== ANONYMOUS_USER_ID) const handleUpdateSelect = () => { const updates = getDesktopUpdates() @@ -173,34 +138,6 @@ export function SidebarFooter({ } } - /** - * Subscription is dropped for viewers the Billing page would turn away — a - * deployment with billing off, or anyone who is not the payer (on an - * organization-hosted workspace, every member who is not an org admin). The - * settings sidebar hides its own Billing entry on exactly this test. - */ - const menuItems = PROFILE_MENU_ITEMS.filter( - (item) => - (item.section !== 'organization' || Boolean(organizationHref)) && - (item.section !== 'billing' || - canViewWorkspaceBillingSettings(hostContext, session?.user?.id)) - ) - - /** - * Teammates is a dead end on a plan that cannot invite, so a blocked viewer is - * sent to the plan itself instead — which resolves to the upgrade page for - * anyone who cannot manage the payer. With billing off there is nowhere to send - * them and no upgrade to make, so the row simply does nothing. This is the gate - * the workspace switcher's "Manage workspace" entry carried before this menu - * took the section over. - */ - const resolveMenuDestination = (section: SettingsSection): SettingsSection | null => { - if (section === 'teammates' && isInvitationsDisabled) { - return billingEnabled ? 'billing' : null - } - return section - } - /** * Built from plain `img`/`div` rather than the emcn `Avatar`, whose Radix root * renders a `` — and globals fade every `span` in the collapsed rail to @@ -276,43 +213,40 @@ export function SidebarFooter({ - {menuItems.map(({ section, label, icon: Icon }) => { - if (section === 'organization') { - if (!organizationHref) return null - return ( - - - - - - - ) - } - const destination = resolveMenuDestination(section) - if (!destination) { - return ( - - - {label} - - ) - } - - return ( - - { - event.preventDefault() - onOpenSettings(destination) - }} - > - - - - - ) - })} + {[ + { + label: 'Settings', + icon: Settings, + href: accountSettingsHref, + onNavigate: onOpenAccountSettings, + }, + ...navigationLinks, + ].map(({ label, icon: Icon, href, onNavigate }) => ( + + { + event.preventDefault() + useSettingsDirtyStore.getState().requestLeave(onNavigate) + }} + > + + + + + ))} + {canSignOut && ( + { + useSettingsDirtyStore.getState().requestLeave(() => { + void signOutAndRedirect(router.push) + }) + }} + > + + Sign out + + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx index c0d1e12e5c2..0983e191dca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx @@ -1,6 +1,6 @@ 'use client' -import { Chip } from '@sim/emcn' +import { DropdownMenuItem } from '@sim/emcn' import { Mail } from '@sim/emcn/icons' import { useMyPendingInvitations } from '@/hooks/queries/invitations' @@ -22,8 +22,9 @@ export function ViewInvitationsMenuItem({ onOpen }: ViewInvitationsMenuItemProps } return ( - + + View invitations - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx index bcef7009e6a..2ba35bebcef 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx @@ -3,21 +3,37 @@ */ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { renderToString } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockNavigateToSettings, mockWorkspacePermissions } = vi.hoisted(() => ({ +const { mockNavigateToSettings, mockWorkspacePermissions, hostContext } = vi.hoisted(() => ({ mockNavigateToSettings: vi.fn(), + hostContext: { + hostOrganizationId: null as string | null, + viewer: { isHostOrganizationMember: false }, + features: { organizationSearch: false as boolean | undefined }, + }, mockWorkspacePermissions: { canAdmin: true, canEdit: true, canRead: true }, })) +vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ + useWorkspaceHostContext: () => hostContext, +})) + const onWorkspaceSwitch = vi.fn() vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }), })) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/workspace/ws-emir/home', +})) vi.mock('@/lib/auth/auth-client', () => ({ useActiveOrganization: () => ({ data: null }) })) vi.mock('@/hooks/use-settings-navigation', () => ({ - useSettingsNavigation: () => ({ navigateToSettings: mockNavigateToSettings }), + useSettingsNavigation: () => ({ + navigateToSettings: mockNavigateToSettings, + }), })) vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: () => ({ isInvitationsDisabled: false }), @@ -89,31 +105,35 @@ function render(overrides: Partial[0]> = {}) document.body.appendChild(container) root = createRoot(container) act(() => { - root.render( - {}} - isWorkspacesLoading={false} - isCreatingWorkspace={false} - isWorkspaceMenuOpen - setIsWorkspaceMenuOpen={() => {}} - onWorkspaceSwitch={onWorkspaceSwitch} - onCreateWorkspace={async () => {}} - onRenameWorkspace={async () => {}} - onDeleteWorkspace={async () => {}} - isDeletingWorkspace={false} - onUploadLogo={() => {}} - onLeaveWorkspace={async () => {}} - isLeavingWorkspace={false} - {...overrides} - /> - ) + root.render(header(overrides)) }) } +function header(overrides: Partial[0]> = {}) { + return ( + {}} + isWorkspacesLoading={false} + isCreatingWorkspace={false} + isWorkspaceMenuOpen + setIsWorkspaceMenuOpen={() => {}} + onWorkspaceSwitch={onWorkspaceSwitch} + onCreateWorkspace={async () => {}} + onRenameWorkspace={async () => {}} + onDeleteWorkspace={async () => {}} + isDeletingWorkspace={false} + onUploadLogo={() => {}} + onLeaveWorkspace={async () => {}} + isLeavingWorkspace={false} + {...overrides} + /> + ) +} + function row(name: string): HTMLElement { const found = [...document.querySelectorAll('[data-workspace-row-idx]')].find((el) => el.textContent?.includes(name) @@ -150,6 +170,9 @@ function typeInto(input: HTMLInputElement, value: string) { beforeEach(() => { vi.clearAllMocks() + hostContext.hostOrganizationId = null + hostContext.viewer.isHostOrganizationMember = false + hostContext.features.organizationSearch = false Object.assign(mockWorkspacePermissions, { canAdmin: true, canEdit: true, canRead: true }) // jsdom implements neither; the component scrolls the active row into view. Element.prototype.scrollIntoView = vi.fn() @@ -161,6 +184,80 @@ afterEach(() => { }) describe('WorkspaceHeader workspace switcher highlight', () => { + it.each([false, true])( + 'renders prefetched workspace identity before hydration (collapsed: %s)', + (isCollapsed) => { + render({ isWorkspaceMenuOpen: false, isCollapsed }) + const html = renderToString(header({ isWorkspaceMenuOpen: false, isCollapsed })) + expect(html).toContain(isCollapsed ? 'Expand sidebar' : 'Switch workspace') + expect(html).not.toContain('animate-pulse') + } + ) + + it.each([5, 6])('only shows search once there are six workspaces: %i', (count) => { + render({ workspaces: WORKSPACES.slice(0, count) }) + expect(Boolean(document.querySelector('input[placeholder="Search workspaces..."]'))).toBe( + count === 6 + ) + }) + + it('handles a no-match query without navigating and restores rows when cleared', () => { + render() + const search = document.querySelector( + 'input[placeholder="Search workspaces..."]' + )! + act(() => typeInto(search, 'no-such-workspace')) + expect(document.querySelectorAll('[data-workspace-row-idx]')).toHaveLength(0) + expect(document.body).toHaveTextContent('No results for "no-such-workspace"') + act(() => search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) + expect(onWorkspaceSwitch).not.toHaveBeenCalled() + + act(() => typeInto(search, ' ACME ')) + expect(document.querySelectorAll('[data-workspace-row-idx]')).toHaveLength(1) + expect(isMarked('Acme')).toBe(true) + act(() => typeInto(search, '')) + expect(document.querySelectorAll('[data-workspace-row-idx]')).toHaveLength(WORKSPACES.length) + }) + + it('keeps search focused when the pointer crosses a workspace row', () => { + render() + const search = document.querySelector( + 'input[placeholder="Search workspaces..."]' + )! + act(() => search.focus()) + const item = row('RVT').querySelector('[role="menuitem"]')! + const pointerMove = new MouseEvent('pointermove', { bubbles: true, cancelable: true }) + Object.defineProperty(pointerMove, 'pointerType', { value: 'mouse' }) + act(() => item.dispatchEvent(pointerMove)) + expect(document.activeElement).toBe(search) + const pointerOut = new MouseEvent('pointerout', { bubbles: true, cancelable: true }) + Object.defineProperty(pointerOut, 'pointerType', { value: 'mouse' }) + act(() => item.dispatchEvent(pointerOut)) + expect(document.activeElement).toBe(search) + }) + + it('keeps pinned status available to assistive technology', () => { + render({ pinnedWorkspaceIds: new Set(['ws-rvt']) }) + expect(row('RVT').querySelector('[aria-label="Pinned"]')).toHaveAttribute( + 'aria-hidden', + 'false' + ) + }) + + it('does not open creation when workspace policy disallows it', () => { + const setIsWorkspaceMenuOpen = vi.fn() + render({ + workspaceCreationPolicy: { canCreate: false, reason: 'Organization limit reached' }, + setIsWorkspaceMenuOpen, + }) + const create = [...document.querySelectorAll('[role="menuitem"]')].find( + (item) => item.textContent?.trim() === 'New workspace' + )! + expect(create).toHaveAttribute('aria-disabled', 'true') + act(() => create.click()) + expect(setIsWorkspaceMenuOpen).not.toHaveBeenCalled() + }) + it.each([ { role: 'viewer', canAdmin: false, canEdit: false }, { role: 'editor', canAdmin: false, canEdit: true }, @@ -170,14 +267,33 @@ describe('WorkspaceHeader workspace switcher highlight', () => { ({ canAdmin, canEdit }) => { Object.assign(mockWorkspacePermissions, { canAdmin, canEdit }) render() - const invite = [...document.querySelectorAll('button')].find( - (button) => button.textContent?.trim() === 'Invite teammates' + const invite = [...document.querySelectorAll('[role="menuitem"]')].find( + (item) => item.textContent?.trim() === 'Invite teammates' ) expect(Boolean(invite)).toBe(canAdmin) expect(container.querySelector('button[aria-label="Switch workspace"]')).not.toBeDisabled() } ) + it('selects a workspace through the menu keyboard interaction', () => { + render() + const item = row('RVT').querySelector('[role="menuitem"]')! + act(() => item.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) + expect(onWorkspaceSwitch).toHaveBeenCalledOnce() + expect(onWorkspaceSwitch).toHaveBeenCalledWith(WORKSPACES[0]) + }) + + it('opens workspace options without switching workspaces', () => { + render() + const options = row('RVT').querySelector('[aria-label="Workspace options"]')! + act(() => { + options.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + options.click() + }) + expect(onWorkspaceSwitch).not.toHaveBeenCalled() + expect(isMarked('RVT')).toBe(true) + }) + it('shows the route workspace identity while the switcher list is unavailable', () => { render({ activeWorkspace: { name: 'Brightwave' }, @@ -286,3 +402,33 @@ describe('WorkspaceHeader workspace switcher highlight', () => { expect(isMarked("Emir's Workspace")).toBe(true) }) }) + +describe('WorkspaceHeader context navigation', () => { + it('links to the current host organization for enrolled members', () => { + hostContext.hostOrganizationId = 'host-org' + hostContext.viewer.isHostOrganizationMember = true + hostContext.features.organizationSearch = true + render() + expect(document.querySelector('a[href="/o/host-org"]')).toHaveTextContent( + 'Back to organization' + ) + }) + + it.each([ + { org: null, member: true, enabled: true }, + { org: 'host-org', member: false, enabled: true }, + { org: 'host-org', member: true, enabled: false }, + { org: 'host-org', member: true, enabled: undefined }, + ])('hides inaccessible organization navigation: %j', ({ org, member, enabled }) => { + hostContext.hostOrganizationId = org + hostContext.viewer.isHostOrganizationMember = member + hostContext.features.organizationSearch = enabled + render() + expect(document.querySelector('a[href^="/o/"]')).toBeNull() + }) + + it('keeps settings in the profile menu instead of duplicating it in the switcher', () => { + render() + expect(document.querySelector('a[href="/workspace/ws-emir/settings/teammates"]')).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index c4bf57a223a..e70913f0ba5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -8,15 +8,15 @@ import { ChipInput, chipContentLabelClass, chipGeometryClass, - chipVariants, cn, DropdownMenu, DropdownMenuContent, + DropdownMenuItem, + DropdownMenuItemAction, + DropdownMenuItemLabel, DropdownMenuSeparator, DropdownMenuTrigger, OverflowText, - Plus, - Send, Skeleton, scrollFadeAttributes, scrollFadeClass, @@ -24,16 +24,19 @@ import { toast, useScrollEdges, } from '@sim/emcn' -import { MoreHorizontal, PanelLeft, Pin, Search } from '@sim/emcn/icons' +import { ArrowLeft, MoreHorizontal, PanelLeft, Pin, Plus, Search, Send } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link' import { WorkspaceContextMenu } from '@/components/workspaces/workspace-context-menu' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { WORKSPACE_SEARCH_THRESHOLD } from '@/lib/workspaces/constants' import { getWorkspaceInitial } from '@/lib/workspaces/initials' +import { getWorkspaceOrganizationHref } from '@/lib/workspaces/organization-navigation' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' +import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' @@ -65,7 +68,9 @@ function DisabledReasonTooltip({ reason, children }: DisabledReasonTooltipProps) if (!reason) return children return ( - {children} + +
{children}
+

{reason}

@@ -177,6 +182,9 @@ function WorkspaceHeaderImpl({ const hasInputFocusedRef = useRef(false) const renameInputRef = useRef(null) const searchInputRef = useRef(null) + const preserveSearchFocus = (event: React.PointerEvent) => { + if (searchInputRef.current === document.activeElement) event.preventDefault() + } const workspaceListRef = useRef(null) /** * Held in state as well as the ref: the list lives in the menu's portal, which @@ -263,13 +271,10 @@ function WorkspaceHeaderImpl({ setIsKeyboardNav(false) }, [isWorkspaceMenuOpen]) - const [isMounted, setIsMounted] = useState(false) - useEffect(() => { - setIsMounted(true) - }, []) - const { navigateToSettings } = useSettingsNavigation() const queryClient = useQueryClient() + const hostContext = useWorkspaceHostContext() + const organizationHref = getWorkspaceOrganizationHref(hostContext) const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null const isWorkspaceReady = !isWorkspacesLoading && activeWorkspaceFull !== null @@ -443,7 +448,7 @@ function WorkspaceHeaderImpl({ return (
- {isMounted && isCollapsed ? ( + {isCollapsed ? ( } /> - ) : isMounted && isWorkspaceReady ? ( + ) : isWorkspaceReady ? ( { @@ -525,6 +530,17 @@ function WorkspaceHeaderImpl({ screen with nothing able to scroll to them. */ className='flex max-h-[var(--radix-dropdown-menu-content-available-height,400px)] w-64 max-w-[calc(100vw-24px)] flex-col overflow-y-auto' > + {organizationHref && ( + <> + + + + Back to organization + + + + + )} {isWorkspacesLoading ? (
Loading workspaces... @@ -534,6 +550,7 @@ function WorkspaceHeaderImpl({ {showSearch && ( {editingWorkspaceId === workspace.id ? ( ) : ( -
event.preventDefault()} + onPointerMove={preserveSearchFocus} + onPointerLeave={preserveSearchFocus} + actionIndicator={ + pinnedWorkspaceIds.has(workspace.id) ? ( + + ) : undefined + } + action={ + { + isContextMenuOpeningRef.current = true + }} + onClick={(event) => { + const rect = event.currentTarget.getBoundingClientRect() + openContextMenuAt(workspace, rect.right, rect.top) + }} + > + + + } onClick={(e) => { if (e.metaKey || e.ctrlKey) { window.open(`/workspace/${workspace.id}`, '_blank') @@ -693,81 +733,39 @@ function WorkspaceHeaderImpl({ logoUrl={workspace.logoUrl} alt={workspace.name || 'Workspace logo'} /> - - {/* Pin and options share one fixed slot, as the chat rows do: - the trailing width never changes, so pinning cannot re-truncate - the name under the user's cursor. */} -
- {pinnedWorkspaceIds.has(workspace.id) && ( - - )} - -
-
+ + )}
) })}
- + -
+
- { - e.stopPropagation() - if (!canCreateWorkspace) return + { + if (!canCreateWorkspace) { + event.preventDefault() + return + } setIsWorkspaceMenuOpen(false) setIsCreateModalOpen(true) }} - disabled={isCreatingWorkspace} - aria-disabled={!canCreateWorkspace || undefined} - fullWidth - className={cn( - 'select-none', - !canCreateWorkspace && - 'cursor-not-allowed opacity-60 hover-hover:bg-transparent' - )} + disabled={isCreatingWorkspace || !canCreateWorkspace} > + New workspace - + {userPermissions.canAdmin && ( - { + { setIsWorkspaceMenuOpen(false) if (isInvitationsDisabled) { if (billingEnabled) navigateToSettings({ section: 'billing' }) @@ -775,11 +773,10 @@ function WorkspaceHeaderImpl({ } setIsInviteModalOpen(true) }} - fullWidth - className='select-none' > + Invite teammates - + )} + id === 'teammates' || + id === 'recently-deleted' || + (id === 'billing' && canViewWorkspaceBillingSettings(hostContext, profile?.id)) + ) + .map(({ id, label, icon }) => ({ + label, + icon, + href: getSettingsHref({ section: id }), + onNavigate: () => handleOpenSettings(id), + })) + + const organizationHref = getWorkspaceOrganizationHref(hostContext) + if (organizationHref) { + profileNavigationLinks.push({ + label: 'Organization', + icon: Building, + href: organizationHref, + onNavigate: () => router.push(organizationHref), + }) + } + const { data: fetchedChats = EMPTY_CHATS, isLoading: chatsLoading } = useMothershipChats( workspaceId, { enabled: chatEnabled } @@ -1764,12 +1797,12 @@ export const Sidebar = memo(function Sidebar() { ) : null} getSettingsHref({ section })} - onOpenSettings={handleOpenSettings} + accountSettingsHref={getSettingsHref({ section: 'general' })} + onOpenAccountSettings={() => handleOpenSettings('general')} + navigationLinks={profileNavigationLinks} onOpenDocs={handleOpenDocs} onJoinSlack={handleOpenSlackCommunity} onContactSupport={handleOpenHelpFromMenu} diff --git a/apps/sim/lib/auth/sign-out.test.ts b/apps/sim/lib/auth/sign-out.test.ts new file mode 100644 index 00000000000..e580005abe8 --- /dev/null +++ b/apps/sim/lib/auth/sign-out.test.ts @@ -0,0 +1,46 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ signOut: vi.fn(), clearUserData: vi.fn() })) +vi.mock('@/lib/auth/auth-client', () => ({ signOut: mocks.signOut })) +vi.mock('@/stores', () => ({ clearUserData: mocks.clearUserData })) + +import { signOutAndRedirect } from '@/lib/auth/sign-out' + +const navigate = vi.fn() +const assign = vi.fn() + +beforeEach(() => { + vi.clearAllMocks() + mocks.signOut.mockResolvedValue(undefined) + mocks.clearUserData.mockResolvedValue(true) + vi.stubGlobal('window', { location: { assign } }) +}) +afterEach(() => vi.unstubAllGlobals()) + +describe('signOutAndRedirect', () => { + it('ends the session and clears user state before navigating in-app', async () => { + await signOutAndRedirect(navigate) + expect(mocks.signOut).toHaveBeenCalledOnce() + expect(mocks.clearUserData).toHaveBeenCalledOnce() + expect(navigate).toHaveBeenCalledWith('/login?fromLogout=true') + expect(assign).not.toHaveBeenCalled() + }) + + it('reloads when in-memory state could not be cleared', async () => { + mocks.clearUserData.mockResolvedValue(false) + await signOutAndRedirect(navigate) + expect(navigate).not.toHaveBeenCalled() + expect(assign).toHaveBeenCalledWith('/login?fromLogout=true') + }) + + it('reloads on sign-out failure and still attempts to clear user state', async () => { + mocks.signOut.mockRejectedValue(new Error('offline')) + await signOutAndRedirect(navigate) + expect(mocks.clearUserData).toHaveBeenCalledOnce() + expect(navigate).not.toHaveBeenCalled() + expect(assign).toHaveBeenCalledWith('/login?fromLogout=true') + }) +}) diff --git a/apps/sim/lib/auth/sign-out.ts b/apps/sim/lib/auth/sign-out.ts new file mode 100644 index 00000000000..4f0e4927446 --- /dev/null +++ b/apps/sim/lib/auth/sign-out.ts @@ -0,0 +1,21 @@ +import { createLogger } from '@sim/logger' +import { signOut } from '@/lib/auth/auth-client' +import { clearUserData } from '@/stores' + +const logger = createLogger('SignOut') + +/** Reloads the page if any in-memory user state could not be cleared. */ +export async function signOutAndRedirect(navigate: (href: string) => void): Promise { + const logoutUrl = '/login?fromLogout=true' + let canNavigateInApp = false + + try { + const [, inMemoryResetSucceeded] = await Promise.all([signOut(), clearUserData()]) + canNavigateInApp = inMemoryResetSucceeded + } catch (error) { + logger.error('Error signing out:', { error }) + } + + if (canNavigateInApp) navigate(logoutUrl) + else window.location.assign(logoutUrl) +} diff --git a/apps/sim/lib/workspaces/organization-navigation.ts b/apps/sim/lib/workspaces/organization-navigation.ts new file mode 100644 index 00000000000..758bf66f2d1 --- /dev/null +++ b/apps/sim/lib/workspaces/organization-navigation.ts @@ -0,0 +1,11 @@ +import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import { organizationRoutes } from '@/lib/navigation/paths' + +/** Returns the workspace's organization destination only when the viewer can enter it. */ +export function getWorkspaceOrganizationHref(hostContext: WorkspaceHostContext): string | null { + return hostContext.hostOrganizationId && + hostContext.viewer.isHostOrganizationMember && + hostContext.features?.organizationSearch + ? organizationRoutes(hostContext.hostOrganizationId).root + : null +} diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx index 87d6295f58d..8417f9e6208 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx @@ -22,9 +22,10 @@ import * as React from 'react' import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { cva, type VariantProps } from 'class-variance-authority' import { Check, ChevronRight, Circle, Search } from '../../icons' import { cn } from '../../lib/cn' -import { chipContentGap, chipFieldSurfaceClass } from '../chip/chip-chrome' +import { chipContentGap, chipFieldSurfaceClass, chipGeometryClass } from '../chip/chip-chrome' import { InsideModalContext } from '../modal/modal' import { OverflowText, type OverflowTextProps } from '../overflow-text/overflow-text' @@ -300,64 +301,87 @@ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName */ export const dropdownMenuRowClass = `relative flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-pointer select-none items-center ${chipContentGap} ${MENU_ROW_RADIUS_CLASS} px-2 text-[var(--text-body)] text-small outline-hidden ${MENU_ROW_TRANSITION_CLASS} data-[disabled]:pointer-events-none data-[disabled]:opacity-50 ${MENU_ROW_SINGLE_LINE_CLASS} [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]` +/** Large rows match the sidebar's chip geometry without changing menu behavior. */ +export const dropdownMenuItemVariants = cva(dropdownMenuRowClass, { + variants: { + size: { default: '', lg: chipGeometryClass }, + }, + defaultVariants: { size: 'default' }, +}) + const DropdownMenuItem = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - /** - * Renders the row as selected — the current route, the checked value, the row - * whose own menu is open. Selected is a state the row *holds*, so it keeps - * `--surface-active` through hover rather than dimming to the hover fill. - * - * Not for a pointer/keyboard cursor: that is the row highlight, which the row - * already paints on its own. A menu that marks its cursor row `active` puts two - * selections on screen. - */ - active?: boolean - /** - * Optional inline action rendered on the right edge of the item — e.g. a - * "more" icon button. Reveals on hover/focus of the row, and the row stays - * highlighted while the cursor is over the action. - */ - action?: React.ReactNode - } ->(({ className, inset, active, action, asChild, children, ...props }, ref) => { - const content = asChild ? children : withOverflowLabel(children) - const stateClasses = active ? MENU_ROW_SELECTED_CLASS : MENU_ROW_HIGHLIGHT_CLASS - if (action) { - return ( -
- - {content} - -
- {action} + React.ComponentPropsWithoutRef & + VariantProps & { + inset?: boolean + /** + * Renders the row as selected — the current route, the checked value, the row + * whose own menu is open. Selected is a state the row *holds*, so it keeps + * `--surface-active` through hover rather than dimming to the hover fill. + * + * Not for a pointer/keyboard cursor: that is the row highlight, which the row + * already paints on its own. A menu that marks its cursor row `active` puts two + * selections on screen. + */ + active?: boolean + /** + * Optional inline action rendered on the right edge of the item — e.g. a + * "more" icon button. Reveals on hover/focus of the row, and the row stays + * highlighted while the cursor is over the action. + */ + action?: React.ReactNode + /** Idle indicator sharing the action slot so the label never shifts. */ + actionIndicator?: React.ReactNode + } +>( + ( + { className, size, inset, active, action, actionIndicator, asChild, children, ...props }, + ref + ) => { + const content = asChild ? children : withOverflowLabel(children) + const stateClasses = active ? MENU_ROW_SELECTED_CLASS : MENU_ROW_HIGHLIGHT_CLASS + if (action) { + return ( +
+ + {content} + +
+ {actionIndicator && ( +
+ {actionIndicator} +
+ )} +
+ {action} +
+
-
+ ) + } + return ( + + {content} + ) } - return ( - - {content} - - ) -}) +) DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName /**