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'; import { jwtDecode } from 'jwt-decode';
interface TokenPayload { interface TokenPayload {
sub: string; sub: string;
user_id: string; user_id: string;
email: string; email: string;
full_name: string;
is_verified: boolean;
exp: number; exp: number;
iat: number; iat: number;
} }
interface TokenResponse { interface TokenResponse {
access_token: string; access_token: string;
refresh_token: string; refresh_token?: string;
token_type: string; token_type: string;
expires_in?: number; expires_in?: number;
user?: any; // User data from registration/login response
} }
class TokenManager { class TokenManager {
@@ -46,6 +48,7 @@ class TokenManager {
try { try {
await this.refreshAccessToken(); await this.refreshAccessToken();
} catch (error) { } catch (error) {
console.error('Failed to refresh token on init:', error);
// If refresh fails on init, clear tokens // If refresh fails on init, clear tokens
this.clearTokens(); this.clearTokens();
} }
@@ -53,18 +56,16 @@ class TokenManager {
} }
} }
async storeTokens(response: TokenResponse | any): Promise<void> { async storeTokens(response: TokenResponse): Promise<void> {
// Handle both direct TokenResponse and login response with nested tokens // Handle the new unified token response format
if (response.access_token) { this.accessToken = response.access_token;
this.accessToken = response.access_token;
this.refreshToken = response.refresh_token; // Store refresh token if provided (it might be optional in some flows)
} else { if (response.refresh_token) {
// Handle login response format
this.accessToken = response.access_token;
this.refreshToken = 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 const expiresIn = response.expires_in || 3600; // Default 1 hour
this.tokenExpiry = new Date(Date.now() + expiresIn * 1000); this.tokenExpiry = new Date(Date.now() + expiresIn * 1000);
@@ -78,17 +79,22 @@ class TokenManager {
async getAccessToken(): Promise<string | null> { async getAccessToken(): Promise<string | null> {
// Check if token is expired or will expire soon (5 min buffer) // Check if token is expired or will expire soon (5 min buffer)
if (this.shouldRefreshToken()) { if (this.shouldRefreshToken() && this.refreshToken) {
try { try {
await this.refreshAccessToken(); await this.refreshAccessToken();
} catch (error) { } catch (error) {
console.error('Token refresh failed:', 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; return this.accessToken;
} }
getRefreshToken(): string | null {
return this.refreshToken;
}
async refreshAccessToken(): Promise<void> { async refreshAccessToken(): Promise<void> {
// Prevent multiple simultaneous refresh attempts // Prevent multiple simultaneous refresh attempts
if (this.refreshPromise) { if (this.refreshPromise) {
@@ -123,11 +129,29 @@ class TokenManager {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); 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(); 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) { } catch (error) {
console.error('Token refresh error:', error); console.error('Token refresh error:', error);
// Clear tokens on refresh failure // Clear tokens on refresh failure
@@ -153,7 +177,7 @@ class TokenManager {
} }
private shouldRefreshToken(): boolean { private shouldRefreshToken(): boolean {
if (!this.tokenExpiry) return true; if (!this.tokenExpiry || !this.refreshToken) return false;
// Refresh if token expires in less than 5 minutes // Refresh if token expires in less than 5 minutes
const bufferTime = 5 * 60 * 1000; // 5 minutes const bufferTime = 5 * 60 * 1000; // 5 minutes
return new Date(Date.now() + bufferTime) >= this.tokenExpiry; return new Date(Date.now() + bufferTime) >= this.tokenExpiry;
@@ -178,6 +202,8 @@ class TokenManager {
return JSON.parse(decrypted); return JSON.parse(decrypted);
} catch (error) { } catch (error) {
console.error('Failed to retrieve stored tokens:', error); console.error('Failed to retrieve stored tokens:', error);
// Clear corrupted storage
this.clearSecureStore();
return null; return null;
} }
} }
@@ -205,10 +231,24 @@ class TokenManager {
try { try {
return jwtDecode<TokenPayload>(this.accessToken); return jwtDecode<TokenPayload>(this.accessToken);
} catch { } catch (error) {
console.error('Failed to decode token:', error);
return null; 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(); 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'; import { tokenManager } from '../auth/tokenManager';
export interface ApiConfig { export interface ApiConfig {
@@ -67,7 +68,7 @@ class ApiClient {
return config; return config;
}); });
// Response interceptor for error handling // Response interceptor for error handling and token refresh
this.addResponseInterceptor( this.addResponseInterceptor(
(response) => response, (response) => response,
async (error) => { async (error) => {
@@ -75,10 +76,19 @@ class ApiClient {
// Try to refresh token // Try to refresh token
try { try {
await tokenManager.refreshAccessToken(); await tokenManager.refreshAccessToken();
// Retry original request // Retry original request with new token
return this.request(error.config); 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) { } 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'; window.location.href = '/login';
throw refreshError; throw refreshError;
} }
@@ -162,92 +172,82 @@ class ApiClient {
// Wait before retry // Wait before retry
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
// Exponential backoff return this.executeWithRetry(fn, attempts - 1, delay * 1.5);
return this.executeWithRetry(fn, attempts - 1, delay * 2);
} }
} }
private isRetryableError(error: any): boolean { private isRetryableError(error: any): boolean {
// Network errors or 5xx server errors are retryable // Retry on network errors or 5xx server errors
if (!error.response) return true; return !error.response || (error.response.status >= 500 && error.response.status < 600);
return error.response.status >= 500;
} }
private transformError(error: any): ApiError { private transformError(error: any): ApiError {
if (error.response) { if (error.response) {
// Server responded with error
return { return {
message: error.response.data?.detail || error.response.statusText, message: error.response.data?.detail || error.response.data?.message || 'Request failed',
code: error.response.data?.code,
status: error.response.status, status: error.response.status,
code: error.response.data?.code,
details: error.response.data 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> { async request<T = any>(endpoint: string, config: RequestConfig = {}): Promise<T> {
const processedConfig = await this.applyRequestInterceptors({ const processedConfig = await this.applyRequestInterceptors(config);
...config,
headers: {
'X-Request-ID': this.generateRequestId(),
...config.headers
}
});
const url = this.buildURL(endpoint, processedConfig.params); const url = this.buildURL(endpoint, processedConfig.params);
const timeout = processedConfig.timeout || this.config.timeout; const timeout = processedConfig.timeout || this.config.timeout!;
const shouldRetry = processedConfig.retry !== false;
const retryAttempts = processedConfig.retryAttempts || this.config.retryAttempts;
const executeRequest = async () => { const makeRequest = async (): Promise<Response> => {
const fetchPromise = fetch(url, { const requestPromise = fetch(url, {
...processedConfig, ...processedConfig,
signal: processedConfig.signal signal: AbortSignal.timeout(timeout)
}); });
const timeoutPromise = this.createTimeoutPromise(timeout); return Promise.race([
requestPromise,
const response = await Promise.race([fetchPromise, timeoutPromise]); this.createTimeoutPromise(timeout)
]);
if (!response.ok) {
throw { response, config: { endpoint, ...processedConfig } };
}
return response;
}; };
try { try {
const response = shouldRetry let response: Response;
? await this.executeWithRetry(
executeRequest,
retryAttempts,
this.config.retryDelay!
)
: await executeRequest();
const processedResponse = await this.applyResponseInterceptors(response);
// Parse response if (config.retry !== false) {
const contentType = processedResponse.headers.get('content-type'); response = await this.executeWithRetry(
if (contentType?.includes('application/json')) { makeRequest,
return await processedResponse.json(); this.config.retryAttempts!,
this.config.retryDelay!
);
} else { } 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) { } 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({ 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 { tokenManager } from '../auth/tokenManager';
import { apiClient } from '../base/apiClient'; import { apiClient } from '../base/apiClient';
@@ -19,45 +18,66 @@ export interface UserProfile {
id: string; id: string;
email: string; email: string;
full_name: string; full_name: string;
tenant_id: string; tenant_id?: string;
role: string; role?: string;
is_active: boolean; is_active: boolean;
is_verified?: boolean;
created_at: string; created_at: string;
} }
class AuthService { export interface TokenResponse {
async login(credentials: LoginCredentials): Promise<UserProfile> { access_token: string;
// FIXED: Use correct endpoint and method refresh_token?: string;
const response = await apiClient.post('/api/v1/auth/login', credentials); token_type: string;
expires_in?: number;
// Store tokens from login response user?: UserProfile;
await tokenManager.storeTokens(response); }
// Get user profile from the response or make separate call 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);
// Return user profile from registration response
if (response.user) { if (response.user) {
return response.user; return response.user;
} else { } else {
// Fallback: get user profile if not included in response
return this.getCurrentUser(); return this.getCurrentUser();
} }
} }
async register(data: RegisterData): Promise<UserProfile> { async login(credentials: LoginCredentials): Promise<UserProfile> {
// FIXED: Use correct endpoint path // UPDATED: Use correct endpoint and unified response handling
const response = await apiClient.post('/api/v1/auth/register', data); const response: TokenResponse = await apiClient.post('/api/v1/auth/login', credentials);
// Registration only returns user data, NOT tokens // Store tokens from login response
// So we need to login separately to get tokens await tokenManager.storeTokens(response);
await this.login({
email: data.email,
password: data.password
});
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> { async logout(): Promise<void> {
try { 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 { } finally {
tokenManager.clearTokens(); tokenManager.clearTokens();
window.location.href = '/login'; window.location.href = '/login';
@@ -79,9 +99,19 @@ class AuthService {
}); });
} }
async refreshToken(): Promise<void> {
await tokenManager.refreshAccessToken();
}
isAuthenticated(): boolean { isAuthenticated(): boolean {
return tokenManager.isAuthenticated(); 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(); 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 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'; import { tokenManager } from '../api/auth/tokenManager';
interface AuthContextType { interface AuthContextType {
@@ -8,13 +8,12 @@ interface AuthContextType {
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
login: (email: string, password: string) => Promise<void>; 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>; logout: () => Promise<void>;
updateProfile: (updates: Partial<UserProfile>) => Promise<void>; updateProfile: (updates: Partial<UserProfile>) => Promise<void>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
} }
// THIS LINE IS CRUCIAL AND MUST BE PRESENT AND UNCOMMENTED
const AuthContext = createContext<AuthContextType | null>(null); const AuthContext = createContext<AuthContextType | null>(null);
export const useAuth = () => { export const useAuth = () => {
@@ -36,11 +35,32 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
await tokenManager.initialize(); await tokenManager.initialize();
if (authService.isAuthenticated()) { if (authService.isAuthenticated()) {
const profile = await authService.getCurrentUser(); // Get user from token first (faster), then validate with API
setUser(profile); 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) { } catch (error) {
console.error('Auth initialization failed:', error); console.error('Auth initialization failed:', error);
// Clear potentially corrupted tokens
tokenManager.clearTokens();
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -50,32 +70,73 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}, []); }, []);
const login = useCallback(async (email: string, password: string) => { const login = useCallback(async (email: string, password: string) => {
const profile = await authService.login({ email, password }); setIsLoading(true);
setUser(profile); 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 register = useCallback(async (data: RegisterData) => {
const profile = await authService.register(data); setIsLoading(true);
setUser(profile); try {
await login(data.email, data.password); // Reuse the login function // NEW: Registration now handles tokens internally - no auto-login needed!
}, [login]); 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 () => { const logout = useCallback(async () => {
await authService.logout(); setIsLoading(true);
setUser(null); 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 updateProfile = useCallback(async (updates: Partial<UserProfile>) => {
const updated = await authService.updateProfile(updates); if (!user) return;
setUser(updated);
}, []); try {
const updated = await authService.updateProfile(updates);
setUser(updated);
} catch (error) {
console.error('Profile update error:', error);
throw error;
}
}, [user]);
const refreshUser = useCallback(async () => { const refreshUser = useCallback(async () => {
if (authService.isAuthenticated()) { if (!authService.isAuthenticated()) return;
try {
const profile = await authService.getCurrentUser(); const profile = await authService.getCurrentUser();
setUser(profile); 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 // Set up token refresh interval
useEffect(() => { useEffect(() => {
@@ -83,29 +144,46 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const interval = setInterval(async () => { const interval = setInterval(async () => {
try { try {
await tokenManager.getAccessToken(); await tokenManager.refreshAccessToken();
} catch (error) { } 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(); await logout();
} }
}, 60000); // 1 minute }, 60000); // Check every 1 minute
return () => clearInterval(interval); return () => clearInterval(interval);
}, [user, logout]); }, [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 ( return (
<AuthContext.Provider // This is now defined! <AuthContext.Provider value={contextValue}>
value={{
user,
isAuthenticated: !!user,
isLoading,
login,
register,
logout,
updateProfile,
refreshUser
}}
>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

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

View File

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

View File

@@ -1,196 +1,186 @@
# ================================================================ # services/auth/app/schemas/auth.py - UPDATED WITH UNIFIED TOKEN RESPONSE
# services/auth/app/schemas/auth.py - COMPLETE SCHEMAS
# ================================================================
""" """
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 pydantic import BaseModel, EmailStr, Field
from typing import Optional, List, Dict, Any from typing import Optional, Dict, Any
from datetime import datetime from datetime import datetime
import re
# ================================================================ # ================================================================
# REQUEST SCHEMAS # REQUEST SCHEMAS
# ================================================================ # ================================================================
class UserRegistration(BaseModel): class UserRegistration(BaseModel):
"""User registration schema""" """User registration request"""
email: EmailStr email: EmailStr
password: str password: str = Field(..., min_length=8, max_length=128)
full_name: str full_name: str = Field(..., min_length=1, max_length=255)
tenant_name: Optional[str] = Field(None, max_length=255)
@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()
class UserLogin(BaseModel): class UserLogin(BaseModel):
"""User login schema""" """User login request"""
email: EmailStr email: EmailStr
password: str password: str
class RefreshTokenRequest(BaseModel): class RefreshTokenRequest(BaseModel):
"""Refresh token request schema""" """Refresh token request"""
refresh_token: str refresh_token: str
class PasswordChange(BaseModel): class PasswordChange(BaseModel):
"""Password change schema""" """Password change request"""
current_password: str current_password: str
new_password: str new_password: str = Field(..., min_length=8, max_length=128)
@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
class PasswordReset(BaseModel): class PasswordReset(BaseModel):
"""Password reset request schema""" """Password reset request"""
email: EmailStr email: EmailStr
class PasswordResetConfirm(BaseModel): class PasswordResetConfirm(BaseModel):
"""Password reset confirmation schema""" """Password reset confirmation"""
token: str token: str
new_password: str new_password: str = Field(..., min_length=8, max_length=128)
@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
# ================================================================ # ================================================================
# RESPONSE SCHEMAS # RESPONSE SCHEMAS
# ================================================================ # ================================================================
class TenantMembership(BaseModel): class UserData(BaseModel):
"""Tenant membership information""" """User data embedded in token responses"""
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"""
id: str id: str
email: str email: str
full_name: str full_name: str
is_active: bool is_active: bool
is_verified: bool is_verified: bool
created_at: datetime created_at: str # ISO format datetime string
last_login: Optional[datetime] = None 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: 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): class TokenVerification(BaseModel):
"""Token verification response""" """Token verification response"""
valid: bool valid: bool
user_id: Optional[str] = None user_id: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
full_name: Optional[str] = None exp: Optional[int] = None
tenants: List[Dict[str, Any]] = [] message: Optional[str] = None
expires_at: Optional[datetime] = None
class UserProfile(BaseModel): class PasswordResetResponse(BaseModel):
"""Extended user profile""" """Password reset response"""
id: str message: str
email: str reset_token: Optional[str] = None
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 Config:
from_attributes = True
# ================================================================ class LogoutResponse(BaseModel):
# UPDATE SCHEMAS """Logout response"""
# ================================================================ message: str
success: bool = True
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
# ================================================================ # ================================================================
# ERROR SCHEMAS # ERROR SCHEMAS
# ================================================================ # ================================================================
class ErrorDetail(BaseModel):
"""Error detail for API responses"""
message: str
code: Optional[str] = None
field: Optional[str] = None
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
"""Error response schema""" """Standardized error response"""
error: str success: bool = False
detail: str error: ErrorDetail
status_code: int timestamp: str
class ValidationErrorResponse(BaseModel): class Config:
"""Validation error response schema""" schema_extra = {
error: str = "validation_error" "example": {
detail: str "success": False,
status_code: int = 422 "error": {
errors: List[Dict[str, Any]] = [] "message": "Invalid credentials",
"code": "AUTH_001"
},
"timestamp": "2025-07-22T10:00:00Z"
}
}
# ================================================================ # ================================================================
# INTERNAL SCHEMAS (for service-to-service communication) # VALIDATION SCHEMAS
# ================================================================ # ================================================================
class UserServiceRequest(BaseModel): class EmailVerificationRequest(BaseModel):
"""Internal user service request""" """Email verification request"""
user_id: str email: EmailStr
action: str
data: Optional[Dict[str, Any]] = None
class TenantAccessRequest(BaseModel): class EmailVerificationConfirm(BaseModel):
"""Tenant access verification request""" """Email verification confirmation"""
user_id: str token: str
tenant_id: str
class TenantAccessResponse(BaseModel): class ProfileUpdate(BaseModel):
"""Tenant access verification response""" """Profile update request"""
has_access: bool full_name: Optional[str] = Field(None, min_length=1, max_length=255)
role: Optional[str] = None email: Optional[EmailStr] = None
tenant_name: Optional[str] = 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 Authentication Service - Updated to support registration with direct token issuance
Handles user authentication without cross-service dependencies
""" """
import logging from datetime import datetime, timezone, timedelta
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from fastapi import HTTPException, status from fastapi import HTTPException, status
import httpx from typing import Dict, Any, Optional
import structlog
from app.models.users import User, RefreshToken from app.models.users import User, RefreshToken
from app.core.security import SecurityManager 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: class AuthService:
"""Authentication service""" """Enhanced Authentication service with unified token response"""
@staticmethod @staticmethod
async def authenticate_user(email: str, password: str, db: AsyncSession) -> Optional[User]: async def register_user_with_tokens(
"""Authenticate user with email and password""" email: str,
try: password: str,
# Get user from database full_name: str,
result = await db.execute( db: AsyncSession
select(User).where( ) -> Dict[str, Any]:
User.email == email, """
User.is_active == True Register new user and return tokens directly (NEW METHOD)
) Follows industry best practices for immediate authentication
) """
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"""
try: try:
# Check if user already exists # Check if user already exists
result = await db.execute( result = await db.execute(select(User).where(User.email == email))
select(User).where(User.email == email)
)
existing_user = result.scalar_one_or_none() existing_user = result.scalar_one_or_none()
if existing_user: if existing_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_409_CONFLICT,
detail="Email already registered" detail="User with this email already exists"
) )
# Create new user # Create new user
hashed_password = SecurityManager.hash_password(password) hashed_password = SecurityManager.hash_password(password)
user = User( new_user = User(
email=email, email=email,
hashed_password=hashed_password, hashed_password=hashed_password,
full_name=full_name, full_name=full_name,
is_active=True, is_active=True,
is_verified=False is_verified=False, # Will be verified via email
created_at=datetime.now(timezone.utc)
) )
db.add(user) db.add(new_user)
await db.commit() await db.flush() # Get user ID without committing
await db.refresh(user)
logger.info(f"User created successfully: {email}") # Generate tokens immediately (shorter lifespan for unverified users)
return user 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: except HTTPException:
await db.rollback()
raise raise
except Exception as e: except Exception as e:
logger.error(f"User creation error: {e}")
await db.rollback() await db.rollback()
logger.error(f"Registration with tokens failed for {email}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create user" detail="Registration failed"
) )
@staticmethod @staticmethod
async def login(email: str, password: str, db: AsyncSession) -> dict: async def create_user(
"""Login user and return tokens""" 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: try:
# Authenticate user # Check if user already exists
user = await AuthService.authenticate_user(email, password, db) result = await db.execute(select(User).where(User.email == email))
if not user: 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( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials" detail="Invalid credentials"
) )
# Create tokens # Create tokens (standard lifespan for verified login)
access_token = SecurityManager.create_access_token( access_token = SecurityManager.create_access_token(
user_data={ user_data={
"user_id": str(user.id), "user_id": str(user.id),
"email": user.email, "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 # Store refresh token in database
refresh_token = RefreshToken( refresh_token = RefreshToken(
user_id=user.id, user_id=user.id,
token=refresh_token_value, 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) db.add(refresh_token)
await db.commit() 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}") logger.info(f"User logged in successfully: {email}")
return { return {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token_value, "refresh_token": refresh_token_value,
"token_type": "bearer", "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: except HTTPException:
@@ -147,10 +249,10 @@ class AuthService:
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Login failed" detail="Login failed"
) )
@staticmethod @staticmethod
async def refresh_access_token(refresh_token: str, db: AsyncSession) -> dict: async def refresh_access_token(refresh_token: str, db: AsyncSession) -> Dict[str, Any]:
"""Refresh access token using refresh token""" """Refresh access token using refresh token (UNCHANGED)"""
try: try:
# Verify refresh token # Verify refresh token
payload = SecurityManager.verify_token(refresh_token) payload = SecurityManager.verify_token(refresh_token)
@@ -164,14 +266,13 @@ class AuthService:
if not user_id: if not user_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, 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( result = await db.execute(
select(RefreshToken).where( select(RefreshToken).where(
RefreshToken.token == refresh_token, RefreshToken.token == refresh_token,
RefreshToken.user_id == user_id,
RefreshToken.is_revoked == False, RefreshToken.is_revoked == False,
RefreshToken.expires_at > datetime.now(timezone.utc) RefreshToken.expires_at > datetime.now(timezone.utc)
) )
@@ -184,10 +285,8 @@ class AuthService:
detail="Invalid or expired refresh token" detail="Invalid or expired refresh token"
) )
# Get user # Get user info
result = await db.execute( result = await db.execute(select(User).where(User.id == user_id))
select(User).where(User.id == user_id, User.is_active == True)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
@@ -201,13 +300,17 @@ class AuthService:
user_data={ user_data={
"user_id": str(user.id), "user_id": str(user.id),
"email": user.email, "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 { return {
"access_token": access_token, "access_token": access_token,
"token_type": "bearer" "token_type": "bearer",
"expires_in": 3600
} }
except HTTPException: except HTTPException:
@@ -221,7 +324,7 @@ class AuthService:
@staticmethod @staticmethod
async def logout(refresh_token: str, db: AsyncSession) -> bool: async def logout(refresh_token: str, db: AsyncSession) -> bool:
"""Logout user by revoking refresh token""" """Logout user by revoking refresh token (UNCHANGED)"""
try: try:
# Revoke refresh token # Revoke refresh token
result = await db.execute( result = await db.execute(
@@ -242,8 +345,8 @@ class AuthService:
return False return False
@staticmethod @staticmethod
async def verify_user_token(token: str) -> dict: async def verify_user_token(token: str) -> Dict[str, Any]:
"""Verify access token and return user info""" """Verify access token and return user info (UNCHANGED)"""
try: try:
payload = SecurityManager.verify_token(token) payload = SecurityManager.verify_token(token)
if not payload: if not payload: