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

This commit is contained in:
2025-10-08 22:44:15 +08:00
parent 9802fb4481
commit 6dbb6ff7fb
6 changed files with 212 additions and 58 deletions

View 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
}

View File

@@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect } from 'react'
import type { ReactNode } from 'react'
import { apiGet } from '../utils/api'
// Response from the new /api/server/statistics/list endpoint
interface ServerListResponse {
@@ -42,10 +43,7 @@ export function ServerProvider({ children }: { children: ReactNode }) {
const fetchServerData = async () => {
try {
// Fetch server list from the new API endpoint
const response = await fetch('/api/server/statistics/list', {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
})
const response = await apiGet('/api/server/statistics/list')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)

View File

@@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect } from 'react'
import type { ReactNode } from 'react'
import { apiPost } from '../utils/api'
interface ChatMessage {
steamID64: string
@@ -52,47 +53,23 @@ export function StatsProvider({ children }: { children: ReactNode }) {
try {
// Fetch statistics
const [playersRes, connectsRes, playTimeRes, killsRes, chatsRes, topPlayersRes] = await Promise.all([
fetch('/api/server/statistics/total-player-count', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
}),
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({
apiPost('/api/server/statistics/total-player-count', requestBody),
apiPost('/api/server/statistics/total-connect-count', requestBody),
apiPost('/api/server/statistics/total-play-time', requestBody),
apiPost('/api/server/statistics/total-kill-count', {
...requestBody,
headshotOnly: false,
weaponFilter: [],
playerFilter: []
})
}),
fetch('/api/server/statistics/recent-chat-message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apiPost('/api/server/statistics/recent-chat-message', {
timeRangeStart: Date.now() - 3600000, // last hour
timeRangeEnd: Date.now()
})
}),
fetch('/api/server/statistics/top-play-time', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apiPost('/api/server/statistics/top-play-time', {
timeRangeStart: 0, // all time
timeRangeEnd: Date.now()
})
})
])
const [players, connects, playTime, kills, chats, topPlayersData] = await Promise.all([

View File

@@ -4,6 +4,7 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import './index.css'
import './i18n'
import { ThemeProvider } from './contexts/ThemeContext'
import { AuthProvider } from './contexts/AuthContext'
import { ServerProvider } from './contexts/ServerContext'
import { StatsProvider } from './contexts/StatsContext'
import ScrollToTop from './components/ScrollToTop'
@@ -17,6 +18,7 @@ import AuthCallback from './pages/AuthCallback.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<AuthProvider>
<StatsProvider>
<ServerProvider>
<Router>
@@ -32,6 +34,7 @@ createRoot(document.getElementById('root')!).render(
</Router>
</ServerProvider>
</StatsProvider>
</AuthProvider>
</ThemeProvider>
</StrictMode>,
)

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import '../App.css'
interface AuthStatus {
@@ -14,15 +15,22 @@ function AuthCallback() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [authStatus, setAuthStatus] = useState<AuthStatus>({ status: null })
const { login } = useAuth()
useEffect(() => {
// Parse query parameters
const status = searchParams.get('status') as 'success' | 'failed' | 'error' | null
const steamId = searchParams.get('steamId') || undefined
const token = searchParams.get('token') || undefined
const message = searchParams.get('message') || undefined
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
if (status === 'success') {
const timer = setTimeout(() => {
@@ -30,7 +38,7 @@ function AuthCallback() {
}, 5000)
return () => clearTimeout(timer)
}
}, [searchParams, navigate])
}, [searchParams, navigate, login])
const getStatusIcon = () => {
switch (authStatus.status) {

86
src/utils/api.ts Normal file
View 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' })
}