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:
dlawler489 2026-06-13 08:02:05 +10:00
parent 715562c96a
commit 4759db4c5b
5 changed files with 252 additions and 52 deletions

View file

@ -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>

View file

@ -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

View 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);

View file

@ -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);

View file

@ -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}`
); );