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

View File

@@ -56,8 +56,6 @@ export const LoginForm: React.FC<LoginFormProps> = ({
if (!credentials.password) {
newErrors.password = 'La contraseña es requerida';
} else if (credentials.password.length < 6) {
newErrors.password = 'La contraseña debe tener al menos 6 caracteres';
}
setErrors(newErrors);

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
import React, { useState } from 'react';
import { Button, Input, Card } from '../../ui';
import { useAuth } from '../../../hooks/api/useAuth';
import { UserRegistration } from '../../../types/auth.types';
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
import { useToast } from '../../../hooks/ui/useToast';
import { isMockRegistration } from '../../../config/mock.config';
interface RegisterFormProps {
onSuccess?: () => void;
@@ -36,9 +35,20 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const { register, isLoading, error } = useAuth();
const { register } = useAuthActions();
const isLoading = useAuthLoading();
const error = useAuthError();
const { success: showSuccessToast, error: showErrorToast } = useToast();
// Helper function to determine password match status
const getPasswordMatchStatus = () => {
if (!formData.confirmPassword) return 'empty';
if (formData.password === formData.confirmPassword) return 'match';
return 'mismatch';
};
const passwordMatchStatus = getPasswordMatchStatus();
const validateForm = (): boolean => {
const newErrors: Partial<SimpleUserRegistration> = {};
@@ -56,8 +66,11 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
if (!formData.password) {
newErrors.password = 'La contraseña es requerida';
} else if (formData.password.length < 8) {
newErrors.password = 'La contraseña debe tener al menos 8 caracteres';
} else {
const passwordErrors = getPasswordErrors(formData.password);
if (passwordErrors.length > 0) {
newErrors.password = passwordErrors[0]; // Show first error
}
}
if (!formData.confirmPassword) {
@@ -76,64 +89,28 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log('Form submitted, mock mode:', isMockRegistration());
// FORCED MOCK MODE FOR TESTING - Always bypass for now
const FORCE_MOCK = true;
if (FORCE_MOCK || isMockRegistration()) {
console.log('Mock registration triggered, showing toast and calling onSuccess');
// Show immediate success notification
try {
showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', {
title: 'Cuenta creada exitosamente'
});
console.log('Toast shown, calling onSuccess callback');
} catch (error) {
console.error('Error showing toast:', error);
// Fallback: show browser alert if toast fails
alert('¡Cuenta creada exitosamente! Redirigiendo al onboarding...');
}
// Call success immediately (removing delay for easier testing)
try {
onSuccess?.();
console.log('onSuccess called');
} catch (error) {
console.error('Error calling onSuccess:', error);
// Fallback: direct redirect if callback fails
window.location.href = '/app/onboarding';
}
return;
}
if (!validateForm()) {
return;
}
try {
const registrationData: UserRegistration = {
const registrationData = {
full_name: formData.full_name,
email: formData.email,
password: formData.password,
tenant_name: 'Default Bakery', // Default value since we're not collecting it
phone: '' // Optional field
};
const success = await register(registrationData);
await register(registrationData);
if (success) {
showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', {
title: 'Cuenta creada exitosamente'
});
onSuccess?.();
} else {
showErrorToast(error || 'No se pudo crear la cuenta. Verifica que el email no esté en uso.', {
title: 'Error al crear la cuenta'
});
}
showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', {
title: 'Cuenta creada exitosamente'
});
onSuccess?.();
} catch (err) {
showErrorToast('No se pudo conectar con el servidor. Verifica tu conexión a internet.', {
title: 'Error de conexión'
showErrorToast(error || 'No se pudo crear la cuenta. Verifica que el email no esté en uso.', {
title: 'Error al crear la cuenta'
});
}
};
@@ -199,6 +176,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
onChange={handleInputChange('password')}
error={errors.password}
disabled={isLoading}
maxLength={128}
autoComplete="new-password"
required
leftIcon={
@@ -227,21 +205,49 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
}
/>
<Input
type={showConfirmPassword ? 'text' : 'password'}
label="Confirmar Contraseña"
placeholder="Repite tu contraseña"
value={formData.confirmPassword}
onChange={handleInputChange('confirmPassword')}
error={errors.confirmPassword}
disabled={isLoading}
autoComplete="new-password"
required
leftIcon={
<svg className="w-5 h-5" 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>
}
{/* Password Criteria - Show when user is typing */}
{formData.password && (
<PasswordCriteria
password={formData.password}
className="mt-2"
showOnlyFailed={false}
/>
)}
<div className="relative">
<Input
type={showConfirmPassword ? 'text' : 'password'}
label="Confirmar Contraseña"
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={
<button
type="button"
@@ -263,6 +269,32 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
}
/>
{/* 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="flex items-start space-x-3">
<input
@@ -292,7 +324,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
size="lg"
isLoading={isLoading}
loadingText="Creando cuenta..."
disabled={isLoading}
disabled={isLoading || !validatePassword(formData.password) || !formData.acceptTerms || passwordMatchStatus !== 'match'}
className="w-full"
onClick={(e) => {
console.log('Button clicked!');

View File

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

View File

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

View File

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

View File

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

View File

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

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 { OnboardingWizard, OnboardingStep } from '../../../components/domain/onboarding/OnboardingWizard';
import { onboardingApiService } from '../../../services/api/onboarding.service';
import { useAuth } from '../../../hooks/useAuth';
import { useAuthUser, useIsAuthenticated } from '../../../stores/auth.store';
import { LoadingSpinner } from '../../../components/shared/LoadingSpinner';
// Step Components
@@ -16,7 +16,8 @@ import { CompletionStep } from '../../../components/domain/onboarding/steps/Comp
const OnboardingPage: React.FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
const user = useAuthUser();
const isAuthenticated = useIsAuthenticated();
const [isLoading, setIsLoading] = useState(false);
const [globalData, setGlobalData] = useState<any>({});
@@ -30,8 +31,11 @@ const OnboardingPage: React.FC = () => {
isRequired: true,
validation: (data) => {
if (!data.bakery?.name) return 'El nombre de la panadería es requerido';
if (!data.bakery?.type) return 'El tipo de panadería es requerido';
if (!data.bakery?.location) return 'La ubicación es requerida';
if (!data.bakery?.business_model) return 'El modelo de negocio es requerido';
if (!data.bakery?.address) return 'La dirección es requerida';
if (!data.bakery?.city) return 'La ciudad es requerida';
if (!data.bakery?.postal_code) return 'El código postal es requerido';
if (!data.bakery?.phone) return 'El teléfono es requerido';
// Tenant creation will happen automatically when validation passes
return null;
}
@@ -142,10 +146,27 @@ const OnboardingPage: React.FC = () => {
}
};
// Redirect to login if not authenticated
useEffect(() => {
if (!isAuthenticated) {
navigate('/login', {
state: {
message: 'Debes iniciar sesión para acceder al onboarding.',
returnUrl: '/app/onboarding'
}
});
}
}, [isAuthenticated, navigate]);
if (isLoading) {
return <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 (
<div className="min-h-screen bg-[var(--bg-primary)]">
<OnboardingWizard

View File

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

View File

@@ -31,7 +31,7 @@ import {
ExternalLink
} from 'lucide-react';
import { useAuth } from '../../../../hooks/api/useAuth';
import { useBakeryStore } from '../../../../stores/bakery.store';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useToast } from '../../../../hooks/ui/useToast';
import {
subscriptionService,
@@ -287,7 +287,7 @@ const PlanComparison: React.FC<PlanComparisonProps> = ({ plans, currentPlan, onU
const SubscriptionPage: React.FC = () => {
const { user, tenant_id } = useAuth();
const { currentTenant } = useBakeryStore();
const currentTenant = useCurrentTenant();
const toast = useToast();
const [usageSummary, setUsageSummary] = useState<UsageSummary | 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 { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores';
import { RouteConfig, canAccessRoute, ROUTES } from './routes.config';
import { isMockAuthentication } from '../config/mock.config';
interface ProtectedRouteProps {
children: React.ReactNode;
@@ -129,12 +128,8 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
const isLoading = useAuthLoading();
const location = useLocation();
// MOCK MODE - Allow access to onboarding routes for testing
const isOnboardingRoute = location.pathname.startsWith('/app/onboarding');
if (isMockAuthentication() && isOnboardingRoute) {
return <>{children}</>;
}
// Note: Onboarding routes are now properly protected and require authentication
// Mock mode only applies to the onboarding flow content, not to route protection
// Show loading spinner while checking authentication
if (isLoading) {

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { apiClient, ApiResponse } from './client';
// Request/Response Types based on backend schemas
// Request/Response Types based on backend schemas - UPDATED TO MATCH BACKEND
export interface BakeryRegistration {
name: string;
address: string;
@@ -101,7 +101,7 @@ class TenantService {
// Tenant CRUD operations
async createTenant(tenantData: BakeryRegistration): Promise<ApiResponse<TenantResponse>> {
return apiClient.post(`${this.baseUrl}`, tenantData);
return apiClient.post(`${this.baseUrl}/register`, tenantData);
}
async getTenant(tenantId: string): Promise<ApiResponse<TenantResponse>> {
@@ -151,15 +151,31 @@ class TenantService {
}
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
try {
const tenant = await this.getTenant(tenantId);
if (tenant.success) {
// Update API client tenant context
apiClient.setTenantId(tenantId);
if (response.success && response.data?.tenant) {
// Update local tenant context
localStorage.setItem('tenant_id', tenantId);
apiClient.setTenantId(tenantId);
return {
success: true,
data: {
message: 'Tenant switched successfully',
tenant: tenant.data
}
};
} else {
throw new Error('Tenant not found');
}
} catch (error) {
return {
success: false,
data: null,
error: error instanceof Error ? error.message : 'Failed to switch tenant'
};
}
return response;
}
// Member management
@@ -228,33 +244,33 @@ class TenantService {
}
// Utility methods
async getUserTenants(): Promise<ApiResponse<TenantResponse[]>> {
return apiClient.get(`${this.baseUrl}/my-tenants`);
async getUserTenants(userId?: string): Promise<ApiResponse<TenantResponse[]>> {
// 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[] }>> {
return apiClient.get(`${this.baseUrl}/validate-slug/${slug}`);
}
// Local state management helpers
// Local state management helpers - Now uses tenant store
getCurrentTenantId(): string | null {
return localStorage.getItem('tenant_id');
// This will be handled by the tenant store
return null;
}
getCurrentTenantData(): TenantResponse | null {
const tenantData = localStorage.getItem('tenant_data');
return tenantData ? JSON.parse(tenantData) : null;
// This will be handled by the tenant store
return null;
}
setCurrentTenant(tenant: TenantResponse) {
localStorage.setItem('tenant_id', tenant.id);
localStorage.setItem('tenant_data', JSON.stringify(tenant));
// This will be handled by the tenant store
apiClient.setTenantId(tenant.id);
}
clearCurrentTenant() {
localStorage.removeItem('tenant_id');
localStorage.removeItem('tenant_data');
// This will be handled by the tenant store
}
// Business type helpers

View File

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

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 type { Theme, Language, ViewMode, SidebarState, Toast, Modal, UIState } from './ui.store';
export { useBakeryStore } from './bakery.store';
export type { BakeryType, BusinessModel } from './bakery.store';
export { useTenantStore, useCurrentTenant, useAvailableTenants, useTenantLoading, useTenantError, useTenantActions, useTenantPermissions, useTenant } from './tenant.store';
export type { TenantState } from './tenant.store';
export { useAlertsStore, useAlerts, useAlertRules, useAlertFilters, useAlertSettings, useUnreadAlertsCount, useCriticalAlertsCount } from './alerts.store';
export type { Alert, AlertRule, AlertCondition, AlertAction, AlertsState, AlertType, AlertCategory, AlertPriority, AlertStatus } from './alerts.store';

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

View File

@@ -7,7 +7,7 @@ FIXED VERSION - Consistent password hashing using passlib
import re
import hashlib
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
import redis.asyncio as redis
from fastapi import HTTPException, status
import structlog
@@ -36,6 +36,9 @@ class SecurityManager:
if len(password) < settings.PASSWORD_MIN_LENGTH:
return False
if len(password) > 128: # Max length from Pydantic schema
return False
if settings.PASSWORD_REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password):
return False
@@ -50,6 +53,31 @@ class SecurityManager:
return True
@staticmethod
def get_password_validation_errors(password: str) -> List[str]:
"""Get detailed password validation errors for better UX"""
errors = []
if len(password) < settings.PASSWORD_MIN_LENGTH:
errors.append(f"Password must be at least {settings.PASSWORD_MIN_LENGTH} characters long")
if len(password) > 128:
errors.append("Password cannot exceed 128 characters")
if settings.PASSWORD_REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password):
errors.append("Password must contain at least one uppercase letter")
if settings.PASSWORD_REQUIRE_LOWERCASE and not re.search(r'[a-z]', password):
errors.append("Password must contain at least one lowercase letter")
if settings.PASSWORD_REQUIRE_NUMBERS and not re.search(r'\d', password):
errors.append("Password must contain at least one number")
if settings.PASSWORD_REQUIRE_SYMBOLS and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
errors.append("Password must contain at least one symbol (!@#$%^&*(),.?\":{}|<>)")
return errors
@staticmethod
def hash_password(password: str) -> str:
"""Hash password using passlib bcrypt - FIXED"""

View File

@@ -50,6 +50,10 @@ class EnhancedAuthService:
if existing_user:
raise DuplicateRecordError("User with this email already exists")
# Validate password strength
if not SecurityManager.validate_password(user_data.password):
raise ValueError("Password does not meet security requirements")
# Create user data
user_role = user_data.role if user_data.role else "user"
hashed_password = SecurityManager.hash_password(user_data.password)
@@ -446,6 +450,13 @@ class EnhancedAuthService:
detail="Invalid old password"
)
# Validate new password strength
if not SecurityManager.validate_password(new_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New password does not meet security requirements"
)
# Hash new password and update
new_hashed_password = SecurityManager.hash_password(new_password)
await user_repo.update(user_id, {"hashed_password": new_hashed_password})