diff --git a/.gitignore b/.gitignore index 28a60e1..6664919 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules -.cursor \ No newline at end of file +.cursor +docs/ +.env \ No newline at end of file diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 61e73a0..5def775 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -17,6 +17,7 @@ import { rotateRefreshSession, revokeRefreshSession, } from '../services/sessionCookies.js'; +import { getLoginUrl, googleConfigured, signConnectState } from '../services/google.js'; const GENERIC_RESET_MESSAGE = 'If that account exists, we sent a password reset link.'; @@ -66,6 +67,17 @@ export function authRoutes(server) { }, }); + server.route({ + method: 'GET', + path: '/api/auth/google/start', + options: { auth: false }, + handler: async (request, h) => { + assertAuthRate(request); + if (!googleConfigured()) return h.redirect(`${env.APP_URL}/auth-1/sign-in?google=error`); + return h.redirect(getLoginUrl(signConnectState('', 'login'))); + }, + }); + server.route({ method: 'POST', path: '/api/auth/refresh', diff --git a/backend/src/routes/google.js b/backend/src/routes/google.js index 27171af..b9803f8 100644 --- a/backend/src/routes/google.js +++ b/backend/src/routes/google.js @@ -1,8 +1,10 @@ import Joi from 'joi'; import Boom from '@hapi/boom'; -import { credentials, authResponse } from '../plugins/auth.js'; +import { credentials, authResponse, signToken } from '../plugins/auth.js'; import { env } from '../config/env.js'; import { User } from '../models/User.js'; +import { writeAudit } from '../services/audit.js'; +import { applySessionCookies, issueRefreshSession } from '../services/sessionCookies.js'; import { applyGoogleTokens, createCalendarEvent, @@ -56,6 +58,32 @@ function profileRedirect(status) { return `${env.APP_URL}/profile?google=${encodeURIComponent(status)}`; } +function signInRedirect(status) { + return `${env.APP_URL}/auth-1/sign-in?google=${encodeURIComponent(status)}`; +} + +// Logs in the active user whose login email or connected Gmail matches the Google account. +async function handleGoogleLogin(request, h, code) { + const { profile } = await exchangeCode(code); + const email = String(profile?.email || '').toLowerCase().trim(); + if (!email || profile.verified_email === false) return h.redirect(signInRedirect('error')); + const user = await User.findOne({ $or: [{ email }, { 'google.email': email }] }).populate('role'); + if (!user) { + await writeAudit(request, { action: 'login.failure', resourceType: 'user' }); + return h.redirect(signInRedirect('no_account')); + } + if (!user.isActive) return h.redirect(signInRedirect('disabled')); + user.lastLoginAt = new Date(); + await user.save(); + await writeAudit(request, { action: 'login.success', resourceType: 'user', resourceId: user._id, actor: user._id }); + const needsCalendar = user.role?.systemKey === 'educator' && !user.google?.refreshToken; + const next = needsCalendar + ? getConnectUrl(signConnectState(user._id, 'login_connect'), email) + : `${env.APP_URL}/dashboard`; + const refresh = await issueRefreshSession(user._id); + return applySessionCookies(h.redirect(next), signToken(user), refresh); +} + export function googleRoutes(server) { server.route({ method: 'GET', @@ -63,7 +91,7 @@ export function googleRoutes(server) { handler: async (request) => { if (!googleConfigured()) throw Boom.badRequest('Google Calendar is not configured'); const { user } = credentials(request); - return { url: getConnectUrl(signConnectState(user._id)) }; + return { url: getConnectUrl(signConnectState(user._id, 'connect')) }; }, }); @@ -83,18 +111,21 @@ export function googleRoutes(server) { }, handler: async (request, h) => { const { code, state, error } = request.query; - if (error || !code || !state) return h.redirect(profileRedirect('error')); - const userId = verifyConnectState(state); - if (!userId) return h.redirect(profileRedirect('error')); + const parsed = verifyConnectState(state); + const mode = parsed?.mode || 'connect'; + const dashboard = `${env.APP_URL}/dashboard`; + const failRedirect = mode === 'login' ? signInRedirect('error') : (mode === 'login_connect' ? dashboard : profileRedirect('error')); + if (error || !code || !parsed) return h.redirect(failRedirect); try { - const user = await User.findById(userId); - if (!user || !user.isActive) return h.redirect(profileRedirect('error')); + if (mode === 'login') return await handleGoogleLogin(request, h, code); + const user = await User.findById(parsed.userId); + if (!user || !user.isActive) return h.redirect(failRedirect); const { tokens, profile } = await exchangeCode(code); applyGoogleTokens(user, tokens, profile); await user.save(); - return h.redirect(profileRedirect('connected')); + return h.redirect(mode === 'login_connect' ? dashboard : profileRedirect('connected')); } catch { - return h.redirect(profileRedirect('error')); + return h.redirect(failRedirect); } }, }); diff --git a/backend/src/services/google.js b/backend/src/services/google.js index 5983b4e..7fafa25 100644 --- a/backend/src/services/google.js +++ b/backend/src/services/google.js @@ -13,6 +13,7 @@ export const GOOGLE_SCOPES = [ MEET_SPACE_SCOPE, 'https://www.googleapis.com/auth/userinfo.email', ]; +export const LOGIN_SCOPES = ['openid', 'email', 'profile']; const STATE_TTL_MS = 10 * 60 * 1000; @@ -31,9 +32,11 @@ export function createOAuthClient() { return new google.auth.OAuth2(env.GOOGLE_CLIENT_ID, env.GOOGLE_CLIENT_SECRET, env.GOOGLE_REDIRECT_URI); } -export function signConnectState(userId) { +// Signs the OAuth state; mode is connect, login, or login_connect. +export function signConnectState(userId, mode = 'connect') { const payload = Buffer.from(JSON.stringify({ - userId: String(userId), + userId: userId ? String(userId) : '', + mode, nonce: crypto.randomBytes(16).toString('hex'), exp: Date.now() + STATE_TTL_MS, })).toString('base64url'); @@ -50,14 +53,16 @@ export function verifyConnectState(state) { if (left.length !== right.length || !crypto.timingSafeEqual(left, right)) return null; try { const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); - if (!data?.userId || !data.exp || Date.now() > data.exp) return null; - return data.userId; + if (!data?.exp || Date.now() > data.exp) return null; + const mode = data.mode || 'connect'; + if (mode !== 'login' && !data.userId) return null; + return { userId: data.userId || '', mode }; } catch { return null; } } -export function getConnectUrl(state) { +export function getConnectUrl(state, loginHint) { const client = createOAuthClient(); return client.generateAuthUrl({ access_type: 'offline', @@ -65,6 +70,17 @@ export function getConnectUrl(state) { include_granted_scopes: false, scope: GOOGLE_SCOPES, state, + login_hint: loginHint || undefined, + }); +} + +// Google sign-in URL that asks only for the user's email. +export function getLoginUrl(state) { + const client = createOAuthClient(); + return client.generateAuthUrl({ + prompt: 'select_account', + scope: LOGIN_SCOPES, + state, }); } diff --git a/frontend/src/assets/scss/pages/_authentication.scss b/frontend/src/assets/scss/pages/_authentication.scss index 3bf62ce..d4bb8da 100644 --- a/frontend/src/assets/scss/pages/_authentication.scss +++ b/frontend/src/assets/scss/pages/_authentication.scss @@ -74,11 +74,16 @@ } .form-control { - background: rgba(255, 255, 255, 0.2); - border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(40, 80, 144, 0.45);// brand blue border so the box is visible color: #285090; font-weight: 600; + &:focus { + border-color: #285090; + box-shadow: 0 0 0 0.2rem rgba(40, 80, 144, 0.2); + } + &::placeholder { color: #031323; opacity: 0.7; diff --git a/frontend/src/views/auth/auth-1/sign-in/index.jsx b/frontend/src/views/auth/auth-1/sign-in/index.jsx index fbaf14e..063b87c 100644 --- a/frontend/src/views/auth/auth-1/sign-in/index.jsx +++ b/frontend/src/views/auth/auth-1/sign-in/index.jsx @@ -2,16 +2,24 @@ import logo from '@/assets/images/logo.png'; import { FooterCredit } from '@/layouts/components/footer'; import { useAuth } from '@/context/useAuthContext'; import { useState } from 'react'; -import { Link, useLocation, useNavigate } from 'react-router'; +import { Link, useLocation, useNavigate, useSearchParams } from 'react-router'; import { Alert, Button, Card, Col, Container, Form, FormControl, FormLabel, Row, Spinner } from 'react-bootstrap'; +import { FcGoogle } from 'react-icons/fc'; + +const GOOGLE_ERRORS = { + no_account: "You don't have an account with this email. Retry with another Gmail or log in with your email and password.", + disabled: 'This account is disabled', + error: 'Google login failed. Try again.', +}; const Index = () => { const { login } = useAuth(); const navigate = useNavigate(); const location = useLocation(); + const [searchParams] = useSearchParams(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [error, setError] = useState(''); + const [error, setError] = useState(GOOGLE_ERRORS[searchParams.get('google')] || ''); const [info, setInfo] = useState(location.state?.resetOk ? 'Password updated. You can log in now.' : ''); const [submitting, setSubmitting] = useState(false); @@ -64,6 +72,16 @@ const Index = () => { +
+
+ OR +
+
+
+ + Login with Gmail + +

Privacy Policy