diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index 63adfbe..b1a4436 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -51,7 +51,11 @@ const Settings = () => { toast.success('Etsy shop connected!'); window.history.replaceState({}, '', window.location.pathname); } else if (etsyResult === 'error') { - toast.error('Etsy connection failed. Please try again.'); + const reason = params.get('reason'); + toast.error( + reason ? `Etsy connection failed: ${reason}` : 'Etsy connection failed. Please try again.', + { duration: 12000 } + ); window.history.replaceState({}, '', window.location.pathname); } }, []); diff --git a/server/src/routes/etsy.ts b/server/src/routes/etsy.ts index c06cd66..86de0b7 100644 --- a/server/src/routes/etsy.ts +++ b/server/src/routes/etsy.ts @@ -44,14 +44,15 @@ const loadConfig = async (userId: string): Promise => { // Where to send the browser after the OAuth callback: same origin as the // redirect URI (the app and API share one domain) -const settingsUrl = (redirectUri: string | undefined, suffix: string): string => { +const settingsUrl = (redirectUri: string | undefined, suffix: string, reason?: string): string => { let origin = process.env.CLIENT_URL || 'http://localhost:3000'; try { if (redirectUri) origin = new URL(redirectUri).origin; } catch { // keep fallback } - return `${origin}/settings?etsy=${suffix}`; + const reasonParam = reason ? `&reason=${encodeURIComponent(reason.slice(0, 300))}` : ''; + return `${origin}/settings?etsy=${suffix}${reasonParam}`; }; const maskKey = (key: string): string => @@ -137,7 +138,12 @@ router.get('/callback', async (req: Request, res: Response) => { const pending = typeof state === 'string' ? pendingAuth.get(state) : undefined; if (error || typeof code !== 'string' || !pending) { - return res.redirect(settingsUrl(pending?.redirectUri, 'error')); + const reason = error + ? `Etsy returned: ${error}${req.query.error_description ? ` — ${req.query.error_description}` : ''}` + : !pending + ? 'Login attempt expired or unknown (possibly a server restart) — try connecting again' + : 'No authorization code returned'; + return res.redirect(settingsUrl(pending?.redirectUri, 'error', reason)); } pendingAuth.delete(String(state)); @@ -163,9 +169,9 @@ router.get('/callback', async (req: Request, res: Response) => { ); res.redirect(settingsUrl(pending.redirectUri, 'connected')); - } catch (err) { + } catch (err: any) { console.error('Etsy OAuth callback failed:', err); - res.redirect(settingsUrl(pending.redirectUri, 'error')); + res.redirect(settingsUrl(pending.redirectUri, 'error', err?.message || 'Token exchange failed')); } }); diff --git a/server/src/services/etsyApi.ts b/server/src/services/etsyApi.ts index 272111e..1cf199f 100644 --- a/server/src/services/etsyApi.ts +++ b/server/src/services/etsyApi.ts @@ -38,10 +38,11 @@ interface TokenResponse { } const requestToken = async (body: Record): Promise => { + // OAuth 2.0 token endpoints take form-encoded bodies (RFC 6749) const res = await fetch(ETSY_TOKEN_URL, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body).toString(), }); if (!res.ok) { throw new Error(`Etsy token request failed (${res.status}): ${await res.text()}`);