Start integrating the onboarding flow with backend 1

This commit is contained in:
Urtzi Alfaro
2025-09-03 18:29:56 +02:00
parent a55d48e635
commit a11fdfba24
31 changed files with 1202 additions and 1142 deletions

View File

@@ -8,7 +8,6 @@ import { LoadingSpinner } from './components/shared/LoadingSpinner';
import { AppRouter } from './router/AppRouter'; import { AppRouter } from './router/AppRouter';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { BakeryProvider } from './contexts/BakeryContext';
import { SSEProvider } from './contexts/SSEContext'; import { SSEProvider } from './contexts/SSEContext';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -29,7 +28,6 @@ function App() {
<BrowserRouter> <BrowserRouter>
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<BakeryProvider>
<SSEProvider> <SSEProvider>
<Suspense fallback={<LoadingSpinner overlay />}> <Suspense fallback={<LoadingSpinner overlay />}>
<AppRouter /> <AppRouter />
@@ -45,7 +43,6 @@ function App() {
/> />
</Suspense> </Suspense>
</SSEProvider> </SSEProvider>
</BakeryProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
</BrowserRouter> </BrowserRouter>

View File

@@ -56,8 +56,6 @@ export const LoginForm: React.FC<LoginFormProps> = ({
if (!credentials.password) { if (!credentials.password) {
newErrors.password = 'La contraseña es requerida'; 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); setErrors(newErrors);

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Button, Input, Card } from '../../ui'; 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'; import { useToast } from '../../../hooks/ui/useToast';
interface PasswordResetFormProps { interface PasswordResetFormProps {
@@ -33,7 +34,10 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
const emailInputRef = useRef<HTMLInputElement>(null); const emailInputRef = useRef<HTMLInputElement>(null);
const passwordInputRef = useRef<HTMLInputElement>(null); const passwordInputRef = useRef<HTMLInputElement>(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 { showToast } = useToast();
const isResetMode = Boolean(token) || mode === 'reset'; const isResetMode = Boolean(token) || mode === 'reset';
@@ -109,12 +113,11 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
if (!password) { if (!password) {
newErrors.password = 'La contraseña es requerida'; newErrors.password = 'La contraseña es requerida';
} else if (password.length < 8) { } else {
newErrors.password = 'La contraseña debe tener al menos 8 caracteres'; const passwordErrors = getPasswordErrors(password);
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) { if (passwordErrors.length > 0) {
newErrors.password = 'La contraseña debe contener mayúsculas, minúsculas y números'; newErrors.password = passwordErrors[0]; // Show first error
} else if (passwordStrength < 50) { }
newErrors.password = 'La contraseña es demasiado débil. Intenta con una más segura';
} }
if (!confirmPassword) { if (!confirmPassword) {
@@ -147,7 +150,9 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
} }
try { try {
const success = await requestPasswordReset(email); // TODO: Implement password reset request
// const success = await requestPasswordReset(email);
const success = false; // Placeholder
if (success) { if (success) {
setIsEmailSent(true); setIsEmailSent(true);
showToast({ showToast({
@@ -192,7 +197,9 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
} }
try { try {
const success = await resetPassword(token, password); // TODO: Implement password reset
// const success = await resetPassword(token, password);
const success = false; // Placeholder
if (success) { if (success) {
showToast({ showToast({
type: 'success', type: 'success',

View File

@@ -231,10 +231,19 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
if (!passwordData.newPassword) { if (!passwordData.newPassword) {
newErrors.newPassword = 'La nueva contraseña es requerida'; newErrors.newPassword = 'La nueva contraseña es requerida';
} else if (passwordData.newPassword.length < 8) { } else {
newErrors.newPassword = 'La contraseña debe tener al menos 8 caracteres'; // Use simpler validation for now to match backend exactly
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(passwordData.newPassword)) { if (passwordData.newPassword.length < 8) {
newErrors.newPassword = 'La contraseña debe contener mayúsculas, minúsculas y números'; 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) { if (!passwordData.confirmNewPassword) {

View File

@@ -1,9 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Button, Input, Card } from '../../ui'; import { Button, Input, Card } from '../../ui';
import { useAuth } from '../../../hooks/api/useAuth'; import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
import { UserRegistration } from '../../../types/auth.types'; import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
import { useToast } from '../../../hooks/ui/useToast'; import { useToast } from '../../../hooks/ui/useToast';
import { isMockRegistration } from '../../../config/mock.config';
interface RegisterFormProps { interface RegisterFormProps {
onSuccess?: () => void; onSuccess?: () => void;
@@ -36,9 +35,20 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = 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(); 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 validateForm = (): boolean => {
const newErrors: Partial<SimpleUserRegistration> = {}; const newErrors: Partial<SimpleUserRegistration> = {};
@@ -56,8 +66,11 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
if (!formData.password) { if (!formData.password) {
newErrors.password = 'La contraseña es requerida'; newErrors.password = 'La contraseña es requerida';
} else if (formData.password.length < 8) { } else {
newErrors.password = 'La contraseña debe tener al menos 8 caracteres'; const passwordErrors = getPasswordErrors(formData.password);
if (passwordErrors.length > 0) {
newErrors.password = passwordErrors[0]; // Show first error
}
} }
if (!formData.confirmPassword) { if (!formData.confirmPassword) {
@@ -76,64 +89,28 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); 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()) { if (!validateForm()) {
return; return;
} }
try { try {
const registrationData: UserRegistration = { const registrationData = {
full_name: formData.full_name, full_name: formData.full_name,
email: formData.email, email: formData.email,
password: formData.password, password: formData.password,
tenant_name: 'Default Bakery', // Default value since we're not collecting it 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.', {
showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', { title: 'Cuenta creada exitosamente'
title: 'Cuenta creada exitosamente' });
}); onSuccess?.();
onSuccess?.();
} else {
showErrorToast(error || 'No se pudo crear la cuenta. Verifica que el email no esté en uso.', {
title: 'Error al crear la cuenta'
});
}
} catch (err) { } catch (err) {
showErrorToast('No se pudo conectar con el servidor. Verifica tu conexión a internet.', { showErrorToast(error || 'No se pudo crear la cuenta. Verifica que el email no esté en uso.', {
title: 'Error de conexión' title: 'Error al crear la cuenta'
}); });
} }
}; };
@@ -199,6 +176,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
onChange={handleInputChange('password')} onChange={handleInputChange('password')}
error={errors.password} error={errors.password}
disabled={isLoading} disabled={isLoading}
maxLength={128}
autoComplete="new-password" autoComplete="new-password"
required required
leftIcon={ leftIcon={
@@ -227,21 +205,49 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
} }
/> />
<Input {/* Password Criteria - Show when user is typing */}
type={showConfirmPassword ? 'text' : 'password'} {formData.password && (
label="Confirmar Contraseña" <PasswordCriteria
placeholder="Repite tu contraseña" password={formData.password}
value={formData.confirmPassword} className="mt-2"
onChange={handleInputChange('confirmPassword')} showOnlyFailed={false}
error={errors.confirmPassword} />
disabled={isLoading} )}
autoComplete="new-password"
required <div className="relative">
leftIcon={ <Input
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> type={showConfirmPassword ? 'text' : 'password'}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> label="Confirmar Contraseña"
</svg> placeholder="Repite tu contraseña"
} value={formData.confirmPassword}
onChange={handleInputChange('confirmPassword')}
error={errors.confirmPassword}
disabled={isLoading}
maxLength={128}
autoComplete="new-password"
required
className={
passwordMatchStatus === 'match' && formData.confirmPassword
? 'border-color-success focus:border-color-success ring-color-success'
: passwordMatchStatus === 'mismatch' && formData.confirmPassword
? 'border-color-error focus:border-color-error ring-color-error'
: ''
}
leftIcon={
passwordMatchStatus === 'match' && formData.confirmPassword ? (
<svg className="w-5 h-5 text-color-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : passwordMatchStatus === 'mismatch' && formData.confirmPassword ? (
<svg className="w-5 h-5 text-color-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className="w-5 h-5 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 0h12a2 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={ rightIcon={
<button <button
type="button" type="button"
@@ -262,6 +268,32 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
</button> </button>
} }
/> />
{/* Password Match Status Message */}
{formData.confirmPassword && (
<div className="mt-2 transition-all duration-300 ease-in-out">
{passwordMatchStatus === 'match' ? (
<div className="flex items-center space-x-2 text-color-success animate-fade-in">
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-color-success/10 flex items-center justify-center">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<span className="text-sm font-medium">¡Las contraseñas coinciden!</span>
</div>
) : (
<div className="flex items-center space-x-2 text-color-error animate-fade-in">
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-color-error/10 flex items-center justify-center">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
<span className="text-sm font-medium">Las contraseñas no coinciden</span>
</div>
)}
</div>
)}
</div>
<div className="space-y-4 pt-4 border-t border-border-primary"> <div className="space-y-4 pt-4 border-t border-border-primary">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
@@ -292,7 +324,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
size="lg" size="lg"
isLoading={isLoading} isLoading={isLoading}
loadingText="Creando cuenta..." loadingText="Creando cuenta..."
disabled={isLoading} disabled={isLoading || !validatePassword(formData.password) || !formData.acceptTerms || passwordMatchStatus !== 'match'}
className="w-full" className="w-full"
onClick={(e) => { onClick={(e) => {
console.log('Button clicked!'); console.log('Button clicked!');

View File

@@ -1,5 +1,9 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { Card, Button, Input, Select, Badge } from '../../ui'; 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 { export interface OnboardingStep {
id: string; id: string;
@@ -37,6 +41,7 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
const [stepData, setStepData] = useState<Record<string, any>>({}); const [stepData, setStepData] = useState<Record<string, any>>({});
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set()); const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({}); const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const { setCurrentTenant } = useTenantActions();
const currentStep = steps[currentStepIndex]; const currentStep = steps[currentStepIndex];
@@ -80,41 +85,55 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
const data = stepData[currentStep.id] || {}; const data = stepData[currentStep.id] || {};
if (!data.bakery?.tenant_id) { if (!data.bakery?.tenant_id) {
// Create tenant inline // Create tenant inline using real backend API
updateStepData(currentStep.id, { updateStepData(currentStep.id, {
...data, ...data,
bakery: { ...data.bakery, isCreating: true } bakery: { ...data.bakery, isCreating: true }
}); });
try { try {
const mockTenantService = { // Use the backend-compatible data directly from BakerySetupStep
createTenant: async (formData: any) => { const bakeryRegistration = {
await new Promise(resolve => setTimeout(resolve, 1000)); name: data.bakery.name,
return { address: data.bakery.address,
tenant_id: `tenant_${Date.now()}`, city: data.bakery.city,
name: formData.name, postal_code: data.bakery.postal_code,
...formData 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, { if (response.success && response.data) {
...data, const tenantData = response.data;
bakery: {
...data.bakery, updateStepData(currentStep.id, {
tenant_id: tenantData.tenant_id, ...data,
created_at: new Date().toISOString(), bakery: {
isCreating: false ...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) { } catch (error) {
console.error('Error creating tenant:', error); console.error('Error creating tenant:', error);
updateStepData(currentStep.id, { updateStepData(currentStep.id, {
...data, ...data,
bakery: { ...data.bakery, isCreating: false } bakery: {
...data.bakery,
isCreating: false,
creationError: error instanceof Error ? error.message : 'Unknown error'
}
}); });
return; return;
} }

View File

@@ -1,8 +1,21 @@
import React, { useState, useEffect } from 'react'; 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 { Button, Card, Input } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard'; 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<OnboardingStepProps> = ({ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
data, data,
onDataChange, onDataChange,
@@ -11,36 +24,39 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
isFirstStep, isFirstStep,
isLastStep isLastStep
}) => { }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState<BakerySetupData>({
name: data.bakery?.name || '', name: data.bakery?.name || '',
type: data.bakery?.type || '', business_type: data.bakery?.business_type || 'bakery',
location: data.bakery?.location || '', 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 || '', phone: data.bakery?.phone || '',
email: data.bakery?.email || '', email: data.bakery?.email || '',
description: data.bakery?.description || '' description: data.bakery?.description || ''
}); });
const bakeryTypes = [ const bakeryModels = [
{ {
value: 'artisan', value: 'individual_bakery',
label: 'Panadería Artesanal', label: 'Panadería Individual',
description: 'Producción propia tradicional con recetas artesanales', description: 'Producción propia tradicional con recetas artesanales',
icon: '🥖' icon: '🥖'
}, },
{ {
value: 'industrial', value: 'central_baker_satellite',
label: 'Panadería Industrial', label: 'Panadería Central con Satélites',
description: 'Producción a gran escala con procesos automatizados', description: 'Producción centralizada con múltiples puntos de venta',
icon: '🏭' icon: '🏭'
}, },
{ {
value: 'retail', value: 'retail_bakery',
label: 'Panadería Retail', label: 'Panadería Retail',
description: 'Punto de venta que compra productos terminados', description: 'Punto de venta que compra productos terminados',
icon: '🏪' icon: '🏪'
}, },
{ {
value: 'hybrid', value: 'hybrid_bakery',
label: 'Modelo Híbrido', label: 'Modelo Híbrido',
description: 'Combina producción propia con productos externos', description: 'Combina producción propia con productos externos',
icon: '🔄' icon: '🔄'
@@ -57,7 +73,7 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
}); });
}, [formData]); }, [formData]);
const handleInputChange = (field: string, value: string) => { const handleInputChange = (field: keyof BakerySetupData, value: string) => {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
[field]: value [field]: value
@@ -84,18 +100,18 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
/> />
</div> </div>
{/* Bakery Type - Simplified */} {/* Business Model Selection */}
<div> <div>
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-4"> <label className="block text-sm font-semibold text-[var(--text-primary)] mb-4">
Tipo de Panadería * Modelo de Negocio *
</label> </label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{bakeryTypes.map((type) => ( {bakeryModels.map((model) => (
<label <label
key={type.value} key={model.value}
className={` className={`
flex items-center p-4 border-2 rounded-lg cursor-pointer transition-all duration-200 hover:shadow-sm flex items-center p-4 border-2 rounded-lg cursor-pointer transition-all duration-200 hover:shadow-sm
${formData.type === type.value ${formData.business_model === model.value
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5' ? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50' : 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50'
} }
@@ -103,20 +119,20 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
> >
<input <input
type="radio" type="radio"
name="bakeryType" name="businessModel"
value={type.value} value={model.value}
checked={formData.type === type.value} checked={formData.business_model === model.value}
onChange={(e) => handleInputChange('type', e.target.value)} onChange={(e) => handleInputChange('business_model', e.target.value as BakerySetupData['business_model'])}
className="sr-only" className="sr-only"
/> />
<div className="flex items-center space-x-3 w-full"> <div className="flex items-center space-x-3 w-full">
<span className="text-2xl">{type.icon}</span> <span className="text-2xl">{model.icon}</span>
<div> <div>
<h4 className="font-medium text-[var(--text-primary)]"> <h4 className="font-medium text-[var(--text-primary)]">
{type.label} {model.label}
</h4> </h4>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
{type.description} {model.description}
</p> </p>
</div> </div>
</div> </div>
@@ -125,17 +141,17 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
</div> </div>
</div> </div>
{/* Location and Contact - Simplified */} {/* Location and Contact - Backend compatible */}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3"> <label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
Ubicación * Dirección *
</label> </label>
<div className="relative"> <div className="relative">
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-[var(--text-tertiary)]" /> <MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-[var(--text-tertiary)]" />
<Input <Input
value={formData.location} value={formData.address}
onChange={(e) => handleInputChange('location', e.target.value)} onChange={(e) => handleInputChange('address', e.target.value)}
placeholder="Dirección completa de tu panadería" placeholder="Dirección completa de tu panadería"
className="w-full pl-12 py-3" className="w-full pl-12 py-3"
/> />
@@ -145,14 +161,48 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Teléfono (opcional) Ciudad *
</label>
<div className="relative">
<Building className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
<Input
value={formData.city}
onChange={(e) => handleInputChange('city', e.target.value)}
placeholder="Madrid"
className="w-full pl-10"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Código Postal *
</label>
<div className="relative">
<Hash className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
<Input
value={formData.postal_code}
onChange={(e) => handleInputChange('postal_code', e.target.value)}
placeholder="28001"
pattern="[0-9]{5}"
className="w-full pl-10"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Teléfono *
</label> </label>
<div className="relative"> <div className="relative">
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" /> <Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
<Input <Input
value={formData.phone} value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)} onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="+1 234 567 8900" placeholder="+34 600 123 456"
type="tel"
className="w-full pl-10" className="w-full pl-10"
/> />
</div> </div>
@@ -177,7 +227,7 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
</div> </div>
</div> </div>
{/* Optional: Show loading state when creating tenant */} {/* Show loading state when creating tenant */}
{data.bakery?.isCreating && ( {data.bakery?.isCreating && (
<div className="text-center p-6 bg-[var(--color-primary)]/5 rounded-lg"> <div className="text-center p-6 bg-[var(--color-primary)]/5 rounded-lg">
<div className="animate-spin w-8 h-8 border-3 border-[var(--color-primary)] border-t-transparent rounded-full mx-auto mb-4" /> <div className="animate-spin w-8 h-8 border-3 border-[var(--color-primary)] border-t-transparent rounded-full mx-auto mb-4" />
@@ -186,6 +236,18 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
</p> </p>
</div> </div>
)} )}
{/* Show error state if tenant creation fails */}
{data.bakery?.creationError && (
<div className="text-center p-6 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 font-medium mb-2">
Error al crear el espacio de trabajo
</p>
<p className="text-red-500 text-sm">
{data.bakery.creationError}
</p>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -2,6 +2,7 @@ import React, { useState, useCallback, forwardRef } from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { useAuthUser, useIsAuthenticated } from '../../../stores'; import { useAuthUser, useIsAuthenticated } from '../../../stores';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { useTenantInitializer } from '../../../hooks/useTenantInitializer';
import { Header } from '../Header'; import { Header } from '../Header';
import { Sidebar } from '../Sidebar'; import { Sidebar } from '../Sidebar';
import { Footer } from '../Footer'; import { Footer } from '../Footer';
@@ -77,6 +78,9 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
const authLoading = false; // Since we're in a protected route, auth loading should be false const authLoading = false; // Since we're in a protected route, auth loading should be false
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
// Initialize tenant data for authenticated users
useTenantInitializer();
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(initialSidebarCollapsed); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(initialSidebarCollapsed);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);

View File

@@ -3,12 +3,11 @@ import { clsx } from 'clsx';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores'; import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { useBakery } from '../../../contexts/BakeryContext';
import { Button } from '../../ui'; import { Button } from '../../ui';
import { Avatar } from '../../ui'; import { Avatar } from '../../ui';
import { Badge } from '../../ui'; import { Badge } from '../../ui';
import { Modal } from '../../ui'; import { Modal } from '../../ui';
import { BakerySelector } from '../../ui/BakerySelector/BakerySelector'; import { TenantSwitcher } from '../../ui/TenantSwitcher';
import { import {
Menu, Menu,
Search, Search,
@@ -102,7 +101,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
const isAuthenticated = useIsAuthenticated(); const isAuthenticated = useIsAuthenticated();
const { logout } = useAuthActions(); const { logout } = useAuthActions();
const { theme, resolvedTheme, setTheme } = useTheme(); const { theme, resolvedTheme, setTheme } = useTheme();
const { bakeries, currentBakery, selectBakery } = useBakery();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [isSearchFocused, setIsSearchFocused] = useState(false); const [isSearchFocused, setIsSearchFocused] = useState(false);
@@ -250,35 +248,21 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
)} )}
</div> </div>
{/* Bakery Selector - Desktop */} {/* Tenant Switcher - Desktop */}
{isAuthenticated && currentBakery && bakeries.length > 0 && ( {isAuthenticated && (
<div className="hidden md:block mx-2 lg:mx-4 flex-shrink-0"> <div className="hidden md:block mx-2 lg:mx-4 flex-shrink-0">
<BakerySelector <TenantSwitcher
bakeries={bakeries} showLabel={true}
selectedBakery={currentBakery}
onSelectBakery={selectBakery}
onAddBakery={() => {
// TODO: Navigate to add bakery page or open modal
console.log('Add new bakery');
}}
size="md"
className="min-w-[160px] max-w-[220px] lg:min-w-[200px] lg:max-w-[280px]" className="min-w-[160px] max-w-[220px] lg:min-w-[200px] lg:max-w-[280px]"
/> />
</div> </div>
)} )}
{/* Bakery Selector - Mobile (in title area) */} {/* Tenant Switcher - Mobile (in title area) */}
{isAuthenticated && currentBakery && bakeries.length > 0 && ( {isAuthenticated && (
<div className="md:hidden flex-1 min-w-0 ml-3"> <div className="md:hidden flex-1 min-w-0 ml-3">
<BakerySelector <TenantSwitcher
bakeries={bakeries} showLabel={false}
selectedBakery={currentBakery}
onSelectBakery={selectBakery}
onAddBakery={() => {
// TODO: Navigate to add bakery page or open modal
console.log('Add new bakery');
}}
size="sm"
className="w-full max-w-none" className="w-full max-w-none"
/> />
</div> </div>

View File

@@ -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<BakerySelectorProps> = ({
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<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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 (
<div className={clsx('relative', className)} ref={dropdownRef}>
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
className={clsx(
'flex items-center gap-2 sm:gap-3 bg-[var(--bg-primary)] border border-[var(--border-primary)]',
'rounded-lg transition-all duration-200 hover:bg-[var(--bg-secondary)]',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]',
'active:scale-[0.98] w-full',
sizeClasses[size],
isOpen && 'ring-2 ring-[var(--color-primary)]/20 border-[var(--color-primary)]'
)}
aria-haspopup="true"
aria-expanded={isOpen}
aria-label={`Panadería seleccionada: ${selectedBakery.name}`}
>
<Avatar
src={selectedBakery.logo}
name={selectedBakery.name}
size={avatarSizes[size]}
className="flex-shrink-0"
/>
<div className="flex-1 text-left min-w-0">
<div className="text-[var(--text-primary)] font-medium truncate text-sm sm:text-base">
{selectedBakery.name}
</div>
{size !== 'sm' && (
<div className={clsx('text-xs truncate hidden sm:block', roleColors[selectedBakery.role])}>
{roleLabels[selectedBakery.role]}
</div>
)}
</div>
<ChevronDown
className={clsx(
'flex-shrink-0 transition-transform duration-200 text-[var(--text-secondary)]',
size === 'sm' ? 'w-4 h-4' : 'w-4 h-4', // Consistent sizing
isOpen && 'rotate-180'
)}
/>
</button>
{isOpen && createPortal(
<>
{/* Mobile backdrop */}
<div
className="fixed inset-0 bg-black/20 z-[9998] sm:hidden"
onClick={() => setIsOpen(false)}
/>
<div
ref={dropdownRef}
className="fixed bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl py-2 z-[9999] sm:min-w-80 sm:max-w-96"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`
}}
>
<div className="px-3 py-2 text-xs font-medium text-[var(--text-tertiary)] border-b border-[var(--border-primary)]">
Mis Panaderías ({bakeries.length})
</div>
<div className="max-h-64 sm:max-h-64 max-h-[60vh] overflow-y-auto scrollbar-thin scrollbar-thumb-[var(--border-secondary)] scrollbar-track-transparent">
{bakeries.map((bakery) => (
<button
key={bakery.id}
onClick={() => {
onSelectBakery(bakery);
setIsOpen(false);
}}
className={clsx(
'w-full flex items-center gap-3 px-3 py-4 text-left min-h-[60px] sm:py-3 sm:min-h-[48px]',
'hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)] transition-colors',
'focus:outline-none focus:bg-[var(--bg-secondary)]',
'touch-manipulation', // Improves touch responsiveness
selectedBakery.id === bakery.id && 'bg-[var(--bg-secondary)]'
)}
>
<Avatar
src={bakery.logo}
name={bakery.name}
size="sm"
className="flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[var(--text-primary)] font-medium truncate">
{bakery.name}
</span>
{selectedBakery.id === bakery.id && (
<Check className="w-4 h-4 text-[var(--color-primary)] flex-shrink-0" />
)}
</div>
<div className="flex items-center gap-2 mt-1">
<span className={clsx('text-xs', roleColors[bakery.role])}>
{roleLabels[bakery.role]}
</span>
<span className="text-xs text-[var(--text-tertiary)]"></span>
<span className={clsx(
'text-xs',
bakery.status === 'active' ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
)}>
{bakery.status === 'active' ? 'Activa' : 'Inactiva'}
</span>
</div>
{bakery.address && (
<div className="text-xs text-[var(--text-tertiary)] truncate mt-1">
{bakery.address}
</div>
)}
</div>
</button>
))}
</div>
{onAddBakery && (
<>
<div className="border-t border-[var(--border-primary)] my-2"></div>
<button
onClick={() => {
onAddBakery();
setIsOpen(false);
}}
className="w-full flex items-center gap-3 px-3 py-4 text-left min-h-[60px] sm:py-3 sm:min-h-[48px] hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)] transition-colors text-[var(--color-primary)] touch-manipulation"
>
<div className="w-8 h-8 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center flex-shrink-0">
<Plus className="w-4 h-4" />
</div>
<span className="font-medium">Agregar Panadería</span>
</button>
</>
)}
</div>
</>,
document.body
)}
</div>
);
};
export default BakerySelector;

View File

@@ -1,2 +0,0 @@
export { BakerySelector } from './BakerySelector';
export type { default as BakerySelector } from './BakerySelector';

View File

@@ -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<PasswordCriteriaProps> = ({
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 (
<div className={`text-sm space-y-1 ${className}`}>
<p className="text-text-secondary font-medium mb-2">
Requisitos de contraseña:
</p>
<ul className="space-y-1">
{displayCriteria.map((criterion, index) => (
<li key={index} className="flex items-center space-x-2">
<span
className={`w-4 h-4 rounded-full flex items-center justify-center text-xs font-bold ${
criterion.isValid
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
}`}
>
{criterion.isValid ? '✓' : '✗'}
</span>
<span
className={`${
criterion.isValid
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}
>
{criterion.label}
</span>
</li>
))}
</ul>
</div>
);
};
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;
};

View File

@@ -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<TenantSwitcherProps> = ({
className = '',
showLabel = true,
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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 ? (
<div className={`flex items-center space-x-2 text-text-secondary ${className}`}>
<Building2 className="w-4 h-4" />
<span className="text-sm font-medium">{currentTenant?.name}</span>
</div>
) : null;
}
return (
<div className={`relative ${className}`}>
{/* Trigger Button */}
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="flex items-center space-x-2 px-3 py-2 text-sm font-medium text-text-primary bg-bg-secondary hover:bg-bg-tertiary border border-border-secondary rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20 disabled:opacity-50 disabled:cursor-not-allowed"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label="Switch tenant"
>
<Building2 className="w-4 h-4 text-text-secondary" />
{showLabel && (
<span className="hidden sm:block max-w-32 truncate">
{currentTenant?.name || 'Select Tenant'}
</span>
)}
<ChevronDown
className={`w-4 h-4 text-text-secondary transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div
ref={dropdownRef}
className="absolute right-0 mt-2 w-72 bg-bg-primary border border-border-secondary rounded-lg shadow-lg z-50"
role="listbox"
aria-label="Available tenants"
>
{/* Header */}
<div className="px-3 py-2 border-b border-border-primary">
<h3 className="text-sm font-semibold text-text-primary">Switch Organization</h3>
<p className="text-xs text-text-secondary">
Select the organization you want to work with
</p>
</div>
{/* Error State */}
{error && (
<div className="px-3 py-2 border-b border-border-primary">
<div className="flex items-center space-x-2 text-color-error text-xs">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{error}</span>
<button
onClick={handleRetry}
className="ml-auto text-color-primary hover:text-color-primary-dark underline"
>
Retry
</button>
</div>
</div>
)}
{/* Tenant List */}
<div className="max-h-80 overflow-y-auto">
{availableTenants.map((tenant) => (
<button
key={tenant.id}
onClick={() => handleTenantSwitch(tenant.id)}
disabled={isLoading}
className="w-full px-3 py-3 text-left hover:bg-bg-secondary focus:bg-bg-secondary focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
role="option"
aria-selected={tenant.id === currentTenant?.id}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-color-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
<Building2 className="w-4 h-4 text-color-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-text-primary truncate">
{tenant.name}
</p>
<p className="text-xs text-text-secondary truncate">
{tenant.business_type} {tenant.city}
</p>
</div>
</div>
</div>
{tenant.id === currentTenant?.id && (
<Check className="w-4 h-4 text-color-success flex-shrink-0 ml-2" />
)}
</div>
</button>
))}
</div>
{/* Footer */}
<div className="px-3 py-2 border-t border-border-primary bg-bg-secondary rounded-b-lg">
<p className="text-xs text-text-secondary">
Need to add a new organization?{' '}
<button className="text-color-primary hover:text-color-primary-dark underline">
Contact Support
</button>
</p>
</div>
</div>
)}
{/* Loading Overlay */}
{isLoading && (
<div className="absolute inset-0 bg-bg-primary/50 rounded-lg flex items-center justify-center">
<div className="w-4 h-4 border-2 border-color-primary border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</div>
);
};

View File

@@ -16,7 +16,7 @@ export { ListItem } from './ListItem';
export { StatsCard, StatsGrid } from './Stats'; export { StatsCard, StatsGrid } from './Stats';
export { StatusCard, getStatusColor } from './StatusCard'; export { StatusCard, getStatusColor } from './StatusCard';
export { StatusModal } from './StatusModal'; export { StatusModal } from './StatusModal';
export { BakerySelector } from './BakerySelector'; export { TenantSwitcher } from './TenantSwitcher';
// Export types // Export types
export type { ButtonProps } from './Button'; export type { ButtonProps } from './Button';

View File

@@ -8,9 +8,9 @@ export const MOCK_CONFIG = {
MOCK_MODE: true, MOCK_MODE: true,
// Component-specific toggles // Component-specific toggles
MOCK_REGISTRATION: true, MOCK_REGISTRATION: false, // Now using real backend
MOCK_AUTHENTICATION: true, MOCK_AUTHENTICATION: false, // Now using real backend
MOCK_ONBOARDING_FLOW: true, MOCK_ONBOARDING_FLOW: true, // Keep onboarding mock for now
// Mock user data // Mock user data
MOCK_USER: { MOCK_USER: {

View File

@@ -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<Bakery> } }
| { 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<Bakery, 'id'>) => Promise<void>;
updateBakery: (id: string, updates: Partial<Bakery>) => Promise<void>;
removeBakery: (id: string) => Promise<void>;
refreshBakeries: () => Promise<void>;
hasPermission: (permission: string) => boolean;
canAccess: (resource: string, action: string) => boolean;
}
const BakeryContext = createContext<BakeryContextType | undefined>(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<Bakery, 'id'>) => {
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<Bakery>) => {
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 (
<BakeryContext.Provider value={value}>
{children}
</BakeryContext.Provider>
);
}
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 };
};

View File

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

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { OnboardingWizard, OnboardingStep } from '../../../components/domain/onboarding/OnboardingWizard'; import { OnboardingWizard, OnboardingStep } from '../../../components/domain/onboarding/OnboardingWizard';
import { onboardingApiService } from '../../../services/api/onboarding.service'; 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'; import { LoadingSpinner } from '../../../components/shared/LoadingSpinner';
// Step Components // Step Components
@@ -16,7 +16,8 @@ import { CompletionStep } from '../../../components/domain/onboarding/steps/Comp
const OnboardingPage: React.FC = () => { const OnboardingPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const user = useAuthUser();
const isAuthenticated = useIsAuthenticated();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [globalData, setGlobalData] = useState<any>({}); const [globalData, setGlobalData] = useState<any>({});
@@ -30,8 +31,11 @@ const OnboardingPage: React.FC = () => {
isRequired: true, isRequired: true,
validation: (data) => { validation: (data) => {
if (!data.bakery?.name) return 'El nombre de la panadería es requerido'; 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?.business_model) return 'El modelo de negocio es requerido';
if (!data.bakery?.location) return 'La ubicación es requerida'; 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 // Tenant creation will happen automatically when validation passes
return null; 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) { if (isLoading) {
return <LoadingSpinner overlay text="Completando configuración..." />; return <LoadingSpinner overlay text="Completando configuración..." />;
} }
// Don't render if not authenticated (will redirect)
if (!isAuthenticated || !user) {
return <LoadingSpinner overlay text="Verificando autenticación..." />;
}
return ( return (
<div className="min-h-screen bg-[var(--bg-primary)]"> <div className="min-h-screen bg-[var(--bg-primary)]">
<OnboardingWizard <OnboardingWizard

View File

@@ -30,8 +30,8 @@ import {
Download, Download,
ExternalLink ExternalLink
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../../../hooks/api/useAuth'; import { useAuthUser } from '../../../../stores/auth.store';
import { useBakeryStore } from '../../../../stores/bakery.store'; import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useToast } from '../../../../hooks/ui/useToast'; import { useToast } from '../../../../hooks/ui/useToast';
import { import {
subscriptionService, subscriptionService,
@@ -238,8 +238,8 @@ const PlanComparison: React.FC<PlanComparisonProps> = ({ plans, currentPlan, onU
}; };
const SubscriptionPage: React.FC = () => { const SubscriptionPage: React.FC = () => {
const { user, tenant_id } = useAuth(); const user = useAuthUser();
const { currentTenant } = useBakeryStore(); const currentTenant = useCurrentTenant();
const toast = useToast(); const toast = useToast();
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null); const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null); const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
@@ -249,13 +249,13 @@ const SubscriptionPage: React.FC = () => {
const [upgrading, setUpgrading] = useState(false); const [upgrading, setUpgrading] = useState(false);
useEffect(() => { useEffect(() => {
if (currentTenant?.id || tenant_id || isMockMode()) { if (currentTenant?.id || user?.tenant_id || isMockMode()) {
loadSubscriptionData(); loadSubscriptionData();
} }
}, [currentTenant, tenant_id]); }, [currentTenant, user?.tenant_id]);
const loadSubscriptionData = async () => { 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 // In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) { if (isMockMode() && !tenantId) {
@@ -290,7 +290,7 @@ const SubscriptionPage: React.FC = () => {
}; };
const handleUpgradeConfirm = async () => { 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 // In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) { if (isMockMode() && !tenantId) {

View File

@@ -31,7 +31,7 @@ import {
ExternalLink ExternalLink
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../../../hooks/api/useAuth'; 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 { useToast } from '../../../../hooks/ui/useToast';
import { import {
subscriptionService, subscriptionService,
@@ -287,7 +287,7 @@ const PlanComparison: React.FC<PlanComparisonProps> = ({ plans, currentPlan, onU
const SubscriptionPage: React.FC = () => { const SubscriptionPage: React.FC = () => {
const { user, tenant_id } = useAuth(); const { user, tenant_id } = useAuth();
const { currentTenant } = useBakeryStore(); const currentTenant = useCurrentTenant();
const toast = useToast(); const toast = useToast();
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null); const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null); const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);

View File

@@ -6,7 +6,6 @@ import React from 'react';
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores'; import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores';
import { RouteConfig, canAccessRoute, ROUTES } from './routes.config'; import { RouteConfig, canAccessRoute, ROUTES } from './routes.config';
import { isMockAuthentication } from '../config/mock.config';
interface ProtectedRouteProps { interface ProtectedRouteProps {
children: React.ReactNode; children: React.ReactNode;
@@ -129,12 +128,8 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
const isLoading = useAuthLoading(); const isLoading = useAuthLoading();
const location = useLocation(); const location = useLocation();
// MOCK MODE - Allow access to onboarding routes for testing // Note: Onboarding routes are now properly protected and require authentication
const isOnboardingRoute = location.pathname.startsWith('/app/onboarding'); // Mock mode only applies to the onboarding flow content, not to route protection
if (isMockAuthentication() && isOnboardingRoute) {
return <>{children}</>;
}
// Show loading spinner while checking authentication // Show loading spinner while checking authentication
if (isLoading) { if (isLoading) {

View File

@@ -86,7 +86,13 @@ class AuthService {
// Authentication endpoints // Authentication endpoints
async register(userData: UserRegistration): Promise<ApiResponse<TokenResponse>> { async register(userData: UserRegistration): Promise<ApiResponse<TokenResponse>> {
return apiClient.post(`${this.baseUrl}/register`, userData); const response = await apiClient.post<TokenResponse>(`${this.baseUrl}/register`, userData);
if (response.success && response.data) {
this.handleSuccessfulAuth(response.data);
}
return response;
} }
async login(credentials: UserLogin): Promise<ApiResponse<TokenResponse>> { async login(credentials: UserLogin): Promise<ApiResponse<TokenResponse>> {
@@ -164,54 +170,70 @@ class AuthService {
return apiClient.post(`${this.baseUrl}/verify-email/confirm`, { token }); 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) { private handleSuccessfulAuth(tokenData: TokenResponse) {
localStorage.setItem('access_token', tokenData.access_token); // Set auth token for API client
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);
}
}
apiClient.setAuthToken(tokenData.access_token); 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() { private clearAuthData() {
localStorage.removeItem('access_token'); // Clear API client tokens
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_data');
localStorage.removeItem('tenant_id');
apiClient.removeAuthToken(); apiClient.removeAuthToken();
} }
// Utility methods // Utility methods - Now get data from Zustand store
isAuthenticated(): boolean { 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 { getCurrentUserData(): UserData | null {
const userData = localStorage.getItem('user_data'); const authStorage = localStorage.getItem('auth-storage');
return userData ? JSON.parse(userData) : null; if (!authStorage) return null;
try {
const { state } = JSON.parse(authStorage);
return state?.user || null;
} catch {
return null;
}
} }
getAccessToken(): string | 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 { 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 { getTenantId(): string | null {
return localStorage.getItem('tenant_id'); const userData = this.getCurrentUserData();
return userData?.tenant_id || null;
} }
// Check if token is expired (basic check) // Check if token is expired (basic check)

View File

@@ -1,5 +1,32 @@
import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'; 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<T = any> { export interface ApiResponse<T = any> {
data: T; data: T;
success: boolean; success: boolean;
@@ -41,12 +68,15 @@ class ApiClient {
// Request interceptor - add auth token and tenant ID // Request interceptor - add auth token and tenant ID
this.axiosInstance.interceptors.request.use( this.axiosInstance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('access_token'); const authData = getAuthData();
if (token) { if (authData?.token) {
config.headers.Authorization = `Bearer ${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) { if (tenantId) {
config.headers['X-Tenant-ID'] = tenantId; config.headers['X-Tenant-ID'] = tenantId;
} }
@@ -67,8 +97,8 @@ class ApiClient {
originalRequest._retry = true; originalRequest._retry = true;
try { try {
const refreshToken = localStorage.getItem('refresh_token'); const authData = getAuthData();
if (refreshToken) { if (authData?.refreshToken) {
const newToken = await this.refreshToken(); const newToken = await this.refreshToken();
originalRequest.headers.Authorization = `Bearer ${newToken}`; originalRequest.headers.Authorization = `Bearer ${newToken}`;
return this.axiosInstance(originalRequest); return this.axiosInstance(originalRequest);
@@ -113,29 +143,34 @@ class ApiClient {
} }
private async performTokenRefresh(): Promise<string> { private async performTokenRefresh(): Promise<string> {
const refreshToken = localStorage.getItem('refresh_token'); const authData = getAuthData();
if (!refreshToken) { if (!authData?.refreshToken) {
throw new Error('No refresh token available'); throw new Error('No refresh token available');
} }
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, { const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
refresh_token: refreshToken, refresh_token: authData.refreshToken,
}); });
const { access_token, refresh_token } = response.data; const { access_token, refresh_token } = response.data;
localStorage.setItem('access_token', access_token);
if (refresh_token) { // Update the Zustand store by modifying the auth-storage directly
localStorage.setItem('refresh_token', refresh_token); const newAuthData = {
} ...authData,
token: access_token,
refreshToken: refresh_token || authData.refreshToken
};
localStorage.setItem('auth-storage', JSON.stringify({
state: newAuthData,
version: 0
}));
return access_token; return access_token;
} }
private handleAuthFailure() { private handleAuthFailure() {
localStorage.removeItem('access_token'); clearAuthData();
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_data');
localStorage.removeItem('tenant_id');
// Redirect to login // Redirect to login
window.location.href = '/login'; window.location.href = '/login';
@@ -197,19 +232,16 @@ class ApiClient {
}; };
} }
// Utility methods // Utility methods - Now work with Zustand store
setAuthToken(token: string) { setAuthToken(token: string) {
localStorage.setItem('access_token', token);
this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${token}`; this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${token}`;
} }
removeAuthToken() { removeAuthToken() {
localStorage.removeItem('access_token');
delete this.axiosInstance.defaults.headers.common['Authorization']; delete this.axiosInstance.defaults.headers.common['Authorization'];
} }
setTenantId(tenantId: string) { setTenantId(tenantId: string) {
localStorage.setItem('tenant_id', tenantId);
this.axiosInstance.defaults.headers.common['X-Tenant-ID'] = tenantId; this.axiosInstance.defaults.headers.common['X-Tenant-ID'] = tenantId;
} }

View File

@@ -1,6 +1,6 @@
import { apiClient, ApiResponse } from './client'; 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 { export interface BakeryRegistration {
name: string; name: string;
address: string; address: string;
@@ -101,7 +101,7 @@ class TenantService {
// Tenant CRUD operations // Tenant CRUD operations
async createTenant(tenantData: BakeryRegistration): Promise<ApiResponse<TenantResponse>> { async createTenant(tenantData: BakeryRegistration): Promise<ApiResponse<TenantResponse>> {
return apiClient.post(`${this.baseUrl}`, tenantData); return apiClient.post(`${this.baseUrl}/register`, tenantData);
} }
async getTenant(tenantId: string): Promise<ApiResponse<TenantResponse>> { async getTenant(tenantId: string): Promise<ApiResponse<TenantResponse>> {
@@ -151,15 +151,31 @@ class TenantService {
} }
async switchTenant(tenantId: string): Promise<ApiResponse<{ message: string; tenant: TenantResponse }>> { async switchTenant(tenantId: string): Promise<ApiResponse<{ message: string; tenant: TenantResponse }>> {
const response = await apiClient.post(`${this.baseUrl}/${tenantId}/switch`); // Frontend-only tenant switching since backend doesn't have this endpoint
// We'll simulate the response and update the tenant store directly
if (response.success && response.data?.tenant) { try {
// Update local tenant context const tenant = await this.getTenant(tenantId);
localStorage.setItem('tenant_id', tenantId); if (tenant.success) {
apiClient.setTenantId(tenantId); // 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 // Member management
@@ -228,33 +244,33 @@ class TenantService {
} }
// Utility methods // Utility methods
async getUserTenants(): Promise<ApiResponse<TenantResponse[]>> { async getUserTenants(userId?: string): Promise<ApiResponse<TenantResponse[]>> {
return apiClient.get(`${this.baseUrl}/my-tenants`); // 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<ApiResponse<{ available: boolean; suggestions?: string[] }>> { async validateTenantSlug(slug: string): Promise<ApiResponse<{ available: boolean; suggestions?: string[] }>> {
return apiClient.get(`${this.baseUrl}/validate-slug/${slug}`); return apiClient.get(`${this.baseUrl}/validate-slug/${slug}`);
} }
// Local state management helpers // Local state management helpers - Now uses tenant store
getCurrentTenantId(): string | null { getCurrentTenantId(): string | null {
return localStorage.getItem('tenant_id'); // This will be handled by the tenant store
return null;
} }
getCurrentTenantData(): TenantResponse | null { getCurrentTenantData(): TenantResponse | null {
const tenantData = localStorage.getItem('tenant_data'); // This will be handled by the tenant store
return tenantData ? JSON.parse(tenantData) : null; return null;
} }
setCurrentTenant(tenant: TenantResponse) { setCurrentTenant(tenant: TenantResponse) {
localStorage.setItem('tenant_id', tenant.id); // This will be handled by the tenant store
localStorage.setItem('tenant_data', JSON.stringify(tenant));
apiClient.setTenantId(tenant.id); apiClient.setTenantId(tenant.id);
} }
clearCurrentTenant() { clearCurrentTenant() {
localStorage.removeItem('tenant_id'); // This will be handled by the tenant store
localStorage.removeItem('tenant_data');
} }
// Business type helpers // Business type helpers

View File

@@ -4,19 +4,16 @@ import { persist, createJSONStorage } from 'zustand/middleware';
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
name: string; full_name: string; // Updated to match backend
role: 'admin' | 'manager' | 'baker' | 'staff'; is_active: boolean;
permissions: string[]; is_verified: boolean;
tenantId: string; created_at: string;
tenantName: string; last_login?: string;
avatar?: string; phone?: string;
lastLogin?: string; language?: string;
preferences?: { timezone?: string;
language: string; tenant_id?: string;
timezone: string; role?: string;
theme: 'light' | 'dark';
notifications: boolean;
};
} }
export interface AuthState { export interface AuthState {
@@ -30,6 +27,7 @@ export interface AuthState {
// Actions // Actions
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
register: (userData: { email: string; password: string; full_name: string; tenant_name?: string }) => Promise<void>;
logout: () => void; logout: () => void;
refreshAuth: () => Promise<void>; refreshAuth: () => Promise<void>;
updateUser: (updates: Partial<User>) => void; updateUser: (updates: Partial<User>) => void;
@@ -42,50 +40,7 @@ export interface AuthState {
canAccess: (resource: string, action: string) => boolean; canAccess: (resource: string, action: string) => boolean;
} }
// Mock API functions (replace with actual API calls) import { authService } from '../services/api/auth.service';
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');
};
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
@@ -103,16 +58,20 @@ export const useAuthStore = create<AuthState>()(
try { try {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
const response = await mockLogin(email, password); const response = await authService.login({ email, password });
set({ if (response.success && response.data) {
user: response.user, set({
token: response.token, user: response.data.user || null,
refreshToken: response.refreshToken, token: response.data.access_token,
isAuthenticated: true, refreshToken: response.data.refresh_token || null,
isLoading: false, isAuthenticated: true,
error: null, isLoading: false,
}); error: null,
});
} else {
throw new Error('Login failed');
}
} catch (error) { } catch (error) {
set({ set({
user: null, user: null,
@@ -126,6 +85,37 @@ export const useAuthStore = create<AuthState>()(
} }
}, },
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: () => { logout: () => {
set({ set({
user: null, user: null,
@@ -146,14 +136,18 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: true }); set({ isLoading: true });
const response = await mockRefreshToken(refreshToken); const response = await authService.refreshToken(refreshToken);
set({ if (response.success && response.data) {
token: response.token, set({
refreshToken: response.refreshToken, token: response.data.access_token,
isLoading: false, refreshToken: response.data.refresh_token || refreshToken,
error: null, isLoading: false,
}); error: null,
});
} else {
throw new Error('Token refresh failed');
}
} catch (error) { } catch (error) {
set({ set({
user: null, user: null,
@@ -184,15 +178,16 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: loading }); set({ isLoading: loading });
}, },
// Permission helpers // Permission helpers - Simplified for backend compatibility
hasPermission: (permission: string): boolean => { hasPermission: (_permission: string): boolean => {
const { user } = get(); const { user } = get();
if (!user) return false; if (!user || !user.is_active) return false;
// Admin has all permissions // 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 => { hasRole: (role: string): boolean => {
@@ -201,28 +196,17 @@ export const useAuthStore = create<AuthState>()(
}, },
canAccess: (resource: string, action: string): boolean => { canAccess: (resource: string, action: string): boolean => {
const { user, hasPermission } = get(); const { user } = get();
if (!user) return false; if (!user || !user.is_active) return false;
// Check specific permission // Role-based access control
if (hasPermission(`${resource}:${action}`)) return true;
// Check wildcard permissions
if (hasPermission(`${resource}:*`)) return true;
if (hasPermission('*')) return true;
// Role-based access fallback
switch (user.role) { switch (user.role) {
case 'admin': case 'admin':
return true; return true;
case 'manager': case 'manager':
return ['inventory', 'production', 'sales', 'reports'].includes(resource); return ['inventory', 'production', 'sales', 'reports'].includes(resource);
case 'baker': case 'user':
return ['production', 'inventory'].includes(resource) && return ['inventory', 'sales'].includes(resource) && action === 'read';
['read', 'update'].includes(action);
case 'staff':
return ['inventory', 'sales'].includes(resource) &&
action === 'read';
default: default:
return false; return false;
} }
@@ -237,6 +221,18 @@ export const useAuthStore = create<AuthState>()(
refreshToken: state.refreshToken, refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated, 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 // Hook for auth actions
export const useAuthActions = () => useAuthStore((state) => ({ export const useAuthActions = () => useAuthStore((state) => ({
login: state.login, login: state.login,
register: state.register,
logout: state.logout, logout: state.logout,
refreshAuth: state.refreshAuth, refreshAuth: state.refreshAuth,
updateUser: state.updateUser, updateUser: state.updateUser,

View File

@@ -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<BakeryState['features']>) => 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<BakeryState>()(
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',
}
)
)
);

View File

@@ -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 { 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 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 { useAlertsStore, useAlerts, useAlertRules, useAlertFilters, useAlertSettings, useUnreadAlertsCount, useCriticalAlertsCount } from './alerts.store';
export type { Alert, AlertRule, AlertCondition, AlertAction, AlertsState, AlertType, AlertCategory, AlertPriority, AlertStatus } from './alerts.store'; export type { Alert, AlertRule, AlertCondition, AlertAction, AlertsState, AlertType, AlertCategory, AlertPriority, AlertStatus } from './alerts.store';

View File

@@ -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<boolean>;
loadUserTenants: () => Promise<void>;
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<TenantState>()(
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<boolean> => {
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<void> => {
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,
};
};

View File

@@ -1,29 +1,25 @@
// Authentication related types // Authentication related types - Updated to match backend exactly
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
full_name: string; full_name: string; // Backend uses full_name, not name
is_active: boolean; is_active: boolean;
is_verified: boolean; is_verified: boolean;
created_at: string; created_at: string; // ISO format datetime string
last_login?: string; last_login?: string;
phone?: string; phone?: string;
language?: string; language?: string;
timezone?: string; timezone?: string;
tenant_id?: string; tenant_id?: string;
role?: UserRole; role?: string; // Backend uses string, not enum
avatar_url?: string;
} }
export interface UserRegistration { export interface UserRegistration {
email: string; email: string;
password: string; password: string;
full_name: string; full_name: string;
tenant_name?: string; tenant_name?: string; // Optional in backend
role?: UserRole; role?: string; // Backend uses string, defaults to "user"
phone?: string;
language?: string;
timezone?: string;
} }
export interface UserLogin { export interface UserLogin {
@@ -35,8 +31,8 @@ export interface UserLogin {
export interface TokenResponse { export interface TokenResponse {
access_token: string; access_token: string;
refresh_token?: string; refresh_token?: string;
token_type: string; token_type: string; // defaults to "bearer"
expires_in: number; expires_in: number; // seconds, defaults to 3600
user?: User; user?: User;
} }
@@ -94,7 +90,7 @@ export interface RegisterFormData {
password: string; password: string;
confirmPassword: string; confirmPassword: string;
full_name: string; full_name: string;
tenant_name: string; tenant_name?: string; // Optional to match backend
phone?: string; phone?: string;
acceptTerms: boolean; acceptTerms: boolean;
} }
@@ -182,13 +178,11 @@ export interface OAuthProvider {
enabled: boolean; enabled: boolean;
} }
// Enums // Enums - Simplified to match backend
export enum UserRole { export enum UserRole {
USER = 'user', USER = 'user',
ADMIN = 'admin', ADMIN = 'admin',
MANAGER = 'manager', MANAGER = 'manager',
OWNER = 'owner',
VIEWER = 'viewer',
} }
export enum AuthProvider { export enum AuthProvider {

View File

@@ -7,7 +7,7 @@ FIXED VERSION - Consistent password hashing using passlib
import re import re
import hashlib import hashlib
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, List
import redis.asyncio as redis import redis.asyncio as redis
from fastapi import HTTPException, status from fastapi import HTTPException, status
import structlog import structlog
@@ -36,6 +36,9 @@ class SecurityManager:
if len(password) < settings.PASSWORD_MIN_LENGTH: if len(password) < settings.PASSWORD_MIN_LENGTH:
return False 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): if settings.PASSWORD_REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password):
return False return False
@@ -50,6 +53,31 @@ class SecurityManager:
return True 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 @staticmethod
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
"""Hash password using passlib bcrypt - FIXED""" """Hash password using passlib bcrypt - FIXED"""

View File

@@ -50,6 +50,10 @@ class EnhancedAuthService:
if existing_user: if existing_user:
raise DuplicateRecordError("User with this email already exists") 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 # Create user data
user_role = user_data.role if user_data.role else "user" user_role = user_data.role if user_data.role else "user"
hashed_password = SecurityManager.hash_password(user_data.password) hashed_password = SecurityManager.hash_password(user_data.password)
@@ -446,6 +450,13 @@ class EnhancedAuthService:
detail="Invalid old password" 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 # Hash new password and update
new_hashed_password = SecurityManager.hash_password(new_password) new_hashed_password = SecurityManager.hash_password(new_password)
await user_repo.update(user_id, {"hashed_password": new_hashed_password}) await user_repo.update(user_id, {"hashed_password": new_hashed_password})