Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35afc0bac7 | ||
|
|
9b13769810 | ||
|
|
d788b645ee | ||
|
|
77d4915ecf | ||
|
|
7f5d9f0f76 | ||
|
|
3281f43629 | ||
|
|
97fb06dba3 | ||
|
|
eec285e41d | ||
|
|
bfc6cd1c0f | ||
|
|
09812b9343 | ||
|
|
00956845bc | ||
|
|
228992549b | ||
|
|
e496bd9cb1 | ||
|
|
4791cef4d7 | ||
|
|
cfafab0f08 | ||
|
|
24b0241ba5 | ||
|
|
81afdf0703 | ||
|
|
d4e82e6c4c | ||
|
|
d3bf813067 | ||
|
|
5c58cd5e89 | ||
|
|
547d58fefb | ||
|
|
a8367a969f | ||
|
|
ab3236148a | ||
|
|
7143482a87 | ||
|
|
bd59e7ec04 | ||
|
|
434d621dc4 |
@@ -1,26 +1,9 @@
|
||||
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: [:complete_install]
|
||||
before_action :validate_contact, only: [:orders]
|
||||
|
||||
def auth
|
||||
shop_domain = params[:shop_domain]
|
||||
return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank?
|
||||
|
||||
state = generate_shopify_token(Current.account.id)
|
||||
|
||||
auth_url = "https://#{shop_domain}/admin/oauth/authorize?"
|
||||
auth_url += URI.encode_www_form(
|
||||
client_id: client_id,
|
||||
scope: REQUIRED_SCOPES.join(','),
|
||||
redirect_uri: redirect_uri,
|
||||
state: state
|
||||
)
|
||||
|
||||
render json: { redirect_url: auth_url }
|
||||
end
|
||||
|
||||
def orders
|
||||
customers = fetch_customers
|
||||
return render json: { orders: [] } if customers.empty?
|
||||
@@ -31,6 +14,23 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def complete_install
|
||||
token_key = "shopify_pending_install:#{params[:pending_install_token]}"
|
||||
data = claim_pending_install_token(token_key, Current.account.id)
|
||||
return render json: { error: data[:error] }, status: :unprocessable_entity if data[:error]
|
||||
|
||||
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(token_key)
|
||||
head :ok
|
||||
end
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
head :ok
|
||||
@@ -40,10 +40,6 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
|
||||
|
||||
private
|
||||
|
||||
def redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
|
||||
end
|
||||
|
||||
def contact
|
||||
@contact ||= Current.account.contacts.find_by(id: params[:contact_id])
|
||||
end
|
||||
@@ -108,4 +104,31 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
|
||||
render json: { error: 'Contact information missing' },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def claim_pending_install_token(token_key, account_id)
|
||||
json_data = Redis::SecureStorage.get(token_key)
|
||||
return { error: 'Invalid or expired install token' } if json_data.blank?
|
||||
|
||||
begin
|
||||
data = JSON.parse(json_data)
|
||||
rescue JSON::ParserError
|
||||
return { error: 'Invalid or corrupted install token' }
|
||||
end
|
||||
|
||||
if data['claimed']
|
||||
Redis::SecureStorage.delete(token_key)
|
||||
return { error: 'Install token already used' }
|
||||
end
|
||||
|
||||
# Check if already bound to a different account (prevents token theft)
|
||||
return { error: 'Install token cannot be used by this account' } if data['account_id'].present? && data['account_id'] != account_id
|
||||
|
||||
# Bind to this account and mark as claimed to prevent replay
|
||||
data['claimed'] = true
|
||||
data['account_id'] = account_id
|
||||
ttl = ::Redis::Alfred.ttl(token_key)
|
||||
Redis::SecureStorage.set(token_key, data, [ttl, 60].max) if ttl.positive?
|
||||
|
||||
data
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,42 +2,74 @@ 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?
|
||||
raise StandardError, 'Invalid HMAC signature' unless valid_hmac?
|
||||
|
||||
@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
|
||||
raise StandardError, 'Invalid shop domain' unless valid_shop_domain?
|
||||
raise StandardError, 'Invalid HMAC signature' unless valid_hmac?
|
||||
|
||||
# Security: HMAC validation ensures params (including shop) haven't been tampered with.
|
||||
# Additionally, the OAuth code is cryptographically bound to the shop that issued it.
|
||||
# Shopify will reject any attempt to exchange a code at a different shop's endpoint.
|
||||
@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'],
|
||||
claimed: false
|
||||
}
|
||||
|
||||
Redis::SecureStorage.set("shopify_pending_install:#{token_key}", pending_data, 10.minutes)
|
||||
|
||||
redirect_url = "settings/integrations/shopify?shopify_pending_install=#{CGI.escape(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
|
||||
@parsed_body ||= @response.response.parsed
|
||||
@parsed_body ||= begin
|
||||
parsed = @response.response.parsed
|
||||
# Handle both SnakyHash (production) and regular Hash (tests)
|
||||
{
|
||||
'access_token' => parsed.respond_to?(:access_token) ? parsed.access_token : parsed['access_token'],
|
||||
'scope' => parsed.respond_to?(:scope) ? parsed.scope : parsed['scope']
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def oauth_client
|
||||
@@ -56,17 +88,51 @@ 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
|
||||
begin
|
||||
"#{shopify_integration_url}?error=true"
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
"#{frontend_url}?error=true"
|
||||
end
|
||||
else
|
||||
"#{frontend_url}?error=true"
|
||||
end
|
||||
end
|
||||
|
||||
ENV.fetch('FRONTEND_URL', nil)
|
||||
def frontend_url
|
||||
ENV.fetch('FRONTEND_URL', '')
|
||||
end
|
||||
|
||||
def valid_shop_domain?
|
||||
return false if params[:shop].blank?
|
||||
|
||||
# Shopify shop domains must match: *.myshopify.com or *.myshopify.io (for dev shops)
|
||||
params[:shop].match?(/\A[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.(com|io)\z/)
|
||||
end
|
||||
|
||||
def valid_hmac?
|
||||
return false if params[:hmac].blank?
|
||||
|
||||
# Shopify HMAC validation
|
||||
# Reference: https://shopify.dev/docs/apps/build/authentication-authorization/get-access-tokens
|
||||
hmac = params[:hmac]
|
||||
|
||||
# Build query string from params, excluding hmac and Rails-added params
|
||||
query_params = params.except(:hmac, :controller, :action).to_unsafe_h
|
||||
query_string = query_params.sort.map { |k, v| "#{k}=#{v}" }.join('&')
|
||||
|
||||
# Compute HMAC-SHA256
|
||||
computed_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA256'), client_secret, query_string)
|
||||
|
||||
ActiveSupport::SecurityUtils.secure_compare(computed_hmac, hmac)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -40,7 +40,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
def allowed_configs
|
||||
mapping = {
|
||||
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
|
||||
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
|
||||
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET SHOPIFY_APP_STORE_URL],
|
||||
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
|
||||
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
|
||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||
|
||||
@@ -32,12 +32,6 @@ class IntegrationsAPI extends ApiClient {
|
||||
deleteHook(hookId) {
|
||||
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
|
||||
}
|
||||
|
||||
connectShopify({ shopDomain }) {
|
||||
return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, {
|
||||
shop_domain: shopDomain,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new IntegrationsAPI();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -20,6 +20,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) {
|
||||
|
||||
@@ -11,9 +11,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,16 +1,17 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
useFunctionGetter,
|
||||
useMapGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Integration from './Integration.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';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
@@ -23,12 +24,11 @@ defineProps({
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
const integrationLoaded = ref(false);
|
||||
const storeUrl = ref('');
|
||||
const isSubmitting = ref(false);
|
||||
const storeUrlError = ref('');
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
const integration = useFunctionGetter('integrations/getIntegration', 'shopify');
|
||||
const uiFlags = useMapGetter('integrations/getUIFlags');
|
||||
|
||||
@@ -39,50 +39,43 @@ const integrationAction = computed(() => {
|
||||
return 'connect';
|
||||
});
|
||||
|
||||
const hideStoreUrlModal = () => {
|
||||
storeUrl.value = '';
|
||||
storeUrlError.value = '';
|
||||
isSubmitting.value = false;
|
||||
};
|
||||
const hook = computed(() => {
|
||||
const { hooks = [] } = integration.value || {};
|
||||
const [firstHook] = hooks;
|
||||
return firstHook || {};
|
||||
});
|
||||
|
||||
const validateStoreUrl = url => {
|
||||
const pattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/;
|
||||
return pattern.test(url);
|
||||
};
|
||||
const storeDomain = computed(() => hook.value.reference_id || '');
|
||||
|
||||
const openStoreUrlDialog = () => {
|
||||
if (dialogRef.value) {
|
||||
dialogRef.value.open();
|
||||
}
|
||||
};
|
||||
const formattedHelpText = computed(() => {
|
||||
return formatMessage(
|
||||
t('INTEGRATION_SETTINGS.SHOPIFY.HELP_TEXT.BODY', {
|
||||
storeDomain: storeDomain.value,
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
const handleStoreUrlSubmit = async () => {
|
||||
const completePendingInstall = async token => {
|
||||
try {
|
||||
storeUrlError.value = '';
|
||||
if (!validateStoreUrl(storeUrl.value)) {
|
||||
storeUrlError.value =
|
||||
'Please enter a valid Shopify store URL (e.g., your-store.myshopify.com)';
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
const { data } = await integrationAPI.connectShopify({
|
||||
shopDomain: storeUrl.value,
|
||||
});
|
||||
|
||||
if (data.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
}
|
||||
} catch (error) {
|
||||
storeUrlError.value = error.message;
|
||||
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 {
|
||||
isSubmitting.value = false;
|
||||
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(() => {
|
||||
@@ -122,35 +115,27 @@ onMounted(() => {
|
||||
/>
|
||||
</template>
|
||||
</Integration>
|
||||
|
||||
<div
|
||||
v-if="integration.enabled"
|
||||
class="flex-1 w-full px-6 py-5 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 items-center justify-center flex-1 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') }}
|
||||
</p>
|
||||
</div>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.TITLE')"
|
||||
:is-loading="isSubmitting"
|
||||
@confirm="handleStoreUrlSubmit"
|
||||
@close="hideStoreUrlModal"
|
||||
>
|
||||
<Input
|
||||
v-model="storeUrl"
|
||||
:label="t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.LABEL')"
|
||||
:placeholder="
|
||||
t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.PLACEHOLDER')
|
||||
"
|
||||
:message="
|
||||
!storeUrlError
|
||||
? t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.HELP')
|
||||
: storeUrlError
|
||||
"
|
||||
:message-type="storeUrlError ? 'error' : 'info'"
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -20,6 +20,7 @@ export default [
|
||||
ssoAccountId: route.query.sso_account_id,
|
||||
ssoConversationId: route.query.sso_conversation_id,
|
||||
authError: route.query.error,
|
||||
redirectUrl: route.query.redirect_url,
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -43,6 +43,8 @@ class Integrations::App
|
||||
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
|
||||
when 'linear'
|
||||
build_linear_action
|
||||
when 'shopify'
|
||||
GlobalConfigService.load('SHOPIFY_APP_STORE_URL', nil)
|
||||
else
|
||||
params[:action]
|
||||
end
|
||||
|
||||
@@ -394,6 +394,10 @@
|
||||
description: 'The Client Secret (API Secret Key) from your Shopify Partner account'
|
||||
locked: false
|
||||
type: secret
|
||||
- name: SHOPIFY_APP_STORE_URL
|
||||
display_title: 'Shopify App Store URL'
|
||||
description: 'The Shopify App Store listing URL (e.g., https://apps.shopify.com/your-app)'
|
||||
locked: false
|
||||
# ------- End of Shopify Related Config ------- #
|
||||
|
||||
# ------- Instagram Channel Related Config ------- #
|
||||
|
||||
+1
-1
@@ -324,8 +324,8 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resource :shopify, controller: 'shopify', only: [:destroy] do
|
||||
collection do
|
||||
post :auth
|
||||
get :orders
|
||||
post :complete_install
|
||||
end
|
||||
end
|
||||
resource :linear, controller: 'linear', only: [] do
|
||||
|
||||
@@ -40,6 +40,11 @@ module Redis::Alfred
|
||||
$alfred.with { |conn| conn.expire(key, seconds) }
|
||||
end
|
||||
|
||||
# get time to live for a key in seconds
|
||||
def ttl(key)
|
||||
$alfred.with { |conn| conn.ttl(key) }
|
||||
end
|
||||
|
||||
# scan keys matching a pattern
|
||||
def scan_each(match: nil, count: 100, &)
|
||||
$alfred.with do |conn|
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Redis::SecureStorage provides encrypted storage for sensitive temporary data in Redis.
|
||||
# Uses ActiveSupport::MessageEncryptor with AES-256-GCM for authenticated encryption.
|
||||
#
|
||||
# Example:
|
||||
# Redis::SecureStorage.set('session:token', { access_token: 'secret' }, 10.minutes)
|
||||
# data = Redis::SecureStorage.get('session:token')
|
||||
#
|
||||
module Redis::SecureStorage
|
||||
class << self
|
||||
# Store data in Redis with encryption
|
||||
# @param key [String] Redis key
|
||||
# @param data [Hash, String] Data to store (will be converted to JSON)
|
||||
# @param expiry [Integer, ActiveSupport::Duration] TTL in seconds
|
||||
def set(key, data, expiry)
|
||||
encrypted = encrypt(data)
|
||||
Redis::Alfred.setex(key, encrypted, expiry)
|
||||
end
|
||||
|
||||
# Retrieve and decrypt data from Redis
|
||||
# @param key [String] Redis key
|
||||
# @return [Hash, nil] Decrypted data or nil if not found/invalid
|
||||
def get(key)
|
||||
encrypted = Redis::Alfred.get(key)
|
||||
return nil if encrypted.blank?
|
||||
|
||||
decrypt(encrypted)
|
||||
rescue ActiveSupport::MessageEncryptor::InvalidMessage, JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
# Delete data from Redis
|
||||
# @param key [String] Redis key
|
||||
def delete(key)
|
||||
Redis::Alfred.delete(key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def encryptor
|
||||
@encryptor ||= begin
|
||||
# Derive a proper 32-byte key from secret_key_base
|
||||
key_generator = ActiveSupport::KeyGenerator.new(Rails.application.secret_key_base)
|
||||
key = key_generator.generate_key('redis_secure_storage', 32)
|
||||
ActiveSupport::MessageEncryptor.new(key, cipher: 'aes-256-gcm')
|
||||
end
|
||||
end
|
||||
|
||||
def encrypt(data)
|
||||
json_data = data.is_a?(String) ? data : data.to_json
|
||||
return json_data unless Chatwoot.encryption_configured?
|
||||
|
||||
encryptor.encrypt_and_sign(json_data)
|
||||
end
|
||||
|
||||
def decrypt(encrypted_data)
|
||||
return encrypted_data unless Chatwoot.encryption_configured?
|
||||
|
||||
encryptor.decrypt_and_verify(encrypted_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -15,42 +15,6 @@ RSpec.describe 'Shopify Integration API', type: :request do
|
||||
let(:unauthorized_agent) { create(:user, account: account, role: :agent) }
|
||||
let(:contact) { create(:contact, account: account, email: 'test@example.com', phone_number: '+1234567890') }
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/integrations/shopify/auth' do
|
||||
let(:shop_domain) { 'test-store.myshopify.com' }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns a redirect URL for Shopify OAuth' do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/shopify/auth",
|
||||
params: { shop_domain: shop_domain },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body).to have_key('redirect_url')
|
||||
expect(response.parsed_body['redirect_url']).to include(shop_domain)
|
||||
end
|
||||
|
||||
it 'returns error when shop domain is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/shopify/auth",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Shop domain is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/shopify/auth",
|
||||
params: { shop_domain: shop_domain },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/integrations/shopify/orders' do
|
||||
before do
|
||||
create(:integrations_hook, :shopify, account: account)
|
||||
|
||||
@@ -5,6 +5,7 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
||||
let(:code) { SecureRandom.hex(10) }
|
||||
let(:state) { SecureRandom.hex(10) }
|
||||
let(:shop) { 'my-store.myshopify.com' }
|
||||
let(:client_secret) { 'test_secret_key_1234567890' }
|
||||
let(:frontend_url) { 'http://www.example.com' }
|
||||
let(:shopify_redirect_uri) { "#{frontend_url}/app/accounts/#{account.id}/settings/integrations/shopify" }
|
||||
let(:oauth_client) { instance_double(OAuth2::Client) }
|
||||
@@ -17,6 +18,12 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to compute HMAC for test requests (matches Shopify's algorithm)
|
||||
def compute_hmac(params, secret)
|
||||
query_string = params.except(:hmac).sort.map { |k, v| "#{k}=#{v}" }.join('&')
|
||||
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA256'), secret, query_string)
|
||||
end
|
||||
|
||||
describe 'GET /shopify/callback' do
|
||||
let(:access_token) { SecureRandom.hex(10) }
|
||||
let(:response_body) do
|
||||
@@ -36,6 +43,7 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
||||
controller = original.call(*args)
|
||||
allow(controller).to receive(:verify_shopify_token).and_return(account.id)
|
||||
allow(controller).to receive(:oauth_client).and_return(oauth_client)
|
||||
allow(controller).to receive(:client_secret).and_return(client_secret)
|
||||
controller
|
||||
end
|
||||
allow(Account).to receive(:find).and_return(account)
|
||||
@@ -57,8 +65,11 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
||||
end
|
||||
|
||||
it 'creates a new integration hook' do
|
||||
params = { code: code, state: state, shop: shop }
|
||||
params[:hmac] = compute_hmac(params, client_secret)
|
||||
|
||||
expect do
|
||||
get shopify_callback_path, params: { code: code, state: state, shop: shop }
|
||||
get shopify_callback_path, params: params
|
||||
end.to change(Integrations::Hook, :count).by(1)
|
||||
|
||||
hook = Integrations::Hook.last
|
||||
@@ -82,7 +93,10 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
||||
end
|
||||
|
||||
it 'redirects to the shopify_redirect_uri with error' do
|
||||
get shopify_callback_path, params: { state: state, shop: shop }
|
||||
params = { state: state, shop: shop }
|
||||
params[:hmac] = compute_hmac(params, client_secret)
|
||||
|
||||
get shopify_callback_path, params: params
|
||||
expect(response).to redirect_to("#{shopify_redirect_uri}?error=true")
|
||||
end
|
||||
end
|
||||
@@ -104,7 +118,10 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
||||
end
|
||||
|
||||
it 'redirects to the shopify_redirect_uri with error' do
|
||||
get shopify_callback_path, params: { code: code, state: state, shop: shop }
|
||||
params = { code: code, state: state, shop: shop }
|
||||
params[:hmac] = compute_hmac(params, client_secret)
|
||||
|
||||
get shopify_callback_path, params: params
|
||||
expect(response).to redirect_to("#{shopify_redirect_uri}?error=true")
|
||||
end
|
||||
end
|
||||
@@ -114,13 +131,20 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
||||
# rubocop:disable RSpec/AnyInstance, RSpec/DescribedClass
|
||||
# Explicit class name and any_instance required for parallel CI stability
|
||||
allow_any_instance_of(Shopify::CallbacksController).to receive(:verify_shopify_token).and_return(nil)
|
||||
allow_any_instance_of(Shopify::CallbacksController).to receive(:account).and_return(nil)
|
||||
allow_any_instance_of(Shopify::CallbacksController).to receive(:oauth_client).and_return(oauth_client)
|
||||
allow_any_instance_of(Shopify::CallbacksController).to receive(:client_secret).and_return(client_secret)
|
||||
# rubocop:enable RSpec/AnyInstance, RSpec/DescribedClass
|
||||
allow(oauth_client).to receive(:auth_code).and_return(auth_code_strategy)
|
||||
allow(auth_code_strategy).to receive(:get_token).and_return(token_response)
|
||||
end
|
||||
|
||||
it 'redirects to the frontend URL with error' do
|
||||
get shopify_callback_path, params: { code: code, state: state, shop: shop }
|
||||
expect(response).to redirect_to("#{frontend_url}?error=true")
|
||||
it 'handles as Shopify-initiated install and redirects to login with pending install token' do
|
||||
params = { code: code, state: state, shop: shop }
|
||||
params[:hmac] = compute_hmac(params, client_secret)
|
||||
|
||||
get shopify_callback_path, params: params
|
||||
expect(response).to redirect_to(%r{#{Regexp.escape(frontend_url)}/app/login\?redirect_url=})
|
||||
expect(CGI.unescape(response.location)).to include('shopify_pending_install=')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user