Implement authentication context and API utility functions for authenticated requests
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 28s
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 28s
This commit is contained in:
82
src/contexts/AuthContext.tsx
Normal file
82
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface AuthUser {
|
||||||
|
steamId: string
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: AuthUser | null
|
||||||
|
token: string | null
|
||||||
|
login: (steamId: string, token: string) => void
|
||||||
|
logout: () => void
|
||||||
|
isAuthenticated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'auth_token'
|
||||||
|
const USER_KEY = 'auth_user'
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null)
|
||||||
|
const [token, setToken] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load auth state from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const storedToken = localStorage.getItem(TOKEN_KEY)
|
||||||
|
const storedUser = localStorage.getItem(USER_KEY)
|
||||||
|
|
||||||
|
if (storedToken && storedUser) {
|
||||||
|
try {
|
||||||
|
const parsedUser = JSON.parse(storedUser)
|
||||||
|
setToken(storedToken)
|
||||||
|
setUser(parsedUser)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse stored user:', error)
|
||||||
|
// Clear invalid data
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem(USER_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = (steamId: string, token: string) => {
|
||||||
|
const authUser: AuthUser = { steamId, token }
|
||||||
|
|
||||||
|
// Store in state
|
||||||
|
setUser(authUser)
|
||||||
|
setToken(token)
|
||||||
|
|
||||||
|
// Store in localStorage
|
||||||
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(authUser))
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
// Clear state
|
||||||
|
setUser(null)
|
||||||
|
setToken(null)
|
||||||
|
|
||||||
|
// Clear localStorage
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem(USER_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = !!token && !!user
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, token, login, logout, isAuthenticated }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext, useState, useEffect } from 'react'
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { apiGet } from '../utils/api'
|
||||||
|
|
||||||
// Response from the new /api/server/statistics/list endpoint
|
// Response from the new /api/server/statistics/list endpoint
|
||||||
interface ServerListResponse {
|
interface ServerListResponse {
|
||||||
@@ -42,10 +43,7 @@ export function ServerProvider({ children }: { children: ReactNode }) {
|
|||||||
const fetchServerData = async () => {
|
const fetchServerData = async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch server list from the new API endpoint
|
// Fetch server list from the new API endpoint
|
||||||
const response = await fetch('/api/server/statistics/list', {
|
const response = await apiGet('/api/server/statistics/list')
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}`)
|
throw new Error(`HTTP ${response.status}`)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext, useState, useEffect } from 'react'
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { apiPost } from '../utils/api'
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
steamID64: string
|
steamID64: string
|
||||||
@@ -52,47 +53,23 @@ export function StatsProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
// Fetch statistics
|
// Fetch statistics
|
||||||
const [playersRes, connectsRes, playTimeRes, killsRes, chatsRes, topPlayersRes] = await Promise.all([
|
const [playersRes, connectsRes, playTimeRes, killsRes, chatsRes, topPlayersRes] = await Promise.all([
|
||||||
fetch('/api/server/statistics/total-player-count', {
|
apiPost('/api/server/statistics/total-player-count', requestBody),
|
||||||
method: 'POST',
|
apiPost('/api/server/statistics/total-connect-count', requestBody),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
apiPost('/api/server/statistics/total-play-time', requestBody),
|
||||||
body: JSON.stringify(requestBody)
|
apiPost('/api/server/statistics/total-kill-count', {
|
||||||
}),
|
|
||||||
fetch('/api/server/statistics/total-connect-count', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(requestBody)
|
|
||||||
}),
|
|
||||||
fetch('/api/server/statistics/total-play-time', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(requestBody)
|
|
||||||
}),
|
|
||||||
fetch('/api/server/statistics/total-kill-count', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
...requestBody,
|
...requestBody,
|
||||||
headshotOnly: false,
|
headshotOnly: false,
|
||||||
weaponFilter: [],
|
weaponFilter: [],
|
||||||
playerFilter: []
|
playerFilter: []
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
fetch('/api/server/statistics/recent-chat-message', {
|
apiPost('/api/server/statistics/recent-chat-message', {
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
timeRangeStart: Date.now() - 3600000, // last hour
|
timeRangeStart: Date.now() - 3600000, // last hour
|
||||||
timeRangeEnd: Date.now()
|
timeRangeEnd: Date.now()
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
fetch('/api/server/statistics/top-play-time', {
|
apiPost('/api/server/statistics/top-play-time', {
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
timeRangeStart: 0, // all time
|
timeRangeStart: 0, // all time
|
||||||
timeRangeEnd: Date.now()
|
timeRangeEnd: Date.now()
|
||||||
})
|
})
|
||||||
})
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const [players, connects, playTime, kills, chats, topPlayersData] = await Promise.all([
|
const [players, connects, playTime, kills, chats, topPlayersData] = await Promise.all([
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import './i18n'
|
import './i18n'
|
||||||
import { ThemeProvider } from './contexts/ThemeContext'
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import { ServerProvider } from './contexts/ServerContext'
|
import { ServerProvider } from './contexts/ServerContext'
|
||||||
import { StatsProvider } from './contexts/StatsContext'
|
import { StatsProvider } from './contexts/StatsContext'
|
||||||
import ScrollToTop from './components/ScrollToTop'
|
import ScrollToTop from './components/ScrollToTop'
|
||||||
@@ -17,6 +18,7 @@ import AuthCallback from './pages/AuthCallback.tsx'
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
<StatsProvider>
|
<StatsProvider>
|
||||||
<ServerProvider>
|
<ServerProvider>
|
||||||
<Router>
|
<Router>
|
||||||
@@ -32,6 +34,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
</Router>
|
</Router>
|
||||||
</ServerProvider>
|
</ServerProvider>
|
||||||
</StatsProvider>
|
</StatsProvider>
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import '../App.css'
|
import '../App.css'
|
||||||
|
|
||||||
interface AuthStatus {
|
interface AuthStatus {
|
||||||
@@ -14,15 +15,22 @@ function AuthCallback() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const [authStatus, setAuthStatus] = useState<AuthStatus>({ status: null })
|
const [authStatus, setAuthStatus] = useState<AuthStatus>({ status: null })
|
||||||
|
const { login } = useAuth()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Parse query parameters
|
// Parse query parameters
|
||||||
const status = searchParams.get('status') as 'success' | 'failed' | 'error' | null
|
const status = searchParams.get('status') as 'success' | 'failed' | 'error' | null
|
||||||
const steamId = searchParams.get('steamId') || undefined
|
const steamId = searchParams.get('steamId') || undefined
|
||||||
|
const token = searchParams.get('token') || undefined
|
||||||
const message = searchParams.get('message') || undefined
|
const message = searchParams.get('message') || undefined
|
||||||
|
|
||||||
setAuthStatus({ status, steamId, message })
|
setAuthStatus({ status, steamId, message })
|
||||||
|
|
||||||
|
// Store JWT token on successful authentication
|
||||||
|
if (status === 'success' && steamId && token) {
|
||||||
|
login(steamId, token)
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-redirect to home after 5 seconds on success
|
// Auto-redirect to home after 5 seconds on success
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -30,7 +38,7 @@ function AuthCallback() {
|
|||||||
}, 5000)
|
}, 5000)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [searchParams, navigate])
|
}, [searchParams, navigate, login])
|
||||||
|
|
||||||
const getStatusIcon = () => {
|
const getStatusIcon = () => {
|
||||||
switch (authStatus.status) {
|
switch (authStatus.status) {
|
||||||
|
|||||||
86
src/utils/api.ts
Normal file
86
src/utils/api.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* API utility for making authenticated requests
|
||||||
|
* Automatically injects JWT token from localStorage into request headers
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RequestOptions extends RequestInit {
|
||||||
|
headers?: HeadersInit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an authenticated API request
|
||||||
|
* @param url - The URL to fetch
|
||||||
|
* @param options - Fetch options (method, body, headers, etc.)
|
||||||
|
* @returns Promise with the response
|
||||||
|
*/
|
||||||
|
export async function apiRequest(url: string, options: RequestOptions = {}): Promise<Response> {
|
||||||
|
// Get token from localStorage
|
||||||
|
const token = localStorage.getItem('auth_token')
|
||||||
|
|
||||||
|
// Prepare headers
|
||||||
|
const headers = new Headers(options.headers || {})
|
||||||
|
|
||||||
|
// Add authorization header if token exists
|
||||||
|
if (token) {
|
||||||
|
headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add content-type if not already set and body exists
|
||||||
|
if (options.body && !headers.has('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'application/json')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the request
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized - token expired or invalid
|
||||||
|
if (response.status === 401) {
|
||||||
|
// Clear auth state
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_user')
|
||||||
|
|
||||||
|
// Optionally redirect to login or emit event
|
||||||
|
window.dispatchEvent(new Event('auth:unauthorized'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for GET requests
|
||||||
|
*/
|
||||||
|
export async function apiGet(url: string, options?: RequestOptions): Promise<Response> {
|
||||||
|
return apiRequest(url, { ...options, method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for POST requests
|
||||||
|
*/
|
||||||
|
export async function apiPost(url: string, data?: unknown, options?: RequestOptions): Promise<Response> {
|
||||||
|
return apiRequest(url, {
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for PUT requests
|
||||||
|
*/
|
||||||
|
export async function apiPut(url: string, data?: unknown, options?: RequestOptions): Promise<Response> {
|
||||||
|
return apiRequest(url, {
|
||||||
|
...options,
|
||||||
|
method: 'PUT',
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for DELETE requests
|
||||||
|
*/
|
||||||
|
export async function apiDelete(url: string, options?: RequestOptions): Promise<Response> {
|
||||||
|
return apiRequest(url, { ...options, method: 'DELETE' })
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user