Chore/v2/onboarding flow cleanup (#5065)

This commit is contained in:
EthanHealy01
2025-12-02 12:40:20 +00:00
committed by GitHub
parent 341adaa07d
commit 179b569769
44 changed files with 1698 additions and 2275 deletions
+2 -2
View File
@@ -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 */}
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
<Onboarding />
</AppLayout>
</AppProviders>
</Suspense>
@@ -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<UpgradeBannerTestScenario>(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;
}
@@ -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,
+5 -49
View File
@@ -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 (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="text-center">
@@ -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 (
<>
<FirstLoginModal
opened={isFirstLogin}
onPasswordChanged={handlePasswordChanged}
username={username}
/>
<HomePage />
</>
);
return <HomePage />;
}
// No session - redirect to login page
@@ -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 }) => (
<MantineProvider>
<PreferencesProvider>
<OnboardingProvider>{children}</OnboardingProvider>
{children}
</PreferencesProvider>
</MantineProvider>
);
@@ -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 },
},