Shopify OAuth popup cannot auto-close after successful install when initiated from admin.shopify.com (Admin shell reclaiming window)

Hi Shopify team :waving_hand: — I’m hoping a Shopify engineer can confirm whether this behavior is expected or if there’s a supported workaround.

I have a Shopify OAuth flow that is technically correct, fully validated, and completes successfully — but the OAuth popup cannot auto-close when the flow is initiated from admin.shopify.com. The popup gets “reclaimed” by the Shopify Admin shell after install.

This happens consistently across:

  • Dev stores

  • Custom apps

  • Legacy install flow

  • Production-equivalent configuration


What works

:white_check_mark: OAuth succeeds
:white_check_mark: HMAC verification passes
:white_check_mark: Access token is exchanged and stored
:white_check_mark: Webhooks registered
:white_check_mark: Shopify data sync triggered
:white_check_mark: My edge function returns a 302 redirect to my app domain
:white_check_mark: The redirect target is same-origin with my app
:white_check_mark: No CSP / X-Frame / COOP console errors


Observed behavior

After OAuth completes, the popup ends up at:

https://admin.shopify.com/store/{store}/apps/oauth/callback?success=true&...

Which shows a 404 page inside Shopify Admin, and the popup never closes.


Critical network evidence

From Chrome DevTools → Network (popup window):

Final OAuth callback request

GET https://{project}.supabase.co/functions/v1/oauth-shopify?code=...

Response

Status: 302
Location: https://app.firefl.ai/oauth/callback?success=true&business_id=...&shop=...&oauth_request_id=...

:right_arrow: This confirms my edge function is emitting an absolute redirect to my app domain, not a relative path.


Next navigation request

GET https://app.firefl.ai/oauth/callback?success=true&...
Status: 200

But immediately after, Shopify Admin forces:

302 → /admin/auth/login?original_uri=...

At that point, my callback page JS no longer controls the window.


Why this breaks popup close

Even though my callback page includes all standard mechanisms:

  • window.close()

  • window.opener.postMessage

  • localStorage handshake

  • BroadcastChannel

…Shopify Admin reclaims the popup window before JS can reliably close it.

This makes it impossible to provide a clean OAuth UX without a “You may now close this window” dead-end screen.


Relevant code (simplified)

Edge function final redirect

const callbackUrl = new URL(`${appUrl}/oauth/callback`);
callbackUrl.searchParams.set('success', 'true');
callbackUrl.searchParams.set('business_id', targetBusinessId);
callbackUrl.searchParams.set('shop', shop);

return new Response(null, {
  status: 302,
  headers: {
    'Location': callbackUrl.toString(),
    'Cache-Control': 'no-store',
  },
});

Frontend callback page

<script>
  if (window.opener) {
    window.opener.postMessage({ type: 'SHOPIFY_CONNECT_SUCCESS' }, '*');
  }
  try { window.close(); } catch {}
</script>


Key question

Is there a Shopify-supported way to:

  • Allow an OAuth popup to auto-close after install

  • When the OAuth flow is initiated from admin.shopify.com

  • Without relying on user manual close or redirects back into Admin?

Or is this behavior by design due to the Admin shell reclaiming navigation?

If it’s by design, confirmation would be incredibly helpful so we can design UX around this limitation.

Thanks in advance — happy to provide a full HAR file or repro steps if needed :folded_hands:


Hey @jesse_epstein, thanks for reaching out.

Just wanted to ask a few clarifying questions to make sure I understand your setup correctly. First, is this app intended to be an embedded app (running inside the Shopify Admin iframe) or a non-embedded/external app? And second, can you walk me through where exactly the OAuth flow is being initiated from? For example, is the user clicking a button from within your external app at app.firefl.ai, or are they starting this flow from somewhere inside the Shopify Admin itself?

The reason I ask is that popup-based OAuth flows aren’t anofficially supported pattern for Shopify app installation. For embedded apps, the recommended approach is to use Shopify managed installation with token exchange, which avoids redirects entirely and provides a smoother UX (https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/token-exchange). For non-embedded apps, the authorization code grant flow is designed around full-page redirects rather than popups (https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant). You also can’t perform redirects from inside an iframe in the Shopify admin due to X-Frame-Options restrictions, so I’m wondering if this is contributing to the issue here.

There’s also a requirement that apps must redirect merchants to the app UI after installation (https://shopify.dev/docs/apps/launch/shopify-app-store/app-store-requirements), but that’s typically handled via standard redirects rather than popup window management.

Once I understand your setup a bit better, I can point you in the right direction for the cleanest solution here. Looking forward to hearing back!

hey @Alan_G thanks for the quick reply. i’m actually creating an external app on app.pureprofit.ai

i’ve got the app configured to Public with the intent on having it unlisted so that all signups happen and are managed on my own site.

let me know if you need anything more from me.

also, thought i’d throw this in there.

i’m having trouble auto-closing my shopify oauth popup window after successful install. the html of the popup says connection successful. any ideas here?

@Alan_G ok, so i’ve got the webhooks working. but now after successful connection the oauth popup is showing 404 in the popup. I’m still failing the immediate redirects after authentication

popup url:

ok, so everything has been updated on our end. here’s the shopify oauth popup after successful connection.

popup url

https://admin.shopify.com/store/pureprofit-dev-15/apps/settings/integrations?success=true&business_id=c7768ad4-7414-443c-ae73-bb5905478359&source=store_switcher&oauth_request_id=3742d458-7ed5-4618-8e30-c3c945dd0d90

is there a way to solve this where the popup window auto closes?

here’s my app configuration showing my redirect url. my Shopify oauth edge function is also using the same redirect url.

note - here’s what i’ve tried

You’re absolutely right to call this out. Let me check what was actually in place before and understand why it failed.

Now let me see the buildCallbackUrl function to understand what URL it’s building:

Now I understand the full picture. Let me check what the memory file says about the JavaScript breakout that was tried before:

Ah - I see it now! The memory file reveals the critical history:

  1. JavaScript breakout was tried FIRST (and failed)
  2. 302 redirect was the REVERT/fallback (current state)
  3. 302 redirect is ALSO failing (Shopify intercepts it)

So we’ve tried both approaches and both failed. The fundamental problem is:

  • 302 redirects: Shopify intercepts the HTTP Location header and rewrites it
  • JavaScript breakout: Must have also been intercepted somehow

Let me search for what the actual JavaScript breakout implementation looked like before, to understand why it failed:

Let me look at that full backup to understand what the HTML approach looked like:

Let me see the actual HTML success page implementation that was in place before:

Now I understand the critical difference:

The Old HTML Approach (That Failed)

Looking at lines 653-740, the previous HTML implementation:

  • Showed a static “Connection Successful!” page
  • Had a button onclick="window.close()"
  • DID NOT have any window.location.href redirect
  • DID NOT signal success to the parent window (no localStorage, no BroadcastChannel, no postMessage)

Why it failed Shopify’s check: Shopify’s automated test expects “Immediately redirects to app UI after authentication”. A static HTML page with a “Close Window” button is NOT a redirect - it’s a termination.

The 302 Approach (Current - Also Failing)

The current 302 redirect to /settings/integrations:

  • Returns HTTP 302 with Location header
  • Shopify intercepts at the protocol level and rewrites to admin.shopify.com

What I Proposed (New JavaScript Breakout)

My proposal was different from the old HTML approach:

  • Return HTML with immediate JavaScript redirect: window.location.href = "...";
  • The JavaScript executes in the browser, triggering client-side navigation
  • This bypasses Shopify’s HTTP-level 302 interception

But wait - looking at your screenshot, the popup URL is:

https://admin.shopify.com/store/pureprofit-dev-15/apps/settings/integrations?success=true&...

This means Shopify is already intercepting before our edge function even returns! The issue is that Shopify is rewriting the callback URL they redirect to after OAuth authorization, not our response.

Let me verify what’s happening in the OAuth flow:

Now I fully understand the problem. Let me check what redirectUri is set to:

Now I understand the complete OAuth flow and where the interception happens:

The Real Problem

The OAuth flow is:

  1. Start: PureProfit → oauth-shopify edge function → 302 to https://{shop}/admin/oauth/authorize?...&redirect_uri=https://ouknuoejkmiapsxepgcc.supabase.co/functions/v1/oauth-shopify
  2. User authorizes: Shopify → 302 back to our edge function with code and state
  3. Token exchange: Edge function exchanges code, stores token
  4. Final redirect: Edge function returns 302 to https://app.pureprofit.ai/settings/integrations

The problem is in step 4. The edge function IS returning a correct 302 to https://app.pureprofit.ai/settings/integrations, but because the OAuth happens inside Shopify Admin context (embedded app frame or popup opened from Admin), Shopify intercepts the 302 and rewrites the Location header.

Why Both Approaches Failed

Approach What It Did Why It Failed
Old HTML (pre-302) Showed static success page with “Close Window” button Shopify check failed: “Not immediately redirecting to app UI”
302 Redirect (current) Returns HTTP 302 to /settings/integrations Shopify intercepts at protocol level, rewrites URL
My proposed JS Breakout Returns HTML with window.location.href = "..." This is different! JS executes client-side after the page loads

The Critical Difference with JavaScript Breakout

The JavaScript approach should work because:

  1. Edge function returns 200 OK with HTML content type
  2. Browser renders the HTML page
  3. JavaScript executes: window.location.href = "https://app.pureprofit.ai/oauth/callback?..."
  4. Browser performs navigation at the JavaScript level, not HTTP level
  5. Shopify’s HTTP 302 interception doesn’t apply to window.location assignments

The old HTML approach did not use window.location.href - it was a static page. My proposal explicitly includes the JavaScript redirect, which is fundamentally different.

However, if the JavaScript breakout was truly tried before and also failed… we need to understand if Shopify is doing something more aggressive like Content Security Policy (CSP) blocking or script injection.

Let me search for any evidence of what specifically failed with the JS breakout:

The memory file says “in case the JavaScript breakout solution fails” - but looking at the actual backup code (lines 653-740), the old implementation was a static HTML page, NOT a JavaScript breakout.

The old HTML had:

  • Close Window
  • NO window.location.href redirect

Conclusion: The true JavaScript breakout (with window.location.href) was never actually implemented. The confusion is that:

  1. The “HTML success page” (old approach) ≠ “JavaScript breakout”
  2. The memory file’s language is misleading - it suggests JS breakout was tried, but the actual code shows it wasn’t

Let me verify this understanding is correct by checking if there was ever a window.location.href in the old code:

Confirmed: There was never a window.location.href in the old backup. The “JavaScript breakout” I’m proposing is genuinely new and different from what was tried before.

Summary for You

You’re right to question this, but here’s the key difference:

What Was Tried Before What I’m Proposing Now
Static HTML success page with “Close Window” button HTML page with window.location.href = "..."
No redirect - just shows “Connection Successful!” Immediately executes JavaScript redirect
Failed Shopify check: “Not redirecting to app UI” Should satisfy check: JS redirect IS a redirect
User had to click button to close window Auto-navigates to callback page, auto-closes

The old approach was named “JavaScript breakout” in the memory files, but the actual implementation was NOT a JavaScript redirect. It was a static HTML page. My proposal adds the actual JavaScript navigation that was missing.

The risk: We don’t know if Shopify’s embedded context blocks JavaScript navigation too. But it’s worth trying because:

  1. It’s technically different from what was tried
  2. It bypasses HTTP-level 302 interception
  3. Other apps successfully use this pattern

here’s the latest i tried. and what grok and claude have told me and what’s happening

ok.

lovable’s thoughts on your recommendation

I appreciate Grok’s analysis, but I respectfully disagree with the core diagnosis. Let me explain why changing the path name won’t solve this problem.

The Real Problem: 302 Interception, Not Path Names

Look at the pattern from both attempts:

Redirect Path We Used What Shopify Rewrote It To
/omni-ai admin.shopify.com/.../apps/omni-ai
/settings/integrations admin.shopify.com/.../apps/settings/integrations
Notice: Shopify takes whatever path we use and appends it to admin.shopify.com/store/[store]/apps/[our-path].
This is NOT pattern matching on words like “settings” or “integrations”. Shopify’s embedded OAuth framework intercepts ALL HTTP 302 redirects and forces them into the admin context.

Why /connect-success Will Fail Too

If we change to /connect-success, the result will be:

https://admin.shopify.com/store/pureprofit-dev-15/apps/connect-success?success=true&...

Same problem, different path. The interception happens at the HTTP protocol level, not the path pattern level.

The Correct Solution: JavaScript Breakout

The only way to bypass Shopify’s 302 interception is to not use a 302 redirect. Instead:

  1. Return an HTML page with status 200
  2. The HTML contains window.location.href = "..."
  3. The browser executes the JavaScript and navigates to our domain
  4. Shopify cannot intercept JavaScript-based navigation
    This is the plan I outlined in my previous response. Here’s why it works:
  • Shopify intercepts HTTP Location headers from 302 responses
  • Shopify does NOT intercept or modify HTML content
  • JavaScript window.location.href executes in the browser, outside Shopify’s control

Evidence This Approach Works

This is a well-documented pattern for escaping embedded app contexts. The memory file memory/shopify-oauth-302-redirect-backup.md even mentions:

The 302 redirects were working correctly at the edge function level… but Shopify Admin was intercepting them at the browser level
The backup was created anticipating this exact scenario.

My Recommendation

Proceed with the JavaScript breakout implementation I outlined previously:

  1. Edge function returns HTML + JS (not 302)

  2. Redirect target is /oauth/callback (already exists and handles success signaling)

  3. OAuthCallback.tsx writes to localStorage, sends BroadcastChannel, tries to close popup

  4. Parent window detects success via existing polling mechanisms
    Would you like me to create a formal plan for this implementation? It’s a small change to the edge function - replacing the 302 response with an HTML response containing a JavaScript redirect.
    Implement JavaScript breakout for OAuth
    Show me the code changes

    lovable implementation
    I’ll save the current implementation to a backup memory file, then read the edge function to implement the JavaScript breakout approach.
    Let me see the rest of the file to find the redirect logic:
    Now I have everything I need. Let me implement the JavaScript breakout approach - replacing the 302 redirect with an HTML page that uses window.location.href:
    \n\n\n \n \n Connecting… - PureProfit.ai\n \n body { \n background: #0a0a1a; \n color: #fff; \n font-family: -apple-system, BlinkMacSystemFont, ‘Segoe UI’, Roboto, sans-serif; \n display: flex; \n justify-content: center; \n align-items: center; \n min-height: 100vh;\n margin: 0;\n }\n .container { text-align: center; padding: 2rem; }\n .spinner { \n width: 40px; height: 40px; \n border: 3px solid rgba(42, 255, 181, 0.3);\n border-top-color: #2AFFB5;\n border-radius: 50%;\n animation: spin 1s linear infinite;\n margin: 0 auto 1rem;\n }\n @keyframes spin { to { transform: rotate(360deg); } }\n p { color: #9ca3af; margin-bottom: 1.5rem; }\n a { color: #2AFFB5; text-decoration: none; }\n a:hover { text-decoration: underline; }\n \n\n\n \n \n Completing connection…\n \n If you’re not redirected, click here.\n \n \n \n // JavaScript Breakout: Immediate client-side redirect\n // This bypasses Shopify’s HTTP 302 interception since it’s a browser-side navigation\n window.location.href = \“${callbackUrl}\”;\n \n\n;\\n}\"}"> window.location.href = \\\"${callbackUrl}\\\";\\n```\\n\\n**Target URL:** https://app.pureprofit.ai/oauth/callback?success=true&…`\\n\\n## Allowed Redirect URLs in Shopify Partner Dashboard\n\n1. https://ouknuoejkmiapsxepgcc.supabase.co/functions/v1/oauth-shopify\n2. https://app.pureprofit.ai/omni-ai\n3. https://app.pureprofit.ai/settings/integrations\n4. https://app.pureprofit.ai/oauth/callback (ADD THIS if not present)\n\n## Success Redirect Flow\n\n1. User completes Shopify OAuth authorization\n2. Edge function exchanges code for token\n3. Edge function stores credentials\n4. Edge function returns HTTP 200 HTML with JavaScript redirect\n5. Browser executes window.location.href → navigates to /oauth/callback\n6. OAuthCallback.tsx writes success to localStorage/BroadcastChannel\n7. Parent window detects success, closes popup, shows toast\n\n## Error Redirect Flow\n\nOn OAuth error, still uses 302 redirect to:\n- /settings/integrations?shopify_error=... for settings source\n- /onboarding?shopify_error=... for onboarding source\n\n## Backup\n\n302 redirect implementation backup available at:\nmemory/shopify-oauth-302-redirect-backup.md"}">
    Now let me deploy the edge function:
    Done! Implemented the true JavaScript breakout approach:

  5. Changed redirect mechanism: Now returns HTTP 200 HTML page with window.location.href = "${callbackUrl}"; instead of HTTP 302

  6. Updated target path: Redirects to /oauth/callback (which already handles success signaling via localStorage/BroadcastChannel)

  7. Saved backup: Current 302 implementation preserved at memory/shopify-oauth-302-redirect-backup.md
    The browser will execute the JavaScript redirect client-side, bypassing Shopify’s HTTP-level 302 interception. Test the OAuth flow now!

    after implementation of lovable’s plan here’s what’s happening

    oauth popup url
    PureProfit.ai - AI Profit Operating Platform for E-commerce

    popup html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Connecting... - PureProfit.ai</title>
  <style>
    body { 
      background: #0a0a1a; 
      color: #fff; 
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 
      display: flex; 
      justify-content: center; 
      align-items: center; 
      min-height: 100vh;
      margin: 0;
    }
    .container { text-align: center; padding: 2rem; }
    .spinner { 
      width: 40px; height: 40px; 
      border: 3px solid rgba(42, 255, 181, 0.3);
      border-top-color: #2AFFB5;
      border-radius: 50%;
      animation: spin 1s linear infinite;
      margin: 0 auto 1rem;
    }
    @keyframes spin { to { transform: rotate(360deg); } }
    p { color: #9ca3af; margin-bottom: 1.5rem; }
    a { color: #2AFFB5; text-decoration: none; }
    a:hover { text-decoration: underline; }
  </style>
</head>
<body>
  <div class="container">
    <div class="spinner"></div>
    <p>Completing connection...</p>
    <p style="font-size: 0.875rem;">
      If you're not redirected, <a href="https://app.pureprofit.ai/oauth/callback?success=true&business_id=c7768ad4-7414-443c-ae73-bb5905478359&shop=pureprofit-dev-16.myshopify.com&source=store_switcher&oauth_request_id=1d679965-e776-4fe1-a536-904e543ce471">click here</a>.
    </p>
  </div>
  <script>
    // JavaScript Breakout: Immediate client-side redirect
    // This bypasses Shopify's HTTP 302 interception since it's a browser-side navigation
    window.location.href = "https://app.pureprofit.ai/oauth/callback?success=true&business_id=c7768ad4-7414-443c-ae73-bb5905478359&shop=pureprofit-dev-16.myshopify.com&source=store_switcher&oauth_request_id=1d679965-e776-4fe1-a536-904e543ce471";
  </script>
</body>
</html>

also trying this

Fix: Shopify OAuth JavaScript Breakout - Meta Refresh Fallback

Problem Discovered

The JavaScript breakout approach is failing because Shopify’s embedded context blocks inline script execution. The user’s popup shows the HTML page being rendered correctly (spinner, “Completing connection…” text) but the <script> tag content is either:

  1. Being stripped by Shopify’s security layer
  2. Blocked from executing by Content Security Policy (CSP)

Evidence: The popup URL remains on ouknuoejkmiapsxepgcc.supabase.co/functions/v1/oauth-shopify?... and the redirect never happens.

Solution: HTML Meta Refresh Redirect

Since JavaScript is blocked, we’ll use an HTML <meta http-equiv="refresh"> tag which works at the browser level and cannot be blocked by CSP. This is a well-known technique for environments that restrict JavaScript.

<meta http-equiv="refresh" content="0;url=https://app.pureprofit.ai/oauth/callback?...">

The content="0;url=..." means:

  • 0 = redirect immediately (0 second delay)
  • url=... = the destination URL

Implementation

Update buildJavaScriptBreakoutHtml function

File: supabase/functions/oauth-shopify/index.ts

Changes:

  1. Add <meta http-equiv="refresh"> tag in the <head> section for immediate redirect
  2. Keep the existing JavaScript as a backup (in case meta refresh is also blocked)
  3. Keep the manual “click here” link as ultimate fallback

Updated HTML Structure

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <!-- PRIMARY: Meta refresh redirect (works even when JS is blocked) -->
  <meta http-equiv="refresh" content="0;url=${callbackUrl}">
  <title>Connecting... - PureProfit.ai</title>
  <style>...</style>
</head>
<body>
  <div class="container">
    <div class="spinner"></div>
    <p>Completing connection...</p>
    <p style="font-size: 0.875rem;">
      If you're not redirected, <a href="${callbackUrl}">click here</a>.
    </p>
  </div>
  <!-- BACKUP: JavaScript redirect (if meta refresh fails) -->
  <script>
    window.location.href = "${callbackUrl}";
  </script>
</body>
</html>

Why This Will Work

Method Mechanism Blocked by CSP?
HTTP 302 Location header Intercepted by Shopify
<script> JavaScript execution Yes - blocked in embedded context
<meta http-equiv="refresh"> Browser-level redirect No - processed before CSP

Meta refresh is processed by the browser at the HTML parsing level, before any Content Security Policy is evaluated. It’s the most reliable way to redirect when JavaScript is restricted.

Technical Details

File to Modify

  • supabase/functions/oauth-shopify/index.ts - Update buildJavaScriptBreakoutHtml function (lines 254-302)

Expected Flow After Fix

  1. Shopify OAuth completes → redirects to edge function
  2. Edge function returns HTML with meta refresh
  3. Browser immediately navigates to app.pureprofit.ai/oauth/callback?...
  4. OAuthCallback page signals success via localStorage/BroadcastChannel
  5. Popup closes, parent window detects success

Deployment

After implementation, the edge function will be automatically deployed and ready for testing.

Here’s Claude’s next suggestion. Is this legit? There’s gotta be an obvious solution to this that I’m missing.

Fix: OAuth Callback Redirect Conflict

Problem Summary

When connecting a new Shopify store via the Store Switcher (or any source), users are unexpectedly redirected to /omni-ai instead of staying on their current page. This is happening because the OAuth callback popup is loading the full app context, which may trigger navigation side effects.

Root Cause

The /oauth/callback route is NOT listed in PUBLIC_MARKETING_PATHS in App.tsx. This means when the popup navigates to /oauth/callback:

  1. The full context stack loads (BusinessProvider, StoreProvider, etc.)
  2. These contexts may trigger re-renders and navigation effects
  3. The popup becomes unpredictable instead of being a simple “signal success and close” page

Solution (3 Parts)

Part 1: Make /oauth/callback a Lightweight Public Page

File: src/App.tsx (line 138)

Add /oauth/callback to the public paths array so it doesn’t load heavy contexts:

const PUBLIC_MARKETING_PATHS = [
  '/', 
  '/privacy', 
  '/terms', 
  '/security-policy', 
  '/legal/quickbooks-eula',
  '/oauth/callback'  // ADD THIS
];

This ensures the popup only renders the simple OAuthCallback component without BusinessProvider, StoreProvider, or any other heavy context that might trigger navigation.

Part 2: Stabilize the OAuthCallback Page

File: src/pages/OAuthCallback.tsx

Update to be a stable success page that:

  1. Signals success via localStorage, BroadcastChannel, and postMessage (already doing this)
  2. Attempts window.close() with multiple retries at different intervals
  3. Shows “Connected! Closing…” instead of redirecting
  4. Removes the redirect fallback - the page should never navigate away

Key changes:

  • Add retry logic: 100ms, 300ms, 500ms, 1000ms, 2000ms delays
  • Update UI to say “Connected! This window should close automatically.”
  • Replace the “Or click here to return to app” link with “Close this window manually if it doesn’t close automatically”
  • Remove window.location.href = returnPath from the handleClose function - just attempt close

Part 3: Confirm Parent Window Handles Navigation Correctly (Already Working)

The parent window navigation is already correctly implemented:

  • GetStarted.tsx: Navigates to /omni-ai after first store connection (correct for onboarding)
  • StoreSwitcher.tsx: Just refetches stores, no navigation (stays on current page)
  • IntegrationsSettings.tsx: Just refetches integrations, no navigation (stays on current page)

No changes needed here.


Technical Implementation Details

OAuthCallback.tsx - New Implementation

const OAuthCallback = () => {
  const [closeFailed, setCloseFailed] = useState(false);
  const params = useMemo(() => new URLSearchParams(window.location.search), []);
  const source = params.get('source') || 'settings';
  
  // Determine return path for manual fallback link
  const returnPath = source === 'onboarding' ? '/onboarding' : '/settings/integrations';

  useEffect(() => {
    const success = params.get('success') === 'true';
    const businessId = params.get('business_id');
    const shop = params.get('shop');
    const oauthRequestId = params.get('oauth_request_id');

    console.log('[OAuthCallback] Page loaded, signaling success...');

    if (success && oauthRequestId) {
      // Signal success via all methods (localStorage, BroadcastChannel, postMessage)
      // ... existing signaling code ...

      // Multiple window.close() attempts with increasing delays
      const closeAttempts = [100, 300, 500, 1000, 2000];
      closeAttempts.forEach((delay, index) => {
        setTimeout(() => {
          try {
            window.close();
          } catch (e) {
            if (index === closeAttempts.length - 1) {
              setCloseFailed(true);
            }
          }
        }, delay);
      });
    }
  }, [params]);

  return (
    <div className="min-h-screen flex items-center justify-center bg-background">
      <div className="text-center p-8 max-w-md">
        <CheckCircle2 className="w-16 h-16 mx-auto text-affirmative mb-6" />
        <h1 className="text-xl font-semibold mb-2">Connected!</h1>
        <p className="text-muted-foreground mb-6">
          {closeFailed 
            ? "Please close this window manually to continue."
            : "This window should close automatically..."}
        </p>
        {closeFailed && (
          <Button onClick={() => window.close()}>
            Close This Window
          </Button>
        )}
      </div>
    </div>
  );
};

Expected Behavior After Fix

Scenario 1: New User Onboarding (GetStarted page)

  1. User clicks Connect Store on /get-started
  2. Popup opens, OAuth completes
  3. Popup navigates to /oauth/callback (lightweight, no heavy contexts)
  4. Popup signals success and closes
  5. Parent window’s GetStarted.tsx detects success → navigates to /omni-ai with toast “Store connected! Welcome to Omni AI!”

Scenario 2: Adding Store via Store Switcher

  1. User clicks “Connect Another Store” from sidebar Store Switcher (on any page)
  2. Popup opens, OAuth completes
  3. Popup navigates to /oauth/callback (lightweight, no heavy contexts)
  4. Popup signals success and closes
  5. Parent window’s StoreSwitcher.tsx detects success → refetches stores, NO navigation
  6. User stays on their current page (e.g., /omni-ai, /settings/integrations, etc.)

Scenario 3: Adding Store from Settings

  1. User is on /settings/integrations
  2. User clicks Connect Store
  3. Popup opens, OAuth completes
  4. Popup navigates to /oauth/callback
  5. Popup signals success and closes
  6. Parent window’s IntegrationsSettings.tsx detects success → refetches integrations, NO navigation
  7. User stays on /settings/integrations with sync progress visible

Files to Modify

  1. src/App.tsx

    • Line 138: Add /oauth/callback to PUBLIC_MARKETING_PATHS
  2. src/pages/OAuthCallback.tsx

    • Add multiple window.close() retry attempts
    • Add state to track if close failed
    • Update UI messaging
    • Remove redirect fallback logic

Why This Will Work

Issue Fix
Popup loads heavy contexts Add to PUBLIC_MARKETING_PATHS → popup is lightweight
Side-effect navigation in popup No contexts = no side effects
window.close() fails silently Multiple retries with delays, fallback UI
Redirect fallback navigates popup Remove redirect, just show “close manually” message
Parent navigation conflicts Each source has its own handler (already correct)

Additional Note: Sync Progress Toasts

The user also mentioned wanting sync progress toasts for onboarding (“Shopify sync started”, “25%”, “50%”, “100%”, “complete”). This is a separate enhancement that should be implemented after the OAuth redirect fix is confirmed working.

The implementation would add real-time sync status listening in the GetStarted.tsx/omni-ai transition, showing toast notifications as the initial sync progresses. This can be added as a follow-up task.

Hey again @jesse_epstein sorry about the delay here, thanks for sharing all the work with Claude and network traces.

Just confirming that popup-based OAuth flows aren’t an officially supported pattern for Shopify app installation. The authorization code grant flow (https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant) is designed around full-page redirects, which is likely why you’re hitting these walls with the popup approach.

What’s happening technically is that when your OAuth flow goes through Shopify Admin, the popup window gets routed through the Admin’s own URL structure and loses its connection to your app’s parent window. This is intentional behavior for security reasons, and it’s not something that can be worked around with JavaScript breakouts or different redirect paths.

The recommended approach for external apps is to use a standard full-page redirect flow instead of a popup: user clicks “Connect to Shopify” on your app, you redirect the entire page to Shopify’s authorization URL, Shopify redirects back to your registered callback after authorization, and your callback completes the token exchange and redirects to your app UI.

This does mean users briefly leave your site during OAuth, but it is a requirement with external apps. If you need a fully seamless experience without any redirects, you might want to explore building an embedded app with token exchange (https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/token-exchange) instead, which avoids redirects entirely.

Happy to help if you have questions about restructuring your OAuth flow!

ok, sorry for all the updates, but i think it’s good to keep you in the loop as to the progress and regressions. we were making progress but then ran into a regression where the HMAC and webhooks were no longer being validated by shopify.

So, we’ve reverted back to the approach below. Note - WE ARE STILL having the issue where we can’t get the shopify popup to auto close. so any guidance there would be great.

I’ll implement the 302 redirect rollback plan. Let me first read the edge function and IntegrationsSettings to see the current state.

Now I have all the context. Let me implement all the changes in parallel:
\n\n…\n \n window.location.href = \“${callbackUrl}\”;\n \n\n`;\n}“, “replace”: “// ============================================\n// Build Callback URL - Simple HTTP 302 Redirect\n// ============================================\nfunction buildCallbackUrl(appUrl: string, shop: string): string {\n const callbackUrl = new URL(‘/settings/integrations’, appUrl);\n callbackUrl.searchParams.set(‘shopify_connected’, ‘true’);\n callbackUrl.searchParams.set(‘shop’, shop);\n const finalUrl = callbackUrl.toString();\n console.log(‘[OAuth v2] Built callback URL:’, finalUrl);\n return finalUrl;\n}”}”>

void;\n source?: ‘settings’ | ‘onboarding’ | ‘store_switcher’;\n}\n\ninterface UseShopifyOAuthReturn {\n // State\n dialogOpen: boolean;\n setDialogOpen: (open: boolean) => void;\n connecting: boolean;\n \n // Methods\n openConnectDialog: () => void;\n handleConnectionStart: (domain: string, popup: Window | null, oauthRequestId: string) => void;\n}\n\nexport function useShopifyOAuth(options: UseShopifyOAuthOptions = {}): UseShopifyOAuthReturn {\n const { onSuccess } = options;\n const { currentBusiness } = useBusiness();\n \n // Simple state: dialog open and connecting\n const [dialogOpen, setDialogOpen] = useState(false);\n const [connecting, setConnecting] = useState(false);\n \n // Refs for tracking\n const popupRef = useRef(null);\n const pollingIntervalRef = useRef(null);\n const initialStoreCountRef = useRef(0);\n const successHandledRef = useRef(false);\n const mountedRef = useRef(true);\n const detectedStoreDomainRef = useRef(null);\n\n // Cleanup helper\n const cleanup = useCallback(() => {\n if (pollingIntervalRef.current) {\n clearInterval(pollingIntervalRef.current);\n pollingIntervalRef.current = null;\n }\n }, );\n\n // Cleanup on unmount\n useEffect(() => {\n mountedRef.current = true;\n return () => {\n mountedRef.current = false;\n cleanup();\n };\n }, [cleanup]);\n\n // Open the domain dialog\n const openConnectDialog = useCallback(() => {\n console.log(‘[useShopifyOAuth] Opening connect dialog’);\n \n // Clear any existing state\n cleanup();\n \n // Reset state\n setConnecting(false);\n successHandledRef.current = false;\n \n // Open dialog\n setDialogOpen(true);\n }, [cleanup]);\n\n // Handle successful connection - centralized cleanup\n const handleConnectionSuccess = useCallback(async () => {\n if (successHandledRef.current || !mountedRef.current) {\n console.log(‘[useShopifyOAuth] handleConnectionSuccess SKIPPED - already handled or unmounted’);\n return;\n }\n successHandledRef.current = true;\n \n console.log(‘[useShopifyOAuth] ====== CONNECTION SUCCESS ======’);\n \n // Cleanup polling\n cleanup();\n \n // Close popup if still open\n if (popupRef.current && !popupRef.current.closed) {\n try {\n popupRef.current.close();\n } catch (e) {\n console.warn(‘[useShopifyOAuth] Popup close failed:’, e);\n }\n }\n popupRef.current = null;\n \n // Focus parent window\n window.focus();\n \n toast.success(‘Shopify store connected successfully!’);\n \n // Invalidate score factors cache so fresh analysis includes new data\n if (currentBusiness?.id) {\n await invalidateScoreFactorsCache(currentBusiness.id);\n }\n \n // Reset state\n setConnecting(false);\n \n // Call external success handler with the detected store domain\n console.log(‘[useShopifyOAuth] Calling onSuccess with domain:’, detectedStoreDomainRef.current);\n onSuccess?.(detectedStoreDomainRef.current || undefined);\n \n console.log(‘[useShopifyOAuth] ================================’);\n }, [onSuccess, cleanup, currentBusiness?.id]);\n\n // Called by ShopifyDomainDialog after popup is opened\n const handleConnectionStart = useCallback(async (domain: string, popup: Window | null, oauthRequestId: string) => {\n console.log(‘[useShopifyOAuth] ====== CONNECTION START ======’);\n console.log(‘[useShopifyOAuth] Domain:’, domain);\n console.log(‘[useShopifyOAuth] OAuth Request ID:’, oauthRequestId);\n console.log(‘[useShopifyOAuth] Business ID:’, currentBusiness?.id);\n \n popupRef.current = popup;\n setConnecting(true);\n successHandledRef.current = false;\n \n if (!currentBusiness?.id) {\n console.error(‘[useShopifyOAuth] ABORT - No business ID’);\n return;\n }\n\n // Get initial store count SYNCHRONOUSLY before starting polling\n console.log(‘[useShopifyOAuth] Fetching initial store count…’);\n const { data: initialData, error: initialError } = await supabase\n .from(‘integrations’)\n .select(‘id’)\n .eq(‘business_id’, currentBusiness.id)\n .eq(‘platform_id’, ‘shopify’)\n .eq(‘status’, ‘connected’);\n \n if (initialError) {\n console.error(‘[useShopifyOAuth] Error fetching initial count:’, initialError);\n }\n \n initialStoreCountRef.current = initialData?.length || 0;\n console.log(‘[useShopifyOAuth] Initial store count:’, initialStoreCountRef.current);\n console.log(‘[useShopifyOAuth] ================================’);\n\n // Start database polling (PRIMARY detection method - 1s intervals for up to 60s)\n let attempts = 0;\n const maxAttempts = 60;\n \n console.log(‘[useShopifyOAuth] Database polling STARTED - will check every 1s for up to 60s’);\n console.log(‘[useShopifyOAuth] Looking for store count >’, initialStoreCountRef.current);\n\n pollingIntervalRef.current = setInterval(async () => {\n if (successHandledRef.current || !mountedRef.current) {\n console.log(‘[useShopifyOAuth] Polling stopped - success handled or unmounted’);\n clearInterval(pollingIntervalRef.current!);\n pollingIntervalRef.current = null;\n return;\n }\n \n attempts++;\n \n // Check popup status\n const popupStatus = popupRef.current ? (popupRef.current.closed ? ‘CLOSED’ : ‘OPEN’) : ‘NULL’;\n \n // Log every 5 attempts with detailed status\n if (attempts % 5 === 0) {\n console.log([useShopifyOAuth] Polling #${attempts}/${maxAttempts} | popup: ${popupStatus} | initial: ${initialStoreCountRef.current});\n }\n \n const { data: currentIntegrations, error } = await supabase\n .from(‘integrations’)\n .select(‘id, store_identifier, connected_at’)\n .eq(‘business_id’, currentBusiness.id)\n .eq(‘platform_id’, ‘shopify’)\n .eq(‘status’, ‘connected’);\n \n if (error) {\n console.warn(‘[useShopifyOAuth] Polling query error:’, error.message);\n } else {\n const currentCount = currentIntegrations?.length || 0;\n \n if (currentCount > initialStoreCountRef.current) {\n // Find the new store\n const newStore = currentIntegrations?.sort((a, b) => \n new Date(b.connected_at || 0).getTime() - new Date(a.connected_at || 0).getTime()\n )[0];\n \n // Store the detected domain so it can be passed to onSuccess\n detectedStoreDomainRef.current = newStore?.store_identifier || null;\n \n console.log(‘[useShopifyOAuth] SUCCESS: New store detected via database polling’);\n console.log(‘[useShopifyOAuth] Previous count:’, initialStoreCountRef.current, ‘Current count:’, currentCount);\n console.log(‘[useShopifyOAuth] New store domain:’, detectedStoreDomainRef.current);\n \n await handleConnectionSuccess();\n return;\n }\n }\n \n if (attempts >= maxAttempts) {\n console.log(‘[useShopifyOAuth] ====== POLLING TIMEOUT ======’);\n console.log(‘[useShopifyOAuth] Total attempts:’, attempts);\n console.log(‘[useShopifyOAuth] Final popup status:’, popupStatus);\n cleanup();\n \n if (popupRef.current && !popupRef.current.closed) {\n console.log(‘[useShopifyOAuth] Closing popup on timeout’);\n popupRef.current.close();\n }\n \n if (mountedRef.current) {\n setConnecting(false);\n toast.error(\“Connection didn’t complete\”, {\n description: \“Please try again. If the issue persists, check your popup blocker settings.\”\n });\n }\n console.log(‘[useShopifyOAuth] =============================’);\n }\n }, 1000);\n }, [currentBusiness?.id, handleConnectionSuccess, cleanup]);\n\n return {\n dialogOpen,\n setDialogOpen,\n connecting,\n openConnectDialog,\n handleConnectionStart,\n };\n}\n"}">

{\n const handleClose = () => {\n try { window.close(); } catch (e) { /* ignore */ }\n };\n\n return (\n \n \n \n \n \n \n \n Connected!\n \n Your store has been connected. You can close this window.\n \n \n Close This Window\n \n \n \n );\n};\n\nexport default OAuthCallback;\n"}">

(null);\n \n useEffect(() => {\n supabase.auth.getUser().then(({ data }) => {\n setCurrentUserId(data.user?.id || null);\n });\n }, );“, “replace”: " const [currentUserId, setCurrentUserId] = useState(null);\n \n useEffect(() => {\n supabase.auth.getUser().then(({ data }) => {\n setCurrentUserId(data.user?.id || null);\n });\n }, );\n \n // Handle shopify_connected URL parameter from OAuth redirect\n useEffect(() => {\n const params = new URLSearchParams(window.location.search);\n if (params.get(‘shopify_connected’) === ‘true’) {\n const shop = params.get(‘shop’);\n toast.success(\“Shopify store connected!\”, {\n description: shop ? ${shop} is now syncing... : \“Syncing products & orders…\”,\n duration: 5000,\n });\n \n // Clean up URL\n window.history.replaceState({}, ‘’, ‘/settings/integrations’);\n \n // Try to close if this is a popup (after showing toast briefly)\n setTimeout(() => {\n try { window.close(); } catch (e) { /* not a popup */ }\n }, 2000);\n }\n }, );”}">

The first replace failed. Let me fix the edge function by re-reading and updating the buildCallbackUrl function:

Now I can see the exact structure. Let me replace both functions and the call site in one go:
\n…\n window.location.href = \“${callbackUrl}\”;\n \n\n`;\n}“, “replace”: “// ============================================\n// Build Callback URL - Simple HTTP 302 Redirect\n// ============================================\nfunction buildCallbackUrl(appUrl: string, shop: string): string {\n const callbackUrl = new URL(‘/settings/integrations’, appUrl);\n callbackUrl.searchParams.set(‘shopify_connected’, ‘true’);\n callbackUrl.searchParams.set(‘shop’, shop);\n const finalUrl = callbackUrl.toString();\n console.log(‘[OAuth v2] Built callback URL:’, finalUrl);\n return finalUrl;\n}”}”>

Let me read the whole file and rewrite it:

I see the issue - lines 665-675 were already successfully replaced in my earlier call. Now I just need to replace the buildCallbackUrl function (lines 223-303) with the simplified version. Let me do an exact match:

\n\n\n \n \n \n \n Connecting… - PureProfit.ai\n \n body { \n background: #0a0a1a; \n color: #fff; \n font-family: -apple-system, BlinkMacSystemFont, ‘Segoe UI’, Roboto, sans-serif; \n display: flex; \n justify-content: center; \n align-items: center; \n min-height: 100vh;\n margin: 0;\n }\n .container { text-align: center; padding: 2rem; }\n .spinner { \n width: 40px; height: 40px; \n border: 3px solid rgba(42, 255, 181, 0.3);\n border-top-color: #2AFFB5;\n border-radius: 50%;\n animation: spin 1s linear infinite;\n margin: 0 auto 1re@keyframe@keyframeskeyframe@keyframes;\n }\n @keyframes spin { to { transform: rotate(360deg); } }\n p { color: #9ca3af; margin-bottom: 1.5rem; }\n a { color: #2AFFB5; text-decoration: none; }\n a:hover { text-decoration: underline; }\n \n\n\n \n \n Completing connection…\n \n If you’re not redirected, click here.\n \n \n \n \n window.location.href = \“${callbackUrl}\”;\n \n\n`;\n}“}”>

Now let me update the task status and deploy the edge function:

Done! Implemented the HTTP 302 redirect rollback plan:

  1. oauth-shopify edge function - Changed from JavaScript breakout to simple HTTP 302 redirect to /settings/integrations?shopify_connected=true&shop=... (removed ~55 lines of breakout HTML)

  2. useShopifyOAuth hook - Removed all multi-channel signaling (localStorage, BroadcastChannel, postMessage) - now uses database polling only (~195 lines reduced to ~200)

  3. OAuthCallback page - Simplified to minimal fallback UI with close button (reduced from ~155 lines to ~35)

  4. IntegrationsSettings - Added handler for shopify_connected URL param: shows toast, cleans URL, attempts auto-close after 2s

This should satisfy Shopify’s “Immediately redirects to app UI” check while maintaining reliable success detection via database polling.

thanks @Alan_G i’m gonna give the embedded with token exchange a try! i’ll let you know how it goes

hey Alan, this is what Claude has said i have to do on the first install. Is this flow my only option or can i go the embedded app as a first option? If so, do you have any docs on how to do that?

Shopify OAuth Migration: Unlisted App with Token Exchange

Key Constraint Update

Since PureProfit’s Shopify app is unlisted (not in Shopify App Store), users will always start their journey from app.pureprofit.ai. This changes the architecture:

  • First-time connection: User must install the app via OAuth redirect (to get install consent)
  • Subsequent sessions when embedded: Can use token exchange (app already installed)

The token exchange approach only works after the app is installed. For first-time connections, we still need OAuth.


New Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│ FLOW A: FIRST-TIME CONNECTION (OAuth Full-Page Redirect)                   │
│ Trigger: User clicks "Connect Shopify" from app.pureprofit.ai              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  1. User enters shop domain in ShopifyDomainDialog                         │
│         │                                                                   │
│         ▼                                                                   │
│  2. FULL-PAGE redirect to oauth-shopify edge function (NOT popup)          │
│         │                                                                   │
│         ▼                                                                   │
│  3. Edge function 302 redirects to Shopify OAuth consent screen            │
│         │                                                                   │
│         ▼                                                                   │
│  4. Merchant grants permissions → Shopify redirects back to edge function  │
│         │                                                                   │
│         ▼                                                                   │
│  5. Edge function exchanges code for access token, stores integration      │
│         │                                                                   │
│         ▼                                                                   │
│  6. 302 redirect to https://app.pureprofit.ai/settings/integrations        │
│     with ?shopify_connected=true&shop=xxx.myshopify.com                    │
│         │                                                                   │
│         ▼                                                                   │
│  7. User sees success toast, sync begins in background                     │
│                                                                             │
│  ✅ CLEAN FLOW - No popup, no window.close(), no postMessage               │
│                                                                             │
├─────────────────────────────────────────────────────────────────────────────┤
│ FLOW B: EMBEDDED SESSION (Token Exchange - No Redirects)                   │
│ Trigger: User opens app from Shopify Admin after initial install           │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  1. Merchant clicks "PureProfit" app link in Shopify Admin                 │
│         │                                                                   │
│         ▼                                                                   │
│  2. App loads in Shopify Admin iframe with shop/host URL params            │
│         │                                                                   │
│         ▼                                                                   │
│  3. ShopifyAppBridgeContext detects embedded mode                          │
│         │                                                                   │
│         ▼                                                                   │
│  4. App Bridge CDN provides session token (window.shopify.idToken())       │
│         │                                                                   │
│         ▼                                                                   │
│  5. Frontend calls shopify-token-exchange edge function with session token │
│         │                                                                   │
│         ▼                                                                   │
│  6. Edge function validates + exchanges for offline access token           │
│         │                                                                   │
│         ▼                                                                   │
│  7. Integration record created/updated → User is authenticated             │
│                                                                             │
│  ✅ SEAMLESS - No redirects, no popups, fully embedded experience          │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Files to Create

File Purpose
supabase/functions/shopify-token-exchange/index.ts Exchange App Bridge session token for offline access token
src/hooks/useShopifyTokenExchange.ts Hook for embedded token exchange flow
src/components/integrations/ShopifyEmbeddedAuth.tsx Auto-auth component for embedded sessions

Files to Modify

File Changes
src/components/integrations/ShopifyDomainDialog.tsx Replace popup with full-page redirect
src/hooks/useShopifyOAuth.tsx Simplify to full-page redirect (remove popup tracking)
src/contexts/ShopifyAppBridgeContext.tsx Add isReady state and improve initialization
src/pages/settings/IntegrationsSettings.tsx Handle embedded auto-auth on mount
src/pages/GetStarted.tsx Handle embedded mode bypass
supabase/functions/oauth-shopify/index.ts Update redirect to include shop param for cleaner UX
supabase/config.toml Add shopify-token-exchange function config

Implementation Details

1. New Edge Function: shopify-token-exchange

Exchanges an App Bridge session token for an offline access token using Shopify’s Token Exchange API.

// supabase/functions/shopify-token-exchange/index.ts

serve(async (req) => {
  const { session_token, business_id, user_id } = await req.json();
  
  // 1. Decode JWT to extract shop domain (no verification needed, Shopify validates)
  const parts = session_token.split('.');
  const payload = JSON.parse(atob(parts[1]));
  const shop = new URL(payload.dest).host; // e.g., "mystore.myshopify.com"
  
  // 2. Exchange session token for offline access token
  const tokenResponse = await fetch(`https://${shop}/admin/oauth/access_token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: SHOPIFY_CLIENT_ID,
      client_secret: SHOPIFY_APP_SECRET,
      grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
      subject_token: session_token,
      subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
      requested_token_type: 'urn:shopify:params:oauth:token-type:offline-access-token',
    }),
  });
  
  if (!tokenResponse.ok) {
    // Token exchange failed - app may not be installed yet
    return Response.json({ 
      error: 'token_exchange_failed',
      needs_install: true 
    }, { status: 400 });
  }
  
  const { access_token, scope } = await tokenResponse.json();
  
  // 3. Fetch shop metadata
  const shopResponse = await fetch(`https://${shop}/admin/api/2026-01/shop.json`, {
    headers: { 'X-Shopify-Access-Token': access_token },
  });
  const { shop: shopInfo } = await shopResponse.json();
  
  // 4. Resolve business (create new if needed)
  let targetBusinessId = business_id;
  if (business_id === 'create_new' || !business_id) {
    // Create business from shop name
    const { data: newBusiness } = await supabase
      .from('businesses')
      .insert({ name: shopInfo.name, created_by: user_id })
      .select('id')
      .single();
    targetBusinessId = newBusiness.id;
    
    // Link user as owner
    await supabase.from('business_users').insert({
      business_id: targetBusinessId,
      user_id,
      role: 'owner',
      status: 'active',
    });
  }
  
  // 5. Store integration (upsert to handle re-auth)
  const { data: integration } = await supabase.from('integrations').upsert({
    business_id: targetBusinessId,
    platform_name: 'Shopify',
    platform_id: 'shopify',
    store_identifier: shop,
    user_id,
    status: 'connected',
    connected_at: new Date().toISOString(),
    metadata: { shop, scope, shop_id: shopInfo.id, shop_name: shopInfo.name },
  }, { onConflict: 'business_id,platform_id,store_identifier' })
    .select('id').single();
  
  // 6. Store token securely
  await supabase.from('secure_integration_tokens').upsert({
    integration_id: integration.id,
    access_token,
  }, { onConflict: 'integration_id' });
  
  // 7. Trigger sync
  fetch(`${supabaseUrl}/functions/v1/shopify-full-sync`, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${supabaseServiceKey}` },
    body: JSON.stringify({ business_id: targetBusinessId, shop_domain: shop }),
  }).catch(console.error);
  
  return Response.json({ 
    success: true, 
    shop,
    business_id: targetBusinessId,
  });
});

2. Update ShopifyDomainDialog - Full-Page Redirect

Replace popup with full-page redirect:

// Key change in handleSubmit:

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  
  // ... domain validation ...
  
  // Store OAuth state in sessionStorage for return handling
  sessionStorage.setItem('shopify_oauth_state', JSON.stringify({
    businessId: currentBusiness?.id || 'create_new',
    source,
    timestamp: Date.now(),
  }));
  
  // Build OAuth URL
  const params = new URLSearchParams({
    business_id: currentBusiness?.id || 'create_new',
    user_id: userIdRef.current!,
    shop: domain,
    source,
  });
  
  // FULL-PAGE REDIRECT - user leaves PureProfit, goes to Shopify, returns
  window.location.href = `${supabaseUrl}/functions/v1/oauth-shopify?${params.toString()}`;
};

3. Simplify useShopifyOAuth Hook

Remove all popup tracking, polling, and detection logic:

// Simplified hook - no popup tracking needed
export function useShopifyOAuth(options: UseShopifyOAuthOptions = {}): UseShopifyOAuthReturn {
  const [dialogOpen, setDialogOpen] = useState(false);
  
  const openConnectDialog = useCallback(() => {
    setDialogOpen(true);
  }, []);

  // Success is detected via URL params on /settings/integrations after redirect
  // No popup tracking, no polling needed
  
  return {
    dialogOpen,
    setDialogOpen,
    connecting: false, // No intermediate state - user leaves page
    openConnectDialog,
  };
}

4. Enhance ShopifyAppBridgeContext

Add readiness state and token exchange method:

interface ShopifyAppBridgeContextType {
  app: typeof window.shopify | null;
  isEmbedded: boolean;
  isReady: boolean;  // NEW: App Bridge fully initialized
  shop: string | null;
  host: string | null;
  sessionToken: string | null;
  getSessionToken: () => Promise<string | null>;
  exchangeForAccessToken: () => Promise<TokenExchangeResult>; // NEW
}

// Add isReady tracking:
const [isReady, setIsReady] = useState(false);

useEffect(() => {
  // ... existing detection ...
  
  if (shopifyAppBridge) {
    shopifyAppBridge.idToken()
      .then((token) => {
        setSessionToken(token);
        setIsReady(true); // Mark as ready after first token
      })
      .catch(/* ... */);
  }
}, []);

// Add exchange method:
const exchangeForAccessToken = useCallback(async () => {
  const token = await getSessionToken();
  if (!token) throw new Error('No session token available');
  
  const response = await supabase.functions.invoke('shopify-token-exchange', {
    body: { 
      session_token: token,
      business_id: /* from context */,
      user_id: /* from auth */,
    },
  });
  
  return response.data;
}, [getSessionToken]);

5. New Component: ShopifyEmbeddedAuth

Auto-authenticates when app loads in Shopify Admin iframe:

// src/components/integrations/ShopifyEmbeddedAuth.tsx

export const ShopifyEmbeddedAuth: React.FC = () => {
  const { isEmbedded, isReady, shop, exchangeForAccessToken } = useShopifyAppBridge();
  const { currentBusiness, refreshBusinesses } = useBusiness();
  const { refetchStores } = useStore();
  const [authState, setAuthState] = useState<'idle' | 'authenticating' | 'success' | 'error'>('idle');
  
  useEffect(() => {
    if (!isEmbedded || !isReady || authState !== 'idle') return;
    
    const authenticate = async () => {
      setAuthState('authenticating');
      
      try {
        const result = await exchangeForAccessToken();
        
        if (result.success) {
          await refreshBusinesses();
          await refetchStores(true);
          setAuthState('success');
          toast.success('Store connected!');
        } else if (result.needs_install) {
          // App not installed - shouldn't happen for unlisted app flow
          // but handle gracefully
          setAuthState('error');
        }
      } catch (err) {
        console.error('[ShopifyEmbeddedAuth] Token exchange failed:', err);
        setAuthState('error');
      }
    };
    
    authenticate();
  }, [isEmbedded, isReady, authState]);
  
  // Render loading state while authenticating
  if (authState === 'authenticating') {
    return (
      <div className="fixed inset-0 bg-background/80 flex items-center justify-center z-50">
        <div className="text-center">
          <Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
          <p>Connecting to {shop}...</p>
        </div>
      </div>
    );
  }
  
  return null; // No UI when idle/success/error
};

6. Update oauth-shopify Edge Function

Simplify redirect to go directly to integrations page (not via /connect-success):

// In callback handling section, change:
const callbackUrl = `${appUrl}/settings/integrations?shopify_connected=true&shop=${encodeURIComponent(shopInfo.domain || shop)}`;

return new Response(null, {
  status: 302,
  headers: {
    'Location': callbackUrl,
    'Cache-Control': 'no-store, no-cache, must-revalidate',
  },
});

7. Update IntegrationsSettings Success Handling

Handle the URL params from OAuth redirect:

// Already exists but ensure it handles full-page redirect:
useEffect(() => {
  const params = new URLSearchParams(window.location.search);
  if (params.get('shopify_connected') === 'true') {
    const shop = params.get('shop');
    
    toast.success("Shopify store connected!", {
      description: shop ? `${shop} is now syncing...` : "Syncing products & orders...",
      duration: 5000,
    });
    
    // Clean up URL
    window.history.replaceState({}, '', '/settings/integrations');
    
    // Refresh data
    refetchIntegrations?.();
    refetchStores(true);
  }
}, []);

8. Update GetStarted.tsx for Embedded Mode

When embedded in Shopify Admin, auto-authenticate and skip platform selection:

// In GetStarted.tsx, add embedded handling:

const { isEmbedded, isReady, shop } = useShopifyAppBridge();

useEffect(() => {
  if (isEmbedded && isReady && shop) {
    // User is in Shopify Admin - auto-connect via token exchange
    // This is handled by ShopifyEmbeddedAuth component at app root
    navigate('/omni-ai');
  }
}, [isEmbedded, isReady, shop, navigate]);

Shopify Partner Dashboard Configuration

Ensure these settings are configured:

Setting Value
App URL https://app.pureprofit.ai
Allowed redirection URL(s) https://ouknuoejkmiapsxepgcc.supabase.co/functions/v1/oauth-shopifyhttps://app.pureprofit.ai/settings/integrationshttps://app.pureprofit.ai/connect-success (keep for safety)
Embedded app Yes (required for token exchange)
Distribution Unlisted

Migration Steps

Phase 1: Create Token Exchange Infrastructure

  1. Create shopify-token-exchange edge function
  2. Add to supabase/config.toml
  3. Deploy and test token exchange in isolation

Phase 2: Enhance App Bridge Context

  1. Add isReady state
  2. Add exchangeForAccessToken method
  3. Create useShopifyTokenExchange hook
  4. Create ShopifyEmbeddedAuth component

Phase 3: Replace Popup with Full-Page Redirect

  1. Update ShopifyDomainDialog to use full-page redirect
  2. Simplify useShopifyOAuth hook (remove popup logic)
  3. Update success detection in IntegrationsSettings

Phase 4: Integrate Embedded Flow

  1. Add ShopifyEmbeddedAuth to app root (inside providers)
  2. Update GetStarted.tsx for embedded mode
  3. Test embedded flow from Shopify Admin

Phase 5: Cleanup

  1. Remove /connect-success route (optional, can keep for safety)
  2. Update memory files with new architecture
  3. Remove popup-related refs from all components

Testing Plan

Flow A: First-Time Connection (Standalone)

  1. Navigate to app.pureprofit.ai/get-started
  2. Click “Connect Store” on Shopify tile
  3. Enter store domain (e.g., mystore)
  4. Page redirects to Shopify OAuth consent screen
  5. Grant permissions
  6. Redirected back to /settings/integrations?shopify_connected=true&shop=mystore.myshopify.com
  7. Success toast shows, sync begins

Flow B: Embedded Session (After Install)

  1. In Shopify Admin, click “Apps” → “PureProfit”
  2. App loads in iframe
  3. ShopifyEmbeddedAuth detects embedded mode
  4. Token exchange happens automatically
  5. User lands in authenticated state (no consent needed - already installed)

Edge Cases

  1. User not logged in to PureProfit: OAuth redirect should include auth flow
  2. Expired session token: Refresh token via App Bridge and retry
  3. Token exchange fails: Show error, offer to retry or use OAuth redirect as fallback

Security Notes

  1. Session token validation: The edge function trusts Shopify’s token (no signature verification needed since we exchange it immediately)
  2. HMAC verification: Keep for OAuth callback to verify Shopify redirects
  3. Token storage: Continue using secure_integration_tokens (service_role only)
  4. Business isolation: RLS policies remain unchanged

Summary

Component Change
First-time connection Popup → Full-page redirect
Embedded authentication New token exchange flow
Success detection URL params (not polling)
useShopifyOAuth Simplified (no popup tracking)
ShopifyDomainDialog Full-page redirect
New: shopify-token-exchange Edge function for token exchange
New: ShopifyEmbeddedAuth Auto-auth component for embedded

@Alan_G just implemented this. here’s where i’m at.

Claude is now suggesting this, which i think is the wrong direction. Do you have clearer direction?

Fix: Shopify Admin Intercepting OAuth 302 Redirects

Root Cause

The edge function logs confirm it’s correctly generating:

https://app.pureprofit.ai/connect-success?success=true&shop=pureprofit-1.myshopify.com

But Shopify Admin intercepts ALL HTTP 302 redirects from the OAuth callback and rewrites them to stay within admin.shopify.com/.../apps/ structure. This is a known Shopify behavior documented in memory/shopify-oauth-302-redirect-backup.md.

What’s happening:

  1. Edge function returns 302 Location: https://app.pureprofit.ai/connect-success?success=true
  2. Shopify Admin intercepts this at the browser level
  3. Rewrites URL to admin.shopify.com/store/pureprofit-1/apps/connect-success?success=true
  4. User sees 404 because there’s no such page in Shopify Admin

Solution: JavaScript Breakout Page

Instead of returning a 302 redirect, return an HTML page with JavaScript that uses window.top.location.href to break out of any iframe context and navigate directly to our app.

This approach:

  • Uses top-level navigation to escape Shopify Admin’s URL rewriting
  • Works whether the OAuth was initiated from embedded context or standalone
  • Provides a brief loading state for user feedback

Implementation

1. Create HTML Breakout Response Function

Add a helper function that returns an HTML page with JavaScript redirect:

function createBreakoutHtml(redirectUrl: string, shopDomain: string): string {
  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Connected! Redirecting...</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      margin: 0;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    }
    .container {
      text-align: center;
      color: white;
      padding: 2rem;
    }
    .checkmark {
      width: 64px;
      height: 64px;
      background: rgba(255,255,255,0.2);
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      margin: 0 auto 1rem;
      font-size: 32px;
    }
    h1 { margin: 0 0 0.5rem; font-size: 1.5rem; }
    p { margin: 0; opacity: 0.9; }
  </style>
</head>
<body>
  <div class="container">
    <div class="checkmark">✓</div>
    <h1>Store Connected!</h1>
    <p>Redirecting to PureProfit.ai...</p>
  </div>
  <script>
    (function() {
      var redirectUrl = ${JSON.stringify(redirectUrl)};
      console.log('[OAuth Breakout] Redirecting to:', redirectUrl);
      
      // Use top-level navigation to break out of Shopify Admin iframe
      // This bypasses Shopify's URL rewriting
      try {
        if (window.top && window.top !== window) {
          window.top.location.href = redirectUrl;
        } else {
          window.location.href = redirectUrl;
        }
      } catch (e) {
        // Cross-origin restriction - fallback to regular redirect
        console.log('[OAuth Breakout] Cross-origin fallback');
        window.location.href = redirectUrl;
      }
    })();
  </script>
</body>
</html>`;
}

2. Update Success Response

Replace the 302 redirect with the HTML breakout response:

// Before (current - broken):
return new Response(null, {
  status: 302,
  headers: {
    'Location': callbackUrl,
    'Cache-Control': 'no-store, no-cache, must-revalidate',
  },
});

// After (fix):
const breakoutHtml = createBreakoutHtml(callbackUrl, shopInfo.domain || shop);
return new Response(breakoutHtml, {
  status: 200,
  headers: {
    'Content-Type': 'text/html; charset=utf-8',
    'Cache-Control': 'no-store, no-cache, must-revalidate',
  },
});

3. Update Error Redirect Too

Apply the same pattern to error redirects to ensure consistency.

Files to Modify

File Change
supabase/functions/oauth-shopify/index.ts Add createBreakoutHtml function and replace 302 redirect with HTML response

Technical Details

Why This Works

  1. HTTP 302: Browser interprets redirect header, Shopify intercepts before navigation
  2. JavaScript window.top.location.href: Executes in browser context, navigates top-level window directly
  3. Cross-origin handling: Graceful fallback if restricted

Security Considerations

  • The redirect URL is built server-side and validated against APP_URL
  • No user-controllable content in the redirect URL
  • JSON.stringify properly escapes the URL in JavaScript context

Testing Plan

  1. Click “Add New Store” → Enter domain → Click Continue
  2. Complete Shopify install consent screen
  3. Verify: Brief “Store Connected! Redirecting…” page appears
  4. Verify: Redirects to app.pureprofit.ai/settings/integrations?shopify_connected=true
  5. Verify: Success toast appears, sync begins

Hey @jesse_epstein :waving_hand: thanks for following up! Just to set expectations on the popup approach: even with the workarounds you’ve built (the 302 redirect, database polling, etc.), you’ll likely keep hitting edge cases where the popup doesn’t close cleanly. When OAuth goes through admin.shopify.com, Shopify’s Admin shell takes control of that window - it’s just how the security model works, unfortunately.

For your external app setup, you’ve got two solid paths forward:

  1. Standard full-page redirect (no popups) - User clicks “Connect Shopify” on pureprofit.ai, gets redirected to Shopify OAuth, then back to your callback. You control the whole experience.

  2. Embedded app with token exchange - Your app runs inside Shopify Admin, and auth happens seamlessly without redirects or popups: Exchange a session token for an access token

Let me know which direction feels right for PureProfit and I’m happy to help with the specifics!