diff --git a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb index 7fe31889b..5e894a005 100644 --- a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb @@ -1,7 +1,7 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController include Shopify::IntegrationHelper before_action :setup_shopify_context, only: [:orders] - before_action :fetch_hook, except: [:auth] + before_action :fetch_hook, except: [:auth, :complete_install] before_action :validate_contact, only: [:orders] def auth @@ -31,6 +31,24 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba render json: { error: e.message }, status: :unprocessable_entity end + def complete_install + pending_data = ::Redis::Alfred.get("shopify_pending_install:#{params[:pending_install_token]}") + return render json: { error: 'Invalid or expired install token' }, status: :unprocessable_entity if pending_data.blank? + + data = JSON.parse(pending_data) + + Current.account.hooks.create!( + app_id: 'shopify', + access_token: data['access_token'], + status: 'enabled', + reference_id: data['shop'], + settings: { scope: data['scope'] } + ) + + ::Redis::Alfred.delete("shopify_pending_install:#{params[:pending_install_token]}") + head :ok + end + def destroy @hook.destroy! head :ok diff --git a/app/controllers/shopify/callbacks_controller.rb b/app/controllers/shopify/callbacks_controller.rb index 7fb8b5a47..eb6d1da42 100644 --- a/app/controllers/shopify/callbacks_controller.rb +++ b/app/controllers/shopify/callbacks_controller.rb @@ -2,38 +2,50 @@ class Shopify::CallbacksController < ApplicationController include Shopify::IntegrationHelper def show - verify_account! - - @response = oauth_client.auth_code.get_token( - params[:code], - redirect_uri: '/shopify/callback' - ) - - handle_response + if chatwoot_initiated? + handle_chatwoot_initiated_flow + else + handle_shopify_initiated_flow + end rescue StandardError => e Rails.logger.error("Shopify callback error: #{e.message}") - redirect_to "#{redirect_uri}?error=true" + redirect_to error_redirect_url end private - def verify_account! - @account_id = verify_shopify_token(params[:state]) - raise StandardError, 'Invalid state parameter' if account.blank? + def chatwoot_initiated? + verify_shopify_token(params[:state]).present? end - def handle_response + def handle_chatwoot_initiated_flow + @account_id = verify_shopify_token(params[:state]) + raise StandardError, 'Invalid state parameter' if account.blank? + + @response = oauth_client.auth_code.get_token(params[:code], redirect_uri: redirect_callback_uri) + create_hook + redirect_to shopify_integration_url + end + + def handle_shopify_initiated_flow + @response = oauth_client.auth_code.get_token(params[:code], redirect_uri: redirect_callback_uri) + + token_key = SecureRandom.hex(16) + pending_data = { access_token: parsed_body['access_token'], shop: params[:shop], scope: parsed_body['scope'] }.to_json + ::Redis::Alfred.setex("shopify_pending_install:#{token_key}", pending_data, 10.minutes) + + redirect_url = "settings/integrations/shopify?shopify_pending_install=#{token_key}" + redirect_to "#{frontend_url}/app/login?redirect_url=#{CGI.escape(redirect_url)}", allow_other_host: true + end + + def create_hook account.hooks.create!( app_id: 'shopify', access_token: parsed_body['access_token'], status: 'enabled', reference_id: params[:shop], - settings: { - scope: parsed_body['scope'] - } + settings: { scope: parsed_body['scope'] } ) - - redirect_to shopify_integration_url end def parsed_body @@ -56,17 +68,23 @@ class Shopify::CallbacksController < ApplicationController @account ||= Account.find(@account_id) end - def account_id - @account_id ||= params[:state].split('_').first + def redirect_callback_uri + "#{frontend_url}/shopify/callback" end def shopify_integration_url - "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify" + "#{frontend_url}/app/accounts/#{account.id}/settings/integrations/shopify" end - def redirect_uri - return shopify_integration_url if account + def error_redirect_url + if @account_id && account + "#{shopify_integration_url}?error=true" + else + "#{frontend_url}/app/login" + end + end - ENV.fetch('FRONTEND_URL', nil) + def frontend_url + ENV.fetch('FRONTEND_URL', '') end end diff --git a/app/javascript/dashboard/api/integrations/shopify.js b/app/javascript/dashboard/api/integrations/shopify.js index 0b6ce8ec1..e2bb4f7ee 100644 --- a/app/javascript/dashboard/api/integrations/shopify.js +++ b/app/javascript/dashboard/api/integrations/shopify.js @@ -12,6 +12,12 @@ class ShopifyAPI extends ApiClient { params: { contact_id: contactId }, }); } + + completeInstall(pendingInstallToken) { + return axios.post(`${this.url}/complete_install`, { + pending_install_token: pendingInstallToken, + }); + } } export default new ShopifyAPI(); diff --git a/app/javascript/dashboard/helper/featureHelper.js b/app/javascript/dashboard/helper/featureHelper.js index ae805ccf1..9d0653cc7 100644 --- a/app/javascript/dashboard/helper/featureHelper.js +++ b/app/javascript/dashboard/helper/featureHelper.js @@ -21,6 +21,7 @@ const FEATURE_HELP_URLS = { billing: 'https://chwt.app/pricing', saml: 'https://chwt.app/hc/saml', captain_billing: 'https://chwt.app/hc/captain_billing', + shopify: 'https://chwt.app/hc/shopify', }; export function getHelpUrlForFeature(featureName) { diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index da3438f97..06fbd95f6 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -10,9 +10,18 @@ "LABEL": "Store URL", "PLACEHOLDER": "your-store.myshopify.com", "HELP": "Enter your Shopify store's myshopify.com URL", + "INVALID_URL": "Please enter a valid Shopify store URL (e.g., your-store.myshopify.com)", "CANCEL": "Cancel", "SUBMIT": "Connect Store" }, + "PENDING_INSTALL": { + "SUCCESS": "Shopify integration connected successfully.", + "ERROR": "Failed to complete Shopify installation. The link may have expired." + }, + "HELP_TEXT": { + "TITLE": "How to use the Shopify Integration?", + "BODY": "With this integration, your Shopify store ***{storeDomain}*** is connected to your Chatwoot workspace. Here's what you can do:\n\n**Track orders in conversations:** When you open a conversation, the Shopify sidebar will automatically display recent orders for the customer based on their email address. This gives your support team instant context without switching tabs.\n\n**Access order details:** View order status, fulfillment status, total amount, and individual line items directly within the conversation panel." + }, "ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists." }, "HEADER": "Integrations", diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue index 304aa1724..6d09d2c40 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue @@ -1,13 +1,18 @@ + +
+
+
+ {{ $t('INTEGRATION_SETTINGS.SHOPIFY.HELP_TEXT.TITLE') }} +
+
+
+
+

{{ $t('INTEGRATION_SETTINGS.SHOPIFY.ERROR') }} @@ -144,7 +201,7 @@ onMounted(() => {

-
+
diff --git a/app/javascript/dashboard/routes/index.js b/app/javascript/dashboard/routes/index.js index 27465eb39..0503aad16 100644 --- a/app/javascript/dashboard/routes/index.js +++ b/app/javascript/dashboard/routes/index.js @@ -28,6 +28,10 @@ export const validateAuthenticateRoutePermission = (to, next) => { } if (to.name === 'no_accounts' || !to.name) { + const { redirect_url: redirectUrl } = to.query || {}; + if (redirectUrl) { + return next(frontendURL(`accounts/${accountId}/${redirectUrl}`)); + } return next(frontendURL(`accounts/${accountId}/dashboard`)); } diff --git a/app/javascript/v3/api/auth.js b/app/javascript/v3/api/auth.js index cccd9468e..3859211ab 100644 --- a/app/javascript/v3/api/auth.js +++ b/app/javascript/v3/api/auth.js @@ -12,6 +12,7 @@ import { export const login = async ({ ssoAccountId, ssoConversationId, + redirectUrl, ...credentials }) => { try { @@ -31,6 +32,7 @@ export const login = async ({ window.location = getLoginRedirectURL({ ssoAccountId, ssoConversationId, + redirectUrl, user: response.data.data, }); return null; diff --git a/app/javascript/v3/helpers/AuthHelper.js b/app/javascript/v3/helpers/AuthHelper.js index c2afec985..ec89a58e0 100644 --- a/app/javascript/v3/helpers/AuthHelper.js +++ b/app/javascript/v3/helpers/AuthHelper.js @@ -40,8 +40,16 @@ export const getCredentialsFromEmail = email => { export const getLoginRedirectURL = ({ ssoAccountId, ssoConversationId, + redirectUrl, user, }) => { + if (redirectUrl) { + const { accounts = [], account_id = null } = user || {}; + const accountId = account_id || accounts[0]?.id; + if (accountId) { + return frontendURL(`accounts/${accountId}/${redirectUrl}`); + } + } const accountPath = getSSOAccountPath({ ssoAccountId, user }); if (accountPath) { if (ssoConversationId) { diff --git a/app/javascript/v3/helpers/RouteHelper.js b/app/javascript/v3/helpers/RouteHelper.js index 5799d6573..a548c6a5a 100644 --- a/app/javascript/v3/helpers/RouteHelper.js +++ b/app/javascript/v3/helpers/RouteHelper.js @@ -29,7 +29,11 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => { // 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); + const { redirect_url: redirectUrl } = to.query || {}; + const redirectTarget = redirectUrl + ? `${DEFAULT_REDIRECT_URL}?redirect_url=${encodeURIComponent(redirectUrl)}` + : DEFAULT_REDIRECT_URL; + replaceRouteWithReload(redirectTarget); return; } diff --git a/app/javascript/v3/views/login/Index.vue b/app/javascript/v3/views/login/Index.vue index 23e598dbd..55f2095c2 100644 --- a/app/javascript/v3/views/login/Index.vue +++ b/app/javascript/v3/views/login/Index.vue @@ -43,6 +43,7 @@ export default { ssoConversationId: { type: String, default: '' }, email: { type: String, default: '' }, authError: { type: String, default: '' }, + redirectUrl: { type: String, default: '' }, }, setup() { const { replaceInstallationName } = useBranding(); @@ -169,6 +170,7 @@ export default { sso_auth_token: this.ssoAuthToken, ssoAccountId: this.ssoAccountId, ssoConversationId: this.ssoConversationId, + redirectUrl: this.redirectUrl, }; login(credentials) diff --git a/app/javascript/v3/views/routes.js b/app/javascript/v3/views/routes.js index 1be56dcb3..1e021184d 100644 --- a/app/javascript/v3/views/routes.js +++ b/app/javascript/v3/views/routes.js @@ -19,6 +19,7 @@ export default [ ssoAccountId: route.query.sso_account_id, ssoConversationId: route.query.sso_conversation_id, authError: route.query.error, + redirectUrl: route.query.redirect_url, }), }, { diff --git a/app/views/api/v1/models/_hook.json.jbuilder b/app/views/api/v1/models/_hook.json.jbuilder index 5df214ac8..7beb86f6c 100644 --- a/app/views/api/v1/models/_hook.json.jbuilder +++ b/app/views/api/v1/models/_hook.json.jbuilder @@ -4,6 +4,7 @@ json.status resource.enabled? json.inbox resource.inbox&.slice(:id, :name) json.account_id resource.account_id json.hook_type resource.hook_type +json.created_at resource.created_at json.settings resource.settings if Current.account_user&.administrator? json.reference_id resource.reference_id if Current.account_user&.administrator? diff --git a/config/routes.rb b/config/routes.rb index cab069201..0a99f525f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -317,6 +317,7 @@ Rails.application.routes.draw do collection do post :auth get :orders + post :complete_install end end resource :linear, controller: 'linear', only: [] do