diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..ad4e634 --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -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(undefined) + +const TOKEN_KEY = 'auth_token' +const USER_KEY = 'auth_user' + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [token, setToken] = useState(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 ( + + {children} + + ) +} + +export function useAuth() { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} diff --git a/src/contexts/ServerContext.tsx b/src/contexts/ServerContext.tsx index 04dc733..49c8620 100644 --- a/src/contexts/ServerContext.tsx +++ b/src/contexts/ServerContext.tsx @@ -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}`) diff --git a/src/contexts/StatsContext.tsx b/src/contexts/StatsContext.tsx index 3762e15..274b118 100644 --- a/src/contexts/StatsContext.tsx +++ b/src/contexts/StatsContext.tsx @@ -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,46 +53,22 @@ 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) + 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/total-connect-count', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) + apiPost('/api/server/statistics/recent-chat-message', { + timeRangeStart: Date.now() - 3600000, // last hour + timeRangeEnd: Date.now() }), - 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, - headshotOnly: false, - weaponFilter: [], - playerFilter: [] - }) - }), - fetch('/api/server/statistics/recent-chat-message', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - 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({ - timeRangeStart: 0, // all time - timeRangeEnd: Date.now() - }) + apiPost('/api/server/statistics/top-play-time', { + timeRangeStart: 0, // all time + timeRangeEnd: Date.now() }) ]) diff --git a/src/main.tsx b/src/main.tsx index f21aea5..ee12b36 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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,21 +18,23 @@ import AuthCallback from './pages/AuthCallback.tsx' createRoot(document.getElementById('root')!).render( - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - - - - + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + + , ) diff --git a/src/pages/AuthCallback.tsx b/src/pages/AuthCallback.tsx index e20a9e8..5be8de6 100644 --- a/src/pages/AuthCallback.tsx +++ b/src/pages/AuthCallback.tsx @@ -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({ 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) { diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 0000000..fb149d8 --- /dev/null +++ b/src/utils/api.ts @@ -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 { + // 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 { + return apiRequest(url, { ...options, method: 'GET' }) +} + +/** + * Convenience method for POST requests + */ +export async function apiPost(url: string, data?: unknown, options?: RequestOptions): Promise { + 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 { + 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 { + return apiRequest(url, { ...options, method: 'DELETE' }) +}