feat(shopify): Support Shopify-initiated install flow and improve integration page
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
useFunctionGetter,
|
||||
useMapGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Integration from './Integration.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import integrationAPI from 'dashboard/api/integrations';
|
||||
import shopifyAPI from 'dashboard/api/integrations/shopify';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
@@ -21,6 +26,10 @@ defineProps({
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
const dialogRef = ref(null);
|
||||
const integrationLoaded = ref(false);
|
||||
const storeUrl = ref('');
|
||||
@@ -29,6 +38,14 @@ const storeUrlError = ref('');
|
||||
const integration = useFunctionGetter('integrations/getIntegration', 'shopify');
|
||||
const uiFlags = useMapGetter('integrations/getUIFlags');
|
||||
|
||||
const hook = computed(() => {
|
||||
const { hooks = [] } = integration.value || {};
|
||||
const [firstHook] = hooks;
|
||||
return firstHook || {};
|
||||
});
|
||||
|
||||
const storeDomain = computed(() => hook.value.reference_id || '');
|
||||
|
||||
const integrationAction = computed(() => {
|
||||
if (integration.value.enabled) {
|
||||
return 'disconnect';
|
||||
@@ -36,6 +53,15 @@ const integrationAction = computed(() => {
|
||||
return 'connect';
|
||||
});
|
||||
|
||||
const formattedHelpText = computed(() => {
|
||||
return formatMessage(
|
||||
t('INTEGRATION_SETTINGS.SHOPIFY.HELP_TEXT.BODY', {
|
||||
storeDomain: storeDomain.value,
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
const hideStoreUrlModal = () => {
|
||||
storeUrl.value = '';
|
||||
storeUrlError.value = '';
|
||||
@@ -57,8 +83,9 @@ const handleStoreUrlSubmit = async () => {
|
||||
try {
|
||||
storeUrlError.value = '';
|
||||
if (!validateStoreUrl(storeUrl.value)) {
|
||||
storeUrlError.value =
|
||||
'Please enter a valid Shopify store URL (e.g., your-store.myshopify.com)';
|
||||
storeUrlError.value = t(
|
||||
'INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.INVALID_URL'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,9 +104,26 @@ const handleStoreUrlSubmit = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const completePendingInstall = async token => {
|
||||
try {
|
||||
await shopifyAPI.completeInstall(token);
|
||||
await store.dispatch('integrations/get', 'shopify');
|
||||
useAlert(t('INTEGRATION_SETTINGS.SHOPIFY.PENDING_INSTALL.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('INTEGRATION_SETTINGS.SHOPIFY.PENDING_INSTALL.ERROR'));
|
||||
} finally {
|
||||
router.replace({ query: {} });
|
||||
}
|
||||
};
|
||||
|
||||
const initializeShopifyIntegration = async () => {
|
||||
await store.dispatch('integrations/get', 'shopify');
|
||||
integrationLoaded.value = true;
|
||||
|
||||
const pendingInstallToken = route.query.shopify_pending_install;
|
||||
if (pendingInstallToken) {
|
||||
await completePendingInstall(pendingInstallToken);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -88,7 +132,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-grow flex-shrink p-4 overflow-auto max-w-6xl mx-auto">
|
||||
<div class="overflow-auto flex-grow flex-shrink p-4 mx-auto max-w-6xl">
|
||||
<div
|
||||
v-if="integrationLoaded && !uiFlags.isCreatingShopify"
|
||||
class="flex flex-col gap-6"
|
||||
@@ -113,9 +157,22 @@ onMounted(() => {
|
||||
/>
|
||||
</template>
|
||||
</Integration>
|
||||
|
||||
<div
|
||||
v-if="integration.enabled"
|
||||
class="flex-1 px-6 py-5 w-full rounded-md shadow outline outline-n-container outline-1 bg-n-alpha-3"
|
||||
>
|
||||
<div class="max-w-5xl prose-lg">
|
||||
<h5 class="tracking-tight text-n-slate-12">
|
||||
{{ $t('INTEGRATION_SETTINGS.SHOPIFY.HELP_TEXT.TITLE') }}
|
||||
</h5>
|
||||
<div v-dompurify-html="formattedHelpText" class="text-n-slate-11" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="flex items-center justify-center flex-1 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow p-6"
|
||||
class="flex flex-1 justify-center items-center p-6 rounded-md shadow outline outline-n-container outline-1 bg-n-alpha-3"
|
||||
>
|
||||
<p class="text-n-ruby-9">
|
||||
{{ $t('INTEGRATION_SETTINGS.SHOPIFY.ERROR') }}
|
||||
@@ -144,7 +201,7 @@ onMounted(() => {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-center flex-1">
|
||||
<div v-else class="flex flex-1 justify-center items-center">
|
||||
<Spinner size="" color-scheme="primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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`));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user