Add subcription feature 5
This commit is contained in:
@@ -160,11 +160,20 @@ export const useChangePassword = (
|
||||
});
|
||||
};
|
||||
|
||||
export const useResetPassword = (
|
||||
options?: UseMutationOptions<{ message: string }, ApiError, PasswordReset>
|
||||
export const useRequestPasswordReset = (
|
||||
options?: UseMutationOptions<{ message: string }, ApiError, string>
|
||||
) => {
|
||||
return useMutation<{ message: string }, ApiError, PasswordReset>({
|
||||
mutationFn: (resetData: PasswordReset) => authService.resetPassword(resetData),
|
||||
return useMutation<{ message: string }, ApiError, string>({
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -440,7 +440,8 @@ export {
|
||||
useRefreshToken,
|
||||
useLogout,
|
||||
useChangePassword,
|
||||
useResetPassword,
|
||||
useRequestPasswordReset,
|
||||
useResetPasswordWithToken,
|
||||
useUpdateProfile,
|
||||
useVerifyEmail,
|
||||
authKeys,
|
||||
|
||||
@@ -93,8 +93,15 @@ export class AuthService {
|
||||
return apiClient.post<{ message: string }>(`${this.baseUrl}/change-password`, passwordData);
|
||||
}
|
||||
|
||||
async resetPassword(resetData: PasswordReset): Promise<{ message: string }> {
|
||||
return apiClient.post<{ message: string }>(`${this.baseUrl}/reset-password`, resetData);
|
||||
async requestPasswordReset(email: string): Promise<{ message: string }> {
|
||||
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', '••••••••')}
|
||||
error={errors.confirmPassword || formData.confirmPasswordError}
|
||||
isValid={formData.confirmPassword.length > 0 && formData.confirmPassword === formData.password}
|
||||
className="w-full"
|
||||
rightIcon={showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
onRightIconClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button, Input, Card } from '../../ui';
|
||||
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
|
||||
import { useAuthActions } from '../../../stores/auth.store';
|
||||
import { showToast } from '../../../utils/toast';
|
||||
import { useResetPassword } from '../../../api/hooks/auth';
|
||||
import { useResetPasswordWithToken } from '../../../api/hooks/auth';
|
||||
|
||||
interface PasswordResetFormProps {
|
||||
token?: string;
|
||||
@@ -36,7 +36,7 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Password reset mutation hooks
|
||||
const { mutateAsync: resetPasswordMutation, isPending: isResetting } = useResetPassword();
|
||||
const { mutateAsync: resetPasswordMutation, isPending: isResetting } = useResetPasswordWithToken();
|
||||
const isLoading = isResetting;
|
||||
const error = null;
|
||||
|
||||
@@ -184,7 +184,7 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
|
||||
// Call the reset password API
|
||||
await resetPasswordMutation({
|
||||
token: token,
|
||||
new_password: password
|
||||
newPassword: password
|
||||
});
|
||||
|
||||
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 { ThreeDSAuthenticationRequired } from '@/api/types/auth';
|
||||
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 { useAuthStore } from '../../../stores/auth.store';
|
||||
import { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT } from '../../../utils/jwt';
|
||||
@@ -23,17 +23,13 @@ const SUPPORTED_COUNTRIES = [
|
||||
];
|
||||
|
||||
interface PaymentStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onPaymentComplete: (paymentData: any) => void;
|
||||
registrationState: any;
|
||||
updateRegistrationState: (updates: any) => void;
|
||||
}
|
||||
|
||||
export const PaymentStep: React.FC<PaymentStepProps> = ({
|
||||
onNext,
|
||||
onBack,
|
||||
onPaymentComplete,
|
||||
registrationState,
|
||||
updateRegistrationState
|
||||
}) => {
|
||||
@@ -285,20 +281,44 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Payment setup successful
|
||||
updateRegistrationState({
|
||||
paymentSetup: {
|
||||
success: true,
|
||||
customerId: paymentSetupResult.customer_id ?? '',
|
||||
subscriptionId: paymentSetupResult.subscription_id ?? '',
|
||||
paymentMethodId: paymentMethod.id,
|
||||
planId: registrationState.subscription.planId,
|
||||
// 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 ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
onPaymentComplete(paymentSetupResult);
|
||||
|
||||
if (completionResult.success) {
|
||||
updateRegistrationState({
|
||||
paymentSetup: {
|
||||
success: true,
|
||||
customerId: completionResult.payment_customer_id ?? paymentSetupResult.customer_id ?? '',
|
||||
subscriptionId: completionResult.subscription_id ?? '',
|
||||
paymentMethodId: paymentMethod.id,
|
||||
planId: registrationState.subscription.planId,
|
||||
},
|
||||
});
|
||||
|
||||
// Perform auto-login if tokens are returned
|
||||
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) {
|
||||
if (error instanceof ThreeDSAuthenticationRequired) {
|
||||
setRequires3DS(true);
|
||||
@@ -384,28 +404,12 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
|
||||
});
|
||||
|
||||
// Perform auto-login if tokens are returned
|
||||
const autoLoginSuccess = performAutoLogin(verificationResult);
|
||||
performAutoLogin(verificationResult);
|
||||
|
||||
if (autoLoginSuccess) {
|
||||
// Show success message
|
||||
showToast.success(t('auth:registration_complete', 'Registration complete! Redirecting to onboarding...'));
|
||||
|
||||
// Navigate directly to onboarding
|
||||
navigate('/app/onboarding');
|
||||
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);
|
||||
// Show success message and navigate to onboarding
|
||||
showToast.success(t('auth:registration_complete', 'Registration complete! Redirecting to onboarding...'));
|
||||
navigate('/app/onboarding');
|
||||
return;
|
||||
} else {
|
||||
setError(verificationResult.message || t('auth:payment_verification_failed'));
|
||||
}
|
||||
@@ -424,30 +428,15 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4 mx-auto">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
// Auto-redirect on success (safety net - should not normally reach here)
|
||||
useEffect(() => {
|
||||
if (success) {
|
||||
const timer = setTimeout(() => {
|
||||
navigate('/app/onboarding');
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [success, navigate]);
|
||||
|
||||
if (requires3DS) {
|
||||
return (
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Card, Progress } from '../../ui';
|
||||
import { Card } from '../../ui';
|
||||
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 { subscriptionService, apiClient } from '../../../api';
|
||||
import { apiClient } from '../../../api';
|
||||
import { validateEmail } from '../../../utils/validation';
|
||||
import { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT } from '../../../utils/jwt';
|
||||
import { loadStripe, Stripe } from '@stripe/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 SubscriptionStep from './SubscriptionStep';
|
||||
import { PaymentStep } from './PaymentStep';
|
||||
@@ -38,7 +38,6 @@ interface RegistrationContainerProps {
|
||||
}
|
||||
|
||||
export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
||||
onSuccess,
|
||||
onLoginClick,
|
||||
className
|
||||
}) => {
|
||||
@@ -46,15 +45,12 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
||||
const navigate = useNavigate();
|
||||
const { isPilot, couponCode } = usePilotDetection();
|
||||
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) => {
|
||||
if (result.access_token && result.refresh_token) {
|
||||
// Set tokens in API client
|
||||
@@ -92,11 +88,12 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
// Get URL parameters
|
||||
const planParam = searchParams.get('plan');
|
||||
const VALID_PLANS = ['starter', 'professional', 'enterprise'];
|
||||
const preSelectedPlan = (planParam && VALID_PLANS.includes(planParam)) ? planParam : null;
|
||||
const VALID_PLANS = ['starter', 'professional', 'enterprise'] as const;
|
||||
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 urlBillingCycle = (billingParam === 'monthly' || billingParam === 'yearly') ? billingParam : null;
|
||||
@@ -116,21 +113,21 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
||||
|
||||
const {
|
||||
step,
|
||||
setStep,
|
||||
registrationState,
|
||||
updateRegistrationState,
|
||||
nextStep,
|
||||
prevStep,
|
||||
isSubmitting,
|
||||
setIsSubmitting
|
||||
isSubmitting
|
||||
} = useRegistrationState({
|
||||
initialPlan: preSelectedPlan || 'starter',
|
||||
initialBillingCycle: urlBillingCycle || 'monthly',
|
||||
isPilot: effectivePilotState // Use the effective pilot state
|
||||
});
|
||||
|
||||
|
||||
const [stripeInstance, setStripeInstance] = useState<Stripe | null>(null);
|
||||
// paymentComplete is used for 3DS redirect recovery to prevent re-processing
|
||||
const [paymentComplete, setPaymentComplete] = useState(false);
|
||||
const [paymentData, setPaymentData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStripeInstance = async () => {
|
||||
@@ -191,9 +188,9 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
||||
updateRegistrationState({
|
||||
basicInfo: savedState.basicInfo,
|
||||
subscription: savedState.subscription,
|
||||
paymentSetup: savedState.paymentSetup,
|
||||
step: 'payment'
|
||||
paymentSetup: savedState.paymentSetup
|
||||
});
|
||||
setStep('payment');
|
||||
|
||||
// Verify the SetupIntent status with our backend
|
||||
const verificationResult = await verifySetupIntent(setupIntentParam);
|
||||
@@ -227,6 +224,9 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
||||
threeds_completed: true
|
||||
};
|
||||
|
||||
// Complete the registration after 3DS verification
|
||||
const result = await completeRegistrationAfterPayment(registrationData);
|
||||
|
||||
if (result.success) {
|
||||
// Clean up saved state
|
||||
sessionStorage.removeItem(savedStateKey);
|
||||
@@ -238,10 +238,15 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
||||
newUrl.searchParams.delete('redirect_status');
|
||||
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...'));
|
||||
|
||||
// Navigate to onboarding (same as in PaymentStep)
|
||||
// Use a small timeout to ensure state updates are processed before navigation
|
||||
// Mark payment as complete to prevent re-processing
|
||||
setPaymentComplete(true);
|
||||
|
||||
// Navigate to onboarding
|
||||
setTimeout(() => {
|
||||
navigate('/app/onboarding');
|
||||
}, 100);
|
||||
@@ -261,13 +266,7 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
||||
|
||||
handle3DSRedirectRecovery();
|
||||
}, [setupIntentParam, redirectStatusParam, stripeInstance, paymentComplete, redirectRecoveryInProgress]);
|
||||
|
||||
const handlePaymentComplete = (data: any) => {
|
||||
setPaymentData(data);
|
||||
setPaymentComplete(true);
|
||||
nextStep();
|
||||
};
|
||||
|
||||
|
||||
const handleRegistrationSubmit = async () => {
|
||||
try {
|
||||
if (step === 'basic_info') {
|
||||
@@ -279,13 +278,14 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
||||
if (preSelectedPlan) {
|
||||
// Ensure subscription data is set with the pre-selected plan
|
||||
updateRegistrationState({
|
||||
step: 'payment',
|
||||
subscription: {
|
||||
...registrationState.subscription,
|
||||
planId: preSelectedPlan,
|
||||
billingInterval: urlBillingCycle || registrationState.subscription.billingInterval
|
||||
}
|
||||
});
|
||||
// Skip directly to payment step
|
||||
setStep('payment');
|
||||
} else {
|
||||
// Go to subscription selection step
|
||||
nextStep();
|
||||
@@ -341,100 +341,22 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
||||
|
||||
const validateSubscription = (): boolean => {
|
||||
const { subscription } = registrationState;
|
||||
|
||||
|
||||
if (!subscription.planId || !subscription.billingInterval) {
|
||||
updateRegistrationState({
|
||||
errors: { subscription: t('auth:register.select_plan') }
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
const getProgressPercentage = () => {
|
||||
const steps = preSelectedPlan
|
||||
? ['basic_info', 'payment', 'complete']
|
||||
: ['basic_info', 'subscription', 'payment', 'complete'];
|
||||
const steps = preSelectedPlan
|
||||
? ['basic_info', 'payment']
|
||||
: ['basic_info', 'subscription', 'payment'];
|
||||
const currentIndex = steps.indexOf(step);
|
||||
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 'subscription': return t('auth:register.step_subscription', 'Suscripción');
|
||||
case 'payment': return t('auth:register.step_payment', 'Pago');
|
||||
case 'complete': return t('auth:register.step_complete', 'Completar');
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Determine which steps to show in progress indicator
|
||||
const progressSteps: RegistrationStep[] = preSelectedPlan
|
||||
? ['basic_info', 'payment', 'complete']
|
||||
: ['basic_info', 'subscription', 'payment', 'complete'];
|
||||
const progressSteps: RegistrationStep[] = preSelectedPlan
|
||||
? ['basic_info', 'payment']
|
||||
: ['basic_info', 'subscription', 'payment'];
|
||||
|
||||
// Render current step
|
||||
const renderStep = () => {
|
||||
@@ -489,37 +410,21 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
||||
);
|
||||
case 'payment':
|
||||
return (
|
||||
<Elements
|
||||
stripe={stripeInstance}
|
||||
<Elements
|
||||
stripe={stripeInstance}
|
||||
options={{
|
||||
mode: 'setup',
|
||||
mode: 'setup',
|
||||
currency: 'eur',
|
||||
paymentMethodCreation: 'manual' // Required for Payment Element
|
||||
}}
|
||||
>
|
||||
<PaymentStep
|
||||
onNext={handleRegistrationSubmit}
|
||||
onBack={prevStep}
|
||||
onPaymentComplete={handlePaymentComplete}
|
||||
registrationState={registrationState}
|
||||
updateRegistrationState={updateRegistrationState}
|
||||
/>
|
||||
</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:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Checkbox } from '../../ui';
|
||||
import { Button } from '../../ui';
|
||||
import { SubscriptionPricingCards } from '../../subscription/SubscriptionPricingCards';
|
||||
import { subscriptionService } from '../../../api';
|
||||
|
||||
@@ -73,9 +73,14 @@ export const SubscriptionStep: React.FC<SubscriptionStepProps> = ({
|
||||
|
||||
fetchPlanMetadata();
|
||||
|
||||
// Automatically apply PILOT2025 coupon for pilot users with trial enabled
|
||||
if (isPilot && useTrial && !couponCode) {
|
||||
updateField('couponCode', 'PILOT2025');
|
||||
// Automatically enable trial and apply PILOT2025 coupon for pilot users
|
||||
if (isPilot) {
|
||||
if (!useTrial) {
|
||||
updateField('useTrial', true);
|
||||
}
|
||||
if (!couponCode) {
|
||||
updateField('couponCode', 'PILOT2025');
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -130,38 +135,14 @@ export const SubscriptionStep: React.FC<SubscriptionStepProps> = ({
|
||||
mode="selection"
|
||||
selectedPlan={selectedPlan}
|
||||
onPlanSelect={(planKey) => updateField('planId', planKey)}
|
||||
showPilotBanner={false} // Pilot program disabled as requested
|
||||
pilotCouponCode={undefined}
|
||||
pilotTrialMonths={0}
|
||||
showPilotBanner={isPilot}
|
||||
pilotCouponCode={isPilot ? 'PILOT2025' : undefined}
|
||||
pilotTrialMonths={isPilot ? 3 : 0}
|
||||
billingCycle={billingCycle}
|
||||
onBillingCycleChange={(cycle) => updateField('billingInterval', cycle)}
|
||||
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 */}
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button type="button" variant="secondary" size="lg" onClick={onBack}>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { validateEmail } from '../../../../utils/validation';
|
||||
|
||||
export type RegistrationStep = 'basic_info' | 'subscription' | 'payment' | 'complete';
|
||||
export type RegistrationStep = 'basic_info' | 'subscription' | 'payment';
|
||||
|
||||
export type BasicInfoData = {
|
||||
firstName: string;
|
||||
@@ -94,18 +94,17 @@ export const useRegistrationState = ({
|
||||
switch (prev) {
|
||||
case 'basic_info': return 'subscription';
|
||||
case 'subscription': return 'payment';
|
||||
case 'payment': return 'complete';
|
||||
// Payment is the last step - no next step
|
||||
default: return prev;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
setStep(prev => {
|
||||
switch (prev) {
|
||||
case 'subscription': return 'basic_info';
|
||||
case 'payment': return 'subscription';
|
||||
case 'complete': return 'payment';
|
||||
default: return prev;
|
||||
}
|
||||
});
|
||||
@@ -210,6 +209,7 @@ export const useRegistrationState = ({
|
||||
|
||||
return {
|
||||
step,
|
||||
setStep,
|
||||
registrationState,
|
||||
updateRegistrationState,
|
||||
nextStep,
|
||||
|
||||
@@ -118,6 +118,7 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const registerBakery = useRegisterBakery();
|
||||
const registerBakeryWithSubscription = useRegisterBakeryWithSubscription();
|
||||
const updateTenant = useUpdateTenant();
|
||||
|
||||
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">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
isLoading={registerBakery.isPending || updateTenant.isPending}
|
||||
isLoading={registerBakery.isPending || registerBakeryWithSubscription.isPending || updateTenant.isPending}
|
||||
loadingText={t(tenantId
|
||||
? 'onboarding:steps.tenant_registration.loading.updating'
|
||||
: (isEnterprise
|
||||
|
||||
@@ -130,7 +130,7 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
<div className={className}>
|
||||
{/* Pilot Program Banner */}
|
||||
{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-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">
|
||||
@@ -138,14 +138,13 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
</div>
|
||||
</div>
|
||||
<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')}
|
||||
</h3>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200"
|
||||
<p className="text-sm text-[var(--text-secondary)]"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('ui.pilot_program_description', { count: pilotTrialMonths })
|
||||
.replace('{count}', `<strong>${pilotTrialMonths}</strong>`)
|
||||
.replace('20%', '<strong>20%</strong>')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -129,8 +129,8 @@ const Checkbox = forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root>, Che
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'hover:shadow-md',
|
||||
'shrink-0', // Prevent shrinking on mobile
|
||||
'border-[var(--border-primary)]', // Ensure visible border by default
|
||||
'dark:border-[var(--border-primary)]', // Ensure visible border in dark mode too
|
||||
'border-[var(--border-tertiary)]', // Use tertiary border for better visibility when unchecked
|
||||
'dark:border-[var(--text-tertiary)]', // Use text-tertiary in dark mode for better contrast
|
||||
'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
|
||||
sizeClasses[size],
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
helperText?: string;
|
||||
isRequired?: boolean;
|
||||
isInvalid?: boolean;
|
||||
isValid?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'outline' | 'filled' | 'unstyled';
|
||||
leftIcon?: React.ReactNode;
|
||||
@@ -23,6 +24,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
helperText,
|
||||
isRequired = false,
|
||||
isInvalid = false,
|
||||
isValid = false,
|
||||
size = 'md',
|
||||
variant = 'outline',
|
||||
leftIcon,
|
||||
@@ -38,6 +40,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
}, ref) => {
|
||||
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const hasError = isInvalid || !!error;
|
||||
const hasSuccess = isValid && !hasError;
|
||||
|
||||
const baseInputClasses = [
|
||||
'w-full transition-colors duration-200',
|
||||
@@ -50,12 +53,14 @@ const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
outline: [
|
||||
'bg-[var(--bg-primary)] border border-[var(--border-secondary)]',
|
||||
'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: [
|
||||
'bg-[var(--bg-secondary)] border border-transparent',
|
||||
'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: [
|
||||
'bg-transparent border-none',
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
},
|
||||
"cta": {
|
||||
"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",
|
||||
"secondary": "See Demo"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"cta": {
|
||||
"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"
|
||||
},
|
||||
"post": {
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
{
|
||||
"category": "pricing",
|
||||
"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",
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
"pilot_period": "First 3 months (pilot)",
|
||||
"regular_price": "€99/month",
|
||||
"pilot_discount_price": "€79/month",
|
||||
"pilot_discount_note": "20% lifetime discount if you were a pilot",
|
||||
"pilot_discount_note": "Pilot program",
|
||||
"features": {
|
||||
"prediction": "AI demand prediction",
|
||||
"inventory": "Automatic inventory management",
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
"error_loading": "Could not load plans. Please try again.",
|
||||
"most_popular": "Most Popular",
|
||||
"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_year": "per year",
|
||||
"save_amount": "Save {amount}/year",
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
},
|
||||
"cta": {
|
||||
"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",
|
||||
"secondary": "Ver Demo"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"cta": {
|
||||
"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"
|
||||
},
|
||||
"post": {
|
||||
|
||||
@@ -1604,7 +1604,7 @@
|
||||
{
|
||||
"category": "pricing",
|
||||
"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",
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
"pilot_period": "Primeros 3 meses (piloto)",
|
||||
"regular_price": "€99/mes",
|
||||
"pilot_discount_price": "€79/mes",
|
||||
"pilot_discount_note": "20% descuento de por vida si fuiste piloto",
|
||||
"pilot_discount_note": "Programa piloto",
|
||||
"features": {
|
||||
"prediction": "Predicción de demanda con IA",
|
||||
"inventory": "Gestión automática de inventario",
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
"error_loading": "No se pudieron cargar los planes. Por favor, intenta nuevamente.",
|
||||
"most_popular": "Más Popular",
|
||||
"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_year": "por año",
|
||||
"save_amount": "Ahorra {amount}/año",
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
},
|
||||
"cta": {
|
||||
"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",
|
||||
"secondary": "Ikusi Demoa"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"cta": {
|
||||
"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"
|
||||
},
|
||||
"post": {
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
{
|
||||
"category": "pricing",
|
||||
"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",
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
"pilot_period": "Lehenengo 3 hilabeteak (pilotua)",
|
||||
"regular_price": "€99/hilean",
|
||||
"pilot_discount_price": "€79/hilean",
|
||||
"pilot_discount_note": "%20ko deskontua bizitza osoan piloto izan bazinen",
|
||||
"pilot_discount_note": "Pilotu programa",
|
||||
"features": {
|
||||
"prediction": "IArekin eskariaren aurreikuspena",
|
||||
"inventory": "Inbentarioaren kudeaketa automatikoa",
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
"error_loading": "Ezin izan dira planak kargatu. Mesedez, saiatu berriro.",
|
||||
"most_popular": "Ezagunena",
|
||||
"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_year": "urteko",
|
||||
"save_amount": "Aurreztu {amount}/urtean",
|
||||
|
||||
@@ -33,13 +33,15 @@ const SubscriptionStatusBanner: React.FC<{
|
||||
isTrial?: boolean;
|
||||
trialEndsAt?: string;
|
||||
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
|
||||
const showTrialInfo = isTrial && trialEndsAt;
|
||||
|
||||
const showPilotInfo = isPilot;
|
||||
|
||||
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-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>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<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>
|
||||
<Badge variant={showTrialInfo ? 'info' : status === 'active' ? 'success' : 'default'} className="mt-2">
|
||||
{showTrialInfo ? `En Prueba (${trialDaysRemaining} días)` : status === 'active' ? 'Activo' : status}
|
||||
<Badge variant={showPilotInfo ? 'info' : showTrialInfo ? 'info' : status === 'active' ? 'success' : 'default'} className="mt-2">
|
||||
{showPilotInfo ? 'Piloto Activo' : showTrialInfo ? `En Prueba (${trialDaysRemaining} días)` : status === 'active' ? 'Activo' : status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-[var(--text-secondary)]">{showTrialInfo ? 'Fin de la Prueba' : 'Próxima Facturación'}</p>
|
||||
<p className="font-semibold text-[var(--text-primary)]">{showTrialInfo ? trialEndsAt : nextBilling}</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)]">{showPilotInfo ? trialEndsAt : showTrialInfo ? trialEndsAt : nextBilling}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-[var(--text-secondary)]">Costo Mensual</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
<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">
|
||||
<ArrowRight className="w-4 h-4 mr-2" />
|
||||
@@ -94,9 +98,9 @@ const SubscriptionStatusBanner: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trial Countdown Banner */}
|
||||
{showTrialInfo && (
|
||||
|
||||
{/* Pilot/Trial Countdown Banner */}
|
||||
{(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="flex items-center gap-3">
|
||||
<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 className="flex-1">
|
||||
<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>
|
||||
</div>
|
||||
<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
|
||||
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 (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
@@ -643,20 +659,21 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
<SubscriptionStatusBanner
|
||||
planName={usageSummary.plan.charAt(0).toUpperCase() + usageSummary.plan.slice(1)}
|
||||
status={usageSummary.status}
|
||||
nextBilling={new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
nextBilling={new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})}
|
||||
cost={subscriptionService.formatPrice(usageSummary.monthly_price)}
|
||||
onUpgrade={() => setShowPlans(true)}
|
||||
onManagePayment={() => setPaymentMethodModalOpen(true)}
|
||||
showUpgradeButton={usageSummary.plan !== 'enterprise'}
|
||||
isTrial={usageSummary.status === 'trialing'}
|
||||
trialEndsAt={usageSummary.trial_ends_at ? new Date(usageSummary.trial_ends_at).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
isTrial={usageSummary.status === 'trialing' || isPilotSubscription}
|
||||
isPilot={isPilotSubscription}
|
||||
trialEndsAt={usageSummary.trial_ends_at ? new Date(usageSummary.trial_ends_at).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
}) : undefined}
|
||||
trialDaysRemaining={usageSummary.trial_ends_at ? Math.max(0, Math.ceil((new Date(usageSummary.trial_ends_at).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))) : undefined}
|
||||
/>
|
||||
|
||||
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,8 +30,12 @@ const LoginPage: React.FC = () => {
|
||||
navigate('/register');
|
||||
};
|
||||
|
||||
const handleForgotPasswordClick = () => {
|
||||
navigate('/forgot-password');
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicLayout
|
||||
<PublicLayout
|
||||
variant="centered"
|
||||
maxWidth="md"
|
||||
headerProps={{
|
||||
@@ -44,6 +48,7 @@ const LoginPage: React.FC = () => {
|
||||
<LoginForm
|
||||
onSuccess={handleLoginSuccess}
|
||||
onRegisterClick={handleRegisterClick}
|
||||
onForgotPasswordClick={handleForgotPasswordClick}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</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;
|
||||
@@ -12,4 +12,6 @@ export { default as CareersPage } from './CareersPage';
|
||||
export { default as HelpCenterPage } from './HelpCenterPage';
|
||||
export { default as DocumentationPage } from './DocumentationPage';
|
||||
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 FeedbackPage = React.lazy(() => import('../pages/public/FeedbackPage'));
|
||||
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'));
|
||||
|
||||
// Operations pages
|
||||
@@ -98,6 +100,8 @@ export const AppRouter: React.FC = () => {
|
||||
<Route path="/cookie-preferences" element={<CookiePreferencesPage />} />
|
||||
<Route path="/unauthorized" element={<UnauthorizedPage />} />
|
||||
<Route path="/401" element={<UnauthorizedPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
|
||||
{/* Protected Routes with AppShell Layout */}
|
||||
<Route
|
||||
|
||||
@@ -222,6 +222,32 @@ export const routesConfig: RouteConfig[] = [
|
||||
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,
|
||||
name: 'Features',
|
||||
|
||||
@@ -37,6 +37,8 @@ PUBLIC_ROUTES = [
|
||||
"/api/v1/auth/start-registration", # Registration step 1 - SetupIntent creation
|
||||
"/api/v1/auth/complete-registration", # Registration step 2 - Completion after 3DS
|
||||
"/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/plans",
|
||||
"/api/v1/demo/accounts",
|
||||
|
||||
@@ -188,7 +188,7 @@ def create_upgrade_required_response(
|
||||
# Additional context
|
||||
'can_preview': feature in ['analytics'],
|
||||
'has_free_trial': True,
|
||||
'trial_days': 14,
|
||||
'trial_days': 0,
|
||||
|
||||
# Social proof
|
||||
'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"
|
||||
)
|
||||
@@ -355,6 +355,12 @@ class SecurityManager:
|
||||
"""Generate secure random token"""
|
||||
import secrets
|
||||
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
|
||||
def mask_sensitive_data(data: str, visible_chars: int = 4) -> str:
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import FastAPI
|
||||
from sqlalchemy import text
|
||||
from app.core.config import settings
|
||||
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.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(account_deletion.router, tags=["gdpr", "account-deletion"])
|
||||
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 .consent import UserConsent, ConsentHistory
|
||||
from .deletion_job import DeletionJob
|
||||
from .password_reset_tokens import PasswordResetToken
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
@@ -25,5 +26,6 @@ __all__ = [
|
||||
'UserConsent',
|
||||
'ConsentHistory',
|
||||
'DeletionJob',
|
||||
'PasswordResetToken',
|
||||
"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'],
|
||||
'payment_customer_id': result['payment_customer_id'],
|
||||
'status': result['status'],
|
||||
'access_token': result.get('access_token'),
|
||||
'refresh_token': result.get('refresh_token'),
|
||||
'message': 'Registration completed successfully after 3DS verification'
|
||||
}
|
||||
|
||||
@@ -593,9 +595,129 @@ class AuthService:
|
||||
detail=f"Token generation failed: {str(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
|
||||
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
|
||||
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)
|
||||
)
|
||||
else:
|
||||
# Create starter subscription with 14-day trial
|
||||
trial_end_date = datetime.now(timezone.utc) + timedelta(days=14)
|
||||
# Create starter subscription with 0-day trial
|
||||
trial_end_date = datetime.now(timezone.utc)
|
||||
next_billing_date = trial_end_date
|
||||
|
||||
await subscription_repo.create_subscription({
|
||||
@@ -229,10 +229,10 @@ async def register_bakery(
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Default free trial subscription created for new tenant",
|
||||
"Default subscription created for new tenant",
|
||||
tenant_id=str(result.id),
|
||||
plan="starter",
|
||||
trial_days=14
|
||||
trial_days=0
|
||||
)
|
||||
except Exception as subscription_error:
|
||||
logger.error(
|
||||
|
||||
@@ -47,7 +47,7 @@ class TenantSettings(BaseServiceSettings):
|
||||
|
||||
# Subscription Plans
|
||||
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
|
||||
BASIC_PLAN_LOCATIONS: int = int(os.getenv("BASIC_PLAN_LOCATIONS", "1"))
|
||||
|
||||
@@ -107,7 +107,7 @@ class CouponRepository:
|
||||
self,
|
||||
code: str,
|
||||
tenant_id: Optional[str],
|
||||
base_trial_days: int = 14
|
||||
base_trial_days: int = 0
|
||||
) -> tuple[bool, Optional[CouponRedemption], Optional[str]]:
|
||||
"""
|
||||
Redeem a coupon for a tenant.
|
||||
@@ -289,9 +289,9 @@ class CouponRepository:
|
||||
}
|
||||
|
||||
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["total_trial_days"] = 14 + coupon.discount_value
|
||||
preview["total_trial_days"] = 0 + coupon.discount_value
|
||||
|
||||
return preview
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ class CouponService:
|
||||
self,
|
||||
coupon_code: str,
|
||||
tenant_id: str,
|
||||
base_trial_days: int = 14
|
||||
base_trial_days: int = 0
|
||||
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""
|
||||
Redeem a coupon for a tenant
|
||||
|
||||
@@ -824,7 +824,7 @@ class SubscriptionOrchestrationService:
|
||||
self,
|
||||
tenant_id: str,
|
||||
coupon_code: str,
|
||||
base_trial_days: int = 14
|
||||
base_trial_days: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Orchestrate coupon redemption workflow
|
||||
|
||||
@@ -594,7 +594,7 @@ class SubscriptionPlanMetadata:
|
||||
"popular": False,
|
||||
"monthly_price": PlanPricing.MONTHLY_PRICES[SubscriptionTier.STARTER],
|
||||
"yearly_price": PlanPricing.YEARLY_PRICES[SubscriptionTier.STARTER],
|
||||
"trial_days": 14,
|
||||
"trial_days": 0,
|
||||
"features": PlanFeatures.STARTER_FEATURES,
|
||||
|
||||
# Hero features (displayed prominently)
|
||||
@@ -635,7 +635,7 @@ class SubscriptionPlanMetadata:
|
||||
"popular": True, # Most popular plan
|
||||
"monthly_price": PlanPricing.MONTHLY_PRICES[SubscriptionTier.PROFESSIONAL],
|
||||
"yearly_price": PlanPricing.YEARLY_PRICES[SubscriptionTier.PROFESSIONAL],
|
||||
"trial_days": 14,
|
||||
"trial_days": 0,
|
||||
"features": PlanFeatures.PROFESSIONAL_FEATURES,
|
||||
|
||||
# Hero features (displayed prominently)
|
||||
@@ -678,7 +678,7 @@ class SubscriptionPlanMetadata:
|
||||
"popular": False,
|
||||
"monthly_price": PlanPricing.MONTHLY_PRICES[SubscriptionTier.ENTERPRISE],
|
||||
"yearly_price": PlanPricing.YEARLY_PRICES[SubscriptionTier.ENTERPRISE],
|
||||
"trial_days": 30,
|
||||
"trial_days": 0,
|
||||
"features": PlanFeatures.ENTERPRISE_FEATURES,
|
||||
|
||||
# Hero features (displayed prominently)
|
||||
|
||||
Reference in New Issue
Block a user