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
- Create
shopify-token-exchange edge function
- Add to
supabase/config.toml
- Deploy and test token exchange in isolation
Phase 2: Enhance App Bridge Context
- Add
isReady state
- Add
exchangeForAccessToken method
- Create
useShopifyTokenExchange hook
- Create
ShopifyEmbeddedAuth component
Phase 3: Replace Popup with Full-Page Redirect
- Update
ShopifyDomainDialog to use full-page redirect
- Simplify
useShopifyOAuth hook (remove popup logic)
- Update success detection in
IntegrationsSettings
Phase 4: Integrate Embedded Flow
- Add
ShopifyEmbeddedAuth to app root (inside providers)
- Update
GetStarted.tsx for embedded mode
- Test embedded flow from Shopify Admin
Phase 5: Cleanup
- Remove
/connect-success route (optional, can keep for safety)
- Update memory files with new architecture
- Remove popup-related refs from all components
Testing Plan
Flow A: First-Time Connection (Standalone)
- Navigate to
app.pureprofit.ai/get-started
- Click “Connect Store” on Shopify tile
- Enter store domain (e.g.,
mystore)
- Page redirects to Shopify OAuth consent screen
- Grant permissions
- Redirected back to
/settings/integrations?shopify_connected=true&shop=mystore.myshopify.com
- Success toast shows, sync begins
Flow B: Embedded Session (After Install)
- In Shopify Admin, click “Apps” → “PureProfit”
- App loads in iframe
ShopifyEmbeddedAuth detects embedded mode
- Token exchange happens automatically
- User lands in authenticated state (no consent needed - already installed)
Edge Cases
- User not logged in to PureProfit: OAuth redirect should include auth flow
- Expired session token: Refresh token via App Bridge and retry
- Token exchange fails: Show error, offer to retry or use OAuth redirect as fallback
Security Notes
- Session token validation: The edge function trusts Shopify’s token (no signature verification needed since we exchange it immediately)
- HMAC verification: Keep for OAuth callback to verify Shopify redirects
- Token storage: Continue using
secure_integration_tokens (service_role only)
- 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 |