Add subcription feature 5
This commit is contained in:
@@ -160,11 +160,20 @@ export const useChangePassword = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useResetPassword = (
|
export const useRequestPasswordReset = (
|
||||||
options?: UseMutationOptions<{ message: string }, ApiError, PasswordReset>
|
options?: UseMutationOptions<{ message: string }, ApiError, string>
|
||||||
) => {
|
) => {
|
||||||
return useMutation<{ message: string }, ApiError, PasswordReset>({
|
return useMutation<{ message: string }, ApiError, string>({
|
||||||
mutationFn: (resetData: PasswordReset) => authService.resetPassword(resetData),
|
mutationFn: (email: string) => authService.requestPasswordReset(email),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useResetPasswordWithToken = (
|
||||||
|
options?: UseMutationOptions<{ message: string }, ApiError, { token: string; newPassword: string }>
|
||||||
|
) => {
|
||||||
|
return useMutation<{ message: string }, ApiError, { token: string; newPassword: string }>({
|
||||||
|
mutationFn: ({ token, newPassword }) => authService.resetPasswordWithToken(token, newPassword),
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -440,7 +440,8 @@ export {
|
|||||||
useRefreshToken,
|
useRefreshToken,
|
||||||
useLogout,
|
useLogout,
|
||||||
useChangePassword,
|
useChangePassword,
|
||||||
useResetPassword,
|
useRequestPasswordReset,
|
||||||
|
useResetPasswordWithToken,
|
||||||
useUpdateProfile,
|
useUpdateProfile,
|
||||||
useVerifyEmail,
|
useVerifyEmail,
|
||||||
authKeys,
|
authKeys,
|
||||||
|
|||||||
@@ -93,8 +93,15 @@ export class AuthService {
|
|||||||
return apiClient.post<{ message: string }>(`${this.baseUrl}/change-password`, passwordData);
|
return apiClient.post<{ message: string }>(`${this.baseUrl}/change-password`, passwordData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetPassword(resetData: PasswordReset): Promise<{ message: string }> {
|
async requestPasswordReset(email: string): Promise<{ message: string }> {
|
||||||
return apiClient.post<{ message: string }>(`${this.baseUrl}/reset-password`, resetData);
|
return apiClient.post<{ message: string }>(`${this.baseUrl}/password/reset-request`, { email });
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPasswordWithToken(token: string, newPassword: string): Promise<{ message: string }> {
|
||||||
|
return apiClient.post<{ message: string }>(`${this.baseUrl}/password/reset`, {
|
||||||
|
token,
|
||||||
|
new_password: newPassword
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ export const BasicInfoStep: React.FC<BasicInfoStepProps> = ({
|
|||||||
}}
|
}}
|
||||||
placeholder={t('auth:register.confirm_password_placeholder', '••••••••')}
|
placeholder={t('auth:register.confirm_password_placeholder', '••••••••')}
|
||||||
error={errors.confirmPassword || formData.confirmPasswordError}
|
error={errors.confirmPassword || formData.confirmPasswordError}
|
||||||
|
isValid={formData.confirmPassword.length > 0 && formData.confirmPassword === formData.password}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
rightIcon={showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
rightIcon={showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
onRightIconClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
onRightIconClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Button, Input, Card } from '../../ui';
|
|||||||
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
|
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
|
||||||
import { useAuthActions } from '../../../stores/auth.store';
|
import { useAuthActions } from '../../../stores/auth.store';
|
||||||
import { showToast } from '../../../utils/toast';
|
import { showToast } from '../../../utils/toast';
|
||||||
import { useResetPassword } from '../../../api/hooks/auth';
|
import { useResetPasswordWithToken } from '../../../api/hooks/auth';
|
||||||
|
|
||||||
interface PasswordResetFormProps {
|
interface PasswordResetFormProps {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -36,7 +36,7 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
|
|||||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Password reset mutation hooks
|
// Password reset mutation hooks
|
||||||
const { mutateAsync: resetPasswordMutation, isPending: isResetting } = useResetPassword();
|
const { mutateAsync: resetPasswordMutation, isPending: isResetting } = useResetPasswordWithToken();
|
||||||
const isLoading = isResetting;
|
const isLoading = isResetting;
|
||||||
const error = null;
|
const error = null;
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
|
|||||||
// Call the reset password API
|
// Call the reset password API
|
||||||
await resetPasswordMutation({
|
await resetPasswordMutation({
|
||||||
token: token,
|
token: token,
|
||||||
new_password: password
|
newPassword: password
|
||||||
});
|
});
|
||||||
|
|
||||||
showToast.success('¡Tu contraseña ha sido restablecida exitosamente! Ya puedes iniciar sesión.', {
|
showToast.success('¡Tu contraseña ha sido restablecida exitosamente! Ya puedes iniciar sesión.', {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useStartRegistration, useCompleteRegistration } from '@/api/hooks/auth';
|
import { useStartRegistration, useCompleteRegistration } from '@/api/hooks/auth';
|
||||||
import { ThreeDSAuthenticationRequired } from '@/api/types/auth';
|
import { ThreeDSAuthenticationRequired } from '@/api/types/auth';
|
||||||
import { Button, Card, Input } from '../../ui';
|
import { Button, Card, Input } from '../../ui';
|
||||||
import { Loader2, CreditCard, Lock, AlertCircle, CheckCircle } from 'lucide-react';
|
import { Loader2, CreditCard, Lock, AlertCircle } from 'lucide-react';
|
||||||
import { subscriptionService, apiClient } from '../../../api';
|
import { subscriptionService, apiClient } from '../../../api';
|
||||||
import { useAuthStore } from '../../../stores/auth.store';
|
import { useAuthStore } from '../../../stores/auth.store';
|
||||||
import { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT } from '../../../utils/jwt';
|
import { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT } from '../../../utils/jwt';
|
||||||
@@ -23,17 +23,13 @@ const SUPPORTED_COUNTRIES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface PaymentStepProps {
|
interface PaymentStepProps {
|
||||||
onNext: () => void;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onPaymentComplete: (paymentData: any) => void;
|
|
||||||
registrationState: any;
|
registrationState: any;
|
||||||
updateRegistrationState: (updates: any) => void;
|
updateRegistrationState: (updates: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PaymentStep: React.FC<PaymentStepProps> = ({
|
export const PaymentStep: React.FC<PaymentStepProps> = ({
|
||||||
onNext,
|
|
||||||
onBack,
|
onBack,
|
||||||
onPaymentComplete,
|
|
||||||
registrationState,
|
registrationState,
|
||||||
updateRegistrationState
|
updateRegistrationState
|
||||||
}) => {
|
}) => {
|
||||||
@@ -285,19 +281,43 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment setup successful
|
// Payment setup successful (no 3DS required)
|
||||||
|
// Now call /complete-registration to create user, subscription, and get tokens
|
||||||
|
const completionResult = await completeRegistrationMutation.mutateAsync({
|
||||||
|
setup_intent_id: paymentSetupResult.setup_intent_id || '',
|
||||||
|
user_data: {
|
||||||
|
email: registrationState.basicInfo.email,
|
||||||
|
password: registrationState.basicInfo.password || '',
|
||||||
|
full_name: `${registrationState.basicInfo.firstName} ${registrationState.basicInfo.lastName}`,
|
||||||
|
subscription_plan: registrationState.subscription.planId,
|
||||||
|
billing_cycle: registrationState.subscription.billingInterval,
|
||||||
|
payment_method_id: paymentMethod.id,
|
||||||
|
coupon_code: registrationState.subscription.couponCode,
|
||||||
|
customer_id: paymentSetupResult.customer_id ?? paymentSetupResult.payment_customer_id ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (completionResult.success) {
|
||||||
updateRegistrationState({
|
updateRegistrationState({
|
||||||
paymentSetup: {
|
paymentSetup: {
|
||||||
success: true,
|
success: true,
|
||||||
customerId: paymentSetupResult.customer_id ?? '',
|
customerId: completionResult.payment_customer_id ?? paymentSetupResult.customer_id ?? '',
|
||||||
subscriptionId: paymentSetupResult.subscription_id ?? '',
|
subscriptionId: completionResult.subscription_id ?? '',
|
||||||
paymentMethodId: paymentMethod.id,
|
paymentMethodId: paymentMethod.id,
|
||||||
planId: registrationState.subscription.planId,
|
planId: registrationState.subscription.planId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setSuccess(true);
|
// Perform auto-login if tokens are returned
|
||||||
onPaymentComplete(paymentSetupResult);
|
performAutoLogin(completionResult);
|
||||||
|
|
||||||
|
// Show success message and navigate to onboarding
|
||||||
|
showToast.success(t('auth:registration_complete', 'Registration complete! Redirecting to onboarding...'));
|
||||||
|
navigate('/app/onboarding');
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
setError(completionResult.message || t('auth:registration_failed', 'Registration failed'));
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ThreeDSAuthenticationRequired) {
|
if (error instanceof ThreeDSAuthenticationRequired) {
|
||||||
@@ -384,28 +404,12 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Perform auto-login if tokens are returned
|
// Perform auto-login if tokens are returned
|
||||||
const autoLoginSuccess = performAutoLogin(verificationResult);
|
performAutoLogin(verificationResult);
|
||||||
|
|
||||||
if (autoLoginSuccess) {
|
// Show success message and navigate to onboarding
|
||||||
// Show success message
|
|
||||||
showToast.success(t('auth:registration_complete', 'Registration complete! Redirecting to onboarding...'));
|
showToast.success(t('auth:registration_complete', 'Registration complete! Redirecting to onboarding...'));
|
||||||
|
|
||||||
// Navigate directly to onboarding
|
|
||||||
navigate('/app/onboarding');
|
navigate('/app/onboarding');
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
setSuccess(true);
|
|
||||||
setRequires3DS(false);
|
|
||||||
|
|
||||||
// Create a combined result for the parent component (fallback if no tokens)
|
|
||||||
const paymentCompleteResult = {
|
|
||||||
...verificationResult,
|
|
||||||
requires_3ds: true,
|
|
||||||
threeds_completed: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
onPaymentComplete(paymentCompleteResult);
|
|
||||||
} else {
|
} else {
|
||||||
setError(verificationResult.message || t('auth:payment_verification_failed'));
|
setError(verificationResult.message || t('auth:payment_verification_failed'));
|
||||||
}
|
}
|
||||||
@@ -424,30 +428,15 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-redirect on success (safety net - should not normally reach here)
|
||||||
|
useEffect(() => {
|
||||||
if (success) {
|
if (success) {
|
||||||
return (
|
const timer = setTimeout(() => {
|
||||||
<div className="text-center py-8">
|
navigate('/app/onboarding');
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4 mx-auto">
|
}, 100);
|
||||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
return () => clearTimeout(timer);
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold mb-2">
|
|
||||||
{t('auth:payment_setup_complete')}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
{t('auth:payment_setup_complete_description')}
|
|
||||||
</p>
|
|
||||||
<div className="mt-6">
|
|
||||||
<Button
|
|
||||||
onClick={onNext}
|
|
||||||
disabled={loading}
|
|
||||||
className="bg-primary hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
{t('auth:continue_to_account_creation')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}, [success, navigate]);
|
||||||
|
|
||||||
if (requires3DS) {
|
if (requires3DS) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,16 +6,16 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { Card, Progress } from '../../ui';
|
import { Card } from '../../ui';
|
||||||
import { showToast } from '../../../utils/toast';
|
import { showToast } from '../../../utils/toast';
|
||||||
import { useAuthActions, useAuthLoading, useAuthError, useAuthStore } from '../../../stores/auth.store';
|
import { useAuthStore } from '../../../stores/auth.store';
|
||||||
import { usePilotDetection } from '../../../hooks/usePilotDetection';
|
import { usePilotDetection } from '../../../hooks/usePilotDetection';
|
||||||
import { subscriptionService, apiClient } from '../../../api';
|
import { apiClient } from '../../../api';
|
||||||
import { validateEmail } from '../../../utils/validation';
|
import { validateEmail } from '../../../utils/validation';
|
||||||
import { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT } from '../../../utils/jwt';
|
import { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT } from '../../../utils/jwt';
|
||||||
import { loadStripe, Stripe } from '@stripe/stripe-js';
|
import { loadStripe, Stripe } from '@stripe/stripe-js';
|
||||||
import { Elements } from '@stripe/react-stripe-js';
|
import { Elements } from '@stripe/react-stripe-js';
|
||||||
import { useRegistrationState } from './hooks/useRegistrationState';
|
import { useRegistrationState, RegistrationStep } from './hooks/useRegistrationState';
|
||||||
import BasicInfoStep from './BasicInfoStep';
|
import BasicInfoStep from './BasicInfoStep';
|
||||||
import SubscriptionStep from './SubscriptionStep';
|
import SubscriptionStep from './SubscriptionStep';
|
||||||
import { PaymentStep } from './PaymentStep';
|
import { PaymentStep } from './PaymentStep';
|
||||||
@@ -38,7 +38,6 @@ interface RegistrationContainerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
||||||
onSuccess,
|
|
||||||
onLoginClick,
|
onLoginClick,
|
||||||
className
|
className
|
||||||
}) => {
|
}) => {
|
||||||
@@ -46,15 +45,12 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isPilot, couponCode } = usePilotDetection();
|
const { isPilot, couponCode } = usePilotDetection();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const {
|
|
||||||
createRegistrationPaymentSetup,
|
|
||||||
verifySetupIntent,
|
|
||||||
completeRegistrationAfterPayment
|
|
||||||
} = useAuthActions();
|
|
||||||
const isLoading = useAuthLoading();
|
|
||||||
const error = useAuthError();
|
|
||||||
|
|
||||||
// Helper function to perform auto-login after registration
|
// Get auth store functions for 3DS redirect recovery
|
||||||
|
const verifySetupIntent = useAuthStore((state) => state.verifySetupIntent);
|
||||||
|
const completeRegistrationAfterPayment = useAuthStore((state) => state.completeRegistrationAfterPayment);
|
||||||
|
|
||||||
|
// Helper function to perform auto-login after registration (used in 3DS redirect recovery)
|
||||||
const performAutoLogin = (result: any) => {
|
const performAutoLogin = (result: any) => {
|
||||||
if (result.access_token && result.refresh_token) {
|
if (result.access_token && result.refresh_token) {
|
||||||
// Set tokens in API client
|
// Set tokens in API client
|
||||||
@@ -95,8 +91,9 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
|||||||
|
|
||||||
// Get URL parameters
|
// Get URL parameters
|
||||||
const planParam = searchParams.get('plan');
|
const planParam = searchParams.get('plan');
|
||||||
const VALID_PLANS = ['starter', 'professional', 'enterprise'];
|
const VALID_PLANS = ['starter', 'professional', 'enterprise'] as const;
|
||||||
const preSelectedPlan = (planParam && VALID_PLANS.includes(planParam)) ? planParam : null;
|
type PlanType = typeof VALID_PLANS[number];
|
||||||
|
const preSelectedPlan: PlanType | null = (planParam && VALID_PLANS.includes(planParam as PlanType)) ? planParam as PlanType : null;
|
||||||
|
|
||||||
const billingParam = searchParams.get('billing_cycle');
|
const billingParam = searchParams.get('billing_cycle');
|
||||||
const urlBillingCycle = (billingParam === 'monthly' || billingParam === 'yearly') ? billingParam : null;
|
const urlBillingCycle = (billingParam === 'monthly' || billingParam === 'yearly') ? billingParam : null;
|
||||||
@@ -116,12 +113,12 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
step,
|
step,
|
||||||
|
setStep,
|
||||||
registrationState,
|
registrationState,
|
||||||
updateRegistrationState,
|
updateRegistrationState,
|
||||||
nextStep,
|
nextStep,
|
||||||
prevStep,
|
prevStep,
|
||||||
isSubmitting,
|
isSubmitting
|
||||||
setIsSubmitting
|
|
||||||
} = useRegistrationState({
|
} = useRegistrationState({
|
||||||
initialPlan: preSelectedPlan || 'starter',
|
initialPlan: preSelectedPlan || 'starter',
|
||||||
initialBillingCycle: urlBillingCycle || 'monthly',
|
initialBillingCycle: urlBillingCycle || 'monthly',
|
||||||
@@ -129,8 +126,8 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [stripeInstance, setStripeInstance] = useState<Stripe | null>(null);
|
const [stripeInstance, setStripeInstance] = useState<Stripe | null>(null);
|
||||||
|
// paymentComplete is used for 3DS redirect recovery to prevent re-processing
|
||||||
const [paymentComplete, setPaymentComplete] = useState(false);
|
const [paymentComplete, setPaymentComplete] = useState(false);
|
||||||
const [paymentData, setPaymentData] = useState<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadStripeInstance = async () => {
|
const loadStripeInstance = async () => {
|
||||||
@@ -191,9 +188,9 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
|||||||
updateRegistrationState({
|
updateRegistrationState({
|
||||||
basicInfo: savedState.basicInfo,
|
basicInfo: savedState.basicInfo,
|
||||||
subscription: savedState.subscription,
|
subscription: savedState.subscription,
|
||||||
paymentSetup: savedState.paymentSetup,
|
paymentSetup: savedState.paymentSetup
|
||||||
step: 'payment'
|
|
||||||
});
|
});
|
||||||
|
setStep('payment');
|
||||||
|
|
||||||
// Verify the SetupIntent status with our backend
|
// Verify the SetupIntent status with our backend
|
||||||
const verificationResult = await verifySetupIntent(setupIntentParam);
|
const verificationResult = await verifySetupIntent(setupIntentParam);
|
||||||
@@ -227,6 +224,9 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
|||||||
threeds_completed: true
|
threeds_completed: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Complete the registration after 3DS verification
|
||||||
|
const result = await completeRegistrationAfterPayment(registrationData);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Clean up saved state
|
// Clean up saved state
|
||||||
sessionStorage.removeItem(savedStateKey);
|
sessionStorage.removeItem(savedStateKey);
|
||||||
@@ -238,10 +238,15 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
|||||||
newUrl.searchParams.delete('redirect_status');
|
newUrl.searchParams.delete('redirect_status');
|
||||||
window.history.replaceState({}, '', newUrl.toString());
|
window.history.replaceState({}, '', newUrl.toString());
|
||||||
|
|
||||||
|
// Perform auto-login if tokens are returned
|
||||||
|
performAutoLogin(result);
|
||||||
|
|
||||||
showToast.success(t('auth:registration_complete', 'Registration complete! Redirecting to onboarding...'));
|
showToast.success(t('auth:registration_complete', 'Registration complete! Redirecting to onboarding...'));
|
||||||
|
|
||||||
// Navigate to onboarding (same as in PaymentStep)
|
// Mark payment as complete to prevent re-processing
|
||||||
// Use a small timeout to ensure state updates are processed before navigation
|
setPaymentComplete(true);
|
||||||
|
|
||||||
|
// Navigate to onboarding
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate('/app/onboarding');
|
navigate('/app/onboarding');
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -262,12 +267,6 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
|||||||
handle3DSRedirectRecovery();
|
handle3DSRedirectRecovery();
|
||||||
}, [setupIntentParam, redirectStatusParam, stripeInstance, paymentComplete, redirectRecoveryInProgress]);
|
}, [setupIntentParam, redirectStatusParam, stripeInstance, paymentComplete, redirectRecoveryInProgress]);
|
||||||
|
|
||||||
const handlePaymentComplete = (data: any) => {
|
|
||||||
setPaymentData(data);
|
|
||||||
setPaymentComplete(true);
|
|
||||||
nextStep();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegistrationSubmit = async () => {
|
const handleRegistrationSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
if (step === 'basic_info') {
|
if (step === 'basic_info') {
|
||||||
@@ -279,13 +278,14 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
|||||||
if (preSelectedPlan) {
|
if (preSelectedPlan) {
|
||||||
// Ensure subscription data is set with the pre-selected plan
|
// Ensure subscription data is set with the pre-selected plan
|
||||||
updateRegistrationState({
|
updateRegistrationState({
|
||||||
step: 'payment',
|
|
||||||
subscription: {
|
subscription: {
|
||||||
...registrationState.subscription,
|
...registrationState.subscription,
|
||||||
planId: preSelectedPlan,
|
planId: preSelectedPlan,
|
||||||
billingInterval: urlBillingCycle || registrationState.subscription.billingInterval
|
billingInterval: urlBillingCycle || registrationState.subscription.billingInterval
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Skip directly to payment step
|
||||||
|
setStep('payment');
|
||||||
} else {
|
} else {
|
||||||
// Go to subscription selection step
|
// Go to subscription selection step
|
||||||
nextStep();
|
nextStep();
|
||||||
@@ -352,89 +352,11 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCompleteRegistration = async () => {
|
|
||||||
if (!paymentData) {
|
|
||||||
showToast.error(t('auth:alerts.payment_required'), {
|
|
||||||
title: t('auth:alerts.error')
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
const registrationData = {
|
|
||||||
email: registrationState.basicInfo.email,
|
|
||||||
full_name: `${registrationState.basicInfo.firstName} ${registrationState.basicInfo.lastName}`,
|
|
||||||
password: registrationState.basicInfo.password,
|
|
||||||
subscription_plan: registrationState.subscription.planId,
|
|
||||||
billing_cycle: registrationState.subscription.billingInterval,
|
|
||||||
payment_customer_id: paymentData.customer_id,
|
|
||||||
payment_method_id: paymentData.payment_method_id,
|
|
||||||
subscription_id: paymentData.subscription_id,
|
|
||||||
coupon_code: isPilot ? couponCode : registrationState.subscription.couponCode,
|
|
||||||
terms_accepted: registrationState.basicInfo.acceptTerms,
|
|
||||||
privacy_accepted: registrationState.basicInfo.acceptTerms,
|
|
||||||
marketing_consent: registrationState.basicInfo.marketingConsent,
|
|
||||||
analytics_consent: registrationState.basicInfo.analyticsConsent,
|
|
||||||
threeds_completed: paymentData.threeds_completed || false,
|
|
||||||
setup_intent_id: paymentData.setup_intent_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Complete registration after successful payment
|
|
||||||
const result = await completeRegistrationAfterPayment(registrationData);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
showToast.success(t('auth:register.registration_complete'), {
|
|
||||||
title: t('auth:alerts.success')
|
|
||||||
});
|
|
||||||
|
|
||||||
// Perform auto-login if tokens are returned
|
|
||||||
const autoLoginSuccess = performAutoLogin(result);
|
|
||||||
|
|
||||||
if (autoLoginSuccess) {
|
|
||||||
// Show success message
|
|
||||||
showToast.success(t('auth:registration_complete', 'Registration complete! Redirecting to onboarding...'));
|
|
||||||
|
|
||||||
// Use a small timeout to ensure state updates are processed before navigation
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('/app/onboarding');
|
|
||||||
}, 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Navigate to onboarding after a delay if auto-login didn't happen
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('/app/onboarding');
|
|
||||||
}, 1000); // Small delay to allow the success toast to be seen
|
|
||||||
|
|
||||||
onSuccess?.();
|
|
||||||
} else {
|
|
||||||
showToast.error(result.message || t('auth:alerts.registration_error'), {
|
|
||||||
title: t('auth:alerts.error')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Registration completion error:', error);
|
|
||||||
showToast.error(t('auth:alerts.registration_error'), {
|
|
||||||
title: t('auth:alerts.error')
|
|
||||||
});
|
|
||||||
|
|
||||||
// Even if there's an error, try to navigate to onboarding
|
|
||||||
// The user might still be able to log in manually
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('/app/onboarding');
|
|
||||||
}, 1000);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate progress percentage
|
// Calculate progress percentage
|
||||||
const getProgressPercentage = () => {
|
const getProgressPercentage = () => {
|
||||||
const steps = preSelectedPlan
|
const steps = preSelectedPlan
|
||||||
? ['basic_info', 'payment', 'complete']
|
? ['basic_info', 'payment']
|
||||||
: ['basic_info', 'subscription', 'payment', 'complete'];
|
: ['basic_info', 'subscription', 'payment'];
|
||||||
const currentIndex = steps.indexOf(step);
|
const currentIndex = steps.indexOf(step);
|
||||||
return currentIndex >= 0 ? ((currentIndex + 1) / steps.length) * 100 : 0;
|
return currentIndex >= 0 ? ((currentIndex + 1) / steps.length) * 100 : 0;
|
||||||
};
|
};
|
||||||
@@ -445,15 +367,14 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
|||||||
case 'basic_info': return t('auth:register.step_basic_info', 'Información Básica');
|
case 'basic_info': return t('auth:register.step_basic_info', 'Información Básica');
|
||||||
case 'subscription': return t('auth:register.step_subscription', 'Suscripción');
|
case 'subscription': return t('auth:register.step_subscription', 'Suscripción');
|
||||||
case 'payment': return t('auth:register.step_payment', 'Pago');
|
case 'payment': return t('auth:register.step_payment', 'Pago');
|
||||||
case 'complete': return t('auth:register.step_complete', 'Completar');
|
|
||||||
default: return '';
|
default: return '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine which steps to show in progress indicator
|
// Determine which steps to show in progress indicator
|
||||||
const progressSteps: RegistrationStep[] = preSelectedPlan
|
const progressSteps: RegistrationStep[] = preSelectedPlan
|
||||||
? ['basic_info', 'payment', 'complete']
|
? ['basic_info', 'payment']
|
||||||
: ['basic_info', 'subscription', 'payment', 'complete'];
|
: ['basic_info', 'subscription', 'payment'];
|
||||||
|
|
||||||
// Render current step
|
// Render current step
|
||||||
const renderStep = () => {
|
const renderStep = () => {
|
||||||
@@ -498,28 +419,12 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PaymentStep
|
<PaymentStep
|
||||||
onNext={handleRegistrationSubmit}
|
|
||||||
onBack={prevStep}
|
onBack={prevStep}
|
||||||
onPaymentComplete={handlePaymentComplete}
|
|
||||||
registrationState={registrationState}
|
registrationState={registrationState}
|
||||||
updateRegistrationState={updateRegistrationState}
|
updateRegistrationState={updateRegistrationState}
|
||||||
/>
|
/>
|
||||||
</Elements>
|
</Elements>
|
||||||
);
|
);
|
||||||
case 'complete':
|
|
||||||
return (
|
|
||||||
<div className="registration-complete-step">
|
|
||||||
<h2>{t('auth:register.registration_complete')}</h2>
|
|
||||||
<p>{t('auth:register.registration_complete_description')}</p>
|
|
||||||
<button
|
|
||||||
onClick={handleCompleteRegistration}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="complete-registration-button"
|
|
||||||
>
|
|
||||||
{isSubmitting ? t('auth:register.completing') : t('auth:register.complete_registration')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Checkbox } from '../../ui';
|
import { Button } from '../../ui';
|
||||||
import { SubscriptionPricingCards } from '../../subscription/SubscriptionPricingCards';
|
import { SubscriptionPricingCards } from '../../subscription/SubscriptionPricingCards';
|
||||||
import { subscriptionService } from '../../../api';
|
import { subscriptionService } from '../../../api';
|
||||||
|
|
||||||
@@ -73,10 +73,15 @@ export const SubscriptionStep: React.FC<SubscriptionStepProps> = ({
|
|||||||
|
|
||||||
fetchPlanMetadata();
|
fetchPlanMetadata();
|
||||||
|
|
||||||
// Automatically apply PILOT2025 coupon for pilot users with trial enabled
|
// Automatically enable trial and apply PILOT2025 coupon for pilot users
|
||||||
if (isPilot && useTrial && !couponCode) {
|
if (isPilot) {
|
||||||
|
if (!useTrial) {
|
||||||
|
updateField('useTrial', true);
|
||||||
|
}
|
||||||
|
if (!couponCode) {
|
||||||
updateField('couponCode', 'PILOT2025');
|
updateField('couponCode', 'PILOT2025');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
@@ -130,38 +135,14 @@ export const SubscriptionStep: React.FC<SubscriptionStepProps> = ({
|
|||||||
mode="selection"
|
mode="selection"
|
||||||
selectedPlan={selectedPlan}
|
selectedPlan={selectedPlan}
|
||||||
onPlanSelect={(planKey) => updateField('planId', planKey)}
|
onPlanSelect={(planKey) => updateField('planId', planKey)}
|
||||||
showPilotBanner={false} // Pilot program disabled as requested
|
showPilotBanner={isPilot}
|
||||||
pilotCouponCode={undefined}
|
pilotCouponCode={isPilot ? 'PILOT2025' : undefined}
|
||||||
pilotTrialMonths={0}
|
pilotTrialMonths={isPilot ? 3 : 0}
|
||||||
billingCycle={billingCycle}
|
billingCycle={billingCycle}
|
||||||
onBillingCycleChange={(cycle) => updateField('billingInterval', cycle)}
|
onBillingCycleChange={(cycle) => updateField('billingInterval', cycle)}
|
||||||
className="mb-8"
|
className="mb-8"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pilot trial option - automatically enabled for pilot users */}
|
|
||||||
{isPilot && (
|
|
||||||
<div className="mb-6 p-4 bg-[var(--color-info-50)] rounded-lg">
|
|
||||||
<Checkbox
|
|
||||||
id="useTrial"
|
|
||||||
checked={useTrial}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateField('useTrial', e.target.checked);
|
|
||||||
// Automatically apply PILOT2025 coupon when trial is enabled
|
|
||||||
if (e.target.checked) {
|
|
||||||
updateField('couponCode', 'PILOT2025');
|
|
||||||
} else {
|
|
||||||
updateField('couponCode', undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
label={t('auth:register.enable_trial', 'Enable 3-month free trial')}
|
|
||||||
className="mb-2 font-medium"
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-[var(--color-info-800)] ml-6">
|
|
||||||
{t('auth:register.trial_description', 'Get full access to Professional plan features for 3 months at no cost.')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="flex justify-between mt-8">
|
<div className="flex justify-between mt-8">
|
||||||
<Button type="button" variant="secondary" size="lg" onClick={onBack}>
|
<Button type="button" variant="secondary" size="lg" onClick={onBack}>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { validateEmail } from '../../../../utils/validation';
|
import { validateEmail } from '../../../../utils/validation';
|
||||||
|
|
||||||
export type RegistrationStep = 'basic_info' | 'subscription' | 'payment' | 'complete';
|
export type RegistrationStep = 'basic_info' | 'subscription' | 'payment';
|
||||||
|
|
||||||
export type BasicInfoData = {
|
export type BasicInfoData = {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -94,7 +94,7 @@ export const useRegistrationState = ({
|
|||||||
switch (prev) {
|
switch (prev) {
|
||||||
case 'basic_info': return 'subscription';
|
case 'basic_info': return 'subscription';
|
||||||
case 'subscription': return 'payment';
|
case 'subscription': return 'payment';
|
||||||
case 'payment': return 'complete';
|
// Payment is the last step - no next step
|
||||||
default: return prev;
|
default: return prev;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -105,7 +105,6 @@ export const useRegistrationState = ({
|
|||||||
switch (prev) {
|
switch (prev) {
|
||||||
case 'subscription': return 'basic_info';
|
case 'subscription': return 'basic_info';
|
||||||
case 'payment': return 'subscription';
|
case 'payment': return 'subscription';
|
||||||
case 'complete': return 'payment';
|
|
||||||
default: return prev;
|
default: return prev;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -210,6 +209,7 @@ export const useRegistrationState = ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
step,
|
step,
|
||||||
|
setStep,
|
||||||
registrationState,
|
registrationState,
|
||||||
updateRegistrationState,
|
updateRegistrationState,
|
||||||
nextStep,
|
nextStep,
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
|||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const registerBakery = useRegisterBakery();
|
const registerBakery = useRegisterBakery();
|
||||||
|
const registerBakeryWithSubscription = useRegisterBakeryWithSubscription();
|
||||||
const updateTenant = useUpdateTenant();
|
const updateTenant = useUpdateTenant();
|
||||||
|
|
||||||
const handleInputChange = (field: keyof BakeryRegistration, value: string) => {
|
const handleInputChange = (field: keyof BakeryRegistration, value: string) => {
|
||||||
@@ -428,7 +429,7 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
|||||||
<div className="flex justify-end pt-4 border-t-2 border-[var(--border-color)]/50">
|
<div className="flex justify-end pt-4 border-t-2 border-[var(--border-color)]/50">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
isLoading={registerBakery.isPending || updateTenant.isPending}
|
isLoading={registerBakery.isPending || registerBakeryWithSubscription.isPending || updateTenant.isPending}
|
||||||
loadingText={t(tenantId
|
loadingText={t(tenantId
|
||||||
? 'onboarding:steps.tenant_registration.loading.updating'
|
? 'onboarding:steps.tenant_registration.loading.updating'
|
||||||
: (isEnterprise
|
: (isEnterprise
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
|||||||
<div className={className}>
|
<div className={className}>
|
||||||
{/* Pilot Program Banner */}
|
{/* Pilot Program Banner */}
|
||||||
{showPilotBanner && pilotCouponCode && (
|
{showPilotBanner && pilotCouponCode && (
|
||||||
<Card className="p-6 mb-8 bg-gradient-to-r from-amber-50 via-orange-50 to-amber-50 dark:from-amber-900/20 dark:via-orange-900/20 dark:to-amber-900/20 border-2 border-amber-400 dark:border-amber-500">
|
<Card className="p-6 mb-8 bg-[var(--bg-secondary)] border border-[var(--border-primary)]">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="w-14 h-14 bg-gradient-to-br from-amber-500 to-orange-500 rounded-full flex items-center justify-center shadow-lg">
|
<div className="w-14 h-14 bg-gradient-to-br from-amber-500 to-orange-500 rounded-full flex items-center justify-center shadow-lg">
|
||||||
@@ -138,14 +138,13 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-xl font-bold text-amber-900 dark:text-amber-100 mb-1">
|
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-1">
|
||||||
{t('ui.pilot_program_active')}
|
{t('ui.pilot_program_active')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-amber-800 dark:text-amber-200"
|
<p className="text-sm text-[var(--text-secondary)]"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: t('ui.pilot_program_description', { count: pilotTrialMonths })
|
__html: t('ui.pilot_program_description', { count: pilotTrialMonths })
|
||||||
.replace('{count}', `<strong>${pilotTrialMonths}</strong>`)
|
.replace('{count}', `<strong>${pilotTrialMonths}</strong>`)
|
||||||
.replace('20%', '<strong>20%</strong>')
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -129,8 +129,8 @@ const Checkbox = forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root>, Che
|
|||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
'hover:shadow-md',
|
'hover:shadow-md',
|
||||||
'shrink-0', // Prevent shrinking on mobile
|
'shrink-0', // Prevent shrinking on mobile
|
||||||
'border-[var(--border-primary)]', // Ensure visible border by default
|
'border-[var(--border-tertiary)]', // Use tertiary border for better visibility when unchecked
|
||||||
'dark:border-[var(--border-primary)]', // Ensure visible border in dark mode too
|
'dark:border-[var(--text-tertiary)]', // Use text-tertiary in dark mode for better contrast
|
||||||
'shadow-sm', // Add subtle shadow for better visibility
|
'shadow-sm', // Add subtle shadow for better visibility
|
||||||
'dark:shadow-[0_1px_2px_rgba(0,0,0,0.2)]', // Dark mode shadow for better visibility
|
'dark:shadow-[0_1px_2px_rgba(0,0,0,0.2)]', // Dark mode shadow for better visibility
|
||||||
sizeClasses[size],
|
sizeClasses[size],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|||||||
helperText?: string;
|
helperText?: string;
|
||||||
isRequired?: boolean;
|
isRequired?: boolean;
|
||||||
isInvalid?: boolean;
|
isInvalid?: boolean;
|
||||||
|
isValid?: boolean;
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
variant?: 'outline' | 'filled' | 'unstyled';
|
variant?: 'outline' | 'filled' | 'unstyled';
|
||||||
leftIcon?: React.ReactNode;
|
leftIcon?: React.ReactNode;
|
||||||
@@ -23,6 +24,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(({
|
|||||||
helperText,
|
helperText,
|
||||||
isRequired = false,
|
isRequired = false,
|
||||||
isInvalid = false,
|
isInvalid = false,
|
||||||
|
isValid = false,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
variant = 'outline',
|
variant = 'outline',
|
||||||
leftIcon,
|
leftIcon,
|
||||||
@@ -38,6 +40,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(({
|
|||||||
}, ref) => {
|
}, ref) => {
|
||||||
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
const hasError = isInvalid || !!error;
|
const hasError = isInvalid || !!error;
|
||||||
|
const hasSuccess = isValid && !hasError;
|
||||||
|
|
||||||
const baseInputClasses = [
|
const baseInputClasses = [
|
||||||
'w-full transition-colors duration-200',
|
'w-full transition-colors duration-200',
|
||||||
@@ -50,12 +53,14 @@ const Input = forwardRef<HTMLInputElement, InputProps>(({
|
|||||||
outline: [
|
outline: [
|
||||||
'bg-[var(--bg-primary)] border border-[var(--border-secondary)]',
|
'bg-[var(--bg-primary)] border border-[var(--border-secondary)]',
|
||||||
'focus:border-[var(--color-primary)] focus:ring-1 focus:ring-[var(--color-primary)]',
|
'focus:border-[var(--color-primary)] focus:ring-1 focus:ring-[var(--color-primary)]',
|
||||||
hasError ? 'border-[var(--color-error)] focus:border-[var(--color-error)] focus:ring-[var(--color-error)]' : ''
|
hasError ? 'border-[var(--color-error)] focus:border-[var(--color-error)] focus:ring-[var(--color-error)]' : '',
|
||||||
|
hasSuccess ? 'border-[var(--color-success)] focus:border-[var(--color-success)] focus:ring-[var(--color-success)]' : ''
|
||||||
],
|
],
|
||||||
filled: [
|
filled: [
|
||||||
'bg-[var(--bg-secondary)] border border-transparent',
|
'bg-[var(--bg-secondary)] border border-transparent',
|
||||||
'focus:bg-[var(--bg-primary)] focus:border-[var(--color-primary)]',
|
'focus:bg-[var(--bg-primary)] focus:border-[var(--color-primary)]',
|
||||||
hasError ? 'border-[var(--color-error)]' : ''
|
hasError ? 'border-[var(--color-error)]' : '',
|
||||||
|
hasSuccess ? 'border-[var(--color-success)]' : ''
|
||||||
],
|
],
|
||||||
unstyled: [
|
unstyled: [
|
||||||
'bg-transparent border-none',
|
'bg-transparent border-none',
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "Be Part of Our Story",
|
"title": "Be Part of Our Story",
|
||||||
"subtitle": "We're looking for 20 pioneering bakeries for our pilot program. 3 months free, 20% lifetime discount, and the opportunity to shape the product.",
|
"subtitle": "We're looking for 20 pioneering bakeries for our pilot program. 3 months free and the opportunity to shape the product.",
|
||||||
"primary": "Request Pilot Spot",
|
"primary": "Request Pilot Spot",
|
||||||
"secondary": "See Demo"
|
"secondary": "See Demo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "Ready to Optimize Your Bakery?",
|
"title": "Ready to Optimize Your Bakery?",
|
||||||
"description": "Join the pilot program and get 3 months free + 20% lifetime discount",
|
"description": "Join the pilot program and get 3 months free",
|
||||||
"button": "Request Pilot Spot"
|
"button": "Request Pilot Spot"
|
||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
|
|||||||
@@ -156,7 +156,7 @@
|
|||||||
{
|
{
|
||||||
"category": "pricing",
|
"category": "pricing",
|
||||||
"question": "How much does the pilot program cost?",
|
"question": "How much does the pilot program cost?",
|
||||||
"answer": "The pilot program is FREE for the first 3 months. After that, you pay only €49/month with a lifetime 20% discount (regular price: €79/month). No commitment contracts, cancel anytime."
|
"answer": "The pilot program is FREE for the first 3 months. No commitment contracts, cancel anytime."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "technical",
|
"category": "technical",
|
||||||
|
|||||||
@@ -182,7 +182,7 @@
|
|||||||
"pilot_period": "First 3 months (pilot)",
|
"pilot_period": "First 3 months (pilot)",
|
||||||
"regular_price": "€99/month",
|
"regular_price": "€99/month",
|
||||||
"pilot_discount_price": "€79/month",
|
"pilot_discount_price": "€79/month",
|
||||||
"pilot_discount_note": "20% lifetime discount if you were a pilot",
|
"pilot_discount_note": "Pilot program",
|
||||||
"features": {
|
"features": {
|
||||||
"prediction": "AI demand prediction",
|
"prediction": "AI demand prediction",
|
||||||
"inventory": "Automatic inventory management",
|
"inventory": "Automatic inventory management",
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
"error_loading": "Could not load plans. Please try again.",
|
"error_loading": "Could not load plans. Please try again.",
|
||||||
"most_popular": "Most Popular",
|
"most_popular": "Most Popular",
|
||||||
"pilot_program_active": "Pilot Program Active",
|
"pilot_program_active": "Pilot Program Active",
|
||||||
"pilot_program_description": "As a pilot program participant, you get {count} completely free months on the plan you choose, plus a lifetime 20% discount if you decide to continue.",
|
"pilot_program_description": "As a pilot program participant, you get {count} completely free months on the plan you choose.",
|
||||||
"per_month": "per month",
|
"per_month": "per month",
|
||||||
"per_year": "per year",
|
"per_year": "per year",
|
||||||
"save_amount": "Save {amount}/year",
|
"save_amount": "Save {amount}/year",
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "Sé Parte de Nuestra Historia",
|
"title": "Sé Parte de Nuestra Historia",
|
||||||
"subtitle": "Estamos buscando 20 panaderías pioneras para nuestro programa piloto. 3 meses gratis, 20% descuento de por vida, y la oportunidad de moldear el producto.",
|
"subtitle": "Estamos buscando 20 panaderías pioneras para nuestro programa piloto. 3 meses gratis y la oportunidad de moldear el producto.",
|
||||||
"primary": "Solicitar Plaza en el Piloto",
|
"primary": "Solicitar Plaza en el Piloto",
|
||||||
"secondary": "Ver Demo"
|
"secondary": "Ver Demo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "¿Listo para Optimizar tu Panadería?",
|
"title": "¿Listo para Optimizar tu Panadería?",
|
||||||
"description": "Únete al programa piloto y obtén 3 meses gratis + 20% descuento de por vida",
|
"description": "Únete al programa piloto y obtén 3 meses gratis",
|
||||||
"button": "Solicitar Plaza en el Piloto"
|
"button": "Solicitar Plaza en el Piloto"
|
||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
|
|||||||
@@ -1604,7 +1604,7 @@
|
|||||||
{
|
{
|
||||||
"category": "pricing",
|
"category": "pricing",
|
||||||
"question": "¿Cuánto cuesta el programa piloto?",
|
"question": "¿Cuánto cuesta el programa piloto?",
|
||||||
"answer": "El programa piloto es GRATIS durante los primeros 3 meses. Después, pagas solo €49/mes con un 20% de descuento de por vida (precio normal: €79/mes). Sin contratos de permanencia, cancela cuando quieras."
|
"answer": "El programa piloto es GRATIS durante los primeros 3 meses. Sin contratos de permanencia, cancela cuando quieras."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "pricing",
|
"category": "pricing",
|
||||||
|
|||||||
@@ -182,7 +182,7 @@
|
|||||||
"pilot_period": "Primeros 3 meses (piloto)",
|
"pilot_period": "Primeros 3 meses (piloto)",
|
||||||
"regular_price": "€99/mes",
|
"regular_price": "€99/mes",
|
||||||
"pilot_discount_price": "€79/mes",
|
"pilot_discount_price": "€79/mes",
|
||||||
"pilot_discount_note": "20% descuento de por vida si fuiste piloto",
|
"pilot_discount_note": "Programa piloto",
|
||||||
"features": {
|
"features": {
|
||||||
"prediction": "Predicción de demanda con IA",
|
"prediction": "Predicción de demanda con IA",
|
||||||
"inventory": "Gestión automática de inventario",
|
"inventory": "Gestión automática de inventario",
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
"error_loading": "No se pudieron cargar los planes. Por favor, intenta nuevamente.",
|
"error_loading": "No se pudieron cargar los planes. Por favor, intenta nuevamente.",
|
||||||
"most_popular": "Más Popular",
|
"most_popular": "Más Popular",
|
||||||
"pilot_program_active": "Programa Piloto Activo",
|
"pilot_program_active": "Programa Piloto Activo",
|
||||||
"pilot_program_description": "Como participante del programa piloto, obtienes {count} meses completamente gratis en el plan que elijas, más un 20% de descuento de por vida si decides continuar.",
|
"pilot_program_description": "Como participante del programa piloto, obtienes {count} meses completamente gratis en el plan que elijas.",
|
||||||
"per_month": "por mes",
|
"per_month": "por mes",
|
||||||
"per_year": "por año",
|
"per_year": "por año",
|
||||||
"save_amount": "Ahorra {amount}/año",
|
"save_amount": "Ahorra {amount}/año",
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "Izan Gure Historiaren Parte",
|
"title": "Izan Gure Historiaren Parte",
|
||||||
"subtitle": "20 okindegi aitzindari bilatzen ari gara gure pilotu-programarako. 3 hilabete doan, %20ko deskontua bizitza osoan, eta produktua moldatzeko aukera.",
|
"subtitle": "20 okindegi aitzindari bilatzen ari gara gure pilotu-programarako. 3 hilabete doan eta produktua moldatzeko aukera.",
|
||||||
"primary": "Eskatu Pilotuko Plaza",
|
"primary": "Eskatu Pilotuko Plaza",
|
||||||
"secondary": "Ikusi Demoa"
|
"secondary": "Ikusi Demoa"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "Prest Zaude Zure Okindegi Optimizatzeko?",
|
"title": "Prest Zaude Zure Okindegi Optimizatzeko?",
|
||||||
"description": "Batu pilotu programara eta lortu 3 hilabete doan + % 20ko beherapena betiko",
|
"description": "Batu pilotu programara eta lortu 3 hilabete doan",
|
||||||
"button": "Eskatu Pilotuko Plaza"
|
"button": "Eskatu Pilotuko Plaza"
|
||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
|
|||||||
@@ -156,7 +156,7 @@
|
|||||||
{
|
{
|
||||||
"category": "pricing",
|
"category": "pricing",
|
||||||
"question": "Zenbat kostatzen du pilotu-programak?",
|
"question": "Zenbat kostatzen du pilotu-programak?",
|
||||||
"answer": "Pilotu-programa DOAN dago lehenengo 3 hilabeteetan. Gero, 49€/hilean ordaintzen duzu bizitza osoko %20ko deskontua (prezio arrunta: 79€/hilean). Ez dago konpromiso-kontraturik, utzi nahi duzunean."
|
"answer": "Pilotu-programa DOAN dago lehenengo 3 hilabeteetan. Ez dago konpromiso-kontraturik, utzi nahi duzunean."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "technical",
|
"category": "technical",
|
||||||
|
|||||||
@@ -181,7 +181,7 @@
|
|||||||
"pilot_period": "Lehenengo 3 hilabeteak (pilotua)",
|
"pilot_period": "Lehenengo 3 hilabeteak (pilotua)",
|
||||||
"regular_price": "€99/hilean",
|
"regular_price": "€99/hilean",
|
||||||
"pilot_discount_price": "€79/hilean",
|
"pilot_discount_price": "€79/hilean",
|
||||||
"pilot_discount_note": "%20ko deskontua bizitza osoan piloto izan bazinen",
|
"pilot_discount_note": "Pilotu programa",
|
||||||
"features": {
|
"features": {
|
||||||
"prediction": "IArekin eskariaren aurreikuspena",
|
"prediction": "IArekin eskariaren aurreikuspena",
|
||||||
"inventory": "Inbentarioaren kudeaketa automatikoa",
|
"inventory": "Inbentarioaren kudeaketa automatikoa",
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
"error_loading": "Ezin izan dira planak kargatu. Mesedez, saiatu berriro.",
|
"error_loading": "Ezin izan dira planak kargatu. Mesedez, saiatu berriro.",
|
||||||
"most_popular": "Ezagunena",
|
"most_popular": "Ezagunena",
|
||||||
"pilot_program_active": "Programa Piloto Aktiboa",
|
"pilot_program_active": "Programa Piloto Aktiboa",
|
||||||
"pilot_program_description": "Programa pilotoko parte-hartzaile gisa, aukeratzen duzun planean {count} hilabete guztiz doakoak lortzen dituzu, gehi bizitza osorako %20ko deskontua jarraitzea erabakitzen baduzu.",
|
"pilot_program_description": "Programa pilotoko parte-hartzaile gisa, aukeratzen duzun planean {count} hilabete guztiz doakoak lortzen dituzu.",
|
||||||
"per_month": "hileko",
|
"per_month": "hileko",
|
||||||
"per_year": "urteko",
|
"per_year": "urteko",
|
||||||
"save_amount": "Aurreztu {amount}/urtean",
|
"save_amount": "Aurreztu {amount}/urtean",
|
||||||
|
|||||||
@@ -33,13 +33,15 @@ const SubscriptionStatusBanner: React.FC<{
|
|||||||
isTrial?: boolean;
|
isTrial?: boolean;
|
||||||
trialEndsAt?: string;
|
trialEndsAt?: string;
|
||||||
trialDaysRemaining?: number;
|
trialDaysRemaining?: number;
|
||||||
}> = ({ planName, status, nextBilling, cost, onUpgrade, onManagePayment, showUpgradeButton, isTrial, trialEndsAt, trialDaysRemaining }) => {
|
isPilot?: boolean;
|
||||||
|
}> = ({ planName, status, nextBilling, cost, onUpgrade, onManagePayment, showUpgradeButton, isTrial, trialEndsAt, trialDaysRemaining, isPilot }) => {
|
||||||
|
|
||||||
// Determine if we should show trial information
|
// Determine if we should show trial information
|
||||||
const showTrialInfo = isTrial && trialEndsAt;
|
const showTrialInfo = isTrial && trialEndsAt;
|
||||||
|
const showPilotInfo = isPilot;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`p-6 mb-6 ${showTrialInfo
|
<Card className={`p-6 mb-6 ${showTrialInfo || showPilotInfo
|
||||||
? 'bg-gradient-to-r from-blue-50 to-cyan-50 dark:from-blue-900/30 dark:to-cyan-900/30 border-2 border-blue-300 dark:border-blue-600'
|
? 'bg-gradient-to-r from-blue-50 to-cyan-50 dark:from-blue-900/30 dark:to-cyan-900/30 border-2 border-blue-300 dark:border-blue-600'
|
||||||
: 'bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 border-2 border-blue-200 dark:border-blue-700'}`}
|
: 'bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 border-2 border-blue-200 dark:border-blue-700'}`}
|
||||||
>
|
>
|
||||||
@@ -47,27 +49,29 @@ const SubscriptionStatusBanner: React.FC<{
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||||
<Crown className="w-5 h-5 text-yellow-500" />
|
<Crown className="w-5 h-5 text-yellow-500" />
|
||||||
{planName} {showTrialInfo && <span className="text-blue-600 dark:text-blue-400">(Prueba Gratis)</span>}
|
{planName} {showPilotInfo && <span className="text-blue-600 dark:text-blue-400">(Programa Piloto)</span>}
|
||||||
|
{!showPilotInfo && showTrialInfo && <span className="text-blue-600 dark:text-blue-400">(Prueba Gratis)</span>}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge variant={showTrialInfo ? 'info' : status === 'active' ? 'success' : 'default'} className="mt-2">
|
<Badge variant={showPilotInfo ? 'info' : showTrialInfo ? 'info' : status === 'active' ? 'success' : 'default'} className="mt-2">
|
||||||
{showTrialInfo ? `En Prueba (${trialDaysRemaining} días)` : status === 'active' ? 'Activo' : status}
|
{showPilotInfo ? 'Piloto Activo' : showTrialInfo ? `En Prueba (${trialDaysRemaining} días)` : status === 'active' ? 'Activo' : status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-[var(--text-secondary)]">{showTrialInfo ? 'Fin de la Prueba' : 'Próxima Facturación'}</p>
|
<p className="text-sm text-[var(--text-secondary)]">{showPilotInfo ? 'Fin del Piloto' : showTrialInfo ? 'Fin de la Prueba' : 'Próxima Facturación'}</p>
|
||||||
<p className="font-semibold text-[var(--text-primary)]">{showTrialInfo ? trialEndsAt : nextBilling}</p>
|
<p className="font-semibold text-[var(--text-primary)]">{showPilotInfo ? trialEndsAt : showTrialInfo ? trialEndsAt : nextBilling}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Costo Mensual</p>
|
<p className="text-sm text-[var(--text-secondary)]">Costo Mensual</p>
|
||||||
<p className="font-semibold text-[var(--text-primary)] text-lg">
|
<p className="font-semibold text-[var(--text-primary)] text-lg">
|
||||||
{cost} {showTrialInfo && <span className="text-xs text-blue-600 dark:text-blue-400">(después de la prueba)</span>}
|
{cost} {showPilotInfo && <span className="text-xs text-blue-600 dark:text-blue-400">(después del periodo piloto)</span>}
|
||||||
|
{!showPilotInfo && showTrialInfo && <span className="text-xs text-blue-600 dark:text-blue-400">(después de la prueba)</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{showTrialInfo ? (
|
{showPilotInfo || showTrialInfo ? (
|
||||||
<>
|
<>
|
||||||
<Button onClick={onUpgrade} variant="primary" size="sm" className="w-full bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700">
|
<Button onClick={onUpgrade} variant="primary" size="sm" className="w-full bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700">
|
||||||
<ArrowRight className="w-4 h-4 mr-2" />
|
<ArrowRight className="w-4 h-4 mr-2" />
|
||||||
@@ -95,8 +99,8 @@ const SubscriptionStatusBanner: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trial Countdown Banner */}
|
{/* Pilot/Trial Countdown Banner */}
|
||||||
{showTrialInfo && (
|
{(showPilotInfo || showTrialInfo) && (
|
||||||
<div className="mt-4 p-4 bg-gradient-to-r from-blue-600/10 to-cyan-600/10 border border-blue-300/50 rounded-lg">
|
<div className="mt-4 p-4 bg-gradient-to-r from-blue-600/10 to-cyan-600/10 border border-blue-300/50 rounded-lg">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-blue-500/20 rounded-lg border border-blue-500/30 flex-shrink-0">
|
<div className="p-2 bg-blue-500/20 rounded-lg border border-blue-500/30 flex-shrink-0">
|
||||||
@@ -104,7 +108,10 @@ const SubscriptionStatusBanner: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
<p className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||||
¡Disfruta de tu periodo de prueba! Tu acceso gratuito termina en <strong>{trialDaysRemaining} días</strong>.
|
{showPilotInfo
|
||||||
|
? `¡Eres parte de nuestro programa piloto! Tu acceso gratuito termina en <strong>${trialDaysRemaining} días</strong>.`
|
||||||
|
: `¡Disfruta de tu periodo de prueba! Tu acceso gratuito termina en <strong>${trialDaysRemaining} días</strong>.`
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onUpgrade} variant="ghost" size="sm" className="text-blue-600 dark:text-blue-400 hover:bg-blue-500/10">
|
<Button onClick={onUpgrade} variant="ghost" size="sm" className="text-blue-600 dark:text-blue-400 hover:bg-blue-500/10">
|
||||||
@@ -632,6 +639,15 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
// Check if there are any high usage metrics
|
// Check if there are any high usage metrics
|
||||||
const hasHighUsageMetrics = forecastUsage && forecastUsage.highUsageMetrics && forecastUsage.highUsageMetrics.length > 0;
|
const hasHighUsageMetrics = forecastUsage && forecastUsage.highUsageMetrics && forecastUsage.highUsageMetrics.length > 0;
|
||||||
|
|
||||||
|
// Determine if this is a pilot subscription based on characteristics
|
||||||
|
// Pilot subscriptions have extended trial periods (typically 90 days from PILOT2025 coupon)
|
||||||
|
// compared to regular trials (typically 14 days), so we check for longer trial periods
|
||||||
|
const isPilotSubscription = usageSummary.status === 'trialing' &&
|
||||||
|
usageSummary.trial_ends_at &&
|
||||||
|
new Date(usageSummary.trial_ends_at) > new Date() &&
|
||||||
|
// Check if trial period is longer than typical trial (e.g., > 60 days indicates pilot)
|
||||||
|
(new Date(usageSummary.trial_ends_at).getTime() - new Date().getTime()) > (60 * 24 * 60 * 60 * 1000); // 60+ days
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -652,7 +668,8 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
onUpgrade={() => setShowPlans(true)}
|
onUpgrade={() => setShowPlans(true)}
|
||||||
onManagePayment={() => setPaymentMethodModalOpen(true)}
|
onManagePayment={() => setPaymentMethodModalOpen(true)}
|
||||||
showUpgradeButton={usageSummary.plan !== 'enterprise'}
|
showUpgradeButton={usageSummary.plan !== 'enterprise'}
|
||||||
isTrial={usageSummary.status === 'trialing'}
|
isTrial={usageSummary.status === 'trialing' || isPilotSubscription}
|
||||||
|
isPilot={isPilotSubscription}
|
||||||
trialEndsAt={usageSummary.trial_ends_at ? new Date(usageSummary.trial_ends_at).toLocaleDateString('es-ES', {
|
trialEndsAt={usageSummary.trial_ends_at ? new Date(usageSummary.trial_ends_at).toLocaleDateString('es-ES', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
|
|||||||
197
frontend/src/pages/public/ForgotPasswordPage.tsx
Normal file
197
frontend/src/pages/public/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Input, Card } from '../../components/ui';
|
||||||
|
import { useRequestPasswordReset } from '../../api/hooks/auth';
|
||||||
|
import { showToast } from '../../utils/toast';
|
||||||
|
import { validateEmail } from '../../utils/validation';
|
||||||
|
import { PublicLayout } from '../../components/layout';
|
||||||
|
|
||||||
|
const ForgotPasswordPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [errors, setErrors] = useState<{ email?: string }>({});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const { mutate: requestPasswordReset } = useRequestPasswordReset({
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast.success(
|
||||||
|
'Si una cuenta con este email existe, recibirás un enlace de recuperación.',
|
||||||
|
{ title: 'Instrucciones enviadas' }
|
||||||
|
);
|
||||||
|
setIsSubmitted(true);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
const errorMessage = error.response?.data?.detail ||
|
||||||
|
'Hubo un problema al enviar las instrucciones de recuperación.';
|
||||||
|
showToast.error(errorMessage, { title: 'Error' });
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: { email?: string } = {};
|
||||||
|
|
||||||
|
const emailValidation = validateEmail(email);
|
||||||
|
if (!emailValidation.isValid) {
|
||||||
|
newErrors.email = t('auth:validation.email_invalid', 'Por favor, ingrese un email válido');
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
requestPasswordReset(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoBackToLogin = () => {
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSubmitted) {
|
||||||
|
return (
|
||||||
|
<PublicLayout
|
||||||
|
variant="centered"
|
||||||
|
maxWidth="md"
|
||||||
|
headerProps={{
|
||||||
|
showThemeToggle: true,
|
||||||
|
showAuthButtons: false,
|
||||||
|
showLanguageSelector: true,
|
||||||
|
variant: "minimal"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card className="p-8 w-full max-w-md mx-auto" role="main">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="mx-auto bg-color-success/10 p-3 rounded-full w-16 h-16 flex items-center justify-center mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-color-success"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary mb-2">
|
||||||
|
{t('auth:forgot_password.submitted_title', '¡Instrucciones Enviadas!')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-text-secondary">
|
||||||
|
{t('auth:forgot_password.submitted_message', 'Hemos enviado un enlace de recuperación a tu correo electrónico.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
isFullWidth
|
||||||
|
onClick={handleGoBackToLogin}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
Volver al Inicio de Sesión
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</PublicLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout
|
||||||
|
variant="centered"
|
||||||
|
maxWidth="md"
|
||||||
|
headerProps={{
|
||||||
|
showThemeToggle: true,
|
||||||
|
showAuthButtons: false,
|
||||||
|
showLanguageSelector: true,
|
||||||
|
variant: "minimal"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card className="p-8 w-full max-w-md mx-auto" role="main">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary mb-2">
|
||||||
|
{t('auth:forgot_password.title', '¿Olvidaste tu contraseña?')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-text-secondary">
|
||||||
|
{t('auth:forgot_password.subtitle', 'Ingresa tu correo electrónico y te enviaremos un enlace para restablecer tu contraseña.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-6"
|
||||||
|
noValidate
|
||||||
|
aria-label={t('auth:forgot_password.title', 'Formulario de recuperación de contraseña')}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
label={t('auth:login.email', 'Correo Electrónico')}
|
||||||
|
placeholder="tu.email@panaderia.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
error={errors.email}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
aria-describedby={errors.email ? 'email-error' : undefined}
|
||||||
|
leftIcon={
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p id="email-error" className="text-color-error text-sm mt-1" role="alert">
|
||||||
|
{errors.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
isFullWidth
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
loadingText="Enviando instrucciones..."
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Enviando...' : 'Enviar Instrucciones'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGoBackToLogin}
|
||||||
|
className="text-sm text-color-primary hover:text-color-primary-dark transition-colors underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-offset-2 rounded"
|
||||||
|
>
|
||||||
|
← Volver al Inicio de Sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</PublicLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForgotPasswordPage;
|
||||||
@@ -30,6 +30,10 @@ const LoginPage: React.FC = () => {
|
|||||||
navigate('/register');
|
navigate('/register');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleForgotPasswordClick = () => {
|
||||||
|
navigate('/forgot-password');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicLayout
|
<PublicLayout
|
||||||
variant="centered"
|
variant="centered"
|
||||||
@@ -44,6 +48,7 @@ const LoginPage: React.FC = () => {
|
|||||||
<LoginForm
|
<LoginForm
|
||||||
onSuccess={handleLoginSuccess}
|
onSuccess={handleLoginSuccess}
|
||||||
onRegisterClick={handleRegisterClick}
|
onRegisterClick={handleRegisterClick}
|
||||||
|
onForgotPasswordClick={handleForgotPasswordClick}
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
/>
|
/>
|
||||||
</PublicLayout>
|
</PublicLayout>
|
||||||
|
|||||||
313
frontend/src/pages/public/ResetPasswordPage.tsx
Normal file
313
frontend/src/pages/public/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Input, Card } from '../../components/ui';
|
||||||
|
import { useResetPasswordWithToken } from '../../api/hooks/auth';
|
||||||
|
import { showToast } from '../../utils/toast';
|
||||||
|
import { PublicLayout } from '../../components/layout';
|
||||||
|
|
||||||
|
const ResetPasswordPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [errors, setErrors] = useState<{ password?: string; confirmPassword?: string }>({});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
navigate('/forgot-password');
|
||||||
|
showToast.error('Token de restablecimiento inválido o ausente.', { title: 'Error' });
|
||||||
|
}
|
||||||
|
}, [token, navigate]);
|
||||||
|
|
||||||
|
const { mutate: resetPasswordWithToken } = useResetPasswordWithToken({
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast.success('Tu contraseña ha sido restablecida exitosamente.', {
|
||||||
|
title: '¡Contraseña Actualizada!'
|
||||||
|
});
|
||||||
|
setIsSubmitted(true);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
const errorMessage = error.response?.data?.detail ||
|
||||||
|
'Hubo un problema al restablecer tu contraseña.';
|
||||||
|
showToast.error(errorMessage, { title: 'Error' });
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: { password?: string; confirmPassword?: string } = {};
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
newErrors.password = 'La contraseña es requerida';
|
||||||
|
} else if (password.length < 8) {
|
||||||
|
newErrors.password = 'La contraseña debe tener al menos 8 caracteres';
|
||||||
|
} else if (!/(?=.*[a-z])/.test(password)) {
|
||||||
|
newErrors.password = 'La contraseña debe contener al menos una letra minúscula';
|
||||||
|
} else if (!/(?=.*[A-Z])/.test(password)) {
|
||||||
|
newErrors.password = 'La contraseña debe contener al menos una letra mayúscula';
|
||||||
|
} else if (!/(?=.*\d)/.test(password)) {
|
||||||
|
newErrors.password = 'La contraseña debe contener al menos un número';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmPassword) {
|
||||||
|
newErrors.confirmPassword = 'Confirma tu nueva contraseña';
|
||||||
|
} else if (password !== confirmPassword) {
|
||||||
|
newErrors.confirmPassword = 'Las contraseñas no coinciden';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm() || !token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
resetPasswordWithToken({ token, newPassword: password });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoToLogin = () => {
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSubmitted) {
|
||||||
|
return (
|
||||||
|
<PublicLayout
|
||||||
|
variant="centered"
|
||||||
|
maxWidth="md"
|
||||||
|
headerProps={{
|
||||||
|
showThemeToggle: true,
|
||||||
|
showAuthButtons: false,
|
||||||
|
showLanguageSelector: true,
|
||||||
|
variant: "minimal"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card className="p-8 w-full max-w-md mx-auto" role="main">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="mx-auto bg-color-success/10 p-3 rounded-full w-16 h-16 flex items-center justify-center mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-color-success"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary mb-2">
|
||||||
|
{t('auth:reset_password.success_title', '¡Contraseña Restablecida!')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-text-secondary">
|
||||||
|
{t('auth:reset_password.success_message', 'Tu contraseña ha sido actualizada exitosamente.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
isFullWidth
|
||||||
|
onClick={handleGoToLogin}
|
||||||
|
>
|
||||||
|
Iniciar Sesión
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</PublicLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout
|
||||||
|
variant="centered"
|
||||||
|
maxWidth="md"
|
||||||
|
headerProps={{
|
||||||
|
showThemeToggle: true,
|
||||||
|
showAuthButtons: false,
|
||||||
|
showLanguageSelector: true,
|
||||||
|
variant: "minimal"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card className="p-8 w-full max-w-md mx-auto" role="main">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary mb-2">
|
||||||
|
{t('auth:reset_password.title', 'Restablecer Contraseña')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-text-secondary">
|
||||||
|
{t('auth:reset_password.subtitle', 'Ingresa tu nueva contraseña.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-6"
|
||||||
|
noValidate
|
||||||
|
aria-label={t('auth:reset_password.title', 'Formulario de restablecimiento de contraseña')}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
label="Nueva Contraseña"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
error={errors.password}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
aria-describedby={errors.password ? 'password-error' : undefined}
|
||||||
|
leftIcon={
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
rightIcon={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p id="password-error" className="text-color-error text-sm mt-1" role="alert">
|
||||||
|
{errors.password}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
label="Confirmar Nueva Contraseña"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
error={errors.confirmPassword}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
aria-describedby={errors.confirmPassword ? 'confirm-password-error' : undefined}
|
||||||
|
leftIcon={
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
rightIcon={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
aria-label={showConfirmPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? (
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p id="confirm-password-error" className="text-color-error text-sm mt-1" role="alert">
|
||||||
|
{errors.confirmPassword}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
isFullWidth
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
loadingText="Actualizando contraseña..."
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Actualizando...' : 'Actualizar Contraseña'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGoToLogin}
|
||||||
|
className="text-sm text-color-primary hover:text-color-primary-dark transition-colors underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-offset-2 rounded"
|
||||||
|
>
|
||||||
|
← Volver al Inicio de Sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</PublicLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResetPasswordPage;
|
||||||
@@ -13,3 +13,5 @@ export { default as HelpCenterPage } from './HelpCenterPage';
|
|||||||
export { default as DocumentationPage } from './DocumentationPage';
|
export { default as DocumentationPage } from './DocumentationPage';
|
||||||
export { default as ContactPage } from './ContactPage';
|
export { default as ContactPage } from './ContactPage';
|
||||||
export { default as FeedbackPage } from './FeedbackPage';
|
export { default as FeedbackPage } from './FeedbackPage';
|
||||||
|
export { default as ForgotPasswordPage } from './ForgotPasswordPage';
|
||||||
|
export { default as ResetPasswordPage } from './ResetPasswordPage';
|
||||||
@@ -23,6 +23,8 @@ const DocumentationPage = React.lazy(() => import('../pages/public/Documentation
|
|||||||
const ContactPage = React.lazy(() => import('../pages/public/ContactPage'));
|
const ContactPage = React.lazy(() => import('../pages/public/ContactPage'));
|
||||||
const FeedbackPage = React.lazy(() => import('../pages/public/FeedbackPage'));
|
const FeedbackPage = React.lazy(() => import('../pages/public/FeedbackPage'));
|
||||||
const UnauthorizedPage = React.lazy(() => import('../pages/public/UnauthorizedPage'));
|
const UnauthorizedPage = React.lazy(() => import('../pages/public/UnauthorizedPage'));
|
||||||
|
const ForgotPasswordPage = React.lazy(() => import('../pages/public/ForgotPasswordPage'));
|
||||||
|
const ResetPasswordPage = React.lazy(() => import('../pages/public/ResetPasswordPage'));
|
||||||
const DashboardPage = React.lazy(() => import('../pages/app/DashboardPage'));
|
const DashboardPage = React.lazy(() => import('../pages/app/DashboardPage'));
|
||||||
|
|
||||||
// Operations pages
|
// Operations pages
|
||||||
@@ -98,6 +100,8 @@ export const AppRouter: React.FC = () => {
|
|||||||
<Route path="/cookie-preferences" element={<CookiePreferencesPage />} />
|
<Route path="/cookie-preferences" element={<CookiePreferencesPage />} />
|
||||||
<Route path="/unauthorized" element={<UnauthorizedPage />} />
|
<Route path="/unauthorized" element={<UnauthorizedPage />} />
|
||||||
<Route path="/401" element={<UnauthorizedPage />} />
|
<Route path="/401" element={<UnauthorizedPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
|
|
||||||
{/* Protected Routes with AppShell Layout */}
|
{/* Protected Routes with AppShell Layout */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -222,6 +222,32 @@ export const routesConfig: RouteConfig[] = [
|
|||||||
hideSidebar: true,
|
hideSidebar: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ROUTES.FORGOT_PASSWORD,
|
||||||
|
name: 'ForgotPassword',
|
||||||
|
component: 'ForgotPasswordPage',
|
||||||
|
title: 'Recuperar Contraseña',
|
||||||
|
requiresAuth: false,
|
||||||
|
showInNavigation: false,
|
||||||
|
meta: {
|
||||||
|
layout: 'auth',
|
||||||
|
hideHeader: true,
|
||||||
|
hideSidebar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ROUTES.RESET_PASSWORD,
|
||||||
|
name: 'ResetPassword',
|
||||||
|
component: 'ResetPasswordPage',
|
||||||
|
title: 'Restablecer Contraseña',
|
||||||
|
requiresAuth: false,
|
||||||
|
showInNavigation: false,
|
||||||
|
meta: {
|
||||||
|
layout: 'auth',
|
||||||
|
hideHeader: true,
|
||||||
|
hideSidebar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: ROUTES.FEATURES,
|
path: ROUTES.FEATURES,
|
||||||
name: 'Features',
|
name: 'Features',
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ PUBLIC_ROUTES = [
|
|||||||
"/api/v1/auth/start-registration", # Registration step 1 - SetupIntent creation
|
"/api/v1/auth/start-registration", # Registration step 1 - SetupIntent creation
|
||||||
"/api/v1/auth/complete-registration", # Registration step 2 - Completion after 3DS
|
"/api/v1/auth/complete-registration", # Registration step 2 - Completion after 3DS
|
||||||
"/api/v1/auth/verify-email", # Email verification
|
"/api/v1/auth/verify-email", # Email verification
|
||||||
|
"/api/v1/auth/password/reset-request", # Password reset request - no auth required
|
||||||
|
"/api/v1/auth/password/reset", # Password reset with token - no auth required
|
||||||
"/api/v1/nominatim/search",
|
"/api/v1/nominatim/search",
|
||||||
"/api/v1/plans",
|
"/api/v1/plans",
|
||||||
"/api/v1/demo/accounts",
|
"/api/v1/demo/accounts",
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ def create_upgrade_required_response(
|
|||||||
# Additional context
|
# Additional context
|
||||||
'can_preview': feature in ['analytics'],
|
'can_preview': feature in ['analytics'],
|
||||||
'has_free_trial': True,
|
'has_free_trial': True,
|
||||||
'trial_days': 14,
|
'trial_days': 0,
|
||||||
|
|
||||||
# Social proof
|
# Social proof
|
||||||
'social_proof': get_social_proof_message(required_tier),
|
'social_proof': get_social_proof_message(required_tier),
|
||||||
|
|||||||
308
services/auth/app/api/password_reset.py
Normal file
308
services/auth/app/api/password_reset.py
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
# services/auth/app/api/password_reset.py
|
||||||
|
"""
|
||||||
|
Password reset API endpoints
|
||||||
|
Handles forgot password and password reset functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Dict, Any
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
|
from app.services.auth_service import auth_service, AuthService
|
||||||
|
from app.schemas.auth import PasswordReset, PasswordResetConfirm
|
||||||
|
from app.core.security import SecurityManager
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.repositories.password_reset_repository import PasswordResetTokenRepository
|
||||||
|
from app.repositories.user_repository import UserRepository
|
||||||
|
from app.models.users import User
|
||||||
|
from shared.clients.notification_client import NotificationServiceClient
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
# Create router
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["password-reset"])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_auth_service() -> AuthService:
|
||||||
|
"""Dependency injection for auth service"""
|
||||||
|
return auth_service
|
||||||
|
|
||||||
|
|
||||||
|
def generate_reset_token() -> str:
|
||||||
|
"""Generate a secure password reset token"""
|
||||||
|
import secrets
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_password_reset_email(email: str, reset_token: str, user_full_name: str):
|
||||||
|
"""Send password reset email in background using notification service"""
|
||||||
|
try:
|
||||||
|
# Construct reset link (this should match your frontend URL)
|
||||||
|
# Use FRONTEND_URL from settings if available, otherwise fall back to gateway URL
|
||||||
|
frontend_url = getattr(settings, 'FRONTEND_URL', settings.GATEWAY_URL)
|
||||||
|
reset_link = f"{frontend_url}/reset-password?token={reset_token}"
|
||||||
|
|
||||||
|
# Create HTML content for the password reset email in Spanish
|
||||||
|
html_content = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Restablecer Contraseña</title>
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}}
|
||||||
|
.header {{
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.content {{
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 30px;
|
||||||
|
background-color: #4F46E5;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}}
|
||||||
|
.footer {{
|
||||||
|
margin-top: 40px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #666;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Restablecer Contraseña</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Hola {user_full_name},</p>
|
||||||
|
|
||||||
|
<p>Recibimos una solicitud para restablecer tu contraseña. Haz clic en el botón de abajo para crear una nueva contraseña:</p>
|
||||||
|
|
||||||
|
<p style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{reset_link}" class="button">Restablecer Contraseña</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>Si no solicitaste un restablecimiento de contraseña, puedes ignorar este correo electrónico de forma segura.</p>
|
||||||
|
|
||||||
|
<p>Este enlace expirará en 1 hora por razones de seguridad.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Este es un mensaje automático de BakeWise. Por favor, no respondas a este correo electrónico.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create text content as fallback
|
||||||
|
text_content = f"""
|
||||||
|
Hola {user_full_name},
|
||||||
|
|
||||||
|
Recibimos una solicitud para restablecer tu contraseña. Haz clic en el siguiente enlace para crear una nueva contraseña:
|
||||||
|
|
||||||
|
{reset_link}
|
||||||
|
|
||||||
|
Si no solicitaste un restablecimiento de contraseña, puedes ignorar este correo electrónico de forma segura.
|
||||||
|
|
||||||
|
Este enlace expirará en 1 hora por razones de seguridad.
|
||||||
|
|
||||||
|
Este es un mensaje automático de BakeWise. Por favor, no respondas a este correo electrónico.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Send email using the notification service
|
||||||
|
notification_client = NotificationServiceClient(settings)
|
||||||
|
|
||||||
|
# Send the notification using the send_email method
|
||||||
|
await notification_client.send_email(
|
||||||
|
tenant_id="system", # Using system tenant for password resets
|
||||||
|
to_email=email,
|
||||||
|
subject="Restablecer Contraseña",
|
||||||
|
message=text_content,
|
||||||
|
html_content=html_content,
|
||||||
|
priority="high"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Password reset email sent successfully to {email}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send password reset email to {email}: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password/reset-request",
|
||||||
|
summary="Request password reset",
|
||||||
|
description="Send a password reset link to the user's email")
|
||||||
|
async def request_password_reset(
|
||||||
|
reset_request: PasswordReset,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
auth_service: AuthService = Depends(get_auth_service)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Request a password reset
|
||||||
|
|
||||||
|
This endpoint:
|
||||||
|
1. Finds the user by email
|
||||||
|
2. Generates a password reset token
|
||||||
|
3. Stores the token in the database
|
||||||
|
4. Sends a password reset email to the user
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Password reset request for email: {reset_request.email}")
|
||||||
|
|
||||||
|
# Find user by email
|
||||||
|
async with auth_service.database_manager.get_session() as session:
|
||||||
|
user_repo = UserRepository(User, session)
|
||||||
|
user = await user_repo.get_by_field("email", reset_request.email)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
# Don't reveal if email exists to prevent enumeration attacks
|
||||||
|
logger.info(f"Password reset request for non-existent email: {reset_request.email}")
|
||||||
|
return {"message": "If an account with this email exists, a reset link has been sent."}
|
||||||
|
|
||||||
|
# Generate a secure reset token
|
||||||
|
reset_token = generate_reset_token()
|
||||||
|
|
||||||
|
# Set token expiration (e.g., 1 hour)
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||||
|
|
||||||
|
# Store the reset token in the database
|
||||||
|
token_repo = PasswordResetTokenRepository(session)
|
||||||
|
|
||||||
|
# Clean up any existing unused tokens for this user
|
||||||
|
await token_repo.cleanup_expired_tokens()
|
||||||
|
|
||||||
|
# Create new reset token
|
||||||
|
await token_repo.create_token(
|
||||||
|
user_id=str(user.id),
|
||||||
|
token=reset_token,
|
||||||
|
expires_at=expires_at
|
||||||
|
)
|
||||||
|
|
||||||
|
# Commit the transaction
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Send password reset email in background
|
||||||
|
background_tasks.add_task(
|
||||||
|
send_password_reset_email,
|
||||||
|
user.email,
|
||||||
|
reset_token,
|
||||||
|
user.full_name
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Password reset token created for user: {user.email}")
|
||||||
|
|
||||||
|
return {"message": "If an account with this email exists, a reset link has been sent."}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Password reset request failed: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Password reset request failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password/reset",
|
||||||
|
summary="Reset password with token",
|
||||||
|
description="Reset user password using a valid reset token")
|
||||||
|
async def reset_password(
|
||||||
|
reset_confirm: PasswordResetConfirm,
|
||||||
|
auth_service: AuthService = Depends(get_auth_service)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Reset password using a valid reset token
|
||||||
|
|
||||||
|
This endpoint:
|
||||||
|
1. Validates the reset token
|
||||||
|
2. Checks if the token is valid and not expired
|
||||||
|
3. Updates the user's password
|
||||||
|
4. Marks the token as used
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Password reset attempt with token: {reset_confirm.token[:10]}...")
|
||||||
|
|
||||||
|
# Validate password strength
|
||||||
|
if not SecurityManager.validate_password(reset_confirm.new_password):
|
||||||
|
errors = SecurityManager.get_password_validation_errors(reset_confirm.new_password)
|
||||||
|
logger.warning(f"Password validation failed: {errors}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Password does not meet requirements: {'; '.join(errors)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find the reset token in the database
|
||||||
|
async with auth_service.database_manager.get_session() as session:
|
||||||
|
token_repo = PasswordResetTokenRepository(session)
|
||||||
|
reset_token_obj = await token_repo.get_token_by_value(reset_confirm.token)
|
||||||
|
|
||||||
|
if not reset_token_obj:
|
||||||
|
logger.warning(f"Invalid or expired password reset token: {reset_confirm.token[:10]}...")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid or expired reset token"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the user associated with this token
|
||||||
|
user_repo = UserRepository(User, session)
|
||||||
|
user = await user_repo.get_by_id(str(reset_token_obj.user_id))
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logger.error(f"User not found for reset token: {reset_confirm.token[:10]}...")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid reset token"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hash the new password
|
||||||
|
hashed_password = SecurityManager.hash_password(reset_confirm.new_password)
|
||||||
|
|
||||||
|
# Update user's password
|
||||||
|
await user_repo.update(str(user.id), {
|
||||||
|
"hashed_password": hashed_password
|
||||||
|
})
|
||||||
|
|
||||||
|
# Mark the reset token as used
|
||||||
|
await token_repo.mark_token_as_used(str(reset_token_obj.id))
|
||||||
|
|
||||||
|
# Commit the transactions
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Password successfully reset for user: {user.email}")
|
||||||
|
|
||||||
|
return {"message": "Password has been reset successfully"}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Password reset failed: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Password reset failed"
|
||||||
|
)
|
||||||
@@ -356,6 +356,12 @@ class SecurityManager:
|
|||||||
import secrets
|
import secrets
|
||||||
return secrets.token_urlsafe(length)
|
return secrets.token_urlsafe(length)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_reset_token() -> str:
|
||||||
|
"""Generate a secure password reset token"""
|
||||||
|
import secrets
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def mask_sensitive_data(data: str, visible_chars: int = 4) -> str:
|
def mask_sensitive_data(data: str, visible_chars: int = 4) -> str:
|
||||||
"""Mask sensitive data for logging"""
|
"""Mask sensitive data for logging"""
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import FastAPI
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import database_manager
|
from app.core.database import database_manager
|
||||||
from app.api import auth_operations, users, onboarding_progress, consent, data_export, account_deletion, internal_demo
|
from app.api import auth_operations, users, onboarding_progress, consent, data_export, account_deletion, internal_demo, password_reset
|
||||||
from shared.service_base import StandardFastAPIService
|
from shared.service_base import StandardFastAPIService
|
||||||
from shared.messaging import UnifiedEventPublisher
|
from shared.messaging import UnifiedEventPublisher
|
||||||
|
|
||||||
@@ -170,3 +170,4 @@ service.add_router(consent.router, tags=["gdpr", "consent"])
|
|||||||
service.add_router(data_export.router, tags=["gdpr", "data-export"])
|
service.add_router(data_export.router, tags=["gdpr", "data-export"])
|
||||||
service.add_router(account_deletion.router, tags=["gdpr", "account-deletion"])
|
service.add_router(account_deletion.router, tags=["gdpr", "account-deletion"])
|
||||||
service.add_router(internal_demo.router, tags=["internal-demo"])
|
service.add_router(internal_demo.router, tags=["internal-demo"])
|
||||||
|
service.add_router(password_reset.router, tags=["password-reset"])
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .tokens import RefreshToken, LoginAttempt
|
|||||||
from .onboarding import UserOnboardingProgress, UserOnboardingSummary
|
from .onboarding import UserOnboardingProgress, UserOnboardingSummary
|
||||||
from .consent import UserConsent, ConsentHistory
|
from .consent import UserConsent, ConsentHistory
|
||||||
from .deletion_job import DeletionJob
|
from .deletion_job import DeletionJob
|
||||||
|
from .password_reset_tokens import PasswordResetToken
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'User',
|
'User',
|
||||||
@@ -25,5 +26,6 @@ __all__ = [
|
|||||||
'UserConsent',
|
'UserConsent',
|
||||||
'ConsentHistory',
|
'ConsentHistory',
|
||||||
'DeletionJob',
|
'DeletionJob',
|
||||||
|
'PasswordResetToken',
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
]
|
]
|
||||||
39
services/auth/app/models/password_reset_tokens.py
Normal file
39
services/auth/app/models/password_reset_tokens.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# services/auth/app/models/password_reset_tokens.py
|
||||||
|
"""
|
||||||
|
Password reset token model for authentication service
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Column, String, DateTime, Boolean, Index
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from shared.database.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetToken(Base):
|
||||||
|
"""
|
||||||
|
Password reset token model
|
||||||
|
Stores temporary tokens for password reset functionality
|
||||||
|
"""
|
||||||
|
__tablename__ = "password_reset_tokens"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
token = Column(String(255), nullable=False, unique=True, index=True)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
is_used = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
used_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# Add indexes for better performance
|
||||||
|
__table_args__ = (
|
||||||
|
Index('ix_password_reset_tokens_user_id', 'user_id'),
|
||||||
|
Index('ix_password_reset_tokens_token', 'token'),
|
||||||
|
Index('ix_password_reset_tokens_expires_at', 'expires_at'),
|
||||||
|
Index('ix_password_reset_tokens_is_used', 'is_used'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<PasswordResetToken(id={self.id}, user_id={self.user_id}, token={self.token[:10]}..., is_used={self.is_used})>"
|
||||||
124
services/auth/app/repositories/password_reset_repository.py
Normal file
124
services/auth/app/repositories/password_reset_repository.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# services/auth/app/repositories/password_reset_repository.py
|
||||||
|
"""
|
||||||
|
Password reset token repository
|
||||||
|
Repository for password reset token operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, and_, text
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import structlog
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from .base import AuthBaseRepository
|
||||||
|
from app.models.password_reset_tokens import PasswordResetToken
|
||||||
|
from shared.database.exceptions import DatabaseError
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetTokenRepository(AuthBaseRepository):
|
||||||
|
"""Repository for password reset token operations"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
super().__init__(PasswordResetToken, session)
|
||||||
|
|
||||||
|
async def create_token(self, user_id: str, token: str, expires_at: datetime) -> PasswordResetToken:
|
||||||
|
"""Create a new password reset token"""
|
||||||
|
try:
|
||||||
|
token_data = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"token": token,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"is_used": False
|
||||||
|
}
|
||||||
|
|
||||||
|
reset_token = await self.create(token_data)
|
||||||
|
|
||||||
|
logger.debug("Password reset token created",
|
||||||
|
user_id=user_id,
|
||||||
|
token_id=reset_token.id,
|
||||||
|
expires_at=expires_at)
|
||||||
|
|
||||||
|
return reset_token
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to create password reset token",
|
||||||
|
user_id=user_id,
|
||||||
|
error=str(e))
|
||||||
|
raise DatabaseError(f"Failed to create password reset token: {str(e)}")
|
||||||
|
|
||||||
|
async def get_token_by_value(self, token: str) -> Optional[PasswordResetToken]:
|
||||||
|
"""Get password reset token by token value"""
|
||||||
|
try:
|
||||||
|
stmt = select(PasswordResetToken).where(
|
||||||
|
and_(
|
||||||
|
PasswordResetToken.token == token,
|
||||||
|
PasswordResetToken.is_used == False,
|
||||||
|
PasswordResetToken.expires_at > datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get password reset token by value", error=str(e))
|
||||||
|
raise DatabaseError(f"Failed to get password reset token: {str(e)}")
|
||||||
|
|
||||||
|
async def mark_token_as_used(self, token_id: str) -> Optional[PasswordResetToken]:
|
||||||
|
"""Mark a password reset token as used"""
|
||||||
|
try:
|
||||||
|
return await self.update(token_id, {
|
||||||
|
"is_used": True,
|
||||||
|
"used_at": datetime.now(timezone.utc)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to mark password reset token as used",
|
||||||
|
token_id=token_id,
|
||||||
|
error=str(e))
|
||||||
|
raise DatabaseError(f"Failed to mark token as used: {str(e)}")
|
||||||
|
|
||||||
|
async def cleanup_expired_tokens(self) -> int:
|
||||||
|
"""Clean up expired password reset tokens"""
|
||||||
|
try:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Delete expired tokens
|
||||||
|
query = text("""
|
||||||
|
DELETE FROM password_reset_tokens
|
||||||
|
WHERE expires_at < :now OR is_used = true
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = await self.session.execute(query, {"now": now})
|
||||||
|
deleted_count = result.rowcount
|
||||||
|
|
||||||
|
logger.info("Cleaned up expired password reset tokens",
|
||||||
|
deleted_count=deleted_count)
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to cleanup expired password reset tokens", error=str(e))
|
||||||
|
raise DatabaseError(f"Token cleanup failed: {str(e)}")
|
||||||
|
|
||||||
|
async def get_valid_token_for_user(self, user_id: str) -> Optional[PasswordResetToken]:
|
||||||
|
"""Get a valid (unused, not expired) password reset token for a user"""
|
||||||
|
try:
|
||||||
|
stmt = select(PasswordResetToken).where(
|
||||||
|
and_(
|
||||||
|
PasswordResetToken.user_id == user_id,
|
||||||
|
PasswordResetToken.is_used == False,
|
||||||
|
PasswordResetToken.expires_at > datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
).order_by(PasswordResetToken.created_at.desc())
|
||||||
|
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get valid token for user",
|
||||||
|
user_id=user_id,
|
||||||
|
error=str(e))
|
||||||
|
raise DatabaseError(f"Failed to get valid token for user: {str(e)}")
|
||||||
@@ -351,6 +351,8 @@ class AuthService:
|
|||||||
'subscription_id': result['subscription_id'],
|
'subscription_id': result['subscription_id'],
|
||||||
'payment_customer_id': result['payment_customer_id'],
|
'payment_customer_id': result['payment_customer_id'],
|
||||||
'status': result['status'],
|
'status': result['status'],
|
||||||
|
'access_token': result.get('access_token'),
|
||||||
|
'refresh_token': result.get('refresh_token'),
|
||||||
'message': 'Registration completed successfully after 3DS verification'
|
'message': 'Registration completed successfully after 3DS verification'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,9 +595,129 @@ class AuthService:
|
|||||||
detail=f"Token generation failed: {str(e)}"
|
detail=f"Token generation failed: {str(e)}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
async def request_password_reset(self, email: str) -> bool:
|
||||||
|
"""
|
||||||
|
Request a password reset for a user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: User's email address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if request was processed (whether user exists or not)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Processing password reset request for email: {email}")
|
||||||
|
|
||||||
|
async with self.database_manager.get_session() as session:
|
||||||
|
user_repo = UserRepository(User, session)
|
||||||
|
|
||||||
|
# Find user by email (don't reveal if user exists)
|
||||||
|
user = await user_repo.get_by_field("email", email)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logger.info(f"Password reset request for non-existent email: {email}")
|
||||||
|
return True # Don't reveal if email exists
|
||||||
|
|
||||||
|
# Generate reset token
|
||||||
|
reset_token = SecurityManager.generate_reset_token()
|
||||||
|
|
||||||
|
# Set expiration (1 hour)
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||||
|
|
||||||
|
# Store reset token
|
||||||
|
token_repo = PasswordResetTokenRepository(session)
|
||||||
|
|
||||||
|
# Clean up expired tokens
|
||||||
|
await token_repo.cleanup_expired_tokens()
|
||||||
|
|
||||||
|
# Create new token
|
||||||
|
await token_repo.create_token(
|
||||||
|
user_id=str(user.id),
|
||||||
|
token=reset_token,
|
||||||
|
expires_at=expires_at
|
||||||
|
)
|
||||||
|
|
||||||
|
# Commit transaction
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Password reset token created for user: {email}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Password reset request failed: {str(e)}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def reset_password_with_token(self, token: str, new_password: str) -> bool:
|
||||||
|
"""
|
||||||
|
Reset password using a valid reset token
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: Password reset token
|
||||||
|
new_password: New password to set
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if password was successfully reset
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Processing password reset with token: {token[:10]}...")
|
||||||
|
|
||||||
|
# Validate password strength
|
||||||
|
if not SecurityManager.validate_password(new_password):
|
||||||
|
errors = SecurityManager.get_password_validation_errors(new_password)
|
||||||
|
logger.warning(f"Password validation failed: {errors}")
|
||||||
|
raise ValueError(f"Password does not meet requirements: {'; '.join(errors)}")
|
||||||
|
|
||||||
|
async with self.database_manager.get_session() as session:
|
||||||
|
# Find the reset token
|
||||||
|
token_repo = PasswordResetTokenRepository(session)
|
||||||
|
reset_token_obj = await token_repo.get_token_by_value(token)
|
||||||
|
|
||||||
|
if not reset_token_obj:
|
||||||
|
logger.warning(f"Invalid or expired password reset token: {token[:10]}...")
|
||||||
|
raise ValueError("Invalid or expired reset token")
|
||||||
|
|
||||||
|
# Get the user
|
||||||
|
user_repo = UserRepository(User, session)
|
||||||
|
user = await user_repo.get_by_id(str(reset_token_obj.user_id))
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logger.error(f"User not found for reset token: {token[:10]}...")
|
||||||
|
raise ValueError("Invalid reset token")
|
||||||
|
|
||||||
|
# Hash the new password
|
||||||
|
hashed_password = SecurityManager.hash_password(new_password)
|
||||||
|
|
||||||
|
# Update user's password
|
||||||
|
await user_repo.update(str(user.id), {
|
||||||
|
"hashed_password": hashed_password
|
||||||
|
})
|
||||||
|
|
||||||
|
# Mark token as used
|
||||||
|
await token_repo.mark_token_as_used(str(reset_token_obj.id))
|
||||||
|
|
||||||
|
# Commit transaction
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Password successfully reset for user: {user.email}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
# Re-raise value errors (validation errors)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Password reset failed: {str(e)}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
# Import database manager for singleton instance
|
# Import database manager for singleton instance
|
||||||
from app.core.database import database_manager
|
from app.core.database import database_manager
|
||||||
|
|
||||||
|
# Import required modules for the password reset functionality
|
||||||
|
from app.repositories.password_reset_repository import PasswordResetTokenRepository
|
||||||
|
from shared.clients.notification_client import NotificationServiceClient
|
||||||
|
|
||||||
# Singleton instance for dependency injection
|
# Singleton instance for dependency injection
|
||||||
auth_service = AuthService(database_manager=database_manager)
|
auth_service = AuthService(database_manager=database_manager)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Add password reset token table
|
||||||
|
|
||||||
|
Revision ID: 20260116_add_password_reset_token_table
|
||||||
|
Revises: 20260113_add_payment_columns_to_users.py
|
||||||
|
Create Date: 2026-01-16 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = '20260116_add_password_reset_token_table'
|
||||||
|
down_revision = '20260113_add_payment_columns_to_users.py'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create password_reset_tokens table
|
||||||
|
op.create_table(
|
||||||
|
'password_reset_tokens',
|
||||||
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('token', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('is_used', sa.Boolean(), nullable=False, default=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.text("timezone('utc', CURRENT_TIMESTAMP)")),
|
||||||
|
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('token'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes
|
||||||
|
op.create_index('ix_password_reset_tokens_user_id', 'password_reset_tokens', ['user_id'])
|
||||||
|
op.create_index('ix_password_reset_tokens_token', 'password_reset_tokens', ['token'])
|
||||||
|
op.create_index('ix_password_reset_tokens_expires_at', 'password_reset_tokens', ['expires_at'])
|
||||||
|
op.create_index('ix_password_reset_tokens_is_used', 'password_reset_tokens', ['is_used'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop indexes
|
||||||
|
op.drop_index('ix_password_reset_tokens_is_used', table_name='password_reset_tokens')
|
||||||
|
op.drop_index('ix_password_reset_tokens_expires_at', table_name='password_reset_tokens')
|
||||||
|
op.drop_index('ix_password_reset_tokens_token', table_name='password_reset_tokens')
|
||||||
|
op.drop_index('ix_password_reset_tokens_user_id', table_name='password_reset_tokens')
|
||||||
|
|
||||||
|
# Drop table
|
||||||
|
op.drop_table('password_reset_tokens')
|
||||||
@@ -214,8 +214,8 @@ async def register_bakery(
|
|||||||
subscription_id=str(existing_subscription.id)
|
subscription_id=str(existing_subscription.id)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Create starter subscription with 14-day trial
|
# Create starter subscription with 0-day trial
|
||||||
trial_end_date = datetime.now(timezone.utc) + timedelta(days=14)
|
trial_end_date = datetime.now(timezone.utc)
|
||||||
next_billing_date = trial_end_date
|
next_billing_date = trial_end_date
|
||||||
|
|
||||||
await subscription_repo.create_subscription({
|
await subscription_repo.create_subscription({
|
||||||
@@ -229,10 +229,10 @@ async def register_bakery(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Default free trial subscription created for new tenant",
|
"Default subscription created for new tenant",
|
||||||
tenant_id=str(result.id),
|
tenant_id=str(result.id),
|
||||||
plan="starter",
|
plan="starter",
|
||||||
trial_days=14
|
trial_days=0
|
||||||
)
|
)
|
||||||
except Exception as subscription_error:
|
except Exception as subscription_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class TenantSettings(BaseServiceSettings):
|
|||||||
|
|
||||||
# Subscription Plans
|
# Subscription Plans
|
||||||
DEFAULT_PLAN: str = os.getenv("DEFAULT_PLAN", "basic")
|
DEFAULT_PLAN: str = os.getenv("DEFAULT_PLAN", "basic")
|
||||||
TRIAL_PERIOD_DAYS: int = int(os.getenv("TRIAL_PERIOD_DAYS", "14"))
|
TRIAL_PERIOD_DAYS: int = int(os.getenv("TRIAL_PERIOD_DAYS", "0"))
|
||||||
|
|
||||||
# Plan Limits
|
# Plan Limits
|
||||||
BASIC_PLAN_LOCATIONS: int = int(os.getenv("BASIC_PLAN_LOCATIONS", "1"))
|
BASIC_PLAN_LOCATIONS: int = int(os.getenv("BASIC_PLAN_LOCATIONS", "1"))
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class CouponRepository:
|
|||||||
self,
|
self,
|
||||||
code: str,
|
code: str,
|
||||||
tenant_id: Optional[str],
|
tenant_id: Optional[str],
|
||||||
base_trial_days: int = 14
|
base_trial_days: int = 0
|
||||||
) -> tuple[bool, Optional[CouponRedemption], Optional[str]]:
|
) -> tuple[bool, Optional[CouponRedemption], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Redeem a coupon for a tenant.
|
Redeem a coupon for a tenant.
|
||||||
@@ -289,9 +289,9 @@ class CouponRepository:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if coupon.discount_type == DiscountType.TRIAL_EXTENSION:
|
if coupon.discount_type == DiscountType.TRIAL_EXTENSION:
|
||||||
trial_end = calculate_trial_end_date(14, coupon.discount_value)
|
trial_end = calculate_trial_end_date(0, coupon.discount_value)
|
||||||
preview["trial_end_date"] = trial_end.isoformat()
|
preview["trial_end_date"] = trial_end.isoformat()
|
||||||
preview["total_trial_days"] = 14 + coupon.discount_value
|
preview["total_trial_days"] = 0 + coupon.discount_value
|
||||||
|
|
||||||
return preview
|
return preview
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class CouponService:
|
|||||||
self,
|
self,
|
||||||
coupon_code: str,
|
coupon_code: str,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
base_trial_days: int = 14
|
base_trial_days: int = 0
|
||||||
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Redeem a coupon for a tenant
|
Redeem a coupon for a tenant
|
||||||
|
|||||||
@@ -824,7 +824,7 @@ class SubscriptionOrchestrationService:
|
|||||||
self,
|
self,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
coupon_code: str,
|
coupon_code: str,
|
||||||
base_trial_days: int = 14
|
base_trial_days: int = 0
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Orchestrate coupon redemption workflow
|
Orchestrate coupon redemption workflow
|
||||||
|
|||||||
@@ -594,7 +594,7 @@ class SubscriptionPlanMetadata:
|
|||||||
"popular": False,
|
"popular": False,
|
||||||
"monthly_price": PlanPricing.MONTHLY_PRICES[SubscriptionTier.STARTER],
|
"monthly_price": PlanPricing.MONTHLY_PRICES[SubscriptionTier.STARTER],
|
||||||
"yearly_price": PlanPricing.YEARLY_PRICES[SubscriptionTier.STARTER],
|
"yearly_price": PlanPricing.YEARLY_PRICES[SubscriptionTier.STARTER],
|
||||||
"trial_days": 14,
|
"trial_days": 0,
|
||||||
"features": PlanFeatures.STARTER_FEATURES,
|
"features": PlanFeatures.STARTER_FEATURES,
|
||||||
|
|
||||||
# Hero features (displayed prominently)
|
# Hero features (displayed prominently)
|
||||||
@@ -635,7 +635,7 @@ class SubscriptionPlanMetadata:
|
|||||||
"popular": True, # Most popular plan
|
"popular": True, # Most popular plan
|
||||||
"monthly_price": PlanPricing.MONTHLY_PRICES[SubscriptionTier.PROFESSIONAL],
|
"monthly_price": PlanPricing.MONTHLY_PRICES[SubscriptionTier.PROFESSIONAL],
|
||||||
"yearly_price": PlanPricing.YEARLY_PRICES[SubscriptionTier.PROFESSIONAL],
|
"yearly_price": PlanPricing.YEARLY_PRICES[SubscriptionTier.PROFESSIONAL],
|
||||||
"trial_days": 14,
|
"trial_days": 0,
|
||||||
"features": PlanFeatures.PROFESSIONAL_FEATURES,
|
"features": PlanFeatures.PROFESSIONAL_FEATURES,
|
||||||
|
|
||||||
# Hero features (displayed prominently)
|
# Hero features (displayed prominently)
|
||||||
@@ -678,7 +678,7 @@ class SubscriptionPlanMetadata:
|
|||||||
"popular": False,
|
"popular": False,
|
||||||
"monthly_price": PlanPricing.MONTHLY_PRICES[SubscriptionTier.ENTERPRISE],
|
"monthly_price": PlanPricing.MONTHLY_PRICES[SubscriptionTier.ENTERPRISE],
|
||||||
"yearly_price": PlanPricing.YEARLY_PRICES[SubscriptionTier.ENTERPRISE],
|
"yearly_price": PlanPricing.YEARLY_PRICES[SubscriptionTier.ENTERPRISE],
|
||||||
"trial_days": 30,
|
"trial_days": 0,
|
||||||
"features": PlanFeatures.ENTERPRISE_FEATURES,
|
"features": PlanFeatures.ENTERPRISE_FEATURES,
|
||||||
|
|
||||||
# Hero features (displayed prominently)
|
# Hero features (displayed prominently)
|
||||||
|
|||||||
Reference in New Issue
Block a user