feat: Google OAuth for login & signup (#6346)

This PR adds Google OAuth for all existing users, allowing users to log in or sign up via their Google account.

---------

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
Co-authored-by: Fayaz Ahmed <15716057+fayazara@users.noreply.github.com>
Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
Shivam Mishra
2023-02-16 11:12:02 +05:30
committed by GitHub
parent 2c8ecbeceb
commit 7be2ef3292
26 changed files with 567 additions and 92 deletions

View File

@@ -0,0 +1,69 @@
import { shallowMount } from '@vue/test-utils';
import GoogleOAuthButton from './GoogleOAuthButton.vue';
function getWrapper(showSeparator, buttonSize) {
return shallowMount(GoogleOAuthButton, {
propsData: { showSeparator: showSeparator, buttonSize: buttonSize },
methods: {
$t(text) {
return text;
},
},
});
}
describe('GoogleOAuthButton.vue', () => {
beforeEach(() => {
window.chatwootConfig = {
googleOAuthClientId: 'clientId',
googleOAuthCallbackUrl: 'http://localhost:3000/test-callback',
};
});
afterEach(() => {
window.chatwootConfig = {};
});
it('renders the OR separator if showSeparator is true', () => {
const wrapper = getWrapper(true);
expect(wrapper.find('.separator').exists()).toBe(true);
});
it('does not render the OR separator if showSeparator is false', () => {
const wrapper = getWrapper(false);
expect(wrapper.find('.separator').exists()).toBe(false);
});
it('generates the correct Google Auth URL', () => {
const wrapper = getWrapper();
const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl());
const params = googleAuthUrl.searchParams;
expect(googleAuthUrl.origin).toBe('https://accounts.google.com');
expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth/oauthchooseaccount');
expect(params.get('client_id')).toBe('clientId');
expect(params.get('redirect_uri')).toBe(
'http://localhost:3000/test-callback'
);
expect(params.get('response_type')).toBe('code');
expect(params.get('scope')).toBe('email profile');
});
it('responds to buttonSize prop properly', () => {
let wrapper = getWrapper(true, 'tiny');
expect(wrapper.find('.button.tiny').exists()).toBe(true);
wrapper = getWrapper(true, 'small');
expect(wrapper.find('.button.small').exists()).toBe(true);
wrapper = getWrapper(true, 'large');
expect(wrapper.find('.button.large').exists()).toBe(true);
// should not render either
wrapper = getWrapper(true, 'default');
expect(wrapper.find('.button.small').exists()).toBe(false);
expect(wrapper.find('.button.tiny').exists()).toBe(false);
expect(wrapper.find('.button.large').exists()).toBe(false);
expect(wrapper.find('.button').exists()).toBe(true);
});
});

View File

@@ -0,0 +1,96 @@
<template>
<div>
<div v-if="showSeparator" class="separator">
OR
</div>
<a :href="getGoogleAuthUrl()">
<button
class="button expanded button__google_login"
:class="{
// Explicit checking to ensure no other value is used
large: buttonSize === 'large',
small: buttonSize === 'small',
tiny: buttonSize === 'tiny',
}"
>
<img
src="/assets/images/auth/google.svg"
alt="Google Logo"
class="icon"
/>
<slot>{{ $t('LOGIN.OAUTH.GOOGLE_LOGIN') }}</slot>
</button>
</a>
</div>
</template>
<script>
const validButtonSizes = ['small', 'tiny', 'large'];
export default {
props: {
showSeparator: {
type: Boolean,
default: true,
},
buttonSize: {
type: String,
default: undefined,
validator: value =>
validButtonSizes.includes(value) || value === undefined,
},
},
methods: {
getGoogleAuthUrl() {
// Ideally a request to /auth/google_oauth2 should be made
// Creating the URL manually because the devise-token-auth with
// omniauth has a standing issue on redirecting the post request
// https://github.com/lynndylanhurley/devise_token_auth/issues/1466
const baseUrl =
'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount';
const clientId = window.chatwootConfig.googleOAuthClientId;
const redirectUri = window.chatwootConfig.googleOAuthCallbackUrl;
const responseType = 'code';
const scope = 'email profile';
// Build the query string
const queryString = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope: scope,
}).toString();
// Construct the full URL
return `${baseUrl}?${queryString}`;
},
},
};
</script>
<style lang="scss" scoped>
.separator {
display: flex;
align-items: center;
margin: var(--space-two) var(--space-zero);
gap: var(--space-one);
color: var(--s-300);
font-size: var(--font-size-small);
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--s-100);
}
}
.button__google_login {
background: var(--white);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-one);
border: 1px solid var(--s-100);
color: var(--b-800);
}
</style>

View File

@@ -14,6 +14,11 @@
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later",
"UNAUTH": "Username / Password Incorrect. Please try again"
},
"OAUTH": {
"GOOGLE_LOGIN": "Login with Google",
"BUSINESS_ACCOUNTS_ONLY": "Please use your company email address to login",
"NO_ACCOUNT_FOUND": "We couldn't find an account for your email address."
},
"FORGOT_PASSWORD": "Forgot your password?",
"CREATE_NEW_ACCOUNT": "Create new account",
"SUBMIT": "Login"

View File

@@ -5,6 +5,9 @@
"TESTIMONIAL_HEADER": "All it takes is one step to move forward",
"TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.",
"TERMS_ACCEPT": "By creating an account, you agree to our <a href=\"https://www.chatwoot.com/terms\">T & C</a> and <a href=\"https://www.chatwoot.com/privacy-policy\">Privacy policy</a>",
"OAUTH": {
"GOOGLE_SIGNUP": "Sign up with Google"
},
"COMPANY_NAME": {
"LABEL": "Company name",
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",

View File

@@ -88,7 +88,7 @@ export default {
}
.signup-form--content {
padding: var(--space-jumbo);
padding: var(--space-larger);
max-width: 600px;
width: 100%;
}

View File

@@ -1,70 +1,79 @@
<template>
<form @submit.prevent="submit">
<div class="input-wrap">
<auth-input
v-model="credentials.fullName"
:class="{ error: $v.credentials.fullName.$error }"
:label="$t('REGISTER.FULL_NAME.LABEL')"
icon-name="person"
:placeholder="$t('REGISTER.FULL_NAME.PLACEHOLDER')"
:error="
$v.credentials.fullName.$error ? $t('REGISTER.FULL_NAME.ERROR') : ''
"
@blur="$v.credentials.fullName.$touch"
<div>
<form @submit.prevent="submit">
<div class="input-wrap">
<div class="input-wrap__two-column">
<auth-input
v-model.trim="credentials.fullName"
:class="{ error: $v.credentials.fullName.$error }"
:label="$t('REGISTER.FULL_NAME.LABEL')"
icon-name="person"
:placeholder="$t('REGISTER.FULL_NAME.PLACEHOLDER')"
:error="
$v.credentials.fullName.$error
? $t('REGISTER.FULL_NAME.ERROR')
: ''
"
@blur="$v.credentials.fullName.$touch"
/>
<auth-input
v-model.trim="credentials.accountName"
:class="{ error: $v.credentials.accountName.$error }"
icon-name="building-bank"
:label="$t('REGISTER.COMPANY_NAME.LABEL')"
:placeholder="$t('REGISTER.COMPANY_NAME.PLACEHOLDER')"
:error="
$v.credentials.accountName.$error
? $t('REGISTER.COMPANY_NAME.ERROR')
: ''
"
@blur="$v.credentials.accountName.$touch"
/>
</div>
<auth-input
v-model.trim="credentials.email"
type="email"
:class="{ error: $v.credentials.email.$error }"
icon-name="mail"
:label="$t('REGISTER.EMAIL.LABEL')"
:placeholder="$t('REGISTER.EMAIL.PLACEHOLDER')"
:error="$v.credentials.email.$error ? $t('REGISTER.EMAIL.ERROR') : ''"
@blur="$v.credentials.email.$touch"
/>
<auth-input
v-model.trim="credentials.password"
type="password"
:class="{ error: $v.credentials.password.$error }"
icon-name="lock-closed"
:label="$t('LOGIN.PASSWORD.LABEL')"
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
:error="passwordErrorText"
@blur="$v.credentials.password.$touch"
/>
</div>
<div v-if="globalConfig.hCaptchaSiteKey" class="h-captcha--box">
<vue-hcaptcha
ref="hCaptcha"
:class="{ error: !hasAValidCaptcha && didCaptchaReset }"
:sitekey="globalConfig.hCaptchaSiteKey"
@verify="onRecaptchaVerified"
/>
<span v-if="!hasAValidCaptcha && didCaptchaReset" class="captcha-error">
{{ $t('SET_NEW_PASSWORD.CAPTCHA.ERROR') }}
</span>
</div>
<auth-submit-button
:label="$t('REGISTER.SUBMIT')"
:is-disabled="isSignupInProgress || !hasAValidCaptcha"
:is-loading="isSignupInProgress"
icon="arrow-chevron-right"
/>
<auth-input
v-model.trim="credentials.email"
type="email"
:class="{ error: $v.credentials.email.$error }"
icon-name="mail"
:label="$t('REGISTER.EMAIL.LABEL')"
:placeholder="$t('REGISTER.EMAIL.PLACEHOLDER')"
:error="$v.credentials.email.$error ? $t('REGISTER.EMAIL.ERROR') : ''"
@blur="$v.credentials.email.$touch"
/>
<auth-input
v-model="credentials.accountName"
:class="{ error: $v.credentials.accountName.$error }"
icon-name="building-bank"
:label="$t('REGISTER.COMPANY_NAME.LABEL')"
:placeholder="$t('REGISTER.COMPANY_NAME.PLACEHOLDER')"
:error="
$v.credentials.accountName.$error
? $t('REGISTER.COMPANY_NAME.ERROR')
: ''
"
@blur="$v.credentials.accountName.$touch"
/>
<auth-input
v-model.trim="credentials.password"
type="password"
:class="{ error: $v.credentials.password.$error }"
icon-name="lock-closed"
:label="$t('LOGIN.PASSWORD.LABEL')"
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
:error="passwordErrorText"
@blur="$v.credentials.password.$touch"
/>
</div>
<div v-if="globalConfig.hCaptchaSiteKey" class="h-captcha--box">
<vue-hcaptcha
ref="hCaptcha"
:class="{ error: !hasAValidCaptcha && didCaptchaReset }"
:sitekey="globalConfig.hCaptchaSiteKey"
@verify="onRecaptchaVerified"
/>
<span v-if="!hasAValidCaptcha && didCaptchaReset" class="captcha-error">
{{ $t('SET_NEW_PASSWORD.CAPTCHA.ERROR') }}
</span>
</div>
<auth-submit-button
:label="$t('REGISTER.SUBMIT')"
:is-disabled="isSignupInProgress || !hasAValidCaptcha"
:is-loading="isSignupInProgress"
icon="arrow-chevron-right"
/>
</form>
<GoogleOAuthButton v-if="showGoogleOAuth()">
{{ $t('REGISTER.OAUTH.GOOGLE_SIGNUP') }}
</GoogleOAuthButton>
<p v-dompurify-html="termsLink" class="accept--terms" />
</form>
</div>
</template>
<script>
@@ -78,6 +87,7 @@ import VueHcaptcha from '@hcaptcha/vue-hcaptcha';
import AuthInput from '../AuthInput.vue';
import AuthSubmitButton from '../AuthSubmitButton.vue';
import { isValidPassword } from 'shared/helpers/Validators';
import GoogleOAuthButton from 'dashboard/components/ui/Auth/GoogleOAuthButton.vue';
var CompanyEmailValidator = require('company-email-validator');
export default {
@@ -85,6 +95,7 @@ export default {
AuthInput,
AuthSubmitButton,
VueHcaptcha,
GoogleOAuthButton,
},
mixins: [globalConfigMixin, alertMixin],
data() {
@@ -183,6 +194,9 @@ export default {
this.credentials.hCaptchaClientResponse = token;
this.didCaptchaReset = false;
},
showGoogleOAuth() {
return Boolean(window.chatwootConfig.googleOAuthClientId);
},
resetCaptcha() {
if (!this.globalConfig.hCaptchaSiteKey) {
return;
@@ -214,4 +228,28 @@ export default {
.accept--terms {
margin: var(--space-normal) 0 var(--space-smaller) 0;
}
.input-wrap {
.input-wrap__two-column {
display: grid;
gap: 1.6rem;
grid-template-columns: repeat(2, minmax(100px, 1fr));
}
}
.separator {
display: flex;
align-items: center;
margin: 2rem 0rem;
gap: var(--space-normal);
color: var(--s-300);
font-size: var(--font-size-small);
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--s-100);
}
}
</style>

View File

@@ -84,6 +84,7 @@ export default {
background: var(--w-400);
display: flex;
flex: 1 1;
overflow: hidden;
position: relative;
}

View File

@@ -42,6 +42,7 @@ const authIgnoreRoutes = [
'auth_confirmation',
'pushBack',
'auth_password_edit',
'oauth-callback',
];
const routeValidators = [
@@ -117,6 +118,7 @@ export const validateRouteAccess = (to, from, next, { getters }) => {
export const initalizeRouter = () => {
const userAuthentication = store.dispatch('setUser');
router.beforeEach((to, from, next) => {
AnalyticsHelper.page(to.name || '', {
path: to.path,

View File

@@ -1,6 +1,6 @@
<template>
<div class="medium-12 column login">
<div class="text-center medium-12 login__hero align-self-top">
<main class="medium-12 column login">
<section class="text-center medium-12 login__hero align-self-top">
<img
:src="globalConfig.logo"
:alt="globalConfig.installationName"
@@ -11,11 +11,16 @@
useInstallationName($t('LOGIN.TITLE'), globalConfig.installationName)
}}
</h2>
</div>
<div class="row align-center">
</section>
<section class="row align-center">
<div v-if="!email" class="small-12 medium-4 column">
<form class="login-box column align-self-top" @submit.prevent="login()">
<div class="column log-in-form">
<div class="login-box column align-self-top">
<GoogleOAuthButton
v-if="showGoogleOAuth()"
button-size="large"
class="oauth-reverse"
/>
<form class="column log-in-form" @submit.prevent="login()">
<label :class="{ error: $v.credentials.email.$error }">
{{ $t('LOGIN.EMAIL.LABEL') }}
<input
@@ -46,9 +51,9 @@
:loading="loginApi.showLoading"
button-class="large expanded"
/>
</div>
</form>
<div class="column text-center sigin__footer">
</form>
</div>
<div class="text-center column sigin__footer">
<p v-if="!globalConfig.disableUserProfileUpdate">
<router-link to="auth/reset/password">
{{ $t('LOGIN.FORGOT_PASSWORD') }}
@@ -62,19 +67,27 @@
</div>
</div>
<woot-spinner v-else size="" />
</div>
</div>
</section>
</main>
</template>
<script>
import { required, email } from 'vuelidate/lib/validators';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import WootSubmitButton from '../../components/buttons/FormSubmitButton';
import WootSubmitButton from 'components/buttons/FormSubmitButton';
import { mapGetters } from 'vuex';
import { parseBoolean } from '@chatwoot/utils';
import GoogleOAuthButton from '../../components/ui/Auth/GoogleOAuthButton.vue';
const ERROR_MESSAGES = {
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
};
export default {
components: {
WootSubmitButton,
GoogleOAuthButton,
},
mixins: [globalConfigMixin],
props: {
@@ -83,6 +96,7 @@ export default {
ssoConversationId: { type: String, default: '' },
config: { type: String, default: '' },
email: { type: String, default: '' },
authError: { type: String, default: '' },
},
data() {
return {
@@ -119,6 +133,16 @@ export default {
if (this.ssoAuthToken) {
this.login();
}
if (this.authError) {
const message = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
this.showAlert(this.$t(message));
// wait for idle state
window.requestIdleCallback(() => {
// Remove the error query param from the url
const { query } = this.$route;
this.$router.replace({ query: { ...query, error: undefined } });
});
}
},
methods: {
showAlert(message) {
@@ -128,7 +152,10 @@ export default {
bus.$emit('newToastMessage', this.loginApi.message);
},
showSignupLink() {
return window.chatwootConfig.signupEnabled === 'true';
return parseBoolean(window.chatwootConfig.signupEnabled);
},
showGoogleOAuth() {
return Boolean(window.chatwootConfig.googleOAuthClientId);
},
login() {
this.loginApi.showLoading = true;
@@ -172,3 +199,10 @@ export default {
},
};
</script>
<style lang="scss" scoped>
.oauth-reverse {
display: flex;
flex-direction: column-reverse;
}
</style>

View File

@@ -13,6 +13,7 @@ export default {
ssoAuthToken: route.query.sso_auth_token,
ssoAccountId: route.query.sso_account_id,
ssoConversationId: route.query.sso_conversation_id,
authError: route.query.error,
}),
},
],