Add subcription feature 5

This commit is contained in:
Urtzi Alfaro
2026-01-16 09:55:54 +01:00
parent 483a9f64cd
commit 6b43116efd
51 changed files with 1428 additions and 312 deletions

View File

@@ -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,
});
};

View File

@@ -440,7 +440,8 @@ export {
useRefreshToken,
useLogout,
useChangePassword,
useResetPassword,
useRequestPasswordReset,
useResetPasswordWithToken,
useUpdateProfile,
useVerifyEmail,
authKeys,

View File

@@ -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
});
}
// ===================================================================

View File

@@ -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)}

View File

@@ -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.', {

View File

@@ -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 (

View File

@@ -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;
}

View File

@@ -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}>

View File

@@ -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,

View File

@@ -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

View File

@@ -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>

View File

@@ -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],

View File

@@ -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',

View File

@@ -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"
}

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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}
/>

View 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;

View File

@@ -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>

View 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;

View File

@@ -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';

View File

@@ -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

View File

@@ -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',

View File

@@ -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",

View File

@@ -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),

View 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"
)

View File

@@ -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:

View File

@@ -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"])

View File

@@ -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",
]

View 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})>"

View 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)}")

View File

@@ -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)

View File

@@ -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')

View File

@@ -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(

View File

@@ -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"))

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)