diff --git a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb index 5e894a005..147019dc1 100644 --- a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb @@ -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, :complete_install] + 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? @@ -32,10 +15,9 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba 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) + token_key = "shopify_pending_install:#{params[:pending_install_token]}" + data = claim_pending_install_token(token_key) + return render json: { error: data[:error] }, status: :unprocessable_entity if data[:error] Current.account.hooks.create!( app_id: 'shopify', @@ -45,7 +27,7 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba settings: { scope: data['scope'] } ) - ::Redis::Alfred.delete("shopify_pending_install:#{params[:pending_install_token]}") + ::Redis::Alfred.delete(token_key) head :ok end @@ -58,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 @@ -126,4 +104,23 @@ 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) + pending_data = ::Redis::Alfred.get(token_key) + return { error: 'Invalid or expired install token' } if pending_data.blank? + + data = JSON.parse(pending_data) + + if data['claimed'] + ::Redis::Alfred.delete(token_key) + return { error: 'Install token already used' } + end + + # Mark as claimed to prevent replay + data['claimed'] = true + ttl = ::Redis::Alfred.ttl(token_key) + ::Redis::Alfred.setex(token_key, data.to_json, [ttl, 60].max) if ttl.positive? + + data + end end diff --git a/app/controllers/shopify/callbacks_controller.rb b/app/controllers/shopify/callbacks_controller.rb index 973cedee7..8c8bcb082 100644 --- a/app/controllers/shopify/callbacks_controller.rb +++ b/app/controllers/shopify/callbacks_controller.rb @@ -28,10 +28,17 @@ class Shopify::CallbacksController < ApplicationController end def handle_shopify_initiated_flow + raise StandardError, 'Invalid shop domain' unless valid_shop_domain? + @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 + pending_data = { + access_token: parsed_body['access_token'], + shop: params[:shop], + scope: parsed_body['scope'], + claimed: false + }.to_json ::Redis::Alfred.setex("shopify_pending_install:#{token_key}", pending_data, 10.minutes) redirect_url = "settings/integrations/shopify?shopify_pending_install=#{token_key}" @@ -91,4 +98,11 @@ class Shopify::CallbacksController < ApplicationController 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 end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 67d58aef1..746dd1af3 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -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], diff --git a/app/javascript/dashboard/api/integrations.js b/app/javascript/dashboard/api/integrations.js index d4ffcbca3..2b816e603 100644 --- a/app/javascript/dashboard/api/integrations.js +++ b/app/javascript/dashboard/api/integrations.js @@ -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(); diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue index 6d09d2c40..dba5f36fd 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue @@ -11,11 +11,8 @@ 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'; import Button from 'dashboard/components-next/button/Button.vue'; defineProps({ @@ -30,11 +27,7 @@ const route = useRoute(); const router = useRouter(); const { t } = useI18n(); const { formatMessage } = useMessageFormatter(); -const dialogRef = ref(null); const integrationLoaded = ref(false); -const storeUrl = ref(''); -const isSubmitting = ref(false); -const storeUrlError = ref(''); const integration = useFunctionGetter('integrations/getIntegration', 'shopify'); const uiFlags = useMapGetter('integrations/getUIFlags'); @@ -62,48 +55,6 @@ const formattedHelpText = computed(() => { ); }); -const hideStoreUrlModal = () => { - storeUrl.value = ''; - storeUrlError.value = ''; - isSubmitting.value = false; -}; - -const validateStoreUrl = url => { - const pattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/; - return pattern.test(url); -}; - -const openStoreUrlDialog = () => { - if (dialogRef.value) { - dialogRef.value.open(); - } -}; - -const handleStoreUrlSubmit = async () => { - try { - storeUrlError.value = ''; - if (!validateStoreUrl(storeUrl.value)) { - storeUrlError.value = t( - 'INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.INVALID_URL' - ); - 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; - } finally { - isSubmitting.value = false; - } -}; - const completePendingInstall = async token => { try { await shopifyAPI.completeInstall(token); @@ -150,11 +101,16 @@ onMounted(() => { }" > - + + + @@ -178,27 +134,6 @@ onMounted(() => { {{ $t('INTEGRATION_SETTINGS.SHOPIFY.ERROR') }}
-