Преглед изворни кода

Inital Printrest Intergration

Benjamin Harris пре 1 месец
родитељ
комит
9278757ef1

+ 16 - 0
docker-compose.yml

@@ -182,6 +182,20 @@ services:
       - messageBroker
       - mongodb
 
+  pinterest:
+    build:
+      context: ./services
+      dockerfile: pinterest/Dockerfile
+    volumes:
+      - pinterest_modules:/services/pinterest/node_modules
+    restart: unless-stopped
+    env_file: .env
+    networks:
+      - socialMediaManagerNetwork
+    depends_on:
+      - messageBroker
+      - mongodb
+
   feed-aggregator:
     build:
       context: ./services
@@ -200,6 +214,7 @@ services:
       - mastodon
       - bluesky
       - instagram
+      - pinterest
       - facebook
 
   scheduler:
@@ -257,6 +272,7 @@ volumes:
   bluesky_modules:
   instagram_modules:
   facebook_modules:
+  pinterest_modules:
   feed_aggregator_modules:
   scheduler_modules:
   ui_modules:

+ 1 - 0
services/feed-aggregator/index.js

@@ -15,6 +15,7 @@ const PLATFORM_SERVICES = {
   bluesky:   process.env.BLUESKY_SERVICE_URL   || 'http://bluesky:3004',
   instagram: process.env.INSTAGRAM_SERVICE_URL || 'http://instagram:3005',
   facebook:  process.env.FACEBOOK_SERVICE_URL  || 'http://facebook:3006',
+  pinterest: process.env.PINTEREST_SERVICE_URL || 'http://pinterest:3008',
 };
 
 const log = createLogger('feed-aggregator');

+ 133 - 1
services/gateway/server.js

@@ -745,18 +745,143 @@ app.delete('/credentials/meta', async () => {
   return { success: true };
 });
 
+// ─── Pinterest OAuth Flow ─────────────────────────────────────────────────────
+
+const PINTEREST_API = 'https://api.pinterest.com/v5';
+const PINTEREST_AUTH_URL = 'https://www.pinterest.com/oauth/';
+const PINTEREST_TOKEN_URL = 'https://api.pinterest.com/v5/oauth/token';
+
+app.post('/credentials/pinterest-app', async (request, reply) => {
+  const { clientId, clientSecret } = request.body || {};
+  if (!clientId || !clientSecret) {
+    return reply.code(400).send({ error: 'clientId and clientSecret are required' });
+  }
+  await setCredentials('pinterest_app', { clientId, clientSecret: encryptToken(clientSecret) });
+  log.info({ action: 'pinterest_app_save', outcome: 'success' });
+  return { success: true };
+});
+
+app.get('/credentials/pinterest-app', async () => {
+  const cred = await getCredentials('pinterest_app');
+  if (!cred) return { configured: false };
+  const plain = decryptToken(cred.clientSecret) || '';
+  return { configured: true, clientId: cred.clientId, clientSecretHint: plain ? `****${plain.slice(-4)}` : '****' };
+});
+
+app.get('/auth/pinterest/init', async (request, reply) => {
+  const cred = await getCredentials('pinterest_app');
+  if (!cred?.clientId) {
+    return reply.code(400).send({ error: 'Save your Pinterest Client ID and Secret first' });
+  }
+  const redirectUri = `${APP_BASE_URL}/api/auth/pinterest/callback`;
+  const scopes = 'pins:read,pins:write,boards:read,user_accounts:read';
+  const url = `${PINTEREST_AUTH_URL}?client_id=${cred.clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${scopes}`;
+  return { url };
+});
+
+app.get('/auth/pinterest/callback', async (request, reply) => {
+  const { code, error: oauthError } = request.query;
+
+  if (oauthError) {
+    return reply.redirect(`${APP_BASE_URL}/settings?pinterest_error=${encodeURIComponent(oauthError)}`);
+  }
+  if (!code) {
+    return reply.redirect(`${APP_BASE_URL}/settings?pinterest_error=no_code`);
+  }
+
+  try {
+    const appCred = await getCredentials('pinterest_app');
+    if (!appCred?.clientId) throw new Error('App credentials not configured');
+    const clientSecret = decryptToken(appCred.clientSecret);
+    if (!clientSecret) throw new Error('Failed to decrypt client secret');
+
+    const redirectUri = `${APP_BASE_URL}/api/auth/pinterest/callback`;
+    const basicAuth = Buffer.from(`${appCred.clientId}:${clientSecret}`).toString('base64');
+
+    const tokenRes = await axios.post(
+      PINTEREST_TOKEN_URL,
+      new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri }).toString(),
+      {
+        headers: {
+          Authorization: `Basic ${basicAuth}`,
+          'Content-Type': 'application/x-www-form-urlencoded',
+        },
+        timeout: 15000,
+      }
+    );
+
+    const { access_token, refresh_token, expires_in } = tokenRes.data;
+    const tokenExpiry = new Date(Date.now() + (expires_in || 30 * 24 * 60 * 60) * 1000).toISOString();
+
+    const [userRes, boardsRes] = await Promise.all([
+      axios.get(`${PINTEREST_API}/user_account`, {
+        headers: { Authorization: `Bearer ${access_token}` },
+        timeout: 10000,
+      }),
+      axios.get(`${PINTEREST_API}/boards`, {
+        headers: { Authorization: `Bearer ${access_token}` },
+        params: { page_size: 100 },
+        timeout: 15000,
+      }),
+    ]);
+
+    const boards = (boardsRes.data.items || []).map((b) => ({
+      id: b.id,
+      name: b.name,
+      privacy: b.privacy,
+      selected: false,
+    }));
+
+    await setCredentials('pinterest', {
+      userId: userRes.data.username,
+      username: userRes.data.username,
+      displayName: userRes.data.business_name || userRes.data.username,
+      avatar: userRes.data.profile_image,
+      accessToken: encryptToken(access_token),
+      refreshToken: refresh_token ? encryptToken(refresh_token) : null,
+      tokenExpiry,
+      boards,
+    });
+
+    log.info({ action: 'pinterest_oauth_callback', username: userRes.data.username, boards: boards.length, outcome: 'success' });
+    reply.redirect(`${APP_BASE_URL}/settings?pinterest_connected=1`);
+  } catch (err) {
+    const msg = err.response?.data?.message || err.message;
+    log.error({ action: 'pinterest_oauth_callback', outcome: 'failure', err: msg });
+    reply.redirect(`${APP_BASE_URL}/settings?pinterest_error=${encodeURIComponent(msg)}`);
+  }
+});
+
+app.post('/credentials/pinterest/boards', async (request, reply) => {
+  const { selectedBoardIds = [] } = request.body || {};
+  const cred = await getCredentials('pinterest');
+  if (!cred) return reply.code(400).send({ error: 'Pinterest not connected' });
+
+  const boards = (cred.boards || []).map((b) => ({ ...b, selected: selectedBoardIds.includes(b.id) }));
+  await setCredentials('pinterest', { ...cred, boards });
+  log.info({ action: 'pinterest_boards_save', selected: boards.filter((b) => b.selected).length, outcome: 'success' });
+  return { success: true, selected: boards.filter((b) => b.selected).length };
+});
+
+app.delete('/credentials/pinterest', async () => {
+  await deleteCredentials('pinterest');
+  return { success: true };
+});
+
 // ─── Credential Status ────────────────────────────────────────────────────────
 
 // Aggregate connection status for all DB-managed platforms
 app.get('/credentials', async () => {
-  const [metaApp, fb, ig] = await Promise.all([
+  const [metaApp, fb, ig, pinterest] = await Promise.all([
     getCredentials('meta_app'),
     getCredentials('facebook'),
     getCredentials('instagram'),
+    getCredentials('pinterest'),
   ]);
 
   const fbPages = (fb?.pages || []).filter((p) => p.selected);
   const igAccounts = (ig?.accounts || []).filter((a) => a.selected);
+  const pinterestBoards = (pinterest?.boards || []).filter((b) => b.selected);
 
   return {
     metaApp: { configured: !!(metaApp?.appId) },
@@ -768,6 +893,12 @@ app.get('/credentials', async () => {
       connected: igAccounts.length > 0,
       accounts: igAccounts.map(({ id, username, avatar }) => ({ id, username, avatar })),
     },
+    pinterest: {
+      connected: pinterestBoards.length > 0,
+      username: pinterest?.username || null,
+      boards: pinterestBoards.map(({ id, name, privacy }) => ({ id, name, privacy })),
+      allBoards: (pinterest?.boards || []).map(({ id, name, privacy, selected }) => ({ id, name, privacy, selected })),
+    },
   };
 });
 
@@ -783,6 +914,7 @@ const INDUSTRY_DEFAULTS = {
   bluesky:   [[1,10],[2,10],[3,10],[1,11],[2,11]],
   reddit:    [[1,7],[2,7],[3,7],[4,7],[0,9]],
   youtube:   [[4,12],[5,12],[6,12],[4,15],[5,15]],
+  pinterest: [[5,12],[6,14],[0,15],[5,20],[6,20]],
 };
 const DEFAULT_SLOTS = [[2,9],[3,9],[4,9],[2,12],[3,12]];
 

+ 7 - 0
services/pinterest/Dockerfile

@@ -0,0 +1,7 @@
+FROM node:20-alpine
+WORKDIR /services/pinterest
+COPY pinterest/package*.json ./
+RUN npm install
+COPY utils ./utils
+COPY pinterest/ .
+CMD ["node", "index.js"]

+ 157 - 0
services/pinterest/index.js

@@ -0,0 +1,157 @@
+require('dotenv').config();
+const axios = require('axios');
+const BasePlatformService = require('./utils/BasePlatformService');
+const { getDb } = require('./utils/MongoDBConnector');
+const { decryptToken, warnIfNoKey } = require('./utils/crypto');
+
+const PINTEREST_API = 'https://api.pinterest.com/v5';
+
+class PinterestService extends BasePlatformService {
+  constructor() {
+    super('pinterest');
+  }
+
+  async _getAccount() {
+    try {
+      const db = await getDb();
+      const cred = await db.collection('platform_credentials').findOne({ _id: 'pinterest' });
+      if (cred?.accessToken) {
+        return { ...cred, accessToken: decryptToken(cred.accessToken) };
+      }
+    } catch (_) {}
+    return null;
+  }
+
+  async getStatus() {
+    const account = await this._getAccount();
+    if (!account?.accessToken) {
+      return { connected: false, platform: 'pinterest', error: 'Not connected — use Settings to connect via Pinterest OAuth' };
+    }
+    try {
+      const res = await axios.get(`${PINTEREST_API}/user_account`, {
+        headers: { Authorization: `Bearer ${account.accessToken}` },
+        timeout: 10000,
+      });
+      const selectedBoards = (account.boards || []).filter((b) => b.selected);
+      return {
+        connected: selectedBoards.length > 0,
+        platform: 'pinterest',
+        username: res.data.username,
+        displayName: res.data.business_name || res.data.username,
+        avatar: res.data.profile_image,
+        boardCount: selectedBoards.length,
+      };
+    } catch (err) {
+      return { connected: false, platform: 'pinterest', error: err.response?.data?.message || err.message };
+    }
+  }
+
+  async fetchFeed({ limit = 25 } = {}) {
+    const account = await this._getAccount();
+    if (!account?.accessToken) throw new Error('Pinterest not connected');
+
+    const allItems = [];
+    const selectedBoards = (account.boards || []).filter((b) => b.selected);
+    const boardsToFetch = selectedBoards.length > 0 ? selectedBoards : (account.boards || []).slice(0, 3);
+
+    for (const board of boardsToFetch) {
+      try {
+        const res = await axios.get(`${PINTEREST_API}/boards/${board.id}/pins`, {
+          headers: { Authorization: `Bearer ${account.accessToken}` },
+          params: { page_size: Math.min(Math.ceil(Number(limit) / Math.max(boardsToFetch.length, 1)), 50) },
+          timeout: 15000,
+        });
+
+        const items = (res.data.items || []).map((pin) =>
+          this.normalizeFeedItem({
+            originalId: pin.id,
+            author: {
+              name: account.displayName || account.username || 'Pinterest',
+              username: account.username || '',
+            },
+            content: pin.description || pin.title || '',
+            media: pin.media?.images?.['600x']?.url
+              ? [{ url: pin.media.images['600x'].url, type: 'image' }]
+              : [],
+            metrics: { likes: pin.save_count || 0, comments: pin.comment_count || 0, shares: 0 },
+            url: `https://www.pinterest.com/pin/${pin.id}/`,
+            createdAt: pin.created_at || new Date(),
+          })
+        );
+
+        allItems.push(...items);
+      } catch (err) {
+        this.app.log.warn({ action: 'feed_fetch', platform: 'pinterest', boardId: board.id, outcome: 'failure', err: err.message });
+      }
+    }
+
+    try {
+      const db = await getDb();
+      const col = db.collection('feeds');
+      for (const item of allItems) {
+        await col.updateOne(
+          { platform: 'pinterest', originalId: item.originalId },
+          { $set: item },
+          { upsert: true }
+        );
+      }
+    } catch (err) {
+      this.app.log.error({ action: 'feed_write', platform: 'pinterest', outcome: 'failure', err: err.message });
+    }
+
+    return allItems;
+  }
+
+  async publishPost({ content, imageUrl, accountId: boardId } = {}) {
+    const account = await this._getAccount();
+    if (!account?.accessToken) throw new Error('Pinterest not connected');
+    if (!boardId) throw new Error('boardId is required for Pinterest — select a board as destination');
+    if (!imageUrl) throw new Error('Pinterest requires an image URL');
+
+    const pinData = {
+      board_id: boardId,
+      description: content || '',
+      media_source: {
+        source_type: 'image_url',
+        url: imageUrl,
+      },
+    };
+
+    if (content) {
+      pinData.title = content.slice(0, 100);
+    }
+
+    try {
+      const res = await axios.post(`${PINTEREST_API}/pins`, pinData, {
+        headers: {
+          Authorization: `Bearer ${account.accessToken}`,
+          'Content-Type': 'application/json',
+        },
+        timeout: 30000,
+      });
+      this.app.log.info({ action: 'publish_post', platform: 'pinterest', boardId, pinId: res.data.id, outcome: 'success' });
+      return { pinId: res.data.id, boardId };
+    } catch (err) {
+      const msg = err.response?.data?.message || err.message;
+      this.app.log.error({ action: 'publish_post', platform: 'pinterest', boardId, outcome: 'failure', err: msg });
+      throw new Error(msg);
+    }
+  }
+}
+
+const service = new PinterestService();
+
+// Returns selected boards from DB (used by gateway/compose to list destinations)
+service.app.get('/boards', async (request, reply) => {
+  try {
+    const account = await service._getAccount();
+    if (!account) return { boards: [] };
+    const selected = (account.boards || []).filter((b) => b.selected);
+    return { boards: selected };
+  } catch (err) {
+    reply.code(500).send({ success: false, error: err.message });
+  }
+});
+
+warnIfNoKey('pinterest');
+service.start(process.env.PORT || 3008);

+ 20 - 0
services/pinterest/package.json

@@ -0,0 +1,20 @@
+{
+  "name": "pinterest-service",
+  "version": "1.0.0",
+  "description": "Pinterest platform service",
+  "main": "index.js",
+  "scripts": {
+    "start": "node index.js",
+    "dev": "nodemon index.js"
+  },
+  "dependencies": {
+    "axios": "^1.6.0",
+    "fastify": "^4.24.3",
+    "mongodb": "^6.3.0",
+    "amqplib": "^0.10.3",
+    "dotenv": "^16.3.1"
+  },
+  "devDependencies": {
+    "nodemon": "^3.0.2"
+  }
+}

+ 1 - 0
services/scheduler/index.js

@@ -17,6 +17,7 @@ const PLATFORM_SERVICES = {
   bluesky:   process.env.BLUESKY_SERVICE_URL   || 'http://bluesky:3004',
   instagram: process.env.INSTAGRAM_SERVICE_URL || 'http://instagram:3005',
   facebook:  process.env.FACEBOOK_SERVICE_URL  || 'http://facebook:3006',
+  pinterest: process.env.PINTEREST_SERVICE_URL || 'http://pinterest:3008',
 };
 
 const log = createLogger('scheduler');

+ 26 - 0
ui/src/locales/en.ts

@@ -237,6 +237,31 @@ export default {
       timezoneAuto: 'Use browser timezone',
     },
 
+    pinterest: {
+      sectionTitle: 'Pinterest',
+      sectionSubtitle: 'Connect your Pinterest account to create pins on your boards.',
+      clientIdLabel: 'Client ID',
+      clientSecretLabel: 'Client Secret',
+      clientIdPlaceholder: 'Your Pinterest App Client ID',
+      clientSecretPlaceholder: 'Your Pinterest App Client Secret',
+      saveApp: 'Save App Credentials',
+      saving: 'Saving...',
+      appConfigured: 'App credentials saved',
+      connectButton: 'Connect with Pinterest',
+      connecting: 'Redirecting to Pinterest...',
+      reconnect: 'Reconnect',
+      disconnect: 'Disconnect',
+      disconnectConfirm: 'This will disconnect your Pinterest account and all boards. Continue?',
+      boardsTitle: 'Your Boards',
+      noBoards: 'No boards found.',
+      saveBoards: 'Save Board Selection',
+      savingBoards: 'Saving...',
+      boardsSaved: 'Saved!',
+      getAppHelp: 'Get your credentials from',
+      devPortal: 'developers.pinterest.com',
+      errorTitle: 'Pinterest OAuth Error',
+    },
+
     meta: {
       sectionTitle: 'Facebook & Instagram',
       sectionSubtitle: 'Both platforms share a single Facebook Developer App. Connect once to manage all your Pages and Instagram accounts.',
@@ -317,5 +342,6 @@ export default {
     facebook: 'Facebook',
     reddit: 'Reddit',
     youtube: 'YouTube',
+    pinterest: 'Pinterest',
   },
 }

+ 26 - 0
ui/src/locales/tr.ts

@@ -237,6 +237,31 @@ export default {
       timezoneAuto: 'Tarayıcı saat dilimini kullan',
     },
 
+    pinterest: {
+      sectionTitle: 'Pinterest',
+      sectionSubtitle: 'Pinterest hesabını bağlayarak panolarına pin oluştur.',
+      clientIdLabel: 'İstemci Kimliği',
+      clientSecretLabel: 'İstemci Gizli Anahtarı',
+      clientIdPlaceholder: 'Pinterest Uygulama İstemci Kimliğin',
+      clientSecretPlaceholder: 'Pinterest Uygulama Gizli Anahtarın',
+      saveApp: 'Uygulama Bilgilerini Kaydet',
+      saving: 'Kaydediliyor...',
+      appConfigured: 'Uygulama bilgileri kaydedildi',
+      connectButton: 'Pinterest ile Bağlan',
+      connecting: 'Pinterest\'e yönlendiriliyor...',
+      reconnect: 'Yeniden Bağlan',
+      disconnect: 'Bağlantıyı Kes',
+      disconnectConfirm: 'Bu işlem Pinterest hesabının ve tüm panolarının bağlantısını keser. Devam edilsin mi?',
+      boardsTitle: 'Panolarınız',
+      noBoards: 'Pano bulunamadı.',
+      saveBoards: 'Pano Seçimini Kaydet',
+      savingBoards: 'Kaydediliyor...',
+      boardsSaved: 'Kaydedildi!',
+      getAppHelp: 'Kimlik bilgilerini şuradan al:',
+      devPortal: 'developers.pinterest.com',
+      errorTitle: 'Pinterest OAuth Hatası',
+    },
+
     meta: {
       sectionTitle: 'Facebook & Instagram',
       sectionSubtitle: 'Her iki platform da aynı Facebook Geliştirici Uygulamasını kullanır. Tüm Sayfaları ve Instagram hesaplarını yönetmek için bir kez bağlan.',
@@ -317,5 +342,6 @@ export default {
     facebook: 'Facebook',
     reddit: 'Reddit',
     youtube: 'YouTube',
+    pinterest: 'Pinterest',
   },
 }

+ 11 - 0
ui/src/stores/compose.ts

@@ -96,6 +96,17 @@ export const useComposeStore = defineStore('compose', () => {
       })
     }
 
+    for (const board of platformsStore.connectedPinterestBoards) {
+      next.push({
+        key: `pinterest:${board.id}`,
+        platform: 'pinterest',
+        accountId: board.id,
+        label: board.name,
+        color: PLATFORM_META.pinterest.color,
+        selected: false,
+      })
+    }
+
     destinations.value = next
   }
 

+ 1 - 1
ui/src/stores/feed.ts

@@ -21,7 +21,7 @@ export interface FeedItem {
 export const useFeedStore = defineStore('feed', () => {
   const items = ref<FeedItem[]>([])
   const loading = ref(false)
-  const activePlatforms = ref<Set<string>>(new Set(['twitter', 'mastodon', 'bluesky', 'linkedin', 'instagram', 'facebook']))
+  const activePlatforms = ref<Set<string>>(new Set(['twitter', 'mastodon', 'bluesky', 'linkedin', 'instagram', 'facebook', 'pinterest']))
   const activePageIds = ref<Set<string>>(new Set())
   const activeIgAccountIds = ref<Set<string>>(new Set())
   const activeTag = ref<string | null>(null)

+ 92 - 0
ui/src/stores/platforms.ts

@@ -31,6 +31,19 @@ export interface MetaDiscovery {
   igAccounts: MetaIgAccount[]
 }
 
+export interface PinterestBoard {
+  id: string
+  name: string
+  privacy?: string
+  selected?: boolean
+}
+
+export interface PinterestCredentials {
+  configured: boolean
+  clientId?: string
+  clientSecretHint?: string
+}
+
 export interface MetaCredentials {
   configured: boolean
   appId?: string
@@ -54,6 +67,7 @@ export const PLATFORM_META: Record<string, { label: string; color: string; icon:
   facebook:  { label: 'Facebook',   color: '#1877F2', icon: 'fa-brands fa-facebook' },
   reddit:    { label: 'Reddit',     color: '#FF4500', icon: 'fa-brands fa-reddit' },
   youtube:   { label: 'YouTube',    color: '#FF0000', icon: 'fa-brands fa-youtube' },
+  pinterest: { label: 'Pinterest',  color: '#E60023', icon: 'fa-brands fa-pinterest' },
 }
 
 export const usePlatformsStore = defineStore('platforms', () => {
@@ -70,6 +84,13 @@ export const usePlatformsStore = defineStore('platforms', () => {
   const connectedPages = ref<MetaPage[]>([])
   const connectedIgAccounts = ref<MetaIgAccount[]>([])
 
+  // Pinterest
+  const pinterestCredentials = ref<PinterestCredentials>({ configured: false })
+  const pinterestLoading = ref(false)
+  const pinterestError = ref<string | null>(null)
+  const connectedPinterestBoards = ref<PinterestBoard[]>([])
+  const allPinterestBoards = ref<PinterestBoard[]>([])
+
   // Token expiry
   const tokenExpiry = ref<TokenExpiryAccount[]>([])
   const tokenExpiryDismissed = ref(false)
@@ -108,9 +129,76 @@ export const usePlatformsStore = defineStore('platforms', () => {
       const data = await res.json()
       connectedPages.value = data.facebook?.pages || []
       connectedIgAccounts.value = data.instagram?.accounts || []
+      connectedPinterestBoards.value = data.pinterest?.boards || []
+      allPinterestBoards.value = data.pinterest?.allBoards || []
     } catch (_) { /* ignore */ }
   }
 
+  async function fetchPinterestCredentials() {
+    try {
+      const res = await axios.get('/api/credentials/pinterest-app')
+      pinterestCredentials.value = res.data
+    } catch (err) {
+      console.error('Pinterest credentials fetch error:', err)
+    }
+  }
+
+  async function savePinterestApp(clientId: string, clientSecret: string) {
+    pinterestLoading.value = true
+    pinterestError.value = null
+    try {
+      await axios.post('/api/credentials/pinterest-app', { clientId, clientSecret })
+      pinterestCredentials.value = { configured: true, clientId, clientSecretHint: `****${clientSecret.slice(-4)}` }
+    } catch (err: any) {
+      pinterestError.value = err.response?.data?.error || 'Failed to save app credentials'
+    } finally {
+      pinterestLoading.value = false
+    }
+  }
+
+  async function startPinterestOAuth() {
+    pinterestLoading.value = true
+    pinterestError.value = null
+    try {
+      const res = await axios.get('/api/auth/pinterest/init')
+      window.location.href = res.data.url
+    } catch (err: any) {
+      pinterestError.value = err.response?.data?.error || 'Failed to start OAuth'
+      pinterestLoading.value = false
+    }
+  }
+
+  async function savePinterestBoards(selectedBoardIds: string[]) {
+    pinterestLoading.value = true
+    pinterestError.value = null
+    try {
+      await axios.post('/api/credentials/pinterest/boards', { selectedBoardIds })
+      allPinterestBoards.value = allPinterestBoards.value.map((b) => ({
+        ...b,
+        selected: selectedBoardIds.includes(b.id),
+      }))
+      connectedPinterestBoards.value = allPinterestBoards.value.filter((b) => b.selected)
+    } catch (err: any) {
+      pinterestError.value = err.response?.data?.error || 'Failed to save board selection'
+    } finally {
+      pinterestLoading.value = false
+    }
+  }
+
+  async function disconnectPinterest() {
+    pinterestLoading.value = true
+    try {
+      await axios.delete('/api/credentials/pinterest')
+      connectedPinterestBoards.value = []
+      allPinterestBoards.value = []
+      await fetchStatuses()
+    } catch (err) {
+      console.error('Pinterest disconnect error:', err)
+    } finally {
+      pinterestLoading.value = false
+    }
+  }
+
   // ─── Platform status ──────────────────────────────────────────────────────
 
   async function fetchStatuses() {
@@ -215,5 +303,9 @@ export const usePlatformsStore = defineStore('platforms', () => {
     fetchMetaDiscovery, saveMetaSelection, disconnectMeta,
     tokenExpiry, expiringAccounts, hasExpiryWarning,
     fetchTokenExpiry, dismissTokenWarning, refreshMetaTokens,
+    pinterestCredentials, pinterestLoading, pinterestError,
+    connectedPinterestBoards, allPinterestBoards,
+    fetchPinterestCredentials, savePinterestApp, startPinterestOAuth,
+    savePinterestBoards, disconnectPinterest,
   }
 })

+ 191 - 3
ui/src/views/Settings.vue

@@ -168,6 +168,146 @@
         </div>
       </div>
 
+      <!-- ═══════════════════════════════════════════════════════════════════
+           PINTEREST — OAuth connection card
+      ════════════════════════════════════════════════════════════════════ -->
+      <div class="bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden">
+
+        <!-- Header -->
+        <div class="p-5 border-b border-gray-800 flex items-center gap-3">
+          <span class="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold" style="background:#E60023">P</span>
+          <div>
+            <p class="font-semibold">{{ $t('settings.pinterest.sectionTitle') }}</p>
+            <p class="text-xs text-gray-500 mt-0.5">{{ $t('settings.pinterest.sectionSubtitle') }}</p>
+          </div>
+        </div>
+
+        <!-- OAuth error banner -->
+        <div v-if="pinterestOauthError" class="mx-5 mt-4 bg-red-900/40 border border-red-700 rounded-lg p-3 text-sm text-red-300 flex items-start gap-2">
+          <span class="shrink-0">⚠</span>
+          <span><strong>{{ $t('settings.pinterest.errorTitle') }}:</strong> {{ pinterestOauthError }}</span>
+        </div>
+
+        <!-- Step 1: App credentials -->
+        <div class="p-5 border-b border-gray-800/60">
+          <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Step 1 — Pinterest Developer App</p>
+
+          <div v-if="pinterestAppConfigured" class="flex items-center justify-between">
+            <div class="flex items-center gap-2 text-sm text-green-400">
+              <span>✓</span>
+              <span>{{ $t('settings.pinterest.appConfigured') }}</span>
+              <span class="text-gray-600 font-mono text-xs">({{ platformsStore.pinterestCredentials.clientId }})</span>
+            </div>
+            <button @click="editingPinterestApp = !editingPinterestApp" class="text-xs px-2.5 py-1 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-md text-gray-400 hover:text-gray-200 transition-colors">
+              Edit
+            </button>
+          </div>
+
+          <div v-if="!pinterestAppConfigured || editingPinterestApp" class="space-y-3 mt-2">
+            <div>
+              <label class="block text-xs text-gray-400 mb-1">{{ $t('settings.pinterest.clientIdLabel') }}</label>
+              <input
+                v-model="pinterestClientId"
+                type="text"
+                :placeholder="$t('settings.pinterest.clientIdPlaceholder')"
+                class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-red-500"
+              />
+            </div>
+            <div>
+              <label class="block text-xs text-gray-400 mb-1">{{ $t('settings.pinterest.clientSecretLabel') }}</label>
+              <input
+                v-model="pinterestClientSecret"
+                type="password"
+                :placeholder="pinterestAppConfigured ? platformsStore.pinterestCredentials.clientSecretHint : $t('settings.pinterest.clientSecretPlaceholder')"
+                class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-red-500"
+              />
+            </div>
+            <div class="flex items-center justify-between">
+              <p class="text-xs text-gray-600">
+                {{ $t('settings.pinterest.getAppHelp') }}
+                <a href="https://developers.pinterest.com/apps/" target="_blank" rel="noopener" class="text-red-400 hover:text-red-300 underline">
+                  {{ $t('settings.pinterest.devPortal') }}
+                </a>
+              </p>
+              <button
+                @click="savePinterestApp"
+                :disabled="!pinterestClientId || !pinterestClientSecret || platformsStore.pinterestLoading"
+                class="px-4 py-1.5 bg-red-600 hover:bg-red-700 disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
+              >
+                {{ platformsStore.pinterestLoading ? $t('settings.pinterest.saving') : $t('settings.pinterest.saveApp') }}
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <!-- Step 2: OAuth connect + boards -->
+        <div class="p-5" :class="{ 'opacity-40 pointer-events-none': !pinterestAppConfigured }">
+          <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Step 2 — Connect Account</p>
+
+          <!-- Connected — show boards -->
+          <div v-if="pinterestConnected" class="space-y-4">
+            <div class="space-y-1.5">
+              <p class="text-xs text-gray-500">{{ $t('settings.pinterest.boardsTitle') }}</p>
+              <div v-if="!platformsStore.allPinterestBoards.length" class="text-sm text-gray-600">{{ $t('settings.pinterest.noBoards') }}</div>
+              <div v-else class="space-y-1.5">
+                <label
+                  v-for="board in platformsStore.allPinterestBoards"
+                  :key="board.id"
+                  class="flex items-center gap-3 bg-gray-800/60 rounded-lg px-3 py-2 cursor-pointer hover:bg-gray-800 transition-colors"
+                >
+                  <input type="checkbox" :value="board.id" v-model="selectedBoardIds" class="w-4 h-4 accent-red-500" />
+                  <span class="w-6 h-6 rounded flex items-center justify-center text-white text-xs font-bold shrink-0" style="background:#E60023">P</span>
+                  <span class="text-sm flex-1">{{ board.name }}</span>
+                  <span class="text-xs text-gray-600">{{ board.privacy }}</span>
+                  <span v-if="board.selected" class="w-2 h-2 rounded-full bg-green-400 shrink-0"></span>
+                </label>
+              </div>
+              <div class="flex items-center justify-between pt-2">
+                <span v-if="pinterestBoardsSaved" class="text-xs text-green-400">{{ $t('settings.pinterest.boardsSaved') }}</span>
+                <span v-else />
+                <button
+                  @click="savePinterestBoards"
+                  :disabled="platformsStore.pinterestLoading"
+                  class="px-4 py-1.5 bg-red-600 hover:bg-red-700 disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
+                >
+                  {{ platformsStore.pinterestLoading ? $t('settings.pinterest.savingBoards') : $t('settings.pinterest.saveBoards') }}
+                </button>
+              </div>
+            </div>
+
+            <div class="flex gap-2">
+              <button
+                @click="platformsStore.startPinterestOAuth()"
+                :disabled="platformsStore.pinterestLoading"
+                class="px-4 py-2 bg-gray-700 hover:bg-gray-600 border border-gray-600 disabled:opacity-40 rounded-lg text-xs font-medium transition-colors"
+              >
+                {{ $t('settings.pinterest.reconnect') }}
+              </button>
+              <button
+                @click="confirmPinterestDisconnect"
+                :disabled="platformsStore.pinterestLoading"
+                class="px-4 py-2 text-red-400 hover:text-red-300 bg-red-900/20 hover:bg-red-900/40 border border-red-900/50 disabled:opacity-40 rounded-lg text-xs font-medium transition-colors"
+              >
+                {{ $t('settings.pinterest.disconnect') }}
+              </button>
+            </div>
+          </div>
+
+          <!-- Not yet connected -->
+          <div v-else>
+            <button
+              @click="platformsStore.startPinterestOAuth()"
+              :disabled="!pinterestAppConfigured || platformsStore.pinterestLoading"
+              class="w-full py-2.5 bg-red-600 hover:bg-red-700 disabled:opacity-40 rounded-xl text-sm font-medium transition-colors flex items-center justify-center gap-2"
+            >
+              <span v-if="platformsStore.pinterestLoading">{{ $t('settings.pinterest.connecting') }}</span>
+              <span v-else>{{ $t('settings.pinterest.connectButton') }}</span>
+            </button>
+          </div>
+        </div>
+
+      </div>
+
       <!-- ═══════════════════════════════════════════════════════════════════
            PAGE/ACCOUNT PICKER — shown after OAuth callback
       ════════════════════════════════════════════════════════════════════ -->
@@ -664,10 +804,44 @@ async function confirmSelection() {
 
 const oauthError = ref<string | null>(null)
 
+// ─── Pinterest ────────────────────────────────────────────────────────────────
+
+const pinterestClientId = ref('')
+const pinterestClientSecret = ref('')
+const editingPinterestApp = ref(false)
+const pinterestOauthError = ref<string | null>(null)
+const pinterestBoardsSaved = ref(false)
+const selectedBoardIds = ref<string[]>([])
+
+const pinterestAppConfigured = computed(() => platformsStore.pinterestCredentials.configured)
+const pinterestConnected = computed(() => platformsStore.allPinterestBoards.length > 0)
+
+async function savePinterestApp() {
+  await platformsStore.savePinterestApp(pinterestClientId.value, pinterestClientSecret.value)
+  if (!platformsStore.pinterestError) {
+    editingPinterestApp.value = false
+    pinterestClientSecret.value = ''
+  }
+}
+
+async function savePinterestBoards() {
+  await platformsStore.savePinterestBoards(selectedBoardIds.value)
+  if (!platformsStore.pinterestError) {
+    pinterestBoardsSaved.value = true
+    setTimeout(() => { pinterestBoardsSaved.value = false }, 2500)
+  }
+}
+
+function confirmPinterestDisconnect() {
+  if (window.confirm(t('settings.pinterest.disconnectConfirm'))) {
+    platformsStore.disconnectPinterest().then(loadMetaConnections)
+  }
+}
+
 // ─── Other platforms (not Meta) ──────────────────────────────────────────────
 
 const otherPlatforms = computed(() => {
-  const skip = new Set(['instagram', 'facebook'])
+  const skip = new Set(['instagram', 'facebook', 'pinterest'])
   return Object.fromEntries(Object.entries(PLATFORM_META).filter(([k]) => !skip.has(k)))
 })
 
@@ -750,7 +924,7 @@ const allConnectedAccounts = computed((): ProfileAccount[] => {
   const accounts: ProfileAccount[] = []
 
   for (const [platform, meta] of Object.entries(PLATFORM_META)) {
-    if (platform === 'facebook' || platform === 'instagram') continue
+    if (platform === 'facebook' || platform === 'instagram' || platform === 'pinterest') continue
     if (platformsStore.isConnected(platform)) {
       accounts.push({ key: platform, label: t(`platforms.${platform}`), platform, color: meta.color, avatar: null })
     }
@@ -764,6 +938,10 @@ const allConnectedAccounts = computed((): ProfileAccount[] => {
     accounts.push({ key: `instagram:${account.id}`, label: `@${account.username}`, platform: 'instagram', color: PLATFORM_META.instagram.color, avatar: account.avatar || null })
   }
 
+  for (const board of platformsStore.connectedPinterestBoards) {
+    accounts.push({ key: `pinterest:${board.id}`, label: board.name, platform: 'pinterest', color: PLATFORM_META.pinterest.color, avatar: null })
+  }
+
   return accounts
 })
 
@@ -833,22 +1011,32 @@ onMounted(async () => {
   // Check for OAuth callback query params
   if (route.query.meta_discovery) {
     await platformsStore.fetchMetaDiscovery()
-    // Clear query param from URL without navigation
     window.history.replaceState({}, '', '/settings')
   }
   if (route.query.meta_error) {
     oauthError.value = decodeURIComponent(String(route.query.meta_error))
     window.history.replaceState({}, '', '/settings')
   }
+  if (route.query.pinterest_connected) {
+    window.history.replaceState({}, '', '/settings')
+  }
+  if (route.query.pinterest_error) {
+    pinterestOauthError.value = decodeURIComponent(String(route.query.pinterest_error))
+    window.history.replaceState({}, '', '/settings')
+  }
 
   await Promise.all([
     platformsStore.fetchStatuses(),
     platformsStore.fetchMetaCredentials(),
+    platformsStore.fetchPinterestCredentials(),
     loadMetaConnections(),
     platformsStore.fetchTokenExpiry(),
     aiStore.fetchConfig(),
   ])
 
+  // Seed board checkboxes from current selection
+  selectedBoardIds.value = platformsStore.allPinterestBoards.filter((b) => b.selected).map((b) => b.id)
+
   // Seed local form from fetched config
   aiEndpoint.value = aiStore.config.endpoint
   aiModel.value = aiStore.config.model