From fa8c52b2bedf3ce0921dea5f6a72ddd24f6e6a43 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 11 Mar 2026 11:53:54 +0000 Subject: [PATCH] Add SaaS frontend code (#5879) # Description of Changes Adds the code for the SaaS frontend as proprietary code to the OSS repo. This version of the code is adapted from 22/1/2026, which was the last SaaS version based on the 'V2' design. This will move us closer to being able to have the OSS products understand whether the user has a SaaS account, and provide the correct UI in those cases. --- LICENSE | 2 + frontend/package-lock.json | 569 ++++++++- frontend/package.json | 21 +- .../public/locales/en-GB/translation.toml | 86 ++ .../components/home/HomePageExtensions.tsx | 7 + .../core/components/shared/QuickAccessBar.tsx | 5 +- .../shared/config/SettingsSearchBar.tsx | 2 +- .../config/configSections/GeneralSection.tsx | 8 +- .../src/core/contexts/AppConfigContext.tsx | 36 +- .../hooks/tools/shared/useToolOperation.ts | 12 +- .../src/core/hooks/useConfigButtonIcon.tsx | 6 + frontend/src/core/hooks/useJwtConfigSync.ts | 26 + frontend/src/core/pages/HomePage.tsx | 2 + frontend/src/core/styles/tailwind.css | 10 + frontend/src/core/tsconfig.json | 18 + frontend/src/desktop/LICENSE | 51 + .../shared/config/configNavSections.tsx | 2 + .../desktop/tsconfig.json} | 15 +- frontend/src/proprietary/tsconfig.json | 23 + frontend/src/saas/App.tsx | 49 + frontend/src/saas/LICENSE | 51 + frontend/src/saas/auth/UseSession.tsx | 583 +++++++++ frontend/src/saas/auth/supabase.ts | 165 +++ .../saas/components/OnboardingBootstrap.tsx | 115 ++ .../saas/components/TrialExpiredBootstrap.tsx | 118 ++ .../saas/components/auth/GuestUserBanner.css | 90 ++ .../saas/components/auth/GuestUserBanner.tsx | 88 ++ .../src/saas/components/auth/RequireAuth.tsx | 44 + .../components/feedback/UserbackWidget.tsx | 57 + .../components/home/HomePageExtensions.tsx | 6 + .../components/onboarding/OnboardingTour.tsx | 7 + .../onboarding/SaasOnboardingModal.tsx | 131 ++ .../components/onboarding/renderButtons.tsx | 95 ++ .../components/onboarding/saasFlowResolver.ts | 40 + .../onboarding/saasOnboardingFlowConfig.ts | 134 ++ .../onboarding/slides/FreeTrialSlide.tsx | 81 ++ .../onboarding/useSaasOnboardingState.ts | 172 +++ .../saas/components/shared/AppConfigModal.tsx | 238 ++++ .../src/saas/components/shared/InfoBanner.tsx | 179 +++ .../components/shared/ManageBillingButton.tsx | 64 + .../saas/components/shared/PrivateContent.tsx | 30 + .../components/shared/StripeCheckoutSaas.tsx | 231 ++++ .../components/shared/TrialExpiredModal.tsx | 178 +++ .../components/shared/TrialStatusBanner.tsx | 121 ++ .../shared/charts/StackedBarChart.tsx | 268 ++++ .../stackedBarChart/StackedBarTooltip.tsx | 43 + .../components/shared/charts/utils/d3Utils.ts | 174 +++ .../shared/charts/utils/themeUtils.ts | 63 + .../shared/charts/utils/tooltipUtils.ts | 74 ++ .../shared/config/ProfilePictureCropper.tsx | 187 +++ .../shared/config/configSections/ApiKeys.tsx | 114 ++ .../shared/config/configSections/Overview.tsx | 515 ++++++++ .../configSections/PasswordSecurity.tsx | 123 ++ .../shared/config/configSections/Plan.tsx | 201 +++ .../configSections/apiKeys/UsageSection.tsx | 139 +++ .../configSections/apiKeys/hooks/useApiKey.ts | 70 ++ .../apiKeys/hooks/useCredits.ts | 79 ++ .../configSections/plan/ActivePlanSection.tsx | 125 ++ .../plan/ApiPackagesSection.tsx | 101 ++ .../plan/AvailablePlansSection.tsx | 123 ++ .../config/configSections/plan/PlanCard.tsx | 98 ++ .../shared/config/saasConfigNavSections.tsx | 153 +++ .../saas/components/shared/config/types.ts | 67 + .../src/saas/components/shared/utils/date.ts | 14 + .../saas/components/toast/ToastRenderer.css | 209 ++++ .../components/tools/sign/SignSettings.tsx | 1090 +++++++++++++++++ frontend/src/saas/constants/app.ts | 15 + frontend/src/saas/constants/authProviders.ts | 13 + frontend/src/saas/constants/links.ts | 1 + .../src/saas/contexts/OnboardingContext.tsx | 10 + .../src/saas/hooks/useAutoAnonymousAuth.ts | 129 ++ .../src/saas/hooks/useConfigButtonIcon.tsx | 7 + frontend/src/saas/hooks/useCreditCheck.ts | 41 + frontend/src/saas/hooks/useCredits.ts | 47 + frontend/src/saas/hooks/useEndpointConfig.ts | 207 ++++ frontend/src/saas/hooks/useJwtConfigSync.ts | 7 + frontend/src/saas/hooks/usePlans.ts | 285 +++++ frontend/src/saas/routes/AuthCallback.tsx | 217 ++++ frontend/src/saas/routes/Landing.tsx | 74 ++ frontend/src/saas/routes/Login.tsx | 294 +++++ frontend/src/saas/routes/ResetPassword.tsx | 240 ++++ frontend/src/saas/routes/Signup.tsx | 226 ++++ .../routes/authShared/AuthLayout.module.css | 74 ++ .../src/saas/routes/authShared/AuthLayout.tsx | 88 ++ .../routes/authShared/GuestSignInButton.tsx | 24 + .../src/saas/routes/authShared/saas-auth.css | 72 ++ .../saas/routes/login/EmailPasswordForm.tsx | 86 ++ .../src/saas/routes/login/LoadingState.tsx | 20 + .../src/saas/routes/login/MagicLinkForm.tsx | 57 + .../src/saas/routes/login/OAuthButtons.tsx | 106 ++ .../src/saas/routes/login/SuccessMessage.tsx | 13 + .../src/saas/routes/signup/AuthService.ts | 54 + frontend/src/saas/services/accountDeletion.ts | 26 + frontend/src/saas/services/apiClient.test.ts | 237 ++++ frontend/src/saas/services/apiClient.ts | 197 +++ .../src/saas/services/avatarSyncService.ts | 338 +++++ .../saas/services/signatureStorageService.ts | 147 +++ .../saas/services/userManagementService.ts | 272 ++++ frontend/src/saas/services/userService.ts | 43 + frontend/src/saas/setupTests.ts | 175 +++ frontend/src/saas/styles/saas-theme.css | 150 +++ frontend/src/saas/styles/zIndex.ts | 12 + .../saas/tsconfig.json} | 21 +- frontend/src/saas/types/charts.ts | 30 + frontend/src/saas/types/credits.ts | 37 + frontend/src/saas/types/stripe.ts | 67 + frontend/src/saas/utils/appSettings.ts | 26 + frontend/src/saas/utils/creditCosts.ts | 92 ++ frontend/src/saas/utils/cropImage.ts | 84 ++ frontend/src/saas/utils/pathUtils.ts | 47 + frontend/tsconfig.core.json | 23 - frontend/tsconfig.saas.vite.json | 22 + frontend/vite.config.ts | 36 +- frontend/vitest.config.ts | 20 + 114 files changed, 12408 insertions(+), 99 deletions(-) create mode 100644 frontend/src/core/components/home/HomePageExtensions.tsx create mode 100644 frontend/src/core/hooks/useConfigButtonIcon.tsx create mode 100644 frontend/src/core/hooks/useJwtConfigSync.ts create mode 100644 frontend/src/core/tsconfig.json create mode 100644 frontend/src/desktop/LICENSE rename frontend/{tsconfig.desktop.json => src/desktop/tsconfig.json} (63%) create mode 100644 frontend/src/proprietary/tsconfig.json create mode 100644 frontend/src/saas/App.tsx create mode 100644 frontend/src/saas/LICENSE create mode 100644 frontend/src/saas/auth/UseSession.tsx create mode 100644 frontend/src/saas/auth/supabase.ts create mode 100644 frontend/src/saas/components/OnboardingBootstrap.tsx create mode 100644 frontend/src/saas/components/TrialExpiredBootstrap.tsx create mode 100644 frontend/src/saas/components/auth/GuestUserBanner.css create mode 100644 frontend/src/saas/components/auth/GuestUserBanner.tsx create mode 100644 frontend/src/saas/components/auth/RequireAuth.tsx create mode 100644 frontend/src/saas/components/feedback/UserbackWidget.tsx create mode 100644 frontend/src/saas/components/home/HomePageExtensions.tsx create mode 100644 frontend/src/saas/components/onboarding/OnboardingTour.tsx create mode 100644 frontend/src/saas/components/onboarding/SaasOnboardingModal.tsx create mode 100644 frontend/src/saas/components/onboarding/renderButtons.tsx create mode 100644 frontend/src/saas/components/onboarding/saasFlowResolver.ts create mode 100644 frontend/src/saas/components/onboarding/saasOnboardingFlowConfig.ts create mode 100644 frontend/src/saas/components/onboarding/slides/FreeTrialSlide.tsx create mode 100644 frontend/src/saas/components/onboarding/useSaasOnboardingState.ts create mode 100644 frontend/src/saas/components/shared/AppConfigModal.tsx create mode 100644 frontend/src/saas/components/shared/InfoBanner.tsx create mode 100644 frontend/src/saas/components/shared/ManageBillingButton.tsx create mode 100644 frontend/src/saas/components/shared/PrivateContent.tsx create mode 100644 frontend/src/saas/components/shared/StripeCheckoutSaas.tsx create mode 100644 frontend/src/saas/components/shared/TrialExpiredModal.tsx create mode 100644 frontend/src/saas/components/shared/TrialStatusBanner.tsx create mode 100644 frontend/src/saas/components/shared/charts/StackedBarChart.tsx create mode 100644 frontend/src/saas/components/shared/charts/stackedBarChart/StackedBarTooltip.tsx create mode 100644 frontend/src/saas/components/shared/charts/utils/d3Utils.ts create mode 100644 frontend/src/saas/components/shared/charts/utils/themeUtils.ts create mode 100644 frontend/src/saas/components/shared/charts/utils/tooltipUtils.ts create mode 100644 frontend/src/saas/components/shared/config/ProfilePictureCropper.tsx create mode 100644 frontend/src/saas/components/shared/config/configSections/ApiKeys.tsx create mode 100644 frontend/src/saas/components/shared/config/configSections/Overview.tsx create mode 100644 frontend/src/saas/components/shared/config/configSections/PasswordSecurity.tsx create mode 100644 frontend/src/saas/components/shared/config/configSections/Plan.tsx create mode 100644 frontend/src/saas/components/shared/config/configSections/apiKeys/UsageSection.tsx create mode 100644 frontend/src/saas/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts create mode 100644 frontend/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts create mode 100644 frontend/src/saas/components/shared/config/configSections/plan/ActivePlanSection.tsx create mode 100644 frontend/src/saas/components/shared/config/configSections/plan/ApiPackagesSection.tsx create mode 100644 frontend/src/saas/components/shared/config/configSections/plan/AvailablePlansSection.tsx create mode 100644 frontend/src/saas/components/shared/config/configSections/plan/PlanCard.tsx create mode 100644 frontend/src/saas/components/shared/config/saasConfigNavSections.tsx create mode 100644 frontend/src/saas/components/shared/config/types.ts create mode 100644 frontend/src/saas/components/shared/utils/date.ts create mode 100644 frontend/src/saas/components/toast/ToastRenderer.css create mode 100644 frontend/src/saas/components/tools/sign/SignSettings.tsx create mode 100644 frontend/src/saas/constants/app.ts create mode 100644 frontend/src/saas/constants/authProviders.ts create mode 100644 frontend/src/saas/constants/links.ts create mode 100644 frontend/src/saas/contexts/OnboardingContext.tsx create mode 100644 frontend/src/saas/hooks/useAutoAnonymousAuth.ts create mode 100644 frontend/src/saas/hooks/useConfigButtonIcon.tsx create mode 100644 frontend/src/saas/hooks/useCreditCheck.ts create mode 100644 frontend/src/saas/hooks/useCredits.ts create mode 100644 frontend/src/saas/hooks/useEndpointConfig.ts create mode 100644 frontend/src/saas/hooks/useJwtConfigSync.ts create mode 100644 frontend/src/saas/hooks/usePlans.ts create mode 100644 frontend/src/saas/routes/AuthCallback.tsx create mode 100644 frontend/src/saas/routes/Landing.tsx create mode 100644 frontend/src/saas/routes/Login.tsx create mode 100644 frontend/src/saas/routes/ResetPassword.tsx create mode 100644 frontend/src/saas/routes/Signup.tsx create mode 100644 frontend/src/saas/routes/authShared/AuthLayout.module.css create mode 100644 frontend/src/saas/routes/authShared/AuthLayout.tsx create mode 100644 frontend/src/saas/routes/authShared/GuestSignInButton.tsx create mode 100644 frontend/src/saas/routes/authShared/saas-auth.css create mode 100644 frontend/src/saas/routes/login/EmailPasswordForm.tsx create mode 100644 frontend/src/saas/routes/login/LoadingState.tsx create mode 100644 frontend/src/saas/routes/login/MagicLinkForm.tsx create mode 100644 frontend/src/saas/routes/login/OAuthButtons.tsx create mode 100644 frontend/src/saas/routes/login/SuccessMessage.tsx create mode 100644 frontend/src/saas/routes/signup/AuthService.ts create mode 100644 frontend/src/saas/services/accountDeletion.ts create mode 100644 frontend/src/saas/services/apiClient.test.ts create mode 100644 frontend/src/saas/services/apiClient.ts create mode 100644 frontend/src/saas/services/avatarSyncService.ts create mode 100644 frontend/src/saas/services/signatureStorageService.ts create mode 100644 frontend/src/saas/services/userManagementService.ts create mode 100644 frontend/src/saas/services/userService.ts create mode 100644 frontend/src/saas/setupTests.ts create mode 100644 frontend/src/saas/styles/saas-theme.css create mode 100644 frontend/src/saas/styles/zIndex.ts rename frontend/{tsconfig.proprietary.json => src/saas/tsconfig.json} (62%) create mode 100644 frontend/src/saas/types/charts.ts create mode 100644 frontend/src/saas/types/credits.ts create mode 100644 frontend/src/saas/types/stripe.ts create mode 100644 frontend/src/saas/utils/appSettings.ts create mode 100644 frontend/src/saas/utils/creditCosts.ts create mode 100644 frontend/src/saas/utils/cropImage.ts create mode 100644 frontend/src/saas/utils/pathUtils.ts delete mode 100644 frontend/tsconfig.core.json create mode 100644 frontend/tsconfig.saas.vite.json diff --git a/LICENSE b/LICENSE index c20c97f6e2..c572f474d7 100644 --- a/LICENSE +++ b/LICENSE @@ -10,6 +10,8 @@ if that directory exists, is licensed under the license defined in "app/propriet if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE". * All content that resides under the "frontend/src/desktop/" directory of this repository, if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE". +* All content that resides under the "frontend/src/saas/" directory of this repository, +if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE". * Content outside of the above mentioned directories or restrictions above is available under the MIT License as defined below. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7607a40def..d82024705e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -57,8 +57,10 @@ "@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-shell": "^2.3.5", + "@userback/widget": "^0.3.12", "autoprefixer": "^10.4.21", "axios": "^1.13.2", + "d3": "^7.9.0", "globals": "^17.1.0", "i18next": "^25.5.2", "i18next-browser-languagedetector": "^8.2.0", @@ -70,6 +72,7 @@ "qrcode.react": "^4.2.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-easy-crop": "^5.5.6", "react-i18next": "^15.7.3", "react-rnd": "^10.5.2", "react-router-dom": "^7.9.1", @@ -89,6 +92,7 @@ "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/d3": "^7.4.3", "@types/gapi": "^0.0.47", "@types/gapi.client.drive-v3": "^0.0.5", "@types/google.accounts": "^0.0.18", @@ -4561,24 +4565,173 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -4594,6 +4747,27 @@ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -4603,6 +4777,20 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -4618,12 +4806,40 @@ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4678,6 +4894,13 @@ "@maxim_mazurok/gapi.client.drive-v3": "latest" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/google.accounts": { "version": "0.0.18", "resolved": "https://registry.npmjs.org/@types/google.accounts/-/google.accounts-0.0.18.tgz", @@ -5025,6 +5248,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@userback/widget": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@userback/widget/-/widget-0.3.12.tgz", + "integrity": "sha512-68YihQblx1+4kFpd7e4nDBSV6ljSY0+hzIHHgbxgbJPJIhNgN4lyvOjcDbCrEb9qh6yXx5W6HBLKTMJD4hLXGg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react-swc": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.3.tgz", @@ -6300,7 +6529,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10" @@ -6464,6 +6692,47 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -6476,6 +6745,43 @@ "node": ">=12" } }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -6485,6 +6791,77 @@ "node": ">=12" } }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -6494,6 +6871,32 @@ "node": ">=12" } }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -6503,6 +6906,27 @@ "node": ">=12" } }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -6524,6 +6948,33 @@ "node": ">=12" } }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -6540,6 +6991,28 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -6585,6 +7058,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", @@ -6736,6 +7244,15 @@ "node": ">= 14" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -8351,6 +8868,18 @@ "node": ">=20.0.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -9812,6 +10341,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, "node_modules/npm-normalize-package-bin": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", @@ -11052,6 +11587,20 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-easy-crop": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.6.tgz", + "integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "^2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, "node_modules/react-i18next": { "version": "15.7.4", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", @@ -11632,6 +12181,12 @@ "dev": true, "license": "ISC" }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -11677,12 +12232,24 @@ "fsevents": "~2.3.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sass-lookup": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/sass-lookup/-/sass-lookup-6.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index db2ec9f9f5..7074f80970 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -52,8 +52,10 @@ "@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-shell": "^2.3.5", + "@userback/widget": "^0.3.12", "autoprefixer": "^10.4.21", "axios": "^1.13.2", + "d3": "^7.9.0", "globals": "^17.1.0", "i18next": "^25.5.2", "i18next-browser-languagedetector": "^8.2.0", @@ -66,6 +68,7 @@ "qrcode.react": "^4.2.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-easy-crop": "^5.5.6", "react-i18next": "^15.7.3", "react-rnd": "^10.5.2", "react-router-dom": "^7.9.1", @@ -79,11 +82,19 @@ "pretauri-build": "node scripts/build-provisioner.mjs", "predev": "npm run generate-icons", "dev": "vite", + "dev:core": "vite --mode core", + "dev:proprietary": "vite --mode proprietary", + "dev:saas": "vite --mode saas", + "dev:desktop": "vite --mode desktop", "prebuild": "npm run generate-icons", "lint": "npm run lint:eslint && npm run lint:cycles", "lint:eslint": "eslint --max-warnings=0", "lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1", "build": "vite build", + "build:core": "vite build --mode core", + "build:proprietary": "vite build --mode proprietary", + "build:saas": "vite build --mode saas", + "build:desktop": "vite build --mode desktop", "preview": "vite preview", "tauri-dev": "tauri dev --no-watch", "tauri-build": "tauri build", @@ -93,10 +104,11 @@ "tauri-build-dev-linux": "tauri build --bundles appimage", "tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build", "typecheck": "npm run typecheck:proprietary", - "typecheck:core": "tsc --noEmit --project tsconfig.core.json", - "typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json", - "typecheck:desktop": "tsc --noEmit --project tsconfig.desktop.json", - "typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:desktop", + "typecheck:core": "tsc --noEmit --project src/core/tsconfig.json", + "typecheck:proprietary": "tsc --noEmit --project src/proprietary/tsconfig.json", + "typecheck:saas": "tsc --noEmit --project src/saas/tsconfig.json", + "typecheck:desktop": "tsc --noEmit --project src/desktop/tsconfig.json", + "typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:saas && npm run typecheck:desktop", "check": "npm run typecheck && npm run lint && npm run test:run", "generate-licenses": "node scripts/generate-licenses.js", "generate-icons": "node scripts/generate-icons.js", @@ -136,6 +148,7 @@ "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/d3": "^7.4.3", "@types/gapi": "^0.0.47", "@types/gapi.client.drive-v3": "^0.0.5", "@types/google.accounts": "^0.0.18", diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index bfbd442e14..c403d835d7 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -142,6 +142,10 @@ welcome = "Welcome" white = "White" WorkInProgess = "Work in progress, May not work or be buggy, Please report any problems!" yes = "Yes" +insufficientCredits = "Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}" +loadingCredits = "Checking credits..." +loadingProStatus = "Checking subscription status..." +noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan" [account] accountSettings = "Account Settings" @@ -2563,10 +2567,45 @@ title = "Quality Adjustment" tags = "squish,small,tiny" [config.account.overview] +confirmDelete = "Delete My Account" +deleteAccount = "Delete Account" +deleteAccountTitle = "Delete Account" +deleteFailed = "Failed to delete account." +deleteFailedTitle = "Unable to delete account" +deleteWarning = "This action is permanent and cannot be undone. All your data will be deleted." +enterEmailConfirm = "To confirm deletion, please type your email address ({{email}}) below:" guestDescription = "You are signed in as a guest. Consider upgrading your account above." +label = "Overview" manageAccountPreferences = "Manage your account preferences" +signedInAs = "Signed in as" title = "Account Settings" +[config.account.profilePicture] +description = "Upload an image to personalize your account." +help = "PNG, JPG, or WebP up to 2MB." +remove = "Remove" +sizeError = "Please select an image smaller than 2MB." +switchedToCustom = "Switched to custom picture. You can now upload your own." +title = "Profile picture" +upload = "Upload" +useCustom = "Use custom picture" +usingProvider = "Using {{provider}} profile picture" + +[config.account.profilePicture.cropper] +cropError = "Failed to crop image. Please try again." +invalidImage = "Invalid image file. Please select a valid PNG, JPG, or WebP file." +processing = "Processing crop..." +save = "Save Cropped Image" +sizeErrorAfterCrop = "Cropped image is too large. Please zoom out or crop a smaller area." +title = "Crop Profile Picture" +zoom = "Zoom" + +[config.account.security] +changePassword = "Change password" +description = "Manage your password and security settings." +title = "Passwords & Security" +update = "Update password" + [config.account.upgrade] description = "Link your account to preserve your history and access more features!" email = "Email" @@ -2580,9 +2619,13 @@ socialLogin = "Upgrade with Social Account" title = "Upgrade Guest Account" upgradeButton = "Upgrade Account" +[config] +plan = "Plan" + [config.apiKeys] chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}" copyKeyAriaLabel = "Copy API key" +creditsRemaining = "Credits Remaining" description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one." docsDescription = "Learn more about integrating with Stirling PDF:" docsLink = "API Documentation" @@ -3842,6 +3885,7 @@ version = "Version" accountCreatedSuccess = "Account created successfully! You can now sign in." alreadyLoggedIn = "You are already logged in to" alreadyLoggedIn2 = "devices. Please log out of the devices and try again." +backToSignIn = "Back to sign in" cancel = "Cancel" changePasswordWarning = "Please change your password after logging in for the first time" credentialsUpdated = "Your credentials have been updated. Please sign in again." @@ -3883,16 +3927,21 @@ or = "Or" password = "Password" passwordChangedSuccess = "Password changed successfully! Please sign in with your new password." passwordResetSent = "Password reset link sent to {{email}}! Check your email and follow the instructions." +passwordUpdatedSuccess = "Your password has been updated successfully." pleaseEnterBoth = "Please enter both email and password" pleaseEnterEmail = "Please enter your email address" relyingPartyRegistrationNotFound = "No relying party registration found" rememberme = "Remember me" +resetHelp = "Enter your email to receive a secure link to reset your password. If the link has expired, please request a new one." +resetYourPassword = "Reset your password" saml2RequiresLicense = "SAML login requires an Enterprise license. Please contact the administrator to upgrade your plan." sending = "Sending…" sendMagicLink = "Send Magic Link" +sendResetLink = "Send reset link" sessionExpired = "Your session has expired. Please sign in again." signin = "Sign in" signInAnonymously = "Sign Up as a Guest" +subtitle = "Sign back in to Stirling PDF" signingIn = "Signing in..." signinTitle = "Please sign in" signInWith = "Sign in with" @@ -3901,6 +3950,7 @@ ssoSignIn = "Login via Single Sign-on" title = "Sign in" toManySessions = "You have too many active sessions" unexpectedError = "Unexpected error: {{message}}" +updatePassword = "Update password" useEmailInstead = "Login with email" useMagicLink = "Use magic link instead" userIsDisabled = "User is deactivated, login is currently blocked with this username. Please contact the administrator." @@ -4210,6 +4260,15 @@ viewSwitcher = "Use these controls to select how you want to view your PDFs." workbench = "This is the Workbench - the main area where you view and edit your PDFs." wrapUp = "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again." +[onboarding.freeTrial] +afterTrialWithoutPayment = "After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access." +afterTrialWithPayment = "Your Pro subscription will start automatically when the trial ends." +body = "You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing." +daysRemaining = "{{days}} days remaining" +daysRemainingSingular = "{{days}} day remaining" +title = "Your 30-Day Pro Trial" +trialEnds = "Trial ends {{date}}" + [onboarding.buttons] back = "Back" download = "Download →" @@ -4947,7 +5006,10 @@ perMonth = "/month" perSeat = "/seat" popular = "Popular" selectPlan = "Select Plan" +selectCredits = "Select Credit Amount" showComparison = "Compare All Features" +purchase = "Purchase" +totalCost = "Total Cost" upgrade = "Upgrade" withServer = "+ Server Plan" @@ -5039,6 +5101,30 @@ successMessage = "Your license has been successfully activated. You can now clos name = "Team" siteLicense = "Site License" +[plan.api] +large = "5,000 Credits" +medium = "1,000 Credits" +small = "500 Credits" +xsmall = "100 Credits" + +[plan.apiPackages] +subtitle = "Purchase API credits for your applications" +title = "API Credit Packages" + +[plan.trial] +badge = "Trial" +continueWithFree = "Continue with Free" +daysRemaining = "Your trial ends in {{days}} days" +endDate = "Expires: {{date}}" +expired = "Your Trial Has Ended" +expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier." +freeTierLimitations = "Free tier includes basic PDF tools with usage limits." +message = "" +subscribe = "Subscribe to Pro" +subscribeToPro = "Subscribe to Pro" +subscriptionScheduled = "Subscription scheduled - starts {{date}}" +title = "Free Trial Active" + [credits] enableOverageBilling = "Enable Overage Billing" maybeLater = "Maybe later" diff --git a/frontend/src/core/components/home/HomePageExtensions.tsx b/frontend/src/core/components/home/HomePageExtensions.tsx new file mode 100644 index 0000000000..5bc8194a77 --- /dev/null +++ b/frontend/src/core/components/home/HomePageExtensions.tsx @@ -0,0 +1,7 @@ +/** + * Core stub for HomePage extensions. + */ + +export function HomePageExtensions() { + return null; +} diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx index 8c1ae4f033..3b59bd0708 100644 --- a/frontend/src/core/components/shared/QuickAccessBar.tsx +++ b/frontend/src/core/components/shared/QuickAccessBar.tsx @@ -28,6 +28,7 @@ import { } from '@app/components/shared/quickAccessBar/QuickAccessBar'; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; import { QuickAccessBarFooterExtensions } from '@app/components/quickAccessBar/QuickAccessBarFooterExtensions'; +import { useConfigButtonIcon } from '@app/hooks/useConfigButtonIcon'; const QuickAccessBar = forwardRef((_, ref) => { const { t } = useTranslation(); @@ -44,6 +45,8 @@ const QuickAccessBar = forwardRef((_, ref) => { const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); + const configButtonIcon = useConfigButtonIcon(); + const { tooltipOpen, manualCloseOnly, @@ -202,7 +205,7 @@ const QuickAccessBar = forwardRef((_, ref) => { ...(shouldHideSettingsButton ? [] : [{ id: 'config', name: t("quickAccess.settings", "Settings"), - icon: , + icon: configButtonIcon ?? , size: 'md' as const, type: 'modal' as const, onClick: () => { diff --git a/frontend/src/core/components/shared/config/SettingsSearchBar.tsx b/frontend/src/core/components/shared/config/SettingsSearchBar.tsx index 7dd1f55b7c..cbbbd97076 100644 --- a/frontend/src/core/components/shared/config/SettingsSearchBar.tsx +++ b/frontend/src/core/components/shared/config/SettingsSearchBar.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import LocalIcon from '@app/components/shared/LocalIcon'; import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; -import type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/config/configNavSections'; +import type { ConfigNavSection, ConfigNavItem } from '@app/components/shared/config/configNavSections'; interface SettingsSearchBarProps { configNavSections: ConfigNavSection[]; diff --git a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx index a773285099..db3eb107f7 100644 --- a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx @@ -30,9 +30,11 @@ const BANNER_DISMISSED_KEY = "stirlingpdf_features_banner_dismissed"; interface GeneralSectionProps { hideTitle?: boolean; + hideUpdateSection?: boolean; + hideAdminBanner?: boolean; } -const GeneralSection: React.FC = ({ hideTitle = false }) => { +const GeneralSection: React.FC = ({ hideTitle = false, hideUpdateSection = false, hideAdminBanner = false }) => { const { t } = useTranslation(); const { preferences, updatePreference } = usePreferences(); const { config } = useAppConfig(); @@ -156,7 +158,7 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => )} - {loginDisabled && !bannerDismissed && ( + {!hideAdminBanner && loginDisabled && !bannerDismissed && ( = ({ hideTitle = false }) => )} {/* Update Check Section */} - {config?.appVersion && ( + {!hideUpdateSection && config?.appVersion && (
diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index 14950f7db3..8ffac68772 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode, useCa import apiClient from '@app/services/apiClient'; import { getSimulatedAppConfig } from '@app/testing/serverExperienceSimulations'; import type { AppConfig, AppConfigBootstrapMode } from '@app/types/appConfig'; +import { useJwtConfigSync } from '@app/hooks/useJwtConfigSync'; /** * Sleep utility for delays @@ -41,6 +42,7 @@ export interface AppConfigProviderProps { initialConfig?: AppConfig | null; bootstrapMode?: AppConfigBootstrapMode; autoFetch?: boolean; + onConfigLoaded?: (config: AppConfig) => void; } export const AppConfigProvider: React.FC = ({ @@ -49,6 +51,7 @@ export const AppConfigProvider: React.FC = ({ initialConfig = null, bootstrapMode = 'blocking', autoFetch = true, + onConfigLoaded, }) => { const isBlockingMode = bootstrapMode === 'blocking'; const [config, setConfig] = useState(initialConfig); @@ -58,6 +61,9 @@ export const AppConfigProvider: React.FC = ({ const [hasResolvedConfig, setHasResolvedConfig] = useState(Boolean(initialConfig) && !isBlockingMode); const [loading, setLoading] = useState(!hasResolvedConfig); + const onConfigLoadedRef = React.useRef(onConfigLoaded); + onConfigLoadedRef.current = onConfigLoaded; + const maxRetries = retryOptions?.maxRetries ?? 0; const initialDelay = retryOptions?.initialDelay ?? 1000; @@ -113,6 +119,7 @@ export const AppConfigProvider: React.FC = ({ setConfig(data); setHasResolvedConfig(true); setLoading(false); + onConfigLoadedRef.current?.(data); return; // Success - exit function } catch (err: any) { const status = err?.response?.status; @@ -151,42 +158,21 @@ export const AppConfigProvider: React.FC = ({ setLoading(false); }, [hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]); - useEffect(() => { - // Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*) - // Config will be fetched after successful authentication via jwt-available event - const currentPath = window.location.pathname; - const isAuthPage = currentPath.includes('/login') || - currentPath.includes('/signup') || - currentPath.includes('/auth/callback') || - currentPath.includes('/invite/'); + const { isAuthPage } = useJwtConfigSync(fetchConfig); - // On auth pages, always skip the config fetch - // The config will be fetched after authentication via jwt-available event + useEffect(() => { if (isAuthPage) { - console.debug('[AppConfig] On auth page - using default config, skipping fetch', { path: currentPath }); + console.debug('[AppConfig] On auth page - using default config, skipping fetch', { path: window.location.pathname }); setConfig({ enableLogin: true }); setHasResolvedConfig(true); setLoading(false); return; } - // On non-auth pages, fetch config (will validate JWT if present) if (autoFetch) { fetchConfig(); } - }, [autoFetch, fetchConfig]); - - // Listen for JWT availability (triggered on login/signup) - useEffect(() => { - const handleJwtAvailable = () => { - console.debug('[AppConfig] JWT available event - refetching config'); - // Force refetch with JWT - fetchConfig(true); - }; - - window.addEventListener('jwt-available', handleJwtAvailable); - return () => window.removeEventListener('jwt-available', handleJwtAvailable); - }, [fetchConfig]); + }, [autoFetch, fetchConfig, isAuthPage]); const refetch = useCallback(() => fetchConfig(true), [fetchConfig]); diff --git a/frontend/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/src/core/hooks/tools/shared/useToolOperation.ts index 9f87387f64..6696ed2b30 100644 --- a/frontend/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/core/hooks/tools/shared/useToolOperation.ts @@ -121,13 +121,11 @@ export const useToolOperation = ( ? config.endpoint(params) : config.endpoint; - // Credit check for cloud operations (desktop SaaS mode only, no-op in web builds) - if (willUseCloud && endpoint) { - const creditError = await checkCredits(); - if (creditError !== null) { - actions.setError(creditError); - return; - } + // Credit check — no-op in core builds, real check in desktop/SaaS versions + const creditError = await checkCredits(); + if (creditError !== null) { + actions.setError(creditError); + return; } // Backend readiness check (will skip for SaaS-routed endpoints) diff --git a/frontend/src/core/hooks/useConfigButtonIcon.tsx b/frontend/src/core/hooks/useConfigButtonIcon.tsx new file mode 100644 index 0000000000..ef7447cd5e --- /dev/null +++ b/frontend/src/core/hooks/useConfigButtonIcon.tsx @@ -0,0 +1,6 @@ +/** + * Core stub — config button uses the default settings icon. + */ +export function useConfigButtonIcon(): React.ReactNode { + return null; +} diff --git a/frontend/src/core/hooks/useJwtConfigSync.ts b/frontend/src/core/hooks/useJwtConfigSync.ts new file mode 100644 index 0000000000..723d02d03f --- /dev/null +++ b/frontend/src/core/hooks/useJwtConfigSync.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; + +/** + * Core implementation: sets up the jwt-available event listener for OSS JWT auth, + * and detects auth pages where config fetch should be skipped. + */ +export function useJwtConfigSync(fetchConfig: (force?: boolean) => void): { isAuthPage: boolean } { + const currentPath = window.location.pathname; + const isAuthPage = + currentPath.includes('/login') || + currentPath.includes('/signup') || + currentPath.includes('/auth/callback') || + currentPath.includes('/invite/'); + + useEffect(() => { + const handleJwtAvailable = () => { + console.debug('[AppConfig] JWT available event - refetching config'); + fetchConfig(true); + }; + + window.addEventListener('jwt-available', handleJwtAvailable); + return () => window.removeEventListener('jwt-available', handleJwtAvailable); + }, [fetchConfig]); + + return { isAuthPage }; +} diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx index 9bbe903914..4a319a1865 100644 --- a/frontend/src/core/pages/HomePage.tsx +++ b/frontend/src/core/pages/HomePage.tsx @@ -23,6 +23,7 @@ import LocalIcon from "@app/components/shared/LocalIcon"; import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import AppConfigModal from "@app/components/shared/AppConfigModal"; import { getStartupNavigationAction } from "@app/utils/homePageNavigation"; +import { HomePageExtensions } from "@app/components/home/HomePageExtensions"; import "@app/pages/HomePage.css"; @@ -204,6 +205,7 @@ export default function HomePage() { return (
+ {isMobile ? (
diff --git a/frontend/src/core/styles/tailwind.css b/frontend/src/core/styles/tailwind.css index 9415e80cee..1408a13968 100644 --- a/frontend/src/core/styles/tailwind.css +++ b/frontend/src/core/styles/tailwind.css @@ -13,3 +13,13 @@ @layer utilities { @tailwind utilities; } + +@layer utilities { + .no-scrollbar::-webkit-scrollbar { + display: none; + } + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } +} diff --git a/frontend/src/core/tsconfig.json b/frontend/src/core/tsconfig.json new file mode 100644 index 0000000000..c32398094b --- /dev/null +++ b/frontend/src/core/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "../../", + "paths": { + "@app/*": [ + "src/core/*" + ] + } + }, + "include": [ + "../global.d.ts", + "../*.js", + "../*.ts", + "../*.tsx", + "." + ] +} diff --git a/frontend/src/desktop/LICENSE b/frontend/src/desktop/LICENSE new file mode 100644 index 0000000000..d268556808 --- /dev/null +++ b/frontend/src/desktop/LICENSE @@ -0,0 +1,51 @@ +Stirling PDF User License + +Copyright (c) 2025 Stirling PDF Inc. + +License Scope & Usage Rights + +Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License. + +For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files +provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical +processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service +(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription +covering the appropriate number of licensed users. + +Trial and Minimal Use + +You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that: +* Use is limited to the capabilities and restrictions defined by the Software itself; +* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts. + +Continued use beyond this scope requires a valid Stirling PDF User License. + +Modifications and Derivative Works + +You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works: + +* May not be deployed in production environments without a valid User License; +* May not be distributed or sublicensed; +* Remain the intellectual property of Stirling PDF and/or its licensors; +* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription. + +Prohibited Actions + +Unless explicitly permitted by a paid license or separate agreement, you may not: + +* Use the Software in production environments; +* Copy, merge, distribute, sublicense, or sell the Software; +* Remove or alter any licensing or copyright notices; +* Circumvent access restrictions or licensing requirements. + +Third-Party Components + +The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by +their original license terms as provided by their respective owners. + +Disclaimer + +THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/frontend/src/desktop/components/shared/config/configNavSections.tsx b/frontend/src/desktop/components/shared/config/configNavSections.tsx index 52635ab3cb..148ed1961e 100644 --- a/frontend/src/desktop/components/shared/config/configNavSections.tsx +++ b/frontend/src/desktop/components/shared/config/configNavSections.tsx @@ -8,6 +8,8 @@ import { SaaSTeamsSection } from '@app/components/shared/config/configSections/S import { connectionModeService } from '@app/services/connectionModeService'; import { authService } from '@app/services/authService'; +export type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/config/configNavSections'; + /** * Hook version of desktop config nav sections with proper i18n support */ diff --git a/frontend/tsconfig.desktop.json b/frontend/src/desktop/tsconfig.json similarity index 63% rename from frontend/tsconfig.desktop.json rename to frontend/src/desktop/tsconfig.json index 1d952b528a..6c6989ebc3 100644 --- a/frontend/tsconfig.desktop.json +++ b/frontend/src/desktop/tsconfig.json @@ -1,6 +1,7 @@ { - "extends": "./tsconfig.proprietary.json", + "extends": "../../tsconfig.json", "compilerOptions": { + "baseUrl": "../../", "paths": { "@app/*": [ "src/desktop/*", @@ -16,11 +17,11 @@ } }, "include": [ - "src/global.d.ts", - "src/*.js", - "src/*.ts", - "src/*.tsx", - "src/core/setupTests.ts", - "src/desktop" + "../global.d.ts", + "../*.js", + "../*.ts", + "../*.tsx", + "../core/setupTests.ts", + "." ] } diff --git a/frontend/src/proprietary/tsconfig.json b/frontend/src/proprietary/tsconfig.json new file mode 100644 index 0000000000..3bba3e8bc2 --- /dev/null +++ b/frontend/src/proprietary/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "../../", + "paths": { + "@app/*": [ + "src/proprietary/*", + "src/core/*" + ], + "@core/*": [ + "src/core/*" + ] + } + }, + "include": [ + "../global.d.ts", + "../*.js", + "../*.ts", + "../*.tsx", + "../core/setupTests.ts", + "." + ] +} diff --git a/frontend/src/saas/App.tsx b/frontend/src/saas/App.tsx new file mode 100644 index 0000000000..4a8c5f9b17 --- /dev/null +++ b/frontend/src/saas/App.tsx @@ -0,0 +1,49 @@ +import { Suspense } from 'react'; +import { Routes, Route } from 'react-router-dom'; +import { AppProviders } from '@app/components/AppProviders'; +import { setBaseUrl } from '@app/constants/app'; +import type { AppConfig } from '@app/contexts/AppConfigContext'; +import { AppLayout } from '@app/components/AppLayout'; +import { LoadingFallback } from '@app/components/shared/LoadingFallback'; +import OnboardingTour from '@app/components/onboarding/OnboardingTour'; +import Landing from '@app/routes/Landing'; +import Login from '@app/routes/Login'; +import Signup from '@app/routes/Signup'; +import AuthCallback from '@app/routes/AuthCallback'; +import ResetPassword from '@app/routes/ResetPassword'; +import OnboardingBootstrap from '@app/components/OnboardingBootstrap'; +import TrialExpiredBootstrap from '@app/components/TrialExpiredBootstrap'; + +// Import global styles +import '@app/styles/tailwind.css'; +import '@app/styles/saas-theme.css'; +import '@app/styles/cookieconsent.css'; +import '@app/styles/index.css'; + +// Import file ID debugging helpers (development only) +import '@app/utils/fileIdSafety'; + +function handleConfigLoaded(config: AppConfig) { + if (config.baseUrl) setBaseUrl(config.baseUrl); +} + +export default function App() { + return ( + }> + + + + + + } /> + } /> + } /> + } /> + } /> + + + + + + ); +} diff --git a/frontend/src/saas/LICENSE b/frontend/src/saas/LICENSE new file mode 100644 index 0000000000..d268556808 --- /dev/null +++ b/frontend/src/saas/LICENSE @@ -0,0 +1,51 @@ +Stirling PDF User License + +Copyright (c) 2025 Stirling PDF Inc. + +License Scope & Usage Rights + +Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License. + +For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files +provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical +processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service +(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription +covering the appropriate number of licensed users. + +Trial and Minimal Use + +You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that: +* Use is limited to the capabilities and restrictions defined by the Software itself; +* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts. + +Continued use beyond this scope requires a valid Stirling PDF User License. + +Modifications and Derivative Works + +You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works: + +* May not be deployed in production environments without a valid User License; +* May not be distributed or sublicensed; +* Remain the intellectual property of Stirling PDF and/or its licensors; +* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription. + +Prohibited Actions + +Unless explicitly permitted by a paid license or separate agreement, you may not: + +* Use the Software in production environments; +* Copy, merge, distribute, sublicense, or sell the Software; +* Remove or alter any licensing or copyright notices; +* Circumvent access restrictions or licensing requirements. + +Third-Party Components + +The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by +their original license terms as provided by their respective owners. + +Disclaimer + +THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/frontend/src/saas/auth/UseSession.tsx b/frontend/src/saas/auth/UseSession.tsx new file mode 100644 index 0000000000..0de2231676 --- /dev/null +++ b/frontend/src/saas/auth/UseSession.tsx @@ -0,0 +1,583 @@ +import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react' +import { supabase } from '@app/auth/supabase' +import type { Session, User as SupabaseUser, AuthError } from '@supabase/supabase-js' +import { CreditSummary, SubscriptionInfo, CreditCheckResult, ApiCredits } from '@app/types/credits' +import apiClient, { setGlobalCreditUpdateCallback } from '@app/services/apiClient' +import { synchronizeUserUpgrade } from '@app/services/userService' +import { syncOAuthAvatar, getProfilePictureMetadata, type ProfilePictureMetadata } from '@app/services/avatarSyncService' + +// Extend Supabase User to include optional username for compatibility +export type User = SupabaseUser & { username?: string }; + +export interface TrialStatus { + isTrialing: boolean + trialEnd: string + daysRemaining: number + hasPaymentMethod: boolean + hasScheduledSub: boolean + status: string +} + +interface AuthContextType { + session: Session | null + user: User | null + loading: boolean + error: AuthError | null + creditBalance: number | null + subscription: SubscriptionInfo | null + creditSummary: CreditSummary | null + isPro: boolean | null + trialStatus: TrialStatus | null + profilePictureUrl: string | null + profilePictureMetadata: ProfilePictureMetadata | null + signOut: () => Promise + refreshSession: () => Promise + hasSufficientCredits: (requiredCredits: number) => CreditCheckResult + updateCredits: (newBalance: number) => void + refreshCredits: () => Promise + refreshProStatus: () => Promise + refreshTrialStatus: () => Promise + refreshProfilePicture: () => Promise + refreshProfilePictureMetadata: () => Promise +} + +const AuthContext = createContext({ + session: null, + user: null, + loading: true, + error: null, + creditBalance: null, + subscription: null, + creditSummary: null, + isPro: null, + trialStatus: null, + profilePictureUrl: null, + profilePictureMetadata: null, + signOut: async () => {}, + refreshSession: async () => {}, + hasSufficientCredits: () => ({ hasSufficientCredits: false, currentBalance: 0, requiredCredits: 0 }), + updateCredits: () => {}, + refreshCredits: async () => {}, + refreshProStatus: async () => {}, + refreshTrialStatus: async () => {}, + refreshProfilePicture: async () => {}, + refreshProfilePictureMetadata: async () => {} +}) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [creditBalance, setCreditBalance] = useState(null) + const [subscription, setSubscription] = useState(null) + const [creditSummary, setCreditSummary] = useState(null) + const [isPro, setIsPro] = useState(null) + const [trialStatus, setTrialStatus] = useState(null) + const [profilePictureUrl, setProfilePictureUrl] = useState(null) + const [profilePictureMetadata, setProfilePictureMetadata] = useState(null) + + const fetchCredits = useCallback(async (sessionToUse?: Session | null) => { + const currentSession = sessionToUse ?? session + + if (!currentSession?.user) { + console.debug('[Auth Debug] No user session, skipping credit fetch') + setCreditBalance(null) + setCreditSummary(null) + setSubscription(null) + return + } + + try { + console.debug('[Auth Debug] Fetching credits for user:', currentSession.user.id) + const response = await apiClient.get('/api/v1/credits') + const apiCredits = response.data + + // Map server payload to app CreditSummary + const credits: CreditSummary = { + currentCredits: apiCredits.totalAvailableCredits, + maxCredits: apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits, + creditsUsed: (apiCredits.weeklyCreditsAllocated - apiCredits.weeklyCreditsRemaining) + (apiCredits.totalBoughtCredits - apiCredits.boughtCreditsRemaining), + creditsRemaining: apiCredits.totalAvailableCredits, + resetDate: apiCredits.weeklyResetDate, + weeklyAllowance: apiCredits.weeklyCreditsAllocated + } + + setCreditSummary(credits) + setCreditBalance(credits.creditsRemaining) + + const subscriptionInfo: SubscriptionInfo = { + status: 'active', + tier: (credits.weeklyAllowance || 0) > 100 ? 'premium' : 'free', + creditsPerWeek: credits.weeklyAllowance, + maxCredits: credits.maxCredits + } + setSubscription(subscriptionInfo) + + console.debug('[Auth Debug] Credits fetched successfully:', credits) + } catch (error: any) { + console.debug('[Auth Debug] Failed to fetch credits:', error) + // Don't set error state for credit fetching failures to avoid disrupting auth flow + // Credits might not be available in all deployments + setCreditBalance(null) + setCreditSummary(null) + setSubscription(null) + } + }, [session]) + + const refreshCredits = useCallback(async () => { + await fetchCredits() + }, [fetchCredits]) + + const fetchProStatus = useCallback(async (sessionToUse?: Session | null) => { + const currentSession = sessionToUse ?? session + + if (!currentSession?.user) { + console.debug('[Auth Debug] No user session, skipping pro status fetch') + setIsPro(null) + return + } + + try { + console.debug('[Auth Debug] Fetching pro status for user:', currentSession.user.id) + const { data: proStatus, error } = await supabase.rpc('is_pro') + + if (error) { + console.error('[Auth Debug] Error checking Pro status:', error) + setIsPro(false) // Default to false if there's an error + } else { + const isProUser = Boolean(proStatus) + setIsPro(isProUser) + console.debug('[Auth Debug] Pro status fetched:', isProUser) + } + } catch (error: any) { + console.debug('[Auth Debug] Failed to fetch pro status:', error) + setIsPro(false) // Default to false if there's an error + } + }, [session]) + + const refreshProStatus = useCallback(async () => { + await fetchProStatus() + }, [fetchProStatus]) + + const fetchTrialStatus = useCallback(async (sessionToUse?: Session | null) => { + const currentSession = sessionToUse ?? session + + if (!currentSession?.user) { + console.debug('[Auth Debug] No user session, skipping trial status fetch') + setTrialStatus(null) + return + } + + try { + console.debug('[Auth Debug] Fetching trial status for user:', currentSession.user.id) + const { data, error } = await supabase + .from('billing_subscriptions') + .select('status, trial_end, has_payment_method, scheduled_subscription_id') + .in('status', ['trialing', 'incomplete_expired', 'canceled']) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + + if (error) { + console.error('[Auth Debug] Error fetching trial status:', error) + setTrialStatus(null) + return + } + + if (data?.trial_end) { + const trialEnd = new Date(data.trial_end) + const now = new Date() + const daysRemaining = Math.ceil((trialEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) + + setTrialStatus({ + isTrialing: data.status === 'trialing' && daysRemaining > 0, + trialEnd: data.trial_end, + daysRemaining: Math.max(0, daysRemaining), + hasPaymentMethod: data.has_payment_method || false, + hasScheduledSub: !!data.scheduled_subscription_id, + status: data.status + }) + console.debug('[Auth Debug] Trial status fetched:', { + status: data.status, + daysRemaining: Math.max(0, daysRemaining), + hasPaymentMethod: data.has_payment_method, + isTrialing: data.status === 'trialing' && daysRemaining > 0 + }) + } else { + setTrialStatus(null) + } + } catch (error: any) { + console.debug('[Auth Debug] Failed to fetch trial status:', error) + setTrialStatus(null) + } + }, [session]) + + const refreshTrialStatus = useCallback(async () => { + await fetchTrialStatus() + }, [fetchTrialStatus]) + + const fetchProfilePicture = useCallback(async (sessionToUse?: Session | null) => { + const currentSession = sessionToUse ?? session + + if (!currentSession?.user) { + console.debug('[Auth Debug] No user session, skipping profile picture fetch') + setProfilePictureUrl(null) + return + } + + try { + const PROFILE_BUCKET = 'profile-pictures' + const profilePath = `${currentSession.user.id}/avatar` + + console.debug('[Auth Debug] Fetching profile picture for user:', currentSession.user.id) + const { data, error } = await supabase + .storage + .from(PROFILE_BUCKET) + .createSignedUrl(profilePath, 60 * 60) + + if (error) { + // Profile picture not found is expected for users without uploads + console.debug('[Auth Debug] Profile picture not available:', error.message) + setProfilePictureUrl(null) + } else { + setProfilePictureUrl(data.signedUrl) + console.debug('[Auth Debug] Profile picture URL fetched successfully') + } + } catch (error: any) { + console.debug('[Auth Debug] Failed to fetch profile picture:', error) + setProfilePictureUrl(null) + } + }, [session]) + + const refreshProfilePicture = useCallback(async () => { + await fetchProfilePicture() + }, [fetchProfilePicture]) + + const fetchProfilePictureMetadata = useCallback(async (sessionToUse?: Session | null) => { + const currentSession = sessionToUse ?? session + + if (!currentSession?.user) { + console.debug('[Auth Debug] No user session, skipping profile picture metadata fetch') + setProfilePictureMetadata(null) + return + } + + try { + console.debug('[Auth Debug] Fetching profile picture metadata for user:', currentSession.user.id) + const metadata = await getProfilePictureMetadata(currentSession.user.id) + setProfilePictureMetadata(metadata) + console.debug('[Auth Debug] Profile picture metadata fetched:', metadata) + } catch (error: any) { + console.debug('[Auth Debug] Failed to fetch profile picture metadata:', error) + setProfilePictureMetadata(null) + } + }, [session]) + + const refreshProfilePictureMetadata = useCallback(async () => { + await fetchProfilePictureMetadata() + }, [fetchProfilePictureMetadata]) + + const updateCredits = useCallback((newBalance: number) => { + console.debug('[Auth Debug] Updating credit balance:', { from: creditBalance, to: newBalance }) + setCreditBalance(newBalance) + // Also update the creditSummary if it exists + if (creditSummary) { + const updatedSummary: CreditSummary = { + ...creditSummary, + creditsRemaining: newBalance, + currentCredits: newBalance + } + setCreditSummary(updatedSummary) + } + }, [creditSummary]) + + const hasSufficientCredits = useCallback((requiredCredits: number): CreditCheckResult => { + const currentBalance = creditBalance ?? 0 + const hasSufficient = currentBalance >= requiredCredits + console.debug('[Auth Debug] Credit check:', { requiredCredits, currentBalance, hasSufficient }) + + return { + hasSufficientCredits: hasSufficient, + currentBalance, + requiredCredits, + shortfall: hasSufficient ? undefined : requiredCredits - currentBalance + } + }, [creditBalance]) + + const refreshSession = async () => { + try { + setLoading(true) + setError(null) + const { data, error } = await supabase.auth.refreshSession() + + if (error) { + console.error('[Auth Debug] Session refresh error:', error) + setError(error) + setSession(null) + } else { + console.debug('[Auth Debug] Session refreshed successfully') + setSession(data.session) + } + } catch (err) { + console.error('[Auth Debug] Unexpected error during session refresh:', err) + setError(err as AuthError) + } finally { + setLoading(false) + } + } + + const signOut = async () => { + try { + setError(null) + const { error } = await supabase.auth.signOut() + + if (error) { + console.error('[Auth Debug] Sign out error:', error) + setError(error) + } else { + console.debug('[Auth Debug] Signed out successfully') + setSession(null) + } + } catch (err) { + console.error('[Auth Debug] Unexpected error during sign out:', err) + setError(err as AuthError) + } + } + + // Set up global credit update callback + useEffect(() => { + setGlobalCreditUpdateCallback(updateCredits) + }, [updateCredits]) + + useEffect(() => { + let mounted = true + + // Load current session on first mount + const initializeAuth = async () => { + try { + console.debug('[Auth Debug] Initializing auth...') + const { data, error } = await supabase.auth.getSession() + + if (!mounted) return + + if (error) { + console.error('[Auth Debug] Initial session error:', error) + setError(error) + } else { + console.debug('[Auth Debug] Initial session loaded:', { + hasSession: !!data.session, + userId: data.session?.user?.id, + email: data.session?.user?.email + }) + setSession(data.session) + + // Fetch credits, pro status, trial status, profile picture metadata, and profile picture using the session from the response + if (data.session?.user) { + // Sync OAuth avatar in background + syncOAuthAvatar(data.session.user).catch((err) => { + console.debug('[Auth Debug] Failed to sync OAuth avatar on init:', err) + }) + + await fetchCredits(data.session) + await fetchProStatus(data.session) + await fetchTrialStatus(data.session) + await fetchProfilePictureMetadata(data.session) + + // Small delay to allow avatar sync to complete if quick + setTimeout(() => { + fetchProfilePicture(data.session) + }, 500) + } + } + } catch (err) { + console.error('[Auth Debug] Unexpected error during auth initialization:', err) + if (mounted) { + setError(err as AuthError) + } + } finally { + if (mounted) { + setLoading(false) + } + } + } + + initializeAuth() + + // Subscribe to auth state changes + const { data: { subscription } } = supabase.auth.onAuthStateChange( + async (event, newSession) => { + if (!mounted) return + + console.debug('[Auth Debug] Auth state change:', { + event, + hasSession: !!newSession, + userId: newSession?.user?.id, + email: newSession?.user?.email, + timestamp: new Date().toISOString() + }) + + // Don't run supabase calls inside this callback; schedule them + setTimeout(() => { + if (mounted) { + setSession(newSession) + setError(null) + + // Additional handling for specific events + if (event === 'SIGNED_OUT') { + console.debug('[Auth Debug] User signed out, clearing session') + // Clear credit data, pro status, trial status, profile picture, and metadata on sign out + setCreditBalance(null) + setCreditSummary(null) + setSubscription(null) + setIsPro(null) + setTrialStatus(null) + setProfilePictureUrl(null) + setProfilePictureMetadata(null) + } else if (event === 'SIGNED_IN') { + console.debug('[Auth Debug] User signed in successfully') + if (newSession?.user) { + setLoading(true) + + // Sync OAuth avatar in background (don't block other fetches) + syncOAuthAvatar(newSession.user).catch((err) => { + console.debug('[Auth Debug] Failed to sync OAuth avatar:', err) + }) + + // Fetch user data in parallel + Promise.all([ + fetchCredits(newSession), + fetchProStatus(newSession), + fetchTrialStatus(newSession), + fetchProfilePictureMetadata(newSession), + ]).then(() => { + // Fetch profile picture AFTER sync has had time to complete + // Use a small delay to allow avatar sync to finish if it's quick + setTimeout(() => { + fetchProfilePicture(newSession).finally(() => { + setLoading(false) + console.debug('[Auth Debug] User data fully loaded after sign in') + }) + }, 500) + }) + } + } else if (event === 'TOKEN_REFRESHED') { + console.debug('[Auth Debug] Token refreshed') + // Optionally refresh credits, pro status, trial status, profile picture metadata, and profile picture on token refresh + if (newSession?.user) { + Promise.all([ + fetchCredits(newSession), + fetchProStatus(newSession), + fetchTrialStatus(newSession), + fetchProfilePictureMetadata(newSession), + fetchProfilePicture(newSession) + ]).then(() => { + console.debug('[Auth Debug] User data refreshed after token refresh') + }) + } + } else if (event === 'USER_UPDATED') { + console.debug('[Auth Debug] User updated') + + // Check if this is a pending OAuth upgrade completion + const pendingUpgrade = sessionStorage.getItem('pendingUpgrade') + const upgradeProvider = sessionStorage.getItem('upgradeProvider') + + if (pendingUpgrade && newSession?.user && newSession.user.is_anonymous === false) { + console.debug('[Auth Debug] Processing pending OAuth upgrade:', upgradeProvider) + + // Clear the flags first to prevent loops + sessionStorage.removeItem('pendingUpgrade') + sessionStorage.removeItem('upgradeProvider') + + // Synchronize with backend + synchronizeUserUpgrade(upgradeProvider || undefined) + .then(() => { + console.debug('[Auth Debug] User upgrade synchronized successfully') + + // Refresh credits, pro status, trial status, profile picture metadata, and profile picture after upgrade + if (newSession?.user) { + return Promise.all([ + fetchCredits(newSession), + fetchProStatus(newSession), + fetchTrialStatus(newSession), + fetchProfilePictureMetadata(newSession), + fetchProfilePicture(newSession) + ]) + } + }) + .then(() => { + console.debug('[Auth Debug] User data refreshed after upgrade') + }) + .catch((err) => { + console.error('[Auth Debug] Failed to synchronize user upgrade:', err) + }) + } + } + } + }, 0) + } + ) + + return () => { + mounted = false + subscription.unsubscribe() + } + }, []) + + const value: AuthContextType = { + session, + user: session?.user ?? null, + loading, + error, + creditBalance, + subscription, + creditSummary, + isPro, + trialStatus, + profilePictureUrl, + profilePictureMetadata, + signOut, + refreshSession, + hasSufficientCredits, + updateCredits, + refreshCredits, + refreshProStatus, + refreshTrialStatus, + refreshProfilePicture, + refreshProfilePictureMetadata + } + + return ( + + {children} + + ) +} + +export function useAuth() { + const context = useContext(AuthContext) + + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider') + } + + return context +} + +// Debug hook to expose auth state for debugging +export function useAuthDebug() { + const auth = useAuth() + + useEffect(() => { + console.debug('[Auth Debug] Current auth state:', { + hasSession: !!auth.session, + hasUser: !!auth.user, + loading: auth.loading, + hasError: !!auth.error, + userId: auth.user?.id, + email: auth.user?.email, + provider: auth.user?.app_metadata?.provider + }) + }, [auth.session, auth.user, auth.loading, auth.error]) + + return auth +} diff --git a/frontend/src/saas/auth/supabase.ts b/frontend/src/saas/auth/supabase.ts new file mode 100644 index 0000000000..b8b473f785 --- /dev/null +++ b/frontend/src/saas/auth/supabase.ts @@ -0,0 +1,165 @@ +import { createClient } from '@supabase/supabase-js' + +// Debug helper to log Supabase configuration +const debugConfig = () => { + const url = import.meta.env.VITE_SUPABASE_URL + const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY + + console.log('[Supabase Debug] Configuration:', { + url: url ? '✓ URL configured' : '✗ URL missing', + key: key ? '✓ Key configured' : '✗ Key missing', + urlValue: url || 'undefined', + keyValue: key ? `${key.substring(0, 20)}...` : 'undefined' + }) + + return { url, key } +} + +const config = debugConfig() + +if (!config.url) { + throw new Error('Missing VITE_SUPABASE_URL environment variable') +} + +if (!config.key) { + throw new Error('Missing VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY environment variable') +} + +export const supabase = createClient( + config.url, + config.key, + { + auth: { + persistSession: true, // keep session in localStorage + autoRefreshToken: true, + detectSessionInUrl: true, // helpful on first load after redirect + // debug: import.meta.env.DEV, // Enable debug logs in development + }, + } +) + +// Debug helper for auth events +export const debugAuthEvents = () => { + supabase.auth.onAuthStateChange((event, session) => { + console.log('[Supabase Debug] Auth state change:', { + event, + hasSession: !!session, + userId: session?.user?.id, + email: session?.user?.email, + provider: session?.user?.app_metadata?.provider, + timestamp: new Date().toISOString() + }) + }) +} + +// Debug auth events can be manually enabled by calling debugAuthEvents() +// Commented out to prevent excessive logging on every page load +// if (import.meta.env.DEV) { +// debugAuthEvents() +// } + +// Anonymous authentication functions +export const signInAnonymously = async () => { + try { + const { data, error } = await supabase.auth.signInAnonymously() + + if (error) { + console.error('[Supabase] Anonymous sign-in error:', error) + throw error + } + + console.log('[Supabase] Anonymous sign-in successful:', { + userId: data.user?.id, + isAnonymous: data.user?.is_anonymous + }) + + return { data, error } + } catch (error) { + console.error('[Supabase] Anonymous sign-in failed:', error) + throw error + } +} + +// Account linking functions +export const linkEmailIdentity = async (email: string, password?: string) => { + try { + const updateData: any = { email } + if (password) { + updateData.password = password + } + + const { data, error } = await supabase.auth.updateUser(updateData) + + if (error) { + console.error('[Supabase] Email linking error:', error) + throw error + } + + // Refresh session to get updated token with new user metadata + const { error: refreshError } = await supabase.auth.refreshSession() + + if (refreshError) { + console.warn('[Supabase] Session refresh after email linking failed:', refreshError) + // Don't throw - linking was successful, refresh is just for consistency + } else { + console.log('[Supabase] Session refreshed after email linking') + } + + console.log('[Supabase] Email linked successfully:', { + email, + userId: data.user?.id + }) + + return { data, error } + } catch (error) { + console.error('[Supabase] Email linking failed:', error) + throw error + } +} + +export const linkOAuthIdentity = async (provider: 'google' | 'github' | 'apple' | 'azure', redirectTo?: string) => { + try { + const { data, error } = await supabase.auth.linkIdentity({ + provider: provider, + options: redirectTo ? { redirectTo } : undefined + }) + + if (error) { + console.error('[Supabase] OAuth linking error:', error) + throw error + } + + console.log('[Supabase] OAuth identity linked successfully:', { + provider, + redirectTo, + url: data.url + }) + + return { data, error } + } catch (error) { + console.error('[Supabase] OAuth linking failed:', error) + throw error + } +} + +// Helper function to check if user is anonymous +export const isUserAnonymous = (user: any) => { + return user?.is_anonymous === true +} + +// Get current user session +export const getCurrentUser = async () => { + try { + const { data: { user }, error } = await supabase.auth.getUser() + + if (error) { + console.error('[Supabase] Get user error:', error) + throw error + } + + return user + } catch (error) { + console.error('[Supabase] Get user failed:', error) + throw error + } +} diff --git a/frontend/src/saas/components/OnboardingBootstrap.tsx b/frontend/src/saas/components/OnboardingBootstrap.tsx new file mode 100644 index 0000000000..3533f429c5 --- /dev/null +++ b/frontend/src/saas/components/OnboardingBootstrap.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react'; +import { usePreferences } from '@app/contexts/PreferencesContext'; +import { useOnboarding } from '@app/contexts/OnboardingContext'; +import { useAuth } from '@app/auth/UseSession'; +import SaasOnboardingModal from '@app/components/onboarding/SaasOnboardingModal'; + +const STORAGE_KEY = 'saas_onboarding_seen'; +const ONBOARDING_SESSION_BLOCK_KEY = 'stirling-onboarding-session-active'; + +/** + * SaaS-only bootstrap to clear deferred tour requests, mark tool panel prompt as completed, + * and show SaaS-specific onboarding on first login. + */ +export default function OnboardingBootstrap() { + const { preferences, updatePreference } = usePreferences(); + const { clearPendingTourRequest, setStartAfterToolModeSelection } = useOnboarding(); + const { user, loading, trialStatus, isPro, refreshTrialStatus } = useAuth(); + const [showModal, setShowModal] = useState(false); + const [isPolling, setIsPolling] = useState(false); + const [pollAttempts, setPollAttempts] = useState(0); + + // Start polling when user logs in + useEffect(() => { + const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === 'true'; + + if (user && !hasSeenOnboarding && !loading && !isPolling && !showModal) { + console.debug('[Onboarding] Starting poll for trial data'); + setIsPolling(true); + setPollAttempts(0); + } + }, [user, loading, isPolling, showModal]); + + // Poll for trial data + useEffect(() => { + if (!isPolling) return; + + const pollInterval = 500; // Check every 500ms + + const timer = setTimeout(async () => { + const newAttempts = pollAttempts + 1; + console.debug('[Onboarding] Polling for trial data, attempt:', newAttempts); + + await refreshTrialStatus(); + setPollAttempts(newAttempts); + + // Check will happen in the next effect + }, pollInterval); + + return () => clearTimeout(timer); + }, [isPolling, pollAttempts, refreshTrialStatus]); + + // Stop polling when data arrives or timeout + useEffect(() => { + if (!isPolling) return; + + const hasData = trialStatus !== undefined && trialStatus !== null; + const hasProStatus = isPro !== null; + const maxAttempts = 10; + + if (hasData || pollAttempts >= maxAttempts) { + console.debug('[Onboarding] Trial data ready or timeout, showing modal', { + hasData, + hasProStatus, + attempts: pollAttempts, + trialStatus, + isPro + }); + setIsPolling(false); + setShowModal(true); + } + }, [isPolling, trialStatus, isPro, pollAttempts]); + + const handleClose = () => { + localStorage.setItem(STORAGE_KEY, 'true'); + setShowModal(false); + }; + + // Keep existing logic to disable core onboarding flags + useEffect(() => { + // Ensure tool panel preference is set so tours are never deferred. + if (!preferences.toolPanelModePromptSeen || !preferences.hasSelectedToolPanelMode) { + updatePreference('toolPanelModePromptSeen', true); + updatePreference('hasSelectedToolPanelMode', true); + } + + // Clear any lingering deferred tour requests. + clearPendingTourRequest(); + setStartAfterToolModeSelection(false); + + // In SaaS, skip the core intro onboarding entirely. + if (!preferences.hasSeenIntroOnboarding) { + updatePreference('hasSeenIntroOnboarding', true); + } + // Also mark completed to avoid follow-up banners/modals. + if (!preferences.hasCompletedOnboarding) { + updatePreference('hasCompletedOnboarding', true); + } + + // Also clear any session flag that might mark onboarding as active. + if (typeof window !== 'undefined') { + window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY); + } + }, [ + preferences.hasSelectedToolPanelMode, + preferences.toolPanelModePromptSeen, + preferences.hasSeenIntroOnboarding, + preferences.hasCompletedOnboarding, + updatePreference, + clearPendingTourRequest, + setStartAfterToolModeSelection, + ]); + + // Only render modal when it should be shown to avoid running hooks unnecessarily + return showModal ? : null; +} diff --git a/frontend/src/saas/components/TrialExpiredBootstrap.tsx b/frontend/src/saas/components/TrialExpiredBootstrap.tsx new file mode 100644 index 0000000000..8ccd575852 --- /dev/null +++ b/frontend/src/saas/components/TrialExpiredBootstrap.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react'; +import { useAuth } from '@app/auth/UseSession'; +import { TrialExpiredModal } from '@app/components/shared/TrialExpiredModal'; +import StripeCheckout from '@app/components/shared/StripeCheckoutSaas'; + +/** + * Bootstrap component that shows the trial expired modal when a user's trial has ended + * and they haven't added a payment method. Shows once per user per expired trial. + */ +export default function TrialExpiredBootstrap() { + const { user, trialStatus, isPro } = useAuth(); + const [showModal, setShowModal] = useState(false); + const [checkoutOpened, setCheckoutOpened] = useState(false); + + useEffect(() => { + // Close modal if user logs out or session expires + if (!user) { + if (showModal) { + console.debug('[TrialExpired] User logged out, closing modal'); + setShowModal(false); + } + if (checkoutOpened) { + setCheckoutOpened(false); + } + return; + } + + // Only check conditions when auth is fully loaded + if (trialStatus === null || isPro === null) { + return; + } + + // Build localStorage key unique to this user + const storageKey = `trialExpiredModalShown_${user.id}`; + const hasSeenModal = localStorage.getItem(storageKey) === 'true'; + + // If user is currently trialing, clear any previous "seen" flag + // This handles the edge case where a user might re-enter a trial + if (trialStatus.isTrialing) { + if (hasSeenModal) { + console.debug('[TrialExpired] User is trialing, clearing seen flag'); + localStorage.removeItem(storageKey); + } + return; + } + + // Check if all conditions are met to show the modal + const isExpired = + trialStatus.status === 'incomplete_expired' || trialStatus.status === 'canceled'; + const hasNoPayment = !trialStatus.hasPaymentMethod && !trialStatus.hasScheduledSub; + const wasDowngraded = !isPro; + const trialEndedRecently = trialStatus.daysRemaining === 0; + + const shouldShowModal = + isExpired && hasNoPayment && wasDowngraded && trialEndedRecently && !hasSeenModal; + + if (shouldShowModal) { + console.debug('[TrialExpired] Showing trial expired modal', { + status: trialStatus.status, + daysRemaining: trialStatus.daysRemaining, + hasPaymentMethod: trialStatus.hasPaymentMethod, + hasScheduledSub: trialStatus.hasScheduledSub, + isPro, + }); + setShowModal(true); + } + }, [user, trialStatus, isPro, showModal, checkoutOpened]); + + const handleClose = () => { + if (user) { + const storageKey = `trialExpiredModalShown_${user.id}`; + localStorage.setItem(storageKey, 'true'); + console.debug('[TrialExpired] Modal dismissed, marking as seen'); + } + setShowModal(false); + }; + + const handleSubscribe = () => { + console.debug('[TrialExpired] User clicked Subscribe to Pro'); + setCheckoutOpened(true); + }; + + const handleCheckoutSuccess = () => { + console.debug('[TrialExpired] Subscription successful, refreshing page'); + // Close modal and refresh to update subscription status + handleClose(); + window.location.reload(); + }; + + const handleCheckoutClose = () => { + console.debug('[TrialExpired] Checkout closed'); + setCheckoutOpened(false); + }; + + return ( + <> + + + {user && ( + console.error('[TrialExpired] Checkout error:', error)} + isTrialConversion={false} // Trial already ended, so this is not a conversion + /> + )} + + ); +} diff --git a/frontend/src/saas/components/auth/GuestUserBanner.css b/frontend/src/saas/components/auth/GuestUserBanner.css new file mode 100644 index 0000000000..d1d6937024 --- /dev/null +++ b/frontend/src/saas/components/auth/GuestUserBanner.css @@ -0,0 +1,90 @@ +.guest-banner { + position: fixed; + top: 1rem; + right: 4.5rem; + z-index: 1000; + background: var(--modal-content-bg, #111418); + border: 1px solid var(--api-keys-card-border, rgba(255,255,255,0.08)); + border-radius: 12px; + box-shadow: 0 6px 24px rgba(0,0,0,0.35); + padding: 12px 16px; + max-width: 30rem; + width: 30rem; + color: var(--mantine-color-text); +} + +.guest-banner-content { + display: flex; + align-items: center; + gap: 12px; +} + +.guest-banner-text { + flex: 1; + min-width: 0; +} + +.guest-banner-title { + font-size: 13px; + font-weight: 600; + color: var(--mantine-color-text); + margin-bottom: 4px; + line-height: 1.3; +} + +.guest-banner-message { + font-size: 12px; + color: var(--mantine-color-dimmed); + line-height: 1.4; + margin-bottom: 8px; +} + +.guest-banner-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + flex-shrink: 0; +} + +.guest-banner-dismiss { + padding: 6px; + border-radius: 8px; + background: transparent; + color: var(--mantine-color-dimmed); + border: none; + cursor: pointer; +} + +.guest-banner-dismiss:hover { + background: var(--mantine-color-gray-1, rgba(255,255,255,0.05)); +} + +.guest-banner-signup { + white-space: nowrap; + padding: 6px 12px; + border-radius: 8px; + background: var(--blue-6, #2563eb); + color: #fff; + border: none; + font-size: 12px; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.guest-banner-signup:hover { + background: var(--blue-7, #1d4ed8); +} + +.guest-banner-icon { + width: 16px; + height: 16px; +} + +.guest-banner-signup-icon { + width: 14px; + height: 14px; +} diff --git a/frontend/src/saas/components/auth/GuestUserBanner.tsx b/frontend/src/saas/components/auth/GuestUserBanner.tsx new file mode 100644 index 0000000000..8c5f786aa0 --- /dev/null +++ b/frontend/src/saas/components/auth/GuestUserBanner.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import CloseIcon from '@mui/icons-material/Close' +import PersonAddIcon from '@mui/icons-material/PersonAdd' +import { useAuth } from '@app/auth/UseSession' +import { isUserAnonymous } from '@app/auth/supabase' +import { withBasePath } from '@app/constants/app' +import '@app/components/auth/GuestUserBanner.css' + +interface GuestUserBannerProps { + className?: string +} + +// Ensure the toast only appears once per full page load, not on re-hydration +let hasShownThisLoad = false + +/** + * Guest user toast encouraging account creation. + * Appears 2s after load, top-right of the viewport, offset by right rail. + */ +export function GuestUserBanner({ className = '' }: GuestUserBannerProps) { + const { t } = useTranslation() + const { session } = useAuth() + const [isDismissed, setIsDismissed] = useState(false) + const [visible, setVisible] = useState(false) + + const isAnon = Boolean(session?.user && isUserAnonymous(session.user)) + + useEffect(() => { + if (!isAnon || hasShownThisLoad) return + + const timer = setTimeout(() => { + setVisible(true) + hasShownThisLoad = true + }, 2000) + + return () => clearTimeout(timer) + }, [isAnon]) + + if (!isAnon || isDismissed || !visible) { + return null + } + + const handleSignUp = () => { + window.location.href = withBasePath('/signup') + } + + const handleDismiss = () => { + setIsDismissed(true) + } + + return ( +
+
+
+
+ {t('guestBanner.title', "You're using Stirling PDF as a guest!")} +
+
+ {t('guestBanner.message', 'Create a free account to save your work, access more features, and support the project.')} +
+
+
+ + +
+
+
+ ) +} + +export default GuestUserBanner \ No newline at end of file diff --git a/frontend/src/saas/components/auth/RequireAuth.tsx b/frontend/src/saas/components/auth/RequireAuth.tsx new file mode 100644 index 0000000000..fb6b550ba2 --- /dev/null +++ b/frontend/src/saas/components/auth/RequireAuth.tsx @@ -0,0 +1,44 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import { useAuth } from '@app/auth/UseSession' +import { useAutoAnonymousAuth } from '@app/hooks/useAutoAnonymousAuth' + +interface RequireAuthProps { + fallbackPath?: string +} + +export function RequireAuth({ fallbackPath = '/login' }: RequireAuthProps) { + const { session, loading } = useAuth() + const location = useLocation() + const { isAutoAuthenticating } = useAutoAnonymousAuth() + + // Safe development-only auth bypass + const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname) + const devBypassEnabled = Boolean(import.meta.env.DEV && isLocalhost && import.meta.env.VITE_DEV_BYPASS_AUTH === 'true') + + if (devBypassEnabled) { + console.warn('[RequireAuth] DEV BYPASS ACTIVE — allowing access without session on localhost') + return + } + + // Wait for both auth bootstrap and auto-anon to finish + if (loading || isAutoAuthenticating) { + return ( +
+
+
+

Preparing your session…

+
+
+ ) + } + + if (!session) { + // Change the URL to /login + return + } + + // Render protected routes + return +} + +export default RequireAuth diff --git a/frontend/src/saas/components/feedback/UserbackWidget.tsx b/frontend/src/saas/components/feedback/UserbackWidget.tsx new file mode 100644 index 0000000000..e664cce535 --- /dev/null +++ b/frontend/src/saas/components/feedback/UserbackWidget.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef } from 'react'; +import Userback from '@userback/widget'; +import { useAuth } from '@app/auth/UseSession'; + +interface UserbackWidgetProps { + token: string; +} + +interface UserbackInstance { + destroy: () => void; +} + +export default function UserbackWidget({ token }: UserbackWidgetProps) { + const { user } = useAuth(); + const userbackRef = useRef(null); + const initializingRef = useRef(false); + + useEffect(() => { + if (!user || initializingRef.current) return; + + initializingRef.current = true; + + const initializeUserback = async () => { + try { + // Prepare user data options + const userInfo: { name?: string; email?: string } = {}; + if (user.user_metadata?.full_name) userInfo.name = user.user_metadata.full_name; + if (user.email) userInfo.email = user.email; + + const options = { + user_data: { + id: user.id, + info: userInfo + } + }; + + // Initialize Userback + userbackRef.current = await Userback(token, options); + } + finally { + initializingRef.current = false; + } + }; + + initializeUserback(); + + // Cleanup function + return () => { + if (userbackRef.current && typeof userbackRef.current.destroy === 'function') { + userbackRef.current.destroy(); + } + initializingRef.current = false; + }; + }, [user, token]); + + return null; // This component doesn't render anything visible +} diff --git a/frontend/src/saas/components/home/HomePageExtensions.tsx b/frontend/src/saas/components/home/HomePageExtensions.tsx new file mode 100644 index 0000000000..3557c976c1 --- /dev/null +++ b/frontend/src/saas/components/home/HomePageExtensions.tsx @@ -0,0 +1,6 @@ +import UserbackWidget from '@app/components/feedback/UserbackWidget'; + +export function HomePageExtensions() { + const userbackToken = import.meta.env.VITE_USERBACK_TOKEN; + return userbackToken ? : null; +} diff --git a/frontend/src/saas/components/onboarding/OnboardingTour.tsx b/frontend/src/saas/components/onboarding/OnboardingTour.tsx new file mode 100644 index 0000000000..00d65af669 --- /dev/null +++ b/frontend/src/saas/components/onboarding/OnboardingTour.tsx @@ -0,0 +1,7 @@ +/** + * SaaS stub — core tour system is suppressed in SaaS. + * SaaS uses SaasOnboardingModal instead. + */ +export default function OnboardingTour() { + return null; +} diff --git a/frontend/src/saas/components/onboarding/SaasOnboardingModal.tsx b/frontend/src/saas/components/onboarding/SaasOnboardingModal.tsx new file mode 100644 index 0000000000..b2f7c2711e --- /dev/null +++ b/frontend/src/saas/components/onboarding/SaasOnboardingModal.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { Modal, Stack } from '@mantine/core'; +import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined'; +import { useTranslation } from 'react-i18next'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground'; +import OnboardingStepper from '@app/components/onboarding/OnboardingStepper'; +import { renderButtons } from '@app/components/onboarding/renderButtons'; +import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; +import { useSaasOnboardingState } from '@app/components/onboarding/useSaasOnboardingState'; +import { BASE_PATH } from '@app/constants/app'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; + +interface SaasOnboardingModalProps { + opened: boolean; + onClose: () => void; +} + +export default function SaasOnboardingModal(props: SaasOnboardingModalProps) { + const { t } = useTranslation(); + const flow = useSaasOnboardingState(props); + + if (!flow) { + return null; + } + + const { + currentStep, + totalSteps, + currentSlide, + slideDefinition, + flowState, + handleButtonAction, + } = flow; + + const renderHero = () => { + if (slideDefinition.hero.type === 'dual-icon') { + return ( +
+
+ Stirling icon +
+
+ ); + } + + return ( +
+ {slideDefinition.hero.type === 'rocket' && ( + + )} + {slideDefinition.hero.type === 'diamond' && } +
+ ); + }; + + return ( + + +
+ +
+ {renderHero()} +
+
+ +
+ +
+ {currentSlide.title} +
+ +
+
+ {currentSlide.body} +
+ +
+ + + +
+ {renderButtons({ + slideDefinition, + flowState, + onAction: handleButtonAction, + t, + })} +
+
+
+
+
+ ); +} diff --git a/frontend/src/saas/components/onboarding/renderButtons.tsx b/frontend/src/saas/components/onboarding/renderButtons.tsx new file mode 100644 index 0000000000..f1f8c7dfcd --- /dev/null +++ b/frontend/src/saas/components/onboarding/renderButtons.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { Button, Group, ActionIcon } from '@mantine/core'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import { TFunction } from 'i18next'; +import { ButtonDefinition, type FlowState, type ButtonAction } from '@app/components/onboarding/saasOnboardingFlowConfig'; + +interface RenderButtonsProps { + slideDefinition: { + buttons: ButtonDefinition[]; + id: string; + }; + flowState: FlowState; + onAction: (action: ButtonAction) => void; + t: TFunction; +} + +export function renderButtons({ slideDefinition, flowState, onAction, t }: RenderButtonsProps) { + const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left'); + const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right'); + + const buttonStyles = (variant: ButtonDefinition['variant']) => + variant === 'primary' + ? { + root: { + background: 'var(--onboarding-primary-button-bg)', + color: 'var(--onboarding-primary-button-text)', + }, + } + : { + root: { + background: 'var(--onboarding-secondary-button-bg)', + border: '1px solid var(--onboarding-secondary-button-border)', + color: 'var(--onboarding-secondary-button-text)', + }, + }; + + const resolveButtonLabel = (button: ButtonDefinition) => { + // Translate the label (it's a translation key) + const label = button.label ?? ''; + if (!label) return ''; + + // Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next') + const fallback = label.split('.').pop() || label; + return t(label, fallback); + }; + + const renderButton = (button: ButtonDefinition) => { + const disabled = button.disabledWhen?.(flowState) ?? false; + + if (button.type === 'icon') { + return ( + onAction(button.action)} + radius="md" + size={40} + disabled={disabled} + styles={{ + root: { + background: 'var(--onboarding-secondary-button-bg)', + border: '1px solid var(--onboarding-secondary-button-border)', + color: 'var(--onboarding-secondary-button-text)', + }, + }} + > + {button.icon === 'chevron-left' && } + + ); + } + + const variant = button.variant ?? 'secondary'; + const label = resolveButtonLabel(button); + + return ( + + ); + }; + + if (leftButtons.length === 0) { + return {rightButtons.map(renderButton)}; + } + + if (rightButtons.length === 0) { + return {leftButtons.map(renderButton)}; + } + + return ( + + {leftButtons.map(renderButton)} + {rightButtons.map(renderButton)} + + ); +} diff --git a/frontend/src/saas/components/onboarding/saasFlowResolver.ts b/frontend/src/saas/components/onboarding/saasFlowResolver.ts new file mode 100644 index 0000000000..87348f9389 --- /dev/null +++ b/frontend/src/saas/components/onboarding/saasFlowResolver.ts @@ -0,0 +1,40 @@ +import { TrialStatus } from '@app/auth/UseSession'; +import { FLOW_SEQUENCES, SlideId } from '@app/components/onboarding/saasOnboardingFlowConfig'; + +export interface FlowConfig { + type: 'saas-trial' | 'saas-paid'; + ids: SlideId[]; +} + +/** + * Resolves the appropriate onboarding flow based on user's subscription status. + * + * @param trialStatus - User's trial information from Supabase + * @param _isPro - Whether user has Pro subscription + * @returns FlowConfig with the appropriate slide sequence + */ +export function resolveSaasFlow( + trialStatus: TrialStatus | null, + _isPro: boolean | null +): FlowConfig { + // Show free trial card if: + // 1. User has active trial (isTrialing = true) + // 2. Trial has not expired (daysRemaining > 0) + // 3. User is not paid Pro (or Pro is from trial) + const hasActiveTrial = + trialStatus?.isTrialing === true && + trialStatus.daysRemaining > 0; + + if (hasActiveTrial) { + return { + type: 'saas-trial', + ids: FLOW_SEQUENCES.saasTrialUser, + }; + } + + // For paid users, expired trials, or no trial info + return { + type: 'saas-paid', + ids: FLOW_SEQUENCES.saasPaidUser, + }; +} diff --git a/frontend/src/saas/components/onboarding/saasOnboardingFlowConfig.ts b/frontend/src/saas/components/onboarding/saasOnboardingFlowConfig.ts new file mode 100644 index 0000000000..6987cad279 --- /dev/null +++ b/frontend/src/saas/components/onboarding/saasOnboardingFlowConfig.ts @@ -0,0 +1,134 @@ +import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide'; +import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide'; +import FreeTrialSlide from '@app/components/onboarding/slides/FreeTrialSlide'; +import { SlideConfig } from '@app/types/types'; +import { TrialStatus } from '@app/auth/UseSession'; + +export type SlideId = 'welcome' | 'free-trial' | 'desktop-install'; + +export type HeroType = 'rocket' | 'dual-icon' | 'diamond'; + +export type ButtonAction = + | 'next' + | 'prev' + | 'close' + | 'download-selected'; + +export type FlowState = Record; + +export interface OSOption { + label: string; + url: string; + value: string; +} + +export interface SlideFactoryParams { + osLabel: string; + osUrl: string; + osOptions?: OSOption[]; + onDownloadUrlChange?: (url: string) => void; + trialStatus?: TrialStatus | null; +} + +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', + }, + ], + }, + 'free-trial': { + id: 'free-trial', + createSlide: ({ trialStatus }) => { + if (!trialStatus) { + throw new Error('Trial status is required for free-trial slide'); + } + return FreeTrialSlide({ trialStatus }); + }, + hero: { type: 'diamond' }, + buttons: [ + { + key: 'trial-back', + type: 'icon', + icon: 'chevron-left', + group: 'left', + action: 'prev', + }, + { + key: 'trial-next', + type: 'button', + label: 'onboarding.buttons.next', + variant: 'primary', + group: 'right', + action: 'next', + }, + ], + }, + 'desktop-install': { + id: 'desktop-install', + createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => + DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }), + hero: { type: 'dual-icon' }, + buttons: [ + { + key: 'desktop-back', + type: 'icon', + icon: 'chevron-left', + group: 'left', + action: 'prev', + }, + { + key: 'desktop-skip', + type: 'button', + label: 'onboarding.buttons.skipForNow', + variant: 'secondary', + group: 'left', + action: 'close', + }, + { + key: 'desktop-download', + type: 'button', + label: 'onboarding.buttons.download', + variant: 'primary', + group: 'right', + action: 'download-selected', + }, + ], + }, +}; + +export const FLOW_SEQUENCES = { + saasTrialUser: ['welcome', 'free-trial', 'desktop-install'] as SlideId[], + saasPaidUser: ['welcome', 'desktop-install'] as SlideId[], +}; diff --git a/frontend/src/saas/components/onboarding/slides/FreeTrialSlide.tsx b/frontend/src/saas/components/onboarding/slides/FreeTrialSlide.tsx new file mode 100644 index 0000000000..6594e0a29b --- /dev/null +++ b/frontend/src/saas/components/onboarding/slides/FreeTrialSlide.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SlideConfig } from '@app/types/types'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; +import { TrialStatus } from '@app/auth/UseSession'; + +interface FreeTrialSlideProps { + trialStatus: TrialStatus; +} + +function FreeTrialSlideTitle() { + const { t } = useTranslation(); + + return ( + + {t('onboarding.freeTrial.title', 'Your 30-Day Pro Trial')} + + ); +} + +const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => { + const { t } = useTranslation(); + + // Format the trial end date + const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + // Determine which message to show based on payment method + const afterTrialMessage = trialStatus.hasScheduledSub + ? t('onboarding.freeTrial.afterTrialWithPayment', 'Your Pro subscription will start automatically when the trial ends.') + : trialStatus.hasPaymentMethod + ? t('onboarding.freeTrial.afterTrialWithPayment', 'Your Pro subscription will start automatically when the trial ends.') + : t('onboarding.freeTrial.afterTrialWithoutPayment', 'After your trial ends, you\'ll continue with our free tier. Add a payment method to keep Pro access.'); + + // Pluralize days remaining + const daysText = trialStatus.daysRemaining === 1 + ? t('onboarding.freeTrial.daysRemainingSingular', '{{days}} day remaining', { days: trialStatus.daysRemaining }) + : t('onboarding.freeTrial.daysRemaining', '{{days}} days remaining', { days: trialStatus.daysRemaining }); + + return ( +
+

+ {t( + 'onboarding.freeTrial.body', + 'You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing.' + )} +

+
+
+ {daysText} +
+
+ {t('onboarding.freeTrial.trialEnds', 'Trial ends {{date}}', { date: trialEndDate })} +
+
+

+ {afterTrialMessage} +

+
+ ); +}; + +export default function FreeTrialSlide({ trialStatus }: FreeTrialSlideProps): SlideConfig { + return { + key: 'free-trial', + title: , + body: , + background: { + gradientStops: ['#10B981', '#06B6D4'], + circles: UNIFIED_CIRCLE_CONFIG, + }, + }; +} diff --git a/frontend/src/saas/components/onboarding/useSaasOnboardingState.ts b/frontend/src/saas/components/onboarding/useSaasOnboardingState.ts new file mode 100644 index 0000000000..70f8528f62 --- /dev/null +++ b/frontend/src/saas/components/onboarding/useSaasOnboardingState.ts @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import { useAuth } from '@app/auth/UseSession'; +import { useOs } from '@app/hooks/useOs'; +import { + SLIDE_DEFINITIONS, + type ButtonAction, + type FlowState, + type SlideId, +} from '@app/components/onboarding/saasOnboardingFlowConfig'; +import { resolveSaasFlow } from '@app/components/onboarding/saasFlowResolver'; +import { DOWNLOAD_URLS } from '@app/constants/downloads'; + +interface UseSaasOnboardingStateResult { + currentStep: number; + totalSteps: number; + slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId]; + currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>; + flowState: FlowState; + handleButtonAction: (action: ButtonAction) => void; +} + +interface UseSaasOnboardingStateProps { + opened: boolean; + onClose: () => void; +} + +export function useSaasOnboardingState({ + opened, + onClose, +}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null { + const { trialStatus, isPro, loading } = useAuth(); + const osType = useOs(); + const selectedDownloadUrlRef = useRef(''); + + const [currentStep, setCurrentStep] = useState(0); + + // Reset state when modal closes + useEffect(() => { + if (!opened) { + setCurrentStep(0); + } + }, [opened]); + + // Determine OS details for desktop download + 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); + }, []); + + // Store selected download URL + const handleDownloadUrlChange = useCallback((url: string) => { + selectedDownloadUrlRef.current = url; + }, []); + + // Resolve flow based on trial status + const resolvedFlow = useMemo( + () => resolveSaasFlow(trialStatus, isPro), + [trialStatus, isPro] + ); + + const flowSlideIds = resolvedFlow.ids; + const totalSteps = flowSlideIds.length; + const maxIndex = Math.max(totalSteps - 1, 0); + + // Ensure current step is within bounds + useEffect(() => { + if (currentStep >= flowSlideIds.length) { + setCurrentStep(Math.max(flowSlideIds.length - 1, 0)); + } + }, [flowSlideIds.length, currentStep]); + + const currentSlideId = flowSlideIds[currentStep] ?? flowSlideIds[flowSlideIds.length - 1]; + const slideDefinition = SLIDE_DEFINITIONS[currentSlideId]; + + // Create slide with appropriate params - must be called before any early returns + const currentSlide = useMemo(() => { + if (!slideDefinition) return null; + return slideDefinition.createSlide({ + osLabel: os.label, + osUrl: os.url, + osOptions, + onDownloadUrlChange: handleDownloadUrlChange, + trialStatus: trialStatus ?? undefined, + }); + }, [slideDefinition, os.label, os.url, osOptions, handleDownloadUrlChange, trialStatus]); + + // Navigation functions + const goNext = useCallback(() => { + setCurrentStep((prev) => Math.min(prev + 1, maxIndex)); + }, [maxIndex]); + + const goPrev = useCallback(() => { + setCurrentStep((prev) => Math.max(prev - 1, 0)); + }, []); + + // Handle button actions + const handleButtonAction = useCallback( + (action: ButtonAction) => { + switch (action) { + case 'next': + // If on last slide, close modal + if (currentStep === maxIndex) { + onClose(); + } else { + goNext(); + } + return; + case 'prev': + goPrev(); + return; + case 'close': + onClose(); + return; + case 'download-selected': { + // Open download URL in new tab + const downloadUrl = selectedDownloadUrlRef.current || os.url; + if (downloadUrl) { + window.open(downloadUrl, '_blank', 'noopener,noreferrer'); + } + // Then advance to next slide or close if last + if (currentStep === maxIndex) { + onClose(); + } else { + goNext(); + } + return; + } + default: + console.warn(`Unhandled button action: ${action}`); + return; + } + }, + [currentStep, maxIndex, goNext, goPrev, onClose, os.url] + ); + + const flowState: FlowState = {}; + + // Early return after all hooks have been called + if (!slideDefinition || !currentSlide || loading) { + return null; + } + + return { + currentStep, + totalSteps, + slideDefinition, + currentSlide, + flowState, + handleButtonAction, + }; +} diff --git a/frontend/src/saas/components/shared/AppConfigModal.tsx b/frontend/src/saas/components/shared/AppConfigModal.tsx new file mode 100644 index 0000000000..680ceeae7b --- /dev/null +++ b/frontend/src/saas/components/shared/AppConfigModal.tsx @@ -0,0 +1,238 @@ +import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import { Modal, Button, Text, ActionIcon } from '@mantine/core'; +import { useMediaQuery } from '@mantine/hooks'; +import { useAuth } from '@app/auth/UseSession'; +import { isUserAnonymous } from '@app/auth/supabase'; +import { useTranslation } from 'react-i18next'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import Overview from '@app/components/shared/config/configSections/Overview'; +import { createSaasConfigNavSections } from '@app/components/shared/config/saasConfigNavSections'; +import { NavKey } from '@app/components/shared/config/types'; +import { withBasePath } from '@app/constants/app'; +import '@app/components/shared/AppConfigModal.css'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex'; + +interface AppConfigModalProps { + opened: boolean; + onClose: () => void; +} + +const AppConfigModal: React.FC = ({ opened, onClose }) => { + const isMobile = useMediaQuery("(max-width: 1024px)"); + + const { signOut, user, creditBalance, refreshCredits } = useAuth(); + const { t } = useTranslation(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [active, setActive] = useState('overview'); + const [notice, setNotice] = useState(null); + + // Check if user can access billing features (non-anonymous users only) + const isAnonymous = user ? isUserAnonymous(user) : false; + useEffect(() => { + const handler = (ev: Event) => { + const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined; + if (detail?.key) { + setActive(detail.key); + } + }; + window.addEventListener('appConfig:navigate', handler as EventListener); + return () => window.removeEventListener('appConfig:navigate', handler as EventListener); + }, []); + + // Listen for notice updates (e.g., "Not enough credits..." next to Plan title) + useEffect(() => { + const handler = (ev: Event) => { + const detail = (ev as CustomEvent).detail as { key?: NavKey; notice?: string } | undefined; + if (detail?.notice && (detail?.key ? detail.key === 'plan' : true)) { + setNotice(detail.notice); + } + }; + window.addEventListener('appConfig:notice', handler as EventListener); + return () => window.removeEventListener('appConfig:notice', handler as EventListener); + }, []); + + // When the modal opens to Plan, proactively refresh credits and log values + useEffect(() => { + if (!opened) return; + if (active !== 'plan') return; + console.log('[AppConfigModal] Opening Plan section. Current creditBalance:', creditBalance); + (async () => { + try { + await refreshCredits(); + } catch (e) { + console.warn('[AppConfigModal] Failed to refresh credits on Plan open:', e); + } + })(); + }, [opened, active]); + + useEffect(() => { + if (!opened) return; + if (active !== 'plan') return; + console.log('[AppConfigModal] Credit balance updated while viewing Plan:', creditBalance); + }, [opened, active, creditBalance]); + + const colors = useMemo(() => ({ + navBg: 'var(--modal-nav-bg)', + sectionTitle: 'var(--modal-nav-section-title)', + navItem: 'var(--modal-nav-item)', + navItemActive: 'var(--modal-nav-item-active)', + navItemActiveBg: 'var(--modal-nav-item-active-bg)', + contentBg: 'var(--modal-content-bg)', + headerBorder: 'var(--modal-header-border)', + }), []); + const isDev = process.env.NODE_ENV === 'development'; + + const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []); + + // Left navigation structure and icons + const configNavSections = useMemo( + () => + createSaasConfigNavSections(Overview, openLogoutConfirm, { + isDev, + isAnonymous, + t, + }), + [openLogoutConfirm, isDev, isAnonymous, t], + ); + + const activeLabel = useMemo(() => { + for (const section of configNavSections) { + const found = section.items.find(i => i.key === active); + if (found) return found.label; + } + return ''; + }, [configNavSections, active]); + + const activeComponent = useMemo(() => { + for (const section of configNavSections) { + const found = section.items.find(i => i.key === active); + if (found) return found.component; + } + return null; + }, [configNavSections, active]); + + return ( + <> + +
+ {/* Left navigation */} +
+
+ {configNavSections.map(section => ( +
+ {!isMobile && ( + + {section.title} + + )} +
+ {section.items.map(item => { + const isActive = active === item.key; + const color = isActive ? colors.navItemActive : colors.navItem; + const iconSize = isMobile ? 28 : 18; + return ( +
setActive(item.key)} + className={`modal-nav-item ${isMobile ? 'mobile' : ''}`} + style={{ + background: isActive ? colors.navItemActiveBg : 'transparent', + }} + > + + {!isMobile && ( + + {item.label} + + )} +
+ ); + })} +
+
+ ))} +
+
+ + {/* Right content */} +
+
+ {/* Sticky header with section title and small close button */} +
+ + {activeLabel} + {active === 'plan' && notice ? ( + + – {notice} + + ) : null} + + + + +
+
+ {activeComponent} +
+
+
+
+
+ {/* Confirm logout modal */} + setConfirmOpen(false)} + title="Sign out" + centered + zIndex={Z_INDEX_OVER_SETTINGS_MODAL} + > +
+ Are you sure you want to sign out? +
+ + +
+
+
+ + ); +}; + +export default AppConfigModal; diff --git a/frontend/src/saas/components/shared/InfoBanner.tsx b/frontend/src/saas/components/shared/InfoBanner.tsx new file mode 100644 index 0000000000..1e5a2b712e --- /dev/null +++ b/frontend/src/saas/components/shared/InfoBanner.tsx @@ -0,0 +1,179 @@ +import React, { ReactNode } from 'react'; +import { Paper, Group, Text, Button, ActionIcon, Stack } from '@mantine/core'; +import LocalIcon from '@app/components/shared/LocalIcon'; + +type InfoBannerTone = 'info' | 'warning'; + +const toneStyles: Record< + InfoBannerTone, + { + background: string; + border: string; + text: string; + icon: string; + buttonColor: string; + } +> = { + info: { + background: 'var(--mantine-color-blue-0)', + border: 'var(--mantine-color-blue-2)', + text: 'var(--mantine-color-blue-9)', + icon: 'var(--mantine-color-blue-6)', + buttonColor: 'blue', + }, + warning: { + background: 'var(--mantine-color-orange-0)', + border: 'var(--mantine-color-orange-3)', + text: 'var(--mantine-color-orange-9)', + icon: 'var(--mantine-color-orange-7)', + buttonColor: 'orange', + }, +}; + +interface InfoBannerProps { + icon?: string | ReactNode; // SaaS supports ReactNode (e.g., logo images) + title?: ReactNode; + message: ReactNode; + buttonText?: string; + buttonIcon?: string; + onButtonClick?: () => void; + onDismiss?: () => void; + dismissible?: boolean; + loading?: boolean; + show?: boolean; + tone?: InfoBannerTone; + background?: string; + borderColor?: string; + textColor?: string; + iconColor?: string; + buttonColor?: string; + buttonVariant?: 'light' | 'filled' | 'white' | 'outline' | 'subtle'; + buttonTextColor?: string; // SaaS-specific for dark theme buttons + minHeight?: number | string; + closeIconColor?: string; // SaaS-specific for dark theme +} + +/** + * SaaS-specific info banner with enhanced theming support + * Supports ReactNode icons (e.g., logo images) and custom button text colors + */ +export const InfoBanner: React.FC = ({ + icon, + title, + message, + buttonText, + buttonIcon = 'check-circle-rounded', + onButtonClick, + onDismiss, + dismissible = true, + loading = false, + show = true, + tone = 'info', + background, + borderColor, + textColor, + iconColor, + buttonColor, + buttonVariant = 'light', + buttonTextColor, + minHeight = 56, + closeIconColor, +}) => { + if (!show) { + return null; + } + + const toneStyle = toneStyles[tone] ?? toneStyles.info; + const handleDismiss = () => { + onDismiss?.(); + }; + + return ( + + + + {icon && ( + typeof icon === 'string' ? ( + + ) : ( +
+ {icon} +
+ ) + )} + + {title && ( + + {title} + + )} + + {message} + + +
+ + {buttonText && onButtonClick && ( + + )} + {dismissible && ( + + + + )} + +
+
+ ); +}; diff --git a/frontend/src/saas/components/shared/ManageBillingButton.tsx b/frontend/src/saas/components/shared/ManageBillingButton.tsx new file mode 100644 index 0000000000..507aeb07e0 --- /dev/null +++ b/frontend/src/saas/components/shared/ManageBillingButton.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { supabase } from '@app/auth/supabase'; +import { Button } from '@mantine/core'; +import { usePlans } from '@app/hooks/usePlans'; + +interface TrialStatus { + isTrialing: boolean; + trialEnd: string; + daysRemaining: number; + hasPaymentMethod: boolean; + hasScheduledSub: boolean; +} + +export function ManageBillingButton({ + returnUrl = typeof window !== 'undefined' ? window.location.href : '/', + children = 'Manage billing', + trialStatus, +}: { + returnUrl?: string; + children?: React.ReactNode; + trialStatus?: TrialStatus; +}) { + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(null); + const { data } = usePlans(); + + // Hide for free plan users + if (!data || data.currentPlan.id === 'free') { + return null; + } + + // Hide for trial users who haven't scheduled a subscription yet + if (trialStatus?.isTrialing && !trialStatus.hasScheduledSub) { + return null; + } + + const onClick = async () => { + setLoading(true); + setErr(null); + try { + const { data, error } = await supabase.functions.invoke('manage-billing', { + body: { + name: 'Functions', + return_url: returnUrl}, + }) + if (error) throw error; + if (!data || 'error' in data) throw new Error((data as any)?.error ?? 'No portal URL'); + window.location.href = (data as any).url; + } catch (e: any) { + setErr(e.message ?? 'Could not open billing portal'); + } finally { + setLoading(false); + } + }; + + return ( +
+ + {err &&
{err}
} +
+ ); +} diff --git a/frontend/src/saas/components/shared/PrivateContent.tsx b/frontend/src/saas/components/shared/PrivateContent.tsx new file mode 100644 index 0000000000..e75a4d7ef5 --- /dev/null +++ b/frontend/src/saas/components/shared/PrivateContent.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +interface PrivateContentProps extends React.HTMLAttributes { + children: React.ReactNode; +} + +/** + * SaaS override of the OSS PrivateContent wrapper. + * Adds both the PostHog no-capture class and the Userback opt-out class + * while keeping the same API and layout behavior (display: contents). + */ +export const PrivateContent: React.FC = ({ + children, + className = '', + style, + ...props +}) => { + const baseClass = 'ph-no-capture userback-block'; + const combinedClassName = className ? `${baseClass} ${className}` : baseClass; + const combinedStyle = { + display: 'contents' as const, + ...style, + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/saas/components/shared/StripeCheckoutSaas.tsx b/frontend/src/saas/components/shared/StripeCheckoutSaas.tsx new file mode 100644 index 0000000000..3f7e13a756 --- /dev/null +++ b/frontend/src/saas/components/shared/StripeCheckoutSaas.tsx @@ -0,0 +1,231 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Button, Text, Alert, Loader, Stack } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { loadStripe } from '@stripe/stripe-js'; +import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js'; +import { supabase } from '@app/auth/supabase'; +import { Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex'; + +const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_DEFAULT_KEY); + +export type PurchaseType = 'subscription' | 'credits'; +export type CreditsPack = 'xsmall' | 'small' | 'medium' | 'large' | null; +export type PlanID = 'pro' | null; + +interface StripeCheckoutProps { + opened: boolean; + onClose: () => void; + // Saas-specific props + planId?: PlanID; + purchaseType?: PurchaseType; + creditsPack?: CreditsPack; + planName?: string; + planPrice?: number; + currency?: string; + isTrialConversion?: boolean; + // Proprietary-specific props (for compatibility) + planGroup?: any; + minimumSeats?: number; + onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void; + hostedCheckoutSuccess?: { + isUpgrade: boolean; + licenseKey?: string; + } | null; + // Common props + onSuccess?: (sessionId: string) => void; + onError?: (error: string) => void; +} + +type CheckoutState = { + status: 'idle' | 'loading' | 'ready' | 'success' | 'error'; + clientSecret?: string; + error?: string; + sessionParams?: { + purchaseType: PurchaseType; + planId: PlanID; + creditsPack: CreditsPack; + }; +}; + +const StripeCheckout: React.FC = ({ + opened, + onClose, + planId, + purchaseType, + creditsPack, + planName, + isTrialConversion, + onSuccess, + onError +}) => { + const { t } = useTranslation(); + const [state, setState] = useState({ status: 'idle' }); + + const createCheckoutSession = async () => { + try { + setState({ status: 'loading' }); + + const { data, error } = await supabase.functions.invoke('create-checkout', { + body: { + purchase_type: purchaseType, + ui_mode: 'embedded', + plan: planId, + credits_pack: creditsPack, + callback_base_url: window.location.origin, + trial_conversion: isTrialConversion || false + } + }); + + if (error) { + throw new Error(error.message || 'Failed to create checkout session'); + } + + if (!data) { + throw new Error('No data received from server'); + } + + const jsonData = typeof data === 'string' ? JSON.parse(data) : data; + + if (!jsonData?.clientSecret) { + throw new Error('No client secret received from server'); + } + + setState({ + status: 'ready', + clientSecret: jsonData.clientSecret, + sessionParams: { + purchaseType: purchaseType!, + planId: planId!, + creditsPack: creditsPack! + } + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to create checkout session'; + setState({ + status: 'error', + error: errorMessage + }); + onError?.(errorMessage); + } + }; + + const handlePaymentComplete = () => { + setState({ status: 'success' }); + + // Call success callback immediately - parent will handle timing + onSuccess?.(''); + + // Note: Parent (Plan.tsx) now handles the delay and modal closing + }; + + const handleClose = () => { + // Reset state to idle to clean up the session + setState({ status: 'idle', clientSecret: undefined, error: undefined, sessionParams: undefined }); + onClose(); + }; + + // Initialize checkout when modal opens or parameters change + useEffect(() => { + if (opened) { + // Check if we need a new session (first time or parameters changed) + const needsNewSession = + state.status === 'idle' || + !state.sessionParams || + state.sessionParams.purchaseType !== purchaseType || + state.sessionParams.planId !== planId || + state.sessionParams.creditsPack !== creditsPack; + + if (needsNewSession) { + console.log('Creating new checkout session:', { purchaseType, planId, creditsPack }); + createCheckoutSession(); + } + } else if (!opened) { + // Clean up state when modal closes + setState({ status: 'idle', clientSecret: undefined, error: undefined, sessionParams: undefined }); + } + }, [opened, purchaseType, planId, creditsPack]); + + const renderContent = () => { + switch (state.status) { + case 'loading': + return ( +
+ + + {t('payment.preparing', 'Preparing your checkout...')} + +
+ ); + + case 'ready': + if (!state.clientSecret) return null; + + return ( + + + + ); + + case 'success': + return ( + + + + {t('payment.successMessage', 'Your plan has been upgraded successfully. You will receive a confirmation email shortly.')} + + + {t('payment.autoClose', 'This window will close automatically...')} + + + + ); + + case 'error': + return ( + + + {state.error} + + + + ); + + default: + return null; + } + }; + + return ( + + + {t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })} + +
+ } + size="xl" + centered + withCloseButton={true} + closeOnEscape={true} + closeOnClickOutside={false} + zIndex={Z_INDEX_OVER_SETTINGS_MODAL} + > + {renderContent()} + + ); +}; + +export default StripeCheckout; +export { StripeCheckout }; diff --git a/frontend/src/saas/components/shared/TrialExpiredModal.tsx b/frontend/src/saas/components/shared/TrialExpiredModal.tsx new file mode 100644 index 0000000000..578d321c73 --- /dev/null +++ b/frontend/src/saas/components/shared/TrialExpiredModal.tsx @@ -0,0 +1,178 @@ +import { Modal, Stack, Button } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined'; +import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground'; +import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; + +interface TrialExpiredModalProps { + opened: boolean; + onClose: () => void; + onSubscribe: () => void; +} + +export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpiredModalProps) { + const { t } = useTranslation(); + + // Use CSS variables for theme colors + const amberColor = getComputedStyle(document.documentElement).getPropertyValue('--color-amber-500').trim() || '#f59e0b'; + const redColor = getComputedStyle(document.documentElement).getPropertyValue('--color-red-500').trim() || '#ef4444'; + const gradientStops: [string, string] = [amberColor, redColor]; + + const circles = [ + { + position: 'bottom-left' as const, + size: 270, // 16.875rem + color: 'rgba(255, 255, 255, 0.25)', + opacity: 0.9, + amplitude: 24, // 1.5rem + duration: 4.5, + offsetX: 18, // 1.125rem + offsetY: 14, // 0.875rem + }, + { + position: 'top-right' as const, + size: 300, // 18.75rem + color: 'rgba(255, 255, 255, 0.2)', + opacity: 0.9, + amplitude: 28, // 1.75rem + duration: 4.5, + delay: 0.5, + offsetX: 24, // 1.5rem + offsetY: 18, // 1.125rem + }, + ]; + + return ( + {}} // Prevent closing by clicking outside or ESC + withCloseButton={false} + closeOnClickOutside={false} + closeOnEscape={false} + centered + size="lg" + radius="lg" + zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} + styles={{ + body: { padding: 0 }, + content: { + overflow: 'hidden', + border: 'none', + background: 'var(--bg-surface)', + maxHeight: '90vh', + display: 'flex', + flexDirection: 'column', + }, + }} + > + +
+ +
+
+ +
+
+
+ +
+ +
+ {t('plan.trial.expired', 'Your Trial Has Ended')} +
+ +
+
+ {t( + 'plan.trial.expiredMessage', + 'Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier.' + )} +
+
+ +
+
+ {t('plan.trial.freeTierLimitations', 'Free tier includes basic PDF tools with usage limits.')} +
+
+ +
+ +
+ + + +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/saas/components/shared/TrialStatusBanner.tsx b/frontend/src/saas/components/shared/TrialStatusBanner.tsx new file mode 100644 index 0000000000..cb0f1951d6 --- /dev/null +++ b/frontend/src/saas/components/shared/TrialStatusBanner.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useBanner } from '@app/contexts/BannerContext'; +import { useAuth } from '@app/auth/UseSession'; +import { useTranslation } from 'react-i18next'; +import { InfoBanner } from '@app/components/shared/InfoBanner'; +import StripeCheckout from '@app/components/shared/StripeCheckoutSaas'; +import { BASE_PATH } from '@app/constants/app'; + +const SESSION_STORAGE_KEY = 'trialBannerDismissed'; + +export function TrialStatusBanner() { + const { setBanner } = useBanner(); + const { t } = useTranslation(); + const { trialStatus } = useAuth(); + const [dismissed, setDismissed] = useState(() => { + return sessionStorage.getItem(SESSION_STORAGE_KEY) === 'true'; + }); + const [checkoutOpen, setCheckoutOpen] = useState(false); + + // Only show banner during ACTIVE trial (not after expiration - modal handles that) + // Don't show if payment method already added (user has scheduled subscription) + const shouldShowBanner = + trialStatus && + trialStatus.isTrialing && // Only show during active trial + trialStatus.daysRemaining > 0 && // Trial hasn't expired yet + !trialStatus.hasPaymentMethod && + !trialStatus.hasScheduledSub && + !dismissed; + + if (trialStatus?.hasPaymentMethod || trialStatus?.hasScheduledSub) { + console.log('Subscription scheduled - hiding trial banner'); + } + + const handleOpenCheckout = useCallback(() => { + setCheckoutOpen(true); + }, []); + + const handleDismiss = useCallback(() => { + setDismissed(true); + sessionStorage.setItem(SESSION_STORAGE_KEY, 'true'); + }, []); + + useEffect(() => { + if (!shouldShowBanner) { + setBanner(null); + return; + } + + const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString('en-GB', { + month: 'short', + day: 'numeric' + }); + + const message = t( + 'plan.trial.message', + `Your trial ends in ${trialStatus.daysRemaining} day${trialStatus.daysRemaining !== 1 ? 's' : ''} (${trialEndDate}). Subscribe to continue Pro access.`, + { days: trialStatus.daysRemaining, date: trialEndDate } + ); + + const logoIcon = ( + Stirling PDF + ); + + setBanner( + + ); + + return () => { + setBanner(null); + }; + }, [shouldShowBanner, trialStatus, setBanner, t, handleOpenCheckout, handleDismiss]); + + const handleCheckoutSuccess = () => { + // Refresh to hide banner and show updated plan + window.location.reload(); + }; + + return ( + <> + {trialStatus && ( + setCheckoutOpen(false)} + purchaseType="subscription" + planId="pro" + creditsPack={null} + planName="Pro" + onSuccess={handleCheckoutSuccess} + onError={(error) => console.error('Checkout error:', error)} + isTrialConversion={true} + /> + )} + + ); +} diff --git a/frontend/src/saas/components/shared/charts/StackedBarChart.tsx b/frontend/src/saas/components/shared/charts/StackedBarChart.tsx new file mode 100644 index 0000000000..207f01f184 --- /dev/null +++ b/frontend/src/saas/components/shared/charts/StackedBarChart.tsx @@ -0,0 +1,268 @@ +import React, { useEffect, useMemo, useRef, useCallback, useId } from 'react'; +import { Group, Loader, Text } from '@mantine/core'; +import * as d3 from 'd3'; +import { StackedBarChartProps, TooltipData, FractionData } from '@app/types/charts'; +import { generateTooltipHTML } from '@app/components/shared/charts/stackedBarChart/StackedBarTooltip'; +import { detectTheme, getChartThemeVars } from '@app/components/shared/charts/utils/themeUtils'; +import { createTooltipPositioner } from '@app/components/shared/charts/utils/tooltipUtils'; +import { createRoundedRectPath, createScale } from '@app/components/shared/charts/utils/d3Utils'; + +export default function StackedBarChart({ + fractions, + width = 640, + height = 22, + showLegend = true, + className = '', + tooltipPosition = 'top', + loading = false, + animate = true, + animationDurationMs = 900, + ariaLabel, +}: StackedBarChartProps) { + const containerRef = useRef(null); + const tooltipRef = useRef(null); + const hasAnimatedRef = useRef(false); + const reactId = useId(); + const clipId = useMemo(() => `clip-${reactId}`, [reactId]); + + // Memoize theme detection to avoid recalculation + const theme = useMemo(() => detectTheme(), []); + const themeVars = useMemo(() => getChartThemeVars(theme.isDark), [theme.isDark]); + + // Memoize tooltip positioner + const tooltipPositioner = useMemo(() => createTooltipPositioner(tooltipPosition), [tooltipPosition]); + + const positionTooltip = useCallback((event: MouseEvent) => { + const tooltip = tooltipRef.current; + if (!tooltip) return; + tooltipPositioner.positionTooltip(event, tooltip, containerRef.current!); + }, [tooltipPositioner]); + + const setTooltipContent = useCallback((labelHtml: string) => { + const tooltip = tooltipRef.current; + if (!tooltip) return; + tooltip.innerHTML = labelHtml; + tooltip.style.background = themeVars.background; + tooltip.style.color = themeVars.textPrimary; + tooltip.style.border = themeVars.border; + tooltip.style.boxShadow = themeVars.boxShadow; + tooltip.style.padding = '8px 10px'; + tooltip.style.fontSize = '12px'; + tooltip.style.lineHeight = '1.25'; + tooltip.style.borderRadius = '8px'; + }, [themeVars]); + + const hideTooltip = useCallback(() => { + const tooltip = tooltipRef.current; + if (!tooltip) return; + tooltip.style.opacity = '0'; + }, []); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + container.innerHTML = ''; + + // Calculate total capacity (sum of all denominators) + const totalCapacity = fractions.reduce((sum: number, fraction: FractionData) => sum + fraction.denominator, 0); + + if (totalCapacity === 0 && !loading) return; + + // Create data for the bar segments + const data = fractions.map((fraction: FractionData) => ({ + ...fraction, + value: fraction.numerator, + remaining: fraction.denominator - fraction.numerator + })); + + const radius = 8; + + const svg = d3 + .select(container) + .append('svg') + .attr('width', '100%') + .attr('height', height) + .attr('viewBox', `0 0 ${width} ${height}`) + .attr('role', ariaLabel ? 'img' : null) + .attr('aria-label', ariaLabel || null); + + const x = createScale([0, totalCapacity], [0, width]); + let cursor = 0; + const g = svg.append('g'); + + // Skip drawing the bar visuals entirely when loading to avoid gray bar under spinner + if (!loading) { + // Create a single rounded rectangle for the entire bar background + const totalBarWidth = x(totalCapacity); + g.append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', totalBarWidth) + .attr('height', height) + .attr('rx', radius) + .attr('ry', radius) + .attr('fill', themeVars.inactive) + .attr('stroke', themeVars.cardBorder); + + // Define a clipPath that will reveal the used portion from left to right + const defs = svg.append('defs'); + const clipRect = defs + .append('clipPath') + .attr('id', clipId) + .append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', 0) + .attr('height', height) + .attr('rx', radius) + .attr('ry', radius); + + // Group to hold the used segments and apply clip-path + const usedGroup = svg.append('g').attr('clip-path', `url(#${clipId})`); + + // Render used segments on top of the background + data.forEach((fraction: typeof data[number], index: number) => { + if (fraction.value <= 0) return; + + const segWidth = x(fraction.value); + const xPos = cursor; + cursor += segWidth; + + const isFirst = index === 0; + const isLast = index === data.length - 1; // Last segment regardless of remaining + + if (isFirst && isLast) { + // Single segment: fully rounded + usedGroup.append('rect') + .attr('x', xPos) + .attr('y', 0) + .attr('width', segWidth) + .attr('height', height) + .attr('rx', radius) + .attr('ry', radius) + .attr('fill', fraction.color); + } else if (isFirst) { + // First segment: rounded on left side only + const path = createRoundedRectPath(xPos, 0, segWidth, height, radius, { + topLeft: true, + topRight: false, + bottomLeft: true, + bottomRight: false + }); + usedGroup.append('path') + .attr('d', path) + .attr('fill', fraction.color); + } else if (isLast) { + // Last segment: rounded on right side only + const path = createRoundedRectPath(xPos, 0, segWidth, height, radius, { + topLeft: false, + topRight: true, + bottomLeft: false, + bottomRight: true + }); + usedGroup.append('path') + .attr('d', path) + .attr('fill', fraction.color); + } else { + // Middle segments: no rounded edges + usedGroup.append('rect') + .attr('x', xPos) + .attr('y', 0) + .attr('width', segWidth) + .attr('height', height) + .attr('fill', fraction.color); + } + }); + + // Add a transparent overlay for hover events on the entire bar (outside clip path) + svg.append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', totalBarWidth) + .attr('height', height) + .attr('rx', radius) + .attr('ry', radius) + .attr('fill', 'transparent') + .style('pointer-events', 'all') + .on('mouseenter', (event: MouseEvent) => { + const tooltipData: TooltipData = { fractions: data, isDark: theme.isDark }; + const html = generateTooltipHTML(tooltipData); + setTooltipContent(html); + const tooltip = tooltipRef.current; + if (tooltip) tooltip.style.opacity = '1'; + positionTooltip(event as unknown as MouseEvent); + }) + .on('mousemove', (event: MouseEvent) => positionTooltip(event as unknown as MouseEvent)) + .on('mouseleave', hideTooltip); + + // Animate reveal of used segments (only on first load, not on re-renders) + const totalUsed = data.reduce((sum: number, f: typeof data[number]) => sum + f.value, 0); + const revealTo = x(totalUsed); + if (animate && !hasAnimatedRef.current) { + clipRect.transition().duration(animationDurationMs).attr('width', revealTo); + hasAnimatedRef.current = true; + } else { + clipRect.attr('width', revealTo); + } + } + + return () => { container.innerHTML = ''; }; + }, [fractions, width, height, tooltipPosition, loading, animate, animationDurationMs, clipId, themeVars, setTooltipContent, hideTooltip, positionTooltip]); + + return ( +
+
+
+
+ {loading && ( +
+ +
+ )} +
+ + {showLegend && ( + + {fractions.map((fraction: FractionData, index: number) => ( + + + {fraction.name} + + ))} + + + Remaining + + + )} +
+ ); +} diff --git a/frontend/src/saas/components/shared/charts/stackedBarChart/StackedBarTooltip.tsx b/frontend/src/saas/components/shared/charts/stackedBarChart/StackedBarTooltip.tsx new file mode 100644 index 0000000000..4f4ada2ce4 --- /dev/null +++ b/frontend/src/saas/components/shared/charts/stackedBarChart/StackedBarTooltip.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { TooltipData } from '@app/types/charts'; + +interface StackedBarTooltipProps { + data: TooltipData; +} + +export default function StackedBarTooltip({ data }: StackedBarTooltipProps) { + const { fractions } = data; + + return ( +
+ {fractions.map((f, index) => ( +
+ + + {f.name} — {f.numeratorLabel}: {f.numerator} · {f.denominatorLabel}: {f.denominator - f.numerator} + +
+ ))} +
+ ); +} + +export function generateTooltipHTML(data: TooltipData): string { + const { fractions } = data; + + return ` +
+ ${fractions.map(f => ` +
+ + ${f.name} — ${f.numeratorLabel}: ${f.numerator} · ${f.denominatorLabel}: ${Math.max(0, f.denominator - f.numerator)} +
+ `).join('')} +
`; +} diff --git a/frontend/src/saas/components/shared/charts/utils/d3Utils.ts b/frontend/src/saas/components/shared/charts/utils/d3Utils.ts new file mode 100644 index 0000000000..44b5b50988 --- /dev/null +++ b/frontend/src/saas/components/shared/charts/utils/d3Utils.ts @@ -0,0 +1,174 @@ +/** + * Reusable D3 utility functions for chart creation + */ + +import * as d3 from 'd3'; + +export interface ChartDimensions { + width: number; + height: number; + margin?: { + top?: number; + right?: number; + bottom?: number; + left?: number; + }; +} + +export interface AnimationConfig { + duration: number; + easing?: (t: number) => number; +} + +/** + * Creates a basic SVG element with proper attributes + * @param container The container element + * @param dimensions Chart dimensions + * @param className Optional CSS class name + * @returns The created SVG selection + */ +export function createSVG( + container: HTMLElement, + dimensions: ChartDimensions, + className?: string +): d3.Selection { + const svg = d3 + .select(container) + .append('svg') + .attr('width', '100%') + .attr('height', dimensions.height) + .attr('viewBox', `0 0 ${dimensions.width} ${dimensions.height}`) + .attr('class', className || ''); + + return svg; +} + +/** + * Creates a clip path for revealing content with animation + * @param svg The SVG selection + * @param clipId Unique ID for the clip path + * @param dimensions Chart dimensions + * @returns The clip rect selection + */ +export function createClipPath( + svg: d3.Selection, + clipId: string, + dimensions: ChartDimensions +): d3.Selection { + const defs = svg.append('defs'); + const clipRect = defs + .append('clipPath') + .attr('id', clipId) + .append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', 0) + .attr('height', dimensions.height); + + return clipRect; +} + +/** + * Animates a clip path to reveal content + * @param clipRect The clip rect selection + * @param targetWidth The target width to animate to + * @param config Animation configuration + */ +export function animateClipReveal( + clipRect: d3.Selection, + targetWidth: number, + config: AnimationConfig +): void { + clipRect + .transition() + .duration(config.duration) + .ease(config.easing || d3.easeCubicInOut) + .attr('width', targetWidth); +} + +/** + * Creates a rounded rectangle path for D3 + * @param x X position + * @param y Y position + * @param width Width + * @param height Height + * @param radius Corner radius + * @param corners Which corners to round (default: all) + * @returns SVG path string + */ +export function createRoundedRectPath( + x: number, + y: number, + width: number, + height: number, + radius: number, + corners: { topLeft?: boolean; topRight?: boolean; bottomLeft?: boolean; bottomRight?: boolean } = {} +): string { + const { topLeft = true, topRight = true, bottomLeft = true, bottomRight = true } = corners; + + if (width <= 0 || height <= 0) return ''; + + const topLeftRadius = topLeft ? radius : 0; + const topRightRadius = topRight ? radius : 0; + const bottomRightRadius = bottomRight ? radius : 0; + const bottomLeftRadius = bottomLeft ? radius : 0; + + let path = `M ${x + topLeftRadius} ${y}`; + + if (topRight) { + path += ` L ${x + width - topRightRadius} ${y}`; + path += ` A ${topRightRadius} ${topRightRadius} 0 0 1 ${x + width} ${y + topRightRadius}`; + } else { + path += ` L ${x + width} ${y}`; + } + + if (bottomRight) { + path += ` L ${x + width} ${y + height - bottomRightRadius}`; + path += ` A ${bottomRightRadius} ${bottomRightRadius} 0 0 1 ${x + width - bottomRightRadius} ${y + height}`; + } else { + path += ` L ${x + width} ${y + height}`; + } + + if (bottomLeft) { + path += ` L ${x + bottomLeftRadius} ${y + height}`; + path += ` A ${bottomLeftRadius} ${bottomLeftRadius} 0 0 1 ${x} ${y + height - bottomLeftRadius}`; + } else { + path += ` L ${x} ${y + height}`; + } + + if (topLeft) { + path += ` L ${x} ${y + topLeftRadius}`; + path += ` A ${topLeftRadius} ${topLeftRadius} 0 0 1 ${x + topLeftRadius} ${y}`; + } else { + path += ` L ${x} ${y}`; + } + + return path + ' Z'; +} + +/** + * Creates a reusable scale factory + * @param domain Domain values + * @param range Range values + * @returns D3 scale function + */ +export function createScale(domain: [number, number], range: [number, number]) { + return d3.scaleLinear().domain(domain).range(range); +} + +/** + * Debounces a function call + * @param func The function to debounce + * @param wait The wait time in milliseconds + * @returns Debounced function + */ +export function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout; + return (...args: Parameters) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +} diff --git a/frontend/src/saas/components/shared/charts/utils/themeUtils.ts b/frontend/src/saas/components/shared/charts/utils/themeUtils.ts new file mode 100644 index 0000000000..581185ef19 --- /dev/null +++ b/frontend/src/saas/components/shared/charts/utils/themeUtils.ts @@ -0,0 +1,63 @@ +/** + * Utility functions for theme detection and styling in D3 charts + */ + +export interface ThemeInfo { + isDark: boolean; + schemeAttr: string | null | undefined; + prefersDark: boolean; +} + +/** + * Detects the current theme from various sources + * @returns ThemeInfo object with theme detection results + */ +export function detectTheme(): ThemeInfo { + const rootEl = typeof document !== 'undefined' ? document.documentElement : null; + const schemeAttr = rootEl?.getAttribute('data-mantine-color-scheme'); + const prefersDark = typeof window !== 'undefined' && + window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches; + + const isDark = schemeAttr ? schemeAttr === 'dark' : prefersDark; + + return { + isDark, + schemeAttr, + prefersDark + }; +} + +/** + * Gets CSS custom properties for chart styling + * @param isDark Whether the theme is dark + * @returns Object with CSS custom property values + */ +export function getChartThemeVars(isDark: boolean) { + return { + background: 'var(--bg-surface)', + textPrimary: 'var(--text-primary)', + border: isDark ? '1px solid var(--border-subtle)' : '1px solid transparent', + boxShadow: isDark ? 'none' : 'var(--shadow-md)', + inactive: 'var(--usage-inactive)', + cardBorder: 'var(--api-keys-card-border)' + }; +} + +/** + * Applies consistent tooltip styling + * @param tooltipElement The tooltip DOM element + * @param isDark Whether the theme is dark + */ +export function applyTooltipStyles(tooltipElement: HTMLElement, isDark: boolean) { + const themeVars = getChartThemeVars(isDark); + + tooltipElement.style.background = themeVars.background; + tooltipElement.style.color = themeVars.textPrimary; + tooltipElement.style.border = themeVars.border; + tooltipElement.style.boxShadow = themeVars.boxShadow; + tooltipElement.style.padding = '8px 10px'; + tooltipElement.style.fontSize = '12px'; + tooltipElement.style.lineHeight = '1.25'; + tooltipElement.style.borderRadius = '8px'; +} diff --git a/frontend/src/saas/components/shared/charts/utils/tooltipUtils.ts b/frontend/src/saas/components/shared/charts/utils/tooltipUtils.ts new file mode 100644 index 0000000000..d50f376ca2 --- /dev/null +++ b/frontend/src/saas/components/shared/charts/utils/tooltipUtils.ts @@ -0,0 +1,74 @@ +/** + * Utility functions for tooltip positioning and management in D3 charts + */ + +export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'; + +export interface TooltipPositioner { + positionTooltip: (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => void; + hideTooltip: (tooltip: HTMLElement) => void; +} + +/** + * Creates a tooltip positioner for the specified position + * @param position The tooltip position preference + * @returns TooltipPositioner object with positioning functions + */ +export function createTooltipPositioner(position: TooltipPosition): TooltipPositioner { + const positionTooltip = (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => { + const bounds = container.getBoundingClientRect(); + const offsetX = event.clientX - bounds.left; + const offsetY = event.clientY - bounds.top; + + // Get tooltip dimensions after content is set + const tooltipHeight = tooltip.offsetHeight; + const tooltipWidth = tooltip.offsetWidth; + const gap = 16; // 1rem gap + + // Position tooltip based on the specified position + switch (position) { + case 'top': + tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, Math.max(10, offsetX - tooltipWidth / 2))}px`; + tooltip.style.top = `${offsetY - tooltipHeight - gap}px`; + break; + case 'bottom': + tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, Math.max(10, offsetX - tooltipWidth / 2))}px`; + tooltip.style.top = `${offsetY + gap}px`; + break; + case 'left': + tooltip.style.left = `${Math.max(10, offsetX - tooltipWidth - gap)}px`; + tooltip.style.top = `${offsetY - tooltipHeight / 2}px`; + break; + case 'right': + tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, offsetX + gap)}px`; + tooltip.style.top = `${offsetY - tooltipHeight / 2}px`; + break; + } + + }; + + const hideTooltip = (tooltip: HTMLElement) => { + tooltip.style.opacity = '0'; + }; + + return { positionTooltip, hideTooltip }; +} + +/** + * Creates a reusable tooltip element with consistent styling + * @param container The container element to append the tooltip to + * @returns The created tooltip element + */ +export function createTooltipElement(container: HTMLElement): HTMLElement { + const tooltip = document.createElement('div'); + tooltip.style.position = 'absolute'; + tooltip.style.left = '0'; + tooltip.style.top = '0'; + tooltip.style.pointerEvents = 'none'; + tooltip.style.opacity = '0'; + tooltip.style.transition = 'opacity 120ms ease'; + tooltip.style.zIndex = '1000'; + + container.appendChild(tooltip); + return tooltip; +} diff --git a/frontend/src/saas/components/shared/config/ProfilePictureCropper.tsx b/frontend/src/saas/components/shared/config/ProfilePictureCropper.tsx new file mode 100644 index 0000000000..1fbbd461b0 --- /dev/null +++ b/frontend/src/saas/components/shared/config/ProfilePictureCropper.tsx @@ -0,0 +1,187 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { Modal, Button, Stack, Slider, Alert, Text, Box } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import Cropper from 'react-easy-crop'; +import { getCroppedImage, type Area } from '@app/utils/cropImage'; +import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; + +interface ProfilePictureCropperProps { + file: File | null; + opened: boolean; + onClose: () => void; + onCropComplete: (croppedBlob: Blob) => void; +} + +export const ProfilePictureCropper: React.FC = ({ + file, + opened, + onClose, + onCropComplete, +}) => { + const { t } = useTranslation(); + + // State management + const [imageSrc, setImageSrc] = useState(null); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + + // Load image when file changes + useEffect(() => { + if (!file) { + setImageSrc(null); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + setImageSrc(reader.result as string); + setError(null); + }; + reader.onerror = () => { + setError(t('config.account.profilePicture.cropper.invalidImage', 'Invalid image file. Please select a valid PNG, JPG, or WebP file.')); + }; + reader.readAsDataURL(file); + + // Cleanup + return () => { + if (imageSrc) { + URL.revokeObjectURL(imageSrc); + } + }; + }, [file, t]); + + // Reset state when modal closes + useEffect(() => { + if (!opened) { + setCrop({ x: 0, y: 0 }); + setZoom(1); + setCroppedAreaPixels(null); + setProcessing(false); + setError(null); + } + }, [opened]); + + // Called when crop area changes + const onCropChange = useCallback((newCrop: { x: number; y: number }) => { + setCrop(newCrop); + }, []); + + // Called when zoom changes + const onZoomChange = useCallback((newZoom: number) => { + setZoom(newZoom); + }, []); + + // Called when crop is complete (stores the crop area in pixels) + const onCropCompleteCallback = useCallback( + (_croppedArea: Area, croppedAreaPixels: Area) => { + setCroppedAreaPixels(croppedAreaPixels); + }, + [] + ); + + // Process and save the cropped image + const handleSave = async () => { + if (!imageSrc || !croppedAreaPixels) { + return; + } + + setProcessing(true); + setError(null); + + try { + // Crop the image + const croppedBlob = await getCroppedImage(imageSrc, croppedAreaPixels); + + // Validate size (2MB limit) + const maxSize = 2 * 1024 * 1024; // 2MB in bytes + if (croppedBlob.size > maxSize) { + setError( + t( + 'config.account.profilePicture.cropper.sizeErrorAfterCrop', + 'Cropped image is too large. Please zoom out or crop a smaller area.' + ) + ); + setProcessing(false); + return; + } + + // Call parent callback with cropped blob + onCropComplete(croppedBlob); + onClose(); + } catch (err) { + console.error('Error cropping image:', err); + setError( + t( + 'config.account.profilePicture.cropper.cropError', + 'Failed to crop image. Please try again.' + ) + ); + } finally { + setProcessing(false); + } + }; + + return ( + + + {error && ( + + {error} + + )} + + {/* Cropper area */} + + {imageSrc && ( + + )} + + + {/* Zoom slider */} + + + {t('config.account.profilePicture.cropper.zoom', 'Zoom')} + + + + + {/* Action buttons */} +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/saas/components/shared/config/configSections/ApiKeys.tsx b/frontend/src/saas/components/shared/config/configSections/ApiKeys.tsx new file mode 100644 index 0000000000..595ea38914 --- /dev/null +++ b/frontend/src/saas/components/shared/config/configSections/ApiKeys.tsx @@ -0,0 +1,114 @@ +import React, { useState } from "react"; +import { Anchor, Group, Stack, Text, Button, Paper } from "@mantine/core"; +import UsageSection from "@app/components/shared/config/configSections/apiKeys/UsageSection"; +import ApiKeySection from "@app/components/shared/config/configSections/apiKeys/ApiKeySection"; +import RefreshModal from "@app/components/shared/config/configSections/apiKeys/RefreshModal"; +import { useCredits } from "@app/components/shared/config/configSections/apiKeys/hooks/useCredits"; +import useApiKey from "@app/components/shared/config/configSections/apiKeys/hooks/useApiKey"; +import SkeletonLoader from "@app/components/shared/SkeletonLoader"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@app/auth/UseSession"; +import { isUserAnonymous } from "@app/auth/supabase"; + +export default function ApiKeys() { + const [copied, setCopied] = useState(null); + const [showRefreshModal, setShowRefreshModal] = useState(false); + const { t } = useTranslation(); + const { user } = useAuth(); + const isAnonymous = Boolean(user && isUserAnonymous(user)); + + const { data: credits, isLoading: creditsLoading } = useCredits(); + const { apiKey, isLoading: apiKeyLoading, refresh, isRefreshing, error: apiKeyError, refetch, hasAttempted } = useApiKey(); + + const copy = async (text: string, tag: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(tag); + setTimeout(() => setCopied(null), 1600); + } catch (e) { + // noop – you can surface a notification here + console.error(e); + } + }; + + const refreshKeys = async () => { + try { + await refresh(); + } finally { + setShowRefreshModal(false); + } + }; + + const goToAccount = () => { + window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'overview' } })); + }; + + const showUsage = Boolean(credits); + + return ( + + {showUsage && ( + + )} + + {!isAnonymous && apiKeyError && ( + + {t('config.apiKeys.generateError', "We couldn't generate your API key.")} {" "} + + {t('common.retry', 'Retry')} + + + )} + + {isAnonymous ? ( + + + {t('config.apiKeys.label', 'API Key')} + + + {t('config.apiKeys.guestInfo', 'Guest users do not receive API keys. Create an account to get an API key you can use in your applications.')} + + + + + + ) : ( + apiKeyLoading ? ( + <> + + {t('config.apiKeys.description', "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.")} + +
+ + + + + +
+ + ) : ( + setShowRefreshModal(true)} + disabled={isRefreshing} + /> + ) + )} + + setShowRefreshModal(false)} + onConfirm={refreshKeys} + /> +
+ ); +} diff --git a/frontend/src/saas/components/shared/config/configSections/Overview.tsx b/frontend/src/saas/components/shared/config/configSections/Overview.tsx new file mode 100644 index 0000000000..90e89c6768 --- /dev/null +++ b/frontend/src/saas/components/shared/config/configSections/Overview.tsx @@ -0,0 +1,515 @@ +import React, { useState } from 'react'; +import { Alert, Avatar, Button, Divider, FileButton, Group, Image, LoadingOverlay, PasswordInput, Text, TextInput, Modal } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '@app/auth/UseSession'; +import { isUserAnonymous, linkEmailIdentity, linkOAuthIdentity, supabase } from '@app/auth/supabase'; +import { BASE_PATH } from '@app/constants/app'; +import { oauthProviders } from '@app/constants/authProviders'; +import { Tooltip } from '@app/components/shared/Tooltip'; +import { absoluteWithBasePath } from '@app/constants/app'; +import { synchronizeUserUpgrade } from '@app/services/userService'; +import { ProfilePictureCropper } from '@app/components/shared/config/ProfilePictureCropper'; +import { updateProfilePictureMetadata } from '@app/services/avatarSyncService'; +import { deleteCurrentAccount } from '@app/services/accountDeletion'; +import { alert as showToast } from '@app/components/toast'; + +interface OverviewProps { + onLogoutClick: () => void; +} + +const Overview: React.FC = ({ onLogoutClick }) => { + const { t } = useTranslation(); + const { user, refreshSession, signOut, profilePictureUrl, profilePictureMetadata, refreshProfilePicture, refreshProfilePictureMetadata } = useAuth(); + + const PROFILE_BUCKET = 'profile-pictures'; + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [upgradeError, setUpgradeError] = useState(null); + const [success, setSuccess] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [profileUploading, setProfileUploading] = useState(false); + const [profileError, setProfileError] = useState(null); + const [cropperFile, setCropperFile] = useState(null); + const [cropperOpen, setCropperOpen] = useState(false); + const [isDeletingAccount, setIsDeletingAccount] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [confirmEmail, setConfirmEmail] = useState(''); + + const isAnonymous = Boolean(user && isUserAnonymous(user)); + const isOAuthPicture = profilePictureMetadata?.source === 'oauth'; + const provider = profilePictureMetadata?.provider; + + const profilePath = user ? `${user.id}/avatar` : null; + const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || 'U'; + + const handleProfileUpload = async (file: File | null) => { + if (!file || !user || !profilePath) { + return; + } + + if (file.size > 2 * 1024 * 1024) { + setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.')); + return; + } + + // Open cropper instead of uploading directly + setProfileError(null); + setCropperFile(file); + setCropperOpen(true); + }; + + const handleCropComplete = async (croppedBlob: Blob) => { + if (!user || !profilePath) { + return; + } + + // Validate cropped size (2MB limit) + if (croppedBlob.size > 2 * 1024 * 1024) { + setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.')); + setCropperOpen(false); + setCropperFile(null); + return; + } + + setProfileUploading(true); + setProfileError(null); + + const { error } = await supabase + .storage + .from(PROFILE_BUCKET) + .upload(profilePath, croppedBlob, { + upsert: true, + cacheControl: '3600', + contentType: 'image/png' + }); + + if (error) { + setProfileError(error.message || 'Failed to upload profile picture'); + } else { + // Mark as manual upload in metadata + await updateProfilePictureMetadata(user.id, { + source: 'upload', + provider: null, + }); + await refreshProfilePictureMetadata(); + await refreshProfilePicture(); + } + + setProfileUploading(false); + setCropperOpen(false); + setCropperFile(null); + }; + + const handleProfileRemove = async () => { + if (!user || !profilePath) { + return; + } + + setProfileUploading(true); + setProfileError(null); + + const { error } = await supabase + .storage + .from(PROFILE_BUCKET) + .remove([profilePath]); + + if (error) { + setProfileError(error.message || 'Failed to remove profile picture'); + } else { + // Clear metadata when removing picture + await updateProfilePictureMetadata(user.id, { + source: 'upload', + provider: null, + }); + await refreshProfilePictureMetadata(); + await refreshProfilePicture(); + } + + setProfileUploading(false); + }; + + const handleUseCustomPicture = async () => { + if (!user) { + return; + } + + setProfileUploading(true); + setProfileError(null); + + try { + // Update metadata to allow manual uploads + await updateProfilePictureMetadata(user.id, { + source: 'upload', + provider: null, + }); + + await refreshProfilePictureMetadata(); + setSuccess(t('config.account.profilePicture.switchedToCustom', 'Switched to custom picture. You can now upload your own.')); + + // Clear success message after 3 seconds + setTimeout(() => setSuccess(null), 3000); + } catch (error: any) { + setProfileError(error.message || 'Failed to switch to custom picture'); + } finally { + setProfileUploading(false); + } + }; + + const handleEmailUpgrade = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!email.trim()) { + setUpgradeError('Email is required'); + return; + } + + try { + setIsLoading(true); + setUpgradeError(null); + setSuccess(null); + + // First, upgrade the account in Supabase + await linkEmailIdentity(email.trim(), password || undefined); + + // Synchronize with backend database (using "email" as auth method for email/password) + await synchronizeUserUpgrade('email'); + + // Refresh the session to reflect changes + await refreshSession(); + + setSuccess('Account upgraded successfully! You can now sign in with your email.'); + setEmail(''); + setPassword(''); + } catch (err: any) { + setUpgradeError(err?.message || 'Failed to upgrade account'); + } finally { + setIsLoading(false); + } + }; + + const handleOAuthUpgrade = async (provider: 'github' | 'google' | 'apple' | 'azure') => { + try { + setIsLoading(true); + setUpgradeError(null); + setSuccess(null); + + // Store provider info for post-redirect handling + sessionStorage.setItem('pendingUpgrade', 'true'); + sessionStorage.setItem('upgradeProvider', provider); + + // Redirect back to homepage after OAuth completes + // The UseSession hook will handle the pendingUpgrade synchronization + const redirectUrl = absoluteWithBasePath('/auth/callback?next=/'); + const result = await linkOAuthIdentity(provider, redirectUrl); + + if (result.data?.url) { + window.location.href = result.data.url; + } + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : `Failed to upgrade account with ${provider}`; + setUpgradeError(errorMessage); + setIsLoading(false); + sessionStorage.removeItem('pendingUpgrade'); + sessionStorage.removeItem('upgradeProvider'); + } + }; + + + const handleDeleteAccount = async () => { + if (isAnonymous) return; + try { + setIsDeletingAccount(true); + await deleteCurrentAccount(); + setDeleteModalOpen(false); + setConfirmEmail(''); + await signOut(); + window.location.href = absoluteWithBasePath('/login'); + } catch (err) { + const fallbackMessage = t('config.account.overview.deleteFailed', 'Failed to delete account.'); + const message = err instanceof Error ? err.message : fallbackMessage; + console.error('[Overview] Delete account failed:', err); + showToast({ + alertType: 'error', + title: t('config.account.overview.deleteFailedTitle', 'Unable to delete account'), + body: message, + expandable: true, + location: 'top-right', + durationMs: 7000 + }); + } finally { + setIsDeletingAccount(false); + } + }; + + const closeDeleteModal = () => { + setDeleteModalOpen(false); + setConfirmEmail(''); + }; + + return ( +
+ + +
+
+
+

+ {t('config.account.overview.title', 'Account Settings')} +

+

+ {isAnonymous + ? t('config.account.overview.guestDescription', 'You are signed in as a guest. Consider upgrading your account below.') + : t('config.account.overview.manageAccountPreferences', 'Manage your account preferences') + } +

+ {user?.email && ( +

+ {t('config.account.overview.signedInAs', 'Signed in as')}: {user.email} +

+ )} +
+ +
+
+ + + +
+

+ {t('config.account.profilePicture.title', 'Profile picture')} +

+

+ {t('config.account.profilePicture.description', 'Upload an image to personalize your account.')} +

+ + {profileError && ( + + {profileError} + + )} + + {success && ( + + {success} + + )} + + {isOAuthPicture ? ( + + + {profileInitial} + +
+ + {t('config.account.profilePicture.usingProvider', 'Using {{provider}} profile picture', { + provider: provider ? provider.charAt(0).toUpperCase() + provider.slice(1) : 'OAuth' + })} + + +
+
+ ) : ( + + + {profileInitial} + +
+ + + {(props) => ( + + )} + + + + + {t('config.account.profilePicture.help', 'PNG, JPG, or WebP up to 2MB.')} + +
+
+ )} +
+ + { + setCropperOpen(false); + setCropperFile(null); + }} + onCropComplete={handleCropComplete} + /> + + {isAnonymous && } + + {isAnonymous && ( +
+
+

+ {t('config.account.upgrade.title', 'Upgrade Guest Account')} +

+

+ {t('config.account.upgrade.description', 'Link your account to preserve your history and access more features!')} +

+
+ + {upgradeError && ( + + {upgradeError} + + )} + + {success && ( + + {success} + + )} + +
+ + {t('config.account.upgrade.socialLogin', 'Upgrade with Social Account')} + +
+ {oauthProviders + .filter(provider => !provider.isDisabled) + .map((provider) => ( + + + + ))} +
+
+ +
+ + {t('config.account.upgrade.emailPassword', 'or enter your email & password')} + +
+ + setEmail(e.target.value)} + required + style={{ flex: 1 }} + /> + setPassword(e.target.value)} + description={t('config.account.upgrade.passwordNote', 'Leave empty to use email verification only')} + style={{ flex: 1 }} + /> + + +
+
+
+ )} + + {/* Delete Account Section */} + {!isAnonymous && ( +
+ +
+ )} + + {/* Delete Account Confirmation Modal */} + +
{ + e.preventDefault(); + if (confirmEmail.toLowerCase() === user?.email?.toLowerCase()) { + handleDeleteAccount(); + } + }}> + + {t('config.account.overview.deleteWarning', + 'This action is permanent and cannot be undone. All your data will be deleted.')} + + + {t('config.account.overview.enterEmailConfirm', + 'To confirm deletion, please type your email address ({{email}}) below:', + { email: user?.email })} + + setConfirmEmail(e.target.value)} + mb="md" + /> + + + + + +
+
+ ); +}; + +export default Overview; diff --git a/frontend/src/saas/components/shared/config/configSections/PasswordSecurity.tsx b/frontend/src/saas/components/shared/config/configSections/PasswordSecurity.tsx new file mode 100644 index 0000000000..e0ff18ddc4 --- /dev/null +++ b/frontend/src/saas/components/shared/config/configSections/PasswordSecurity.tsx @@ -0,0 +1,123 @@ +import React, { useState } from 'react'; +import { Button, PasswordInput, Group, Alert, LoadingOverlay, Modal, Divider } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '@app/auth/UseSession'; +import { supabase } from '@app/auth/supabase'; +import { Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex'; + +const PasswordSecurity: React.FC = () => { + const { t } = useTranslation(); + const { refreshSession } = useAuth(); + + const [opened, setOpened] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [didUpdate, setDidUpdate] = useState(false); + + const handleChangePassword = async () => { + if (!newPassword || !confirmPassword) { + setError(t('signup.pleaseFillAllFields', 'Please fill in all fields')); + return; + } + if (newPassword.length < 6) { + setError(t('signup.passwordTooShort', 'Password must be at least 6 characters long')); + return; + } + if (newPassword !== confirmPassword) { + setError(t('signup.passwordsDoNotMatch', 'Passwords do not match')); + return; + } + + try { + setIsLoading(true); + setError(null); + setSuccess(null); + + // Update to the new password directly + const { error: updateError } = await supabase.auth.updateUser({ password: newPassword }); + if (updateError) { + setError(updateError.message); + return; + } + + setSuccess(t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.')); + setNewPassword(''); + setConfirmPassword(''); + setDidUpdate(true); + + // Replace form with success text, then close after 2s + setTimeout(() => { + // refresh session after closing to avoid UI jank + void refreshSession(); + setOpened(false); + setDidUpdate(false); + }, 2000); + } catch (e: any) { + setError(e?.message || 'Failed to change password'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + +
+
+

+ {t('config.account.security.title', 'Passwords & Security')} +

+

+ {t('config.account.security.description', 'Manage your password and security settings.')} +

+
+ +
+ + setOpened(false)} centered title={t('config.account.security.changePassword', 'Change password')} zIndex={Z_INDEX_OVER_SETTINGS_MODAL}> + {error && ( + {error} + )} + + {didUpdate ? ( + {success || t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.')} + ) : ( +
+ setNewPassword(e.currentTarget.value)} + /> + setConfirmPassword(e.currentTarget.value)} + /> + + + + + + +
+ )} +
+
+ ); +}; + +export default PasswordSecurity; + + diff --git a/frontend/src/saas/components/shared/config/configSections/Plan.tsx b/frontend/src/saas/components/shared/config/configSections/Plan.tsx new file mode 100644 index 0000000000..e5704e6293 --- /dev/null +++ b/frontend/src/saas/components/shared/config/configSections/Plan.tsx @@ -0,0 +1,201 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { Divider, Loader, Alert, Select, Group, Text } from '@mantine/core'; +import { usePlans, PlanTier } from '@app/hooks/usePlans'; +import StripeCheckout, { PurchaseType, CreditsPack, PlanID } from '@app/components/shared/StripeCheckoutSaas'; +import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection'; +import ApiPackagesSection from '@app/components/shared/config/configSections/plan/ApiPackagesSection'; +import ActivePlanSection from '@app/components/shared/config/configSections/plan/ActivePlanSection'; +import { useAuth } from '@app/auth/UseSession'; + +const Plan: React.FC = () => { + const [checkoutOpen, setCheckoutOpen] = useState(false); + const [selectedPlan, setSelectedPlan] = useState(null); + const [selectedCredits, setSelectedCredits] = useState(0); // Index of selected credit package + const [purchaseType, setPurchaseType] = useState('subscription'); + const [selectedCreditsPack, setSelectedCreditsPack] = useState(null); + const [currency, setCurrency] = useState('gbp'); + const { trialStatus } = useAuth(); + const { data, loading, error, updateCurrentPlan } = usePlans(currency); + + const currencyOptions = [ + { value: 'cny', label: 'Chinese yuan (CNY, ¥)' }, + { value: 'usd', label: 'US dollar (USD, $)' }, + { value: 'inr', label: 'Indian rupee (INR, ₹)' }, + { value: 'brl', label: 'Brazilian real (BRL, R$)' }, + { value: 'eur', label: 'Euro (EUR, €)' }, + { value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' }, + { value: 'gbp', label: 'British pound (GBP, £)' } + ]; + + const handleUpgradeClick = useCallback((plan: PlanTier) => { + if (!data) return; + + if (plan.isContactOnly) { + // Open contact form or redirect to contact page + window.open('mailto:contact@stirlingpdf.com?subject=Enterprise Plan Inquiry', '_blank'); + return; + } + + if (plan.id !== data.currentPlan.id) { + setSelectedPlan(plan); + setPurchaseType('subscription'); + setSelectedCreditsPack(null); + setCheckoutOpen(true); + } + }, [data]); + + const handleCreditPurchaseClick = useCallback((creditsPack: CreditsPack) => { + if (!data) return; + + setSelectedCreditsPack(creditsPack); + setPurchaseType('credits'); + setSelectedPlan(null); + setCheckoutOpen(true); + }, [data]); + + const handlePaymentSuccess = useCallback((sessionId: string) => { + console.log('Payment successful, session:', sessionId); + + // Update local state immediately - no page reload needed + if (selectedPlan && purchaseType === 'subscription') { + updateCurrentPlan(selectedPlan.id); + } + + // Close modal after brief delay to show success message + setTimeout(() => { + setCheckoutOpen(false); + setSelectedPlan(null); + setSelectedCreditsPack(null); + }, 2000); + }, [selectedPlan, purchaseType, updateCurrentPlan]); + + const handlePaymentError = useCallback((error: string) => { + console.error('Payment error:', error); + // Error is already displayed in the StripeCheckout component + }, []); + + const handleCheckoutClose = useCallback(() => { + setCheckoutOpen(false); + setSelectedPlan(null); + setSelectedCreditsPack(null); + }, []); + + const handleAddPaymentClick = useCallback(() => { + if (!data) return; + + // Find Pro plan from available plans + const proPlan = Array.from(data.plans.values()).find(plan => plan.id === 'pro'); + + if (proPlan) { + setSelectedPlan(proPlan); + setPurchaseType('subscription'); + setSelectedCreditsPack(null); + setCheckoutOpen(true); + } + }, [data]); + + // Check URL parameters for action=add-payment + useEffect(() => { + if (!data) return; + + const params = new URLSearchParams(window.location.search); + if (params.get('action') === 'add-payment') { + handleAddPaymentClick(); + // Clean up URL + params.delete('action'); + const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname; + window.history.replaceState({}, '', newUrl); + } + }, [data, handleAddPaymentClick]); + + // Early returns after all hooks are called + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!data) { + return ( + + Plans data is not available at the moment. + + ); + } + + const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } = data; + const plansArray = Array.from(plans.values()); + + return ( +
+ {/* Currency Selector */} +
+ + Currency + setPassword(e.target.value)} + className="auth-input" + /> +
+
+ + setConfirmPassword(e.target.value)} + className="auth-input" + /> +
+
+ + navigate('/login')} + text={t('login.backToSignIn', 'Back to sign in')} + isDisabled={isSubmitting} + /> + + ) : ( + <> + {}} + onSubmit={handleSendEmail} + isSubmitting={isSubmitting} + submitButtonText={t('login.sendResetLink', 'Send reset link')} + showPasswordField={false} + /> +

+ {t('login.resetHelp', 'Enter your email to receive a secure link to reset your password. If the link has expired, please request a new one.')} +

+ navigate('/login')} + text={t('login.backToSignIn', 'Back to sign in')} + isDisabled={isSubmitting} + /> + + )} + + ) +} + + diff --git a/frontend/src/saas/routes/Signup.tsx b/frontend/src/saas/routes/Signup.tsx new file mode 100644 index 0000000000..94df229650 --- /dev/null +++ b/frontend/src/saas/routes/Signup.tsx @@ -0,0 +1,226 @@ +import { useState, useEffect } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' +import { signInAnonymously } from '@app/auth/supabase' +import { useAuth } from '@app/auth/UseSession' +import { useTranslation } from '@app/hooks/useTranslation' +import { useDocumentMeta } from '@app/hooks/useDocumentMeta' +import { getBaseUrl } from '@app/constants/app' +import AuthLayout from '@app/routes/authShared/AuthLayout' +import '@app/routes/authShared/auth.css' +import '@app/routes/authShared/saas-auth.css' +import GuestSignInButton from '@app/routes/authShared/GuestSignInButton' +import { alert } from '@app/components/toast' + +// Import signup components +import LoginHeader from '@app/routes/login/LoginHeader' +import ErrorMessage from '@app/routes/login/ErrorMessage' +import OAuthButtons from '@app/routes/login/OAuthButtons' +import DividerWithText from '@app/components/shared/DividerWithText' +import SignupForm from '@app/routes/signup/SignupForm' +import { useSignupFormValidation, SignupFieldErrors } from '@app/routes/signup/SignupFormValidation' +import { useAuthService } from '@app/routes/signup/AuthService' + +export default function Signup() { + const navigate = useNavigate() + const location = useLocation() + const { session, loading, refreshSession } = useAuth() + const { t } = useTranslation() + const [isSigningUp, setIsSigningUp] = useState(false) + const [error, setError] = useState(null) + const [showEmailForm, setShowEmailForm] = useState(false) + const [name, setName] = useState(undefined as string | undefined) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [agree, setAgree] = useState(true) + const [fieldErrors, setFieldErrors] = useState({}) + + // Check if we were redirected here with an auto-auth error + useEffect(() => { + const state = location.state as { autoAuthError?: string } | null + if (state?.autoAuthError) { + setError(`Unable to access tool: ${state.autoAuthError}`) + } + }, [location.state]) + + // Redirect back to original tool URL once session appears (after auto-anon completes) + useEffect(() => { + if (!loading && session) { + const state = location.state as { from?: { pathname?: string; search?: string; hash?: string } } | null + const from = state?.from + if (from?.pathname && from.pathname !== '/signup' && from.pathname !== '/login') { + const target = `${from.pathname}${from.search ?? ''}${from.hash ?? ''}` + console.log('[Signup] Session detected, redirecting back to:', target) + navigate(target, { replace: true }) + } + } + }, [loading, session, location.state, navigate]) + + const handleAnonymousSignIn = async () => { + try { + setIsSigningUp(true) + setError(null) + + console.log('[Signup] Initiating anonymous sign-in...') + const { data, error } = await signInAnonymously() + + if (error) { + console.error('[Signup] Anonymous sign-in error:', error) + setError(`Failed to create guest account: ${(error as any)?.message || 'Unknown error'}`) + } else if (data.user) { + console.log('[Signup] Anonymous sign-in successful, refreshing session...') + + // Refresh session to ensure backend endpoints are properly synchronized + await refreshSession() + + console.log('[Signup] Session refreshed, redirecting to home page') + // Redirect to home page after successful anonymous login and session refresh + navigate('/') + } + } catch (err) { + console.error('[Signup] Anonymous sign-in unexpected error:', err) + setError(`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`) + } finally { + setIsSigningUp(false) + } + } + + const baseUrl = getBaseUrl(); + + // Set document meta + useDocumentMeta({ + title: `${t('signup.title', 'Create an account')} - Stirling PDF`, + description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'), + ogTitle: `${t('signup.title', 'Create an account')} - Stirling PDF`, + ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'), + ogImage: `${baseUrl}/og_images/home.png`, + ogUrl: `${window.location.origin}${window.location.pathname}` + }) + + const { validateSignupForm } = useSignupFormValidation() + const { signUp, signInWithProvider } = useAuthService() + + const handleSignUp = async () => { + const validation = validateSignupForm(email, password, confirmPassword, name) + if (!validation.isValid) { + setError(validation.error) + setFieldErrors(validation.fieldErrors || {}) + return + } + + try { + setIsSigningUp(true) + setError(null) + setFieldErrors({}) + + const result = await signUp(email, password, name) + + if (result.requiresEmailConfirmation) { + alert({ + alertType: 'success', + title: t('signup.checkEmailConfirmation'), + location: 'top-right', + isPersistentPopup: true + }) + } else { + alert({ + alertType: 'success', + title: t('signup.accountCreatedSuccessfully'), + location: 'top-right', + durationMs: 3000 + }) + setTimeout(() => navigate('/login'), 2000) + } + } catch (err) { + console.error('[Signup] Unexpected error:', err) + setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' })) + } finally { + setIsSigningUp(false) + } + } + + const handleProviderSignIn = async (provider: 'github' | 'google' | 'apple' | 'azure') => { + try { + setIsSigningUp(true) + setError(null) + await signInWithProvider(provider) + } catch (err) { + setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' })) + } finally { + setIsSigningUp(false) + } + } + + return ( + + + + + + {/* OAuth first */} +
+ +
+ + {/* Divider between OAuth and Email */} +
+ +
+ + {/* Use Email Instead button (toggles email form) */} +
+ +
+ + {showEmailForm && ( + + )} + +
+ +
+ + + + {/* Bottom row */} +
+ +
+
+ ) +} diff --git a/frontend/src/saas/routes/authShared/AuthLayout.module.css b/frontend/src/saas/routes/authShared/AuthLayout.module.css new file mode 100644 index 0000000000..7784f5b1d2 --- /dev/null +++ b/frontend/src/saas/routes/authShared/AuthLayout.module.css @@ -0,0 +1,74 @@ +.authContainer { + position: relative; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--auth-bg-color-light-only); + padding: 1.5rem 1.5rem 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + overflow: auto; +} + +/* Main area above footer: keep card centered even with footer visible */ +.authMain { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 95vh; +} + +.authCard { + width: min(45rem, 96vw); + height: min(50.875rem, 96vh); + display: grid; + grid-template-columns: 1fr; + background-color: var(--auth-card-bg); + border-radius: 1.25rem; + box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12); + overflow: hidden; + min-height: 0; + max-height: 96vh; +} + +.authCardTwoColumns { + width: min(73.75rem, 96vw); + grid-template-columns: 1fr 1fr; +} + +.authLeftPanel { + display: flex; + justify-content: center; + padding: 2rem; + min-height: 0; + height: 100%; +} + +.authLeftPanelCentered { + align-items: center; + overflow: hidden; +} + +.authLeftPanelScrollable { + align-items: flex-start; + overflow-y: auto; + overflow-x: hidden; +} + +.authLeftPanel::-webkit-scrollbar { + display: none; /* WebKit browsers (Chrome, Safari, Edge) */ +} + +.authContent { + max-width: 26.25rem; /* 420px */ + width: 100%; + display: flex; + flex-direction: column; +} + +.authLeftPanelScrollable .authContent { + min-height: 100%; +} diff --git a/frontend/src/saas/routes/authShared/AuthLayout.tsx b/frontend/src/saas/routes/authShared/AuthLayout.tsx new file mode 100644 index 0000000000..58244bf55f --- /dev/null +++ b/frontend/src/saas/routes/authShared/AuthLayout.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import LoginRightCarousel from '@app/components/shared/LoginRightCarousel'; +import buildLoginSlides from '@app/components/shared/loginSlides'; +import styles from '@app/routes/authShared/AuthLayout.module.css'; +import { useLogoVariant } from '@app/hooks/useLogoVariant'; +import { useIsOverflowing } from '@app/hooks/useIsOverflowing'; +import Footer from '@app/components/shared/Footer'; + +interface AuthLayoutProps { + children: React.ReactNode + isEmailFormExpanded?: boolean +} + +export default function AuthLayout({ children, isEmailFormExpanded = false }: AuthLayoutProps) { + const { t } = useTranslation(); + const cardRef = useRef(null); + const leftPanelRef = useRef(null); + const [hideRightPanel, setHideRightPanel] = useState(false); + const logoVariant = useLogoVariant(); + const imageSlides = useMemo(() => buildLoginSlides(logoVariant, t), [logoVariant, t]); + const isOverflowing = useIsOverflowing(leftPanelRef); + + // Use either overflow detection or email form expansion to determine scrollable state + const shouldBeScrollable = isOverflowing || isEmailFormExpanded; + + // Force light mode on auth pages + useEffect(() => { + const htmlElement = document.documentElement; + const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme'); + + // Set light mode + htmlElement.setAttribute('data-mantine-color-scheme', 'light'); + + // Cleanup: restore previous theme when leaving auth pages + return () => { + if (previousColorScheme) { + htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme); + } + }; + }, []); + + useEffect(() => { + const update = () => { + // Use viewport to avoid hysteresis when the card is already in single-column mode + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw) + const columnWidth = cardWidthIfTwoCols / 2; + const tooNarrow = columnWidth < 470; + const tooShort = viewportHeight < 740; + setHideRightPanel(tooNarrow || tooShort); + }; + update(); + window.addEventListener('resize', update); + window.addEventListener('orientationchange', update); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('orientationchange', update); + }; + }, []); + + return ( +
+
+
+
+
+ {children} +
+
+ {!hideRightPanel && ( + + )} +
+
+
+
+
+
+ ) +} diff --git a/frontend/src/saas/routes/authShared/GuestSignInButton.tsx b/frontend/src/saas/routes/authShared/GuestSignInButton.tsx new file mode 100644 index 0000000000..7d4ec1b476 --- /dev/null +++ b/frontend/src/saas/routes/authShared/GuestSignInButton.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import '@app/routes/authShared/auth.css' +import '@app/routes/authShared/saas-auth.css' + +interface GuestSignInButtonProps { + label: string + onClick: () => void + disabled?: boolean +} + +export default function GuestSignInButton({ label, onClick, disabled }: GuestSignInButtonProps) { + return ( + + ) +} + + diff --git a/frontend/src/saas/routes/authShared/saas-auth.css b/frontend/src/saas/routes/authShared/saas-auth.css new file mode 100644 index 0000000000..601c64360a --- /dev/null +++ b/frontend/src/saas/routes/authShared/saas-auth.css @@ -0,0 +1,72 @@ +/* SaaS-specific auth styles — imported alongside the base auth.css */ + +.oauth-container-fullwidth { + display: flex; + flex-direction: column; + gap: 0.75rem; /* 12px */ + margin-bottom: 0.625rem; /* 10px */ +} + +.oauth-button-fullwidth { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1rem; + border: 1px solid #d1d5db; + border-radius: 0.625rem; + background-color: #ffffff; + font-size: 1rem; + font-weight: 600; + color: #000000; + cursor: pointer; + gap: 0.5rem; + box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); +} + +.oauth-button-fullwidth:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.auth-dropdown-wrapper { + position: relative; +} + +.auth-dropdown-backdrop { + position: fixed; + inset: 0; + background: transparent; + z-index: 30; +} + +.auth-dropdown { + position: absolute; + z-index: 40; + margin-top: 0.5rem; + min-width: 16rem; + background-color: #ffffff; + color: #000000; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + padding: 0.25rem; +} + +.auth-dropdown-item { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + color: #000; + cursor: pointer; +} + +.auth-guest-button { + background-color: #ffffff; + color: #9c2f30; + border: 2px solid currentColor; +} diff --git a/frontend/src/saas/routes/login/EmailPasswordForm.tsx b/frontend/src/saas/routes/login/EmailPasswordForm.tsx new file mode 100644 index 0000000000..828b033e47 --- /dev/null +++ b/frontend/src/saas/routes/login/EmailPasswordForm.tsx @@ -0,0 +1,86 @@ +import { useTranslation } from 'react-i18next'; +import '@app/routes/authShared/auth.css'; +import '@app/routes/authShared/saas-auth.css'; + +interface EmailPasswordFormProps { + email: string + password: string + setEmail: (email: string) => void + setPassword: (password: string) => void + onSubmit: () => void + isSubmitting: boolean + submitButtonText: string + showPasswordField?: boolean + fieldErrors?: { + email?: string + password?: string + } +} + +export default function EmailPasswordForm({ + email, + password, + setEmail, + setPassword, + onSubmit, + isSubmitting, + submitButtonText, + showPasswordField = true, + fieldErrors = {} +}: EmailPasswordFormProps) { + const { t } = useTranslation(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(); + }; + return ( +
+
+
+ + setEmail(e.target.value)} + className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`} + /> + {fieldErrors.email && ( +
{fieldErrors.email}
+ )} +
+ + {showPasswordField && ( +
+ + setPassword(e.target.value)} + className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`} + /> + {fieldErrors.password && ( +
{fieldErrors.password}
+ )} +
+ )} +
+ + +
+ ); +} diff --git a/frontend/src/saas/routes/login/LoadingState.tsx b/frontend/src/saas/routes/login/LoadingState.tsx new file mode 100644 index 0000000000..659ca9389d --- /dev/null +++ b/frontend/src/saas/routes/login/LoadingState.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from '@app/hooks/useTranslation' + +export default function LoadingState() { + const { t } = useTranslation() + + return ( +
+
+
+

{t('loading')}

+
+
+ ) +} diff --git a/frontend/src/saas/routes/login/MagicLinkForm.tsx b/frontend/src/saas/routes/login/MagicLinkForm.tsx new file mode 100644 index 0000000000..54666f35ae --- /dev/null +++ b/frontend/src/saas/routes/login/MagicLinkForm.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from '@app/hooks/useTranslation' +import '@app/routes/authShared/auth.css' +import '@app/routes/authShared/saas-auth.css' + +interface MagicLinkFormProps { + showMagicLink: boolean + magicLinkEmail: string + setMagicLinkEmail: (email: string) => void + setShowMagicLink: (show: boolean) => void + onSubmit: () => void + isSubmitting: boolean +} + +export default function MagicLinkForm({ + showMagicLink, + magicLinkEmail, + setMagicLinkEmail, + setShowMagicLink, + onSubmit, + isSubmitting +}: MagicLinkFormProps) { + const { t } = useTranslation() + + if (!showMagicLink) { + return ( +
+ +
+ ) + } + + return ( +
+ setMagicLinkEmail(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()} + className="auth-input" + /> + +
+ ) +} diff --git a/frontend/src/saas/routes/login/OAuthButtons.tsx b/frontend/src/saas/routes/login/OAuthButtons.tsx new file mode 100644 index 0000000000..aeb6c337e6 --- /dev/null +++ b/frontend/src/saas/routes/login/OAuthButtons.tsx @@ -0,0 +1,106 @@ +import { oauthProviders } from '@app/constants/authProviders' +import { useTranslation } from '@app/hooks/useTranslation' +import { Tooltip } from '@app/components/shared/Tooltip' +import { withBasePath } from '@app/constants/app' + +// Exports for compatibility with proprietary code +export const DEBUG_SHOW_ALL_PROVIDERS = false; +export const oauthProviderConfig = { + google: { label: 'Google', file: 'google.svg' }, + github: { label: 'GitHub', file: 'github.svg' }, + apple: { label: 'Apple', file: 'apple.svg' }, + azure: { label: 'Microsoft', file: 'microsoft.svg' }, +}; + +interface OAuthButtonsProps { + onProviderClick: (provider: 'github' | 'google') => void + isSubmitting: boolean + layout?: 'vertical' | 'grid' | 'icons' | 'fullwidth' + enabledProviders?: string[] // List of enabled provider IDs from backend +} + +export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders: _enabledProviders = [] }: OAuthButtonsProps) { + const { t } = useTranslation() + + if (layout === 'icons') { + return ( +
+ {oauthProviders.map((p) => ( + + + + ))} +
+ ) + } + + if (layout === 'grid') { + return ( +
+ {oauthProviders.map((p) => ( + + + + ))} +
+ ) + } + + if (layout === 'fullwidth') { + return ( +
+ {oauthProviders.map((p) => ( + + ))} +
+ ) + } + + return ( +
+ {oauthProviders.map((p) => ( + + ))} +
+ ) +} diff --git a/frontend/src/saas/routes/login/SuccessMessage.tsx b/frontend/src/saas/routes/login/SuccessMessage.tsx new file mode 100644 index 0000000000..a0cd4f2629 --- /dev/null +++ b/frontend/src/saas/routes/login/SuccessMessage.tsx @@ -0,0 +1,13 @@ +interface SuccessMessageProps { + success: string | null +} + +export default function SuccessMessage({ success }: SuccessMessageProps) { + if (!success) return null + + return ( +
+

{success}

+
+ ) +} diff --git a/frontend/src/saas/routes/signup/AuthService.ts b/frontend/src/saas/routes/signup/AuthService.ts new file mode 100644 index 0000000000..45e0926e07 --- /dev/null +++ b/frontend/src/saas/routes/signup/AuthService.ts @@ -0,0 +1,54 @@ +import { supabase } from '@app/auth/supabase' +import { absoluteWithBasePath } from '@app/constants/app' + +export const useAuthService = () => { + + const signUp = async ( + email: string, + password: string, + name?: string + ) => { + console.log('[Signup] Creating account for:', email) + + const { data, error } = await supabase.auth.signUp({ + email: email.trim(), + password: password, + options: { + emailRedirectTo: absoluteWithBasePath('/auth/callback'), + data: { full_name: name } + } + }) + + if (error) { + console.error('[Signup] Sign up error:', error) + throw new Error(error.message) + } + + if (data.user) { + console.log('[Signup] Sign up successful:', data.user) + return { + user: data.user, + session: data.session, + requiresEmailConfirmation: data.user && !data.session + } + } + + throw new Error('Unknown error occurred during signup') + } + + const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => { + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { redirectTo: absoluteWithBasePath('/auth/callback') } + }) + + if (error) { + throw new Error(error.message) + } + } + + return { + signUp, + signInWithProvider + } +} diff --git a/frontend/src/saas/services/accountDeletion.ts b/frontend/src/saas/services/accountDeletion.ts new file mode 100644 index 0000000000..5311036f33 --- /dev/null +++ b/frontend/src/saas/services/accountDeletion.ts @@ -0,0 +1,26 @@ +import { supabase } from '@app/auth/supabase'; + +interface DeleteAccountOptions { + notifyUser?: boolean; +} + +interface DeleteUserResponse { + success?: boolean; + error?: string; + deleted_supabase_id?: string; + stripe_redaction_job_id?: string | null; +} + +export async function deleteCurrentAccount(options?: DeleteAccountOptions): Promise { + const { data, error } = await supabase.functions.invoke('delete-user', { + body: { + notify_user: options?.notifyUser ?? true, + }, + }); + + if (error || !data?.success) { + const serverMessage = data?.error; + const errorMessage = serverMessage || error?.message || 'Failed to delete account'; + throw new Error(errorMessage); + } +} diff --git a/frontend/src/saas/services/apiClient.test.ts b/frontend/src/saas/services/apiClient.test.ts new file mode 100644 index 0000000000..5858e2c436 --- /dev/null +++ b/frontend/src/saas/services/apiClient.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { supabase } from '@app/auth/supabase'; + +// Mock supabase +vi.mock('@app/auth/supabase', () => ({ + supabase: { + auth: { + getSession: vi.fn(), + refreshSession: vi.fn(), + }, + }, +})); + +describe('apiClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset modules to get fresh instance of apiClient + vi.resetModules(); + }); + + it('should add JWT token to request headers when session exists', async () => { + const mockToken = 'test-jwt-token-12345'; + const mockSession: any = { + access_token: mockToken, + refresh_token: 'refresh-token', + expires_in: 3600, + token_type: 'bearer', + user: { id: 'user-123' }, + }; + + // Mock getSession to return a session with token + vi.mocked(supabase.auth.getSession).mockResolvedValue({ + data: { session: mockSession }, + error: null, + }); + + // Import apiClient after mocking + const { default: apiClient } = await import('@app/services/apiClient'); + + // Create a mock adapter to intercept the request + const mockAdapter = vi.fn((config) => { + // Verify the Authorization header is set correctly + expect(config.headers.Authorization).toBe(`Bearer ${mockToken}`); + return Promise.resolve({ + data: { success: true }, + status: 200, + statusText: 'OK', + headers: {}, + config, + }); + }); + + // Replace the adapter + apiClient.defaults.adapter = mockAdapter; + + // Make a test request + await apiClient.get('/api/v1/test'); + + // Verify the request was made with the token + expect(mockAdapter).toHaveBeenCalled(); + expect(supabase.auth.getSession).toHaveBeenCalled(); + }); + + it('should handle requests when no session exists', async () => { + // Mock getSession to return no session + vi.mocked(supabase.auth.getSession).mockResolvedValue({ + data: { session: null }, + error: null, + }); + + // Import apiClient after mocking + const { default: apiClient } = await import('@app/services/apiClient'); + + // Create a mock adapter to intercept the request + const mockAdapter = vi.fn((config) => { + // Verify no Authorization header is set + expect(config.headers.Authorization).toBeUndefined(); + return Promise.resolve({ + data: { success: true }, + status: 200, + statusText: 'OK', + headers: {}, + config, + }); + }); + + // Replace the adapter + apiClient.defaults.adapter = mockAdapter; + + // Make a test request + await apiClient.get('/api/v1/test'); + + // Verify the request was made without a token + expect(mockAdapter).toHaveBeenCalled(); + expect(supabase.auth.getSession).toHaveBeenCalled(); + }); + + it('should refresh token on 401 response', async () => { + const oldToken = 'old-token'; + const newToken = 'new-refreshed-token'; + + const oldSession: any = { + access_token: oldToken, + refresh_token: 'refresh-token', + expires_in: 3600, + token_type: 'bearer', + user: { id: 'user-123' }, + }; + + const newSession: any = { + access_token: newToken, + refresh_token: 'new-refresh-token', + expires_in: 3600, + token_type: 'bearer', + user: { id: 'user-123' }, + }; + + // Mock initial session for first request + let getSessionCallCount = 0; + vi.mocked(supabase.auth.getSession).mockImplementation(async () => { + getSessionCallCount++; + // First call returns old session, subsequent calls return new session + if (getSessionCallCount === 1) { + return { data: { session: oldSession }, error: null }; + } + return { data: { session: newSession }, error: null }; + }); + + // Mock refresh to return new session + vi.mocked(supabase.auth.refreshSession).mockResolvedValue({ + data: { user: null, session: newSession }, + error: null as any, + } as any); + + // Import apiClient after mocking + const { default: apiClient } = await import('@app/services/apiClient'); + + let requestCount = 0; + const mockAdapter = vi.fn((config) => { + requestCount++; + + // First request returns 401 + if (requestCount === 1) { + // Verify first request has old token + expect(config.headers.Authorization).toBe(`Bearer ${oldToken}`); + const error: any = new Error('Unauthorized'); + error.response = { + status: 401, + data: { error: 'Unauthorized' }, + }; + error.config = config; + return Promise.reject(error); + } + + // Second request (after refresh) should have new token + // The interceptor will call getSession again, which now returns the new session + expect(config.headers.Authorization).toBe(`Bearer ${newToken}`); + return Promise.resolve({ + data: { success: true }, + status: 200, + statusText: 'OK', + headers: {}, + config, + }); + }); + + // Replace the adapter + apiClient.defaults.adapter = mockAdapter; + + // Make a test request that will trigger 401 and retry + const response = await apiClient.get('/api/v1/test'); + + // Verify the token was refreshed and request retried + expect(response.data).toEqual({ success: true }); + expect(supabase.auth.refreshSession).toHaveBeenCalled(); + expect(mockAdapter).toHaveBeenCalledTimes(2); + expect(getSessionCallCount).toBe(3); // Called for initial request, for checking if refresh is possible, and for retry + }); + + it('should handle refresh token failure', async () => { + const oldToken = 'old-token'; + + const oldSession = { + access_token: oldToken, + user: { id: 'user-123' }, + }; + + // Mock initial session + vi.mocked(supabase.auth.getSession).mockResolvedValue({ + data: { session: oldSession }, + error: null, + } as any); + vi.mocked(supabase.auth.getSession).mockResolvedValue({ + data: { session: oldSession }, + error: null, + } as any); + + // Mock refresh to fail + vi.mocked(supabase.auth.refreshSession).mockResolvedValue({ + data: { user: null, session: null }, + error: { name: 'AuthError', message: 'Refresh failed', status: 400, code: 'auth_error', __isAuthError: true } as any, + } as any); + + // Import apiClient after mocking + const { default: apiClient } = await import('@app/services/apiClient'); + + // Mock window.location for redirect test + delete (window as any).location; + window.location = { href: '' } as any; + + const mockAdapter = vi.fn((config) => { + // Always return 401 to trigger refresh + const error: any = new Error('Unauthorized'); + error.response = { + status: 401, + data: { error: 'Unauthorized' }, + }; + error.config = config; + return Promise.reject(error); + }); + + // Replace the adapter + apiClient.defaults.adapter = mockAdapter; + + // Make a test request that will trigger 401 + try { + await apiClient.get('/api/v1/test'); + // Should not reach here + expect(true).toBe(false); + } catch (_) { + // Verify refresh was attempted + expect(supabase.auth.refreshSession).toHaveBeenCalled(); + // Verify redirect to login + expect(window.location.href).toBe('/login'); + } + }); +}); diff --git a/frontend/src/saas/services/apiClient.ts b/frontend/src/saas/services/apiClient.ts new file mode 100644 index 0000000000..2508cce493 --- /dev/null +++ b/frontend/src/saas/services/apiClient.ts @@ -0,0 +1,197 @@ +import axios from 'axios'; +import { supabase } from '@app/auth/supabase'; +import { handleHttpError } from '@app/services/httpErrorHandler'; +import { alert } from '@app/components/toast'; +import { openPlanSettings } from '@app/utils/appSettings'; + +// Global credit update callback - will be set by the AuthProvider +let globalCreditUpdateCallback: ((credits: number) => void) | null = null; + +// Function to set the global credit update callback +export const setGlobalCreditUpdateCallback = (callback: (credits: number) => void) => { + globalCreditUpdateCallback = callback; +}; + +// Helper: decode base64url JWT payload safely +function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split('.'); + if (parts.length < 2) return null; + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '='); + const json = typeof atob !== 'undefined' + ? atob(padded) + : Buffer.from(padded, 'base64').toString('binary'); + return JSON.parse(json); + } catch (e) { + console.warn('[API Client] Failed to decode JWT payload:', e); + return null; + } +} + +// Create axios instance with default config +const apiClient = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || '/', // Use env var or relative path (proxied by Vite in dev) + responseType: 'json', +}); + +const LOW_CREDIT_THRESHOLD = 10; +function notifyLowCredits(credits: number) { + const title = 'Credit balance low'; + const body = `You have ${credits} credits remaining.`; + alert({ + alertType: 'warning', + title, + body, + buttonText: 'Top up', + buttonCallback: () => openPlanSettings(), + isPersistentPopup: true, + location: 'bottom-right' + }); +} +// Request interceptor to add JWT token to all requests +apiClient.interceptors.request.use( + async (config) => { + try { + // Get the current session from Supabase + const { data: { session }, error } = await supabase.auth.getSession(); + + if (error) { + console.error('[API Client] Error getting session:', error); + } + + // If we have a session with an access token, add it to the Authorization header + if (session?.access_token) { + config.headers.Authorization = `Bearer ${session.access_token}`; + const payload = decodeJwtPayload(session.access_token); + const role = (payload?.['role'] as string) || (payload?.['user_role'] as string) || undefined; + const aud = payload?.['aud'] as string | undefined; + const isAnon = role === 'anon' || aud === 'anon'; + + // Debug logs for visibility during integration + if (import.meta.env.DEV) { + console.debug('[API Client] Added JWT token to request:', config.url); + console.debug('[API Client] JWT payload:', payload); + console.debug('[API Client] Token role:', role, '| aud:', aud, '| isAnon:', isAnon); + } + } else { + console.debug('[API Client] No JWT token available for request:', config.url); + } + } catch (error) { + console.error('[API Client] Error in request interceptor:', error); + } + + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// List of endpoints that don't require authentication +const publicEndpoints = [ + '/api/v1/config/app-config', + '/api/v1/info/status', + '/api/v1/config/public-config', + '/api/v1/config/endpoints-enabled', +]; + +// Response interceptor for handling token refresh and credit updates +apiClient.interceptors.response.use( + (response) => { + // Check for X-Credits-Remaining header and update credits automatically + const creditsRemaining = response.headers['x-credits-remaining']; + if (creditsRemaining && globalCreditUpdateCallback) { + const credits = parseInt(creditsRemaining, 10); + if (!isNaN(credits) && credits >= 0) { + console.debug('[API Client] Updating credits from response header:', credits, 'for URL:', response.config?.url); + globalCreditUpdateCallback(credits); + // Show low-credit toast with top-up button when below threshold + if (credits < LOW_CREDIT_THRESHOLD) { + notifyLowCredits(credits); + } + } else { + console.warn('[API Client] Invalid credits value in response header:', creditsRemaining); + } + } + if (response.config?.url?.includes('/api/v1/credits')) { + console.debug('[API Client] Credits endpoint response headers:', response.headers); + } + return response; + }, + async (error) => { + const originalRequest = error.config; + const isPublicEndpoint = publicEndpoints.some(endpoint => + originalRequest.url?.includes(endpoint) + ); + + // If we get a 401 and haven't already tried to refresh, and it's not a public endpoint + if (error.response?.status === 401 && !originalRequest._retry && !isPublicEndpoint) { + originalRequest._retry = true; + + try { + // Check if we have a session to refresh + const { data: { session } } = await supabase.auth.getSession(); + + // Only try to refresh if we actually have a session + if (session) { + const { data: { session: refreshedSession }, error: refreshError } = await supabase.auth.refreshSession(); + + if (refreshError) { + console.error('[API Client] Token refresh failed:', refreshError); + + // Only redirect to login for protected endpoints, not public ones + const isPublicEndpoint = originalRequest.url?.includes('/api/v1/config/') || + originalRequest.url?.includes('/api/v1/info/'); + + if (!isPublicEndpoint) { + // Redirect to login only for protected endpoints + window.location.href = '/login'; + } + + return Promise.reject(error); + } + + if (refreshedSession?.access_token) { + // Update the Authorization header with the new token + originalRequest.headers = originalRequest.headers || {}; + originalRequest.headers.Authorization = `Bearer ${refreshedSession.access_token}`; + console.debug('[API Client] Retrying request with refreshed token'); + + // Retry the original request with the new token + return apiClient(originalRequest); + } + } else { + // No session exists, only redirect if not already on login page + console.debug('[API Client] No session to refresh, 401 on protected endpoint'); + if (window.location.pathname !== '/login') { + window.location.href = '/login'; + } + } + } catch (refreshError) { + console.error('[API Client] Error during token refresh:', refreshError); + } + } + + // For public endpoints with 401, just log and continue (don't redirect) + if (isPublicEndpoint && error.response?.status === 401) { + console.debug('[API Client] 401 on public endpoint, continuing without auth:', originalRequest.url); + } + const status = error.response?.status; + const url = error.config?.url; + const method = error.config?.method?.toUpperCase(); + + console.error('[API Client] HTTP Error', { + status, + method, + url, + error: error.message, + data: error.response?.data + }); + await handleHttpError(error); // Handle error (shows toast unless suppressed) + + return Promise.reject(error); + } +); + +export default apiClient; diff --git a/frontend/src/saas/services/avatarSyncService.ts b/frontend/src/saas/services/avatarSyncService.ts new file mode 100644 index 0000000000..3ca58a245f --- /dev/null +++ b/frontend/src/saas/services/avatarSyncService.ts @@ -0,0 +1,338 @@ +/** + * Avatar sync service for OAuth provider profile pictures + * Downloads, optimizes, and syncs profile pictures from OAuth providers + */ + +import { supabase } from '@app/auth/supabase' +import type { User } from '@supabase/supabase-js' + +const PROFILE_BUCKET = 'profile-pictures' +const AVATAR_SIZE = 256 // 256x256 pixels +const MAX_AVATAR_SIZE = 500 * 1024 // 500KB max file size after optimization +const SYNC_INTERVAL_DAYS = 7 // Resync every 7 days + +// Client-side cache to prevent repeated sync attempts in same browser session +const sessionSyncCache = new Map() + +export interface ProfilePictureMetadata { + user_id: string + source: 'oauth' | 'upload' + provider: 'google' | 'github' | 'apple' | 'azure' | null + last_synced_at: string | null + created_at: string + updated_at: string +} + +/** + * Extract avatar URL from OAuth provider user metadata + * @param user Supabase User object + * @returns Avatar URL or null if not available + */ +export function getProviderAvatarUrl(user: User): string | null { + const provider = user.app_metadata?.provider + const metadata = user.user_metadata + + if (!provider || !metadata) { + return null + } + + switch (provider) { + case 'google': + case 'azure': + // Google and Azure use 'picture' field + return metadata.picture || null + case 'github': + // GitHub uses 'avatar_url' field + return metadata.avatar_url || null + case 'apple': + // Apple doesn't provide profile pictures via OAuth + return null + default: + return null + } +} + +/** + * Download and optimize an avatar image + * Resizes to 256x256 and converts to PNG format + * @param url Avatar URL from OAuth provider + * @returns Optimized image blob + */ +export async function downloadAndOptimizeAvatar(url: string): Promise { + try { + // 1. Fetch image from provider URL + const response = await fetch(url, { + mode: 'cors', + credentials: 'omit', + }) + + if (!response.ok) { + throw new Error(`Failed to download avatar: ${response.status} ${response.statusText}`) + } + + const blob = await response.blob() + + // 2. Create image bitmap + const img = await createImageBitmap(blob) + + // 3. Create canvas and draw scaled image + const canvas = document.createElement('canvas') + canvas.width = AVATAR_SIZE + canvas.height = AVATAR_SIZE + const ctx = canvas.getContext('2d') + + if (!ctx) { + throw new Error('Failed to get canvas context') + } + + // Draw image scaled to fit (maintains aspect ratio, centered) + const scale = Math.min(AVATAR_SIZE / img.width, AVATAR_SIZE / img.height) + const x = (AVATAR_SIZE - img.width * scale) / 2 + const y = (AVATAR_SIZE - img.height * scale) / 2 + ctx.drawImage(img, x, y, img.width * scale, img.height * scale) + + // 4. Convert to PNG blob with quality optimization + return new Promise((resolve, reject) => { + canvas.toBlob( + (optimizedBlob) => { + if (!optimizedBlob) { + reject(new Error('Failed to create optimized blob')) + return + } + + // Check file size + if (optimizedBlob.size > MAX_AVATAR_SIZE) { + console.warn('[Avatar Sync] Optimized avatar exceeds max size:', optimizedBlob.size) + // Try with lower quality + canvas.toBlob( + (lowerQualityBlob) => { + if (lowerQualityBlob) { + resolve(lowerQualityBlob) + } else { + reject(new Error('Failed to create lower quality blob')) + } + }, + 'image/png', + 0.7 + ) + } else { + resolve(optimizedBlob) + } + }, + 'image/png', + 0.9 + ) + }) + } catch (error) { + console.error('[Avatar Sync] Failed to download and optimize avatar:', error) + throw error + } +} + +/** + * Upload avatar blob to Supabase Storage + * @param userId User ID + * @param blob Optimized avatar blob + */ +export async function uploadAvatarToStorage(userId: string, blob: Blob): Promise { + try { + const profilePath = `${userId}/avatar` + + console.debug('[Avatar Sync] Uploading avatar to storage:', profilePath) + + // Upload to Supabase Storage (overwrites existing file) + const { error: uploadError } = await supabase.storage + .from(PROFILE_BUCKET) + .upload(profilePath, blob, { + upsert: true, // Overwrite existing file + contentType: 'image/png', + cacheControl: '3600', // Cache for 1 hour + }) + + if (uploadError) { + throw uploadError + } + + console.debug('[Avatar Sync] Avatar uploaded successfully') + } catch (error) { + console.error('[Avatar Sync] Failed to upload avatar to storage:', error) + throw error + } +} + +/** + * Fetch profile picture metadata for a user + * @param userId User ID + * @returns Metadata or null if not found + */ +export async function getProfilePictureMetadata( + userId: string +): Promise { + try { + const { data, error } = await supabase + .from('profile_picture_metadata') + .select('*') + .eq('user_id', userId) + .maybeSingle() + + if (error) { + // If table doesn't exist, that's expected before migration runs + if (error.code === 'PGRST116' || error.message?.includes('does not exist')) { + console.debug('[Avatar Sync] Metadata table not found - migration may not be applied yet') + return null + } + console.error('[Avatar Sync] Failed to fetch profile picture metadata:', error) + return null + } + + return data + } catch (error) { + console.error('[Avatar Sync] Unexpected error fetching metadata:', error) + return null + } +} + +/** + * Update or insert profile picture metadata + * @param userId User ID + * @param data Partial metadata to update + */ +export async function updateProfilePictureMetadata( + userId: string, + data: Partial> +): Promise { + try { + const { error } = await supabase.from('profile_picture_metadata').upsert( + { + user_id: userId, + ...data, + }, + { + onConflict: 'user_id', + } + ) + + if (error) { + // If table doesn't exist, log but don't crash + if (error.code === 'PGRST116' || error.message?.includes('does not exist')) { + console.warn('[Avatar Sync] Cannot update metadata - table does not exist. Run migration first.') + return // Don't throw, allow feature to work without metadata tracking + } + throw error + } + + console.debug('[Avatar Sync] Metadata updated successfully') + } catch (error) { + console.error('[Avatar Sync] Failed to update metadata:', error) + throw error + } +} + +/** + * Main function to sync OAuth avatar for a user + * Downloads avatar from OAuth provider and uploads to Supabase Storage + * Only syncs if: + * - User is authenticated via OAuth provider that supports avatars + * - User hasn't manually uploaded a picture (source !== 'upload') + * - Last sync was more than SYNC_INTERVAL_DAYS ago (or never synced) + * + * @param user Supabase User object + * @returns true if sync was performed, false if skipped + */ +export async function syncOAuthAvatar(user: User): Promise { + const cacheKey = user.id + + try { + // 0. Check client-side session cache first (prevent repeated attempts) + const cached = sessionSyncCache.get(cacheKey) + if (cached) { + const minutesSinceLastAttempt = (Date.now() - cached.timestamp) / (1000 * 60) + if (minutesSinceLastAttempt < 60) { + console.debug('[Avatar Sync] Skipping sync - already attempted in this session:', { + minutesAgo: minutesSinceLastAttempt.toFixed(1), + lastSuccess: cached.success + }) + return cached.success + } + } + + // 1. Check if user is OAuth authenticated + const provider = user.app_metadata?.provider + console.debug('[Avatar Sync] Checking user for sync:', { + provider, + userId: user.id, + email: user.email, + hasUserMetadata: !!user.user_metadata, + userMetadataKeys: user.user_metadata ? Object.keys(user.user_metadata) : [] + }) + + if (!provider || !['google', 'github', 'azure'].includes(provider)) { + console.debug('[Avatar Sync] Skipping sync - not an OAuth provider with avatar support') + sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false }) + return false + } + + // 2. Get metadata to check if sync is needed + const metadata = await getProfilePictureMetadata(user.id) + + // Skip if user has manually uploaded a picture + if (metadata?.source === 'upload') { + console.debug('[Avatar Sync] Skipping sync - user has manual upload') + sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false }) + return false + } + + // Skip if synced recently (within SYNC_INTERVAL_DAYS) + if (metadata?.last_synced_at) { + const lastSync = new Date(metadata.last_synced_at) + const daysSinceSync = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60 * 24) + if (daysSinceSync < SYNC_INTERVAL_DAYS) { + console.debug('[Avatar Sync] Skipping sync - synced recently:', { + daysSinceSync: daysSinceSync.toFixed(1), + threshold: SYNC_INTERVAL_DAYS, + }) + sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: true }) + return false + } + } + + // 3. Extract provider avatar URL + const avatarUrl = getProviderAvatarUrl(user) + console.debug('[Avatar Sync] Avatar URL extraction:', { + provider, + avatarUrl, + hasAvatarUrl: !!avatarUrl + }) + + if (!avatarUrl) { + console.debug('[Avatar Sync] No avatar URL available from provider') + sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false }) + return false + } + + console.debug('[Avatar Sync] Starting sync for provider:', provider, 'with URL:', avatarUrl) + + // 4. Download and optimize avatar + const optimizedBlob = await downloadAndOptimizeAvatar(avatarUrl) + + // 5. Upload to Supabase Storage + await uploadAvatarToStorage(user.id, optimizedBlob) + + // 6. Update metadata + await updateProfilePictureMetadata(user.id, { + source: 'oauth', + provider: provider as ProfilePictureMetadata['provider'], + last_synced_at: new Date().toISOString(), + }) + + console.debug('[Avatar Sync] Sync completed successfully') + sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: true }) + return true + } catch (error) { + console.error('[Avatar Sync] Failed to sync OAuth avatar:', error) + // Cache the failure to prevent repeated attempts + sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false }) + // Don't throw - gracefully degrade to existing picture or initials + return false + } +} diff --git a/frontend/src/saas/services/signatureStorageService.ts b/frontend/src/saas/services/signatureStorageService.ts new file mode 100644 index 0000000000..2019994585 --- /dev/null +++ b/frontend/src/saas/services/signatureStorageService.ts @@ -0,0 +1,147 @@ +import type { SavedSignature } from '@app/hooks/tools/sign/useSavedSignatures'; + +export type StorageType = 'backend' | 'localStorage'; + +interface SignatureStorageCapabilities { + supportsBackend: boolean; + storageType: StorageType; +} + +/** + * SaaS-specific signature storage service that always uses localStorage. + * + * In SaaS mode, the proprietary backend signature API is not available + * (requires Spring Security JWT, not Supabase JWT), so we skip detection + * and force localStorage-only mode to avoid unnecessary 401/403 errors. + */ +class SignatureStorageService { + private capabilities: SignatureStorageCapabilities | null = null; + private blobUrls: Set = new Set(); + private readonly STORAGE_KEY = 'stirling:saved-signatures:v1'; + + /** + * Detect capabilities - in SaaS mode, always returns localStorage + */ + async detectCapabilities(): Promise { + if (this.capabilities) { + return this.capabilities; + } + + // SaaS mode always uses localStorage (no backend signature API available) + console.log('[SignatureStorage] SaaS mode - using localStorage (backend not available)'); + this.capabilities = { + supportsBackend: false, + storageType: 'localStorage', + }; + + return this.capabilities; + } + + /** + * Get current storage type + */ + async getStorageType(): Promise { + const capabilities = await this.detectCapabilities(); + return capabilities.storageType; + } + + /** + * Load all signatures + */ + async loadSignatures(): Promise { + // Clean up old blob URLs before loading new ones + this.cleanup(); + + // Always use localStorage in SaaS mode + return this._loadFromLocalStorage(); + } + + /** + * Save a signature + */ + async saveSignature(signature: SavedSignature): Promise { + // Force scope to localStorage for SaaS mode + signature.scope = 'localStorage'; + this._saveToLocalStorage(signature); + } + + /** + * Delete a signature + */ + async deleteSignature(id: string): Promise { + this._deleteFromLocalStorage(id); + } + + /** + * Update signature label + */ + async updateSignatureLabel(id: string, label: string): Promise { + this._updateLabelInLocalStorage(id, label); + } + + // LocalStorage methods + private _loadFromLocalStorage(): SavedSignature[] { + try { + const raw = localStorage.getItem(this.STORAGE_KEY); + if (!raw) return []; + const signatures = JSON.parse(raw); + // Ensure all localStorage signatures have the correct scope + return signatures.map((sig: SavedSignature) => ({ + ...sig, + scope: 'localStorage' as const, + })); + } catch { + return []; + } + } + + private _saveToLocalStorage(signature: SavedSignature): void { + const signatures = this._loadFromLocalStorage(); + const index = signatures.findIndex(s => s.id === signature.id); + + if (index >= 0) { + signatures[index] = signature; + } else { + signatures.unshift(signature); + } + + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures)); + } + + private _deleteFromLocalStorage(id: string): void { + const signatures = this._loadFromLocalStorage(); + const filtered = signatures.filter(s => s.id !== id); + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered)); + } + + private _updateLabelInLocalStorage(id: string, label: string): void { + const signatures = this._loadFromLocalStorage(); + const signature = signatures.find(s => s.id === id); + if (signature) { + signature.label = label; + signature.updatedAt = Date.now(); + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures)); + } + } + + /** + * Migrate signatures from localStorage to backend + * In SaaS mode, this is a no-op since we don't support backend storage + */ + async migrateToBackend(): Promise<{ migrated: number; failed: number }> { + console.log('[SignatureStorage] Migration not supported in SaaS mode'); + return { migrated: 0, failed: 0 }; + } + + /** + * Clean up blob URLs to prevent memory leaks + */ + cleanup(): void { + this.blobUrls.forEach(url => { + URL.revokeObjectURL(url); + }); + this.blobUrls.clear(); + } +} + +export const signatureStorageService = new SignatureStorageService(); diff --git a/frontend/src/saas/services/userManagementService.ts b/frontend/src/saas/services/userManagementService.ts new file mode 100644 index 0000000000..f72fe0696e --- /dev/null +++ b/frontend/src/saas/services/userManagementService.ts @@ -0,0 +1,272 @@ +import apiClient from '@app/services/apiClient'; +import { supabase, isSupabaseConfigured } from '@app/services/supabaseClient'; + +export interface User { + id: number; + username: string; + email?: string; + supabaseId?: string | null; + roleName: string; // Translation key like "adminUserSettings.admin" + rolesAsString?: string; // Actual role ID like "ROLE_ADMIN" + enabled: boolean; + isFirstLogin?: boolean; + authenticationType?: string; + team?: { + id: number; + name: string; + }; + createdAt?: string; + updatedAt?: string; + // Enriched client-side fields + isActive?: boolean; + lastRequest?: number; // timestamp in milliseconds +} + +export interface AdminSettingsData { + users: User[]; + userSessions: Record; + userLastRequest: Record; // username -> timestamp in milliseconds + totalUsers: number; + activeUsers: number; + disabledUsers: number; + currentUsername?: string; + roleDetails?: Record; + teams?: any[]; + maxPaidUsers?: number; + // License information + maxAllowedUsers: number; + availableSlots: number; + licenseMaxUsers: number; + premiumEnabled: boolean; +} + +export interface CreateUserRequest { + username: string; + password?: string; + role: string; + teamId?: number; + authType: 'password' | 'SSO'; + forceChange?: boolean; +} + +export interface UpdateUserRoleRequest { + username: string; + role: string; + teamId?: number; +} + +export interface InviteUsersRequest { + emails: string; // Comma-separated email addresses + role: string; + teamId?: number; +} + +export interface InviteUsersResponse { + successCount: number; + failureCount: number; + message?: string; + errors?: string; + error?: string; +} + +export interface InviteLinkRequest { + email?: string; + role: string; + teamId?: number; + expiryHours?: number; + sendEmail?: boolean; +} + +export interface InviteLinkResponse { + token: string; + inviteUrl: string; + email: string; + expiresAt: string; + expiryHours: number; + emailSent?: boolean; + emailError?: string; + error?: string; +} + +export interface InviteToken { + id: number; + email: string; + role: string; + teamId?: number; + createdBy: string; + createdAt: string; + expiresAt: string; +} + +/** + * User Management Service + * Provides functions to interact with user management backend APIs + */ +export const userManagementService = { + /** + * Get all users with session data (admin only) + */ + async getUsers(): Promise { + const response = await apiClient.get('/api/v1/proprietary/ui-data/admin-settings'); + return response.data; + }, + + /** + * Get users without a team + */ + async getUsersWithoutTeam(): Promise { + const response = await apiClient.get('/api/v1/users/without-team'); + return response.data; + }, + + /** + * Create a new user (admin only) + */ + async createUser(data: CreateUserRequest): Promise { + const formData = new FormData(); + formData.append('username', data.username); + if (data.password) { + formData.append('password', data.password); + } + formData.append('role', data.role); + if (data.teamId) { + formData.append('teamId', data.teamId.toString()); + } + formData.append('authType', data.authType); + if (data.forceChange !== undefined) { + formData.append('forceChange', data.forceChange.toString()); + } + await apiClient.post('/api/v1/user/admin/saveUser', formData, { + suppressErrorToast: true, // Component will handle error display + } as any); + }, + + /** + * Update user role and/or team (admin only) + */ + async updateUserRole(data: UpdateUserRoleRequest): Promise { + const formData = new FormData(); + formData.append('username', data.username); + formData.append('role', data.role); + if (data.teamId) { + formData.append('teamId', data.teamId.toString()); + } + await apiClient.post('/api/v1/user/admin/changeRole', formData, { + suppressErrorToast: true, + } as any); + }, + + /** + * Enable or disable a user (admin only) + */ + async toggleUserEnabled(username: string, enabled: boolean): Promise { + const formData = new FormData(); + formData.append('enabled', enabled.toString()); + await apiClient.post(`/api/v1/user/admin/changeUserEnabled/${username}`, formData, { + suppressErrorToast: true, + } as any); + }, + + /** + * Delete a user (admin only) + */ + async deleteUser(user: User, options?: { notifyUser?: boolean }): Promise { + if (isSupabaseConfigured && supabase) { + if (!user.email) { + throw new Error('Email missing for this user. Please contact support for manual removal.'); + } + + const { error } = await supabase.functions.invoke('delete-user', { + body: { + target_email: user.email, + notify_user: options?.notifyUser ?? true, + }, + }); + + if (error) { + throw new Error(error.message || 'Supabase deletion failed'); + } + return; + } + }, + + /** + * Invite users via email (admin only) + * Sends comma-separated email addresses, creates accounts with random passwords, + * and sends invitation emails + */ + async inviteUsers(data: InviteUsersRequest): Promise { + const formData = new FormData(); + formData.append('emails', data.emails); + formData.append('role', data.role); + if (data.teamId) { + formData.append('teamId', data.teamId.toString()); + } + + const response = await apiClient.post( + '/api/v1/user/admin/inviteUsers', + formData, + { + suppressErrorToast: true, // Component will handle error display + } as any + ); + + return response.data; + }, + + /** + * Generate an invite link (admin only) + */ + async generateInviteLink(data: InviteLinkRequest): Promise { + const formData = new FormData(); + // Only append email if it's provided and not empty + if (data.email && data.email.trim()) { + formData.append('email', data.email); + } + formData.append('role', data.role); + if (data.teamId) { + formData.append('teamId', data.teamId.toString()); + } + if (data.expiryHours) { + formData.append('expiryHours', data.expiryHours.toString()); + } + if (data.sendEmail !== undefined) { + formData.append('sendEmail', data.sendEmail.toString()); + } + + const response = await apiClient.post( + '/api/v1/invite/generate', + formData, + { + suppressErrorToast: true, + } as any + ); + + return response.data; + }, + + /** + * Get list of active invite links (admin only) + */ + async getInviteLinks(): Promise { + const response = await apiClient.get<{ invites: InviteToken[] }>('/api/v1/invite/list'); + return response.data.invites; + }, + + /** + * Revoke an invite link (admin only) + */ + async revokeInviteLink(inviteId: number): Promise { + await apiClient.delete(`/api/v1/invite/revoke/${inviteId}`, { + suppressErrorToast: true, + } as any); + }, + + /** + * Clean up expired invite links (admin only) + */ + async cleanupExpiredInvites(): Promise<{ deletedCount: number }> { + const response = await apiClient.post<{ deletedCount: number }>('/api/v1/invite/cleanup'); + return response.data; + }, +}; diff --git a/frontend/src/saas/services/userService.ts b/frontend/src/saas/services/userService.ts new file mode 100644 index 0000000000..6d75312a43 --- /dev/null +++ b/frontend/src/saas/services/userService.ts @@ -0,0 +1,43 @@ +/** + * User service for handling user-related API calls + */ + +const API_BASE = '/api/v1'; + +/** + * Synchronizes user upgrade from anonymous to authenticated status with the backend. + * This should be called after Supabase has successfully upgraded the user. + * Only the current user can upgrade their own account - the backend determines + * the user from the security context and derives email from SupabaseUser. + * + * @param authMethod - The authentication method used (e.g., "email", "google", "github", "apple", "azure") + * @returns Promise with the synchronization result + */ +export const synchronizeUserUpgrade = async ( + authMethod?: string +): Promise<{ + message: string; + userId: string; + email: string; +}> => { + const formData = new URLSearchParams(); + if (authMethod) { + formData.append('authMethod', authMethod); + } + + const response = await fetch(`${API_BASE}/user-role/promptToAuthUser`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + credentials: 'include', // Include cookies for authentication + body: formData.toString(), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Failed to synchronize user upgrade' })); + throw new Error(errorData.error || 'Failed to synchronize user upgrade'); + } + + return response.json(); +}; \ No newline at end of file diff --git a/frontend/src/saas/setupTests.ts b/frontend/src/saas/setupTests.ts new file mode 100644 index 0000000000..ce2532f47a --- /dev/null +++ b/frontend/src/saas/setupTests.ts @@ -0,0 +1,175 @@ +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +// Mock localStorage for tests +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { store[key] = value; }), + removeItem: vi.fn((key: string) => { delete store[key]; }), + clear: vi.fn(() => { store = {}; }), + get length() { return Object.keys(store).length; }, + key: vi.fn((index: number) => Object.keys(store)[index] || null), + }; +})(); +Object.defineProperty(global, 'localStorage', { value: localStorageMock }); + +// Mock Supabase for tests +vi.mock('@app/auth/supabase', () => ({ + supabase: { + auth: { + getSession: vi.fn().mockResolvedValue({ data: { session: null }, error: null }), + refreshSession: vi.fn().mockResolvedValue({ data: { session: null }, error: null }), + onAuthStateChange: vi.fn().mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } } }) + } + }, + debugAuthEvents: vi.fn() +})) + +// Mock i18next for tests +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { + changeLanguage: vi.fn(), + }, + }), + initReactI18next: { + type: '3rdParty', + init: vi.fn(), + }, + I18nextProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +// Mock i18next-http-backend +vi.mock('i18next-http-backend', () => ({ + default: { + type: 'backend', + init: vi.fn(), + read: vi.fn(), + save: vi.fn(), + }, +})); + +// Mock window.URL.createObjectURL and revokeObjectURL for tests +global.URL.createObjectURL = vi.fn(() => 'mocked-url'); +global.URL.revokeObjectURL = vi.fn(); + +// Mock File and Blob API methods that aren't available in jsdom +if (!globalThis.File.prototype.arrayBuffer) { + globalThis.File.prototype.arrayBuffer = function() { + // Return a simple ArrayBuffer with some mock data + const buffer = new ArrayBuffer(8); + const view = new Uint8Array(buffer); + view.set([1, 2, 3, 4, 5, 6, 7, 8]); + return Promise.resolve(buffer); + }; +} + +if (!globalThis.Blob.prototype.arrayBuffer) { + globalThis.Blob.prototype.arrayBuffer = function() { + // Return a simple ArrayBuffer with some mock data + const buffer = new ArrayBuffer(8); + const view = new Uint8Array(buffer); + view.set([1, 2, 3, 4, 5, 6, 7, 8]); + return Promise.resolve(buffer); + }; +} + +// Mock crypto.subtle for hashing in tests - force override even if exists +const mockHashBuffer = new ArrayBuffer(32); +const mockHashView = new Uint8Array(mockHashBuffer); +// Fill with predictable mock hash data +for (let i = 0; i < 32; i++) { + mockHashView[i] = i; +} + +// Force override crypto.subtle to avoid Node.js native implementation +Object.defineProperty(globalThis, 'crypto', { + value: { + subtle: { + digest: vi.fn().mockImplementation(async (_algorithm: string, _data: any) => { + // Always return the mock hash buffer regardless of input + return mockHashBuffer.slice(); + }), + }, + getRandomValues: vi.fn().mockImplementation((array: any) => { + // Mock getRandomValues if needed + for (let i = 0; i < array.length; i++) { + array[i] = Math.floor(Math.random() * 256); + } + return array; + }), + } as unknown as Crypto, + writable: true, + configurable: true, +}); + +// Mock Worker for tests (Web Workers not available in test environment) +global.Worker = vi.fn().mockImplementation(() => ({ + postMessage: vi.fn(), + terminate: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + onmessage: null, + onerror: null, +})); + +// Mock ResizeObserver for Mantine components +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +// Mock IntersectionObserver for components that might use it +global.IntersectionObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +// Mock matchMedia for responsive components +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// Mock DOMMatrix for PDF.js tests +Object.defineProperty(global, 'DOMMatrix', { + value: class MockDOMMatrix { + a = 1; b = 0; c = 0; d = 1; e = 0; f = 0; + m11 = 1; m12 = 0; m13 = 0; m14 = 0; + m21 = 0; m22 = 1; m23 = 0; m24 = 0; + m31 = 0; m32 = 0; m33 = 1; m34 = 0; + m41 = 0; m42 = 0; m43 = 0; m44 = 1; + is2D = true; + isIdentity = true; + + toString() { return 'matrix(1, 0, 0, 1, 0, 0)'; } + scale() { return this; } + translate() { return this; } + rotate() { return this; } + inverse() { return this; } + multiply() { return this; } + + static fromFloat32Array() { return new MockDOMMatrix(); } + static fromFloat64Array() { return new MockDOMMatrix(); } + static fromMatrix() { return new MockDOMMatrix(); } + }, + writable: true, + configurable: true, +}) + +// Set global test timeout to prevent hangs +vi.setConfig({ testTimeout: 5000, hookTimeout: 5000 }); diff --git a/frontend/src/saas/styles/saas-theme.css b/frontend/src/saas/styles/saas-theme.css new file mode 100644 index 0000000000..0f29bc77f7 --- /dev/null +++ b/frontend/src/saas/styles/saas-theme.css @@ -0,0 +1,150 @@ +/* SaaS-specific CSS variables — imported alongside core theme.css */ + +:root { + /* Orange scale (used for warning toasts) */ + --color-orange-50: #FFF4ED; + --color-orange-100: #FFE1CC; + --color-orange-200: #FFB089; + --color-orange-300: #FF7A45; + --color-orange-400: #D84A1B; + + /* Amber scale (trial/warning emphasis) */ + --color-amber-50: #fffbeb; + --color-amber-100: #fef3c7; + --color-amber-200: #fde68a; + --color-amber-300: #fcd34d; + --color-amber-400: #fbbf24; + --color-amber-500: #f59e0b; + --color-amber-600: #d97706; + --color-amber-700: #b45309; + --color-amber-800: #92400e; + --color-amber-900: #78350f; + + /* Subcategory / divider vars (light) */ + --tool-subcategory-text-color-light: #9CA3AF; + --tool-subcategory-rule-color-light: #E5E7EB; + --text-divider-rule-color: var(--color-gray-200); + --text-divider-label-color: var(--color-gray-400); + --text-divider-rule-color-light: var(--color-gray-200); + --text-divider-label-color-light: var(--color-gray-400); + + /* Auth color vars */ + --auth-input-bg: #f9fafb; + --auth-input-border: #e5e7eb; + --auth-input-text: #1f2937; + --auth-label-text: #2B3230; + --auth-button-bg: #AF3434; + --auth-button-text: #ffffff; + --auth-magic-button-bg: #8b5cf6; + --auth-magic-button-text: #ffffff; + + /* Light-only auth colors (no dark mode equivalents) used for login/signup */ + --auth-input-bg-light-only: #f9fafb; + --auth-input-border-light-only: #e5e7eb; + --auth-input-text-light-only: #1f2937; + --auth-label-text-light-only: #2B3230; + --auth-button-bg-light-only: #AF3434; + --auth-button-text-light-only: #ffffff; + --auth-magic-button-bg-light-only: #8b5cf6; + --auth-magic-button-text-light-only: #ffffff; + --auth-bg-color-light-only: #ffffff; + --auth-card-bg-light-only: #ffffff; + --auth-text-primary-light-only: #2B3230; + --auth-text-secondary-light-only: #1f2937; + --auth-border-color-light-only: #e5e7eb; + --auth-border-focus-light-only: #cbd5e1; + --auth-focus-ring-light-only: rgba(59, 130, 246, 0.15); + + /* App Config Modal colors (light mode) */ + --modal-nav-bg: #F5F6F8; + --modal-nav-section-title: #6B7280; + --modal-nav-item: #374151; + --modal-nav-item-active: #0A8BFF; + --modal-nav-item-active-bg: rgba(10, 139, 255, 0.08); + --modal-content-bg: #ffffff; + --modal-header-border: rgba(0, 0, 0, 0.06); + + /* API usage progress bar colors (light mode) */ + --usage-weekly-active: #3B82F6; + --usage-bought-active: #14B8A6; + --usage-total-used: #3B82F6; + --usage-inactive: #E5E7EB; + + /* API Keys section colors (light mode) */ + --api-keys-card-bg: #ffffff; + --api-keys-card-border: #e0e0e0; + --api-keys-card-shadow: rgba(0, 0, 0, 0.06); + --api-keys-input-bg: #f8f8f8; + --api-keys-input-border: #e0e0e0; + --api-keys-button-bg: #f5f5f5; + --api-keys-button-color: #333333; +} + +[data-mantine-color-scheme="dark"] { + /* Compare highlight colors (dark mode) */ + --spdf-compare-removed-bg: rgba(255, 107, 107, 0.45); + --spdf-compare-added-bg: rgba(81, 207, 102, 0.35); + --spdf-compare-removed-badge-bg: rgba(255, 59, 48, 0.15); + --spdf-compare-removed-badge-fg: var(--color-red-500); + --spdf-compare-added-badge-bg: rgba(52, 199, 89, 0.18); + --spdf-compare-added-badge-fg: var(--color-green-500); + --spdf-compare-inline-removed-bg: rgba(255, 59, 48, 0.25); + --spdf-compare-inline-added-bg: rgba(52, 199, 89, 0.25); + --compare-page-label-bg: #2A2F36; + --compare-page-label-fg: #D0D6DC; + + /* Orange scale (dark mode mirrors light values to match UI) */ + --color-orange-50: #FFF4ED; + --color-orange-100: #FFE1CC; + --color-orange-200: #FFB089; + --color-orange-300: #FF7A45; + --color-orange-400: #D84A1B; + + /* Auth page colors (light mode only - auth pages force light mode) */ + --auth-bg-color-light-only: #f3f4f6; + --auth-card-bg: #ffffff; + --auth-card-bg-light-only: #ffffff; + --auth-label-text-light-only: #374151; + --auth-input-border-light-only: #d1d5db; + --auth-input-bg-light-only: #ffffff; + --auth-input-text-light-only: #111827; + --auth-border-focus-light-only: #3b82f6; + --auth-focus-ring-light-only: rgba(59, 130, 246, 0.1); + --auth-button-bg-light-only: #AF3434; + --auth-button-text-light-only: #ffffff; + --auth-magic-button-bg-light-only: #e5e7eb; + --auth-magic-button-text-light-only: #374151; + --auth-text-primary-light-only: #111827; + --auth-text-secondary-light-only: #6b7280; + --text-divider-rule-rgb-light: 229, 231, 235; + --text-divider-label-rgb-light: 156, 163, 175; + --tool-subcategory-rule-color-light: #e5e7eb; + --tool-subcategory-text-color-light: #9ca3af; + + /* API usage progress bar colors (dark mode) */ + --usage-weekly-active: #60A5FA; + --usage-bought-active: #34D399; + --usage-total-used: #FFFFFF; + --usage-inactive: #43464B; + + /* API Keys section colors (dark mode) */ + --api-keys-card-bg: #2A2F36; + --api-keys-card-border: #3A4047; + --api-keys-card-shadow: none; + --api-keys-input-bg: #1F2329; + --api-keys-input-border: #3A4047; + --api-keys-button-bg: #3A4047; + --api-keys-button-color: #D0D6DC; + + --text-divider-rule-color: var(--tool-subcategory-rule-color); + --text-divider-label-color: var(--text-muted); + + /* App Config Modal colors (dark mode) */ + --modal-nav-bg: #1F2329; + --modal-nav-section-title: #9CA3AF; + --modal-nav-item: #D0D6DC; + --modal-nav-item-active: #0A8BFF; + --modal-nav-item-active-bg: rgba(10, 139, 255, 0.15); + --modal-content-bg: #2A2F36; + --modal-header-border: rgba(255, 255, 255, 0.08); +} diff --git a/frontend/src/saas/styles/zIndex.ts b/frontend/src/saas/styles/zIndex.ts new file mode 100644 index 0000000000..69f464d951 --- /dev/null +++ b/frontend/src/saas/styles/zIndex.ts @@ -0,0 +1,12 @@ +// Centralized z-index constants for new usages added in this branch. +// Keep values identical to their original inline usages. + +// Re-export all core z-index constants +export * from '@core/styles/zIndex'; + +// SaaS-specific z-index constants +export const Z_ANALYTICS_MODAL = 1301; +export const Z_INDEX_OVER_SETTINGS_MODAL = 1400; + + + diff --git a/frontend/tsconfig.proprietary.json b/frontend/src/saas/tsconfig.json similarity index 62% rename from frontend/tsconfig.proprietary.json rename to frontend/src/saas/tsconfig.json index 45c1347f4a..a18cc0e492 100644 --- a/frontend/tsconfig.proprietary.json +++ b/frontend/src/saas/tsconfig.json @@ -1,25 +1,26 @@ { - "extends": "./tsconfig.json", + "extends": "../../tsconfig.json", "compilerOptions": { + "baseUrl": "../../", "paths": { "@app/*": [ + "src/saas/*", "src/proprietary/*", "src/core/*" ], - "@core/*": [ - "src/core/*" - ], "@proprietary/*": [ "src/proprietary/*" + ], + "@core/*": [ + "src/core/*" ] } }, "include": [ - "src/global.d.ts", - "src/*.js", - "src/*.ts", - "src/*.tsx", - "src/core/setupTests.ts", - "src/proprietary" + "../global.d.ts", + "../*.js", + "../*.ts", + "../*.tsx", + "." ] } diff --git a/frontend/src/saas/types/charts.ts b/frontend/src/saas/types/charts.ts new file mode 100644 index 0000000000..fae648f39c --- /dev/null +++ b/frontend/src/saas/types/charts.ts @@ -0,0 +1,30 @@ +export interface FractionData { + name: string; + numerator: number; + denominator: number; + numeratorLabel: string; + denominatorLabel: string; + color: string; +} + +export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'; + +export interface StackedBarChartProps { + fractions: FractionData[]; + width?: number; + height?: number; + showLegend?: boolean; + className?: string; + tooltipPosition?: TooltipPosition; + loading?: boolean; + animate?: boolean; + animationDurationMs?: number; + ariaLabel?: string; +} + +export interface TooltipData { + fractions: FractionData[]; + isDark: boolean; +} + + diff --git a/frontend/src/saas/types/credits.ts b/frontend/src/saas/types/credits.ts new file mode 100644 index 0000000000..7c6b77ccda --- /dev/null +++ b/frontend/src/saas/types/credits.ts @@ -0,0 +1,37 @@ +export interface ApiCredits { + weeklyCreditsRemaining: number; + weeklyCreditsAllocated: number; + boughtCreditsRemaining: number; + totalBoughtCredits: number; + totalAvailableCredits: number; + weeklyResetDate: string; + lastApiUsage: string; +} + +export interface CreditSummary { + currentCredits: number; + maxCredits: number; + creditsUsed: number; + creditsRemaining: number; + resetDate: string; // ISO date string + weeklyAllowance: number; +} + +export interface SubscriptionInfo { + id?: string; + status: 'active' | 'inactive' | 'cancelled' | 'expired'; + tier: 'free' | 'basic' | 'premium' | 'enterprise'; + startDate?: string; // ISO date string + endDate?: string; // ISO date string + creditsPerWeek?: number; + maxCredits?: number; +} + +export interface CreditCheckResult { + hasSufficientCredits: boolean; + currentBalance: number; + requiredCredits: number; + shortfall?: number; +} + + diff --git a/frontend/src/saas/types/stripe.ts b/frontend/src/saas/types/stripe.ts new file mode 100644 index 0000000000..8c7a81f5e0 --- /dev/null +++ b/frontend/src/saas/types/stripe.ts @@ -0,0 +1,67 @@ +// Stripe API Types + +export interface CreateCheckoutSessionRequest { + planId: string; + planName: string; + planPrice: number; + currency: string; + successUrl: string; + cancelUrl: string; +} + +export interface CreateCheckoutSessionResponse { + clientSecret: string; + sessionId: string; +} + +export interface StripeWebhookEvent { + id: string; + type: string; + data: { + object: { + id: string; + payment_status: 'paid' | 'unpaid'; + customer_details?: { + email: string; + name?: string; + }; + metadata?: Record; + }; + }; +} + +export interface PaymentSuccessData { + sessionId: string; + planId: string; + customerId: string; + paymentIntentId: string; + amountTotal: number; + currency: string; +} + +export interface ApiPackagePurchaseRequest { + packageId: string; + packageName: string; + packagePrice: number; + credits: number; + currency: string; + successUrl: string; + cancelUrl: string; +} + +// Error responses +export interface StripeApiError { + error: string; + message: string; + code?: string; +} + +// Webhook event types that the backend should handle +export type StripeWebhookEventType = + | 'checkout.session.completed' + | 'checkout.session.expired' + | 'payment_intent.succeeded' + | 'payment_intent.payment_failed' + | 'customer.subscription.created' + | 'customer.subscription.updated' + | 'customer.subscription.deleted'; \ No newline at end of file diff --git a/frontend/src/saas/utils/appSettings.ts b/frontend/src/saas/utils/appSettings.ts new file mode 100644 index 0000000000..d06b7a72f4 --- /dev/null +++ b/frontend/src/saas/utils/appSettings.ts @@ -0,0 +1,26 @@ +// Utility helpers to open the settings/config modal programmatically +// and optionally navigate to a specific section (e.g., 'plan'). + +import type { NavKey } from '@app/components/shared/config/types'; + +export function openAppSettings(targetKey?: NavKey, notice?: string) { + try { + const detail: { key?: NavKey; notice?: string } = {}; + if (targetKey) detail.key = targetKey; + if (notice) detail.notice = notice; + // Ask the UI to open the App Config modal + window.dispatchEvent(new CustomEvent('appConfig:open', { detail })); + // If a specific section is requested, navigate there once modal mounts + if (targetKey) { + window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: targetKey } })); + } + } catch (_e) { + // no-op on SSR or test environments + } +} + +export function openPlanSettings(notice?: string) { + openAppSettings('plan', notice); +} + + diff --git a/frontend/src/saas/utils/creditCosts.ts b/frontend/src/saas/utils/creditCosts.ts new file mode 100644 index 0000000000..d0b2acae0b --- /dev/null +++ b/frontend/src/saas/utils/creditCosts.ts @@ -0,0 +1,92 @@ +import { ToolId } from '@app/types/toolId' + +// Credit costs based on ResourceWeight enum from backend +export const CREDIT_COSTS = { + NONE: 0, + SMALL: 1, + MEDIUM: 3, + LARGE: 5, + XLARGE: 10 +} as const + +/** + * Mapping of tool IDs to their credit costs + * Based on backend ResourceWeight annotations + */ +export const TOOL_CREDIT_COSTS: Record = { + // No cost operations (0 credits) + showJS: CREDIT_COSTS.NONE, + devApi: CREDIT_COSTS.NONE, + devFolderScanning: CREDIT_COSTS.NONE, + devSsoGuide: CREDIT_COSTS.NONE, + devAirgapped: CREDIT_COSTS.NONE, + + // Small operations (1 credit) + rotate: CREDIT_COSTS.SMALL, + removePages: CREDIT_COSTS.SMALL, + addText: CREDIT_COSTS.SMALL, + addPassword: CREDIT_COSTS.SMALL, + removePassword: CREDIT_COSTS.SMALL, + changePermissions: CREDIT_COSTS.SMALL, + flatten: CREDIT_COSTS.SMALL, + repair: CREDIT_COSTS.SMALL, + unlockPDFForms: CREDIT_COSTS.SMALL, + crop: CREDIT_COSTS.SMALL, + addPageNumbers: CREDIT_COSTS.SMALL, + extractPages: CREDIT_COSTS.SMALL, + reorganizePages: CREDIT_COSTS.SMALL, + scalePages: CREDIT_COSTS.SMALL, + editTableOfContents: CREDIT_COSTS.SMALL, + sign: CREDIT_COSTS.SMALL, + annotate: CREDIT_COSTS.SMALL, + formFill: CREDIT_COSTS.SMALL, + removeAnnotations: CREDIT_COSTS.SMALL, + removeImage: CREDIT_COSTS.SMALL, + scannerImageSplit: CREDIT_COSTS.SMALL, + adjustContrast: CREDIT_COSTS.SMALL, + multiTool: CREDIT_COSTS.SMALL, + compare: CREDIT_COSTS.SMALL, + addAttachments: CREDIT_COSTS.SMALL, + getPdfInfo: CREDIT_COSTS.MEDIUM, + validateSignature: CREDIT_COSTS.SMALL, + read: CREDIT_COSTS.SMALL, + + // Medium operations (3 credits) + split: CREDIT_COSTS.MEDIUM, + merge: CREDIT_COSTS.MEDIUM, + pdfTextEditor: CREDIT_COSTS.MEDIUM, + changeMetadata: CREDIT_COSTS.MEDIUM, + watermark: CREDIT_COSTS.MEDIUM, + bookletImposition: CREDIT_COSTS.MEDIUM, + pdfToSinglePage: CREDIT_COSTS.MEDIUM, + removeBlanks: CREDIT_COSTS.MEDIUM, + autoRename: CREDIT_COSTS.MEDIUM, + sanitize: CREDIT_COSTS.MEDIUM, + addImage: CREDIT_COSTS.MEDIUM, + addStamp: CREDIT_COSTS.MEDIUM, + extractImages: CREDIT_COSTS.MEDIUM, + overlayPdfs: CREDIT_COSTS.MEDIUM, + pageLayout: CREDIT_COSTS.MEDIUM, + redact: CREDIT_COSTS.MEDIUM, + removeCertSign: CREDIT_COSTS.MEDIUM, + scannerEffect: CREDIT_COSTS.MEDIUM, + replaceColor: CREDIT_COSTS.MEDIUM, + + // Large operations (5 credits) + compress: CREDIT_COSTS.LARGE, + convert: CREDIT_COSTS.LARGE, + ocr: CREDIT_COSTS.LARGE, + certSign: CREDIT_COSTS.LARGE, + + // Extra large operations (10 credits) + automate: CREDIT_COSTS.XLARGE, +} + +/** + * Get the credit cost for a specific tool + * @param toolId - The tool identifier + * @returns The credit cost for the tool, defaults to MEDIUM if not found + */ +export const getToolCreditCost = (toolId: ToolId): number => { + return TOOL_CREDIT_COSTS[toolId] ?? CREDIT_COSTS.MEDIUM +} diff --git a/frontend/src/saas/utils/cropImage.ts b/frontend/src/saas/utils/cropImage.ts new file mode 100644 index 0000000000..2b6957916b --- /dev/null +++ b/frontend/src/saas/utils/cropImage.ts @@ -0,0 +1,84 @@ +/** + * Crops an image based on the provided pixel crop area using HTML5 Canvas API. + * Returns a PNG blob ready for upload. + */ + +export interface Area { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Creates a cropped image blob from the source image and crop area. + * + * @param imageSrc - Data URL or blob URL of the source image + * @param pixelCrop - Pixel coordinates and dimensions of the crop area + * @returns Promise that resolves to a PNG Blob of the cropped image + */ +export async function getCroppedImage( + imageSrc: string, + pixelCrop: Area +): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + + image.onload = () => { + try { + // Create canvas with crop dimensions + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('Failed to get canvas context')); + return; + } + + // Set canvas size to crop dimensions + canvas.width = pixelCrop.width; + canvas.height = pixelCrop.height; + + // Draw the cropped region + // drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh) + // sx, sy: source x, y coordinates + // sw, sh: source width, height + // dx, dy: destination x, y coordinates (0, 0 for top-left) + // dw, dh: destination width, height + ctx.drawImage( + image, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height + ); + + // Convert canvas to PNG blob + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Failed to create blob from canvas')); + return; + } + resolve(blob); + }, + 'image/png', + 1.0 // Maximum quality + ); + } catch (error) { + reject(error); + } + }; + + image.onerror = () => { + reject(new Error('Failed to load image')); + }; + + // Start loading the image + image.src = imageSrc; + }); +} diff --git a/frontend/src/saas/utils/pathUtils.ts b/frontend/src/saas/utils/pathUtils.ts new file mode 100644 index 0000000000..46daa5cadb --- /dev/null +++ b/frontend/src/saas/utils/pathUtils.ts @@ -0,0 +1,47 @@ +import { URL_TO_TOOL_MAP } from '@app/utils/urlMapping' + +const SUBPATH = (import.meta.env.VITE_RUN_SUBPATH || '').replace(/^\/|\/$/g, '') // "app" or "" + +/** + * Normalize pathname by stripping subpath prefix and trailing slashes + */ +export function normalizePath(pathname: string): string { + // Ensure leading slash, strip subpath prefix if configured + let p = pathname.startsWith('/') ? pathname : `/${pathname}` + if (SUBPATH && p.startsWith(`/${SUBPATH}/`)) { + p = p.slice(SUBPATH.length + 1) // remove "/app" + } else if (SUBPATH && p === `/${SUBPATH}`) { + p = '/' + } + // Strip trailing slash except root + if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1) + return p +} + +/** + * Check if pathname is an auth route + */ +export function isAuthRoute(pathname: string): boolean { + const p = normalizePath(pathname) + return p === '/login' || p === '/signup' || p === '/auth/callback' +} + +/** + * Check if pathname is home route + */ +export function isHomeRoute(pathname: string): boolean { + return normalizePath(pathname) === '/' +} + +/** + * Check if pathname is a tool route + */ +export function isToolRoute(pathname: string): boolean { + const p = normalizePath(pathname) + // direct match or try without trailing slash variants if your map uses them + if (URL_TO_TOOL_MAP[p] !== undefined) return true + // Fallback: try adding/removing trailing slash + if (URL_TO_TOOL_MAP[`${p}/`] !== undefined) return true + if (p.endsWith('/') && URL_TO_TOOL_MAP[p.slice(0, -1)] !== undefined) return true + return false +} \ No newline at end of file diff --git a/frontend/tsconfig.core.json b/frontend/tsconfig.core.json deleted file mode 100644 index 8574561a09..0000000000 --- a/frontend/tsconfig.core.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "paths": { - "@app/*": [ - "src/core/*" - ], - "@core/*": [ - "src/core/*" - ], - "@proprietary/*": [ - "src/core/*" - ] - } - }, - "include": [ - "src/global.d.ts", - "src/*.js", - "src/*.ts", - "src/*.tsx", - "src/core" - ] -} diff --git a/frontend/tsconfig.saas.vite.json b/frontend/tsconfig.saas.vite.json new file mode 100644 index 0000000000..843368ca75 --- /dev/null +++ b/frontend/tsconfig.saas.vite.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@app/*": [ + "src/saas/*", + "src/proprietary/*", + "src/core/*" + ], + "@proprietary/*": ["src/proprietary/*"], + "@core/*": ["src/core/*"] + } + }, + "exclude": [ + "src/core/**/*.test.ts*", + "src/core/**/*.spec.ts*", + "src/proprietary/**/*.test.ts*", + "src/proprietary/**/*.spec.ts*", + "src/desktop", + "node_modules" + ] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 02fc41a8ca..5670928060 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,7 +2,16 @@ import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; import { viteStaticCopy } from 'vite-plugin-static-copy'; -import path from 'path'; + +const VALID_MODES = ['core', 'proprietary', 'saas', 'desktop'] as const; +type BuildMode = typeof VALID_MODES[number]; + +const TSCONFIG_MAP: Record = { + core: './tsconfig.core.vite.json', + proprietary: './tsconfig.proprietary.vite.json', + saas: './tsconfig.saas.vite.json', + desktop: './tsconfig.desktop.vite.json', +}; export default defineConfig(({ mode }) => { @@ -11,15 +20,15 @@ export default defineConfig(({ mode }) => { // `VITE_` prefix. const env = loadEnv(mode, process.cwd(), '') - // When DISABLE_ADDITIONAL_FEATURES is false (or unset), enable proprietary features - const isProprietary = process.env.DISABLE_ADDITIONAL_FEATURES !== 'true'; - const isDesktopMode = - mode === 'desktop' || - env.STIRLING_DESKTOP === 'true' || - env.VITE_DESKTOP === 'true'; + // Resolve the effective build mode. + // Explicit --mode flags take precedence; otherwise default to proprietary + // unless DISABLE_ADDITIONAL_FEATURES=true, in which case default to core. + const effectiveMode: BuildMode = (VALID_MODES as readonly string[]).includes(mode) + ? (mode as BuildMode) + : process.env.DISABLE_ADDITIONAL_FEATURES === 'true' ? 'core' : 'proprietary'; // Validate required environment variables for desktop builds - if (isDesktopMode) { + if (effectiveMode === 'desktop') { const requiredEnvVars = [ 'VITE_SAAS_SERVER_URL', 'VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY', @@ -35,9 +44,7 @@ export default defineConfig(({ mode }) => { } } - const baseProject = isProprietary ? './tsconfig.proprietary.vite.json' : './tsconfig.core.vite.json'; - const desktopProject = isProprietary ? './tsconfig.desktop.vite.json' : baseProject; - const tsconfigProject = isDesktopMode ? desktopProject : baseProject; + const tsconfigProject = TSCONFIG_MAP[effectiveMode]; return { plugins: [ @@ -71,7 +78,7 @@ export default defineConfig(({ mode }) => { ignored: ['**/src-tauri/**'], }, // Only use proxy in web mode - Tauri handles backend connections directly - proxy: isDesktopMode ? undefined : { + proxy: effectiveMode === 'desktop' ? undefined : { '/api': { target: 'http://localhost:8080', changeOrigin: true, @@ -117,10 +124,5 @@ export default defineConfig(({ mode }) => { }, }, base: env.RUN_SUBPATH ? `/${env.RUN_SUBPATH}` : './', - resolve: { - alias: { - 'posthog-js/react': path.resolve(__dirname, 'node_modules/posthog-js/react/dist/esm/index.js'), - }, - }, }; }); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 6bfdf92dcb..63fef12cfb 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -20,6 +20,8 @@ export default defineConfig({ exclude: [ 'node_modules/', 'src/core/setupTests.ts', + 'src/proprietary/setupTests.ts', + 'src/saas/setupTests.ts', '**/*.d.ts', 'src/tests/test-fixtures/**', 'src/**/*.spec.ts' @@ -80,6 +82,24 @@ export default defineConfig({ target: 'es2020' } }, + { + test: { + name: 'saas', + include: ['src/saas/**/*.test.{ts,tsx}'], + environment: 'jsdom', + globals: true, + setupFiles: ['./src/saas/setupTests.ts'], + }, + plugins: [ + react(), + tsconfigPaths({ + projects: ['./tsconfig.saas.vite.json'], + }), + ], + esbuild: { + target: 'es2020' + } + }, ], }, esbuild: {