From 4759db4c5b8f1ff65e4b76b4d7df4c35ac567349 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Sat, 13 Jun 2026 08:02:05 +1000 Subject: [PATCH] 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 --- client/src/pages/Settings.tsx | 111 ++++++++++++++++++++++++--- server/.env.example | 9 +-- server/src/models/EtsySettings.ts | 24 ++++++ server/src/routes/etsy.ts | 123 ++++++++++++++++++++++++++---- server/src/services/etsyApi.ts | 37 ++++----- 5 files changed, 252 insertions(+), 52 deletions(-) create mode 100644 server/src/models/EtsySettings.ts diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index 6e409bd..63adfbe 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -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(null); + const [etsyConfig, setEtsyConfig] = useState(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([]); @@ -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,19 +238,65 @@ const Settings = () => { )} ) : ( -
-

+

+

Connect your Etsy shop to sync orders automatically — order details, items, variations, and tracking, with costs matched from your product catalog.

- + + {/* API credentials, stored in the database */} +
+

API Configuration

+
+ + setApiKeyInput(e.target.value)} + /> +
+
+ + setRedirectUriInput(e.target.value)} + /> +

+ Register this exact URL as a callback URL in your Etsy app settings + (etsy.com/developers) before connecting. +

+
+ +
+ +
+ +
)}
diff --git a/server/.env.example b/server/.env.example index 9543429..395f5b8 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 \ No newline at end of file +# 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 \ No newline at end of file diff --git a/server/src/models/EtsySettings.ts b/server/src/models/EtsySettings.ts new file mode 100644 index 0000000..755213a --- /dev/null +++ b/server/src/models/EtsySettings.ts @@ -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('EtsySettings', EtsySettingsSchema); diff --git a/server/src/routes/etsy.ts b/server/src/routes/etsy.ts index a769eec..c06cd66 100644 --- a/server/src/routes/etsy.ts +++ b/server/src/routes/etsy.ts @@ -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(); +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 => { + 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); diff --git a/server/src/services/etsyApi.ts b/server/src/services/etsyApi.ts index 3d7caf1..272111e 100644 --- a/server/src/services/etsyApi.ts +++ b/server/src/services/etsyApi.ts @@ -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): Promise; }; -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 => { +const ensureFreshToken = async (apiKey: string, connection: IEtsyConnection): Promise => { 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 => { // ---------- API client ---------- -const etsyGet = async (connection: IEtsyConnection, path: string): Promise => { - await ensureFreshToken(connection); +const etsyGet = async (apiKey: string, connection: IEtsyConnection, path: string): Promise => { + 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 => { +export const fetchIdentity = async (apiKey: string, accessToken: string): Promise => { 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 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 => { +export const syncReceipts = async (apiKey: string, connection: IEtsyConnection): Promise => { 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