@@ -292,7 +324,7 @@ export const RegisterForm: React.FC = ({
size="lg"
isLoading={isLoading}
loadingText="Creando cuenta..."
- disabled={isLoading}
+ disabled={isLoading || !validatePassword(formData.password) || !formData.acceptTerms || passwordMatchStatus !== 'match'}
className="w-full"
onClick={(e) => {
console.log('Button clicked!');
diff --git a/frontend/src/components/domain/onboarding/OnboardingWizard.tsx b/frontend/src/components/domain/onboarding/OnboardingWizard.tsx
index 0d4bc999..82de7f55 100644
--- a/frontend/src/components/domain/onboarding/OnboardingWizard.tsx
+++ b/frontend/src/components/domain/onboarding/OnboardingWizard.tsx
@@ -1,5 +1,9 @@
import React, { useState, useCallback } from 'react';
import { Card, Button, Input, Select, Badge } from '../../ui';
+import { tenantService } from '../../../services/api/tenant.service';
+import { useAuthUser } from '../../../stores/auth.store';
+import { useTenantActions } from '../../../stores/tenant.store';
+
export interface OnboardingStep {
id: string;
@@ -37,6 +41,7 @@ export const OnboardingWizard: React.FC = ({
const [stepData, setStepData] = useState>({});
const [completedSteps, setCompletedSteps] = useState>(new Set());
const [validationErrors, setValidationErrors] = useState>({});
+ const { setCurrentTenant } = useTenantActions();
const currentStep = steps[currentStepIndex];
@@ -80,41 +85,55 @@ export const OnboardingWizard: React.FC = ({
const data = stepData[currentStep.id] || {};
if (!data.bakery?.tenant_id) {
- // Create tenant inline
+ // Create tenant inline using real backend API
updateStepData(currentStep.id, {
...data,
bakery: { ...data.bakery, isCreating: true }
});
try {
- const mockTenantService = {
- createTenant: async (formData: any) => {
- await new Promise(resolve => setTimeout(resolve, 1000));
- return {
- tenant_id: `tenant_${Date.now()}`,
- name: formData.name,
- ...formData
- };
- }
+ // Use the backend-compatible data directly from BakerySetupStep
+ const bakeryRegistration = {
+ name: data.bakery.name,
+ address: data.bakery.address,
+ city: data.bakery.city,
+ postal_code: data.bakery.postal_code,
+ phone: data.bakery.phone,
+ business_type: data.bakery.business_type,
+ business_model: data.bakery.business_model
};
- const tenantData = await mockTenantService.createTenant(data.bakery);
+ const response = await tenantService.createTenant(bakeryRegistration);
- updateStepData(currentStep.id, {
- ...data,
- bakery: {
- ...data.bakery,
- tenant_id: tenantData.tenant_id,
- created_at: new Date().toISOString(),
- isCreating: false
- }
- });
+ if (response.success && response.data) {
+ const tenantData = response.data;
+
+ updateStepData(currentStep.id, {
+ ...data,
+ bakery: {
+ ...data.bakery,
+ tenant_id: tenantData.id,
+ created_at: tenantData.created_at,
+ isCreating: false
+ }
+ });
+
+ // Update the tenant store with the new tenant
+ setCurrentTenant(tenantData);
+
+ } else {
+ throw new Error(response.error || 'Failed to create tenant');
+ }
} catch (error) {
console.error('Error creating tenant:', error);
updateStepData(currentStep.id, {
...data,
- bakery: { ...data.bakery, isCreating: false }
+ bakery: {
+ ...data.bakery,
+ isCreating: false,
+ creationError: error instanceof Error ? error.message : 'Unknown error'
+ }
});
return;
}
diff --git a/frontend/src/components/domain/onboarding/steps/BakerySetupStep.tsx b/frontend/src/components/domain/onboarding/steps/BakerySetupStep.tsx
index 6a922577..c37c5ba8 100644
--- a/frontend/src/components/domain/onboarding/steps/BakerySetupStep.tsx
+++ b/frontend/src/components/domain/onboarding/steps/BakerySetupStep.tsx
@@ -1,8 +1,21 @@
import React, { useState, useEffect } from 'react';
-import { Store, MapPin, Phone, Mail } from 'lucide-react';
+import { Store, MapPin, Phone, Mail, Hash, Building } from 'lucide-react';
import { Button, Card, Input } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard';
+// Backend-compatible bakery setup interface
+interface BakerySetupData {
+ name: string;
+ business_type: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant';
+ business_model: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery';
+ address: string;
+ city: string;
+ postal_code: string;
+ phone: string;
+ email?: string;
+ description?: string;
+}
+
export const BakerySetupStep: React.FC = ({
data,
onDataChange,
@@ -11,36 +24,39 @@ export const BakerySetupStep: React.FC = ({
isFirstStep,
isLastStep
}) => {
- const [formData, setFormData] = useState({
+ const [formData, setFormData] = useState({
name: data.bakery?.name || '',
- type: data.bakery?.type || '',
- location: data.bakery?.location || '',
+ business_type: data.bakery?.business_type || 'bakery',
+ business_model: data.bakery?.business_model || 'individual_bakery',
+ address: data.bakery?.address || '',
+ city: data.bakery?.city || 'Madrid',
+ postal_code: data.bakery?.postal_code || '',
phone: data.bakery?.phone || '',
email: data.bakery?.email || '',
description: data.bakery?.description || ''
});
- const bakeryTypes = [
+ const bakeryModels = [
{
- value: 'artisan',
- label: 'Panadería Artesanal',
+ value: 'individual_bakery',
+ label: 'Panadería Individual',
description: 'Producción propia tradicional con recetas artesanales',
icon: '🥖'
},
{
- value: 'industrial',
- label: 'Panadería Industrial',
- description: 'Producción a gran escala con procesos automatizados',
+ value: 'central_baker_satellite',
+ label: 'Panadería Central con Satélites',
+ description: 'Producción centralizada con múltiples puntos de venta',
icon: '🏭'
},
{
- value: 'retail',
+ value: 'retail_bakery',
label: 'Panadería Retail',
description: 'Punto de venta que compra productos terminados',
icon: '🏪'
},
{
- value: 'hybrid',
+ value: 'hybrid_bakery',
label: 'Modelo Híbrido',
description: 'Combina producción propia con productos externos',
icon: '🔄'
@@ -57,7 +73,7 @@ export const BakerySetupStep: React.FC = ({
});
}, [formData]);
- const handleInputChange = (field: string, value: string) => {
+ const handleInputChange = (field: keyof BakerySetupData, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
@@ -84,18 +100,18 @@ export const BakerySetupStep: React.FC = ({
/>
- {/* Bakery Type - Simplified */}
+ {/* Business Model Selection */}
- {bakeryTypes.map((type) => (
+ {bakeryModels.map((model) => (
- {/* Location and Contact - Simplified */}
+ {/* Location and Contact - Backend compatible */}
handleInputChange('location', e.target.value)}
+ value={formData.address}
+ onChange={(e) => handleInputChange('address', e.target.value)}
placeholder="Dirección completa de tu panadería"
className="w-full pl-12 py-3"
/>
@@ -145,14 +161,48 @@ export const BakerySetupStep: React.FC
= ({
+
+
+ handleInputChange('city', e.target.value)}
+ placeholder="Madrid"
+ className="w-full pl-10"
+ />
+
+
+
+
+
+
+
+ handleInputChange('postal_code', e.target.value)}
+ placeholder="28001"
+ pattern="[0-9]{5}"
+ className="w-full pl-10"
+ />
+
+
+
+
+
+
+
@@ -177,7 +227,7 @@ export const BakerySetupStep: React.FC
= ({
- {/* Optional: Show loading state when creating tenant */}
+ {/* Show loading state when creating tenant */}
{data.bakery?.isCreating && (
@@ -186,6 +236,18 @@ export const BakerySetupStep: React.FC
= ({
)}
+
+ {/* Show error state if tenant creation fails */}
+ {data.bakery?.creationError && (
+
+
+ Error al crear el espacio de trabajo
+
+
+ {data.bakery.creationError}
+
+
+ )}
);
};
\ No newline at end of file
diff --git a/frontend/src/components/layout/AppShell/AppShell.tsx b/frontend/src/components/layout/AppShell/AppShell.tsx
index 1f2b7dbf..738af9a5 100644
--- a/frontend/src/components/layout/AppShell/AppShell.tsx
+++ b/frontend/src/components/layout/AppShell/AppShell.tsx
@@ -2,6 +2,7 @@ import React, { useState, useCallback, forwardRef } from 'react';
import { clsx } from 'clsx';
import { useAuthUser, useIsAuthenticated } from '../../../stores';
import { useTheme } from '../../../contexts/ThemeContext';
+import { useTenantInitializer } from '../../../hooks/useTenantInitializer';
import { Header } from '../Header';
import { Sidebar } from '../Sidebar';
import { Footer } from '../Footer';
@@ -77,6 +78,9 @@ export const AppShell = forwardRef
(({
const authLoading = false; // Since we're in a protected route, auth loading should be false
const { resolvedTheme } = useTheme();
+ // Initialize tenant data for authenticated users
+ useTenantInitializer();
+
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(initialSidebarCollapsed);
const [error, setError] = useState(null);
diff --git a/frontend/src/components/layout/Header/Header.tsx b/frontend/src/components/layout/Header/Header.tsx
index 114ff102..27a229b4 100644
--- a/frontend/src/components/layout/Header/Header.tsx
+++ b/frontend/src/components/layout/Header/Header.tsx
@@ -3,12 +3,11 @@ import { clsx } from 'clsx';
import { useNavigate } from 'react-router-dom';
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
import { useTheme } from '../../../contexts/ThemeContext';
-import { useBakery } from '../../../contexts/BakeryContext';
import { Button } from '../../ui';
import { Avatar } from '../../ui';
import { Badge } from '../../ui';
import { Modal } from '../../ui';
-import { BakerySelector } from '../../ui/BakerySelector/BakerySelector';
+import { TenantSwitcher } from '../../ui/TenantSwitcher';
import {
Menu,
Search,
@@ -102,7 +101,6 @@ export const Header = forwardRef(({
const isAuthenticated = useIsAuthenticated();
const { logout } = useAuthActions();
const { theme, resolvedTheme, setTheme } = useTheme();
- const { bakeries, currentBakery, selectBakery } = useBakery();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [isSearchFocused, setIsSearchFocused] = useState(false);
@@ -250,35 +248,21 @@ export const Header = forwardRef(({
)}
- {/* Bakery Selector - Desktop */}
- {isAuthenticated && currentBakery && bakeries.length > 0 && (
+ {/* Tenant Switcher - Desktop */}
+ {isAuthenticated && (
- {
- // TODO: Navigate to add bakery page or open modal
- console.log('Add new bakery');
- }}
- size="md"
+
)}
- {/* Bakery Selector - Mobile (in title area) */}
- {isAuthenticated && currentBakery && bakeries.length > 0 && (
+ {/* Tenant Switcher - Mobile (in title area) */}
+ {isAuthenticated && (
- {
- // TODO: Navigate to add bakery page or open modal
- console.log('Add new bakery');
- }}
- size="sm"
+
diff --git a/frontend/src/components/ui/BakerySelector/BakerySelector.tsx b/frontend/src/components/ui/BakerySelector/BakerySelector.tsx
deleted file mode 100644
index 70122a93..00000000
--- a/frontend/src/components/ui/BakerySelector/BakerySelector.tsx
+++ /dev/null
@@ -1,312 +0,0 @@
-import React, { useState, useRef, useEffect } from 'react';
-import { createPortal } from 'react-dom';
-import { clsx } from 'clsx';
-import { ChevronDown, Building, Check, Plus } from 'lucide-react';
-import { Button } from '../Button';
-import { Avatar } from '../Avatar';
-
-interface Bakery {
- id: string;
- name: string;
- logo?: string;
- role: 'owner' | 'manager' | 'baker' | 'staff';
- status: 'active' | 'inactive';
- address?: string;
-}
-
-interface BakerySelectorProps {
- bakeries: Bakery[];
- selectedBakery: Bakery;
- onSelectBakery: (bakery: Bakery) => void;
- onAddBakery?: () => void;
- className?: string;
- size?: 'sm' | 'md' | 'lg';
-}
-
-const roleLabels = {
- owner: 'Propietario',
- manager: 'Gerente',
- baker: 'Panadero',
- staff: 'Personal'
-};
-
-const roleColors = {
- owner: 'text-color-success',
- manager: 'text-color-info',
- baker: 'text-color-warning',
- staff: 'text-text-secondary'
-};
-
-export const BakerySelector: React.FC
= ({
- bakeries,
- selectedBakery,
- onSelectBakery,
- onAddBakery,
- className,
- size = 'md'
-}) => {
- const [isOpen, setIsOpen] = useState(false);
- const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
- const dropdownRef = useRef(null);
- const buttonRef = useRef(null);
-
- // Calculate dropdown position when opening
- useEffect(() => {
- if (isOpen && buttonRef.current) {
- const rect = buttonRef.current.getBoundingClientRect();
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
- const isMobile = viewportWidth < 640; // sm breakpoint
-
- let top = rect.bottom + window.scrollY + 8;
- let left = rect.left + window.scrollX;
- let width = Math.max(rect.width, isMobile ? viewportWidth - 32 : 320); // 16px margin on each side for mobile
-
- // Adjust for mobile - center dropdown with margins
- if (isMobile) {
- left = 16; // 16px margin from left
- width = viewportWidth - 32; // 16px margins on both sides
- } else {
- // Adjust horizontal position to prevent overflow
- const dropdownWidth = Math.max(width, 320);
- if (left + dropdownWidth > viewportWidth - 16) {
- left = viewportWidth - dropdownWidth - 16;
- }
- if (left < 16) {
- left = 16;
- }
- }
-
- // Adjust vertical position if dropdown would overflow bottom
- const dropdownMaxHeight = 320; // Approximate max height
- const headerHeight = 64; // Approximate header height
-
- if (top + dropdownMaxHeight > viewportHeight + window.scrollY - 16) {
- // Try to position above the button
- const topPosition = rect.top + window.scrollY - dropdownMaxHeight - 8;
-
- // Ensure it doesn't go above the header
- if (topPosition < window.scrollY + headerHeight) {
- // If it can't fit above, position it at the top of the visible area
- top = window.scrollY + headerHeight + 8;
- } else {
- top = topPosition;
- }
- }
-
- setDropdownPosition({ top, left, width });
- }
- }, [isOpen]);
-
- // Close dropdown when clicking outside
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (buttonRef.current && !buttonRef.current.contains(event.target as Node) &&
- dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
- setIsOpen(false);
- }
- };
-
- if (isOpen) {
- document.addEventListener('mousedown', handleClickOutside);
- }
-
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
- }, [isOpen]);
-
- // Close on escape key and handle body scroll lock
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- setIsOpen(false);
- buttonRef.current?.focus();
- }
- };
-
- if (isOpen) {
- document.addEventListener('keydown', handleKeyDown);
-
- // Prevent body scroll on mobile when dropdown is open
- const isMobile = window.innerWidth < 640;
- if (isMobile) {
- document.body.style.overflow = 'hidden';
- }
- }
-
- return () => {
- document.removeEventListener('keydown', handleKeyDown);
- // Restore body scroll
- document.body.style.overflow = '';
- };
- }, [isOpen]);
-
- const sizeClasses = {
- sm: 'h-10 px-3 text-sm sm:h-8', // Always at least 40px (10) for better touch targets on mobile
- md: 'h-12 px-4 text-base sm:h-10', // 48px (12) on mobile, 40px on desktop
- lg: 'h-14 px-5 text-lg sm:h-12' // 56px (14) on mobile, 48px on desktop
- };
-
- const avatarSizes = {
- sm: 'sm' as const, // Changed from xs to sm for better mobile visibility
- md: 'sm' as const,
- lg: 'md' as const
- };
-
- const getBakeryInitials = (name: string) => {
- return name
- .split(' ')
- .map(word => word[0])
- .join('')
- .toUpperCase()
- .slice(0, 2);
- };
-
- return (
-
-
-
- {isOpen && createPortal(
- <>
- {/* Mobile backdrop */}
-
setIsOpen(false)}
- />
-
-
-
- Mis Panaderías ({bakeries.length})
-
-
-
- {bakeries.map((bakery) => (
-
- ))}
-
-
- {onAddBakery && (
- <>
-
-
- >
- )}
-
- >,
- document.body
- )}
-
- );
-};
-
-export default BakerySelector;
\ No newline at end of file
diff --git a/frontend/src/components/ui/BakerySelector/index.ts b/frontend/src/components/ui/BakerySelector/index.ts
deleted file mode 100644
index 2f8daa84..00000000
--- a/frontend/src/components/ui/BakerySelector/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { BakerySelector } from './BakerySelector';
-export type { default as BakerySelector } from './BakerySelector';
\ No newline at end of file
diff --git a/frontend/src/components/ui/PasswordCriteria.tsx b/frontend/src/components/ui/PasswordCriteria.tsx
new file mode 100644
index 00000000..84e3752f
--- /dev/null
+++ b/frontend/src/components/ui/PasswordCriteria.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+
+interface PasswordCriteria {
+ label: string;
+ isValid: boolean;
+ regex?: RegExp;
+ checkFn?: (password: string) => boolean;
+}
+
+interface PasswordCriteriaProps {
+ password: string;
+ className?: string;
+ showOnlyFailed?: boolean;
+}
+
+export const PasswordCriteria: React.FC
= ({
+ password,
+ className = '',
+ showOnlyFailed = false
+}) => {
+ const criteria: PasswordCriteria[] = [
+ {
+ label: 'Al menos 8 caracteres',
+ isValid: password.length >= 8,
+ checkFn: (pwd) => pwd.length >= 8
+ },
+ {
+ label: 'Máximo 128 caracteres',
+ isValid: password.length <= 128,
+ checkFn: (pwd) => pwd.length <= 128
+ },
+ {
+ label: 'Al menos una letra mayúscula',
+ isValid: /[A-Z]/.test(password),
+ regex: /[A-Z]/
+ },
+ {
+ label: 'Al menos una letra minúscula',
+ isValid: /[a-z]/.test(password),
+ regex: /[a-z]/
+ },
+ {
+ label: 'Al menos un número',
+ isValid: /\d/.test(password),
+ regex: /\d/
+ }
+ ];
+
+ const validatedCriteria = criteria.map(criterion => ({
+ ...criterion,
+ isValid: criterion.regex
+ ? criterion.regex.test(password)
+ : criterion.checkFn
+ ? criterion.checkFn(password)
+ : false
+ }));
+
+ const displayCriteria = showOnlyFailed
+ ? validatedCriteria.filter(c => !c.isValid)
+ : validatedCriteria;
+
+ if (displayCriteria.length === 0) return null;
+
+ return (
+
+
+ Requisitos de contraseña:
+
+
+ {displayCriteria.map((criterion, index) => (
+ -
+
+ {criterion.isValid ? '✓' : '✗'}
+
+
+ {criterion.label}
+
+
+ ))}
+
+
+ );
+};
+
+export const validatePassword = (password: string): boolean => {
+ return (
+ password.length >= 8 &&
+ password.length <= 128 &&
+ /[A-Z]/.test(password) &&
+ /[a-z]/.test(password) &&
+ /\d/.test(password)
+ );
+};
+
+export const getPasswordErrors = (password: string): string[] => {
+ const errors: string[] = [];
+
+ if (password.length < 8) {
+ errors.push('La contraseña debe tener al menos 8 caracteres');
+ }
+
+ if (password.length > 128) {
+ errors.push('La contraseña no puede exceder 128 caracteres');
+ }
+
+ if (!/[A-Z]/.test(password)) {
+ errors.push('La contraseña debe contener al menos una letra mayúscula');
+ }
+
+ if (!/[a-z]/.test(password)) {
+ errors.push('La contraseña debe contener al menos una letra minúscula');
+ }
+
+ if (!/\d/.test(password)) {
+ errors.push('La contraseña debe contener al menos un número');
+ }
+
+ return errors;
+};
\ No newline at end of file
diff --git a/frontend/src/components/ui/TenantSwitcher.tsx b/frontend/src/components/ui/TenantSwitcher.tsx
new file mode 100644
index 00000000..1715a325
--- /dev/null
+++ b/frontend/src/components/ui/TenantSwitcher.tsx
@@ -0,0 +1,215 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { useTenant } from '../../stores/tenant.store';
+import { useToast } from '../../hooks/ui/useToast';
+import { ChevronDown, Building2, Check, AlertCircle } from 'lucide-react';
+
+interface TenantSwitcherProps {
+ className?: string;
+ showLabel?: boolean;
+}
+
+export const TenantSwitcher: React.FC = ({
+ className = '',
+ showLabel = true,
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+ const buttonRef = useRef(null);
+
+ const {
+ currentTenant,
+ availableTenants,
+ isLoading,
+ error,
+ switchTenant,
+ loadUserTenants,
+ clearError,
+ } = useTenant();
+
+ const { success: showSuccessToast, error: showErrorToast } = useToast();
+
+ // Load tenants on mount
+ useEffect(() => {
+ if (!availableTenants) {
+ loadUserTenants();
+ }
+ }, [availableTenants, loadUserTenants]);
+
+ // Handle click outside to close dropdown
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target as Node) &&
+ !buttonRef.current?.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isOpen]);
+
+ // Handle tenant switch
+ const handleTenantSwitch = async (tenantId: string) => {
+ if (tenantId === currentTenant?.id) {
+ setIsOpen(false);
+ return;
+ }
+
+ const success = await switchTenant(tenantId);
+ setIsOpen(false);
+
+ if (success) {
+ const newTenant = availableTenants?.find(t => t.id === tenantId);
+ showSuccessToast(`Switched to ${newTenant?.name}`, {
+ title: 'Tenant Switched'
+ });
+ } else {
+ showErrorToast(error || 'Failed to switch tenant', {
+ title: 'Switch Failed'
+ });
+ }
+ };
+
+ // Handle retry loading tenants
+ const handleRetry = () => {
+ clearError();
+ loadUserTenants();
+ };
+
+ // Don't render if no tenants available
+ if (!availableTenants || availableTenants.length === 0) {
+ return null;
+ }
+
+ // Don't render if only one tenant
+ if (availableTenants.length === 1) {
+ return showLabel ? (
+
+
+ {currentTenant?.name}
+
+ ) : null;
+ }
+
+ return (
+
+ {/* Trigger Button */}
+
+
+ {/* Dropdown Menu */}
+ {isOpen && (
+
+ {/* Header */}
+
+
Switch Organization
+
+ Select the organization you want to work with
+
+
+
+ {/* Error State */}
+ {error && (
+
+
+
+
{error}
+
+
+
+ )}
+
+ {/* Tenant List */}
+
+ {availableTenants.map((tenant) => (
+
+ ))}
+
+
+ {/* Footer */}
+
+
+ Need to add a new organization?{' '}
+
+
+
+
+ )}
+
+ {/* Loading Overlay */}
+ {isLoading && (
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts
index a4b6f0ae..ee60e900 100644
--- a/frontend/src/components/ui/index.ts
+++ b/frontend/src/components/ui/index.ts
@@ -16,7 +16,7 @@ export { ListItem } from './ListItem';
export { StatsCard, StatsGrid } from './Stats';
export { StatusCard, getStatusColor } from './StatusCard';
export { StatusModal } from './StatusModal';
-export { BakerySelector } from './BakerySelector';
+export { TenantSwitcher } from './TenantSwitcher';
// Export types
export type { ButtonProps } from './Button';
diff --git a/frontend/src/config/mock.config.ts b/frontend/src/config/mock.config.ts
index d830eef0..94f73e6f 100644
--- a/frontend/src/config/mock.config.ts
+++ b/frontend/src/config/mock.config.ts
@@ -8,9 +8,9 @@ export const MOCK_CONFIG = {
MOCK_MODE: true,
// Component-specific toggles
- MOCK_REGISTRATION: true,
- MOCK_AUTHENTICATION: true,
- MOCK_ONBOARDING_FLOW: true,
+ MOCK_REGISTRATION: false, // Now using real backend
+ MOCK_AUTHENTICATION: false, // Now using real backend
+ MOCK_ONBOARDING_FLOW: true, // Keep onboarding mock for now
// Mock user data
MOCK_USER: {
diff --git a/frontend/src/contexts/BakeryContext.tsx b/frontend/src/contexts/BakeryContext.tsx
deleted file mode 100644
index 84a42b39..00000000
--- a/frontend/src/contexts/BakeryContext.tsx
+++ /dev/null
@@ -1,359 +0,0 @@
-import React, { createContext, useContext, useReducer, useEffect, ReactNode } from 'react';
-
-export interface Bakery {
- id: string;
- name: string;
- logo?: string;
- role: 'owner' | 'manager' | 'baker' | 'staff';
- status: 'active' | 'inactive';
- address?: string;
- description?: string;
- settings?: {
- timezone: string;
- currency: string;
- language: string;
- };
- permissions?: string[];
-}
-
-interface BakeryState {
- bakeries: Bakery[];
- currentBakery: Bakery | null;
- isLoading: boolean;
- error: string | null;
-}
-
-type BakeryAction =
- | { type: 'SET_LOADING'; payload: boolean }
- | { type: 'SET_ERROR'; payload: string | null }
- | { type: 'SET_BAKERIES'; payload: Bakery[] }
- | { type: 'SET_CURRENT_BAKERY'; payload: Bakery }
- | { type: 'ADD_BAKERY'; payload: Bakery }
- | { type: 'UPDATE_BAKERY'; payload: { id: string; updates: Partial } }
- | { type: 'REMOVE_BAKERY'; payload: string };
-
-const initialState: BakeryState = {
- bakeries: [],
- currentBakery: null,
- isLoading: false,
- error: null,
-};
-
-// Mock bakeries data
-const mockBakeries: Bakery[] = [
- {
- id: '1',
- name: 'Panadería San Miguel',
- logo: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=100&h=100&fit=crop&crop=center',
- role: 'owner',
- status: 'active',
- address: 'Calle Mayor 123, Madrid, 28013',
- description: 'Panadería tradicional familiar con más de 30 años de experiencia.',
- settings: {
- timezone: 'Europe/Madrid',
- currency: 'EUR',
- language: 'es',
- },
- permissions: ['*'],
- },
- {
- id: '2',
- name: 'Panadería La Artesana',
- logo: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?w=100&h=100&fit=crop&crop=center',
- role: 'manager',
- status: 'active',
- address: 'Avenida de la Paz 45, Barcelona, 08001',
- description: 'Panadería artesanal especializada en panes tradicionales catalanes.',
- settings: {
- timezone: 'Europe/Madrid',
- currency: 'EUR',
- language: 'ca',
- },
- permissions: ['inventory:*', 'production:*', 'sales:*', 'reports:read'],
- },
- {
- id: '3',
- name: 'Pan y Masa',
- logo: 'https://images.unsplash.com/photo-1524704654690-b56c05c78a00?w=100&h=100&fit=crop&crop=center',
- role: 'baker',
- status: 'active',
- address: 'Plaza Central 8, Valencia, 46001',
- description: 'Panadería moderna con enfoque en productos orgánicos.',
- settings: {
- timezone: 'Europe/Madrid',
- currency: 'EUR',
- language: 'es',
- },
- permissions: ['production:*', 'inventory:read', 'inventory:update'],
- },
- {
- id: '4',
- name: 'Horno Dorado',
- role: 'staff',
- status: 'inactive',
- address: 'Calle del Sol 67, Sevilla, 41001',
- description: 'Panadería tradicional andaluza.',
- settings: {
- timezone: 'Europe/Madrid',
- currency: 'EUR',
- language: 'es',
- },
- permissions: ['inventory:read', 'sales:read'],
- },
-];
-
-function bakeryReducer(state: BakeryState, action: BakeryAction): BakeryState {
- switch (action.type) {
- case 'SET_LOADING':
- return { ...state, isLoading: action.payload };
-
- case 'SET_ERROR':
- return { ...state, error: action.payload, isLoading: false };
-
- case 'SET_BAKERIES':
- return {
- ...state,
- bakeries: action.payload,
- currentBakery: state.currentBakery || action.payload[0] || null,
- isLoading: false,
- error: null
- };
-
- case 'SET_CURRENT_BAKERY':
- // Save to localStorage for persistence
- localStorage.setItem('selectedBakery', action.payload.id);
- return { ...state, currentBakery: action.payload };
-
- case 'ADD_BAKERY':
- return {
- ...state,
- bakeries: [...state.bakeries, action.payload]
- };
-
- case 'UPDATE_BAKERY':
- return {
- ...state,
- bakeries: state.bakeries.map(bakery =>
- bakery.id === action.payload.id
- ? { ...bakery, ...action.payload.updates }
- : bakery
- ),
- currentBakery: state.currentBakery?.id === action.payload.id
- ? { ...state.currentBakery, ...action.payload.updates }
- : state.currentBakery
- };
-
- case 'REMOVE_BAKERY':
- const remainingBakeries = state.bakeries.filter(b => b.id !== action.payload);
- return {
- ...state,
- bakeries: remainingBakeries,
- currentBakery: state.currentBakery?.id === action.payload
- ? remainingBakeries[0] || null
- : state.currentBakery
- };
-
- default:
- return state;
- }
-}
-
-interface BakeryContextType extends BakeryState {
- selectBakery: (bakery: Bakery) => void;
- addBakery: (bakery: Omit) => Promise;
- updateBakery: (id: string, updates: Partial) => Promise;
- removeBakery: (id: string) => Promise;
- refreshBakeries: () => Promise;
- hasPermission: (permission: string) => boolean;
- canAccess: (resource: string, action: string) => boolean;
-}
-
-const BakeryContext = createContext(undefined);
-
-interface BakeryProviderProps {
- children: ReactNode;
-}
-
-export function BakeryProvider({ children }: BakeryProviderProps) {
- const [state, dispatch] = useReducer(bakeryReducer, initialState);
-
- // Load bakeries on mount
- useEffect(() => {
- loadBakeries();
- }, []);
-
- // Load saved bakery selection
- useEffect(() => {
- if (state.bakeries.length > 0 && !state.currentBakery) {
- const savedBakeryId = localStorage.getItem('selectedBakery');
- const savedBakery = savedBakeryId
- ? state.bakeries.find(b => b.id === savedBakeryId)
- : null;
-
- if (savedBakery) {
- dispatch({ type: 'SET_CURRENT_BAKERY', payload: savedBakery });
- } else if (state.bakeries[0]) {
- dispatch({ type: 'SET_CURRENT_BAKERY', payload: state.bakeries[0] });
- }
- }
- }, [state.bakeries, state.currentBakery]);
-
- const loadBakeries = async () => {
- try {
- dispatch({ type: 'SET_LOADING', payload: true });
-
- // Simulate API call delay
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- // In a real app, this would be an API call
- dispatch({ type: 'SET_BAKERIES', payload: mockBakeries });
- } catch (error) {
- dispatch({
- type: 'SET_ERROR',
- payload: error instanceof Error ? error.message : 'Error loading bakeries'
- });
- }
- };
-
- const selectBakery = (bakery: Bakery) => {
- dispatch({ type: 'SET_CURRENT_BAKERY', payload: bakery });
- };
-
- const addBakery = async (bakeryData: Omit) => {
- try {
- dispatch({ type: 'SET_LOADING', payload: true });
-
- // Simulate API call
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- const newBakery: Bakery = {
- ...bakeryData,
- id: Date.now().toString(), // In real app, this would come from the API
- };
-
- dispatch({ type: 'ADD_BAKERY', payload: newBakery });
- dispatch({ type: 'SET_LOADING', payload: false });
- } catch (error) {
- dispatch({
- type: 'SET_ERROR',
- payload: error instanceof Error ? error.message : 'Error adding bakery'
- });
- }
- };
-
- const updateBakery = async (id: string, updates: Partial) => {
- try {
- dispatch({ type: 'SET_LOADING', payload: true });
-
- // Simulate API call
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- dispatch({ type: 'UPDATE_BAKERY', payload: { id, updates } });
- dispatch({ type: 'SET_LOADING', payload: false });
- } catch (error) {
- dispatch({
- type: 'SET_ERROR',
- payload: error instanceof Error ? error.message : 'Error updating bakery'
- });
- }
- };
-
- const removeBakery = async (id: string) => {
- try {
- dispatch({ type: 'SET_LOADING', payload: true });
-
- // Simulate API call
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- dispatch({ type: 'REMOVE_BAKERY', payload: id });
- dispatch({ type: 'SET_LOADING', payload: false });
- } catch (error) {
- dispatch({
- type: 'SET_ERROR',
- payload: error instanceof Error ? error.message : 'Error removing bakery'
- });
- }
- };
-
- const refreshBakeries = async () => {
- await loadBakeries();
- };
-
- const hasPermission = (permission: string): boolean => {
- if (!state.currentBakery) return false;
-
- const permissions = state.currentBakery.permissions || [];
-
- // Admin/owner has all permissions
- if (permissions.includes('*')) return true;
-
- return permissions.includes(permission);
- };
-
- const canAccess = (resource: string, action: string): boolean => {
- if (!state.currentBakery) return false;
-
- // Check specific permission
- if (hasPermission(`${resource}:${action}`)) return true;
-
- // Check wildcard permissions
- if (hasPermission(`${resource}:*`)) return true;
-
- // Role-based access fallback
- switch (state.currentBakery.role) {
- case 'owner':
- return true;
- case 'manager':
- return ['inventory', 'production', 'sales', 'reports'].includes(resource);
- case 'baker':
- return ['production', 'inventory'].includes(resource) &&
- ['read', 'update'].includes(action);
- case 'staff':
- return ['inventory', 'sales'].includes(resource) &&
- action === 'read';
- default:
- return false;
- }
- };
-
- const value: BakeryContextType = {
- ...state,
- selectBakery,
- addBakery,
- updateBakery,
- removeBakery,
- refreshBakeries,
- hasPermission,
- canAccess,
- };
-
- return (
-
- {children}
-
- );
-}
-
-export function useBakery(): BakeryContextType {
- const context = useContext(BakeryContext);
- if (context === undefined) {
- throw new Error('useBakery must be used within a BakeryProvider');
- }
- return context;
-}
-
-// Convenience hooks
-export const useCurrentBakery = () => {
- const { currentBakery } = useBakery();
- return currentBakery;
-};
-
-export const useBakeries = () => {
- const { bakeries } = useBakery();
- return bakeries;
-};
-
-export const useBakeryPermissions = () => {
- const { hasPermission, canAccess } = useBakery();
- return { hasPermission, canAccess };
-};
\ No newline at end of file
diff --git a/frontend/src/hooks/useTenantInitializer.ts b/frontend/src/hooks/useTenantInitializer.ts
new file mode 100644
index 00000000..aa8c32cb
--- /dev/null
+++ b/frontend/src/hooks/useTenantInitializer.ts
@@ -0,0 +1,27 @@
+import { useEffect } from 'react';
+import { useIsAuthenticated } from '../stores/auth.store';
+import { useTenantActions, useAvailableTenants } from '../stores/tenant.store';
+
+/**
+ * Hook to automatically initialize tenant data when user is authenticated
+ * This should be used at the app level to ensure tenant data is loaded
+ */
+export const useTenantInitializer = () => {
+ const isAuthenticated = useIsAuthenticated();
+ const availableTenants = useAvailableTenants();
+ const { loadUserTenants } = useTenantActions();
+
+ useEffect(() => {
+ if (isAuthenticated && !availableTenants) {
+ // Load user's available tenants when authenticated and not already loaded
+ loadUserTenants();
+ }
+ }, [isAuthenticated, availableTenants, loadUserTenants]);
+
+ // Also load tenants when user becomes authenticated (e.g., after login)
+ useEffect(() => {
+ if (isAuthenticated && availableTenants === null) {
+ loadUserTenants();
+ }
+ }, [isAuthenticated, availableTenants, loadUserTenants]);
+};
\ No newline at end of file
diff --git a/frontend/src/pages/app/onboarding/OnboardingPage.tsx b/frontend/src/pages/app/onboarding/OnboardingPage.tsx
index 566aec21..8afe271a 100644
--- a/frontend/src/pages/app/onboarding/OnboardingPage.tsx
+++ b/frontend/src/pages/app/onboarding/OnboardingPage.tsx
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { OnboardingWizard, OnboardingStep } from '../../../components/domain/onboarding/OnboardingWizard';
import { onboardingApiService } from '../../../services/api/onboarding.service';
-import { useAuth } from '../../../hooks/useAuth';
+import { useAuthUser, useIsAuthenticated } from '../../../stores/auth.store';
import { LoadingSpinner } from '../../../components/shared/LoadingSpinner';
// Step Components
@@ -16,7 +16,8 @@ import { CompletionStep } from '../../../components/domain/onboarding/steps/Comp
const OnboardingPage: React.FC = () => {
const navigate = useNavigate();
- const { user } = useAuth();
+ const user = useAuthUser();
+ const isAuthenticated = useIsAuthenticated();
const [isLoading, setIsLoading] = useState(false);
const [globalData, setGlobalData] = useState({});
@@ -30,8 +31,11 @@ const OnboardingPage: React.FC = () => {
isRequired: true,
validation: (data) => {
if (!data.bakery?.name) return 'El nombre de la panadería es requerido';
- if (!data.bakery?.type) return 'El tipo de panadería es requerido';
- if (!data.bakery?.location) return 'La ubicación es requerida';
+ if (!data.bakery?.business_model) return 'El modelo de negocio es requerido';
+ if (!data.bakery?.address) return 'La dirección es requerida';
+ if (!data.bakery?.city) return 'La ciudad es requerida';
+ if (!data.bakery?.postal_code) return 'El código postal es requerido';
+ if (!data.bakery?.phone) return 'El teléfono es requerido';
// Tenant creation will happen automatically when validation passes
return null;
}
@@ -142,10 +146,27 @@ const OnboardingPage: React.FC = () => {
}
};
+ // Redirect to login if not authenticated
+ useEffect(() => {
+ if (!isAuthenticated) {
+ navigate('/login', {
+ state: {
+ message: 'Debes iniciar sesión para acceder al onboarding.',
+ returnUrl: '/app/onboarding'
+ }
+ });
+ }
+ }, [isAuthenticated, navigate]);
+
if (isLoading) {
return ;
}
+ // Don't render if not authenticated (will redirect)
+ if (!isAuthenticated || !user) {
+ return ;
+ }
+
return (
= ({ plans, currentPlan, onU
};
const SubscriptionPage: React.FC = () => {
- const { user, tenant_id } = useAuth();
- const { currentTenant } = useBakeryStore();
+ const user = useAuthUser();
+ const currentTenant = useCurrentTenant();
const toast = useToast();
const [usageSummary, setUsageSummary] = useState(null);
const [availablePlans, setAvailablePlans] = useState(null);
@@ -249,13 +249,13 @@ const SubscriptionPage: React.FC = () => {
const [upgrading, setUpgrading] = useState(false);
useEffect(() => {
- if (currentTenant?.id || tenant_id || isMockMode()) {
+ if (currentTenant?.id || user?.tenant_id || isMockMode()) {
loadSubscriptionData();
}
- }, [currentTenant, tenant_id]);
+ }, [currentTenant, user?.tenant_id]);
const loadSubscriptionData = async () => {
- let tenantId = currentTenant?.id || tenant_id;
+ let tenantId = currentTenant?.id || user?.tenant_id;
// In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) {
@@ -290,7 +290,7 @@ const SubscriptionPage: React.FC = () => {
};
const handleUpgradeConfirm = async () => {
- let tenantId = currentTenant?.id || tenant_id;
+ let tenantId = currentTenant?.id || user?.tenant_id;
// In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) {
diff --git a/frontend/src/pages/app/settings/subscription/SubscriptionPageOld.tsx b/frontend/src/pages/app/settings/subscription/SubscriptionPageOld.tsx
index 8ea0cc22..e9035c34 100644
--- a/frontend/src/pages/app/settings/subscription/SubscriptionPageOld.tsx
+++ b/frontend/src/pages/app/settings/subscription/SubscriptionPageOld.tsx
@@ -31,7 +31,7 @@ import {
ExternalLink
} from 'lucide-react';
import { useAuth } from '../../../../hooks/api/useAuth';
-import { useBakeryStore } from '../../../../stores/bakery.store';
+import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useToast } from '../../../../hooks/ui/useToast';
import {
subscriptionService,
@@ -287,7 +287,7 @@ const PlanComparison: React.FC = ({ plans, currentPlan, onU
const SubscriptionPage: React.FC = () => {
const { user, tenant_id } = useAuth();
- const { currentTenant } = useBakeryStore();
+ const currentTenant = useCurrentTenant();
const toast = useToast();
const [usageSummary, setUsageSummary] = useState(null);
const [availablePlans, setAvailablePlans] = useState(null);
diff --git a/frontend/src/router/ProtectedRoute.tsx b/frontend/src/router/ProtectedRoute.tsx
index f67e11f8..36b9fad3 100644
--- a/frontend/src/router/ProtectedRoute.tsx
+++ b/frontend/src/router/ProtectedRoute.tsx
@@ -6,7 +6,6 @@ import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores';
import { RouteConfig, canAccessRoute, ROUTES } from './routes.config';
-import { isMockAuthentication } from '../config/mock.config';
interface ProtectedRouteProps {
children: React.ReactNode;
@@ -129,12 +128,8 @@ export const ProtectedRoute: React.FC = ({
const isLoading = useAuthLoading();
const location = useLocation();
- // MOCK MODE - Allow access to onboarding routes for testing
- const isOnboardingRoute = location.pathname.startsWith('/app/onboarding');
-
- if (isMockAuthentication() && isOnboardingRoute) {
- return <>{children}>;
- }
+ // Note: Onboarding routes are now properly protected and require authentication
+ // Mock mode only applies to the onboarding flow content, not to route protection
// Show loading spinner while checking authentication
if (isLoading) {
diff --git a/frontend/src/services/api/auth.service.ts b/frontend/src/services/api/auth.service.ts
index f4fc6986..312478a4 100644
--- a/frontend/src/services/api/auth.service.ts
+++ b/frontend/src/services/api/auth.service.ts
@@ -86,7 +86,13 @@ class AuthService {
// Authentication endpoints
async register(userData: UserRegistration): Promise> {
- return apiClient.post(`${this.baseUrl}/register`, userData);
+ const response = await apiClient.post(`${this.baseUrl}/register`, userData);
+
+ if (response.success && response.data) {
+ this.handleSuccessfulAuth(response.data);
+ }
+
+ return response;
}
async login(credentials: UserLogin): Promise> {
@@ -164,54 +170,70 @@ class AuthService {
return apiClient.post(`${this.baseUrl}/verify-email/confirm`, { token });
}
- // Local auth state management
+ // Local auth state management - Now handled by Zustand store
private handleSuccessfulAuth(tokenData: TokenResponse) {
- localStorage.setItem('access_token', tokenData.access_token);
-
- if (tokenData.refresh_token) {
- localStorage.setItem('refresh_token', tokenData.refresh_token);
- }
-
- if (tokenData.user) {
- localStorage.setItem('user_data', JSON.stringify(tokenData.user));
-
- if (tokenData.user.tenant_id) {
- localStorage.setItem('tenant_id', tokenData.user.tenant_id);
- apiClient.setTenantId(tokenData.user.tenant_id);
- }
- }
-
+ // Set auth token for API client
apiClient.setAuthToken(tokenData.access_token);
+
+ // Set tenant ID for API client if available
+ if (tokenData.user?.tenant_id) {
+ apiClient.setTenantId(tokenData.user.tenant_id);
+ }
}
private clearAuthData() {
- localStorage.removeItem('access_token');
- localStorage.removeItem('refresh_token');
- localStorage.removeItem('user_data');
- localStorage.removeItem('tenant_id');
+ // Clear API client tokens
apiClient.removeAuthToken();
}
- // Utility methods
+ // Utility methods - Now get data from Zustand store
isAuthenticated(): boolean {
- return !!localStorage.getItem('access_token');
+ const authStorage = localStorage.getItem('auth-storage');
+ if (!authStorage) return false;
+ try {
+ const { state } = JSON.parse(authStorage);
+ return state?.isAuthenticated || false;
+ } catch {
+ return false;
+ }
}
getCurrentUserData(): UserData | null {
- const userData = localStorage.getItem('user_data');
- return userData ? JSON.parse(userData) : null;
+ const authStorage = localStorage.getItem('auth-storage');
+ if (!authStorage) return null;
+ try {
+ const { state } = JSON.parse(authStorage);
+ return state?.user || null;
+ } catch {
+ return null;
+ }
}
getAccessToken(): string | null {
- return localStorage.getItem('access_token');
+ const authStorage = localStorage.getItem('auth-storage');
+ if (!authStorage) return null;
+ try {
+ const { state } = JSON.parse(authStorage);
+ return state?.token || null;
+ } catch {
+ return null;
+ }
}
getRefreshToken(): string | null {
- return localStorage.getItem('refresh_token');
+ const authStorage = localStorage.getItem('auth-storage');
+ if (!authStorage) return null;
+ try {
+ const { state } = JSON.parse(authStorage);
+ return state?.refreshToken || null;
+ } catch {
+ return null;
+ }
}
getTenantId(): string | null {
- return localStorage.getItem('tenant_id');
+ const userData = this.getCurrentUserData();
+ return userData?.tenant_id || null;
}
// Check if token is expired (basic check)
diff --git a/frontend/src/services/api/client.ts b/frontend/src/services/api/client.ts
index 1d0d9f45..3c49b743 100644
--- a/frontend/src/services/api/client.ts
+++ b/frontend/src/services/api/client.ts
@@ -1,5 +1,32 @@
import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
+// Utility functions to access auth and tenant store data from localStorage
+const getAuthData = () => {
+ const authStorage = localStorage.getItem('auth-storage');
+ if (!authStorage) return null;
+ try {
+ const { state } = JSON.parse(authStorage);
+ return state;
+ } catch {
+ return null;
+ }
+};
+
+const getTenantData = () => {
+ const tenantStorage = localStorage.getItem('tenant-storage');
+ if (!tenantStorage) return null;
+ try {
+ const { state } = JSON.parse(tenantStorage);
+ return state;
+ } catch {
+ return null;
+ }
+};
+
+const clearAuthData = () => {
+ localStorage.removeItem('auth-storage');
+};
+
export interface ApiResponse {
data: T;
success: boolean;
@@ -41,12 +68,15 @@ class ApiClient {
// Request interceptor - add auth token and tenant ID
this.axiosInstance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
- const token = localStorage.getItem('access_token');
- if (token) {
- config.headers.Authorization = `Bearer ${token}`;
+ const authData = getAuthData();
+ if (authData?.token) {
+ config.headers.Authorization = `Bearer ${authData.token}`;
}
- const tenantId = localStorage.getItem('tenant_id');
+ // Get tenant ID from tenant store (priority) or fallback to user's tenant_id
+ const tenantData = getTenantData();
+ const tenantId = tenantData?.currentTenant?.id || authData?.user?.tenant_id;
+
if (tenantId) {
config.headers['X-Tenant-ID'] = tenantId;
}
@@ -67,8 +97,8 @@ class ApiClient {
originalRequest._retry = true;
try {
- const refreshToken = localStorage.getItem('refresh_token');
- if (refreshToken) {
+ const authData = getAuthData();
+ if (authData?.refreshToken) {
const newToken = await this.refreshToken();
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return this.axiosInstance(originalRequest);
@@ -113,29 +143,34 @@ class ApiClient {
}
private async performTokenRefresh(): Promise {
- const refreshToken = localStorage.getItem('refresh_token');
- if (!refreshToken) {
+ const authData = getAuthData();
+ if (!authData?.refreshToken) {
throw new Error('No refresh token available');
}
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
- refresh_token: refreshToken,
+ refresh_token: authData.refreshToken,
});
const { access_token, refresh_token } = response.data;
- localStorage.setItem('access_token', access_token);
- if (refresh_token) {
- localStorage.setItem('refresh_token', refresh_token);
- }
+
+ // Update the Zustand store by modifying the auth-storage directly
+ const newAuthData = {
+ ...authData,
+ token: access_token,
+ refreshToken: refresh_token || authData.refreshToken
+ };
+
+ localStorage.setItem('auth-storage', JSON.stringify({
+ state: newAuthData,
+ version: 0
+ }));
return access_token;
}
private handleAuthFailure() {
- localStorage.removeItem('access_token');
- localStorage.removeItem('refresh_token');
- localStorage.removeItem('user_data');
- localStorage.removeItem('tenant_id');
+ clearAuthData();
// Redirect to login
window.location.href = '/login';
@@ -197,19 +232,16 @@ class ApiClient {
};
}
- // Utility methods
+ // Utility methods - Now work with Zustand store
setAuthToken(token: string) {
- localStorage.setItem('access_token', token);
this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
removeAuthToken() {
- localStorage.removeItem('access_token');
delete this.axiosInstance.defaults.headers.common['Authorization'];
}
setTenantId(tenantId: string) {
- localStorage.setItem('tenant_id', tenantId);
this.axiosInstance.defaults.headers.common['X-Tenant-ID'] = tenantId;
}
diff --git a/frontend/src/services/api/tenant.service.ts b/frontend/src/services/api/tenant.service.ts
index 45db6aa4..b7179de7 100644
--- a/frontend/src/services/api/tenant.service.ts
+++ b/frontend/src/services/api/tenant.service.ts
@@ -1,6 +1,6 @@
import { apiClient, ApiResponse } from './client';
-// Request/Response Types based on backend schemas
+// Request/Response Types based on backend schemas - UPDATED TO MATCH BACKEND
export interface BakeryRegistration {
name: string;
address: string;
@@ -101,7 +101,7 @@ class TenantService {
// Tenant CRUD operations
async createTenant(tenantData: BakeryRegistration): Promise> {
- return apiClient.post(`${this.baseUrl}`, tenantData);
+ return apiClient.post(`${this.baseUrl}/register`, tenantData);
}
async getTenant(tenantId: string): Promise> {
@@ -151,15 +151,31 @@ class TenantService {
}
async switchTenant(tenantId: string): Promise> {
- const response = await apiClient.post(`${this.baseUrl}/${tenantId}/switch`);
-
- if (response.success && response.data?.tenant) {
- // Update local tenant context
- localStorage.setItem('tenant_id', tenantId);
- apiClient.setTenantId(tenantId);
+ // Frontend-only tenant switching since backend doesn't have this endpoint
+ // We'll simulate the response and update the tenant store directly
+ try {
+ const tenant = await this.getTenant(tenantId);
+ if (tenant.success) {
+ // Update API client tenant context
+ apiClient.setTenantId(tenantId);
+
+ return {
+ success: true,
+ data: {
+ message: 'Tenant switched successfully',
+ tenant: tenant.data
+ }
+ };
+ } else {
+ throw new Error('Tenant not found');
+ }
+ } catch (error) {
+ return {
+ success: false,
+ data: null,
+ error: error instanceof Error ? error.message : 'Failed to switch tenant'
+ };
}
-
- return response;
}
// Member management
@@ -228,33 +244,33 @@ class TenantService {
}
// Utility methods
- async getUserTenants(): Promise> {
- return apiClient.get(`${this.baseUrl}/my-tenants`);
+ async getUserTenants(userId?: string): Promise> {
+ // If no userId provided, we'll get it from the auth store/token
+ return apiClient.get(`${this.baseUrl}/users/${userId || 'current'}`);
}
async validateTenantSlug(slug: string): Promise> {
return apiClient.get(`${this.baseUrl}/validate-slug/${slug}`);
}
- // Local state management helpers
+ // Local state management helpers - Now uses tenant store
getCurrentTenantId(): string | null {
- return localStorage.getItem('tenant_id');
+ // This will be handled by the tenant store
+ return null;
}
getCurrentTenantData(): TenantResponse | null {
- const tenantData = localStorage.getItem('tenant_data');
- return tenantData ? JSON.parse(tenantData) : null;
+ // This will be handled by the tenant store
+ return null;
}
setCurrentTenant(tenant: TenantResponse) {
- localStorage.setItem('tenant_id', tenant.id);
- localStorage.setItem('tenant_data', JSON.stringify(tenant));
+ // This will be handled by the tenant store
apiClient.setTenantId(tenant.id);
}
clearCurrentTenant() {
- localStorage.removeItem('tenant_id');
- localStorage.removeItem('tenant_data');
+ // This will be handled by the tenant store
}
// Business type helpers
diff --git a/frontend/src/stores/auth.store.ts b/frontend/src/stores/auth.store.ts
index 9444c376..2bb1293a 100644
--- a/frontend/src/stores/auth.store.ts
+++ b/frontend/src/stores/auth.store.ts
@@ -4,19 +4,16 @@ import { persist, createJSONStorage } from 'zustand/middleware';
export interface User {
id: string;
email: string;
- name: string;
- role: 'admin' | 'manager' | 'baker' | 'staff';
- permissions: string[];
- tenantId: string;
- tenantName: string;
- avatar?: string;
- lastLogin?: string;
- preferences?: {
- language: string;
- timezone: string;
- theme: 'light' | 'dark';
- notifications: boolean;
- };
+ full_name: string; // Updated to match backend
+ is_active: boolean;
+ is_verified: boolean;
+ created_at: string;
+ last_login?: string;
+ phone?: string;
+ language?: string;
+ timezone?: string;
+ tenant_id?: string;
+ role?: string;
}
export interface AuthState {
@@ -30,6 +27,7 @@ export interface AuthState {
// Actions
login: (email: string, password: string) => Promise;
+ register: (userData: { email: string; password: string; full_name: string; tenant_name?: string }) => Promise;
logout: () => void;
refreshAuth: () => Promise;
updateUser: (updates: Partial) => void;
@@ -42,50 +40,7 @@ export interface AuthState {
canAccess: (resource: string, action: string) => boolean;
}
-// Mock API functions (replace with actual API calls)
-const mockLogin = async (email: string, password: string): Promise<{ user: User; token: string; refreshToken: string }> => {
- // Simulate API delay
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- if (email === 'admin@bakery.com' && password === 'admin12345') {
- return {
- user: {
- id: '1',
- email: 'admin@bakery.com',
- name: 'Admin User',
- role: 'admin',
- permissions: ['*'],
- tenantId: 'tenant-1',
- tenantName: 'Panadería San Miguel',
- avatar: undefined,
- lastLogin: new Date().toISOString(),
- preferences: {
- language: 'es',
- timezone: 'Europe/Madrid',
- theme: 'light',
- notifications: true,
- },
- },
- token: 'mock-jwt-token',
- refreshToken: 'mock-refresh-token',
- };
- }
-
- throw new Error('Credenciales inválidas');
-};
-
-const mockRefreshToken = async (refreshToken: string): Promise<{ token: string; refreshToken: string }> => {
- await new Promise(resolve => setTimeout(resolve, 500));
-
- if (refreshToken === 'mock-refresh-token') {
- return {
- token: 'new-mock-jwt-token',
- refreshToken: 'new-mock-refresh-token',
- };
- }
-
- throw new Error('Invalid refresh token');
-};
+import { authService } from '../services/api/auth.service';
export const useAuthStore = create()(
persist(
@@ -103,16 +58,20 @@ export const useAuthStore = create()(
try {
set({ isLoading: true, error: null });
- const response = await mockLogin(email, password);
+ const response = await authService.login({ email, password });
- set({
- user: response.user,
- token: response.token,
- refreshToken: response.refreshToken,
- isAuthenticated: true,
- isLoading: false,
- error: null,
- });
+ if (response.success && response.data) {
+ set({
+ user: response.data.user || null,
+ token: response.data.access_token,
+ refreshToken: response.data.refresh_token || null,
+ isAuthenticated: true,
+ isLoading: false,
+ error: null,
+ });
+ } else {
+ throw new Error('Login failed');
+ }
} catch (error) {
set({
user: null,
@@ -126,6 +85,37 @@ export const useAuthStore = create()(
}
},
+ register: async (userData: { email: string; password: string; full_name: string; tenant_name?: string }) => {
+ try {
+ set({ isLoading: true, error: null });
+
+ const response = await authService.register(userData);
+
+ if (response.success && response.data) {
+ set({
+ user: response.data.user || null,
+ token: response.data.access_token,
+ refreshToken: response.data.refresh_token || null,
+ isAuthenticated: true,
+ isLoading: false,
+ error: null,
+ });
+ } else {
+ throw new Error('Registration failed');
+ }
+ } catch (error) {
+ set({
+ user: null,
+ token: null,
+ refreshToken: null,
+ isAuthenticated: false,
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Error de registro',
+ });
+ throw error;
+ }
+ },
+
logout: () => {
set({
user: null,
@@ -146,14 +136,18 @@ export const useAuthStore = create()(
set({ isLoading: true });
- const response = await mockRefreshToken(refreshToken);
+ const response = await authService.refreshToken(refreshToken);
- set({
- token: response.token,
- refreshToken: response.refreshToken,
- isLoading: false,
- error: null,
- });
+ if (response.success && response.data) {
+ set({
+ token: response.data.access_token,
+ refreshToken: response.data.refresh_token || refreshToken,
+ isLoading: false,
+ error: null,
+ });
+ } else {
+ throw new Error('Token refresh failed');
+ }
} catch (error) {
set({
user: null,
@@ -184,15 +178,16 @@ export const useAuthStore = create()(
set({ isLoading: loading });
},
- // Permission helpers
- hasPermission: (permission: string): boolean => {
+ // Permission helpers - Simplified for backend compatibility
+ hasPermission: (_permission: string): boolean => {
const { user } = get();
- if (!user) return false;
+ if (!user || !user.is_active) return false;
// Admin has all permissions
- if (user.permissions.includes('*')) return true;
+ if (user.role === 'admin') return true;
- return user.permissions.includes(permission);
+ // Basic role-based permissions
+ return false;
},
hasRole: (role: string): boolean => {
@@ -201,28 +196,17 @@ export const useAuthStore = create()(
},
canAccess: (resource: string, action: string): boolean => {
- const { user, hasPermission } = get();
- if (!user) return false;
+ const { user } = get();
+ if (!user || !user.is_active) return false;
- // Check specific permission
- if (hasPermission(`${resource}:${action}`)) return true;
-
- // Check wildcard permissions
- if (hasPermission(`${resource}:*`)) return true;
- if (hasPermission('*')) return true;
-
- // Role-based access fallback
+ // Role-based access control
switch (user.role) {
case 'admin':
return true;
case 'manager':
return ['inventory', 'production', 'sales', 'reports'].includes(resource);
- case 'baker':
- return ['production', 'inventory'].includes(resource) &&
- ['read', 'update'].includes(action);
- case 'staff':
- return ['inventory', 'sales'].includes(resource) &&
- action === 'read';
+ case 'user':
+ return ['inventory', 'sales'].includes(resource) && action === 'read';
default:
return false;
}
@@ -237,6 +221,18 @@ export const useAuthStore = create()(
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
}),
+ onRehydrateStorage: () => (state) => {
+ // Initialize API client with stored token when store rehydrates
+ if (state?.token) {
+ import('../services/api/client').then(({ apiClient }) => {
+ apiClient.setAuthToken(state.token!);
+
+ if (state.user?.tenant_id) {
+ apiClient.setTenantId(state.user.tenant_id);
+ }
+ });
+ }
+ },
}
)
);
@@ -255,6 +251,7 @@ export const usePermissions = () => useAuthStore((state) => ({
// Hook for auth actions
export const useAuthActions = () => useAuthStore((state) => ({
login: state.login,
+ register: state.register,
logout: state.logout,
refreshAuth: state.refreshAuth,
updateUser: state.updateUser,
diff --git a/frontend/src/stores/bakery.store.ts b/frontend/src/stores/bakery.store.ts
deleted file mode 100644
index 78431c71..00000000
--- a/frontend/src/stores/bakery.store.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { create } from 'zustand';
-import { devtools, persist } from 'zustand/middleware';
-import { immer } from 'zustand/middleware/immer';
-
-export type BakeryType = 'artisan' | 'dependent';
-export type BusinessModel = 'production' | 'retail' | 'hybrid';
-
-interface BakeryState {
- // State
- currentTenant: Tenant | null;
- bakeryType: BakeryType;
- businessModel: BusinessModel;
- operatingHours: {
- start: string;
- end: string;
- };
- features: {
- inventory: boolean;
- production: boolean;
- forecasting: boolean;
- analytics: boolean;
- pos: boolean;
- };
-
- // Actions
- setTenant: (tenant: Tenant) => void;
- setBakeryType: (type: BakeryType) => void;
- setBusinessModel: (model: BusinessModel) => void;
- updateFeatures: (features: Partial) => void;
- reset: () => void;
-}
-
-interface Tenant {
- id: string;
- name: string;
- subscription_plan: string;
- created_at: string;
- members_count: number;
-}
-
-const initialState = {
- currentTenant: null,
- bakeryType: 'artisan' as BakeryType,
- businessModel: 'production' as BusinessModel,
- operatingHours: {
- start: '04:00',
- end: '20:00',
- },
- features: {
- inventory: true,
- production: true,
- forecasting: true,
- analytics: true,
- pos: false,
- },
-};
-
-export const useBakeryStore = create()(
- devtools(
- persist(
- immer((set) => ({
- ...initialState,
-
- setTenant: (tenant) =>
- set((state) => {
- state.currentTenant = tenant;
- }),
-
- setBakeryType: (type) =>
- set((state) => {
- state.bakeryType = type;
- // Adjust features based on bakery type
- if (type === 'artisan') {
- state.features.pos = false;
- state.businessModel = 'production';
- } else if (type === 'dependent') {
- state.features.pos = true;
- state.businessModel = 'retail';
- }
- }),
-
- setBusinessModel: (model) =>
- set((state) => {
- state.businessModel = model;
- }),
-
- updateFeatures: (features) =>
- set((state) => {
- state.features = { ...state.features, ...features };
- }),
-
- reset: () => set(() => initialState),
- })),
- {
- name: 'bakery-storage',
- }
- )
- )
-);
\ No newline at end of file
diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts
index 9ce8ff66..6d40508c 100644
--- a/frontend/src/stores/index.ts
+++ b/frontend/src/stores/index.ts
@@ -5,8 +5,9 @@ export type { User, AuthState } from './auth.store';
export { useUIStore, useLanguage, useSidebar, useCompactMode, useViewMode, useLoading, useToasts, useModals, useBreadcrumbs, usePreferences, useUIActions } from './ui.store';
export type { Theme, Language, ViewMode, SidebarState, Toast, Modal, UIState } from './ui.store';
-export { useBakeryStore } from './bakery.store';
-export type { BakeryType, BusinessModel } from './bakery.store';
+
+export { useTenantStore, useCurrentTenant, useAvailableTenants, useTenantLoading, useTenantError, useTenantActions, useTenantPermissions, useTenant } from './tenant.store';
+export type { TenantState } from './tenant.store';
export { useAlertsStore, useAlerts, useAlertRules, useAlertFilters, useAlertSettings, useUnreadAlertsCount, useCriticalAlertsCount } from './alerts.store';
export type { Alert, AlertRule, AlertCondition, AlertAction, AlertsState, AlertType, AlertCategory, AlertPriority, AlertStatus } from './alerts.store';
\ No newline at end of file
diff --git a/frontend/src/stores/tenant.store.ts b/frontend/src/stores/tenant.store.ts
new file mode 100644
index 00000000..4a0e895a
--- /dev/null
+++ b/frontend/src/stores/tenant.store.ts
@@ -0,0 +1,230 @@
+import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+import { tenantService, TenantResponse } from '../services/api/tenant.service';
+import { useAuthUser } from './auth.store';
+
+export interface TenantState {
+ // State
+ currentTenant: TenantResponse | null;
+ availableTenants: TenantResponse[] | null;
+ isLoading: boolean;
+ error: string | null;
+
+ // Actions
+ setCurrentTenant: (tenant: TenantResponse) => void;
+ switchTenant: (tenantId: string) => Promise;
+ loadUserTenants: () => Promise;
+ clearTenants: () => void;
+ clearError: () => void;
+ setLoading: (loading: boolean) => void;
+
+ // Permission helpers (migrated from BakeryContext)
+ hasPermission: (permission: string) => boolean;
+ canAccess: (resource: string, action: string) => boolean;
+}
+
+export const useTenantStore = create()(
+ persist(
+ (set, get) => ({
+ // Initial state
+ currentTenant: null,
+ availableTenants: null,
+ isLoading: false,
+ error: null,
+
+ // Actions
+ setCurrentTenant: (tenant: TenantResponse) => {
+ set({ currentTenant: tenant });
+ // Update API client with new tenant ID
+ tenantService.setCurrentTenant(tenant);
+ },
+
+ switchTenant: async (tenantId: string): Promise => {
+ try {
+ set({ isLoading: true, error: null });
+
+ const { availableTenants } = get();
+
+ // Find tenant in available tenants first
+ const targetTenant = availableTenants?.find(t => t.id === tenantId);
+ if (!targetTenant) {
+ throw new Error('Tenant not found in available tenants');
+ }
+
+ // Switch tenant using service
+ const response = await tenantService.switchTenant(tenantId);
+
+ if (response.success && response.data?.tenant) {
+ get().setCurrentTenant(response.data.tenant);
+ set({ isLoading: false });
+ return true;
+ } else {
+ throw new Error(response.error || 'Failed to switch tenant');
+ }
+ } catch (error) {
+ set({
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Failed to switch tenant',
+ });
+ return false;
+ }
+ },
+
+ loadUserTenants: async (): Promise => {
+ try {
+ set({ isLoading: true, error: null });
+
+ // Get current user to determine user ID
+ const user = useAuthUser.getState?.() || JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.user;
+
+ if (!user?.id) {
+ throw new Error('User not authenticated');
+ }
+
+ const response = await tenantService.getUserTenants(user.id);
+
+ if (response.success && response.data) {
+ const tenants = Array.isArray(response.data) ? response.data : [response.data];
+
+ set({
+ availableTenants: tenants,
+ isLoading: false
+ });
+
+ // If no current tenant is set, set the first one as current
+ const { currentTenant } = get();
+ if (!currentTenant && tenants.length > 0) {
+ get().setCurrentTenant(tenants[0]);
+ }
+ } else {
+ throw new Error(response.error || 'Failed to load user tenants');
+ }
+ } catch (error) {
+ set({
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Failed to load tenants',
+ });
+ }
+ },
+
+ clearTenants: () => {
+ set({
+ currentTenant: null,
+ availableTenants: null,
+ error: null,
+ });
+ tenantService.clearCurrentTenant();
+ },
+
+ clearError: () => {
+ set({ error: null });
+ },
+
+ setLoading: (loading: boolean) => {
+ set({ isLoading: loading });
+ },
+
+ // Permission helpers (migrated from BakeryContext)
+ hasPermission: (permission: string): boolean => {
+ const { currentTenant } = get();
+ if (!currentTenant) return false;
+
+ // Get user to determine role within this tenant
+ const user = useAuthUser.getState?.() || JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.user;
+
+ // Admin role has all permissions
+ if (user?.role === 'admin') return true;
+
+ // TODO: Implement proper tenant-based permissions
+ // For now, use basic role-based permissions
+ switch (user?.role) {
+ case 'admin':
+ return true;
+ case 'manager':
+ return ['inventory', 'production', 'sales', 'reports'].some(resource =>
+ permission.startsWith(resource)
+ );
+ case 'baker':
+ return ['production', 'inventory'].some(resource =>
+ permission.startsWith(resource)
+ ) && !permission.includes(':delete');
+ case 'staff':
+ return ['inventory', 'sales'].some(resource =>
+ permission.startsWith(resource)
+ ) && permission.includes(':read');
+ default:
+ return false;
+ }
+ },
+
+ canAccess: (resource: string, action: string): boolean => {
+ const { hasPermission } = get();
+
+ // Check specific permission
+ if (hasPermission(`${resource}:${action}`)) return true;
+
+ // Check wildcard permissions
+ if (hasPermission(`${resource}:*`)) return true;
+
+ return false;
+ },
+ }),
+ {
+ name: 'tenant-storage',
+ storage: createJSONStorage(() => localStorage),
+ partialize: (state) => ({
+ currentTenant: state.currentTenant,
+ availableTenants: state.availableTenants,
+ }),
+ onRehydrateStorage: () => (state) => {
+ // Initialize API client with stored tenant when store rehydrates
+ if (state?.currentTenant) {
+ import('../services/api/client').then(({ apiClient }) => {
+ apiClient.setTenantId(state.currentTenant!.id);
+ });
+ }
+ },
+ }
+ )
+);
+
+// Selectors for common use cases
+export const useCurrentTenant = () => useTenantStore((state) => state.currentTenant);
+export const useAvailableTenants = () => useTenantStore((state) => state.availableTenants);
+export const useTenantLoading = () => useTenantStore((state) => state.isLoading);
+export const useTenantError = () => useTenantStore((state) => state.error);
+
+// Hook for tenant actions
+export const useTenantActions = () => useTenantStore((state) => ({
+ setCurrentTenant: state.setCurrentTenant,
+ switchTenant: state.switchTenant,
+ loadUserTenants: state.loadUserTenants,
+ clearTenants: state.clearTenants,
+ clearError: state.clearError,
+ setLoading: state.setLoading,
+}));
+
+// Hook for tenant permissions (replaces useBakeryPermissions)
+export const useTenantPermissions = () => useTenantStore((state) => ({
+ hasPermission: state.hasPermission,
+ canAccess: state.canAccess,
+}));
+
+// Combined hook for convenience
+export const useTenant = () => {
+ const currentTenant = useCurrentTenant();
+ const availableTenants = useAvailableTenants();
+ const isLoading = useTenantLoading();
+ const error = useTenantError();
+ const actions = useTenantActions();
+ const permissions = useTenantPermissions();
+
+ return {
+ currentTenant,
+ availableTenants,
+ isLoading,
+ error,
+ ...actions,
+ ...permissions,
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/types/auth.types.ts b/frontend/src/types/auth.types.ts
index 38c0c912..8ec5bcb6 100644
--- a/frontend/src/types/auth.types.ts
+++ b/frontend/src/types/auth.types.ts
@@ -1,29 +1,25 @@
-// Authentication related types
+// Authentication related types - Updated to match backend exactly
export interface User {
id: string;
email: string;
- full_name: string;
+ full_name: string; // Backend uses full_name, not name
is_active: boolean;
is_verified: boolean;
- created_at: string;
+ created_at: string; // ISO format datetime string
last_login?: string;
phone?: string;
language?: string;
timezone?: string;
tenant_id?: string;
- role?: UserRole;
- avatar_url?: string;
+ role?: string; // Backend uses string, not enum
}
export interface UserRegistration {
email: string;
password: string;
full_name: string;
- tenant_name?: string;
- role?: UserRole;
- phone?: string;
- language?: string;
- timezone?: string;
+ tenant_name?: string; // Optional in backend
+ role?: string; // Backend uses string, defaults to "user"
}
export interface UserLogin {
@@ -35,8 +31,8 @@ export interface UserLogin {
export interface TokenResponse {
access_token: string;
refresh_token?: string;
- token_type: string;
- expires_in: number;
+ token_type: string; // defaults to "bearer"
+ expires_in: number; // seconds, defaults to 3600
user?: User;
}
@@ -94,7 +90,7 @@ export interface RegisterFormData {
password: string;
confirmPassword: string;
full_name: string;
- tenant_name: string;
+ tenant_name?: string; // Optional to match backend
phone?: string;
acceptTerms: boolean;
}
@@ -182,13 +178,11 @@ export interface OAuthProvider {
enabled: boolean;
}
-// Enums
+// Enums - Simplified to match backend
export enum UserRole {
USER = 'user',
ADMIN = 'admin',
MANAGER = 'manager',
- OWNER = 'owner',
- VIEWER = 'viewer',
}
export enum AuthProvider {
diff --git a/services/auth/app/core/security.py b/services/auth/app/core/security.py
index 52fe8591..fd8c4d0c 100644
--- a/services/auth/app/core/security.py
+++ b/services/auth/app/core/security.py
@@ -7,7 +7,7 @@ FIXED VERSION - Consistent password hashing using passlib
import re
import hashlib
from datetime import datetime, timedelta, timezone
-from typing import Optional, Dict, Any
+from typing import Optional, Dict, Any, List
import redis.asyncio as redis
from fastapi import HTTPException, status
import structlog
@@ -36,6 +36,9 @@ class SecurityManager:
if len(password) < settings.PASSWORD_MIN_LENGTH:
return False
+ if len(password) > 128: # Max length from Pydantic schema
+ return False
+
if settings.PASSWORD_REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password):
return False
@@ -50,6 +53,31 @@ class SecurityManager:
return True
+ @staticmethod
+ def get_password_validation_errors(password: str) -> List[str]:
+ """Get detailed password validation errors for better UX"""
+ errors = []
+
+ if len(password) < settings.PASSWORD_MIN_LENGTH:
+ errors.append(f"Password must be at least {settings.PASSWORD_MIN_LENGTH} characters long")
+
+ if len(password) > 128:
+ errors.append("Password cannot exceed 128 characters")
+
+ if settings.PASSWORD_REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password):
+ errors.append("Password must contain at least one uppercase letter")
+
+ if settings.PASSWORD_REQUIRE_LOWERCASE and not re.search(r'[a-z]', password):
+ errors.append("Password must contain at least one lowercase letter")
+
+ if settings.PASSWORD_REQUIRE_NUMBERS and not re.search(r'\d', password):
+ errors.append("Password must contain at least one number")
+
+ if settings.PASSWORD_REQUIRE_SYMBOLS and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
+ errors.append("Password must contain at least one symbol (!@#$%^&*(),.?\":{}|<>)")
+
+ return errors
+
@staticmethod
def hash_password(password: str) -> str:
"""Hash password using passlib bcrypt - FIXED"""
diff --git a/services/auth/app/services/auth_service.py b/services/auth/app/services/auth_service.py
index 016980e0..8f95d34d 100644
--- a/services/auth/app/services/auth_service.py
+++ b/services/auth/app/services/auth_service.py
@@ -50,6 +50,10 @@ class EnhancedAuthService:
if existing_user:
raise DuplicateRecordError("User with this email already exists")
+ # Validate password strength
+ if not SecurityManager.validate_password(user_data.password):
+ raise ValueError("Password does not meet security requirements")
+
# Create user data
user_role = user_data.role if user_data.role else "user"
hashed_password = SecurityManager.hash_password(user_data.password)
@@ -446,6 +450,13 @@ class EnhancedAuthService:
detail="Invalid old password"
)
+ # Validate new password strength
+ if not SecurityManager.validate_password(new_password):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="New password does not meet security requirements"
+ )
+
# Hash new password and update
new_hashed_password = SecurityManager.hash_password(new_password)
await user_repo.update(user_id, {"hashed_password": new_hashed_password})