login system

This commit is contained in:
2026-06-02 11:31:11 -04:00
parent f6bced78f3
commit a1b120076b
17 changed files with 579 additions and 27 deletions
+58
View File
@@ -13,6 +13,7 @@
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.16.0",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
@@ -1340,6 +1341,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2437,6 +2451,44 @@
"react": "^19.2.6"
}
},
"node_modules/react-router": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz",
"integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz",
"integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==",
"license": "MIT",
"dependencies": {
"react-router": "7.16.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rolldown": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
@@ -2486,6 +2538,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+1
View File
@@ -15,6 +15,7 @@
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.16.0",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
+112 -11
View File
@@ -1,8 +1,10 @@
import { useState, useMemo } from 'react'
import { useMemo, useState } from 'react'
import { Settings } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { useStats } from './hooks/useStats'
import { useTests } from './hooks/useTests'
import { useConfig } from './hooks/useConfig'
import { useAuth } from './hooks/useAuth'
import cgw453Image from './assets/CGW453.PNG'
import StatCard from './components/StatCard'
import CompletionBar from './components/CompletionBar'
@@ -24,12 +26,19 @@ function hasCoePairs(value) {
}
export default function App() {
const queryClient = useQueryClient()
const { user, isReady, isAuthenticated, isAdmin, login, logout } = useAuth()
const [showConfig, setShowConfig] = useState(false)
const [filters, setFilters] = useState({})
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loginError, setLoginError] = useState('')
const [isLoggingIn, setIsLoggingIn] = useState(false)
const { data: stats, isLoading: statsLoading } = useStats()
const { data: allTests = [], isLoading: testsLoading } = useTests()
const { data: config, isLoading: configLoading } = useConfig()
const { data: stats, isLoading: statsLoading } = useStats(isAuthenticated)
const { data: allTests = [], isLoading: testsLoading } = useTests({}, isAuthenticated)
const { data: config, isLoading: configLoading } = useConfig(isAuthenticated && isAdmin)
// Apply filters client-side
const filteredTests = useMemo(() => {
@@ -52,22 +61,114 @@ export default function App() {
})
}, [allTests, filters])
const noConfig = !configLoading && !config?.target_dir && !config?.results_dir
const configuredButEmpty = !configLoading && config?.target_dir && config?.results_dir &&
const noConfig = isAdmin && !configLoading && !config?.target_dir && !config?.results_dir
const configuredButEmpty = isAdmin && !configLoading && config?.target_dir && config?.results_dir &&
!statsLoading && stats?.overall.total === 0
async function handleLogin(e) {
e.preventDefault()
setLoginError('')
setIsLoggingIn(true)
try {
await login(username.trim(), password)
setPassword('')
} catch (err) {
setLoginError(err?.message ?? 'Login failed')
} finally {
setIsLoggingIn(false)
}
}
function handleLogout() {
logout()
queryClient.clear()
setShowConfig(false)
setFilters({})
}
if (!isReady) {
return (
<div className="min-h-screen bg-slate-950 text-slate-200 flex items-center justify-center">
<p className="text-sm text-slate-400">Checking session...</p>
</div>
)
}
if (!isAuthenticated) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center px-4">
<form
onSubmit={handleLogin}
className="w-full max-w-md bg-slate-900 border border-slate-800 rounded-xl p-6 space-y-4"
>
<div>
<h1 className="text-xl font-semibold tracking-tight">Sign in</h1>
</div>
{loginError && (
<div className="bg-red-950/50 border border-red-700 rounded-lg px-3 py-2 text-red-300 text-sm">
{loginError}
</div>
)}
<label className="block text-sm text-slate-300">
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-600"
autoComplete="username"
required
/>
</label>
<label className="block text-sm text-slate-300">
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-600"
autoComplete="current-password"
required
/>
</label>
<button
type="submit"
disabled={isLoggingIn}
className="w-full rounded-lg bg-cyan-700 hover:bg-cyan-600 disabled:opacity-60 px-4 py-2 text-sm font-medium transition-colors"
>
{isLoggingIn ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
)
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col">
{/* Top bar */}
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
<h1 className="text-lg font-bold tracking-tight">CGW453 Test Dashboard</h1>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 hidden md:inline">
{user?.username} ({user?.role})
</span>
{isAdmin && (
<button
onClick={() => setShowConfig(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
>
<Settings size={14} />
Settings
</button>
)}
<button
onClick={() => setShowConfig(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
onClick={handleLogout}
className="px-3 py-1.5 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
>
<Settings size={14} />
Settings
Logout
</button>
</div>
</header>
@@ -155,7 +256,7 @@ export default function App() {
</div>
</main>
{showConfig && <ConfigModal onClose={() => setShowConfig(false)} />}
{isAdmin && showConfig && <ConfigModal onClose={() => setShowConfig(false)} />}
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { createContext, createElement, useContext, useEffect, useMemo, useState } from 'react'
import { getMe, getStoredToken, login as loginApi, setStoredToken } from '../lib/api'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [isReady, setIsReady] = useState(false)
useEffect(() => {
async function initializeAuth() {
const token = getStoredToken()
if (!token) {
setIsReady(true)
return
}
try {
const data = await getMe()
setUser(data?.user ?? null)
} catch {
setStoredToken(null)
setUser(null)
} finally {
setIsReady(true)
}
}
initializeAuth()
}, [])
useEffect(() => {
function handleUnauthorized() {
setUser(null)
}
window.addEventListener('auth:unauthorized', handleUnauthorized)
return () => window.removeEventListener('auth:unauthorized', handleUnauthorized)
}, [])
async function login(username, password) {
const data = await loginApi({ username, password })
setStoredToken(data?.token)
setUser(data?.user ?? null)
return data?.user ?? null
}
function logout() {
setStoredToken(null)
setUser(null)
}
const value = useMemo(
() => ({
user,
isReady,
isAuthenticated: Boolean(user),
isAdmin: user?.role === 'admin',
login,
logout,
}),
[user, isReady]
)
return createElement(AuthContext.Provider, { value }, children)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used inside AuthProvider')
}
return context
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { getConfig, saveConfig } from '../lib/api'
export function useConfig() {
return useQuery({ queryKey: ['config'], queryFn: getConfig })
export function useConfig(enabled = true) {
return useQuery({ queryKey: ['config'], queryFn: getConfig, enabled })
}
export function useSaveConfig() {
+2 -1
View File
@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { getStats } from '../lib/api'
export function useStats() {
export function useStats(enabled = true) {
const statsQuery = useQuery({
queryKey: ['stats'],
queryFn: getStats,
enabled,
})
return {
+2 -1
View File
@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { getTests } from '../lib/api'
export function useTests(filters = {}) {
export function useTests(filters = {}, enabled = true) {
return useQuery({
queryKey: ['tests', filters],
queryFn: () => getTests(filters),
keepPreviousData: true,
enabled,
})
}
+28 -2
View File
@@ -1,10 +1,33 @@
const BASE = '/api'
const TOKEN_KEY = 'dashboard_jwt'
export function getStoredToken() {
return localStorage.getItem(TOKEN_KEY)
}
export function setStoredToken(token) {
if (!token) {
localStorage.removeItem(TOKEN_KEY)
return
}
localStorage.setItem(TOKEN_KEY, token)
}
export async function apiFetch(path, options = {}) {
const token = getStoredToken()
const authHeader = token ? { Authorization: `Bearer ${token}` } : {}
const { headers: customHeaders = {}, ...restOptions } = options
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
...restOptions,
headers: { 'Content-Type': 'application/json', ...authHeader, ...customHeaders },
})
if (res.status === 401) {
setStoredToken(null)
window.dispatchEvent(new Event('auth:unauthorized'))
}
if (!res.ok) {
const text = await res.text().catch(() => res.statusText)
throw new Error(text || res.statusText)
@@ -12,6 +35,9 @@ export async function apiFetch(path, options = {}) {
return res.json()
}
export const login = (body) => apiFetch('/auth/login', { method: 'POST', body: JSON.stringify(body) })
export const getMe = () => apiFetch('/auth/me')
export const getStats = () => apiFetch('/stats')
export const getTests = (params = {}) => {
const qs = new URLSearchParams(
+4 -1
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App.jsx'
import { AuthProvider } from './hooks/useAuth'
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 10_000, retry: 1 } },
@@ -11,7 +12,9 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<AuthProvider>
<App />
</AuthProvider>
</QueryClientProvider>
</StrictMode>,
)