From 6b43116efdaa08e6ea7b8d88d597bddf4a25ade7 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Fri, 16 Jan 2026 09:55:54 +0100 Subject: [PATCH] Add subcription feature 5 --- frontend/src/api/hooks/auth.ts | 17 +- frontend/src/api/index.ts | 3 +- frontend/src/api/services/auth.ts | 11 +- .../components/domain/auth/BasicInfoStep.tsx | 1 + .../domain/auth/PasswordResetForm.tsx | 6 +- .../components/domain/auth/PaymentStep.tsx | 111 +++---- .../domain/auth/RegistrationContainer.tsx | 181 +++------- .../domain/auth/SubscriptionStep.tsx | 43 +-- .../domain/auth/hooks/useRegistrationState.ts | 8 +- .../onboarding/steps/RegisterTenantStep.tsx | 3 +- .../subscription/SubscriptionPricingCards.tsx | 7 +- .../src/components/ui/Checkbox/Checkbox.tsx | 4 +- frontend/src/components/ui/Input/Input.tsx | 9 +- frontend/src/locales/en/about.json | 2 +- frontend/src/locales/en/blog.json | 2 +- frontend/src/locales/en/help.json | 2 +- frontend/src/locales/en/landing.json | 2 +- frontend/src/locales/en/subscription.json | 2 +- frontend/src/locales/es/about.json | 2 +- frontend/src/locales/es/blog.json | 2 +- frontend/src/locales/es/help.json | 2 +- frontend/src/locales/es/landing.json | 2 +- frontend/src/locales/es/subscription.json | 2 +- frontend/src/locales/eu/about.json | 2 +- frontend/src/locales/eu/blog.json | 2 +- frontend/src/locales/eu/help.json | 2 +- frontend/src/locales/eu/landing.json | 2 +- frontend/src/locales/eu/subscription.json | 2 +- .../subscription/SubscriptionPage.tsx | 71 ++-- .../src/pages/public/ForgotPasswordPage.tsx | 197 +++++++++++ frontend/src/pages/public/LoginPage.tsx | 7 +- .../src/pages/public/ResetPasswordPage.tsx | 313 ++++++++++++++++++ frontend/src/pages/public/index.ts | 4 +- frontend/src/router/AppRouter.tsx | 4 + frontend/src/router/routes.config.ts | 26 ++ gateway/app/middleware/auth.py | 2 + .../app/utils/subscription_error_responses.py | 2 +- services/auth/app/api/password_reset.py | 308 +++++++++++++++++ services/auth/app/core/security.py | 6 + services/auth/app/main.py | 3 +- services/auth/app/models/__init__.py | 2 + .../auth/app/models/password_reset_tokens.py | 39 +++ .../repositories/password_reset_repository.py | 124 +++++++ services/auth/app/services/auth_service.py | 122 +++++++ ...20260116_add_password_reset_token_table.py | 50 +++ services/tenant/app/api/tenant_operations.py | 8 +- services/tenant/app/core/config.py | 2 +- .../app/repositories/coupon_repository.py | 6 +- .../tenant/app/services/coupon_service.py | 2 +- .../subscription_orchestration_service.py | 2 +- shared/subscription/plans.py | 6 +- 51 files changed, 1428 insertions(+), 312 deletions(-) create mode 100644 frontend/src/pages/public/ForgotPasswordPage.tsx create mode 100644 frontend/src/pages/public/ResetPasswordPage.tsx create mode 100644 services/auth/app/api/password_reset.py create mode 100644 services/auth/app/models/password_reset_tokens.py create mode 100644 services/auth/app/repositories/password_reset_repository.py create mode 100644 services/auth/migrations/versions/20260116_add_password_reset_token_table.py diff --git a/frontend/src/api/hooks/auth.ts b/frontend/src/api/hooks/auth.ts index d08e1b40..7e35fd76 100644 --- a/frontend/src/api/hooks/auth.ts +++ b/frontend/src/api/hooks/auth.ts @@ -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, }); }; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index f200ea35..d187fd69 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -440,7 +440,8 @@ export { useRefreshToken, useLogout, useChangePassword, - useResetPassword, + useRequestPasswordReset, + useResetPasswordWithToken, useUpdateProfile, useVerifyEmail, authKeys, diff --git a/frontend/src/api/services/auth.ts b/frontend/src/api/services/auth.ts index 5dc1f59b..17a1b495 100644 --- a/frontend/src/api/services/auth.ts +++ b/frontend/src/api/services/auth.ts @@ -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 + }); } // =================================================================== diff --git a/frontend/src/components/domain/auth/BasicInfoStep.tsx b/frontend/src/components/domain/auth/BasicInfoStep.tsx index b576fbc9..853d51e1 100644 --- a/frontend/src/components/domain/auth/BasicInfoStep.tsx +++ b/frontend/src/components/domain/auth/BasicInfoStep.tsx @@ -279,6 +279,7 @@ export const BasicInfoStep: React.FC = ({ }} 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 ? : } onRightIconClick={() => setShowConfirmPassword(!showConfirmPassword)} diff --git a/frontend/src/components/domain/auth/PasswordResetForm.tsx b/frontend/src/components/domain/auth/PasswordResetForm.tsx index 92db2f2b..a9e3a74e 100644 --- a/frontend/src/components/domain/auth/PasswordResetForm.tsx +++ b/frontend/src/components/domain/auth/PasswordResetForm.tsx @@ -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 = ({ const passwordInputRef = useRef(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 = ({ // 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.', { diff --git a/frontend/src/components/domain/auth/PaymentStep.tsx b/frontend/src/components/domain/auth/PaymentStep.tsx index 9cbec17b..bdc7a7cf 100644 --- a/frontend/src/components/domain/auth/PaymentStep.tsx +++ b/frontend/src/components/domain/auth/PaymentStep.tsx @@ -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 = ({ - onNext, onBack, - onPaymentComplete, registrationState, updateRegistrationState }) => { @@ -285,20 +281,44 @@ export const PaymentStep: React.FC = ({ 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 = ({ }); // 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 = ({ setError(null); }; - if (success) { - return ( -
-
- -
-

- {t('auth:payment_setup_complete')} -

-

- {t('auth:payment_setup_complete_description')} -

-
- -
-
- ); - } + // 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 ( diff --git a/frontend/src/components/domain/auth/RegistrationContainer.tsx b/frontend/src/components/domain/auth/RegistrationContainer.tsx index b74b50d2..b00429d1 100644 --- a/frontend/src/components/domain/auth/RegistrationContainer.tsx +++ b/frontend/src/components/domain/auth/RegistrationContainer.tsx @@ -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 = ({ - onSuccess, onLoginClick, className }) => { @@ -46,15 +45,12 @@ export const RegistrationContainer: React.FC = ({ 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 = ({ } 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 = ({ 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(null); + // paymentComplete is used for 3DS redirect recovery to prevent re-processing const [paymentComplete, setPaymentComplete] = useState(false); - const [paymentData, setPaymentData] = useState(null); useEffect(() => { const loadStripeInstance = async () => { @@ -191,9 +188,9 @@ export const RegistrationContainer: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ ); case 'payment': return ( - ); - case 'complete': - return ( -
-

{t('auth:register.registration_complete')}

-

{t('auth:register.registration_complete_description')}

- -
- ); default: return null; } diff --git a/frontend/src/components/domain/auth/SubscriptionStep.tsx b/frontend/src/components/domain/auth/SubscriptionStep.tsx index 93aa153a..a4cc7f46 100644 --- a/frontend/src/components/domain/auth/SubscriptionStep.tsx +++ b/frontend/src/components/domain/auth/SubscriptionStep.tsx @@ -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 = ({ 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 = ({ 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 && ( -
- { - 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" - /> -

- {t('auth:register.trial_description', 'Get full access to Professional plan features for 3 months at no cost.')} -

-
- )} - {/* Navigation */}
- - {/* Trial Countdown Banner */} - {showTrialInfo && ( + + {/* Pilot/Trial Countdown Banner */} + {(showPilotInfo || showTrialInfo) && (
@@ -104,7 +108,10 @@ const SubscriptionStatusBanner: React.FC<{

- ¡Disfruta de tu periodo de prueba! Tu acceso gratuito termina en {trialDaysRemaining} días. + {showPilotInfo + ? `¡Eres parte de nuestro programa piloto! Tu acceso gratuito termina en ${trialDaysRemaining} días.` + : `¡Disfruta de tu periodo de prueba! Tu acceso gratuito termina en ${trialDaysRemaining} días.` + }

+
+ + + ); + } + + return ( + + +
+

+ {t('auth:forgot_password.title', '¿Olvidaste tu contraseña?')} +

+

+ {t('auth:forgot_password.subtitle', 'Ingresa tu correo electrónico y te enviaremos un enlace para restablecer tu contraseña.')} +

+
+ +
+
+ setEmail(e.target.value)} + error={errors.email} + disabled={isSubmitting} + autoComplete="email" + required + aria-describedby={errors.email ? 'email-error' : undefined} + leftIcon={ + + } + /> + {errors.email && ( + + )} +
+ + +
+ +
+ +
+
+
+ ); +}; + +export default ForgotPasswordPage; \ No newline at end of file diff --git a/frontend/src/pages/public/LoginPage.tsx b/frontend/src/pages/public/LoginPage.tsx index 0e013909..c841cfa0 100644 --- a/frontend/src/pages/public/LoginPage.tsx +++ b/frontend/src/pages/public/LoginPage.tsx @@ -30,8 +30,12 @@ const LoginPage: React.FC = () => { navigate('/register'); }; + const handleForgotPasswordClick = () => { + navigate('/forgot-password'); + }; + return ( - { diff --git a/frontend/src/pages/public/ResetPasswordPage.tsx b/frontend/src/pages/public/ResetPasswordPage.tsx new file mode 100644 index 00000000..9a9d3f51 --- /dev/null +++ b/frontend/src/pages/public/ResetPasswordPage.tsx @@ -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 ( + + +
+
+ +
+

+ {t('auth:reset_password.success_title', '¡Contraseña Restablecida!')} +

+

+ {t('auth:reset_password.success_message', 'Tu contraseña ha sido actualizada exitosamente.')} +

+
+ +
+ +
+
+
+ ); + } + + return ( + + +
+

+ {t('auth:reset_password.title', 'Restablecer Contraseña')} +

+

+ {t('auth:reset_password.subtitle', 'Ingresa tu nueva contraseña.')} +

+
+ +
+
+ setPassword(e.target.value)} + error={errors.password} + disabled={isSubmitting} + required + aria-describedby={errors.password ? 'password-error' : undefined} + leftIcon={ + + } + rightIcon={ + + } + /> + {errors.password && ( + + )} +
+ +
+ setConfirmPassword(e.target.value)} + error={errors.confirmPassword} + disabled={isSubmitting} + required + aria-describedby={errors.confirmPassword ? 'confirm-password-error' : undefined} + leftIcon={ + + } + rightIcon={ + + } + /> + {errors.confirmPassword && ( + + )} +
+ + +
+ +
+ +
+
+
+ ); +}; + +export default ResetPasswordPage; \ No newline at end of file diff --git a/frontend/src/pages/public/index.ts b/frontend/src/pages/public/index.ts index d0b668fa..b0428268 100644 --- a/frontend/src/pages/public/index.ts +++ b/frontend/src/pages/public/index.ts @@ -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'; \ No newline at end of file +export { default as FeedbackPage } from './FeedbackPage'; +export { default as ForgotPasswordPage } from './ForgotPasswordPage'; +export { default as ResetPasswordPage } from './ResetPasswordPage'; \ No newline at end of file diff --git a/frontend/src/router/AppRouter.tsx b/frontend/src/router/AppRouter.tsx index 4fd284ab..a66c4a6d 100644 --- a/frontend/src/router/AppRouter.tsx +++ b/frontend/src/router/AppRouter.tsx @@ -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 = () => { } /> } /> } /> + } /> + } /> {/* Protected Routes with AppShell Layout */} 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""" + + + + + + Restablecer Contraseña + + + +
+

Restablecer Contraseña

+
+ +
+

Hola {user_full_name},

+ +

Recibimos una solicitud para restablecer tu contraseña. Haz clic en el botón de abajo para crear una nueva contraseña:

+ +

+ Restablecer Contraseña +

+ +

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.

+
+ + + + + """ + + # 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" + ) \ No newline at end of file diff --git a/services/auth/app/core/security.py b/services/auth/app/core/security.py index a0408bc2..316c238a 100644 --- a/services/auth/app/core/security.py +++ b/services/auth/app/core/security.py @@ -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: diff --git a/services/auth/app/main.py b/services/auth/app/main.py index f7aa5e29..580bd2c2 100644 --- a/services/auth/app/main.py +++ b/services/auth/app/main.py @@ -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"]) diff --git a/services/auth/app/models/__init__.py b/services/auth/app/models/__init__.py index 5858da4a..ec41295d 100644 --- a/services/auth/app/models/__init__.py +++ b/services/auth/app/models/__init__.py @@ -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", ] \ No newline at end of file diff --git a/services/auth/app/models/password_reset_tokens.py b/services/auth/app/models/password_reset_tokens.py new file mode 100644 index 00000000..195e5638 --- /dev/null +++ b/services/auth/app/models/password_reset_tokens.py @@ -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"" \ No newline at end of file diff --git a/services/auth/app/repositories/password_reset_repository.py b/services/auth/app/repositories/password_reset_repository.py new file mode 100644 index 00000000..6f1a6d01 --- /dev/null +++ b/services/auth/app/repositories/password_reset_repository.py @@ -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)}") \ No newline at end of file diff --git a/services/auth/app/services/auth_service.py b/services/auth/app/services/auth_service.py index cb032b3d..25584c5f 100644 --- a/services/auth/app/services/auth_service.py +++ b/services/auth/app/services/auth_service.py @@ -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) diff --git a/services/auth/migrations/versions/20260116_add_password_reset_token_table.py b/services/auth/migrations/versions/20260116_add_password_reset_token_table.py new file mode 100644 index 00000000..88b240bc --- /dev/null +++ b/services/auth/migrations/versions/20260116_add_password_reset_token_table.py @@ -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') \ No newline at end of file diff --git a/services/tenant/app/api/tenant_operations.py b/services/tenant/app/api/tenant_operations.py index ee2e874a..132dd6b6 100644 --- a/services/tenant/app/api/tenant_operations.py +++ b/services/tenant/app/api/tenant_operations.py @@ -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( diff --git a/services/tenant/app/core/config.py b/services/tenant/app/core/config.py index d61f0726..1363146c 100644 --- a/services/tenant/app/core/config.py +++ b/services/tenant/app/core/config.py @@ -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")) diff --git a/services/tenant/app/repositories/coupon_repository.py b/services/tenant/app/repositories/coupon_repository.py index 96b5b37a..f1d840db 100644 --- a/services/tenant/app/repositories/coupon_repository.py +++ b/services/tenant/app/repositories/coupon_repository.py @@ -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 diff --git a/services/tenant/app/services/coupon_service.py b/services/tenant/app/services/coupon_service.py index abae8952..bc7e9489 100644 --- a/services/tenant/app/services/coupon_service.py +++ b/services/tenant/app/services/coupon_service.py @@ -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 diff --git a/services/tenant/app/services/subscription_orchestration_service.py b/services/tenant/app/services/subscription_orchestration_service.py index 1ff0cb4f..31229abf 100644 --- a/services/tenant/app/services/subscription_orchestration_service.py +++ b/services/tenant/app/services/subscription_orchestration_service.py @@ -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 diff --git a/shared/subscription/plans.py b/shared/subscription/plans.py index 43fc1c94..9302394f 100755 --- a/shared/subscription/plans.py +++ b/shared/subscription/plans.py @@ -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)