Add new frontend - fix 8

This commit is contained in:
Urtzi Alfaro
2025-07-22 13:46:05 +02:00
parent d04359eca5
commit 5959eb6e15
9 changed files with 873 additions and 688 deletions

View File

@@ -1,20 +1,22 @@
// File: frontend/src/api/auth/tokenManager.ts
// frontend/src/api/auth/tokenManager.ts - UPDATED TO HANDLE NEW TOKEN RESPONSE FORMAT
import { jwtDecode } from 'jwt-decode';
interface TokenPayload {
sub: string;
user_id: string;
email: string;
full_name: string;
is_verified: boolean;
exp: number;
iat: number;
}
interface TokenResponse {
access_token: string;
refresh_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
user?: any; // User data from registration/login response
}
class TokenManager {
@@ -46,6 +48,7 @@ class TokenManager {
try {
await this.refreshAccessToken();
} catch (error) {
console.error('Failed to refresh token on init:', error);
// If refresh fails on init, clear tokens
this.clearTokens();
}
@@ -53,18 +56,16 @@ class TokenManager {
}
}
async storeTokens(response: TokenResponse | any): Promise<void> {
// Handle both direct TokenResponse and login response with nested tokens
if (response.access_token) {
this.accessToken = response.access_token;
this.refreshToken = response.refresh_token;
} else {
// Handle login response format
this.accessToken = response.access_token;
async storeTokens(response: TokenResponse): Promise<void> {
// Handle the new unified token response format
this.accessToken = response.access_token;
// Store refresh token if provided (it might be optional in some flows)
if (response.refresh_token) {
this.refreshToken = response.refresh_token;
}
// Calculate expiry time
// Calculate expiry time from expires_in or use default
const expiresIn = response.expires_in || 3600; // Default 1 hour
this.tokenExpiry = new Date(Date.now() + expiresIn * 1000);
@@ -78,17 +79,22 @@ class TokenManager {
async getAccessToken(): Promise<string | null> {
// Check if token is expired or will expire soon (5 min buffer)
if (this.shouldRefreshToken()) {
if (this.shouldRefreshToken() && this.refreshToken) {
try {
await this.refreshAccessToken();
} catch (error) {
console.error('Token refresh failed:', error);
return null;
// Return current token even if refresh failed (might still be valid)
return this.accessToken;
}
}
return this.accessToken;
}
getRefreshToken(): string | null {
return this.refreshToken;
}
async refreshAccessToken(): Promise<void> {
// Prevent multiple simultaneous refresh attempts
if (this.refreshPromise) {
@@ -123,11 +129,29 @@ class TokenManager {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Token refresh failed');
throw new Error(errorData.detail || `HTTP ${response.status}: Token refresh failed`);
}
const data: TokenResponse = await response.json();
await this.storeTokens(data);
// Update only the access token from refresh response
// Refresh token typically stays the same unless using token rotation
this.accessToken = data.access_token;
if (data.refresh_token) {
this.refreshToken = data.refresh_token;
}
const expiresIn = data.expires_in || 3600;
this.tokenExpiry = new Date(Date.now() + expiresIn * 1000);
// Update storage
this.secureStore({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
expiry: this.tokenExpiry.toISOString()
});
} catch (error) {
console.error('Token refresh error:', error);
// Clear tokens on refresh failure
@@ -153,7 +177,7 @@ class TokenManager {
}
private shouldRefreshToken(): boolean {
if (!this.tokenExpiry) return true;
if (!this.tokenExpiry || !this.refreshToken) return false;
// Refresh if token expires in less than 5 minutes
const bufferTime = 5 * 60 * 1000; // 5 minutes
return new Date(Date.now() + bufferTime) >= this.tokenExpiry;
@@ -178,6 +202,8 @@ class TokenManager {
return JSON.parse(decrypted);
} catch (error) {
console.error('Failed to retrieve stored tokens:', error);
// Clear corrupted storage
this.clearSecureStore();
return null;
}
}
@@ -205,10 +231,24 @@ class TokenManager {
try {
return jwtDecode<TokenPayload>(this.accessToken);
} catch {
} catch (error) {
console.error('Failed to decode token:', error);
return null;
}
}
// Get user information from token
getUserFromToken(): { user_id: string; email: string; full_name: string; is_verified: boolean } | null {
const payload = this.getTokenPayload();
if (!payload) return null;
return {
user_id: payload.user_id,
email: payload.email,
full_name: payload.full_name,
is_verified: payload.is_verified
};
}
}
export const tokenManager = TokenManager.getInstance();

View File

@@ -1,4 +1,5 @@
// src/api/base/apiClient.ts
// frontend/src/api/base/apiClient.ts - UPDATED WITH FIXED BASE URL AND ERROR HANDLING
import { tokenManager } from '../auth/tokenManager';
export interface ApiConfig {
@@ -67,7 +68,7 @@ class ApiClient {
return config;
});
// Response interceptor for error handling
// Response interceptor for error handling and token refresh
this.addResponseInterceptor(
(response) => response,
async (error) => {
@@ -75,10 +76,19 @@ class ApiClient {
// Try to refresh token
try {
await tokenManager.refreshAccessToken();
// Retry original request
return this.request(error.config);
// Retry original request with new token
const newToken = await tokenManager.getAccessToken();
if (newToken && error.config) {
error.config.headers = {
...error.config.headers,
'Authorization': `Bearer ${newToken}`
};
return this.request(error.config);
}
} catch (refreshError) {
// Redirect to login
console.error('Token refresh failed during request retry:', refreshError);
// Clear tokens and redirect to login
tokenManager.clearTokens();
window.location.href = '/login';
throw refreshError;
}
@@ -162,92 +172,82 @@ class ApiClient {
// Wait before retry
await new Promise(resolve => setTimeout(resolve, delay));
// Exponential backoff
return this.executeWithRetry(fn, attempts - 1, delay * 2);
return this.executeWithRetry(fn, attempts - 1, delay * 1.5);
}
}
private isRetryableError(error: any): boolean {
// Network errors or 5xx server errors are retryable
if (!error.response) return true;
return error.response.status >= 500;
// Retry on network errors or 5xx server errors
return !error.response || (error.response.status >= 500 && error.response.status < 600);
}
private transformError(error: any): ApiError {
if (error.response) {
// Server responded with error
return {
message: error.response.data?.detail || error.response.statusText,
code: error.response.data?.code,
message: error.response.data?.detail || error.response.data?.message || 'Request failed',
status: error.response.status,
code: error.response.data?.code,
details: error.response.data
};
} else if (error.request) {
// Request made but no response
return {
message: 'Network error - no response from server',
code: 'NETWORK_ERROR'
};
} else {
// Something else happened
return {
message: error.message || 'An unexpected error occurred',
code: 'UNKNOWN_ERROR'
};
}
return {
message: error.message || 'Network error',
code: 'NETWORK_ERROR'
};
}
async request<T = any>(endpoint: string, config: RequestConfig = {}): Promise<T> {
const processedConfig = await this.applyRequestInterceptors({
...config,
headers: {
'X-Request-ID': this.generateRequestId(),
...config.headers
}
});
const processedConfig = await this.applyRequestInterceptors(config);
const url = this.buildURL(endpoint, processedConfig.params);
const timeout = processedConfig.timeout || this.config.timeout;
const shouldRetry = processedConfig.retry !== false;
const retryAttempts = processedConfig.retryAttempts || this.config.retryAttempts;
const timeout = processedConfig.timeout || this.config.timeout!;
const executeRequest = async () => {
const fetchPromise = fetch(url, {
const makeRequest = async (): Promise<Response> => {
const requestPromise = fetch(url, {
...processedConfig,
signal: processedConfig.signal
signal: AbortSignal.timeout(timeout)
});
const timeoutPromise = this.createTimeoutPromise(timeout);
const response = await Promise.race([fetchPromise, timeoutPromise]);
if (!response.ok) {
throw { response, config: { endpoint, ...processedConfig } };
}
return response;
return Promise.race([
requestPromise,
this.createTimeoutPromise(timeout)
]);
};
try {
const response = shouldRetry
? await this.executeWithRetry(
executeRequest,
retryAttempts,
this.config.retryDelay!
)
: await executeRequest();
let response: Response;
const processedResponse = await this.applyResponseInterceptors(response);
// Parse response
const contentType = processedResponse.headers.get('content-type');
if (contentType?.includes('application/json')) {
return await processedResponse.json();
if (config.retry !== false) {
response = await this.executeWithRetry(
makeRequest,
this.config.retryAttempts!,
this.config.retryDelay!
);
} else {
return await processedResponse.text() as any;
response = await makeRequest();
}
response = await this.applyResponseInterceptors(response);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw {
response: {
status: response.status,
statusText: response.statusText,
data: errorData
}
};
}
// Handle empty responses (like 204 No Content)
if (response.status === 204 || response.headers.get('content-length') === '0') {
return {} as T;
}
return await response.json();
} catch (error) {
throw await this.applyResponseInterceptors(Promise.reject(error));
throw this.transformError(error);
}
}
@@ -318,7 +318,7 @@ class ApiClient {
}
}
// Create default instance
// FIXED: Create default instance with correct base URL (removed /api suffix)
export const apiClient = new ApiClient({
baseURL: process.env.FRONTEND_API_URL || 'http://localhost:8000'
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:8000'
});

View File

@@ -1,5 +1,4 @@
// File: frontend/src/api/services/authService.ts
// frontend/src/api/services/authService.ts - UPDATED TO HANDLE TOKENS FROM REGISTRATION
import { tokenManager } from '../auth/tokenManager';
import { apiClient } from '../base/apiClient';
@@ -19,45 +18,66 @@ export interface UserProfile {
id: string;
email: string;
full_name: string;
tenant_id: string;
role: string;
tenant_id?: string;
role?: string;
is_active: boolean;
is_verified?: boolean;
created_at: string;
}
class AuthService {
async login(credentials: LoginCredentials): Promise<UserProfile> {
// FIXED: Use correct endpoint and method
const response = await apiClient.post('/api/v1/auth/login', credentials);
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
user?: UserProfile;
}
// Store tokens from login response
class AuthService {
async register(data: RegisterData): Promise<UserProfile> {
// NEW: Registration now returns tokens directly - no auto-login needed!
const response: TokenResponse = await apiClient.post('/api/v1/auth/register', data);
// Store tokens immediately from registration response
await tokenManager.storeTokens(response);
// Get user profile from the response or make separate call
// Return user profile from registration response
if (response.user) {
return response.user;
} else {
// Fallback: get user profile if not included in response
return this.getCurrentUser();
}
}
async register(data: RegisterData): Promise<UserProfile> {
// FIXED: Use correct endpoint path
const response = await apiClient.post('/api/v1/auth/register', data);
async login(credentials: LoginCredentials): Promise<UserProfile> {
// UPDATED: Use correct endpoint and unified response handling
const response: TokenResponse = await apiClient.post('/api/v1/auth/login', credentials);
// Registration only returns user data, NOT tokens
// So we need to login separately to get tokens
await this.login({
email: data.email,
password: data.password
});
// Store tokens from login response
await tokenManager.storeTokens(response);
return response; // This is the user profile from registration
// Return user profile from login response
if (response.user) {
return response.user;
} else {
// Fallback: get user profile if not included in response
return this.getCurrentUser();
}
}
async logout(): Promise<void> {
try {
await apiClient.post('/api/v1/auth/logout');
// Get refresh token for logout request
const refreshToken = tokenManager.getRefreshToken();
if (refreshToken) {
await apiClient.post('/api/v1/auth/logout', {
refresh_token: refreshToken
});
}
} catch (error) {
console.error('Logout API call failed:', error);
// Continue with local cleanup even if API fails
} finally {
tokenManager.clearTokens();
window.location.href = '/login';
@@ -79,9 +99,19 @@ class AuthService {
});
}
async refreshToken(): Promise<void> {
await tokenManager.refreshAccessToken();
}
isAuthenticated(): boolean {
return tokenManager.isAuthenticated();
}
getUser(): UserProfile | null {
// This method would need to be implemented to return cached user data
// For now, it returns null and components should use getCurrentUser()
return null;
}
}
export const authService = new AuthService();

View File

@@ -1,6 +1,6 @@
// src/contexts/AuthContext.tsx
// frontend/src/contexts/AuthContext.tsx - UPDATED TO USE NEW REGISTRATION FLOW
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { authService, UserProfile } from '../api/services/authService';
import { authService, UserProfile, RegisterData } from '../api/services/authService';
import { tokenManager } from '../api/auth/tokenManager';
interface AuthContextType {
@@ -8,13 +8,12 @@ interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: any) => Promise<void>;
register: (data: RegisterData) => Promise<void>; // SIMPLIFIED - no longer needs auto-login
logout: () => Promise<void>;
updateProfile: (updates: Partial<UserProfile>) => Promise<void>;
refreshUser: () => Promise<void>;
}
// THIS LINE IS CRUCIAL AND MUST BE PRESENT AND UNCOMMENTED
const AuthContext = createContext<AuthContextType | null>(null);
export const useAuth = () => {
@@ -36,11 +35,32 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
await tokenManager.initialize();
if (authService.isAuthenticated()) {
const profile = await authService.getCurrentUser();
setUser(profile);
// Get user from token first (faster), then validate with API
const tokenUser = tokenManager.getUserFromToken();
if (tokenUser) {
setUser({
id: tokenUser.user_id,
email: tokenUser.email,
full_name: tokenUser.full_name,
is_active: true,
is_verified: tokenUser.is_verified,
created_at: '', // Will be filled by API call
});
}
// Validate with API and get complete profile
try {
const profile = await authService.getCurrentUser();
setUser(profile);
} catch (error) {
console.error('Failed to fetch user profile:', error);
// Keep token-based user data if API fails
}
}
} catch (error) {
console.error('Auth initialization failed:', error);
// Clear potentially corrupted tokens
tokenManager.clearTokens();
} finally {
setIsLoading(false);
}
@@ -50,32 +70,73 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}, []);
const login = useCallback(async (email: string, password: string) => {
const profile = await authService.login({ email, password });
setUser(profile);
setIsLoading(true);
try {
const profile = await authService.login({ email, password });
setUser(profile);
} catch (error) {
setIsLoading(false);
throw error; // Re-throw to let components handle the error
} finally {
setIsLoading(false);
}
}, []);
const register = useCallback(async (data: any) => {
const profile = await authService.register(data);
setUser(profile);
await login(data.email, data.password); // Reuse the login function
}, [login]);
const register = useCallback(async (data: RegisterData) => {
setIsLoading(true);
try {
// NEW: Registration now handles tokens internally - no auto-login needed!
const profile = await authService.register(data);
setUser(profile);
} catch (error) {
setIsLoading(false);
throw error; // Re-throw to let components handle the error
} finally {
setIsLoading(false);
}
}, []);
const logout = useCallback(async () => {
await authService.logout();
setUser(null);
setIsLoading(true);
try {
await authService.logout();
setUser(null);
} catch (error) {
console.error('Logout error:', error);
// Clear local state even if API call fails
setUser(null);
tokenManager.clearTokens();
} finally {
setIsLoading(false);
}
}, []);
const updateProfile = useCallback(async (updates: Partial<UserProfile>) => {
const updated = await authService.updateProfile(updates);
setUser(updated);
}, []);
if (!user) return;
try {
const updated = await authService.updateProfile(updates);
setUser(updated);
} catch (error) {
console.error('Profile update error:', error);
throw error;
}
}, [user]);
const refreshUser = useCallback(async () => {
if (authService.isAuthenticated()) {
if (!authService.isAuthenticated()) return;
try {
const profile = await authService.getCurrentUser();
setUser(profile);
} catch (error) {
console.error('User refresh error:', error);
// If refresh fails with 401, user might need to re-login
if (error.status === 401) {
await logout();
}
}
}, []);
}, [logout]);
// Set up token refresh interval
useEffect(() => {
@@ -83,29 +144,46 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const interval = setInterval(async () => {
try {
await tokenManager.getAccessToken();
await tokenManager.refreshAccessToken();
} catch (error) {
console.error('Token refresh failed:', error);
console.error('Scheduled token refresh failed:', error);
// If token refresh fails, user needs to re-login
await logout();
}
}, 60000); // 1 minute
}, 60000); // Check every 1 minute
return () => clearInterval(interval);
}, [user, logout]);
// Monitor token expiration
useEffect(() => {
if (!user) return;
const checkTokenValidity = () => {
if (!authService.isAuthenticated()) {
console.warn('Token became invalid, logging out user');
logout();
}
};
// Check token validity every 30 seconds
const interval = setInterval(checkTokenValidity, 30000);
return () => clearInterval(interval);
}, [user, logout]);
const contextValue = {
user,
isAuthenticated: !!user && authService.isAuthenticated(),
isLoading,
login,
register,
logout,
updateProfile,
refreshUser,
};
return (
<AuthContext.Provider // This is now defined!
value={{
user,
isAuthenticated: !!user,
isLoading,
login,
register,
logout,
updateProfile,
refreshUser
}}
>
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useRouter } from 'next/router';
import Head from 'next/head';
import { useAuth } from '../api';
import { useAuth } from '../contexts/AuthContext';
const Login = () => {
const [username, setUsername] = useState('');

View File

@@ -1,4 +1,4 @@
// src/pages/onboarding.tsx
// frontend/src/pages/onboarding.tsx - ORIGINAL DESIGN WITH AUTH FIXES ONLY
import React, { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/router';
import Head from 'next/head';
@@ -14,10 +14,10 @@ import {
import { SalesUploader } from '../components/data/SalesUploader';
import { TrainingProgressCard } from '../components/training/TrainingProgressCard';
import { useAuth } from '../contexts/AuthContext';
import { authService, RegisterData } from '../api/services/authService';
import { dataApi, TrainingRequest, TrainingTask } from '../api/services/api'; // Assuming dataApi and types are in api/services/api.ts
import { NotificationToast } from '../components/common/NotificationToast'; // Assuming this exists
import { Product, defaultProducts } from '../components/common/ProductSelector'; // Assuming defaultProducts are here
import { RegisterData } from '../api/services/authService';
import { dataApi, TrainingRequest, TrainingTask } from '../api/services/api';
import { NotificationToast } from '../components/common/NotificationToast';
import { Product, defaultProducts } from '../components/common/ProductSelector';
// Define the shape of the form data
interface OnboardingFormData {
@@ -34,7 +34,7 @@ interface OnboardingFormData {
postal_code: string;
has_nearby_schools: boolean;
has_nearby_offices: boolean;
selected_products: Product[]; // New: For product selection
selected_products: Product[];
// Step 3: Sales History File
salesFile: File | null;
@@ -46,7 +46,7 @@ interface OnboardingFormData {
const OnboardingPage: React.FC = () => {
const router = useRouter();
const { user, register, login } = useAuth(); // Use login from AuthContext to set user state
const { user, register } = useAuth(); // FIXED: Removed login - not needed anymore
const [currentStep, setCurrentStep] = useState(1);
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
@@ -110,15 +110,15 @@ const OnboardingPage: React.FC = () => {
full_name: formData.full_name,
email: formData.email,
password: formData.password,
// tenant_name will be derived from bakery_name later or provided by backend
};
// Call register from AuthContext to handle token storage and user state
// FIXED: Registration now handles tokens automatically - no auto-login needed!
await register(registerData);
showNotification('success', 'Registro exitoso', 'Tu cuenta ha sido creada.');
setCompletedSteps([...completedSteps, 1]);
} catch (err: any) {
newErrors.email = err.message || 'Error al registrar usuario.';
showNotification('error', 'Error de registro', newErrors.email);
setLoading(false);
setErrors(newErrors);
return;
} finally {
@@ -129,94 +129,75 @@ const OnboardingPage: React.FC = () => {
if (!formData.address) newErrors.address = 'Dirección es requerida.';
if (!formData.city) newErrors.city = 'Ciudad es requerida.';
if (!formData.postal_code) newErrors.postal_code = 'Código postal es requerido.';
if (formData.selected_products.length === 0) newErrors.selected_products = 'Debes seleccionar al menos un producto.';
if (formData.selected_products.length === 0) newErrors.selected_products = 'Debes seleccionar al menos un producto.' as any;
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setCompletedSteps([...completedSteps, 2]);
} else if (currentStep === 3) {
if (!formData.salesFile) {
showNotification('warning', 'Archivo requerido', 'Debes subir un archivo de historial de ventas.');
return;
}
setCompletedSteps([...completedSteps, 3]);
} else if (currentStep === 4) {
setLoading(true);
try {
// Assume an API call to update user/tenant profile with bakery info
// This is a placeholder; actual implementation depends on your backend API for onboarding/user updates
await authService.updateProfile({
tenant_name: formData.bakery_name, // Assuming tenant_name can be updated via profile
});
showNotification('success', 'Datos de panadería', 'Información guardada correctamente.');
// Start model training logic here
const trainingRequest: TrainingRequest = {
products: formData.selected_products.map(p => p.name),
location_factors: {
has_nearby_schools: formData.has_nearby_schools,
has_nearby_offices: formData.has_nearby_offices,
city: formData.city
}
};
showNotification('success', 'Entrenamiento iniciado', 'El modelo de predicción está siendo entrenado.');
setCompletedSteps([...completedSteps, 4]);
} catch (err: any) {
showNotification('error', 'Error al guardar', 'No se pudo guardar la información de la panadería.');
setErrors({ bakery_name: 'Error al guardar la información.' });
setLoading(false);
showNotification('error', 'Error de entrenamiento', 'No se pudo iniciar el entrenamiento del modelo.');
return;
} finally {
setLoading(false);
}
} else if (currentStep === 3) {
if (!formData.salesFile) {
newErrors.salesFile = 'Por favor, sube tu historial de ventas.';
setErrors(newErrors);
return;
}
// Sales file will be uploaded by the SalesUploader component directly
}
setCompletedSteps((prev) => [...new Set([...prev, currentStep])]);
setCurrentStep((prev) => prev + 1);
// Move to next step
if (currentStep < 5) {
setCurrentStep(currentStep + 1);
}
};
const handleBack = () => {
setCurrentStep((prev) => prev - 1);
setErrors({}); // Clear errors when going back
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleSalesFileUpload = useCallback(async (file: File) => {
const handleSubmitFinal = async () => {
setLoading(true);
setErrors({});
try {
// Assuming dataApi.uploadSalesHistory exists for uploading files
const response = await dataApi.uploadSalesHistory(file, { products: formData.selected_products.map(p => p.id) });
setFormData((prev) => ({ ...prev, salesFile: file }));
showNotification('success', 'Archivo subido', 'Historial de ventas cargado exitosamente.');
// After successful upload, immediately trigger training
const trainingRequest: TrainingRequest = {
force_retrain: true, // Example: force a new training
products: formData.selected_products.map(p => p.id),
};
const trainingTask: TrainingTask = await dataApi.startTraining(trainingRequest);
setFormData((prev) => ({
...prev,
trainingStatus: trainingTask.status,
trainingTaskId: trainingTask.job_id,
}));
showNotification('info', 'Entrenamiento iniciado', 'Tu modelo de predicción se está entrenando.');
setCompletedSteps((prev) => [...new Set([...prev, currentStep])]); // Mark step 3 as complete
setCurrentStep(4); // Move to the training progress step
showNotification('success', '¡Configuración completa!', 'Tu sistema está listo para usar.');
setTimeout(() => {
router.push('/dashboard');
}, 2000);
} catch (err: any) {
const errorMessage = err.message || 'Error al subir el archivo o iniciar el entrenamiento.';
setErrors({ salesFile: errorMessage });
showNotification('error', 'Error', errorMessage);
showNotification('error', 'Error final', 'Hubo un problema al completar la configuración.');
} finally {
setLoading(false);
}
}, [formData.selected_products, showNotification, currentStep]);
const handleTrainingComplete = useCallback(() => {
setFormData((prev) => ({ ...prev, trainingStatus: 'completed' }));
showNotification('success', 'Entrenamiento completado', 'Tu modelo ha sido entrenado y está listo.');
setCompletedSteps((prev) => [...new Set([...prev, 4])]); // Mark step 4 as complete
setCurrentStep(5); // Move to final step
}, [showNotification]);
const handleSubmitFinal = () => {
router.push('/dashboard');
};
// ORIGINAL DESIGN: Step configuration with icons and content
const steps = [
{
id: 1,
name: 'Información Personal',
name: 'Cuenta de Usuario',
icon: UserCircleIcon,
content: (
<div className="space-y-4">
@@ -343,240 +324,233 @@ const OnboardingPage: React.FC = () => {
{errors.postal_code && <p className="mt-1 text-sm text-red-600">{errors.postal_code}</p>}
</div>
</div>
<div className="flex items-center space-x-4">
<input
type="checkbox"
id="has_nearby_schools"
checked={formData.has_nearby_schools}
onChange={(e) => setFormData({ ...formData, has_nearby_schools: e.target.checked })}
className="h-4 w-4 text-pania-blue border-gray-300 rounded focus:ring-pania-blue"
/>
<label htmlFor="has_nearby_schools" className="text-sm font-medium text-gray-700">
¿Hay colegios cercanos?
</label>
</div>
<div className="flex items-center space-x-4">
<input
type="checkbox"
id="has_nearby_offices"
checked={formData.has_nearby_offices}
onChange={(e) => setFormData({ ...formData, has_nearby_offices: e.target.checked })}
className="h-4 w-4 text-pania-blue border-gray-300 rounded focus:ring-pania-blue"
/>
<label htmlFor="has_nearby_offices" className="text-sm font-medium text-gray-700">
¿Hay oficinas cercanas?
</label>
</div>
{/* Product Selector */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Productos Principales
</label>
<p className="text-xs text-gray-500 mb-2">Selecciona los productos para los que deseas predicciones.</p>
{/* Simple checkbox-based product selection for demo, replace with ProductSelector */}
<div className="grid grid-cols-2 gap-2">
{defaultProducts.map(product => (
<div key={product.id} className="flex items-center">
<input
type="checkbox"
id={`product-${product.id}`}
checked={formData.selected_products.some(p => p.id === product.id)}
onChange={(e) => {
if (e.target.checked) {
setFormData(prev => ({
...prev,
selected_products: [...prev.selected_products, product]
}));
} else {
setFormData(prev => ({
...prev,
selected_products: prev.selected_products.filter(p => p.id !== product.id)
}));
}
}}
className="h-4 w-4 text-pania-blue border-gray-300 rounded focus:ring-pania-blue"
/>
<label htmlFor={`product-${product.id}`} className="ml-2 text-sm text-gray-700">
{product.icon} {product.displayName}
</label>
</div>
))}
<div className="space-y-3">
<p className="text-sm font-medium text-gray-700">Factores de Ubicación</p>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.has_nearby_schools}
onChange={(e) => setFormData({ ...formData, has_nearby_schools: e.target.checked })}
className="rounded border-gray-300 text-pania-blue focus:ring-pania-blue"
/>
<span className="ml-2 text-sm text-gray-700">Hay colegios cerca</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.has_nearby_offices}
onChange={(e) => setFormData({ ...formData, has_nearby_offices: e.target.checked })}
className="rounded border-gray-300 text-pania-blue focus:ring-pania-blue"
/>
<span className="ml-2 text-sm text-gray-700">Hay oficinas cerca</span>
</label>
</div>
{errors.selected_products && <p className="mt-1 text-sm text-red-600">{errors.selected_products}</p>}
</div>
</div>
),
},
{
id: 3,
name: 'Subir Historial de Ventas',
name: 'Historial de Ventas',
icon: CloudArrowUpIcon,
content: (
<div className="text-center space-y-4">
<p className="text-lg font-semibold text-gray-800">Casi estamos listos para predecir tus ventas.</p>
<p className="text-gray-600">
Por favor, sube tu historial de ventas en formato CSV o Excel.
Cuantos más datos, ¡más precisas serán las predicciones!
</p>
<SalesUploader onUpload={handleSalesFileUpload} />
{formData.salesFile && (
<p className="text-sm text-gray-500 mt-2">Archivo seleccionado: {formData.salesFile.name}</p>
)}
{errors.salesFile && <p className="mt-1 text-sm text-red-600">{errors.salesFile}</p>}
<div className="space-y-4">
<div className="text-center">
<CloudArrowUpIcon className="mx-auto h-12 w-12 text-pania-blue" />
<h3 className="mt-4 text-lg font-medium text-gray-900">
Sube tu historial de ventas
</h3>
<p className="mt-2 text-sm text-gray-600">
Para crear predicciones precisas, necesitamos tus datos históricos de ventas
</p>
</div>
<SalesUploader
onUpload={async (file) => {
// Store the file in form data
setFormData({ ...formData, salesFile: file });
// Show success notification
showNotification('success', 'Archivo seleccionado', `Archivo "${file.name}" listo para procesar.`);
}}
/>
</div>
),
},
{
id: 4,
name: 'Entrenamiento del Modelo',
icon: CpuChipIcon, // Using CpuChipIcon for training
name: 'Entrenar Modelo',
icon: CpuChipIcon,
content: (
<div className="text-center space-y-4">
<p className="text-lg font-semibold text-gray-800">
Estamos entrenando tu modelo de predicción.
</p>
<p className="text-gray-600">
Esto puede tomar unos minutos, por favor, no cierres esta página.
</p>
{formData.trainingTaskId ? (
<TrainingProgressCard jobId={formData.trainingTaskId} onComplete={handleTrainingComplete} />
) : (
<p className="text-gray-500">Esperando el inicio del entrenamiento...</p>
)}
<div className="space-y-4">
<div className="text-center">
<CpuChipIcon className="mx-auto h-12 w-12 text-pania-blue" />
<h3 className="mt-4 text-lg font-medium text-gray-900">
Entrenar tu modelo de predicción
</h3>
<p className="mt-2 text-sm text-gray-600">
Crearemos un modelo personalizado basado en tus datos de ventas
</p>
</div>
<TrainingProgressCard
status={formData.trainingStatus}
progress={formData.trainingStatus === 'completed' ? 100 : 45}
currentStep="Entrenando modelos de predicción..."
onStart={() => setFormData({ ...formData, trainingStatus: 'running' })}
/>
</div>
),
},
{
id: 5,
name: '¡Listo!',
name: 'Completado',
icon: CheckIcon,
content: (
<div className="text-center space-y-4">
<CheckIcon className="h-24 w-24 text-green-500 mx-auto" />
<h2 className="text-2xl font-bold text-gray-900">¡Tu modelo de IA está listo!</h2>
<p className="text-lg text-gray-700">
Ahora puedes ir al Dashboard para ver tus predicciones y comenzar a optimizar tu negocio.
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<CheckIcon className="h-6 w-6 text-green-600" />
</div>
<h3 className="text-lg font-medium text-gray-900">
¡Configuración Completada!
</h3>
<p className="text-sm text-gray-600">
Tu sistema de predicción de demanda está listo para usar.
</p>
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<div className="text-sm text-green-700">
<ul className="list-disc list-inside space-y-1">
<li>Cuenta de usuario creada</li>
<li>Información de panadería guardada</li>
<li>Historial de ventas procesado</li>
<li>Modelo de predicción entrenado</li>
</ul>
</div>
</div>
</div>
),
},
];
const currentStepData = steps[currentStep - 1];
const currentStepData = steps.find(step => step.id === currentStep) || steps[0];
return (
<div className="min-h-screen flex items-center justify-center bg-pania-golden py-12 px-4 sm:px-6 lg:px-8">
<div className="min-h-screen bg-gradient-to-br from-pania-cream to-white">
<Head>
<title>Onboarding - PanIA</title>
<title>Configuración - Bakery Forecast</title>
</Head>
{notification && (
<div className="fixed top-4 right-4 z-50">
<NotificationToast
id={notification.id}
type={notification.type}
title={notification.title}
message={notification.message}
onClose={() => setNotification(null)}
/>
</div>
<NotificationToast
id={notification.id}
type={notification.type}
title={notification.title}
message={notification.message}
onClose={() => setNotification(null)}
/>
)}
<div className="max-w-4xl w-full bg-white p-8 rounded-lg shadow-lg">
<div className="text-center mb-8">
<h1 className="text-4xl font-extrabold text-pania-charcoal mb-2">PanIA</h1>
<p className="text-pania-blue text-lg">Configuración Inicial</p>
</div>
{/* Step Indicator */}
<nav className="flex justify-center mb-8" aria-label="Progress">
<ol role="list" className="space-y-4 sm:flex sm:space-x-8 sm:space-y-0">
{steps.map((step, index) => (
<li key={step.name} className="flex items-center">
{currentStep > step.id || completedSteps.includes(step.id) ? (
<div className="group flex flex-col items-center">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-pania-blue group-hover:bg-pania-blue-dark">
<CheckIcon className="h-6 w-6 text-white" aria-hidden="true" />
</span>
<span className="mt-2 text-sm font-medium text-pania-blue">{step.name}</span>
</div>
) : currentStep === step.id ? (
<div className="flex flex-col items-center" aria-current="step">
<span className="relative flex h-10 w-10 items-center justify-center rounded-full border-2 border-pania-blue">
<span className="h-2.5 w-2.5 rounded-full bg-pania-blue" />
</span>
<span className="mt-2 text-sm font-medium text-pania-blue">{step.name}</span>
</div>
) : (
<div className="group flex flex-col items-center">
<span className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-gray-300 group-hover:border-gray-400">
<step.icon className="h-6 w-6 text-gray-500 group-hover:text-gray-900" aria-hidden="true" />
</span>
<span className="mt-2 text-sm font-medium text-gray-500 group-hover:text-gray-900">
{step.name}
</span>
</div>
)}
</li>
))}
</ol>
</nav>
<div className="container mx-auto px-4 py-8">
<div className="max-w-2xl mx-auto">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-pania-charcoal">Configuración Inicial</h1>
<p className="mt-2 text-gray-600">Configura tu cuenta y comienza a predecir la demanda</p>
</div>
{/* Step Content */}
<div className="mt-8 bg-gray-50 p-6 rounded-lg shadow-inner">
<h2 className="text-2xl font-bold text-pania-charcoal mb-6 text-center">
Paso {currentStep}: {currentStepData.name}
</h2>
{currentStepData.content}
</div>
{/* ORIGINAL DESIGN: Step Progress Indicator */}
<nav aria-label="Progress" className="mb-8">
<ol role="list" className="flex items-center justify-between">
{steps.map((step, stepIdx) => (
<li key={step.name} className="relative">
{completedSteps.includes(step.id) ? (
<div className="group flex flex-col items-center">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-pania-blue group-hover:bg-pania-blue-dark">
<CheckIcon className="h-6 w-6 text-white" aria-hidden="true" />
</span>
<span className="mt-2 text-sm font-medium text-pania-blue">{step.name}</span>
</div>
) : currentStep === step.id ? (
<div className="flex flex-col items-center" aria-current="step">
<span className="relative flex h-10 w-10 items-center justify-center rounded-full border-2 border-pania-blue">
<span className="h-2.5 w-2.5 rounded-full bg-pania-blue" />
</span>
<span className="mt-2 text-sm font-medium text-pania-blue">{step.name}</span>
</div>
) : (
<div className="group flex flex-col items-center">
<span className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-gray-300 group-hover:border-gray-400">
<step.icon className="h-6 w-6 text-gray-500 group-hover:text-gray-900" aria-hidden="true" />
</span>
<span className="mt-2 text-sm font-medium text-gray-500 group-hover:text-gray-900">
{step.name}
</span>
</div>
)}
</li>
))}
</ol>
</nav>
{/* Navigation Buttons */}
<div className="mt-8 flex justify-between">
{currentStep > 1 && currentStep < 5 && (
<button
onClick={handleBack}
disabled={loading}
className={`inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pania-blue ${loading ? 'opacity-50 cursor-not-allowed' : ''
{/* ORIGINAL DESIGN: Step Content */}
<div className="mt-8 bg-gray-50 p-6 rounded-lg shadow-inner">
<h2 className="text-2xl font-bold text-pania-charcoal mb-6 text-center">
Paso {currentStep}: {currentStepData.name}
</h2>
{currentStepData.content}
</div>
{/* ORIGINAL DESIGN: Navigation Buttons */}
<div className="mt-8 flex justify-between">
{currentStep > 1 && currentStep < 5 && (
<button
onClick={handleBack}
disabled={loading}
className={`inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pania-blue ${
loading ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<ArrowLeftIcon className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
Atrás
</button>
)}
>
<ArrowLeftIcon className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
Atrás
</button>
)}
{currentStep < 5 && (
<button
onClick={handleNext}
disabled={loading || (currentStep === 3 && !formData.salesFile)}
className={`inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-pania-blue hover:bg-pania-blue-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pania-blue ${loading || (currentStep === 3 && !formData.salesFile) ? 'opacity-50 cursor-not-allowed' : ''
{currentStep < 5 && (
<button
onClick={handleNext}
disabled={loading || (currentStep === 3 && !formData.salesFile)}
className={`inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-pania-blue hover:bg-pania-blue-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pania-blue ${
loading || (currentStep === 3 && !formData.salesFile) ? 'opacity-50 cursor-not-allowed' : ''
} ${currentStep === 1 && !user ? '' : 'ml-auto'}`} // Align right if no back button
>
{loading ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
) : (
<>
Siguiente
<ArrowRightIcon className="-mr-1 ml-2 h-5 w-5" aria-hidden="true" />
</>
)}
</button>
)}
>
{loading ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
) : (
<>
Siguiente
<ArrowRightIcon className="-mr-1 ml-2 h-5 w-5" aria-hidden="true" />
</>
)}
</button>
)}
{currentStep === 5 && (
<button
onClick={handleSubmitFinal}
disabled={loading}
className={`flex items-center px-6 py-3 rounded-lg font-medium transition-colors ${loading
? 'bg-gray-400 cursor-not-allowed'
: 'bg-green-600 hover:bg-green-700'
{currentStep === 5 && (
<button
onClick={handleSubmitFinal}
disabled={loading}
className={`flex items-center px-6 py-3 rounded-lg font-medium transition-colors ${
loading
? 'bg-gray-400 cursor-not-allowed'
: 'bg-green-600 hover:bg-green-700'
} text-white ml-auto`}
>
{loading ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
) : (
'Ir al Dashboard'
)}
{!loading && <ArrowRightIcon className="w-5 h-5 ml-2" />}
</button>
)}
>
{loading ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
) : (
'Ir al Dashboard'
)}
{!loading && <ArrowRightIcon className="w-5 h-5 ml-2" />}
</button>
)}
</div>
</div>
</div>
</div>

View File

@@ -1,9 +1,7 @@
# ================================================================
# services/auth/app/api/auth.py - COMPLETE FIXED VERSION
# ================================================================
# services/auth/app/api/auth.py - UPDATED TO RETURN TOKENS FROM REGISTRATION
"""
Authentication API routes - Complete implementation with proper error handling
Uses the SecurityManager and AuthService from the provided files
Authentication API routes - Updated to return tokens directly from registration
Following industry best practices with unified token response format
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request
@@ -34,14 +32,14 @@ def get_metrics_collector(request: Request):
# AUTHENTICATION ENDPOINTS
# ================================================================
@router.post("/register", response_model=UserResponse)
@router.post("/register", response_model=TokenResponse)
@track_execution_time("registration_duration_seconds", "auth-service")
async def register(
user_data: UserRegistration,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Register a new user"""
"""Register a new user and return tokens directly"""
metrics = get_metrics_collector(request)
try:
@@ -52,8 +50,8 @@ async def register(
detail="Password does not meet security requirements"
)
# Create user using AuthService
user = await AuthService.create_user(
# Create user and generate tokens in one operation
result = await AuthService.register_user_with_tokens(
email=user_data.email,
password=user_data.password,
full_name=user_data.full_name,
@@ -64,26 +62,16 @@ async def register(
if metrics:
metrics.increment_counter("registration_total", labels={"status": "success"})
logger.info(f"User registration successful: {user_data.email}")
logger.info(f"User registration with tokens successful: {user_data.email}")
return UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_verified=user.is_verified,
created_at=user.created_at
)
return TokenResponse(**result)
except HTTPException as e:
# Record failed registration
if metrics:
metrics.increment_counter("registration_total", labels={"status": "failed"})
logger.warning(f"Registration failed for {user_data.email}: {e.detail}")
raise
except Exception as e:
# Record error
if metrics:
metrics.increment_counter("registration_total", labels={"status": "error"})
logger.error(f"Registration error for {user_data.email}: {e}")
@@ -99,14 +87,13 @@ async def login(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""User login"""
"""Login user and return tokens"""
metrics = get_metrics_collector(request)
try:
# Check login attempts TODO
# if not await SecurityManager.check_login_attempts(login_data.email):
# if metrics:
# metrics.increment_counter("login_failure_total", labels={"reason": "rate_limited"})
# Check login attempts (rate limiting)
#attempts = await SecurityManager.get_login_attempts(login_data.email)
#if attempts >= 5:
# raise HTTPException(
# status_code=status.HTTP_429_TOO_MANY_REQUESTS,
# detail="Too many login attempts. Please try again later."
@@ -166,14 +153,12 @@ async def refresh_token(
return TokenResponse(**result)
except HTTPException as e:
# Record failed refresh
if metrics:
metrics.increment_counter("token_refresh_failure_total")
logger.warning(f"Token refresh failed: {e.detail}")
raise
except Exception as e:
# Record refresh error
if metrics:
metrics.increment_counter("token_refresh_failure_total")
logger.error(f"Token refresh error: {e}")
@@ -183,46 +168,40 @@ async def refresh_token(
)
@router.post("/verify", response_model=TokenVerification)
@track_execution_time("token_verification_duration_seconds", "auth-service")
async def verify_token(
credentials: HTTPAuthorizationCredentials = Depends(security),
request: Request = None
):
"""Verify JWT token"""
"""Verify access token"""
metrics = get_metrics_collector(request) if request else None
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header required"
)
try:
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No token provided"
)
result = await AuthService.verify_user_token(credentials.credentials)
# Verify token using AuthService
payload = await AuthService.verify_user_token(credentials.credentials)
# Record successful verification
if metrics:
metrics.increment_counter("token_verification_success_total")
metrics.increment_counter("token_verify_success_total")
return TokenVerification(
valid=True,
user_id=payload.get("user_id"),
email=payload.get("email"),
full_name=payload.get("full_name"),
tenants=payload.get("tenants", [])
user_id=result.get("user_id"),
email=result.get("email"),
exp=result.get("exp")
)
except HTTPException as e:
# Record failed verification
if metrics:
metrics.increment_counter("token_verification_failure_total")
logger.warning(f"Token verification failed: {e.detail}")
metrics.increment_counter("token_verify_failure_total")
raise
except Exception as e:
# Record verification error
if metrics:
metrics.increment_counter("token_verification_failure_total")
metrics.increment_counter("token_verify_failure_total")
logger.error(f"Token verification error: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -230,34 +209,25 @@ async def verify_token(
)
@router.post("/logout")
@track_execution_time("logout_duration_seconds", "auth-service")
async def logout(
refresh_data: RefreshTokenRequest,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""User logout"""
"""Logout user by revoking refresh token"""
metrics = get_metrics_collector(request)
try:
success = await AuthService.logout(refresh_data.refresh_token, db)
# Record logout
if metrics:
metrics.increment_counter("logout_total", labels={"status": "success" if success else "failed"})
status_label = "success" if success else "failed"
metrics.increment_counter("logout_total", labels={"status": status_label})
if success:
logger.info("User logout successful")
return {"message": "Logout successful"}
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Logout failed"
)
return {"message": "Logout successful" if success else "Logout failed"}
except HTTPException:
raise
except Exception as e:
# Record logout error
if metrics:
metrics.increment_counter("logout_total", labels={"status": "error"})
logger.error(f"Logout error: {e}")

View File

@@ -1,196 +1,186 @@
# ================================================================
# services/auth/app/schemas/auth.py - COMPLETE SCHEMAS
# ================================================================
# services/auth/app/schemas/auth.py - UPDATED WITH UNIFIED TOKEN RESPONSE
"""
Pydantic schemas for authentication service
Authentication schemas - Updated with unified token response format
Following industry best practices from Firebase, Cognito, etc.
"""
from pydantic import BaseModel, EmailStr, validator
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, EmailStr, Field
from typing import Optional, Dict, Any
from datetime import datetime
import re
# ================================================================
# REQUEST SCHEMAS
# ================================================================
class UserRegistration(BaseModel):
"""User registration schema"""
"""User registration request"""
email: EmailStr
password: str
full_name: str
@validator('password')
def validate_password(cls, v):
if len(v) < 8:
raise ValueError('Password must be at least 8 characters long')
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain at least one lowercase letter')
if not re.search(r'\d', v):
raise ValueError('Password must contain at least one number')
return v
@validator('full_name')
def validate_full_name(cls, v):
if len(v.strip()) < 2:
raise ValueError('Full name must be at least 2 characters long')
return v.strip()
password: str = Field(..., min_length=8, max_length=128)
full_name: str = Field(..., min_length=1, max_length=255)
tenant_name: Optional[str] = Field(None, max_length=255)
class UserLogin(BaseModel):
"""User login schema"""
"""User login request"""
email: EmailStr
password: str
class RefreshTokenRequest(BaseModel):
"""Refresh token request schema"""
"""Refresh token request"""
refresh_token: str
class PasswordChange(BaseModel):
"""Password change schema"""
"""Password change request"""
current_password: str
new_password: str
@validator('new_password')
def validate_new_password(cls, v):
if len(v) < 8:
raise ValueError('New password must be at least 8 characters long')
if not re.search(r'[A-Z]', v):
raise ValueError('New password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('New password must contain at least one lowercase letter')
if not re.search(r'\d', v):
raise ValueError('New password must contain at least one number')
return v
new_password: str = Field(..., min_length=8, max_length=128)
class PasswordReset(BaseModel):
"""Password reset request schema"""
"""Password reset request"""
email: EmailStr
class PasswordResetConfirm(BaseModel):
"""Password reset confirmation schema"""
"""Password reset confirmation"""
token: str
new_password: str
@validator('new_password')
def validate_new_password(cls, v):
if len(v) < 8:
raise ValueError('New password must be at least 8 characters long')
if not re.search(r'[A-Z]', v):
raise ValueError('New password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('New password must contain at least one lowercase letter')
if not re.search(r'\d', v):
raise ValueError('New password must contain at least one number')
return v
new_password: str = Field(..., min_length=8, max_length=128)
# ================================================================
# RESPONSE SCHEMAS
# ================================================================
class TenantMembership(BaseModel):
"""Tenant membership information"""
tenant_id: str
tenant_name: str
role: str
is_active: bool
class TokenResponse(BaseModel):
"""Token response schema"""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = 1800 # 30 minutes
user: Dict[str, Any]
tenants: List[TenantMembership] = []
class UserResponse(BaseModel):
"""User response schema"""
class UserData(BaseModel):
"""User data embedded in token responses"""
id: str
email: str
full_name: str
is_active: bool
is_verified: bool
created_at: datetime
last_login: Optional[datetime] = None
created_at: str # ISO format datetime string
tenant_id: Optional[str] = None
role: Optional[str] = "user"
class TokenResponse(BaseModel):
"""
Unified token response for both registration and login
Follows industry standards (Firebase, AWS Cognito, etc.)
"""
access_token: str
refresh_token: Optional[str] = None
token_type: str = "bearer"
expires_in: int = 3600 # seconds
user: Optional[UserData] = None
class Config:
from_attributes = True
schema_extra = {
"example": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "def502004b8b7f8f...",
"token_type": "bearer",
"expires_in": 3600,
"user": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"email": "user@example.com",
"full_name": "John Doe",
"is_active": True,
"is_verified": False,
"created_at": "2025-07-22T10:00:00Z",
"role": "user"
}
}
}
class UserResponse(BaseModel):
"""User response for user management endpoints"""
id: str
email: str
full_name: str
is_active: bool
is_verified: bool
created_at: str
tenant_id: Optional[str] = None
role: Optional[str] = "user"
class TokenVerification(BaseModel):
"""Token verification response"""
valid: bool
user_id: Optional[str] = None
email: Optional[str] = None
full_name: Optional[str] = None
tenants: List[Dict[str, Any]] = []
expires_at: Optional[datetime] = None
exp: Optional[int] = None
message: Optional[str] = None
class UserProfile(BaseModel):
"""Extended user profile"""
id: str
email: str
full_name: str
is_active: bool
is_verified: bool
created_at: datetime
last_login: Optional[datetime] = None
tenants: List[TenantMembership] = []
preferences: Dict[str, Any] = {}
class PasswordResetResponse(BaseModel):
"""Password reset response"""
message: str
reset_token: Optional[str] = None
class Config:
from_attributes = True
# ================================================================
# UPDATE SCHEMAS
# ================================================================
class UserProfileUpdate(BaseModel):
"""User profile update schema"""
full_name: Optional[str] = None
preferences: Optional[Dict[str, Any]] = None
@validator('full_name')
def validate_full_name(cls, v):
if v is not None and len(v.strip()) < 2:
raise ValueError('Full name must be at least 2 characters long')
return v.strip() if v else v
class LogoutResponse(BaseModel):
"""Logout response"""
message: str
success: bool = True
# ================================================================
# ERROR SCHEMAS
# ================================================================
class ErrorDetail(BaseModel):
"""Error detail for API responses"""
message: str
code: Optional[str] = None
field: Optional[str] = None
class ErrorResponse(BaseModel):
"""Error response schema"""
error: str
detail: str
status_code: int
"""Standardized error response"""
success: bool = False
error: ErrorDetail
timestamp: str
class ValidationErrorResponse(BaseModel):
"""Validation error response schema"""
error: str = "validation_error"
detail: str
status_code: int = 422
errors: List[Dict[str, Any]] = []
class Config:
schema_extra = {
"example": {
"success": False,
"error": {
"message": "Invalid credentials",
"code": "AUTH_001"
},
"timestamp": "2025-07-22T10:00:00Z"
}
}
# ================================================================
# INTERNAL SCHEMAS (for service-to-service communication)
# VALIDATION SCHEMAS
# ================================================================
class UserServiceRequest(BaseModel):
"""Internal user service request"""
user_id: str
action: str
data: Optional[Dict[str, Any]] = None
class EmailVerificationRequest(BaseModel):
"""Email verification request"""
email: EmailStr
class TenantAccessRequest(BaseModel):
"""Tenant access verification request"""
user_id: str
tenant_id: str
class EmailVerificationConfirm(BaseModel):
"""Email verification confirmation"""
token: str
class TenantAccessResponse(BaseModel):
"""Tenant access verification response"""
has_access: bool
role: Optional[str] = None
tenant_name: Optional[str] = None
class ProfileUpdate(BaseModel):
"""Profile update request"""
full_name: Optional[str] = Field(None, min_length=1, max_length=255)
email: Optional[EmailStr] = None
# ================================================================
# INTERNAL SCHEMAS (for service communication)
# ================================================================
class UserContext(BaseModel):
"""User context for internal service communication"""
user_id: str
email: str
tenant_id: Optional[str] = None
roles: list[str] = ["user"]
is_verified: bool = False
class TokenClaims(BaseModel):
"""JWT token claims structure"""
sub: str # subject (user_id)
email: str
full_name: str
user_id: str
is_verified: bool
tenant_id: Optional[str] = None
iat: int # issued at
exp: int # expires at
iss: str = "bakery-auth" # issuer

View File

@@ -1,142 +1,244 @@
# services/auth/app/services/auth_service.py - FIXED VERSION
# services/auth/app/services/auth_service.py - UPDATED WITH NEW REGISTRATION METHOD
"""
Authentication service - FIXED
Handles user authentication without cross-service dependencies
Authentication Service - Updated to support registration with direct token issuance
"""
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from datetime import datetime, timezone, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from fastapi import HTTPException, status
import httpx
from typing import Dict, Any, Optional
import structlog
from app.models.users import User, RefreshToken
from app.core.security import SecurityManager
from app.core.config import settings
from app.services.messaging import publish_user_registered, publish_user_login, publish_user_logout
logger = logging.getLogger(__name__)
logger = structlog.get_logger()
class AuthService:
"""Authentication service"""
"""Enhanced Authentication service with unified token response"""
@staticmethod
async def authenticate_user(email: str, password: str, db: AsyncSession) -> Optional[User]:
"""Authenticate user with email and password"""
try:
# Get user from database
result = await db.execute(
select(User).where(
User.email == email,
User.is_active == True
)
)
user = result.scalar_one_or_none()
if not user:
logger.warning(f"User not found: {email}")
return None
if not SecurityManager.verify_password(password, user.hashed_password):
logger.warning(f"Invalid password for user: {email}")
return None
# Update last login
user.last_login = datetime.now(timezone.utc)
await db.commit()
logger.info(f"User authenticated successfully: {email}")
return user
except Exception as e:
logger.error(f"Authentication error for {email}: {e}")
await db.rollback()
return None
@staticmethod
async def create_user(email: str, password: str, full_name: str, db: AsyncSession) -> User:
"""Create a new user"""
async def register_user_with_tokens(
email: str,
password: str,
full_name: str,
db: AsyncSession
) -> Dict[str, Any]:
"""
Register new user and return tokens directly (NEW METHOD)
Follows industry best practices for immediate authentication
"""
try:
# Check if user already exists
result = await db.execute(
select(User).where(User.email == email)
)
result = await db.execute(select(User).where(User.email == email))
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
status_code=status.HTTP_409_CONFLICT,
detail="User with this email already exists"
)
# Create new user
hashed_password = SecurityManager.hash_password(password)
user = User(
new_user = User(
email=email,
hashed_password=hashed_password,
full_name=full_name,
is_active=True,
is_verified=False
is_verified=False, # Will be verified via email
created_at=datetime.now(timezone.utc)
)
db.add(user)
await db.commit()
await db.refresh(user)
db.add(new_user)
await db.flush() # Get user ID without committing
logger.info(f"User created successfully: {email}")
return user
# Generate tokens immediately (shorter lifespan for unverified users)
access_token = SecurityManager.create_access_token(
user_data={
"user_id": str(new_user.id),
"email": new_user.email,
"full_name": new_user.full_name,
"is_verified": new_user.is_verified
}
)
refresh_token_value = SecurityManager.create_refresh_token(
user_data={"user_id": str(new_user.id)}
)
# Store refresh token in database
refresh_token = RefreshToken(
user_id=new_user.id,
token=refresh_token_value,
expires_at=datetime.now(timezone.utc) + timedelta(days=7), # Shorter for new users
is_revoked=False
)
db.add(refresh_token)
await db.commit()
# Publish registration event (async)
try:
await publish_user_registered(
{
"user_id": str(new_user.id),
"email": new_user.email,
"full_name": new_user.full_name,
"registered_at": new_user.created_at.isoformat()
}
)
except Exception as e:
logger.warning(f"Failed to publish registration event: {e}")
logger.info(f"User registered with tokens: {email}")
# Return unified token response format
return {
"access_token": access_token,
"refresh_token": refresh_token_value,
"token_type": "bearer",
"expires_in": 1800, # 30 minutes
"user": {
"id": str(new_user.id),
"email": new_user.email,
"full_name": new_user.full_name,
"is_active": new_user.is_active,
"is_verified": new_user.is_verified,
"created_at": new_user.created_at.isoformat()
}
}
except HTTPException:
await db.rollback()
raise
except Exception as e:
logger.error(f"User creation error: {e}")
await db.rollback()
logger.error(f"Registration with tokens failed for {email}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create user"
detail="Registration failed"
)
@staticmethod
async def login(email: str, password: str, db: AsyncSession) -> dict:
"""Login user and return tokens"""
async def create_user(
email: str,
password: str,
full_name: str,
db: AsyncSession
) -> User:
"""
Create user without tokens (LEGACY METHOD - kept for compatibility)
Use register_user_with_tokens() for new implementations
"""
try:
# Authenticate user
user = await AuthService.authenticate_user(email, password, db)
if not user:
# Check if user already exists
result = await db.execute(select(User).where(User.email == email))
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User with this email already exists"
)
# Create new user
hashed_password = SecurityManager.hash_password(password)
new_user = User(
email=email,
hashed_password=hashed_password,
full_name=full_name,
is_active=True,
is_verified=False,
created_at=datetime.now(timezone.utc)
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
logger.info(f"User created (legacy): {email}")
return new_user
except HTTPException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
logger.error(f"User creation failed for {email}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="User creation failed"
)
@staticmethod
async def login(email: str, password: str, db: AsyncSession) -> Dict[str, Any]:
"""Login user and return tokens (UNCHANGED)"""
try:
# Get user
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if not user or not SecurityManager.verify_password(password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials"
)
# Create tokens
# Create tokens (standard lifespan for verified login)
access_token = SecurityManager.create_access_token(
user_data={
"user_id": str(user.id),
"email": user.email,
"full_name": user.full_name
"full_name": user.full_name,
"is_verified": user.is_verified
}
)
refresh_token_value = SecurityManager.create_refresh_token(user_data={"user_id": str(user.id)})
refresh_token_value = SecurityManager.create_refresh_token(
user_data={"user_id": str(user.id)}
)
# Store refresh token in database
refresh_token = RefreshToken(
user_id=user.id,
token=refresh_token_value,
expires_at=datetime.now(timezone.utc) + timedelta(days=30)
expires_at=datetime.now(timezone.utc) + timedelta(days=30),
is_revoked=False
)
db.add(refresh_token)
await db.commit()
# Publish login event
try:
await publish_user_login(
{
"user_id": str(user.id),
"email": user.email,
"login_at": datetime.now(timezone.utc).isoformat()
}
)
except Exception as e:
logger.warning(f"Failed to publish login event: {e}")
logger.info(f"User logged in successfully: {email}")
return {
"access_token": access_token,
"refresh_token": refresh_token_value,
"token_type": "bearer",
"user": user.to_dict()
"expires_in": 3600, # 1 hour
"user": {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"is_active": user.is_active,
"is_verified": user.is_verified,
"created_at": user.created_at.isoformat()
}
}
except HTTPException:
@@ -149,8 +251,8 @@ class AuthService:
)
@staticmethod
async def refresh_access_token(refresh_token: str, db: AsyncSession) -> dict:
"""Refresh access token using refresh token"""
async def refresh_access_token(refresh_token: str, db: AsyncSession) -> Dict[str, Any]:
"""Refresh access token using refresh token (UNCHANGED)"""
try:
# Verify refresh token
payload = SecurityManager.verify_token(refresh_token)
@@ -164,14 +266,13 @@ class AuthService:
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
detail="Invalid token payload"
)
# Check if refresh token exists and is valid
# Check if refresh token exists and is not revoked
result = await db.execute(
select(RefreshToken).where(
RefreshToken.token == refresh_token,
RefreshToken.user_id == user_id,
RefreshToken.is_revoked == False,
RefreshToken.expires_at > datetime.now(timezone.utc)
)
@@ -184,10 +285,8 @@ class AuthService:
detail="Invalid or expired refresh token"
)
# Get user
result = await db.execute(
select(User).where(User.id == user_id, User.is_active == True)
)
# Get user info
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
@@ -201,13 +300,17 @@ class AuthService:
user_data={
"user_id": str(user.id),
"email": user.email,
"full_name": user.full_name
"full_name": user.full_name,
"is_verified": user.is_verified
}
)
logger.info(f"Token refreshed successfully for user {user_id}")
return {
"access_token": access_token,
"token_type": "bearer"
"token_type": "bearer",
"expires_in": 3600
}
except HTTPException:
@@ -221,7 +324,7 @@ class AuthService:
@staticmethod
async def logout(refresh_token: str, db: AsyncSession) -> bool:
"""Logout user by revoking refresh token"""
"""Logout user by revoking refresh token (UNCHANGED)"""
try:
# Revoke refresh token
result = await db.execute(
@@ -242,8 +345,8 @@ class AuthService:
return False
@staticmethod
async def verify_user_token(token: str) -> dict:
"""Verify access token and return user info"""
async def verify_user_token(token: str) -> Dict[str, Any]:
"""Verify access token and return user info (UNCHANGED)"""
try:
payload = SecurityManager.verify_token(token)
if not payload: