feat: add SHOPIFY_APP_STORE_URL
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {
|
||||
}"
|
||||
>
|
||||
<template #action>
|
||||
<Button
|
||||
teal
|
||||
:label="$t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT')"
|
||||
@click="openStoreUrlDialog"
|
||||
/>
|
||||
<a
|
||||
:href="integration.action"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
teal
|
||||
:label="$t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT')"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</Integration>
|
||||
|
||||
@@ -178,27 +134,6 @@ onMounted(() => {
|
||||
{{ $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>
|
||||
|
||||
<div v-else class="flex flex-1 justify-center items-center">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -387,6 +387,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 ------- #
|
||||
|
||||
@@ -315,7 +315,6 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resource :shopify, controller: 'shopify', only: [:destroy] do
|
||||
collection do
|
||||
post :auth
|
||||
get :orders
|
||||
post :complete_install
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user