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 { AppRouter } from './router/AppRouter';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { BakeryProvider } from './contexts/BakeryContext';
|
|
||||||
import { SSEProvider } from './contexts/SSEContext';
|
import { SSEProvider } from './contexts/SSEContext';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -29,7 +28,6 @@ function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BakeryProvider>
|
|
||||||
<SSEProvider>
|
<SSEProvider>
|
||||||
<Suspense fallback={<LoadingSpinner overlay />}>
|
<Suspense fallback={<LoadingSpinner overlay />}>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
@@ -45,7 +43,6 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</SSEProvider>
|
</SSEProvider>
|
||||||
</BakeryProvider>
|
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ export const LoginForm: React.FC<LoginFormProps> = ({
|
|||||||
|
|
||||||
if (!credentials.password) {
|
if (!credentials.password) {
|
||||||
newErrors.password = 'La contraseña es requerida';
|
newErrors.password = 'La contraseña es requerida';
|
||||||
} else if (credentials.password.length < 6) {
|
|
||||||
newErrors.password = 'La contraseña debe tener al menos 6 caracteres';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Button, Input, Card } from '../../ui';
|
import { Button, Input, Card } from '../../ui';
|
||||||
import { useAuth } from '../../../hooks/api/useAuth';
|
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
|
||||||
|
import { useAuthActions } from '../../../stores/auth.store';
|
||||||
import { useToast } from '../../../hooks/ui/useToast';
|
import { useToast } from '../../../hooks/ui/useToast';
|
||||||
|
|
||||||
interface PasswordResetFormProps {
|
interface PasswordResetFormProps {
|
||||||
@@ -33,7 +34,10 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
|
|||||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { requestPasswordReset, resetPassword, isLoading, error } = useAuth();
|
// TODO: Implement password reset in Zustand auth store
|
||||||
|
// const { requestPasswordReset, resetPassword, isLoading, error } = useAuth();
|
||||||
|
const isLoading = false;
|
||||||
|
const error = null;
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
const isResetMode = Boolean(token) || mode === 'reset';
|
const isResetMode = Boolean(token) || mode === 'reset';
|
||||||
@@ -109,12 +113,11 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
|
|||||||
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
newErrors.password = 'La contraseña es requerida';
|
newErrors.password = 'La contraseña es requerida';
|
||||||
} else if (password.length < 8) {
|
} else {
|
||||||
newErrors.password = 'La contraseña debe tener al menos 8 caracteres';
|
const passwordErrors = getPasswordErrors(password);
|
||||||
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) {
|
if (passwordErrors.length > 0) {
|
||||||
newErrors.password = 'La contraseña debe contener mayúsculas, minúsculas y números';
|
newErrors.password = passwordErrors[0]; // Show first error
|
||||||
} else if (passwordStrength < 50) {
|
}
|
||||||
newErrors.password = 'La contraseña es demasiado débil. Intenta con una más segura';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirmPassword) {
|
if (!confirmPassword) {
|
||||||
@@ -147,7 +150,9 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = await requestPasswordReset(email);
|
// TODO: Implement password reset request
|
||||||
|
// const success = await requestPasswordReset(email);
|
||||||
|
const success = false; // Placeholder
|
||||||
if (success) {
|
if (success) {
|
||||||
setIsEmailSent(true);
|
setIsEmailSent(true);
|
||||||
showToast({
|
showToast({
|
||||||
@@ -192,7 +197,9 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = await resetPassword(token, password);
|
// TODO: Implement password reset
|
||||||
|
// const success = await resetPassword(token, password);
|
||||||
|
const success = false; // Placeholder
|
||||||
if (success) {
|
if (success) {
|
||||||
showToast({
|
showToast({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
|||||||
@@ -231,10 +231,19 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
|||||||
|
|
||||||
if (!passwordData.newPassword) {
|
if (!passwordData.newPassword) {
|
||||||
newErrors.newPassword = 'La nueva contraseña es requerida';
|
newErrors.newPassword = 'La nueva contraseña es requerida';
|
||||||
} else if (passwordData.newPassword.length < 8) {
|
} else {
|
||||||
newErrors.newPassword = 'La contraseña debe tener al menos 8 caracteres';
|
// Use simpler validation for now to match backend exactly
|
||||||
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(passwordData.newPassword)) {
|
if (passwordData.newPassword.length < 8) {
|
||||||
newErrors.newPassword = 'La contraseña debe contener mayúsculas, minúsculas y números';
|
newErrors.newPassword = 'La contraseña debe tener al menos 8 caracteres';
|
||||||
|
} else if (passwordData.newPassword.length > 128) {
|
||||||
|
newErrors.newPassword = 'La contraseña no puede exceder 128 caracteres';
|
||||||
|
} else if (!/[A-Z]/.test(passwordData.newPassword)) {
|
||||||
|
newErrors.newPassword = 'La contraseña debe contener al menos una letra mayúscula';
|
||||||
|
} else if (!/[a-z]/.test(passwordData.newPassword)) {
|
||||||
|
newErrors.newPassword = 'La contraseña debe contener al menos una letra minúscula';
|
||||||
|
} else if (!/\d/.test(passwordData.newPassword)) {
|
||||||
|
newErrors.newPassword = 'La contraseña debe contener al menos un número';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!passwordData.confirmNewPassword) {
|
if (!passwordData.confirmNewPassword) {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Button, Input, Card } from '../../ui';
|
import { Button, Input, Card } from '../../ui';
|
||||||
import { useAuth } from '../../../hooks/api/useAuth';
|
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
|
||||||
import { UserRegistration } from '../../../types/auth.types';
|
import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
|
||||||
import { useToast } from '../../../hooks/ui/useToast';
|
import { useToast } from '../../../hooks/ui/useToast';
|
||||||
import { isMockRegistration } from '../../../config/mock.config';
|
|
||||||
|
|
||||||
interface RegisterFormProps {
|
interface RegisterFormProps {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
@@ -36,9 +35,20 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
const { register, isLoading, error } = useAuth();
|
const { register } = useAuthActions();
|
||||||
|
const isLoading = useAuthLoading();
|
||||||
|
const error = useAuthError();
|
||||||
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
||||||
|
|
||||||
|
// Helper function to determine password match status
|
||||||
|
const getPasswordMatchStatus = () => {
|
||||||
|
if (!formData.confirmPassword) return 'empty';
|
||||||
|
if (formData.password === formData.confirmPassword) return 'match';
|
||||||
|
return 'mismatch';
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordMatchStatus = getPasswordMatchStatus();
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors: Partial<SimpleUserRegistration> = {};
|
const newErrors: Partial<SimpleUserRegistration> = {};
|
||||||
|
|
||||||
@@ -56,8 +66,11 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
|
|
||||||
if (!formData.password) {
|
if (!formData.password) {
|
||||||
newErrors.password = 'La contraseña es requerida';
|
newErrors.password = 'La contraseña es requerida';
|
||||||
} else if (formData.password.length < 8) {
|
} else {
|
||||||
newErrors.password = 'La contraseña debe tener al menos 8 caracteres';
|
const passwordErrors = getPasswordErrors(formData.password);
|
||||||
|
if (passwordErrors.length > 0) {
|
||||||
|
newErrors.password = passwordErrors[0]; // Show first error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.confirmPassword) {
|
if (!formData.confirmPassword) {
|
||||||
@@ -76,64 +89,28 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log('Form submitted, mock mode:', isMockRegistration());
|
|
||||||
|
|
||||||
// FORCED MOCK MODE FOR TESTING - Always bypass for now
|
|
||||||
const FORCE_MOCK = true;
|
|
||||||
if (FORCE_MOCK || isMockRegistration()) {
|
|
||||||
console.log('Mock registration triggered, showing toast and calling onSuccess');
|
|
||||||
// Show immediate success notification
|
|
||||||
try {
|
|
||||||
showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', {
|
|
||||||
title: 'Cuenta creada exitosamente'
|
|
||||||
});
|
|
||||||
console.log('Toast shown, calling onSuccess callback');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error showing toast:', error);
|
|
||||||
// Fallback: show browser alert if toast fails
|
|
||||||
alert('¡Cuenta creada exitosamente! Redirigiendo al onboarding...');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call success immediately (removing delay for easier testing)
|
|
||||||
try {
|
|
||||||
onSuccess?.();
|
|
||||||
console.log('onSuccess called');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error calling onSuccess:', error);
|
|
||||||
// Fallback: direct redirect if callback fails
|
|
||||||
window.location.href = '/app/onboarding';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validateForm()) {
|
if (!validateForm()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const registrationData: UserRegistration = {
|
const registrationData = {
|
||||||
full_name: formData.full_name,
|
full_name: formData.full_name,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
tenant_name: 'Default Bakery', // Default value since we're not collecting it
|
tenant_name: 'Default Bakery', // Default value since we're not collecting it
|
||||||
phone: '' // Optional field
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const success = await register(registrationData);
|
await register(registrationData);
|
||||||
|
|
||||||
if (success) {
|
showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', {
|
||||||
showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', {
|
title: 'Cuenta creada exitosamente'
|
||||||
title: 'Cuenta creada exitosamente'
|
});
|
||||||
});
|
onSuccess?.();
|
||||||
onSuccess?.();
|
|
||||||
} else {
|
|
||||||
showErrorToast(error || 'No se pudo crear la cuenta. Verifica que el email no esté en uso.', {
|
|
||||||
title: 'Error al crear la cuenta'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast('No se pudo conectar con el servidor. Verifica tu conexión a internet.', {
|
showErrorToast(error || 'No se pudo crear la cuenta. Verifica que el email no esté en uso.', {
|
||||||
title: 'Error de conexión'
|
title: 'Error al crear la cuenta'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -199,6 +176,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
onChange={handleInputChange('password')}
|
onChange={handleInputChange('password')}
|
||||||
error={errors.password}
|
error={errors.password}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
maxLength={128}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
leftIcon={
|
leftIcon={
|
||||||
@@ -227,21 +205,49 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
{/* Password Criteria - Show when user is typing */}
|
||||||
type={showConfirmPassword ? 'text' : 'password'}
|
{formData.password && (
|
||||||
label="Confirmar Contraseña"
|
<PasswordCriteria
|
||||||
placeholder="Repite tu contraseña"
|
password={formData.password}
|
||||||
value={formData.confirmPassword}
|
className="mt-2"
|
||||||
onChange={handleInputChange('confirmPassword')}
|
showOnlyFailed={false}
|
||||||
error={errors.confirmPassword}
|
/>
|
||||||
disabled={isLoading}
|
)}
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
<div className="relative">
|
||||||
leftIcon={
|
<Input
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
label="Confirmar Contraseña"
|
||||||
</svg>
|
placeholder="Repite tu contraseña"
|
||||||
}
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleInputChange('confirmPassword')}
|
||||||
|
error={errors.confirmPassword}
|
||||||
|
disabled={isLoading}
|
||||||
|
maxLength={128}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
className={
|
||||||
|
passwordMatchStatus === 'match' && formData.confirmPassword
|
||||||
|
? 'border-color-success focus:border-color-success ring-color-success'
|
||||||
|
: passwordMatchStatus === 'mismatch' && formData.confirmPassword
|
||||||
|
? 'border-color-error focus:border-color-error ring-color-error'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
leftIcon={
|
||||||
|
passwordMatchStatus === 'match' && formData.confirmPassword ? (
|
||||||
|
<svg className="w-5 h-5 text-color-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
) : passwordMatchStatus === 'mismatch' && formData.confirmPassword ? (
|
||||||
|
<svg className="w-5 h-5 text-color-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 0h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
rightIcon={
|
rightIcon={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -263,6 +269,32 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Password Match Status Message */}
|
||||||
|
{formData.confirmPassword && (
|
||||||
|
<div className="mt-2 transition-all duration-300 ease-in-out">
|
||||||
|
{passwordMatchStatus === 'match' ? (
|
||||||
|
<div className="flex items-center space-x-2 text-color-success animate-fade-in">
|
||||||
|
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-color-success/10 flex items-center justify-center">
|
||||||
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">¡Las contraseñas coinciden!</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2 text-color-error animate-fade-in">
|
||||||
|
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-color-error/10 flex items-center justify-center">
|
||||||
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">Las contraseñas no coinciden</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 pt-4 border-t border-border-primary">
|
<div className="space-y-4 pt-4 border-t border-border-primary">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<input
|
<input
|
||||||
@@ -292,7 +324,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
size="lg"
|
size="lg"
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
loadingText="Creando cuenta..."
|
loadingText="Creando cuenta..."
|
||||||
disabled={isLoading}
|
disabled={isLoading || !validatePassword(formData.password) || !formData.acceptTerms || passwordMatchStatus !== 'match'}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
console.log('Button clicked!');
|
console.log('Button clicked!');
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { Card, Button, Input, Select, Badge } from '../../ui';
|
import { Card, Button, Input, Select, Badge } from '../../ui';
|
||||||
|
import { tenantService } from '../../../services/api/tenant.service';
|
||||||
|
import { useAuthUser } from '../../../stores/auth.store';
|
||||||
|
import { useTenantActions } from '../../../stores/tenant.store';
|
||||||
|
|
||||||
|
|
||||||
export interface OnboardingStep {
|
export interface OnboardingStep {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,6 +41,7 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
|||||||
const [stepData, setStepData] = useState<Record<string, any>>({});
|
const [stepData, setStepData] = useState<Record<string, any>>({});
|
||||||
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
|
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
|
||||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||||
|
const { setCurrentTenant } = useTenantActions();
|
||||||
|
|
||||||
const currentStep = steps[currentStepIndex];
|
const currentStep = steps[currentStepIndex];
|
||||||
|
|
||||||
@@ -80,41 +85,55 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
|||||||
const data = stepData[currentStep.id] || {};
|
const data = stepData[currentStep.id] || {};
|
||||||
|
|
||||||
if (!data.bakery?.tenant_id) {
|
if (!data.bakery?.tenant_id) {
|
||||||
// Create tenant inline
|
// Create tenant inline using real backend API
|
||||||
updateStepData(currentStep.id, {
|
updateStepData(currentStep.id, {
|
||||||
...data,
|
...data,
|
||||||
bakery: { ...data.bakery, isCreating: true }
|
bakery: { ...data.bakery, isCreating: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mockTenantService = {
|
// Use the backend-compatible data directly from BakerySetupStep
|
||||||
createTenant: async (formData: any) => {
|
const bakeryRegistration = {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
name: data.bakery.name,
|
||||||
return {
|
address: data.bakery.address,
|
||||||
tenant_id: `tenant_${Date.now()}`,
|
city: data.bakery.city,
|
||||||
name: formData.name,
|
postal_code: data.bakery.postal_code,
|
||||||
...formData
|
phone: data.bakery.phone,
|
||||||
};
|
business_type: data.bakery.business_type,
|
||||||
}
|
business_model: data.bakery.business_model
|
||||||
};
|
};
|
||||||
|
|
||||||
const tenantData = await mockTenantService.createTenant(data.bakery);
|
const response = await tenantService.createTenant(bakeryRegistration);
|
||||||
|
|
||||||
updateStepData(currentStep.id, {
|
if (response.success && response.data) {
|
||||||
...data,
|
const tenantData = response.data;
|
||||||
bakery: {
|
|
||||||
...data.bakery,
|
updateStepData(currentStep.id, {
|
||||||
tenant_id: tenantData.tenant_id,
|
...data,
|
||||||
created_at: new Date().toISOString(),
|
bakery: {
|
||||||
isCreating: false
|
...data.bakery,
|
||||||
}
|
tenant_id: tenantData.id,
|
||||||
});
|
created_at: tenantData.created_at,
|
||||||
|
isCreating: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the tenant store with the new tenant
|
||||||
|
setCurrentTenant(tenantData);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw new Error(response.error || 'Failed to create tenant');
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating tenant:', error);
|
console.error('Error creating tenant:', error);
|
||||||
updateStepData(currentStep.id, {
|
updateStepData(currentStep.id, {
|
||||||
...data,
|
...data,
|
||||||
bakery: { ...data.bakery, isCreating: false }
|
bakery: {
|
||||||
|
...data.bakery,
|
||||||
|
isCreating: false,
|
||||||
|
creationError: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Store, MapPin, Phone, Mail } from 'lucide-react';
|
import { Store, MapPin, Phone, Mail, Hash, Building } from 'lucide-react';
|
||||||
import { Button, Card, Input } from '../../../ui';
|
import { Button, Card, Input } from '../../../ui';
|
||||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||||
|
|
||||||
|
// Backend-compatible bakery setup interface
|
||||||
|
interface BakerySetupData {
|
||||||
|
name: string;
|
||||||
|
business_type: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant';
|
||||||
|
business_model: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery';
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
postal_code: string;
|
||||||
|
phone: string;
|
||||||
|
email?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
||||||
data,
|
data,
|
||||||
onDataChange,
|
onDataChange,
|
||||||
@@ -11,36 +24,39 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
isFirstStep,
|
isFirstStep,
|
||||||
isLastStep
|
isLastStep
|
||||||
}) => {
|
}) => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState<BakerySetupData>({
|
||||||
name: data.bakery?.name || '',
|
name: data.bakery?.name || '',
|
||||||
type: data.bakery?.type || '',
|
business_type: data.bakery?.business_type || 'bakery',
|
||||||
location: data.bakery?.location || '',
|
business_model: data.bakery?.business_model || 'individual_bakery',
|
||||||
|
address: data.bakery?.address || '',
|
||||||
|
city: data.bakery?.city || 'Madrid',
|
||||||
|
postal_code: data.bakery?.postal_code || '',
|
||||||
phone: data.bakery?.phone || '',
|
phone: data.bakery?.phone || '',
|
||||||
email: data.bakery?.email || '',
|
email: data.bakery?.email || '',
|
||||||
description: data.bakery?.description || ''
|
description: data.bakery?.description || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const bakeryTypes = [
|
const bakeryModels = [
|
||||||
{
|
{
|
||||||
value: 'artisan',
|
value: 'individual_bakery',
|
||||||
label: 'Panadería Artesanal',
|
label: 'Panadería Individual',
|
||||||
description: 'Producción propia tradicional con recetas artesanales',
|
description: 'Producción propia tradicional con recetas artesanales',
|
||||||
icon: '🥖'
|
icon: '🥖'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'industrial',
|
value: 'central_baker_satellite',
|
||||||
label: 'Panadería Industrial',
|
label: 'Panadería Central con Satélites',
|
||||||
description: 'Producción a gran escala con procesos automatizados',
|
description: 'Producción centralizada con múltiples puntos de venta',
|
||||||
icon: '🏭'
|
icon: '🏭'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'retail',
|
value: 'retail_bakery',
|
||||||
label: 'Panadería Retail',
|
label: 'Panadería Retail',
|
||||||
description: 'Punto de venta que compra productos terminados',
|
description: 'Punto de venta que compra productos terminados',
|
||||||
icon: '🏪'
|
icon: '🏪'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'hybrid',
|
value: 'hybrid_bakery',
|
||||||
label: 'Modelo Híbrido',
|
label: 'Modelo Híbrido',
|
||||||
description: 'Combina producción propia con productos externos',
|
description: 'Combina producción propia con productos externos',
|
||||||
icon: '🔄'
|
icon: '🔄'
|
||||||
@@ -57,7 +73,7 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
});
|
});
|
||||||
}, [formData]);
|
}, [formData]);
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: string) => {
|
const handleInputChange = (field: keyof BakerySetupData, value: string) => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[field]: value
|
[field]: value
|
||||||
@@ -84,18 +100,18 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bakery Type - Simplified */}
|
{/* Business Model Selection */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-4">
|
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-4">
|
||||||
Tipo de Panadería *
|
Modelo de Negocio *
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{bakeryTypes.map((type) => (
|
{bakeryModels.map((model) => (
|
||||||
<label
|
<label
|
||||||
key={type.value}
|
key={model.value}
|
||||||
className={`
|
className={`
|
||||||
flex items-center p-4 border-2 rounded-lg cursor-pointer transition-all duration-200 hover:shadow-sm
|
flex items-center p-4 border-2 rounded-lg cursor-pointer transition-all duration-200 hover:shadow-sm
|
||||||
${formData.type === type.value
|
${formData.business_model === model.value
|
||||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
|
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
|
||||||
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50'
|
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50'
|
||||||
}
|
}
|
||||||
@@ -103,20 +119,20 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="bakeryType"
|
name="businessModel"
|
||||||
value={type.value}
|
value={model.value}
|
||||||
checked={formData.type === type.value}
|
checked={formData.business_model === model.value}
|
||||||
onChange={(e) => handleInputChange('type', e.target.value)}
|
onChange={(e) => handleInputChange('business_model', e.target.value as BakerySetupData['business_model'])}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center space-x-3 w-full">
|
<div className="flex items-center space-x-3 w-full">
|
||||||
<span className="text-2xl">{type.icon}</span>
|
<span className="text-2xl">{model.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-[var(--text-primary)]">
|
<h4 className="font-medium text-[var(--text-primary)]">
|
||||||
{type.label}
|
{model.label}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
{type.description}
|
{model.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,17 +141,17 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Location and Contact - Simplified */}
|
{/* Location and Contact - Backend compatible */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||||
Ubicación *
|
Dirección *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-[var(--text-tertiary)]" />
|
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-[var(--text-tertiary)]" />
|
||||||
<Input
|
<Input
|
||||||
value={formData.location}
|
value={formData.address}
|
||||||
onChange={(e) => handleInputChange('location', e.target.value)}
|
onChange={(e) => handleInputChange('address', e.target.value)}
|
||||||
placeholder="Dirección completa de tu panadería"
|
placeholder="Dirección completa de tu panadería"
|
||||||
className="w-full pl-12 py-3"
|
className="w-full pl-12 py-3"
|
||||||
/>
|
/>
|
||||||
@@ -145,14 +161,48 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||||
Teléfono (opcional)
|
Ciudad *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Building className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
||||||
|
<Input
|
||||||
|
value={formData.city}
|
||||||
|
onChange={(e) => handleInputChange('city', e.target.value)}
|
||||||
|
placeholder="Madrid"
|
||||||
|
className="w-full pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
Código Postal *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Hash className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
||||||
|
<Input
|
||||||
|
value={formData.postal_code}
|
||||||
|
onChange={(e) => handleInputChange('postal_code', e.target.value)}
|
||||||
|
placeholder="28001"
|
||||||
|
pattern="[0-9]{5}"
|
||||||
|
className="w-full pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
Teléfono *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
||||||
<Input
|
<Input
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||||
placeholder="+1 234 567 8900"
|
placeholder="+34 600 123 456"
|
||||||
|
type="tel"
|
||||||
className="w-full pl-10"
|
className="w-full pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +227,7 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional: Show loading state when creating tenant */}
|
{/* Show loading state when creating tenant */}
|
||||||
{data.bakery?.isCreating && (
|
{data.bakery?.isCreating && (
|
||||||
<div className="text-center p-6 bg-[var(--color-primary)]/5 rounded-lg">
|
<div className="text-center p-6 bg-[var(--color-primary)]/5 rounded-lg">
|
||||||
<div className="animate-spin w-8 h-8 border-3 border-[var(--color-primary)] border-t-transparent rounded-full mx-auto mb-4" />
|
<div className="animate-spin w-8 h-8 border-3 border-[var(--color-primary)] border-t-transparent rounded-full mx-auto mb-4" />
|
||||||
@@ -186,6 +236,18 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Show error state if tenant creation fails */}
|
||||||
|
{data.bakery?.creationError && (
|
||||||
|
<div className="text-center p-6 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-red-600 font-medium mb-2">
|
||||||
|
Error al crear el espacio de trabajo
|
||||||
|
</p>
|
||||||
|
<p className="text-red-500 text-sm">
|
||||||
|
{data.bakery.creationError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,7 @@ import React, { useState, useCallback, forwardRef } from 'react';
|
|||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
|
import { useTenantInitializer } from '../../../hooks/useTenantInitializer';
|
||||||
import { Header } from '../Header';
|
import { Header } from '../Header';
|
||||||
import { Sidebar } from '../Sidebar';
|
import { Sidebar } from '../Sidebar';
|
||||||
import { Footer } from '../Footer';
|
import { Footer } from '../Footer';
|
||||||
@@ -77,6 +78,9 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
|
|||||||
const authLoading = false; // Since we're in a protected route, auth loading should be false
|
const authLoading = false; // Since we're in a protected route, auth loading should be false
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
// Initialize tenant data for authenticated users
|
||||||
|
useTenantInitializer();
|
||||||
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(initialSidebarCollapsed);
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(initialSidebarCollapsed);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { clsx } from 'clsx';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
|
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { useBakery } from '../../../contexts/BakeryContext';
|
|
||||||
import { Button } from '../../ui';
|
import { Button } from '../../ui';
|
||||||
import { Avatar } from '../../ui';
|
import { Avatar } from '../../ui';
|
||||||
import { Badge } from '../../ui';
|
import { Badge } from '../../ui';
|
||||||
import { Modal } from '../../ui';
|
import { Modal } from '../../ui';
|
||||||
import { BakerySelector } from '../../ui/BakerySelector/BakerySelector';
|
import { TenantSwitcher } from '../../ui/TenantSwitcher';
|
||||||
import {
|
import {
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
Search,
|
||||||
@@ -102,7 +101,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
const isAuthenticated = useIsAuthenticated();
|
const isAuthenticated = useIsAuthenticated();
|
||||||
const { logout } = useAuthActions();
|
const { logout } = useAuthActions();
|
||||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||||
const { bakeries, currentBakery, selectBakery } = useBakery();
|
|
||||||
|
|
||||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||||
@@ -250,35 +248,21 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bakery Selector - Desktop */}
|
{/* Tenant Switcher - Desktop */}
|
||||||
{isAuthenticated && currentBakery && bakeries.length > 0 && (
|
{isAuthenticated && (
|
||||||
<div className="hidden md:block mx-2 lg:mx-4 flex-shrink-0">
|
<div className="hidden md:block mx-2 lg:mx-4 flex-shrink-0">
|
||||||
<BakerySelector
|
<TenantSwitcher
|
||||||
bakeries={bakeries}
|
showLabel={true}
|
||||||
selectedBakery={currentBakery}
|
|
||||||
onSelectBakery={selectBakery}
|
|
||||||
onAddBakery={() => {
|
|
||||||
// TODO: Navigate to add bakery page or open modal
|
|
||||||
console.log('Add new bakery');
|
|
||||||
}}
|
|
||||||
size="md"
|
|
||||||
className="min-w-[160px] max-w-[220px] lg:min-w-[200px] lg:max-w-[280px]"
|
className="min-w-[160px] max-w-[220px] lg:min-w-[200px] lg:max-w-[280px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bakery Selector - Mobile (in title area) */}
|
{/* Tenant Switcher - Mobile (in title area) */}
|
||||||
{isAuthenticated && currentBakery && bakeries.length > 0 && (
|
{isAuthenticated && (
|
||||||
<div className="md:hidden flex-1 min-w-0 ml-3">
|
<div className="md:hidden flex-1 min-w-0 ml-3">
|
||||||
<BakerySelector
|
<TenantSwitcher
|
||||||
bakeries={bakeries}
|
showLabel={false}
|
||||||
selectedBakery={currentBakery}
|
|
||||||
onSelectBakery={selectBakery}
|
|
||||||
onAddBakery={() => {
|
|
||||||
// TODO: Navigate to add bakery page or open modal
|
|
||||||
console.log('Add new bakery');
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
className="w-full max-w-none"
|
className="w-full max-w-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { StatsCard, StatsGrid } from './Stats';
|
||||||
export { StatusCard, getStatusColor } from './StatusCard';
|
export { StatusCard, getStatusColor } from './StatusCard';
|
||||||
export { StatusModal } from './StatusModal';
|
export { StatusModal } from './StatusModal';
|
||||||
export { BakerySelector } from './BakerySelector';
|
export { TenantSwitcher } from './TenantSwitcher';
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type { ButtonProps } from './Button';
|
export type { ButtonProps } from './Button';
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ export const MOCK_CONFIG = {
|
|||||||
MOCK_MODE: true,
|
MOCK_MODE: true,
|
||||||
|
|
||||||
// Component-specific toggles
|
// Component-specific toggles
|
||||||
MOCK_REGISTRATION: true,
|
MOCK_REGISTRATION: false, // Now using real backend
|
||||||
MOCK_AUTHENTICATION: true,
|
MOCK_AUTHENTICATION: false, // Now using real backend
|
||||||
MOCK_ONBOARDING_FLOW: true,
|
MOCK_ONBOARDING_FLOW: true, // Keep onboarding mock for now
|
||||||
|
|
||||||
// Mock user data
|
// Mock user data
|
||||||
MOCK_USER: {
|
MOCK_USER: {
|
||||||
|
|||||||
@@ -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 { useNavigate } from 'react-router-dom';
|
||||||
import { OnboardingWizard, OnboardingStep } from '../../../components/domain/onboarding/OnboardingWizard';
|
import { OnboardingWizard, OnboardingStep } from '../../../components/domain/onboarding/OnboardingWizard';
|
||||||
import { onboardingApiService } from '../../../services/api/onboarding.service';
|
import { onboardingApiService } from '../../../services/api/onboarding.service';
|
||||||
import { useAuth } from '../../../hooks/useAuth';
|
import { useAuthUser, useIsAuthenticated } from '../../../stores/auth.store';
|
||||||
import { LoadingSpinner } from '../../../components/shared/LoadingSpinner';
|
import { LoadingSpinner } from '../../../components/shared/LoadingSpinner';
|
||||||
|
|
||||||
// Step Components
|
// Step Components
|
||||||
@@ -16,7 +16,8 @@ import { CompletionStep } from '../../../components/domain/onboarding/steps/Comp
|
|||||||
|
|
||||||
const OnboardingPage: React.FC = () => {
|
const OnboardingPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const user = useAuthUser();
|
||||||
|
const isAuthenticated = useIsAuthenticated();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [globalData, setGlobalData] = useState<any>({});
|
const [globalData, setGlobalData] = useState<any>({});
|
||||||
|
|
||||||
@@ -30,8 +31,11 @@ const OnboardingPage: React.FC = () => {
|
|||||||
isRequired: true,
|
isRequired: true,
|
||||||
validation: (data) => {
|
validation: (data) => {
|
||||||
if (!data.bakery?.name) return 'El nombre de la panadería es requerido';
|
if (!data.bakery?.name) return 'El nombre de la panadería es requerido';
|
||||||
if (!data.bakery?.type) return 'El tipo de panadería es requerido';
|
if (!data.bakery?.business_model) return 'El modelo de negocio es requerido';
|
||||||
if (!data.bakery?.location) return 'La ubicación es requerida';
|
if (!data.bakery?.address) return 'La dirección es requerida';
|
||||||
|
if (!data.bakery?.city) return 'La ciudad es requerida';
|
||||||
|
if (!data.bakery?.postal_code) return 'El código postal es requerido';
|
||||||
|
if (!data.bakery?.phone) return 'El teléfono es requerido';
|
||||||
// Tenant creation will happen automatically when validation passes
|
// Tenant creation will happen automatically when validation passes
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -142,10 +146,27 @@ const OnboardingPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
navigate('/login', {
|
||||||
|
state: {
|
||||||
|
message: 'Debes iniciar sesión para acceder al onboarding.',
|
||||||
|
returnUrl: '/app/onboarding'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <LoadingSpinner overlay text="Completando configuración..." />;
|
return <LoadingSpinner overlay text="Completando configuración..." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't render if not authenticated (will redirect)
|
||||||
|
if (!isAuthenticated || !user) {
|
||||||
|
return <LoadingSpinner overlay text="Verificando autenticación..." />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--bg-primary)]">
|
<div className="min-h-screen bg-[var(--bg-primary)]">
|
||||||
<OnboardingWizard
|
<OnboardingWizard
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
ExternalLink
|
ExternalLink
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../../../../hooks/api/useAuth';
|
import { useAuthUser } from '../../../../stores/auth.store';
|
||||||
import { useBakeryStore } from '../../../../stores/bakery.store';
|
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||||
import { useToast } from '../../../../hooks/ui/useToast';
|
import { useToast } from '../../../../hooks/ui/useToast';
|
||||||
import {
|
import {
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
@@ -238,8 +238,8 @@ const PlanComparison: React.FC<PlanComparisonProps> = ({ plans, currentPlan, onU
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SubscriptionPage: React.FC = () => {
|
const SubscriptionPage: React.FC = () => {
|
||||||
const { user, tenant_id } = useAuth();
|
const user = useAuthUser();
|
||||||
const { currentTenant } = useBakeryStore();
|
const currentTenant = useCurrentTenant();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
|
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
|
||||||
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
|
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
|
||||||
@@ -249,13 +249,13 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
const [upgrading, setUpgrading] = useState(false);
|
const [upgrading, setUpgrading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentTenant?.id || tenant_id || isMockMode()) {
|
if (currentTenant?.id || user?.tenant_id || isMockMode()) {
|
||||||
loadSubscriptionData();
|
loadSubscriptionData();
|
||||||
}
|
}
|
||||||
}, [currentTenant, tenant_id]);
|
}, [currentTenant, user?.tenant_id]);
|
||||||
|
|
||||||
const loadSubscriptionData = async () => {
|
const loadSubscriptionData = async () => {
|
||||||
let tenantId = currentTenant?.id || tenant_id;
|
let tenantId = currentTenant?.id || user?.tenant_id;
|
||||||
|
|
||||||
// In mock mode, use the mock tenant ID if no real tenant is available
|
// In mock mode, use the mock tenant ID if no real tenant is available
|
||||||
if (isMockMode() && !tenantId) {
|
if (isMockMode() && !tenantId) {
|
||||||
@@ -290,7 +290,7 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpgradeConfirm = async () => {
|
const handleUpgradeConfirm = async () => {
|
||||||
let tenantId = currentTenant?.id || tenant_id;
|
let tenantId = currentTenant?.id || user?.tenant_id;
|
||||||
|
|
||||||
// In mock mode, use the mock tenant ID if no real tenant is available
|
// In mock mode, use the mock tenant ID if no real tenant is available
|
||||||
if (isMockMode() && !tenantId) {
|
if (isMockMode() && !tenantId) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
ExternalLink
|
ExternalLink
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../../../../hooks/api/useAuth';
|
import { useAuth } from '../../../../hooks/api/useAuth';
|
||||||
import { useBakeryStore } from '../../../../stores/bakery.store';
|
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||||
import { useToast } from '../../../../hooks/ui/useToast';
|
import { useToast } from '../../../../hooks/ui/useToast';
|
||||||
import {
|
import {
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
@@ -287,7 +287,7 @@ const PlanComparison: React.FC<PlanComparisonProps> = ({ plans, currentPlan, onU
|
|||||||
|
|
||||||
const SubscriptionPage: React.FC = () => {
|
const SubscriptionPage: React.FC = () => {
|
||||||
const { user, tenant_id } = useAuth();
|
const { user, tenant_id } = useAuth();
|
||||||
const { currentTenant } = useBakeryStore();
|
const currentTenant = useCurrentTenant();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
|
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
|
||||||
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
|
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import React from 'react';
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores';
|
import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores';
|
||||||
import { RouteConfig, canAccessRoute, ROUTES } from './routes.config';
|
import { RouteConfig, canAccessRoute, ROUTES } from './routes.config';
|
||||||
import { isMockAuthentication } from '../config/mock.config';
|
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -129,12 +128,8 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
|||||||
const isLoading = useAuthLoading();
|
const isLoading = useAuthLoading();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// MOCK MODE - Allow access to onboarding routes for testing
|
// Note: Onboarding routes are now properly protected and require authentication
|
||||||
const isOnboardingRoute = location.pathname.startsWith('/app/onboarding');
|
// Mock mode only applies to the onboarding flow content, not to route protection
|
||||||
|
|
||||||
if (isMockAuthentication() && isOnboardingRoute) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading spinner while checking authentication
|
// Show loading spinner while checking authentication
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -86,7 +86,13 @@ class AuthService {
|
|||||||
|
|
||||||
// Authentication endpoints
|
// Authentication endpoints
|
||||||
async register(userData: UserRegistration): Promise<ApiResponse<TokenResponse>> {
|
async register(userData: UserRegistration): Promise<ApiResponse<TokenResponse>> {
|
||||||
return apiClient.post(`${this.baseUrl}/register`, userData);
|
const response = await apiClient.post<TokenResponse>(`${this.baseUrl}/register`, userData);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.handleSuccessfulAuth(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(credentials: UserLogin): Promise<ApiResponse<TokenResponse>> {
|
async login(credentials: UserLogin): Promise<ApiResponse<TokenResponse>> {
|
||||||
@@ -164,54 +170,70 @@ class AuthService {
|
|||||||
return apiClient.post(`${this.baseUrl}/verify-email/confirm`, { token });
|
return apiClient.post(`${this.baseUrl}/verify-email/confirm`, { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local auth state management
|
// Local auth state management - Now handled by Zustand store
|
||||||
private handleSuccessfulAuth(tokenData: TokenResponse) {
|
private handleSuccessfulAuth(tokenData: TokenResponse) {
|
||||||
localStorage.setItem('access_token', tokenData.access_token);
|
// Set auth token for API client
|
||||||
|
|
||||||
if (tokenData.refresh_token) {
|
|
||||||
localStorage.setItem('refresh_token', tokenData.refresh_token);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tokenData.user) {
|
|
||||||
localStorage.setItem('user_data', JSON.stringify(tokenData.user));
|
|
||||||
|
|
||||||
if (tokenData.user.tenant_id) {
|
|
||||||
localStorage.setItem('tenant_id', tokenData.user.tenant_id);
|
|
||||||
apiClient.setTenantId(tokenData.user.tenant_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apiClient.setAuthToken(tokenData.access_token);
|
apiClient.setAuthToken(tokenData.access_token);
|
||||||
|
|
||||||
|
// Set tenant ID for API client if available
|
||||||
|
if (tokenData.user?.tenant_id) {
|
||||||
|
apiClient.setTenantId(tokenData.user.tenant_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearAuthData() {
|
private clearAuthData() {
|
||||||
localStorage.removeItem('access_token');
|
// Clear API client tokens
|
||||||
localStorage.removeItem('refresh_token');
|
|
||||||
localStorage.removeItem('user_data');
|
|
||||||
localStorage.removeItem('tenant_id');
|
|
||||||
apiClient.removeAuthToken();
|
apiClient.removeAuthToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility methods
|
// Utility methods - Now get data from Zustand store
|
||||||
isAuthenticated(): boolean {
|
isAuthenticated(): boolean {
|
||||||
return !!localStorage.getItem('access_token');
|
const authStorage = localStorage.getItem('auth-storage');
|
||||||
|
if (!authStorage) return false;
|
||||||
|
try {
|
||||||
|
const { state } = JSON.parse(authStorage);
|
||||||
|
return state?.isAuthenticated || false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentUserData(): UserData | null {
|
getCurrentUserData(): UserData | null {
|
||||||
const userData = localStorage.getItem('user_data');
|
const authStorage = localStorage.getItem('auth-storage');
|
||||||
return userData ? JSON.parse(userData) : null;
|
if (!authStorage) return null;
|
||||||
|
try {
|
||||||
|
const { state } = JSON.parse(authStorage);
|
||||||
|
return state?.user || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAccessToken(): string | null {
|
getAccessToken(): string | null {
|
||||||
return localStorage.getItem('access_token');
|
const authStorage = localStorage.getItem('auth-storage');
|
||||||
|
if (!authStorage) return null;
|
||||||
|
try {
|
||||||
|
const { state } = JSON.parse(authStorage);
|
||||||
|
return state?.token || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getRefreshToken(): string | null {
|
getRefreshToken(): string | null {
|
||||||
return localStorage.getItem('refresh_token');
|
const authStorage = localStorage.getItem('auth-storage');
|
||||||
|
if (!authStorage) return null;
|
||||||
|
try {
|
||||||
|
const { state } = JSON.parse(authStorage);
|
||||||
|
return state?.refreshToken || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getTenantId(): string | null {
|
getTenantId(): string | null {
|
||||||
return localStorage.getItem('tenant_id');
|
const userData = this.getCurrentUserData();
|
||||||
|
return userData?.tenant_id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is expired (basic check)
|
// Check if token is expired (basic check)
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
// Utility functions to access auth and tenant store data from localStorage
|
||||||
|
const getAuthData = () => {
|
||||||
|
const authStorage = localStorage.getItem('auth-storage');
|
||||||
|
if (!authStorage) return null;
|
||||||
|
try {
|
||||||
|
const { state } = JSON.parse(authStorage);
|
||||||
|
return state;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTenantData = () => {
|
||||||
|
const tenantStorage = localStorage.getItem('tenant-storage');
|
||||||
|
if (!tenantStorage) return null;
|
||||||
|
try {
|
||||||
|
const { state } = JSON.parse(tenantStorage);
|
||||||
|
return state;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAuthData = () => {
|
||||||
|
localStorage.removeItem('auth-storage');
|
||||||
|
};
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
export interface ApiResponse<T = any> {
|
||||||
data: T;
|
data: T;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -41,12 +68,15 @@ class ApiClient {
|
|||||||
// Request interceptor - add auth token and tenant ID
|
// Request interceptor - add auth token and tenant ID
|
||||||
this.axiosInstance.interceptors.request.use(
|
this.axiosInstance.interceptors.request.use(
|
||||||
(config: InternalAxiosRequestConfig) => {
|
(config: InternalAxiosRequestConfig) => {
|
||||||
const token = localStorage.getItem('access_token');
|
const authData = getAuthData();
|
||||||
if (token) {
|
if (authData?.token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${authData.token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantId = localStorage.getItem('tenant_id');
|
// Get tenant ID from tenant store (priority) or fallback to user's tenant_id
|
||||||
|
const tenantData = getTenantData();
|
||||||
|
const tenantId = tenantData?.currentTenant?.id || authData?.user?.tenant_id;
|
||||||
|
|
||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
config.headers['X-Tenant-ID'] = tenantId;
|
config.headers['X-Tenant-ID'] = tenantId;
|
||||||
}
|
}
|
||||||
@@ -67,8 +97,8 @@ class ApiClient {
|
|||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const refreshToken = localStorage.getItem('refresh_token');
|
const authData = getAuthData();
|
||||||
if (refreshToken) {
|
if (authData?.refreshToken) {
|
||||||
const newToken = await this.refreshToken();
|
const newToken = await this.refreshToken();
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
return this.axiosInstance(originalRequest);
|
return this.axiosInstance(originalRequest);
|
||||||
@@ -113,29 +143,34 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async performTokenRefresh(): Promise<string> {
|
private async performTokenRefresh(): Promise<string> {
|
||||||
const refreshToken = localStorage.getItem('refresh_token');
|
const authData = getAuthData();
|
||||||
if (!refreshToken) {
|
if (!authData?.refreshToken) {
|
||||||
throw new Error('No refresh token available');
|
throw new Error('No refresh token available');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||||
refresh_token: refreshToken,
|
refresh_token: authData.refreshToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { access_token, refresh_token } = response.data;
|
const { access_token, refresh_token } = response.data;
|
||||||
localStorage.setItem('access_token', access_token);
|
|
||||||
if (refresh_token) {
|
// Update the Zustand store by modifying the auth-storage directly
|
||||||
localStorage.setItem('refresh_token', refresh_token);
|
const newAuthData = {
|
||||||
}
|
...authData,
|
||||||
|
token: access_token,
|
||||||
|
refreshToken: refresh_token || authData.refreshToken
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('auth-storage', JSON.stringify({
|
||||||
|
state: newAuthData,
|
||||||
|
version: 0
|
||||||
|
}));
|
||||||
|
|
||||||
return access_token;
|
return access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleAuthFailure() {
|
private handleAuthFailure() {
|
||||||
localStorage.removeItem('access_token');
|
clearAuthData();
|
||||||
localStorage.removeItem('refresh_token');
|
|
||||||
localStorage.removeItem('user_data');
|
|
||||||
localStorage.removeItem('tenant_id');
|
|
||||||
|
|
||||||
// Redirect to login
|
// Redirect to login
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
@@ -197,19 +232,16 @@ class ApiClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility methods
|
// Utility methods - Now work with Zustand store
|
||||||
setAuthToken(token: string) {
|
setAuthToken(token: string) {
|
||||||
localStorage.setItem('access_token', token);
|
|
||||||
this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAuthToken() {
|
removeAuthToken() {
|
||||||
localStorage.removeItem('access_token');
|
|
||||||
delete this.axiosInstance.defaults.headers.common['Authorization'];
|
delete this.axiosInstance.defaults.headers.common['Authorization'];
|
||||||
}
|
}
|
||||||
|
|
||||||
setTenantId(tenantId: string) {
|
setTenantId(tenantId: string) {
|
||||||
localStorage.setItem('tenant_id', tenantId);
|
|
||||||
this.axiosInstance.defaults.headers.common['X-Tenant-ID'] = tenantId;
|
this.axiosInstance.defaults.headers.common['X-Tenant-ID'] = tenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { apiClient, ApiResponse } from './client';
|
import { apiClient, ApiResponse } from './client';
|
||||||
|
|
||||||
// Request/Response Types based on backend schemas
|
// Request/Response Types based on backend schemas - UPDATED TO MATCH BACKEND
|
||||||
export interface BakeryRegistration {
|
export interface BakeryRegistration {
|
||||||
name: string;
|
name: string;
|
||||||
address: string;
|
address: string;
|
||||||
@@ -101,7 +101,7 @@ class TenantService {
|
|||||||
|
|
||||||
// Tenant CRUD operations
|
// Tenant CRUD operations
|
||||||
async createTenant(tenantData: BakeryRegistration): Promise<ApiResponse<TenantResponse>> {
|
async createTenant(tenantData: BakeryRegistration): Promise<ApiResponse<TenantResponse>> {
|
||||||
return apiClient.post(`${this.baseUrl}`, tenantData);
|
return apiClient.post(`${this.baseUrl}/register`, tenantData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTenant(tenantId: string): Promise<ApiResponse<TenantResponse>> {
|
async getTenant(tenantId: string): Promise<ApiResponse<TenantResponse>> {
|
||||||
@@ -151,15 +151,31 @@ class TenantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async switchTenant(tenantId: string): Promise<ApiResponse<{ message: string; tenant: TenantResponse }>> {
|
async switchTenant(tenantId: string): Promise<ApiResponse<{ message: string; tenant: TenantResponse }>> {
|
||||||
const response = await apiClient.post(`${this.baseUrl}/${tenantId}/switch`);
|
// Frontend-only tenant switching since backend doesn't have this endpoint
|
||||||
|
// We'll simulate the response and update the tenant store directly
|
||||||
|
try {
|
||||||
|
const tenant = await this.getTenant(tenantId);
|
||||||
|
if (tenant.success) {
|
||||||
|
// Update API client tenant context
|
||||||
|
apiClient.setTenantId(tenantId);
|
||||||
|
|
||||||
if (response.success && response.data?.tenant) {
|
return {
|
||||||
// Update local tenant context
|
success: true,
|
||||||
localStorage.setItem('tenant_id', tenantId);
|
data: {
|
||||||
apiClient.setTenantId(tenantId);
|
message: 'Tenant switched successfully',
|
||||||
|
tenant: tenant.data
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error('Tenant not found');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to switch tenant'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Member management
|
// Member management
|
||||||
@@ -228,33 +244,33 @@ class TenantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Utility methods
|
// Utility methods
|
||||||
async getUserTenants(): Promise<ApiResponse<TenantResponse[]>> {
|
async getUserTenants(userId?: string): Promise<ApiResponse<TenantResponse[]>> {
|
||||||
return apiClient.get(`${this.baseUrl}/my-tenants`);
|
// If no userId provided, we'll get it from the auth store/token
|
||||||
|
return apiClient.get(`${this.baseUrl}/users/${userId || 'current'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateTenantSlug(slug: string): Promise<ApiResponse<{ available: boolean; suggestions?: string[] }>> {
|
async validateTenantSlug(slug: string): Promise<ApiResponse<{ available: boolean; suggestions?: string[] }>> {
|
||||||
return apiClient.get(`${this.baseUrl}/validate-slug/${slug}`);
|
return apiClient.get(`${this.baseUrl}/validate-slug/${slug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local state management helpers
|
// Local state management helpers - Now uses tenant store
|
||||||
getCurrentTenantId(): string | null {
|
getCurrentTenantId(): string | null {
|
||||||
return localStorage.getItem('tenant_id');
|
// This will be handled by the tenant store
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentTenantData(): TenantResponse | null {
|
getCurrentTenantData(): TenantResponse | null {
|
||||||
const tenantData = localStorage.getItem('tenant_data');
|
// This will be handled by the tenant store
|
||||||
return tenantData ? JSON.parse(tenantData) : null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentTenant(tenant: TenantResponse) {
|
setCurrentTenant(tenant: TenantResponse) {
|
||||||
localStorage.setItem('tenant_id', tenant.id);
|
// This will be handled by the tenant store
|
||||||
localStorage.setItem('tenant_data', JSON.stringify(tenant));
|
|
||||||
apiClient.setTenantId(tenant.id);
|
apiClient.setTenantId(tenant.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCurrentTenant() {
|
clearCurrentTenant() {
|
||||||
localStorage.removeItem('tenant_id');
|
// This will be handled by the tenant store
|
||||||
localStorage.removeItem('tenant_data');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business type helpers
|
// Business type helpers
|
||||||
|
|||||||
@@ -4,19 +4,16 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
full_name: string; // Updated to match backend
|
||||||
role: 'admin' | 'manager' | 'baker' | 'staff';
|
is_active: boolean;
|
||||||
permissions: string[];
|
is_verified: boolean;
|
||||||
tenantId: string;
|
created_at: string;
|
||||||
tenantName: string;
|
last_login?: string;
|
||||||
avatar?: string;
|
phone?: string;
|
||||||
lastLogin?: string;
|
language?: string;
|
||||||
preferences?: {
|
timezone?: string;
|
||||||
language: string;
|
tenant_id?: string;
|
||||||
timezone: string;
|
role?: string;
|
||||||
theme: 'light' | 'dark';
|
|
||||||
notifications: boolean;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
@@ -30,6 +27,7 @@ export interface AuthState {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (userData: { email: string; password: string; full_name: string; tenant_name?: string }) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
refreshAuth: () => Promise<void>;
|
refreshAuth: () => Promise<void>;
|
||||||
updateUser: (updates: Partial<User>) => void;
|
updateUser: (updates: Partial<User>) => void;
|
||||||
@@ -42,50 +40,7 @@ export interface AuthState {
|
|||||||
canAccess: (resource: string, action: string) => boolean;
|
canAccess: (resource: string, action: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock API functions (replace with actual API calls)
|
import { authService } from '../services/api/auth.service';
|
||||||
const mockLogin = async (email: string, password: string): Promise<{ user: User; token: string; refreshToken: string }> => {
|
|
||||||
// Simulate API delay
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
if (email === 'admin@bakery.com' && password === 'admin12345') {
|
|
||||||
return {
|
|
||||||
user: {
|
|
||||||
id: '1',
|
|
||||||
email: 'admin@bakery.com',
|
|
||||||
name: 'Admin User',
|
|
||||||
role: 'admin',
|
|
||||||
permissions: ['*'],
|
|
||||||
tenantId: 'tenant-1',
|
|
||||||
tenantName: 'Panadería San Miguel',
|
|
||||||
avatar: undefined,
|
|
||||||
lastLogin: new Date().toISOString(),
|
|
||||||
preferences: {
|
|
||||||
language: 'es',
|
|
||||||
timezone: 'Europe/Madrid',
|
|
||||||
theme: 'light',
|
|
||||||
notifications: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
token: 'mock-jwt-token',
|
|
||||||
refreshToken: 'mock-refresh-token',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Credenciales inválidas');
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockRefreshToken = async (refreshToken: string): Promise<{ token: string; refreshToken: string }> => {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
if (refreshToken === 'mock-refresh-token') {
|
|
||||||
return {
|
|
||||||
token: 'new-mock-jwt-token',
|
|
||||||
refreshToken: 'new-mock-refresh-token',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Invalid refresh token');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
@@ -103,16 +58,20 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
try {
|
try {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
const response = await mockLogin(email, password);
|
const response = await authService.login({ email, password });
|
||||||
|
|
||||||
set({
|
if (response.success && response.data) {
|
||||||
user: response.user,
|
set({
|
||||||
token: response.token,
|
user: response.data.user || null,
|
||||||
refreshToken: response.refreshToken,
|
token: response.data.access_token,
|
||||||
isAuthenticated: true,
|
refreshToken: response.data.refresh_token || null,
|
||||||
isLoading: false,
|
isAuthenticated: true,
|
||||||
error: null,
|
isLoading: false,
|
||||||
});
|
error: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('Login failed');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
@@ -126,6 +85,37 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
register: async (userData: { email: string; password: string; full_name: string; tenant_name?: string }) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
const response = await authService.register(userData);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
set({
|
||||||
|
user: response.data.user || null,
|
||||||
|
token: response.data.access_token,
|
||||||
|
refreshToken: response.data.refresh_token || null,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('Registration failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Error de registro',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
@@ -146,14 +136,18 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
|
|
||||||
const response = await mockRefreshToken(refreshToken);
|
const response = await authService.refreshToken(refreshToken);
|
||||||
|
|
||||||
set({
|
if (response.success && response.data) {
|
||||||
token: response.token,
|
set({
|
||||||
refreshToken: response.refreshToken,
|
token: response.data.access_token,
|
||||||
isLoading: false,
|
refreshToken: response.data.refresh_token || refreshToken,
|
||||||
error: null,
|
isLoading: false,
|
||||||
});
|
error: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('Token refresh failed');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
@@ -184,15 +178,16 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({ isLoading: loading });
|
set({ isLoading: loading });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Permission helpers
|
// Permission helpers - Simplified for backend compatibility
|
||||||
hasPermission: (permission: string): boolean => {
|
hasPermission: (_permission: string): boolean => {
|
||||||
const { user } = get();
|
const { user } = get();
|
||||||
if (!user) return false;
|
if (!user || !user.is_active) return false;
|
||||||
|
|
||||||
// Admin has all permissions
|
// Admin has all permissions
|
||||||
if (user.permissions.includes('*')) return true;
|
if (user.role === 'admin') return true;
|
||||||
|
|
||||||
return user.permissions.includes(permission);
|
// Basic role-based permissions
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
hasRole: (role: string): boolean => {
|
hasRole: (role: string): boolean => {
|
||||||
@@ -201,28 +196,17 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
canAccess: (resource: string, action: string): boolean => {
|
canAccess: (resource: string, action: string): boolean => {
|
||||||
const { user, hasPermission } = get();
|
const { user } = get();
|
||||||
if (!user) return false;
|
if (!user || !user.is_active) return false;
|
||||||
|
|
||||||
// Check specific permission
|
// Role-based access control
|
||||||
if (hasPermission(`${resource}:${action}`)) return true;
|
|
||||||
|
|
||||||
// Check wildcard permissions
|
|
||||||
if (hasPermission(`${resource}:*`)) return true;
|
|
||||||
if (hasPermission('*')) return true;
|
|
||||||
|
|
||||||
// Role-based access fallback
|
|
||||||
switch (user.role) {
|
switch (user.role) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
return true;
|
return true;
|
||||||
case 'manager':
|
case 'manager':
|
||||||
return ['inventory', 'production', 'sales', 'reports'].includes(resource);
|
return ['inventory', 'production', 'sales', 'reports'].includes(resource);
|
||||||
case 'baker':
|
case 'user':
|
||||||
return ['production', 'inventory'].includes(resource) &&
|
return ['inventory', 'sales'].includes(resource) && action === 'read';
|
||||||
['read', 'update'].includes(action);
|
|
||||||
case 'staff':
|
|
||||||
return ['inventory', 'sales'].includes(resource) &&
|
|
||||||
action === 'read';
|
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -237,6 +221,18 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
refreshToken: state.refreshToken,
|
refreshToken: state.refreshToken,
|
||||||
isAuthenticated: state.isAuthenticated,
|
isAuthenticated: state.isAuthenticated,
|
||||||
}),
|
}),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
// Initialize API client with stored token when store rehydrates
|
||||||
|
if (state?.token) {
|
||||||
|
import('../services/api/client').then(({ apiClient }) => {
|
||||||
|
apiClient.setAuthToken(state.token!);
|
||||||
|
|
||||||
|
if (state.user?.tenant_id) {
|
||||||
|
apiClient.setTenantId(state.user.tenant_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -255,6 +251,7 @@ export const usePermissions = () => useAuthStore((state) => ({
|
|||||||
// Hook for auth actions
|
// Hook for auth actions
|
||||||
export const useAuthActions = () => useAuthStore((state) => ({
|
export const useAuthActions = () => useAuthStore((state) => ({
|
||||||
login: state.login,
|
login: state.login,
|
||||||
|
register: state.register,
|
||||||
logout: state.logout,
|
logout: state.logout,
|
||||||
refreshAuth: state.refreshAuth,
|
refreshAuth: state.refreshAuth,
|
||||||
updateUser: state.updateUser,
|
updateUser: state.updateUser,
|
||||||
|
|||||||
@@ -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 { useUIStore, useLanguage, useSidebar, useCompactMode, useViewMode, useLoading, useToasts, useModals, useBreadcrumbs, usePreferences, useUIActions } from './ui.store';
|
||||||
export type { Theme, Language, ViewMode, SidebarState, Toast, Modal, UIState } from './ui.store';
|
export type { Theme, Language, ViewMode, SidebarState, Toast, Modal, UIState } from './ui.store';
|
||||||
|
|
||||||
export { useBakeryStore } from './bakery.store';
|
|
||||||
export type { BakeryType, BusinessModel } from './bakery.store';
|
export { useTenantStore, useCurrentTenant, useAvailableTenants, useTenantLoading, useTenantError, useTenantActions, useTenantPermissions, useTenant } from './tenant.store';
|
||||||
|
export type { TenantState } from './tenant.store';
|
||||||
|
|
||||||
export { useAlertsStore, useAlerts, useAlertRules, useAlertFilters, useAlertSettings, useUnreadAlertsCount, useCriticalAlertsCount } from './alerts.store';
|
export { useAlertsStore, useAlerts, useAlertRules, useAlertFilters, useAlertSettings, useUnreadAlertsCount, useCriticalAlertsCount } from './alerts.store';
|
||||||
export type { Alert, AlertRule, AlertCondition, AlertAction, AlertsState, AlertType, AlertCategory, AlertPriority, AlertStatus } from './alerts.store';
|
export type { Alert, AlertRule, AlertCondition, AlertAction, AlertsState, AlertType, AlertCategory, AlertPriority, AlertStatus } from './alerts.store';
|
||||||
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 {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
full_name: string;
|
full_name: string; // Backend uses full_name, not name
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_verified: boolean;
|
is_verified: boolean;
|
||||||
created_at: string;
|
created_at: string; // ISO format datetime string
|
||||||
last_login?: string;
|
last_login?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
tenant_id?: string;
|
tenant_id?: string;
|
||||||
role?: UserRole;
|
role?: string; // Backend uses string, not enum
|
||||||
avatar_url?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserRegistration {
|
export interface UserRegistration {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
tenant_name?: string;
|
tenant_name?: string; // Optional in backend
|
||||||
role?: UserRole;
|
role?: string; // Backend uses string, defaults to "user"
|
||||||
phone?: string;
|
|
||||||
language?: string;
|
|
||||||
timezone?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserLogin {
|
export interface UserLogin {
|
||||||
@@ -35,8 +31,8 @@ export interface UserLogin {
|
|||||||
export interface TokenResponse {
|
export interface TokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
token_type: string;
|
token_type: string; // defaults to "bearer"
|
||||||
expires_in: number;
|
expires_in: number; // seconds, defaults to 3600
|
||||||
user?: User;
|
user?: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +90,7 @@ export interface RegisterFormData {
|
|||||||
password: string;
|
password: string;
|
||||||
confirmPassword: string;
|
confirmPassword: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
tenant_name: string;
|
tenant_name?: string; // Optional to match backend
|
||||||
phone?: string;
|
phone?: string;
|
||||||
acceptTerms: boolean;
|
acceptTerms: boolean;
|
||||||
}
|
}
|
||||||
@@ -182,13 +178,11 @@ export interface OAuthProvider {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enums
|
// Enums - Simplified to match backend
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
USER = 'user',
|
USER = 'user',
|
||||||
ADMIN = 'admin',
|
ADMIN = 'admin',
|
||||||
MANAGER = 'manager',
|
MANAGER = 'manager',
|
||||||
OWNER = 'owner',
|
|
||||||
VIEWER = 'viewer',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AuthProvider {
|
export enum AuthProvider {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ FIXED VERSION - Consistent password hashing using passlib
|
|||||||
import re
|
import re
|
||||||
import hashlib
|
import hashlib
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, List
|
||||||
import redis.asyncio as redis
|
import redis.asyncio as redis
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
import structlog
|
import structlog
|
||||||
@@ -36,6 +36,9 @@ class SecurityManager:
|
|||||||
if len(password) < settings.PASSWORD_MIN_LENGTH:
|
if len(password) < settings.PASSWORD_MIN_LENGTH:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if len(password) > 128: # Max length from Pydantic schema
|
||||||
|
return False
|
||||||
|
|
||||||
if settings.PASSWORD_REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password):
|
if settings.PASSWORD_REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -50,6 +53,31 @@ class SecurityManager:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_password_validation_errors(password: str) -> List[str]:
|
||||||
|
"""Get detailed password validation errors for better UX"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if len(password) < settings.PASSWORD_MIN_LENGTH:
|
||||||
|
errors.append(f"Password must be at least {settings.PASSWORD_MIN_LENGTH} characters long")
|
||||||
|
|
||||||
|
if len(password) > 128:
|
||||||
|
errors.append("Password cannot exceed 128 characters")
|
||||||
|
|
||||||
|
if settings.PASSWORD_REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password):
|
||||||
|
errors.append("Password must contain at least one uppercase letter")
|
||||||
|
|
||||||
|
if settings.PASSWORD_REQUIRE_LOWERCASE and not re.search(r'[a-z]', password):
|
||||||
|
errors.append("Password must contain at least one lowercase letter")
|
||||||
|
|
||||||
|
if settings.PASSWORD_REQUIRE_NUMBERS and not re.search(r'\d', password):
|
||||||
|
errors.append("Password must contain at least one number")
|
||||||
|
|
||||||
|
if settings.PASSWORD_REQUIRE_SYMBOLS and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
|
||||||
|
errors.append("Password must contain at least one symbol (!@#$%^&*(),.?\":{}|<>)")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""Hash password using passlib bcrypt - FIXED"""
|
"""Hash password using passlib bcrypt - FIXED"""
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ class EnhancedAuthService:
|
|||||||
if existing_user:
|
if existing_user:
|
||||||
raise DuplicateRecordError("User with this email already exists")
|
raise DuplicateRecordError("User with this email already exists")
|
||||||
|
|
||||||
|
# Validate password strength
|
||||||
|
if not SecurityManager.validate_password(user_data.password):
|
||||||
|
raise ValueError("Password does not meet security requirements")
|
||||||
|
|
||||||
# Create user data
|
# Create user data
|
||||||
user_role = user_data.role if user_data.role else "user"
|
user_role = user_data.role if user_data.role else "user"
|
||||||
hashed_password = SecurityManager.hash_password(user_data.password)
|
hashed_password = SecurityManager.hash_password(user_data.password)
|
||||||
@@ -446,6 +450,13 @@ class EnhancedAuthService:
|
|||||||
detail="Invalid old password"
|
detail="Invalid old password"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate new password strength
|
||||||
|
if not SecurityManager.validate_password(new_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="New password does not meet security requirements"
|
||||||
|
)
|
||||||
|
|
||||||
# Hash new password and update
|
# Hash new password and update
|
||||||
new_hashed_password = SecurityManager.hash_password(new_password)
|
new_hashed_password = SecurityManager.hash_password(new_password)
|
||||||
await user_repo.update(user_id, {"hashed_password": new_hashed_password})
|
await user_repo.update(user_id, {"hashed_password": new_hashed_password})
|
||||||
|
|||||||
Reference in New Issue
Block a user