diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 56a8b3b0..36df59a5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,6 @@ import { LoadingSpinner } from './components/shared/LoadingSpinner'; import { AppRouter } from './router/AppRouter'; import { ThemeProvider } from './contexts/ThemeContext'; import { AuthProvider } from './contexts/AuthContext'; -import { BakeryProvider } from './contexts/BakeryContext'; import { SSEProvider } from './contexts/SSEContext'; const queryClient = new QueryClient({ @@ -29,7 +28,6 @@ function App() { - }> @@ -45,7 +43,6 @@ function App() { /> - diff --git a/frontend/src/components/domain/auth/LoginForm.tsx b/frontend/src/components/domain/auth/LoginForm.tsx index 3fa0c291..b115a75e 100644 --- a/frontend/src/components/domain/auth/LoginForm.tsx +++ b/frontend/src/components/domain/auth/LoginForm.tsx @@ -56,8 +56,6 @@ export const LoginForm: React.FC = ({ if (!credentials.password) { newErrors.password = 'La contraseña es requerida'; - } else if (credentials.password.length < 6) { - newErrors.password = 'La contraseña debe tener al menos 6 caracteres'; } setErrors(newErrors); diff --git a/frontend/src/components/domain/auth/PasswordResetForm.tsx b/frontend/src/components/domain/auth/PasswordResetForm.tsx index ab742c98..76e4905d 100644 --- a/frontend/src/components/domain/auth/PasswordResetForm.tsx +++ b/frontend/src/components/domain/auth/PasswordResetForm.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Button, Input, Card } from '../../ui'; -import { useAuth } from '../../../hooks/api/useAuth'; +import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria'; +import { useAuthActions } from '../../../stores/auth.store'; import { useToast } from '../../../hooks/ui/useToast'; interface PasswordResetFormProps { @@ -33,7 +34,10 @@ export const PasswordResetForm: React.FC = ({ const emailInputRef = useRef(null); const passwordInputRef = useRef(null); - const { requestPasswordReset, resetPassword, isLoading, error } = useAuth(); + // TODO: Implement password reset in Zustand auth store + // const { requestPasswordReset, resetPassword, isLoading, error } = useAuth(); + const isLoading = false; + const error = null; const { showToast } = useToast(); const isResetMode = Boolean(token) || mode === 'reset'; @@ -109,12 +113,11 @@ export const PasswordResetForm: React.FC = ({ 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])(?=.*[A-Z])(?=.*\d)/.test(password)) { - newErrors.password = 'La contraseña debe contener mayúsculas, minúsculas y números'; - } else if (passwordStrength < 50) { - newErrors.password = 'La contraseña es demasiado débil. Intenta con una más segura'; + } else { + const passwordErrors = getPasswordErrors(password); + if (passwordErrors.length > 0) { + newErrors.password = passwordErrors[0]; // Show first error + } } if (!confirmPassword) { @@ -147,7 +150,9 @@ export const PasswordResetForm: React.FC = ({ } try { - const success = await requestPasswordReset(email); + // TODO: Implement password reset request + // const success = await requestPasswordReset(email); + const success = false; // Placeholder if (success) { setIsEmailSent(true); showToast({ @@ -192,7 +197,9 @@ export const PasswordResetForm: React.FC = ({ } try { - const success = await resetPassword(token, password); + // TODO: Implement password reset + // const success = await resetPassword(token, password); + const success = false; // Placeholder if (success) { showToast({ type: 'success', diff --git a/frontend/src/components/domain/auth/ProfileSettings.tsx b/frontend/src/components/domain/auth/ProfileSettings.tsx index a0023a5d..b1c9ad97 100644 --- a/frontend/src/components/domain/auth/ProfileSettings.tsx +++ b/frontend/src/components/domain/auth/ProfileSettings.tsx @@ -231,10 +231,19 @@ export const ProfileSettings: React.FC = ({ if (!passwordData.newPassword) { newErrors.newPassword = 'La nueva contraseña es requerida'; - } else if (passwordData.newPassword.length < 8) { - newErrors.newPassword = 'La contraseña debe tener al menos 8 caracteres'; - } else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(passwordData.newPassword)) { - newErrors.newPassword = 'La contraseña debe contener mayúsculas, minúsculas y números'; + } else { + // Use simpler validation for now to match backend exactly + if (passwordData.newPassword.length < 8) { + newErrors.newPassword = 'La contraseña debe tener al menos 8 caracteres'; + } else if (passwordData.newPassword.length > 128) { + newErrors.newPassword = 'La contraseña no puede exceder 128 caracteres'; + } else if (!/[A-Z]/.test(passwordData.newPassword)) { + newErrors.newPassword = 'La contraseña debe contener al menos una letra mayúscula'; + } else if (!/[a-z]/.test(passwordData.newPassword)) { + newErrors.newPassword = 'La contraseña debe contener al menos una letra minúscula'; + } else if (!/\d/.test(passwordData.newPassword)) { + newErrors.newPassword = 'La contraseña debe contener al menos un número'; + } } if (!passwordData.confirmNewPassword) { diff --git a/frontend/src/components/domain/auth/RegisterForm.tsx b/frontend/src/components/domain/auth/RegisterForm.tsx index 7ca19f0b..2c2f5e04 100644 --- a/frontend/src/components/domain/auth/RegisterForm.tsx +++ b/frontend/src/components/domain/auth/RegisterForm.tsx @@ -1,9 +1,8 @@ import React, { useState } from 'react'; import { Button, Input, Card } from '../../ui'; -import { useAuth } from '../../../hooks/api/useAuth'; -import { UserRegistration } from '../../../types/auth.types'; +import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria'; +import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store'; import { useToast } from '../../../hooks/ui/useToast'; -import { isMockRegistration } from '../../../config/mock.config'; interface RegisterFormProps { onSuccess?: () => void; @@ -36,9 +35,20 @@ export const RegisterForm: React.FC = ({ const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const { register, isLoading, error } = useAuth(); + const { register } = useAuthActions(); + const isLoading = useAuthLoading(); + const error = useAuthError(); const { success: showSuccessToast, error: showErrorToast } = useToast(); + // Helper function to determine password match status + const getPasswordMatchStatus = () => { + if (!formData.confirmPassword) return 'empty'; + if (formData.password === formData.confirmPassword) return 'match'; + return 'mismatch'; + }; + + const passwordMatchStatus = getPasswordMatchStatus(); + const validateForm = (): boolean => { const newErrors: Partial = {}; @@ -56,8 +66,11 @@ export const RegisterForm: React.FC = ({ if (!formData.password) { newErrors.password = 'La contraseña es requerida'; - } else if (formData.password.length < 8) { - newErrors.password = 'La contraseña debe tener al menos 8 caracteres'; + } else { + const passwordErrors = getPasswordErrors(formData.password); + if (passwordErrors.length > 0) { + newErrors.password = passwordErrors[0]; // Show first error + } } if (!formData.confirmPassword) { @@ -76,64 +89,28 @@ export const RegisterForm: React.FC = ({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - console.log('Form submitted, mock mode:', isMockRegistration()); - - // FORCED MOCK MODE FOR TESTING - Always bypass for now - const FORCE_MOCK = true; - if (FORCE_MOCK || isMockRegistration()) { - console.log('Mock registration triggered, showing toast and calling onSuccess'); - // Show immediate success notification - try { - showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', { - title: 'Cuenta creada exitosamente' - }); - console.log('Toast shown, calling onSuccess callback'); - } catch (error) { - console.error('Error showing toast:', error); - // Fallback: show browser alert if toast fails - alert('¡Cuenta creada exitosamente! Redirigiendo al onboarding...'); - } - - // Call success immediately (removing delay for easier testing) - try { - onSuccess?.(); - console.log('onSuccess called'); - } catch (error) { - console.error('Error calling onSuccess:', error); - // Fallback: direct redirect if callback fails - window.location.href = '/app/onboarding'; - } - return; - } if (!validateForm()) { return; } try { - const registrationData: UserRegistration = { + const registrationData = { full_name: formData.full_name, email: formData.email, password: formData.password, tenant_name: 'Default Bakery', // Default value since we're not collecting it - phone: '' // Optional field }; - const success = await register(registrationData); + await register(registrationData); - if (success) { - showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', { - title: 'Cuenta creada exitosamente' - }); - onSuccess?.(); - } else { - showErrorToast(error || 'No se pudo crear la cuenta. Verifica que el email no esté en uso.', { - title: 'Error al crear la cuenta' - }); - } + showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', { + title: 'Cuenta creada exitosamente' + }); + onSuccess?.(); } catch (err) { - showErrorToast('No se pudo conectar con el servidor. Verifica tu conexión a internet.', { - title: 'Error de conexión' + showErrorToast(error || 'No se pudo crear la cuenta. Verifica que el email no esté en uso.', { + title: 'Error al crear la cuenta' }); } }; @@ -199,6 +176,7 @@ export const RegisterForm: React.FC = ({ onChange={handleInputChange('password')} error={errors.password} disabled={isLoading} + maxLength={128} autoComplete="new-password" required leftIcon={ @@ -227,21 +205,49 @@ export const RegisterForm: React.FC = ({ } /> - - - - } + {/* Password Criteria - Show when user is typing */} + {formData.password && ( + + )} + +
+ + + + ) : passwordMatchStatus === 'mismatch' && formData.confirmPassword ? ( + + + + ) : ( + + + + ) + } rightIcon={ } /> + + {/* Password Match Status Message */} + {formData.confirmPassword && ( +
+ {passwordMatchStatus === 'match' ? ( +
+
+ + + +
+ ¡Las contraseñas coinciden! +
+ ) : ( +
+
+ + + +
+ Las contraseñas no coinciden +
+ )} +
+ )} +
@@ -292,7 +324,7 @@ export const RegisterForm: React.FC = ({ size="lg" isLoading={isLoading} loadingText="Creando cuenta..." - disabled={isLoading} + disabled={isLoading || !validatePassword(formData.password) || !formData.acceptTerms || passwordMatchStatus !== 'match'} className="w-full" onClick={(e) => { console.log('Button clicked!'); diff --git a/frontend/src/components/domain/onboarding/OnboardingWizard.tsx b/frontend/src/components/domain/onboarding/OnboardingWizard.tsx index 0d4bc999..82de7f55 100644 --- a/frontend/src/components/domain/onboarding/OnboardingWizard.tsx +++ b/frontend/src/components/domain/onboarding/OnboardingWizard.tsx @@ -1,5 +1,9 @@ import React, { useState, useCallback } from 'react'; import { Card, Button, Input, Select, Badge } from '../../ui'; +import { tenantService } from '../../../services/api/tenant.service'; +import { useAuthUser } from '../../../stores/auth.store'; +import { useTenantActions } from '../../../stores/tenant.store'; + export interface OnboardingStep { id: string; @@ -37,6 +41,7 @@ export const OnboardingWizard: React.FC = ({ const [stepData, setStepData] = useState>({}); const [completedSteps, setCompletedSteps] = useState>(new Set()); const [validationErrors, setValidationErrors] = useState>({}); + const { setCurrentTenant } = useTenantActions(); const currentStep = steps[currentStepIndex]; @@ -80,41 +85,55 @@ export const OnboardingWizard: React.FC = ({ const data = stepData[currentStep.id] || {}; if (!data.bakery?.tenant_id) { - // Create tenant inline + // Create tenant inline using real backend API updateStepData(currentStep.id, { ...data, bakery: { ...data.bakery, isCreating: true } }); try { - const mockTenantService = { - createTenant: async (formData: any) => { - await new Promise(resolve => setTimeout(resolve, 1000)); - return { - tenant_id: `tenant_${Date.now()}`, - name: formData.name, - ...formData - }; - } + // Use the backend-compatible data directly from BakerySetupStep + const bakeryRegistration = { + name: data.bakery.name, + address: data.bakery.address, + city: data.bakery.city, + postal_code: data.bakery.postal_code, + phone: data.bakery.phone, + business_type: data.bakery.business_type, + business_model: data.bakery.business_model }; - const tenantData = await mockTenantService.createTenant(data.bakery); + const response = await tenantService.createTenant(bakeryRegistration); - updateStepData(currentStep.id, { - ...data, - bakery: { - ...data.bakery, - tenant_id: tenantData.tenant_id, - created_at: new Date().toISOString(), - isCreating: false - } - }); + if (response.success && response.data) { + const tenantData = response.data; + + updateStepData(currentStep.id, { + ...data, + bakery: { + ...data.bakery, + tenant_id: tenantData.id, + created_at: tenantData.created_at, + isCreating: false + } + }); + + // Update the tenant store with the new tenant + setCurrentTenant(tenantData); + + } else { + throw new Error(response.error || 'Failed to create tenant'); + } } catch (error) { console.error('Error creating tenant:', error); updateStepData(currentStep.id, { ...data, - bakery: { ...data.bakery, isCreating: false } + bakery: { + ...data.bakery, + isCreating: false, + creationError: error instanceof Error ? error.message : 'Unknown error' + } }); return; } diff --git a/frontend/src/components/domain/onboarding/steps/BakerySetupStep.tsx b/frontend/src/components/domain/onboarding/steps/BakerySetupStep.tsx index 6a922577..c37c5ba8 100644 --- a/frontend/src/components/domain/onboarding/steps/BakerySetupStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/BakerySetupStep.tsx @@ -1,8 +1,21 @@ import React, { useState, useEffect } from 'react'; -import { Store, MapPin, Phone, Mail } from 'lucide-react'; +import { Store, MapPin, Phone, Mail, Hash, Building } from 'lucide-react'; import { Button, Card, Input } from '../../../ui'; import { OnboardingStepProps } from '../OnboardingWizard'; +// Backend-compatible bakery setup interface +interface BakerySetupData { + name: string; + business_type: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant'; + business_model: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery'; + address: string; + city: string; + postal_code: string; + phone: string; + email?: string; + description?: string; +} + export const BakerySetupStep: React.FC = ({ data, onDataChange, @@ -11,36 +24,39 @@ export const BakerySetupStep: React.FC = ({ isFirstStep, isLastStep }) => { - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ name: data.bakery?.name || '', - type: data.bakery?.type || '', - location: data.bakery?.location || '', + business_type: data.bakery?.business_type || 'bakery', + business_model: data.bakery?.business_model || 'individual_bakery', + address: data.bakery?.address || '', + city: data.bakery?.city || 'Madrid', + postal_code: data.bakery?.postal_code || '', phone: data.bakery?.phone || '', email: data.bakery?.email || '', description: data.bakery?.description || '' }); - const bakeryTypes = [ + const bakeryModels = [ { - value: 'artisan', - label: 'Panadería Artesanal', + value: 'individual_bakery', + label: 'Panadería Individual', description: 'Producción propia tradicional con recetas artesanales', icon: '🥖' }, { - value: 'industrial', - label: 'Panadería Industrial', - description: 'Producción a gran escala con procesos automatizados', + value: 'central_baker_satellite', + label: 'Panadería Central con Satélites', + description: 'Producción centralizada con múltiples puntos de venta', icon: '🏭' }, { - value: 'retail', + value: 'retail_bakery', label: 'Panadería Retail', description: 'Punto de venta que compra productos terminados', icon: '🏪' }, { - value: 'hybrid', + value: 'hybrid_bakery', label: 'Modelo Híbrido', description: 'Combina producción propia con productos externos', icon: '🔄' @@ -57,7 +73,7 @@ export const BakerySetupStep: React.FC = ({ }); }, [formData]); - const handleInputChange = (field: string, value: string) => { + const handleInputChange = (field: keyof BakerySetupData, value: string) => { setFormData(prev => ({ ...prev, [field]: value @@ -84,18 +100,18 @@ export const BakerySetupStep: React.FC = ({ />
- {/* Bakery Type - Simplified */} + {/* Business Model Selection */}
- {bakeryTypes.map((type) => ( + {bakeryModels.map((model) => (
- {/* Location and Contact - Simplified */} + {/* Location and Contact - Backend compatible */}
handleInputChange('location', e.target.value)} + value={formData.address} + onChange={(e) => handleInputChange('address', e.target.value)} placeholder="Dirección completa de tu panadería" className="w-full pl-12 py-3" /> @@ -145,14 +161,48 @@ export const BakerySetupStep: React.FC = ({
+
+ + handleInputChange('city', e.target.value)} + placeholder="Madrid" + className="w-full pl-10" + /> +
+
+ +
+ +
+ + handleInputChange('postal_code', e.target.value)} + placeholder="28001" + pattern="[0-9]{5}" + className="w-full pl-10" + /> +
+
+
+ +
+
+
handleInputChange('phone', e.target.value)} - placeholder="+1 234 567 8900" + placeholder="+34 600 123 456" + type="tel" className="w-full pl-10" />
@@ -177,7 +227,7 @@ export const BakerySetupStep: React.FC = ({
- {/* Optional: Show loading state when creating tenant */} + {/* Show loading state when creating tenant */} {data.bakery?.isCreating && (
@@ -186,6 +236,18 @@ export const BakerySetupStep: React.FC = ({

)} + + {/* Show error state if tenant creation fails */} + {data.bakery?.creationError && ( +
+

+ Error al crear el espacio de trabajo +

+

+ {data.bakery.creationError} +

+
+ )}
); }; \ No newline at end of file diff --git a/frontend/src/components/layout/AppShell/AppShell.tsx b/frontend/src/components/layout/AppShell/AppShell.tsx index 1f2b7dbf..738af9a5 100644 --- a/frontend/src/components/layout/AppShell/AppShell.tsx +++ b/frontend/src/components/layout/AppShell/AppShell.tsx @@ -2,6 +2,7 @@ import React, { useState, useCallback, forwardRef } from 'react'; import { clsx } from 'clsx'; import { useAuthUser, useIsAuthenticated } from '../../../stores'; import { useTheme } from '../../../contexts/ThemeContext'; +import { useTenantInitializer } from '../../../hooks/useTenantInitializer'; import { Header } from '../Header'; import { Sidebar } from '../Sidebar'; import { Footer } from '../Footer'; @@ -77,6 +78,9 @@ export const AppShell = forwardRef(({ const authLoading = false; // Since we're in a protected route, auth loading should be false const { resolvedTheme } = useTheme(); + // Initialize tenant data for authenticated users + useTenantInitializer(); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(initialSidebarCollapsed); const [error, setError] = useState(null); diff --git a/frontend/src/components/layout/Header/Header.tsx b/frontend/src/components/layout/Header/Header.tsx index 114ff102..27a229b4 100644 --- a/frontend/src/components/layout/Header/Header.tsx +++ b/frontend/src/components/layout/Header/Header.tsx @@ -3,12 +3,11 @@ import { clsx } from 'clsx'; import { useNavigate } from 'react-router-dom'; import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores'; import { useTheme } from '../../../contexts/ThemeContext'; -import { useBakery } from '../../../contexts/BakeryContext'; import { Button } from '../../ui'; import { Avatar } from '../../ui'; import { Badge } from '../../ui'; import { Modal } from '../../ui'; -import { BakerySelector } from '../../ui/BakerySelector/BakerySelector'; +import { TenantSwitcher } from '../../ui/TenantSwitcher'; import { Menu, Search, @@ -102,7 +101,6 @@ export const Header = forwardRef(({ const isAuthenticated = useIsAuthenticated(); const { logout } = useAuthActions(); const { theme, resolvedTheme, setTheme } = useTheme(); - const { bakeries, currentBakery, selectBakery } = useBakery(); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const [isSearchFocused, setIsSearchFocused] = useState(false); @@ -250,35 +248,21 @@ export const Header = forwardRef(({ )}
- {/* Bakery Selector - Desktop */} - {isAuthenticated && currentBakery && bakeries.length > 0 && ( + {/* Tenant Switcher - Desktop */} + {isAuthenticated && (
- { - // TODO: Navigate to add bakery page or open modal - console.log('Add new bakery'); - }} - size="md" +
)} - {/* Bakery Selector - Mobile (in title area) */} - {isAuthenticated && currentBakery && bakeries.length > 0 && ( + {/* Tenant Switcher - Mobile (in title area) */} + {isAuthenticated && (
- { - // TODO: Navigate to add bakery page or open modal - console.log('Add new bakery'); - }} - size="sm" +
diff --git a/frontend/src/components/ui/BakerySelector/BakerySelector.tsx b/frontend/src/components/ui/BakerySelector/BakerySelector.tsx deleted file mode 100644 index 70122a93..00000000 --- a/frontend/src/components/ui/BakerySelector/BakerySelector.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { createPortal } from 'react-dom'; -import { clsx } from 'clsx'; -import { ChevronDown, Building, Check, Plus } from 'lucide-react'; -import { Button } from '../Button'; -import { Avatar } from '../Avatar'; - -interface Bakery { - id: string; - name: string; - logo?: string; - role: 'owner' | 'manager' | 'baker' | 'staff'; - status: 'active' | 'inactive'; - address?: string; -} - -interface BakerySelectorProps { - bakeries: Bakery[]; - selectedBakery: Bakery; - onSelectBakery: (bakery: Bakery) => void; - onAddBakery?: () => void; - className?: string; - size?: 'sm' | 'md' | 'lg'; -} - -const roleLabels = { - owner: 'Propietario', - manager: 'Gerente', - baker: 'Panadero', - staff: 'Personal' -}; - -const roleColors = { - owner: 'text-color-success', - manager: 'text-color-info', - baker: 'text-color-warning', - staff: 'text-text-secondary' -}; - -export const BakerySelector: React.FC = ({ - bakeries, - selectedBakery, - onSelectBakery, - onAddBakery, - className, - size = 'md' -}) => { - const [isOpen, setIsOpen] = useState(false); - const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); - const dropdownRef = useRef(null); - const buttonRef = useRef(null); - - // Calculate dropdown position when opening - useEffect(() => { - if (isOpen && buttonRef.current) { - const rect = buttonRef.current.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const isMobile = viewportWidth < 640; // sm breakpoint - - let top = rect.bottom + window.scrollY + 8; - let left = rect.left + window.scrollX; - let width = Math.max(rect.width, isMobile ? viewportWidth - 32 : 320); // 16px margin on each side for mobile - - // Adjust for mobile - center dropdown with margins - if (isMobile) { - left = 16; // 16px margin from left - width = viewportWidth - 32; // 16px margins on both sides - } else { - // Adjust horizontal position to prevent overflow - const dropdownWidth = Math.max(width, 320); - if (left + dropdownWidth > viewportWidth - 16) { - left = viewportWidth - dropdownWidth - 16; - } - if (left < 16) { - left = 16; - } - } - - // Adjust vertical position if dropdown would overflow bottom - const dropdownMaxHeight = 320; // Approximate max height - const headerHeight = 64; // Approximate header height - - if (top + dropdownMaxHeight > viewportHeight + window.scrollY - 16) { - // Try to position above the button - const topPosition = rect.top + window.scrollY - dropdownMaxHeight - 8; - - // Ensure it doesn't go above the header - if (topPosition < window.scrollY + headerHeight) { - // If it can't fit above, position it at the top of the visible area - top = window.scrollY + headerHeight + 8; - } else { - top = topPosition; - } - } - - setDropdownPosition({ top, left, width }); - } - }, [isOpen]); - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (buttonRef.current && !buttonRef.current.contains(event.target as Node) && - dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isOpen]); - - // Close on escape key and handle body scroll lock - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setIsOpen(false); - buttonRef.current?.focus(); - } - }; - - if (isOpen) { - document.addEventListener('keydown', handleKeyDown); - - // Prevent body scroll on mobile when dropdown is open - const isMobile = window.innerWidth < 640; - if (isMobile) { - document.body.style.overflow = 'hidden'; - } - } - - return () => { - document.removeEventListener('keydown', handleKeyDown); - // Restore body scroll - document.body.style.overflow = ''; - }; - }, [isOpen]); - - const sizeClasses = { - sm: 'h-10 px-3 text-sm sm:h-8', // Always at least 40px (10) for better touch targets on mobile - md: 'h-12 px-4 text-base sm:h-10', // 48px (12) on mobile, 40px on desktop - lg: 'h-14 px-5 text-lg sm:h-12' // 56px (14) on mobile, 48px on desktop - }; - - const avatarSizes = { - sm: 'sm' as const, // Changed from xs to sm for better mobile visibility - md: 'sm' as const, - lg: 'md' as const - }; - - const getBakeryInitials = (name: string) => { - return name - .split(' ') - .map(word => word[0]) - .join('') - .toUpperCase() - .slice(0, 2); - }; - - return ( -
- - - {isOpen && createPortal( - <> - {/* Mobile backdrop */} -
setIsOpen(false)} - /> - -
-
- Mis Panaderías ({bakeries.length}) -
- -
- {bakeries.map((bakery) => ( - - ))} -
- - {onAddBakery && ( - <> -
- - - )} -
- , - document.body - )} -
- ); -}; - -export default BakerySelector; \ No newline at end of file diff --git a/frontend/src/components/ui/BakerySelector/index.ts b/frontend/src/components/ui/BakerySelector/index.ts deleted file mode 100644 index 2f8daa84..00000000 --- a/frontend/src/components/ui/BakerySelector/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { BakerySelector } from './BakerySelector'; -export type { default as BakerySelector } from './BakerySelector'; \ No newline at end of file diff --git a/frontend/src/components/ui/PasswordCriteria.tsx b/frontend/src/components/ui/PasswordCriteria.tsx new file mode 100644 index 00000000..84e3752f --- /dev/null +++ b/frontend/src/components/ui/PasswordCriteria.tsx @@ -0,0 +1,131 @@ +import React from 'react'; + +interface PasswordCriteria { + label: string; + isValid: boolean; + regex?: RegExp; + checkFn?: (password: string) => boolean; +} + +interface PasswordCriteriaProps { + password: string; + className?: string; + showOnlyFailed?: boolean; +} + +export const PasswordCriteria: React.FC = ({ + password, + className = '', + showOnlyFailed = false +}) => { + const criteria: PasswordCriteria[] = [ + { + label: 'Al menos 8 caracteres', + isValid: password.length >= 8, + checkFn: (pwd) => pwd.length >= 8 + }, + { + label: 'Máximo 128 caracteres', + isValid: password.length <= 128, + checkFn: (pwd) => pwd.length <= 128 + }, + { + label: 'Al menos una letra mayúscula', + isValid: /[A-Z]/.test(password), + regex: /[A-Z]/ + }, + { + label: 'Al menos una letra minúscula', + isValid: /[a-z]/.test(password), + regex: /[a-z]/ + }, + { + label: 'Al menos un número', + isValid: /\d/.test(password), + regex: /\d/ + } + ]; + + const validatedCriteria = criteria.map(criterion => ({ + ...criterion, + isValid: criterion.regex + ? criterion.regex.test(password) + : criterion.checkFn + ? criterion.checkFn(password) + : false + })); + + const displayCriteria = showOnlyFailed + ? validatedCriteria.filter(c => !c.isValid) + : validatedCriteria; + + if (displayCriteria.length === 0) return null; + + return ( +
+

+ Requisitos de contraseña: +

+
    + {displayCriteria.map((criterion, index) => ( +
  • + + {criterion.isValid ? '✓' : '✗'} + + + {criterion.label} + +
  • + ))} +
+
+ ); +}; + +export const validatePassword = (password: string): boolean => { + return ( + password.length >= 8 && + password.length <= 128 && + /[A-Z]/.test(password) && + /[a-z]/.test(password) && + /\d/.test(password) + ); +}; + +export const getPasswordErrors = (password: string): string[] => { + const errors: string[] = []; + + if (password.length < 8) { + errors.push('La contraseña debe tener al menos 8 caracteres'); + } + + if (password.length > 128) { + errors.push('La contraseña no puede exceder 128 caracteres'); + } + + if (!/[A-Z]/.test(password)) { + errors.push('La contraseña debe contener al menos una letra mayúscula'); + } + + if (!/[a-z]/.test(password)) { + errors.push('La contraseña debe contener al menos una letra minúscula'); + } + + if (!/\d/.test(password)) { + errors.push('La contraseña debe contener al menos un número'); + } + + return errors; +}; \ No newline at end of file diff --git a/frontend/src/components/ui/TenantSwitcher.tsx b/frontend/src/components/ui/TenantSwitcher.tsx new file mode 100644 index 00000000..1715a325 --- /dev/null +++ b/frontend/src/components/ui/TenantSwitcher.tsx @@ -0,0 +1,215 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useTenant } from '../../stores/tenant.store'; +import { useToast } from '../../hooks/ui/useToast'; +import { ChevronDown, Building2, Check, AlertCircle } from 'lucide-react'; + +interface TenantSwitcherProps { + className?: string; + showLabel?: boolean; +} + +export const TenantSwitcher: React.FC = ({ + className = '', + showLabel = true, +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + + const { + currentTenant, + availableTenants, + isLoading, + error, + switchTenant, + loadUserTenants, + clearError, + } = useTenant(); + + const { success: showSuccessToast, error: showErrorToast } = useToast(); + + // Load tenants on mount + useEffect(() => { + if (!availableTenants) { + loadUserTenants(); + } + }, [availableTenants, loadUserTenants]); + + // Handle click outside to close dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + !buttonRef.current?.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Handle tenant switch + const handleTenantSwitch = async (tenantId: string) => { + if (tenantId === currentTenant?.id) { + setIsOpen(false); + return; + } + + const success = await switchTenant(tenantId); + setIsOpen(false); + + if (success) { + const newTenant = availableTenants?.find(t => t.id === tenantId); + showSuccessToast(`Switched to ${newTenant?.name}`, { + title: 'Tenant Switched' + }); + } else { + showErrorToast(error || 'Failed to switch tenant', { + title: 'Switch Failed' + }); + } + }; + + // Handle retry loading tenants + const handleRetry = () => { + clearError(); + loadUserTenants(); + }; + + // Don't render if no tenants available + if (!availableTenants || availableTenants.length === 0) { + return null; + } + + // Don't render if only one tenant + if (availableTenants.length === 1) { + return showLabel ? ( +
+ + {currentTenant?.name} +
+ ) : null; + } + + return ( +
+ {/* Trigger Button */} + + + {/* Dropdown Menu */} + {isOpen && ( +
+ {/* Header */} +
+

Switch Organization

+

+ Select the organization you want to work with +

+
+ + {/* Error State */} + {error && ( +
+
+ + {error} + +
+
+ )} + + {/* Tenant List */} +
+ {availableTenants.map((tenant) => ( + + ))} +
+ + {/* Footer */} +
+

+ Need to add a new organization?{' '} + +

+
+
+ )} + + {/* Loading Overlay */} + {isLoading && ( +
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index a4b6f0ae..ee60e900 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -16,7 +16,7 @@ export { ListItem } from './ListItem'; export { StatsCard, StatsGrid } from './Stats'; export { StatusCard, getStatusColor } from './StatusCard'; export { StatusModal } from './StatusModal'; -export { BakerySelector } from './BakerySelector'; +export { TenantSwitcher } from './TenantSwitcher'; // Export types export type { ButtonProps } from './Button'; diff --git a/frontend/src/config/mock.config.ts b/frontend/src/config/mock.config.ts index d830eef0..94f73e6f 100644 --- a/frontend/src/config/mock.config.ts +++ b/frontend/src/config/mock.config.ts @@ -8,9 +8,9 @@ export const MOCK_CONFIG = { MOCK_MODE: true, // Component-specific toggles - MOCK_REGISTRATION: true, - MOCK_AUTHENTICATION: true, - MOCK_ONBOARDING_FLOW: true, + MOCK_REGISTRATION: false, // Now using real backend + MOCK_AUTHENTICATION: false, // Now using real backend + MOCK_ONBOARDING_FLOW: true, // Keep onboarding mock for now // Mock user data MOCK_USER: { diff --git a/frontend/src/contexts/BakeryContext.tsx b/frontend/src/contexts/BakeryContext.tsx deleted file mode 100644 index 84a42b39..00000000 --- a/frontend/src/contexts/BakeryContext.tsx +++ /dev/null @@ -1,359 +0,0 @@ -import React, { createContext, useContext, useReducer, useEffect, ReactNode } from 'react'; - -export interface Bakery { - id: string; - name: string; - logo?: string; - role: 'owner' | 'manager' | 'baker' | 'staff'; - status: 'active' | 'inactive'; - address?: string; - description?: string; - settings?: { - timezone: string; - currency: string; - language: string; - }; - permissions?: string[]; -} - -interface BakeryState { - bakeries: Bakery[]; - currentBakery: Bakery | null; - isLoading: boolean; - error: string | null; -} - -type BakeryAction = - | { type: 'SET_LOADING'; payload: boolean } - | { type: 'SET_ERROR'; payload: string | null } - | { type: 'SET_BAKERIES'; payload: Bakery[] } - | { type: 'SET_CURRENT_BAKERY'; payload: Bakery } - | { type: 'ADD_BAKERY'; payload: Bakery } - | { type: 'UPDATE_BAKERY'; payload: { id: string; updates: Partial } } - | { type: 'REMOVE_BAKERY'; payload: string }; - -const initialState: BakeryState = { - bakeries: [], - currentBakery: null, - isLoading: false, - error: null, -}; - -// Mock bakeries data -const mockBakeries: Bakery[] = [ - { - id: '1', - name: 'Panadería San Miguel', - logo: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=100&h=100&fit=crop&crop=center', - role: 'owner', - status: 'active', - address: 'Calle Mayor 123, Madrid, 28013', - description: 'Panadería tradicional familiar con más de 30 años de experiencia.', - settings: { - timezone: 'Europe/Madrid', - currency: 'EUR', - language: 'es', - }, - permissions: ['*'], - }, - { - id: '2', - name: 'Panadería La Artesana', - logo: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?w=100&h=100&fit=crop&crop=center', - role: 'manager', - status: 'active', - address: 'Avenida de la Paz 45, Barcelona, 08001', - description: 'Panadería artesanal especializada en panes tradicionales catalanes.', - settings: { - timezone: 'Europe/Madrid', - currency: 'EUR', - language: 'ca', - }, - permissions: ['inventory:*', 'production:*', 'sales:*', 'reports:read'], - }, - { - id: '3', - name: 'Pan y Masa', - logo: 'https://images.unsplash.com/photo-1524704654690-b56c05c78a00?w=100&h=100&fit=crop&crop=center', - role: 'baker', - status: 'active', - address: 'Plaza Central 8, Valencia, 46001', - description: 'Panadería moderna con enfoque en productos orgánicos.', - settings: { - timezone: 'Europe/Madrid', - currency: 'EUR', - language: 'es', - }, - permissions: ['production:*', 'inventory:read', 'inventory:update'], - }, - { - id: '4', - name: 'Horno Dorado', - role: 'staff', - status: 'inactive', - address: 'Calle del Sol 67, Sevilla, 41001', - description: 'Panadería tradicional andaluza.', - settings: { - timezone: 'Europe/Madrid', - currency: 'EUR', - language: 'es', - }, - permissions: ['inventory:read', 'sales:read'], - }, -]; - -function bakeryReducer(state: BakeryState, action: BakeryAction): BakeryState { - switch (action.type) { - case 'SET_LOADING': - return { ...state, isLoading: action.payload }; - - case 'SET_ERROR': - return { ...state, error: action.payload, isLoading: false }; - - case 'SET_BAKERIES': - return { - ...state, - bakeries: action.payload, - currentBakery: state.currentBakery || action.payload[0] || null, - isLoading: false, - error: null - }; - - case 'SET_CURRENT_BAKERY': - // Save to localStorage for persistence - localStorage.setItem('selectedBakery', action.payload.id); - return { ...state, currentBakery: action.payload }; - - case 'ADD_BAKERY': - return { - ...state, - bakeries: [...state.bakeries, action.payload] - }; - - case 'UPDATE_BAKERY': - return { - ...state, - bakeries: state.bakeries.map(bakery => - bakery.id === action.payload.id - ? { ...bakery, ...action.payload.updates } - : bakery - ), - currentBakery: state.currentBakery?.id === action.payload.id - ? { ...state.currentBakery, ...action.payload.updates } - : state.currentBakery - }; - - case 'REMOVE_BAKERY': - const remainingBakeries = state.bakeries.filter(b => b.id !== action.payload); - return { - ...state, - bakeries: remainingBakeries, - currentBakery: state.currentBakery?.id === action.payload - ? remainingBakeries[0] || null - : state.currentBakery - }; - - default: - return state; - } -} - -interface BakeryContextType extends BakeryState { - selectBakery: (bakery: Bakery) => void; - addBakery: (bakery: Omit) => Promise; - updateBakery: (id: string, updates: Partial) => Promise; - removeBakery: (id: string) => Promise; - refreshBakeries: () => Promise; - hasPermission: (permission: string) => boolean; - canAccess: (resource: string, action: string) => boolean; -} - -const BakeryContext = createContext(undefined); - -interface BakeryProviderProps { - children: ReactNode; -} - -export function BakeryProvider({ children }: BakeryProviderProps) { - const [state, dispatch] = useReducer(bakeryReducer, initialState); - - // Load bakeries on mount - useEffect(() => { - loadBakeries(); - }, []); - - // Load saved bakery selection - useEffect(() => { - if (state.bakeries.length > 0 && !state.currentBakery) { - const savedBakeryId = localStorage.getItem('selectedBakery'); - const savedBakery = savedBakeryId - ? state.bakeries.find(b => b.id === savedBakeryId) - : null; - - if (savedBakery) { - dispatch({ type: 'SET_CURRENT_BAKERY', payload: savedBakery }); - } else if (state.bakeries[0]) { - dispatch({ type: 'SET_CURRENT_BAKERY', payload: state.bakeries[0] }); - } - } - }, [state.bakeries, state.currentBakery]); - - const loadBakeries = async () => { - try { - dispatch({ type: 'SET_LOADING', payload: true }); - - // Simulate API call delay - await new Promise(resolve => setTimeout(resolve, 1000)); - - // In a real app, this would be an API call - dispatch({ type: 'SET_BAKERIES', payload: mockBakeries }); - } catch (error) { - dispatch({ - type: 'SET_ERROR', - payload: error instanceof Error ? error.message : 'Error loading bakeries' - }); - } - }; - - const selectBakery = (bakery: Bakery) => { - dispatch({ type: 'SET_CURRENT_BAKERY', payload: bakery }); - }; - - const addBakery = async (bakeryData: Omit) => { - try { - dispatch({ type: 'SET_LOADING', payload: true }); - - // Simulate API call - await new Promise(resolve => setTimeout(resolve, 1000)); - - const newBakery: Bakery = { - ...bakeryData, - id: Date.now().toString(), // In real app, this would come from the API - }; - - dispatch({ type: 'ADD_BAKERY', payload: newBakery }); - dispatch({ type: 'SET_LOADING', payload: false }); - } catch (error) { - dispatch({ - type: 'SET_ERROR', - payload: error instanceof Error ? error.message : 'Error adding bakery' - }); - } - }; - - const updateBakery = async (id: string, updates: Partial) => { - try { - dispatch({ type: 'SET_LOADING', payload: true }); - - // Simulate API call - await new Promise(resolve => setTimeout(resolve, 1000)); - - dispatch({ type: 'UPDATE_BAKERY', payload: { id, updates } }); - dispatch({ type: 'SET_LOADING', payload: false }); - } catch (error) { - dispatch({ - type: 'SET_ERROR', - payload: error instanceof Error ? error.message : 'Error updating bakery' - }); - } - }; - - const removeBakery = async (id: string) => { - try { - dispatch({ type: 'SET_LOADING', payload: true }); - - // Simulate API call - await new Promise(resolve => setTimeout(resolve, 1000)); - - dispatch({ type: 'REMOVE_BAKERY', payload: id }); - dispatch({ type: 'SET_LOADING', payload: false }); - } catch (error) { - dispatch({ - type: 'SET_ERROR', - payload: error instanceof Error ? error.message : 'Error removing bakery' - }); - } - }; - - const refreshBakeries = async () => { - await loadBakeries(); - }; - - const hasPermission = (permission: string): boolean => { - if (!state.currentBakery) return false; - - const permissions = state.currentBakery.permissions || []; - - // Admin/owner has all permissions - if (permissions.includes('*')) return true; - - return permissions.includes(permission); - }; - - const canAccess = (resource: string, action: string): boolean => { - if (!state.currentBakery) return false; - - // Check specific permission - if (hasPermission(`${resource}:${action}`)) return true; - - // Check wildcard permissions - if (hasPermission(`${resource}:*`)) return true; - - // Role-based access fallback - switch (state.currentBakery.role) { - case 'owner': - return true; - case 'manager': - return ['inventory', 'production', 'sales', 'reports'].includes(resource); - case 'baker': - return ['production', 'inventory'].includes(resource) && - ['read', 'update'].includes(action); - case 'staff': - return ['inventory', 'sales'].includes(resource) && - action === 'read'; - default: - return false; - } - }; - - const value: BakeryContextType = { - ...state, - selectBakery, - addBakery, - updateBakery, - removeBakery, - refreshBakeries, - hasPermission, - canAccess, - }; - - return ( - - {children} - - ); -} - -export function useBakery(): BakeryContextType { - const context = useContext(BakeryContext); - if (context === undefined) { - throw new Error('useBakery must be used within a BakeryProvider'); - } - return context; -} - -// Convenience hooks -export const useCurrentBakery = () => { - const { currentBakery } = useBakery(); - return currentBakery; -}; - -export const useBakeries = () => { - const { bakeries } = useBakery(); - return bakeries; -}; - -export const useBakeryPermissions = () => { - const { hasPermission, canAccess } = useBakery(); - return { hasPermission, canAccess }; -}; \ No newline at end of file diff --git a/frontend/src/hooks/useTenantInitializer.ts b/frontend/src/hooks/useTenantInitializer.ts new file mode 100644 index 00000000..aa8c32cb --- /dev/null +++ b/frontend/src/hooks/useTenantInitializer.ts @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; +import { useIsAuthenticated } from '../stores/auth.store'; +import { useTenantActions, useAvailableTenants } from '../stores/tenant.store'; + +/** + * Hook to automatically initialize tenant data when user is authenticated + * This should be used at the app level to ensure tenant data is loaded + */ +export const useTenantInitializer = () => { + const isAuthenticated = useIsAuthenticated(); + const availableTenants = useAvailableTenants(); + const { loadUserTenants } = useTenantActions(); + + useEffect(() => { + if (isAuthenticated && !availableTenants) { + // Load user's available tenants when authenticated and not already loaded + loadUserTenants(); + } + }, [isAuthenticated, availableTenants, loadUserTenants]); + + // Also load tenants when user becomes authenticated (e.g., after login) + useEffect(() => { + if (isAuthenticated && availableTenants === null) { + loadUserTenants(); + } + }, [isAuthenticated, availableTenants, loadUserTenants]); +}; \ No newline at end of file diff --git a/frontend/src/pages/app/onboarding/OnboardingPage.tsx b/frontend/src/pages/app/onboarding/OnboardingPage.tsx index 566aec21..8afe271a 100644 --- a/frontend/src/pages/app/onboarding/OnboardingPage.tsx +++ b/frontend/src/pages/app/onboarding/OnboardingPage.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { OnboardingWizard, OnboardingStep } from '../../../components/domain/onboarding/OnboardingWizard'; import { onboardingApiService } from '../../../services/api/onboarding.service'; -import { useAuth } from '../../../hooks/useAuth'; +import { useAuthUser, useIsAuthenticated } from '../../../stores/auth.store'; import { LoadingSpinner } from '../../../components/shared/LoadingSpinner'; // Step Components @@ -16,7 +16,8 @@ import { CompletionStep } from '../../../components/domain/onboarding/steps/Comp const OnboardingPage: React.FC = () => { const navigate = useNavigate(); - const { user } = useAuth(); + const user = useAuthUser(); + const isAuthenticated = useIsAuthenticated(); const [isLoading, setIsLoading] = useState(false); const [globalData, setGlobalData] = useState({}); @@ -30,8 +31,11 @@ const OnboardingPage: React.FC = () => { isRequired: true, validation: (data) => { if (!data.bakery?.name) return 'El nombre de la panadería es requerido'; - if (!data.bakery?.type) return 'El tipo de panadería es requerido'; - if (!data.bakery?.location) return 'La ubicación es requerida'; + if (!data.bakery?.business_model) return 'El modelo de negocio es requerido'; + if (!data.bakery?.address) return 'La dirección es requerida'; + if (!data.bakery?.city) return 'La ciudad es requerida'; + if (!data.bakery?.postal_code) return 'El código postal es requerido'; + if (!data.bakery?.phone) return 'El teléfono es requerido'; // Tenant creation will happen automatically when validation passes return null; } @@ -142,10 +146,27 @@ const OnboardingPage: React.FC = () => { } }; + // Redirect to login if not authenticated + useEffect(() => { + if (!isAuthenticated) { + navigate('/login', { + state: { + message: 'Debes iniciar sesión para acceder al onboarding.', + returnUrl: '/app/onboarding' + } + }); + } + }, [isAuthenticated, navigate]); + if (isLoading) { return ; } + // Don't render if not authenticated (will redirect) + if (!isAuthenticated || !user) { + return ; + } + return (
= ({ plans, currentPlan, onU }; const SubscriptionPage: React.FC = () => { - const { user, tenant_id } = useAuth(); - const { currentTenant } = useBakeryStore(); + const user = useAuthUser(); + const currentTenant = useCurrentTenant(); const toast = useToast(); const [usageSummary, setUsageSummary] = useState(null); const [availablePlans, setAvailablePlans] = useState(null); @@ -249,13 +249,13 @@ const SubscriptionPage: React.FC = () => { const [upgrading, setUpgrading] = useState(false); useEffect(() => { - if (currentTenant?.id || tenant_id || isMockMode()) { + if (currentTenant?.id || user?.tenant_id || isMockMode()) { loadSubscriptionData(); } - }, [currentTenant, tenant_id]); + }, [currentTenant, user?.tenant_id]); const loadSubscriptionData = async () => { - let tenantId = currentTenant?.id || tenant_id; + let tenantId = currentTenant?.id || user?.tenant_id; // In mock mode, use the mock tenant ID if no real tenant is available if (isMockMode() && !tenantId) { @@ -290,7 +290,7 @@ const SubscriptionPage: React.FC = () => { }; const handleUpgradeConfirm = async () => { - let tenantId = currentTenant?.id || tenant_id; + let tenantId = currentTenant?.id || user?.tenant_id; // In mock mode, use the mock tenant ID if no real tenant is available if (isMockMode() && !tenantId) { diff --git a/frontend/src/pages/app/settings/subscription/SubscriptionPageOld.tsx b/frontend/src/pages/app/settings/subscription/SubscriptionPageOld.tsx index 8ea0cc22..e9035c34 100644 --- a/frontend/src/pages/app/settings/subscription/SubscriptionPageOld.tsx +++ b/frontend/src/pages/app/settings/subscription/SubscriptionPageOld.tsx @@ -31,7 +31,7 @@ import { ExternalLink } from 'lucide-react'; import { useAuth } from '../../../../hooks/api/useAuth'; -import { useBakeryStore } from '../../../../stores/bakery.store'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useToast } from '../../../../hooks/ui/useToast'; import { subscriptionService, @@ -287,7 +287,7 @@ const PlanComparison: React.FC = ({ plans, currentPlan, onU const SubscriptionPage: React.FC = () => { const { user, tenant_id } = useAuth(); - const { currentTenant } = useBakeryStore(); + const currentTenant = useCurrentTenant(); const toast = useToast(); const [usageSummary, setUsageSummary] = useState(null); const [availablePlans, setAvailablePlans] = useState(null); diff --git a/frontend/src/router/ProtectedRoute.tsx b/frontend/src/router/ProtectedRoute.tsx index f67e11f8..36b9fad3 100644 --- a/frontend/src/router/ProtectedRoute.tsx +++ b/frontend/src/router/ProtectedRoute.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores'; import { RouteConfig, canAccessRoute, ROUTES } from './routes.config'; -import { isMockAuthentication } from '../config/mock.config'; interface ProtectedRouteProps { children: React.ReactNode; @@ -129,12 +128,8 @@ export const ProtectedRoute: React.FC = ({ const isLoading = useAuthLoading(); const location = useLocation(); - // MOCK MODE - Allow access to onboarding routes for testing - const isOnboardingRoute = location.pathname.startsWith('/app/onboarding'); - - if (isMockAuthentication() && isOnboardingRoute) { - return <>{children}; - } + // Note: Onboarding routes are now properly protected and require authentication + // Mock mode only applies to the onboarding flow content, not to route protection // Show loading spinner while checking authentication if (isLoading) { diff --git a/frontend/src/services/api/auth.service.ts b/frontend/src/services/api/auth.service.ts index f4fc6986..312478a4 100644 --- a/frontend/src/services/api/auth.service.ts +++ b/frontend/src/services/api/auth.service.ts @@ -86,7 +86,13 @@ class AuthService { // Authentication endpoints async register(userData: UserRegistration): Promise> { - return apiClient.post(`${this.baseUrl}/register`, userData); + const response = await apiClient.post(`${this.baseUrl}/register`, userData); + + if (response.success && response.data) { + this.handleSuccessfulAuth(response.data); + } + + return response; } async login(credentials: UserLogin): Promise> { @@ -164,54 +170,70 @@ class AuthService { return apiClient.post(`${this.baseUrl}/verify-email/confirm`, { token }); } - // Local auth state management + // Local auth state management - Now handled by Zustand store private handleSuccessfulAuth(tokenData: TokenResponse) { - localStorage.setItem('access_token', tokenData.access_token); - - if (tokenData.refresh_token) { - localStorage.setItem('refresh_token', tokenData.refresh_token); - } - - if (tokenData.user) { - localStorage.setItem('user_data', JSON.stringify(tokenData.user)); - - if (tokenData.user.tenant_id) { - localStorage.setItem('tenant_id', tokenData.user.tenant_id); - apiClient.setTenantId(tokenData.user.tenant_id); - } - } - + // Set auth token for API client apiClient.setAuthToken(tokenData.access_token); + + // Set tenant ID for API client if available + if (tokenData.user?.tenant_id) { + apiClient.setTenantId(tokenData.user.tenant_id); + } } private clearAuthData() { - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); - localStorage.removeItem('user_data'); - localStorage.removeItem('tenant_id'); + // Clear API client tokens apiClient.removeAuthToken(); } - // Utility methods + // Utility methods - Now get data from Zustand store isAuthenticated(): boolean { - return !!localStorage.getItem('access_token'); + const authStorage = localStorage.getItem('auth-storage'); + if (!authStorage) return false; + try { + const { state } = JSON.parse(authStorage); + return state?.isAuthenticated || false; + } catch { + return false; + } } getCurrentUserData(): UserData | null { - const userData = localStorage.getItem('user_data'); - return userData ? JSON.parse(userData) : null; + const authStorage = localStorage.getItem('auth-storage'); + if (!authStorage) return null; + try { + const { state } = JSON.parse(authStorage); + return state?.user || null; + } catch { + return null; + } } getAccessToken(): string | null { - return localStorage.getItem('access_token'); + const authStorage = localStorage.getItem('auth-storage'); + if (!authStorage) return null; + try { + const { state } = JSON.parse(authStorage); + return state?.token || null; + } catch { + return null; + } } getRefreshToken(): string | null { - return localStorage.getItem('refresh_token'); + const authStorage = localStorage.getItem('auth-storage'); + if (!authStorage) return null; + try { + const { state } = JSON.parse(authStorage); + return state?.refreshToken || null; + } catch { + return null; + } } getTenantId(): string | null { - return localStorage.getItem('tenant_id'); + const userData = this.getCurrentUserData(); + return userData?.tenant_id || null; } // Check if token is expired (basic check) diff --git a/frontend/src/services/api/client.ts b/frontend/src/services/api/client.ts index 1d0d9f45..3c49b743 100644 --- a/frontend/src/services/api/client.ts +++ b/frontend/src/services/api/client.ts @@ -1,5 +1,32 @@ import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'; +// Utility functions to access auth and tenant store data from localStorage +const getAuthData = () => { + const authStorage = localStorage.getItem('auth-storage'); + if (!authStorage) return null; + try { + const { state } = JSON.parse(authStorage); + return state; + } catch { + return null; + } +}; + +const getTenantData = () => { + const tenantStorage = localStorage.getItem('tenant-storage'); + if (!tenantStorage) return null; + try { + const { state } = JSON.parse(tenantStorage); + return state; + } catch { + return null; + } +}; + +const clearAuthData = () => { + localStorage.removeItem('auth-storage'); +}; + export interface ApiResponse { data: T; success: boolean; @@ -41,12 +68,15 @@ class ApiClient { // Request interceptor - add auth token and tenant ID this.axiosInstance.interceptors.request.use( (config: InternalAxiosRequestConfig) => { - const token = localStorage.getItem('access_token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; + const authData = getAuthData(); + if (authData?.token) { + config.headers.Authorization = `Bearer ${authData.token}`; } - const tenantId = localStorage.getItem('tenant_id'); + // Get tenant ID from tenant store (priority) or fallback to user's tenant_id + const tenantData = getTenantData(); + const tenantId = tenantData?.currentTenant?.id || authData?.user?.tenant_id; + if (tenantId) { config.headers['X-Tenant-ID'] = tenantId; } @@ -67,8 +97,8 @@ class ApiClient { originalRequest._retry = true; try { - const refreshToken = localStorage.getItem('refresh_token'); - if (refreshToken) { + const authData = getAuthData(); + if (authData?.refreshToken) { const newToken = await this.refreshToken(); originalRequest.headers.Authorization = `Bearer ${newToken}`; return this.axiosInstance(originalRequest); @@ -113,29 +143,34 @@ class ApiClient { } private async performTokenRefresh(): Promise { - const refreshToken = localStorage.getItem('refresh_token'); - if (!refreshToken) { + const authData = getAuthData(); + if (!authData?.refreshToken) { throw new Error('No refresh token available'); } const response = await axios.post(`${API_BASE_URL}/auth/refresh`, { - refresh_token: refreshToken, + refresh_token: authData.refreshToken, }); const { access_token, refresh_token } = response.data; - localStorage.setItem('access_token', access_token); - if (refresh_token) { - localStorage.setItem('refresh_token', refresh_token); - } + + // Update the Zustand store by modifying the auth-storage directly + const newAuthData = { + ...authData, + token: access_token, + refreshToken: refresh_token || authData.refreshToken + }; + + localStorage.setItem('auth-storage', JSON.stringify({ + state: newAuthData, + version: 0 + })); return access_token; } private handleAuthFailure() { - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); - localStorage.removeItem('user_data'); - localStorage.removeItem('tenant_id'); + clearAuthData(); // Redirect to login window.location.href = '/login'; @@ -197,19 +232,16 @@ class ApiClient { }; } - // Utility methods + // Utility methods - Now work with Zustand store setAuthToken(token: string) { - localStorage.setItem('access_token', token); this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${token}`; } removeAuthToken() { - localStorage.removeItem('access_token'); delete this.axiosInstance.defaults.headers.common['Authorization']; } setTenantId(tenantId: string) { - localStorage.setItem('tenant_id', tenantId); this.axiosInstance.defaults.headers.common['X-Tenant-ID'] = tenantId; } diff --git a/frontend/src/services/api/tenant.service.ts b/frontend/src/services/api/tenant.service.ts index 45db6aa4..b7179de7 100644 --- a/frontend/src/services/api/tenant.service.ts +++ b/frontend/src/services/api/tenant.service.ts @@ -1,6 +1,6 @@ import { apiClient, ApiResponse } from './client'; -// Request/Response Types based on backend schemas +// Request/Response Types based on backend schemas - UPDATED TO MATCH BACKEND export interface BakeryRegistration { name: string; address: string; @@ -101,7 +101,7 @@ class TenantService { // Tenant CRUD operations async createTenant(tenantData: BakeryRegistration): Promise> { - return apiClient.post(`${this.baseUrl}`, tenantData); + return apiClient.post(`${this.baseUrl}/register`, tenantData); } async getTenant(tenantId: string): Promise> { @@ -151,15 +151,31 @@ class TenantService { } async switchTenant(tenantId: string): Promise> { - const response = await apiClient.post(`${this.baseUrl}/${tenantId}/switch`); - - if (response.success && response.data?.tenant) { - // Update local tenant context - localStorage.setItem('tenant_id', tenantId); - apiClient.setTenantId(tenantId); + // Frontend-only tenant switching since backend doesn't have this endpoint + // We'll simulate the response and update the tenant store directly + try { + const tenant = await this.getTenant(tenantId); + if (tenant.success) { + // Update API client tenant context + apiClient.setTenantId(tenantId); + + return { + success: true, + data: { + message: 'Tenant switched successfully', + tenant: tenant.data + } + }; + } else { + throw new Error('Tenant not found'); + } + } catch (error) { + return { + success: false, + data: null, + error: error instanceof Error ? error.message : 'Failed to switch tenant' + }; } - - return response; } // Member management @@ -228,33 +244,33 @@ class TenantService { } // Utility methods - async getUserTenants(): Promise> { - return apiClient.get(`${this.baseUrl}/my-tenants`); + async getUserTenants(userId?: string): Promise> { + // If no userId provided, we'll get it from the auth store/token + return apiClient.get(`${this.baseUrl}/users/${userId || 'current'}`); } async validateTenantSlug(slug: string): Promise> { return apiClient.get(`${this.baseUrl}/validate-slug/${slug}`); } - // Local state management helpers + // Local state management helpers - Now uses tenant store getCurrentTenantId(): string | null { - return localStorage.getItem('tenant_id'); + // This will be handled by the tenant store + return null; } getCurrentTenantData(): TenantResponse | null { - const tenantData = localStorage.getItem('tenant_data'); - return tenantData ? JSON.parse(tenantData) : null; + // This will be handled by the tenant store + return null; } setCurrentTenant(tenant: TenantResponse) { - localStorage.setItem('tenant_id', tenant.id); - localStorage.setItem('tenant_data', JSON.stringify(tenant)); + // This will be handled by the tenant store apiClient.setTenantId(tenant.id); } clearCurrentTenant() { - localStorage.removeItem('tenant_id'); - localStorage.removeItem('tenant_data'); + // This will be handled by the tenant store } // Business type helpers diff --git a/frontend/src/stores/auth.store.ts b/frontend/src/stores/auth.store.ts index 9444c376..2bb1293a 100644 --- a/frontend/src/stores/auth.store.ts +++ b/frontend/src/stores/auth.store.ts @@ -4,19 +4,16 @@ import { persist, createJSONStorage } from 'zustand/middleware'; export interface User { id: string; email: string; - name: string; - role: 'admin' | 'manager' | 'baker' | 'staff'; - permissions: string[]; - tenantId: string; - tenantName: string; - avatar?: string; - lastLogin?: string; - preferences?: { - language: string; - timezone: string; - theme: 'light' | 'dark'; - notifications: boolean; - }; + full_name: string; // Updated to match backend + is_active: boolean; + is_verified: boolean; + created_at: string; + last_login?: string; + phone?: string; + language?: string; + timezone?: string; + tenant_id?: string; + role?: string; } export interface AuthState { @@ -30,6 +27,7 @@ export interface AuthState { // Actions login: (email: string, password: string) => Promise; + register: (userData: { email: string; password: string; full_name: string; tenant_name?: string }) => Promise; logout: () => void; refreshAuth: () => Promise; updateUser: (updates: Partial) => void; @@ -42,50 +40,7 @@ export interface AuthState { canAccess: (resource: string, action: string) => boolean; } -// Mock API functions (replace with actual API calls) -const mockLogin = async (email: string, password: string): Promise<{ user: User; token: string; refreshToken: string }> => { - // Simulate API delay - await new Promise(resolve => setTimeout(resolve, 1000)); - - if (email === 'admin@bakery.com' && password === 'admin12345') { - return { - user: { - id: '1', - email: 'admin@bakery.com', - name: 'Admin User', - role: 'admin', - permissions: ['*'], - tenantId: 'tenant-1', - tenantName: 'Panadería San Miguel', - avatar: undefined, - lastLogin: new Date().toISOString(), - preferences: { - language: 'es', - timezone: 'Europe/Madrid', - theme: 'light', - notifications: true, - }, - }, - token: 'mock-jwt-token', - refreshToken: 'mock-refresh-token', - }; - } - - throw new Error('Credenciales inválidas'); -}; - -const mockRefreshToken = async (refreshToken: string): Promise<{ token: string; refreshToken: string }> => { - await new Promise(resolve => setTimeout(resolve, 500)); - - if (refreshToken === 'mock-refresh-token') { - return { - token: 'new-mock-jwt-token', - refreshToken: 'new-mock-refresh-token', - }; - } - - throw new Error('Invalid refresh token'); -}; +import { authService } from '../services/api/auth.service'; export const useAuthStore = create()( persist( @@ -103,16 +58,20 @@ export const useAuthStore = create()( try { set({ isLoading: true, error: null }); - const response = await mockLogin(email, password); + const response = await authService.login({ email, password }); - set({ - user: response.user, - token: response.token, - refreshToken: response.refreshToken, - isAuthenticated: true, - isLoading: false, - error: null, - }); + if (response.success && response.data) { + set({ + user: response.data.user || null, + token: response.data.access_token, + refreshToken: response.data.refresh_token || null, + isAuthenticated: true, + isLoading: false, + error: null, + }); + } else { + throw new Error('Login failed'); + } } catch (error) { set({ user: null, @@ -126,6 +85,37 @@ export const useAuthStore = create()( } }, + register: async (userData: { email: string; password: string; full_name: string; tenant_name?: string }) => { + try { + set({ isLoading: true, error: null }); + + const response = await authService.register(userData); + + if (response.success && response.data) { + set({ + user: response.data.user || null, + token: response.data.access_token, + refreshToken: response.data.refresh_token || null, + isAuthenticated: true, + isLoading: false, + error: null, + }); + } else { + throw new Error('Registration failed'); + } + } catch (error) { + set({ + user: null, + token: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false, + error: error instanceof Error ? error.message : 'Error de registro', + }); + throw error; + } + }, + logout: () => { set({ user: null, @@ -146,14 +136,18 @@ export const useAuthStore = create()( set({ isLoading: true }); - const response = await mockRefreshToken(refreshToken); + const response = await authService.refreshToken(refreshToken); - set({ - token: response.token, - refreshToken: response.refreshToken, - isLoading: false, - error: null, - }); + if (response.success && response.data) { + set({ + token: response.data.access_token, + refreshToken: response.data.refresh_token || refreshToken, + isLoading: false, + error: null, + }); + } else { + throw new Error('Token refresh failed'); + } } catch (error) { set({ user: null, @@ -184,15 +178,16 @@ export const useAuthStore = create()( set({ isLoading: loading }); }, - // Permission helpers - hasPermission: (permission: string): boolean => { + // Permission helpers - Simplified for backend compatibility + hasPermission: (_permission: string): boolean => { const { user } = get(); - if (!user) return false; + if (!user || !user.is_active) return false; // Admin has all permissions - if (user.permissions.includes('*')) return true; + if (user.role === 'admin') return true; - return user.permissions.includes(permission); + // Basic role-based permissions + return false; }, hasRole: (role: string): boolean => { @@ -201,28 +196,17 @@ export const useAuthStore = create()( }, canAccess: (resource: string, action: string): boolean => { - const { user, hasPermission } = get(); - if (!user) return false; + const { user } = get(); + if (!user || !user.is_active) return false; - // Check specific permission - if (hasPermission(`${resource}:${action}`)) return true; - - // Check wildcard permissions - if (hasPermission(`${resource}:*`)) return true; - if (hasPermission('*')) return true; - - // Role-based access fallback + // Role-based access control switch (user.role) { case 'admin': return true; case 'manager': return ['inventory', 'production', 'sales', 'reports'].includes(resource); - case 'baker': - return ['production', 'inventory'].includes(resource) && - ['read', 'update'].includes(action); - case 'staff': - return ['inventory', 'sales'].includes(resource) && - action === 'read'; + case 'user': + return ['inventory', 'sales'].includes(resource) && action === 'read'; default: return false; } @@ -237,6 +221,18 @@ export const useAuthStore = create()( refreshToken: state.refreshToken, isAuthenticated: state.isAuthenticated, }), + onRehydrateStorage: () => (state) => { + // Initialize API client with stored token when store rehydrates + if (state?.token) { + import('../services/api/client').then(({ apiClient }) => { + apiClient.setAuthToken(state.token!); + + if (state.user?.tenant_id) { + apiClient.setTenantId(state.user.tenant_id); + } + }); + } + }, } ) ); @@ -255,6 +251,7 @@ export const usePermissions = () => useAuthStore((state) => ({ // Hook for auth actions export const useAuthActions = () => useAuthStore((state) => ({ login: state.login, + register: state.register, logout: state.logout, refreshAuth: state.refreshAuth, updateUser: state.updateUser, diff --git a/frontend/src/stores/bakery.store.ts b/frontend/src/stores/bakery.store.ts deleted file mode 100644 index 78431c71..00000000 --- a/frontend/src/stores/bakery.store.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { create } from 'zustand'; -import { devtools, persist } from 'zustand/middleware'; -import { immer } from 'zustand/middleware/immer'; - -export type BakeryType = 'artisan' | 'dependent'; -export type BusinessModel = 'production' | 'retail' | 'hybrid'; - -interface BakeryState { - // State - currentTenant: Tenant | null; - bakeryType: BakeryType; - businessModel: BusinessModel; - operatingHours: { - start: string; - end: string; - }; - features: { - inventory: boolean; - production: boolean; - forecasting: boolean; - analytics: boolean; - pos: boolean; - }; - - // Actions - setTenant: (tenant: Tenant) => void; - setBakeryType: (type: BakeryType) => void; - setBusinessModel: (model: BusinessModel) => void; - updateFeatures: (features: Partial) => void; - reset: () => void; -} - -interface Tenant { - id: string; - name: string; - subscription_plan: string; - created_at: string; - members_count: number; -} - -const initialState = { - currentTenant: null, - bakeryType: 'artisan' as BakeryType, - businessModel: 'production' as BusinessModel, - operatingHours: { - start: '04:00', - end: '20:00', - }, - features: { - inventory: true, - production: true, - forecasting: true, - analytics: true, - pos: false, - }, -}; - -export const useBakeryStore = create()( - devtools( - persist( - immer((set) => ({ - ...initialState, - - setTenant: (tenant) => - set((state) => { - state.currentTenant = tenant; - }), - - setBakeryType: (type) => - set((state) => { - state.bakeryType = type; - // Adjust features based on bakery type - if (type === 'artisan') { - state.features.pos = false; - state.businessModel = 'production'; - } else if (type === 'dependent') { - state.features.pos = true; - state.businessModel = 'retail'; - } - }), - - setBusinessModel: (model) => - set((state) => { - state.businessModel = model; - }), - - updateFeatures: (features) => - set((state) => { - state.features = { ...state.features, ...features }; - }), - - reset: () => set(() => initialState), - })), - { - name: 'bakery-storage', - } - ) - ) -); \ No newline at end of file diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts index 9ce8ff66..6d40508c 100644 --- a/frontend/src/stores/index.ts +++ b/frontend/src/stores/index.ts @@ -5,8 +5,9 @@ export type { User, AuthState } from './auth.store'; export { useUIStore, useLanguage, useSidebar, useCompactMode, useViewMode, useLoading, useToasts, useModals, useBreadcrumbs, usePreferences, useUIActions } from './ui.store'; export type { Theme, Language, ViewMode, SidebarState, Toast, Modal, UIState } from './ui.store'; -export { useBakeryStore } from './bakery.store'; -export type { BakeryType, BusinessModel } from './bakery.store'; + +export { useTenantStore, useCurrentTenant, useAvailableTenants, useTenantLoading, useTenantError, useTenantActions, useTenantPermissions, useTenant } from './tenant.store'; +export type { TenantState } from './tenant.store'; export { useAlertsStore, useAlerts, useAlertRules, useAlertFilters, useAlertSettings, useUnreadAlertsCount, useCriticalAlertsCount } from './alerts.store'; export type { Alert, AlertRule, AlertCondition, AlertAction, AlertsState, AlertType, AlertCategory, AlertPriority, AlertStatus } from './alerts.store'; \ No newline at end of file diff --git a/frontend/src/stores/tenant.store.ts b/frontend/src/stores/tenant.store.ts new file mode 100644 index 00000000..4a0e895a --- /dev/null +++ b/frontend/src/stores/tenant.store.ts @@ -0,0 +1,230 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { tenantService, TenantResponse } from '../services/api/tenant.service'; +import { useAuthUser } from './auth.store'; + +export interface TenantState { + // State + currentTenant: TenantResponse | null; + availableTenants: TenantResponse[] | null; + isLoading: boolean; + error: string | null; + + // Actions + setCurrentTenant: (tenant: TenantResponse) => void; + switchTenant: (tenantId: string) => Promise; + loadUserTenants: () => Promise; + clearTenants: () => void; + clearError: () => void; + setLoading: (loading: boolean) => void; + + // Permission helpers (migrated from BakeryContext) + hasPermission: (permission: string) => boolean; + canAccess: (resource: string, action: string) => boolean; +} + +export const useTenantStore = create()( + persist( + (set, get) => ({ + // Initial state + currentTenant: null, + availableTenants: null, + isLoading: false, + error: null, + + // Actions + setCurrentTenant: (tenant: TenantResponse) => { + set({ currentTenant: tenant }); + // Update API client with new tenant ID + tenantService.setCurrentTenant(tenant); + }, + + switchTenant: async (tenantId: string): Promise => { + try { + set({ isLoading: true, error: null }); + + const { availableTenants } = get(); + + // Find tenant in available tenants first + const targetTenant = availableTenants?.find(t => t.id === tenantId); + if (!targetTenant) { + throw new Error('Tenant not found in available tenants'); + } + + // Switch tenant using service + const response = await tenantService.switchTenant(tenantId); + + if (response.success && response.data?.tenant) { + get().setCurrentTenant(response.data.tenant); + set({ isLoading: false }); + return true; + } else { + throw new Error(response.error || 'Failed to switch tenant'); + } + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to switch tenant', + }); + return false; + } + }, + + loadUserTenants: async (): Promise => { + try { + set({ isLoading: true, error: null }); + + // Get current user to determine user ID + const user = useAuthUser.getState?.() || JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.user; + + if (!user?.id) { + throw new Error('User not authenticated'); + } + + const response = await tenantService.getUserTenants(user.id); + + if (response.success && response.data) { + const tenants = Array.isArray(response.data) ? response.data : [response.data]; + + set({ + availableTenants: tenants, + isLoading: false + }); + + // If no current tenant is set, set the first one as current + const { currentTenant } = get(); + if (!currentTenant && tenants.length > 0) { + get().setCurrentTenant(tenants[0]); + } + } else { + throw new Error(response.error || 'Failed to load user tenants'); + } + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to load tenants', + }); + } + }, + + clearTenants: () => { + set({ + currentTenant: null, + availableTenants: null, + error: null, + }); + tenantService.clearCurrentTenant(); + }, + + clearError: () => { + set({ error: null }); + }, + + setLoading: (loading: boolean) => { + set({ isLoading: loading }); + }, + + // Permission helpers (migrated from BakeryContext) + hasPermission: (permission: string): boolean => { + const { currentTenant } = get(); + if (!currentTenant) return false; + + // Get user to determine role within this tenant + const user = useAuthUser.getState?.() || JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.user; + + // Admin role has all permissions + if (user?.role === 'admin') return true; + + // TODO: Implement proper tenant-based permissions + // For now, use basic role-based permissions + switch (user?.role) { + case 'admin': + return true; + case 'manager': + return ['inventory', 'production', 'sales', 'reports'].some(resource => + permission.startsWith(resource) + ); + case 'baker': + return ['production', 'inventory'].some(resource => + permission.startsWith(resource) + ) && !permission.includes(':delete'); + case 'staff': + return ['inventory', 'sales'].some(resource => + permission.startsWith(resource) + ) && permission.includes(':read'); + default: + return false; + } + }, + + canAccess: (resource: string, action: string): boolean => { + const { hasPermission } = get(); + + // Check specific permission + if (hasPermission(`${resource}:${action}`)) return true; + + // Check wildcard permissions + if (hasPermission(`${resource}:*`)) return true; + + return false; + }, + }), + { + name: 'tenant-storage', + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ + currentTenant: state.currentTenant, + availableTenants: state.availableTenants, + }), + onRehydrateStorage: () => (state) => { + // Initialize API client with stored tenant when store rehydrates + if (state?.currentTenant) { + import('../services/api/client').then(({ apiClient }) => { + apiClient.setTenantId(state.currentTenant!.id); + }); + } + }, + } + ) +); + +// Selectors for common use cases +export const useCurrentTenant = () => useTenantStore((state) => state.currentTenant); +export const useAvailableTenants = () => useTenantStore((state) => state.availableTenants); +export const useTenantLoading = () => useTenantStore((state) => state.isLoading); +export const useTenantError = () => useTenantStore((state) => state.error); + +// Hook for tenant actions +export const useTenantActions = () => useTenantStore((state) => ({ + setCurrentTenant: state.setCurrentTenant, + switchTenant: state.switchTenant, + loadUserTenants: state.loadUserTenants, + clearTenants: state.clearTenants, + clearError: state.clearError, + setLoading: state.setLoading, +})); + +// Hook for tenant permissions (replaces useBakeryPermissions) +export const useTenantPermissions = () => useTenantStore((state) => ({ + hasPermission: state.hasPermission, + canAccess: state.canAccess, +})); + +// Combined hook for convenience +export const useTenant = () => { + const currentTenant = useCurrentTenant(); + const availableTenants = useAvailableTenants(); + const isLoading = useTenantLoading(); + const error = useTenantError(); + const actions = useTenantActions(); + const permissions = useTenantPermissions(); + + return { + currentTenant, + availableTenants, + isLoading, + error, + ...actions, + ...permissions, + }; +}; \ No newline at end of file diff --git a/frontend/src/types/auth.types.ts b/frontend/src/types/auth.types.ts index 38c0c912..8ec5bcb6 100644 --- a/frontend/src/types/auth.types.ts +++ b/frontend/src/types/auth.types.ts @@ -1,29 +1,25 @@ -// Authentication related types +// Authentication related types - Updated to match backend exactly export interface User { id: string; email: string; - full_name: string; + full_name: string; // Backend uses full_name, not name is_active: boolean; is_verified: boolean; - created_at: string; + created_at: string; // ISO format datetime string last_login?: string; phone?: string; language?: string; timezone?: string; tenant_id?: string; - role?: UserRole; - avatar_url?: string; + role?: string; // Backend uses string, not enum } export interface UserRegistration { email: string; password: string; full_name: string; - tenant_name?: string; - role?: UserRole; - phone?: string; - language?: string; - timezone?: string; + tenant_name?: string; // Optional in backend + role?: string; // Backend uses string, defaults to "user" } export interface UserLogin { @@ -35,8 +31,8 @@ export interface UserLogin { export interface TokenResponse { access_token: string; refresh_token?: string; - token_type: string; - expires_in: number; + token_type: string; // defaults to "bearer" + expires_in: number; // seconds, defaults to 3600 user?: User; } @@ -94,7 +90,7 @@ export interface RegisterFormData { password: string; confirmPassword: string; full_name: string; - tenant_name: string; + tenant_name?: string; // Optional to match backend phone?: string; acceptTerms: boolean; } @@ -182,13 +178,11 @@ export interface OAuthProvider { enabled: boolean; } -// Enums +// Enums - Simplified to match backend export enum UserRole { USER = 'user', ADMIN = 'admin', MANAGER = 'manager', - OWNER = 'owner', - VIEWER = 'viewer', } export enum AuthProvider { diff --git a/services/auth/app/core/security.py b/services/auth/app/core/security.py index 52fe8591..fd8c4d0c 100644 --- a/services/auth/app/core/security.py +++ b/services/auth/app/core/security.py @@ -7,7 +7,7 @@ FIXED VERSION - Consistent password hashing using passlib import re import hashlib from datetime import datetime, timedelta, timezone -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List import redis.asyncio as redis from fastapi import HTTPException, status import structlog @@ -36,6 +36,9 @@ class SecurityManager: if len(password) < settings.PASSWORD_MIN_LENGTH: return False + if len(password) > 128: # Max length from Pydantic schema + return False + if settings.PASSWORD_REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password): return False @@ -50,6 +53,31 @@ class SecurityManager: return True + @staticmethod + def get_password_validation_errors(password: str) -> List[str]: + """Get detailed password validation errors for better UX""" + errors = [] + + if len(password) < settings.PASSWORD_MIN_LENGTH: + errors.append(f"Password must be at least {settings.PASSWORD_MIN_LENGTH} characters long") + + if len(password) > 128: + errors.append("Password cannot exceed 128 characters") + + if settings.PASSWORD_REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password): + errors.append("Password must contain at least one uppercase letter") + + if settings.PASSWORD_REQUIRE_LOWERCASE and not re.search(r'[a-z]', password): + errors.append("Password must contain at least one lowercase letter") + + if settings.PASSWORD_REQUIRE_NUMBERS and not re.search(r'\d', password): + errors.append("Password must contain at least one number") + + if settings.PASSWORD_REQUIRE_SYMBOLS and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password): + errors.append("Password must contain at least one symbol (!@#$%^&*(),.?\":{}|<>)") + + return errors + @staticmethod def hash_password(password: str) -> str: """Hash password using passlib bcrypt - FIXED""" diff --git a/services/auth/app/services/auth_service.py b/services/auth/app/services/auth_service.py index 016980e0..8f95d34d 100644 --- a/services/auth/app/services/auth_service.py +++ b/services/auth/app/services/auth_service.py @@ -50,6 +50,10 @@ class EnhancedAuthService: if existing_user: raise DuplicateRecordError("User with this email already exists") + # Validate password strength + if not SecurityManager.validate_password(user_data.password): + raise ValueError("Password does not meet security requirements") + # Create user data user_role = user_data.role if user_data.role else "user" hashed_password = SecurityManager.hash_password(user_data.password) @@ -446,6 +450,13 @@ class EnhancedAuthService: detail="Invalid old password" ) + # Validate new password strength + if not SecurityManager.validate_password(new_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="New password does not meet security requirements" + ) + # Hash new password and update new_hashed_password = SecurityManager.hash_password(new_password) await user_repo.update(user_id, {"hashed_password": new_hashed_password})