diff --git a/frontend/src/core/App.tsx b/frontend/src/core/App.tsx index c21ad03b3..be5e561e5 100644 --- a/frontend/src/core/App.tsx +++ b/frontend/src/core/App.tsx @@ -3,7 +3,7 @@ import { AppProviders } from "@app/components/AppProviders"; import { AppLayout } from "@app/components/AppLayout"; import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import HomePage from "@app/pages/HomePage"; -import OnboardingTour from "@app/components/onboarding/OnboardingTour"; +import Onboarding from "@app/components/onboarding/Onboarding"; // Import global styles import "@app/styles/tailwind.css"; @@ -19,7 +19,7 @@ export default function App() { - + diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index e65388ada..7e47d00e0 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -12,7 +12,6 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from import { RightRailProvider } from "@app/contexts/RightRailContext"; import { ViewerProvider } from "@app/contexts/ViewerContext"; import { SignatureProvider } from "@app/contexts/SignatureContext"; -import { OnboardingProvider } from "@app/contexts/OnboardingContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; import { PageEditorProvider } from "@app/contexts/PageEditorContext"; @@ -77,7 +76,6 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/flowResolver.ts b/frontend/src/core/components/onboarding/InitialOnboardingModal/flowResolver.ts deleted file mode 100644 index ec742c143..000000000 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal/flowResolver.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { FLOW_SEQUENCES, type SlideId } from '@app/components/onboarding/onboardingFlowConfig'; - -export type FlowType = 'login-admin' | 'login-user' | 'no-login' | 'no-login-admin'; - -export interface FlowConfig { - type: FlowType; - ids: SlideId[]; -} - -export function resolveFlow(enableLogin: boolean, isAdmin: boolean, selfReportedAdmin: boolean): FlowConfig { - if (!enableLogin) { - return selfReportedAdmin - ? { - type: 'no-login-admin', - ids: [...FLOW_SEQUENCES.noLoginBase, ...FLOW_SEQUENCES.noLoginAdmin], - } - : { - type: 'no-login', - ids: FLOW_SEQUENCES.noLoginBase, - }; - } - - return isAdmin - ? { - type: 'login-admin', - ids: FLOW_SEQUENCES.loginAdmin, - } - : { - type: 'login-user', - ids: FLOW_SEQUENCES.loginUser, - }; -} - diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx index 4325d9b2f..ae90c27da 100644 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx @@ -6,7 +6,7 @@ import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onb import type { LicenseNotice } from '@app/types/types'; import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig'; -interface RenderButtonsProps { +interface SlideButtonsProps { slideDefinition: { buttons: ButtonDefinition[]; id: string; @@ -16,7 +16,7 @@ interface RenderButtonsProps { onAction: (action: ButtonAction) => void; } -export function renderButtons({ slideDefinition, licenseNotice, flowState, onAction }: RenderButtonsProps) { +export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) { const { t } = useTranslation(); const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left'); const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right'); @@ -105,5 +105,4 @@ export function renderButtons({ slideDefinition, licenseNotice, flowState, onAct {rightButtons.map(renderButton)} ); -} - +} \ No newline at end of file diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/types.ts b/frontend/src/core/components/onboarding/InitialOnboardingModal/types.ts deleted file mode 100644 index d8bc96a1c..000000000 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { LicenseNotice } from '@app/types/types'; - -export interface InitialOnboardingModalProps { - opened: boolean; - onClose: () => void; - onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void; - onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void; -} - -export interface OnboardingState { - step: number; - selectedRole: 'admin' | 'user' | null; - selfReportedAdmin: boolean; -} - -export const DEFAULT_STATE: OnboardingState = { - step: 0, - selectedRole: null, - selfReportedAdmin: false, -}; - diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts b/frontend/src/core/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts deleted file mode 100644 index 066a777cc..000000000 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import { usePreferences } from '@app/contexts/PreferencesContext'; -import { useOnboarding } from '@app/contexts/OnboardingContext'; -import { useOs } from '@app/hooks/useOs'; -import { useNavigate } from 'react-router-dom'; -import { - SLIDE_DEFINITIONS, - type ButtonAction, - type FlowState, - type SlideId, -} from '@app/components/onboarding/onboardingFlowConfig'; -import type { LicenseNotice } from '@app/types/types'; -import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver'; -import { useServerExperience } from '@app/hooks/useServerExperience'; -import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types'; -import { DOWNLOAD_URLS } from '@app/constants/downloads'; - -interface UseInitialOnboardingStateResult { - state: OnboardingState; - totalSteps: number; - slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId]; - currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>; - licenseNotice: LicenseNotice; - flowState: FlowState; - closeAndMarkSeen: () => void; - handleButtonAction: (action: ButtonAction) => void; -} - -export function useInitialOnboardingState({ - opened, - onClose, - onRequestServerLicense, - onLicenseNoticeUpdate, -}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null { - const { preferences, updatePreference } = usePreferences(); - const { startTour } = useOnboarding(); - const { - loginEnabled: loginEnabledFromServer, - configIsAdmin, - totalUsers: serverTotalUsers, - userCountResolved: serverUserCountResolved, - freeTierLimit, - hasPaidLicense, - scenarioKey, - setSelfReportedAdmin, - isNewServer, - } = useServerExperience(); - const osType = useOs(); - const navigate = useNavigate(); - const selectedDownloadUrlRef = useRef(''); - - const [state, setState] = useState(DEFAULT_STATE); - - const resetState = useCallback(() => { - setState(DEFAULT_STATE); - }, []); - - useEffect(() => { - if (!opened) { - resetState(); - } - }, [opened, resetState]); - - const handleRoleSelect = useCallback( - (role: 'admin' | 'user' | null) => { - const isAdminSelection = role === 'admin'; - setState((prev) => ({ - ...prev, - selectedRole: role, - selfReportedAdmin: isAdminSelection, - })); - - if (typeof window !== 'undefined') { - if (isAdminSelection) { - window.localStorage.setItem('stirling-self-reported-admin', 'true'); - } else { - window.localStorage.removeItem('stirling-self-reported-admin'); - } - } - - setSelfReportedAdmin(isAdminSelection); - }, - [setSelfReportedAdmin], - ); - - const closeAndMarkSeen = useCallback(() => { - if (!preferences.hasSeenIntroOnboarding) { - updatePreference('hasSeenIntroOnboarding', true); - } - onClose(); - }, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]); - - const isAdmin = configIsAdmin; - const enableLogin = loginEnabledFromServer; - - const effectiveEnableLogin = enableLogin; - const effectiveIsAdmin = isAdmin; - const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin; - - useEffect(() => { - if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) { - handleRoleSelect('admin'); - } - }, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]); - - const shouldUseServerCount = - (effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin; - const licenseUserCountFromServer = - shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null; - - const effectiveLicenseUserCount = licenseUserCountFromServer ?? null; - - const os = useMemo(() => { - switch (osType) { - case 'windows': - return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS }; - case 'mac-apple': - return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON }; - case 'mac-intel': - return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL }; - case 'linux-x64': - case 'linux-arm64': - return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS }; - default: - return { label: '', url: '' }; - } - }, [osType]); - - const osOptions = useMemo(() => { - const options = [ - { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' }, - { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' }, - { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' }, - { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' }, - ]; - return options.filter(opt => opt.url); - }, []); - - const resolvedFlow = useMemo( - () => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin), - [effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin], - ); - const shouldSkipSecurityCheck = shouldAssumeAdminForNewServer; - const flowSlideIds = useMemo( - () => - shouldSkipSecurityCheck - ? resolvedFlow.ids.filter((id) => id !== 'security-check') - : resolvedFlow.ids, - [resolvedFlow.ids, shouldSkipSecurityCheck], - ); - const flowType = resolvedFlow.type; - const totalSteps = flowSlideIds.length; - const maxIndex = Math.max(totalSteps - 1, 0); - - useEffect(() => { - if (state.step >= flowSlideIds.length) { - setState((prev) => ({ - ...prev, - step: Math.max(flowSlideIds.length - 1, 0), - })); - } - }, [flowSlideIds.length, state.step]); - - const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1]; - const slideDefinition = SLIDE_DEFINITIONS[currentSlideId]; - - if (!slideDefinition) { - return null; - } - - const scenarioProvidesInfo = - scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed'; - const scenarioIndicatesAdmin = scenarioProvidesInfo - ? scenarioKey!.includes('admin') - : state.selfReportedAdmin || effectiveIsAdmin; - const scenarioIndicatesOverLimit = scenarioProvidesInfo - ? scenarioKey!.includes('over-limit') - : effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit; - const scenarioRequiresLicense = - scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true; - - const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense; - - const licenseNotice = useMemo( - () => ({ - totalUsers: effectiveLicenseUserCount, - freeTierLimit, - isOverLimit: scenarioIndicatesOverLimit, - requiresLicense: shouldShowServerLicenseInfo, - }), - [ - effectiveLicenseUserCount, - freeTierLimit, - scenarioIndicatesOverLimit, - shouldShowServerLicenseInfo, - ], - ); - - const requestServerLicenseIfNeeded = useCallback( - (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => { - if (!shouldShowServerLicenseInfo) { - return; - } - onRequestServerLicense?.(options); - }, - [onRequestServerLicense, shouldShowServerLicenseInfo], - ); - - useEffect(() => { - onLicenseNoticeUpdate?.(licenseNotice); - }, [licenseNotice, onLicenseNoticeUpdate]); - - // Initialize ref with default URL - useEffect(() => { - if (!selectedDownloadUrlRef.current && os.url) { - selectedDownloadUrlRef.current = os.url; - } - }, [os.url]); - - const handleDownloadUrlChange = useCallback((url: string) => { - selectedDownloadUrlRef.current = url; - }, []); - - const currentSlide = slideDefinition.createSlide({ - osLabel: os.label, - osUrl: os.url, - osOptions, - onDownloadUrlChange: handleDownloadUrlChange, - selectedRole: state.selectedRole, - onRoleSelect: handleRoleSelect, - licenseNotice, - loginEnabled: effectiveEnableLogin, - }); - - const goNext = useCallback(() => { - setState((prev) => ({ - ...prev, - step: Math.min(prev.step + 1, maxIndex), - })); - }, [maxIndex]); - - const goPrev = useCallback(() => { - setState((prev) => ({ - ...prev, - step: Math.max(prev.step - 1, 0), - })); - }, []); - - const launchTour = useCallback( - (mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => { - if (options?.closeOnboardingSlides) { - closeAndMarkSeen(); - } - - startTour(mode, { - source: 'initial-onboarding-modal', - metadata: { - hasCompletedOnboarding: preferences.hasCompletedOnboarding, - toolPanelModePromptSeen: preferences.toolPanelModePromptSeen, - selfReportedAdmin: state.selfReportedAdmin, - }, - }); - }, - [closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin], - ); - - const handleButtonAction = useCallback( - (action: ButtonAction) => { - const currentSlideIdLocal = currentSlideId; - const shouldAutoLaunchLoginUserTour = - flowType === 'login-user' && currentSlideIdLocal === 'desktop-install'; - - switch (action) { - case 'next': - if (shouldAutoLaunchLoginUserTour) { - launchTour('tools', { closeOnboardingSlides: true }); - return; - } - goNext(); - return; - case 'prev': - goPrev(); - return; - case 'close': - closeAndMarkSeen(); - return; - case 'download-selected': { - const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl; - if (downloadUrl) { - window.open(downloadUrl, '_blank', 'noopener'); - } - if (shouldAutoLaunchLoginUserTour) { - launchTour('tools', { closeOnboardingSlides: true }); - return; - } - goNext(); - return; - } - case 'complete-close': - updatePreference('hasCompletedOnboarding', true); - closeAndMarkSeen(); - return; - case 'security-next': - if (!state.selectedRole) { - return; - } - if (state.selectedRole === 'admin') { - goNext(); - } else { - launchTour('tools', { closeOnboardingSlides: true }); - } - return; - case 'launch-admin': - requestServerLicenseIfNeeded({ - deferUntilTourComplete: true, - selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin, - }); - launchTour('admin', { closeOnboardingSlides: true }); - return; - case 'launch-tools': - launchTour('tools', { closeOnboardingSlides: true }); - return; - case 'launch-auto': { - const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools'; - if (launchMode === 'admin') { - requestServerLicenseIfNeeded({ - deferUntilTourComplete: true, - selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin, - }); - } - launchTour(launchMode, { closeOnboardingSlides: true }); - return; - } - case 'skip-to-license': - updatePreference('hasCompletedOnboarding', true); - requestServerLicenseIfNeeded({ - deferUntilTourComplete: false, - selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin, - }); - closeAndMarkSeen(); - return; - case 'see-plans': - closeAndMarkSeen(); - navigate('/settings/adminPlan'); - return; - default: - return; - } - }, - [ - closeAndMarkSeen, - currentSlide, - effectiveIsAdmin, - flowType, - goNext, - goPrev, - launchTour, - navigate, - requestServerLicenseIfNeeded, - onRequestServerLicense, - os.url, - state.selectedRole, - state.selfReportedAdmin, - updatePreference, - ], - ); - - const flowState: FlowState = { selectedRole: state.selectedRole }; - - return { - state, - totalSteps, - slideDefinition, - currentSlide, - licenseNotice, - flowState, - closeAndMarkSeen, - handleButtonAction, - }; -} - diff --git a/frontend/src/core/components/onboarding/Onboarding.tsx b/frontend/src/core/components/onboarding/Onboarding.tsx new file mode 100644 index 000000000..eb752ec6a --- /dev/null +++ b/frontend/src/core/components/onboarding/Onboarding.tsx @@ -0,0 +1,319 @@ +import { useEffect, useMemo, useCallback, useState } from 'react'; +import { type StepType } from '@reactour/tour'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { isAuthRoute } from '@app/constants/routes'; +import { dispatchTourState } from '@app/constants/events'; +import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator'; +import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage'; +import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour'; +import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide'; +import { + useServerLicenseRequest, + useTourRequest, +} from '@app/components/onboarding/useOnboardingEffects'; +import { useOnboardingDownload } from '@app/components/onboarding/useOnboardingDownload'; +import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from '@app/components/onboarding/onboardingFlowConfig'; +import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt'; +import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext'; +import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext'; +import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig'; +import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig'; +import { removeAllGlows } from '@app/components/onboarding/tourGlow'; +import { useFilesModalContext } from '@app/contexts/FilesModalContext'; +import { useServerExperience } from '@app/hooks/useServerExperience'; +import AdminAnalyticsChoiceModal from '@app/components/shared/AdminAnalyticsChoiceModal'; +import '@app/components/onboarding/OnboardingTour.css'; + +export default function Onboarding() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const { state, actions } = useOnboardingOrchestrator(); + const serverExperience = useServerExperience(); + const onAuthRoute = isAuthRoute(location.pathname); + const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state; + + const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload(); + const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest(); + const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest(); + + const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => { + actions.updateRuntimeState({ selectedRole: role }); + serverExperience.setSelfReportedAdmin(role === 'admin'); + }, [actions, serverExperience]); + + const handlePasswordChanged = useCallback(() => { + actions.updateRuntimeState({ requiresPasswordChange: false }); + window.location.href = '/login'; + }, [actions]); + + const handleButtonAction = useCallback((action: ButtonAction) => { + switch (action) { + case 'next': + case 'complete-close': + actions.complete(); + break; + case 'prev': + actions.prev(); + break; + case 'close': + actions.skip(); + break; + case 'download-selected': + handleDownloadSelected(); + actions.complete(); + break; + case 'security-next': + if (!runtimeState.selectedRole) return; + if (runtimeState.selectedRole !== 'admin') { + actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' }); + } + actions.complete(); + break; + case 'launch-admin': + actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' }); + actions.complete(); + break; + case 'launch-tools': + actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' }); + actions.complete(); + break; + case 'launch-auto': { + const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools'; + actions.updateRuntimeState({ tourRequested: true, tourType }); + actions.complete(); + break; + } + case 'skip-to-license': + markStepSeen('tour'); + actions.updateRuntimeState({ tourRequested: false }); + actions.complete(); + break; + case 'see-plans': + actions.complete(); + navigate('/settings/adminPlan'); + break; + } + }, [actions, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]); + + const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false; + const [isTourOpen, setIsTourOpen] = useState(false); + + useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]); + + const { openFilesModal, closeFilesModal } = useFilesModalContext(); + const tourOrch = useTourOrchestration(); + const adminTourOrch = useAdminTourOrchestration(); + + const userStepsConfig = useMemo( + () => createUserStepsConfig({ + t, + actions: { + saveWorkbenchState: tourOrch.saveWorkbenchState, + closeFilesModal, + backToAllTools: tourOrch.backToAllTools, + selectCropTool: tourOrch.selectCropTool, + loadSampleFile: tourOrch.loadSampleFile, + switchToViewer: tourOrch.switchToViewer, + switchToPageEditor: tourOrch.switchToPageEditor, + switchToActiveFiles: tourOrch.switchToActiveFiles, + selectFirstFile: tourOrch.selectFirstFile, + pinFile: tourOrch.pinFile, + modifyCropSettings: tourOrch.modifyCropSettings, + executeTool: tourOrch.executeTool, + openFilesModal, + }, + }), + [t, tourOrch, closeFilesModal, openFilesModal] + ); + + const adminStepsConfig = useMemo( + () => createAdminStepsConfig({ + t, + actions: { + saveAdminState: adminTourOrch.saveAdminState, + openConfigModal: adminTourOrch.openConfigModal, + navigateToSection: adminTourOrch.navigateToSection, + scrollNavToSection: adminTourOrch.scrollNavToSection, + }, + }), + [t, adminTourOrch] + ); + + const tourSteps = useMemo(() => { + const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig; + return Object.values(config); + }, [adminStepsConfig, runtimeState.tourType, userStepsConfig]); + + useEffect(() => { + if (currentStep?.id === 'tour' && !isTourOpen) { + markStepSeen('tour'); + setIsTourOpen(true); + } + }, [currentStep, isTourOpen, activeFlow]); + + useEffect(() => { + if (externalTourRequested) { + actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType }); + markStepSeen('tour'); + setIsTourOpen(true); + clearTourRequest(); + } + }, [externalTourRequested, requestedTourType, actions, clearTourRequest]); + + useEffect(() => { + if (!isTourOpen) removeAllGlows(); + return () => removeAllGlows(); + }, [isTourOpen]); + + const finishTour = useCallback(() => { + setIsTourOpen(false); + if (runtimeState.tourType === 'admin') { + adminTourOrch.restoreAdminState(); + } else { + tourOrch.restoreWorkbenchState(); + } + markStepSeen('tour'); + if (currentStep?.id === 'tour') actions.complete(); + }, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]); + + const handleAdvanceTour = useCallback((args: AdvanceArgs) => { + const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args; + if (steps && tourCurrentStep === steps.length - 1) { + setIsOpen(false); + finishTour(); + } else if (steps) { + setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1)); + } + }, [finishTour]); + + const handleCloseTour = useCallback((args: CloseArgs) => { + args.setIsOpen(false); + finishTour(); + }, [finishTour]); + + const currentSlideDefinition = useMemo(() => { + if (!currentStep || currentStep.type !== 'modal-slide' || !currentStep.slideId) { + return null; + } + return SLIDE_DEFINITIONS[currentStep.slideId as SlideId]; + }, [currentStep]); + + const currentSlideContent = useMemo(() => { + if (!currentSlideDefinition) return null; + return currentSlideDefinition.createSlide({ + osLabel: osInfo.label, + osUrl: osInfo.url, + osOptions, + onDownloadUrlChange: setSelectedDownloadUrl, + selectedRole: runtimeState.selectedRole, + onRoleSelect: handleRoleSelect, + licenseNotice: runtimeState.licenseNotice, + loginEnabled: serverExperience.loginEnabled, + firstLoginUsername: runtimeState.firstLoginUsername, + onPasswordChanged: handlePasswordChanged, + usingDefaultCredentials: runtimeState.usingDefaultCredentials, + }); + }, [currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]); + + const modalSlideCount = useMemo(() => { + return activeFlow.filter((step) => step.type === 'modal-slide').length; + }, [activeFlow]); + + const currentModalSlideIndex = useMemo(() => { + if (!currentStep || currentStep.type !== 'modal-slide') return 0; + const modalSlides = activeFlow.filter((step) => step.type === 'modal-slide'); + return modalSlides.findIndex((step) => step.id === currentStep.id); + }, [activeFlow, currentStep]); + + if (onAuthRoute) { + return null; + } + + if (showLicenseSlide) { + const slideDefinition = SLIDE_DEFINITIONS['server-license']; + const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice; + const slideContent = slideDefinition.createSlide({ + osLabel: '', + osUrl: '', + osOptions: [], + onDownloadUrlChange: () => {}, + selectedRole: null, + onRoleSelect: () => {}, + licenseNotice: effectiveLicenseNotice, + loginEnabled: serverExperience.loginEnabled, + }); + + return ( + { + if (action === 'see-plans') { + closeLicenseSlide(); + navigate('/settings/adminPlan'); + } else { + closeLicenseSlide(); + } + }} + /> + ); + } + + if (isLoading || !isActive || !currentStep) { + return ( + + ); + } + + switch (currentStep.type) { + case 'tool-prompt': + return ; + + case 'tour': + return ( + + ); + + case 'analytics-modal': + return ; + + case 'modal-slide': + if (!currentSlideDefinition || !currentSlideContent) return null; + return ( + + ); + + default: + return null; + } +} diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/index.tsx b/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx similarity index 55% rename from frontend/src/core/components/onboarding/InitialOnboardingModal/index.tsx rename to frontend/src/core/components/onboarding/OnboardingModalSlide.tsx index 0d8214270..381a608d8 100644 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal/index.tsx +++ b/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx @@ -1,33 +1,44 @@ +/** + * OnboardingModalSlide Component + * + * Renders a single modal slide in the onboarding flow. + * Handles the hero image, content, stepper, and button actions. + */ + import React from 'react'; import { Modal, Stack } from '@mantine/core'; import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined'; -import LocalIcon from '@app/components/shared/LocalIcon'; + +import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig'; +import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig'; +import type { SlideConfig } from '@app/types/types'; import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground'; import OnboardingStepper from '@app/components/onboarding/OnboardingStepper'; -import { renderButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons'; -import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; -import type { InitialOnboardingModalProps } from '@app/components/onboarding/InitialOnboardingModal/types'; -import { useInitialOnboardingState } from '@app/components/onboarding/InitialOnboardingModal/useInitialOnboardingState'; +import { SlideButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons'; +import LocalIcon from '@app/components/shared/LocalIcon'; import { BASE_PATH } from '@app/constants/app'; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; +import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; -export default function InitialOnboardingModal(props: InitialOnboardingModalProps) { - const flow = useInitialOnboardingState(props); +interface OnboardingModalSlideProps { + slideDefinition: SlideDefinition; + slideContent: SlideConfig; + runtimeState: OnboardingRuntimeState; + modalSlideCount: number; + currentModalSlideIndex: number; + onSkip: () => void; + onAction: (action: ButtonAction) => void; +} - if (!flow) { - return null; - } - - const { - state, - totalSteps, - currentSlide, - slideDefinition, - licenseNotice, - flowState, - closeAndMarkSeen, - handleButtonAction, - } = flow; +export default function OnboardingModalSlide({ + slideDefinition, + slideContent, + runtimeState, + modalSlideCount, + currentModalSlideIndex, + onSkip, + onAction, +}: OnboardingModalSlideProps) { const renderHero = () => { if (slideDefinition.hero.type === 'dual-icon') { @@ -48,6 +59,9 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp {slideDefinition.hero.type === 'shield' && ( )} + {slideDefinition.hero.type === 'lock' && ( + + )} {slideDefinition.hero.type === 'diamond' && } {slideDefinition.hero.type === 'logo' && ( Stirling logo @@ -58,8 +72,8 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp return (
-
+
{renderHero()}
-
+
- {currentSlide.title} + {slideContent.title}
-
- {currentSlide.body} +
+ {slideContent.body}
- +
- {renderButtons({ - slideDefinition, - licenseNotice, - flowState, - onAction: handleButtonAction, - })} +
diff --git a/frontend/src/core/components/onboarding/OnboardingTour.tsx b/frontend/src/core/components/onboarding/OnboardingTour.tsx index b044fd3e5..571596798 100644 --- a/frontend/src/core/components/onboarding/OnboardingTour.tsx +++ b/frontend/src/core/components/onboarding/OnboardingTour.tsx @@ -1,231 +1,151 @@ -import React, { useEffect, useMemo } from "react"; -import { TourProvider, type StepType } from '@reactour/tour'; -import { useTranslation } from 'react-i18next'; +/** + * OnboardingTour Component + * + * Reusable tour wrapper that encapsulates all Reactour configuration. + * Used by the main Onboarding component for both the 'tour' step and + * when the tour is open but onboarding is inactive. + */ + +import React from 'react'; +import { TourProvider, useTour, type StepType } from '@reactour/tour'; import { CloseButton, ActionIcon } from '@mantine/core'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; -import CheckIcon from '@mui/icons-material/Check'; -import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal'; -import ServerLicenseModal from '@app/components/onboarding/ServerLicenseModal'; -import '@app/components/onboarding/OnboardingTour.css'; -import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt'; -import { useFilesModalContext } from '@app/contexts/FilesModalContext'; -import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext'; -import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext'; -import { useOnboardingFlow } from '@app/components/onboarding/hooks/useOnboardingFlow'; -import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig'; -import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig'; -import { removeAllGlows } from '@app/components/onboarding/tourGlow'; -import TourContent from '@app/components/onboarding/TourContent'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import '@app/components/onboarding/OnboardingTour.css'; -import i18n from "@app/i18n"; +import CheckIcon from '@mui/icons-material/Check'; +import type { TFunction } from 'i18next'; +import i18n from '@app/i18n'; -export default function OnboardingTour() { - const { t } = useTranslation(); - const flow = useOnboardingFlow(); - const { openFilesModal, closeFilesModal } = useFilesModalContext(); - const { - saveWorkbenchState, - restoreWorkbenchState, - backToAllTools, - selectCropTool, - loadSampleFile, - switchToViewer, - switchToPageEditor, - switchToActiveFiles, - selectFirstFile, - pinFile, - modifyCropSettings, - executeTool, - } = useTourOrchestration(); - const { - saveAdminState, - restoreAdminState, - openConfigModal, - navigateToSection, - scrollNavToSection, - } = useAdminTourOrchestration(); +/** + * TourContent - Controls the tour visibility + * Syncs the forceOpen prop with the reactour tour state. + */ +function TourContent({ forceOpen = false }: { forceOpen?: boolean }) { + const { setIsOpen, setCurrentStep } = useTour(); + const previousIsOpenRef = React.useRef(forceOpen); - const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false; + React.useEffect(() => { + const wasClosedNowOpen = !previousIsOpenRef.current && forceOpen; + previousIsOpenRef.current = forceOpen; - useEffect(() => { - if (!flow.isTourOpen) { - removeAllGlows(); + if (wasClosedNowOpen) { + setCurrentStep(0); } - return () => removeAllGlows(); - }, [flow.isTourOpen]); + setIsOpen(forceOpen); + }, [forceOpen, setIsOpen, setCurrentStep]); - const userStepsConfig = useMemo( - () => - createUserStepsConfig({ - t, - actions: { - saveWorkbenchState, - closeFilesModal, - backToAllTools, - selectCropTool, - loadSampleFile, - switchToViewer, - switchToPageEditor, - switchToActiveFiles, - selectFirstFile, - pinFile, - modifyCropSettings, - executeTool, - openFilesModal, - }, - }), - [ - t, - backToAllTools, - closeFilesModal, - executeTool, - loadSampleFile, - modifyCropSettings, - openFilesModal, - pinFile, - saveWorkbenchState, - selectCropTool, - selectFirstFile, - switchToActiveFiles, - switchToPageEditor, - switchToViewer, - ], - ); + return null; +} - const adminStepsConfig = useMemo( - () => - createAdminStepsConfig({ - t, - actions: { - saveAdminState, - openConfigModal, - navigateToSection, - scrollNavToSection, - }, - }), - [navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t], - ); +interface AdvanceArgs { + setCurrentStep: (value: number | ((prev: number) => number)) => void; + currentStep: number; + steps?: StepType[]; + setIsOpen: (value: boolean) => void; +} - const steps = useMemo(() => { - const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig; - return Object.values(config); - }, [adminStepsConfig, flow.tourType, userStepsConfig]); +interface CloseArgs { + setIsOpen: (value: boolean) => void; +} - const advanceTour = ({ - setCurrentStep, - currentStep, - steps, - setIsOpen, - }: { - setCurrentStep: (value: number | ((prev: number) => number)) => void; - currentStep: number; - steps?: StepType[]; - setIsOpen: (value: boolean) => void; - }) => { - if (steps && currentStep === steps.length - 1) { - setIsOpen(false); - if (flow.tourType === 'admin') { - restoreAdminState(); - } else { - restoreWorkbenchState(); - } - flow.handleTourCompletion(); - } else if (steps) { - setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1)); - } - }; +interface OnboardingTourProps { + tourSteps: StepType[]; + tourType: 'admin' | 'tools'; + isRTL: boolean; + t: TFunction; + isOpen: boolean; + onAdvance: (args: AdvanceArgs) => void; + onClose: (args: CloseArgs) => void; +} - const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => { - setIsOpen(false); - if (flow.tourType === 'admin') { - restoreAdminState(); - } else { - restoreWorkbenchState(); - } - flow.handleTourCompletion(); - }; +export default function OnboardingTour({ + tourSteps, + tourType, + isRTL, + t, + isOpen, + onAdvance, + onClose, +}: OnboardingTourProps) { + if (!isOpen) return null; return ( - <> - - - { - e.stopPropagation(); - advanceTour(clickProps); - }} - keyboardHandler={(e, clickProps, status) => { - if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) { - e.preventDefault(); - advanceTour(clickProps); - } else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) { - e.preventDefault(); - handleCloseTour(clickProps); - } - }} - rtl={isRTL} - styles={{ - popover: (base) => ({ - ...base, - backgroundColor: 'var(--mantine-color-body)', - color: 'var(--mantine-color-text)', - borderRadius: '8px', - padding: '20px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', - maxWidth: '400px', - }), - maskArea: (base) => ({ - ...base, - rx: 8, - }), - badge: (base) => ({ - ...base, - backgroundColor: 'var(--mantine-primary-color-filled)', - }), - controls: (base) => ({ - ...base, - justifyContent: 'center', - }), - }} - highlightedMaskClassName="tour-highlight-glow" - showNavigation={true} - showBadge={false} - showCloseButton={true} - disableInteraction={true} - disableDotsNavigation={false} - prevButton={() => null} - nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => { - const isLast = currentStep === stepsLength - 1; - const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon; - return ( - advanceTour({ setCurrentStep, currentStep, steps, setIsOpen })} - variant="subtle" - size="lg" - aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')} - > - {isLast ? : } - - ); - }} - components={{ - Close: ({ onClick }) => ( - - ), - Content: ({ content }: { content: string }) => ( -
- ), - }} - > - - - - + { + e.stopPropagation(); + onAdvance(clickProps); + }} + keyboardHandler={(e, clickProps, status) => { + if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) { + e.preventDefault(); + onAdvance(clickProps); + } else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) { + e.preventDefault(); + onClose(clickProps); + } + }} + rtl={isRTL} + styles={{ + popover: (base) => ({ + ...base, + backgroundColor: 'var(--mantine-color-body)', + color: 'var(--mantine-color-text)', + borderRadius: '8px', + padding: '20px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + maxWidth: '400px', + }), + maskArea: (base) => ({ + ...base, + rx: 8, + }), + badge: (base) => ({ + ...base, + backgroundColor: 'var(--mantine-primary-color-filled)', + }), + controls: (base) => ({ + ...base, + justifyContent: 'center', + }), + }} + highlightedMaskClassName="tour-highlight-glow" + showNavigation={true} + showBadge={false} + showCloseButton={true} + disableInteraction={true} + disableDotsNavigation={false} + prevButton={() => null} + nextButton={({ currentStep: tourCurrentStep, stepsLength, setCurrentStep, setIsOpen }) => { + const isLast = tourCurrentStep === stepsLength - 1; + const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon; + return ( + onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })} + variant="subtle" + size="lg" + aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')} + > + {isLast ? : } + + ); + }} + components={{ + Close: ({ onClick }) => ( + + ), + Content: ({ content }: { content: string }) => ( +
+ ), + }} + > + + ); } + +export type { AdvanceArgs, CloseArgs }; + diff --git a/frontend/src/core/components/onboarding/ServerLicenseModal.tsx b/frontend/src/core/components/onboarding/ServerLicenseModal.tsx deleted file mode 100644 index 9a577d04d..000000000 --- a/frontend/src/core/components/onboarding/ServerLicenseModal.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import React from 'react'; -import { Modal, Button, Group, Stack } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground'; -import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide'; -import { LicenseNotice } from '@app/types/types'; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; -import { BASE_PATH } from '@app/constants/app'; -import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; - -interface ServerLicenseModalProps { - opened: boolean; - onClose: () => void; - onSeePlans?: () => void; - licenseNotice: LicenseNotice; -} - -export default function ServerLicenseModal({ - opened, - onClose, - onSeePlans, - licenseNotice, -}: ServerLicenseModalProps) { - const { t } = useTranslation(); - const slide = React.useMemo(() => ServerLicenseSlide({ licenseNotice }), [licenseNotice]); - const primaryLabel = licenseNotice.isOverLimit - ? t('onboarding.serverLicense.upgrade', 'Upgrade now →') - : t('onboarding.serverLicense.seePlans', 'See Plans →'); - const secondaryLabel = t('onboarding.serverLicense.skip', 'Skip for now'); - - const handleSeePlans = () => { - onSeePlans?.(); - onClose(); - }; - - const secondaryStyles = { - root: { - background: 'var(--onboarding-secondary-button-bg)', - border: '1px solid var(--onboarding-secondary-button-border)', - color: 'var(--onboarding-secondary-button-text)', - }, - }; - - const primaryStyles = { - root: { - background: 'var(--onboarding-primary-button-bg)', - color: 'var(--onboarding-primary-button-text)', - }, - }; - - return ( - - -
- -
-
-
- Stirling icon -
-
-
-
- -
- -
- {slide.title} -
-
- {slide.body} -
- - - - -
-
-
-
- ); -} - diff --git a/frontend/src/core/components/onboarding/TourContent.tsx b/frontend/src/core/components/onboarding/TourContent.tsx deleted file mode 100644 index c0f787ec9..000000000 --- a/frontend/src/core/components/onboarding/TourContent.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { useTour } from '@reactour/tour'; -import { useOnboarding } from '@app/contexts/OnboardingContext'; - -export default function TourContent() { - const { isOpen } = useOnboarding(); - const { setIsOpen, setCurrentStep } = useTour(); - const previousIsOpenRef = React.useRef(isOpen); - - React.useEffect(() => { - const wasClosedNowOpen = !previousIsOpenRef.current && isOpen; - previousIsOpenRef.current = isOpen; - - if (wasClosedNowOpen) { - setCurrentStep(0); - } - setIsOpen(isOpen); - }, [isOpen, setIsOpen, setCurrentStep]); - - return null; -} - diff --git a/frontend/src/core/components/onboarding/TourWelcomeModal.tsx b/frontend/src/core/components/onboarding/TourWelcomeModal.tsx deleted file mode 100644 index 0e4eea9c9..000000000 --- a/frontend/src/core/components/onboarding/TourWelcomeModal.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Modal, Title, Text, Button, Stack, Group } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; - -interface TourWelcomeModalProps { - opened: boolean; - onStartTour: () => void; - onMaybeLater: () => void; - onDontShowAgain: () => void; -} - -export default function TourWelcomeModal({ - opened, - onStartTour, - onMaybeLater, - onDontShowAgain, -}: TourWelcomeModalProps) { - const { t } = useTranslation(); - - return ( - - - - - {t('onboarding.welcomeModal.title', 'Welcome to Stirling PDF!')} - - - {t('onboarding.welcomeModal.description', - "Would you like to take a quick 1-minute tour to learn the key features and how to get started?" - )} - - Help button in the bottom left.' - ) - }} - /> - - - - - - - - - - - - - - ); -} diff --git a/frontend/src/core/components/onboarding/adminStepsConfig.ts b/frontend/src/core/components/onboarding/adminStepsConfig.ts index 5eb993cc9..b7a5c0b66 100644 --- a/frontend/src/core/components/onboarding/adminStepsConfig.ts +++ b/frontend/src/core/components/onboarding/adminStepsConfig.ts @@ -1,8 +1,19 @@ import type { StepType } from '@reactour/tour'; import type { TFunction } from 'i18next'; -import { AdminTourStep } from '@app/components/onboarding/tourSteps'; import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow'; +export enum AdminTourStep { + WELCOME, + CONFIG_BUTTON, + SETTINGS_OVERVIEW, + TEAMS_AND_USERS, + SYSTEM_CUSTOMIZATION, + DATABASE_SECTION, + CONNECTIONS_SECTION, + ADMIN_TOOLS, + WRAP_UP, +} + interface AdminStepActions { saveAdminState: () => void; openConfigModal: () => void; diff --git a/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts b/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts deleted file mode 100644 index 139e2b58a..000000000 --- a/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { usePreferences } from '@app/contexts/PreferencesContext'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import { useOnboarding } from '@app/contexts/OnboardingContext'; -import type { LicenseNotice } from '@app/types/types'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { - ONBOARDING_SESSION_BLOCK_KEY, - ONBOARDING_SESSION_EVENT, - SERVER_LICENSE_REQUEST_EVENT, - type ServerLicenseRequestPayload, -} from '@app/constants/events'; -import { useServerExperience } from '@app/hooks/useServerExperience'; - -// Auth routes where onboarding should NOT show -const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite']; - -// Check if user has an auth token (to avoid flash before redirect) -function hasAuthToken(): boolean { - if (typeof window === 'undefined') return false; - return !!localStorage.getItem('stirling_jwt'); -} - -interface InitialModalHandlers { - opened: boolean; - onLicenseNoticeUpdate: (notice: LicenseNotice) => void; - onRequestServerLicense: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void; - onClose: () => void; -} - -interface ServerLicenseModalHandlers { - opened: boolean; - licenseNotice: LicenseNotice; - onClose: () => void; - onSeePlans: () => void; -} - -export function useOnboardingFlow() { - const { preferences, updatePreference } = usePreferences(); - const { config, loading: configLoading } = useAppConfig(); - const { completeTour, tourType, isOpen } = useOnboarding(); - const location = useLocation(); - - // Check if we're on an auth route (login, signup, etc.) - const isOnAuthRoute = AUTH_ROUTES.some(route => location.pathname.startsWith(route)); - - // Check if login is enabled but user doesn't have a token - // This prevents a flash of the modal before redirect to /login - const loginEnabled = config?.enableLogin === true; - const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken(); - - // Don't show intro onboarding: - // 1. On explicit auth routes (/login, /signup, etc.) - // 2. While config is still loading - // 3. When login is enabled but user isn't authenticated (would redirect to /login) - // This ensures: - // - If login is enabled: user must be logged in before seeing onboarding - // - If login is disabled: homepage must have rendered first - const shouldShowIntro = !preferences.hasSeenIntroOnboarding - && !isOnAuthRoute - && !configLoading - && !isUnauthenticatedWithLoginEnabled; - const isAdminUser = !!config?.isAdmin; - const { hasPaidLicense } = useServerExperience(); - - const [licenseNotice, setLicenseNotice] = useState({ - totalUsers: null, - freeTierLimit: 5, - isOverLimit: false, - requiresLicense: false, - }); - const [serverLicenseIntent, setServerLicenseIntent] = useState<'idle' | 'pending' | 'deferred'>('idle'); - const [serverLicenseSource, setServerLicenseSource] = useState<'config' | 'self-reported' | null>(null); - const [isServerLicenseOpen, setIsServerLicenseOpen] = useState(false); - const [hasShownServerLicense, setHasShownServerLicense] = useState(false); - const [toolPromptCompleted, setToolPromptCompleted] = useState( - preferences.toolPanelModePromptSeen || preferences.hasSelectedToolPanelMode, - ); - const introWasOpenRef = useRef(false); - const navigate = useNavigate(); - const onboardingSessionMarkedRef = useRef(false); - - const handleInitialModalClose = useCallback(() => { - if (!preferences.hasSeenIntroOnboarding) { - updatePreference('hasSeenIntroOnboarding', true); - } - }, [preferences.hasSeenIntroOnboarding, updatePreference]); - - const handleLicenseNoticeUpdate = useCallback((notice: LicenseNotice) => { - setLicenseNotice(notice); - }, []); - - const handleToolPromptComplete = useCallback(() => { - setToolPromptCompleted(true); - }, []); - - const requestServerLicense = useCallback( - ({ - deferUntilTourComplete = false, - selfReportedAdmin = false, - }: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean } = {}) => { - const qualifies = isAdminUser || selfReportedAdmin; - if (!qualifies) { - return; - } - if (hasPaidLicense || !licenseNotice.requiresLicense) { - return; - } - setServerLicenseSource(isAdminUser ? 'config' : 'self-reported'); - setServerLicenseIntent((prev) => { - if (prev === 'pending') { - return prev; - } - if (prev === 'deferred') { - return deferUntilTourComplete ? prev : 'pending'; - } - if (prev === 'idle') { - return deferUntilTourComplete ? 'deferred' : 'pending'; - } - return prev; - }); - }, - [hasPaidLicense, isAdminUser, licenseNotice.requiresLicense], - ); - - useEffect(() => { - if (typeof window === 'undefined') { - return; - } - - const handleServerLicenseRequested = (event: Event) => { - const { detail } = event as CustomEvent; - - if (detail?.licenseNotice) { - setLicenseNotice((prev) => ({ - ...prev, - ...detail.licenseNotice, - totalUsers: - detail.licenseNotice?.totalUsers ?? prev.totalUsers, - freeTierLimit: - detail.licenseNotice?.freeTierLimit ?? prev.freeTierLimit, - isOverLimit: - detail.licenseNotice?.isOverLimit ?? prev.isOverLimit, - requiresLicense: - detail.licenseNotice?.requiresLicense ?? prev.requiresLicense, - })); - } - - requestServerLicense({ - deferUntilTourComplete: detail?.deferUntilTourComplete ?? false, - selfReportedAdmin: detail?.selfReportedAdmin ?? false, - }); - }; - - window.addEventListener( - SERVER_LICENSE_REQUEST_EVENT, - handleServerLicenseRequested as EventListener, - ); - - return () => { - window.removeEventListener( - SERVER_LICENSE_REQUEST_EVENT, - handleServerLicenseRequested as EventListener, - ); - }; - }, [requestServerLicense]); - - useEffect(() => { - const isEligibleAdmin = - isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense; - if ( - introWasOpenRef.current && - !shouldShowIntro && - isEligibleAdmin && - toolPromptCompleted && - !hasShownServerLicense && - licenseNotice.requiresLicense && - serverLicenseIntent === 'idle' - ) { - if (!serverLicenseSource) { - setServerLicenseSource(isAdminUser ? 'config' : 'self-reported'); - } - setServerLicenseIntent('pending'); - } - introWasOpenRef.current = shouldShowIntro; - }, [ - hasShownServerLicense, - isAdminUser, - serverLicenseIntent, - shouldShowIntro, - serverLicenseSource, - toolPromptCompleted, - licenseNotice.requiresLicense, - ]); - - useEffect(() => { - const isEligibleAdmin = - isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense; - if ( - serverLicenseIntent !== 'idle' && - !shouldShowIntro && - !isOpen && - !isServerLicenseOpen && - isEligibleAdmin && - toolPromptCompleted && - licenseNotice.requiresLicense - ) { - setIsServerLicenseOpen(true); - setServerLicenseIntent(serverLicenseIntent === 'deferred' ? 'pending' : 'idle'); - } - }, [ - isAdminUser, - isOpen, - isServerLicenseOpen, - serverLicenseIntent, - shouldShowIntro, - serverLicenseSource, - toolPromptCompleted, - licenseNotice.requiresLicense, - ]); - - const handleServerLicenseClose = useCallback(() => { - setIsServerLicenseOpen(false); - setHasShownServerLicense(true); - setServerLicenseIntent('idle'); - setServerLicenseSource(null); - }, []); - - useEffect(() => { - if (onboardingSessionMarkedRef.current) { - return; - } - if (typeof window === 'undefined') { - return; - } - if (shouldShowIntro || isOpen) { - onboardingSessionMarkedRef.current = true; - window.sessionStorage.setItem(ONBOARDING_SESSION_BLOCK_KEY, 'true'); - window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT)); - } - }, [isOpen, shouldShowIntro]); - - useEffect(() => { - if (typeof window === 'undefined') { - return; - } - if (!shouldShowIntro && !isOpen) { - window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY); - window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT)); - } - }, [isOpen, shouldShowIntro]); - - const handleServerLicenseSeePlans = useCallback(() => { - handleServerLicenseClose(); - navigate('/settings/adminPlan'); - }, [handleServerLicenseClose, navigate]); - - const handleTourCompletion = useCallback(() => { - completeTour(); - if (serverLicenseIntent === 'deferred') { - setServerLicenseIntent('pending'); - } else if (tourType === 'admin' && (isAdminUser || serverLicenseSource === 'self-reported')) { - setServerLicenseSource((prev) => prev ?? (isAdminUser ? 'config' : 'self-reported')); - setServerLicenseIntent((prev) => (prev === 'pending' ? prev : 'pending')); - } - }, [ - completeTour, - isAdminUser, - serverLicenseIntent, - serverLicenseSource, - tourType, - ]); - - const initialModalProps: InitialModalHandlers = useMemo( - () => ({ - opened: shouldShowIntro, - onLicenseNoticeUpdate: handleLicenseNoticeUpdate, - onRequestServerLicense: requestServerLicense, - onClose: handleInitialModalClose, - }), - [handleInitialModalClose, handleLicenseNoticeUpdate, requestServerLicense, shouldShowIntro], - ); - - const serverLicenseModalProps: ServerLicenseModalHandlers = useMemo( - () => ({ - opened: isServerLicenseOpen, - licenseNotice, - onClose: handleServerLicenseClose, - onSeePlans: handleServerLicenseSeePlans, - }), - [handleServerLicenseClose, handleServerLicenseSeePlans, isServerLicenseOpen, licenseNotice], - ); - - return { - tourType, - isTourOpen: isOpen, - maskClassName: tourType === 'admin' ? 'admin-tour-mask' : undefined, - initialModalProps, - handleToolPromptComplete, - serverLicenseModalProps, - handleTourCompletion, - }; -} - diff --git a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts index 7243d79a2..ff8ef1ccc 100644 --- a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts +++ b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts @@ -3,16 +3,18 @@ import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstal import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide'; import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide'; import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide'; +import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide'; import { SlideConfig, LicenseNotice } from '@app/types/types'; export type SlideId = + | 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license'; -export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo'; +export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock'; export type ButtonAction = | 'next' @@ -46,6 +48,10 @@ export interface SlideFactoryParams { onRoleSelect: (role: 'admin' | 'user' | null) => void; licenseNotice?: LicenseNotice; loginEnabled?: boolean; + // First login params + firstLoginUsername?: string; + onPasswordChanged?: () => void; + usingDefaultCredentials?: boolean; } export interface HeroDefinition { @@ -71,6 +77,17 @@ export interface SlideDefinition { } export const SLIDE_DEFINITIONS: Record = { + 'first-login': { + id: 'first-login', + createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) => + FirstLoginSlide({ + username: firstLoginUsername || '', + onPasswordChanged: onPasswordChanged || (() => {}), + usingDefaultCredentials: usingDefaultCredentials || false, + }), + hero: { type: 'lock' }, + buttons: [], // Form has its own submit button + }, 'welcome': { id: 'welcome', createSlide: () => WelcomeSlide(), @@ -197,11 +214,3 @@ export const SLIDE_DEFINITIONS: Record = { }, }; -export const FLOW_SEQUENCES = { - loginAdmin: ['welcome', 'desktop-install', 'admin-overview'] as SlideId[], - loginUser: ['welcome', 'desktop-install'] as SlideId[], - noLoginBase: ['welcome', 'desktop-install', 'security-check'] as SlideId[], - noLoginAdmin: ['admin-overview'] as SlideId[], -}; - - diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts new file mode 100644 index 000000000..60843dea1 --- /dev/null +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts @@ -0,0 +1,127 @@ +export type OnboardingStepId = + | 'first-login' + | 'welcome' + | 'desktop-install' + | 'security-check' + | 'admin-overview' + | 'tool-layout' + | 'tour' + | 'server-license' + | 'analytics-choice'; + +export type OnboardingStepType = + | 'modal-slide' + | 'tool-prompt' + | 'tour' + | 'analytics-modal'; + +export interface OnboardingRuntimeState { + selectedRole: 'admin' | 'user' | null; + tourRequested: boolean; + tourType: 'admin' | 'tools'; + isDesktopApp: boolean; + analyticsNotConfigured: boolean; + analyticsEnabled: boolean; + licenseNotice: { + totalUsers: number | null; + freeTierLimit: number; + isOverLimit: boolean; + requiresLicense: boolean; + }; + requiresPasswordChange: boolean; + firstLoginUsername: string; + usingDefaultCredentials: boolean; +} + +export interface OnboardingConditionContext extends OnboardingRuntimeState { + loginEnabled: boolean; + effectiveIsAdmin: boolean; +} + +export interface OnboardingStep { + id: OnboardingStepId; + type: OnboardingStepType; + condition: (ctx: OnboardingConditionContext) => boolean; + slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license'; +} + +export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = { + selectedRole: null, + tourRequested: false, + tourType: 'tools', + isDesktopApp: false, + analyticsNotConfigured: false, + analyticsEnabled: false, + licenseNotice: { + totalUsers: null, + freeTierLimit: 5, + isOverLimit: false, + requiresLicense: false, + }, + requiresPasswordChange: false, + firstLoginUsername: '', + usingDefaultCredentials: false, +}; + +export const ONBOARDING_STEPS: OnboardingStep[] = [ + { + id: 'first-login', + type: 'modal-slide', + slideId: 'first-login', + condition: (ctx) => ctx.requiresPasswordChange, + }, + { + id: 'welcome', + type: 'modal-slide', + slideId: 'welcome', + condition: () => true, + }, + { + id: 'desktop-install', + type: 'modal-slide', + slideId: 'desktop-install', + condition: (ctx) => !ctx.isDesktopApp, + }, + { + id: 'security-check', + type: 'modal-slide', + slideId: 'security-check', + condition: (ctx) => !ctx.loginEnabled && !ctx.isDesktopApp, + }, + { + id: 'admin-overview', + type: 'modal-slide', + slideId: 'admin-overview', + condition: (ctx) => ctx.effectiveIsAdmin, + }, + { + id: 'tool-layout', + type: 'tool-prompt', + condition: () => true, + }, + { + id: 'tour', + type: 'tour', + condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin, + }, + { + id: 'server-license', + type: 'modal-slide', + slideId: 'server-license', + condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense, + }, + { + id: 'analytics-choice', + type: 'analytics-modal', + condition: (ctx) => ctx.effectiveIsAdmin && ctx.analyticsNotConfigured, + }, +]; + +export function getStepById(id: OnboardingStepId): OnboardingStep | undefined { + return ONBOARDING_STEPS.find((step) => step.id === id); +} + +export function getStepIndex(id: OnboardingStepId): number { + return ONBOARDING_STEPS.findIndex((step) => step.id === id); +} + diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts new file mode 100644 index 000000000..ef9f51be1 --- /dev/null +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts @@ -0,0 +1,97 @@ +import { type OnboardingStepId, ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig'; + +const STORAGE_PREFIX = 'onboarding'; + +export function getStorageKey(stepId: OnboardingStepId): string { + return `${STORAGE_PREFIX}::${stepId}`; +} + +export function hasSeenStep(stepId: OnboardingStepId): boolean { + if (typeof window === 'undefined') return false; + try { + return localStorage.getItem(getStorageKey(stepId)) === 'true'; + } catch { + return false; + } +} + +export function markStepSeen(stepId: OnboardingStepId): void { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(getStorageKey(stepId), 'true'); + } catch (error) { + console.error('[onboardingStorage] Error marking step as seen:', error); + } +} + +export function resetStepSeen(stepId: OnboardingStepId): void { + if (typeof window === 'undefined') return; + try { + localStorage.removeItem(getStorageKey(stepId)); + } catch (error) { + console.error('[onboardingStorage] Error resetting step seen:', error); + } +} + +export function resetAllOnboardingProgress(): void { + if (typeof window === 'undefined') return; + try { + const prefix = `${STORAGE_PREFIX}::`; + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(prefix)) keysToRemove.push(key); + } + keysToRemove.forEach((key) => localStorage.removeItem(key)); + } catch (error) { + console.error('[onboardingStorage] Error resetting all onboarding progress:', error); + } +} + +export function getOnboardingStorageState(): Record { + const state: Record = {}; + ONBOARDING_STEPS.forEach((step) => { + state[step.id] = hasSeenStep(step.id); + }); + return state; +} + +export function migrateFromLegacyPreferences(): void { + if (typeof window === 'undefined') return; + + const migrationKey = `${STORAGE_PREFIX}::migrated`; + + try { + // Skip if already migrated + if (localStorage.getItem(migrationKey) === 'true') return; + + const prefsRaw = localStorage.getItem('stirlingpdf_preferences'); + if (prefsRaw) { + const prefs = JSON.parse(prefsRaw) as Record; + + // Migrate based on legacy flags + if (prefs.hasSeenIntroOnboarding === true) { + markStepSeen('welcome'); + markStepSeen('desktop-install'); + markStepSeen('security-check'); + markStepSeen('admin-overview'); + } + + if (prefs.toolPanelModePromptSeen === true || prefs.hasSelectedToolPanelMode === true) { + markStepSeen('tool-layout'); + } + + if (prefs.hasCompletedOnboarding === true) { + markStepSeen('tour'); + markStepSeen('analytics-choice'); + markStepSeen('server-license'); + } + + } + + // Mark migration complete + localStorage.setItem(migrationKey, 'true'); + } catch { + // If migration fails, onboarding will show again - safer than hiding it + } +} diff --git a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts new file mode 100644 index 000000000..07888b7ce --- /dev/null +++ b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -0,0 +1,353 @@ +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useServerExperience } from '@app/hooks/useServerExperience'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; + +import { + ONBOARDING_STEPS, + type OnboardingStepId, + type OnboardingStep, + type OnboardingRuntimeState, + type OnboardingConditionContext, + DEFAULT_RUNTIME_STATE, +} from '@app/components/onboarding/orchestrator/onboardingConfig'; +import { + hasSeenStep, + markStepSeen, + migrateFromLegacyPreferences, +} from '@app/components/onboarding/orchestrator/onboardingStorage'; +import { accountService } from '@app/services/accountService'; + +const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite']; +const SESSION_TOUR_REQUESTED = 'onboarding::session::tour-requested'; +const SESSION_TOUR_TYPE = 'onboarding::session::tour-type'; +const SESSION_SELECTED_ROLE = 'onboarding::session::selected-role'; + +// Check if user has an auth token (to avoid flash before redirect) +function hasAuthToken(): boolean { + if (typeof window === 'undefined') return false; + return !!localStorage.getItem('stirling_jwt'); +} + +// Get initial runtime state from session storage (survives remounts) +function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState { + if (typeof window === 'undefined') { + return baseState; + } + + try { + const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true'; + const tourType = (sessionStorage.getItem(SESSION_TOUR_TYPE) as 'admin' | 'tools') || 'tools'; + const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null; + + return { + ...baseState, + tourRequested, + tourType, + selectedRole, + }; + } catch { + return baseState; + } +} + +function persistRuntimeState(state: Partial): void { + if (typeof window === 'undefined') return; + + try { + if (state.tourRequested !== undefined) { + sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false'); + } + if (state.tourType !== undefined) { + sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType); + } + if (state.selectedRole !== undefined) { + if (state.selectedRole) { + sessionStorage.setItem(SESSION_SELECTED_ROLE, state.selectedRole); + } else { + sessionStorage.removeItem(SESSION_SELECTED_ROLE); + } + } + } catch (error) { + console.error('[useOnboardingOrchestrator] Error persisting runtime state:', error); + } +} + +function clearRuntimeStateSession(): void { + if (typeof window === 'undefined') return; + + try { + sessionStorage.removeItem(SESSION_TOUR_REQUESTED); + sessionStorage.removeItem(SESSION_TOUR_TYPE); + sessionStorage.removeItem(SESSION_SELECTED_ROLE); + } catch { + // Ignore errors + } +} + +export interface OnboardingOrchestratorState { + /** Whether onboarding is currently active */ + isActive: boolean; + /** The current step being shown (null if no step is active) */ + currentStep: OnboardingStep | null; + /** Index of current step in the active flow (for display purposes) */ + currentStepIndex: number; + /** Total number of steps in the active flow */ + totalSteps: number; + /** Runtime state that affects conditions */ + runtimeState: OnboardingRuntimeState; + /** All steps that will be shown in this flow (filtered by conditions) */ + activeFlow: OnboardingStep[]; + /** Whether all steps have been seen */ + isComplete: boolean; + /** Whether we're still initializing */ + isLoading: boolean; +} + +export interface OnboardingOrchestratorActions { + /** Move to the next step */ + next: () => void; + /** Move to the previous step */ + prev: () => void; + /** Skip the current step (marks as seen but doesn't complete) */ + skip: () => void; + /** Mark current step as seen and move to next */ + complete: () => void; + /** Update runtime state (e.g., after role selection) */ + updateRuntimeState: (updates: Partial) => void; + /** Force re-evaluation of the flow (used when conditions change) */ + refreshFlow: () => void; + /** Manually start a specific step (for external triggers) */ + startStep: (stepId: OnboardingStepId) => void; + /** Close/pause onboarding (can be resumed later) */ + pause: () => void; + /** Resume onboarding from where it was paused */ + resume: () => void; +} + +export interface UseOnboardingOrchestratorResult { + state: OnboardingOrchestratorState; + actions: OnboardingOrchestratorActions; +} + +export interface UseOnboardingOrchestratorOptions { + /** Override the default runtime state (used by desktop to set isDesktopApp: true) */ + defaultRuntimeState?: OnboardingRuntimeState; +} + +export function useOnboardingOrchestrator( + options?: UseOnboardingOrchestratorOptions +): UseOnboardingOrchestratorResult { + const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE; + const serverExperience = useServerExperience(); + const { config, loading: configLoading } = useAppConfig(); + const location = useLocation(); + + const [runtimeState, setRuntimeState] = useState(() => + getInitialRuntimeState(defaultState) + ); + const [isPaused, setIsPaused] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + const [currentStepIndex, setCurrentStepIndex] = useState(-1); + const migrationDone = useRef(false); + const initialIndexSet = useRef(false); + + useEffect(() => { + if (!migrationDone.current) { + migrateFromLegacyPreferences(); + migrationDone.current = true; + } + }, []); + + useEffect(() => { + setRuntimeState((prev) => ({ + ...prev, + analyticsEnabled: config?.enableAnalytics === true, + analyticsNotConfigured: config?.enableAnalytics == null, + licenseNotice: { + totalUsers: serverExperience.totalUsers, + freeTierLimit: serverExperience.freeTierLimit, + isOverLimit: serverExperience.overFreeTierLimit ?? false, + requiresLicense: !serverExperience.hasPaidLicense && ( + serverExperience.overFreeTierLimit === true || + (serverExperience.effectiveIsAdmin && serverExperience.userCountResolved) + ), + }, + })); + }, [ + config?.enableAnalytics, + serverExperience.totalUsers, + serverExperience.freeTierLimit, + serverExperience.overFreeTierLimit, + serverExperience.hasPaidLicense, + serverExperience.effectiveIsAdmin, + serverExperience.userCountResolved, + ]); + + useEffect(() => { + const checkFirstLogin = async () => { + if (config?.enableLogin !== true || !hasAuthToken()) return; + + try { + const [accountData, loginPageData] = await Promise.all([ + accountService.getAccountData(), + accountService.getLoginPageData(), + ]); + + setRuntimeState((prev) => ({ + ...prev, + requiresPasswordChange: accountData.changeCredsFlag, + firstLoginUsername: accountData.username, + usingDefaultCredentials: loginPageData.showDefaultCredentials, + })); + } catch { + // Account endpoint failed - user not logged in or security disabled + } + }; + + if (!configLoading) { + checkFirstLogin(); + } + }, [config?.enableLogin, configLoading]); + + const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route)); + const loginEnabled = config?.enableLogin === true; + const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken(); + const shouldBlockOnboarding = isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled; + + const conditionContext = useMemo(() => ({ + ...serverExperience, + ...runtimeState, + effectiveIsAdmin: serverExperience.effectiveIsAdmin || + (!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'), + }), [serverExperience, runtimeState]); + + const activeFlow = useMemo(() => { + return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext)); + }, [conditionContext]); + + // Wait for config AND admin status before calculating initial step + const adminStatusResolved = !configLoading && ( + config?.enableLogin === false || + config?.enableLogin === undefined || + config?.isAdmin !== undefined + ); + + useEffect(() => { + if (configLoading || !adminStatusResolved || activeFlow.length === 0) return; + + let firstUnseenIndex = -1; + for (let i = 0; i < activeFlow.length; i++) { + if (!hasSeenStep(activeFlow[i].id)) { + firstUnseenIndex = i; + break; + } + } + + if (firstUnseenIndex === -1) { + setCurrentStepIndex(activeFlow.length); + initialIndexSet.current = true; + } else if (!initialIndexSet.current) { + setCurrentStepIndex(firstUnseenIndex); + initialIndexSet.current = true; + } + }, [activeFlow, configLoading, adminStatusResolved]); + + const totalSteps = activeFlow.length; + + const allStepsAlreadySeen = useMemo(() => { + if (activeFlow.length === 0) return false; + return activeFlow.every(step => hasSeenStep(step.id)); + }, [activeFlow]); + + const isComplete = isInitialized && initialIndexSet.current && + (currentStepIndex >= totalSteps || allStepsAlreadySeen); + const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps && !allStepsAlreadySeen) + ? activeFlow[currentStepIndex] + : null; + const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null; + const isLoading = configLoading || !adminStatusResolved || !isInitialized || + !initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0); + + useEffect(() => { + if (!configLoading && !isInitialized) setIsInitialized(true); + }, [configLoading, isInitialized]); + + useEffect(() => { + if (isComplete) clearRuntimeStateSession(); + }, [isComplete]); + + const next = useCallback(() => { + if (currentStep) markStepSeen(currentStep.id); + setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps)); + }, [currentStep, totalSteps]); + + const prev = useCallback(() => { + setCurrentStepIndex((prev) => Math.max(prev - 1, 0)); + }, []); + + const skip = useCallback(() => { + if (currentStep) markStepSeen(currentStep.id); + setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps)); + }, [currentStep, totalSteps]); + + const complete = useCallback(() => { + if (currentStep) markStepSeen(currentStep.id); + setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps)); + }, [currentStep, totalSteps]); + + useEffect(() => { + if (!currentStep || isLoading) { + return; + } + if (hasSeenStep(currentStep.id)) { + complete(); + } + }, [currentStep, isLoading, complete]); + + const updateRuntimeState = useCallback((updates: Partial) => { + persistRuntimeState(updates); + setRuntimeState((prev) => ({ ...prev, ...updates })); + }, []); + + const refreshFlow = useCallback(() => { + initialIndexSet.current = false; + setCurrentStepIndex(-1); + }, []); + + const startStep = useCallback((stepId: OnboardingStepId) => { + const index = activeFlow.findIndex((step) => step.id === stepId); + if (index !== -1) { + setCurrentStepIndex(index); + setIsPaused(false); + } + }, [activeFlow]); + + const pause = useCallback(() => setIsPaused(true), []); + const resume = useCallback(() => setIsPaused(false), []); + + const state: OnboardingOrchestratorState = { + isActive, + currentStep, + currentStepIndex, + totalSteps, + runtimeState, + activeFlow, + isComplete, + isLoading, + }; + + const actions: OnboardingOrchestratorActions = { + next, + prev, + skip, + complete, + updateRuntimeState, + refreshFlow, + startStep, + pause, + resume, + }; + + return { state, actions }; +} diff --git a/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx b/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx new file mode 100644 index 000000000..28a36fdeb --- /dev/null +++ b/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx @@ -0,0 +1,184 @@ +import React, { useState } from 'react'; +import { Stack, PasswordInput, Button, Alert, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { SlideConfig } from '@app/types/types'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; +import { accountService } from '@app/services/accountService'; +import { alert as showToast } from '@app/components/toast'; +import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; + +interface FirstLoginSlideProps { + username: string; + onPasswordChanged: () => void; + usingDefaultCredentials?: boolean; +} + +const DEFAULT_PASSWORD = 'stirling'; + +function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) { + const { t } = useTranslation(); + // If using default credentials, pre-fill with "stirling" - user won't see this field + const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : ''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async () => { + // Validation + if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) { + setError(t('firstLogin.allFieldsRequired', 'All fields are required')); + return; + } + + if (newPassword !== confirmPassword) { + setError(t('firstLogin.passwordsDoNotMatch', 'New passwords do not match')); + return; + } + + if (newPassword.length < 8) { + setError(t('firstLogin.passwordTooShort', 'Password must be at least 8 characters')); + return; + } + + if (newPassword === currentPassword) { + setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password')); + return; + } + + try { + setLoading(true); + setError(''); + + await accountService.changePasswordOnLogin(currentPassword, newPassword); + + showToast({ + alertType: 'success', + title: t('firstLogin.passwordChangedSuccess', 'Password changed successfully! Please log in again.') + }); + + // Clear form + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + + // Wait a moment for the user to see the success message + setTimeout(() => { + onPasswordChanged(); + }, 1500); + } catch (err) { + console.error('Failed to change password:', err); + // Extract error message from axios response if available + const axiosError = err as { response?: { data?: { message?: string } } }; + setError( + axiosError.response?.data?.message || + t('firstLogin.passwordChangeFailed', 'Failed to change password. Please check your current password.') + ); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ + + {t( + 'firstLogin.welcomeMessage', + 'For security reasons, you must change your password on your first login.' + )} + +
+ + + {t('firstLogin.loggedInAs', 'Logged in as')}: {username} + + + {error && ( + } + color="red" + variant="light" + > + {error} + + )} + + {/* Only show current password field if not using default credentials */} + {!usingDefaultCredentials && ( + setCurrentPassword(e.currentTarget.value)} + required + styles={{ + input: { height: 44 }, + }} + /> + )} + + setNewPassword(e.currentTarget.value)} + required + styles={{ + input: { height: 44 }, + }} + /> + + setConfirmPassword(e.currentTarget.value)} + required + styles={{ + input: { height: 44 }, + }} + /> + + +
+
+
+ ); +} + +export default function FirstLoginSlide({ + username, + onPasswordChanged, + usingDefaultCredentials = false, +}: FirstLoginSlideProps): SlideConfig { + return { + key: 'first-login', + title: 'Set Your Password', + body: ( + + ), + background: { + gradientStops: ['#059669', '#0891B2'], // Green to teal - security/trust colors + circles: UNIFIED_CIRCLE_CONFIG, + }, + }; +} + diff --git a/frontend/src/core/components/onboarding/tourSteps.ts b/frontend/src/core/components/onboarding/tourSteps.ts deleted file mode 100644 index a3f7b6cfc..000000000 --- a/frontend/src/core/components/onboarding/tourSteps.ts +++ /dev/null @@ -1,33 +0,0 @@ -export enum TourStep { - ALL_TOOLS, - SELECT_CROP_TOOL, - TOOL_INTERFACE, - FILES_BUTTON, - FILE_SOURCES, - WORKBENCH, - VIEW_SWITCHER, - VIEWER, - PAGE_EDITOR, - ACTIVE_FILES, - FILE_CHECKBOX, - SELECT_CONTROLS, - CROP_SETTINGS, - RUN_BUTTON, - RESULTS, - FILE_REPLACEMENT, - PIN_BUTTON, - WRAP_UP, -} - -export enum AdminTourStep { - WELCOME, - CONFIG_BUTTON, - SETTINGS_OVERVIEW, - TEAMS_AND_USERS, - SYSTEM_CUSTOMIZATION, - DATABASE_SECTION, - CONNECTIONS_SECTION, - ADMIN_TOOLS, - WRAP_UP, -} - diff --git a/frontend/src/core/components/onboarding/useOnboardingDownload.ts b/frontend/src/core/components/onboarding/useOnboardingDownload.ts new file mode 100644 index 000000000..760180031 --- /dev/null +++ b/frontend/src/core/components/onboarding/useOnboardingDownload.ts @@ -0,0 +1,79 @@ +/** + * useOnboardingDownload Hook + * + * Encapsulates OS detection and download URL logic for the desktop install slide. + */ + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useOs } from '@app/hooks/useOs'; +import { DOWNLOAD_URLS } from '@app/constants/downloads'; + +interface OsInfo { + label: string; + url: string; +} + +interface OsOption { + label: string; + url: string; + value: string; +} + +interface UseOnboardingDownloadResult { + osInfo: OsInfo; + osOptions: OsOption[]; + selectedDownloadUrl: string; + setSelectedDownloadUrl: (url: string) => void; + handleDownloadSelected: () => void; +} + +export function useOnboardingDownload(): UseOnboardingDownloadResult { + const osType = useOs(); + const [selectedDownloadUrl, setSelectedDownloadUrl] = useState(''); + + const osInfo = useMemo(() => { + switch (osType) { + case 'windows': + return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS }; + case 'mac-apple': + return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON }; + case 'mac-intel': + return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL }; + case 'linux-x64': + case 'linux-arm64': + return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS }; + default: + return { label: '', url: '' }; + } + }, [osType]); + + const osOptions = useMemo(() => [ + { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' }, + { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' }, + { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' }, + { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' }, + ].filter((opt) => opt.url), []); + + // Initialize selected URL from detected OS + useEffect(() => { + if (!selectedDownloadUrl && osInfo.url) { + setSelectedDownloadUrl(osInfo.url); + } + }, [osInfo.url, selectedDownloadUrl]); + + const handleDownloadSelected = useCallback(() => { + const downloadUrl = selectedDownloadUrl || osInfo.url; + if (downloadUrl) { + window.open(downloadUrl, '_blank', 'noopener'); + } + }, [selectedDownloadUrl, osInfo.url]); + + return { + osInfo, + osOptions, + selectedDownloadUrl, + setSelectedDownloadUrl, + handleDownloadSelected, + }; +} + diff --git a/frontend/src/core/components/onboarding/useOnboardingEffects.ts b/frontend/src/core/components/onboarding/useOnboardingEffects.ts new file mode 100644 index 000000000..54c58ecee --- /dev/null +++ b/frontend/src/core/components/onboarding/useOnboardingEffects.ts @@ -0,0 +1,74 @@ +import { useEffect, useCallback, useState } from 'react'; +import { + SERVER_LICENSE_REQUEST_EVENT, + START_TOUR_EVENT, + type ServerLicenseRequestPayload, + type TourType, + type StartTourPayload, +} from '@app/constants/events'; +import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig'; + +export function useServerLicenseRequest(): { + showLicenseSlide: boolean; + licenseNotice: OnboardingRuntimeState['licenseNotice'] | null; + closeLicenseSlide: () => void; +} { + const [showLicenseSlide, setShowLicenseSlide] = useState(false); + const [licenseNotice, setLicenseNotice] = useState(null); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleLicenseRequest = (event: Event) => { + const { detail } = event as CustomEvent; + + if (detail?.licenseNotice) { + setLicenseNotice({ + totalUsers: detail.licenseNotice.totalUsers ?? null, + freeTierLimit: detail.licenseNotice.freeTierLimit ?? 5, + isOverLimit: detail.licenseNotice.isOverLimit ?? false, + requiresLicense: true, + }); + } + + setShowLicenseSlide(true); + }; + + window.addEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest); + return () => window.removeEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest); + }, []); + + const closeLicenseSlide = useCallback(() => { + setShowLicenseSlide(false); + }, []); + + return { showLicenseSlide, licenseNotice, closeLicenseSlide }; +} + +export function useTourRequest(): { + tourRequested: boolean; + requestedTourType: TourType; + clearTourRequest: () => void; +} { + const [tourRequested, setTourRequested] = useState(false); + const [requestedTourType, setRequestedTourType] = useState('tools'); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleTourRequest = (event: Event) => { + const { detail } = event as CustomEvent; + setRequestedTourType(detail?.tourType ?? 'tools'); + setTourRequested(true); + }; + + window.addEventListener(START_TOUR_EVENT, handleTourRequest); + return () => window.removeEventListener(START_TOUR_EVENT, handleTourRequest); + }, []); + + const clearTourRequest = useCallback(() => { + setTourRequested(false); + }, []); + + return { tourRequested, requestedTourType, clearTourRequest }; +} diff --git a/frontend/src/core/components/onboarding/userStepsConfig.ts b/frontend/src/core/components/onboarding/userStepsConfig.ts index bd914210f..07e3da760 100644 --- a/frontend/src/core/components/onboarding/userStepsConfig.ts +++ b/frontend/src/core/components/onboarding/userStepsConfig.ts @@ -1,6 +1,26 @@ import type { StepType } from '@reactour/tour'; import type { TFunction } from 'i18next'; -import { TourStep } from '@app/components/onboarding/tourSteps'; + +export enum TourStep { + ALL_TOOLS, + SELECT_CROP_TOOL, + TOOL_INTERFACE, + FILES_BUTTON, + FILE_SOURCES, + WORKBENCH, + VIEW_SWITCHER, + VIEWER, + PAGE_EDITOR, + ACTIVE_FILES, + FILE_CHECKBOX, + SELECT_CONTROLS, + CROP_SETTINGS, + RUN_BUTTON, + RESULTS, + FILE_REPLACEMENT, + PIN_BUTTON, + WRAP_UP, +} interface UserStepActions { saveWorkbenchState: () => void; diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx index 2b64e60e3..6dd7491d4 100644 --- a/frontend/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/src/core/components/shared/AppConfigModal.tsx @@ -7,7 +7,7 @@ import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types'; import { useAppConfig } from '@app/contexts/AppConfigContext'; import '@app/components/shared/AppConfigModal.css'; import { useIsMobile } from '@app/hooks/useIsMobile'; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +import { Z_INDEX_CONFIG_MODAL, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; import { useLicenseAlert } from '@app/hooks/useLicenseAlert'; import { UnsavedChangesProvider, useUnsavedChanges } from '@app/contexts/UnsavedChangesContext'; @@ -122,7 +122,7 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) centered radius="lg" withCloseButton={false} - zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} + zIndex={Z_INDEX_CONFIG_MODAL} overlayProps={{ opacity: 0.35, blur: 2 }} padding={0} fullScreen={isMobile} diff --git a/frontend/src/core/components/shared/InfoBanner.tsx b/frontend/src/core/components/shared/InfoBanner.tsx index babfb95c2..f64cf6dd2 100644 --- a/frontend/src/core/components/shared/InfoBanner.tsx +++ b/frontend/src/core/components/shared/InfoBanner.tsx @@ -130,11 +130,6 @@ export const InfoBanner: React.FC = ({ onClick={onButtonClick} loading={loading} leftSection={} - styles={{ - label: { - color: textColor ?? toneStyle.text, - }, - }} > {buttonText} diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx index 38e67ce1f..029a6d567 100644 --- a/frontend/src/core/components/shared/QuickAccessBar.tsx +++ b/frontend/src/core/components/shared/QuickAccessBar.tsx @@ -15,8 +15,8 @@ import AllToolsNavButton from '@app/components/shared/AllToolsNavButton'; import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton"; import AppConfigModal from '@app/components/shared/AppConfigModal'; import { useAppConfig } from '@app/contexts/AppConfigContext'; -import { useOnboarding } from '@app/contexts/OnboardingContext'; import { useLicenseAlert } from "@app/hooks/useLicenseAlert"; +import { requestStartTour } from '@app/constants/events'; import { isNavButtonActive, @@ -34,7 +34,6 @@ const QuickAccessBar = forwardRef((_, ref) => { const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow(); const { getToolNavigation } = useSidebarNavigation(); const { config } = useAppConfig(); - const { startTour } = useOnboarding(); const licenseAlert = useLicenseAlert(); const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); @@ -269,7 +268,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
startTour('tools')} + onClick={() => requestStartTour('tools')} > {renderNavButton(buttonConfig, index)}
@@ -286,7 +285,7 @@ const QuickAccessBar = forwardRef((_, ref) => { } - onClick={() => startTour('tools')} + onClick={() => requestStartTour('tools')} >
@@ -299,7 +298,7 @@ const QuickAccessBar = forwardRef((_, ref) => { } - onClick={() => startTour('admin')} + onClick={() => requestStartTour('admin')} >
diff --git a/frontend/src/core/components/tools/ToolPanelModePrompt.tsx b/frontend/src/core/components/tools/ToolPanelModePrompt.tsx index f9e3c2e44..3b34e8af4 100644 --- a/frontend/src/core/components/tools/ToolPanelModePrompt.tsx +++ b/frontend/src/core/components/tools/ToolPanelModePrompt.tsx @@ -3,65 +3,39 @@ import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { usePreferences } from '@app/contexts/PreferencesContext'; -import { useOnboarding } from '@app/contexts/OnboardingContext'; import '@app/components/tools/ToolPanelModePrompt.css'; import { type ToolPanelMode } from '@app/constants/toolPanel'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; interface ToolPanelModePromptProps { onComplete?: () => void; + /** If true, the modal will be forced open (used by orchestrator) */ + forceOpen?: boolean; } -const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => { +/** + * ToolPanelModePrompt - Lets users choose between sidebar and fullscreen tool modes + * + * The orchestrator controls this via forceOpen prop. When shown standalone (legacy), + * it uses internal state based on preferences. + */ +const ToolPanelModePrompt = ({ onComplete, forceOpen }: ToolPanelModePromptProps = {}) => { const { t } = useTranslation(); const { toolPanelMode, setToolPanelMode } = useToolWorkflow(); const { preferences, updatePreference } = usePreferences(); - const { - startTour, - startAfterToolModeSelection, - setStartAfterToolModeSelection, - pendingTourRequest, - } = useOnboarding(); - const [opened, setOpened] = useState(false); - const { config } = useAppConfig(); - const isAdmin = !!config?.isAdmin; + const [internalOpened, setInternalOpened] = useState(false); - // Only show after the new 3-slide onboarding has been completed + // Only show after the intro onboarding has been completed (legacy standalone mode) const shouldShowPrompt = !preferences.toolPanelModePromptSeen && preferences.hasSeenIntroOnboarding; useEffect(() => { - if (shouldShowPrompt) { - setOpened(true); + if (shouldShowPrompt && forceOpen === undefined) { + setInternalOpened(true); } - }, [shouldShowPrompt]); + }, [shouldShowPrompt, forceOpen]); - const resolveRequestedTourType = (): 'admin' | 'tools' => { - if (pendingTourRequest?.type) { - return pendingTourRequest.type; - } - if (pendingTourRequest?.metadata && 'selfReportedAdmin' in pendingTourRequest.metadata) { - return pendingTourRequest.metadata.selfReportedAdmin ? 'admin' : 'tools'; - } - return isAdmin ? 'admin' : 'tools'; - }; - - const resumeDeferredTour = (context?: { selection?: ToolPanelMode; dismissed?: boolean }) => { - if (!startAfterToolModeSelection) { - return; - } - setStartAfterToolModeSelection(false); - const targetType = resolveRequestedTourType(); - startTour(targetType, { - skipToolPromptRequirement: true, - source: 'tool-panel-mode-prompt', - metadata: { - ...pendingTourRequest?.metadata, - resumedFromToolPrompt: true, - ...(context?.selection ? { selection: context.selection } : {}), - ...(context?.dismissed ? { dismissed: true } : {}), - }, - }); - }; + // If forceOpen is provided, use it; otherwise use internal state + const opened = forceOpen ?? internalOpened; + const setOpened = forceOpen !== undefined ? () => {} : setInternalOpened; const handleSelect = (mode: ToolPanelMode) => { setToolPanelMode(mode); @@ -69,8 +43,6 @@ const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => { updatePreference('toolPanelModePromptSeen', true); updatePreference('hasSelectedToolPanelMode', true); setOpened(false); - - resumeDeferredTour({ selection: mode }); onComplete?.(); }; @@ -83,7 +55,6 @@ const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => { updatePreference('hasSelectedToolPanelMode', true); updatePreference('toolPanelModePromptSeen', true); setOpened(false); - resumeDeferredTour({ dismissed: true }); onComplete?.(); }; diff --git a/frontend/src/core/constants/events.ts b/frontend/src/core/constants/events.ts index c8adc9938..e95895f93 100644 --- a/frontend/src/core/constants/events.ts +++ b/frontend/src/core/constants/events.ts @@ -1,10 +1,10 @@ import type { LicenseNotice } from '@app/types/types'; -export const ONBOARDING_SESSION_BLOCK_KEY = 'stirling-onboarding-session-active'; -export const ONBOARDING_SESSION_EVENT = 'stirling:onboarding-session-started'; export const SERVER_LICENSE_REQUEST_EVENT = 'stirling:server-license-requested'; export const UPGRADE_BANNER_TEST_EVENT = 'stirling:upgrade-banner-test'; export const UPGRADE_BANNER_ALERT_EVENT = 'stirling:upgrade-banner-alert'; +export const START_TOUR_EVENT = 'stirling:start-tour'; +export const TOUR_STATE_EVENT = 'stirling:tour-state'; export interface ServerLicenseRequestPayload { licenseNotice?: Partial; @@ -25,3 +25,29 @@ export interface UpgradeBannerAlertPayload { freeTierLimit?: number; } +export type TourType = 'admin' | 'tools'; + +export interface StartTourPayload { + tourType: TourType; +} + +export interface TourStatePayload { + isOpen: boolean; +} + +/** Helper to dispatch the start tour event */ +export function requestStartTour(tourType: TourType): void { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(START_TOUR_EVENT, { detail: { tourType } }) + ); +} + +/** Helper to dispatch tour state changes (for hiding cookie consent during tour) */ +export function dispatchTourState(isOpen: boolean): void { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(TOUR_STATE_EVENT, { detail: { isOpen } }) + ); +} + diff --git a/frontend/src/core/constants/routes.ts b/frontend/src/core/constants/routes.ts new file mode 100644 index 000000000..98081b7c4 --- /dev/null +++ b/frontend/src/core/constants/routes.ts @@ -0,0 +1,25 @@ +/** + * Route constants used across the application + */ + +/** + * Routes where onboarding, cookie consent, and upgrade banners should not appear. + * These are authentication-related pages where users are not yet logged in or + * the main app chrome is not displayed. + */ +export const AUTH_ROUTES = [ + '/login', + '/signup', + '/auth', + '/invite', + '/forgot-password', + '/reset-password', +]; + +/** + * Check if a pathname matches any auth route + */ +export function isAuthRoute(pathname: string): boolean { + return AUTH_ROUTES.some((route) => pathname.startsWith(route)); +} + diff --git a/frontend/src/core/contexts/OnboardingContext.tsx b/frontend/src/core/contexts/OnboardingContext.tsx deleted file mode 100644 index 3599af5c7..000000000 --- a/frontend/src/core/contexts/OnboardingContext.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import React, { createContext, useContext, useState, useCallback } from 'react'; -import { usePreferences } from '@app/contexts/PreferencesContext'; - -export type TourType = 'tools' | 'admin'; - -export interface StartTourOptions { - source?: string; - skipToolPromptRequirement?: boolean; - metadata?: Record; -} - -interface PendingTourRequest { - type: TourType; - source?: string; - metadata?: Record; - requestedAt: number; -} - -interface OnboardingContextValue { - isOpen: boolean; - currentStep: number; - tourType: TourType; - setCurrentStep: (step: number) => void; - startTour: (type?: TourType, options?: StartTourOptions) => void; - closeTour: () => void; - completeTour: () => void; - resetTour: (type?: TourType) => void; - startAfterToolModeSelection: boolean; - setStartAfterToolModeSelection: (value: boolean) => void; - pendingTourRequest: PendingTourRequest | null; - clearPendingTourRequest: () => void; -} - -const OnboardingContext = createContext(undefined); - -export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { preferences, updatePreference } = usePreferences(); - const [isOpen, setIsOpen] = useState(false); - const [currentStep, setCurrentStep] = useState(0); - const [tourType, setTourType] = useState('tools'); - const [startAfterToolModeSelection, setStartAfterToolModeSelection] = useState(false); - const [pendingTourRequest, setPendingTourRequest] = useState(null); - - const openTour = useCallback((type: TourType = 'tools') => { - setTourType(type); - setCurrentStep(0); - setIsOpen(true); - }, []); - - const startTour = useCallback( - (type: TourType = 'tools', options?: StartTourOptions) => { - const requestedType = type ?? 'tools'; - const source = options?.source ?? 'unspecified'; - const metadata = options?.metadata; - const skipToolPromptRequirement = options?.skipToolPromptRequirement ?? false; - const toolPromptSeen = preferences.toolPanelModePromptSeen; - const hasSelectedToolPanelMode = preferences.hasSelectedToolPanelMode; - const hasToolPreference = toolPromptSeen || hasSelectedToolPanelMode; - const shouldDefer = !skipToolPromptRequirement && !hasToolPreference; - - console.log('[onboarding] startTour invoked', { - requestedType, - source, - toolPromptSeen, - hasSelectedToolPanelMode, - shouldDefer, - hasPendingTourRequest: !!pendingTourRequest, - metadata, - }); - - if (shouldDefer) { - setPendingTourRequest({ - type: requestedType, - source, - metadata, - requestedAt: Date.now(), - }); - setStartAfterToolModeSelection(true); - console.log('[onboarding] deferring tour launch until tool panel mode selection completes', { - requestedType, - source, - }); - return; - } - - if (pendingTourRequest) { - console.log('[onboarding] clearing previous pending tour request before starting new tour', { - previousRequest: pendingTourRequest, - newType: requestedType, - source, - }); - } - - setPendingTourRequest(null); - setStartAfterToolModeSelection(false); - console.log('[onboarding] starting tour', { - requestedType, - source, - }); - openTour(requestedType); - }, - [openTour, pendingTourRequest, preferences.toolPanelModePromptSeen, preferences.hasSelectedToolPanelMode], - ); - - const closeTour = useCallback(() => { - setIsOpen(false); - }, []); - - const completeTour = useCallback(() => { - setIsOpen(false); - updatePreference('hasCompletedOnboarding', true); - }, [updatePreference]); - - const resetTour = useCallback((type: TourType = 'tools') => { - updatePreference('hasCompletedOnboarding', false); - setTourType(type); - setCurrentStep(0); - setIsOpen(true); - }, [updatePreference]); - - const clearPendingTourRequest = useCallback(() => { - if (pendingTourRequest) { - console.log('[onboarding] clearing pending tour request manually', { - pendingTourRequest, - }); - } - setPendingTourRequest(null); - setStartAfterToolModeSelection(false); - }, [pendingTourRequest]); - - return ( - - {children} - - ); -}; - -export const useOnboarding = (): OnboardingContextValue => { - const context = useContext(OnboardingContext); - if (!context) { - throw new Error('useOnboarding must be used within an OnboardingProvider'); - } - return context; -}; diff --git a/frontend/src/core/hooks/useCookieConsent.ts b/frontend/src/core/hooks/useCookieConsent.ts index fc64e9de9..a3a7977c1 100644 --- a/frontend/src/core/hooks/useCookieConsent.ts +++ b/frontend/src/core/hooks/useCookieConsent.ts @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { BASE_PATH } from '@app/constants/app'; import { useAppConfig } from '@app/contexts/AppConfigContext'; -import { useOnboarding } from '@app/contexts/OnboardingContext'; +import { TOUR_STATE_EVENT, type TourStatePayload } from '@app/constants/events'; declare global { interface Window { @@ -28,16 +28,11 @@ export const useCookieConsent = ({ }: CookieConsentConfig = {}) => { const { t } = useTranslation(); const { config } = useAppConfig(); - const { isOpen: tourIsOpen } = useOnboarding(); const [isInitialized, setIsInitialized] = useState(false); useEffect(() => { - if (!analyticsEnabled) { - console.log('Cookie consent not enabled - analyticsEnabled is false'); - return; - } + if (!analyticsEnabled) return; - // Load the cookie consent CSS files first (always needed) const mainCSS = document.createElement('link'); mainCSS.rel = 'stylesheet'; mainCSS.href = `${BASE_PATH}css/cookieconsent.css`; @@ -52,26 +47,19 @@ export const useCookieConsent = ({ document.head.appendChild(customCSS); } - // Prevent double initialization if (window.CookieConsent) { + if (forceLightMode) { + document.documentElement.classList.remove('cc--darkmode'); + } setIsInitialized(true); - // Force show the modal if it exists but isn't visible - setTimeout(() => { - window.CookieConsent?.show(); - }, 100); return; } - // Load the cookie consent library const script = document.createElement('script'); script.src = `${BASE_PATH}js/thirdParty/cookieconsent.umd.js`; script.onload = () => { - // Small delay to ensure DOM is ready setTimeout(() => { - - // Detect current theme and set appropriate mode const detectTheme = () => { - // If forceLightMode is enabled, always use light mode if (forceLightMode) { document.documentElement.classList.remove('cc--darkmode'); return false; @@ -82,7 +70,6 @@ export const useCookieConsent = ({ const hasDarkClass = document.documentElement.classList.contains('dark'); const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - // Priority: Mantine attribute > CSS classes > system preference let isDarkMode = false; if (mantineScheme) { @@ -95,24 +82,17 @@ export const useCookieConsent = ({ isDarkMode = systemPrefersDark; } - // Always explicitly set or remove the class document.documentElement.classList.toggle('cc--darkmode', isDarkMode); - return isDarkMode; }; - // Initial theme detection with slight delay to ensure DOM is ready - setTimeout(() => { - detectTheme(); - }, 50); + setTimeout(() => detectTheme(), 50); - // Check if CookieConsent is available if (!window.CookieConsent) { console.error('CookieConsent is not available on window object'); return; } - // Listen for theme changes (but not if forceLightMode is enabled) let themeObserver: MutationObserver | null = null; if (!forceLightMode) { themeObserver = new MutationObserver((mutations) => { @@ -131,8 +111,6 @@ export const useCookieConsent = ({ }); } - - // Initialize cookie consent with full configuration try { window.CookieConsent.run({ autoShow: true, @@ -160,15 +138,11 @@ export const useCookieConsent = ({ ...(config?.enablePosthog !== false && { posthog: { label: t('cookieBanner.services.posthog', 'PostHog Analytics'), - onAccept: () => console.log('PostHog service accepted'), - onReject: () => console.log('PostHog service rejected') } }), ...(config?.enableScarf !== false && { scarf: { label: t('cookieBanner.services.scarf', 'Scarf Pixel'), - onAccept: () => console.log('Scarf service accepted'), - onReject: () => console.log('Scarf service rejected') } }) } @@ -224,16 +198,11 @@ export const useCookieConsent = ({ } }); - // Force show after initialization - setTimeout(() => { - window.CookieConsent?.show(); - }, 200); - } catch (error) { console.error('Error initializing CookieConsent:', error); } - setIsInitialized(true); - }, 100); // Small delay to ensure DOM is ready + setIsInitialized(true); + }, 100); }; script.onerror = () => { @@ -243,7 +212,6 @@ export const useCookieConsent = ({ document.head.appendChild(script); return () => { - // Cleanup script and CSS when component unmounts if (document.head.contains(script)) { document.head.removeChild(script); } @@ -254,9 +222,8 @@ export const useCookieConsent = ({ document.head.removeChild(customCSS); } }; - }, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]); + }, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t, forceLightMode]); - // Update theme when forceLightMode changes useEffect(() => { if (!isInitialized) return; @@ -286,10 +253,8 @@ export const useCookieConsent = ({ return isDarkMode; }; - // Update theme immediately detectTheme(); - // Set up or remove theme observer based on forceLightMode let themeObserver: MutationObserver | null = null; if (!forceLightMode) { themeObserver = new MutationObserver((mutations) => { @@ -315,23 +280,23 @@ export const useCookieConsent = ({ }; }, [forceLightMode, isInitialized]); - // Hide cookie banner when tour is active useEffect(() => { - if (!isInitialized || !window.CookieConsent) { - return; - } + if (!isInitialized || !window.CookieConsent) return; - if (tourIsOpen) { - window.CookieConsent.hide(); - } else { - // Only show if user hasn't made a choice yet - const consentCookie = window.CookieConsent.getCookie?.(); - const hasConsented = consentCookie && Object.keys(consentCookie).length > 0; - if (!hasConsented) { - window.CookieConsent.show(); + const handleTourState = (event: Event) => { + const { detail } = event as CustomEvent; + if (detail?.isOpen) { + window.CookieConsent?.hide(); + } else { + const consentCookie = window.CookieConsent?.getCookie?.(); + const hasConsented = consentCookie && Object.keys(consentCookie).length > 0; + if (!hasConsented) window.CookieConsent?.show(); } - } - }, [tourIsOpen, isInitialized]); + }; + + window.addEventListener(TOUR_STATE_EVENT, handleTourState); + return () => window.removeEventListener(TOUR_STATE_EVENT, handleTourState); + }, [isInitialized]); const showCookieConsent = useCallback(() => { if (isInitialized && window.CookieConsent) { diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx index 04174d29a..b629e86a5 100644 --- a/frontend/src/core/pages/HomePage.tsx +++ b/frontend/src/core/pages/HomePage.tsx @@ -22,7 +22,6 @@ import FileManager from "@app/components/FileManager"; import LocalIcon from "@app/components/shared/LocalIcon"; import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import AppConfigModal from "@app/components/shared/AppConfigModal"; -import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal"; import "@app/pages/HomePage.css"; @@ -53,20 +52,12 @@ export default function HomePage() { const [activeMobileView, setActiveMobileView] = useState("tools"); const isProgrammaticScroll = useRef(false); const [configModalOpen, setConfigModalOpen] = useState(false); - const [showAnalyticsModal, setShowAnalyticsModal] = useState(false); const { activeFiles } = useFileContext(); const { actions } = useNavigationActions(); const { setActiveFileIndex } = useViewer(); const prevFileCountRef = useRef(activeFiles.length); - // Show admin analytics choice modal if analytics settings not configured - useEffect(() => { - if (config && config.enableAnalytics === null) { - setShowAnalyticsModal(true); - } - }, [config]); - // Auto-switch to viewer when going from 0 to 1 file useEffect(() => { const prevCount = prevFileCountRef.current; @@ -181,10 +172,6 @@ export default function HomePage() { return (
- setShowAnalyticsModal(false)} - /> {isMobile ? (
diff --git a/frontend/src/core/services/accountService.ts b/frontend/src/core/services/accountService.ts index 0b2aa52e0..72d9f1873 100644 --- a/frontend/src/core/services/accountService.ts +++ b/frontend/src/core/services/accountService.ts @@ -9,11 +9,26 @@ export interface AccountData { saml2Login: boolean; } +export interface LoginPageData { + showDefaultCredentials: boolean; + firstTimeSetup: boolean; + enableLogin: boolean; +} + /** * Account Service * Provides functions to interact with account-related backend APIs */ export const accountService = { + /** + * Get login page data (includes showDefaultCredentials flag) + * This is a public endpoint - doesn't require authentication + */ + async getLoginPageData(): Promise { + const response = await apiClient.get('/api/v1/proprietary/ui-data/login'); + return response.data; + }, + /** * Get current user account data */ diff --git a/frontend/src/core/styles/zIndex.ts b/frontend/src/core/styles/zIndex.ts index 1057bf2f2..bad490f17 100644 --- a/frontend/src/core/styles/zIndex.ts +++ b/frontend/src/core/styles/zIndex.ts @@ -4,6 +4,8 @@ export const Z_INDEX_FULLSCREEN_SURFACE = 1000; export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300; export const Z_ANALYTICS_MODAL = 1301; +// Config/Settings modal - should appear above analytics modal when navigating from onboarding +export const Z_INDEX_CONFIG_MODAL = 1400; export const Z_INDEX_FILE_MANAGER_MODAL = 1200; export const Z_INDEX_OVER_FILE_MANAGER_MODAL = 1300; diff --git a/frontend/src/desktop/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts b/frontend/src/desktop/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts deleted file mode 100644 index 22ed22918..000000000 --- a/frontend/src/desktop/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * Desktop override of useInitialOnboardingState. - * - * Key difference: Handles the simplified onboarding flow where desktop-install - * and security-check slides are removed. When welcome is the last slide, - * clicking Next will launch the tools tour instead of doing nothing. - */ - -import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import { usePreferences } from '@app/contexts/PreferencesContext'; -import { useOnboarding } from '@app/contexts/OnboardingContext'; -import { useOs } from '@app/hooks/useOs'; -import { useNavigate } from 'react-router-dom'; -import { - SLIDE_DEFINITIONS, - type ButtonAction, - type FlowState, - type SlideId, -} from '@app/components/onboarding/onboardingFlowConfig'; -import type { LicenseNotice } from '@app/types/types'; -import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver'; -import { useServerExperience } from '@app/hooks/useServerExperience'; -import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types'; -import { DOWNLOAD_URLS } from '@app/constants/downloads'; - -interface UseInitialOnboardingStateResult { - state: OnboardingState; - totalSteps: number; - slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId]; - currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>; - licenseNotice: LicenseNotice; - flowState: FlowState; - closeAndMarkSeen: () => void; - handleButtonAction: (action: ButtonAction) => void; -} - -export function useInitialOnboardingState({ - opened, - onClose, - onRequestServerLicense, - onLicenseNoticeUpdate, -}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null { - const { preferences, updatePreference } = usePreferences(); - const { startTour } = useOnboarding(); - const { - loginEnabled: loginEnabledFromServer, - configIsAdmin, - totalUsers: serverTotalUsers, - userCountResolved: serverUserCountResolved, - freeTierLimit, - hasPaidLicense, - scenarioKey, - setSelfReportedAdmin, - isNewServer, - } = useServerExperience(); - const osType = useOs(); - const navigate = useNavigate(); - const selectedDownloadUrlRef = useRef(''); - - const [state, setState] = useState(DEFAULT_STATE); - - const resetState = useCallback(() => { - setState(DEFAULT_STATE); - }, []); - - useEffect(() => { - if (!opened) { - resetState(); - } - }, [opened, resetState]); - - const handleRoleSelect = useCallback( - (role: 'admin' | 'user' | null) => { - const isAdminSelection = role === 'admin'; - setState((prev) => ({ - ...prev, - selectedRole: role, - selfReportedAdmin: isAdminSelection, - })); - - if (typeof window !== 'undefined') { - if (isAdminSelection) { - window.localStorage.setItem('stirling-self-reported-admin', 'true'); - } else { - window.localStorage.removeItem('stirling-self-reported-admin'); - } - } - - setSelfReportedAdmin(isAdminSelection); - }, - [setSelfReportedAdmin], - ); - - const closeAndMarkSeen = useCallback(() => { - if (!preferences.hasSeenIntroOnboarding) { - updatePreference('hasSeenIntroOnboarding', true); - } - onClose(); - }, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]); - - const isAdmin = configIsAdmin; - const enableLogin = loginEnabledFromServer; - - const effectiveEnableLogin = enableLogin; - const effectiveIsAdmin = isAdmin; - const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin; - - useEffect(() => { - if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) { - handleRoleSelect('admin'); - } - }, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]); - - const shouldUseServerCount = - (effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin; - const licenseUserCountFromServer = - shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null; - - const effectiveLicenseUserCount = licenseUserCountFromServer ?? null; - - const os = useMemo(() => { - switch (osType) { - case 'windows': - return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS }; - case 'mac-apple': - return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON }; - case 'mac-intel': - return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL }; - case 'linux-x64': - case 'linux-arm64': - return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS }; - default: - return { label: '', url: '' }; - } - }, [osType]); - - const osOptions = useMemo(() => { - const options = [ - { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' }, - { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' }, - { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' }, - { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' }, - ]; - return options.filter(opt => opt.url); - }, []); - - const resolvedFlow = useMemo( - () => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin), - [effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin], - ); - // Desktop: No security-check slide, so no need to filter it out - const flowSlideIds = resolvedFlow.ids; - const flowType = resolvedFlow.type; - const totalSteps = flowSlideIds.length; - const maxIndex = Math.max(totalSteps - 1, 0); - - useEffect(() => { - if (state.step >= flowSlideIds.length) { - setState((prev) => ({ - ...prev, - step: Math.max(flowSlideIds.length - 1, 0), - })); - } - }, [flowSlideIds.length, state.step]); - - const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1]; - const slideDefinition = SLIDE_DEFINITIONS[currentSlideId]; - - if (!slideDefinition) { - return null; - } - - const scenarioProvidesInfo = - scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed'; - const scenarioIndicatesAdmin = scenarioProvidesInfo - ? scenarioKey!.includes('admin') - : state.selfReportedAdmin || effectiveIsAdmin; - const scenarioIndicatesOverLimit = scenarioProvidesInfo - ? scenarioKey!.includes('over-limit') - : effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit; - const scenarioRequiresLicense = - scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true; - - const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense; - - const licenseNotice = useMemo( - () => ({ - totalUsers: effectiveLicenseUserCount, - freeTierLimit, - isOverLimit: scenarioIndicatesOverLimit, - requiresLicense: shouldShowServerLicenseInfo, - }), - [ - effectiveLicenseUserCount, - freeTierLimit, - scenarioIndicatesOverLimit, - shouldShowServerLicenseInfo, - ], - ); - - const requestServerLicenseIfNeeded = useCallback( - (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => { - if (!shouldShowServerLicenseInfo) { - return; - } - onRequestServerLicense?.(options); - }, - [onRequestServerLicense, shouldShowServerLicenseInfo], - ); - - useEffect(() => { - onLicenseNoticeUpdate?.(licenseNotice); - }, [licenseNotice, onLicenseNoticeUpdate]); - - // Initialize ref with default URL - useEffect(() => { - if (!selectedDownloadUrlRef.current && os.url) { - selectedDownloadUrlRef.current = os.url; - } - }, [os.url]); - - const handleDownloadUrlChange = useCallback((url: string) => { - selectedDownloadUrlRef.current = url; - }, []); - - const currentSlide = slideDefinition.createSlide({ - osLabel: os.label, - osUrl: os.url, - osOptions, - onDownloadUrlChange: handleDownloadUrlChange, - selectedRole: state.selectedRole, - onRoleSelect: handleRoleSelect, - licenseNotice, - loginEnabled: effectiveEnableLogin, - }); - - const goNext = useCallback(() => { - setState((prev) => ({ - ...prev, - step: Math.min(prev.step + 1, maxIndex), - })); - }, [maxIndex]); - - const goPrev = useCallback(() => { - setState((prev) => ({ - ...prev, - step: Math.max(prev.step - 1, 0), - })); - }, []); - - const launchTour = useCallback( - (mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => { - if (options?.closeOnboardingSlides) { - closeAndMarkSeen(); - } - - startTour(mode, { - source: 'initial-onboarding-modal', - metadata: { - hasCompletedOnboarding: preferences.hasCompletedOnboarding, - toolPanelModePromptSeen: preferences.toolPanelModePromptSeen, - selfReportedAdmin: state.selfReportedAdmin, - }, - }); - }, - [closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin], - ); - - const handleButtonAction = useCallback( - (action: ButtonAction) => { - const isOnLastSlide = state.step >= maxIndex; - - // Desktop: For login-user and no-login flows, when on the last slide (welcome), - // clicking Next should launch the tools tour - const shouldAutoLaunchToolsTour = - (flowType === 'login-user' || flowType === 'no-login') && isOnLastSlide; - - switch (action) { - case 'next': - if (shouldAutoLaunchToolsTour) { - launchTour('tools', { closeOnboardingSlides: true }); - return; - } - goNext(); - return; - case 'prev': - goPrev(); - return; - case 'close': - closeAndMarkSeen(); - return; - case 'download-selected': { - const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl; - if (downloadUrl) { - window.open(downloadUrl, '_blank', 'noopener'); - } - if (shouldAutoLaunchToolsTour) { - launchTour('tools', { closeOnboardingSlides: true }); - return; - } - goNext(); - return; - } - case 'complete-close': - updatePreference('hasCompletedOnboarding', true); - closeAndMarkSeen(); - return; - case 'security-next': - // Desktop: security-check slide is removed, but keep this for completeness - if (!state.selectedRole) { - return; - } - if (state.selectedRole === 'admin') { - goNext(); - } else { - launchTour('tools', { closeOnboardingSlides: true }); - } - return; - case 'launch-admin': - requestServerLicenseIfNeeded({ - deferUntilTourComplete: true, - selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin, - }); - launchTour('admin', { closeOnboardingSlides: true }); - return; - case 'launch-tools': - launchTour('tools', { closeOnboardingSlides: true }); - return; - case 'launch-auto': { - const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools'; - if (launchMode === 'admin') { - requestServerLicenseIfNeeded({ - deferUntilTourComplete: true, - selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin, - }); - } - launchTour(launchMode, { closeOnboardingSlides: true }); - return; - } - case 'skip-to-license': - updatePreference('hasCompletedOnboarding', true); - requestServerLicenseIfNeeded({ - deferUntilTourComplete: false, - selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin, - }); - closeAndMarkSeen(); - return; - case 'see-plans': - closeAndMarkSeen(); - navigate('/settings/adminPlan'); - return; - default: - return; - } - }, - [ - closeAndMarkSeen, - currentSlide, - currentSlideId, - effectiveIsAdmin, - flowType, - goNext, - goPrev, - launchTour, - maxIndex, - navigate, - requestServerLicenseIfNeeded, - onRequestServerLicense, - os.url, - state.selectedRole, - state.selfReportedAdmin, - state.step, - updatePreference, - ], - ); - - const flowState: FlowState = { selectedRole: state.selectedRole }; - - return { - state, - totalSteps, - slideDefinition, - currentSlide, - licenseNotice, - flowState, - closeAndMarkSeen, - handleButtonAction, - }; -} - diff --git a/frontend/src/desktop/components/onboarding/onboardingFlowConfig.ts b/frontend/src/desktop/components/onboarding/onboardingFlowConfig.ts deleted file mode 100644 index 6eaf245a1..000000000 --- a/frontend/src/desktop/components/onboarding/onboardingFlowConfig.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Desktop override of onboarding flow config. - * - * This version removes the desktop-install and security-check slides - * since they're not relevant when running as a desktop app. - * - * The SlideId type still includes all values for type compatibility, - * but the actual FLOW_SEQUENCES don't use these slides. - */ - -import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide'; -import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide'; -import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide'; -import { SlideConfig, LicenseNotice } from '@app/types/types'; - -// Keep the full type for compatibility, but these slides won't be used -export type SlideId = - | 'welcome' - | 'desktop-install' - | 'security-check' - | 'admin-overview' - | 'server-license'; - -export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo'; - -export type ButtonAction = - | 'next' - | 'prev' - | 'close' - | 'complete-close' - | 'download-selected' - | 'security-next' - | 'launch-admin' - | 'launch-tools' - | 'launch-auto' - | 'see-plans' - | 'skip-to-license'; - -export interface FlowState { - selectedRole: 'admin' | 'user' | null; -} - -export interface OSOption { - label: string; - url: string; - value: string; -} - -export interface SlideFactoryParams { - osLabel: string; - osUrl: string; - osOptions?: OSOption[]; - onDownloadUrlChange?: (url: string) => void; - selectedRole: 'admin' | 'user' | null; - onRoleSelect: (role: 'admin' | 'user' | null) => void; - licenseNotice?: LicenseNotice; - loginEnabled?: boolean; -} - -export interface HeroDefinition { - type: HeroType; -} - -export interface ButtonDefinition { - key: string; - type: 'button' | 'icon'; - label?: string; - icon?: 'chevron-left'; - variant?: 'primary' | 'secondary' | 'default'; - group: 'left' | 'right'; - action: ButtonAction; - disabledWhen?: (state: FlowState) => boolean; -} - -export interface SlideDefinition { - id: SlideId; - createSlide: (params: SlideFactoryParams) => SlideConfig; - hero: HeroDefinition; - buttons: ButtonDefinition[]; -} - -export const SLIDE_DEFINITIONS: Record = { - 'welcome': { - id: 'welcome', - createSlide: () => WelcomeSlide(), - hero: { type: 'rocket' }, - buttons: [ - { - key: 'welcome-next', - type: 'button', - label: 'onboarding.buttons.next', - variant: 'primary', - group: 'right', - action: 'next', - }, - ], - }, - // Stub definitions for desktop-install and security-check - not used on desktop - // but kept for type compatibility with core code - 'desktop-install': { - id: 'desktop-install', - createSlide: () => WelcomeSlide(), // Placeholder - never used - hero: { type: 'dual-icon' }, - buttons: [], - }, - 'security-check': { - id: 'security-check', - createSlide: () => WelcomeSlide(), // Placeholder - never used - hero: { type: 'shield' }, - buttons: [], - }, - 'admin-overview': { - id: 'admin-overview', - createSlide: ({ licenseNotice, loginEnabled }) => - PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }), - hero: { type: 'diamond' }, - buttons: [ - { - key: 'admin-back', - type: 'icon', - icon: 'chevron-left', - group: 'left', - action: 'prev', - }, - { - key: 'admin-show', - type: 'button', - label: 'onboarding.buttons.showMeAround', - variant: 'primary', - group: 'right', - action: 'launch-admin', - }, - { - key: 'admin-skip', - type: 'button', - label: 'onboarding.buttons.skipTheTour', - variant: 'secondary', - group: 'left', - action: 'skip-to-license', - }, - ], - }, - 'server-license': { - id: 'server-license', - createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }), - hero: { type: 'dual-icon' }, - buttons: [ - { - key: 'license-close', - type: 'button', - label: 'onboarding.buttons.skipForNow', - variant: 'secondary', - group: 'left', - action: 'close', - }, - { - key: 'license-see-plans', - type: 'button', - label: 'onboarding.serverLicense.seePlans', - variant: 'primary', - group: 'right', - action: 'see-plans', - }, - ], - }, -}; - -/** - * Desktop flow sequences - simplified without desktop-install and security-check slides - * since users are already on desktop and security check is not needed. - */ -export const FLOW_SEQUENCES = { - loginAdmin: ['welcome', 'admin-overview'] as SlideId[], - loginUser: ['welcome'] as SlideId[], - noLoginBase: ['welcome'] as SlideId[], - noLoginAdmin: ['admin-overview'] as SlideId[], -}; - diff --git a/frontend/src/desktop/components/onboarding/orchestrator/onboardingConfig.ts b/frontend/src/desktop/components/onboarding/orchestrator/onboardingConfig.ts new file mode 100644 index 000000000..87451ca5e --- /dev/null +++ b/frontend/src/desktop/components/onboarding/orchestrator/onboardingConfig.ts @@ -0,0 +1,38 @@ +/** + * Desktop Override: Onboarding Configuration + * + * This version modifies the onboarding config for the desktop app: + * - Sets isDesktopApp to true in the default runtime state + * - This causes desktop-install step to be skipped + * + * All other step definitions and logic remain the same. + */ + +// Re-export everything from core +export { + ONBOARDING_STEPS, + getStepById, + getStepIndex, +} from '@core/components/onboarding/orchestrator/onboardingConfig'; + +export type { + OnboardingStepId, + OnboardingStepType, + OnboardingStep, + OnboardingRuntimeState, + OnboardingConditionContext, +} from '@core/components/onboarding/orchestrator/onboardingConfig'; + +// Import and override the default runtime state +import { DEFAULT_RUNTIME_STATE as CORE_DEFAULT_RUNTIME_STATE } from '@core/components/onboarding/orchestrator/onboardingConfig'; +import type { OnboardingRuntimeState } from '@core/components/onboarding/orchestrator/onboardingConfig'; + +/** + * Desktop default runtime state + * Sets isDesktopApp to true so desktop-install step is skipped + */ +export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = { + ...CORE_DEFAULT_RUNTIME_STATE, + isDesktopApp: true, +}; + diff --git a/frontend/src/desktop/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/src/desktop/components/onboarding/orchestrator/useOnboardingOrchestrator.ts new file mode 100644 index 000000000..a0e602ec9 --- /dev/null +++ b/frontend/src/desktop/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -0,0 +1,22 @@ +/** + * Desktop Override: Onboarding Orchestrator Hook + * + * Simply wraps the core hook with the desktop-specific default runtime state + * which has isDesktopApp set to true. + */ + +import { + useOnboardingOrchestrator as useCoreOnboardingOrchestrator, + type UseOnboardingOrchestratorResult, +} from '@core/components/onboarding/orchestrator/useOnboardingOrchestrator'; +import { DEFAULT_RUNTIME_STATE } from '@desktop/components/onboarding/orchestrator/onboardingConfig'; + +export type { + OnboardingOrchestratorState, + OnboardingOrchestratorActions, + UseOnboardingOrchestratorResult, +} from '@core/components/onboarding/orchestrator/useOnboardingOrchestrator'; + +export function useOnboardingOrchestrator(): UseOnboardingOrchestratorResult { + return useCoreOnboardingOrchestrator({ defaultRuntimeState: DEFAULT_RUNTIME_STATE }); +} diff --git a/frontend/src/proprietary/App.tsx b/frontend/src/proprietary/App.tsx index 9edb9ab83..52a246fc6 100644 --- a/frontend/src/proprietary/App.tsx +++ b/frontend/src/proprietary/App.tsx @@ -8,7 +8,7 @@ import Login from "@app/routes/Login"; import Signup from "@app/routes/Signup"; import AuthCallback from "@app/routes/AuthCallback"; import InviteAccept from "@app/routes/InviteAccept"; -import OnboardingTour from "@app/components/onboarding/OnboardingTour"; +import Onboarding from "@app/components/onboarding/Onboarding"; // Import global styles import "@app/styles/tailwind.css"; @@ -34,7 +34,7 @@ export default function App() { {/* Main app routes - Landing handles auth logic */} } /> - + diff --git a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx index c71546d3a..966a82f2e 100644 --- a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx +++ b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx @@ -1,12 +1,10 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { useOnboarding } from '@app/contexts/OnboardingContext'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { isAuthRoute } from '@core/constants/routes'; import { useCheckout } from '@app/contexts/CheckoutContext'; import { InfoBanner } from '@app/components/shared/InfoBanner'; import { - ONBOARDING_SESSION_BLOCK_KEY, - ONBOARDING_SESSION_EVENT, SERVER_LICENSE_REQUEST_EVENT, type ServerLicenseRequestPayload, UPGRADE_BANNER_TEST_EVENT, @@ -15,13 +13,17 @@ import { UPGRADE_BANNER_ALERT_EVENT, } from '@core/constants/events'; import { useServerExperience } from '@app/hooks/useServerExperience'; +import { hasSeenStep } from '@core/components/onboarding/orchestrator/onboardingStorage'; const FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt'; const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000; const UpgradeBanner: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const { isOpen: tourOpen } = useOnboarding(); + const location = useLocation(); + + // Check if we're on an auth route (evaluated after hooks, used in render) + const onAuthRoute = isAuthRoute(location.pathname); const { openCheckout } = useCheckout(); const { totalUsers, @@ -34,40 +36,18 @@ const UpgradeBanner: React.FC = () => { overFreeTierLimit, scenarioKey, } = useServerExperience(); - const [sessionBlocked, setSessionBlocked] = useState(true); - const [friendlyVisible, setFriendlyVisible] = useState(false); + const onboardingComplete = hasSeenStep('welcome'); + const [friendlyVisible, setFriendlyVisible] = useState(() => { + if (typeof window === 'undefined') return false; + const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY); + if (!lastShownRaw) return false; + const lastShown = parseInt(lastShownRaw, 10); + if (!Number.isFinite(lastShown)) return false; + return Date.now() - lastShown >= WEEK_IN_MS; + }); const isDev = import.meta.env.DEV; const [testScenario, setTestScenario] = useState(null); - // Track onboarding session flag so we don't show banner if onboarding ran this load - useEffect(() => { - if (typeof window === 'undefined') { - return; - } - - const evaluateBlock = () => { - const blocked = window.sessionStorage.getItem(ONBOARDING_SESSION_BLOCK_KEY) === 'true'; - setSessionBlocked(blocked); - }; - - evaluateBlock(); - - const timer = window.setTimeout(() => { - evaluateBlock(); - }, 1000); - - const handleOnboardingEvent = () => { - evaluateBlock(); - }; - - window.addEventListener(ONBOARDING_SESSION_EVENT, handleOnboardingEvent as EventListener); - - return () => { - clearTimeout(timer); - window.removeEventListener(ONBOARDING_SESSION_EVENT, handleOnboardingEvent as EventListener); - }; - }, []); - useEffect(() => { if (!isDev || typeof window === 'undefined') { return; @@ -157,28 +137,16 @@ const UpgradeBanner: React.FC = () => { shouldShowFriendlyBase && !licenseLoading && effectiveTotalUsersLoaded && - !tourOpen && - !sessionBlocked, + onboardingComplete, ); + // Urgent banner should always show when over-limit const shouldEvaluateUrgent = scenario ? Boolean(scenario && !scenarioIsFriendly) : Boolean( shouldShowUrgentBase && - !licenseLoading && - !tourOpen && - !sessionBlocked, + !licenseLoading, ); - useEffect(() => { - if (typeof window === 'undefined') { - return; - } - - if (!shouldShowFriendlyBase && effectiveTotalUsersLoaded) { - window.localStorage.removeItem(FRIENDLY_LAST_SEEN_KEY); - } - }, [shouldShowFriendlyBase, effectiveTotalUsersLoaded]); - useEffect(() => { if (scenario === 'friendly') { return; @@ -214,16 +182,6 @@ const UpgradeBanner: React.FC = () => { } : { active: false }; - console.debug('[UpgradeBanner] Dispatching alert event', { - shouldEvaluateUrgent, - detail, - totalUsers: effectiveTotalUsers, - freeTierLimit, - effectiveIsAdmin, - effectiveHasPaidLicense, - userCountLoaded: effectiveTotalUsersLoaded, - }); - window.dispatchEvent( new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail }), ); @@ -246,12 +204,6 @@ const UpgradeBanner: React.FC = () => { window.localStorage.setItem(FRIENDLY_LAST_SEEN_KEY, Date.now().toString()); }, []); - useEffect(() => { - if (friendlyVisible) { - recordFriendlyLastShown(); - } - }, [friendlyVisible, recordFriendlyLastShown]); - const handleUpgrade = () => { recordFriendlyLastShown(); @@ -308,15 +260,8 @@ const UpgradeBanner: React.FC = () => { const renderUrgentBanner = () => { if (!shouldEvaluateUrgent) { - console.debug('[UpgradeBanner] renderUrgentBanner → hidden (shouldEvaluateUrgent=false)'); return null; } - console.debug('[UpgradeBanner] renderUrgentBanner → visible', { - totalUsers: effectiveTotalUsers, - freeTierLimit, - effectiveIsAdmin, - effectiveHasPaidLicense, - }); const buttonText = effectiveIsAdmin ? t('upgradeBanner.seeInfo', 'See info') : undefined; @@ -351,7 +296,8 @@ const UpgradeBanner: React.FC = () => { ); }; - if (!friendlyVisible && !shouldEvaluateUrgent) { + // Don't show on auth routes or if neither banner type should show + if (onAuthRoute || (!friendlyVisible && !shouldEvaluateUrgent)) { return null; } diff --git a/frontend/src/proprietary/contexts/ServerExperienceContext.tsx b/frontend/src/proprietary/contexts/ServerExperienceContext.tsx index 338dc159a..17ac572fc 100644 --- a/frontend/src/proprietary/contexts/ServerExperienceContext.tsx +++ b/frontend/src/proprietary/contexts/ServerExperienceContext.tsx @@ -173,19 +173,8 @@ export function ServerExperienceProvider({ children }: { children: ReactNode }) } const shouldUseAdminData = (config.enableLogin ?? true) && config.isAdmin; - const shouldUseEstimate = config.enableLogin === false; - - if (!shouldUseAdminData && !shouldUseEstimate) { - setUserCountState((prev) => ({ - ...prev, - totalUsers: null, - weeklyActiveUsers: null, - loading: false, - source: 'unknown', - error: null, - })); - return; - } + // Use WAU estimate for no-login scenarios OR for login non-admin users + const shouldUseEstimate = config.enableLogin === false || !config.isAdmin; setUserCountState((prev) => ({ ...prev, diff --git a/frontend/src/proprietary/routes/Landing.tsx b/frontend/src/proprietary/routes/Landing.tsx index 86156dd50..6fb5fec66 100644 --- a/frontend/src/proprietary/routes/Landing.tsx +++ b/frontend/src/proprietary/routes/Landing.tsx @@ -1,11 +1,8 @@ -import { useState, useEffect } from 'react' +import { useEffect } from 'react' import { Navigate, useLocation, useNavigate } from 'react-router-dom' import { useAuth } from '@app/auth/UseSession' import { useAppConfig } from '@app/contexts/AppConfigContext' import HomePage from '@app/pages/HomePage' -// Login component is used via routing, not directly imported -import FirstLoginModal from '@app/components/shared/FirstLoginModal' -import { accountService } from '@app/services/accountService' import { useBackendProbe } from '@app/hooks/useBackendProbe' import AuthLayout from '@app/routes/authShared/AuthLayout' import LoginHeader from '@app/routes/login/LoginHeader' @@ -19,15 +16,12 @@ import { useTranslation } from 'react-i18next' * If user is not authenticated: Show Login or redirect to /login */ export default function Landing() { - const { session, loading: authLoading, refreshSession } = useAuth(); + const { session, loading: authLoading } = useAuth(); const { config, loading: configLoading, refetch } = useAppConfig(); const backendProbe = useBackendProbe(); const location = useLocation(); const navigate = useNavigate(); const { t } = useTranslation(); - const [isFirstLogin, setIsFirstLogin] = useState(false); - const [checkingFirstLogin, setCheckingFirstLogin] = useState(false); - const [username, setUsername] = useState(''); const loading = authLoading || configLoading || backendProbe.loading; @@ -51,42 +45,12 @@ export default function Landing() { return () => window.clearInterval(intervalId); }, [backendProbe.status, backendProbe.loginDisabled, backendProbe.probe, navigate, refetch]); - // Check if user needs to change password on first login - useEffect(() => { - const checkFirstLogin = async () => { - if (session && config?.enableLogin !== false) { - try { - setCheckingFirstLogin(true) - const accountData = await accountService.getAccountData() - setUsername(accountData.username) - setIsFirstLogin(accountData.changeCredsFlag) - } catch (err) { - console.error('Failed to check first login status:', err) - // If account endpoint fails (404), user probably doesn't have security enabled - setIsFirstLogin(false) - } finally { - setCheckingFirstLogin(false) - } - } - } - - checkFirstLogin() - }, [session, config]) - useEffect(() => { if (backendProbe.status === 'up') { void refetch(); } }, [backendProbe.status, refetch]); - const handlePasswordChanged = async () => { - // After password change, backend logs out the user - // Refresh session to detect logout and redirect to login - setIsFirstLogin(false) // Close modal first - await refreshSession() - // The auth system will automatically redirect to login when session is null - } - console.log('[Landing] State:', { pathname: location.pathname, loading, @@ -95,7 +59,7 @@ export default function Landing() { }); // Show loading while checking auth and config - if (loading || checkingFirstLogin) { + if (loading) { return (
@@ -154,17 +118,9 @@ export default function Landing() { } // If we have a session, show the main app + // Note: First login password change is now handled by the onboarding flow if (session) { - return ( - <> - - - - ); + return ; } // No session - redirect to login page diff --git a/frontend/src/proprietary/routes/Login.test.tsx b/frontend/src/proprietary/routes/Login.test.tsx index f4e5c9faf..62679f22a 100644 --- a/frontend/src/proprietary/routes/Login.test.tsx +++ b/frontend/src/proprietary/routes/Login.test.tsx @@ -7,7 +7,6 @@ import Login from '@app/routes/Login'; import { useAuth } from '@app/auth/UseSession'; import { springAuth } from '@app/auth/springAuthClient'; import { PreferencesProvider } from '@app/contexts/PreferencesContext'; -import { OnboardingProvider } from '@app/contexts/OnboardingContext'; // Mock i18n to return fallback text vi.mock('react-i18next', () => ({ @@ -67,7 +66,7 @@ vi.mock('react-router-dom', async () => { const TestWrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + {children} ); diff --git a/frontend/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/src/proprietary/testing/serverExperienceSimulations.ts index 41aec7120..0aa745d54 100644 --- a/frontend/src/proprietary/testing/serverExperienceSimulations.ts +++ b/frontend/src/proprietary/testing/serverExperienceSimulations.ts @@ -129,8 +129,12 @@ const SIMULATION_SCENARIOS: SimulationScenario[] = [ ...BASE_LOGIN_CONFIG, isAdmin: false, }, - adminUsage: { - totalUsers: 3, + // Non-admin users use WAU estimate (not adminUsage) + wau: { + trackingSince: '2025-11-18T23:20:12.520884200Z', + daysOnline: 0, + totalUniqueBrowsers: 3, + weeklyActiveUsers: 3, }, licenseInfo: { ...FREE_LICENSE_INFO }, }, @@ -151,8 +155,12 @@ const SIMULATION_SCENARIOS: SimulationScenario[] = [ ...BASE_LOGIN_CONFIG, isAdmin: false, }, - adminUsage: { - totalUsers: 12, + // Non-admin users use WAU estimate (not adminUsage) + wau: { + trackingSince: '2025-09-01T00:00:00Z', + daysOnline: 30, + totalUniqueBrowsers: 12, + weeklyActiveUsers: 9, }, licenseInfo: { ...FREE_LICENSE_INFO }, },