diff --git a/.env.example b/.env.example index 642a81fa9..f405ea03d 100644 --- a/.env.example +++ b/.env.example @@ -222,20 +222,10 @@ STRIPE_WEBHOOK_SECRET= # Make sure to follow https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration on the cloud storage after setting this to true. DIRECT_UPLOADS_ENABLED= -# MS OAUTH creds +#MS OAUTH creds AZURE_APP_ID= AZURE_APP_SECRET= -## MS Azure Tenant ID -# Set the following id to the id of your Azure 'tenant'. -# This will enable single tenant applications to work. -# If the following id is set, Chatwoot will use the Microsoft Graph API -# to send and receive emails, as that seems to be required for single -# tenant applications. -# -# https://learn.microsoft.com/en-us/azure/active-directory/fundamentals/active-directory-how-to-find-tenant -AZURE_TENANT_ID= - ## Advanced configurations ## Change these values to fine tune performance # control the concurrency setting of sidekiq diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb index 7bdd88aa2..bee47b213 100644 --- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -4,7 +4,13 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts def create email = params[:authorization][:email] - redirect_url = microsoft_client.auth_code.authorize_url(auth_params) + redirect_url = microsoft_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/microsoft/callback", + scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile', + prompt: 'consent' + } + ) if redirect_url email = email.downcase ::Redis::Alfred.setex(email, Current.account.id, 5.minutes) @@ -19,31 +25,4 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts def check_authorization raise Pundit::NotAuthorizedError unless Current.account_user.administrator? end - - # SMTP, Pop and IMAP are being deprecated by Outlook. - # https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online - # - # As such, Microsoft has made it a real pain to use them. - # If AZURE_TENANT_ID is set, we will use the MS Graph API instead. - def auth_params - return graph_auth_params if ENV.fetch('AZURE_TENANT_ID', false) - - standard_auth_params - end - - def standard_auth_params - { - redirect_uri: "#{base_url}/microsoft/callback", - scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile', - prompt: 'consent' - } - end - - def graph_auth_params - { - redirect_uri: "#{base_url}/microsoft/callback", - scope: 'offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/Mail.Send openid profile', - prompt: 'consent' - } - end end diff --git a/app/controllers/concerns/microsoft_concern.rb b/app/controllers/concerns/microsoft_concern.rb index d1bcb49a6..3aa3e4e81 100644 --- a/app/controllers/concerns/microsoft_concern.rb +++ b/app/controllers/concerns/microsoft_concern.rb @@ -5,8 +5,8 @@ module MicrosoftConcern ::OAuth2::Client.new(ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil), { site: 'https://login.microsoftonline.com', - authorize_url: "https://login.microsoftonline.com/#{azure_tenant_id}/oauth2/v2.0/authorize", - token_url: "https://login.microsoftonline.com/#{azure_tenant_id}/oauth2/v2.0/token" + authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + token_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token' }) end @@ -19,8 +19,4 @@ module MicrosoftConcern def base_url ENV.fetch('FRONTEND_URL', 'http://localhost:3000') end - - def azure_tenant_id - MicrosoftGraphAuth.azure_tenant_id - end end 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/constants/globals.js b/app/javascript/dashboard/constants/globals.js index 1074810ad..2ef50e732 100644 --- a/app/javascript/dashboard/constants/globals.js +++ b/app/javascript/dashboard/constants/globals.js @@ -39,5 +39,7 @@ export default { UNTIL_NEXT_MONTH: 'until_next_month', UNTIL_CUSTOM_TIME: 'until_custom_time', }, + EXAMPLE_URL: 'https://example.com', + EXAMPLE_WEBHOOK_URL: 'https://example/api/webhook', }; export const DEFAULT_REDIRECT_URL = '/app/'; 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/snoozeHelpers.js b/app/javascript/dashboard/helper/snoozeHelpers.js index 07cb3a20f..60954364b 100644 --- a/app/javascript/dashboard/helper/snoozeHelpers.js +++ b/app/javascript/dashboard/helper/snoozeHelpers.js @@ -8,6 +8,8 @@ import { isMonday, isToday, setHours, + setMinutes, + setSeconds, } from 'date-fns'; import wootConstants from 'dashboard/constants/globals'; @@ -36,7 +38,7 @@ export const findNextDay = currentDate => { }; export const setHoursToNine = date => { - return setHours(date, 9, 0, 0); + return setSeconds(setMinutes(setHours(date, 9), 0), 0); }; export const findSnoozeTime = (snoozeType, currentDate = new Date()) => { 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/helper/specs/snoozeHelpers.spec.js b/app/javascript/dashboard/helper/specs/snoozeHelpers.spec.js index 6da35d02c..31c07ae65 100644 --- a/app/javascript/dashboard/helper/specs/snoozeHelpers.spec.js +++ b/app/javascript/dashboard/helper/specs/snoozeHelpers.spec.js @@ -40,6 +40,11 @@ describe('#Snooze Helpers', () => { nextDay.setHours(9, 0, 0, 0); expect(setHoursToNine(nextDay)).toEqual(nextDay); }); + it('should return date with 9.00AM time if date with 10am is passes', () => { + const nextDay = new Date('06/17/2023 10:00:00'); + nextDay.setHours(9, 0, 0, 0); + expect(setHoursToNine(nextDay)).toEqual(nextDay); + }); }); describe('findSnoozeTime', () => { diff --git a/app/javascript/dashboard/i18n/locale/en/cannedMgmt.json b/app/javascript/dashboard/i18n/locale/en/cannedMgmt.json index e3391967d..0cd52986e 100644 --- a/app/javascript/dashboard/i18n/locale/en/cannedMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/cannedMgmt.json @@ -9,11 +9,7 @@ "404": "There are no canned responses available in this account.", "TITLE": "Manage canned responses", "DESC": "Canned Responses are predefined reply templates which can be used to quickly send out replies to tickets.", - "TABLE_HEADER": [ - "Short Code", - "Content", - "Actions" - ] + "TABLE_HEADER": ["Short Code", "Content", "Actions"] }, "ADD": { "TITLE": "Add Canned Response", @@ -34,7 +30,7 @@ }, "API": { "SUCCESS_MESSAGE": "Canned Response added successfully", - "ERROR_MESSAGE": "Could not create canned response, Please try again later" + "ERROR_MESSAGE": "Could not create canned response. Please try again later." } }, "EDIT": { @@ -56,14 +52,14 @@ "BUTTON_TEXT": "Edit", "API": { "SUCCESS_MESSAGE": "Canned Response updated successfully", - "ERROR_MESSAGE": "Could not update canned response, Please try again later" + "ERROR_MESSAGE": "Could not update canned response. Please try again later." } }, "DELETE": { "BUTTON_TEXT": "Delete", "API": { "SUCCESS_MESSAGE": "Canned response deleted successfully", - "ERROR_MESSAGE": "Could not delete canned response, Please try again later" + "ERROR_MESSAGE": "Could not delete canned response. Please try again later." }, "CONFIRM": { "TITLE": "Confirm Deletion", diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index b96b76fb0..2ed17e3bf 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -78,7 +78,7 @@ "BUTTON_LABEL": "Export", "TITLE": "Export Contacts", "DESC": "Export contacts to a CSV file.", - "SUCCESS_MESSAGE": "Export is in progress, You will be notified via email when export file is ready to dowanlod.", + "SUCCESS_MESSAGE": "Export is in progress. You will be notified on email when the export file is ready to download.", "ERROR_MESSAGE": "There was an error, please try again" }, "DELETE_NOTE": { 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/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index 55c406fa9..65e80ad64 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -179,7 +179,8 @@ } }, "ADD": { - "CREATE_FLOW": [{ + "CREATE_FLOW": [ + { "title": "Help center information", "route": "new_portal_information", "body": "Basic information about portal", @@ -235,13 +236,13 @@ "DOMAIN": { "LABEL": "Custom Domain", "PLACEHOLDER": "Portal custom domain", - "HELP_TEXT": "Add only If you want to use a custom domain for your portals. Eg: https://example.com", + "HELP_TEXT": "Add only If you want to use a custom domain for your portals. Eg: %{exampleURL}", "ERROR": "Enter a valid domain URL" }, "HOME_PAGE_LINK": { "LABEL": "Home Page Link", "PLACEHOLDER": "Portal home page link", - "HELP_TEXT": "The link used to return from the portal to the home page. Eg: https://example.com", + "HELP_TEXT": "The link used to return from the portal to the home page. Eg: %{exampleURL}", "ERROR": "Enter a valid home page URL" }, "THEME_COLOR": { diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 3821fe6e8..e3104bd4b 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -21,7 +21,7 @@ }, "END_POINT": { "LABEL": "Webhook URL", - "PLACEHOLDER": "Example: https://example/api/webhook", + "PLACEHOLDER": "Example: %{webhookExampleURL}", "ERROR": "Please enter a valid URL" }, "EDIT_SUBMIT": "Update webhook", 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/dashboard/helpcenter/components/PortalSettingsBasicForm.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/components/PortalSettingsBasicForm.vue index dbd662b1d..255e1acc5 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/components/PortalSettingsBasicForm.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/components/PortalSettingsBasicForm.vue @@ -61,7 +61,7 @@ :class="{ error: $v.domain.$error }" :label="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.LABEL')" :placeholder="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.PLACEHOLDER')" - :help-text="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.HELP_TEXT')" + :help-text="domainExampleHelpText" :error="domainError" @blur="$v.domain.$touch" /> @@ -86,6 +86,9 @@ import { isDomain } from 'shared/helpers/Validators'; import thumbnail from 'dashboard/components/widgets/Thumbnail'; import { convertToCategorySlug } from 'dashboard/helper/commons.js'; import { buildPortalURL } from 'dashboard/helper/portalHelper'; +import wootConstants from 'dashboard/constants/globals'; + +const { EXAMPLE_URL } = wootConstants; export default { components: { @@ -147,6 +150,11 @@ export default { domainHelpText() { return buildPortalURL(this.slug); }, + domainExampleHelpText() { + return this.$t('HELP_CENTER.PORTAL.ADD.DOMAIN.HELP_TEXT', { + exampleURL: EXAMPLE_URL, + }); + }, }, mounted() { const portal = this.portal || {}; diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/components/PortalSettingsCustomizationForm.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/components/PortalSettingsCustomizationForm.vue index 4d72fa791..ba055a419 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/components/PortalSettingsCustomizationForm.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/components/PortalSettingsCustomizationForm.vue @@ -41,7 +41,7 @@ :placeholder=" $t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.PLACEHOLDER') " - :help-text="$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.HELP_TEXT')" + :help-text="homepageExampleHelpText" :error=" $v.homePageLink.$error ? $t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.ERROR') @@ -74,6 +74,9 @@ import { url } from 'vuelidate/lib/validators'; import { getRandomColor } from 'dashboard/helper/labelColor'; import alertMixin from 'shared/mixins/alertMixin'; +import wootConstants from 'dashboard/constants/globals'; + +const { EXAMPLE_URL } = wootConstants; export default { components: {}, @@ -102,6 +105,13 @@ export default { url, }, }, + computed: { + homepageExampleHelpText() { + return this.$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.HELP_TEXT', { + exampleURL: EXAMPLE_URL, + }); + }, + }, mounted() { this.color = getRandomColor(); this.updateDataFromStore(); diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue index 0463dd07b..1bb111d84 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue @@ -7,9 +7,7 @@ v-model.trim="url" type="text" name="url" - :placeholder=" - $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER') - " + :placeholder="webhookURLInputPlaceholder" @input="$v.url.$touch" /> @@ -53,6 +51,9 @@ - - 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/jobs/inboxes/fetch_imap_email_inboxes_job.rb b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb index c40abeabe..b360940e3 100644 --- a/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb +++ b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb @@ -2,19 +2,8 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob queue_as :scheduled_jobs def perform - # check imap_enabled for channel Inbox.where(channel_type: 'Channel::Email').all.find_each(batch_size: 100) do |inbox| - next unless inbox.channel.imap_enabled? - - fetch_emails(inbox) - end - end - - def fetch_emails(inbox) - if inbox.channel.microsoft? && ENV.fetch('AZURE_TENANT_ID', false) - ::Inboxes::FetchMsGraphEmailForTenantJob.perform_later(inbox.channel) - else - ::Inboxes::FetchImapEmailsJob.perform_later(inbox.channel) + ::Inboxes::FetchImapEmailsJob.perform_later(inbox.channel) if inbox.channel.imap_enabled end end end diff --git a/app/jobs/inboxes/fetch_ms_graph_email_for_tenant_job.rb b/app/jobs/inboxes/fetch_ms_graph_email_for_tenant_job.rb deleted file mode 100644 index 7efd1784e..000000000 --- a/app/jobs/inboxes/fetch_ms_graph_email_for_tenant_job.rb +++ /dev/null @@ -1,101 +0,0 @@ -require 'net/http' - -class Inboxes::FetchMsGraphEmailForTenantJob < ApplicationJob - queue_as :scheduled_jobs - - def perform(channel) - process_email_for_channel(channel) - rescue EOFError => e - Rails.logger.error e - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: channel.account).capture_exception - end - - private - - def should_fetch_email?(channel) - channel.imap_enabled? && channel.microsoft? && !channel.reauthorization_required? - end - - def process_email_for_channel(channel) - # fetching email for microsoft provider - fetch_mail_for_channel(channel) - - # clearing old failures like timeouts since the mail is now successfully processed - channel.reauthorized! - end - - def fetch_mail_for_channel(channel) - return if channel.provider_config['access_token'].blank? - - access_token = valid_access_token channel - - return unless access_token - - graph = graph_authenticate(access_token) - - process_mails(graph, channel) - end - - def process_mails(graph, channel) - response = graph.get_from_api('me/messages', {}, graph_query) - - unless response.is_a?(Net::HTTPSuccess) - channel.authorization_error! - return false - end - - json_response = JSON.parse(response.body) - json_response['value'].each do |message| - inbound_mail = Mail.read_from_string retrieve_mail_mime(graph, message['id']) - - next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present? - - process_mail(inbound_mail, channel) - end - end - - def retrieve_mail_mime(graph, id) - response = graph.get_from_api("me/messages/#{id}/$value") - return unless response.is_a?(Net::HTTPSuccess) - - response.body - end - - def graph_authenticate(access_token) - MicrosoftGraphApi.new(access_token) - end - - def yesterday - (Time.zone.today - 1).strftime('%FT%TZ') - end - - def tomorrow - (Time.zone.today + 1).strftime('%FT%TZ') - end - - # Query to replicate the IMAP search used in Inboxes::FetchImapEmailsJob - # Selects the top 1000 records within the given filter, as that is the maximum - # page size for the API. Might need to look into paginating the requests later, - # for inboxes that receive more than 1000 emails a day? - # - # 1. https://learn.microsoft.com/en-us/graph/api/user-list-messages - # 2. https://learn.microsoft.com/en-us/graph/query-parameters - def graph_query - { - '$filter': "receivedDateTime ge #{yesterday} and receivedDateTime le #{tomorrow}", - '$top': '1000', '$select': 'id' - } - end - - def process_mail(inbound_mail, channel) - Imap::ImapMailbox.new.process(inbound_mail, channel) - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: channel.account).capture_exception - end - - # Making sure the access token is valid for microsoft provider - def valid_access_token(channel) - Microsoft::RefreshOauthTokenService.new(channel: channel).access_token - end -end diff --git a/app/mailers/conversation_reply_mailer_helper.rb b/app/mailers/conversation_reply_mailer_helper.rb index ad45b64d6..346f4c9ca 100644 --- a/app/mailers/conversation_reply_mailer_helper.rb +++ b/app/mailers/conversation_reply_mailer_helper.rb @@ -23,7 +23,6 @@ module ConversationReplyMailerHelper def ms_smtp_settings return unless @inbox.email? && @channel.imap_enabled && @inbox.channel.provider == 'microsoft' - return ms_graph_settings if ENV.fetch('AZURE_TENANT_ID', false) smtp_settings = { address: 'smtp.office365.com', @@ -41,15 +40,6 @@ module ConversationReplyMailerHelper @options[:delivery_method_options] = smtp_settings end - def ms_graph_settings - graph_settings = { - token: @channel.provider_config['access_token'] - } - - @options[:delivery_method] = :microsoft_graph - @options[:delivery_method_options] = graph_settings - end - def set_delivery_method return unless @inbox.inbox_type == 'Email' && @channel.smtp_enabled 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/initializers/delivery_methods.rb b/config/initializers/delivery_methods.rb deleted file mode 100644 index 2c5bcd777..000000000 --- a/config/initializers/delivery_methods.rb +++ /dev/null @@ -1,3 +0,0 @@ -require 'microsoft_graph_delivery_method' - -ActionMailer::Base.add_delivery_method :microsoft_graph, MicrosoftGraphDeliveryMethod 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/lib/microsoft_graph_api.rb b/lib/microsoft_graph_api.rb deleted file mode 100644 index 47d05e964..000000000 --- a/lib/microsoft_graph_api.rb +++ /dev/null @@ -1,62 +0,0 @@ -# Simple HTTPS API helper class for interacting with MS Graph. -# Uses the standard ruby HTTP library for interacting with the API. - -require 'uri' -require 'net/http' - -class MicrosoftGraphApi - API_VERSION = 'v1.0'.freeze - API_PORT = 443 - API_URL = "https://graph.microsoft.com/#{API_VERSION}".freeze - - def initialize(token) - @token = token - end - - # Simple get request to the endpoint - # - # 'queries' are the get variables after the main url - # eg. foo/bar?query=myquery - def get_from_api(endpoint, headers = {}, query = {}) - uri = endpoint_to_uri(endpoint, query) - https = setup_https(uri.host) - request = Net::HTTP::Get.new(uri.request_uri) - - # Assign each header to the request - headers.each { |key, value| request[key.to_s] = value.to_s } - request['Authorization'] = "Bearer #{@token}" - - https.request(request) - end - - # Simple post request to the endpoint - def post_to_api(endpoint, headers = {}, body = '') - uri = endpoint_to_uri(endpoint) - https = setup_https(uri.host) - request = Net::HTTP::Post.new(uri.path) - - # Assign each header to the request - headers.each { |key, value| request[key.to_s] = value.to_s } - request['Authorization'] = "Bearer #{@token}" - - request.body = body - https.request(request) - end - - private - - def setup_https(host) - https = Net::HTTP.new(host, API_PORT) - https.use_ssl = true - https - end - - def endpoint_to_uri(endpoint, query = {}) - endpoint.delete_prefix('/') - uri = URI("#{API_URL}/#{endpoint}") - return uri if query.empty? - - uri.query = URI.encode_www_form(query) - uri - end -end diff --git a/lib/microsoft_graph_auth.rb b/lib/microsoft_graph_auth.rb index 8c6016aeb..a1c0bbea0 100644 --- a/lib/microsoft_graph_auth.rb +++ b/lib/microsoft_graph_auth.rb @@ -9,18 +9,6 @@ require 'omniauth-oauth2' # Implements an OmniAuth strategy to get a Microsoft Graph # compatible token from Azure AD class MicrosoftGraphAuth < OmniAuth::Strategies::OAuth2 - # Microsoft Azure Tenant - # For single tenant applications, meant to be used by - # organisations for their own apps, the 'common' endpoint is not allowed. - # If the environment variable 'AZURE_TENANT_ID' is set, - # this will return it's value, otherwise, it will default to 'common'. - # - # The tenant id for your Azure organization can be obtained by - # by accessing 'Tenant properties' from the Azure portal. - def self.azure_tenant_id - ENV.fetch('AZURE_TENANT_ID', 'common') - end - option :name, :microsoft_graph_auth DEFAULT_SCOPE = 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send' @@ -28,8 +16,8 @@ class MicrosoftGraphAuth < OmniAuth::Strategies::OAuth2 # Configure the Microsoft identity platform endpoints option :client_options, site: 'https://login.microsoftonline.com', - authorize_url: "/#{azure_tenant_id}/oauth2/v2.0/authorize", - token_url: "/#{azure_tenant_id}/oauth2/v2.0/token" + authorize_url: '/common/oauth2/v2.0/authorize', + token_url: '/common/oauth2/v2.0/token' option :pcke, true # Send the scope parameter during authorize diff --git a/lib/microsoft_graph_delivery_method.rb b/lib/microsoft_graph_delivery_method.rb deleted file mode 100644 index 7d03495a3..000000000 --- a/lib/microsoft_graph_delivery_method.rb +++ /dev/null @@ -1,26 +0,0 @@ -# Recently (around Feb/Mar 2023), Microsoft has made sending -# email through SMTP with Outlook near impossible, at least -# for single tenant applications. -# -# As such, adding a delivery method to use the Microsoft Graph -# API allows for emails to be sent again. -require 'base64' - -class MicrosoftGraphDeliveryMethod - def initialize(config) - @config = config - end - - def deliver!(mail) - # Create a new API connection, and post the mail to the `me/sendMail` endpoint. - # https://learn.microsoft.com/en-us/graph/api/user-sendmail#example-4-send-a-new-message-using-mime-format - - headers = { - 'Content-Type' => 'text/plain' - } - body = Base64.encode64(mail.to_s) - - graph = MicrosoftGraphApi.new(@config[:token]) - graph.post_to_api('me/sendMail', headers, body) - end -end 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/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb index 91fb060e0..853cf2850 100644 --- a/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb @@ -43,29 +43,6 @@ RSpec.describe 'Microsoft Authorization API', type: :request do expect(response.parsed_body['url']).to eq response_url expect(Redis::Alfred.get(administrator.email)).to eq(account.id.to_s) end - - it 'creates a new authorization and returns the redirect url for single tenant' do - with_modified_env AZURE_TENANT_ID: 'azure_tenant_id' do - post "/api/v1/accounts/#{account.id}/microsoft/authorization", - headers: administrator.create_new_auth_token, - params: { email: administrator.email }, - as: :json - - microsoft_service = Class.new { extend MicrosoftConcern } - - response_url = microsoft_service.microsoft_client.auth_code.authorize_url( - { - redirect_uri: "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback", - scope: 'offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/Mail.Send openid profile', - prompt: 'consent' - } - ) - expect(response.parsed_body['url']).to eq response_url - end - - expect(response).to have_http_status(:success) - expect(Redis::Alfred.get(administrator.email)).to eq(account.id.to_s) - end end end end diff --git a/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb b/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb index 57ee00bef..ae4f540d4 100644 --- a/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb +++ b/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb @@ -7,12 +7,6 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do imap_password: 'password', account: account) end let(:email_inbox) { create(:inbox, channel: imap_email_channel, account: account) } - let(:microsoft_imap_email_channel) do - create(:channel_email, provider: 'microsoft', imap_enabled: true, imap_address: 'outlook.office365.com', - imap_port: 993, imap_login: 'imap@outlook.com', imap_password: 'password', account: account, - provider_config: { access_token: 'access_token' }) - end - let(:ms_email_inbox) { create(:inbox, channel: microsoft_imap_email_channel, account: account) } it 'enqueues the job' do expect { described_class.perform_later }.to have_enqueued_job(described_class) @@ -26,24 +20,4 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do described_class.perform_now end end - - context 'when microsoft inbox' do - it 'calls fetch ms graph email job for single tenant app' do - stub_request(:get, 'https://graph.microsoft.com/v1.0/me/messages?$filter=receivedDateTime%20ge%202023-05-23T00:00:00Z%20and%20receivedDateTime%20le%202023-05-25T00:00:00Z&$select=id&$top=1000') - - with_modified_env AZURE_TENANT_ID: 'azure_tenant_id' do - expect(Inboxes::FetchMsGraphEmailForTenantJob).to receive(:perform_later).with(microsoft_imap_email_channel).once - - described_class.perform_now - end - end - - it 'calls fetch imap email job for multi tenant app' do - with_modified_env AZURE_TENANT_ID: nil do - expect(Inboxes::FetchImapEmailsJob).to receive(:perform_later).with(microsoft_imap_email_channel).once - - described_class.perform_now - end - end - end end diff --git a/spec/jobs/inboxes/fetch_ms_graph_email_for_tenant_job_spec.rb b/spec/jobs/inboxes/fetch_ms_graph_email_for_tenant_job_spec.rb deleted file mode 100644 index 79160a800..000000000 --- a/spec/jobs/inboxes/fetch_ms_graph_email_for_tenant_job_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -require 'rails_helper' - -RSpec.describe Inboxes::FetchMsGraphEmailForTenantJob do - include ActionMailbox::TestHelper - - let(:account) { create(:account) } - let(:microsoft_imap_email_channel) do - create(:channel_email, provider: 'microsoft', imap_enabled: true, imap_address: 'outlook.office365.com', - imap_port: 993, imap_login: 'imap@outlook.com', imap_password: 'password', account: account, - provider_config: { access_token: 'access_token' }) - end - let(:ms_email_inbox) { create(:inbox, channel: microsoft_imap_email_channel, account: account) } - let(:inbound_mail) { create_inbound_email_from_mail(from: 'testemail@gmail.com', to: 'imap@outlook.com', subject: 'Hello!') } - let(:yesterday) { (Time.zone.today - 1).strftime('%FT%TZ') } - let(:tomorrow) { (Time.zone.today + 1).strftime('%FT%TZ') } - - it 'enqueues the job' do - expect { described_class.perform_later }.to have_enqueued_job(described_class) - .on_queue('scheduled_jobs') - end - - context 'when imap fetch new emails for microsoft mailer' do - before do - stub_request(:get, "https://graph.microsoft.com/v1.0/me/messages?$filter=receivedDateTime%20ge%20#{yesterday}%20and%20receivedDateTime%20le%20#{tomorrow}&$select=id&$top=1000") - .with( - headers: { - 'Accept' => '*/*', - 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', - 'Authorization' => 'Bearer access_token', - 'User-Agent' => 'Ruby' - } - ) - .to_return(status: 200, body: '{"value":[{"id":"1"}]}', headers: {}) - - stub_request(:get, 'https://graph.microsoft.com/v1.0/me/messages/1/$value') - .with( - headers: { - 'Accept' => '*/*', - 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', - 'Authorization' => 'Bearer access_token', - 'User-Agent' => 'Ruby' - } - ) - .to_return(status: 200, body: '', headers: {}) - end - - it 'fetch and process all emails' do - ms_imap_email_inbox = double - - with_modified_env AZURE_TENANT_ID: 'azure_tenant_id' do - email = Mail.new do - to 'test@outlook.com' - from 'test@gmail.com' - subject :test.to_s - body 'hello' - end - imap_fetch_mail = Net::IMAP::FetchData.new - imap_fetch_mail.attr = { RFC822: email }.with_indifferent_access - - allow(Mail).to receive(:read_from_string).and_return(inbound_mail) - allow(Imap::ImapMailbox).to receive(:new).and_return(ms_imap_email_inbox) - expect(ms_imap_email_inbox).to receive(:process).with(inbound_mail, microsoft_imap_email_channel).once - - described_class.perform_now(microsoft_imap_email_channel) - end - end - end -end diff --git a/spec/lib/microsoft_graph_api_spec.rb b/spec/lib/microsoft_graph_api_spec.rb deleted file mode 100644 index f16291c5e..000000000 --- a/spec/lib/microsoft_graph_api_spec.rb +++ /dev/null @@ -1,61 +0,0 @@ -require 'rails_helper' -# explicitly requiring since we are loading apms conditionally in application.rb -require 'sentry-ruby' - -describe MicrosoftGraphApi do - let(:yesterday) { (Time.zone.today - 1).strftime('%FT%TZ') } - let(:tomorrow) { (Time.zone.today + 1).strftime('%FT%TZ') } - - describe '#get_from_api' do - before do - stub_request(:get, "https://graph.microsoft.com/v1.0/me/messages?$filter=receivedDateTime%20ge%20#{yesterday}%20and%20receivedDateTime%20le%20#{tomorrow}&$select=id&$top=1000") - .with( - headers: { - 'Accept' => '*/*', - 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', - 'Authorization' => 'Bearer access_token', - 'User-Agent' => 'Ruby' - } - ) - .to_return(status: 200, body: '{"value":[{"id":"1"}]}', headers: {}) - - stub_request(:get, 'https://graph.microsoft.com/v1.0/me/messages/1/$value') - .with( - headers: { - 'Accept' => '*/*', - 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', - 'Authorization' => 'Bearer access_token', - 'User-Agent' => 'Ruby' - } - ) - .to_return(status: 200, body: '', headers: {}) - - stub_request(:post, 'https://graph.microsoft.com/v1.0/me/sendMail') - .with( - body: 'email body', - headers: { - 'Accept' => '*/*', - 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', - 'Authorization' => 'Bearer access_token', - 'User-Agent' => 'Ruby' - } - ) - .to_return(status: 200, body: 'email body', headers: { 'Content-Type' => 'text/plain' }) - end - - it 'fetch emails' do - graph_query = { :$filter => "receivedDateTime ge #{yesterday} and receivedDateTime le #{tomorrow}", :$top => '1000', :$select => 'id' } - response = described_class.new('access_token').get_from_api('me/messages', {}, graph_query) - - json_response = JSON.parse(response.body) - expect(json_response['value'][0]['id']).to eq '1' - end - - it 'post emails' do - response = described_class.new('access_token').post_to_api('me/sendMail', {}, 'email body') - - expect(response.is_a?(Net::HTTPSuccess)).to be true - expect(response.body).to eq('email body') - end - end -end 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',