From a60b150d9f209efa84264784e574482ce77e5784 Mon Sep 17 00:00:00 2001 From: KeshavAnandCode Date: Mon, 22 Dec 2025 13:56:41 -0600 Subject: [PATCH] working full end-to-end with encryption --- src/App.jsx | 41 +++++--- src/components/AuthPage.css | 171 +++++++++++++++++++++++++++++++++ src/components/AuthPage.jsx | 127 ++++++++++++++++++++++++ src/components/GradesTable.jsx | 62 +++++++++--- src/contexts/AuthContext.jsx | 91 ++++++++++++++++++ src/index.css | 2 +- src/main.jsx | 5 +- src/services/api.js | 16 ++- 8 files changed, 487 insertions(+), 28 deletions(-) create mode 100644 src/components/AuthPage.css create mode 100644 src/components/AuthPage.jsx create mode 100644 src/contexts/AuthContext.jsx diff --git a/src/App.jsx b/src/App.jsx index d77753d..70fadf8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,27 +2,29 @@ import { useState, useEffect } from 'react'; import { AlertCircle } from 'lucide-react'; import { GradesTable } from './components/GradesTable'; import { LoadingSpinner } from './components/LoadingSpinner'; +import { AuthPage } from './components/AuthPage'; +import { useAuth } from './contexts/AuthContext'; import { api } from './services/api'; import './App.css'; function App() { + const { user, loading: authLoading, logout } = useAuth(); const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [username] = useState( - import.meta.env.VITE_DEFAULT_USERNAME || 'keshav.anand.1' - ); useEffect(() => { - fetchGrades(); + if (user) { + fetchGrades(); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [username]); + }, [user]); const fetchGrades = async () => { setLoading(true); setError(null); try { - const result = await api.getGrades(username); + const result = await api.getGrades(user.username); setData(result.user); } catch (err) { setError(err.message); @@ -31,10 +33,22 @@ function App() { } }; + // Show loading spinner while checking auth + if (authLoading) { + return ; + } + + // Show auth page if not logged in + if (!user) { + return ; + } + + // Show loading spinner while fetching grades if (loading) { return ; } + // Show error state if (error) { return (
@@ -55,12 +69,17 @@ function App() {

STUDENT GRADES

- Logged in as: {username} + Logged in as: {user.username}

- +
+ + +
diff --git a/src/components/AuthPage.css b/src/components/AuthPage.css new file mode 100644 index 0000000..804495c --- /dev/null +++ b/src/components/AuthPage.css @@ -0,0 +1,171 @@ +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #1e1e1e 0%, #2b1055 100%); + padding: 2rem; +} + +.auth-card { + background-color: #2b2b2b; + border-radius: 1rem; + padding: 3rem; + width: 100%; + max-width: 450px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); + border: 1px solid #374151; +} + +.auth-header { + text-align: center; + margin-bottom: 2rem; +} + +.auth-title { + font-size: 2rem; + font-weight: 700; + background: linear-gradient(to right, #a78bfa, #ec4899); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 0.5rem; +} + +.auth-subtitle { + color: #9ca3af; + font-size: 0.875rem; +} + +.auth-error { + background-color: rgba(239, 68, 68, 0.1); + border: 1px solid #ef4444; + color: #fca5a5; + padding: 0.75rem; + border-radius: 0.5rem; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-label { + color: #d1d5db; + font-size: 0.875rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.form-input { + background-color: #1e1e1e; + border: 1px solid #374151; + color: #e0e0e0; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + font-family: "JetBrains Mono", monospace; + font-size: 0.875rem; + transition: all 0.2s; +} + +.form-input:focus { + outline: none; + border-color: #7c3aed; + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1); +} + +.form-hint { + color: #6b7280; + font-size: 0.75rem; + margin: 0; +} + +.submit-button { + background: linear-gradient(to right, #7c3aed, #ec4899); + color: white; + padding: 0.875rem; + border: none; + border-radius: 0.5rem; + font-family: "JetBrains Mono", monospace; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.submit-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(124, 58, 237, 0.3); +} + +.submit-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.auth-switch { + margin-top: 1.5rem; + text-align: center; +} + +.switch-button { + background: none; + border: none; + color: #a78bfa; + font-family: "JetBrains Mono", monospace; + font-size: 0.875rem; + cursor: pointer; + transition: color 0.2s; +} + +.switch-button:hover { + color: #c4b5fd; + text-decoration: underline; +} + +.auth-note { + margin-top: 1.5rem; + padding: 1rem; + background-color: rgba(124, 58, 237, 0.1); + border-radius: 0.5rem; + border: 1px solid rgba(124, 58, 237, 0.2); +} + +.auth-note p { + color: #d8b4fe; + font-size: 0.75rem; + line-height: 1.5; + margin: 0; +} diff --git a/src/components/AuthPage.jsx b/src/components/AuthPage.jsx new file mode 100644 index 0000000..2fe327b --- /dev/null +++ b/src/components/AuthPage.jsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { Loader2, Lock, User as UserIcon, AlertCircle } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; +import './AuthPage.css'; + +export const AuthPage = () => { + const [isLogin, setIsLogin] = useState(true); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { login, register } = useAuth(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + if (isLogin) { + await login(username, password); + } else { + await register(username, password); + } + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

STUDENT GRADES

+

+ {isLogin ? 'Sign in to your account' : 'Create a new account'} +

+
+ + {error && ( +
+ + {error} +
+ )} + +
+
+ + setUsername(e.target.value)} + placeholder="your.username" + className="form-input" + required + autoComplete="username" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + className="form-input" + required + autoComplete={isLogin ? 'current-password' : 'new-password'} + minLength={4} + /> + {!isLogin && ( +

Must be at least 4 characters

+ )} +
+ + +
+ +
+ +
+ + {!isLogin && ( +
+

+ Note: Your Skyward credentials will be verified during registration. + Make sure to use your actual Skyward username and password. +

+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/GradesTable.jsx b/src/components/GradesTable.jsx index 297ab69..2319ae8 100644 --- a/src/components/GradesTable.jsx +++ b/src/components/GradesTable.jsx @@ -27,18 +27,34 @@ export const GradesTable = ({ data }) => { return acc; }, {}); - const calculateSemesterGrade = (nw1, nw2, se1) => { - const grades = [ - parseFloat(nw1) * 0.4, - parseFloat(nw2) * 0.4, - parseFloat(se1) * 0.2 - ]; +const calculateSemesterGrade = (nw1, nw2, se1) => { + let totalWeight = 0; + let weightedSum = 0; - // Check if all grades are valid numbers - if (grades.some(g => isNaN(g))) return null; + // Add NW1 (40% weight) + if (nw1 && !isNaN(parseFloat(nw1))) { + weightedSum += parseFloat(nw1) * 0.4; + totalWeight += 0.4; + } - const total = grades.reduce((sum, g) => sum + g, 0); - return total.toFixed(2); + // Add NW2 (40% weight) + if (nw2 && !isNaN(parseFloat(nw2))) { + weightedSum += parseFloat(nw2) * 0.4; + totalWeight += 0.4; + } + + // Add SE1 (20% weight) + if (se1 && !isNaN(parseFloat(se1))) { + weightedSum += parseFloat(se1) * 0.2; + totalWeight += 0.2; + } + + // If no valid grades, return null + if (totalWeight === 0) return null; + + // Normalize by the total weight to get the final grade + const normalizedGrade = weightedSum / totalWeight; + return normalizedGrade.toFixed(2); }; const getGradeColor = (grade) => { @@ -99,7 +115,31 @@ export const GradesTable = ({ data }) => { cls.grades['SE2'] ); isCalculated = true; - } + } // Calculate FIN from S1 and S2 + else if (cat === 'FIN') { + const s1 = calculateSemesterGrade( + cls.grades['NW1'], + cls.grades['NW2'], + cls.grades['SE1'] + ); + const s2 = calculateSemesterGrade( + cls.grades['NW3'], + cls.grades['NW4'], + cls.grades['SE2'] + ); + + const validSemesters = []; + if (s1 && !isNaN(parseFloat(s1))) validSemesters.push(parseFloat(s1)); + if (s2 && !isNaN(parseFloat(s2))) validSemesters.push(parseFloat(s2)); + + if (validSemesters.length > 0) { + const average = validSemesters.reduce((sum, g) => sum + g, 0) / validSemesters.length; + displayGrade = average.toFixed(2); + } else { + displayGrade = null; + } + isCalculated = true; + } return ( diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..964f7f0 --- /dev/null +++ b/src/contexts/AuthContext.jsx @@ -0,0 +1,91 @@ +import { createContext, useContext, useState, useEffect } from 'react'; + +const AuthContext = createContext(null); + +// eslint-disable-next-line react-refresh/only-export-components +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + return context; +}; + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + checkAuth(); + }, []); + + const checkAuth = async () => { + try { + const response = await fetch('http://localhost:4000/api/auth/me', { + credentials: 'include' + }); + + if (response.ok) { + const data = await response.json(); + setUser(data.user); + } + } catch (error) { + console.error('Auth check failed:', error); + } finally { + setLoading(false); + } + }; + + const login = async (username, password) => { + const response = await fetch('http://localhost:4000/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ username, password }) + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Login failed'); + } + + const data = await response.json(); + setUser(data.user); + return data; + }; + + const register = async (username, password) => { + const response = await fetch('http://localhost:4000/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ username, password }) + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Registration failed'); + } + + const data = await response.json(); + setUser(data.user); + return data; + }; + + const logout = async () => { + try { + await fetch('http://localhost:4000/api/auth/logout', { + method: 'POST', + credentials: 'include' + }); + } finally { + setUser(null); + } + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/src/index.css b/src/index.css index 0844130..c3030a2 100644 --- a/src/index.css +++ b/src/index.css @@ -16,7 +16,7 @@ body { } #root { - min-height: 100vh; + min-height: 10a0vh; } /* Custom scrollbar */ diff --git a/src/main.jsx b/src/main.jsx index ecf2cca..97b9728 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,10 +1,13 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.jsx'; +import { AuthProvider } from './contexts/AuthContext'; // ADD THIS import './index.css'; ReactDOM.createRoot(document.getElementById('root')).render( - + + + , ); \ No newline at end of file diff --git a/src/services/api.js b/src/services/api.js index 48822ef..8166914 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -2,7 +2,9 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000 export const api = { async getGrades(username) { - const response = await fetch(`${API_BASE_URL}/users/${username}/grades`); + const response = await fetch(`${API_BASE_URL}/users/${username}/grades`, { + credentials: 'include' // ADD THIS + }); if (!response.ok) { throw new Error('Failed to fetch grades'); } @@ -10,7 +12,9 @@ export const api = { }, async getClasses(username) { - const response = await fetch(`${API_BASE_URL}/users/${username}/classes`); + const response = await fetch(`${API_BASE_URL}/users/${username}/classes`, { + credentials: 'include' // ADD THIS + }); if (!response.ok) { throw new Error('Failed to fetch classes'); } @@ -18,7 +22,9 @@ export const api = { }, async getFinalGrades(username) { - const response = await fetch(`${API_BASE_URL}/users/${username}/final-grades`); + const response = await fetch(`${API_BASE_URL}/users/${username}/final-grades`, { + credentials: 'include' // ADD THIS + }); if (!response.ok) { throw new Error('Failed to fetch final grades'); } @@ -26,7 +32,9 @@ export const api = { }, async getStats(username) { - const response = await fetch(`${API_BASE_URL}/stats/users/${username}`); + const response = await fetch(`${API_BASE_URL}/stats/users/${username}`, { + credentials: 'include' // ADD THIS + }); if (!response.ok) { throw new Error('Failed to fetch stats'); }