Browse Source

feat: signin with google

pull/1/head
isra el 3 years ago
parent
commit
09baa28923
  1. 98
      api/package-lock.json
  2. 1
      api/package.json
  3. 7
      api/src/auth/auth.controller.ts
  4. 35
      api/src/auth/auth.service.ts
  5. 8
      api/src/users/schemas/user.schema.ts
  6. 2
      web/.env.example
  7. 6
      web/components/Navbar.tsx
  8. 16
      web/package-lock.json
  9. 1
      web/package.json
  10. 17
      web/pages/_app.tsx
  11. 18
      web/pages/index.tsx
  12. 17
      web/pages/login.tsx
  13. 17
      web/pages/register.tsx
  14. 8
      web/services/index.ts
  15. 5
      web/services/types.ts
  16. 30
      web/store/authReducer.ts
  17. 3043
      web/yarn.lock

98
api/package-lock.json

@ -16,6 +16,7 @@
"@nestjs/passport": "^8.2.1",
"@nestjs/platform-express": "^8.0.0",
"@nestjs/swagger": "^5.2.1",
"axios": "^1.3.4",
"bcryptjs": "^2.4.3",
"dotenv": "^16.0.0",
"firebase-admin": "^10.0.2",
@ -1728,6 +1729,14 @@
}
}
},
"node_modules/@nestjs/common/node_modules/axios": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
"dependencies": {
"follow-redirects": "^1.14.4"
}
},
"node_modules/@nestjs/core": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-8.2.4.tgz",
@ -3228,8 +3237,7 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
"dev": true
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
},
"node_modules/at-least-node": {
"version": "1.0.0",
@ -3241,11 +3249,26 @@
}
},
"node_modules/axios": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.3.4.tgz",
"integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==",
"dependencies": {
"follow-redirects": "^1.14.4"
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/babel-jest": {
@ -3791,7 +3814,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@ -4141,7 +4163,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@ -5155,9 +5176,9 @@
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==",
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"funding": [
{
"type": "individual",
@ -8255,6 +8276,11 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
@ -11506,6 +11532,16 @@
"iterare": "1.2.1",
"tslib": "2.3.1",
"uuid": "8.3.2"
},
"dependencies": {
"axios": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
"requires": {
"follow-redirects": "^1.14.4"
}
}
}
},
"@nestjs/core": {
@ -12686,8 +12722,7 @@
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
"dev": true
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
},
"at-least-node": {
"version": "1.0.0",
@ -12696,11 +12731,25 @@
"dev": true
},
"axios": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.3.4.tgz",
"integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==",
"requires": {
"follow-redirects": "^1.14.4"
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
},
"dependencies": {
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
}
}
},
"babel-jest": {
@ -13105,7 +13154,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"requires": {
"delayed-stream": "~1.0.0"
}
@ -13408,8 +13456,7 @@
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
"dev": true
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
},
"denque": {
"version": "2.0.1",
@ -14193,9 +14240,9 @@
"dev": true
},
"follow-redirects": {
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w=="
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="
},
"fork-ts-checker-webpack-plugin": {
"version": "6.5.0",
@ -16565,6 +16612,11 @@
"ipaddr.js": "1.9.1"
}
},
"proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",

1
api/package.json

@ -27,6 +27,7 @@
"@nestjs/passport": "^8.2.1",
"@nestjs/platform-express": "^8.0.0",
"@nestjs/swagger": "^5.2.1",
"axios": "^1.3.4",
"bcryptjs": "^2.4.3",
"dotenv": "^16.0.0",
"firebase-admin": "^10.0.2",

7
api/src/auth/auth.controller.ts

@ -27,6 +27,13 @@ export class AuthController {
return { data }
}
@ApiOperation({ summary: 'Login With Google' })
@Post('/google-login')
async googleLogin(@Body() input: any) {
const data = await this.authService.loginWithGoogle(input.idToken)
return { data }
}
@ApiOperation({ summary: 'Register' })
@Post('/register')
async register(@Body() input: RegisterInputDTO) {

35
api/src/auth/auth.service.ts

@ -7,6 +7,7 @@ import { InjectModel } from '@nestjs/mongoose'
import { ApiKey, ApiKeyDocument } from './schemas/api-key.schema'
import { Model } from 'mongoose'
import { User } from 'src/users/schemas/user.schema'
import axios from 'axios'
@Injectable()
export class AuthService {
constructor(
@ -46,6 +47,40 @@ export class AuthService {
}
}
async loginWithGoogle(idToken: string) {
const response = await axios.get(
`https://oauth2.googleapis.com/tokeninfo?id_token=${idToken}`,
)
const { sub: googleId, name, email, picture } = response.data
let user = await this.usersService.findOne({ email })
if (!user) {
user = await this.usersService.create({
name,
email,
googleId,
avatar: picture,
})
} else {
user.googleId = googleId
if (!user.name) {
user.name = name
}
if (!user.avatar) {
user.avatar = picture
}
await user.save()
}
const payload = { email: user.email, sub: user._id }
return {
accessToken: this.jwtService.sign(payload),
user,
}
}
async register(userData: any) {
const hashedPassword = await bcrypt.hash(userData.password, 10)
const user = await this.usersService.create({

8
api/src/users/schemas/user.schema.ts

@ -14,10 +14,16 @@ export class User {
@Prop({ type: String, required: true, unique: true, lowercase: true })
email: string
@Prop({ type: String, unique: true })
googleId?: string
@Prop({ type: String })
avatar?: string
@Prop({ type: String, trim: true })
primaryPhone: string
@Prop({ type: String, required: true })
@Prop({ type: String })
password: string
@Prop({ type: String, default: UserRole.REGULAR })

2
web/.env.example

@ -0,0 +1,2 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:3006/api/v1
NEXT_PUBLIC_GOOGLE_CLIENT_ID=

6
web/components/Navbar.tsx

@ -72,7 +72,10 @@ export default function Navbar() {
>
<Avatar
size={'sm'}
src={'https://avatars.dicebear.com/api/male/username.svg'}
src={
user?.avatar ??
'https://avatars.dicebear.com/api/male/username.svg'
}
/>
</MenuButton>
<MenuList alignItems={'center'}>
@ -81,6 +84,7 @@ export default function Navbar() {
<Avatar
size={'xl'}
src={
user?.avatar ??
'https://avatars.dicebear.com/api/male/username.svg'
}
/>

16
web/package-lock.json

@ -12,6 +12,7 @@
"@chakra-ui/react": "^1.8.7",
"@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1",
"@react-oauth/google": "^0.9.0",
"@reduxjs/toolkit": "^1.9.3",
"axios": "^0.26.1",
"framer-motion": "^6.2.8",
@ -1721,6 +1722,15 @@
"react-dom": "^16.8.0 || 17.x"
}
},
"node_modules/@react-oauth/google": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.9.0.tgz",
"integrity": "sha512-iq9I6A4uwZezU/BixqLM6UET6an559ufC4Nh0lEIeIaKC3TJRvcPNWCjjHny56yAhgdT6ivUicLIvEoiSMjnmg==",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.3.tgz",
@ -6284,6 +6294,12 @@
"tslib": "^2.1.0"
}
},
"@react-oauth/google": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.9.0.tgz",
"integrity": "sha512-iq9I6A4uwZezU/BixqLM6UET6an559ufC4Nh0lEIeIaKC3TJRvcPNWCjjHny56yAhgdT6ivUicLIvEoiSMjnmg==",
"requires": {}
},
"@reduxjs/toolkit": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.3.tgz",

1
web/package.json

@ -13,6 +13,7 @@
"@chakra-ui/react": "^1.8.7",
"@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1",
"@react-oauth/google": "^0.9.0",
"@reduxjs/toolkit": "^1.9.3",
"axios": "^0.26.1",
"framer-motion": "^6.2.8",

17
web/pages/_app.tsx

@ -5,16 +5,19 @@ import { store } from '../store/store'
import { ChakraProvider } from '@chakra-ui/react'
import Navbar from '../components/Navbar'
import Meta from '../components/meta/Meta'
import { GoogleOAuthProvider } from '@react-oauth/google'
function MyApp({ Component, pageProps }: AppProps) {
return (
<Provider store={store}>
<ChakraProvider>
<Meta />
<Navbar />
<Wrapper>
<Component {...pageProps} />
</Wrapper>
</ChakraProvider>
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID }>
<ChakraProvider>
<Meta />
<Navbar />
<Wrapper>
<Component {...pageProps} />
</Wrapper>
</ChakraProvider>
</GoogleOAuthProvider>
</Provider>
)
}

18
web/pages/index.tsx

@ -1,11 +1,12 @@
import { Container } from '@chakra-ui/react'
import { useGoogleOneTapLogin } from '@react-oauth/google'
import Router from 'next/router'
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { useDispatch, useSelector } from 'react-redux'
import FeaturesSection from '../components/home/FeaturesSection'
import HowItWorksSection from '../components/home/HowItWorksSection'
import IntroSection from '../components/home/IntroSection'
import { selectAuth } from '../store/authReducer'
import { loginWithGoogle, selectAuth } from '../store/authReducer'
export default function HomePage() {
const { accessToken, user } = useSelector(selectAuth)
@ -15,6 +16,19 @@ export default function HomePage() {
}
}, [accessToken, user])
const dispatch = useDispatch()
useGoogleOneTapLogin({
onSuccess: ({ credential: idToken }) => {
dispatch(
loginWithGoogle({
idToken,
})
)
},
onError: () => {},
})
return (
<Container maxW={'7xl'}>
<IntroSection />

17
web/pages/login.tsx

@ -17,9 +17,10 @@ import {
import Link from 'next/link'
import { useState } from 'react'
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons'
import { login, selectAuth } from '../store/authReducer'
import { login, loginWithGoogle, selectAuth } from '../store/authReducer'
import { useDispatch, useSelector } from 'react-redux'
import { LoginRequestPayload } from '../services/types'
import { GoogleLogin } from '@react-oauth/google'
export default function LoginPage() {
const [showPassword, setShowPassword] = useState<boolean>(false)
@ -112,6 +113,20 @@ export default function LoginPage() {
{authState.loading ? 'Please Wait...' : 'Login'}
</Button>
</Stack>
<GoogleLogin
onSuccess={({ credential: idToken }) => {
dispatch(loginWithGoogle({ idToken }))
}}
onError={() => {
toast({
title: 'Error',
description: 'Something went wrong',
status: 'error',
})
}}
useOneTap={true}
width='100%'
/>
<Stack pt={6}>
<Text align={'center'}>
Don&apos;t have an account?{' '}

17
web/pages/register.tsx

@ -16,9 +16,10 @@ import {
import Link from 'next/link'
import { useState } from 'react'
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons'
import { register, selectAuth } from '../store/authReducer'
import { loginWithGoogle, register, selectAuth } from '../store/authReducer'
import { useDispatch, useSelector } from 'react-redux'
import { RegisterRequestPayload } from '../services/types'
import { GoogleLogin } from '@react-oauth/google'
export default function RegisterPage() {
const [showPassword, setShowPassword] = useState(false)
@ -116,6 +117,20 @@ export default function RegisterPage() {
{authState.loading ? 'Please wait...' : 'Register'}
</Button>
</Stack>
<GoogleLogin
onSuccess={({ credential: idToken }) => {
dispatch(loginWithGoogle({ idToken }))
}}
onError={() => {
toast({
title: 'Error',
description: 'Something went wrong',
status: 'error',
})
}}
useOneTap={true}
width='100%'
/>
<Stack pt={6}>
<Text align={'center'}>
Already a user? <Link href='/login'>Login</Link>

8
web/services/index.ts

@ -1,6 +1,7 @@
import axios from 'axios'
import { LOCAL_STORAGE_KEY } from '../shared/constants'
import {
GoogleLoginRequestPayload,
LoginRequestPayload,
LoginResponse,
RegisterRequestPayload,
@ -21,6 +22,13 @@ export const loginRequest = async (
return res.data.data
}
export const loginWithGoogleRequest = async (
payload: GoogleLoginRequestPayload
): Promise<LoginResponse> => {
const res = await axios.post(`${BASE_URL}/auth/google-login`, payload)
return res.data.data
}
export const registerRequest = async (
payload: RegisterRequestPayload
): Promise<RegisterResponse> => {

5
web/services/types.ts

@ -8,6 +8,7 @@ export interface UserEntity {
name: string
email: string
role: UserRole
avatar?: string
}
export interface AuthState {
loading: boolean
@ -20,6 +21,10 @@ export interface LoginRequestPayload {
password: string
}
export interface GoogleLoginRequestPayload {
idToken: string
}
export interface RegisterRequestPayload extends LoginRequestPayload {
name: string
}

30
web/store/authReducer.ts

@ -1,16 +1,22 @@
import { createAsyncThunk, createSlice, isAnyOf } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
import { loginRequest, registerRequest } from '../services'
import {
loginRequest,
loginWithGoogleRequest,
registerRequest,
} from '../services'
import { createStandaloneToast } from '@chakra-ui/react'
import Router from 'next/router'
import { RootState } from './store'
import {
AuthState,
GoogleLoginRequestPayload,
LoginRequestPayload,
RegisterRequestPayload,
} from '../services/types'
import { removeUserAndToken, saveUserAndToken } from '../shared/utils'
import { LOCAL_STORAGE_KEY } from '../shared/constants'
import { googleLogout } from '@react-oauth/google'
const toast = createStandaloneToast()
@ -45,6 +51,25 @@ export const login = createAsyncThunk(
}
)
export const loginWithGoogle = createAsyncThunk(
'auth/google-login',
async (payload: GoogleLoginRequestPayload, thunkAPI) => {
try {
const res = await loginWithGoogleRequest(payload)
const { accessToken, user } = res
saveUserAndToken(user, accessToken)
Router.push('/')
return res
} catch (e) {
toast({
title: e.response.data.error || 'Login failed',
status: 'error',
})
return thunkAPI.rejectWithValue(e.response.data)
}
}
)
export const register = createAsyncThunk(
'auth/register',
async (payload: RegisterRequestPayload, thunkAPI) => {
@ -70,6 +95,7 @@ export const authSlice = createSlice({
reducers: {
logout: (state) => {
removeUserAndToken()
googleLogout()
state.accessToken = null
state.user = null
Router.push('/login')
@ -78,7 +104,7 @@ export const authSlice = createSlice({
extraReducers: (builder) => {
builder
.addMatcher(
isAnyOf(login.fulfilled, register.fulfilled),
isAnyOf(login.fulfilled, register.fulfilled, loginWithGoogle.fulfilled),
(state, action: PayloadAction<any>) => {
state.loading = false
state.user = action.payload.user

3043
web/yarn.lock
File diff suppressed because it is too large
View File

Loading…
Cancel
Save