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
132 changes: 132 additions & 0 deletions server/src/app/api/invitations/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { InvitationRepository, UserRepository } from "@/repositories/repositories";
import getSession from "@/server_actions/getSession";
import { Role } from "@prisma/client";

const invitationRepository = new InvitationRepository();
const userRepository = new UserRepository();

export const dynamic = 'force-dynamic';

export async function POST(req: NextRequest) {
try {
// 1. Authenticate the request
const session = await getSession();
if (!session || !session.isAuthenticated()) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}

// 2. Check for permissions (Only super_admin can invite)
const userRole = session.getRole();
if (userRole !== Role.super_admin) {
return NextResponse.json({ message: "Forbidden: Only Super Admins can create invitations" }, { status: 403 });
}

const invitedBy = session.getId();
if (!invitedBy) {
return NextResponse.json({ message: "Unauthorized: Invalid session" }, { status: 401 });
}

// 3. Parse and validate the request body
const body = await req.json();
const { email, role } = body;

if (!email || !role) {
return NextResponse.json({ message: "Email and role are required" }, { status: 400 });
}

// Validate role
if (!Object.values(Role).includes(role)) {
return NextResponse.json({ message: "Invalid role" }, { status: 400 });
}

// 4. Check if user already exists
const existingUser = await userRepository.getByEmail(email);
if (existingUser) {
return NextResponse.json({ message: "User with this email already exists" }, { status: 409 });
}

// 5. Generate secure token and expiration
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7); // Expire in 7 days

// 6. Create the invitation via repository
// Note: The repository method has some unused variables but uses our passed data correctly.
// We use createInvite because it handles the specific input type expected for Invitation creation
// better than BaseRepository.create which might expect all model fields.
const invitation = await invitationRepository.createInvite({
email,
role: role as Role,
token,
invitedBy,
expiresAt,
accepted: false,
});

// 7. Return the invitation details (In a real app, send email here)
return NextResponse.json({
message: "Invitation created successfully",
invitation: {
id: invitation.id,
email: invitation.email,
role: invitation.role,
token: invitation.token,
expiresAt: invitation.expiresAt,
link: `${process.env.NEXT_PUBLIC_BASE_URL}/auth/register?token=${invitation.token}` // Helper for frontend dev
}
}, { status: 201 });

} catch (error: any) {
console.error("Error creating invitation:", error);
// Handle specific Prisma errors (e.g., uniqueness constraints) if necessary
if (error.code === 'P2002') {
return NextResponse.json({ message: "An invitation for this email or token already exists" }, { status: 409 });
}
return NextResponse.json({ message: "Internal Server Error" }, { status: 500 });
}
}

export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const token = searchParams.get("token");

if (!token) {
return NextResponse.json({ message: "Token is required" }, { status: 400 });
}

// Use getAll from BaseRepository to find by token without modifying the repository file
// The token is unique, so we expect at most one result
const invitations = await invitationRepository.getAll({
where: { token },
});

if (invitations.length === 0) {
return NextResponse.json({ valid: false, message: "Invalid token" }, { status: 404 });
}

const invitation = invitations[0];

// Check if used
if (invitation.accepted) {
return NextResponse.json({ valid: false, message: "Token has already been used" }, { status: 400 });
}

// Check if expired
if (new Date() > new Date(invitation.expiresAt)) {
return NextResponse.json({ valid: false, message: "Token has expired" }, { status: 400 });
}

return NextResponse.json({
valid: true,
email: invitation.email,
role: invitation.role,
});

} catch (error) {
console.error("Error validating invitation:", error);
return NextResponse.json({ message: "Internal Server Error" }, { status: 500 });
}
}
114 changes: 114 additions & 0 deletions server/src/app/auth/register/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcrypt";
import * as jose from 'jose';
import { InvitationRepository, UserRepository } from "@/repositories/repositories";
import { Role } from "@prisma/client";

const invitationRepository = new InvitationRepository();
const userRepository = new UserRepository();

export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { token, firstName, lastName, password } = body;

if (!token || !firstName || !lastName || !password) {
return NextResponse.json({ message: "Token, first name, last name, and password are required" }, { status: 400 });
}

// 1. Validate the invitation token
const invitations = await invitationRepository.getAll({
where: { token },
});

if (invitations.length === 0) {
return NextResponse.json({ message: "Invalid token" }, { status: 400 });
}

const invitation = invitations[0];

// Check if token is used
if (invitation.accepted) {
return NextResponse.json({ message: "Invitation has already been used" }, { status: 400 });
}

// Check if token is expired
if (new Date() > new Date(invitation.expiresAt)) {
return NextResponse.json({ message: "Invitation has expired" }, { status: 400 });
}

// 2. Check if user already exists
const existingUser = await userRepository.getByEmail(invitation.email);
if (existingUser) {
return NextResponse.json({ message: "User with this email already exists" }, { status: 409 });
}

// 3. Hash the password
const hashedPassword = await bcrypt.hash(password, 10);

// 4. Create the user
const newUser:any = await userRepository.create({
email: invitation.email,
passwordHash: hashedPassword,
firstName,
lastName,
role: invitation.role,
emailConfirmed: true, // Email is confirmed via the invitation link
isFirstTimeLogin: true,
invitedBy: invitation.invitedBy,
isActive: true, // Default value
createdAt: new Date(),
updatedAt: new Date(),
});

// 5. Mark invitation as accepted
await invitationRepository.update(invitation.id, {
accepted: true,
});

// 6. Generate JWT token (Log the user in)
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const algo = 'HS256';

const jwtToken = await new jose.SignJWT({
id: newUser.id,
email: newUser.email,
role: newUser.role.toString(),
fname: newUser.firstName,
lname: newUser.lastName
})
.setProtectedHeader({ alg: algo })
.setIssuedAt()
.setIssuer(process.env.ISSUER!)
.setAudience(process.env.AUDIENCE!)
.setExpirationTime(process.env.JWT_EXPIRY!)
.sign(secret);

// 7. Return response
// Determine redirect URL based on role
let redirectUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/unauthorized`;
if (newUser.role === 'student') {
// Since they just registered and set password, maybe go straight to dashboard?
redirectUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/student`;
} else if (newUser.role === 'mentor') {
redirectUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/mentor`;
}

const response = NextResponse.json({
message: "Registration successful",
redirectUrl: redirectUrl,
user: {
id: newUser.id,
email: newUser.email,
role: newUser.role,
}
});

response.headers.set("Set-Cookie", `token=${jwtToken}; Path=/; HttpOnly`);
return response;

} catch (error) {
console.error("Error registering user with invite:", error);
return NextResponse.json({ message: "Internal Server Error" }, { status: 500 });
}
}
2 changes: 1 addition & 1 deletion server/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ export default chain([withAuthMiddleware, withRoleBasedRoutingMiddleware])

export const config = {
matcher: [
'/((?!login|auth/login|_next/static|_next/image|favicon.ico).*)',
'/((?!login|auth/login|auth/register|api/invitations|_next/static|_next/image|favicon.ico).*)',
],
}