Surface Etsy OAuth errors and use form-encoded token requests

- Callback failures now redirect with the underlying error message, shown
  in the Settings toast, instead of a generic failure
- Token endpoint requests use application/x-www-form-urlencoded per
  RFC 6749 instead of JSON

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-06-13 11:09:09 +10:00
parent 64aae6adf7
commit 08160775e7
3 changed files with 19 additions and 8 deletions

View file

@ -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);
}
}, []);

View file

@ -44,14 +44,15 @@ const loadConfig = async (userId: string): Promise<EtsyConfig | null> => {
// 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'));
}
});

View file

@ -38,10 +38,11 @@ interface TokenResponse {
}
const requestToken = async (body: Record<string, string>): Promise<TokenResponse> => {
// 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()}`);