added login with gmail

This commit is contained in:
Abhijit 2026-10-26 15:16:42 +05:30
parent 96c1a44105
commit 0914b59e58
6 changed files with 103 additions and 19 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
node_modules
.cursor
docs/
.env

View File

@ -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',

View File

@ -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);
}
},
});

View File

@ -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,
});
}

View File

@ -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;

View File

@ -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 = () => {
</Button>
</div>
</Form>
<div className="d-flex align-items-center my-3">
<hr className="flex-grow-1 m-0" />
<span className="px-3 text-muted fw-semibold">OR</span>
<hr className="flex-grow-1 m-0" />
</div>
<div className="d-grid">
<a href="/api/auth/google/start" className="btn btn-outline-secondary fw-semibold py-2 d-flex align-items-center justify-content-center gap-2">
<FcGoogle size={20} /> Login with Gmail
</a>
</div>
</Card>
<p className="text-center auth-pr-footer mt-4 mb-0">
<Link to="/privacy">Privacy Policy</Link>