feat: Complete Milestone 1 frontend authentication (Issues #7-10)
Issue #7: Create Auth Store - Implement Zustand auth store with persistence - Add login, logout, checkAuth actions - Store user, token, and authentication state Issue #8: Create API Client - Setup Axios instance with interceptors - Add JWT token to all requests - Handle 401 errors with automatic logout - Create auth API client functions Issue #9: Create Login Page - Complete login form with validation - Add loading states and error handling - Implement form submission with auth store - Add default credentials hint Issue #10: Update Protected Route Component - Integrate with auth store - Add loading state during auth check - Implement role-based access control - Add access denied UI Additional improvements: - Update Layout with user info and logout button - Add useAuth custom hook Closes #7, #8, #9, #10 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
42
frontend/src/api/auth.ts
Normal file
42
frontend/src/api/auth.ts
Normal file
@@ -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<AuthResponse> => {
|
||||||
|
const response = await apiClient.post<AuthResponse>('/auth/login', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
refreshToken: async (data: RefreshTokenRequest): Promise<AuthResponse> => {
|
||||||
|
const response = await apiClient.post<AuthResponse>('/auth/refresh', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
me: async (): Promise<User> => {
|
||||||
|
const response = await apiClient.get<User>('/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
|
||||||
|
},
|
||||||
|
}
|
||||||
46
frontend/src/api/client.ts
Normal file
46
frontend/src/api/client.ts
Normal file
@@ -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
|
||||||
@@ -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() {
|
export default function Layout() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Dashboard', path: '/dashboard', icon: '📊' },
|
{ name: 'Dashboard', path: '/dashboard', icon: '📊' },
|
||||||
@@ -12,6 +15,15 @@ export default function Layout() {
|
|||||||
{ name: 'Settings', path: '/settings', icon: '⚙️' },
|
{ name: 'Settings', path: '/settings', icon: '⚙️' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserInitials = (username: string) => {
|
||||||
|
return username.substring(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100">
|
<div className="min-h-screen bg-gray-100">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
@@ -45,15 +57,28 @@ export default function Layout() {
|
|||||||
|
|
||||||
{/* User info */}
|
{/* User info */}
|
||||||
<div className="p-4 border-t">
|
<div className="p-4 border-t">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center mb-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary-200 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-full bg-primary-200 flex items-center justify-center">
|
||||||
<span className="text-primary-700 font-medium">A</span>
|
<span className="text-primary-700 font-medium">
|
||||||
|
{user ? getUserInitials(user.username) : 'U'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3">
|
<div className="ml-3 flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-700">Admin User</p>
|
<p className="text-sm font-medium text-gray-700 truncate">
|
||||||
<p className="text-xs text-gray-500">admin@example.com</p>
|
{user?.username || 'User'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
|
||||||
|
<p className="text-xs text-primary-600 font-medium mt-0.5">
|
||||||
|
{user?.role}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
🚪 Logout
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,63 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { Navigate } from 'react-router-dom'
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
requiredRole?: 'admin' | 'editor' | 'analyst' | 'viewer'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
export default function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
|
||||||
// TODO: Implement actual authentication check
|
const { isAuthenticated, isLoading, user } = useAuth()
|
||||||
const isAuthenticated = true // Temporary - always allow access
|
|
||||||
|
|
||||||
|
// Show loading state while checking authentication
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||||
|
<div className="card max-w-md text-center">
|
||||||
|
<div className="text-red-500 text-5xl mb-4">🚫</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 mb-2">Access Denied</h2>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
You don't have permission to access this page.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Required role: <strong>{requiredRole}</strong><br />
|
||||||
|
Your role: <strong>{user.role}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
34
frontend/src/hooks/useAuth.ts
Normal file
34
frontend/src/hooks/useAuth.ts
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,135 @@
|
|||||||
|
import { useState, FormEvent } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
|
||||||
export default function Login() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100">
|
||||||
<div className="card max-w-md w-full">
|
<div className="card max-w-md w-full">
|
||||||
<h1 className="text-2xl font-bold text-center mb-6">VictoriaLogs Manager</h1>
|
{/* Logo/Title */}
|
||||||
<p className="text-center text-gray-600">Login page - To be implemented</p>
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-primary-700 mb-2">
|
||||||
|
VictoriaLogs Manager
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">Sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error messages */}
|
||||||
|
{(error || validationError) && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-red-800 text-sm">
|
||||||
|
{validationError || error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Login form */}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
disabled={isLoading}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Signing in...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Sign in'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Default credentials hint (for development) */}
|
||||||
|
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-600 text-center">
|
||||||
|
<strong>Default credentials:</strong><br />
|
||||||
|
admin / admin123 or viewer / viewer123
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
101
frontend/src/store/authStore.ts
Normal file
101
frontend/src/store/authStore.ts
Normal file
@@ -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<void>
|
||||||
|
logout: () => void
|
||||||
|
checkAuth: () => Promise<void>
|
||||||
|
clearError: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user