working full end-to-end with encryption

This commit is contained in:
2025-12-22 13:56:41 -06:00
parent d10f7f3173
commit a60b150d9f
8 changed files with 487 additions and 28 deletions

View File

@@ -2,27 +2,29 @@ import { useState, useEffect } from 'react';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { GradesTable } from './components/GradesTable'; import { GradesTable } from './components/GradesTable';
import { LoadingSpinner } from './components/LoadingSpinner'; import { LoadingSpinner } from './components/LoadingSpinner';
import { AuthPage } from './components/AuthPage';
import { useAuth } from './contexts/AuthContext';
import { api } from './services/api'; import { api } from './services/api';
import './App.css'; import './App.css';
function App() { function App() {
const { user, loading: authLoading, logout } = useAuth();
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [username] = useState(
import.meta.env.VITE_DEFAULT_USERNAME || 'keshav.anand.1'
);
useEffect(() => { useEffect(() => {
if (user) {
fetchGrades(); fetchGrades();
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [username]); }, [user]);
const fetchGrades = async () => { const fetchGrades = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const result = await api.getGrades(username); const result = await api.getGrades(user.username);
setData(result.user); setData(result.user);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -31,10 +33,22 @@ function App() {
} }
}; };
// Show loading spinner while checking auth
if (authLoading) {
return <LoadingSpinner />;
}
// Show auth page if not logged in
if (!user) {
return <AuthPage />;
}
// Show loading spinner while fetching grades
if (loading) { if (loading) {
return <LoadingSpinner />; return <LoadingSpinner />;
} }
// Show error state
if (error) { if (error) {
return ( return (
<div className="error-container"> <div className="error-container">
@@ -55,12 +69,17 @@ function App() {
<div> <div>
<h1 className="app-title">STUDENT GRADES</h1> <h1 className="app-title">STUDENT GRADES</h1>
<p className="app-subtitle"> <p className="app-subtitle">
Logged in as: <span className="username-highlight">{username}</span> Logged in as: <span className="username-highlight">{user.username}</span>
</p> </p>
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button onClick={fetchGrades} className="refresh-button"> <button onClick={fetchGrades} className="refresh-button">
Refresh Refresh
</button> </button>
<button onClick={logout} className="refresh-button" style={{ background: '#dc2626' }}>
Logout
</button>
</div>
</div> </div>
<GradesTable data={data} /> <GradesTable data={data} />

171
src/components/AuthPage.css Normal file
View File

@@ -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;
}

127
src/components/AuthPage.jsx Normal file
View File

@@ -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 (
<div className="auth-container">
<div className="auth-card">
<div className="auth-header">
<h1 className="auth-title">STUDENT GRADES</h1>
<p className="auth-subtitle">
{isLogin ? 'Sign in to your account' : 'Create a new account'}
</p>
</div>
{error && (
<div className="auth-error">
<AlertCircle size={20} />
<span>{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="auth-form">
<div className="form-group">
<label className="form-label">
<UserIcon size={18} />
Skyward Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="your.username"
className="form-input"
required
autoComplete="username"
/>
</div>
<div className="form-group">
<label className="form-label">
<Lock size={18} />
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="form-input"
required
autoComplete={isLogin ? 'current-password' : 'new-password'}
minLength={4}
/>
{!isLogin && (
<p className="form-hint">Must be at least 4 characters</p>
)}
</div>
<button
type="submit"
disabled={loading}
className="submit-button"
>
{loading ? (
<>
<Loader2 className="spinner" size={20} />
{isLogin ? 'Signing in...' : 'Creating account...'}
</>
) : (
isLogin ? 'Sign In' : 'Create Account'
)}
</button>
</form>
<div className="auth-switch">
<button
onClick={() => {
setIsLogin(!isLogin);
setError('');
}}
className="switch-button"
>
{isLogin
? "Don't have an account? Register"
: 'Already have an account? Sign in'}
</button>
</div>
{!isLogin && (
<div className="auth-note">
<p>
Note: Your Skyward credentials will be verified during registration.
Make sure to use your actual Skyward username and password.
</p>
</div>
)}
</div>
</div>
);
};

View File

@@ -27,18 +27,34 @@ export const GradesTable = ({ data }) => {
return acc; return acc;
}, {}); }, {});
const calculateSemesterGrade = (nw1, nw2, se1) => { const calculateSemesterGrade = (nw1, nw2, se1) => {
const grades = [ let totalWeight = 0;
parseFloat(nw1) * 0.4, let weightedSum = 0;
parseFloat(nw2) * 0.4,
parseFloat(se1) * 0.2
];
// Check if all grades are valid numbers // Add NW1 (40% weight)
if (grades.some(g => isNaN(g))) return null; if (nw1 && !isNaN(parseFloat(nw1))) {
weightedSum += parseFloat(nw1) * 0.4;
totalWeight += 0.4;
}
const total = grades.reduce((sum, g) => sum + g, 0); // Add NW2 (40% weight)
return total.toFixed(2); 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) => { const getGradeColor = (grade) => {
@@ -99,6 +115,30 @@ export const GradesTable = ({ data }) => {
cls.grades['SE2'] cls.grades['SE2']
); );
isCalculated = true; 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 ( return (

View File

@@ -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 (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
};

View File

@@ -16,7 +16,7 @@ body {
} }
#root { #root {
min-height: 100vh; min-height: 10a0vh;
} }
/* Custom scrollbar */ /* Custom scrollbar */

View File

@@ -1,10 +1,13 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App.jsx'; import App from './App.jsx';
import { AuthProvider } from './contexts/AuthContext'; // ADD THIS
import './index.css'; import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<AuthProvider>
<App /> <App />
</AuthProvider>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -2,7 +2,9 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000
export const api = { export const api = {
async getGrades(username) { 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) { if (!response.ok) {
throw new Error('Failed to fetch grades'); throw new Error('Failed to fetch grades');
} }
@@ -10,7 +12,9 @@ export const api = {
}, },
async getClasses(username) { 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) { if (!response.ok) {
throw new Error('Failed to fetch classes'); throw new Error('Failed to fetch classes');
} }
@@ -18,7 +22,9 @@ export const api = {
}, },
async getFinalGrades(username) { 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) { if (!response.ok) {
throw new Error('Failed to fetch final grades'); throw new Error('Failed to fetch final grades');
} }
@@ -26,7 +32,9 @@ export const api = {
}, },
async getStats(username) { 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) { if (!response.ok) {
throw new Error('Failed to fetch stats'); throw new Error('Failed to fetch stats');
} }