Merge branch 'V2' of https://github.com/Stirling-Tools/Stirling-PDF into chore/v2/translation-fixes
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
#############################################################################################################
|
||||
|
||||
security:
|
||||
enableLogin: false # set to 'true' to enable login
|
||||
enableLogin: true # set to 'true' to enable login
|
||||
csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production)
|
||||
loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1
|
||||
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
|
||||
|
||||
+33
@@ -120,6 +120,37 @@ public class ProprietaryUIDataController {
|
||||
// Add enableLogin flag so frontend doesn't need to call /app-config
|
||||
data.setEnableLogin(securityProps.getEnableLogin());
|
||||
|
||||
// Check if this is first-time setup with default credentials
|
||||
// The isFirstLogin flag captures: default username/password usage and unchanged state
|
||||
boolean isFirstTimeSetup = false;
|
||||
boolean showDefaultCredentials = false;
|
||||
|
||||
List<User> allUsers = userRepository.findAll();
|
||||
List<User> realUsers =
|
||||
allUsers.stream()
|
||||
.filter(
|
||||
user ->
|
||||
!Role.INTERNAL_API_USER
|
||||
.getRoleId()
|
||||
.equals(user.getUsername()))
|
||||
.toList();
|
||||
long userCount = realUsers.size();
|
||||
|
||||
if (userCount == 0) {
|
||||
isFirstTimeSetup = true;
|
||||
showDefaultCredentials = true;
|
||||
} else if (userCount == 1) {
|
||||
Optional<User> adminUser = userRepository.findByUsernameIgnoreCase("admin");
|
||||
|
||||
if (adminUser.isPresent() && Boolean.TRUE.equals(adminUser.get().getIsFirstLogin())) {
|
||||
isFirstTimeSetup = true;
|
||||
showDefaultCredentials = true;
|
||||
}
|
||||
}
|
||||
|
||||
data.setFirstTimeSetup(isFirstTimeSetup);
|
||||
data.setShowDefaultCredentials(showDefaultCredentials);
|
||||
|
||||
OAUTH2 oauth = securityProps.getOauth2();
|
||||
|
||||
if (oauth != null && oauth.getEnabled()) {
|
||||
@@ -456,6 +487,8 @@ public class ProprietaryUIDataController {
|
||||
private Map<String, String> providerList;
|
||||
private String loginMethod;
|
||||
private boolean altLogin;
|
||||
private boolean firstTimeSetup;
|
||||
private boolean showDefaultCredentials;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3517,6 +3517,8 @@
|
||||
"accountCreatedSuccess": "Account created successfully! You can now sign in.",
|
||||
"passwordChangedSuccess": "Password changed successfully! Please sign in with your new password.",
|
||||
"credentialsUpdated": "Your credentials have been updated. Please sign in again.",
|
||||
"defaultCredentials": "Default Login Credentials",
|
||||
"changePasswordWarning": "Please change your password after logging in for the first time",
|
||||
"slides": {
|
||||
"overview": {
|
||||
"alt": "Stirling PDF overview",
|
||||
@@ -5580,6 +5582,7 @@
|
||||
},
|
||||
"payment": {
|
||||
"preparing": "Preparing your checkout...",
|
||||
"redirecting": "Redirecting to secure checkout...",
|
||||
"upgradeTitle": "Upgrade to {{planName}}",
|
||||
"success": "Payment Successful!",
|
||||
"successMessage": "Your subscription has been activated successfully. You will receive a confirmation email shortly.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
|
||||
return (
|
||||
<div className={styles.heroIconsContainer}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<img src={`${BASE_PATH}/branding/StirlingLogoLegacy.svg`} alt="Stirling icon" className={styles.downloadIcon} />
|
||||
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
ONBOARDING_SESSION_BLOCK_KEY,
|
||||
ONBOARDING_SESSION_EVENT,
|
||||
@@ -13,6 +13,15 @@ import {
|
||||
} from '@app/constants/events';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
|
||||
// Auth routes where onboarding should NOT show
|
||||
const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];
|
||||
|
||||
// Check if user has an auth token (to avoid flash before redirect)
|
||||
function hasAuthToken(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return !!localStorage.getItem('stirling_jwt');
|
||||
}
|
||||
|
||||
interface InitialModalHandlers {
|
||||
opened: boolean;
|
||||
onLicenseNoticeUpdate: (notice: LicenseNotice) => void;
|
||||
@@ -29,11 +38,30 @@ interface ServerLicenseModalHandlers {
|
||||
|
||||
export function useOnboardingFlow() {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config } = useAppConfig();
|
||||
const { config, loading: configLoading } = useAppConfig();
|
||||
const { showCookieConsent, isReady: isCookieConsentReady } = useCookieConsentContext();
|
||||
const { completeTour, tourType, isOpen } = useOnboarding();
|
||||
const location = useLocation();
|
||||
|
||||
const shouldShowIntro = !preferences.hasSeenIntroOnboarding;
|
||||
// Check if we're on an auth route (login, signup, etc.)
|
||||
const isOnAuthRoute = AUTH_ROUTES.some(route => location.pathname.startsWith(route));
|
||||
|
||||
// Check if login is enabled but user doesn't have a token
|
||||
// This prevents a flash of the modal before redirect to /login
|
||||
const loginEnabled = config?.enableLogin === true;
|
||||
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
|
||||
|
||||
// Don't show intro onboarding:
|
||||
// 1. On explicit auth routes (/login, /signup, etc.)
|
||||
// 2. While config is still loading
|
||||
// 3. When login is enabled but user isn't authenticated (would redirect to /login)
|
||||
// This ensures:
|
||||
// - If login is enabled: user must be logged in before seeing onboarding
|
||||
// - If login is disabled: homepage must have rendered first
|
||||
const shouldShowIntro = !preferences.hasSeenIntroOnboarding
|
||||
&& !isOnAuthRoute
|
||||
&& !configLoading
|
||||
&& !isUnauthenticatedWithLoginEnabled;
|
||||
const isAdminUser = !!config?.isAdmin;
|
||||
const { hasPaidLicense } = useServerExperience();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://rficokptxxxxtyzcvgmx.supabase.co';
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb';
|
||||
|
||||
// Check if Supabase is configured
|
||||
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey);
|
||||
|
||||
@@ -8,4 +8,4 @@ export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL ||
|
||||
|
||||
// Supabase publishable key from environment variable
|
||||
// Used for SaaS authentication
|
||||
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || '';
|
||||
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb';
|
||||
|
||||
@@ -2,9 +2,9 @@ import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/compo
|
||||
import { AuthProvider } from "@app/auth/UseSession";
|
||||
import { LicenseProvider } from "@app/contexts/LicenseContext";
|
||||
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
|
||||
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext"
|
||||
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
|
||||
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
|
||||
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
|
||||
|
||||
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
|
||||
return (
|
||||
|
||||
+21
-5
@@ -54,6 +54,7 @@ export default function AdminGeneralSection() {
|
||||
const [originalSettingsSnapshot, setOriginalSettingsSnapshot] = useState<string>('');
|
||||
const [isDirty, setLocalIsDirty] = useState(false);
|
||||
const isInitialLoad = useRef(true);
|
||||
const justSavedRef = useRef(false);
|
||||
|
||||
const {
|
||||
settings,
|
||||
@@ -158,9 +159,12 @@ export default function AdminGeneralSection() {
|
||||
}
|
||||
}, [loginEnabled, fetchSettings]);
|
||||
|
||||
// Snapshot original settings after initial load and sync local preference with server
|
||||
// Snapshot original settings after initial load OR after successful save (when refetch completes)
|
||||
useEffect(() => {
|
||||
if (!loading && isInitialLoad.current && Object.keys(settings).length > 0) {
|
||||
if (loading || Object.keys(settings).length === 0) return;
|
||||
|
||||
// After initial load: set snapshot and sync preference
|
||||
if (isInitialLoad.current) {
|
||||
setOriginalSettingsSnapshot(JSON.stringify(settings));
|
||||
|
||||
// Sync local preference with server setting on initial load to ensure they're in sync
|
||||
@@ -170,8 +174,17 @@ export default function AdminGeneralSection() {
|
||||
}
|
||||
|
||||
isInitialLoad.current = false;
|
||||
return;
|
||||
}
|
||||
}, [loading, settings, loginEnabled, updatePreference]);
|
||||
|
||||
// After save: update snapshot to new server state so dirty tracking is accurate
|
||||
if (justSavedRef.current) {
|
||||
setOriginalSettingsSnapshot(JSON.stringify(settings));
|
||||
setLocalIsDirty(false);
|
||||
setIsDirty(false);
|
||||
justSavedRef.current = false;
|
||||
}
|
||||
}, [loading, settings, loginEnabled, updatePreference, setIsDirty]);
|
||||
|
||||
// Track dirty state by comparing current settings to snapshot
|
||||
useEffect(() => {
|
||||
@@ -238,6 +251,9 @@ export default function AdminGeneralSection() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Mark that we just saved - the snapshot will be updated when refetch completes
|
||||
justSavedRef.current = true;
|
||||
|
||||
await saveSettings();
|
||||
|
||||
// Update local preference after successful save so the app reflects the saved logo style
|
||||
@@ -245,12 +261,12 @@ export default function AdminGeneralSection() {
|
||||
updatePreference('logoVariant', settings.ui.logoStyle);
|
||||
}
|
||||
|
||||
// Update snapshot to current settings after successful save
|
||||
setOriginalSettingsSnapshot(JSON.stringify(settings));
|
||||
// Clear dirty state immediately (snapshot will be updated by effect when refetch completes)
|
||||
setLocalIsDirty(false);
|
||||
markClean();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
justSavedRef.current = false;
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
|
||||
+4
-3
@@ -31,9 +31,9 @@ const AdminPlanSection: React.FC = () => {
|
||||
|
||||
// Check if we should use static version
|
||||
useEffect(() => {
|
||||
// Check if Stripe is configured
|
||||
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!stripeKey || !isSupabaseConfigured || error) {
|
||||
// Only use static version if Supabase is not configured or there's an error
|
||||
// Stripe key is not required - hosted checkout works without it
|
||||
if (!isSupabaseConfigured || error) {
|
||||
setUseStaticVersion(true);
|
||||
}
|
||||
}, [error]);
|
||||
@@ -222,6 +222,7 @@ const AdminPlanSection: React.FC = () => {
|
||||
buttonColor="orange.7"
|
||||
/>
|
||||
)}
|
||||
|
||||
<AvailablePlansSection
|
||||
plans={plans}
|
||||
currentLicenseInfo={licenseInfo}
|
||||
|
||||
-40
@@ -101,46 +101,6 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{/* Current Plan Section */}
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('plan.activePlan.title', 'Active Plan')}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.25rem 0 1rem 0',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{t('plan.activePlan.subtitle', 'Your current subscription details')}
|
||||
</p>
|
||||
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap="xs">
|
||||
<Group gap="sm">
|
||||
<Text size="lg" fw={600}>
|
||||
{currentPlan.name}
|
||||
</Text>
|
||||
<Badge color="green" variant="light">
|
||||
{t('subscription.status.active', 'Active')}
|
||||
</Badge>
|
||||
</Group>
|
||||
{currentLicenseInfo && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.static.maxUsers', 'Max Users')}: {currentLicenseInfo.maxUsers}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Text size="xl" fw={700}>
|
||||
{currentPlan.price === 0 ? t('plan.free.name', 'Free') : `${currentPlan.currency}${currentPlan.price}${currentPlan.period}`}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Available Plans */}
|
||||
<div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Modal, Text, Alert, Stack, Button, Group, ActionIcon } from '@mantine/core';
|
||||
import { Modal, Text, Group, ActionIcon } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import licenseService from '@app/services/licenseService';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
@@ -20,7 +19,7 @@ import { SuccessStage } from '@app/components/shared/stripeCheckout/stages/Succe
|
||||
import { ErrorStage } from '@app/components/shared/stripeCheckout/stages/ErrorStage';
|
||||
|
||||
// Validate Stripe key (static validation, no dynamic imports)
|
||||
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || 'pk_live_51Q56W2P9mY5IAnSnp3kcxG50uyFMLuhM4fFs774DAP3t88KmlwUrUo31CecpnAZ9FHsNp8xJyOnYNYNVVP6z4oi500q5sFYPEp';
|
||||
|
||||
if (!STRIPE_KEY) {
|
||||
console.error(
|
||||
@@ -37,8 +36,6 @@ if (STRIPE_KEY && !STRIPE_KEY.startsWith('pk_')) {
|
||||
);
|
||||
}
|
||||
|
||||
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
|
||||
|
||||
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
@@ -192,25 +189,8 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
|
||||
// Render stage content
|
||||
const renderContent = () => {
|
||||
// Check if Stripe is configured
|
||||
if (!stripePromise) {
|
||||
return (
|
||||
<Alert color="red" title={t('payment.stripeNotConfigured', 'Stripe Not Configured')}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'payment.stripeNotConfiguredMessage',
|
||||
'Stripe payment integration is not configured. Please contact your administrator.'
|
||||
)}
|
||||
</Text>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('common.close', 'Close')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't block checkout - hosted mode works without publishable key
|
||||
// The checkout will automatically redirect to Stripe hosted page if key is missing
|
||||
switch (checkoutState.state.currentStage) {
|
||||
case 'email':
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe
|
||||
import { PlanTier } from '@app/services/licenseService';
|
||||
|
||||
// Load Stripe once
|
||||
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || 'pk_live_51Q56W2P9mY5IAnSnp3kcxG50uyFMLuhM4fFs774DAP3t88KmlwUrUo31CecpnAZ9FHsNp8xJyOnYNYNVVP6z4oi500q5sFYPEp';
|
||||
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
|
||||
|
||||
interface PaymentStageProps {
|
||||
@@ -35,10 +35,15 @@ export const PaymentStage: React.FC<PaymentStageProps> = ({
|
||||
}
|
||||
|
||||
if (!stripePromise) {
|
||||
// This should only happen if embedded mode was attempted without key
|
||||
// Hosted checkout should have redirected before reaching this component
|
||||
return (
|
||||
<Text size="sm" c="red">
|
||||
Stripe is not configured properly.
|
||||
</Text>
|
||||
<Stack align="center" gap="md" style={{ padding: '2rem 0' }}>
|
||||
<Loader size="lg" />
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
{t('payment.redirecting', 'Redirecting to secure checkout...')}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlans } from '@app/hooks/usePlans';
|
||||
import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
|
||||
import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier, PlanTier } from '@app/services/licenseService';
|
||||
import { StripeCheckout } from '@app/components/shared/stripeCheckout';
|
||||
import { userManagementService } from '@app/services/userManagementService';
|
||||
import { alert } from '@app/components/toast';
|
||||
@@ -54,8 +53,33 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
||||
licenseKey?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Load plans with current currency
|
||||
const { plans, refetch: refetchPlans } = usePlans(currentCurrency);
|
||||
// Lazy-loaded plans state (no fetch on mount)
|
||||
const [plans, setPlans] = useState<PlanTier[]>([]);
|
||||
const [plansLoaded, setPlansLoaded] = useState(false);
|
||||
const [plansLoading, setPlansLoading] = useState(false);
|
||||
|
||||
// Lazy fetch plans only when needed
|
||||
const fetchPlansIfNeeded = useCallback(async (currency: string) => {
|
||||
// Don't fetch if already loading
|
||||
if (plansLoading) return;
|
||||
|
||||
try {
|
||||
setPlansLoading(true);
|
||||
const response = await licenseService.getPlans(currency);
|
||||
setPlans(response.plans);
|
||||
setPlansLoaded(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plans:', error);
|
||||
// Don't block - let components handle the error
|
||||
} finally {
|
||||
setPlansLoading(false);
|
||||
}
|
||||
}, [plansLoading]);
|
||||
|
||||
const refetchPlans = useCallback(() => {
|
||||
setPlansLoaded(false); // Force refetch
|
||||
return fetchPlansIfNeeded(currentCurrency);
|
||||
}, [currentCurrency, fetchPlansIfNeeded]);
|
||||
|
||||
// Handle return from hosted Stripe checkout
|
||||
useEffect(() => {
|
||||
@@ -89,6 +113,11 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
||||
if (activation.success) {
|
||||
console.log('License synced successfully, refreshing license context');
|
||||
|
||||
// Ensure plans are loaded before using them
|
||||
if (!plansLoaded) {
|
||||
await fetchPlansIfNeeded(currentCurrency);
|
||||
}
|
||||
|
||||
// Refresh global license context
|
||||
await refetchLicense();
|
||||
await refetchPlans();
|
||||
@@ -135,6 +164,11 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
||||
if (activation.success) {
|
||||
console.log(`License key activated: ${activation.licenseType}`);
|
||||
|
||||
// Ensure plans are loaded before using them
|
||||
if (!plansLoaded) {
|
||||
await fetchPlansIfNeeded(currentCurrency);
|
||||
}
|
||||
|
||||
// Refresh global license context
|
||||
await refetchLicense();
|
||||
await refetchPlans();
|
||||
@@ -201,7 +235,7 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
||||
};
|
||||
|
||||
handleCheckoutReturn();
|
||||
}, [t, refetchPlans, refetchLicense, plans]);
|
||||
}, [t, refetchPlans, refetchLicense, plans, fetchPlansIfNeeded, plansLoaded, currentCurrency]);
|
||||
|
||||
const openCheckout = useCallback(
|
||||
async (tier: 'server' | 'enterprise', options: CheckoutOptions = {}) => {
|
||||
@@ -217,7 +251,11 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
||||
const currency = options.currency || currentCurrency;
|
||||
if (currency !== currentCurrency) {
|
||||
setCurrentCurrency(currency);
|
||||
// Plans will reload automatically via usePlans
|
||||
}
|
||||
|
||||
// Fetch plans if not already loaded
|
||||
if (!plansLoaded) {
|
||||
await fetchPlansIfNeeded(currency);
|
||||
}
|
||||
|
||||
// Fetch license info and user data for seat calculations
|
||||
@@ -277,7 +315,7 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[currentCurrency, plans]
|
||||
[currentCurrency, plans, plansLoaded, fetchPlansIfNeeded]
|
||||
);
|
||||
|
||||
const closeCheckout = useCallback(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Text, Stack, Alert } from '@mantine/core';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -29,6 +30,8 @@ export default function Login() {
|
||||
const [enabledProviders, setEnabledProviders] = useState<string[]>([]);
|
||||
const [hasSSOProviders, setHasSSOProviders] = useState(false);
|
||||
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
|
||||
const [isFirstTimeSetup, setIsFirstTimeSetup] = useState(false);
|
||||
const [showDefaultCredentials, setShowDefaultCredentials] = useState(false);
|
||||
|
||||
// Redirect immediately if user has valid session (JWT already validated by AuthProvider)
|
||||
useEffect(() => {
|
||||
@@ -55,6 +58,10 @@ export default function Login() {
|
||||
|
||||
setEnableLogin(data.enableLogin ?? true);
|
||||
|
||||
// Set first-time setup flags
|
||||
setIsFirstTimeSetup(data.firstTimeSetup ?? false);
|
||||
setShowDefaultCredentials(data.showDefaultCredentials ?? false);
|
||||
|
||||
// Extract provider IDs from the providerList map
|
||||
// The keys are like "/oauth2/authorization/google" - extract the last part
|
||||
const providerIds = Object.keys(data.providerList || {})
|
||||
@@ -259,6 +266,31 @@ export default function Login() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help section - only show on first-time setup with default credentials */}
|
||||
{isFirstTimeSetup && showDefaultCredentials && (
|
||||
<Alert
|
||||
color="blue"
|
||||
variant="light"
|
||||
radius="md"
|
||||
mt="xl"
|
||||
>
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="sm" fw={600} ta="center">
|
||||
{t('login.defaultCredentials', 'Default Login Credentials')}
|
||||
</Text>
|
||||
<Text size="sm" ta="center">
|
||||
<Text component="span" fw={600}>{t('login.username', 'Username')}:</Text> admin
|
||||
</Text>
|
||||
<Text size="sm" ta="center">
|
||||
<Text component="span" fw={600}>{t('login.password', 'Password')}:</Text> stirling
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" mt="xs">
|
||||
{t('login.changePasswordWarning', 'Please change your password after logging in for the first time')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ''}`}
|
||||
style={{ marginBottom: 'auto' }}
|
||||
>
|
||||
<div className={styles.authLeftPanel}>
|
||||
<div className={styles.authContent}>
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
* Used to decide between Embedded Checkout (HTTPS) and Hosted Checkout (HTTP)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if Stripe publishable key is configured
|
||||
* Similar to isSupabaseConfigured pattern - checks availability at decision points
|
||||
* @returns true if key exists and has valid format
|
||||
*/
|
||||
export function isStripeConfigured(): boolean {
|
||||
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || 'pk_live_51Q56W2P9mY5IAnSnp3kcxG50uyFMLuhM4fFs774DAP3t88KmlwUrUo31CecpnAZ9FHsNp8xJyOnYNYNVVP6z4oi500q5sFYPEp';
|
||||
return !!stripeKey && stripeKey.startsWith('pk_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current context is secure (HTTPS or localhost)
|
||||
* @returns true if HTTPS or localhost, false if HTTP
|
||||
@@ -28,16 +38,24 @@ export function isSecureContext(): boolean {
|
||||
|
||||
/**
|
||||
* Get the appropriate Stripe checkout UI mode based on current context
|
||||
* @returns 'embedded' for HTTPS/localhost, 'hosted' for HTTP
|
||||
* @returns 'embedded' for HTTPS with key, 'hosted' for HTTP or missing key
|
||||
*/
|
||||
export function getCheckoutMode(): 'embedded' | 'hosted' {
|
||||
// Force hosted checkout if no publishable key (regardless of protocol)
|
||||
// Hosted checkout works without the key - it just redirects to Stripe
|
||||
if (!isStripeConfigured()) {
|
||||
return 'hosted';
|
||||
}
|
||||
|
||||
// Normal protocol-based detection if key is available
|
||||
return isSecureContext() ? 'embedded' : 'hosted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Embedded Checkout can be used in current context
|
||||
* @returns true if secure context (HTTPS/localhost)
|
||||
* Requires both HTTPS and Stripe publishable key
|
||||
* @returns true if secure context AND key is configured
|
||||
*/
|
||||
export function canUseEmbeddedCheckout(): boolean {
|
||||
return isSecureContext();
|
||||
return isSecureContext() && isStripeConfigured();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user