Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions backend/middleware/verifyToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
// =============================================================

const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'cinebook_dev_secret_change_me';

module.exports = function verifyToken(req, res, next) {
// Expect: Authorization: Bearer <token>
Expand All @@ -26,10 +27,10 @@ module.exports = function verifyToken(req, res, next) {
}

try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded; // Attach decoded payload to req so routes can read it
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token. Please log in again.' });
}
};
};
99 changes: 47 additions & 52 deletions frontend/src/context/AuthContext.jsx
Original file line number Diff line number Diff line change
@@ -1,80 +1,75 @@
// =============================================================
// CineBook — frontend/src/context/AuthContext.jsx
// Authentication state — wraps the whole app in App.jsx
// CMPG 311 | Group 4 | 2026
// =============================================================
// Any component can access auth state with:
// import { useAuth } from '../context/AuthContext';
// const { user, isLoggedIn, login, logout } = useAuth();
// =============================================================

import { createContext, useContext, useState, useEffect } from 'react';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';

const AuthContext = createContext(null);
const TOKEN_KEY = 'cinebook_token';
const USER_KEY = 'cinebook_user';

function normalizeUser(userData) {
if (!userData) return null;
return {
User_Id: userData.User_Id ?? userData.user_id,
First_Name: userData.First_Name ?? userData.first_name,
Last_Name: userData.Last_Name ?? userData.last_name,
Email: userData.Email ?? userData.email,
Role: userData.Role ?? userData.role,
Loyalty_Status: userData.Loyalty_Status ?? userData.loyalty_status ?? 'Standard',
Theatre_Id: userData.Theatre_Id ?? userData.theatre_id ?? null
};
}

// ── Provider ─────────────────────────────────────────────────
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const [loading, setLoading] = useState(true); // true while restoring session
const [token, setToken] = useState('');
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);

// Restore session from localStorage on app load (page refresh)
useEffect(() => {
const savedToken = localStorage.getItem('cinebook_token');
const savedUser = localStorage.getItem('cinebook_user');

if (savedToken && savedUser) {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedUser = localStorage.getItem(USER_KEY);
if (storedToken && storedUser) {
try {
setToken(savedToken);
setUser(JSON.parse(savedUser));
setToken(storedToken);
setUser(normalizeUser(JSON.parse(storedUser)));
} catch {
// Corrupted data in storage — clear it
localStorage.removeItem('cinebook_token');
localStorage.removeItem('cinebook_user');
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
}
setLoading(false);
}, []);

// Called by LoginPage and RegisterPage after successful auth
const login = (newToken, userData) => {
localStorage.setItem('cinebook_token', newToken);
localStorage.setItem('cinebook_user', JSON.stringify(userData));
const normalizedUser = normalizeUser(userData);
localStorage.setItem(TOKEN_KEY, newToken);
localStorage.setItem(USER_KEY, JSON.stringify(normalizedUser));
setToken(newToken);
setUser(userData);
setUser(normalizedUser);
};

// Called by Navbar logout button
const logout = () => {
localStorage.removeItem('cinebook_token');
localStorage.removeItem('cinebook_user');
setToken(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
localStorage.removeItem('cinebook_pending_payment');
setToken('');
setUser(null);
};

const value = {
user,
token,
loading,
isLoggedIn : !!user,
login,
logout
};

return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
const value = useMemo(
() => ({
isLoggedIn: !!token && !!user,
token,
user,
loading,
login,
logout
}),
[token, user, loading]
);

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

// ── Hook (shortcut for consuming the context) ────────────────
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used inside an AuthProvider');
}
if (!context) throw new Error('useAuth must be used inside AuthProvider');
return context;
}

export default AuthContext;
112 changes: 103 additions & 9 deletions frontend/src/pages/LoginPage.jsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,109 @@
// frontend/src/pages/LoginPage.jsx
// Login page — email/password form that calls POST /api/auth/login
// and updates the AuthContext with the returned user and JWT token.
import { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import MovieImage from '../components/MovieImage.jsx';
import { useAuth } from '../context/AuthContext.jsx';
import { authApi } from '../services/api.js';
import { dashboardPathForRole } from '../utils/roles.js';

import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import api from '../services/api.js';
import { useAuth } from '../context/AuthContext.jsx';
const posters = [
['Sinners', '/fWPgbnt2LSqkQ6cdQc0SZN9CpLm.jpg', '2025-04-18'],
['Mission: Impossible - The Final Reckoning', '/z53D72EAOxGRqdr7KXXWp9dJiDe.jpg', '2025-05-23'],
['Thunderbolts*', '/hqcexYHbiTBfDIdDWxrxPtVndBX.jpg', '2025-05-02'],
['Final Destination: Bloodlines', '/6WxhEvFsauuACfv8HyoVX6mZKFj.jpg', '2025-05-16']
];

export default function LoginPage() {
// TODO: Render an email and password form, submit credentials to POST /api/auth/login, call login() from AuthContext with the returned user and token, then navigate to the home page
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [form, setForm] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const [shake, setShake] = useState(false);
const [submitting, setSubmitting] = useState(false);

const submit = async (event) => {
event.preventDefault();

if (!form.email || !form.password) {
setError('Please enter your email and password.');
setShake(true);
setTimeout(() => setShake(false), 420);
return;
}

try {
setSubmitting(true);
setError('');
const { data } = await authApi.login({
Email: form.email,
Password: form.password
});
login(data.token, data.user);
navigate(location.state?.from || dashboardPathForRole(data.user?.Role));
} catch (err) {
setError(err.response?.data?.error || 'Unable to sign in. Please check your details.');
setShake(true);
setTimeout(() => setShake(false), 420);
} finally {
setSubmitting(false);
}
};

return (
<div>Login form goes here</div>
<main className="auth-shell page-enter grid min-h-screen pt-20 lg:grid-cols-[1.05fr_0.95fr]">
<section className="relative hidden overflow-hidden lg:block">
<MovieImage
src="https://image.tmdb.org/t/p/w1280/xPNDRM50a58uvv1il2GVZrtWjkZ.jpg"
alt="Mission: Impossible - The Final Reckoning"
type="backdrop"
releaseDate="2025-05-23"
className="absolute inset-0"
imageClassName="h-full w-full object-cover"
/>
<div className="absolute inset-0 z-20 bg-[linear-gradient(90deg,rgba(5,5,6,0.82),rgba(5,5,6,0.35),rgba(5,5,6,0.92))]" />
<div className="absolute bottom-10 left-10 right-10 z-30">
<p className="eyebrow">CineBook Access</p>
<h1 className="section-title mt-3 max-w-xl text-7xl">Your next seat is waiting</h1>
<div className="mt-6 flex gap-3">
{posters.map(([title, path, releaseDate], idx) => (
<MovieImage
key={title}
src={`https://image.tmdb.org/t/p/w500${path}`}
alt={title}
type="poster"
releaseDate={releaseDate}
className="poster-float h-48 w-32 rounded-lg border border-cb-border shadow-2xl"
style={{ '--poster-rotate': `${idx % 2 ? '-' : ''}${2 + idx}deg`, animationDelay: `${idx * 0.18}s` }}
/>
))}
</div>
</div>
</section>

<section className="flex items-center justify-center px-4 py-8">
<form onSubmit={submit} className={`cinema-panel w-full max-w-md p-6 md:p-8 ${shake ? 'animate-[shake_0.32s_ease]' : ''}`}>
<p className="eyebrow">Welcome Back</p>
<h1 className="section-title mt-2 text-6xl">Sign In</h1>
<div className="mt-7 space-y-4">
<label>
<span className="input-label">Email</span>
<input value={form.email} onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))} type="email" className="input-field" autoComplete="email" />
</label>
<label>
<span className="input-label">Password</span>
<input value={form.password} onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))} type="password" className="input-field" autoComplete="current-password" />
</label>
{error && <p className="rounded-lg border border-red-900/70 bg-red-950/40 p-3 text-sm text-red-300">{error}</p>}
<button disabled={submitting} className="btn-primary w-full disabled:opacity-70">
{submitting ? 'Signing In...' : 'Sign In'}
</button>
</div>
<div className="mt-5 flex flex-wrap justify-between gap-3 text-sm text-cb-secondary">
<a href="mailto:support@cinebook.co.za?subject=Password%20Reset%20Request" className="hover:text-cb-accent">Forgot password</a>
<p>New here? <Link to="/register" className="font-bold text-cb-accent">Create account</Link></p>
</div>
</form>
</section>
</main>
);
}
Loading