feat: add Google OAuth2 Sign-In with verified student badge for DevCollab

This commit is contained in:
Shanmuga Krishnan S M
2026-08-06 12:23:50 +05:30
parent 2be0c04b80
commit 302a39e933
13 changed files with 802 additions and 29 deletions

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@/context/AuthContext';
import MainLayout from '@/layouts/MainLayout';
import Home from '@/pages/Home/Home';
import Notes from '@/pages/Notes/Notes';
@@ -36,21 +37,23 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ScrollToTop />
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<Home />} />
<Route path="/notes" element={<Notes />} />
<Route path="/ai-assistant" element={<AIAssistant />} />
<Route path="/campus" element={<Campus />} />
<Route path="/bus-routes" element={<BusRoutes />} />
<Route path="/faculty" element={<Faculty />} />
<Route path="/events" element={<Events />} />
<Route path="/community" element={<Community />} />
<Route path="/collab" element={<DevCollab />} />
<Route path="/leetcode" element={<LeetcodeLeaderboard />} />
</Route>
</Routes>
<AuthProvider>
<ScrollToTop />
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<Home />} />
<Route path="/notes" element={<Notes />} />
<Route path="/ai-assistant" element={<AIAssistant />} />
<Route path="/campus" element={<Campus />} />
<Route path="/bus-routes" element={<BusRoutes />} />
<Route path="/faculty" element={<Faculty />} />
<Route path="/events" element={<Events />} />
<Route path="/community" element={<Community />} />
<Route path="/collab" element={<DevCollab />} />
<Route path="/leetcode" element={<LeetcodeLeaderboard />} />
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);

View File

@@ -1,13 +1,17 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Menu, X } from 'lucide-react';
import { Menu, X, LogIn, LogOut, ShieldCheck, ChevronDown } from 'lucide-react';
import { NAV_LINKS } from '@/constants';
import { useAuth } from '@/context/AuthContext';
export default function Navbar() {
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const [isProfileOpen, setIsProfileOpen] = useState(false);
const location = useLocation();
const { user, isAuthenticated, isVerifiedStudent, loginWithGoogle, logout } = useAuth();
const profileRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleScroll = () => setIsScrolled(window.scrollY > 20);
@@ -19,6 +23,17 @@ export default function Navbar() {
setIsMobileOpen(false);
}, [location.pathname]);
// Close profile dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setIsProfileOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const checkIsActive = (linkPath: string) => {
const [pathName, searchString] = linkPath.split('?');
if (location.pathname !== pathName) return false;
@@ -104,8 +119,111 @@ export default function Navbar() {
})}
</div>
{/* Right Side */}
<div className="flex items-center gap-3">
{/* Right Side: Auth + Mobile Toggle */}
<div className="flex items-center gap-2">
{/* Auth Section */}
{isAuthenticated && user ? (
<div className="relative" ref={profileRef}>
<button
onClick={() => setIsProfileOpen(!isProfileOpen)}
className="flex items-center gap-2 px-2 py-1.5 rounded-xl hover:bg-white/10 transition-all cursor-pointer"
>
{user.pictureUrl ? (
<img
src={user.pictureUrl}
alt={user.name}
className="w-8 h-8 rounded-full border-2 border-white/30 object-cover"
referrerPolicy="no-referrer"
/>
) : (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center text-white font-bold text-sm">
{user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="hidden lg:flex flex-col items-start">
<span className="text-white text-[11px] font-semibold leading-tight max-w-[100px] truncate">
{user.name.split(' ')[0]}
</span>
{isVerifiedStudent && (
<span className="flex items-center gap-0.5 text-[9px] text-emerald-400 font-bold">
<ShieldCheck className="w-2.5 h-2.5" /> Verified
</span>
)}
</div>
<ChevronDown className="w-3 h-3 text-slate-400 hidden lg:block" />
</button>
{/* Profile Dropdown */}
<AnimatePresence>
{isProfileOpen && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="absolute right-0 top-full mt-2 w-64 bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden z-50"
>
<div className="p-4 border-b border-slate-700/60">
<div className="flex items-center gap-3">
{user.pictureUrl ? (
<img
src={user.pictureUrl}
alt={user.name}
className="w-10 h-10 rounded-full border-2 border-orange-500/40 object-cover"
referrerPolicy="no-referrer"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center text-white font-bold text-lg">
{user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-bold truncate">{user.name}</p>
<p className="text-slate-400 text-[11px] truncate">{user.email}</p>
</div>
</div>
{isVerifiedStudent ? (
<div className="mt-3 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-emerald-500/15 border border-emerald-500/25">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
<span className="text-[11px] font-bold text-emerald-400">Verified RIT Student</span>
</div>
) : (
<div className="mt-3 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-amber-500/15 border border-amber-500/25">
<span className="text-[11px] font-semibold text-amber-400">
Sign in with @ritchennai.edu.in for verified badge
</span>
</div>
)}
</div>
<div className="p-2">
<button
onClick={() => {
logout();
setIsProfileOpen(false);
}}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-slate-300 hover:text-white hover:bg-red-500/15 text-xs font-semibold transition-all cursor-pointer"
>
<LogOut className="w-4 h-4" />
Sign Out
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
) : (
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={loginWithGoogle}
className="hidden md:flex items-center gap-2 px-4 py-2 rounded-xl text-white text-[11px] font-semibold border border-white/15 hover:border-orange-500/40 hover:bg-white/5 transition-all cursor-pointer"
style={{ fontFamily: 'Poppins, sans-serif' }}
>
<LogIn className="w-3.5 h-3.5" />
Sign In
</motion.button>
)}
{/* Mobile Toggle */}
<motion.button
whileTap={{ scale: 0.9 }}
@@ -148,6 +266,33 @@ export default function Navbar() {
</Link>
);
})}
{/* Mobile Auth Button */}
{!isAuthenticated ? (
<button
onClick={() => {
loginWithGoogle();
setIsMobileOpen(false);
}}
className="col-span-2 mt-2 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-white text-xs font-semibold border border-orange-500/40 bg-orange-500/10 hover:bg-orange-500/20 transition-all cursor-pointer"
style={{ fontFamily: 'Poppins, sans-serif' }}
>
<LogIn className="w-4 h-4" />
Sign In with Google
</button>
) : (
<button
onClick={() => {
logout();
setIsMobileOpen(false);
}}
className="col-span-2 mt-2 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-red-400 text-xs font-semibold border border-red-500/30 bg-red-500/10 hover:bg-red-500/20 transition-all cursor-pointer"
style={{ fontFamily: 'Poppins, sans-serif' }}
>
<LogOut className="w-4 h-4" />
Sign Out ({user?.name.split(' ')[0]})
</button>
)}
</div>
</motion.div>
)}

226
src/context/AuthContext.tsx Normal file
View File

@@ -0,0 +1,226 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import { getBackendUrl } from '@/lib/utils';
// ──────────────────────────────────────────────────
// Types
// ──────────────────────────────────────────────────
export interface AuthUser {
id: number;
email: string;
name: string;
pictureUrl?: string;
verifiedStudent: boolean;
}
interface AuthContextType {
user: AuthUser | null;
isAuthenticated: boolean;
isVerifiedStudent: boolean;
isLoading: boolean;
loginWithGoogle: () => void;
logout: () => void;
}
// ──────────────────────────────────────────────────
// Google Client ID
// ──────────────────────────────────────────────────
// IMPORTANT: Replace this with your actual Google Cloud Console OAuth2 Client ID
// Create one at: https://console.cloud.google.com/apis/credentials
// Authorized origins: https://rit-services.in, http://localhost:5173
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
// ──────────────────────────────────────────────────
// Context
// ──────────────────────────────────────────────────
const AuthContext = createContext<AuthContextType>({
user: null,
isAuthenticated: false,
isVerifiedStudent: false,
isLoading: true,
loginWithGoogle: () => {},
logout: () => {},
});
export const useAuth = () => useContext(AuthContext);
// ──────────────────────────────────────────────────
// Google GSI global type
// ──────────────────────────────────────────────────
declare global {
interface Window {
google?: {
accounts: {
id: {
initialize: (config: Record<string, unknown>) => void;
prompt: (callback?: (notification: { isNotDisplayed: () => boolean; isSkippedMoment: () => boolean }) => void) => void;
renderButton: (element: HTMLElement, config: Record<string, unknown>) => void;
revoke: (email: string, callback: () => void) => void;
};
};
};
}
}
// ──────────────────────────────────────────────────
// Provider
// ──────────────────────────────────────────────────
const STORAGE_KEY = 'rit_auth_user';
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Handle the credential response from Google GSI
const handleCredentialResponse = useCallback(async (response: { credential: string }) => {
try {
const res = await fetch(getBackendUrl('/api/auth/google'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: response.credential }),
});
if (!res.ok) {
console.error('Auth failed:', res.status);
return;
}
const userData: AuthUser = await res.json();
setUser(userData);
localStorage.setItem(STORAGE_KEY, JSON.stringify(userData));
} catch (err) {
console.error('Google auth error:', err);
}
}, []);
// Initialize Google GSI
useEffect(() => {
// 1. Restore session from localStorage
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored) as AuthUser;
setUser(parsed);
// Validate session is still valid against backend
fetch(getBackendUrl(`/api/auth/me?email=${encodeURIComponent(parsed.email)}`))
.then(res => {
if (!res.ok) {
// Session expired, clear it
localStorage.removeItem(STORAGE_KEY);
setUser(null);
} else {
// Update with fresh data
return res.json();
}
})
.then(freshData => {
if (freshData) {
setUser(freshData);
localStorage.setItem(STORAGE_KEY, JSON.stringify(freshData));
}
})
.catch(() => {
// Backend offline, keep cached session
});
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}
setIsLoading(false);
// 2. Initialize Google GSI when the script loads
const initGSI = () => {
if (!window.google || !GOOGLE_CLIENT_ID) return;
window.google.accounts.id.initialize({
client_id: GOOGLE_CLIENT_ID,
callback: handleCredentialResponse,
auto_select: false,
cancel_on_tap_outside: true,
});
};
// The GSI script may not have loaded yet (async), so poll briefly
if (window.google) {
initGSI();
} else {
const interval = setInterval(() => {
if (window.google) {
clearInterval(interval);
initGSI();
}
}, 200);
// Stop polling after 10 seconds
setTimeout(() => clearInterval(interval), 10000);
}
}, [handleCredentialResponse]);
const loginWithGoogle = useCallback(() => {
if (!GOOGLE_CLIENT_ID) {
console.warn('Google Client ID not configured. Set VITE_GOOGLE_CLIENT_ID in .env');
return;
}
if (window.google) {
// Trigger the One Tap prompt
window.google.accounts.id.prompt((notification) => {
if (notification.isNotDisplayed() || notification.isSkippedMoment()) {
// If One Tap doesn't show (e.g. blocked by browser), fall back to button-based flow
// Render a temporary popup button
const popup = document.createElement('div');
popup.id = 'google-signin-popup';
popup.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:99999;background:white;padding:32px;border-radius:16px;box-shadow:0 25px 50px -12px rgba(0,0,0,0.25);';
document.body.appendChild(popup);
window.google!.accounts.id.renderButton(popup, {
theme: 'outline',
size: 'large',
text: 'signin_with',
shape: 'pill',
width: 300,
});
// Remove after 30s or when clicked
setTimeout(() => popup.remove(), 30000);
const observer = new MutationObserver(() => {
if (!document.getElementById('google-signin-popup')) {
observer.disconnect();
}
});
observer.observe(document.body, { childList: true });
}
});
}
}, []);
const logout = useCallback(() => {
if (user?.email && window.google) {
window.google.accounts.id.revoke(user.email, () => {
console.log('Google session revoked');
});
}
setUser(null);
localStorage.removeItem(STORAGE_KEY);
}, [user]);
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isVerifiedStudent: !!user?.verifiedStudent,
isLoading,
loginWithGoogle,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}

View File

@@ -3,10 +3,12 @@ import { motion, AnimatePresence } from 'framer-motion';
import {
Code2, Plus, GitBranch, Tag, User, Send, CheckCircle2,
Search, Filter, Sparkles, X, ExternalLink, Layers,
ChevronRight, MessageSquare, Clock, Users, ArrowUpRight
ChevronRight, MessageSquare, Clock, Users, ArrowUpRight,
ShieldCheck, LogIn, ShieldAlert
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { getBackendUrl } from '@/lib/utils';
import { useAuth } from '@/context/AuthContext';
export interface CollabRequestItem {
id: number;
@@ -84,6 +86,8 @@ const INITIAL_MOCK_REQUESTS: CollabRequestItem[] = [
];
export default function DevCollab() {
const { user, isAuthenticated, isVerifiedStudent, loginWithGoogle } = useAuth();
const [requests, setRequests] = useState<CollabRequestItem[]>(INITIAL_MOCK_REQUESTS);
const [loading, setLoading] = useState(false);
const [activeTagFilter, setActiveTagFilter] = useState('All');
@@ -93,24 +97,34 @@ export default function DevCollab() {
const [showPostModal, setShowPostModal] = useState(false);
const [selectedCollab, setSelectedCollab] = useState<CollabRequestItem | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [showAuthGate, setShowAuthGate] = useState(false);
// New Request Form
const [newAuthorName, setNewAuthorName] = useState('');
// New Request Form — auto-populated from Google profile for verified students
const [newAuthorName, setNewAuthorName] = useState(user?.name || '');
const [newDept, setNewDept] = useState('CSE');
const [newYear, setNewYear] = useState('1st Year');
const [newTag, setNewTag] = useState(TAG_OPTIONS[0]);
const [newCollaboratorsNeeded, setNewCollaboratorsNeeded] = useState(1);
const [newIdea, setNewIdea] = useState('');
const [newGithub, setNewGithub] = useState('');
const [newContact, setNewContact] = useState('');
const [newContact, setNewContact] = useState(user?.email || '');
const [submittingPost, setSubmittingPost] = useState(false);
// Application Form
const [appApplicantName, setAppApplicantName] = useState('');
// Application Form — auto-populated from Google profile
const [appApplicantName, setAppApplicantName] = useState(user?.name || '');
const [appDept, setAppDept] = useState('CSE');
const [appYear, setAppYear] = useState('1st Year');
const [appContact, setAppContact] = useState('');
const [appContact, setAppContact] = useState(user?.email || '');
const [appMessage, setAppMessage] = useState('');
// Helper: gate action behind verified student check
const requireVerifiedStudent = (action: () => void) => {
if (!isAuthenticated || !isVerifiedStudent) {
setShowAuthGate(true);
return;
}
action();
};
const [submittingApp, setSubmittingApp] = useState(false);
const fetchCollabRequests = () => {
@@ -306,10 +320,11 @@ export default function DevCollab() {
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={() => setShowPostModal(true)}
onClick={() => requireVerifiedStudent(() => setShowPostModal(true))}
className="flex items-center gap-2.5 px-6 py-3.5 rounded-2xl text-white font-semibold text-sm shadow-lg shadow-orange-500/25 shrink-0"
style={{ background: 'linear-gradient(135deg, #F97316, #FB923C)', fontFamily: 'Poppins, sans-serif' }}
>
{isVerifiedStudent && <ShieldCheck className="w-4 h-4" />}
<Plus className="w-4.5 h-4.5" />
Post Collaboration Request
</motion.button>
@@ -471,7 +486,7 @@ export default function DevCollab() {
</button>
) : (
<button
onClick={() => setSelectedCollab(item)}
onClick={() => requireVerifiedStudent(() => setSelectedCollab(item))}
className="w-full py-2.5 rounded-xl bg-[#FFF7ED] hover:bg-[#F97316] text-[#F97316] hover:text-white border border-[#FED7AA] hover:border-transparent text-xs font-semibold transition-all flex items-center justify-center gap-2 cursor-pointer shadow-xs"
style={{ fontFamily: 'Poppins, sans-serif' }}
>
@@ -494,7 +509,7 @@ export default function DevCollab() {
Be the first developer to post a request or try adjusting your filters!
</p>
<button
onClick={() => setShowPostModal(true)}
onClick={() => requireVerifiedStudent(() => setShowPostModal(true))}
className="px-5 py-2.5 rounded-xl bg-[#F97316] text-white text-xs font-semibold hover:bg-[#EA580C] transition-all"
>
Post First Collaboration Request
@@ -818,6 +833,96 @@ export default function DevCollab() {
</div>
)}
</AnimatePresence>
{/* ─── AUTH GATE MODAL (Verified Student Required) ─────────────────────── */}
<AnimatePresence>
{showAuthGate && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowAuthGate(false)}
className="absolute inset-0 bg-slate-900/60 backdrop-blur-xs"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 30 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 30 }}
className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl p-8 z-10 text-center"
>
<button
onClick={() => setShowAuthGate(false)}
className="absolute top-4 right-4 p-2 rounded-full hover:bg-slate-100 text-slate-400 transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="w-16 h-16 rounded-2xl bg-orange-100 flex items-center justify-center mx-auto mb-5">
<ShieldAlert className="w-8 h-8 text-[#F97316]" />
</div>
<h2
className="text-xl font-bold text-[#1E293B] mb-2"
style={{ fontFamily: 'Poppins, sans-serif' }}
>
{!isAuthenticated
? 'Sign In Required'
: 'Verified Student Account Required'}
</h2>
<p
className="text-sm text-[#64748B] mb-6 leading-relaxed"
style={{ fontFamily: 'Inter, sans-serif' }}
>
{!isAuthenticated ? (
<>To post or apply for collaboration requests, please sign in with your <strong className="text-[#F97316]">@ritchennai.edu.in</strong> Google account.</>
) : (
<>You are signed in as <strong>{user?.email}</strong>, which is not a verified RIT college email. Please sign in with your <strong className="text-[#F97316]">@ritchennai.edu.in</strong> email to access DevCollab features.</>
)}
</p>
<div className="space-y-3">
{!isAuthenticated ? (
<button
onClick={() => {
loginWithGoogle();
setShowAuthGate(false);
}}
className="w-full flex items-center justify-center gap-2.5 px-6 py-3.5 rounded-2xl text-white font-semibold text-sm shadow-lg"
style={{
background: 'linear-gradient(135deg, #F97316, #FB923C)',
fontFamily: 'Poppins, sans-serif',
}}
>
<LogIn className="w-4.5 h-4.5" />
Sign In with Google
</button>
) : (
<button
onClick={() => {
loginWithGoogle();
setShowAuthGate(false);
}}
className="w-full flex items-center justify-center gap-2.5 px-6 py-3.5 rounded-2xl text-white font-semibold text-sm shadow-lg"
style={{
background: 'linear-gradient(135deg, #F97316, #FB923C)',
fontFamily: 'Poppins, sans-serif',
}}
>
<ShieldCheck className="w-4.5 h-4.5" />
Switch to College Account
</button>
)}
<p className="text-[11px] text-[#94A3B8]">
We only verify your email domain — no personal data is stored beyond your name and email.
</p>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}