diff --git a/package-lock.json b/package-lock.json index 2e800e61..f8144e16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "1.23.0", "license": "Apache-2.0", "dependencies": { - "@hotwax/oms-api": "^1.8.1", + "@hotwax/oms-api": "^1.26.0", "@ionic/core": "^7.6.0", "@ionic/vue": "^7.6.0", + "@shopify/app-bridge": "^3.7.10", + "@shopify/app-bridge-utils": "^3.5.1", "firebase": "^10.3.1", "luxon": "^3.3.0", "pinia": "2.0.36", @@ -1038,9 +1040,10 @@ } }, "node_modules/@hotwax/oms-api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@hotwax/oms-api/-/oms-api-1.9.0.tgz", - "integrity": "sha512-1PcS95vP8PzzlBRwHQRk99eJT0xStBSmNdpdoATRUSlLamxmHZ8RAwAwY3usph5gNy7Z+WzAxl9fJXX9VgWP9g==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@hotwax/oms-api/-/oms-api-1.26.0.tgz", + "integrity": "sha512-evdbruZIZdM+Uxb0Sa+WFFBrQCTJG3ary+0UcJ5V/EKms1VxhffjJ9chTO8n2NQub6bR6KkKhhmfgZSKLmWfYQ==", + "license": "Apache-2.0", "dependencies": { "@types/node-json-transform": "^1.0.0", "axios": "^0.21.1", @@ -1271,6 +1274,33 @@ "node": ">= 8.0.0" } }, + "node_modules/@shopify/app-bridge": { + "version": "3.7.10", + "resolved": "https://registry.npmjs.org/@shopify/app-bridge/-/app-bridge-3.7.10.tgz", + "integrity": "sha512-zy4c05DEOkYXm1yWIe0FrI0lq6hzffIT4JWzb9XKqY5OPRNHRHg+ihDt0ZnR8eMVmYV03yStpGAITIfqOgn4TQ==", + "license": "MIT", + "dependencies": { + "@shopify/app-bridge-core": "1.1.1", + "base64url": "^3.0.1", + "web-vitals": "^3.0.1" + } + }, + "node_modules/@shopify/app-bridge-core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@shopify/app-bridge-core/-/app-bridge-core-1.1.1.tgz", + "integrity": "sha512-kEnJUpkC9vDdmGMN3Mezq/FlDBqP+DFg/A1PFzqTW8FILu0wzFmz1aFk4Hxy6Y6ilceMmn8QXsyLA9DfaeH4jQ==", + "license": "MIT" + }, + "node_modules/@shopify/app-bridge-utils": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@shopify/app-bridge-utils/-/app-bridge-utils-3.5.1.tgz", + "integrity": "sha512-VZRvRfg1nF/0/C7zIwYlQ2RXY2S7ALw04Lp6vXPkzBxggDYakx9Lbvc5/k7ZTvhwZv2q8+FVHh319IMW81b27Q==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "@shopify/app-bridge": "^3.5.1" + } + }, "node_modules/@stencil/core": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.9.0.tgz", @@ -1940,6 +1970,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/big-integer": { "version": "1.6.51", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", @@ -4838,6 +4877,12 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/web-vitals": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.2.tgz", + "integrity": "sha512-c0rhqNcHXRkY/ogGDJQxZ9Im9D19hDihbzSQJrsioex+KnFgmMzBiy57Z1EjkhX/+OjyBpclDCzz2ITtjokFmg==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -5694,9 +5739,9 @@ } }, "@hotwax/oms-api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@hotwax/oms-api/-/oms-api-1.9.0.tgz", - "integrity": "sha512-1PcS95vP8PzzlBRwHQRk99eJT0xStBSmNdpdoATRUSlLamxmHZ8RAwAwY3usph5gNy7Z+WzAxl9fJXX9VgWP9g==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@hotwax/oms-api/-/oms-api-1.26.0.tgz", + "integrity": "sha512-evdbruZIZdM+Uxb0Sa+WFFBrQCTJG3ary+0UcJ5V/EKms1VxhffjJ9chTO8n2NQub6bR6KkKhhmfgZSKLmWfYQ==", "requires": { "@types/node-json-transform": "^1.0.0", "axios": "^0.21.1", @@ -5881,6 +5926,29 @@ "picomatch": "^2.2.2" } }, + "@shopify/app-bridge": { + "version": "3.7.10", + "resolved": "https://registry.npmjs.org/@shopify/app-bridge/-/app-bridge-3.7.10.tgz", + "integrity": "sha512-zy4c05DEOkYXm1yWIe0FrI0lq6hzffIT4JWzb9XKqY5OPRNHRHg+ihDt0ZnR8eMVmYV03yStpGAITIfqOgn4TQ==", + "requires": { + "@shopify/app-bridge-core": "1.1.1", + "base64url": "^3.0.1", + "web-vitals": "^3.0.1" + } + }, + "@shopify/app-bridge-core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@shopify/app-bridge-core/-/app-bridge-core-1.1.1.tgz", + "integrity": "sha512-kEnJUpkC9vDdmGMN3Mezq/FlDBqP+DFg/A1PFzqTW8FILu0wzFmz1aFk4Hxy6Y6ilceMmn8QXsyLA9DfaeH4jQ==" + }, + "@shopify/app-bridge-utils": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@shopify/app-bridge-utils/-/app-bridge-utils-3.5.1.tgz", + "integrity": "sha512-VZRvRfg1nF/0/C7zIwYlQ2RXY2S7ALw04Lp6vXPkzBxggDYakx9Lbvc5/k7ZTvhwZv2q8+FVHh319IMW81b27Q==", + "requires": { + "@shopify/app-bridge": "^3.5.1" + } + }, "@stencil/core": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.9.0.tgz", @@ -6367,6 +6435,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "big-integer": { "version": "1.6.51", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", @@ -8373,6 +8446,11 @@ } } }, + "web-vitals": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.2.tgz", + "integrity": "sha512-c0rhqNcHXRkY/ogGDJQxZ9Im9D19hDihbzSQJrsioex+KnFgmMzBiy57Z1EjkhX/+OjyBpclDCzz2ITtjokFmg==" + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index c8c487e8..cb83e9ad 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,11 @@ "author": "HotWax Commerce", "license": "Apache-2.0", "dependencies": { - "@hotwax/oms-api": "^1.8.1", + "@hotwax/oms-api": "^1.26.0", "@ionic/core": "^7.6.0", "@ionic/vue": "^7.6.0", + "@shopify/app-bridge": "^3.7.10", + "@shopify/app-bridge-utils": "^3.5.1", "firebase": "^10.3.1", "luxon": "^3.3.0", "pinia": "2.0.36", diff --git a/src/components/DxpLogin.vue b/src/components/DxpLogin.vue index 6663f4bd..8f84e825 100644 --- a/src/components/DxpLogin.vue +++ b/src/components/DxpLogin.vue @@ -38,7 +38,8 @@ import { useUserStore } from "../index" import { DateTime } from "luxon" -import { getAppLoginUrl } from "src/utils"; +import { createShopifyAppBridge, getSessionTokenFromShopify } from "src/utils"; +import { loginShopifyAppUser } from "@hotwax/oms-api"; declare var process: any; const authStore = useAuthStore() @@ -50,18 +51,30 @@ const error = ref({ }) onMounted(async () => { - if (!Object.keys(route.query).length) { + // This will be false for when apps run in browser directly and when user first time comes from Shopify POS or Admin embedded app. + let isEmbedded = authStore.isEmbedded; + + // Cases Handled: + // If the app is not embedded and there are no query params, redirect to launchpad + // If the app is embedded, it will have query params from Shopify, even if the app is not marked as embedded in the auth store, we will mark it as embedded here. + // In case if the token expired and user is routed to login path, the app was already marked as embedded, so we should not redirect to launchpad in that case. + if (!isEmbedded && !Object.keys(route.query).length) { window.location.replace(context.appLoginUrl) return } - const { token, oms, expirationTime, omsRedirectionUrl, isEmbedded, shop, host} = route.query - // Update the flag in auth, since the store is updated app login url will be embedded luanchpad's url. - const isEmbeddedFlag = isEmbedded === 'true' - await handleUserFlow(token, oms, expirationTime, omsRedirectionUrl, isEmbeddedFlag, shop, host) + const { token, oms, expirationTime, omsRedirectionUrl, embedded, shop, host } = route.query + isEmbedded = isEmbedded || embedded === '1' + + if (isEmbedded) { + await appBridgeLogin(shop as string, host as string); + } else { + // Update the flag in auth, since the store is updated app login url will be embedded luanchpad's url. + await handleUserFlow(token, oms, expirationTime, omsRedirectionUrl, isEmbedded, shop, host); + } }); -async function handleUserFlow(token: string, oms: string, expirationTime: string, omsRedirectionUrl = "", isEmbedded: boolean, shop: string, host: string) { +async function handleUserFlow(token: string, oms: string, expirationTime: string, omsRedirectionUrl = "", isEmbedded: boolean, shop: string, host: string, shopifyAppBridge: any = undefined) { // fetch the current config for the user const appConfig = loginContext.getConfig() @@ -76,19 +89,8 @@ async function handleUserFlow(token: string, oms: string, expirationTime: string console.error('User token has expired, redirecting to launchpad.') error.value.message = 'User token has expired, redirecting to launchpad.' - // This will be the url of referer launchpad, we maintain two launchpads. - // The launchpad urls are defined the env file in each PW App. - // Setting this flag here because it is needed to identify the launchpad's URL, this will updated in this function later. - authStore.isEmbedded = isEmbedded - authStore.shop = shop - authStore.host = host - const appLoginUrl = getAppLoginUrl() - if (isEmbedded) { - window.location.replace(appLoginUrl) - } else { const redirectUrl = window.location.origin + '/login' // current app URL - window.location.replace(`${appLoginUrl}?isLoggedOut=true&redirectUrl=${redirectUrl}`) - } + window.location.replace(`${context.appLoginUrl}?isLoggedOut=true&redirectUrl=${redirectUrl}`) return } @@ -98,7 +100,8 @@ async function handleUserFlow(token: string, oms: string, expirationTime: string oms, isEmbedded, shop: shop as any, - host: host as any + host: host as any, + shopifyAppBridge: shopifyAppBridge as any }) context.loader.present('Logging in') @@ -134,7 +137,67 @@ async function handleUserFlow(token: string, oms: string, expirationTime: string } function goToLaunchpad() { - window.location.replace(getAppLoginUrl()) + window.location.replace(process.env.VUE_APP_LOGIN_URL) +} + +async function appBridgeLogin(shop: string, host: string) { + console.log("This is an embedded app user, proceeding with Shopify App Bridge login flow."); + // In case where token expired and user is routed login path, the query params will not have shop and host, + // So we get them from auth store before it is cleared. + if (!shop) { + shop = authStore.shop + } + if (!host) { + host = authStore.host + } + if (!shop || !host) { + console.error("Shop or host is missing, cannot proceed further."); + error.value.message = "Please contact the administrator."; + return; + } + const loginPayload = {} as any; + let loginResponse; + const maargUrl = JSON.parse(process.env.VUE_APP_SHOPIFY_SHOP_CONFIG)[shop].maarg; + let shopifyAppBridge; + try { + shopifyAppBridge = await createShopifyAppBridge(shop, host); + const shopifySessionToken = await getSessionTokenFromShopify(shopifyAppBridge); + const appState: any = await shopifyAppBridge.getState(); + + if (!appState) { + throw new Error("Couldn't get Shopify App Bridge state, cannot proceed further."); + } + // Since the Shopify Admin doesn't provide location and user details, + // we are using the app state to get the POS location and user details in case of POS Embedded Apps. + loginPayload.sessionToken = shopifySessionToken; + if (appState.pos?.location?.id) { + loginPayload.locationId = appState.pos.location.id + } + if (appState.pos?.user?.firstName) { + loginPayload.firstName = appState.pos.user.firstName; + } + if (appState.pos?.user?.lastName) { + loginPayload.lastName = appState.pos.user.lastName; + } + + loginResponse = await loginShopifyAppUser(`${maargUrl}/rest/s1/`, loginPayload); + + if (!loginResponse?.token) { + throw new Error("Login response doesn't have token, cannot proceed further."); + } + } catch (e) { + console.error("Error ", e); + error.value.message = "Please contact the administrator."; + return; + } + + const loginToken = loginResponse.token; + const omsInstanceUrl = loginResponse.omsInstanceUrl; + const expiresAt = loginResponse.expiresAt; + const appConfig: any = loginContext.getConfig(); + // Switch Maarg and OMS URLs for Moqui first Apps + const isMoquiFirst = appConfig.systemType === "MOQUI"; + await handleUserFlow(loginToken, isMoquiFirst ? maargUrl : omsInstanceUrl, expiresAt, isMoquiFirst ? omsInstanceUrl : maargUrl, true, shop, host, shopifyAppBridge); } diff --git a/src/index.ts b/src/index.ts index c11077a5..15358bac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import { createPinia } from "pinia"; import { useProductIdentificationStore } from "./store/productIdentification"; import { useAuthStore } from "./store/auth"; import { DxpAppVersionInfo, DxpFacilitySwitcher, DxpGitBookSearch, DxpImage, DxpLanguageSwitcher, DxpLogin, DxpMenuFooterNavigation, DxpOmsInstanceNavigator, DxpPagination, DxpProductIdentifier, DxpProductStoreSelector, DxpShopifyImg, DxpTimeZoneSwitcher, DxpUserProfile } from "./components"; -import { goToOms, getProductIdentificationValue, getAppLoginUrl } from "./utils"; +import { goToOms, getProductIdentificationValue, getAppLoginUrl, openPosScanner } from "./utils"; import { initialiseFirebaseApp } from "./utils/firebase" import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import { createI18n } from 'vue-i18n' @@ -171,5 +171,6 @@ export { useProductIdentificationStore, useUserStore, userContext, - getAppLoginUrl + getAppLoginUrl, + openPosScanner } diff --git a/src/store/auth.ts b/src/store/auth.ts index b3fbc0d0..e3f8fb90 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -13,6 +13,7 @@ export const useAuthStore = defineStore('userAuth', { isEmbedded: false, shop: undefined, host: undefined, + shopifyAppBridge: undefined } }, getters: { diff --git a/src/utils/index.ts b/src/utils/index.ts index 501bb679..885a2d61 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,6 +3,9 @@ import { DateTime } from "luxon"; import { translate, useAuthStore } from "src"; import DxpGitBookSearch from "../components/DxpGitBookSearch.vue"; import { computed, ref } from "vue"; +import createApp from "@shopify/app-bridge"; +import { getSessionToken } from "@shopify/app-bridge-utils"; +import { Scanner, Features, Group, Redirect } from '@shopify/app-bridge/actions'; declare var process: any; @@ -64,10 +67,93 @@ const getAppLoginUrl = () => { } } +const createShopifyAppBridge = async (shop: string, host: string) => { + try { + if (!shop || !host) { + throw new Error("Shop or host missing"); + } + const apiKey = JSON.parse(process.env.VUE_APP_SHOPIFY_SHOP_CONFIG)[shop]?.apiKey; + if (!apiKey) { + throw new Error("Api Key not found"); + } + const shopifyAppBridgeConfig = { + apiKey: apiKey || '', + host: host || '', + forceRedirect: false, + }; + + const appBridge = createApp(shopifyAppBridgeConfig); + + return Promise.resolve(appBridge); + } catch (error) { + console.error(error); + return Promise.reject(error); + } +} + +// TODO: Move this to Utils +const getSessionTokenFromShopify = async (appBridgeConfig: any) => { + try { + if (appBridgeConfig) { + const shopifySessionToken = await getSessionToken(appBridgeConfig); + return Promise.resolve(shopifySessionToken); + } else { + throw new Error("Invalid App Config"); + } + } catch (error) { + return Promise.reject(error); + } +} + +const openPosScanner = (): Promise => { + return new Promise((resolve, reject) => { + try { + const authStore = useAuthStore(); + const app = authStore.shopifyAppBridge; + + if (!app) { + return reject(new Error("Shopify App Bridge not initialized.")); + } + + const scanner = Scanner.create(app); + const features = Features.create(app); + + const unsubscribeScanner = scanner.subscribe(Scanner.Action.CAPTURE, (payload) => { + unsubscribeScanner(); + unsubscribeFeatures(); + resolve(payload?.data?.scanData); + }); + + const unsubscribeFeatures = features.subscribe(Features.Action.REQUEST_UPDATE, (payload) => { + if (payload.feature[Scanner.Action.OPEN_CAMERA]) { + const available = payload.feature[Scanner.Action.OPEN_CAMERA].Dispatch; + if (available) { + scanner.dispatch(Scanner.Action.OPEN_CAMERA); + } else { + unsubscribeScanner(); + unsubscribeFeatures(); + reject(new Error("Scanner feature not available.")); + } + } + }); + + features.dispatch(Features.Action.REQUEST, { + feature: Group.Scanner, + action: Scanner.Action.OPEN_CAMERA + }); + } catch(error) { + reject(error); + } + }); +} + export { getCurrentTime, getProductIdentificationValue, goToOms, showToast, - getAppLoginUrl + getAppLoginUrl, + createShopifyAppBridge, + getSessionTokenFromShopify, + openPosScanner, } \ No newline at end of file