From dbf2dec46318553536c8162fd30e0a1b52e65386 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 15 Dec 2025 11:55:14 -0500 Subject: [PATCH 1/8] refactor(browserbase): update evaluation status and reason handling (#1923) --- .../src/browserbase/browserbase.service.ts | 85 +++---------------- 1 file changed, 10 insertions(+), 75 deletions(-) diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index 1377ac533..b38f61400 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -548,15 +548,15 @@ export class BrowserbaseService { completedAt: new Date(), durationMs: run.startedAt ? Date.now() - run.startedAt.getTime() : 0, screenshotUrl: screenshotKey, - evaluationStatus: 'pass', - evaluationReason: result.evaluationReason ?? 'Requirement verified', + evaluationStatus: result.evaluationStatus ?? null, + evaluationReason: result.evaluationReason ?? 'Screenshot captured', }, }); return { success: true, screenshotUrl: presignedUrl, - evaluationStatus: 'pass', + evaluationStatus: result.evaluationStatus, evaluationReason: result.evaluationReason, }; } catch (err) { @@ -687,8 +687,8 @@ export class BrowserbaseService { completedAt: new Date(), durationMs: Date.now() - run.startedAt!.getTime(), screenshotUrl: screenshotKey, - evaluationStatus: 'pass', - evaluationReason: result.evaluationReason ?? 'Requirement verified', + evaluationStatus: result.evaluationStatus ?? null, + evaluationReason: result.evaluationReason ?? 'Screenshot captured', }, }); @@ -696,7 +696,7 @@ export class BrowserbaseService { runId: run.id, success: true, screenshotUrl: presignedUrl, - evaluationStatus: 'pass', + evaluationStatus: result.evaluationStatus, evaluationReason: result.evaluationReason, }; } finally { @@ -793,73 +793,7 @@ export class BrowserbaseService { // Wait for final page to settle await delay(2000); - // Evaluate if the automation fulfills the task requirements BEFORE taking screenshot - if (taskContext) { - // Re-acquire page in case the agent closed/replaced it during execution - page = await this.ensureActivePage(stagehand); - - const evaluationSchema = z.object({ - passes: z - .boolean() - .describe( - 'Whether the current page state shows that the requirement is fulfilled', - ), - reason: z - .string() - .describe( - 'A brief explanation of why it passes or fails the requirement', - ), - }); - - const evaluationPrompt = `You are evaluating whether a compliance requirement is being met. - -Task/Requirement: "${taskContext.title}" -${taskContext.description ? `Description: "${taskContext.description}"` : ''} - -Navigation completed: "${instruction}" - -Look at the current page and determine if the visible configuration, settings, or state demonstrates that this requirement is fulfilled. - -For example: -- If the task is about "branch protection", check if branch protection rules are visible and enabled -- If the task is about "MFA/2FA", check if multi-factor authentication is shown as enabled -- If the task is about "access controls", check if appropriate access restrictions are configured - -Be strict: if the setting is disabled, not configured, or shows a warning/error state, it should FAIL. -Only pass if there is clear evidence the requirement is properly configured and active.`; - - try { - const evaluation = (await stagehand.extract( - evaluationPrompt, - evaluationSchema as any, - )) as { passes: boolean; reason: string }; - - this.logger.log( - `Automation evaluation: ${evaluation.passes ? 'PASS' : 'FAIL'} - ${evaluation.reason}`, - ); - - // If evaluation fails, abort without taking screenshot - if (!evaluation.passes) { - return { - success: false, - evaluationStatus: 'fail', - evaluationReason: evaluation.reason, - error: `Requirement not met: ${evaluation.reason}`, - }; - } - } catch (evalErr) { - this.logger.warn( - `Failed to evaluate automation: ${evalErr instanceof Error ? evalErr.message : String(evalErr)}`, - ); - // If evaluation itself errors, fail the automation - return { - success: false, - error: `Evaluation error: ${evalErr instanceof Error ? evalErr.message : 'Unknown error'}`, - }; - } - } - - // Only take screenshot if evaluation passed (or no task context) + // Always take a screenshot at the end (no pass/fail criteria gate) page = await this.ensureActivePage(stagehand); const screenshot = await page.screenshot({ type: 'jpeg', @@ -870,8 +804,9 @@ Only pass if there is clear evidence the requirement is properly configured and return { success: true, screenshot: screenshot.toString('base64'), - evaluationStatus: 'pass', - evaluationReason: 'Requirement verified successfully', + evaluationReason: taskContext + ? `Navigation completed for "${taskContext.title}". Screenshot captured.` + : 'Navigation completed. Screenshot captured.', }; } catch (err) { this.logger.error('Failed to execute automation', err); From 44faa9e679cd9d540430fc97517bf74fb60516e2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 09:48:08 -0500 Subject: [PATCH 2/8] [dev] [Itsnotaka] daniel/ui (#1915) * feat(ui)(sidebar): remove old sidebar components and update dependencies * feat(ui): enhance organization switcher with dropdown menu and sorting options * revert(ui): sheet ui * fix(ui): type fix * fix: tooltip leak --------- Co-authored-by: Daniel Fu --- apps/app/src/actions/sidebar.ts | 24 - apps/app/src/app/(app)/[orgId]/layout.tsx | 23 +- apps/app/src/app/providers.tsx | 9 +- apps/app/src/components/animated-layout.tsx | 25 - .../{sidebar.tsx => app-sidebar.tsx} | 70 +- apps/app/src/components/header.tsx | 28 +- apps/app/src/components/main-menu.tsx | 200 +++-- apps/app/src/components/mobile-menu.tsx | 66 -- .../src/components/organization-switcher.tsx | 259 ++++--- .../components/sidebar-collapse-button.tsx | 29 +- apps/app/src/components/sidebar-logo.tsx | 15 +- apps/app/src/context/sidebar-context.tsx | 39 - bun.lock | 458 +++++++----- packages/ui/components.json | 4 +- packages/ui/package.json | 10 +- packages/ui/src/components/button.tsx | 5 +- packages/ui/src/components/dropdown-menu.tsx | 352 +++++---- packages/ui/src/components/sheet.tsx | 3 +- packages/ui/src/components/sidebar.tsx | 685 ++++++++++++++++++ packages/ui/src/globals.css | 46 ++ packages/ui/src/hooks/use-mobile.ts | 19 + 21 files changed, 1555 insertions(+), 814 deletions(-) delete mode 100644 apps/app/src/actions/sidebar.ts delete mode 100644 apps/app/src/components/animated-layout.tsx rename apps/app/src/components/{sidebar.tsx => app-sidebar.tsx} (60%) delete mode 100644 apps/app/src/components/mobile-menu.tsx delete mode 100644 apps/app/src/context/sidebar-context.tsx create mode 100644 packages/ui/src/components/sidebar.tsx create mode 100644 packages/ui/src/hooks/use-mobile.ts diff --git a/apps/app/src/actions/sidebar.ts b/apps/app/src/actions/sidebar.ts deleted file mode 100644 index 16d12e8e8..000000000 --- a/apps/app/src/actions/sidebar.ts +++ /dev/null @@ -1,24 +0,0 @@ -'use server'; - -import { addYears } from 'date-fns'; -import { createSafeActionClient } from 'next-safe-action'; -import { cookies } from 'next/headers'; -import { z } from 'zod'; - -const schema = z.object({ - isCollapsed: z.boolean(), -}); - -export const updateSidebarState = createSafeActionClient() - .inputSchema(schema) - .action(async ({ parsedInput }) => { - const cookieStore = await cookies(); - - cookieStore.set({ - name: 'sidebar-collapsed', - value: JSON.stringify(parsedInput.isCollapsed), - expires: addYears(new Date(), 1), - }); - - return { success: true }; - }); diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index 99d1b69ea..2271bd923 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -1,11 +1,10 @@ -import { AnimatedLayout } from '@/components/animated-layout'; +import { AppSidebar } from '@/components/app-sidebar'; import { CheckoutCompleteDialog } from '@/components/dialogs/checkout-complete-dialog'; import { Header } from '@/components/header'; import { AssistantSheet } from '@/components/sheets/assistant-sheet'; -import { Sidebar } from '@/components/sidebar'; import { TriggerTokenProvider } from '@/components/trigger-token-provider'; -import { SidebarProvider } from '@/context/sidebar-context'; import { auth } from '@/utils/auth'; +import { SidebarInset, SidebarProvider } from '@comp/ui/sidebar'; import { db, Role } from '@db'; import dynamic from 'next/dynamic'; import { cookies, headers } from 'next/headers'; @@ -15,7 +14,6 @@ import { ConditionalOnboardingTracker } from './components/ConditionalOnboarding import { ConditionalPaddingWrapper } from './components/ConditionalPaddingWrapper'; import { DynamicMinHeight } from './components/DynamicMinHeight'; -// Helper to safely parse comma-separated roles string function parseRolesString(rolesStr: string | null | undefined): Role[] { if (!rolesStr) return []; return rolesStr @@ -38,10 +36,9 @@ export default async function Layout({ const { orgId: requestedOrgId } = await params; const cookieStore = await cookies(); - const isCollapsed = cookieStore.get('sidebar-collapsed')?.value === 'true'; - let publicAccessToken = cookieStore.get('publicAccessToken')?.value || undefined; + const defaultOpen = cookieStore.get('sidebar_state')?.value !== 'false'; + const publicAccessToken = cookieStore.get('publicAccessToken')?.value || undefined; - // Check if user has access to this organization const session = await auth.api.getSession({ headers: await headers(), }); @@ -51,13 +48,11 @@ export default async function Layout({ return redirect('/auth'); } - // First check if the organization exists and load access flags const organization = await db.organization.findUnique({ where: { id: requestedOrgId }, }); if (!organization) { - // Organization doesn't exist return redirect('/auth/not-found'); } @@ -70,7 +65,6 @@ export default async function Layout({ }); if (!member) { - // User doesn't have access to this organization return redirect('/auth/unauthorized'); } @@ -82,12 +76,10 @@ export default async function Layout({ return redirect('/no-access'); } - // If this org is not accessible on current plan, redirect to upgrade if (!organization.hasAccess) { return redirect(`/upgrade/${organization.id}`); } - // If onboarding is required and user isn't already on onboarding, redirect if (!organization.onboardingCompleted) { return redirect(`/onboarding/${organization.id}`); } @@ -103,8 +95,9 @@ export default async function Layout({ triggerJobId={onboarding?.triggerJobId || undefined} initialToken={publicAccessToken || undefined} > - - } isCollapsed={isCollapsed}> + + + {onboarding?.triggerJobId && }
@@ -114,7 +107,7 @@ export default async function Layout({ - + diff --git a/apps/app/src/app/providers.tsx b/apps/app/src/app/providers.tsx index ef89fb701..a0b4a520f 100644 --- a/apps/app/src/app/providers.tsx +++ b/apps/app/src/app/providers.tsx @@ -4,6 +4,7 @@ import { JwtTokenManager } from '@/components/auth/jwt-token-manager'; import { env } from '@/env.mjs'; import { AnalyticsProvider } from '@comp/analytics'; import { Toaster } from '@comp/ui/sooner'; +import { TooltipProvider } from '@comp/ui/tooltip'; import { GoogleTagManager } from '@next/third-parties/google'; import { defaultShouldDehydrateQuery, @@ -86,9 +87,11 @@ export function Providers({ children, session }: ProviderProps) { userId={session?.user?.id ?? undefined} userEmail={session?.user?.email ?? undefined} > - - {children} - + + + {children} + + diff --git a/apps/app/src/components/animated-layout.tsx b/apps/app/src/components/animated-layout.tsx deleted file mode 100644 index a41fc493d..000000000 --- a/apps/app/src/components/animated-layout.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { cn } from '@comp/ui/cn'; - -interface AnimatedLayoutProps { - children: React.ReactNode; - sidebar: React.ReactNode; - isCollapsed: boolean; - blurred?: boolean; -} - -export function AnimatedLayout({ children, sidebar, isCollapsed, blurred }: AnimatedLayoutProps) { - return ( -
- -
{children}
-
- ); -} diff --git a/apps/app/src/components/sidebar.tsx b/apps/app/src/components/app-sidebar.tsx similarity index 60% rename from apps/app/src/components/sidebar.tsx rename to apps/app/src/components/app-sidebar.tsx index 878f16183..029ae5bee 100644 --- a/apps/app/src/components/sidebar.tsx +++ b/apps/app/src/components/app-sidebar.tsx @@ -4,15 +4,20 @@ import { getOrganizations } from '@/data/getOrganizations'; import { auth } from '@/utils/auth'; import { GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { cn } from '@comp/ui/cn'; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarRail, +} from '@comp/ui/sidebar'; import { db, type Organization, Role } from '@db'; -import { cookies, headers } from 'next/headers'; +import { headers } from 'next/headers'; import { MainMenu } from './main-menu'; import { OrganizationSwitcher } from './organization-switcher'; import { SidebarCollapseButton } from './sidebar-collapse-button'; import { SidebarLogo } from './sidebar-logo'; -// Helper to safely parse comma-separated roles string function parseRolesString(rolesStr: string | null | undefined): Role[] { if (!rolesStr) return []; return rolesStr @@ -21,18 +26,9 @@ function parseRolesString(rolesStr: string | null | undefined): Role[] { .filter((r) => r in Role) as Role[]; } -export async function Sidebar({ - organization, - collapsed = false, -}: { - organization: Organization | null; - collapsed?: boolean; -}) { - const cookieStore = await cookies(); - const isCollapsed = collapsed || cookieStore.get('sidebar-collapsed')?.value === 'true'; +export async function AppSidebar({ organization }: { organization?: Organization | null }) { const { organizations } = await getOrganizations(); - // Generate logo URLs for all organizations const logoUrls: Record = {}; if (s3Client && APP_AWS_ORG_ASSETS_BUCKET) { await Promise.all( @@ -52,7 +48,6 @@ export async function Sidebar({ ); } - // Check feature flags for menu items const session = await auth.api.getSession({ headers: await headers(), }); @@ -78,41 +73,38 @@ export async function Sidebar({ if (member?.role) { const roles = parseRolesString(member.role); hasAuditorRole = roles.includes(Role.auditor); - // Only hide tabs if auditor is their ONLY role - // If they have multiple roles (e.g., "owner, auditor" or "admin, auditor"), show tabs isOnlyAuditor = hasAuditorRole && roles.length === 1; } } return ( -
-
-
- -
-
+ + + +
-
-
-
+ + + + + -
- -
-
+ + + + + ); } diff --git a/apps/app/src/components/header.tsx b/apps/app/src/components/header.tsx index 0c12a4185..f3a4387ed 100644 --- a/apps/app/src/components/header.tsx +++ b/apps/app/src/components/header.tsx @@ -1,12 +1,8 @@ -import { getFeatureFlags } from '@/app/posthog'; import { UserMenu } from '@/components/user-menu'; -import { getOrganizations } from '@/data/getOrganizations'; -import { auth } from '@/utils/auth'; import { Skeleton } from '@comp/ui/skeleton'; -import { headers } from 'next/headers'; +import { SidebarTrigger } from '@comp/ui/sidebar'; import { Suspense } from 'react'; import { AssistantButton } from './ai/chat-button'; -import { MobileMenu } from './mobile-menu'; import { NotificationBell } from './notifications/notification-bell'; export async function Header({ @@ -16,29 +12,9 @@ export async function Header({ organizationId?: string; hideChat?: boolean; }) { - const { organizations } = await getOrganizations(); - - // Check feature flags for menu items - const session = await auth.api.getSession({ - headers: await headers(), - }); - let isQuestionnaireEnabled = false; - let isTrustNdaEnabled = false; - if (session?.user?.id) { - const flags = await getFeatureFlags(session.user.id); - isQuestionnaireEnabled = flags['ai-vendor-questionnaire'] === true; - isTrustNdaEnabled = - flags['is-trust-nda-enabled'] === true || flags['is-trust-nda-enabled'] === 'true'; - } - return (
- + {!hideChat && } diff --git a/apps/app/src/components/main-menu.tsx b/apps/app/src/components/main-menu.tsx index 32c3f37d6..4410e720f 100644 --- a/apps/app/src/components/main-menu.tsx +++ b/apps/app/src/components/main-menu.tsx @@ -1,10 +1,16 @@ 'use client'; import { Badge } from '@comp/ui/badge'; -import { Button } from '@comp/ui/button'; import { cn } from '@comp/ui/cn'; import { Icons } from '@comp/ui/icons'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; +import { + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '@comp/ui/sidebar'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@comp/ui/tooltip'; import { ClipboardCheck, FileTextIcon, @@ -22,13 +28,12 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; -// Define menu item types with icon component type MenuItem = { id: string; path: string; name: string; disabled: boolean; - icon: React.FC<{ size?: number }>; + icon: React.FC<{ size?: number; className?: string }>; protected: boolean; badge?: { text: string; @@ -42,15 +47,14 @@ interface ItemProps { item: MenuItem; isActive: boolean; disabled: boolean; - isCollapsed?: boolean; + isCollapsed: boolean; onItemClick?: () => void; - itemRef: (el: HTMLDivElement | null) => void; + itemRef: (el: HTMLLIElement | null) => void; } export type Props = { organizationId?: string; organization?: { advancedModeEnabled?: boolean } | null; - isCollapsed?: boolean; onItemClick?: () => void; isQuestionnaireEnabled?: boolean; isTrustNdaEnabled?: boolean; @@ -61,7 +65,6 @@ export type Props = { export function MainMenu({ organizationId, organization, - isCollapsed = false, onItemClick, isQuestionnaireEnabled = false, isTrustNdaEnabled = false, @@ -69,8 +72,10 @@ export function MainMenu({ isOnlyAuditor = false, }: Props) { const pathname = usePathname(); + const { state } = useSidebar(); + const isCollapsed = state === 'collapsed'; const [activeStyle, setActiveStyle] = useState({ top: '0px', height: '0px' }); - const itemRefs = useRef<(HTMLDivElement | null)[]>([]); + const itemRefs = useRef<(HTMLLIElement | null)[]>([]); const items: MenuItem[] = [ { @@ -185,19 +190,13 @@ export function MainMenu({ }, ]; - // Helper function to check if a path is active const isPathActive = (itemPath: string) => { const normalizedItemPath = itemPath.replace(':organizationId', organizationId ?? ''); - - // Extract the base path from the menu item (first two segments after normalization) const itemPathParts = normalizedItemPath.split('/').filter(Boolean); const itemBaseSegment = itemPathParts.length > 1 ? itemPathParts[1] : ''; - - // Extract the current path parts const currentPathParts = pathname.split('/').filter(Boolean); const currentBaseSegment = currentPathParts.length > 1 ? currentPathParts[1] : ''; - // Special case for root organization path if ( normalizedItemPath === `/${organizationId}` || normalizedItemPath === `/${organizationId}/implementation` @@ -214,7 +213,6 @@ export function MainMenu({ const visibleItems = items.filter((item) => !item.disabled && !item.hidden); const activeIndex = visibleItems.findIndex((item) => isPathActive(item.path)); - // Update active indicator position useEffect(() => { if (activeIndex >= 0) { const activeElement = itemRefs.current[activeIndex]; @@ -226,9 +224,8 @@ export function MainMenu({ }); } } - }, [activeIndex, pathname]); + }, [activeIndex, pathname, isCollapsed]); - // Handle window resize to recalculate positions useEffect(() => { const handleResize = () => { if (activeIndex >= 0) { @@ -250,113 +247,108 @@ export function MainMenu({ }, [activeIndex]); return ( - + + {visibleItems.map((item, index) => { + const isActive = isPathActive(item.path); + return ( + { + itemRefs.current[index] = el; + }} + /> + ); + })} + + ); } - -const Item = ({ +function Item({ organizationId, item, isActive, disabled, - isCollapsed = false, + isCollapsed, onItemClick, itemRef, -}: ItemProps) => { +}: ItemProps) { const Icon = item.icon; const linkDisabled = disabled || item.disabled; const itemPath = item.path.replace(':organizationId', organizationId ?? ''); if (linkDisabled) { return ( -
- - - - - - {isCollapsed && Coming Soon} - - -
- ); - } - - return ( -
- + - + + {!isCollapsed && Coming Soon} + - {isCollapsed && ( - -
- {item.name} - {item.badge && ( - - {item.badge.text} - - )} -
-
- )} + {isCollapsed && Coming Soon}
-
+ + ); + } + + const tooltipContent = ( +
+ {item.name} + {item.badge && ( + + {item.badge.text} + + )}
); -}; + + return ( + + + + + + + {!isCollapsed && ( + <> + {item.name} + {item.badge && ( + + {item.badge.text} + + )} + + )} + + + + {isCollapsed && ( + + {tooltipContent} + + )} + + + ); +} diff --git a/apps/app/src/components/mobile-menu.tsx b/apps/app/src/components/mobile-menu.tsx deleted file mode 100644 index 02eeacef3..000000000 --- a/apps/app/src/components/mobile-menu.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client'; - -import { Button } from '@comp/ui/button'; -import { Icons } from '@comp/ui/icons'; -import { Sheet, SheetContent } from '@comp/ui/sheet'; -import type { Organization } from '@db'; -import { useState } from 'react'; -import { MainMenu } from './main-menu'; -import { OrganizationSwitcher } from './organization-switcher'; - -interface MobileMenuProps { - organizations: Organization[]; - isCollapsed?: boolean; - organizationId?: string; - isQuestionnaireEnabled?: boolean; - isTrustNdaEnabled?: boolean; -} - -export function MobileMenu({ - organizationId, - organizations, - isQuestionnaireEnabled = false, - isTrustNdaEnabled = false, -}: MobileMenuProps) { - const [isOpen, setOpen] = useState(false); - - const handleCloseSheet = () => { - setOpen(false); - }; - - const currentOrganization = organizations.find((org) => org.id === organizationId) || null; - - return ( - -
- -
- -
- -
-
- - -
-
-
- ); -} diff --git a/apps/app/src/components/organization-switcher.tsx b/apps/app/src/components/organization-switcher.tsx index 2758cf613..53cb15f01 100644 --- a/apps/app/src/components/organization-switcher.tsx +++ b/apps/app/src/components/organization-switcher.tsx @@ -13,10 +13,12 @@ import { CommandSeparator, } from '@comp/ui/command'; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@comp/ui/dialog'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@comp/ui/dropdown-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; +import { useSidebar } from '@comp/ui/sidebar'; import type { Organization } from '@db'; import { Check, ChevronsUpDown, Loader2, Plus, Search } from 'lucide-react'; -import { useAction } from 'next-safe-action/hooks'; +import { useAction, type HookActionStatus } from 'next-safe-action/hooks'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { useQueryState } from 'nuqs'; @@ -25,7 +27,6 @@ import { useEffect, useState } from 'react'; interface OrganizationSwitcherProps { organizations: Organization[]; organization: Organization | null; - isCollapsed?: boolean; logoUrls?: Record; } @@ -63,7 +64,6 @@ function OrganizationAvatar({ }: OrganizationAvatarProps) { const sizeClass = size === 'sm' ? 'h-6 w-6' : 'h-8 w-8'; - // If logo URL exists, show the image if (logoUrl) { return (
@@ -72,7 +72,6 @@ function OrganizationAvatar({ ); } - // Fallback to initials const initials = name?.slice(0, 2).toUpperCase() || ''; let colorIndex = 0; @@ -98,14 +97,108 @@ function OrganizationAvatar({ ); } +function OrganizationSwitcherContent({ + sortedOrganizations, + currentOrganization, + logoUrls, + sortOrder, + setSortOrder, + status, + pendingOrgId, + handleOrgChange, + handleOpenChange, + router, + getDisplayName, +}: { + sortedOrganizations: Organization[]; + currentOrganization: Organization | null; + logoUrls: Record; + sortOrder: string; + setSortOrder: (value: string) => void; + status: HookActionStatus; + pendingOrgId: string | null; + handleOrgChange: (org: Organization) => void; + handleOpenChange: (open: boolean) => void; + router: ReturnType; + getDisplayName: (org: Organization) => string; +}) { + return ( + +
+ + +
+
+ +
+ + No results found + + {sortedOrganizations.map((org) => ( + { + if (org.id !== currentOrganization?.id) { + handleOrgChange(org); + } else { + handleOpenChange(false); + } + }} + disabled={status === 'executing'} + className="flex items-center gap-2" + > + {status === 'executing' && pendingOrgId === org.id ? ( + + ) : currentOrganization?.id === org.id ? ( + + ) : ( +
+ )} + + {getDisplayName(org)} + + ))} + + + + { + router.push('/setup?intent=create-additional'); + handleOpenChange(false); + }} + disabled={status === 'executing'} + className="flex items-center gap-2" + > + + Create Organization + + + + + ); +} + export function OrganizationSwitcher({ organizations, organization, - isCollapsed = false, logoUrls = {}, }: OrganizationSwitcherProps) { + const { state } = useSidebar(); + const isCollapsed = state === 'collapsed'; const router = useRouter(); - const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isOpen, setIsOpen] = useState(false); const [pendingOrgId, setPendingOrgId] = useState(null); const [sortOrder, setSortOrder] = useState('alphabetical'); @@ -144,7 +237,8 @@ export function OrganizationSwitcher({ if (orgId) { router.push(`/${orgId}/`); } - setIsDialogOpen(false); + setIsOpen(false); + setShowOrganizationSwitcher(null); setPendingOrgId(null); }, onExecute: (args) => { @@ -180,105 +274,68 @@ export function OrganizationSwitcher({ }; const handleOpenChange = (open: boolean) => { - setShowOrganizationSwitcher(open); - setIsDialogOpen(open); + setShowOrganizationSwitcher(open ? true : null); + setIsOpen(open); }; - return ( -
- - - - + const isOpenState = showOrganizationSwitcher ?? isOpen; + + const triggerButton = ( + + ); + + const contentProps = { + sortedOrganizations, + currentOrganization, + logoUrls, + sortOrder, + setSortOrder, + status, + pendingOrgId, + handleOrgChange, + handleOpenChange, + router, + getDisplayName, + }; + + if (isCollapsed) { + return ( + + {triggerButton} Select Organization - -
- - -
-
- -
- - No results found - - {sortedOrganizations.map((org) => ( - { - if (org.id !== currentOrganization?.id) { - handleOrgChange(org); - } else { - handleOpenChange(false); - } - }} - disabled={status === 'executing'} - className="flex items-center gap-2" - > - {status === 'executing' && pendingOrgId === org.id ? ( - - ) : currentOrganization?.id === org.id ? ( - - ) : ( -
- )} - - {getDisplayName(org)} - - ))} - - - - { - router.push('/setup?intent=create-additional'); - setIsDialogOpen(false); - }} - disabled={status === 'executing'} - className="flex items-center gap-2" - > - - Create Organization - - - - +
-
+ ); + } + + return ( + + {triggerButton} + + + + ); } diff --git a/apps/app/src/components/sidebar-collapse-button.tsx b/apps/app/src/components/sidebar-collapse-button.tsx index 00ab06bc6..4c0fea824 100644 --- a/apps/app/src/components/sidebar-collapse-button.tsx +++ b/apps/app/src/components/sidebar-collapse-button.tsx @@ -1,39 +1,20 @@ 'use client'; -import { updateSidebarState } from '@/actions/sidebar'; -import { useSidebar } from '@/context/sidebar-context'; import { Button } from '@comp/ui/button'; import { cn } from '@comp/ui/cn'; +import { useSidebar } from '@comp/ui/sidebar'; import { ArrowLeftFromLine } from 'lucide-react'; -import { useAction } from 'next-safe-action/hooks'; -interface SidebarCollapseButtonProps { - isCollapsed: boolean; -} - -export function SidebarCollapseButton({ isCollapsed }: SidebarCollapseButtonProps) { - const { setIsCollapsed } = useSidebar(); - - const { execute } = useAction(updateSidebarState, { - onError: () => { - // Revert the optimistic update if the server action fails - setIsCollapsed(isCollapsed); - }, - }); - - const handleToggle = () => { - // Update local state immediately for responsive UI - setIsCollapsed(!isCollapsed); - // Update server state (cookie) in the background - execute({ isCollapsed: !isCollapsed }); - }; +export function SidebarCollapseButton() { + const { state, toggleSidebar } = useSidebar(); + const isCollapsed = state === 'collapsed'; return ( + ); +} + +function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { + const { toggleSidebar } = useSidebar(); + + return ( + + + + )}
- - - - {'Remove Team Member'} - - {'Are you sure you want to remove'} {memberName}?{' '} - {'They will no longer have access to this organization.'} - - - - {'Cancel'} - - {'Remove'} - - - - + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/RemoveDeviceAlert.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/RemoveDeviceAlert.tsx new file mode 100644 index 000000000..b639432e1 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/RemoveDeviceAlert.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@comp/ui/alert-dialog'; + +interface RemoveDeviceAlertProps { + open: boolean; + onOpenChange: (open: boolean) => void; + memberName: string; + onRemove: () => void; + isRemoving: boolean; +} + +export function RemoveDeviceAlert({ + open, + onOpenChange, + memberName, + onRemove, + isRemoving, +}: RemoveDeviceAlertProps) { + return ( + + + + {'Remove Device'} + + {'Are you sure you want to remove the device for this user '} {memberName}?{' '} + {'This will disconnect the device from the organization.'} + + + + {'Cancel'} + + {'Remove'} + + + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/RemoveMemberAlert.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/RemoveMemberAlert.tsx new file mode 100644 index 000000000..58462ef1a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/RemoveMemberAlert.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@comp/ui/alert-dialog'; + +interface RemoveMemberAlertProps { + open: boolean; + onOpenChange: (open: boolean) => void; + memberName: string; + onRemove: () => void; + isRemoving: boolean; +} + +export function RemoveMemberAlert({ + open, + onOpenChange, + memberName, + onRemove, + isRemoving, +}: RemoveMemberAlertProps) { + return ( + + + + {'Remove Team Member'} + + {'Are you sure you want to remove'} {memberName}?{' '} + {'They will no longer have access to this organization.'} + + + + {'Cancel'} + + {'Remove'} + + + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx index 12c9173e4..99b4be700 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx @@ -38,6 +38,7 @@ export async function TeamMembers() { // Parse roles from comma-separated string and check if user has admin or owner role const currentUserRoles = currentUserMember?.role?.split(',').map((r) => r.trim()) ?? []; const canManageMembers = currentUserRoles.some((role) => ['owner', 'admin'].includes(role)); + const isCurrentUserOwner = currentUserRoles.includes('owner'); let members: MemberWithUser[] = []; let pendingInvitations: Invitation[] = []; @@ -85,6 +86,7 @@ export async function TeamMembers() { removeMemberAction={removeMember} revokeInvitationAction={revokeInvitation} canManageMembers={canManageMembers} + isCurrentUserOwner={isCurrentUserOwner} employeeSyncData={employeeSyncData} /> ); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index 590a34230..f92271230 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -26,6 +26,7 @@ import type { revokeInvitation } from '../actions/revokeInvitation'; import type { EmployeeSyncConnectionsData } from '../data/queries'; import { useEmployeeSync } from '../hooks/useEmployeeSync'; import { InviteMembersModal } from './InviteMembersModal'; +import { usePeopleActions } from '@/hooks/use-people-api'; // Define prop types using typeof for the actions still used interface TeamMembersClientProps { @@ -34,6 +35,7 @@ interface TeamMembersClientProps { removeMemberAction: typeof removeMember; revokeInvitationAction: typeof revokeInvitation; canManageMembers: boolean; + isCurrentUserOwner: boolean; employeeSyncData: EmployeeSyncConnectionsData; } @@ -55,6 +57,7 @@ export function TeamMembersClient({ removeMemberAction, revokeInvitationAction, canManageMembers, + isCurrentUserOwner, employeeSyncData, }: TeamMembersClientProps) { const router = useRouter(); @@ -62,6 +65,8 @@ export function TeamMembersClient({ const [roleFilter, setRoleFilter] = useQueryState('role', parseAsString.withDefault('all')); const [statusFilter, setStatusFilter] = useQueryState('status', parseAsString.withDefault('all')); + const { unlinkDevice } = usePeopleActions(); + // Add state for the modal const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); @@ -188,6 +193,12 @@ export function TeamMembersClient({ } }; + const handleRemoveDevice = async (memberId: string) => { + await unlinkDevice(memberId); + toast.success('Device unlinked successfully'); + router.refresh(); // Revalidate data to update UI + }; + // Update handleUpdateRole to use authClient and add toasts const handleUpdateRole = async (memberId: string, roles: Role[]) => { const rolesArray = Array.isArray(roles) ? roles : [roles]; @@ -389,8 +400,10 @@ export function TeamMembersClient({ key={member.displayId} member={member as MemberWithUser} onRemove={handleRemoveMember} + onRemoveDevice={handleRemoveDevice} onUpdateRole={handleUpdateRole} canEdit={canManageMembers} + isCurrentUserOwner={isCurrentUserOwner} /> ))}
diff --git a/apps/app/src/hooks/use-api.ts b/apps/app/src/hooks/use-api.ts index 8da929f8a..c6e894341 100644 --- a/apps/app/src/hooks/use-api.ts +++ b/apps/app/src/hooks/use-api.ts @@ -14,7 +14,7 @@ export function useApi() { const apiCall = useCallback( ( - method: 'get' | 'post' | 'put' | 'delete', + method: 'get' | 'post' | 'put' | 'patch' | 'delete', endpoint: string, bodyOrOrgId?: unknown, explicitOrgId?: string, @@ -30,7 +30,7 @@ export function useApi() { explicitOrgId || orgIdFromParams; } else { - // For POST/PUT: second param is body, third is organizationId + // For POST/PUT/PATCH: second param is body, third is organizationId body = bodyOrOrgId; organizationId = explicitOrgId || orgIdFromParams; } @@ -47,6 +47,8 @@ export function useApi() { return api.post(endpoint, body, organizationId); case 'put': return api.put(endpoint, body, organizationId); + case 'patch': + return api.patch(endpoint, body, organizationId); case 'delete': return api.delete(endpoint, organizationId); default: @@ -79,6 +81,12 @@ export function useApi() { [apiCall], ), + patch: useCallback( + (endpoint: string, body?: unknown, organizationId?: string) => + apiCall('patch', endpoint, body, organizationId), + [apiCall], + ), + delete: useCallback( (endpoint: string, organizationId?: string) => apiCall('delete', endpoint, organizationId), diff --git a/apps/app/src/hooks/use-people-api.ts b/apps/app/src/hooks/use-people-api.ts new file mode 100644 index 000000000..374d168e2 --- /dev/null +++ b/apps/app/src/hooks/use-people-api.ts @@ -0,0 +1,49 @@ +'use client'; + +import { useApi } from '@/hooks/use-api'; +import { useCallback } from 'react'; + +export interface PeopleResponseDto { + id: string; + organizationId: string; + userId: string; + role: string; + createdAt: string; // ISO string from API + department: string; + isActive: boolean; + fleetDmLabelId: number | null; + user: { + id: string; + name: string; + email: string; + emailVerified: boolean; + image: string | null; + createdAt: string; // ISO string from API + updatedAt: string; // ISO string from API + lastLogin: string | null; // ISO string from API + }; +} + +/** + * Hook for people/member actions + */ +export function usePeopleActions() { + const api = useApi(); + + const unlinkDevice = useCallback( + async (memberId: string) => { + const response = await api.patch( + `/v1/people/${memberId}/unlink-device`, + ); + if (response.error) { + throw new Error(response.error); + } + return response.data!; + }, + [api], + ); + + return { + unlinkDevice, + }; +} diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 4ef9e7c93..eda38adb0 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -1572,6 +1572,138 @@ ] } }, + "/v1/people/{id}/unlink-device": { + "patch": { + "description": "Resets the fleetDmLabelId for a member, effectively unlinking their device from FleetDM. This will disconnect the device from the organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).", + "operationId": "PeopleController_unlinkDevice_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "description": "Member ID", + "schema": { + "example": "mem_abc123def456", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Member updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PeopleResponseDto" + }, + "example": { + "id": "mem_abc123def456", + "organizationId": "org_abc123def456", + "userId": "usr_abc123def456", + "role": "member", + "createdAt": "2024-01-01T00:00:00Z", + "department": "it", + "isActive": true, + "fleetDmLabelId": 123, + "user": { + "id": "usr_abc123def456", + "name": "John Doe", + "email": "john.doe@company.com", + "emailVerified": true, + "image": "https://example.com/avatar.jpg", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-15T00:00:00Z", + "lastLogin": "2024-01-15T12:00:00Z" + } + } + } + } + }, + "400": { + "description": "Bad Request - Invalid update data or user conflict", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "User user@example.com is already a member of this organization" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid authentication or insufficient permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + }, + "404": { + "description": "Organization, member, or user not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Member with ID mem_abc123def456 not found in organization org_abc123def456" + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Internal server error" + } + } + } + } + } + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Unlink device from member", + "tags": [ + "People" + ] + } + }, "/v1/risks": { "get": { "description": "Returns all risks for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).", From d58341b6f84dbce8309f9059bc845a1846021fa6 Mon Sep 17 00:00:00 2001 From: Min Chun Fu <70210356+Itsnotaka@users.noreply.github.com> Date: Wed, 17 Dec 2025 04:29:22 +0900 Subject: [PATCH 5/8] feat: Added logout function to onboarding/setup (#1914) * chore(bun.lock): downgrade configVersion from 1 to 0 * chore(api): update tsconfig path mappings and output directory * feat(layout): add variant prop to MinimalHeader for onboarding and setup --------- Co-authored-by: Mariano Fuentes --- apps/api/tsconfig.json | 12 +- apps/app/e2e/tests/onboarding-signout.spec.ts | 142 ++++++++++++++++++ .../app/(app)/onboarding/[orgId]/layout.tsx | 1 + .../src/app/(app)/setup/[setupId]/page.tsx | 2 +- .../src/components/layout/MinimalHeader.tsx | 6 +- .../components/layout/OnboardingUserMenu.tsx | 72 +++++++++ bun.lock | 2 +- 7 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 apps/app/e2e/tests/onboarding-signout.spec.ts create mode 100644 apps/app/src/components/layout/OnboardingUserMenu.tsx diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index aab7de9c4..916c91daa 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -12,8 +12,8 @@ "allowSyntheticDefaultImports": true, "target": "esnext", "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", + "outDir": "dist", + "baseUrl": ".", "incremental": true, "skipLibCheck": true, "strictNullChecks": true, @@ -23,13 +23,7 @@ "noFallthroughCasesInSwitch": false, "paths": { "@/*": ["./src/*"], - "@db": ["./prisma/index"], - "@comp/integration-platform": [ - "../../packages/integration-platform/src/index.ts" - ], - "@comp/integration-platform/*": [ - "../../packages/integration-platform/src/*" - ] + "@db": ["./prisma/index"] }, "jsx": "react-jsx" } diff --git a/apps/app/e2e/tests/onboarding-signout.spec.ts b/apps/app/e2e/tests/onboarding-signout.spec.ts new file mode 100644 index 000000000..aff016042 --- /dev/null +++ b/apps/app/e2e/tests/onboarding-signout.spec.ts @@ -0,0 +1,142 @@ +import { expect, test } from '@playwright/test'; +import { authenticateTestUser, clearAuth, grantAccess } from '../utils/auth-helpers'; +import { generateTestData } from '../utils/helpers'; + +test.describe('Onboarding Sign Out Flow', () => { + test.setTimeout(60000); + + test.beforeEach(async ({ page }) => { + await clearAuth(page); + }); + + test('user can sign out from onboarding to switch email', async ({ page }) => { + const testData = generateTestData(); + const website = `example${Date.now()}.com`; + + await authenticateTestUser(page, { + email: testData.email, + name: testData.userName, + skipOrg: true, + hasAccess: true, + }); + + await page.goto('/setup'); + await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/); + + await page.waitForSelector('input[type="checkbox"]:checked'); + await page.waitForFunction( + () => { + const button = document.querySelector( + '[data-testid="setup-next-button"]', + ) as HTMLButtonElement; + return button && !button.disabled && button.offsetParent !== null; + }, + { timeout: 10000 }, + ); + await page.waitForTimeout(500); + await page.getByTestId('setup-next-button').click(); + + await page.getByPlaceholder('e.g., Acme Inc.').fill(testData.organizationName); + await page.waitForFunction( + () => { + const button = document.querySelector( + '[data-testid="setup-next-button"]', + ) as HTMLButtonElement; + return button && !button.disabled && button.offsetParent !== null; + }, + { timeout: 5000 }, + ); + await page.waitForTimeout(300); + await page.getByTestId('setup-next-button').click(); + + await page.getByPlaceholder('example.com').fill(website); + await Promise.all([ + page.waitForURL(/\/upgrade\/org_/), + page.getByTestId('setup-finish-button').click(), + ]); + + const orgId = page.url().match(/org_[a-zA-Z0-9]+/)?.[0]; + expect(orgId).toBeTruthy(); + + await grantAccess(page, orgId!, true); + await page.reload(); + + await expect(page).toHaveURL(`/onboarding/${orgId!}`); + await expect(page.getByText('Step 1 of 9')).toBeVisible(); + + const avatarTrigger = page.getByTestId('onboarding-user-menu-trigger'); + await expect(avatarTrigger).toBeVisible(); + await avatarTrigger.click(); + + const signOutButton = page.getByTestId('onboarding-sign-out'); + await expect(signOutButton).toBeVisible(); + await signOutButton.click(); + + await expect(page).toHaveURL(/\/auth/); + + await page.goto(`/onboarding/${orgId!}`); + await expect(page).toHaveURL(/\/auth/); + }); + + test('onboarding avatar menu displays user info', async ({ page }) => { + const testData = generateTestData(); + const website = `example${Date.now()}.com`; + + await authenticateTestUser(page, { + email: testData.email, + name: testData.userName, + skipOrg: true, + hasAccess: true, + }); + + await page.goto('/setup'); + await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/); + + await page.waitForSelector('input[type="checkbox"]:checked'); + await page.waitForFunction( + () => { + const button = document.querySelector( + '[data-testid="setup-next-button"]', + ) as HTMLButtonElement; + return button && !button.disabled && button.offsetParent !== null; + }, + { timeout: 10000 }, + ); + await page.waitForTimeout(500); + await page.getByTestId('setup-next-button').click(); + + await page.getByPlaceholder('e.g., Acme Inc.').fill(testData.organizationName); + await page.waitForFunction( + () => { + const button = document.querySelector( + '[data-testid="setup-next-button"]', + ) as HTMLButtonElement; + return button && !button.disabled && button.offsetParent !== null; + }, + { timeout: 5000 }, + ); + await page.waitForTimeout(300); + await page.getByTestId('setup-next-button').click(); + + await page.getByPlaceholder('example.com').fill(website); + await Promise.all([ + page.waitForURL(/\/upgrade\/org_/), + page.getByTestId('setup-finish-button').click(), + ]); + + const orgId = page.url().match(/org_[a-zA-Z0-9]+/)?.[0]; + expect(orgId).toBeTruthy(); + + await grantAccess(page, orgId!, true); + await page.reload(); + + await expect(page).toHaveURL(`/onboarding/${orgId!}`); + + const avatarTrigger = page.getByTestId('onboarding-user-menu-trigger'); + await expect(avatarTrigger).toBeVisible(); + await avatarTrigger.click(); + + await expect(page.getByText(testData.userName)).toBeVisible(); + await expect(page.getByText(testData.email)).toBeVisible(); + }); +}); diff --git a/apps/app/src/app/(app)/onboarding/[orgId]/layout.tsx b/apps/app/src/app/(app)/onboarding/[orgId]/layout.tsx index c270f5c48..f93dc6e62 100644 --- a/apps/app/src/app/(app)/onboarding/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/onboarding/[orgId]/layout.tsx @@ -51,6 +51,7 @@ export default async function OnboardingRouteLayout({ user={session.user} organizations={[]} currentOrganization={organization} + variant="onboarding" /> {children}
diff --git a/apps/app/src/app/(app)/setup/[setupId]/page.tsx b/apps/app/src/app/(app)/setup/[setupId]/page.tsx index 16e4bac81..3cd916332 100644 --- a/apps/app/src/app/(app)/setup/[setupId]/page.tsx +++ b/apps/app/src/app/(app)/setup/[setupId]/page.tsx @@ -45,7 +45,7 @@ export default async function SetupWithIdPage({ params, searchParams }: SetupPag
{/* Form Section - Left Side */}
- + 0; return ( -
+
+ {(variant === 'onboarding' || variant === 'setup') && ( + + )}
); } diff --git a/apps/app/src/components/layout/OnboardingUserMenu.tsx b/apps/app/src/components/layout/OnboardingUserMenu.tsx new file mode 100644 index 000000000..5376a76b3 --- /dev/null +++ b/apps/app/src/components/layout/OnboardingUserMenu.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { authClient } from '@/utils/auth-client'; +import { Avatar, AvatarFallback, AvatarImageNext } from '@comp/ui/avatar'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@comp/ui/dropdown-menu'; +import type { User } from 'better-auth'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +interface OnboardingUserMenuProps { + user: User; +} + +export function OnboardingUserMenu({ user }: OnboardingUserMenuProps) { + const router = useRouter(); + const [isSigningOut, setIsSigningOut] = useState(false); + + const handleSignOut = async () => { + setIsSigningOut(true); + await authClient.signOut({ + fetchOptions: { + onSuccess: () => { + router.push('/auth'); + }, + }, + }); + }; + + return ( + + + + {user.image && ( + + )} + + + {user.name?.charAt(0)?.toUpperCase() || user.email?.charAt(0)?.toUpperCase()} + + + + + + +
+
+ {user.name} + {user.email} +
+
+
+ + + {isSigningOut ? 'Signing out...' : 'Sign out'} + +
+
+ ); +} diff --git a/bun.lock b/bun.lock index 51f1f004c..0172b05e4 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,6 @@ { "lockfileVersion": 1, - "configVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "comp", From b522c10231ad82a8391881d66230c3df7c050c58 Mon Sep 17 00:00:00 2001 From: Min Chun Fu <70210356+Itsnotaka@users.noreply.github.com> Date: Wed, 17 Dec 2025 04:30:51 +0900 Subject: [PATCH 6/8] feat(onboarding): add skip functionality to onboarding steps (#1925) Co-authored-by: Mariano Fuentes --- .../onboarding/actions/complete-onboarding.ts | 10 +++++-- .../components/PostPaymentOnboarding.tsx | 24 +++++++++++++++++ .../hooks/usePostPaymentOnboarding.ts | 27 +++++++++++++++++++ apps/app/src/app/(app)/setup/lib/constants.ts | 3 ++- apps/app/src/app/(app)/setup/lib/types.ts | 3 ++- 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts index 8863450db..ef7884a9d 100644 --- a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts +++ b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts @@ -29,7 +29,7 @@ const onboardingCompletionSchema = z.object({ }), devices: z.string().min(1), authentication: z.string().min(1), - software: z.string().min(1), + software: z.string().optional(), workLocation: z.string().min(1), infrastructure: z.string().min(1), dataTypes: z.string().min(1), @@ -81,7 +81,13 @@ export const completeOnboarding = authActionClient // Save the remaining steps to context const postPaymentSteps = steps.slice(3); // Steps 4-12 const contextData = postPaymentSteps - .filter((step) => step.key in parsedInput) + .filter((step) => { + const value = parsedInput[step.key as keyof typeof parsedInput]; + // Filter out steps that aren't in parsedInput or have empty values (skipped steps) + if (!(step.key in parsedInput)) return false; + if (value === undefined || value === null || value === '') return false; + return true; + }) .map((step) => ({ question: step.question, answer: diff --git a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx index 9808ada47..e783c0700 100644 --- a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx +++ b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx @@ -34,7 +34,9 @@ export function PostPaymentOnboarding({ isLoading, onSubmit, handleBack, + handleSkip, isLastStep, + isSkippable, currentStepNumber, totalSteps, completeNow, @@ -193,6 +195,28 @@ export function PostPaymentOnboarding({ )} + + {isSkippable && ( + + + + )} + {isLocal && ( { + // Track skip event + trackOnboardingEvent(`${step.key}_skipped`, stepIndex + 1, { + phase: 'post_payment', + }); + + // Clear form errors + form.clearErrors(); + + // Move to next step without saving current value + if (stepIndex < postPaymentSteps.length - 1) { + const newStepIndex = stepIndex + 1; + setStepIndex(newStepIndex); + setSavedStepIndex(newStepIndex); + } else { + // If this is the last step, complete onboarding without this field + const allAnswers: Partial = { + ...savedAnswers, + organizationName, + }; + handleCompleteOnboarding(allAnswers); + } + }; + const isLastStep = stepIndex === postPaymentSteps.length - 1; + const isSkippable = step?.skippable ?? false; return { stepIndex, @@ -262,7 +287,9 @@ export function usePostPaymentOnboarding({ isLoading, onSubmit, handleBack, + handleSkip, isLastStep, + isSkippable, currentStepNumber: stepIndex + 1, // Display as steps 1-9 totalSteps: postPaymentSteps.length, // Total 9 steps for post-payment completeNow, diff --git a/apps/app/src/app/(app)/setup/lib/constants.ts b/apps/app/src/app/(app)/setup/lib/constants.ts index 8274a5c6d..3c9988a22 100644 --- a/apps/app/src/app/(app)/setup/lib/constants.ts +++ b/apps/app/src/app/(app)/setup/lib/constants.ts @@ -26,7 +26,7 @@ export const companyDetailsSchema = z.object({ jobTitle: z.string().min(1, 'Job title is required'), email: z.string().email('Please enter a valid email'), }), - software: z.string().min(1, 'Please select software you use'), + software: z.string().optional(), infrastructure: z.string().min(1, 'Please select your infrastructure'), dataTypes: z.string().min(1, 'Please select types of data you handle'), devices: z.string().min(1, 'Please select device types'), @@ -111,6 +111,7 @@ export const steps: Step[] = [ key: 'software', question: 'What software do you use?', placeholder: 'e.g., Rippling', + skippable: true, options: [ 'Rippling', 'Gusto', diff --git a/apps/app/src/app/(app)/setup/lib/types.ts b/apps/app/src/app/(app)/setup/lib/types.ts index 3dd9712ff..683a67feb 100644 --- a/apps/app/src/app/(app)/setup/lib/types.ts +++ b/apps/app/src/app/(app)/setup/lib/types.ts @@ -23,7 +23,7 @@ export type CompanyDetails = { workLocation: string; infrastructure: string; dataTypes: string; - software: string; + software?: string; geo: string; shipping: { fullName: string; @@ -45,4 +45,5 @@ export type Step = { placeholder: string; options?: string[]; description?: string; + skippable?: boolean; }; From ec93c2ee8add06e598213a17a0eb29eca073d56c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:54:53 -0500 Subject: [PATCH 7/8] Revert "[dev] [Itsnotaka] daniel/ui (#1915)" (#1928) This reverts commit 44faa9e679cd9d540430fc97517bf74fb60516e2. Co-authored-by: Mariano Fuentes --- apps/app/src/actions/sidebar.ts | 24 + apps/app/src/app/(app)/[orgId]/layout.tsx | 23 +- apps/app/src/app/providers.tsx | 9 +- apps/app/src/components/animated-layout.tsx | 25 + apps/app/src/components/header.tsx | 28 +- apps/app/src/components/main-menu.tsx | 200 ++--- apps/app/src/components/mobile-menu.tsx | 66 ++ .../src/components/organization-switcher.tsx | 259 +++---- .../components/sidebar-collapse-button.tsx | 29 +- apps/app/src/components/sidebar-logo.tsx | 15 +- .../{app-sidebar.tsx => sidebar.tsx} | 70 +- apps/app/src/context/sidebar-context.tsx | 39 + bun.lock | 28 +- packages/ui/components.json | 4 +- packages/ui/package.json | 10 +- packages/ui/src/components/button.tsx | 5 +- packages/ui/src/components/dropdown-menu.tsx | 352 ++++----- packages/ui/src/components/sheet.tsx | 3 +- packages/ui/src/components/sidebar.tsx | 685 ------------------ packages/ui/src/globals.css | 46 -- packages/ui/src/hooks/use-mobile.ts | 19 - 21 files changed, 636 insertions(+), 1303 deletions(-) create mode 100644 apps/app/src/actions/sidebar.ts create mode 100644 apps/app/src/components/animated-layout.tsx create mode 100644 apps/app/src/components/mobile-menu.tsx rename apps/app/src/components/{app-sidebar.tsx => sidebar.tsx} (60%) create mode 100644 apps/app/src/context/sidebar-context.tsx delete mode 100644 packages/ui/src/components/sidebar.tsx delete mode 100644 packages/ui/src/hooks/use-mobile.ts diff --git a/apps/app/src/actions/sidebar.ts b/apps/app/src/actions/sidebar.ts new file mode 100644 index 000000000..16d12e8e8 --- /dev/null +++ b/apps/app/src/actions/sidebar.ts @@ -0,0 +1,24 @@ +'use server'; + +import { addYears } from 'date-fns'; +import { createSafeActionClient } from 'next-safe-action'; +import { cookies } from 'next/headers'; +import { z } from 'zod'; + +const schema = z.object({ + isCollapsed: z.boolean(), +}); + +export const updateSidebarState = createSafeActionClient() + .inputSchema(schema) + .action(async ({ parsedInput }) => { + const cookieStore = await cookies(); + + cookieStore.set({ + name: 'sidebar-collapsed', + value: JSON.stringify(parsedInput.isCollapsed), + expires: addYears(new Date(), 1), + }); + + return { success: true }; + }); diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index 2271bd923..99d1b69ea 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -1,10 +1,11 @@ -import { AppSidebar } from '@/components/app-sidebar'; +import { AnimatedLayout } from '@/components/animated-layout'; import { CheckoutCompleteDialog } from '@/components/dialogs/checkout-complete-dialog'; import { Header } from '@/components/header'; import { AssistantSheet } from '@/components/sheets/assistant-sheet'; +import { Sidebar } from '@/components/sidebar'; import { TriggerTokenProvider } from '@/components/trigger-token-provider'; +import { SidebarProvider } from '@/context/sidebar-context'; import { auth } from '@/utils/auth'; -import { SidebarInset, SidebarProvider } from '@comp/ui/sidebar'; import { db, Role } from '@db'; import dynamic from 'next/dynamic'; import { cookies, headers } from 'next/headers'; @@ -14,6 +15,7 @@ import { ConditionalOnboardingTracker } from './components/ConditionalOnboarding import { ConditionalPaddingWrapper } from './components/ConditionalPaddingWrapper'; import { DynamicMinHeight } from './components/DynamicMinHeight'; +// Helper to safely parse comma-separated roles string function parseRolesString(rolesStr: string | null | undefined): Role[] { if (!rolesStr) return []; return rolesStr @@ -36,9 +38,10 @@ export default async function Layout({ const { orgId: requestedOrgId } = await params; const cookieStore = await cookies(); - const defaultOpen = cookieStore.get('sidebar_state')?.value !== 'false'; - const publicAccessToken = cookieStore.get('publicAccessToken')?.value || undefined; + const isCollapsed = cookieStore.get('sidebar-collapsed')?.value === 'true'; + let publicAccessToken = cookieStore.get('publicAccessToken')?.value || undefined; + // Check if user has access to this organization const session = await auth.api.getSession({ headers: await headers(), }); @@ -48,11 +51,13 @@ export default async function Layout({ return redirect('/auth'); } + // First check if the organization exists and load access flags const organization = await db.organization.findUnique({ where: { id: requestedOrgId }, }); if (!organization) { + // Organization doesn't exist return redirect('/auth/not-found'); } @@ -65,6 +70,7 @@ export default async function Layout({ }); if (!member) { + // User doesn't have access to this organization return redirect('/auth/unauthorized'); } @@ -76,10 +82,12 @@ export default async function Layout({ return redirect('/no-access'); } + // If this org is not accessible on current plan, redirect to upgrade if (!organization.hasAccess) { return redirect(`/upgrade/${organization.id}`); } + // If onboarding is required and user isn't already on onboarding, redirect if (!organization.onboardingCompleted) { return redirect(`/onboarding/${organization.id}`); } @@ -95,9 +103,8 @@ export default async function Layout({ triggerJobId={onboarding?.triggerJobId || undefined} initialToken={publicAccessToken || undefined} > - - - + + } isCollapsed={isCollapsed}> {onboarding?.triggerJobId && }
@@ -107,7 +114,7 @@ export default async function Layout({ - + diff --git a/apps/app/src/app/providers.tsx b/apps/app/src/app/providers.tsx index a0b4a520f..ef89fb701 100644 --- a/apps/app/src/app/providers.tsx +++ b/apps/app/src/app/providers.tsx @@ -4,7 +4,6 @@ import { JwtTokenManager } from '@/components/auth/jwt-token-manager'; import { env } from '@/env.mjs'; import { AnalyticsProvider } from '@comp/analytics'; import { Toaster } from '@comp/ui/sooner'; -import { TooltipProvider } from '@comp/ui/tooltip'; import { GoogleTagManager } from '@next/third-parties/google'; import { defaultShouldDehydrateQuery, @@ -87,11 +86,9 @@ export function Providers({ children, session }: ProviderProps) { userId={session?.user?.id ?? undefined} userEmail={session?.user?.email ?? undefined} > - - - {children} - - + + {children} + diff --git a/apps/app/src/components/animated-layout.tsx b/apps/app/src/components/animated-layout.tsx new file mode 100644 index 000000000..a41fc493d --- /dev/null +++ b/apps/app/src/components/animated-layout.tsx @@ -0,0 +1,25 @@ +import { cn } from '@comp/ui/cn'; + +interface AnimatedLayoutProps { + children: React.ReactNode; + sidebar: React.ReactNode; + isCollapsed: boolean; + blurred?: boolean; +} + +export function AnimatedLayout({ children, sidebar, isCollapsed, blurred }: AnimatedLayoutProps) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/apps/app/src/components/header.tsx b/apps/app/src/components/header.tsx index f3a4387ed..0c12a4185 100644 --- a/apps/app/src/components/header.tsx +++ b/apps/app/src/components/header.tsx @@ -1,8 +1,12 @@ +import { getFeatureFlags } from '@/app/posthog'; import { UserMenu } from '@/components/user-menu'; +import { getOrganizations } from '@/data/getOrganizations'; +import { auth } from '@/utils/auth'; import { Skeleton } from '@comp/ui/skeleton'; -import { SidebarTrigger } from '@comp/ui/sidebar'; +import { headers } from 'next/headers'; import { Suspense } from 'react'; import { AssistantButton } from './ai/chat-button'; +import { MobileMenu } from './mobile-menu'; import { NotificationBell } from './notifications/notification-bell'; export async function Header({ @@ -12,9 +16,29 @@ export async function Header({ organizationId?: string; hideChat?: boolean; }) { + const { organizations } = await getOrganizations(); + + // Check feature flags for menu items + const session = await auth.api.getSession({ + headers: await headers(), + }); + let isQuestionnaireEnabled = false; + let isTrustNdaEnabled = false; + if (session?.user?.id) { + const flags = await getFeatureFlags(session.user.id); + isQuestionnaireEnabled = flags['ai-vendor-questionnaire'] === true; + isTrustNdaEnabled = + flags['is-trust-nda-enabled'] === true || flags['is-trust-nda-enabled'] === 'true'; + } + return (
- + {!hideChat && } diff --git a/apps/app/src/components/main-menu.tsx b/apps/app/src/components/main-menu.tsx index 4410e720f..32c3f37d6 100644 --- a/apps/app/src/components/main-menu.tsx +++ b/apps/app/src/components/main-menu.tsx @@ -1,16 +1,10 @@ 'use client'; import { Badge } from '@comp/ui/badge'; +import { Button } from '@comp/ui/button'; import { cn } from '@comp/ui/cn'; import { Icons } from '@comp/ui/icons'; -import { - SidebarGroup, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from '@comp/ui/sidebar'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@comp/ui/tooltip'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; import { ClipboardCheck, FileTextIcon, @@ -28,12 +22,13 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; +// Define menu item types with icon component type MenuItem = { id: string; path: string; name: string; disabled: boolean; - icon: React.FC<{ size?: number; className?: string }>; + icon: React.FC<{ size?: number }>; protected: boolean; badge?: { text: string; @@ -47,14 +42,15 @@ interface ItemProps { item: MenuItem; isActive: boolean; disabled: boolean; - isCollapsed: boolean; + isCollapsed?: boolean; onItemClick?: () => void; - itemRef: (el: HTMLLIElement | null) => void; + itemRef: (el: HTMLDivElement | null) => void; } export type Props = { organizationId?: string; organization?: { advancedModeEnabled?: boolean } | null; + isCollapsed?: boolean; onItemClick?: () => void; isQuestionnaireEnabled?: boolean; isTrustNdaEnabled?: boolean; @@ -65,6 +61,7 @@ export type Props = { export function MainMenu({ organizationId, organization, + isCollapsed = false, onItemClick, isQuestionnaireEnabled = false, isTrustNdaEnabled = false, @@ -72,10 +69,8 @@ export function MainMenu({ isOnlyAuditor = false, }: Props) { const pathname = usePathname(); - const { state } = useSidebar(); - const isCollapsed = state === 'collapsed'; const [activeStyle, setActiveStyle] = useState({ top: '0px', height: '0px' }); - const itemRefs = useRef<(HTMLLIElement | null)[]>([]); + const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const items: MenuItem[] = [ { @@ -190,13 +185,19 @@ export function MainMenu({ }, ]; + // Helper function to check if a path is active const isPathActive = (itemPath: string) => { const normalizedItemPath = itemPath.replace(':organizationId', organizationId ?? ''); + + // Extract the base path from the menu item (first two segments after normalization) const itemPathParts = normalizedItemPath.split('/').filter(Boolean); const itemBaseSegment = itemPathParts.length > 1 ? itemPathParts[1] : ''; + + // Extract the current path parts const currentPathParts = pathname.split('/').filter(Boolean); const currentBaseSegment = currentPathParts.length > 1 ? currentPathParts[1] : ''; + // Special case for root organization path if ( normalizedItemPath === `/${organizationId}` || normalizedItemPath === `/${organizationId}/implementation` @@ -213,6 +214,7 @@ export function MainMenu({ const visibleItems = items.filter((item) => !item.disabled && !item.hidden); const activeIndex = visibleItems.findIndex((item) => isPathActive(item.path)); + // Update active indicator position useEffect(() => { if (activeIndex >= 0) { const activeElement = itemRefs.current[activeIndex]; @@ -224,8 +226,9 @@ export function MainMenu({ }); } } - }, [activeIndex, pathname, isCollapsed]); + }, [activeIndex, pathname]); + // Handle window resize to recalculate positions useEffect(() => { const handleResize = () => { if (activeIndex >= 0) { @@ -247,108 +250,113 @@ export function MainMenu({ }, [activeIndex]); return ( - + ); } -function Item({ + +const Item = ({ organizationId, item, isActive, disabled, - isCollapsed, + isCollapsed = false, onItemClick, itemRef, -}: ItemProps) { +}: ItemProps) => { const Icon = item.icon; const linkDisabled = disabled || item.disabled; const itemPath = item.path.replace(':organizationId', organizationId ?? ''); if (linkDisabled) { return ( - +
+ + + + + + {isCollapsed && Coming Soon} + + +
+ ); + } + + return ( +
+ - - - {!isCollapsed && Coming Soon} - + + + {!isCollapsed && ( + <> + {item.name} + {item.badge && ( + + {item.badge.text} + + )} + + )} + + - {isCollapsed && Coming Soon} + {isCollapsed && ( + +
+ {item.name} + {item.badge && ( + + {item.badge.text} + + )} +
+
+ )}
- - ); - } - - const tooltipContent = ( -
- {item.name} - {item.badge && ( - - {item.badge.text} - - )} +
); - - return ( - - - - - - - {!isCollapsed && ( - <> - {item.name} - {item.badge && ( - - {item.badge.text} - - )} - - )} - - - - {isCollapsed && ( - - {tooltipContent} - - )} - - - ); -} +}; diff --git a/apps/app/src/components/mobile-menu.tsx b/apps/app/src/components/mobile-menu.tsx new file mode 100644 index 000000000..02eeacef3 --- /dev/null +++ b/apps/app/src/components/mobile-menu.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { Button } from '@comp/ui/button'; +import { Icons } from '@comp/ui/icons'; +import { Sheet, SheetContent } from '@comp/ui/sheet'; +import type { Organization } from '@db'; +import { useState } from 'react'; +import { MainMenu } from './main-menu'; +import { OrganizationSwitcher } from './organization-switcher'; + +interface MobileMenuProps { + organizations: Organization[]; + isCollapsed?: boolean; + organizationId?: string; + isQuestionnaireEnabled?: boolean; + isTrustNdaEnabled?: boolean; +} + +export function MobileMenu({ + organizationId, + organizations, + isQuestionnaireEnabled = false, + isTrustNdaEnabled = false, +}: MobileMenuProps) { + const [isOpen, setOpen] = useState(false); + + const handleCloseSheet = () => { + setOpen(false); + }; + + const currentOrganization = organizations.find((org) => org.id === organizationId) || null; + + return ( + +
+ +
+ +
+ +
+
+ + +
+
+
+ ); +} diff --git a/apps/app/src/components/organization-switcher.tsx b/apps/app/src/components/organization-switcher.tsx index 53cb15f01..2758cf613 100644 --- a/apps/app/src/components/organization-switcher.tsx +++ b/apps/app/src/components/organization-switcher.tsx @@ -13,12 +13,10 @@ import { CommandSeparator, } from '@comp/ui/command'; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@comp/ui/dialog'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@comp/ui/dropdown-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import { useSidebar } from '@comp/ui/sidebar'; import type { Organization } from '@db'; import { Check, ChevronsUpDown, Loader2, Plus, Search } from 'lucide-react'; -import { useAction, type HookActionStatus } from 'next-safe-action/hooks'; +import { useAction } from 'next-safe-action/hooks'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { useQueryState } from 'nuqs'; @@ -27,6 +25,7 @@ import { useEffect, useState } from 'react'; interface OrganizationSwitcherProps { organizations: Organization[]; organization: Organization | null; + isCollapsed?: boolean; logoUrls?: Record; } @@ -64,6 +63,7 @@ function OrganizationAvatar({ }: OrganizationAvatarProps) { const sizeClass = size === 'sm' ? 'h-6 w-6' : 'h-8 w-8'; + // If logo URL exists, show the image if (logoUrl) { return (
@@ -72,6 +72,7 @@ function OrganizationAvatar({ ); } + // Fallback to initials const initials = name?.slice(0, 2).toUpperCase() || ''; let colorIndex = 0; @@ -97,108 +98,14 @@ function OrganizationAvatar({ ); } -function OrganizationSwitcherContent({ - sortedOrganizations, - currentOrganization, - logoUrls, - sortOrder, - setSortOrder, - status, - pendingOrgId, - handleOrgChange, - handleOpenChange, - router, - getDisplayName, -}: { - sortedOrganizations: Organization[]; - currentOrganization: Organization | null; - logoUrls: Record; - sortOrder: string; - setSortOrder: (value: string) => void; - status: HookActionStatus; - pendingOrgId: string | null; - handleOrgChange: (org: Organization) => void; - handleOpenChange: (open: boolean) => void; - router: ReturnType; - getDisplayName: (org: Organization) => string; -}) { - return ( - -
- - -
-
- -
- - No results found - - {sortedOrganizations.map((org) => ( - { - if (org.id !== currentOrganization?.id) { - handleOrgChange(org); - } else { - handleOpenChange(false); - } - }} - disabled={status === 'executing'} - className="flex items-center gap-2" - > - {status === 'executing' && pendingOrgId === org.id ? ( - - ) : currentOrganization?.id === org.id ? ( - - ) : ( -
- )} - - {getDisplayName(org)} - - ))} - - - - { - router.push('/setup?intent=create-additional'); - handleOpenChange(false); - }} - disabled={status === 'executing'} - className="flex items-center gap-2" - > - - Create Organization - - - - - ); -} - export function OrganizationSwitcher({ organizations, organization, + isCollapsed = false, logoUrls = {}, }: OrganizationSwitcherProps) { - const { state } = useSidebar(); - const isCollapsed = state === 'collapsed'; const router = useRouter(); - const [isOpen, setIsOpen] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); const [pendingOrgId, setPendingOrgId] = useState(null); const [sortOrder, setSortOrder] = useState('alphabetical'); @@ -237,8 +144,7 @@ export function OrganizationSwitcher({ if (orgId) { router.push(`/${orgId}/`); } - setIsOpen(false); - setShowOrganizationSwitcher(null); + setIsDialogOpen(false); setPendingOrgId(null); }, onExecute: (args) => { @@ -274,68 +180,105 @@ export function OrganizationSwitcher({ }; const handleOpenChange = (open: boolean) => { - setShowOrganizationSwitcher(open ? true : null); - setIsOpen(open); - }; - - const isOpenState = showOrganizationSwitcher ?? isOpen; - - const triggerButton = ( - - ); - - const contentProps = { - sortedOrganizations, - currentOrganization, - logoUrls, - sortOrder, - setSortOrder, - status, - pendingOrgId, - handleOrgChange, - handleOpenChange, - router, - getDisplayName, + setShowOrganizationSwitcher(open); + setIsDialogOpen(open); }; - if (isCollapsed) { - return ( - - {triggerButton} + return ( +
+ + + + Select Organization - + +
+ + +
+
+ +
+ + No results found + + {sortedOrganizations.map((org) => ( + { + if (org.id !== currentOrganization?.id) { + handleOrgChange(org); + } else { + handleOpenChange(false); + } + }} + disabled={status === 'executing'} + className="flex items-center gap-2" + > + {status === 'executing' && pendingOrgId === org.id ? ( + + ) : currentOrganization?.id === org.id ? ( + + ) : ( +
+ )} + + {getDisplayName(org)} + + ))} + + + + { + router.push('/setup?intent=create-additional'); + setIsDialogOpen(false); + }} + disabled={status === 'executing'} + className="flex items-center gap-2" + > + + Create Organization + + + +
- ); - } - - return ( - - {triggerButton} - - - - +
); } diff --git a/apps/app/src/components/sidebar-collapse-button.tsx b/apps/app/src/components/sidebar-collapse-button.tsx index 4c0fea824..00ab06bc6 100644 --- a/apps/app/src/components/sidebar-collapse-button.tsx +++ b/apps/app/src/components/sidebar-collapse-button.tsx @@ -1,20 +1,39 @@ 'use client'; +import { updateSidebarState } from '@/actions/sidebar'; +import { useSidebar } from '@/context/sidebar-context'; import { Button } from '@comp/ui/button'; import { cn } from '@comp/ui/cn'; -import { useSidebar } from '@comp/ui/sidebar'; import { ArrowLeftFromLine } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; -export function SidebarCollapseButton() { - const { state, toggleSidebar } = useSidebar(); - const isCollapsed = state === 'collapsed'; +interface SidebarCollapseButtonProps { + isCollapsed: boolean; +} + +export function SidebarCollapseButton({ isCollapsed }: SidebarCollapseButtonProps) { + const { setIsCollapsed } = useSidebar(); + + const { execute } = useAction(updateSidebarState, { + onError: () => { + // Revert the optimistic update if the server action fails + setIsCollapsed(isCollapsed); + }, + }); + + const handleToggle = () => { + // Update local state immediately for responsive UI + setIsCollapsed(!isCollapsed); + // Update server state (cookie) in the background + execute({ isCollapsed: !isCollapsed }); + }; return ( - ); -} - -function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { - const { toggleSidebar } = useSidebar(); - - return ( -