diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1593314c..913bdbd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v1 with: - bun-version: latest + bun-version: 1.3.9 - name: Install dependencies run: bun install diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f7b24bf7..e3e47b91 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -35,6 +35,7 @@ import { HumanInputsModule } from './human-inputs/human-inputs.module'; import { McpServersModule } from './mcp-servers/mcp-servers.module'; import { McpGroupsModule } from './mcp-groups/mcp-groups.module'; import { TemplatesModule } from './templates/templates.module'; +import { UserPreferencesModule } from './user-preferences/user-preferences.module'; const coreModules = [ AgentsModule, @@ -56,6 +57,7 @@ const coreModules = [ StudioMcpModule, TemplatesModule, AuditModule, + UserPreferencesModule, ]; const testingModules = process.env.NODE_ENV === 'production' ? [] : [TestingSupportModule]; diff --git a/backend/src/database/schema/index.ts b/backend/src/database/schema/index.ts index 89fec502..55a0ba40 100644 --- a/backend/src/database/schema/index.ts +++ b/backend/src/database/schema/index.ts @@ -22,3 +22,4 @@ export * from './mcp-servers'; export * from './node-io'; export * from './organization-settings'; export * from './templates'; +export * from './user-preferences'; diff --git a/backend/src/database/schema/user-preferences.ts b/backend/src/database/schema/user-preferences.ts new file mode 100644 index 00000000..a25656d6 --- /dev/null +++ b/backend/src/database/schema/user-preferences.ts @@ -0,0 +1,20 @@ +import { boolean, index, pgTable, primaryKey, timestamp, varchar } from 'drizzle-orm/pg-core'; + +export const userPreferencesTable = pgTable( + 'user_preferences', + { + userId: varchar('user_id', { length: 191 }).notNull(), + organizationId: varchar('organization_id', { length: 191 }).notNull(), + hasCompletedOnboarding: boolean('has_completed_onboarding').notNull().default(false), + hasCompletedBuilderTour: boolean('has_completed_builder_tour').notNull().default(false), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.userId, table.organizationId] }), + orgIdx: index('user_preferences_org_idx').on(table.organizationId), + }), +); + +export type UserPreferences = typeof userPreferencesTable.$inferSelect; +export type NewUserPreferences = typeof userPreferencesTable.$inferInsert; diff --git a/backend/src/user-preferences/user-preferences.controller.ts b/backend/src/user-preferences/user-preferences.controller.ts new file mode 100644 index 00000000..095a08fd --- /dev/null +++ b/backend/src/user-preferences/user-preferences.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Patch } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; + +import { UserPreferencesService } from './user-preferences.service'; +import { UpdateUserPreferencesDto, UserPreferencesResponseDto } from './user-preferences.dto'; +import { CurrentAuth } from '../auth/auth-context.decorator'; +import type { AuthContext } from '../auth/types'; + +@ApiTags('user-preferences') +@Controller('users/me/preferences') +export class UserPreferencesController { + constructor(private readonly service: UserPreferencesService) {} + + @Get() + @ApiOkResponse({ type: UserPreferencesResponseDto }) + async getPreferences( + @CurrentAuth() auth: AuthContext | null, + ): Promise { + return this.service.getPreferences(auth); + } + + @Patch() + @ApiOkResponse({ type: UserPreferencesResponseDto }) + async updatePreferences( + @CurrentAuth() auth: AuthContext | null, + @Body() body: UpdateUserPreferencesDto, + ): Promise { + return this.service.updatePreferences(auth, body); + } +} diff --git a/backend/src/user-preferences/user-preferences.dto.ts b/backend/src/user-preferences/user-preferences.dto.ts new file mode 100644 index 00000000..8c4ea2f4 --- /dev/null +++ b/backend/src/user-preferences/user-preferences.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateUserPreferencesDto { + @ApiPropertyOptional({ description: 'Whether the user has completed the onboarding tour' }) + @IsOptional() + @IsBoolean() + hasCompletedOnboarding?: boolean; + + @ApiPropertyOptional({ description: 'Whether the user has completed the workflow builder tour' }) + @IsOptional() + @IsBoolean() + hasCompletedBuilderTour?: boolean; +} + +export class UserPreferencesResponseDto { + @ApiProperty() + hasCompletedOnboarding!: boolean; + + @ApiProperty() + hasCompletedBuilderTour!: boolean; +} diff --git a/backend/src/user-preferences/user-preferences.module.ts b/backend/src/user-preferences/user-preferences.module.ts new file mode 100644 index 00000000..daeba724 --- /dev/null +++ b/backend/src/user-preferences/user-preferences.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { DatabaseModule } from '../database/database.module'; +import { UserPreferencesController } from './user-preferences.controller'; +import { UserPreferencesRepository } from './user-preferences.repository'; +import { UserPreferencesService } from './user-preferences.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [UserPreferencesController], + providers: [UserPreferencesService, UserPreferencesRepository], + exports: [UserPreferencesService], +}) +export class UserPreferencesModule {} diff --git a/backend/src/user-preferences/user-preferences.repository.ts b/backend/src/user-preferences/user-preferences.repository.ts new file mode 100644 index 00000000..87787109 --- /dev/null +++ b/backend/src/user-preferences/user-preferences.repository.ts @@ -0,0 +1,73 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { and, eq, sql } from 'drizzle-orm'; + +import { DRIZZLE_TOKEN } from '../database/database.module'; +import { userPreferencesTable } from '../database/schema'; + +export interface UserPreferencesData { + hasCompletedOnboarding: boolean; + hasCompletedBuilderTour: boolean; +} + +@Injectable() +export class UserPreferencesRepository { + constructor( + @Inject(DRIZZLE_TOKEN) + private readonly db: NodePgDatabase, + ) {} + + async findByUserAndOrg( + userId: string, + organizationId: string, + ): Promise { + const rows = await this.db + .select({ + hasCompletedOnboarding: userPreferencesTable.hasCompletedOnboarding, + hasCompletedBuilderTour: userPreferencesTable.hasCompletedBuilderTour, + }) + .from(userPreferencesTable) + .where( + and( + eq(userPreferencesTable.userId, userId), + eq(userPreferencesTable.organizationId, organizationId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async upsert( + userId: string, + organizationId: string, + updates: Partial, + ): Promise { + const [result] = await this.db + .insert(userPreferencesTable) + .values({ + userId, + organizationId, + hasCompletedOnboarding: updates.hasCompletedOnboarding ?? false, + hasCompletedBuilderTour: updates.hasCompletedBuilderTour ?? false, + }) + .onConflictDoUpdate({ + target: [userPreferencesTable.userId, userPreferencesTable.organizationId], + set: { + ...(updates.hasCompletedOnboarding !== undefined + ? { hasCompletedOnboarding: updates.hasCompletedOnboarding } + : {}), + ...(updates.hasCompletedBuilderTour !== undefined + ? { hasCompletedBuilderTour: updates.hasCompletedBuilderTour } + : {}), + updatedAt: sql`now()`, + }, + }) + .returning({ + hasCompletedOnboarding: userPreferencesTable.hasCompletedOnboarding, + hasCompletedBuilderTour: userPreferencesTable.hasCompletedBuilderTour, + }); + + return result; + } +} diff --git a/backend/src/user-preferences/user-preferences.service.ts b/backend/src/user-preferences/user-preferences.service.ts new file mode 100644 index 00000000..f1759051 --- /dev/null +++ b/backend/src/user-preferences/user-preferences.service.ts @@ -0,0 +1,33 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; + +import { UserPreferencesRepository, type UserPreferencesData } from './user-preferences.repository'; +import type { AuthContext } from '../auth/types'; +import { DEFAULT_ORGANIZATION_ID } from '../auth/constants'; + +@Injectable() +export class UserPreferencesService { + constructor(private readonly repository: UserPreferencesRepository) {} + + private resolveIds(auth: AuthContext | null): { userId: string; organizationId: string } { + const userId = auth?.userId; + if (!userId) { + throw new BadRequestException('User context is required'); + } + const organizationId = auth?.organizationId ?? DEFAULT_ORGANIZATION_ID; + return { userId, organizationId }; + } + + async getPreferences(auth: AuthContext | null): Promise { + const { userId, organizationId } = this.resolveIds(auth); + const prefs = await this.repository.findByUserAndOrg(userId, organizationId); + return prefs ?? { hasCompletedOnboarding: false, hasCompletedBuilderTour: false }; + } + + async updatePreferences( + auth: AuthContext | null, + updates: Partial, + ): Promise { + const { userId, organizationId } = this.resolveIds(auth); + return this.repository.upsert(userId, organizationId, updates); + } +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 25bd9360..dff72951 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -41,6 +41,12 @@ import { setMobilePlacementSidebarClose } from '@/components/layout/sidebar-stat import { useCommandPaletteStore } from '@/store/commandPaletteStore'; import { usePrefetchOnIdle } from '@/hooks/usePrefetchOnIdle'; import { prefetchIdleRoutes, prefetchRoute } from '@/lib/prefetch-routes'; +import { OnboardingDialog } from '@/components/onboarding/OnboardingDialog'; +import { useOnboardingStore } from '@/store/onboardingStore'; +import { + useUserPreferences, + useUpdateUserPreferences, +} from '@/hooks/queries/useUserPreferencesQueries'; interface AppLayoutProps { children: React.ReactNode; @@ -88,6 +94,58 @@ export function AppLayout({ children }: AppLayoutProps) { const { theme, startTransition } = useThemeStore(); const openCommandPalette = useCommandPaletteStore((state) => state.open); + // Onboarding tutorial state (persisted in DB via API) + const { + data: userPreferences, + isLoading: prefsLoading, + isSuccess: prefsLoaded, + } = useUserPreferences(); + const updatePreferences = useUpdateUserPreferences(); + // While loading: hide dialog (true). After loaded: use server value, default false for new users. + const hasCompletedOnboarding = prefsLoaded + ? (userPreferences?.hasCompletedOnboarding ?? false) + : true; + const onboardingStep = useOnboardingStore((state) => state.currentStep); + const setOnboardingStep = useOnboardingStore((state) => state.setCurrentStep); + const completeOnboarding = () => updatePreferences.mutate({ hasCompletedOnboarding: true }); + + // One-time migration: localStorage -> DB + useEffect(() => { + if (prefsLoading || !isAuthenticated) return; + + const STORAGE_KEY = 'shipsec-onboarding'; + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + + try { + const stored = JSON.parse(raw); + const localState = stored?.state; + if (!localState) { + localStorage.removeItem(STORAGE_KEY); + return; + } + + const updates: { hasCompletedOnboarding?: boolean; hasCompletedBuilderTour?: boolean } = {}; + + if (localState.hasCompletedOnboarding && !userPreferences?.hasCompletedOnboarding) { + updates.hasCompletedOnboarding = true; + } + if (localState.hasCompletedBuilderTour && !userPreferences?.hasCompletedBuilderTour) { + updates.hasCompletedBuilderTour = true; + } + + if (Object.keys(updates).length > 0) { + updatePreferences.mutate(updates, { + onSuccess: () => localStorage.removeItem(STORAGE_KEY), + }); + } else { + localStorage.removeItem(STORAGE_KEY); + } + } catch { + localStorage.removeItem(STORAGE_KEY); + } + }, [prefsLoading, isAuthenticated]); + // Get git SHA for version display (monorepo - same for frontend and backend) const gitSha = env.VITE_GIT_SHA; // If it's a tag (starts with v), show full tag. Otherwise show first 7 chars of SHA @@ -100,7 +158,13 @@ export function AppLayout({ children }: AppLayoutProps) { // Auto-collapse sidebar when opening workflow builder, expand for other routes // On mobile, always start collapsed + // During onboarding, force sidebar open so highlighted elements are visible useEffect(() => { + if (isAuthenticated && !hasCompletedOnboarding) { + setSidebarOpen(true); + setWasExplicitlyOpened(true); + return; + } if (isMobile) { setSidebarOpen(false); setWasExplicitlyOpened(false); @@ -112,7 +176,14 @@ export function AppLayout({ children }: AppLayoutProps) { setSidebarOpen(!isWorkflowRoute); setWasExplicitlyOpened(!isWorkflowRoute); } - }, [location.pathname, isMobile]); + }, [location.pathname, isMobile, isAuthenticated, hasCompletedOnboarding]); + + // Expand Manage section when onboarding highlights it + useEffect(() => { + if (isAuthenticated && !hasCompletedOnboarding && onboardingStep === 5) { + setSettingsOpen(true); + } + }, [isAuthenticated, hasCompletedOnboarding, onboardingStep]); // Close sidebar on mobile when navigating useEffect(() => { @@ -268,6 +339,14 @@ export function AppLayout({ children }: AppLayoutProps) { toggle: handleToggle, }; + // Onboarding target IDs for spotlight tour + const onboardingTargetIds: Record = { + '/': 'workflow-builder', + '/templates': 'template-library', + '/schedules': 'schedules', + '/action-center': 'action-center', + }; + const navigationItems = [ { name: 'Workflow Builder', @@ -382,6 +461,12 @@ export function AppLayout({ children }: AppLayoutProps) { return ( +
{/* Mobile backdrop overlay */} {isMobile && sidebarOpen && ( @@ -493,6 +578,7 @@ export function AppLayout({ children }: AppLayoutProps) { prefetchRoute(item.href)} onClick={(e) => { // If modifier key is held (CMD+click, Ctrl+click), link opens in new tab @@ -541,6 +627,7 @@ export function AppLayout({ children }: AppLayoutProps) { {/* Manage Collapsible Section */}
- - - - {!hasAnalyticsSink - ? 'Connect analytics sink to view analytics' - : !isOrgReady - ? 'Loading organization context...' - : selectedRunId - ? 'View analytics for this run in OpenSearch Dashboards' - : 'View analytics for this workflow in OpenSearch Dashboards'} - - - - )} + {/* Run button */} + - {/* Run button with dropdown for Publish */} -
- - {onPublishTemplate && isInWorkflowBuilder && ( - - - - - - - - Publish as Template - - - - )} -
- - {/* Vertical three-dots menu: Undo, Redo, Import, Export */} - {mode === 'design' && ( - <> - {onImport && ( - - )} - - - - - + {/* More options menu: Undo, Redo, Import, Export, Publish, Analytics */} + {onImport && ( + + )} + + + + + + {mode === 'design' && ( + <> Undo @@ -506,26 +430,65 @@ export function TopBar({ Redo ⌘⇧Z - {(onImport || onExport) && } - {onImport && ( - - - Import - - )} - {onExport && ( - - - Export - - )} - - - - )} + + )} + {mode === 'design' && (onImport || onExport) && } + {mode === 'design' && onImport && ( + + + Import + + )} + {mode === 'design' && onExport && ( + + + Export + + )} + {(onPublishTemplate || env.VITE_OPENSEARCH_DASHBOARDS_URL) && ( + + )} + {onPublishTemplate && isInWorkflowBuilder && ( + + + Publish as Template + + )} + {env.VITE_OPENSEARCH_DASHBOARDS_URL && + workflowId && + (!selectedRunId || (selectedRunStatus && selectedRunStatus !== 'RUNNING')) && ( + { + if (!isOrgReady || !hasAnalyticsSink) return; + const baseUrl = env.VITE_OPENSEARCH_DASHBOARDS_URL.replace(/\/+$/, ''); + const filterQuery = selectedRunId + ? `shipsec.run_id.keyword:"${selectedRunId}"` + : `shipsec.workflow_id.keyword:"${workflowId}"`; + const effectiveOrgId = (selectedRunOrgId || organizationId).toLowerCase(); + const orgScopedPattern = `security-findings-${effectiveOrgId}-*`; + const aParam = encodeURIComponent( + `(discover:(columns:!(_source),interval:auto,sort:!()),metadata:(indexPattern:'${orgScopedPattern}',view:discover))`, + ); + const qParam = encodeURIComponent( + `(query:(language:kuery,query:'${filterQuery}'))`, + ); + const gParam = encodeURIComponent( + '(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-1y,to:now))', + ); + const url = `${baseUrl}/app/data-explorer/discover/#?_a=${aParam}&_q=${qParam}&_g=${gParam}`; + window.open(url, '_blank', 'noopener,noreferrer'); + }} + > + + View Analytics + + )} + +
diff --git a/frontend/src/components/onboarding/OnboardingDialog.tsx b/frontend/src/components/onboarding/OnboardingDialog.tsx new file mode 100644 index 00000000..1d5cd0bf --- /dev/null +++ b/frontend/src/components/onboarding/OnboardingDialog.tsx @@ -0,0 +1,335 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { Button } from '@/components/ui/button'; +import { + Workflow, + Package, + CalendarClock, + Zap, + KeyRound, + ArrowRight, + ArrowLeft, + Sparkles, + X, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface OnboardingStep { + title: string; + description: string; + icon: typeof Sparkles; + content: string; + gradient: string; + iconColor: string; + target: string | null; +} + +const ONBOARDING_STEPS: OnboardingStep[] = [ + { + title: 'Welcome to ShipSec Studio!', + description: "Let's take a quick tour of the platform.", + icon: Sparkles, + content: + "ShipSec Studio is your all-in-one security automation platform. We'll walk you through the key features to get you started.", + gradient: 'from-purple-500/20 via-violet-500/10 to-transparent', + iconColor: 'text-purple-500', + target: null, + }, + { + title: 'Workflow Builder', + description: 'Your starting point for security automation.', + icon: Workflow, + content: + 'Design powerful security workflows using the visual builder. Drag and drop nodes, configure actions, and chain them together.', + gradient: 'from-blue-500/20 via-blue-500/10 to-transparent', + iconColor: 'text-blue-500', + target: '[data-onboarding="workflow-builder"]', + }, + { + title: 'Template Library', + description: 'Get started faster with pre-built templates.', + icon: Package, + content: + 'Browse ready-made workflow templates for common security tasks. Pick one and customize it to fit your needs.', + gradient: 'from-green-500/20 via-green-500/10 to-transparent', + iconColor: 'text-green-500', + target: '[data-onboarding="template-library"]', + }, + { + title: 'Schedules', + description: 'Automate workflow execution on a schedule.', + icon: CalendarClock, + content: + 'Set up schedules to run workflows at specific intervals. Automate recurring security scans and checks effortlessly.', + gradient: 'from-orange-500/20 via-orange-500/10 to-transparent', + iconColor: 'text-orange-500', + target: '[data-onboarding="schedules"]', + }, + { + title: 'Action Center', + description: 'Monitor and manage workflow results.', + icon: Zap, + content: + 'Review workflow executions, inspect findings, and take action on results — all from one central dashboard.', + gradient: 'from-yellow-500/20 via-yellow-500/10 to-transparent', + iconColor: 'text-yellow-500', + target: '[data-onboarding="action-center"]', + }, + { + title: 'Manage Settings', + description: 'Secrets, API Keys, and MCP Servers.', + icon: KeyRound, + content: + 'Store API keys, tokens, and credentials securely. Configure MCP servers and manage all your sensitive data from here.', + gradient: 'from-red-500/20 via-red-500/10 to-transparent', + iconColor: 'text-red-500', + target: '[data-onboarding="manage-section"]', + }, +]; + +const SPOTLIGHT_PADDING = 10; +const TOOLTIP_GAP = 16; +const TOOLTIP_WIDTH = 380; + +interface OnboardingDialogProps { + open: boolean; + onComplete: () => void; + currentStep: number; + onStepChange: (step: number) => void; +} + +export function OnboardingDialog({ + open, + onComplete, + currentStep, + onStepChange, +}: OnboardingDialogProps) { + const [targetRect, setTargetRect] = useState(null); + const tooltipRef = useRef(null); + + const step = ONBOARDING_STEPS[currentStep]; + const isLastStep = currentStep === ONBOARDING_STEPS.length - 1; + const Icon = step?.icon ?? Sparkles; + const isCenter = !step?.target; + + // Track target element position + useEffect(() => { + if (!open) { + setTargetRect(null); + return; + } + + if (!step?.target) { + setTargetRect(null); + return; + } + + const timer = setTimeout(() => { + const el = document.querySelector(step.target!); + if (el) { + setTargetRect(el.getBoundingClientRect()); + } else { + setTargetRect(null); + } + }, 120); + + return () => clearTimeout(timer); + }, [open, step?.target, currentStep]); + + // Update on window resize + useEffect(() => { + if (!open || !step?.target) return; + + const updateRect = () => { + const el = document.querySelector(step.target!); + if (el) { + setTargetRect(el.getBoundingClientRect()); + } + }; + + window.addEventListener('resize', updateRect); + return () => window.removeEventListener('resize', updateRect); + }, [open, step?.target]); + + const handleNext = useCallback(() => { + if (isLastStep) { + onComplete(); + } else { + onStepChange(currentStep + 1); + } + }, [isLastStep, onComplete, onStepChange, currentStep]); + + const handleBack = useCallback(() => { + if (currentStep > 0) { + onStepChange(currentStep - 1); + } + }, [currentStep, onStepChange]); + + // Global keyboard handler + useEffect(() => { + if (!open) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight') { + e.preventDefault(); + handleNext(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + handleBack(); + } else if (e.key === 'Escape') { + e.preventDefault(); + onComplete(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [open, handleNext, handleBack, onComplete]); + + if (!open || !step) return null; + + const getTooltipStyle = (): React.CSSProperties => { + if (isCenter || !targetRect) { + return { + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: TOOLTIP_WIDTH, + }; + } + + const tooltipTop = Math.max( + 16, + Math.min(window.innerHeight - 340, targetRect.top + targetRect.height / 2 - 100), + ); + + const tooltipLeft = targetRect.right + TOOLTIP_GAP; + + if (tooltipLeft + TOOLTIP_WIDTH > window.innerWidth - 16) { + return { + top: targetRect.bottom + TOOLTIP_GAP, + left: Math.max(16, targetRect.left + targetRect.width / 2 - TOOLTIP_WIDTH / 2), + width: TOOLTIP_WIDTH, + }; + } + + return { + top: tooltipTop, + left: tooltipLeft, + width: TOOLTIP_WIDTH, + }; + }; + + return createPortal( +
+