From 7303812763ad347ea0216d584ab856ac0221ed55 Mon Sep 17 00:00:00 2001 From: Shaurya Date: Mon, 1 Sep 2025 04:22:12 +0530 Subject: [PATCH 1/3] Add Frontend + Complete Auth System --- apps/backend/.env.example | 5 + apps/backend/helpers/auth.ts | 91 + apps/backend/helpers/bcrypt.ts | 12 + apps/backend/index.ts | 56 +- apps/backend/package.json | 13 +- apps/backend/routes/admin.ts | 66 +- apps/backend/routes/user.ts | 225 +- apps/web/.gitignore | 11 +- apps/web/README.md | 36 - apps/web/app/admin/page.tsx | 207 + apps/web/app/admin/sections/admin-client.tsx | 174 + apps/web/app/api/auth/logout/route.ts | 9 + apps/web/app/api/auth/request-otp/route.ts | 12 + apps/web/app/api/auth/verify-otp/route.ts | 21 + apps/web/app/api/challenges/live/route.ts | 6 + apps/web/app/api/challenges/start/route.ts | 13 + .../app/api/contests/[id]/challenges/route.ts | 17 + apps/web/app/api/contests/route.ts | 20 + apps/web/app/api/leaderboard/route.ts | 6 + apps/web/app/api/me/route.ts | 7 + apps/web/app/challenges/[id]/page.tsx | 161 + apps/web/app/dashboard/page.tsx | 102 + .../dashboard/sections/dashboard-client.tsx | 55 + apps/web/app/fonts/GeistMonoVF.woff | Bin 67864 -> 0 bytes apps/web/app/fonts/GeistVF.woff | Bin 66268 -> 0 bytes apps/web/app/globals.css | 169 +- apps/web/app/layout.tsx | 51 +- apps/web/app/leaderboard/page.tsx | 116 + .../sections/leaderboard-client.tsx | 56 + apps/web/app/page.module.css | 188 - apps/web/app/page.tsx | 101 +- apps/web/app/signin/page.tsx | 140 + apps/web/app/signup/page.tsx | 196 + apps/web/components.json | 21 + apps/web/components/contest-card.tsx | 104 + apps/web/components/sign-out-button.tsx | 15 + apps/web/components/site-header.tsx | 110 + apps/web/components/ui/avatar.tsx | 53 + apps/web/components/ui/button.tsx | 59 + apps/web/components/ui/card.tsx | 92 + apps/web/components/ui/checkbox.tsx | 32 + apps/web/components/ui/input.tsx | 21 + apps/web/components/ui/label.tsx | 24 + apps/web/components/ui/select.tsx | 185 + apps/web/components/ui/switch.tsx | 31 + apps/web/components/ui/table.tsx | 116 + apps/web/components/ui/textarea.tsx | 18 + apps/web/config/index.ts | 3 + apps/web/context/AuthProvider.tsx | 132 + apps/web/eslint.config.js | 4 - apps/web/eslint.config.mjs | 16 + apps/web/lib/auth.ts | 35 + apps/web/lib/data.ts | 101 + apps/web/lib/utils.ts | 6 + apps/web/next.config.js | 4 - apps/web/next.config.ts | 7 + apps/web/package-lock.json | 7084 +++++++++++++++++ apps/web/package.json | 45 +- apps/web/postcss.config.mjs | 5 + apps/web/public/file-text.svg | 3 - apps/web/public/globe.svg | 10 - apps/web/public/next.svg | 1 - apps/web/public/turborepo-dark.svg | 19 - apps/web/public/turborepo-light.svg | 19 - apps/web/public/vercel.svg | 10 - apps/web/public/window.svg | 3 - apps/web/tsconfig.json | 31 +- 67 files changed, 10264 insertions(+), 497 deletions(-) create mode 100644 apps/backend/.env.example create mode 100644 apps/backend/helpers/auth.ts create mode 100644 apps/backend/helpers/bcrypt.ts delete mode 100644 apps/web/README.md create mode 100644 apps/web/app/admin/page.tsx create mode 100644 apps/web/app/admin/sections/admin-client.tsx create mode 100644 apps/web/app/api/auth/logout/route.ts create mode 100644 apps/web/app/api/auth/request-otp/route.ts create mode 100644 apps/web/app/api/auth/verify-otp/route.ts create mode 100644 apps/web/app/api/challenges/live/route.ts create mode 100644 apps/web/app/api/challenges/start/route.ts create mode 100644 apps/web/app/api/contests/[id]/challenges/route.ts create mode 100644 apps/web/app/api/contests/route.ts create mode 100644 apps/web/app/api/leaderboard/route.ts create mode 100644 apps/web/app/api/me/route.ts create mode 100644 apps/web/app/challenges/[id]/page.tsx create mode 100644 apps/web/app/dashboard/page.tsx create mode 100644 apps/web/app/dashboard/sections/dashboard-client.tsx delete mode 100644 apps/web/app/fonts/GeistMonoVF.woff delete mode 100644 apps/web/app/fonts/GeistVF.woff create mode 100644 apps/web/app/leaderboard/page.tsx create mode 100644 apps/web/app/leaderboard/sections/leaderboard-client.tsx delete mode 100644 apps/web/app/page.module.css create mode 100644 apps/web/app/signin/page.tsx create mode 100644 apps/web/app/signup/page.tsx create mode 100644 apps/web/components.json create mode 100644 apps/web/components/contest-card.tsx create mode 100644 apps/web/components/sign-out-button.tsx create mode 100644 apps/web/components/site-header.tsx create mode 100644 apps/web/components/ui/avatar.tsx create mode 100644 apps/web/components/ui/button.tsx create mode 100644 apps/web/components/ui/card.tsx create mode 100644 apps/web/components/ui/checkbox.tsx create mode 100644 apps/web/components/ui/input.tsx create mode 100644 apps/web/components/ui/label.tsx create mode 100644 apps/web/components/ui/select.tsx create mode 100644 apps/web/components/ui/switch.tsx create mode 100644 apps/web/components/ui/table.tsx create mode 100644 apps/web/components/ui/textarea.tsx create mode 100644 apps/web/config/index.ts create mode 100644 apps/web/context/AuthProvider.tsx delete mode 100644 apps/web/eslint.config.js create mode 100644 apps/web/eslint.config.mjs create mode 100644 apps/web/lib/auth.ts create mode 100644 apps/web/lib/data.ts create mode 100644 apps/web/lib/utils.ts delete mode 100644 apps/web/next.config.js create mode 100644 apps/web/next.config.ts create mode 100644 apps/web/package-lock.json create mode 100644 apps/web/postcss.config.mjs delete mode 100644 apps/web/public/file-text.svg delete mode 100644 apps/web/public/globe.svg delete mode 100644 apps/web/public/next.svg delete mode 100644 apps/web/public/turborepo-dark.svg delete mode 100644 apps/web/public/turborepo-light.svg delete mode 100644 apps/web/public/vercel.svg delete mode 100644 apps/web/public/window.svg diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..cb78ca3 --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,5 @@ +PORT= +JWT_SECRET= +JWT_REFRESH_SECRET= +EMAIL_USER= +EMAIL_PASS= \ No newline at end of file diff --git a/apps/backend/helpers/auth.ts b/apps/backend/helpers/auth.ts new file mode 100644 index 0000000..12e070e --- /dev/null +++ b/apps/backend/helpers/auth.ts @@ -0,0 +1,91 @@ +import jwt from "jsonwebtoken"; +import nodemailer from "nodemailer"; +import crypto from "crypto"; +import { client } from "db/client"; + +const generateOtp = () => { + return crypto.randomInt(100000, 999999); +}; + +const sendOtp = async (email: string, otp: string) => { + const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, + }); + + const mailOptions = { + from: `"100xContest" <${process.env.EMAIL_USER}>`, + to: email, + subject: "Email verification code", + + html: ` +
+

Hi there,

+

+ Please use the code below to confirm your email address and continue on 100xContest. + This code will expire in 5 minutes. If you don't think you should be receiving this email, you can safely ignore it. +

+ +
+ ${otp} +
+ +
+ +

+ You received this email because you requested a confirmation code from 100xContest. +

+
+ `, + }; + + await transporter.sendMail(mailOptions); +}; + +const resendOtp = async (email: string) => { + const otp = generateOtp().toString(); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + + await client.otpVerification.upsert({ + where: { email }, + update: { otp, expiresAt, attempts: 0 }, + create: { email, otp, expiresAt }, + }); + + await sendOtp(email, otp); +}; + +const generateAccessToken = (user: any) => { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error("JWT_SECRET is not defined in environment variables"); + } + return jwt.sign({ userId: user.id, email: user.email }, secret, { + expiresIn: "2h", + }); +}; + +const generateRefreshToken = (user: any) => { + const refreshSecret = process.env.JWT_REFRESH_SECRET; + if (!refreshSecret) { + throw new Error( + "JWT_REFRESH_SECRET is not defined in environment variables" + ); + } + return jwt.sign({ userId: user.id, email: user.email }, refreshSecret, { + expiresIn: "30d", + }); +}; + +const refreshAccessToken = (refreshToken: string) => { + const decoded = jwt.verify( + refreshToken, + process.env.JWT_REFRESH_SECRET as string + ) as any; + return generateAccessToken({ id: decoded.userId, email: decoded.email }); +}; + +export { generateOtp, generateAccessToken, generateRefreshToken, sendOtp, resendOtp, refreshAccessToken }; diff --git a/apps/backend/helpers/bcrypt.ts b/apps/backend/helpers/bcrypt.ts new file mode 100644 index 0000000..b319cca --- /dev/null +++ b/apps/backend/helpers/bcrypt.ts @@ -0,0 +1,12 @@ +import bcrypt from "bcrypt"; + +const hash = (password: string) => { + const hashed = bcrypt.hash(password, 10); + + return hashed; +}; +const compare = (password: string, hash: string) => { + return bcrypt.compare(password, hash); +}; + +export { hash, compare }; diff --git a/apps/backend/index.ts b/apps/backend/index.ts index 31505fd..79c1d76 100644 --- a/apps/backend/index.ts +++ b/apps/backend/index.ts @@ -1,12 +1,52 @@ - import express from "express"; -import userRouter from "./routes/user" -import contestRouter from "./routes/contest" -import adminRouter from "./routes/admin" +import cors from "cors"; +import cookieParser from "cookie-parser"; +import dotenv from "dotenv"; + +// routes import +import contestRouter from "./routes/contest"; +import userRouter from "./routes/user"; +import adminRouter from "./routes/admin"; + const app = express(); +dotenv.config(); + +const allowedOrigins = ["http://localhost:3000"]; +const corsOptions = { + origin: function (origin: any, callback: any) { + if (!origin) { + return callback(null, true); + } + + if (allowedOrigins.includes(origin)) { + callback(null, true); + } else { + console.error(`Blocked by CORS: ${origin}`); + callback(new Error(`Origin ${origin} not allowed by CORS`)); + } + }, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], + credentials: true, + preflightContinue: false, + optionsSuccessStatus: 204, +}; + +// Middleware +app.use(cors(corsOptions)); +app.use(cookieParser()); +app.use(express.json()); + +// Routes +app.get("/health", (req, res) => { + res.json({ message: "Health Check!" }); +}); +app.use("/api/v1/user", userRouter); +app.use("/api/v1/admin", adminRouter); +app.use("/api/v1/contest", contestRouter); -app.use("/user", userRouter); -app.use("/admin", adminRouter); -app.use("/contest", contestRouter); +const port = process.env.PORT || 4000; -app.listen(process.env.PORT ?? 4000); \ No newline at end of file +app.listen(port, () => { + console.log(`Server is running at http://localhost:${port}`); +}); diff --git a/apps/backend/package.json b/apps/backend/package.json index cd7ff4e..d2356a7 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -9,8 +9,19 @@ "typescript": "^5.0.0" }, "dependencies": { + "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.9", + "@types/cors": "^2.8.17", "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.10", + "@types/nodemailer": "^7.0.1", + "bcrypt": "^6.0.0", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "db": "*", + "dotenv": "^17.2.1", "express": "^5.1.0", - "db": "*" + "jsonwebtoken": "^9.0.2", + "nodemailer": "^7.0.6" } } \ No newline at end of file diff --git a/apps/backend/routes/admin.ts b/apps/backend/routes/admin.ts index ce6afbb..55f75ee 100644 --- a/apps/backend/routes/admin.ts +++ b/apps/backend/routes/admin.ts @@ -1,15 +1,71 @@ import { Router } from "express"; +import { client } from "db/client"; +import { generateAccessToken, generateRefreshToken } from "../helpers/auth"; const router = Router(); -router.post("/signup", (req, res) => { +// Note for bhayia - no signup needed for admin, seed an email- pass for admin, just login +// router.post("/signup", (req, res) => { +// }) -}) +router.post("/signin", async (req, res) => { + try { + const { email, password } = req.body; -router.post("/signin", (req, res) => { + if (!email || !password) { + return res.status(400).json({ + success: false, + message: "Email and password are required", + }); + } + const admin = await client.user.findFirst({ + where: { + email, + role: "Admin", + }, + }); -}) + if (!admin) { + return res.status(401).json({ + success: false, + message: "Invalid credentials", + }); + } -export default router; \ No newline at end of file + if (admin.password !== password) { + return res.status(401).json({ + success: false, + message: "Invalid credentials", + }); + } + + const accessToken = generateAccessToken(admin); + const refreshToken = generateRefreshToken(admin); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + + res.json({ + accessToken, + user: { + id: admin.id, + email: admin.email, + role: admin.role, + }, + }); + } catch (error) { + console.error("Admin signin error:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + }); + } +}); + +export default router; diff --git a/apps/backend/routes/user.ts b/apps/backend/routes/user.ts index e204b2b..87006b6 100644 --- a/apps/backend/routes/user.ts +++ b/apps/backend/routes/user.ts @@ -1,15 +1,232 @@ import { Router } from "express"; import { client } from "db/client"; +import { + generateAccessToken, + generateOtp, + generateRefreshToken, + refreshAccessToken, + sendOtp, + resendOtp, +} from "../helpers/auth"; +import { hash, compare } from "../helpers/bcrypt"; const router = Router(); -router.post("/signup", (req, res) => { +router.post("/signup", async (req, res) => { + try { + const { email, password } = req.body; -}) + if (!email && !password) { + return res + .status(400) + .json({ message: "Email and password are required!" }); + } + + const exists = await client.user.findFirst({ + where: { email: email.toLowerCase() }, + }); + + if (exists) { + return res.status(409).json({ message: "User already exists!" }); + } + + const otp = generateOtp().toString(); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + + await client.otpVerification.upsert({ + where: { email }, + update: { otp, expiresAt, attempts: 0 }, + create: { email, otp, expiresAt }, + }); + + await sendOtp(email, otp); + + res.status(200).json({ + message: "OTP sent successfully", + email, + nextStep: "/verify-otp", + }); + } catch (error) { + console.log(error); + } +}); + +router.post("/verify-otp", async (req, res) => { + try { + const { email, otp, password } = req.body; + + if (!email && !otp && !password) { + return res.status(400).json({ message: "Credentials are required!" }); + } + + const otpRecord = await client.otpVerification.findUnique({ + where: { email }, + }); + + if (!otpRecord) { + return res.status(404).json({ message: "OTP not found or expired" }); + } + + if (otpRecord.expiresAt < new Date()) { + await client.otpVerification.delete({ where: { email } }); + return res.status(410).json({ message: "OTP expired" }); + } + + if (otpRecord.attempts >= 5) { + await client.otpVerification.delete({ where: { email } }); + return res.status(429).json({ message: "Too many failed attempts" }); + } + + if (otpRecord.otp !== otp) { + await client.otpVerification.update({ + where: { email }, + data: { attempts: { increment: 1 } }, + }); + + const remainingAttempts = 5 - (otpRecord.attempts + 1); + return res.status(401).json({ + message: `Invalid OTP. ${remainingAttempts} attempts remaining.`, + }); + } + + const hashedPassword = await hash(password); + + const userData: any = { + email: email.toLowerCase(), + password: hashedPassword, + role: "User", // change -- for admin signups bhayia + }; + + const user = await client.user.create({ + data: userData, + }); + + client.otpVerification.delete({ where: { email } }); + + const accessToken = generateAccessToken(user); + const refreshToken = generateRefreshToken(user); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + + res.status(201).json({ + message: "User created successfully", + accessToken, + user: { + id: user.id, + email: user.email, + role: user.role, + }, + }); + } catch (error) { + console.log(error); + } +}); + +router.post("/resend-otp", async (req, res) => { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ message: "Email is required" }); + } + + const existingUser = await client.user.findFirst({ + where: { email: email.toLowerCase() }, + }); + + if (existingUser) { + return res.status(409).json({ message: "User already exists" }); + } + + await resendOtp(email); + + res.status(200).json({ + message: "New OTP sent successfully", + email, + }); + } catch (error) { + console.log(error); + } +}); + +router.post("/signin", async (req, res) => { + try { + const { email, password } = req.body; + if (!email || !password) { + return res + .status(400) + .json({ message: "Email and password are required!" }); + } + + const user = await client.user.findFirst({ + where: { email: email.toLowerCase(), role: 'User' }, + }); + + if (!user || !user.password) { + return res.status(401).json({ message: "Invalid credentials" }); + } + + const isValidPassword = await compare(password, user.password); + if (!isValidPassword) { + return res.status(401).json({ message: "Invalid credentials" }); + } + const accessToken = generateAccessToken(user); + const refreshToken = generateRefreshToken(user); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + + res.json({ + accessToken, + user: { + id: user.id, + email: user.email, + role: user.role, + }, + }); + } catch (error) { + console.log(error); + } +}); -router.post("/signin", (req, res) => { +router.post("/signout", async (req, res) => { + const cookieOptions = [ + { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict" as const, + path: "/", + }, + ]; + cookieOptions.forEach((options, index) => { + res.clearCookie("refreshToken", options); + }); + + res.status(200).json({ message: "Logged out successfully" }); +}); +// note for bhayia - common for admin and users both +router.post("/refresh", async (req, res) => { + const refreshToken = req.cookies.refreshToken; + if (!refreshToken) { + return res.status(401).json({ message: "Refresh token missing" }); + } + try { + const newAccessToken = refreshAccessToken(refreshToken); + res.status(200).json({ accessToken: newAccessToken }); + } catch (error) { + return res.status(403).json({ message: "Invalid refresh token" }); + } }) -export default router; \ No newline at end of file +export default router; diff --git a/apps/web/.gitignore b/apps/web/.gitignore index f886745..5ef6a52 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -3,8 +3,12 @@ # dependencies /node_modules /.pnp -.pnp.js -.yarn/install-state.gz +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions # testing /coverage @@ -24,8 +28,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +.pnpm-debug.log* -# env files (can opt-in for commiting if needed) +# env files (can opt-in for committing if needed) .env* # vercel diff --git a/apps/web/README.md b/apps/web/README.md deleted file mode 100644 index a98bfa8..0000000 --- a/apps/web/README.md +++ /dev/null @@ -1,36 +0,0 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx new file mode 100644 index 0000000..04c51d9 --- /dev/null +++ b/apps/web/app/admin/page.tsx @@ -0,0 +1,207 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Switch } from "@/components/ui/switch" +// import { useToast } from "@/hooks/use-toast" + +type SubChallenge = { title: string; points: number; live: boolean } + +export default function AdminPage() { + // const { toast } = useToast() + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [live, setLive] = useState(false) + const [subs, setSubs] = useState([{ title: "Warm-up task", points: 100, live: true }]) + const [scheduled, setScheduled] = useState(false) + const [startAt, setStartAt] = useState("") + const [endAt, setEndAt] = useState("") + + const addSub = () => setSubs((s) => [...s, { title: "", points: 100, live: false }]) + const removeSub = (idx: number) => setSubs((s) => s.filter((_, i) => i !== idx)) + const updateSub = (idx: number, patch: Partial) => + setSubs((s) => s.map((item, i) => (i === idx ? { ...item, ...patch } : item))) + + const submit = (e: React.FormEvent) => { + e.preventDefault() + // toast({ + // title: "Contest created (mock)", + // description: `“${name || "Untitled"}” with ${subs.length} sub-challenge(s). ${ + // scheduled && startAt ? `Starts at ${new Date(startAt).toLocaleString()}.` : "" + // }`, + // }) + setName("") + setDescription("") + setLive(false) + setSubs([{ title: "Warm-up task", points: 100, live: true }]) + setScheduled(false) + setStartAt("") + setEndAt("") + } + + return ( +
+
+

Admin

+

+ Create a contest and add sub-challenges. This is a frontend-only mock. +

+
+ + + + New Contest + Define the contest metadata and add sub-challenges. + + +
+
+ + setName(e.target.value)} + placeholder="100x Sprint" + required + className="transition-shadow duration-150 focus-visible:shadow-sm" + /> +

Keep it short and recognizable.

+
+ +
+ +