diff --git a/frontend/src/core/auth/UseSession.tsx b/frontend/src/core/auth/UseSession.tsx new file mode 100644 index 000000000..4cdfed453 --- /dev/null +++ b/frontend/src/core/auth/UseSession.tsx @@ -0,0 +1,34 @@ +import { createContext, useContext, type ReactNode } from 'react'; + +interface AuthContextValue { + session: null; + user: null; + loading: boolean; + error: null; + signOut: () => Promise; + refreshSession: () => Promise; +} + +const defaultValue: AuthContextValue = { + session: null, + user: null, + loading: false, + error: null, + signOut: async () => {}, + refreshSession: async () => {}, +}; + +const AuthContext = createContext(defaultValue); + +export function AuthProvider({ children }: { children: ReactNode }) { + return {children}; +} + +export function useAuth(): AuthContextValue { + return useContext(AuthContext); +} + +export function useAuthDebug(): AuthContextValue { + return useAuth(); +} + diff --git a/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts b/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts index d8b335897..a0341d229 100644 --- a/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts +++ b/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts @@ -11,7 +11,7 @@ import { ONBOARDING_SESSION_EVENT, SERVER_LICENSE_REQUEST_EVENT, type ServerLicenseRequestPayload, -} from '@core/constants/events'; +} from '@app/constants/events'; import { useServerExperience } from '@app/hooks/useServerExperience'; interface InitialModalHandlers { diff --git a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx index 9367fc7a8..cf14fece2 100644 --- a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx +++ b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import styles from './AnimatedSlideBackground.module.css'; -import { AnimatedSlideBackgroundProps } from '../../../types/types'; +import styles from '@app/components/onboarding/slides/AnimatedSlideBackground.module.css'; +import { AnimatedSlideBackgroundProps } from '@app/types/types'; type CircleStyles = React.CSSProperties & { '--circle-move-x'?: string; diff --git a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx index 3416c0590..9cebfbb9c 100644 --- a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { SlideConfig } from '../../../types/types'; -import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig'; -import { DesktopInstallTitle, type OSOption } from './DesktopInstallTitle'; +import { SlideConfig } from '@app/types/types'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; +import { DesktopInstallTitle, type OSOption } from '@app/components/onboarding/slides/DesktopInstallTitle'; export type { OSOption }; diff --git a/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx index 029cc4f8f..3b8d4bfb0 100644 --- a/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import { SlideConfig, LicenseNotice } from '../../../types/types'; -import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig'; +import { SlideConfig, LicenseNotice } from '@app/types/types'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; interface PlanOverviewSlideProps { isAdmin: boolean; diff --git a/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx index 6cfc136ed..868d06abe 100644 --- a/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { Select } from '@mantine/core'; import styles from '../InitialOnboardingModal/InitialOnboardingModal.module.css'; -import { SlideConfig } from '../../../types/types'; +import { SlideConfig } from '@app/types/types'; import LocalIcon from '@app/components/shared/LocalIcon'; -import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; import i18n from '@app/i18n'; interface SecurityCheckSlideProps { diff --git a/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx b/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx index 4b2f9873b..f118d2ac0 100644 --- a/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Trans } from 'react-i18next'; -import { SlideConfig, LicenseNotice } from '../../../types/types'; -import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig'; +import { SlideConfig, LicenseNotice } from '@app/types/types'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; import i18n from '@app/i18n'; interface ServerLicenseSlideProps { diff --git a/frontend/src/core/components/onboarding/slides/WelcomeSlide.tsx b/frontend/src/core/components/onboarding/slides/WelcomeSlide.tsx index 0065da23a..eb9160eba 100644 --- a/frontend/src/core/components/onboarding/slides/WelcomeSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/WelcomeSlide.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { useTranslation, Trans } from 'react-i18next'; -import { SlideConfig } from '../../../types/types'; -import styles from '../InitialOnboardingModal/InitialOnboardingModal.module.css'; -import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig'; +import { SlideConfig } from '@app/types/types'; +import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; function WelcomeSlideTitle() { const { t } = useTranslation(); diff --git a/frontend/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts b/frontend/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts index f7b2fa111..8ba3c0fbb 100644 --- a/frontend/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts +++ b/frontend/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts @@ -1,4 +1,4 @@ -import { AnimatedCircleConfig } from '../../../types/types'; +import { AnimatedCircleConfig } from '@app/types/types'; /** * Unified circle background configuration used across all onboarding slides. diff --git a/frontend/src/core/hooks/useLicenseAlert.ts b/frontend/src/core/hooks/useLicenseAlert.ts index 5fff4bef0..a9035ef5e 100644 --- a/frontend/src/core/hooks/useLicenseAlert.ts +++ b/frontend/src/core/hooks/useLicenseAlert.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { UPGRADE_BANNER_ALERT_EVENT, type UpgradeBannerAlertPayload, -} from '@core/constants/events'; +} from '@app/constants/events'; export interface LicenseAlertState { active: boolean; diff --git a/frontend/src/core/hooks/useServerExperience.ts b/frontend/src/core/hooks/useServerExperience.ts new file mode 100644 index 000000000..8dd60069a --- /dev/null +++ b/frontend/src/core/hooks/useServerExperience.ts @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; + +const SELF_REPORTED_ADMIN_KEY = 'stirling-self-reported-admin'; +const FREE_TIER_LIMIT = 5; + +type UserCountSource = 'admin' | 'estimate' | 'unknown'; + +export type ServerScenarioKey = + | 'unknown' + | 'licensed' + | 'no-login-user-under-limit-no-license' + | 'no-login-admin-under-limit-no-license' + | 'no-login-user-over-limit-no-license' + | 'no-login-admin-over-limit-no-license' + | 'login-user-under-limit-no-license' + | 'login-admin-under-limit-no-license' + | 'login-user-over-limit-no-license' + | 'login-admin-over-limit-no-license'; + +export interface ServerExperienceValue { + loginEnabled: boolean; + configIsAdmin: boolean; + effectiveIsAdmin: boolean; + selfReportedAdmin: boolean; + isAuthenticated: boolean; + isNewServer: boolean | null; + isNewUser: boolean | null; + premiumEnabled: boolean | null; + license: string | undefined; + runningProOrHigher: boolean | undefined; + runningEE: boolean | undefined; + hasPaidLicense: boolean; + licenseKeyValid: boolean | null; + licenseLoading: boolean; + licenseInfoAvailable: boolean; + totalUsers: number | null; + weeklyActiveUsers: number | null; + userCountLoading: boolean; + userCountError: string | null; + userCountSource: UserCountSource; + userCountResolved: boolean; + overFreeTierLimit: boolean | null; + freeTierLimit: number; + refreshUserCounts: () => Promise; + setSelfReportedAdmin: (value: boolean) => void; + scenarioKey: ServerScenarioKey; +} + +function readSelfReportedAdmin(): boolean { + if (typeof window === 'undefined') { + return false; + } + try { + return window.localStorage.getItem(SELF_REPORTED_ADMIN_KEY) === 'true'; + } catch { + return false; + } +} + +export function useServerExperience(): ServerExperienceValue { + const { config } = useAppConfig(); + const [selfReportedAdmin, setSelfReportedAdminState] = useState(readSelfReportedAdmin); + + const loginEnabled = config?.enableLogin !== false; + const configIsAdmin = Boolean(config?.isAdmin); + const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin); + const hasPaidLicense = config?.license === 'PRO' || config?.license === 'ENTERPRISE'; + + const setSelfReportedAdmin = useCallback((value: boolean) => { + setSelfReportedAdminState(value); + if (typeof window === 'undefined') { + return; + } + try { + if (value) { + window.localStorage.setItem(SELF_REPORTED_ADMIN_KEY, 'true'); + } else { + window.localStorage.removeItem(SELF_REPORTED_ADMIN_KEY); + } + } catch { + // ignore storage write failures + } + }, []); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + const handleStorage = (event: StorageEvent) => { + if (event.key === SELF_REPORTED_ADMIN_KEY) { + setSelfReportedAdminState(event.newValue === 'true'); + } + }; + window.addEventListener('storage', handleStorage); + return () => window.removeEventListener('storage', handleStorage); + }, []); + + useEffect(() => { + if (config?.isNewServer && !loginEnabled && !selfReportedAdmin) { + setSelfReportedAdmin(true); + } + }, [config?.isNewServer, loginEnabled, selfReportedAdmin, setSelfReportedAdmin]); + + const scenarioKey: ServerScenarioKey = useMemo(() => { + if (hasPaidLicense) { + return 'licensed'; + } + return 'unknown'; + }, [hasPaidLicense]); + + const value = useMemo(() => ({ + loginEnabled, + configIsAdmin, + effectiveIsAdmin, + selfReportedAdmin, + isAuthenticated: false, + isNewServer: config?.isNewServer ?? null, + isNewUser: config?.isNewUser ?? null, + premiumEnabled: config?.premiumEnabled ?? null, + license: config?.license, + runningProOrHigher: config?.runningProOrHigher, + runningEE: config?.runningEE, + hasPaidLicense, + licenseKeyValid: config?.premiumEnabled ?? null, + licenseLoading: false, + licenseInfoAvailable: false, + totalUsers: null, + weeklyActiveUsers: null, + userCountLoading: false, + userCountError: null, + userCountSource: 'unknown', + userCountResolved: false, + overFreeTierLimit: null, + freeTierLimit: FREE_TIER_LIMIT, + refreshUserCounts: async () => {}, + setSelfReportedAdmin, + scenarioKey, + }), [ + config?.isNewServer, + config?.isNewUser, + config?.license, + config?.premiumEnabled, + config?.runningEE, + config?.runningProOrHigher, + configIsAdmin, + effectiveIsAdmin, + hasPaidLicense, + loginEnabled, + scenarioKey, + selfReportedAdmin, + setSelfReportedAdmin, + ]); + + return value; +} + diff --git a/frontend/src/core/testing/serverExperienceSimulations.ts b/frontend/src/core/testing/serverExperienceSimulations.ts new file mode 100644 index 000000000..f136ce489 --- /dev/null +++ b/frontend/src/core/testing/serverExperienceSimulations.ts @@ -0,0 +1,192 @@ +import type { AppConfig } from '@app/contexts/AppConfigContext'; + +interface LicenseInfo { + licenseType: string; + enabled: boolean; + maxUsers: number; + hasKey: boolean; +} + +interface WauResponse { + trackingSince: string; + daysOnline: number; + totalUniqueBrowsers: number; + weeklyActiveUsers: number; +} + +interface AdminUsageResponse { + totalUsers?: number; +} + +interface SimulationScenario { + label: string; + appConfig: AppConfig; + wau?: WauResponse; + adminUsage?: AdminUsageResponse; + licenseInfo: LicenseInfo; +} + +const DEV_TESTING_MODE = false; +const SIMULATION_INDEX = 0; + +const FREE_LICENSE_INFO: LicenseInfo = { + licenseType: 'NORMAL', + enabled: false, + maxUsers: 5, + hasKey: false, +}; + +const BASE_NO_LOGIN_CONFIG: AppConfig = { + enableAnalytics: true, + appVersion: '2.0.0', + serverCertificateEnabled: false, + enableAlphaFunctionality: false, + serverPort: 8080, + premiumEnabled: false, + runningProOrHigher: false, + runningEE: false, + enableLogin: false, + activeSecurity: false, + languages: [], + contextPath: '/', + license: 'NORMAL', + baseUrl: 'http://localhost', + enableEmailInvites: true, +}; + +const BASE_LOGIN_CONFIG: AppConfig = { + ...BASE_NO_LOGIN_CONFIG, + enableLogin: true, + activeSecurity: true, +}; + +const SIMULATION_SCENARIOS: SimulationScenario[] = [ + { + label: 'no-login-user-under-limit (no-license)', + appConfig: { + ...BASE_NO_LOGIN_CONFIG, + }, + wau: { + trackingSince: '2025-11-18T23:20:12.520884200Z', + daysOnline: 0, + totalUniqueBrowsers: 3, + weeklyActiveUsers: 3, + }, + licenseInfo: { ...FREE_LICENSE_INFO }, + }, + { + label: 'no-login-admin-under-limit (no-license)', + appConfig: { + ...BASE_NO_LOGIN_CONFIG, + }, + wau: { + trackingSince: '2025-10-01T00:00:00Z', + daysOnline: 14, + totalUniqueBrowsers: 4, + weeklyActiveUsers: 4, + }, + licenseInfo: { ...FREE_LICENSE_INFO }, + }, + { + label: 'no-login-user-over-limit (no-license)', + appConfig: { + ...BASE_NO_LOGIN_CONFIG, + }, + wau: { + trackingSince: '2025-09-01T00:00:00Z', + daysOnline: 30, + totalUniqueBrowsers: 12, + weeklyActiveUsers: 9, + }, + licenseInfo: { ...FREE_LICENSE_INFO }, + }, + { + label: 'no-login-admin-over-limit (no-license)', + appConfig: { + ...BASE_NO_LOGIN_CONFIG, + }, + wau: { + trackingSince: '2025-08-15T00:00:00Z', + daysOnline: 45, + totalUniqueBrowsers: 18, + weeklyActiveUsers: 12, + }, + licenseInfo: { ...FREE_LICENSE_INFO }, + }, + { + label: 'login-user-under-limit (no-license)', + appConfig: { + ...BASE_LOGIN_CONFIG, + isAdmin: false, + }, + adminUsage: { + totalUsers: 3, + }, + licenseInfo: { ...FREE_LICENSE_INFO }, + }, + { + label: 'login-admin-under-limit (no-license)', + appConfig: { + ...BASE_LOGIN_CONFIG, + isAdmin: true, + }, + adminUsage: { + totalUsers: 4, + }, + licenseInfo: { ...FREE_LICENSE_INFO }, + }, + { + label: 'login-user-over-limit (no-license)', + appConfig: { + ...BASE_LOGIN_CONFIG, + isAdmin: false, + }, + adminUsage: { + totalUsers: 12, + }, + licenseInfo: { ...FREE_LICENSE_INFO }, + }, + { + label: 'login-admin-over-limit (no-license)', + appConfig: { + ...BASE_LOGIN_CONFIG, + isAdmin: true, + }, + adminUsage: { + totalUsers: 57, + }, + licenseInfo: { ...FREE_LICENSE_INFO }, + }, +]; + +function getActiveScenario(): SimulationScenario | null { + if (!DEV_TESTING_MODE) { + return null; + } + const scenario = SIMULATION_SCENARIOS[SIMULATION_INDEX]; + if (!scenario) { + console.warn('[Simulation] SIMULATION_INDEX out of range, using live backend.'); + return null; + } + console.warn(`[Simulation] Using scenario #${SIMULATION_INDEX} (${scenario.label}).`); + return scenario; +} + +export function getSimulatedAppConfig(): AppConfig | null { + return getActiveScenario()?.appConfig ?? null; +} + +export function getSimulatedWauResponse(): WauResponse | null { + return getActiveScenario()?.wau ?? null; +} + +export function getSimulatedAdminUsage(): AdminUsageResponse | null { + return getActiveScenario()?.adminUsage ?? null; +} + +export function getSimulatedLicenseInfo(): LicenseInfo | null { + return getActiveScenario()?.licenseInfo ?? null; +} + +export const DEV_TESTING_ENABLED = DEV_TESTING_MODE; +