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;
}
interface EtsyConfig {
configured: boolean;
apiKeyMasked: string | null;
redirectUri: string | null;
source: 'database' | 'environment' | null;
}
const Settings = () => {
const dispatch = useDispatch();
const [storageSummary, setStorageSummary] = useState({
@ -24,6 +31,10 @@ const Settings = () => {
});
const [showClearConfirm, setShowClearConfirm] = useState(false);
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 [isSyncing, setIsSyncing] = useState(false);
const [unmatchedItems, setUnmatchedItems] = useState<string[]>([]);
@ -31,6 +42,7 @@ const Settings = () => {
useEffect(() => {
updateStorageSummary();
loadEtsyStatus();
loadEtsyConfig();
// Handle the redirect back from the Etsy consent screen
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 () => {
setIsConnecting(true);
try {
@ -193,20 +238,66 @@ const Settings = () => {
)}
</div>
) : (
<div className="flex items-center justify-between">
<p className="text-gray-600 text-sm max-w-md">
<div className="space-y-4">
<p className="text-gray-600 text-sm">
Connect your Etsy shop to sync orders automatically order details, items,
variations, and tracking, with costs matched from your product catalog.
</p>
{/* API credentials, stored in the database */}
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
<h3 className="text-sm font-medium text-gray-900">API Configuration</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Etsy API Keystring
</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}
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>

View file

@ -12,8 +12,7 @@ JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
# Session Secret (change in production)
SESSION_SECRET=your-super-secret-session-key-change-this-in-production
# Etsy API (personal-use app from etsy.com/developers)
# ETSY_API_KEY is the app keystring; ETSY_REDIRECT_URI must exactly match a
# callback URL registered in the Etsy app settings
ETSY_API_KEY=your-etsy-keystring
ETSY_REDIRECT_URI=https://etsy.plexultra.com/api/etsy/callback
# Etsy API (optional fallback — normally configured per user in the
# Settings UI and stored in the database)
# ETSY_API_KEY=your-etsy-keystring
# 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 { authenticate, AuthRequest } from '../middleware/authenticate';
import EtsyConnection from '../models/EtsyConnection';
import EtsySettings from '../models/EtsySettings';
import {
buildAuthUrl,
exchangeCode,
@ -14,7 +15,10 @@ const router = Router();
// 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.
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 prunePending = () => {
@ -24,23 +28,104 @@ const prunePending = () => {
}
};
const redirectUri = (): string => {
const uri = process.env.ETSY_REDIRECT_URI;
if (!uri) throw new Error('ETSY_REDIRECT_URI is not configured on the server');
return uri;
interface EtsyConfig {
apiKey: string;
redirectUri: string;
}
// 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 =>
`${process.env.CLIENT_URL || 'http://localhost:3000'}/settings?etsy=${suffix}`;
// 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 => {
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
router.get('/connect', authenticate, (req: AuthRequest, res: Response) => {
router.get('/connect', authenticate, async (req: AuthRequest, res: Response) => {
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();
const state = crypto.randomBytes(16).toString('hex');
const { verifier, challenge } = generatePkce();
pendingAuth.set(state, { userId: req.userId!, verifier, createdAt: Date.now() });
res.json({ url: buildAuthUrl(redirectUri(), state, challenge) });
pendingAuth.set(state, {
userId: req.userId!,
verifier,
redirectUri: config.redirectUri,
createdAt: Date.now(),
});
res.json({ url: buildAuthUrl(config.apiKey, config.redirectUri, state, challenge) });
} catch (err: any) {
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;
if (error || typeof code !== 'string' || !pending) {
return res.redirect(settingsUrl('error'));
return res.redirect(settingsUrl(pending?.redirectUri, 'error'));
}
pendingAuth.delete(String(state));
try {
const tokens = await exchangeCode(code, pending.verifier, redirectUri());
const identity = await fetchIdentity(tokens.access_token);
const config = await loadConfig(pending.userId);
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(
{ userId: pending.userId },
@ -74,10 +162,10 @@ router.get('/callback', async (req: Request, res: Response) => {
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
res.redirect(settingsUrl('connected'));
res.redirect(settingsUrl(pending.redirectUri, 'connected'));
} catch (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 });
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);
} catch (err: any) {
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
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) ----------
export const generatePkce = () => {
@ -24,10 +18,10 @@ export const generatePkce = () => {
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({
response_type: 'code',
client_id: apiKey(),
client_id: apiKey,
redirect_uri: redirectUri,
scope: ETSY_SCOPES,
state,
@ -55,27 +49,27 @@ const requestToken = async (body: Record<string, string>): 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({
grant_type: 'authorization_code',
client_id: apiKey(),
client_id: apiKey,
redirect_uri: redirectUri,
code,
code_verifier: codeVerifier,
});
const refreshTokens = (refreshToken: string) =>
const refreshTokens = (apiKey: string, refreshToken: string) =>
requestToken({
grant_type: 'refresh_token',
client_id: apiKey(),
client_id: apiKey,
refresh_token: refreshToken,
});
// 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;
const tokens = await refreshTokens(connection.refreshToken);
const tokens = await refreshTokens(apiKey, connection.refreshToken);
connection.accessToken = tokens.access_token;
connection.refreshToken = tokens.refresh_token;
connection.tokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000);
@ -84,11 +78,11 @@ const ensureFreshToken = async (connection: IEtsyConnection): Promise<void> => {
// ---------- API client ----------
const etsyGet = async (connection: IEtsyConnection, path: string): Promise<any> => {
await ensureFreshToken(connection);
const etsyGet = async (apiKey: string, connection: IEtsyConnection, path: string): Promise<any> => {
await ensureFreshToken(apiKey, connection);
const res = await fetch(`${ETSY_API_BASE}${path}`, {
headers: {
'x-api-key': apiKey(),
'x-api-key': apiKey,
Authorization: `Bearer ${connection.accessToken}`,
},
});
@ -105,9 +99,9 @@ export interface EtsyIdentity {
}
// 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`, {
headers: { 'x-api-key': apiKey(), Authorization: `Bearer ${accessToken}` },
headers: { 'x-api-key': apiKey, Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
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;
try {
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) {
const shop: any = await shopRes.json();
@ -200,7 +194,7 @@ export interface SyncResult {
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 result: SyncResult = { created: 0, updated: 0, unmatchedItems: [], receiptsSeen: 0 };
@ -209,6 +203,7 @@ export const syncReceipts = async (connection: IEtsyConnection): Promise<SyncRes
while (true) {
const page = await etsyGet(
apiKey,
connection,
`/application/shops/${connection.shopId}/receipts?limit=${limit}&offset=${offset}`
);