added login with gmail
This commit is contained in:
parent
96c1a44105
commit
0914b59e58
|
|
@ -1,2 +1,4 @@
|
|||
node_modules
|
||||
.cursor
|
||||
.cursor
|
||||
docs/
|
||||
.env
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue