Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion common/components/ShopifyLogin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@
<script setup lang="ts">
import { IonContent, IonIcon, IonItem, IonPage, onIonViewDidEnter, onIonViewDidLeave } from "@ionic/vue";
import { ref } from "vue";
import { emitter, translate, useShopify, useEmbeddedAppStore } from "../index";
import { commonUtil, emitter, translate, useShopify, useEmbeddedAppStore } from "../index";
import Logo from "./Logo.vue";
import { accxuiConfig } from "../core/configRegistry";
import { warningOutline } from "ionicons/icons";
import { useAuth } from "../composables/useAuth"

const { appBridgeLogin } = useShopify();
const embeddedAppStore = useEmbeddedAppStore();
Expand All @@ -41,6 +42,17 @@ onIonViewDidEnter(async () => {

let { shop, host } = route.query;

console.log('router.', shop, host, import.meta.env.VITE_SHOPIFY_SHOP_CONFIG)

// Resolve the pinned app version BEFORE touching App Bridge. Shopify's App URL can't carry a version,
// so an embedded session always starts unversioned and may need one redirect — doing it first means we
// don't throw away a completed bridge handshake and session-token exchange, and App Bridge is created
// exactly once, on the page that will actually run. This needs no token (`appVersions` is an anonymous
// endpoint) and no login round trip: the shop's Maarg instance comes from the build-time shop config.
const shopConfig = commonUtil.jsonParse(import.meta.env.VITE_SHOPIFY_SHOP_CONFIG)?.[shop as string];
// A redirect is a pending page load — stop here rather than starting work that gets torn down.
if (await useAuth().fetchAppVersion(commonUtil.getMaargURL(shopConfig?.maarg))) return;

const success = await appBridgeLogin(shop as string, host as string);

if (success) {
Expand Down
114 changes: 114 additions & 0 deletions common/composables/appVersionRedirect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// @vitest-environment jsdom

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";

// Each scenario needs a fresh module instance: the in-flight flag and the "reconciled once" flag are
// module-scoped, standing in for a page load. resetModules() + dynamic import is how we simulate a reload.
const loadUseAuth = async () => {
vi.resetModules();
const [{ useAuth }, { accxuiConfig }] = await Promise.all([
import("./useAuth"),
import("../core/configRegistry")
]);
return { useAuth, accxuiConfig };
};

const setBuildVersion = (buildVersion: string) =>
vi.stubEnv("VITE_APP_VERSION_CONFIG", JSON.stringify({ appId: "BOPIS", environmentTypeId: "AppEnvDev", buildVersion }));

const setUrl = (url: string) => window.history.replaceState({}, "", url);

// Simulate one page load: bundle built as `buildVersion`, served at `pathname`, OMS pinning `pinned`.
// Returns whether a redirect was issued and where to, plus the composable for follow-up calls.
const load = async (buildVersion: string, pathname: string, pinned: string) => {
setBuildVersion(buildVersion);
setUrl(pathname);
const { useAuth, accxuiConfig } = await loadUseAuth();
accxuiConfig.value.appVersion = pinned;

const replace = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: { ...window.location, pathname, search: "", hash: "", origin: "https://app.test", replace }
});

const auth = useAuth();
const redirected = auth.checkAppVersionRedirect();
return { redirected, to: replace.mock.calls[0]?.[0] as string | undefined, auth };
};

describe("checkAppVersionRedirect", () => {
beforeEach(() => {
setActivePinia(createPinia());
sessionStorage.clear();
});

afterEach(() => vi.unstubAllEnvs());

it("redirects the root build onto the pinned version", async () => {
const { redirected, to } = await load("", "/", "v5.1.0");

expect(redirected).toBe(true);
expect(to).toContain("/v5.1.0/");
});

it("reports the in-flight redirect to later callers in the same page load", async () => {
const { auth } = await load("", "/", "v5.1.0");

// Login.vue resolves the version from more than one path; the router guard fires per navigation.
expect(auth.checkAppVersionRedirect()).toBe(true);
expect(auth.checkAppVersionRedirect()).toBe(true);
});

it("does nothing once the pinned version is the one being served", async () => {
sessionStorage.setItem("appVersionRedirectPending", "v5.1.0");
const { redirected, to } = await load("v5.1.0", "/v5.1.0/tabs/orders", "v5.1.0");

expect(redirected).toBe(false);
expect(to).toBeUndefined();
// The landed attempt must not be recorded as a failure, or a later rollback here would be refused.
expect(sessionStorage.getItem("appVersionRedirectFailed")).toBeNull();
});

it("moves to a newly pinned version when the backend changes", async () => {
const { redirected, to } = await load("v5.1.0", "/v5.1.0/tabs/orders", "v5.2.0");

expect(redirected).toBe(true);
expect(to).toContain("/v5.2.0/tabs/orders");
});

it("converges to root — without oscillating — when the pinned version isn't deployed", async () => {
// 1. Root build asks for v5.1.0.
const first = await load("", "/", "v5.1.0");
expect(first.to).toContain("/v5.1.0/");

// 2. The host's catch-all served the root bundle at the versioned path, so the attempt didn't land.
const second = await load("", "/v5.1.0/", "v5.1.0");
expect(second.redirected).toBe(true);
expect(second.to).toBe("/");
expect(sessionStorage.getItem("appVersionRedirectFailed")).toBe("v5.1.0");

// 3. Back at root: v5.1.0 is known-unreachable, so we stay put instead of bouncing back to it.
const third = await load("", "/", "v5.1.0");
expect(third.redirected).toBe(false);
expect(third.to).toBeUndefined();
});

it("still honours a different version after one proved unreachable", async () => {
sessionStorage.setItem("appVersionRedirectFailed", "v5.1.0");
const { redirected, to } = await load("", "/", "v5.2.0");

expect(redirected).toBe(true);
expect(to).toContain("/v5.2.0/");
});

it("no-ops while the version is still unresolved", async () => {
setBuildVersion("");
setUrl("/");
const { useAuth, accxuiConfig } = await loadUseAuth();
accxuiConfig.value.appVersion = undefined;

expect(useAuth().checkAppVersionRedirect()).toBe(false);
});
});
144 changes: 131 additions & 13 deletions common/composables/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,78 @@ const isMoquiOmsRef = ref(commonUtil.isMoqui())
const token = ref(cookieHelper().get("token") || "")
const expirationTime = ref(cookieHelper().get("expirationTime") || "")

// Loop protection for version redirects, scoped to the tab: sessionStorage survives the reload a redirect
// causes, which is exactly the span we need to reason across, and a new tab starts clean. Access is
// guarded because embedded webviews can restrict storage — losing loop protection must never block the
// redirect itself.
const PENDING_VERSION_KEY = "appVersionRedirectPending"
const FAILED_VERSIONS_KEY = "appVersionRedirectFailed"

const readSession = (key: string) => {
try {
return sessionStorage.getItem(key)
} catch (e) {
return null
}
}

const writeSession = (key: string, value: string) => {
try {
sessionStorage.setItem(key, value)
} catch (e) {
// Storage unavailable — proceed without loop protection rather than stranding the user.
}
}

const clearSession = (key: string) => {
try {
sessionStorage.removeItem(key)
} catch (e) {
// As above.
}
}

const getFailedVersions = () => (readSession(FAILED_VERSIONS_KEY) || "").split(",").filter(Boolean)

// A redirect issued during THIS page load. window.location.replace() doesn't stop the current task, and
// several callers can run before the navigation happens (Login.vue resolves the version from more than
// one path; the router guard fires on every navigation). They must be told a redirect is already in
// flight, not treated as a fresh attempt.
let versionRedirectIssued = false

// Settle the previous page load's attempt, once per load. If the version we redirected to isn't the
// version actually being served, the host isn't serving what the OMS named: record it so we stop
// targeting it, otherwise root and that version bounce off each other forever. A redirect that landed
// records nothing, so a later backend change — including a rollback to a version we already ran — still
// redirects normally.
let versionAttemptReconciled = false

const reconcileVersionAttempt = () => {
if (versionAttemptReconciled) return
versionAttemptReconciled = true

const pending = readSession(PENDING_VERSION_KEY)
if (pending === null) return
clearSession(PENDING_VERSION_KEY)

// Redirecting to root is the fallback and always lands, so only a named version can fail.
if (!pending || pending === commonUtil.getBuildVersion()) return

const failed = getFailedVersions()
if (!failed.includes(pending)) writeSession(FAILED_VERSIONS_KEY, [...failed, pending].join(","))
logger.error(`App version "${pending}" is not served by this host; falling back to the root build for this session.`)
}

// Pinia isn't active at module-import time (and never in a bare unit context), so resolve the store
// defensively — mirrors commonUtil's own guarded accessor.
const getEmbeddedAppStoreSafe = () => {
try {
return useEmbeddedAppStore()
} catch (e) {
return undefined
}
}

export function useAuth() {
const getDuration = (expirationTime?: any) => {
const expiry = (expirationTime !== undefined && expirationTime !== null) ? expirationTime : commonUtil.getTokenExpiration();
Expand Down Expand Up @@ -58,9 +130,19 @@ export function useAuth() {
let isOmsVerified = false;
let isUserVerified = false;

if (!token.value || !expirationTime.value) return false;
// An embedded (Shopify) session keeps its credentials in the embedded-app store, not in cookies:
// document.cookie is unreliable inside Shopify's cross-origin iframe, since SameSite=Lax cookies
// aren't sent in a third-party context. So read the store first and fall back to the cookie-backed
// refs for standalone sessions — those stay reactive because updateToken() writes them. Reading the
// store's getters here (rather than commonUtil.getToken(), which resolves the cookie non-reactively)
// is what keeps this computed invalidating on both paths.
const embeddedAppStore = getEmbeddedAppStoreSafe();
const currentToken = embeddedAppStore?.getToken || token.value;
const currentExpiration = embeddedAppStore?.getTokenExpiration || expirationTime.value;

Comment on lines +141 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate embedded credentials on the embedded session

When a merchant opens the standalone app on the same origin after an embedded session, the persisted embedded store still contains its token and expiration because only shopifyAppBridge is omitted from persistence. These lines therefore prefer those credentials even though commonUtil.isAppEmbedded() is false: an expired embedded token makes a valid cookie-backed standalone session appear unauthenticated, while a live one is paired with the store-first token/OMS helpers and can send requests using the previous embedded session. Select these values from the store only when the current page is actually embedded, or clear the embedded credentials when entering standalone mode.

Useful? React with 👍 / 👎.

if (!currentToken || !currentExpiration) return false;

const expiry = Number(expirationTime.value);
const expiry = Number(currentExpiration);
if(expiry) {
const currTime = DateTime.now().toMillis();
isTokenExpired = expiry < currTime;
Expand Down Expand Up @@ -201,9 +283,10 @@ export function useAuth() {
localStorage.removeItem("requestedPagePath")

if (commonUtil.isAppEmbedded()) {
const embeddedAppStore = useEmbeddedAppStore();
redirectionUrl = window.location.origin + '/shopify-login?shop=' + embeddedAppStore.shop + '&host=' + embeddedAppStore.host + '&embedded=1';
embeddedAppStore.$reset();
// Build the entry URL before the reset, while shop/host are still in the store, and let the helper
// keep the version segment of the path we're on.
redirectionUrl = commonUtil.getEmbeddedAppEntryUrl();
useEmbeddedAppStore().$reset();
}

if(redirectionUrl) {
Expand Down Expand Up @@ -277,17 +360,49 @@ export function useAuth() {
// (not resolved yet — acting would risk a premature/looping redirect) or already canonical. Shared by
// the router guard (every navigation) and fetchAppVersion (right after it resolves the version).
const checkAppVersionRedirect = () => {
// A redirect is already in flight for this page load: report it as such so callers bail, rather than
// letting a second caller treat it as a fresh attempt (and, by returning false, let an in-app
// navigation proceed and supersede the pending page load).
if(versionRedirectIssued) return true;

const configuredVersion = accxuiConfig.value.appVersion;
if(configuredVersion === undefined) return false;

const canonicalPath = getCanonicalPath(configuredVersion, window.location.pathname);
reconcileVersionAttempt();

// Hosting's catch-all rewrite serves the root bootstrap for any version it doesn't have, so the URL
// can claim a version this bundle isn't. Getting back onto a path this deployment can actually serve
// takes priority over honouring the OMS's answer — otherwise the versioned path matches no route and
// renders a blank outlet. The loop guard below then stops us being sent straight back.
const undeployedVersion = commonUtil.getUndeployedVersion();
// A version already proven unreachable this session is never targeted again — otherwise root and that
// version bounce off each other forever, and there is no address bar to escape from in Shopify POS.
const wantedVersion = getFailedVersions().includes(configuredVersion) ? "" : configuredVersion;
const targetVersion = undeployedVersion ? "" : wantedVersion;

const canonicalPath = getCanonicalPath(targetVersion, window.location.pathname);
if(canonicalPath === null) return false;

// Remember what we're about to try so the next load can tell whether it landed.
writeSession(PENDING_VERSION_KEY, targetVersion);
versionRedirectIssued = true;

// A version switch is always a full page load, which destroys the live App Bridge instance. An
// embedded session can only rebuild it by going through /shopify-login, so send it there (carrying
// shop/host) instead of to the canonical path — landing anywhere else leaves it with no bridge.
if(commonUtil.isAppEmbedded()) {
window.location.replace(commonUtil.getEmbeddedAppEntryUrl(targetVersion));
return true;
}

window.location.replace(`${canonicalPath}${window.location.search}${window.location.hash}`);
return true;
};

const fetchAppVersion = async () => {
// `baseURL` lets the caller name the backend to ask before the session knows its own — the embedded
// flow resolves the version from the shop's Maarg instance ahead of login. Returns true when a redirect
// was issued, so callers can stop instead of continuing into a page that is being torn down.
const fetchAppVersion = async (baseURL?: string) => {
try {
// appId (endpoint path) and environmentTypeId come from the single multi-version config object
// (VITE_APP_VERSION_CONFIG), so they match this deployment's app rather than being hardcoded.
Expand All @@ -298,21 +413,24 @@ export function useAuth() {
params: {
appId,
environmentTypeId
}
},
...(baseURL ? { baseURL } : {})
});

const appVersions = Array.isArray(resp.data) ? resp.data : resp.data?.docs;
const configuredVersion = appVersions?.[0]?.currentVersion;

// Persist the OMS's answer, then move the Login page onto that version's canonical URL.
accxuiConfig.value.appVersion = configuredVersion || "";
checkAppVersionRedirect();
return checkAppVersionRedirect();
} catch (error) {
// The call failed outright (endpoint unreachable/absent, or the config JSON was unparseable).
// Resolve to "" so the app runs unversioned at root instead of staying unresolved.
accxuiConfig.value.appVersion = "";
checkAppVersionRedirect();
// The call failed outright (endpoint unreachable/absent, or the config JSON was unparseable). Don't
// demote a session already running a version this deployment serves — a transient OMS outage must
// not move every merchant onto the root build. Otherwise resolve to "" and run unversioned at root.
const runningVersion = commonUtil.getBuildVersion();
accxuiConfig.value.appVersion = runningVersion && !commonUtil.getUndeployedVersion() ? runningVersion : "";
logger.error(error);
return checkAppVersionRedirect();
}
};

Expand Down
5 changes: 4 additions & 1 deletion common/composables/useShopify.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Scanner, Features, Group, Redirect } from '@shopify/app-bridge/actions';
import { useEmbeddedAppStore } from "../store/embeddedApp";
import { commonUtil } from "../utils/commonUtil";
import { createApp } from "@shopify/app-bridge";
import { getSessionToken } from "@shopify/app-bridge-utils";
import api from '../core/remoteApi';
Expand Down Expand Up @@ -136,7 +137,9 @@ const openPosScanner = (): Promise<any> => {
url: `app-bridge/login`,
method: 'post',
data: loginPayload,
baseURL: `${maargUrl}/rest/s1/`
// Route through getMaargURL so a shop config holding a bare instance name ("demo") resolves the
// same way as a full URL — string-concatenating /rest/s1/ onto it produced an invalid base.
baseURL: commonUtil.getMaargURL(maargUrl)
});

if (!loginResp.data.token || !loginResp.data.omsInstanceUrl) {
Expand Down
5 changes: 4 additions & 1 deletion common/store/embeddedApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,8 @@ export const useEmbeddedAppStore = defineStore('embeddedApp', {
this.token.expiration = expiration;
}
},
persist: true
// `shopifyAppBridge` must never be persisted: it is a live App Bridge client whose methods are
// dropped by JSON serialization, so rehydrating it yields a truthy but non-functional object that
// outlives the embedded session it belonged to. It is recreated by appBridgeLogin on every load.
persist: { omit: ["shopifyAppBridge"] }
});
Loading
Loading