Store Etsy API credentials in the database instead of env vars
- New EtsySettings model holds the per-user API keystring and callback URL, managed via GET/PUT /api/etsy/config; env vars remain as optional fallback - Settings UI gains an API Configuration form (masked saved key, callback URL prefilled with this origin's /api/etsy/callback); Connect is enabled once configuration is saved - OAuth and sync resolve the key per user; post-callback redirect derives from the stored callback URL origin instead of CLIENT_URL Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
715562c96a
commit
4759db4c5b
5 changed files with 252 additions and 52 deletions
|
|
@ -13,6 +13,13 @@ interface EtsyStatus {
|
||||||
lastSyncedAt?: string;
|
lastSyncedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EtsyConfig {
|
||||||
|
configured: boolean;
|
||||||
|
apiKeyMasked: string | null;
|
||||||
|
redirectUri: string | null;
|
||||||
|
source: 'database' | 'environment' | null;
|
||||||
|
}
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [storageSummary, setStorageSummary] = useState({
|
const [storageSummary, setStorageSummary] = useState({
|
||||||
|
|
@ -24,6 +31,10 @@ const Settings = () => {
|
||||||
});
|
});
|
||||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||||
const [etsyStatus, setEtsyStatus] = useState<EtsyStatus | null>(null);
|
const [etsyStatus, setEtsyStatus] = useState<EtsyStatus | null>(null);
|
||||||
|
const [etsyConfig, setEtsyConfig] = useState<EtsyConfig | null>(null);
|
||||||
|
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||||
|
const [redirectUriInput, setRedirectUriInput] = useState('');
|
||||||
|
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [unmatchedItems, setUnmatchedItems] = useState<string[]>([]);
|
const [unmatchedItems, setUnmatchedItems] = useState<string[]>([]);
|
||||||
|
|
@ -31,6 +42,7 @@ const Settings = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateStorageSummary();
|
updateStorageSummary();
|
||||||
loadEtsyStatus();
|
loadEtsyStatus();
|
||||||
|
loadEtsyConfig();
|
||||||
|
|
||||||
// Handle the redirect back from the Etsy consent screen
|
// Handle the redirect back from the Etsy consent screen
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
@ -53,6 +65,39 @@ const Settings = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadEtsyConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/etsy/config');
|
||||||
|
setEtsyConfig(res.data);
|
||||||
|
// Prefill the callback URL: stored value, or this app's own callback route
|
||||||
|
setRedirectUriInput(res.data.redirectUri || `${window.location.origin}/api/etsy/callback`);
|
||||||
|
} catch {
|
||||||
|
setEtsyConfig(null);
|
||||||
|
setRedirectUriInput(`${window.location.origin}/api/etsy/callback`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEtsyConfig = async () => {
|
||||||
|
if (!apiKeyInput.trim() && !etsyConfig?.configured) {
|
||||||
|
toast.error('Enter your Etsy API keystring');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSavingConfig(true);
|
||||||
|
try {
|
||||||
|
await api.put('/etsy/config', {
|
||||||
|
apiKey: apiKeyInput.trim() || undefined,
|
||||||
|
redirectUri: redirectUriInput.trim(),
|
||||||
|
});
|
||||||
|
toast.success('Etsy configuration saved');
|
||||||
|
setApiKeyInput('');
|
||||||
|
loadEtsyConfig();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'Failed to save Etsy configuration');
|
||||||
|
} finally {
|
||||||
|
setIsSavingConfig(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEtsyConnect = async () => {
|
const handleEtsyConnect = async () => {
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -193,19 +238,65 @@ const Settings = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-4">
|
||||||
<p className="text-gray-600 text-sm max-w-md">
|
<p className="text-gray-600 text-sm">
|
||||||
Connect your Etsy shop to sync orders automatically — order details, items,
|
Connect your Etsy shop to sync orders automatically — order details, items,
|
||||||
variations, and tracking, with costs matched from your product catalog.
|
variations, and tracking, with costs matched from your product catalog.
|
||||||
</p>
|
</p>
|
||||||
<button
|
|
||||||
onClick={handleEtsyConnect}
|
{/* API credentials, stored in the database */}
|
||||||
disabled={isConnecting}
|
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:bg-gray-400"
|
<h3 className="text-sm font-medium text-gray-900">API Configuration</h3>
|
||||||
>
|
<div>
|
||||||
<Link2 className="w-4 h-4" />
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
{isConnecting ? 'Redirecting…' : 'Connect Etsy Shop'}
|
Etsy API Keystring
|
||||||
</button>
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={etsyConfig?.apiKeyMasked
|
||||||
|
? `Saved (${etsyConfig.apiKeyMasked}) — enter a new key to replace it`
|
||||||
|
: 'Paste your keystring from etsy.com/developers'}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||||
|
value={apiKeyInput}
|
||||||
|
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Callback URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||||
|
value={redirectUriInput}
|
||||||
|
onChange={(e) => setRedirectUriInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Register this exact URL as a callback URL in your Etsy app settings
|
||||||
|
(etsy.com/developers) before connecting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveEtsyConfig}
|
||||||
|
disabled={isSavingConfig}
|
||||||
|
className="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 disabled:bg-gray-400 text-sm"
|
||||||
|
>
|
||||||
|
{isSavingConfig ? 'Saving…' : 'Save Configuration'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleEtsyConnect}
|
||||||
|
disabled={isConnecting || !etsyConfig?.configured}
|
||||||
|
title={!etsyConfig?.configured ? 'Save your API configuration first' : undefined}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:bg-gray-400"
|
||||||
|
>
|
||||||
|
<Link2 className="w-4 h-4" />
|
||||||
|
{isConnecting ? 'Redirecting…' : 'Connect Etsy Shop'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,7 @@ JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||||
# Session Secret (change in production)
|
# Session Secret (change in production)
|
||||||
SESSION_SECRET=your-super-secret-session-key-change-this-in-production
|
SESSION_SECRET=your-super-secret-session-key-change-this-in-production
|
||||||
|
|
||||||
# Etsy API (personal-use app from etsy.com/developers)
|
# Etsy API (optional fallback — normally configured per user in the
|
||||||
# ETSY_API_KEY is the app keystring; ETSY_REDIRECT_URI must exactly match a
|
# Settings UI and stored in the database)
|
||||||
# callback URL registered in the Etsy app settings
|
# ETSY_API_KEY=your-etsy-keystring
|
||||||
ETSY_API_KEY=your-etsy-keystring
|
# ETSY_REDIRECT_URI=https://etsy.plexultra.com/api/etsy/callback
|
||||||
ETSY_REDIRECT_URI=https://etsy.plexultra.com/api/etsy/callback
|
|
||||||
24
server/src/models/EtsySettings.ts
Normal file
24
server/src/models/EtsySettings.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import mongoose, { Document, Schema } from 'mongoose';
|
||||||
|
|
||||||
|
// Per-user Etsy app credentials, entered in the Settings UI.
|
||||||
|
// Falls back to ETSY_API_KEY / ETSY_REDIRECT_URI env vars when absent.
|
||||||
|
export interface IEtsySettings extends Document {
|
||||||
|
userId: mongoose.Types.ObjectId;
|
||||||
|
apiKey: string;
|
||||||
|
redirectUri: string;
|
||||||
|
dateUpdated: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EtsySettingsSchema: Schema = new Schema({
|
||||||
|
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, unique: true, index: true },
|
||||||
|
apiKey: { type: String, required: true, trim: true },
|
||||||
|
redirectUri: { type: String, required: true, trim: true },
|
||||||
|
dateUpdated: { type: Date, default: Date.now },
|
||||||
|
});
|
||||||
|
|
||||||
|
EtsySettingsSchema.pre('save', function (next) {
|
||||||
|
this.dateUpdated = new Date();
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default mongoose.model<IEtsySettings>('EtsySettings', EtsySettingsSchema);
|
||||||
|
|
@ -2,6 +2,7 @@ import { Router, Response, Request } from 'express';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { authenticate, AuthRequest } from '../middleware/authenticate';
|
import { authenticate, AuthRequest } from '../middleware/authenticate';
|
||||||
import EtsyConnection from '../models/EtsyConnection';
|
import EtsyConnection from '../models/EtsyConnection';
|
||||||
|
import EtsySettings from '../models/EtsySettings';
|
||||||
import {
|
import {
|
||||||
buildAuthUrl,
|
buildAuthUrl,
|
||||||
exchangeCode,
|
exchangeCode,
|
||||||
|
|
@ -14,7 +15,10 @@ const router = Router();
|
||||||
|
|
||||||
// Pending OAuth attempts keyed by state; ties the browser callback (which has
|
// Pending OAuth attempts keyed by state; ties the browser callback (which has
|
||||||
// no JWT) back to the user who initiated the connect. Single-process only.
|
// no JWT) back to the user who initiated the connect. Single-process only.
|
||||||
const pendingAuth = new Map<string, { userId: string; verifier: string; createdAt: number }>();
|
const pendingAuth = new Map<
|
||||||
|
string,
|
||||||
|
{ userId: string; verifier: string; redirectUri: string; createdAt: number }
|
||||||
|
>();
|
||||||
const PENDING_TTL_MS = 10 * 60 * 1000;
|
const PENDING_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
const prunePending = () => {
|
const prunePending = () => {
|
||||||
|
|
@ -24,23 +28,104 @@ const prunePending = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const redirectUri = (): string => {
|
interface EtsyConfig {
|
||||||
const uri = process.env.ETSY_REDIRECT_URI;
|
apiKey: string;
|
||||||
if (!uri) throw new Error('ETSY_REDIRECT_URI is not configured on the server');
|
redirectUri: string;
|
||||||
return uri;
|
}
|
||||||
|
|
||||||
|
// Per-user config from the database, falling back to env vars
|
||||||
|
const loadConfig = async (userId: string): Promise<EtsyConfig | null> => {
|
||||||
|
const settings = await EtsySettings.findOne({ userId });
|
||||||
|
const apiKey = settings?.apiKey || process.env.ETSY_API_KEY;
|
||||||
|
const redirectUri = settings?.redirectUri || process.env.ETSY_REDIRECT_URI;
|
||||||
|
if (!apiKey || !redirectUri) return null;
|
||||||
|
return { apiKey, redirectUri };
|
||||||
};
|
};
|
||||||
|
|
||||||
const settingsUrl = (suffix: string): string =>
|
// Where to send the browser after the OAuth callback: same origin as the
|
||||||
`${process.env.CLIENT_URL || 'http://localhost:3000'}/settings?etsy=${suffix}`;
|
// redirect URI (the app and API share one domain)
|
||||||
|
const settingsUrl = (redirectUri: string | undefined, suffix: 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 maskKey = (key: string): string =>
|
||||||
|
key.length > 4 ? `${'•'.repeat(Math.max(4, key.length - 4))}${key.slice(-4)}` : '••••';
|
||||||
|
|
||||||
|
// ---------- Configuration (API key stored in the database) ----------
|
||||||
|
|
||||||
|
router.get('/config', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const settings = await EtsySettings.findOne({ userId: req.userId });
|
||||||
|
const apiKey = settings?.apiKey || process.env.ETSY_API_KEY;
|
||||||
|
res.json({
|
||||||
|
configured: Boolean(apiKey && (settings?.redirectUri || process.env.ETSY_REDIRECT_URI)),
|
||||||
|
apiKeyMasked: apiKey ? maskKey(apiKey) : null,
|
||||||
|
redirectUri: settings?.redirectUri || process.env.ETSY_REDIRECT_URI || null,
|
||||||
|
source: settings ? 'database' : apiKey ? 'environment' : null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ message: 'Failed to fetch Etsy config', error: err });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/config', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { apiKey, redirectUri } = req.body || {};
|
||||||
|
if (typeof redirectUri !== 'string' || !redirectUri.trim()) {
|
||||||
|
return res.status(400).json({ message: 'redirectUri is required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new URL(redirectUri);
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ message: 'redirectUri must be a valid URL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await EtsySettings.findOne({ userId: req.userId });
|
||||||
|
const newKey = typeof apiKey === 'string' && apiKey.trim() ? apiKey.trim() : existing?.apiKey;
|
||||||
|
if (!newKey) {
|
||||||
|
return res.status(400).json({ message: 'apiKey is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await EtsySettings.findOneAndUpdate(
|
||||||
|
{ userId: req.userId },
|
||||||
|
{ userId: req.userId, apiKey: newKey, redirectUri: redirectUri.trim() },
|
||||||
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Etsy configuration saved' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ message: 'Failed to save Etsy config', error: err });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- OAuth ----------
|
||||||
|
|
||||||
// Start the OAuth flow: returns the Etsy consent URL for the client to open
|
// Start the OAuth flow: returns the Etsy consent URL for the client to open
|
||||||
router.get('/connect', authenticate, (req: AuthRequest, res: Response) => {
|
router.get('/connect', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
const config = await loadConfig(req.userId!);
|
||||||
|
if (!config) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'Enter your Etsy API key and callback URL in Settings first',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
prunePending();
|
prunePending();
|
||||||
const state = crypto.randomBytes(16).toString('hex');
|
const state = crypto.randomBytes(16).toString('hex');
|
||||||
const { verifier, challenge } = generatePkce();
|
const { verifier, challenge } = generatePkce();
|
||||||
pendingAuth.set(state, { userId: req.userId!, verifier, createdAt: Date.now() });
|
pendingAuth.set(state, {
|
||||||
res.json({ url: buildAuthUrl(redirectUri(), state, challenge) });
|
userId: req.userId!,
|
||||||
|
verifier,
|
||||||
|
redirectUri: config.redirectUri,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
res.json({ url: buildAuthUrl(config.apiKey, config.redirectUri, state, challenge) });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ message: err.message || 'Failed to start Etsy connection' });
|
res.status(500).json({ message: err.message || 'Failed to start Etsy connection' });
|
||||||
}
|
}
|
||||||
|
|
@ -52,13 +137,16 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||||
const pending = typeof state === 'string' ? pendingAuth.get(state) : undefined;
|
const pending = typeof state === 'string' ? pendingAuth.get(state) : undefined;
|
||||||
|
|
||||||
if (error || typeof code !== 'string' || !pending) {
|
if (error || typeof code !== 'string' || !pending) {
|
||||||
return res.redirect(settingsUrl('error'));
|
return res.redirect(settingsUrl(pending?.redirectUri, 'error'));
|
||||||
}
|
}
|
||||||
pendingAuth.delete(String(state));
|
pendingAuth.delete(String(state));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokens = await exchangeCode(code, pending.verifier, redirectUri());
|
const config = await loadConfig(pending.userId);
|
||||||
const identity = await fetchIdentity(tokens.access_token);
|
if (!config) throw new Error('Etsy API key is no longer configured');
|
||||||
|
|
||||||
|
const tokens = await exchangeCode(config.apiKey, code, pending.verifier, pending.redirectUri);
|
||||||
|
const identity = await fetchIdentity(config.apiKey, tokens.access_token);
|
||||||
|
|
||||||
await EtsyConnection.findOneAndUpdate(
|
await EtsyConnection.findOneAndUpdate(
|
||||||
{ userId: pending.userId },
|
{ userId: pending.userId },
|
||||||
|
|
@ -74,10 +162,10 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.redirect(settingsUrl('connected'));
|
res.redirect(settingsUrl(pending.redirectUri, 'connected'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Etsy OAuth callback failed:', err);
|
console.error('Etsy OAuth callback failed:', err);
|
||||||
res.redirect(settingsUrl('error'));
|
res.redirect(settingsUrl(pending.redirectUri, 'error'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -101,7 +189,10 @@ router.post('/sync', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
const connection = await EtsyConnection.findOne({ userId: req.userId });
|
const connection = await EtsyConnection.findOne({ userId: req.userId });
|
||||||
if (!connection) return res.status(400).json({ message: 'Etsy is not connected' });
|
if (!connection) return res.status(400).json({ message: 'Etsy is not connected' });
|
||||||
|
|
||||||
const result = await syncReceipts(connection);
|
const config = await loadConfig(req.userId!);
|
||||||
|
if (!config) return res.status(400).json({ message: 'Etsy API key is not configured' });
|
||||||
|
|
||||||
|
const result = await syncReceipts(config.apiKey, connection);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Etsy sync failed:', err);
|
console.error('Etsy sync failed:', err);
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,6 @@ const ETSY_CONNECT_URL = 'https://www.etsy.com/oauth/connect';
|
||||||
// Read scopes: receipts/transactions, listings, shop info
|
// Read scopes: receipts/transactions, listings, shop info
|
||||||
export const ETSY_SCOPES = 'transactions_r listings_r shops_r';
|
export const ETSY_SCOPES = 'transactions_r listings_r shops_r';
|
||||||
|
|
||||||
const apiKey = (): string => {
|
|
||||||
const key = process.env.ETSY_API_KEY;
|
|
||||||
if (!key) throw new Error('ETSY_API_KEY is not configured on the server');
|
|
||||||
return key;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- OAuth (authorization code + PKCE) ----------
|
// ---------- OAuth (authorization code + PKCE) ----------
|
||||||
|
|
||||||
export const generatePkce = () => {
|
export const generatePkce = () => {
|
||||||
|
|
@ -24,10 +18,10 @@ export const generatePkce = () => {
|
||||||
return { verifier, challenge };
|
return { verifier, challenge };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildAuthUrl = (redirectUri: string, state: string, codeChallenge: string): string => {
|
export const buildAuthUrl = (apiKey: string, redirectUri: string, state: string, codeChallenge: string): string => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
client_id: apiKey(),
|
client_id: apiKey,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
scope: ETSY_SCOPES,
|
scope: ETSY_SCOPES,
|
||||||
state,
|
state,
|
||||||
|
|
@ -55,27 +49,27 @@ const requestToken = async (body: Record<string, string>): Promise<TokenResponse
|
||||||
return res.json() as Promise<TokenResponse>;
|
return res.json() as Promise<TokenResponse>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const exchangeCode = (code: string, codeVerifier: string, redirectUri: string) =>
|
export const exchangeCode = (apiKey: string, code: string, codeVerifier: string, redirectUri: string) =>
|
||||||
requestToken({
|
requestToken({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
client_id: apiKey(),
|
client_id: apiKey,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
code,
|
code,
|
||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshTokens = (refreshToken: string) =>
|
const refreshTokens = (apiKey: string, refreshToken: string) =>
|
||||||
requestToken({
|
requestToken({
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
client_id: apiKey(),
|
client_id: apiKey,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh the connection's access token if it expires within the next minute
|
// Refresh the connection's access token if it expires within the next minute
|
||||||
const ensureFreshToken = async (connection: IEtsyConnection): Promise<void> => {
|
const ensureFreshToken = async (apiKey: string, connection: IEtsyConnection): Promise<void> => {
|
||||||
if (connection.tokenExpiresAt.getTime() > Date.now() + 60_000) return;
|
if (connection.tokenExpiresAt.getTime() > Date.now() + 60_000) return;
|
||||||
|
|
||||||
const tokens = await refreshTokens(connection.refreshToken);
|
const tokens = await refreshTokens(apiKey, connection.refreshToken);
|
||||||
connection.accessToken = tokens.access_token;
|
connection.accessToken = tokens.access_token;
|
||||||
connection.refreshToken = tokens.refresh_token;
|
connection.refreshToken = tokens.refresh_token;
|
||||||
connection.tokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
connection.tokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||||
|
|
@ -84,11 +78,11 @@ const ensureFreshToken = async (connection: IEtsyConnection): Promise<void> => {
|
||||||
|
|
||||||
// ---------- API client ----------
|
// ---------- API client ----------
|
||||||
|
|
||||||
const etsyGet = async (connection: IEtsyConnection, path: string): Promise<any> => {
|
const etsyGet = async (apiKey: string, connection: IEtsyConnection, path: string): Promise<any> => {
|
||||||
await ensureFreshToken(connection);
|
await ensureFreshToken(apiKey, connection);
|
||||||
const res = await fetch(`${ETSY_API_BASE}${path}`, {
|
const res = await fetch(`${ETSY_API_BASE}${path}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': apiKey(),
|
'x-api-key': apiKey,
|
||||||
Authorization: `Bearer ${connection.accessToken}`,
|
Authorization: `Bearer ${connection.accessToken}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -105,9 +99,9 @@ export interface EtsyIdentity {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the connected user's shop from a fresh access token
|
// Resolve the connected user's shop from a fresh access token
|
||||||
export const fetchIdentity = async (accessToken: string): Promise<EtsyIdentity> => {
|
export const fetchIdentity = async (apiKey: string, accessToken: string): Promise<EtsyIdentity> => {
|
||||||
const res = await fetch(`${ETSY_API_BASE}/application/users/me`, {
|
const res = await fetch(`${ETSY_API_BASE}/application/users/me`, {
|
||||||
headers: { 'x-api-key': apiKey(), Authorization: `Bearer ${accessToken}` },
|
headers: { 'x-api-key': apiKey, Authorization: `Bearer ${accessToken}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Etsy users/me failed (${res.status}): ${await res.text()}`);
|
throw new Error(`Etsy users/me failed (${res.status}): ${await res.text()}`);
|
||||||
|
|
@ -120,7 +114,7 @@ export const fetchIdentity = async (accessToken: string): Promise<EtsyIdentity>
|
||||||
let shopName: string | undefined;
|
let shopName: string | undefined;
|
||||||
try {
|
try {
|
||||||
const shopRes = await fetch(`${ETSY_API_BASE}/application/shops/${me.shop_id}`, {
|
const shopRes = await fetch(`${ETSY_API_BASE}/application/shops/${me.shop_id}`, {
|
||||||
headers: { 'x-api-key': apiKey(), Authorization: `Bearer ${accessToken}` },
|
headers: { 'x-api-key': apiKey, Authorization: `Bearer ${accessToken}` },
|
||||||
});
|
});
|
||||||
if (shopRes.ok) {
|
if (shopRes.ok) {
|
||||||
const shop: any = await shopRes.json();
|
const shop: any = await shopRes.json();
|
||||||
|
|
@ -200,7 +194,7 @@ export interface SyncResult {
|
||||||
receiptsSeen: number;
|
receiptsSeen: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const syncReceipts = async (connection: IEtsyConnection): Promise<SyncResult> => {
|
export const syncReceipts = async (apiKey: string, connection: IEtsyConnection): Promise<SyncResult> => {
|
||||||
const products = await Product.find({ userId: connection.userId });
|
const products = await Product.find({ userId: connection.userId });
|
||||||
const result: SyncResult = { created: 0, updated: 0, unmatchedItems: [], receiptsSeen: 0 };
|
const result: SyncResult = { created: 0, updated: 0, unmatchedItems: [], receiptsSeen: 0 };
|
||||||
|
|
||||||
|
|
@ -209,6 +203,7 @@ export const syncReceipts = async (connection: IEtsyConnection): Promise<SyncRes
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const page = await etsyGet(
|
const page = await etsyGet(
|
||||||
|
apiKey,
|
||||||
connection,
|
connection,
|
||||||
`/application/shops/${connection.shopId}/receipts?limit=${limit}&offset=${offset}`
|
`/application/shops/${connection.shopId}/receipts?limit=${limit}&offset=${offset}`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue