From 2cb1147fed2d97bbf546e9a06adb2cc89a99fd5c Mon Sep 17 00:00:00 2001 From: Harshvardhan Rawat Date: Mon, 1 Jun 2026 22:47:06 +0530 Subject: [PATCH] fix: Issue #898 - allow anonymous public profile viewing and redesign layout --- client/src/App.tsx | 1 + .../student/profile/PublicProfilePage.tsx | 597 +++++++++++------- server/src/module/auth/auth.controller.ts | 19 +- server/src/module/auth/auth.routes.ts | 4 +- server/src/module/auth/auth.service.ts | 24 +- 5 files changed, 405 insertions(+), 240 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 80089124..418b1305 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -331,6 +331,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/client/src/module/student/profile/PublicProfilePage.tsx b/client/src/module/student/profile/PublicProfilePage.tsx index f3787bfe..0bd321b5 100644 --- a/client/src/module/student/profile/PublicProfilePage.tsx +++ b/client/src/module/student/profile/PublicProfilePage.tsx @@ -1,10 +1,11 @@ +import React from "react"; import { useParams, useNavigate } from "react-router"; import { useQuery } from "@tanstack/react-query"; import { motion } from "framer-motion"; import { ArrowLeft, MapPin, GraduationCap, Linkedin, Github, Globe, ExternalLink, FileText, ShieldCheck, Trophy, FolderGit2, Briefcase, Calendar, - Phone, Mail, Clock, User, + Phone, Mail, Clock, User, Lock, } from "lucide-react"; import api from "../../../lib/axios"; import { LoadingScreen } from "../../../components/LoadingScreen"; @@ -14,6 +15,9 @@ import { BadgesSection } from "../badges/BadgesSection"; import ContributionGraphs from "../../../components/ContributionGraphs"; import GitHubStatsCard from "./GitHubStatsCard"; import type { ProjectItem, AchievementItem, VerifiedSkill } from "../../../lib/types"; +import { useAuthStore } from "../../../lib/auth.store"; +import { Navbar } from "../../../components/Navbar"; +import { Footer } from "../../../components/Footer"; interface PublicProfile { id: number; @@ -52,9 +56,9 @@ const fadeInUp = { function getJobStatusInfo(status: string | null | undefined) { const map: Record = { - LOOKING: { label: "Looking for job", cls: "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400" }, - OPEN_TO_OFFER: { label: "Open to offer", cls: "bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400" }, - NO_OFFER: { label: "No offer", cls: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400" }, + LOOKING: { label: "Looking for job", cls: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border border-emerald-500/20" }, + OPEN_TO_OFFER: { label: "Open to offer", cls: "bg-blue-500/10 text-blue-700 dark:text-blue-400 border border-blue-500/20" }, + NO_OFFER: { label: "No offer", cls: "bg-stone-500/10 text-stone-500 dark:text-stone-400 border border-stone-500/20" }, }; return status ? map[status] ?? null : null; } @@ -75,23 +79,167 @@ function formatDate(dateStr: string) { return new Date(dateStr).toLocaleDateString("en-US", { month: "short", year: "numeric" }); } +// Memoized Stat Card Component +const StatCard = React.memo(function StatCard({ value, label, colorClass }: { value: string | number; label: string; colorClass?: string }) { + return ( +
+

{value}

+

{label}

+
+ ); +}); + +// Memoized Project Card Component +export const ProjectCard = React.memo(function ProjectCard({ p }: { p: ProjectItem }) { + return ( +
+
+

{p.title}

+ {p.builtAt && ( + + {p.builtAt} + + )} +
+

{p.description}

+ {p.techStack.length > 0 && ( +
+ {p.techStack.map((t, i) => ( + {t} + ))} +
+ )} + {(p.liveUrl || p.repoUrl) && ( +
+ {p.liveUrl && ( + + Live + + )} + {p.repoUrl && ( + + Code + + )} +
+ )} +
+ ); +}); + +// Memoized Achievement Card Component +export const AchievementCard = React.memo(function AchievementCard({ a }: { a: AchievementItem }) { + return ( +
+
+ +
+
+
+

{a.title}

+ {a.date && ( + + {a.date} + + )} +
+

{a.description}

+
+
+ ); +}); + export default function PublicProfilePage() { const { id } = useParams(); const navigate = useNavigate(); + const { user } = useAuthStore(); const { data: profile, isLoading, error } = useQuery({ queryKey: ["public-profile", id], queryFn: () => api.get(`/auth/profile/${id}`).then((res) => res.data.profile as PublicProfile), enabled: !!id, + retry: false, }); + const isPrivateError = error && (error as any).response?.status === 403; + if (isLoading) return ; + + if (isPrivateError) { + return ( +
+ +
+ + + {/* Subtle background glow */} +
+
+ +
+ {/* Lock Icon Container */} +
+
+ +
+
+ +

+ This Profile is Private +

+ +

+ The student has configured their profile to be visible only to authorized recruiters and administrators. + {user ? ( + " Since you are logged in as a student, you do not have permission to view other students' private profiles." + ) : ( + " If you are a recruiter or administrator, please sign in to view this profile." + )} +

+ +
+ {!user && ( + + )} + +
+
+ +
+
+
+ ); + } + if (error || !profile) { return ( -
-

Profile not found

-

This student profile doesn't exist or you don't have permission to view it.

- +
+ +
+
+

Profile not found

+

This student profile doesn't exist or you don't have permission to view it.

+ +
+
+
); } @@ -100,256 +248,257 @@ export default function PublicProfilePage() { const jobStatusInfo = getJobStatusInfo(profile.jobStatus); return ( -
+
5 ? " and more" : ""}. ${profile.bio ? profile.bio.slice(0, 100) : "View their projects, achievements, and verified skills on InternHack."}`} - ogImage={profile.profilePic || undefined} - ogType="profile" - canonicalUrl={`https://internhack.xyz/student/profile/${profile.id}`} -/> - - {/* Back button */} - navigate(-1)} - className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-6 transition-colors" - > - Back - - - {/* ── Hero Card with Cover Image ── */} - - {/* Cover / Banner */} -
- {profile.coverImage ? ( - - ) : ( -
-
-
- )} -
+ title={`${profile.name} — InternHack Profile`} + description={`${profile.name}'s skills: ${profile.skills.slice(0, 5).join(", ")}${profile.skills.length > 5 ? " and more" : ""}. ${profile.bio ? profile.bio.slice(0, 100) : "View their projects, achievements, and verified skills on InternHack."}`} + ogImage={profile.profilePic || undefined} + ogType="profile" + canonicalUrl={`https://internhack.xyz/student/profile/${profile.id}`} + /> + - {/* Profile Info */} -
-
-
- {profile.profilePic ? ( - {profile.name} { e.currentTarget.style.display = "none"; }} /> - ) : ( - - )} -
-
-
-

{profile.name}

- {jobStatusInfo && ( - {jobStatusInfo.label} +
+ {/* Back button */} + navigate(-1)} + className="flex items-center gap-2 text-sm text-stone-500 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white mb-6 transition-colors font-medium border border-stone-200/80 dark:border-stone-800/80 bg-white/70 dark:bg-stone-900/50 backdrop-blur-xs px-3.5 py-1.5 rounded-xl shadow-2xs hover:scale-[1.01]" + > + Back + + + {/* ── Hero Card with Cover Image ── */} + + {/* Cover / Banner */} +
+ {profile.coverImage ? ( + + ) : ( +
+
+
+ )} +
+ + {/* Profile Info Layout (Responsive Flex/Grid) */} +
+
+
+ {profile.profilePic ? ( + {profile.name} { e.currentTarget.style.display = "none"; }} /> + ) : ( + + )} +
+
+
+

{profile.name}

+ {jobStatusInfo && ( + + {jobStatusInfo.label} + + )} +
+ {(profile.designation || profile.company) && ( +

+ {profile.designation}{profile.designation && profile.company ? " at " : ""}{profile.company} +

)}
- {(profile.designation || profile.company) && ( -

- {profile.designation}{profile.designation && profile.company ? " at " : ""}{profile.company} -

+
+ + {profile.bio &&

{profile.bio}

} + + {/* Contact & Info Grid */} +
+ {profile.email} + {profile.contactNo && {profile.contactNo}} + {profile.college && ( + + {profile.college} + {profile.graduationYear && ({profile.graduationYear})} + )} + {profile.location && {profile.location}} + {profile.createdAt && Joined {formatDate(profile.createdAt)}}
-
- {profile.bio &&

{profile.bio}

} - - {/* Contact & Info Row */} -
- {profile.email} - {profile.contactNo && {profile.contactNo}} - {profile.college && ( - - {profile.college} - {profile.graduationYear && ({profile.graduationYear})} - + {/* Social Links */} + {(profile.linkedinUrl || profile.githubUrl || profile.portfolioUrl) && ( +
+ {profile.linkedinUrl && ( + + LinkedIn + + )} + {profile.githubUrl && ( + + GitHub + + )} + {profile.portfolioUrl && ( + + Portfolio + + )} +
)} - {profile.location && {profile.location}} - {profile.createdAt && Joined {formatDate(profile.createdAt)}}
+ - {/* Social Links */} - {(profile.linkedinUrl || profile.githubUrl || profile.portfolioUrl) && ( -
- {profile.linkedinUrl && ( - - LinkedIn - - )} - {profile.githubUrl && ( - - GitHub - - )} - {profile.portfolioUrl && ( - - Portfolio - - )} -
+ {/* ── Stats Cards Row ── */} + + {profile.bestAtsScore !== null && ( + = 80 ? "text-emerald-600 dark:text-emerald-400" : profile.bestAtsScore >= 60 ? "text-amber-600 dark:text-amber-400" : "text-red-500 dark:text-red-400"} + /> )} -
-
- - {/* ── Stats Row ── */} - - {profile.bestAtsScore !== null && ( -
-

= 80 ? "text-emerald-600" : profile.bestAtsScore >= 60 ? "text-amber-600" : "text-red-500"}`}>{profile.bestAtsScore}

-

Best ATS Score

-
- )} -
-

{profile.verifiedSkills.length}

-

Verified Skills

-
-
-

{profile.projects.length}

-

Projects

-
-
-

{profile.resumes.length}

-

Resumes

-
-
- - {/* ── Content Grid ── */} -
- {/* Left Column */} -
- {/* Skills */} - {profile.skills.length > 0 && ( + + + + + + {/* ── Content Grid (Responsive Layout) ── */} +
+ {/* Left Column */} +
+ {/* Skills */} -

+ className="bg-white/70 dark:bg-stone-900/75 backdrop-blur-md border border-stone-200/80 dark:border-stone-800/80 p-5 rounded-2xl shadow-xs"> +

Skills

-
- {profile.skills.map((skill) => { - const v = verifiedMap.get(skill.toLowerCase()); - return ( - - {v && } - {skill} - {v && {v.score}%} - - ); - })} -
+ {profile.skills.length > 0 ? ( +
+ {profile.skills.map((skill) => { + const v = verifiedMap.get(skill.toLowerCase()); + return ( + + {v && } + {skill} + {v && ({v.score}%)} + + ); + })} +
+ ) : ( +
+ +

No skills listed yet

+
+ )}
- )} - {/* Resumes */} - {profile.resumes.length > 0 && ( + {/* Resumes */} -

+ className="bg-white/70 dark:bg-stone-900/75 backdrop-blur-md border border-stone-200/80 dark:border-stone-800/80 p-5 rounded-2xl shadow-xs"> +

Resumes

-
- {profile.resumes.map((url) => ( - -
- -
- {getFileNameFromUrl(url)} - -
- ))} -
+ {profile.resumes.length > 0 ? ( +
+ {profile.resumes.map((url) => ( + +
+ +
+ {getFileNameFromUrl(url)} + +
+ ))} +
+ ) : ( +
+ +

No resumes uploaded yet

+
+ )}
- )} - {/* Badges */} - - - - - - - -
+ {/* Badges */} + + + - {/* Right Column */} -
- {/* Coding Activity */} - {profile.githubUrl && ( - - + + - )} +
+ + {/* Right Column */} +
+ {/* Coding Activity */} + {profile.githubUrl ? ( + + + + ) : ( + +

+ Coding Activity +

+
+ +

Link a GitHub profile to display repositories and contributions

+
+
+ )} - {/* Projects */} - {profile.projects.length > 0 && ( + {/* Projects */} -

- {/* GSSoC '26: Updated title to Featured Projects */} + className="bg-white/70 dark:bg-stone-900/75 backdrop-blur-md border border-stone-200/80 dark:border-stone-800/80 p-6 rounded-2xl shadow-xs"> +

Featured Projects

-
- {profile.projects.map((p) => ( -
-
-

{p.title}

- {p.builtAt && {p.builtAt}} -
-

{p.description}

- {p.techStack.length > 0 && ( -
- {p.techStack.map((t, i) => ( - {t} - ))} -
- )} - {(p.liveUrl || p.repoUrl) && ( -
- {p.liveUrl && Live} - {p.repoUrl && Code} -
- )} -
- ))} -
+ {profile.projects.length > 0 ? ( +
+ {profile.projects.map((p) => ( + + ))} +
+ ) : ( +
+ +

No featured projects uploaded yet

+
+ )}
- )} - {/* Achievements */} - {profile.achievements.length > 0 && ( + {/* Achievements */} -

+ className="bg-white/70 dark:bg-stone-900/75 backdrop-blur-md border border-stone-200/80 dark:border-stone-800/80 p-6 rounded-2xl shadow-xs"> +

Achievements & Leadership

-
- {profile.achievements.map((a) => ( -
-
- -
-
-

{a.title}

-

{a.description}

- {a.date &&

{a.date}

} -
-
- ))} -
+ {profile.achievements.length > 0 ? ( +
+ {profile.achievements.map((a) => ( + + ))} +
+ ) : ( +
+ +

No achievements or leadership roles listed yet

+
+ )}
- )} +
-
+
+ +
); } diff --git a/server/src/module/auth/auth.controller.ts b/server/src/module/auth/auth.controller.ts index 850d001a..33cd82d8 100644 --- a/server/src/module/auth/auth.controller.ts +++ b/server/src/module/auth/auth.controller.ts @@ -137,23 +137,22 @@ export class AuthController { async getPublicProfile(req: Request, res: Response) { try { - if (!req.user) { - return res.status(401).json({ message: "Authentication required" }); - } - if (req.user.role !== "RECRUITER" && req.user.role !== "ADMIN") { - return res.status(403).json({ message: "Not authorized" }); - } - const id = Number(req.params["id"]); if (!id || isNaN(id)) { return res.status(400).json({ message: "Invalid user ID" }); } - const profile = await this.authService.getPublicProfile(id); + const viewer = req.user ? { id: req.user.id, role: req.user.role } : undefined; + const profile = await this.authService.getPublicProfile(id, viewer); return res.status(200).json({ profile }); } catch (error) { - if (error instanceof Error && error.message === "User not found") { - return res.status(404).json({ message: error.message }); + if (error instanceof Error) { + if (error.message === "User not found") { + return res.status(404).json({ message: error.message }); + } + if (error.message === "Profile is private") { + return res.status(403).json({ message: error.message, isPrivate: true }); + } } console.error(error); return res.status(500).json({ message: "Internal Server Error" }); diff --git a/server/src/module/auth/auth.routes.ts b/server/src/module/auth/auth.routes.ts index 7f1c5d6d..645dd790 100644 --- a/server/src/module/auth/auth.routes.ts +++ b/server/src/module/auth/auth.routes.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import { AuthController } from "./auth.controller.js"; import { AuthService } from "./auth.service.js"; -import { authMiddleware } from "../../middleware/auth.middleware.js"; +import { authMiddleware, optionalAuthMiddleware } from "../../middleware/auth.middleware.js"; import { usageLimit } from "../../middleware/usage-limit.middleware.js"; import rateLimit from "express-rate-limit"; import { createRateLimitStore } from "../../utils/rate-limit-store.js"; @@ -60,4 +60,4 @@ authRouter.get("/me", authMiddleware, (req, res) => authController.getProfile(re authRouter.put("/me", authMiddleware, validateBody(updateProfileSchema), (req, res) => authController.updateProfile(req, res)); authRouter.post("/import-github", authMiddleware, validateBody(importGitHubSchema), (req, res) => authController.importGitHub(req, res)); authRouter.get("/github-stats", authMiddleware, usageLimit("GITHUB_STATS"), (req, res) => authController.getGitHubStats(req, res)); -authRouter.get("/profile/:id", authMiddleware, (req, res) => authController.getPublicProfile(req, res)); +authRouter.get("/profile/:id", optionalAuthMiddleware, (req, res) => authController.getPublicProfile(req, res)); diff --git a/server/src/module/auth/auth.service.ts b/server/src/module/auth/auth.service.ts index c15108f4..fb88489a 100644 --- a/server/src/module/auth/auth.service.ts +++ b/server/src/module/auth/auth.service.ts @@ -1,4 +1,4 @@ -import crypto from "crypto"; +import crypto from "crypto"; import { OAuth2Client } from "google-auth-library"; import { prisma } from "../../database/db.js"; import { hashPassword, comparePassword } from "../../utils/password.utils.js"; @@ -516,15 +516,16 @@ export class AuthService { return user; } - async getPublicProfile(userId: number) { + async getPublicProfile(userId: number, viewer?: { id: number; role: string }) { const cacheKey = `profile:public:${userId}`; const cached = await cacheGet(cacheKey); if (cached) return cached as never; const user = await prisma.user.findUnique({ - where: { id: userId, role: "STUDENT", isProfilePublic: true }, + where: { id: userId, role: "STUDENT" }, select: { ...this.profileSelect, + isProfilePublic: true, verifiedSkills: { select: { skillName: true, score: true, verifiedAt: true }, }, @@ -540,6 +541,17 @@ export class AuthService { throw new Error("User not found"); } + if (!user.isProfilePublic) { + const isAuthorized = viewer && ( + viewer.role === "ADMIN" || + viewer.role === "RECRUITER" || + viewer.id === userId + ); + if (!isAuthorized) { + throw new Error("Profile is private"); + } + } + const { atsScores, ...rest } = user; if (rest.resumes.length > 0) { (rest as Record).resumes = await signUrls(rest.resumes); @@ -554,7 +566,11 @@ export class AuthService { ...rest, bestAtsScore: atsScores[0]?.overallScore ?? null, }; - await cacheSet(cacheKey, result, PROFILE_TTL); + + if (user.isProfilePublic) { + await cacheSet(cacheKey, result, PROFILE_TTL); + } + return result; }