Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci-frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ on:
- "shatter-web/**"
- ".github/workflows/ci-frontend.yml"
pull_request:
branches: [main]
paths:
- "shatter-web/**"
- ".github/workflows/ci-frontend.yml"
workflow_dispatch:

jobs:
build:
Expand Down
38 changes: 38 additions & 0 deletions shatter-backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions shatter-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"license": "ISC",
"description": "",
"dependencies": {
"axios": "^1.13.5",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
Expand Down
166 changes: 165 additions & 1 deletion shatter-backend/src/controllers/auth_controller.ts
Original file line number Diff line number Diff line change
@@ -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,}$/;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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' });
}
};

16 changes: 6 additions & 10 deletions shatter-backend/src/controllers/bingo_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
60 changes: 57 additions & 3 deletions shatter-backend/src/controllers/event_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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 });
}
}
Loading