Start integrating the onboarding flow with backend 1
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
@@ -262,6 +268,32 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
</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="flex items-start space-x-3">
|
||||
@@ -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!');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { BakerySelector } from './BakerySelector';
|
||||
export type { default as BakerySelector } from './BakerySelector';
|
||||
131
frontend/src/components/ui/PasswordCriteria.tsx
Normal file
131
frontend/src/components/ui/PasswordCriteria.tsx
Normal 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;
|
||||
};
|
||||
215
frontend/src/components/ui/TenantSwitcher.tsx
Normal file
215
frontend/src/components/ui/TenantSwitcher.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
27
frontend/src/hooks/useTenantInitializer.ts
Normal file
27
frontend/src/hooks/useTenantInitializer.ts
Normal 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]);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
if (response.success && response.data?.tenant) {
|
||||
// Update local tenant context
|
||||
localStorage.setItem('tenant_id', tenantId);
|
||||
apiClient.setTenantId(tenantId);
|
||||
// Frontend-only tenant switching since backend doesn't have this endpoint
|
||||
// We'll simulate the response and update the tenant store directly
|
||||
try {
|
||||
const tenant = await this.getTenant(tenantId);
|
||||
if (tenant.success) {
|
||||
// Update API client tenant context
|
||||
apiClient.setTenantId(tenantId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Tenant switched successfully',
|
||||
tenant: tenant.data
|
||||
}
|
||||
};
|
||||
} else {
|
||||
throw new Error('Tenant not found');
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : 'Failed to switch tenant'
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Member management
|
||||
@@ -228,33 +244,33 @@ class TenantService {
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
async getUserTenants(): Promise<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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -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';
|
||||
230
frontend/src/stores/tenant.store.ts
Normal file
230
frontend/src/stores/tenant.store.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user