- Fixed Challenge Modal overlap issue by adjusting the main stacking context in App.tsx

- Implemented "click-outside-to-close" functionality for both the Challenge Modal and User Dropdown
- Added protocol-specific action buttons for challenges: "Open in new tab" for HTTP and "Copy to clipboard" for NC
- Enhanced Scoreboard rankings with significantly larger, consistent font sizes (text-2xl) for better readability
- Rebranded "TEAM_IDENTIFIER" to "PLAYER" and "TOTAL_POINTS" to "POINTS" across the platform (Scoreboard, Matrix, User Menu)
- Updated navigation: Renamed "SCOREBOARD" to "SCORES" in the nav bar and dynamic page titles
- Modernized User Dropdown menu with a dedicated "PLAYER" header and "LOGOUT" action
- Improved Score Matrix and Score Graph titles for consistency with the new "Player" terminology
- Added CAPTCHA human verification (svg-captcha) to Login and Registration flows for enhanced security
- Optimized frontend assets by migrating Tailwind and JetBrains Mono to local hosting
- Refactored Admin panel: Renamed "Operators" to "Users" and improved layout alignment
This commit is contained in:
m0rph3us1987
2026-03-11 17:47:46 +01:00
parent 27566a7813
commit 0d07264788
20 changed files with 753 additions and 489 deletions

View File

@@ -23,7 +23,7 @@ export const Admin: React.FC = () => {
return [...state.challenges].sort((a, b) => a.title.localeCompare(b.title));
}, [state.challenges]);
const sortedOperators = useMemo(() => {
const sortedUsers = useMemo(() => {
return [...state.teams]
.filter(t => t.id !== 'admin-0')
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
@@ -107,8 +107,7 @@ export const Admin: React.FC = () => {
return (
<div className="max-w-6xl mx-auto py-12 px-6">
<div className="flex flex-wrap gap-4 justify-between items-center mb-12 border-b-4 border-[#ff0000] pb-6">
<h2 className="text-5xl font-black italic text-white uppercase tracking-tighter">ADMIN_CONSOLE</h2>
<div className="flex flex-wrap gap-4 justify-end items-center mb-12 border-b-4 border-[#ff0000] pb-6">
<div className="flex gap-4 items-center">
{isBeforeStart && (
<div className="px-4 py-2 hxp-border-purple bg-[#bf00ff]/10 hidden sm:flex items-center gap-3">
@@ -256,19 +255,19 @@ export const Admin: React.FC = () => {
</section>
<section className="space-y-6">
<h3 className="text-2xl font-black text-[#bf00ff] border-b-2 border-[#bf00ff] uppercase italic">OPERATORS</h3>
<h3 className="text-2xl font-black text-[#bf00ff] border-b-2 border-[#bf00ff] uppercase italic">USERS</h3>
<div className="hxp-border border-2 overflow-hidden bg-black">
<table className="w-full text-[10px] font-black">
<thead className="bg-[#333] uppercase">
<tr>
<th className="p-3 text-left">TEAM_IDENTIFIER</th>
<th className="p-3 text-left">USER_IDENTIFIER</th>
<th className="p-3 text-center">ROLE</th>
<th className="p-3 text-center">STATUS</th>
<th className="p-3 text-right">ACTIONS</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10 italic">
{sortedOperators.map(team => (
{sortedUsers.map(team => (
<tr key={team.id} className="hover:bg-white/5 transition-colors group">
<td className="p-3 text-white text-sm">
<div className="flex items-center gap-2">
@@ -283,7 +282,7 @@ export const Admin: React.FC = () => {
title={team.isAdmin ? "Revoke Admin Privileges" : "Grant Admin Privileges"}
>
{team.isAdmin ? <ShieldCheck size={12} /> : <Shield size={12} />}
<span>{team.isAdmin ? 'ADMIN' : 'OPERATOR'}</span>
<span>{team.isAdmin ? 'ADMIN' : 'USER'}</span>
</button>
</td>
<td className="p-3 text-center uppercase">
@@ -304,9 +303,9 @@ export const Admin: React.FC = () => {
</td>
</tr>
))}
{sortedOperators.length === 0 && (
{sortedUsers.length === 0 && (
<tr>
<td colSpan={4} className="p-10 text-center text-slate-700 font-black italic uppercase tracking-widest text-xs">No registered operators detected.</td>
<td colSpan={4} className="p-10 text-center text-slate-700 font-black italic uppercase tracking-widest text-xs">No registered users detected.</td>
</tr>
)}
</tbody>
@@ -451,7 +450,7 @@ export const Admin: React.FC = () => {
<div className="fixed inset-0 bg-black/95 z-[400] flex items-center justify-center p-4">
<div className="hxp-border border-4 max-md w-full max-w-md bg-black p-8 relative">
<button onClick={() => setEditingTeam(null)} className="absolute top-4 right-4 text-white hover:text-red-500 transition-colors"><X size={24}/></button>
<h3 className="text-3xl font-black italic text-white mb-8 uppercase">OPERATOR_PROFILE</h3>
<h3 className="text-3xl font-black italic text-white mb-8 uppercase">USER_PROFILE</h3>
<form onSubmit={async e => { e.preventDefault(); await updateTeam(editingTeam.id as string, { name: editingTeam.name, isDisabled: editingTeam.isDisabled, isAdmin: editingTeam.isAdmin, password: editingTeam.newPassword }); setEditingTeam(null); }} className="space-y-4">
<input className="w-full bg-black hxp-border-purple p-3 text-white font-black" value={editingTeam.name} onChange={e => setEditingTeam({...editingTeam, name: e.target.value})} required />
<input type="password" placeholder="UPDATE SECRET_KEY (OPTIONAL)" className="w-full bg-black hxp-border-purple p-3 text-white font-black" onChange={e => setEditingTeam({...editingTeam, newPassword: e.target.value})} />

128
App.tsx
View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { HashRouter, Routes, Route, Link, Navigate } from 'react-router-dom';
import { Terminal, Flag, Trophy, Newspaper, Shield, Settings, LogOut, X, History } from 'lucide-react';
import React, { useState, useEffect, useRef } from 'react';
import { HashRouter, Routes, Route, Link, Navigate, useLocation } from 'react-router-dom';
import { Terminal, Flag, Trophy, Newspaper, Settings, LogOut, X, History, User, ChevronDown } from 'lucide-react';
import { CTFProvider, useCTF } from './CTFContext';
import { ProtectedRoute, Button, Countdown } from './UIComponents';
import { Home } from './Home';
@@ -10,7 +10,7 @@ import { Blog } from './Blog';
import { Scoreboard, ScoreMatrix } from './Scoreboard';
import { Admin } from './Admin';
import { Login, Register } from './Auth';
import { Log } from './Log';
import { calculateTeamTotalScore } from './services/scoring';
const ProfileSettingsModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const { updateProfile } = useCTF();
@@ -43,6 +43,21 @@ const ProfileSettingsModal: React.FC<{ onClose: () => void }> = ({ onClose }) =>
const LayoutShell: React.FC = () => {
const { state, currentUser, logout, loading, loadError } = useCTF();
const [showProfileModal, setShowProfileModal] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const location = useLocation();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowUserMenu(false);
}
};
if (showUserMenu) {
document.body.addEventListener('mousedown', handleClickOutside);
}
return () => document.body.removeEventListener('mousedown', handleClickOutside);
}, [showUserMenu]);
useEffect(() => {
if (state.config && state.config.conferenceName) {
@@ -59,6 +74,22 @@ const LayoutShell: React.FC = () => {
const bgStyles: React.CSSProperties = { backgroundColor: state.config.bgType === 'color' ? (state.config.bgColor || '#000000') : '#000000' };
const userScore = currentUser ? calculateTeamTotalScore(currentUser.id, state.challenges, state.solves) : 0;
const getPageTitle = () => {
const path = location.pathname;
if (path === '/') return { text: 'HOME', icon: <Terminal size={28} /> };
if (path === '/challenges') return { text: 'CHALLENGES', icon: <Flag size={28} /> };
if (path === '/blog') return { text: 'BLOG', icon: <Newspaper size={28} /> };
if (path === '/scoreboard' || path === '/matrix') return { text: 'SCORES', icon: <Trophy size={28} /> };
if (path === '/admin') return { text: 'ADMIN PANEL', icon: <Settings size={28} /> };
if (path === '/login') return { text: 'LOGIN', icon: <User size={28} /> };
if (path === '/register') return { text: 'REGISTER', icon: <User size={28} /> };
return { text: '', icon: null };
};
const pageTitle = getPageTitle();
return (
<div style={bgStyles} className="min-h-screen text-white font-mono selection:bg-[#ff0000] selection:text-black relative overflow-x-hidden">
{loadError && <div className="fixed top-0 left-0 right-0 bg-[#ff0000] text-black font-black text-center py-1 z-[999] text-[10px] italic">{loadError}</div>}
@@ -70,35 +101,93 @@ const LayoutShell: React.FC = () => {
filter: `brightness(${state.config.bgBrightness || '1.0'}) contrast(${state.config.bgContrast || '1.0'})`
}} />
)}
<nav className="border-b-4 border-[#333] px-6 py-4 flex justify-between items-center sticky top-0 bg-black/80 backdrop-blur-md z-[100]">
<Link to="/" className="flex items-center gap-3 group">
<nav className="border-b-4 border-[#333] px-6 py-4 sticky top-0 bg-black/80 backdrop-blur-md z-[100]">
<div className="grid grid-cols-3 items-center">
{/* Left: Branding */}
<Link to="/" className="flex items-center gap-3 group justify-self-start">
{state.config.logoData ? <img src={state.config.logoData} className="w-8 h-8 object-contain" /> : <Terminal className="text-[#ff0000]" />}
<span className="text-2xl font-black italic uppercase group-hover:text-[#ff0000] transition-colors tracking-tighter">{state.config.conferenceName}</span>
<span className="text-2xl font-black italic uppercase group-hover:text-[#ff0000] transition-colors tracking-tighter truncate max-w-[200px]">{state.config.conferenceName}</span>
</Link>
<div className="flex items-center gap-8">
<div className="hidden md:flex gap-8 text-[10px] font-black uppercase tracking-widest">
{currentUser ? <Link to="/challenges" className="hover:text-[#bf00ff] flex items-center gap-2 transition-colors"><Flag size={14}/> Challenges</Link> : null}
<Link to="/blog" className="hover:text-[#bf00ff] flex items-center gap-2 transition-colors"><Newspaper size={14}/> Blog</Link>
{currentUser ? <Link to="/scoreboard" className="hover:text-[#bf00ff] flex items-center gap-2 transition-colors"><Trophy size={14}/> Scoreboard</Link> : null}
{currentUser ? <Link to="/log" className="hover:text-[#bf00ff] flex items-center gap-2 transition-colors"><History size={14}/> Log</Link> : null}
{currentUser?.isAdmin ? <Link to="/admin" className="text-[#ff0000] hover:text-white transition-colors flex items-center gap-2"><Shield size={14}/> Admin</Link> : null}
{/* Middle: Dynamic Page Title */}
<div className="flex justify-center text-center">
<div className="flex items-center gap-3 text-white">
{pageTitle.icon && <div className="text-[#bf00ff]">{pageTitle.icon}</div>}
<span className="text-3xl font-black italic uppercase tracking-tighter truncate">
{pageTitle.text}
</span>
</div>
</div>
{/* Right: Menu & User */}
<div className="flex items-center gap-6 justify-self-end">
<div className="hidden lg:flex gap-8 text-xs font-black uppercase tracking-widest">
{currentUser ? <Link to="/challenges" className="hover:text-[#bf00ff] flex items-center gap-2 transition-colors"><Flag size={16}/> CHALLENGES</Link> : null}
<Link to="/blog" className="hover:text-[#bf00ff] flex items-center gap-2 transition-colors"><Newspaper size={16}/> BLOG</Link>
{currentUser ? <Link to="/scoreboard" className="hover:text-[#bf00ff] flex items-center gap-2 transition-colors"><Trophy size={16}/> SCORES</Link> : null}
</div>
{isEventLive && (
<div className="px-4 py-2 hxp-border-purple bg-[#bf00ff]/10 hidden sm:block">
<Countdown target={eventEndTime} label="Time Left" />
</div>
)}
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 relative">
{currentUser ? (
<div className="flex items-center gap-3">
<button onClick={() => setShowProfileModal(true)} title="Profile Settings" className="p-2 border-2 border-[#bf00ff] text-[#bf00ff] hover:bg-[#bf00ff] hover:text-black transition-all"><Settings size={18} /></button>
<button onClick={logout} title="Sign Out" className="p-2 border-2 border-[#ff0000] text-[#ff0000] hover:bg-[#ff0000] hover:text-black transition-all"><LogOut size={18} /></button>
<div className="relative" ref={menuRef}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className={`flex items-center gap-2 px-3 py-2 border-2 transition-all ${showUserMenu ? 'bg-[#bf00ff] text-black border-[#bf00ff]' : 'border-[#333] text-white hover:border-[#bf00ff]'}`}
>
<User size={18} />
<span className="text-xs font-black truncate max-w-[120px] hidden sm:inline">{currentUser.name}</span>
<ChevronDown size={14} className={`transition-transform ${showUserMenu ? 'rotate-180' : ''}`} />
</button>
{showUserMenu && (
<div className="absolute right-0 mt-2 w-64 bg-black border-2 border-[#bf00ff] shadow-[0_0_20px_rgba(191,0,255,0.2)] z-[50] animate-in fade-in slide-in-from-top-2 duration-200">
<div className="p-4 border-b border-[#333] bg-[#bf00ff]/5">
<p className="text-xs font-black text-slate-500 uppercase tracking-widest mb-1">PLAYER</p>
<p className="text-lg font-black text-white truncate">{currentUser.name}</p>
<div className="mt-2 flex items-center gap-2">
<span className="text-base font-black bg-[#bf00ff] text-black px-2 py-0.5">SCORE: {userScore} PTS</span>
</div>
</div>
<div className="p-2 flex flex-col gap-1">
{currentUser.isAdmin && (
<Link
to="/admin"
onClick={() => setShowUserMenu(false)}
className="flex items-center gap-3 px-3 py-2 text-xs font-black uppercase text-[#ff0000] hover:bg-[#ff0000]/10 transition-colors"
>
<Settings size={14} /> Admin Panel
</Link>
)}
<button
onClick={() => { setShowProfileModal(true); setShowUserMenu(false); }}
className="flex items-center gap-3 px-3 py-2 text-xs font-black uppercase text-white hover:bg-white/10 transition-colors text-left"
>
<Settings size={14} /> Change Password
</button>
<div className="h-px bg-[#333] my-1" />
<button
onClick={() => { logout(); setShowUserMenu(false); }}
className="flex items-center gap-3 px-3 py-2 text-xs font-black uppercase text-red-500 hover:bg-red-500/10 transition-colors text-left"
>
<LogOut size={14} /> LOGOUT
</button>
</div>
</div>
)}
</div>
) : <Link to="/login"><Button variant="secondary" className="text-[10px] py-1 px-4">SIGN_IN</Button></Link>}
</div>
</div>
</div>
</nav>
<main className="relative z-10">
<main className="relative">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
@@ -106,7 +195,6 @@ const LayoutShell: React.FC = () => {
<Route path="/challenges" element={<ProtectedRoute><ChallengeList /></ProtectedRoute>} />
<Route path="/blog" element={<Blog />} />
<Route path="/scoreboard" element={<ProtectedRoute><Scoreboard /></ProtectedRoute>} />
<Route path="/log" element={<ProtectedRoute><Log /></ProtectedRoute>} />
<Route path="/matrix" element={<ProtectedRoute><ScoreMatrix /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute>{currentUser?.isAdmin ? <Admin /> : <Navigate to="/" />}</ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" />} />

124
Auth.tsx
View File

@@ -6,34 +6,81 @@ import { useCTF } from './CTFContext';
import { Button } from './UIComponents';
export const Login: React.FC = () => {
const { login } = useCTF();
const { login, getCaptcha } = useCTF();
const navigate = useNavigate();
const location = useLocation();
const [name, setName] = useState('');
const [pass, setPass] = useState('');
const [error, setError] = useState('');
// CAPTCHA State
const [captchaData, setCaptchaData] = useState<{ id: string, data: string } | null>(null);
const [captchaInput, setCaptchaInput] = useState('');
const fetchCaptcha = async () => {
try {
const res = await getCaptcha();
setCaptchaData(res);
setCaptchaInput('');
} catch (err) {
setError('FAILED_TO_LOAD_CAPTCHA');
}
};
useEffect(() => {
fetchCaptcha();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (await login(name, pass)) {
if (!captchaData) return;
if (await login(name, pass, captchaData.id, captchaInput)) {
const from = (location.state as any)?.from?.pathname || '/';
navigate(from, { replace: true });
} else setError('ACCESS_DENIED: INVALID_CREDENTIALS');
} else {
setError('ACCESS_DENIED: INVALID_CREDENTIALS_OR_CAPTCHA');
fetchCaptcha();
}
};
return (
<div className="min-h-[80vh] flex items-center justify-center px-6">
<div className="w-full max-w-md">
<div className="hxp-border border-4 p-10 bg-black relative">
<div className="absolute -top-4 -left-4 bg-[#ff0000] text-black font-black px-4 py-1 text-xs italic">AUTH_GATE_01</div>
<h2 className="text-5xl font-black italic text-white mb-8 tracking-tighter uppercase">SIGN_IN</h2>
<h2 className="text-5xl font-black italic text-white mb-8 tracking-tighter uppercase">LOGIN</h2>
<form onSubmit={handleSubmit} className="space-y-6">
<input placeholder="TEAM_IDENTIFIER" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={name} onChange={e => setName(e.target.value)} required />
<input type="password" placeholder="SECRET_KEY" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={pass} onChange={e => setPass(e.target.value)} required />
<input placeholder="PLAYER" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={name} onChange={e => setName(e.target.value)} required />
<input type="password" placeholder="PASSWORD" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={pass} onChange={e => setPass(e.target.value)} required />
<div className="space-y-2 border-t border-white/10 pt-4">
<div className="flex justify-between items-center mb-2">
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Human Verification</label>
<button type="button" onClick={fetchCaptcha} className="text-[#bf00ff] hover:text-white transition-colors" title="Regenerate CAPTCHA">
<RefreshCw size={14} />
</button>
</div>
{captchaData ? (
<div
className="bg-white/5 p-2 hxp-border-purple flex justify-center mb-4"
dangerouslySetInnerHTML={{ __html: captchaData.data }}
/>
) : <div className="h-16 bg-white/5 animate-pulse mb-4" />}
<input
placeholder="ENTER CODE"
className="w-full bg-black hxp-border-purple p-4 text-white font-black"
value={captchaInput}
onChange={e => setCaptchaInput(e.target.value)}
required
/>
</div>
{error && <p className="text-red-500 font-black italic animate-pulse uppercase">{error}</p>}
<Button type="submit" className="w-full py-4 text-xl uppercase">Initiate Session</Button>
<Button type="submit" className="w-full py-4 text-xl uppercase">LOGIN</Button>
</form>
<p className="mt-8 text-center text-[10px] font-bold text-slate-500 tracking-widest uppercase">New operator? <Link to="/register" className="text-[#bf00ff] hover:underline">Register_Identity</Link></p>
<p className="mt-8 text-center text-[10px] font-bold text-slate-500 tracking-widest uppercase">New user? <Link to="/register" className="text-[#bf00ff] hover:underline">REGISTER</Link></p>
</div>
</div>
</div>
@@ -41,46 +88,40 @@ export const Login: React.FC = () => {
};
export const Register: React.FC = () => {
const { register } = useCTF();
const { register, getCaptcha } = useCTF();
const navigate = useNavigate();
const [name, setName] = useState('');
const [pass, setPass] = useState('');
const [error, setError] = useState('');
// CAPTCHA State
const [captchaQ, setCaptchaQ] = useState('');
const [captchaData, setCaptchaData] = useState<{ id: string, data: string } | null>(null);
const [captchaInput, setCaptchaInput] = useState('');
const generateCaptcha = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 6; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
setCaptchaQ(result);
const fetchCaptcha = async () => {
try {
const res = await getCaptcha();
setCaptchaData(res);
setCaptchaInput('');
} catch (err) {
setError('FAILED_TO_LOAD_CAPTCHA');
}
};
useEffect(() => {
generateCaptcha();
fetchCaptcha();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// CAPTCHA Validation (Case-insensitive)
if (captchaInput.toLowerCase() !== captchaQ.toLowerCase()) {
setError('VALIDATION_ERROR: HUMAN_VERIFICATION_FAILED');
generateCaptcha();
return;
}
if (!captchaData) return;
try {
await register(name, pass);
await register(name, pass, captchaData.id, captchaInput);
navigate('/challenges');
} catch (err: any) {
setError(err.message || 'REGISTRATION_FAILED');
generateCaptcha();
fetchCaptcha();
}
};
@@ -88,28 +129,35 @@ export const Register: React.FC = () => {
<div className="min-h-[80vh] flex items-center justify-center px-6">
<div className="w-full max-w-md">
<div className="hxp-border border-4 p-10 bg-black relative">
<div className="absolute -top-4 -left-4 bg-[#bf00ff] text-black font-black px-4 py-1 text-xs italic">IDENTITY_GEN_01</div>
<h2 className="text-5xl font-black italic text-white mb-8 tracking-tighter uppercase">REGISTER</h2>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Team Identifier</label>
<input placeholder="DESIRED_IDENTIFIER" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={name} onChange={e => setName(e.target.value)} required />
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Player name</label>
<input placeholder="DESIRED NAME" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={name} onChange={e => setName(e.target.value)} required />
</div>
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Secret Key</label>
<input type="password" placeholder="SECRET_KEY" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={pass} onChange={e => setPass(e.target.value)} required />
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">PASSWORD</label>
<input type="password" placeholder="PASSWORD" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={pass} onChange={e => setPass(e.target.value)} required />
</div>
<div className="space-y-2 border-t border-white/10 pt-4">
<div className="flex justify-between items-center">
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Human Verification: <span className="text-[#00ff00] font-mono tracking-widest bg-white/5 px-2 py-1">{captchaQ}</span></label>
<button type="button" onClick={generateCaptcha} className="text-[#bf00ff] hover:text-white transition-colors" title="Regenerate CAPTCHA">
<div className="flex justify-between items-center mb-2">
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Human Verification</label>
<button type="button" onClick={fetchCaptcha} className="text-[#bf00ff] hover:text-white transition-colors" title="Regenerate CAPTCHA">
<RefreshCw size={14} />
</button>
</div>
{captchaData ? (
<div
className="bg-white/5 p-2 hxp-border-purple flex justify-center mb-4"
dangerouslySetInnerHTML={{ __html: captchaData.data }}
/>
) : <div className="h-16 bg-white/5 animate-pulse mb-4" />}
<input
placeholder="ENTER_CODE"
placeholder="ENTER CODE"
className="w-full bg-black hxp-border-purple p-4 text-white font-black"
value={captchaInput}
onChange={e => setCaptchaInput(e.target.value)}
@@ -118,7 +166,7 @@ export const Register: React.FC = () => {
</div>
{error && <p className="text-red-500 font-black italic animate-pulse uppercase text-[10px]">{error}</p>}
<Button type="submit" className="w-full py-4 text-xl uppercase">Create Identity</Button>
<Button type="submit" className="w-full py-4 text-xl uppercase">REGISTER</Button>
</form>
</div>
</div>

View File

@@ -6,8 +6,7 @@ import { useCTF } from './CTFContext';
export const Blog: React.FC = () => {
const { state } = useCTF();
return (
<div className="max-w-4xl mx-auto py-20 px-6">
<div className="mb-12 border-b-4 border-[#ff0000] pb-6"><h2 className="text-6xl font-black italic text-white uppercase flex items-center gap-4"><Newspaper size={48} className="text-[#ff0000]" /> BLOG_FEED</h2></div>
<div className="max-w-4xl mx-auto py-12 px-6">
<div className="space-y-12">
{state.blogs.map((post) => (
<div key={post.id} className="hxp-border-purple bg-white/5 p-8 relative group">

View File

@@ -5,8 +5,9 @@ import { api } from './services/api';
interface CTFContextType {
state: CTFState;
currentUser: Team | null;
login: (name: string, pass: string) => Promise<boolean>;
register: (name: string, pass: string) => Promise<void>;
login: (name: string, pass: string, captchaId: string, captchaAnswer: string) => Promise<boolean>;
register: (name: string, pass: string, captchaId: string, captchaAnswer: string) => Promise<void>;
getCaptcha: () => Promise<{ id: string, data: string }>;
logout: () => void;
submitFlag: (challengeId: string, flag: string) => Promise<boolean>;
toggleCtf: () => Promise<void>;
@@ -94,8 +95,19 @@ export const CTFProvider: React.FC<{ children: React.ReactNode }> = ({ children
return () => clearInterval(interval);
}, [refreshState]);
const login = async (n: string, p: string) => { try { const { team, token } = await api.login(n, p); localStorage.setItem('hip6_session', JSON.stringify({ team, token })); setCurrentUser(team); await refreshState(); return true; } catch (e) { return false; } };
const register = async (n: string, p: string) => { const { team, token } = await api.register(n, p); localStorage.setItem('hip6_session', JSON.stringify({ team, token })); setCurrentUser(team); await refreshState(); };
const login = async (n: string, p: string, cid: string, cans: string) => {
try {
const { team, token } = await api.login(n, p, cid, cans);
localStorage.setItem('hip6_session', JSON.stringify({ team, token }));
setCurrentUser(team);
await refreshState();
return true;
} catch (e) {
return false;
}
};
const register = async (n: string, p: string, cid: string, cans: string) => { const { team, token } = await api.register(n, p, cid, cans); localStorage.setItem('hip6_session', JSON.stringify({ team, token })); setCurrentUser(team); await refreshState(); };
const getCaptcha = async () => await api.getCaptcha();
const logout = () => { localStorage.removeItem('hip6_session'); setCurrentUser(null); };
const submitFlag = async (cid: string, f: string) => { const res = await api.submitFlag(cid, f); await refreshState(); return res.success; };
const toggleCtf = async () => { await api.toggleCtf(); await refreshState(); };
@@ -109,7 +121,7 @@ export const CTFProvider: React.FC<{ children: React.ReactNode }> = ({ children
const restoreDatabase = async (f: File) => await api.restoreDatabase(f);
const updateTeam = async (id: string, d: any) => { await api.updateTeam(id, d); await refreshState(); };
const updateProfile = async (p?: string) => { await api.updateProfile({ password: p }); await refreshState(); };
const deleteTeam = async (id: string) => { if (window.confirm("EXPEL_OPERATOR?")) { await api.deleteTeam(id); await refreshState(); } };
const deleteTeam = async (id: string) => { if (window.confirm("EXPEL_USER?")) { await api.deleteTeam(id); await refreshState(); } };
const deleteSolve = async (teamId: string, challengeId: string) => { if (window.confirm("DELETE_SOLVE?")) { await api.deleteSolve(teamId, challengeId); await refreshState(); } };
const createBlogPost = async (d: any) => { await api.createBlogPost(d); await refreshState(); };
const updateBlogPost = async (id: string, d: any) => { await api.updateBlogPost(id, d); await refreshState(); };
@@ -118,7 +130,7 @@ export const CTFProvider: React.FC<{ children: React.ReactNode }> = ({ children
return (
<CTFContext.Provider value={{
state, currentUser, login, register, logout, submitFlag, toggleCtf, resetScores,
state, currentUser, login, register, getCaptcha, logout, submitFlag, toggleCtf, resetScores,
upsertChallenge, deleteChallenge, deleteAllChallenges, exportChallenges,
importChallenges, backupDatabase, restoreDatabase, updateTeam, updateProfile,
deleteTeam, deleteSolve, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig,

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { X, CheckCircle2, Download, Globe, Sparkles, Heart, Clock, Bell } from 'lucide-react';
import { X, CheckCircle2, Download, Globe, Sparkles, Heart, Clock, Bell, Copy, ExternalLink } from 'lucide-react';
import { useCTF } from './CTFContext';
import { Challenge, Difficulty } from './types';
import { CATEGORIES } from './constants';
@@ -43,8 +43,8 @@ const ChallengeModal: React.FC<{ challenge: Challenge; onClose: () => void; }> =
const difficultyColor = challenge.difficulty === 'Low' ? 'text-[#00ff00]' : challenge.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]';
return (
<div className="fixed inset-0 bg-black/95 z-[300] flex items-center justify-center p-4">
<div className="hxp-border border-4 max-w-2xl w-full bg-black p-8 relative overflow-y-auto max-h-[90vh] custom-scrollbar">
<div className="fixed inset-0 bg-black/95 z-[300] flex items-center justify-center p-4" onClick={onClose}>
<div className="hxp-border border-4 max-w-2xl w-full bg-black p-8 relative overflow-y-auto max-h-[90vh] custom-scrollbar" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} className="absolute top-4 right-4 bg-[#ff0000] text-black font-black p-2 hover:bg-white transition-colors"><X size={24} /></button>
<div className="mb-8">
<h3 className="text-4xl font-black italic tracking-tighter text-white mb-2">{challenge.title}</h3>
@@ -57,7 +57,34 @@ const ChallengeModal: React.FC<{ challenge: Challenge; onClose: () => void; }> =
{connectionDetails && (
<div className="mb-8 p-4 hxp-border border-[#00ccff] bg-[#00ccff]/5">
<h4 className="text-[10px] font-black text-[#00ccff] uppercase tracking-[0.2em] mb-2 flex items-center gap-2"><Globe size={12}/> Connect to:</h4>
<code className="block bg-black p-3 text-[#00ccff] font-black text-sm border border-[#00ccff]/30 break-all select-all">{connectionDetails}</code>
<div className="flex gap-2">
<code className="flex-1 bg-black p-3 text-[#00ccff] font-black text-sm border border-[#00ccff]/30 break-all select-all">{connectionDetails}</code>
{(challenge.connectionType || 'nc') === 'nc' ? (
<button
onClick={() => {
navigator.clipboard.writeText(connectionDetails);
const btn = document.activeElement as HTMLButtonElement;
const originalContent = btn.innerHTML;
btn.innerHTML = 'COPIED!';
setTimeout(() => { btn.innerHTML = originalContent; }, 2000);
}}
className="bg-[#00ccff] text-black p-3 hover:bg-white transition-colors flex items-center justify-center min-w-[50px]"
title="Copy to clipboard"
>
<Copy size={20} />
</button>
) : (
<a
href={connectionDetails}
target="_blank"
rel="noopener noreferrer"
className="bg-[#00ccff] text-black p-3 hover:bg-white transition-colors flex items-center justify-center min-w-[50px]"
title="Open in new tab"
>
<ExternalLink size={20} />
</a>
)}
</div>
</div>
)}
@@ -87,7 +114,7 @@ const ChallengeModal: React.FC<{ challenge: Challenge; onClose: () => void; }> =
) : <p className="text-center font-bold text-red-500 mb-10 uppercase tracking-widest">PLEASE_SIGN_IN_TO_SOLVE</p>}
<div className="border-t-4 border-[#333] pt-6">
<h4 className="text-sm font-black text-white tracking-[0.2em] mb-4 flex items-center gap-2 uppercase"><Sparkles size={14} /> SOLVER_LOG</h4>
<h4 className="text-sm font-black text-white tracking-[0.2em] mb-4 flex items-center gap-2 uppercase"><Sparkles size={14} /> SOLVES</h4>
<div className="space-y-1">
{(challenge.solves || []).length > 0 ? (
state.solves.filter(s => s.challengeId === challenge.id).sort((a, b) => a.timestamp - b.timestamp).map((solve, idx) => {
@@ -100,7 +127,7 @@ const ChallengeModal: React.FC<{ challenge: Challenge; onClose: () => void; }> =
<div key={solve.teamId + idx} className="flex justify-between items-center p-3 bg-white/5 border border-white/10 text-[10px] font-bold">
<div className="flex items-center gap-3">
<span className={`w-5 h-5 flex items-center justify-center font-black ${idx === 0 ? 'bg-[#ffaa00] text-black' : idx === 1 ? 'bg-slate-400 text-black' : idx === 2 ? 'bg-[#cd7f32] text-black' : 'bg-white/10 text-slate-500'}`}>{idx + 1}</span>
<span className="text-white text-xs uppercase italic">{team?.name}</span>
<span className="text-white text-xs font-black">{team?.name}</span>
{bonus > 0 && <span className="text-[#00ff00] animate-pulse">+{bonus} BONUS</span>}
</div>
<div className="flex items-center gap-4">
@@ -170,8 +197,8 @@ export const ChallengeList: React.FC = () => {
return (
<div className="max-w-4xl mx-auto py-32 text-center hxp-border border-4 p-12">
<Clock className="w-20 h-20 text-slate-500 mx-auto mb-6" />
<h2 className="text-4xl font-black italic mb-4 uppercase">{now > endTime ? 'MISSION_COMPLETE' : 'BOARD_PAUSED'}</h2>
<p className="text-slate-500 font-bold tracking-widest uppercase">{now > endTime ? 'The operation timeframe has concluded.' : 'Waiting for HQ clearance...'}</p>
<h2 className="text-4xl font-black italic mb-4 uppercase">{now > endTime ? 'TIME IS UP' : 'BOARD_PAUSED'}</h2>
<p className="text-slate-500 font-bold tracking-widest uppercase">{now > endTime ? 'THE EVENT HAS CONCLUDED' : 'Waiting for HQ clearance...'}</p>
</div>
);
}

View File

@@ -36,20 +36,20 @@ export const Home: React.FC = () => {
{/* Pyramid Base */}
<div className="flex flex-wrap justify-center gap-6">
<Link to="/register">
<Button className="text-xl px-12">Join_Us</Button>
<Button className="text-xl px-12">REGISTER</Button>
</Link>
<Link to="/login">
<Button variant="secondary" className="text-xl px-12">Sign_In</Button>
<Button variant="secondary" className="text-xl px-12">LOGIN</Button>
</Link>
</div>
</>
) : (
<div className="flex flex-wrap justify-center gap-6">
<Link to="/challenges">
<Button className="text-xl px-10">Challenges</Button>
<Button className="text-xl px-10">CHALLENGES</Button>
</Link>
<Link to="/blog">
<Button variant="secondary" className="text-xl px-10">Read Blog</Button>
<Button variant="secondary" className="text-xl px-10">BLOG</Button>
</Link>
</div>
)}

233
Log.tsx
View File

@@ -1,233 +0,0 @@
import React, { useMemo, useState } from 'react';
import { Medal, CheckCircle2, History, LineChart as LineChartIcon, List } from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { useCTF } from './CTFContext';
import { calculateChallengeValue, getFirstBloodBonusFactor, calculateTeamTotalScore } from './services/scoring';
const COLORS = [
'#ff0000', // Red
'#00ff00', // Green
'#0000ff', // Blue
'#ffff00', // Yellow
'#ff00ff', // Magenta
'#00ffff', // Cyan
'#ffaa00', // Orange
'#bf00ff', // Purple
'#ff0080', // Pink
'#aaff00' // Lime
];
export const Log: React.FC = () => {
const { state } = useCTF();
const [view, setView] = useState<'log' | 'graph'>('log');
const sortedSolves = useMemo(() => {
return [...state.solves].sort((a, b) => b.timestamp - a.timestamp);
}, [state.solves]);
const topTeams = useMemo(() => {
return state.teams
.filter(t => !t.isAdmin && !t.isDisabled)
.map(team => ({ ...team, score: calculateTeamTotalScore(team.id, state.challenges, state.solves) }))
.sort((a, b) => b.score - a.score)
.slice(0, 3);
}, [state]);
const top10Teams = useMemo(() => {
return state.teams
.filter(t => !t.isAdmin && !t.isDisabled)
.map(team => ({ ...team, score: calculateTeamTotalScore(team.id, state.challenges, state.solves) }))
.sort((a, b) => b.score - a.score)
.slice(0, 10);
}, [state]);
const graphData = useMemo(() => {
if (state.solves.length === 0) return [];
const sortedSolvesAsc = [...state.solves].sort((a, b) => a.timestamp - b.timestamp);
const dataPoints: any[] = [];
const startTime = state.startTime || (sortedSolvesAsc.length > 0 ? sortedSolvesAsc[0].timestamp - 1000 : 0);
const initialPoint: any = { time: startTime, displayTime: new Date(startTime).toLocaleTimeString() };
top10Teams.forEach(t => initialPoint[t.name] = 0);
dataPoints.push(initialPoint);
sortedSolvesAsc.forEach((solve, idx) => {
const solvesUpToNow = sortedSolvesAsc.slice(0, idx + 1);
const point: any = { time: solve.timestamp, displayTime: new Date(solve.timestamp).toLocaleTimeString() };
top10Teams.forEach(team => {
let total = 0;
const teamSolves = solvesUpToNow.filter(s => s.teamId === team.id);
teamSolves.forEach(ts => {
const challenge = state.challenges.find(c => c.id === ts.challengeId);
if (!challenge) return;
const challengeSolvesUpToNow = solvesUpToNow.filter(s => s.challengeId === ts.challengeId);
const baseValue = calculateChallengeValue(
challenge.initialPoints,
challenge.minimumPoints || 0,
challenge.decaySolves || 1,
challengeSolvesUpToNow.length
);
const rank = challengeSolvesUpToNow.findIndex(s => s.teamId === team.id);
const bonus = Math.floor(challenge.initialPoints * getFirstBloodBonusFactor(rank));
total += (baseValue + bonus);
});
point[team.name] = total;
});
dataPoints.push(point);
});
return dataPoints;
}, [state, top10Teams]);
return (
<div className="w-full px-6 py-12 max-w-5xl mx-auto">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-8 border-b-4 border-[#333] pb-6">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<History size={32} className="text-[#bf00ff]" />
<h2 className="text-4xl font-black italic text-white uppercase tracking-tighter">Event Log</h2>
</div>
<div className="flex gap-2">
<button
onClick={() => setView('log')}
className={`flex items-center gap-2 px-4 py-2 font-bold uppercase tracking-wider text-sm transition-colors ${
view === 'log' ? 'bg-[#bf00ff] text-white' : 'bg-[#333] text-slate-300 hover:bg-[#444]'
}`}
>
<List size={18} /> List View
</button>
<button
onClick={() => setView('graph')}
className={`flex items-center gap-2 px-4 py-2 font-bold uppercase tracking-wider text-sm transition-colors ${
view === 'graph' ? 'bg-[#bf00ff] text-white' : 'bg-[#333] text-slate-300 hover:bg-[#444]'
}`}
>
<LineChartIcon size={18} /> Graph View
</button>
</div>
</div>
{topTeams.length > 0 && (
<div className="flex items-center gap-6 text-sm font-black italic uppercase">
{topTeams.map((team, idx) => (
<div key={team.id} className="flex items-center gap-2">
<span className={`w-5 h-5 flex items-center justify-center text-black not-italic ${idx === 0 ? 'bg-[#ffaa00]' : idx === 1 ? 'bg-slate-400' : 'bg-[#cd7f32]'}`}>
{idx + 1}
</span>
<span className="text-white">{team.name}</span>
<span className="text-[#bf00ff]">{team.score} PTS</span>
</div>
))}
</div>
)}
</div>
{view === 'log' ? (
<div className="flex flex-col gap-2">
{sortedSolves.length === 0 ? (
<div className="p-8 hxp-border border-[#333] text-center text-slate-500 font-bold uppercase tracking-widest bg-white/5">
No activities recorded yet.
</div>
) : (
sortedSolves.map((solve, idx) => {
const team = state.teams.find(t => t.id === solve.teamId);
const challenge = state.challenges.find(c => c.id === solve.challengeId);
if (!team || !challenge) return null;
const challengeSolves = state.solves
.filter(s => s.challengeId === solve.challengeId)
.sort((a, b) => a.timestamp - b.timestamp);
const rank = challengeSolves.findIndex(s => s.teamId === solve.teamId);
const bonusFactor = getFirstBloodBonusFactor(rank);
const basePoints = calculateChallengeValue(
challenge.initialPoints,
challenge.minimumPoints || 0,
challenge.decaySolves || 1,
challengeSolves.length
);
const bonus = Math.floor(challenge.initialPoints * bonusFactor);
const pointsGained = basePoints + bonus;
const getRankIcon = (rank: number) => {
if (rank === 0) return <Medal size={18} className="text-[#ffaa00]" />; // Gold
if (rank === 1) return <Medal size={18} className="text-[#c0c0c0]" />; // Silver
if (rank === 2) return <Medal size={18} className="text-[#cd7f32]" />; // Bronze
return <CheckCircle2 size={18} className="text-[#00ff00]" />; // Checkmark
};
const difficultyColor =
challenge.difficulty === 'Low' ? 'text-[#00ff00]' :
challenge.difficulty === 'Medium' ? 'text-[#ffaa00]' :
'text-[#ff0000]';
const dateStr = new Date(solve.timestamp).toLocaleString();
return (
<div key={`${solve.teamId}-${solve.challengeId}-${idx}`} className="p-4 hxp-border border-[#333] bg-white/5 flex items-center gap-4 hover:border-[#bf00ff] transition-colors">
<div className="flex-shrink-0">
{getRankIcon(rank)}
</div>
<div className="text-sm font-mono text-slate-300">
<span className="text-slate-500 mr-2">[{dateStr}]</span>
<span className="font-bold text-white uppercase italic">{team.name}</span> solved <span className="text-[#bf00ff] uppercase">{challenge.category}</span> - <span className={`font-bold ${difficultyColor}`}>{challenge.title}</span> and gained <span className="text-white font-bold hxp-border-purple px-1 py-0.5 bg-[#bf00ff]/10">{pointsGained} points</span>.
</div>
</div>
);
})
)}
</div>
) : (
<div className="w-full h-[600px] p-4 hxp-border border-[#333] bg-white/5 flex flex-col">
<h3 className="text-xl font-bold italic text-white mb-4 uppercase">Top 10 Teams - Score Progression</h3>
{graphData.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-slate-500 font-bold uppercase tracking-widest">
No data to display.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={graphData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis
dataKey="displayTime"
stroke="#888"
tick={{ fill: '#888' }}
/>
<YAxis
stroke="#888"
tick={{ fill: '#888' }}
label={{ value: 'Points', angle: -90, position: 'insideLeft', fill: '#888' }}
/>
<Tooltip
contentStyle={{ backgroundColor: '#111', borderColor: '#333', color: '#fff' }}
/>
<Legend />
{top10Teams.map((team, idx) => (
<Line
key={team.id}
type="stepAfter"
dataKey={team.name}
stroke={COLORS[idx % COLORS.length]}
strokeWidth={2}
dot={false}
activeDot={{ r: 8 }}
/>
))}
</LineChart>
</ResponsiveContainer>
)}
</div>
)}
</div>
);
};

View File

@@ -1,98 +1,153 @@
import React, { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Trophy, Table, CheckCircle2, Medal, SearchX } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { Trophy, Table, CheckCircle2, Medal, SearchX, History, LineChart as LineChartIcon, List } from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { useCTF } from './CTFContext';
import { calculateTeamTotalScore, calculateChallengeValue, getFirstBloodBonusFactor } from './services/scoring';
import { Button } from './UIComponents';
const COLORS = [
'#ff0000', // Red
'#00ff00', // Green
'#0000ff', // Blue
'#ffff00', // Yellow
'#ff00ff', // Magenta
'#00ffff', // Cyan
'#ffaa00', // Orange
'#bf00ff', // Purple
'#ff0080', // Pink
'#aaff00' // Lime
];
type ScoreboardView = 'ranking' | 'matrix' | 'log' | 'graph';
export const Scoreboard: React.FC = () => {
const { state } = useCTF();
const rankings = useMemo(() => state.teams.filter(t => !t.isAdmin && !t.isDisabled).map(team => ({ ...team, score: calculateTeamTotalScore(team.id, state.challenges, state.solves), solveCount: state.solves.filter(s => s.teamId === team.id).length })).sort((a, b) => b.score - a.score), [state]);
const [view, setView] = useState<ScoreboardView>('ranking');
const rankings = useMemo(() => {
return state.teams
.filter(t => !t.isAdmin && !t.isDisabled)
.map(team => ({
...team,
score: calculateTeamTotalScore(team.id, state.challenges, state.solves),
solveCount: state.solves.filter(s => s.teamId === team.id).length
}))
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
}, [state]);
const top10Teams = useMemo(() => rankings.slice(0, 10), [rankings]);
const sortedSolves = useMemo(() => {
return [...state.solves].sort((a, b) => b.timestamp - a.timestamp);
}, [state.solves]);
const sortedChallenges = useMemo(() => {
return [...state.challenges].sort((a, b) => (b.solves?.length || 0) - (a.solves?.length || 0));
}, [state.challenges]);
const graphData = useMemo(() => {
if (state.solves.length === 0) return [];
const sortedSolvesAsc = [...state.solves].sort((a, b) => a.timestamp - b.timestamp);
const dataPoints: any[] = [];
const startTime = state.startTime || (sortedSolvesAsc.length > 0 ? sortedSolvesAsc[0].timestamp - 1000 : 0);
const initialPoint: any = { time: startTime, displayTime: new Date(startTime).toLocaleTimeString() };
top10Teams.forEach(t => initialPoint[t.name] = 0);
dataPoints.push(initialPoint);
sortedSolvesAsc.forEach((solve, idx) => {
const solvesUpToNow = sortedSolvesAsc.slice(0, idx + 1);
const point: any = { time: solve.timestamp, displayTime: new Date(solve.timestamp).toLocaleTimeString() };
top10Teams.forEach(team => {
let total = 0;
const teamSolves = solvesUpToNow.filter(s => s.teamId === team.id);
teamSolves.forEach(ts => {
const challenge = state.challenges.find(c => c.id === ts.challengeId);
if (!challenge) return;
const challengeSolvesUpToNow = solvesUpToNow.filter(s => s.challengeId === ts.challengeId);
const baseValue = calculateChallengeValue(
challenge.initialPoints,
challenge.minimumPoints || 0,
challenge.decaySolves || 1,
challengeSolvesUpToNow.length
);
const rank = challengeSolvesUpToNow.findIndex(s => s.teamId === team.id);
const bonus = Math.floor(challenge.initialPoints * getFirstBloodBonusFactor(rank));
total += (baseValue + bonus);
});
point[team.name] = total;
});
dataPoints.push(point);
});
return dataPoints;
}, [state, top10Teams]);
const renderContent = () => {
switch (view) {
case 'ranking':
return (
<div className="max-w-5xl mx-auto py-12 px-6">
<div className="mb-12 border-b-4 border-[#ff0000] pb-6 flex justify-between items-end">
<div>
<h2 className="text-6xl font-black italic text-white uppercase tracking-tighter">SCOREBOARD</h2>
</div>
<Link to="/matrix" className="text-[10px] font-black text-[#ff0000] hover:underline flex items-center gap-1 uppercase tracking-widest"><Table size={12}/> View_Matrix</Link>
</div>
<div className="hxp-border border-2 bg-black overflow-hidden">
<div className="hxp-border border-2 bg-black overflow-hidden max-w-[1000px] mx-auto">
<table className="w-full text-left">
<thead className="bg-[#333] text-[10px] font-black uppercase"><tr><th className="p-4">RANK</th><th className="p-4">TEAM_IDENTIFIER</th><th className="p-4 text-center">SOLVES</th><th className="p-4 text-right">TOTAL_POINTS</th></tr></thead>
<thead className="bg-[#333] font-black uppercase">
<tr>
<th className="p-4 text-2xl italic">RANK</th>
<th className="p-4 text-2xl italic">PLAYER</th>
<th className="p-4 text-center text-2xl italic">SOLVES</th>
<th className="p-4 text-right text-2xl italic">POINTS</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{rankings.map((team, idx) => (
<tr key={team.id} className="hover:bg-white/5 group">
<td className="p-4 font-black">
<div className={`w-8 h-8 flex items-center justify-center font-black italic text-lg ${idx === 0 ? 'bg-[#ffaa00] text-black rotate-[-12deg]' : idx === 1 ? 'bg-slate-400 text-black rotate-[6deg]' : idx === 2 ? 'bg-[#cd7f32] text-black rotate-[-6deg]' : 'text-slate-500'}`}>{idx + 1}</div>
<div className={`w-10 h-10 flex items-center justify-center font-black italic text-2xl ${idx === 0 ? 'bg-[#ffaa00] text-black rotate-[-12deg]' : idx === 1 ? 'bg-slate-400 text-black rotate-[6deg]' : idx === 2 ? 'bg-[#cd7f32] text-black rotate-[-6deg]' : 'text-slate-500'}`}>{idx + 1}</div>
</td>
<td className="p-4 font-black italic text-xl text-white group-hover:text-[#bf00ff] transition-colors">{team.name}</td>
<td className="p-4 text-center font-black text-[#bf00ff]">{team.solveCount}</td>
<td className="p-4 font-black italic text-2xl text-white group-hover:text-[#bf00ff] transition-colors">{team.name}</td>
<td className="p-4 text-center font-black text-2xl text-[#bf00ff]">{team.solveCount}</td>
<td className="p-4 text-right text-2xl font-black text-white italic">{team.score}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export const ScoreMatrix: React.FC = () => {
const { state } = useCTF();
const sortedTeams = useMemo(() => {
return state.teams
.filter(t => !t.isAdmin && !t.isDisabled)
.map(t => ({ ...t, score: calculateTeamTotalScore(t.id, state.challenges, state.solves) }))
.sort((a, b) => b.score - a.score);
}, [state]);
const sortedChallenges = useMemo(() => {
return [...state.challenges].sort((a, b) => (b.solves?.length || 0) - (a.solves?.length || 0));
}, [state.challenges]);
case 'matrix':
if (state.solves.length === 0) {
return (
<div className="max-w-4xl mx-auto py-32 px-6">
<div className="mb-12 border-b-4 border-[#00ff00] pb-6 flex justify-between items-end">
<h2 className="text-6xl font-black italic tracking-tighter text-white uppercase leading-none">SCORE_MATRIX</h2>
<Link to="/scoreboard" className="text-[10px] font-black text-[#00ff00] hover:underline flex items-center gap-1 uppercase tracking-widest"><Trophy size={12}/> View_Rankings</Link>
</div>
<div className="text-center hxp-border border-4 p-16 bg-black">
<SearchX className="w-20 h-20 text-slate-700 mx-auto mb-6" />
<h2 className="text-4xl font-black italic mb-4 uppercase text-white tracking-tighter">No solves yet!</h2>
<p className="text-slate-500 font-bold tracking-widest uppercase mb-8">The matrix is currently empty. Be the first to solve a challenge!</p>
<Link to="/challenges">
<Button className="px-12 py-4 text-lg">Go to Challenges</Button>
</Link>
</div>
</div>
);
}
return (
<div className="w-full overflow-x-auto py-12 px-6 custom-scrollbar">
<div className="mb-12 border-b-4 border-[#00ff00] pb-6 max-w-6xl mx-auto flex justify-between items-end">
<h2 className="text-6xl font-black italic tracking-tighter text-white uppercase leading-none">SCORE_MATRIX</h2>
<Link to="/scoreboard" className="text-[10px] font-black text-[#00ff00] hover:underline flex items-center gap-1 uppercase tracking-widest"><Trophy size={12}/> View_Rankings</Link>
</div>
<div className="w-full overflow-x-auto custom-scrollbar">
<div className="inline-block min-w-full bg-black hxp-border overflow-hidden">
<table className="border-collapse w-full">
<thead>
<tr>
<th className="sticky left-0 z-20 bg-black p-4 text-[10px] font-black uppercase text-slate-500 border-b-2 border-r-2 border-[#333] whitespace-nowrap min-w-[200px]">Team / Challenge</th>
<th className="sticky left-0 z-20 bg-black p-4 text-[10px] font-black uppercase text-slate-500 border-b-2 border-r-2 border-[#333] whitespace-nowrap min-w-[200px]">Player / Challenge</th>
{sortedChallenges.map(c => {
const diffColor = c.difficulty === 'Low' ? 'text-[#00ff00]' : c.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]';
return (
<th key={c.id} className="p-0 border-b-2 border-r-2 border-[#333] min-w-[80px] align-bottom relative group/col">
{/* Vertical text container using writing-mode so it stretches cell height automatically */}
<div className="flex flex-col items-center justify-end pb-12 pt-8">
<span className={`[writing-mode:vertical-rl] rotate-180 whitespace-nowrap text-[11px] font-black tracking-[0.2em] transition-colors uppercase ${diffColor} group-hover/col:brightness-125`}>
{c.title}
</span>
</div>
{/* Solve count tag at the bottom of the header cell */}
<div className="absolute bottom-1 left-0 right-0 text-[8px] text-slate-600 font-bold uppercase text-center py-1 border-t border-[#333] bg-black/50">
{(c.solves || []).length} SOLVES
</div>
@@ -102,7 +157,7 @@ export const ScoreMatrix: React.FC = () => {
</tr>
</thead>
<tbody>
{sortedTeams.map(team => (
{rankings.map(team => (
<tr key={team.id} className="hover:bg-white/5 group">
<td className="sticky left-0 z-10 bg-black p-3 font-black italic text-sm border-r-2 border-b-2 border-[#333] whitespace-nowrap group-hover:text-[#bf00ff]">
{team.name} <span className="text-white not-italic opacity-40 ml-1 text-[10px] tracking-widest">({team.score})</span>
@@ -116,14 +171,10 @@ export const ScoreMatrix: React.FC = () => {
</td>
);
}
// Find rank for the medal and bonus
const challengeSolves = state.solves
.filter(s => s.challengeId === c.id)
.sort((a, b) => a.timestamp - b.timestamp);
const rank = challengeSolves.findIndex(s => s.teamId === team.id);
// Calculate point gain for this specific solve
const baseValue = calculateChallengeValue(
c.initialPoints,
c.minimumPoints || 0,
@@ -135,20 +186,9 @@ export const ScoreMatrix: React.FC = () => {
return (
<td key={c.id} className="p-0 border-r-2 border-b-2 border-[#333] text-center">
<div
className={`w-full h-12 flex flex-col items-center justify-center ${rank === 0 ? 'bg-[#ffaa00]/10' : rank === 1 ? 'bg-slate-400/10' : rank === 2 ? 'bg-[#cd7f32]/10' : 'bg-[#00ff00]/5'}`}
title={rank === 0 ? "First Blood!" : rank === 1 ? "Second Solver" : rank === 2 ? "Third Solver" : "Solved"}
>
<div className={`w-full h-12 flex flex-col items-center justify-center ${rank === 0 ? 'bg-[#ffaa00]/10' : rank === 1 ? 'bg-slate-400/10' : rank === 2 ? 'bg-[#cd7f32]/10' : 'bg-[#00ff00]/5'}`}>
<div className="flex items-center justify-center">
{rank === 0 ? (
<Medal size={16} className="text-[#ffaa00]" />
) : rank === 1 ? (
<Medal size={16} className="text-slate-400" />
) : rank === 2 ? (
<Medal size={16} className="text-[#cd7f32]" />
) : (
<CheckCircle2 size={14} className="text-[#00ff00]" />
)}
{rank === 0 ? <Medal size={16} className="text-[#ffaa00]" /> : rank === 1 ? <Medal size={16} className="text-slate-400" /> : rank === 2 ? <Medal size={16} className="text-[#cd7f32]" /> : <CheckCircle2 size={14} className="text-[#00ff00]" />}
</div>
<span className="text-[9px] font-black text-white/70 mt-1 leading-none">{totalPoints}</span>
</div>
@@ -162,4 +202,95 @@ export const ScoreMatrix: React.FC = () => {
</div>
</div>
);
case 'log':
return (
<div className="flex flex-col gap-2 max-w-[1000px] mx-auto">
{sortedSolves.length === 0 ? (
<div className="p-8 hxp-border border-[#333] text-center text-slate-500 font-bold uppercase tracking-widest bg-white/5">
No activities recorded yet.
</div>
) : (
sortedSolves.map((solve, idx) => {
const team = state.teams.find(t => t.id === solve.teamId);
const challenge = state.challenges.find(c => c.id === solve.challengeId);
if (!team || !challenge) return null;
const challengeSolves = state.solves.filter(s => s.challengeId === solve.challengeId).sort((a, b) => a.timestamp - b.timestamp);
const rank = challengeSolves.findIndex(s => s.teamId === solve.teamId);
const basePoints = calculateChallengeValue(challenge.initialPoints, challenge.minimumPoints || 0, challenge.decaySolves || 1, challengeSolves.length);
const bonus = Math.floor(challenge.initialPoints * getFirstBloodBonusFactor(rank));
const pointsGained = basePoints + bonus;
const difficultyColor = challenge.difficulty === 'Low' ? 'text-[#00ff00]' : challenge.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]';
return (
<div key={`${solve.teamId}-${solve.challengeId}-${idx}`} className="p-4 hxp-border border-[#333] bg-white/5 flex items-center gap-4 hover:border-[#bf00ff] transition-colors">
<div className="flex-shrink-0">
{rank === 0 ? <Medal size={18} className="text-[#ffaa00]" /> : rank === 1 ? <Medal size={18} className="text-[#c0c0c0]" /> : rank === 2 ? <Medal size={18} className="text-[#cd7f32]" /> : <CheckCircle2 size={18} className="text-[#00ff00]" />}
</div>
<div className="text-sm font-mono text-slate-300">
<span className="text-slate-500 mr-2">[{new Date(solve.timestamp).toLocaleString()}]</span>
<span className="font-bold text-white uppercase italic">{team.name}</span> solved <span className="text-[#bf00ff] uppercase">{challenge.category}</span> - <span className={`font-bold ${difficultyColor}`}>{challenge.title}</span> and gained <span className="text-white font-bold hxp-border-purple px-1 py-0.5 bg-[#bf00ff]/10">{pointsGained} points</span>.
</div>
</div>
);
})
)}
</div>
);
case 'graph':
return (
<div className="w-full h-[750px] p-4 hxp-border border-[#333] bg-white/5 flex flex-col">
<h3 className="text-xl font-bold italic text-white mb-4 uppercase">Top 10 Players - Score Progression</h3>
{graphData.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-slate-500 font-bold uppercase tracking-widest">No data to display.</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={graphData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="displayTime" stroke="#888" tick={{ fill: '#888' }} />
<YAxis stroke="#888" tick={{ fill: '#888' }} label={{ value: 'Points', angle: -90, position: 'insideLeft', fill: '#888' }} />
<Tooltip
contentStyle={{ backgroundColor: '#111', borderColor: '#333', color: '#fff' }}
itemSorter={(item: any) => -item.value}
/>
<Legend />
{top10Teams.map((team, idx) => (
<Line key={team.id} type="stepAfter" dataKey={team.name} stroke={COLORS[idx % COLORS.length]} strokeWidth={2} dot={false} activeDot={{ r: 8 }} />
))}
</LineChart>
</ResponsiveContainer>
)}
</div>
);
}
};
return (
<div className="max-w-[1920px] mx-auto py-12 px-6">
<div className="mb-12 flex justify-center">
<div className="flex flex-wrap justify-center gap-4">
<button onClick={() => setView('ranking')} className={`flex items-center gap-2 px-6 py-3 font-bold uppercase tracking-wider text-sm transition-colors ${view === 'ranking' ? 'bg-[#ff0000] text-black shadow-[0_0_15px_rgba(255,0,0,0.3)]' : 'bg-[#333] text-slate-300 hover:bg-[#444]'}`}>
<Trophy size={18} /> Rankings
</button>
<button onClick={() => setView('matrix')} className={`flex items-center gap-2 px-6 py-3 font-bold uppercase tracking-wider text-sm transition-colors ${view === 'matrix' ? 'bg-[#ff0000] text-black shadow-[0_0_15px_rgba(255,0,0,0.3)]' : 'bg-[#333] text-slate-300 hover:bg-[#444]'}`}>
<Table size={18} /> Matrix
</button>
<button onClick={() => setView('log')} className={`flex items-center gap-2 px-6 py-3 font-bold uppercase tracking-wider text-sm transition-colors ${view === 'log' ? 'bg-[#ff0000] text-black shadow-[0_0_15px_rgba(255,0,0,0.3)]' : 'bg-[#333] text-slate-300 hover:bg-[#444]'}`}>
<List size={18} /> Event Log
</button>
<button onClick={() => setView('graph')} className={`flex items-center gap-2 px-6 py-3 font-bold uppercase tracking-wider text-sm transition-colors ${view === 'graph' ? 'bg-[#ff0000] text-black shadow-[0_0_15px_rgba(255,0,0,0.3)]' : 'bg-[#333] text-slate-300 hover:bg-[#444]'}`}>
<LineChartIcon size={18} /> Score Graph
</button>
</div>
</div>
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
{renderContent()}
</div>
</div>
);
};
export const ScoreMatrix: React.FC = () => {
// Keeping this for compatibility but it's now integrated into Scoreboard main component
return <Scoreboard />;
};

View File

@@ -1,3 +1,16 @@
2026-03-11
- Fixed Challenge Modal overlap issue by adjusting the main stacking context in App.tsx
- Implemented "click-outside-to-close" functionality for both the Challenge Modal and User Dropdown
- Added protocol-specific action buttons for challenges: "Open in new tab" for HTTP and "Copy to clipboard" for NC
- Enhanced Scoreboard rankings with significantly larger, consistent font sizes (text-2xl) for better readability
- Rebranded "TEAM_IDENTIFIER" to "PLAYER" and "TOTAL_POINTS" to "POINTS" across the platform (Scoreboard, Matrix, User Menu)
- Updated navigation: Renamed "SCOREBOARD" to "SCORES" in the nav bar and dynamic page titles
- Modernized User Dropdown menu with a dedicated "PLAYER" header and "LOGOUT" action
- Improved Score Matrix and Score Graph titles for consistency with the new "Player" terminology
- Added CAPTCHA human verification (svg-captcha) to Login and Registration flows for enhanced security
- Optimized frontend assets by migrating Tailwind and JetBrains Mono to local hosting
- Refactored Admin panel: Renamed "Operators" to "Users" and improved layout alignment
2026-03-10
- Added "HW" (Hardware) category to the platform with a dedicated icon and color
- Updated challenge grid to 6 columns on desktop to accommodate the new category

View File

@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HIPCTF</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700;800&display=swap" rel="stylesheet">
<script src="/tailwind.js"></script>
<link href="/jetbrains-mono.css" rel="stylesheet">
<style>
body {
font-family: 'JetBrains Mono', monospace;
@@ -55,7 +55,6 @@
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body>
<div id="root"></div>

33
package-lock.json generated
View File

@@ -17,7 +17,8 @@
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
"recharts": "^3.8.0",
"sqlite3": "^5.1.6"
"sqlite3": "^5.1.6",
"svg-captcha": "^1.4.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
@@ -3473,6 +3474,18 @@
"wrappy": "1"
}
},
"node_modules/opentype.js": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-0.7.3.tgz",
"integrity": "sha512-Veui5vl2bLonFJ/SjX/WRWJT3SncgiZNnKUyahmXCc2sa1xXW15u3R/3TN5+JFiP7RsjK5ER4HA5eWaEmV9deA==",
"license": "MIT",
"dependencies": {
"tiny-inflate": "^1.0.2"
},
"bin": {
"ot": "bin/ot"
}
},
"node_modules/p-map": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
@@ -4347,6 +4360,18 @@
"node": ">=0.10.0"
}
},
"node_modules/svg-captcha": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/svg-captcha/-/svg-captcha-1.4.0.tgz",
"integrity": "sha512-/fkkhavXPE57zRRCjNqAP3txRCSncpMx3NnNZL7iEoyAtYwUjPhJxW6FQTQPG5UPEmCrbFoXS10C3YdJlW7PDg==",
"license": "MIT",
"dependencies": {
"opentype.js": "^0.7.3"
},
"engines": {
"node": ">=4.x"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
@@ -4439,6 +4464,12 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",

View File

@@ -12,7 +12,8 @@
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
"recharts": "^3.8.0",
"sqlite3": "^5.1.6"
"sqlite3": "^5.1.6",
"svg-captcha": "^1.4.0"
},
"devDependencies": {
"@types/react": "^19.0.0",

Binary file not shown.

Binary file not shown.

Binary file not shown.

21
public/jetbrains-mono.css Normal file
View File

@@ -0,0 +1,21 @@
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./jetbrains-mono-400.ttf') format('truetype');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./jetbrains-mono-700.ttf') format('truetype');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url('./jetbrains-mono-800.ttf') format('truetype');
}

83
public/tailwind.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -6,10 +6,20 @@ const path = require('path');
const fs = require('fs');
const cors = require('cors');
const crypto = require('crypto');
const svgCaptcha = require('svg-captcha');
const app = express();
const port = process.env.PORT || 3000;
// Memory storage for captchas (briefly)
const captchas = new Map();
setInterval(() => {
const now = Date.now();
for (const [id, data] of captchas.entries()) {
if (now - data.timestamp > 300000) captchas.delete(id); // 5 min expiry
}
}, 60000);
// Password Hashing Helpers
function hashPassword(password) {
if (typeof password !== 'string') throw new Error('Password must be a string');
@@ -223,9 +233,30 @@ apiRouter.use('/admin', async (req, res, next) => {
}
});
apiRouter.get('/auth/captcha', (req, res) => {
const captcha = svgCaptcha.create({
size: 6,
noise: 3,
color: true,
background: '#1a1a1a'
});
const id = crypto.randomUUID();
captchas.set(id, { text: captcha.text, timestamp: Date.now() });
res.json({ id, data: captcha.data });
});
apiRouter.post('/auth/register', (req, res) => {
const { name, password } = req.body;
const { name, password, captchaId, captchaAnswer } = req.body;
if (!name || !password) return res.status(400).json({ message: 'Missing credentials' });
// CAPTCHA Validation
if (!captchaId || !captchaAnswer) return res.status(400).json({ message: 'Human verification required' });
const stored = captchas.get(captchaId);
if (!stored || stored.text.toLowerCase() !== captchaAnswer.toLowerCase()) {
return res.status(400).json({ message: 'Invalid human verification code' });
}
captchas.delete(captchaId); // One-time use
const id = 'team-' + Math.random().toString(36).substr(2, 9);
const hashedPass = hashPassword(password);
db.run("INSERT INTO teams (id, name, password, isAdmin, isDisabled) VALUES (?, ?, ?, 0, 0)", [id, name, hashedPass], function(err) {
@@ -238,7 +269,16 @@ apiRouter.post('/auth/register', (req, res) => {
});
apiRouter.post('/auth/login', (req, res) => {
const { name, password } = req.body;
const { name, password, captchaId, captchaAnswer } = req.body;
// CAPTCHA Validation
if (!captchaId || !captchaAnswer) return res.status(400).json({ message: 'Human verification required' });
const stored = captchas.get(captchaId);
if (!stored || stored.text.toLowerCase() !== captchaAnswer.toLowerCase()) {
return res.status(400).json({ message: 'Invalid human verification code' });
}
captchas.delete(captchaId); // One-time use
db.get("SELECT * FROM teams WHERE name = ?", [name], (err, team) => {
if (err || !team || !comparePassword(password, team.password)) return res.status(401).json({ message: 'Invalid credentials' });
if (team.isDisabled) return res.status(403).json({ message: 'Account disabled' });

View File

@@ -22,11 +22,16 @@ class ApiService {
return res.json();
}
async login(name: string, pass: string): Promise<{ team: Team, token: string }> {
async getCaptcha(): Promise<{ id: string, data: string }> {
const res = await fetch(`${API_BASE}/auth/captcha`);
return res.json();
}
async login(name: string, pass: string, captchaId: string, captchaAnswer: string): Promise<{ team: Team, token: string }> {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, password: pass }),
body: JSON.stringify({ name, password: pass, captchaId, captchaAnswer }),
});
if (!res.ok) {
const err = await res.json();
@@ -35,11 +40,11 @@ class ApiService {
return res.json();
}
async register(name: string, pass: string): Promise<{ team: Team, token: string }> {
async register(name: string, pass: string, captchaId: string, captchaAnswer: string): Promise<{ team: Team, token: string }> {
const res = await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, password: pass }),
body: JSON.stringify({ name, password: pass, captchaId, captchaAnswer }),
});
if (!res.ok) {
const err = await res.json();