chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { db } from '@sim/db'
|
||||
import { pendingCredentialDraft, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, lt } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { auth, getSession } from '@/lib/auth/auth'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getAllOAuthServices } from '@/lib/oauth/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('OAuth2Authorize')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const DRAFT_TTL_MS = 15 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Creates the pending credential draft at click time so its TTL starts when the
|
||||
* user actually initiates the connect. Better Auth's `account.create.after` hook
|
||||
* consumes this draft to materialize the real credential after the OAuth
|
||||
* callback; starting the clock here guarantees the draft outlives the (≤5 min)
|
||||
* OAuth round-trip rather than expiring mid-flow and silently producing no
|
||||
* credential.
|
||||
*/
|
||||
async function createConnectDraft(params: {
|
||||
userId: string
|
||||
workspaceId: string
|
||||
providerId: string
|
||||
}): Promise<void> {
|
||||
const { userId, workspaceId, providerId } = params
|
||||
|
||||
const service = getAllOAuthServices().find((s) => s.providerId === providerId)
|
||||
const serviceName = service?.name ?? providerId
|
||||
|
||||
let displayName = serviceName
|
||||
try {
|
||||
const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId))
|
||||
if (row?.name) {
|
||||
displayName = `${row.name}'s ${serviceName}`
|
||||
}
|
||||
} catch {
|
||||
// Fall back to service name only
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS)
|
||||
await db
|
||||
.delete(pendingCredentialDraft)
|
||||
.where(
|
||||
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
|
||||
)
|
||||
await db
|
||||
.insert(pendingCredentialDraft)
|
||||
.values({
|
||||
id: generateId(),
|
||||
userId,
|
||||
workspaceId,
|
||||
providerId,
|
||||
displayName,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
pendingCredentialDraft.userId,
|
||||
pendingCredentialDraft.providerId,
|
||||
pendingCredentialDraft.workspaceId,
|
||||
],
|
||||
set: { displayName, expiresAt, createdAt: now },
|
||||
})
|
||||
|
||||
logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-initiated entrypoint for linking a generic OAuth2 account.
|
||||
*/
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const loginUrl = new URL('/login', baseUrl)
|
||||
loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname + request.nextUrl.search)
|
||||
return NextResponse.redirect(loginUrl.toString())
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
const parsed = await parseRequest(authorizeOAuth2Contract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { providerId, workspaceId, callbackURL: requestedCallback } = parsed.data.query
|
||||
|
||||
const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`)
|
||||
? requestedCallback
|
||||
: `${baseUrl}/workspace`
|
||||
|
||||
try {
|
||||
const access = await checkWorkspaceAccess(workspaceId, userId)
|
||||
if (!access.canWrite) {
|
||||
logger.warn('Workspace write access denied for OAuth2 authorize', {
|
||||
userId,
|
||||
workspaceId,
|
||||
providerId,
|
||||
})
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`)
|
||||
}
|
||||
|
||||
// Create the draft before initiating the link so it is guaranteed to exist
|
||||
// (and freshly clocked) when the OAuth callback's `account.create.after`
|
||||
// hook runs. If this throws, we never start the OAuth flow.
|
||||
await createConnectDraft({ userId, workspaceId, providerId })
|
||||
|
||||
const linkResponse = await auth.api.oAuth2LinkAccount({
|
||||
body: { providerId, callbackURL },
|
||||
headers: request.headers,
|
||||
asResponse: true,
|
||||
})
|
||||
|
||||
const payload = (await linkResponse.json().catch(() => null)) as { url?: string } | null
|
||||
if (!linkResponse.ok || !payload?.url) {
|
||||
logger.error('oAuth2LinkAccount did not return an authorization URL', {
|
||||
providerId,
|
||||
status: linkResponse.status,
|
||||
})
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
|
||||
}
|
||||
|
||||
const response = NextResponse.redirect(payload.url)
|
||||
// Forward the signed `state` cookie Better Auth set so it lands in the user's
|
||||
// browser and is present when the provider redirects back to the callback.
|
||||
const linkHeaders = linkResponse.headers as Headers & {
|
||||
getSetCookie?: () => string[]
|
||||
}
|
||||
for (const cookie of linkHeaders.getSetCookie?.() ?? []) {
|
||||
response.headers.append('set-cookie', cookie)
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Failed to initiate OAuth2 authorization', { providerId, error })
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { safeCompare } from '@sim/security/compare'
|
||||
import { hmacSha256Hex } from '@sim/security/hmac'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
shopifyCallbackQuerySchema,
|
||||
shopifyShopDomainSchema,
|
||||
} from '@/lib/api/contracts/oauth-connections'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('ShopifyCallback')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Validates the HMAC signature from Shopify to ensure the request is authentic
|
||||
* @see https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens
|
||||
*/
|
||||
function validateHmac(searchParams: URLSearchParams, clientSecret: string): boolean {
|
||||
const hmac = searchParams.get('hmac')
|
||||
if (!hmac) {
|
||||
return false
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {}
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key !== 'hmac') {
|
||||
params[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
const message = Object.keys(params)
|
||||
.sort()
|
||||
.map((key) => `${key}=${params[key]}`)
|
||||
.join('&')
|
||||
|
||||
const generatedHmac = hmacSha256Hex(message, clientSecret)
|
||||
|
||||
return safeCompare(hmac, generatedHmac)
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
|
||||
}
|
||||
|
||||
const { searchParams } = request.nextUrl
|
||||
const { code, state, shop } = shopifyCallbackQuerySchema.parse({
|
||||
code: searchParams.get('code') || undefined,
|
||||
state: searchParams.get('state') || undefined,
|
||||
shop: searchParams.get('shop') || undefined,
|
||||
})
|
||||
|
||||
const storedState = request.cookies.get('shopify_oauth_state')?.value
|
||||
const storedShop = request.cookies.get('shopify_shop_domain')?.value
|
||||
|
||||
const clientId = env.SHOPIFY_CLIENT_ID
|
||||
const clientSecret = env.SHOPIFY_CLIENT_SECRET
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
logger.error('Shopify credentials not configured')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_config_error`)
|
||||
}
|
||||
|
||||
if (!validateHmac(searchParams, clientSecret)) {
|
||||
logger.error('HMAC validation failed in Shopify OAuth callback')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_hmac_invalid`)
|
||||
}
|
||||
|
||||
if (!state || state !== storedState) {
|
||||
logger.error('State mismatch in Shopify OAuth callback')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_state_mismatch`)
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
logger.error('No code received from Shopify')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_code`)
|
||||
}
|
||||
|
||||
const shopDomain = shop || storedShop
|
||||
if (!shopDomain) {
|
||||
logger.error('No shop domain available')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`)
|
||||
}
|
||||
|
||||
if (!shopifyShopDomainSchema.safeParse(shopDomain).success) {
|
||||
logger.error('Invalid shop domain format:', { shopDomain })
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`)
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch(`https://${shopDomain}/admin/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code: code,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text()
|
||||
logger.error('Failed to exchange code for token:', {
|
||||
status: tokenResponse.status,
|
||||
body: errorText,
|
||||
})
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_token_error`)
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.access_token
|
||||
const scope = tokenData.scope
|
||||
|
||||
logger.info('Shopify token exchange successful:', {
|
||||
hasAccessToken: !!accessToken,
|
||||
scope: scope,
|
||||
})
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token in response')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_token`)
|
||||
}
|
||||
|
||||
const storeUrl = new URL(`${baseUrl}/api/auth/oauth2/shopify/store`)
|
||||
|
||||
const response = NextResponse.redirect(storeUrl)
|
||||
|
||||
response.cookies.set('shopify_pending_token', accessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('shopify_pending_shop', shopDomain, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('shopify_pending_scope', scope || '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.delete('shopify_oauth_state')
|
||||
response.cookies.delete('shopify_shop_domain')
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Error in Shopify OAuth callback:', error)
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_callback_error`)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
shopifyShopDomainSchema,
|
||||
shopifyStoreCookieSchema,
|
||||
} from '@/lib/api/contracts/oauth-connections'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { isSameOrigin } from '@/lib/core/utils/validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
|
||||
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('ShopifyStore')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('Unauthorized attempt to store Shopify token')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
|
||||
}
|
||||
|
||||
const parsedCookies = shopifyStoreCookieSchema.safeParse({
|
||||
accessToken: request.cookies.get('shopify_pending_token')?.value,
|
||||
shopDomain: request.cookies.get('shopify_pending_shop')?.value,
|
||||
scope: request.cookies.get('shopify_pending_scope')?.value || undefined,
|
||||
returnUrl: request.cookies.get('shopify_return_url')?.value || undefined,
|
||||
})
|
||||
|
||||
if (!parsedCookies.success) {
|
||||
logger.error('Missing token or shop domain in cookies')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_missing_data`)
|
||||
}
|
||||
const { accessToken, shopDomain, scope, returnUrl } = parsedCookies.data
|
||||
|
||||
if (!shopifyShopDomainSchema.safeParse(shopDomain).success) {
|
||||
logger.error('Invalid shop domain format in cookie', { shopDomain })
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`)
|
||||
}
|
||||
|
||||
const shopResponse = await fetch(`https://${shopDomain}/admin/api/2024-10/shop.json`, {
|
||||
headers: {
|
||||
'X-Shopify-Access-Token': accessToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!shopResponse.ok) {
|
||||
const errorText = await shopResponse.text()
|
||||
logger.error('Invalid Shopify token', {
|
||||
status: shopResponse.status,
|
||||
error: errorText,
|
||||
})
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_token`)
|
||||
}
|
||||
|
||||
const shopData = await shopResponse.json()
|
||||
const shopInfo = shopData.shop
|
||||
const stableAccountId = shopInfo.id?.toString() || shopDomain
|
||||
|
||||
const existing = await db.query.account.findFirst({
|
||||
where: and(
|
||||
eq(account.userId, session.user.id),
|
||||
eq(account.providerId, 'shopify'),
|
||||
eq(account.accountId, stableAccountId)
|
||||
),
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const accountData = {
|
||||
accessToken: accessToken,
|
||||
accountId: stableAccountId,
|
||||
scope: scope || '',
|
||||
updatedAt: now,
|
||||
idToken: shopDomain,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await db.update(account).set(accountData).where(eq(account.id, existing.id))
|
||||
logger.info('Updated existing Shopify account', { accountId: existing.id })
|
||||
} else {
|
||||
await safeAccountInsert(
|
||||
{
|
||||
id: `shopify_${session.user.id}_${Date.now()}`,
|
||||
userId: session.user.id,
|
||||
providerId: 'shopify',
|
||||
accountId: accountData.accountId,
|
||||
accessToken: accountData.accessToken,
|
||||
scope: accountData.scope,
|
||||
idToken: accountData.idToken,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{ provider: 'Shopify', identifier: shopDomain }
|
||||
)
|
||||
}
|
||||
|
||||
const persisted =
|
||||
existing ??
|
||||
(await db.query.account.findFirst({
|
||||
where: and(
|
||||
eq(account.userId, session.user.id),
|
||||
eq(account.providerId, 'shopify'),
|
||||
eq(account.accountId, stableAccountId)
|
||||
),
|
||||
}))
|
||||
|
||||
if (persisted) {
|
||||
try {
|
||||
await processCredentialDraft({
|
||||
userId: session.user.id,
|
||||
providerId: 'shopify',
|
||||
accountId: persisted.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to process credential draft for Shopify', { error })
|
||||
}
|
||||
}
|
||||
|
||||
const redirectUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : `${baseUrl}/workspace`
|
||||
const finalUrl = new URL(redirectUrl)
|
||||
finalUrl.searchParams.set('shopify_connected', 'true')
|
||||
|
||||
const response = NextResponse.redirect(finalUrl.toString())
|
||||
response.cookies.delete('shopify_pending_token')
|
||||
response.cookies.delete('shopify_pending_shop')
|
||||
response.cookies.delete('shopify_pending_scope')
|
||||
response.cookies.delete('shopify_return_url')
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Error storing Shopify token:', error)
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_store_error`)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user