diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index de3b1b70e..9e59758ea 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,6 +1,7 @@ class DashboardController < ActionController::Base include SwitchLocale + before_action :set_application_pack before_action :set_global_config around_action :switch_locale before_action :ensure_installation_onboarding, only: [:index] @@ -60,4 +61,12 @@ class DashboardController < ActionController::Base GIT_SHA: GIT_HASH } end + + def set_application_pack + @application_pack = if request.path.include?('/auth') || request.path.include?('/login') + 'v3app' + else + 'application' + end + end end diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index 040c27313..883644d17 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -3,47 +3,11 @@ import Cookies from 'js-cookie'; import endPoints from './endPoints'; import { - setAuthCredentials, clearCookiesOnLogout, deleteIndexedDBOnLogout, } from '../store/utils/api'; export default { - login(creds) { - return new Promise((resolve, reject) => { - axios - .post('auth/sign_in', creds) - .then(response => { - setAuthCredentials(response); - resolve(response.data); - }) - .catch(error => { - reject(error.response); - }); - }); - }, - - register(creds) { - const urlData = endPoints('register'); - const fetchPromise = new Promise((resolve, reject) => { - axios - .post(urlData.url, { - account_name: creds.accountName.trim(), - user_full_name: creds.fullName.trim(), - email: creds.email, - password: creds.password, - h_captcha_client_response: creds.hCaptchaClientResponse, - }) - .then(response => { - setAuthCredentials(response); - resolve(response); - }) - .catch(error => { - reject(error); - }); - }); - return fetchPromise; - }, validityCheck() { const urlData = endPoints('validityCheck'); return axios.get(urlData.url); @@ -73,45 +37,6 @@ export default { } return false; }, - verifyPasswordToken({ confirmationToken }) { - return new Promise((resolve, reject) => { - axios - .post('auth/confirmation', { - confirmation_token: confirmationToken, - }) - .then(response => { - setAuthCredentials(response); - resolve(response); - }) - .catch(error => { - reject(error.response); - }); - }); - }, - - setNewPassword({ resetPasswordToken, password, confirmPassword }) { - return new Promise((resolve, reject) => { - axios - .put('auth/password', { - reset_password_token: resetPasswordToken, - password_confirmation: confirmPassword, - password, - }) - .then(response => { - setAuthCredentials(response); - resolve(response); - }) - .catch(error => { - reject(error.response); - }); - }); - }, - - resetPassword({ email }) { - const urlData = endPoints('resetPassword'); - return axios.post(urlData.url, { email }); - }, - profileUpdate({ password, password_confirmation, diff --git a/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.vue b/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.vue deleted file mode 100644 index 404afe619..000000000 --- a/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.vue +++ /dev/null @@ -1,96 +0,0 @@ - - - - - diff --git a/app/javascript/dashboard/helper/URLHelper.js b/app/javascript/dashboard/helper/URLHelper.js index c4c544da0..c5f274e4f 100644 --- a/app/javascript/dashboard/helper/URLHelper.js +++ b/app/javascript/dashboard/helper/URLHelper.js @@ -1,41 +1,8 @@ -import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals'; - export const frontendURL = (path, params) => { const stringifiedParams = params ? `?${new URLSearchParams(params)}` : ''; return `/app/${path}${stringifiedParams}`; }; -const getSSOAccountPath = ({ ssoAccountId, user }) => { - const { accounts = [], account_id = null } = user || {}; - const ssoAccount = accounts.find( - account => account.id === Number(ssoAccountId) - ); - let accountPath = ''; - if (ssoAccount) { - accountPath = `accounts/${ssoAccountId}`; - } else if (accounts.length) { - // If the account id is not found, redirect to the first account - const accountId = account_id || accounts[0].id; - accountPath = `accounts/${accountId}`; - } - return accountPath; -}; - -export const getLoginRedirectURL = ({ - ssoAccountId, - ssoConversationId, - user, -}) => { - const accountPath = getSSOAccountPath({ ssoAccountId, user }); - if (accountPath) { - if (ssoConversationId) { - return frontendURL(`${accountPath}/conversations/${ssoConversationId}`); - } - return frontendURL(`${accountPath}/dashboard`); - } - return DEFAULT_REDIRECT_URL; -}; - export const conversationUrl = ({ accountId, activeInbox, diff --git a/app/javascript/dashboard/helper/specs/URLHelper.spec.js b/app/javascript/dashboard/helper/specs/URLHelper.spec.js index cd2623c23..204d3384f 100644 --- a/app/javascript/dashboard/helper/specs/URLHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/URLHelper.spec.js @@ -2,7 +2,6 @@ import { frontendURL, conversationUrl, isValidURL, - getLoginRedirectURL, conversationListPageURL, } from '../URLHelper'; @@ -76,44 +75,4 @@ describe('#URL Helpers', () => { expect(isValidURL('alert.window')).toBe(false); }); }); - - describe('getLoginRedirectURL', () => { - it('should return correct Account URL if account id is present', () => { - expect( - getLoginRedirectURL({ - ssoAccountId: '7500', - user: { - accounts: [{ id: 7500, name: 'Test Account 7500' }], - }, - }) - ).toBe('/app/accounts/7500/dashboard'); - }); - - it('should return correct conversation URL if account id and conversationId is present', () => { - expect( - getLoginRedirectURL({ - ssoAccountId: '7500', - ssoConversationId: '752', - user: { - accounts: [{ id: 7500, name: 'Test Account 7500' }], - }, - }) - ).toBe('/app/accounts/7500/conversations/752'); - }); - - it('should return default URL if account id is not present', () => { - expect(getLoginRedirectURL({ ssoAccountId: '7500', user: {} })).toBe( - '/app/' - ); - expect( - getLoginRedirectURL({ - ssoAccountId: '7500', - user: { - accounts: [{ id: '7501', name: 'Test Account 7501' }], - }, - }) - ).toBe('/app/accounts/7501/dashboard'); - expect(getLoginRedirectURL('7500', null)).toBe('/app/'); - }); - }); }); diff --git a/app/javascript/dashboard/i18n/locale/en/generalSettings.json b/app/javascript/dashboard/i18n/locale/en/generalSettings.json index 349d840e2..63b597a53 100644 --- a/app/javascript/dashboard/i18n/locale/en/generalSettings.json +++ b/app/javascript/dashboard/i18n/locale/en/generalSettings.json @@ -151,5 +151,9 @@ }, "DASHBOARD_APPS": { "LOADING_MESSAGE": "Loading Dashboard App..." + }, + "COMMON": { + "OR": "Or", + "CLICK_HERE": "click here" } } diff --git a/app/javascript/dashboard/i18n/locale/en/resetPassword.json b/app/javascript/dashboard/i18n/locale/en/resetPassword.json index 37aa1860a..955696b0c 100644 --- a/app/javascript/dashboard/i18n/locale/en/resetPassword.json +++ b/app/javascript/dashboard/i18n/locale/en/resetPassword.json @@ -1,6 +1,8 @@ { "RESET_PASSWORD": { "TITLE": "Reset password", + "DESCRIPTION": "Enter the email address you use to log in to Chatwoot to get the password reset instructions.", + "GO_BACK_TO_LOGIN": "If you want to go back to the login page,", "EMAIL": { "LABEL": "Email", "PLACEHOLDER": "Please enter your email.", diff --git a/app/javascript/dashboard/routes/auth/Auth.vue b/app/javascript/dashboard/routes/auth/Auth.vue deleted file mode 100644 index 757bd6578..000000000 --- a/app/javascript/dashboard/routes/auth/Auth.vue +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/app/javascript/dashboard/routes/auth/ResetPassword.vue b/app/javascript/dashboard/routes/auth/ResetPassword.vue deleted file mode 100644 index e9eae00ab..000000000 --- a/app/javascript/dashboard/routes/auth/ResetPassword.vue +++ /dev/null @@ -1,84 +0,0 @@ - - - diff --git a/app/javascript/dashboard/routes/auth/Signup.vue b/app/javascript/dashboard/routes/auth/Signup.vue deleted file mode 100644 index 06d8355cb..000000000 --- a/app/javascript/dashboard/routes/auth/Signup.vue +++ /dev/null @@ -1,133 +0,0 @@ - - - - diff --git a/app/javascript/dashboard/routes/auth/auth.routes.js b/app/javascript/dashboard/routes/auth/auth.routes.js deleted file mode 100644 index 0a12cb041..000000000 --- a/app/javascript/dashboard/routes/auth/auth.routes.js +++ /dev/null @@ -1,50 +0,0 @@ -import Auth from './Auth'; -import Confirmation from './Confirmation'; -import PasswordEdit from './PasswordEdit'; -import ResetPassword from './ResetPassword'; -import { frontendURL } from '../../helper/URLHelper'; - -const Signup = () => import('./Signup'); - -export default { - routes: [ - { - path: frontendURL('auth/signup'), - name: 'auth_signup', - component: Signup, - meta: { requireSignupEnabled: true }, - }, - { - path: frontendURL('auth'), - name: 'auth', - component: Auth, - children: [ - { - path: 'confirmation', - name: 'auth_confirmation', - component: Confirmation, - props: route => ({ - config: route.query.config, - confirmationToken: route.query.confirmation_token, - redirectUrl: route.query.route_url, - }), - }, - { - path: 'password/edit', - name: 'auth_password_edit', - component: PasswordEdit, - props: route => ({ - config: route.query.config, - resetPasswordToken: route.query.reset_password_token, - redirectUrl: route.query.route_url, - }), - }, - { - path: 'reset/password', - name: 'auth_reset_password', - component: ResetPassword, - }, - ], - }, - ], -}; diff --git a/app/javascript/dashboard/routes/auth/components/AuthInput.vue b/app/javascript/dashboard/routes/auth/components/AuthInput.vue deleted file mode 100644 index a1bec50f5..000000000 --- a/app/javascript/dashboard/routes/auth/components/AuthInput.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - - diff --git a/app/javascript/dashboard/routes/auth/components/Testimonials/Index.vue b/app/javascript/dashboard/routes/auth/components/Testimonials/Index.vue deleted file mode 100644 index 691f21b98..000000000 --- a/app/javascript/dashboard/routes/auth/components/Testimonials/Index.vue +++ /dev/null @@ -1,120 +0,0 @@ - - - - - diff --git a/app/javascript/dashboard/routes/auth/components/Testimonials/TestimonialCard.vue b/app/javascript/dashboard/routes/auth/components/Testimonials/TestimonialCard.vue deleted file mode 100644 index 72d02753d..000000000 --- a/app/javascript/dashboard/routes/auth/components/Testimonials/TestimonialCard.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - - diff --git a/app/javascript/dashboard/routes/auth/components/Testimonials/TestimonialFooter.vue b/app/javascript/dashboard/routes/auth/components/Testimonials/TestimonialFooter.vue deleted file mode 100644 index f225c15d5..000000000 --- a/app/javascript/dashboard/routes/auth/components/Testimonials/TestimonialFooter.vue +++ /dev/null @@ -1,47 +0,0 @@ - - - diff --git a/app/javascript/dashboard/routes/index.js b/app/javascript/dashboard/routes/index.js index a85b4149e..e2245e20f 100644 --- a/app/javascript/dashboard/routes/index.js +++ b/app/javascript/dashboard/routes/index.js @@ -1,15 +1,12 @@ import VueRouter from 'vue-router'; import { frontendURL } from '../helper/URLHelper'; -import { clearBrowserSessionCookies } from '../store/utils/api'; -import authRoute from './auth/auth.routes'; import dashboard from './dashboard/dashboard.routes'; -import login from './login/login.routes'; import store from '../store'; import { validateLoggedInRoutes } from '../helper/routeHelpers'; import AnalyticsHelper from '../helper/AnalyticsHelper'; -const routes = [...login.routes, ...dashboard.routes, ...authRoute.routes]; +const routes = [...dashboard.routes]; window.roleWiseRoutes = { agent: [], @@ -36,86 +33,26 @@ generateRoleWiseRoute(routes); export const router = new VueRouter({ mode: 'history', routes }); -const unProtectedRoutes = ['login', 'auth_signup', 'auth_reset_password']; +export const validateAuthenticateRoutePermission = (to, next, { getters }) => { + const { isLoggedIn, getCurrentUser: user } = getters; -const authIgnoreRoutes = [ - 'auth_confirmation', - 'pushBack', - 'auth_password_edit', - 'oauth-callback', -]; + if (!isLoggedIn) { + window.location = '/app/login'; + return '/app/login'; + } -const routeValidators = [ - { - protected: false, - loggedIn: true, - handler: (_, getters) => { - const user = getters.getCurrentUser; - return `accounts/${user.account_id}/dashboard`; - }, - }, - { - protected: true, - loggedIn: false, - handler: () => 'login', - }, - { - protected: true, - loggedIn: true, - handler: (to, getters) => - validateLoggedInRoutes(to, getters.getCurrentUser, window.roleWiseRoutes), - }, - { - protected: false, - loggedIn: false, - handler: () => null, - }, -]; + if (!to.name) { + return next(frontendURL(`accounts/${user.account_id}/dashboard`)); + } -export const validateAuthenticateRoutePermission = ( - to, - from, - next, - { getters } -) => { - const isLoggedIn = getters.isLoggedIn; - const isProtectedRoute = !unProtectedRoutes.includes(to.name); - const strategy = routeValidators.find( - validator => - validator.protected === isProtectedRoute && - validator.loggedIn === isLoggedIn + const nextRoute = validateLoggedInRoutes( + to, + getters.getCurrentUser, + window.roleWiseRoutes ); - const nextRoute = strategy.handler(to, getters); return nextRoute ? next(frontendURL(nextRoute)) : next(); }; -const validateSSOLoginParams = to => { - const isLoginRoute = to.name === 'login'; - const { email, sso_auth_token: ssoAuthToken } = to.query || {}; - const hasValidSSOParams = email && ssoAuthToken; - return isLoginRoute && hasValidSSOParams; -}; - -export const validateRouteAccess = (to, from, next, { getters }) => { - // Disable navigation to signup page if signups are disabled - // Signup route has an attribute (requireSignupEnabled) - // defined in it's route definition - if ( - window.chatwootConfig.signupEnabled !== 'true' && - to.meta && - to.meta.requireSignupEnabled - ) { - return next(frontendURL('login')); - } - - // For routes which doesn't care about authentication, skip validation - if (authIgnoreRoutes.includes(to.name)) { - return next(); - } - - return validateAuthenticateRoutePermission(to, from, next, { getters }); -}; - export const initalizeRouter = () => { const userAuthentication = store.dispatch('setUser'); @@ -125,22 +62,8 @@ export const initalizeRouter = () => { name: to.name, }); - if (validateSSOLoginParams(to)) { - clearBrowserSessionCookies(); - next(); - return; - } - userAuthentication.then(() => { - if (!to.name) { - const { isLoggedIn, getCurrentUser: user } = store.getters; - if (isLoggedIn) { - return next(frontendURL(`accounts/${user.account_id}/dashboard`)); - } - return next('/app/login'); - } - - return validateRouteAccess(to, from, next, store); + return validateAuthenticateRoutePermission(to, next, store); }); }); }; diff --git a/app/javascript/dashboard/routes/index.spec.js b/app/javascript/dashboard/routes/index.spec.js index b9bf3c251..21ec30191 100644 --- a/app/javascript/dashboard/routes/index.spec.js +++ b/app/javascript/dashboard/routes/index.spec.js @@ -1,52 +1,17 @@ import 'expect-more-jest'; -import { - validateAuthenticateRoutePermission, - validateRouteAccess, -} from './index'; +import { validateAuthenticateRoutePermission } from './index'; jest.mock('./dashboard/dashboard.routes', () => ({ routes: [], })); -jest.mock('./auth/auth.routes', () => ({ - routes: [], -})); -jest.mock('./login/login.routes', () => ({ - routes: [], -})); window.roleWiseRoutes = {}; describe('#validateAuthenticateRoutePermission', () => { - describe(`when route is not protected`, () => { - it(`should go to the dashboard when user is logged in`, () => { - const to = { name: 'login', params: { accountId: 1 } }; - const from = { name: '', params: { accountId: 1 } }; - const next = jest.fn(); - const getters = { - isLoggedIn: true, - getCurrentUser: { - account_id: 1, - id: 1, - accounts: [{ id: 1, role: 'admin', status: 'active' }], - }, - }; - validateAuthenticateRoutePermission(to, from, next, { getters }); - expect(next).toHaveBeenCalledWith('/app/accounts/1/dashboard'); - }); - it(`should go there when user is not logged in`, () => { - const to = { name: 'login', params: {} }; - const from = { name: '', params: {} }; - const next = jest.fn(); - const getters = { isLoggedIn: false }; - validateAuthenticateRoutePermission(to, from, next, { getters }); - expect(next).toHaveBeenCalledWith(); - }); - }); describe(`when route is protected`, () => { describe(`when user not logged in`, () => { it(`should redirect to login`, () => { // Arrange const to = { name: 'some-protected-route', params: { accountId: 1 } }; - const from = { name: '' }; const next = jest.fn(); const getters = { isLoggedIn: false, @@ -56,8 +21,10 @@ describe('#validateAuthenticateRoutePermission', () => { accounts: [], }, }; - validateAuthenticateRoutePermission(to, from, next, { getters }); - expect(next).toHaveBeenCalledWith('/app/login'); + + expect(validateAuthenticateRoutePermission(to, next, { getters })).toBe( + '/app/login' + ); }); }); describe(`when user is logged in`, () => { @@ -65,7 +32,6 @@ describe('#validateAuthenticateRoutePermission', () => { it(`should redirect to dashboard`, () => { window.roleWiseRoutes.agent = ['dashboard']; const to = { name: 'admin', params: { accountId: 1 } }; - const from = { name: '' }; const next = jest.fn(); const getters = { isLoggedIn: true, @@ -75,7 +41,7 @@ describe('#validateAuthenticateRoutePermission', () => { accounts: [{ id: 1, role: 'agent', status: 'active' }], }, }; - validateAuthenticateRoutePermission(to, from, next, { getters }); + validateAuthenticateRoutePermission(to, next, { getters }); expect(next).toHaveBeenCalledWith('/app/accounts/1/dashboard'); }); }); @@ -83,7 +49,6 @@ describe('#validateAuthenticateRoutePermission', () => { it(`should go there`, () => { window.roleWiseRoutes.agent = ['dashboard', 'admin']; const to = { name: 'admin', params: { accountId: 1 } }; - const from = { name: '' }; const next = jest.fn(); const getters = { isLoggedIn: true, @@ -93,39 +58,10 @@ describe('#validateAuthenticateRoutePermission', () => { accounts: [{ id: 1, role: 'agent', status: 'active' }], }, }; - validateAuthenticateRoutePermission(to, from, next, { getters }); + validateAuthenticateRoutePermission(to, next, { getters }); expect(next).toHaveBeenCalledWith(); }); }); }); }); }); - -describe('#validateRouteAccess', () => { - it('returns to login if signup is disabled', () => { - window.chatwootConfig = { signupEnabled: 'false' }; - const to = { name: 'auth_signup', meta: { requireSignupEnabled: true } }; - const from = { name: '' }; - const next = jest.fn(); - validateRouteAccess(to, from, next, {}); - expect(next).toHaveBeenCalledWith('/app/login'); - }); - - it('returns next for an auth ignore route ', () => { - const to = { name: 'auth_confirmation' }; - const from = { name: '' }; - const next = jest.fn(); - - validateRouteAccess(to, from, next, {}); - expect(next).toHaveBeenCalledWith(); - }); - - it('returns route validation for everything else ', () => { - const to = { name: 'login' }; - const from = { name: '' }; - const next = jest.fn(); - - validateRouteAccess(to, from, next, { getters: { isLoggedIn: false } }); - expect(next).toHaveBeenCalledWith(); - }); -}); diff --git a/app/javascript/dashboard/routes/login/Login.vue b/app/javascript/dashboard/routes/login/Login.vue deleted file mode 100644 index 7a04fd2c1..000000000 --- a/app/javascript/dashboard/routes/login/Login.vue +++ /dev/null @@ -1,208 +0,0 @@ - - - - - diff --git a/app/javascript/dashboard/routes/login/login.routes.js b/app/javascript/dashboard/routes/login/login.routes.js deleted file mode 100644 index 37e333a5b..000000000 --- a/app/javascript/dashboard/routes/login/login.routes.js +++ /dev/null @@ -1,20 +0,0 @@ -import Login from './Login'; -import { frontendURL } from '../../helper/URLHelper'; - -export default { - routes: [ - { - path: frontendURL('login'), - name: 'login', - component: Login, - props: route => ({ - config: route.query.config, - email: route.query.email, - ssoAuthToken: route.query.sso_auth_token, - ssoAccountId: route.query.sso_account_id, - ssoConversationId: route.query.sso_conversation_id, - authError: route.query.error, - }), - }, - ], -}; diff --git a/app/javascript/dashboard/store/modules/auth.js b/app/javascript/dashboard/store/modules/auth.js index f06ea3750..cc1129970 100644 --- a/app/javascript/dashboard/store/modules/auth.js +++ b/app/javascript/dashboard/store/modules/auth.js @@ -2,12 +2,7 @@ import Vue from 'vue'; import types from '../mutation-types'; import authAPI from '../../api/auth'; -import { - setUser, - clearCookiesOnLogout, - clearLocalStorageOnLogout, -} from '../utils/api'; -import { getLoginRedirectURL } from '../../helper/URLHelper'; +import { setUser, clearCookiesOnLogout } from '../utils/api'; const initialState = { currentUser: { @@ -97,24 +92,6 @@ export const getters = { // actions export const actions = { - login(_, { ssoAccountId, ssoConversationId, ...credentials }) { - return new Promise((resolve, reject) => { - authAPI - .login(credentials) - .then(response => { - clearLocalStorageOnLogout(); - window.location = getLoginRedirectURL({ - ssoAccountId, - ssoConversationId, - user: response.data, - }); - resolve(); - }) - .catch(error => { - reject(error); - }); - }); - }, async validityCheck(context) { try { const response = await authAPI.validityCheck(); diff --git a/app/javascript/dashboard/store/utils/api.js b/app/javascript/dashboard/store/utils/api.js index 54c8af5aa..32afbeb94 100644 --- a/app/javascript/dashboard/store/utils/api.js +++ b/app/javascript/dashboard/store/utils/api.js @@ -67,6 +67,9 @@ export const parseAPIErrorResponse = error => { if (error?.response?.data?.error) { return error?.response?.data?.error; } + if (error?.response?.data?.errors) { + return error?.response?.data?.errors[0]; + } return error; }; diff --git a/app/javascript/packs/v3app.js b/app/javascript/packs/v3app.js new file mode 100644 index 000000000..b510a587c --- /dev/null +++ b/app/javascript/packs/v3app.js @@ -0,0 +1,61 @@ +import Vue from 'vue'; +import VueI18n from 'vue-i18n'; +import VueRouter from 'vue-router'; +import Vuelidate from 'vuelidate'; +import i18n from 'dashboard/i18n'; +import * as Sentry from '@sentry/vue'; +import { Integrations } from '@sentry/tracing'; +import { + initializeAnalyticsEvents, + initializeChatwootEvents, +} from 'dashboard/helper/scriptHelpers'; +import AnalyticsPlugin from 'dashboard/helper/AnalyticsHelper/plugin'; +import App from '../v3/App.vue'; +import router, { initalizeRouter } from '../v3/views/index'; +import store from '../v3/store'; +import FluentIcon from 'shared/components/FluentIcon/DashboardIcon'; + +Vue.config.env = process.env; + +if (window.errorLoggingConfig) { + Sentry.init({ + Vue, + dsn: window.errorLoggingConfig, + denyUrls: [ + // Chrome extensions + /^chrome:\/\//i, + /chrome-extension:/i, + /extensions\//i, + + // Locally saved copies + /file:\/\//i, + + // Safari extensions. + /safari-web-extension:/i, + /safari-extension:/i, + ], + integrations: [new Integrations.BrowserTracing()], + }); +} + +Vue.use(VueRouter); +Vue.use(VueI18n); +Vue.use(Vuelidate); +Vue.use(AnalyticsPlugin); +Vue.component('fluent-icon', FluentIcon); + +const i18nConfig = new VueI18n({ locale: 'en', messages: i18n }); + +window.bus = new Vue(); +initializeChatwootEvents(); +initializeAnalyticsEvents(); +initalizeRouter(); +window.onload = () => { + new Vue({ + router, + store, + i18n: i18nConfig, + components: { App }, + template: '', + }).$mount('#app'); +}; diff --git a/app/javascript/shared/components/Spinner.vue b/app/javascript/shared/components/Spinner.vue index ec02b05cb..64a193708 100644 --- a/app/javascript/shared/components/Spinner.vue +++ b/app/javascript/shared/components/Spinner.vue @@ -1,5 +1,5 @@ diff --git a/app/javascript/v3/api/apiClient.js b/app/javascript/v3/api/apiClient.js new file mode 100644 index 000000000..f8fdb5f51 --- /dev/null +++ b/app/javascript/v3/api/apiClient.js @@ -0,0 +1,6 @@ +import axios from 'axios'; + +const { apiHost = '' } = window.chatwootConfig || {}; +const wootAPI = axios.create({ baseURL: `${apiHost}/` }); + +export default wootAPI; diff --git a/app/javascript/v3/api/auth.js b/app/javascript/v3/api/auth.js new file mode 100644 index 000000000..a4793d3d0 --- /dev/null +++ b/app/javascript/v3/api/auth.js @@ -0,0 +1,74 @@ +import { + setAuthCredentials, + throwErrorMessage, + clearLocalStorageOnLogout, +} from 'dashboard/store/utils/api'; +import wootAPI from './apiClient'; +import { getLoginRedirectURL } from '../helpers/AuthHelper'; + +export const login = async ({ + ssoAccountId, + ssoConversationId, + ...credentials +}) => { + try { + const response = await wootAPI.post('auth/sign_in', credentials); + setAuthCredentials(response); + clearLocalStorageOnLogout(); + window.location = getLoginRedirectURL({ + ssoAccountId, + ssoConversationId, + user: response.data.data, + }); + } catch (error) { + throwErrorMessage(error); + } +}; + +export const register = async creds => { + try { + const response = await wootAPI.post('api/v1/accounts.json', { + account_name: creds.accountName.trim(), + user_full_name: creds.fullName.trim(), + email: creds.email, + password: creds.password, + h_captcha_client_response: creds.hCaptchaClientResponse, + }); + setAuthCredentials(response); + return response.data; + } catch (error) { + throwErrorMessage(error); + } + return null; +}; + +export const verifyPasswordToken = async ({ confirmationToken }) => { + try { + const response = await wootAPI.post('auth/confirmation', { + confirmation_token: confirmationToken, + }); + setAuthCredentials(response); + } catch (error) { + throwErrorMessage(error); + } +}; + +export const setNewPassword = async ({ + resetPasswordToken, + password, + confirmPassword, +}) => { + try { + const response = await wootAPI.put('auth/password', { + reset_password_token: resetPasswordToken, + password_confirmation: confirmPassword, + password, + }); + setAuthCredentials(response); + } catch (error) { + throwErrorMessage(error); + } +}; + +export const resetPassword = async ({ email }) => + wootAPI.post('auth/password', { email }); diff --git a/app/javascript/dashboard/api/testimonials.js b/app/javascript/v3/api/testimonials.js similarity index 54% rename from app/javascript/dashboard/api/testimonials.js rename to app/javascript/v3/api/testimonials.js index 2d24945a5..4aa667b47 100644 --- a/app/javascript/dashboard/api/testimonials.js +++ b/app/javascript/v3/api/testimonials.js @@ -1,6 +1,6 @@ -/* global axios */ import wootConstants from 'dashboard/constants/globals'; +import wootAPI from './apiClient'; export const getTestimonialContent = () => { - return axios.get(wootConstants.TESTIMONIAL_URL); + return wootAPI.get(wootConstants.TESTIMONIAL_URL); }; diff --git a/app/javascript/v3/components/Button/SubmitButton.vue b/app/javascript/v3/components/Button/SubmitButton.vue new file mode 100644 index 000000000..ab4d86800 --- /dev/null +++ b/app/javascript/v3/components/Button/SubmitButton.vue @@ -0,0 +1,63 @@ + + + diff --git a/app/javascript/v3/components/Divider/SimpleDivider.vue b/app/javascript/v3/components/Divider/SimpleDivider.vue new file mode 100644 index 000000000..e54af1747 --- /dev/null +++ b/app/javascript/v3/components/Divider/SimpleDivider.vue @@ -0,0 +1,24 @@ + + diff --git a/app/javascript/v3/components/Form/Input.vue b/app/javascript/v3/components/Form/Input.vue new file mode 100644 index 000000000..16497c336 --- /dev/null +++ b/app/javascript/v3/components/Form/Input.vue @@ -0,0 +1,80 @@ + + diff --git a/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.spec.js b/app/javascript/v3/components/GoogleOauth/Button.spec.js similarity index 56% rename from app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.spec.js rename to app/javascript/v3/components/GoogleOauth/Button.spec.js index 47cd387e5..e1b50eb57 100644 --- a/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.spec.js +++ b/app/javascript/v3/components/GoogleOauth/Button.spec.js @@ -1,9 +1,9 @@ import { shallowMount } from '@vue/test-utils'; -import GoogleOAuthButton from './GoogleOAuthButton.vue'; +import GoogleOAuthButton from './Button.vue'; -function getWrapper(showSeparator, buttonSize) { +function getWrapper(showSeparator) { return shallowMount(GoogleOAuthButton, { - propsData: { showSeparator: showSeparator, buttonSize: buttonSize }, + propsData: { showSeparator: showSeparator }, methods: { $t(text) { return text; @@ -26,18 +26,17 @@ describe('GoogleOAuthButton.vue', () => { it('renders the OR separator if showSeparator is true', () => { const wrapper = getWrapper(true); - expect(wrapper.find('.separator').exists()).toBe(true); + expect(wrapper.findComponent({ ref: 'divider' }).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); + expect(wrapper.findComponent({ ref: 'divider' }).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'); @@ -47,23 +46,7 @@ describe('GoogleOAuthButton.vue', () => { ); 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); + expect(wrapper.findComponent({ ref: 'divider' }).exists()).toBe(true); }); }); diff --git a/app/javascript/v3/components/GoogleOauth/Button.vue b/app/javascript/v3/components/GoogleOauth/Button.vue new file mode 100644 index 000000000..0d4d2a866 --- /dev/null +++ b/app/javascript/v3/components/GoogleOauth/Button.vue @@ -0,0 +1,60 @@ + + + diff --git a/app/javascript/v3/components/SnackBar/Container.vue b/app/javascript/v3/components/SnackBar/Container.vue new file mode 100644 index 000000000..2c761fb65 --- /dev/null +++ b/app/javascript/v3/components/SnackBar/Container.vue @@ -0,0 +1,54 @@ + + + diff --git a/app/javascript/v3/components/SnackBar/Item.vue b/app/javascript/v3/components/SnackBar/Item.vue new file mode 100644 index 000000000..b87845ad6 --- /dev/null +++ b/app/javascript/v3/components/SnackBar/Item.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/javascript/v3/helpers/AuthHelper.js b/app/javascript/v3/helpers/AuthHelper.js new file mode 100644 index 000000000..1eaea34e6 --- /dev/null +++ b/app/javascript/v3/helpers/AuthHelper.js @@ -0,0 +1,38 @@ +import Cookies from 'js-cookie'; +import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals'; +import { frontendURL } from 'dashboard/helper/URLHelper'; + +export const hasAuthCookie = () => { + return !!Cookies.getJSON('cw_d_session_info'); +}; + +const getSSOAccountPath = ({ ssoAccountId, user }) => { + const { accounts = [], account_id = null } = user || {}; + const ssoAccount = accounts.find( + account => account.id === Number(ssoAccountId) + ); + let accountPath = ''; + if (ssoAccount) { + accountPath = `accounts/${ssoAccountId}`; + } else if (accounts.length) { + // If the account id is not found, redirect to the first account + const accountId = account_id || accounts[0].id; + accountPath = `accounts/${accountId}`; + } + return accountPath; +}; + +export const getLoginRedirectURL = ({ + ssoAccountId, + ssoConversationId, + user, +}) => { + const accountPath = getSSOAccountPath({ ssoAccountId, user }); + if (accountPath) { + if (ssoConversationId) { + return frontendURL(`${accountPath}/conversations/${ssoConversationId}`); + } + return frontendURL(`${accountPath}/dashboard`); + } + return DEFAULT_REDIRECT_URL; +}; diff --git a/app/javascript/v3/helpers/CommonHelper.js b/app/javascript/v3/helpers/CommonHelper.js new file mode 100644 index 000000000..cdd913769 --- /dev/null +++ b/app/javascript/v3/helpers/CommonHelper.js @@ -0,0 +1,3 @@ +export const replaceRouteWithReload = url => { + window.location = url; +}; diff --git a/app/javascript/v3/helpers/RouteHelper.js b/app/javascript/v3/helpers/RouteHelper.js new file mode 100644 index 000000000..db508ac13 --- /dev/null +++ b/app/javascript/v3/helpers/RouteHelper.js @@ -0,0 +1,50 @@ +import { frontendURL } from 'dashboard/helper/URLHelper'; +import { clearBrowserSessionCookies } from 'dashboard/store/utils/api'; +import { hasAuthCookie } from './AuthHelper'; +import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals'; +import { replaceRouteWithReload } from './CommonHelper'; + +const validateSSOLoginParams = to => { + const isLoginRoute = to.name === 'login'; + const { email, sso_auth_token: ssoAuthToken } = to.query || {}; + const hasValidSSOParams = email && ssoAuthToken; + return isLoginRoute && hasValidSSOParams; +}; + +export const validateRouteAccess = (to, next, chatwootConfig = {}) => { + // Pages with ignoreSession:true would be rendered + // even if there is an active session + // Used for confirmation or password reset pages + if (to.meta && to.meta.ignoreSession) { + next(); + return; + } + + if (validateSSOLoginParams(to)) { + clearBrowserSessionCookies(); + next(); + return; + } + + // Redirect to dashboard if a cookie is present, the cookie + // cleanup and token validation happens in the application pack. + if (hasAuthCookie()) { + replaceRouteWithReload(DEFAULT_REDIRECT_URL); + return; + } + + // If the URL is an invalid path, redirect to login page + // Disable navigation to signup page if signups are disabled + // Signup route has an attribute (requireSignupEnabled) in it's definition + const isAnInalidSignupNavigation = + chatwootConfig.signupEnabled !== 'true' && + to.meta && + to.meta.requireSignupEnabled; + + if (!to.name || isAnInalidSignupNavigation) { + next(frontendURL('login')); + return; + } + + next(); +}; diff --git a/app/javascript/v3/helpers/specs/AuthHelper.spec.js b/app/javascript/v3/helpers/specs/AuthHelper.spec.js new file mode 100644 index 000000000..2bf3ba450 --- /dev/null +++ b/app/javascript/v3/helpers/specs/AuthHelper.spec.js @@ -0,0 +1,43 @@ +import { getLoginRedirectURL } from '../AuthHelper'; + +describe('#URL Helpers', () => { + describe('getLoginRedirectURL', () => { + it('should return correct Account URL if account id is present', () => { + expect( + getLoginRedirectURL({ + ssoAccountId: '7500', + user: { + accounts: [{ id: 7500, name: 'Test Account 7500' }], + }, + }) + ).toBe('/app/accounts/7500/dashboard'); + }); + + it('should return correct conversation URL if account id and conversationId is present', () => { + expect( + getLoginRedirectURL({ + ssoAccountId: '7500', + ssoConversationId: '752', + user: { + accounts: [{ id: 7500, name: 'Test Account 7500' }], + }, + }) + ).toBe('/app/accounts/7500/conversations/752'); + }); + + it('should return default URL if account id is not present', () => { + expect(getLoginRedirectURL({ ssoAccountId: '7500', user: {} })).toBe( + '/app/' + ); + expect( + getLoginRedirectURL({ + ssoAccountId: '7500', + user: { + accounts: [{ id: '7501', name: 'Test Account 7501' }], + }, + }) + ).toBe('/app/accounts/7501/dashboard'); + expect(getLoginRedirectURL('7500', null)).toBe('/app/'); + }); + }); +}); diff --git a/app/javascript/v3/helpers/specs/RouteHelper.spec.js b/app/javascript/v3/helpers/specs/RouteHelper.spec.js new file mode 100644 index 000000000..9c699a44f --- /dev/null +++ b/app/javascript/v3/helpers/specs/RouteHelper.spec.js @@ -0,0 +1,69 @@ +import { validateRouteAccess } from '../RouteHelper'; +import { clearBrowserSessionCookies } from 'dashboard/store/utils/api'; +import { replaceRouteWithReload } from '../CommonHelper'; +import Cookies from 'js-cookie'; + +const next = jest.fn(); +jest.mock('dashboard/store/utils/api', () => ({ + clearBrowserSessionCookies: jest.fn(), +})); +jest.mock('../CommonHelper', () => ({ replaceRouteWithReload: jest.fn() })); + +jest.mock('js-cookie', () => ({ + getJSON: jest.fn(), +})); + +Cookies.getJSON.mockReturnValueOnce(true).mockReturnValue(false); +describe('#validateRouteAccess', () => { + it('reset cookies and continues to the login page if the SSO parameters are present', () => { + validateRouteAccess( + { + name: 'login', + query: { sso_auth_token: 'random_token', email: 'random@email.com' }, + }, + next + ); + expect(clearBrowserSessionCookies).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('ignore session and continue to the page if the ignoreSession is present in route definition', () => { + validateRouteAccess( + { + name: 'login', + meta: { ignoreSession: true }, + }, + next + ); + expect(clearBrowserSessionCookies).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('redirects to dashboard if auth cookie is present', () => { + Cookies.getJSON.mockImplementation(() => true); + validateRouteAccess({ name: 'login' }, next); + expect(clearBrowserSessionCookies).not.toHaveBeenCalled(); + expect(replaceRouteWithReload).toHaveBeenCalledWith('/app/'); + expect(next).not.toHaveBeenCalled(); + }); + + it('redirects to login if route is empty', () => { + validateRouteAccess({}, next); + expect(clearBrowserSessionCookies).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith('/app/login'); + }); + + it('redirects to login if signup is disabled', () => { + validateRouteAccess({ meta: { requireSignupEnabled: true } }, next, { + signupEnabled: 'true', + }); + expect(clearBrowserSessionCookies).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith('/app/login'); + }); + + it('continues to the route in every other case', () => { + validateRouteAccess({ name: 'reset_password' }, next); + expect(clearBrowserSessionCookies).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + }); +}); diff --git a/app/javascript/v3/store/index.js b/app/javascript/v3/store/index.js new file mode 100644 index 000000000..7cc5cd6fa --- /dev/null +++ b/app/javascript/v3/store/index.js @@ -0,0 +1,10 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import globalConfig from 'shared/store/globalConfig'; + +Vue.use(Vuex); +export default new Vuex.Store({ + modules: { + globalConfig, + }, +}); diff --git a/app/javascript/dashboard/routes/auth/Confirmation.vue b/app/javascript/v3/views/auth/confirmation/Index.vue similarity index 60% rename from app/javascript/dashboard/routes/auth/Confirmation.vue rename to app/javascript/v3/views/auth/confirmation/Index.vue index 305e826f7..2949ac79b 100644 --- a/app/javascript/dashboard/routes/auth/Confirmation.vue +++ b/app/javascript/v3/views/auth/confirmation/Index.vue @@ -1,14 +1,16 @@ diff --git a/app/javascript/v3/views/auth/signup/Index.vue b/app/javascript/v3/views/auth/signup/Index.vue new file mode 100644 index 000000000..e1621db18 --- /dev/null +++ b/app/javascript/v3/views/auth/signup/Index.vue @@ -0,0 +1,87 @@ + + + diff --git a/app/javascript/dashboard/routes/auth/components/AuthSubmitButton.vue b/app/javascript/v3/views/auth/signup/components/AuthSubmitButton.vue similarity index 100% rename from app/javascript/dashboard/routes/auth/components/AuthSubmitButton.vue rename to app/javascript/v3/views/auth/signup/components/AuthSubmitButton.vue diff --git a/app/javascript/dashboard/routes/auth/components/Signup/Form.vue b/app/javascript/v3/views/auth/signup/components/Signup/Form.vue similarity index 50% rename from app/javascript/dashboard/routes/auth/components/Signup/Form.vue rename to app/javascript/v3/views/auth/signup/components/Signup/Form.vue index d222b47aa..898e26ddd 100644 --- a/app/javascript/dashboard/routes/auth/components/Signup/Form.vue +++ b/app/javascript/v3/views/auth/signup/components/Signup/Form.vue @@ -1,101 +1,103 @@ diff --git a/app/javascript/v3/views/auth/signup/components/Testimonials/Index.vue b/app/javascript/v3/views/auth/signup/components/Testimonials/Index.vue new file mode 100644 index 000000000..be15a8575 --- /dev/null +++ b/app/javascript/v3/views/auth/signup/components/Testimonials/Index.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/app/javascript/v3/views/auth/signup/components/Testimonials/TestimonialCard.vue b/app/javascript/v3/views/auth/signup/components/Testimonials/TestimonialCard.vue new file mode 100644 index 000000000..af8341379 --- /dev/null +++ b/app/javascript/v3/views/auth/signup/components/Testimonials/TestimonialCard.vue @@ -0,0 +1,40 @@ + + diff --git a/app/javascript/v3/views/index.js b/app/javascript/v3/views/index.js new file mode 100644 index 000000000..57da18116 --- /dev/null +++ b/app/javascript/v3/views/index.js @@ -0,0 +1,20 @@ +import VueRouter from 'vue-router'; + +import routes from './routes'; +import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper'; +import { validateRouteAccess } from '../helpers/RouteHelper'; + +export const router = new VueRouter({ mode: 'history', routes }); + +export const initalizeRouter = () => { + router.beforeEach((to, _, next) => { + AnalyticsHelper.page(to.name || '', { + path: to.path, + name: to.name, + }); + + return validateRouteAccess(to, next, window.chatwootConfig); + }); +}; + +export default router; diff --git a/app/javascript/v3/views/login/Index.vue b/app/javascript/v3/views/login/Index.vue new file mode 100644 index 000000000..29c442cf2 --- /dev/null +++ b/app/javascript/v3/views/login/Index.vue @@ -0,0 +1,201 @@ + + + diff --git a/app/javascript/v3/views/routes.js b/app/javascript/v3/views/routes.js new file mode 100644 index 000000000..6049530be --- /dev/null +++ b/app/javascript/v3/views/routes.js @@ -0,0 +1,56 @@ +import { frontendURL } from 'dashboard/helper/URLHelper'; + +const Login = () => import('./login/Index.vue'); +const Signup = () => import('./auth/signup/Index.vue'); +const ResetPassword = () => import('./auth/reset/password/Index.vue'); +const Confirmation = () => import('./auth/confirmation/Index.vue'); +const PasswordEdit = () => import('./auth/password/Edit.vue'); + +export default [ + { + path: frontendURL('login'), + name: 'login', + component: Login, + props: route => ({ + config: route.query.config, + email: route.query.email, + ssoAuthToken: route.query.sso_auth_token, + ssoAccountId: route.query.sso_account_id, + ssoConversationId: route.query.sso_conversation_id, + authError: route.query.error, + }), + }, + { + path: frontendURL('auth/signup'), + name: 'auth_signup', + component: Signup, + meta: { requireSignupEnabled: true }, + }, + { + path: frontendURL('auth/confirmation'), + name: 'auth_confirmation', + component: Confirmation, + meta: { ignoreSession: true }, + props: route => ({ + config: route.query.config, + confirmationToken: route.query.confirmation_token, + redirectUrl: route.query.route_url, + }), + }, + { + path: frontendURL('auth/password/edit'), + name: 'auth_password_edit', + component: PasswordEdit, + meta: { ignoreSession: true }, + props: route => ({ + config: route.query.config, + resetPasswordToken: route.query.reset_password_token, + redirectUrl: route.query.route_url, + }), + }, + { + path: frontendURL('auth/reset/password'), + name: 'auth_reset_password', + component: ResetPassword, + }, +]; diff --git a/app/views/layouts/vueapp.html.erb b/app/views/layouts/vueapp.html.erb index 32ab67e80..c7d1120f6 100644 --- a/app/views/layouts/vueapp.html.erb +++ b/app/views/layouts/vueapp.html.erb @@ -60,8 +60,8 @@ } <% end %> - <%= javascript_pack_tag 'application' %> - <%= stylesheet_pack_tag 'application' %> + <%= javascript_pack_tag @application_pack %> + <%= stylesheet_pack_tag @application_pack %>
diff --git a/config/installation_config.yml b/config/installation_config.yml index 7460814b2..c1bbb299b 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -73,4 +73,4 @@ - name: CSML_EDITOR_HOST value: - name: LOGO_DARK - value: '/brand-assets/logo-dark.svg' + value: '/brand-assets/logo_dark.svg' diff --git a/package.json b/package.json index b4fcf9e73..63c61d01f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "eslint": "eslint app/**/*.{js,vue}", "eslint:fix": "eslint app/**/*.{js,vue} --fix", "pretest": "rimraf .jest-cache", - "test": "jest -w 1 --no-cache", + "test": "jest -w 1 --no-cache", "test:watch": "jest -w 1 --watch --no-cache", "test:coverage": "jest -w 1 --no-cache --collectCoverage", "webpacker-start": "webpack-dev-server -d --config webpack.dev.config.js --content-base public/ --progress --colors", diff --git a/public/brand-assets/logo_dark.svg b/public/brand-assets/logo-dark.svg similarity index 100% rename from public/brand-assets/logo_dark.svg rename to public/brand-assets/logo-dark.svg diff --git a/tailwind.config.js b/tailwind.config.js index e83e92d90..50dfaacb2 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -2,6 +2,7 @@ module.exports = { darkMode: 'class', content: [ './app/javascript/widget/**/*.vue', + './app/javascript/v3/**/*.vue', './app/javascript/portal/**/*.vue', './app/javascript/shared/**/*.vue', './app/javascript/survey/**/*.vue',