diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..ef1cf86 Binary files /dev/null and b/.DS_Store differ diff --git a/shatter-backend/package-lock.json b/shatter-backend/package-lock.json index b7963ac..dd99515 100644 --- a/shatter-backend/package-lock.json +++ b/shatter-backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "axios": "^1.13.5", "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^17.2.3", @@ -1003,6 +1004,17 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1931,6 +1943,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -3035,6 +3067,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/shatter-backend/package.json b/shatter-backend/package.json index a3552a1..a9c6f6f 100644 --- a/shatter-backend/package.json +++ b/shatter-backend/package.json @@ -13,6 +13,7 @@ "license": "ISC", "description": "", "dependencies": { + "axios": "^1.13.5", "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^17.2.3", diff --git a/shatter-backend/src/.DS_Store b/shatter-backend/src/.DS_Store new file mode 100644 index 0000000..d83f85b Binary files /dev/null and b/shatter-backend/src/.DS_Store differ diff --git a/shatter-backend/src/controllers/auth_controller.ts b/shatter-backend/src/controllers/auth_controller.ts index 0c49a79..7d23c0c 100644 --- a/shatter-backend/src/controllers/auth_controller.ts +++ b/shatter-backend/src/controllers/auth_controller.ts @@ -1,7 +1,13 @@ +import crypto from 'crypto'; +import jwt from 'jsonwebtoken'; import { Request, Response } from 'express'; import { User } from '../models/user_model'; +import { AuthCode } from '../models/auth_code_model'; import { hashPassword, comparePassword } from '../utils/password_hash'; import { generateToken } from '../utils/jwt_utils'; +import { getLinkedInAuthUrl, getLinkedInAccessToken, getLinkedInProfile } from '../utils/linkedin_oauth'; + +const JWT_SECRET = process.env.JWT_SECRET || ''; // Email validation regex const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; @@ -140,7 +146,12 @@ export const login = async (req: Request, res: Response) => { }); } - // 7 - verify password + // 7 - verify password (OAuth users won't have a passwordHash) + if (!user.passwordHash) { + return res.status(401).json({ + error: 'This account uses LinkedIn login. Please sign in with LinkedIn.', + }); + } const isPasswordValid = await comparePassword(password, user.passwordHash); if (!isPasswordValid) { @@ -173,3 +184,156 @@ export const login = async (req: Request, res: Response) => { } }; + +/** + * GET /api/auth/linkedin + * Initiates LinkedIn OAuth flow by redirecting to LinkedIn + */ +export const linkedinAuth = async (req: Request, res: Response) => { + try { + // Generate CSRF protection state token + const state = crypto.randomBytes(16).toString('hex'); + + // Encode state as JWT with 5-minute expiration (stateless validation) + const stateToken = jwt.sign({ state }, JWT_SECRET, { expiresIn: '5m' }); + + // Build LinkedIn authorization URL and redirect + const authUrl = getLinkedInAuthUrl(stateToken); + res.redirect(authUrl); + } catch (error) { + console.error('LinkedIn auth initiation error:', error); + res.status(500).json({ error: 'Failed to initiate LinkedIn authentication' }); + } +}; + + +/** + * GET /api/auth/linkedin/callback + * LinkedIn redirects here after user authorization + */ +export const linkedinCallback = async (req: Request, res: Response) => { + try { + const { code, state, error: oauthError } = req.query as { + code?: string; + state?: string; + error?: string; + }; + + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:19006'; + + // Handle user denial + if (oauthError === 'user_cancelled_authorize') { + return res.redirect(`${frontendUrl}/auth/error?message=Authorization cancelled`); + } + + // Validate required parameters + if (!code || !state) { + return res.status(400).json({ error: 'Missing code or state parameter' }); + } + + // Verify state token (CSRF protection) + try { + jwt.verify(state, JWT_SECRET); + } catch { + return res.status(401).json({ error: 'Invalid state parameter' }); + } + + // Exchange code for access token + const accessToken = await getLinkedInAccessToken(code); + + // Fetch user profile from LinkedIn + const linkedinProfile = await getLinkedInProfile(accessToken); + + // Validate email is provided + if (!linkedinProfile.email) { + return res.status(400).json({ + error: 'Email address required', + suggestion: 'Please make your email visible to third-party apps in LinkedIn settings', + }); + } + + // Find existing user by LinkedIn ID + let user = await User.findOne({ linkedinId: linkedinProfile.sub }); + + if (!user) { + // Check if email already exists with password auth (email conflict) + const existingEmailUser = await User.findOne({ + email: linkedinProfile.email.toLowerCase().trim(), + }); + + if (existingEmailUser) { + return res.redirect( + `${frontendUrl}/auth/error?message=Email already registered with password&suggestion=Please login with your password` + ); + } + + // Create new user from LinkedIn data + user = await User.create({ + name: linkedinProfile.name, + email: linkedinProfile.email.toLowerCase().trim(), + linkedinId: linkedinProfile.sub, + profilePhoto: linkedinProfile.picture, + authProvider: 'linkedin', + lastLogin: new Date(), + }); + } else { + // Update existing user's last login + user.lastLogin = new Date(); + await user.save(); + } + + // Generate single-use auth code and redirect to frontend + const authCode = crypto.randomBytes(32).toString('hex'); + await AuthCode.create({ code: authCode, userId: user._id }); + return res.redirect(`${frontendUrl}/auth/callback?code=${authCode}`); + + } catch (error: any) { + console.error('LinkedIn callback error:', error); + + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:19006'; + if (error.message?.includes('LinkedIn')) { + return res.redirect(`${frontendUrl}/auth/error?message=LinkedIn authentication failed`); + } + + res.status(500).json({ error: 'Authentication failed. Please try again.' }); + } +}; + + +/** + * POST /api/auth/exchange + * Exchange a single-use auth code for a JWT token + * + * @param req.body.code - The auth code from the OAuth callback redirect + * @returns 200 with userId and JWT token on success + */ +export const exchangeAuthCode = async (req: Request, res: Response) => { + try { + const { code } = req.body as { code?: string }; + + if (!code) { + return res.status(400).json({ error: 'Auth code is required' }); + } + + // Find and delete the auth code in one atomic operation (single-use) + const authCodeDoc = await AuthCode.findOneAndDelete({ code }); + + if (!authCodeDoc) { + return res.status(401).json({ error: 'Invalid or expired auth code' }); + } + + // Generate JWT token + const token = generateToken(authCodeDoc.userId.toString()); + + res.status(200).json({ + message: 'Authentication successful', + userId: authCodeDoc.userId, + token, + }); + + } catch (err: any) { + console.error('POST /api/auth/exchange error:', err); + res.status(500).json({ error: 'Failed to exchange auth code' }); + } +}; + diff --git a/shatter-backend/src/controllers/bingo_controller.ts b/shatter-backend/src/controllers/bingo_controller.ts index acf7661..e094962 100644 --- a/shatter-backend/src/controllers/bingo_controller.ts +++ b/shatter-backend/src/controllers/bingo_controller.ts @@ -72,25 +72,21 @@ export async function createBingo(req: Request, res: Response) { } } - -/** - * @param req.body.id - Bingo _id (string) OR Event _id (ObjectId string) (required) - */ export async function getBingo(req: Request, res: Response) { try { - const { id } = req.body; + const { eventId } = req.params; - if (!id) { + if (!eventId) { return res.status(400).json({ success: false, - msg: "id is required", + msg: "eventId is required", }); } - let bingo = await Bingo.findById(id); + let bingo = await Bingo.findById(eventId); - if (!bingo && Types.ObjectId.isValid(id)) { - bingo = await Bingo.findOne({ _eventId: id }); + if (!bingo && Types.ObjectId.isValid(eventId)) { + bingo = await Bingo.findOne({ _eventId: eventId }); } if (!bingo) { diff --git a/shatter-backend/src/controllers/event_controller.ts b/shatter-backend/src/controllers/event_controller.ts index 9d1468b..f884d3a 100644 --- a/shatter-backend/src/controllers/event_controller.ts +++ b/shatter-backend/src/controllers/event_controller.ts @@ -5,6 +5,7 @@ import { pusher } from "../utils/pusher_websocket"; import "../models/participant_model"; import { generateJoinCode } from "../utils/event_utils"; +import { generateToken } from "../utils/jwt_utils"; import { Participant } from "../models/participant_model"; import { User } from "../models/user_model"; import { Types } from "mongoose"; @@ -247,20 +248,33 @@ export async function joinEventAsGuest(req: Request, res: Response) { return res.status(400).json({ success: false, msg: "Event is full" }); } - // Create guest participant (userId is null) + // Create a guest user account so they get a JWT and can complete their profile later + const user = await User.create({ + name, + authProvider: 'guest', + }); + + const userId = user._id as Types.ObjectId; + const token = generateToken(userId.toString()); + + // Create participant linked to the new user const participant = await Participant.create({ - userId: null, + userId, name, eventId, }); const participantId = participant._id as Types.ObjectId; - // Add participant to event + // Add participant to event and event to user history await Event.updateOne( { _id: eventId }, { $addToSet: { participantIds: participantId } }, ); + await User.updateOne( + { _id: userId }, + { $addToSet: { eventHistoryIds: eventId } }, + ); // Emit socket console.log("Room socket:", eventId); @@ -278,6 +292,8 @@ export async function joinEventAsGuest(req: Request, res: Response) { return res.json({ success: true, participant, + userId, + token, }); } catch (e: any) { if (e.code === 11000) { @@ -328,3 +344,41 @@ export async function getEventById(req: Request, res: Response) { res.status(500).json({ success: false, error: err.message }); } } + +/** + * GET /api/events/createdEvents/user/:userId + * Get list of events created by a specific user + * + * @param req.params.userId - User ID (required) + * + * @returns 200 with list of events on success + * @returns 400 if userId is missing + * @returns 404 if no events are found for the user + */ +export async function getEventsByUserId(req: Request, res: Response) { + try { + const { userId } = req.params; + + if (!userId) { + return res + .status(400) + .json({ success: false, error: "userId is required" }); + } + + const events = await Event.find({ createdBy: userId }); + + if (!events || events.length === 0) { + return res.status(404).json({ + success: false, + error: "No events found for this user", + }); + } + + res.status(200).json({ + success: true, + events, + }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } +} \ No newline at end of file diff --git a/shatter-backend/src/controllers/user_controller.ts b/shatter-backend/src/controllers/user_controller.ts index e33c0d0..55e745b 100644 --- a/shatter-backend/src/controllers/user_controller.ts +++ b/shatter-backend/src/controllers/user_controller.ts @@ -1,5 +1,8 @@ import { Request, Response } from "express"; import { User } from "../models/user_model"; +import { hashPassword } from "../utils/password_hash"; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; // controller: GET /api/users // This function handles GET reqs to /api/users @@ -88,3 +91,90 @@ export const getUserEvents = async (req: Request, res: Response) => { res.status(500).json({ success: false, error: err.message }); } }; + +/** + * PUT /api/users/:userId + * Update user profile. Users can only update their own profile. + * Guest users can upgrade to local auth by providing email + password. + */ +export const updateUser = async (req: Request, res: Response) => { + try { + const { userId } = req.params; + + // Users can only update their own profile + if (req.user?.userId !== userId) { + return res.status(403).json({ success: false, error: "You can only update your own profile" }); + } + + const { name, email, password, bio, profilePhoto, socialLinks } = req.body as { + name?: string; + email?: string; + password?: string; + bio?: string; + profilePhoto?: string; + socialLinks?: { linkedin?: string; github?: string; other?: string }; + }; + + const updateFields: Record = {}; + + if (name !== undefined) { + if (!name.trim()) { + return res.status(400).json({ success: false, error: "Name cannot be empty" }); + } + updateFields.name = name.trim(); + } + + if (email !== undefined) { + const normalizedEmail = email.toLowerCase().trim(); + if (!EMAIL_REGEX.test(normalizedEmail)) { + return res.status(400).json({ success: false, error: "Invalid email format" }); + } + // Check for duplicate email + const existing = await User.findOne({ email: normalizedEmail, _id: { $ne: userId } }).lean(); + if (existing) { + return res.status(409).json({ success: false, error: "Email already in use" }); + } + updateFields.email = normalizedEmail; + } + + if (password !== undefined) { + if (password.length < 8) { + return res.status(400).json({ success: false, error: "Password must be at least 8 characters long" }); + } + updateFields.passwordHash = await hashPassword(password); + updateFields.passwordChangedAt = new Date(); + // Upgrade guest users to local auth when they set a password + const currentUser = await User.findById(userId).lean(); + if (currentUser?.authProvider === 'guest') { + updateFields.authProvider = 'local'; + } + } + + if (bio !== undefined) updateFields.bio = bio; + if (profilePhoto !== undefined) updateFields.profilePhoto = profilePhoto; + if (socialLinks !== undefined) updateFields.socialLinks = socialLinks; + + if (Object.keys(updateFields).length === 0) { + return res.status(400).json({ success: false, error: "No fields to update" }); + } + + const result = await User.updateOne( + { _id: userId }, + { $set: updateFields }, + ); + + if (result.matchedCount === 0) { + return res.status(404).json({ success: false, error: "User not found" }); + } + + const updatedUser = await User.findById(userId).select("-passwordHash"); + + res.status(200).json({ success: true, user: updatedUser }); + } catch (err: any) { + if (err?.code === 11000) { + return res.status(409).json({ success: false, error: "Email already in use" }); + } + console.error("PUT /api/users/:userId error:", err); + res.status(500).json({ success: false, error: "Failed to update user" }); + } +}; diff --git a/shatter-backend/src/models/auth_code_model.ts b/shatter-backend/src/models/auth_code_model.ts new file mode 100644 index 0000000..8a593d8 --- /dev/null +++ b/shatter-backend/src/models/auth_code_model.ts @@ -0,0 +1,28 @@ +import { Schema, model } from 'mongoose'; + +export interface IAuthCode { + code: string; + userId: Schema.Types.ObjectId; + createdAt: Date; +} + +const AuthCodeSchema = new Schema({ + code: { + type: String, + required: true, + unique: true, + index: true, + }, + userId: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + createdAt: { + type: Date, + default: Date.now, + expires: 60, // TTL: auto-deletes after 60 seconds + }, +}); + +export const AuthCode = model('AuthCode', AuthCodeSchema); diff --git a/shatter-backend/src/models/user_model.ts b/shatter-backend/src/models/user_model.ts index a2d29e2..521553e 100644 --- a/shatter-backend/src/models/user_model.ts +++ b/shatter-backend/src/models/user_model.ts @@ -8,8 +8,18 @@ import { Schema, model } from "mongoose"; export interface IUser { name: string; - email: string; - passwordHash: string; + email?: string; + passwordHash?: string; + linkedinId?: string; + linkedinUrl?: string; + bio?: string; + profilePhoto?: string; + socialLinks?: { + linkedin?: string; + github?: string; + other?: string; + }; + authProvider: 'local' | 'linkedin' | 'guest'; lastLogin?: Date; passwordChangedAt?: Date; createdAt?: Date; @@ -30,10 +40,11 @@ const UserSchema = new Schema( }, email: { type: String, - required: true, + required: false, trim: true, lowercase: true, unique: true, + sparse: true, // allows multiple users without email (guests) index: true, match: [ /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/, @@ -42,9 +53,37 @@ const UserSchema = new Schema( }, passwordHash: { type: String, - required: true, + required: false, select: false, // Don't return in queries by default }, + linkedinId: { + type: String, + unique: true, + sparse: true, // allows null but enforces uniqueness when set + }, + linkedinUrl: { + type: String, + unique: true, + sparse: true, + }, + bio: { + type: String, + trim: true, + }, + profilePhoto: { + type: String, + }, + socialLinks: { + linkedin: { type: String }, + github: { type: String }, + other: { type: String }, + }, + authProvider: { + type: String, + enum: ['local', 'linkedin', 'guest'], + default: 'local', + required: true, + }, lastLogin: { type: Date, default: null, @@ -68,7 +107,15 @@ const UserSchema = new Schema( } ); -// Add middleware to auto-update passwordChangedAt +// Ensure local auth users have a password +UserSchema.pre("save", function (next) { + if (this.authProvider === "local" && !this.passwordHash) { + return next(new Error("Password required for local authentication")); + } + next(); +}); + +// Auto-update passwordChangedAt UserSchema.pre("save", function (next) { if (this.isModified("passwordHash") && !this.isNew) { this.passwordChangedAt = new Date(); diff --git a/shatter-backend/src/routes/auth_routes.ts b/shatter-backend/src/routes/auth_routes.ts index 5db96cd..e1487fc 100644 --- a/shatter-backend/src/routes/auth_routes.ts +++ b/shatter-backend/src/routes/auth_routes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { signup, login } from '../controllers/auth_controller'; +import { signup, login, linkedinAuth, linkedinCallback, exchangeAuthCode } from '../controllers/auth_controller'; const router = Router(); @@ -9,4 +9,11 @@ router.post('/signup', signup); // POST /api/auth/login - authenticate user router.post('/login', login); +// LinkedIn OAuth routes +router.get('/linkedin', linkedinAuth); +router.get('/linkedin/callback', linkedinCallback); + +// Auth code exchange (OAuth callback → JWT) +router.post('/exchange', exchangeAuthCode); + export default router; diff --git a/shatter-backend/src/routes/bingo_routes.ts b/shatter-backend/src/routes/bingo_routes.ts index 2e9238f..7529310 100644 --- a/shatter-backend/src/routes/bingo_routes.ts +++ b/shatter-backend/src/routes/bingo_routes.ts @@ -1,16 +1,16 @@ import { Router } from 'express'; import { createBingo, getBingo, updateBingo} from '../controllers/bingo_controller'; +import { authMiddleware } from '../middleware/auth_middleware'; const router = Router(); -// POST /api/bingo/createEvent - create new event -router.post('/createBingo', createBingo); +router.post('/createBingo', authMiddleware, createBingo); // POST /api/bingo/getBingo - get bingo details -router.post('/getBingo', getBingo); +router.get('/getBingo/:eventId', getBingo); // POST /api/bingo/updateBingo - update bingo details -router.put("/updateBingo", updateBingo); +router.put("/updateBingo", authMiddleware, updateBingo); export default router; diff --git a/shatter-backend/src/routes/event_routes.ts b/shatter-backend/src/routes/event_routes.ts index 761a120..cf03550 100644 --- a/shatter-backend/src/routes/event_routes.ts +++ b/shatter-backend/src/routes/event_routes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { createEvent, getEventByJoinCode, getEventById, joinEventAsUser, joinEventAsGuest } from '../controllers/event_controller'; +import { createEvent, getEventByJoinCode, getEventById, joinEventAsUser, joinEventAsGuest, getEventsByUserId } from '../controllers/event_controller'; import { authMiddleware } from '../middleware/auth_middleware'; const router = Router(); @@ -10,6 +10,7 @@ router.get("/event/:joinCode", getEventByJoinCode); router.get("/:eventId", getEventById); router.post("/:eventId/join/user", authMiddleware, joinEventAsUser); router.post("/:eventId/join/guest", joinEventAsGuest); +router.get("/createdEvents/user/:userId", authMiddleware, getEventsByUserId); export default router; \ No newline at end of file diff --git a/shatter-backend/src/routes/user_route.ts b/shatter-backend/src/routes/user_route.ts index d8df3e2..4d7688a 100644 --- a/shatter-backend/src/routes/user_route.ts +++ b/shatter-backend/src/routes/user_route.ts @@ -1,5 +1,5 @@ import { Router, Request, Response } from 'express'; -import { getUsers, createUser, getUserById, getUserEvents } from '../controllers/user_controller'; +import { getUsers, createUser, getUserById, getUserEvents, updateUser } from '../controllers/user_controller'; import { authMiddleware } from '../middleware/auth_middleware'; import { User } from '../models/user_model'; @@ -25,6 +25,9 @@ router.get('/me', authMiddleware, async (req: Request, res: Response) => { // Get all events a user has joined - must come before /:userId to avoid route conflict router.get('/:userId/events', authMiddleware, getUserEvents); +// Update user profile (protected, self only) +router.put('/:userId', authMiddleware, updateUser); + // Get user by ID router.get('/:userId', authMiddleware, getUserById); diff --git a/shatter-backend/src/server.ts b/shatter-backend/src/server.ts index 7bff319..e209977 100644 --- a/shatter-backend/src/server.ts +++ b/shatter-backend/src/server.ts @@ -7,6 +7,21 @@ const MONGODB_URI = process.env.MONGO_URI; async function start() { try { + // Validate required environment variables + const requiredEnvVars = [ + 'MONGO_URI', + 'JWT_SECRET', + 'LINKEDIN_CLIENT_ID', + 'LINKEDIN_CLIENT_SECRET', + 'LINKEDIN_CALLBACK_URL', + ]; + + for (const envVar of requiredEnvVars) { + if (!process.env[envVar]) { + throw new Error(`Missing required environment variable: ${envVar}`); + } + } + if (!MONGODB_URI) { throw new Error("MONGO_URI is not set"); } diff --git a/shatter-backend/src/utils/linkedin_oauth.ts b/shatter-backend/src/utils/linkedin_oauth.ts new file mode 100644 index 0000000..1fdb49e --- /dev/null +++ b/shatter-backend/src/utils/linkedin_oauth.ts @@ -0,0 +1,78 @@ +import axios from 'axios'; + +const LINKEDIN_AUTH_URL = 'https://www.linkedin.com/oauth/v2/authorization'; +const LINKEDIN_TOKEN_URL = 'https://www.linkedin.com/oauth/v2/accessToken'; +const LINKEDIN_PROFILE_URL = 'https://api.linkedin.com/v2/userinfo'; + +export interface LinkedInProfile { + sub: string; // LinkedIn user ID + name: string; // Full name + email: string; // Email address + picture?: string; // Profile picture URL +} + +/** + * Generate LinkedIn authorization URL + */ +export const getLinkedInAuthUrl = (state: string): string => { + const params = new URLSearchParams({ + response_type: 'code', + client_id: process.env.LINKEDIN_CLIENT_ID!, + redirect_uri: process.env.LINKEDIN_CALLBACK_URL!, + state: state, + scope: 'openid profile email', + }); + + return `${LINKEDIN_AUTH_URL}?${params.toString()}`; +}; + +/** + * Exchange authorization code for access token + */ +export const getLinkedInAccessToken = async (code: string): Promise => { + try { + const response = await axios.post( + LINKEDIN_TOKEN_URL, + new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_id: process.env.LINKEDIN_CLIENT_ID!, + client_secret: process.env.LINKEDIN_CLIENT_SECRET!, + redirect_uri: process.env.LINKEDIN_CALLBACK_URL!, + }), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + } + ); + + return response.data.access_token; + } catch (error: any) { + console.error('LinkedIn token exchange error:', error.response?.data || error.message); + throw new Error('Failed to obtain LinkedIn access token'); + } +}; + +/** + * Fetch user profile from LinkedIn using access token + */ +export const getLinkedInProfile = async (accessToken: string): Promise => { + try { + const response = await axios.get(LINKEDIN_PROFILE_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.data || !response.data.sub) { + throw new Error('Invalid LinkedIn API response format'); + } + + return { + sub: response.data.sub, + name: response.data.name || 'LinkedIn User', + email: response.data.email || '', + picture: response.data.picture, + }; + } catch (error: any) { + console.error('LinkedIn profile fetch error:', error.response?.data || error.message); + throw new Error('Failed to fetch LinkedIn profile'); + } +}; diff --git a/shatter-mobile/app/(tabs)/Events.tsx b/shatter-mobile/app/(tabs)/Events.tsx index f04954f..9ddf410 100644 --- a/shatter-mobile/app/(tabs)/Events.tsx +++ b/shatter-mobile/app/(tabs)/Events.tsx @@ -79,7 +79,7 @@ const NewEvents = () => { onPress={() => handlePress(item._id)} onJoinGame={() => { router.push({ - pathname: "/Game", + pathname: "/GamePages/Game", params: { eventId: item._id }, }); }} diff --git a/shatter-mobile/app/(tabs)/JoinEvent.tsx b/shatter-mobile/app/(tabs)/JoinEvent.tsx index f144720..7f59d5d 100644 --- a/shatter-mobile/app/(tabs)/JoinEvent.tsx +++ b/shatter-mobile/app/(tabs)/JoinEvent.tsx @@ -43,9 +43,9 @@ export default function JoinEventPage() { + + + {/* Stats */} +
+
+
+ 100+ +
+
+ Events Hosted +
+
+
+
+ 5K+ +
+
+ Connections Made +
+
+
+
+ 98% +
+
+ Satisfaction Rate +
+
+
+ + + {/* Right Column - QR Card */} +
+ +
+ + + + {/* Scroll Indicator */} +
+ + + +
+ + {/* Custom Animations */} + + + ); +}; + +export default Hero; diff --git a/shatter-web/src/components/Navbar.tsx b/shatter-web/src/components/Navbar.tsx new file mode 100644 index 0000000..5d6e5b3 --- /dev/null +++ b/shatter-web/src/components/Navbar.tsx @@ -0,0 +1,301 @@ +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +import logo from "../assets/ShatterLogo_White.png"; + +export default function Navbar() { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const navigate = useNavigate(); + + // Check if user is logged in on component mount and when localStorage changes + useEffect(() => { + const checkAuthStatus = () => { + const token = localStorage.getItem('token'); + setIsLoggedIn(!!token); + }; + + checkAuthStatus(); + + // Listen for storage changes (logout from another tab) + window.addEventListener('storage', checkAuthStatus); + + // Custom event for login/logout within the same tab + window.addEventListener('authChange', checkAuthStatus); + + return () => { + window.removeEventListener('storage', checkAuthStatus); + window.removeEventListener('authChange', checkAuthStatus); + }; + }, []); + + const toggleMenu = () => { + setIsMenuOpen(!isMenuOpen); + }; + + const handleSignOut = () => { + // Remove token from localStorage + localStorage.removeItem('token'); + + // Update state + setIsLoggedIn(false); + + // Dispatch custom event for other components to react + window.dispatchEvent(new Event('authChange')); + + // Close mobile menu if open + setIsMenuOpen(false); + + // Redirect to home page + navigate('/'); + }; + + return ( + + ); +} diff --git a/shatter-web/src/components/QRCard.tsx b/shatter-web/src/components/QRCard.tsx new file mode 100644 index 0000000..ee139f3 --- /dev/null +++ b/shatter-web/src/components/QRCard.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { QRCodeSVG } from 'qrcode.react'; + +interface QrCardProps { + qrPayload: string; + size?: number; +} + +const QRCard: React.FC = ({ qrPayload, size = 220 }) => ( +
+
+
+

Join Event

+ Scan +
+ +
+ +
+ +

+ Scan the QR to join this creative networking event. +

+ + + Join Code: {qrPayload} + +
+
+); + +export default QRCard; diff --git a/shatter-web/src/components/QRCodeDisplay.tsx b/shatter-web/src/components/QRCodeDisplay.tsx deleted file mode 100644 index ddb9e08..0000000 --- a/shatter-web/src/components/QRCodeDisplay.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useState } from "react"; -import { QRCodeSVG } from 'qrcode.react'; - -function QRCodeDisplay() { - return ( - <> -
- -
- - ); -} - -export default QRCodeDisplay; diff --git a/shatter-web/src/components/TestComponent.tsx b/shatter-web/src/components/TestComponent.tsx deleted file mode 100644 index ab13f14..0000000 --- a/shatter-web/src/components/TestComponent.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useState } from "react"; - -function TestComponent() { - return ( - <> -
-

Hello!!!!!

-
- - ); -} - -export default TestComponent; diff --git a/shatter-web/src/hooks/useEventData.ts b/shatter-web/src/hooks/useEventData.ts new file mode 100644 index 0000000..0116d51 --- /dev/null +++ b/shatter-web/src/hooks/useEventData.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import type { Participant } from "../types/participant"; + +interface EventDetails { + _id: string; + name: string; + description: string; + joinCode: string; + startDate: string; + endDate: string; + maxParticipant: number; + currentState: string; + participantIds: Participant[]; +} + +interface EventResponse { + success: boolean; + event: EventDetails; +} + +export function useEventData(joinCode: string | undefined) { + const [eventId, setEventId] = useState(null); + const [eventDetails, setEventDetails] = useState(null); + const [participants, setParticipants] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!joinCode) { + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + fetch(`${import.meta.env.VITE_API_URL}/events/event/${joinCode}`, { + cache: "no-store", + }) + .then((res) => { + if (!res.ok) { + throw new Error("Event not found"); + } + return res.json(); + }) + .then((data: EventResponse) => { + if (data.success && data.event) { + setEventId(data.event._id); + setEventDetails(data.event); + setParticipants(data.event.participantIds); + } else { + setEventId(null); + setEventDetails(null); + setError("Event not found"); + } + }) + .catch((err) => { + console.error("Error fetching event:", err); + setEventId(null); + setEventDetails(null); + setError(err.message || "Failed to load event"); + }) + .finally(() => setLoading(false)); + }, [joinCode]); + + return { eventId, eventDetails, participants, loading, error }; +} diff --git a/shatter-web/src/hooks/useEventParticipants.ts b/shatter-web/src/hooks/useEventParticipants.ts new file mode 100644 index 0000000..a762b82 --- /dev/null +++ b/shatter-web/src/hooks/useEventParticipants.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import { pusher } from "../libs/pusher_websocket"; +import type { Participant } from "../types/participant"; + +export function useEventParticipants( + eventId: string | null, + initialParticipants: Participant[], +) { + const [participants, setParticipants] = useState([]); + + useEffect(() => { + if (initialParticipants.length > 0) { + setParticipants(initialParticipants); + } + }, [initialParticipants]); + + useEffect(() => { + if (!eventId) return; + + const channelName = `event-${eventId}`; + const channel = pusher.subscribe(channelName); + + const handleParticipantJoined = (p: Participant) => { + setParticipants((prev) => { + if (prev.some((x) => x.participantId === p.participantId)) { + return prev; + } + return [...prev, p]; + }); + }; + + channel.bind("participant-joined", handleParticipantJoined); + + return () => { + channel.unbind("participant-joined", handleParticipantJoined); + pusher.unsubscribe(channelName); + }; + }, [eventId]); + + return { participants }; +} diff --git a/shatter-web/src/libs/pusher_websocket.ts b/shatter-web/src/libs/pusher_websocket.ts new file mode 100644 index 0000000..5d12c19 --- /dev/null +++ b/shatter-web/src/libs/pusher_websocket.ts @@ -0,0 +1,9 @@ +import Pusher from "pusher-js"; + +export const pusher = new Pusher( + import.meta.env.VITE_PUSHER_KEY, + { + cluster: import.meta.env.VITE_PUSHER_CLUSTER, + forceTLS: true, + } +); diff --git a/shatter-web/src/main.tsx b/shatter-web/src/main.tsx index bef5202..85a16d5 100644 --- a/shatter-web/src/main.tsx +++ b/shatter-web/src/main.tsx @@ -1,10 +1,13 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import "./index.css"; +import App from "./App.tsx"; -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( - - , -) + + + + +); diff --git a/shatter-web/src/pages/CreateEventPage.tsx b/shatter-web/src/pages/CreateEventPage.tsx new file mode 100644 index 0000000..effc0b2 --- /dev/null +++ b/shatter-web/src/pages/CreateEventPage.tsx @@ -0,0 +1,276 @@ +import React, { useState } from "react"; +import { useEffect } from "react"; +import Navbar from "../components/Navbar"; +import Footer from "../components/Footer"; +import { CreateEvent } from "../service/CreateEvent"; +import { createBingoGame } from "../service/BingoGame"; // ✅ NEW +import { useNavigate } from "react-router-dom"; + +function CreateEventPage() { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [maxParticipant, setMaxParticipant] = useState( + undefined + ); + + // ✅ NEW: Name Bingo selection + const [nameBingoSelected, setNameBingoSelected] = useState(false); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const navigate = useNavigate(); + + const handleCreateEvent = async () => { + try { + setLoading(true); + setError(null); + + // 1️⃣ Create event + const { eventId, joinCode } = await CreateEvent({ + name, + description, + startDate, + endDate, + maxParticipants: maxParticipant ?? 0, + }); + + // 2️⃣ If Name Bingo selected, create bingo game + if (nameBingoSelected) { + const token = localStorage.getItem("token"); + if (!token) { + throw new Error("Authentication required to create bingo game."); + } + + await createBingoGame(eventId, token); + } + + // 3️⃣ Navigate to the newly created event page + navigate(`/events/${joinCode}`); + } catch (err: any) { + setError(err.message || "Failed to create event. Please try again."); + setLoading(false); + } + }; + + const handleDiscard = () => { + setName(""); + setDescription(""); + setStartDate(""); + setEndDate(""); + setMaxParticipant(undefined); + setNameBingoSelected(false); + setError(null); + navigate("/"); + }; + + // Form validation + const isFormValid = + name.trim() !== "" && + description.trim() !== "" && + startDate.trim() !== "" && + maxParticipant !== undefined && + maxParticipant > 0; + + return ( +
+ + +
+

+ Create Event +

+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Form Grid */} +
+ {/* Event Name */} +
+ + setName(e.target.value)} + disabled={loading} + /> +
+ + {/* Description */} +
+ +