diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..dbc6be2 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,42 @@ +import apiClient from './client' +import { User, AuthResponse } from '../types' + +export interface LoginRequest { + username: string + password: string +} + +export interface RefreshTokenRequest { + refresh_token: string +} + +export interface ChangePasswordRequest { + old_password: string + new_password: string +} + +export const authApi = { + // Login + login: async (data: LoginRequest): Promise => { + const response = await apiClient.post('/auth/login', data) + return response.data + }, + + // Refresh token + refreshToken: async (data: RefreshTokenRequest): Promise => { + const response = await apiClient.post('/auth/refresh', data) + return response.data + }, + + // Get current user + me: async (): Promise => { + const response = await apiClient.get('/auth/me') + return response.data + }, + + // Change password + changePassword: async (data: ChangePasswordRequest): Promise<{ message: string }> => { + const response = await apiClient.post<{ message: string }>('/auth/change-password', data) + return response.data + }, +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..ce212fa --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,46 @@ +import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios' +import { useAuthStore } from '../store/authStore' + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1' + +// Create axios instance +const apiClient: AxiosInstance = axios.create({ + baseURL: API_BASE_URL, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor - add JWT token +apiClient.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const token = useAuthStore.getState().token + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// Response interceptor - handle 401 errors +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + if (error.response?.status === 401) { + // Token expired or invalid - logout user + useAuthStore.getState().logout() + + // Redirect to login if not already there + if (window.location.pathname !== '/login') { + window.location.href = '/login' + } + } + return Promise.reject(error) + } +) + +export default apiClient diff --git a/frontend/src/components/common/Layout.tsx b/frontend/src/components/common/Layout.tsx index e5f3292..8d0023e 100644 --- a/frontend/src/components/common/Layout.tsx +++ b/frontend/src/components/common/Layout.tsx @@ -1,7 +1,10 @@ -import { Outlet, Link, useLocation } from 'react-router-dom' +import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom' +import { useAuth } from '../../hooks/useAuth' export default function Layout() { const location = useLocation() + const navigate = useNavigate() + const { user, logout } = useAuth() const navigation = [ { name: 'Dashboard', path: '/dashboard', icon: '📊' }, @@ -12,6 +15,15 @@ export default function Layout() { { name: 'Settings', path: '/settings', icon: '⚙️' }, ] + const handleLogout = () => { + logout() + navigate('/login') + } + + const getUserInitials = (username: string) => { + return username.substring(0, 2).toUpperCase() + } + return (
{/* Sidebar */} @@ -45,15 +57,28 @@ export default function Layout() { {/* User info */}
-
+
- A + + {user ? getUserInitials(user.username) : 'U'} +
-
-

Admin User

-

admin@example.com

+
+

+ {user?.username || 'User'} +

+

{user?.email}

+

+ {user?.role} +

+
diff --git a/frontend/src/components/common/ProtectedRoute.tsx b/frontend/src/components/common/ProtectedRoute.tsx index f78f87c..fbea1ed 100644 --- a/frontend/src/components/common/ProtectedRoute.tsx +++ b/frontend/src/components/common/ProtectedRoute.tsx @@ -1,17 +1,63 @@ import { ReactNode } from 'react' import { Navigate } from 'react-router-dom' +import { useAuth } from '../../hooks/useAuth' interface ProtectedRouteProps { children: ReactNode + requiredRole?: 'admin' | 'editor' | 'analyst' | 'viewer' } -export default function ProtectedRoute({ children }: ProtectedRouteProps) { - // TODO: Implement actual authentication check - const isAuthenticated = true // Temporary - always allow access +export default function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) { + const { isAuthenticated, isLoading, user } = useAuth() + // Show loading state while checking authentication + if (isLoading) { + return ( +
+
+
+

Loading...

+
+
+ ) + } + + // Redirect to login if not authenticated if (!isAuthenticated) { return } + // Check role-based access if required + if (requiredRole && user) { + const roleHierarchy = { + admin: 4, + editor: 3, + analyst: 2, + viewer: 1, + } + + const userRoleLevel = roleHierarchy[user.role] || 0 + const requiredRoleLevel = roleHierarchy[requiredRole] || 0 + + // User must have equal or higher role level + if (userRoleLevel < requiredRoleLevel) { + return ( +
+
+
🚫
+

Access Denied

+

+ You don't have permission to access this page. +

+

+ Required role: {requiredRole}
+ Your role: {user.role} +

+
+
+ ) + } + } + return <>{children} } diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..924a023 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,34 @@ +import { useAuthStore } from '../store/authStore' +import { useEffect } from 'react' + +export const useAuth = () => { + const { + user, + token, + isAuthenticated, + isLoading, + error, + login, + logout, + checkAuth, + clearError, + } = useAuthStore() + + // Check authentication on mount + useEffect(() => { + if (token && !user) { + checkAuth() + } + }, [token, user, checkAuth]) + + return { + user, + token, + isAuthenticated, + isLoading, + error, + login, + logout, + clearError, + } +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 1a4acf2..557de70 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,9 +1,135 @@ +import { useState, FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../hooks/useAuth' + export default function Login() { + const navigate = useNavigate() + const { login, isLoading, error, clearError } = useAuth() + + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [validationError, setValidationError] = useState('') + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setValidationError('') + clearError() + + // Validation + if (!username.trim()) { + setValidationError('Username is required') + return + } + if (!password) { + setValidationError('Password is required') + return + } + + try { + await login(username, password) + // Redirect to dashboard on successful login + navigate('/dashboard') + } catch (err) { + // Error is handled by auth store + console.error('Login failed:', err) + } + } + return ( -
+
-

VictoriaLogs Manager

-

Login page - To be implemented

+ {/* Logo/Title */} +
+

+ VictoriaLogs Manager +

+

Sign in to your account

+
+ + {/* Error messages */} + {(error || validationError) && ( +
+

+ {validationError || error} +

+
+ )} + + {/* Login form */} +
+
+ + setUsername(e.target.value)} + className="input" + placeholder="Enter your username" + disabled={isLoading} + autoFocus + /> +
+ +
+ + setPassword(e.target.value)} + className="input" + placeholder="Enter your password" + disabled={isLoading} + /> +
+ + +
+ + {/* Default credentials hint (for development) */} +
+

+ Default credentials:
+ admin / admin123 or viewer / viewer123 +

+
) diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts new file mode 100644 index 0000000..44db820 --- /dev/null +++ b/frontend/src/store/authStore.ts @@ -0,0 +1,101 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import { authApi } from '../api/auth' +import { User } from '../types' + +interface AuthState { + user: User | null + token: string | null + refreshToken: string | null + isAuthenticated: boolean + isLoading: boolean + error: string | null + + // Actions + login: (username: string, password: string) => Promise + logout: () => void + checkAuth: () => Promise + clearError: () => void +} + +export const useAuthStore = create()( + persist( + (set, get) => ({ + user: null, + token: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false, + error: null, + + login: async (username: string, password: string) => { + set({ isLoading: true, error: null }) + try { + const response = await authApi.login({ username, password }) + set({ + user: response.user, + token: response.token, + refreshToken: response.refreshToken, + isAuthenticated: true, + isLoading: false, + error: null, + }) + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'Login failed' + set({ + user: null, + token: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false, + error: errorMessage, + }) + throw error + } + }, + + logout: () => { + set({ + user: null, + token: null, + refreshToken: null, + isAuthenticated: false, + error: null, + }) + }, + + checkAuth: async () => { + const token = get().token + if (!token) { + set({ isAuthenticated: false }) + return + } + + try { + const user = await authApi.me() + set({ + user, + isAuthenticated: true, + error: null, + }) + } catch (error) { + // Token invalid - logout + get().logout() + } + }, + + clearError: () => { + set({ error: null }) + }, + }), + { + name: 'auth-storage', + partialize: (state) => ({ + user: state.user, + token: state.token, + refreshToken: state.refreshToken, + isAuthenticated: state.isAuthenticated, + }), + } + ) +)