Просмотр исходного кода

feat: add Facebook app changes

Benjamin Harris 1 месяц назад
Родитель
Сommit
b3352b4a82

+ 26 - 1
.env.example

@@ -1,3 +1,8 @@
+# ─── App ───────────────────────────────────────────────────────────────────────
+# Public URL of the app — used as OAuth redirect_uri base. Must match exactly
+# what is registered in your Facebook Developer App > Valid OAuth Redirect URIs.
+APP_BASE_URL=http://localhost:8081
+
 # ─── RabbitMQ ──────────────────────────────────────────────────────────────────
 # ─── RabbitMQ ──────────────────────────────────────────────────────────────────
 RABBITMQ_URL=amqp://username:password@messageBroker:5672
 RABBITMQ_URL=amqp://username:password@messageBroker:5672
 
 
@@ -33,9 +38,29 @@ BLUESKY_IDENTIFIER=
 # Settings > App Passwords bölümünden oluşturulan şifre (gerçek şifreni kullanma!)
 # Settings > App Passwords bölümünden oluşturulan şifre (gerçek şifreni kullanma!)
 BLUESKY_APP_PASSWORD=
 BLUESKY_APP_PASSWORD=
 
 
+# ─── Facebook Developer App (shared by Instagram + Facebook services) ───────────
+# Create your app at: https://developers.facebook.com/apps/
+# Required permissions: pages_manage_posts, pages_read_engagement,
+#   instagram_basic, instagram_content_publish, instagram_manage_insights
+FACEBOOK_APP_ID=
+FACEBOOK_APP_SECRET=
+
 # ─── Instagram ─────────────────────────────────────────────────────────────────
 # ─── Instagram ─────────────────────────────────────────────────────────────────
-# Facebook Developer App üzerinden alınır (Business/Creator hesabı gerekli)
+# Requires a Business or Creator Instagram account linked to a Facebook Page.
+# Get via OAuth callback (/auth/callback on the instagram service) or from
+# Graph API Explorer: https://developers.facebook.com/tools/explorer/
+# INSTAGRAM_ACCESS_TOKEN  — long-lived user access token (~60 days, use fb_exchange_token to refresh)
+# INSTAGRAM_BUSINESS_ACCOUNT_ID — numeric ID, found via GET /{page-id}?fields=instagram_business_account
 INSTAGRAM_ACCESS_TOKEN=
 INSTAGRAM_ACCESS_TOKEN=
+INSTAGRAM_BUSINESS_ACCOUNT_ID=
+
+# ─── Facebook ──────────────────────────────────────────────────────────────────
+# Requires a Facebook Page (personal timelines are not supported by the Graph API).
+# FACEBOOK_PAGE_ID          — numeric Page ID (visible in Page settings or About section)
+# FACEBOOK_PAGE_ACCESS_TOKEN — never-expiring Page token obtained via GET /me/accounts
+#                              after exchanging a long-lived user token
+FACEBOOK_PAGE_ID=
+FACEBOOK_PAGE_ACCESS_TOKEN=
 
 
 # ─── Reddit ────────────────────────────────────────────────────────────────────
 # ─── Reddit ────────────────────────────────────────────────────────────────────
 # reddit.com/prefs/apps üzerinden oluşturulan uygulama
 # reddit.com/prefs/apps üzerinden oluşturulan uygulama

+ 2 - 0
.gitignore

@@ -1,3 +1,5 @@
+.env
+
 # Logs
 # Logs
 logs
 logs
 *.log
 *.log

+ 80 - 1
README.md

@@ -101,11 +101,90 @@ Open **http://localhost:8081** in your browser.
 | Reddit | Free | ✅ | ✅ | Register an app at reddit.com/prefs/apps |
 | Reddit | Free | ✅ | ✅ | Register an app at reddit.com/prefs/apps |
 | Twitter/X | Paid ($100/mo Basic) | ⚠️ | ✅ | Free tier very limited |
 | Twitter/X | Paid ($100/mo Basic) | ⚠️ | ✅ | Free tier very limited |
 | LinkedIn | Free | ⚠️ | ✅ | Personal feed read not available via API |
 | LinkedIn | Free | ⚠️ | ✅ | Personal feed read not available via API |
-| Instagram | Free | ⚠️ | ⚠️ | Business/Creator account required |
+| Instagram | Free | ✅ | ✅ | Business/Creator account + Facebook Page required |
+| Facebook | Free | ✅ | ✅ | Facebook Page required (personal timelines not supported) |
 | YouTube | Free | ✅ | ❌ | Subscription feed read-only |
 | YouTube | Free | ✅ | ❌ | Subscription feed read-only |
 
 
 ---
 ---
 
 
+## Instagram & Facebook Setup (Facebook Developer App)
+
+Both Instagram and Facebook share **one Facebook Developer App**. You set it up once, then connect everything from the Settings UI — no token copying required.
+
+### Prerequisites
+
+- A [Facebook Developer account](https://developers.facebook.com/)
+- A **Facebook Page** (personal timelines are not supported by the Graph API)
+- For Instagram: a **Business or Creator Instagram account** linked to that Facebook Page
+
+---
+
+### Step 1 — Create a Facebook App
+
+1. Go to [developers.facebook.com/apps](https://developers.facebook.com/apps/) and click **Create App**
+2. Choose **Business** as the app type and give it a name (e.g. `SocialManager Local`)
+3. In **Settings > Basic**, note down your **App ID** and **App Secret**
+
+---
+
+### Step 2 — Add Products & Permissions
+
+In your app dashboard:
+
+#### Facebook Login for Business
+
+- Products > **Facebook Login for Business** > Set Up
+- Under **Client OAuth Settings**, add to **Valid OAuth Redirect URIs**:
+
+  ```text
+  http://localhost:8081/api/auth/meta/callback
+  ```
+
+  (If hosting remotely, replace `http://localhost:8081` with your `APP_BASE_URL`)
+
+#### Instagram Graph API
+
+- Products > **Instagram Graph API** > Set Up
+
+#### Required Permissions
+
+Enable in Development mode — no App Review needed for your own accounts:
+
+| Permission | Used for |
+|------------|----------|
+| `pages_manage_posts` | Post to Facebook Page |
+| `pages_read_engagement` | Read Facebook Page feed |
+| `instagram_basic` | Read Instagram media |
+| `instagram_content_publish` | Publish Instagram posts |
+| `instagram_manage_insights` | Required alongside content_publish |
+
+---
+
+### Step 3 — Connect from the Settings UI
+
+1. Open <http://localhost:8081/settings>
+2. In the **Facebook & Instagram** card, enter your App ID and App Secret → **Save**
+3. Click **Connect with Facebook & Instagram** — this redirects you to Facebook's OAuth page
+4. Authorise the app
+5. You are returned to Settings with a **page picker** listing all your Facebook Pages and linked Instagram accounts
+6. Check the ones you want to manage → **Connect Selected**
+
+That's it. Tokens are stored in MongoDB — no `.env` editing required. You can connect multiple Pages and Instagram accounts simultaneously.
+
+---
+
+### Token Notes
+
+| Token | Expiry | How it is handled |
+| --- | --- | --- |
+| Short-lived user token | 1–2 hours | Exchanged automatically during OAuth |
+| Long-lived user token | ~60 days | Stored in MongoDB; reconnect via Settings when it expires |
+| Page access token | Never expires | Fetched during OAuth and stored; does not need refreshing |
+
+> **Instagram publishing:** Instagram does not support text-only posts via the Graph API. Every post requires at least one image URL (`imageUrl`) or video URL (`videoUrl`) in addition to the caption. The compose view will need these fields when targeting Instagram.
+
+---
+
 ## Adding a New Language
 ## Adding a New Language
 
 
 1. Create `ui/src/locales/xx.ts` (copy `en.ts` and translate)
 1. Create `ui/src/locales/xx.ts` (copy `en.ts` and translate)

+ 34 - 0
docker-compose.yml

@@ -66,10 +66,12 @@ services:
       - ./services/utils:/services/gateway/utils
       - ./services/utils:/services/gateway/utils
       - ./services/gateway:/services/gateway
       - ./services/gateway:/services/gateway
       - gateway_modules:/services/gateway/node_modules
       - gateway_modules:/services/gateway/node_modules
+    env_file: .env
     networks:
     networks:
       - socialMediaManagerNetwork
       - socialMediaManagerNetwork
     depends_on:
     depends_on:
       - messageBroker
       - messageBroker
+      - mongodb
 
 
   socket:
   socket:
     build: ./services/socket
     build: ./services/socket
@@ -150,6 +152,34 @@ services:
       - messageBroker
       - messageBroker
       - mongodb
       - mongodb
 
 
+  instagram:
+    build:
+      context: ./services
+      dockerfile: instagram/Dockerfile
+    volumes:
+      - instagram_modules:/services/instagram/node_modules
+    restart: unless-stopped
+    env_file: .env
+    networks:
+      - socialMediaManagerNetwork
+    depends_on:
+      - messageBroker
+      - mongodb
+
+  facebook:
+    build:
+      context: ./services
+      dockerfile: facebook/Dockerfile
+    volumes:
+      - facebook_modules:/services/facebook/node_modules
+    restart: unless-stopped
+    env_file: .env
+    networks:
+      - socialMediaManagerNetwork
+    depends_on:
+      - messageBroker
+      - mongodb
+
   feed-aggregator:
   feed-aggregator:
     build:
     build:
       context: ./services
       context: ./services
@@ -167,6 +197,8 @@ services:
       - linkedin
       - linkedin
       - mastodon
       - mastodon
       - bluesky
       - bluesky
+      - instagram
+      - facebook
 
 
   scheduler:
   scheduler:
     build:
     build:
@@ -206,6 +238,8 @@ volumes:
   linkedin_modules:
   linkedin_modules:
   mastodon_modules:
   mastodon_modules:
   bluesky_modules:
   bluesky_modules:
+  instagram_modules:
+  facebook_modules:
   feed_aggregator_modules:
   feed_aggregator_modules:
   scheduler_modules:
   scheduler_modules:
   ui_modules:
   ui_modules:

+ 7 - 0
services/facebook/Dockerfile

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

+ 132 - 0
services/facebook/index.js

@@ -0,0 +1,132 @@
+require('dotenv').config();
+const axios = require('axios');
+const BasePlatformService = require('./utils/BasePlatformService');
+const { getDb } = require('./utils/MongoDBConnector');
+
+const GRAPH_API = 'https://graph.facebook.com/v22.0';
+
+class FacebookService extends BasePlatformService {
+  constructor() {
+    super('facebook');
+  }
+
+  // Read selected Facebook Pages from MongoDB.
+  // Falls back to env vars for backwards compatibility.
+  async _getPages() {
+    try {
+      const db = await getDb();
+      const cred = await db.collection('platform_credentials').findOne({ _id: 'facebook' });
+      const dbPages = (cred?.pages || []).filter((p) => p.selected);
+      if (dbPages.length > 0) return dbPages;
+    } catch (_) { /* fall through */ }
+
+    // Env var fallback (legacy single-page mode)
+    const { FACEBOOK_PAGE_ACCESS_TOKEN, FACEBOOK_PAGE_ID } = process.env;
+    if (FACEBOOK_PAGE_ACCESS_TOKEN && FACEBOOK_PAGE_ID) {
+      return [{ id: FACEBOOK_PAGE_ID, accessToken: FACEBOOK_PAGE_ACCESS_TOKEN }];
+    }
+
+    return [];
+  }
+
+  async getStatus() {
+    const pages = await this._getPages();
+    if (pages.length === 0) {
+      return { connected: false, platform: 'facebook', error: 'No Facebook Pages connected — use Settings to connect via Facebook OAuth' };
+    }
+    try {
+      const first = pages[0];
+      const res = await axios.get(`${GRAPH_API}/${first.id}`, {
+        params: {
+          fields: 'id,name,username,picture',
+          access_token: first.accessToken,
+        },
+      });
+      return {
+        connected: true,
+        platform: 'facebook',
+        username: res.data.username || res.data.name,
+        displayName: res.data.name,
+        avatar: res.data.picture?.data?.url,
+        pageCount: pages.length,
+      };
+    } catch (err) {
+      return { connected: false, platform: 'facebook', error: err.response?.data?.error?.message || err.message };
+    }
+  }
+
+  async fetchFeed({ limit = 20 } = {}) {
+    const pages = await this._getPages();
+    if (pages.length === 0) throw new Error('No Facebook Pages connected');
+
+    const allItems = [];
+
+    for (const page of pages) {
+      const res = await axios.get(`${GRAPH_API}/${page.id}/feed`, {
+        params: {
+          fields: 'id,message,story,full_picture,created_time,permalink_url,likes.summary(true),comments.summary(true),shares',
+          limit: Math.min(Number(limit), 100),
+          access_token: page.accessToken,
+        },
+      });
+
+      const items = (res.data.data || []).map((post) =>
+        this.normalizeFeedItem({
+          originalId: post.id,
+          author: {
+            name: page.name || '',
+            username: page.name || '',
+          },
+          content: post.message || post.story || '',
+          media: post.full_picture ? [{ url: post.full_picture, type: 'image' }] : [],
+          metrics: {
+            likes: post.likes?.summary?.total_count || 0,
+            comments: post.comments?.summary?.total_count || 0,
+            shares: post.shares?.count || 0,
+          },
+          url: post.permalink_url,
+          createdAt: post.created_time,
+        })
+      );
+
+      allItems.push(...items);
+    }
+
+    try {
+      const db = await getDb();
+      const col = db.collection('feeds');
+      for (const item of allItems) {
+        await col.updateOne(
+          { platform: 'facebook', originalId: item.originalId },
+          { $set: item },
+          { upsert: true }
+        );
+      }
+    } catch (err) {
+      console.error('[Facebook] MongoDB write error:', err.message);
+    }
+
+    return allItems;
+  }
+
+  async publishPost({ content, link, imageUrl } = {}) {
+    const pages = await this._getPages();
+    if (pages.length === 0) throw new Error('No Facebook Pages connected');
+    if (!content) throw new Error('content is required');
+
+    const results = [];
+    for (const page of pages) {
+      const params = { message: content, access_token: page.accessToken };
+      if (link) params.link = link;
+      if (imageUrl) params.picture = imageUrl;
+
+      const res = await axios.post(`${GRAPH_API}/${page.id}/feed`, null, { params });
+      results.push({ pageId: page.id, pageName: page.name, postId: res.data.id });
+    }
+
+    return results;
+  }
+}
+
+const service = new FacebookService();
+service.start(process.env.PORT || 3006);

+ 20 - 0
services/facebook/package.json

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

+ 6 - 4
services/feed-aggregator/index.js

@@ -9,10 +9,12 @@ const FEED_REFRESH_INTERVAL = process.env.FEED_REFRESH_INTERVAL || '*/5 * * * *'
 
 
 // Platform servis URL'leri (docker network içinde)
 // Platform servis URL'leri (docker network içinde)
 const PLATFORM_SERVICES = {
 const PLATFORM_SERVICES = {
-  twitter:  process.env.TWITTER_SERVICE_URL  || 'http://twitter:3001',
-  linkedin: process.env.LINKEDIN_SERVICE_URL || 'http://linkedin:3002',
-  mastodon: process.env.MASTODON_SERVICE_URL || 'http://mastodon:3003',
-  bluesky:  process.env.BLUESKY_SERVICE_URL  || 'http://bluesky:3004',
+  twitter:   process.env.TWITTER_SERVICE_URL   || 'http://twitter:3001',
+  linkedin:  process.env.LINKEDIN_SERVICE_URL  || 'http://linkedin:3002',
+  mastodon:  process.env.MASTODON_SERVICE_URL  || 'http://mastodon:3003',
+  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',
 };
 };
 
 
 const app = Fastify({ logger: false });
 const app = Fastify({ logger: false });

+ 3 - 7
services/gateway/dockerfile

@@ -1,10 +1,6 @@
-FROM node:current-alpine3.17
-
+FROM node:20-alpine
+WORKDIR /services/gateway
 COPY package*.json ./
 COPY package*.json ./
 RUN npm install
 RUN npm install
-
-WORKDIR /services/gateway
-
 COPY . .
 COPY . .
-
-CMD [ "npm", "start" ]
+CMD ["node", "start.js"]

+ 5 - 2
services/gateway/package.json

@@ -9,11 +9,14 @@
     },
     },
     "dependencies": {
     "dependencies": {
         "amqplib": "^0.10.3",
         "amqplib": "^0.10.3",
-        "fastify": "^3.22.0",
+        "axios": "^1.6.7",
+        "dotenv": "^16.3.1",
+        "fastify": "^4.24.3",
+        "mongodb": "^6.3.0",
         "nodemon": "^3.0.1"
         "nodemon": "^3.0.1"
     },
     },
     "engines": {
     "engines": {
-        "node": ">=12.0.0"
+        "node": ">=18.0.0"
     },
     },
     "devDependencies": {
     "devDependencies": {
         "jest": "^29.6.4",
         "jest": "^29.6.4",

+ 250 - 2
services/gateway/server.js

@@ -1,5 +1,49 @@
-const app = require('fastify')();
+require('dotenv').config();
+const app = require('fastify')({ logger: false });
+const axios = require('axios');
+const { getDb } = require('./utils/MongoDBConnector');
 const RabbitMQProducer = require('./utils/RabbitMQProducer');
 const RabbitMQProducer = require('./utils/RabbitMQProducer');
+
+const GRAPH_API = 'https://graph.facebook.com/v22.0';
+
+// The public base URL of this app (used for OAuth redirect_uri)
+const APP_BASE_URL = process.env.APP_BASE_URL || 'http://localhost:8081';
+
+// ─── CORS ────────────────────────────────────────────────────────────────────
+
+app.addHook('onSend', async (request, reply) => {
+  reply.header('Access-Control-Allow-Origin', '*');
+  reply.header('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
+  reply.header('Access-Control-Allow-Headers', 'Content-Type');
+});
+
+app.options('*', async (request, reply) => {
+  reply.code(204).send();
+});
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+async function getCredentials(id) {
+  const db = await getDb();
+  return db.collection('platform_credentials').findOne({ _id: id });
+}
+
+async function setCredentials(id, data) {
+  const db = await getDb();
+  await db.collection('platform_credentials').updateOne(
+    { _id: id },
+    { $set: { _id: id, ...data, updatedAt: new Date() } },
+    { upsert: true }
+  );
+}
+
+async function deleteCredentials(id) {
+  const db = await getDb();
+  await db.collection('platform_credentials').deleteOne({ _id: id });
+}
+
+// ─── Legacy post route ────────────────────────────────────────────────────────
+
 let rabbitMQProducer = new RabbitMQProducer();
 let rabbitMQProducer = new RabbitMQProducer();
 
 
 app.post('/', async (request, reply) => {
 app.post('/', async (request, reply) => {
@@ -12,4 +56,208 @@ app.post('/', async (request, reply) => {
   }
   }
 });
 });
 
 
-module.exports = app;
+// ─── Meta App Credentials ────────────────────────────────────────────────────
+
+// Save Facebook App ID + Secret (entered by user in Settings UI)
+app.post('/credentials/meta-app', async (request, reply) => {
+  const { appId, appSecret } = request.body || {};
+  if (!appId || !appSecret) {
+    return reply.code(400).send({ error: 'appId and appSecret are required' });
+  }
+  await setCredentials('meta_app', { appId, appSecret });
+  return { success: true };
+});
+
+// Get Meta App config (secret is masked for UI display)
+app.get('/credentials/meta-app', async () => {
+  const cred = await getCredentials('meta_app');
+  if (!cred) return { configured: false };
+  return { configured: true, appId: cred.appId, appSecretHint: `****${cred.appSecret.slice(-4)}` };
+});
+
+// ─── Meta OAuth Flow ──────────────────────────────────────────────────────────
+
+// Return the Facebook OAuth URL to redirect the user to
+app.get('/auth/meta/init', async (request, reply) => {
+  const cred = await getCredentials('meta_app');
+  if (!cred?.appId) {
+    return reply.code(400).send({ error: 'Save your Facebook App ID and Secret first' });
+  }
+
+  const redirectUri = `${APP_BASE_URL}/api/auth/meta/callback`;
+  const scopes = [
+    'pages_manage_posts',
+    'pages_read_engagement',
+    'instagram_basic',
+    'instagram_content_publish',
+    'instagram_manage_insights',
+  ].join(',');
+
+  const url = `https://www.facebook.com/v22.0/dialog/oauth?client_id=${cred.appId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scopes}&response_type=code`;
+  return { url };
+});
+
+// OAuth callback — Facebook redirects here after user authorises
+app.get('/auth/meta/callback', async (request, reply) => {
+  const { code, error: oauthError } = request.query;
+
+  if (oauthError) {
+    return reply.redirect(`${APP_BASE_URL}/settings?meta_error=${encodeURIComponent(oauthError)}`);
+  }
+
+  if (!code) {
+    return reply.redirect(`${APP_BASE_URL}/settings?meta_error=no_code`);
+  }
+
+  try {
+    const appCred = await getCredentials('meta_app');
+    if (!appCred?.appId) throw new Error('App credentials not configured');
+
+    const redirectUri = `${APP_BASE_URL}/api/auth/meta/callback`;
+
+    // Exchange code for short-lived token
+    const shortRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
+      params: {
+        client_id: appCred.appId,
+        client_secret: appCred.appSecret,
+        redirect_uri: redirectUri,
+        code,
+      },
+    });
+
+    // Upgrade to long-lived user token (~60 days)
+    const longRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
+      params: {
+        grant_type: 'fb_exchange_token',
+        client_id: appCred.appId,
+        client_secret: appCred.appSecret,
+        fb_exchange_token: shortRes.data.access_token,
+      },
+    });
+    const userToken = longRes.data.access_token;
+
+    // Fetch all managed Facebook Pages
+    const pagesRes = await axios.get(`${GRAPH_API}/me/accounts`, {
+      params: { access_token: userToken, fields: 'id,name,access_token,picture' },
+    });
+
+    const pages = [];
+    const igAccounts = [];
+
+    for (const page of pagesRes.data.data || []) {
+      pages.push({
+        id: page.id,
+        name: page.name,
+        accessToken: page.access_token,
+        picture: page.picture?.data?.url || null,
+        selected: false,
+      });
+
+      // Check for linked Instagram Business Account
+      try {
+        const igRes = await axios.get(`${GRAPH_API}/${page.id}`, {
+          params: {
+            fields: 'instagram_business_account',
+            access_token: page.access_token,
+          },
+        });
+        if (igRes.data.instagram_business_account?.id) {
+          const igId = igRes.data.instagram_business_account.id;
+          // Fetch IG account details
+          const igProfile = await axios.get(`${GRAPH_API}/${igId}`, {
+            params: {
+              fields: 'id,username,name,profile_picture_url',
+              access_token: userToken,
+            },
+          });
+          igAccounts.push({
+            id: igId,
+            username: igProfile.data.username || igProfile.data.name,
+            name: igProfile.data.name,
+            avatar: igProfile.data.profile_picture_url || null,
+            accessToken: userToken,
+            pageId: page.id,
+            selected: false,
+          });
+        }
+      } catch (_) {
+        // Page has no linked Instagram account — skip
+      }
+    }
+
+    // Store discovery results for the UI to pick from
+    await setCredentials('meta_discovery', { pages, igAccounts, discoveredAt: new Date() });
+
+    reply.redirect(`${APP_BASE_URL}/settings?meta_discovery=1`);
+  } catch (err) {
+    console.error('[Gateway] Meta OAuth error:', err.response?.data || err.message);
+    reply.redirect(`${APP_BASE_URL}/settings?meta_error=${encodeURIComponent(err.message)}`);
+  }
+});
+
+// Return pending discovery results so the UI can render the page picker
+app.get('/auth/meta/discovered', async () => {
+  const discovery = await getCredentials('meta_discovery');
+  if (!discovery) return { pages: [], igAccounts: [] };
+  return { pages: discovery.pages || [], igAccounts: discovery.igAccounts || [] };
+});
+
+// User has chosen which pages/accounts to connect
+app.post('/auth/meta/save', async (request, reply) => {
+  const { selectedPageIds = [], selectedIgAccountIds = [] } = request.body || {};
+
+  const discovery = await getCredentials('meta_discovery');
+  if (!discovery) return reply.code(400).send({ error: 'No discovery data found — reconnect via OAuth' });
+
+  const fbPages = (discovery.pages || []).map((p) => ({
+    ...p,
+    selected: selectedPageIds.includes(p.id),
+  }));
+
+  const igAccounts = (discovery.igAccounts || []).map((a) => ({
+    ...a,
+    selected: selectedIgAccountIds.includes(a.id),
+  }));
+
+  await setCredentials('facebook', { pages: fbPages });
+  await setCredentials('instagram', { accounts: igAccounts });
+  await deleteCredentials('meta_discovery');
+
+  return { success: true, facebookPages: fbPages.filter((p) => p.selected).length, instagramAccounts: igAccounts.filter((a) => a.selected).length };
+});
+
+// Disconnect all Meta platforms
+app.delete('/credentials/meta', async () => {
+  await deleteCredentials('facebook');
+  await deleteCredentials('instagram');
+  await deleteCredentials('meta_discovery');
+  return { success: true };
+});
+
+// ─── Credential Status ────────────────────────────────────────────────────────
+
+// Aggregate connection status for all DB-managed platforms
+app.get('/credentials', async () => {
+  const [metaApp, fb, ig] = await Promise.all([
+    getCredentials('meta_app'),
+    getCredentials('facebook'),
+    getCredentials('instagram'),
+  ]);
+
+  const fbPages = (fb?.pages || []).filter((p) => p.selected);
+  const igAccounts = (ig?.accounts || []).filter((a) => a.selected);
+
+  return {
+    metaApp: { configured: !!(metaApp?.appId) },
+    facebook: {
+      connected: fbPages.length > 0,
+      pages: fbPages.map(({ id, name, picture }) => ({ id, name, picture })),
+    },
+    instagram: {
+      connected: igAccounts.length > 0,
+      accounts: igAccounts.map(({ id, username, avatar }) => ({ id, username, avatar })),
+    },
+  };
+});
+
+module.exports = app;

+ 13 - 8
services/gateway/start.js

@@ -1,9 +1,14 @@
-const app = require("./server.js");
+require('dotenv').config();
+const app = require('./server.js');
+const { connect } = require('./utils/MongoDBConnector');
 
 
-app.listen(8084, 'gateway', (err, address) => {
-    if (err) {
-        console.error(err);
-        process.exit(1);
-    }
-    console.log(`Gateway api service workin on ${address}`);
-});
+async function start() {
+  await connect();
+  await app.listen({ port: 8084, host: '0.0.0.0' });
+  console.log('[Gateway] API service running on port 8084');
+}
+
+start().catch((err) => {
+  console.error('[Gateway] Failed to start:', err);
+  process.exit(1);
+});

+ 7 - 0
services/instagram/Dockerfile

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

+ 158 - 0
services/instagram/index.js

@@ -0,0 +1,158 @@
+require('dotenv').config();
+const axios = require('axios');
+const BasePlatformService = require('./utils/BasePlatformService');
+const { getDb } = require('./utils/MongoDBConnector');
+
+const GRAPH_API = 'https://graph.facebook.com/v22.0';
+
+class InstagramService extends BasePlatformService {
+  constructor() {
+    super('instagram');
+  }
+
+  // Read selected Instagram Business Accounts from MongoDB.
+  // Falls back to env vars for backwards compatibility.
+  async _getAccounts() {
+    try {
+      const db = await getDb();
+      const cred = await db.collection('platform_credentials').findOne({ _id: 'instagram' });
+      const dbAccounts = (cred?.accounts || []).filter((a) => a.selected);
+      if (dbAccounts.length > 0) return dbAccounts;
+    } catch (_) { /* fall through */ }
+
+    // Env var fallback (legacy single-account mode)
+    const { INSTAGRAM_ACCESS_TOKEN, INSTAGRAM_BUSINESS_ACCOUNT_ID } = process.env;
+    if (INSTAGRAM_ACCESS_TOKEN && INSTAGRAM_BUSINESS_ACCOUNT_ID) {
+      return [{ id: INSTAGRAM_BUSINESS_ACCOUNT_ID, accessToken: INSTAGRAM_ACCESS_TOKEN }];
+    }
+
+    return [];
+  }
+
+  async getStatus() {
+    const accounts = await this._getAccounts();
+    if (accounts.length === 0) {
+      return { connected: false, platform: 'instagram', error: 'No Instagram accounts connected — use Settings to connect via Facebook OAuth' };
+    }
+    try {
+      const first = accounts[0];
+      const res = await axios.get(`${GRAPH_API}/${first.id}`, {
+        params: {
+          fields: 'id,name,username,profile_picture_url',
+          access_token: first.accessToken,
+        },
+      });
+      return {
+        connected: true,
+        platform: 'instagram',
+        username: res.data.username || res.data.name,
+        displayName: res.data.name,
+        avatar: res.data.profile_picture_url,
+        accountCount: accounts.length,
+      };
+    } catch (err) {
+      return { connected: false, platform: 'instagram', error: err.response?.data?.error?.message || err.message };
+    }
+  }
+
+  async fetchFeed({ limit = 20 } = {}) {
+    const accounts = await this._getAccounts();
+    if (accounts.length === 0) throw new Error('No Instagram accounts connected');
+
+    const allItems = [];
+
+    for (const account of accounts) {
+      const res = await axios.get(`${GRAPH_API}/${account.id}/media`, {
+        params: {
+          fields: 'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count,username',
+          limit: Math.min(Number(limit), 100),
+          access_token: account.accessToken,
+        },
+      });
+
+      const items = (res.data.data || []).map((post) =>
+        this.normalizeFeedItem({
+          originalId: post.id,
+          author: {
+            name: post.username || account.username || '',
+            username: post.username || account.username || '',
+            profileUrl: `https://www.instagram.com/${post.username || account.username || ''}/`,
+          },
+          content: post.caption || '',
+          media: post.media_url
+            ? [{
+                url: post.media_url,
+                type: (post.media_type || 'IMAGE').toLowerCase(),
+                thumbnail: post.thumbnail_url || post.media_url,
+              }]
+            : [],
+          metrics: {
+            likes: post.like_count || 0,
+            comments: post.comments_count || 0,
+          },
+          url: post.permalink,
+          createdAt: post.timestamp,
+        })
+      );
+
+      allItems.push(...items);
+    }
+
+    try {
+      const db = await getDb();
+      const col = db.collection('feeds');
+      for (const item of allItems) {
+        await col.updateOne(
+          { platform: 'instagram', originalId: item.originalId },
+          { $set: item },
+          { upsert: true }
+        );
+      }
+    } catch (err) {
+      console.error('[Instagram] MongoDB write error:', err.message);
+    }
+
+    return allItems;
+  }
+
+  // Instagram requires media (image_url or video_url) — text-only posts are not supported.
+  async publishPost({ content, imageUrl, videoUrl } = {}) {
+    const accounts = await this._getAccounts();
+    if (accounts.length === 0) throw new Error('No Instagram accounts connected');
+
+    if (!imageUrl && !videoUrl) {
+      throw new Error('Instagram requires imageUrl or videoUrl — text-only posts are not supported by the Graph API');
+    }
+
+    const results = [];
+    for (const account of accounts) {
+      const containerParams = {
+        caption: content,
+        access_token: account.accessToken,
+      };
+      if (videoUrl) {
+        containerParams.media_type = 'REELS';
+        containerParams.video_url = videoUrl;
+      } else {
+        containerParams.image_url = imageUrl;
+      }
+
+      const containerRes = await axios.post(
+        `${GRAPH_API}/${account.id}/media`,
+        null,
+        { params: containerParams }
+      );
+      const publishRes = await axios.post(
+        `${GRAPH_API}/${account.id}/media_publish`,
+        null,
+        { params: { creation_id: containerRes.data.id, access_token: account.accessToken } }
+      );
+      results.push({ accountId: account.id, username: account.username, postId: publishRes.data.id });
+    }
+
+    return results;
+  }
+}
+
+const service = new InstagramService();
+service.start(process.env.PORT || 3005);

+ 20 - 0
services/instagram/package.json

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

+ 6 - 4
services/scheduler/index.js

@@ -8,10 +8,12 @@ const { getDb, connect } = require('./utils/MongoDBConnector');
 const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379';
 const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379';
 
 
 const PLATFORM_SERVICES = {
 const PLATFORM_SERVICES = {
-  twitter:  process.env.TWITTER_SERVICE_URL  || 'http://twitter:3001',
-  linkedin: process.env.LINKEDIN_SERVICE_URL || 'http://linkedin:3002',
-  mastodon: process.env.MASTODON_SERVICE_URL || 'http://mastodon:3003',
-  bluesky:  process.env.BLUESKY_SERVICE_URL  || 'http://bluesky:3004',
+  twitter:   process.env.TWITTER_SERVICE_URL   || 'http://twitter:3001',
+  linkedin:  process.env.LINKEDIN_SERVICE_URL  || 'http://linkedin:3002',
+  mastodon:  process.env.MASTODON_SERVICE_URL  || 'http://mastodon:3003',
+  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',
 };
 };
 
 
 const app = Fastify({ logger: false });
 const app = Fastify({ logger: false });

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

@@ -52,6 +52,41 @@ export default {
     notConnected: 'Not connected',
     notConnected: 'Not connected',
     refreshStatus: '↻ Refresh Status',
     refreshStatus: '↻ Refresh Status',
     envHint: 'Configuration required',
     envHint: 'Configuration required',
+
+    meta: {
+      sectionTitle: 'Facebook & Instagram',
+      sectionSubtitle: 'Both platforms share a single Facebook Developer App. Connect once to manage all your Pages and Instagram accounts.',
+      appIdLabel: 'App ID',
+      appSecretLabel: 'App Secret',
+      appIdPlaceholder: 'Your Facebook App ID',
+      appSecretPlaceholder: 'Your Facebook App Secret',
+      saveApp: 'Save App Credentials',
+      saving: 'Saving...',
+      appConfigured: 'App credentials saved',
+      connectButton: 'Connect with Facebook & Instagram',
+      connecting: 'Redirecting to Facebook...',
+      reconnect: 'Reconnect',
+      disconnect: 'Disconnect all',
+      disconnectConfirm: 'This will disconnect all Facebook Pages and Instagram accounts. Continue?',
+
+      discoveryTitle: 'Choose Pages & Accounts to Connect',
+      discoverySubtitle: 'Select any combination of Facebook Pages and Instagram accounts below.',
+      pagesHeading: 'Facebook Pages',
+      igHeading: 'Instagram Accounts',
+      noPages: 'No Facebook Pages found for this account.',
+      noIgAccounts: 'No Instagram Business accounts found.',
+      igLinkedTo: 'Linked to',
+      confirmSelection: 'Connect Selected',
+      confirmingSelection: 'Connecting...',
+      nothingSelected: 'Select at least one Page or Instagram account.',
+
+      connectedPages: 'Connected Pages',
+      connectedAccounts: 'Connected Accounts',
+
+      errorTitle: 'OAuth Error',
+      getAppHelp: 'Get your App ID and Secret from',
+      devPortal: 'developers.facebook.com',
+    },
   },
   },
 
 
   feed: {
   feed: {
@@ -64,6 +99,7 @@ export default {
     mastodon: 'Mastodon',
     mastodon: 'Mastodon',
     bluesky: 'Bluesky',
     bluesky: 'Bluesky',
     instagram: 'Instagram',
     instagram: 'Instagram',
+    facebook: 'Facebook',
     reddit: 'Reddit',
     reddit: 'Reddit',
     youtube: 'YouTube',
     youtube: 'YouTube',
   },
   },

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

@@ -52,6 +52,41 @@ export default {
     notConnected: 'Bağlı değil',
     notConnected: 'Bağlı değil',
     refreshStatus: '↻ Durumları Yenile',
     refreshStatus: '↻ Durumları Yenile',
     envHint: 'Yapılandırma gerekli',
     envHint: 'Yapılandırma gerekli',
+
+    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.',
+      appIdLabel: 'Uygulama Kimliği',
+      appSecretLabel: 'Uygulama Gizli Anahtarı',
+      appIdPlaceholder: 'Facebook Uygulama Kimliğin',
+      appSecretPlaceholder: 'Facebook Uygulama Gizli Anahtarın',
+      saveApp: 'Uygulama Bilgilerini Kaydet',
+      saving: 'Kaydediliyor...',
+      appConfigured: 'Uygulama bilgileri kaydedildi',
+      connectButton: 'Facebook & Instagram ile Bağlan',
+      connecting: 'Facebook\'a yönlendiriliyor...',
+      reconnect: 'Yeniden Bağlan',
+      disconnect: 'Tümünü Bağlantıyı Kes',
+      disconnectConfirm: 'Bu işlem tüm Facebook Sayfaları ve Instagram hesaplarının bağlantısını keser. Devam edilsin mi?',
+
+      discoveryTitle: 'Bağlanacak Sayfa ve Hesapları Seç',
+      discoverySubtitle: 'Aşağıdan istediğin Facebook Sayfası ve Instagram hesabı kombinasyonunu seç.',
+      pagesHeading: 'Facebook Sayfaları',
+      igHeading: 'Instagram Hesapları',
+      noPages: 'Bu hesap için Facebook Sayfası bulunamadı.',
+      noIgAccounts: 'Instagram İşletme Hesabı bulunamadı.',
+      igLinkedTo: 'Bağlı sayfa:',
+      confirmSelection: 'Seçilenleri Bağla',
+      confirmingSelection: 'Bağlanıyor...',
+      nothingSelected: 'En az bir Sayfa veya Instagram hesabı seç.',
+
+      connectedPages: 'Bağlı Sayfalar',
+      connectedAccounts: 'Bağlı Hesaplar',
+
+      errorTitle: 'OAuth Hatası',
+      getAppHelp: 'Uygulama Kimliği ve Gizli Anahtarını şuradan al:',
+      devPortal: 'developers.facebook.com',
+    },
   },
   },
 
 
   feed: {
   feed: {
@@ -64,6 +99,7 @@ export default {
     mastodon: 'Mastodon',
     mastodon: 'Mastodon',
     bluesky: 'Bluesky',
     bluesky: 'Bluesky',
     instagram: 'Instagram',
     instagram: 'Instagram',
+    facebook: 'Facebook',
     reddit: 'Reddit',
     reddit: 'Reddit',
     youtube: 'YouTube',
     youtube: 'YouTube',
   },
   },

+ 122 - 8
ui/src/stores/platforms.ts

@@ -9,22 +9,57 @@ export interface PlatformStatus {
   displayName?: string
   displayName?: string
   avatar?: string
   avatar?: string
   error?: string
   error?: string
+  pageCount?: number
+  accountCount?: number
+}
+
+export interface MetaPage {
+  id: string
+  name: string
+  picture?: string
+}
+
+export interface MetaIgAccount {
+  id: string
+  username: string
+  avatar?: string
+  pageId: string
+}
+
+export interface MetaDiscovery {
+  pages: MetaPage[]
+  igAccounts: MetaIgAccount[]
+}
+
+export interface MetaCredentials {
+  configured: boolean
+  appId?: string
+  appSecretHint?: string
 }
 }
 
 
 export const PLATFORM_META: Record<string, { label: string; color: string; icon: string }> = {
 export const PLATFORM_META: Record<string, { label: string; color: string; icon: string }> = {
-  twitter:  { label: 'Twitter/X',  color: '#000000', icon: 'fa-brands fa-x-twitter' },
-  linkedin: { label: 'LinkedIn',   color: '#0077B5', icon: 'fa-brands fa-linkedin' },
-  mastodon: { label: 'Mastodon',   color: '#6364FF', icon: 'fa-brands fa-mastodon' },
-  bluesky:  { label: 'Bluesky',    color: '#0085FF', icon: 'fa-solid fa-cloud' },
-  instagram:{ label: 'Instagram',  color: '#E1306C', icon: 'fa-brands fa-instagram' },
-  reddit:   { label: 'Reddit',     color: '#FF4500', icon: 'fa-brands fa-reddit' },
-  youtube:  { label: 'YouTube',    color: '#FF0000', icon: 'fa-brands fa-youtube' },
+  twitter:   { label: 'Twitter/X',  color: '#000000', icon: 'fa-brands fa-x-twitter' },
+  linkedin:  { label: 'LinkedIn',   color: '#0077B5', icon: 'fa-brands fa-linkedin' },
+  mastodon:  { label: 'Mastodon',   color: '#6364FF', icon: 'fa-brands fa-mastodon' },
+  bluesky:   { label: 'Bluesky',    color: '#0085FF', icon: 'fa-solid fa-cloud' },
+  instagram: { label: 'Instagram',  color: '#E1306C', icon: 'fa-brands fa-instagram' },
+  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' },
 }
 }
 
 
 export const usePlatformsStore = defineStore('platforms', () => {
 export const usePlatformsStore = defineStore('platforms', () => {
   const statuses = ref<PlatformStatus[]>([])
   const statuses = ref<PlatformStatus[]>([])
   const loading = ref(false)
   const loading = ref(false)
 
 
+  // Meta-specific state
+  const metaCredentials = ref<MetaCredentials>({ configured: false })
+  const metaDiscovery = ref<MetaDiscovery | null>(null)
+  const metaLoading = ref(false)
+  const metaError = ref<string | null>(null)
+
+  // ─── Platform status ──────────────────────────────────────────────────────
+
   async function fetchStatuses() {
   async function fetchStatuses() {
     loading.value = true
     loading.value = true
     try {
     try {
@@ -45,5 +80,84 @@ export const usePlatformsStore = defineStore('platforms', () => {
     return getStatus(platform)?.connected ?? false
     return getStatus(platform)?.connected ?? false
   }
   }
 
 
-  return { statuses, loading, fetchStatuses, getStatus, isConnected }
+  // ─── Meta App credentials ─────────────────────────────────────────────────
+
+  async function fetchMetaCredentials() {
+    try {
+      const res = await axios.get('/api/credentials/meta-app')
+      metaCredentials.value = res.data
+    } catch (err) {
+      console.error('Meta credentials fetch error:', err)
+    }
+  }
+
+  async function saveMetaApp(appId: string, appSecret: string) {
+    metaLoading.value = true
+    metaError.value = null
+    try {
+      await axios.post('/api/credentials/meta-app', { appId, appSecret })
+      metaCredentials.value = { configured: true, appId, appSecretHint: `****${appSecret.slice(-4)}` }
+    } catch (err: any) {
+      metaError.value = err.response?.data?.error || 'Failed to save app credentials'
+    } finally {
+      metaLoading.value = false
+    }
+  }
+
+  // ─── OAuth flow ───────────────────────────────────────────────────────────
+
+  async function startMetaOAuth() {
+    metaLoading.value = true
+    metaError.value = null
+    try {
+      const res = await axios.get('/api/auth/meta/init')
+      // Redirect the browser to Facebook OAuth
+      window.location.href = res.data.url
+    } catch (err: any) {
+      metaError.value = err.response?.data?.error || 'Failed to start OAuth'
+      metaLoading.value = false
+    }
+  }
+
+  async function fetchMetaDiscovery() {
+    try {
+      const res = await axios.get('/api/auth/meta/discovered')
+      metaDiscovery.value = res.data
+    } catch (err) {
+      console.error('Meta discovery fetch error:', err)
+    }
+  }
+
+  async function saveMetaSelection(selectedPageIds: string[], selectedIgAccountIds: string[]) {
+    metaLoading.value = true
+    metaError.value = null
+    try {
+      await axios.post('/api/auth/meta/save', { selectedPageIds, selectedIgAccountIds })
+      metaDiscovery.value = null
+      await fetchStatuses()
+    } catch (err: any) {
+      metaError.value = err.response?.data?.error || 'Failed to save selection'
+    } finally {
+      metaLoading.value = false
+    }
+  }
+
+  async function disconnectMeta() {
+    metaLoading.value = true
+    try {
+      await axios.delete('/api/credentials/meta')
+      await fetchStatuses()
+    } catch (err) {
+      console.error('Meta disconnect error:', err)
+    } finally {
+      metaLoading.value = false
+    }
+  }
+
+  return {
+    statuses, loading, fetchStatuses, getStatus, isConnected,
+    metaCredentials, metaDiscovery, metaLoading, metaError,
+    fetchMetaCredentials, saveMetaApp, startMetaOAuth,
+    fetchMetaDiscovery, saveMetaSelection, disconnectMeta,
+  }
 })
 })

+ 354 - 45
ui/src/views/Settings.vue

@@ -1,73 +1,355 @@
 <template>
 <template>
   <div class="min-h-screen bg-gray-950 text-gray-100 p-6">
   <div class="min-h-screen bg-gray-950 text-gray-100 p-6">
-    <div class="max-w-2xl mx-auto">
-      <h1 class="text-2xl font-bold mb-2">{{ $t('settings.title') }}</h1>
-      <p class="text-sm text-gray-500 mb-8">
-        {{ $t('settings.subtitle', { env: '.env' }) }}
-      </p>
-
-      <div class="space-y-4">
-        <div
-          v-for="(meta, key) in PLATFORM_META"
-          :key="key"
-          class="bg-gray-900 border rounded-xl p-4 transition-colors"
-          :class="isConnected(key) ? 'border-gray-700' : 'border-gray-800'"
-        >
-          <div class="flex items-center justify-between">
-            <div class="flex items-center gap-3">
-              <span
-                class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm"
-                :style="{ backgroundColor: meta.color }"
+    <div class="max-w-2xl mx-auto space-y-8">
+
+      <div>
+        <h1 class="text-2xl font-bold mb-1">{{ $t('settings.title') }}</h1>
+      </div>
+
+      <!-- ═══════════════════════════════════════════════════════════════════
+           FACEBOOK & INSTAGRAM — 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">
+          <div class="flex gap-1.5">
+            <span class="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold" style="background:#1877F2">f</span>
+            <span class="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold" style="background:#E1306C">I</span>
+          </div>
+          <div>
+            <p class="font-semibold">{{ $t('settings.meta.sectionTitle') }}</p>
+            <p class="text-xs text-gray-500 mt-0.5">{{ $t('settings.meta.sectionSubtitle') }}</p>
+          </div>
+        </div>
+
+        <!-- OAuth error banner -->
+        <div v-if="oauthError" 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.meta.errorTitle') }}:</strong> {{ oauthError }}</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 — Facebook Developer App</p>
+
+          <div v-if="metaAppConfigured" class="flex items-center justify-between">
+            <div class="flex items-center gap-2 text-sm text-green-400">
+              <span>✓</span>
+              <span>{{ $t('settings.meta.appConfigured') }}</span>
+              <span class="text-gray-600 font-mono text-xs">({{ platformsStore.metaCredentials.appId }})</span>
+            </div>
+            <button @click="editingApp = !editingApp" 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="!metaAppConfigured || editingApp" class="space-y-3 mt-2">
+            <div>
+              <label class="block text-xs text-gray-400 mb-1">{{ $t('settings.meta.appIdLabel') }}</label>
+              <input
+                v-model="appId"
+                type="text"
+                :placeholder="$t('settings.meta.appIdPlaceholder')"
+                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-blue-500"
+              />
+            </div>
+            <div>
+              <label class="block text-xs text-gray-400 mb-1">{{ $t('settings.meta.appSecretLabel') }}</label>
+              <input
+                v-model="appSecret"
+                type="password"
+                :placeholder="metaAppConfigured ? platformsStore.metaCredentials.appSecretHint : $t('settings.meta.appSecretPlaceholder')"
+                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-blue-500"
+              />
+            </div>
+            <div class="flex items-center justify-between">
+              <p class="text-xs text-gray-600">
+                {{ $t('settings.meta.getAppHelp') }}
+                <a href="https://developers.facebook.com/apps/" target="_blank" rel="noopener" class="text-blue-400 hover:text-blue-300 underline">
+                  {{ $t('settings.meta.devPortal') }}
+                </a>
+              </p>
+              <button
+                @click="saveApp"
+                :disabled="!appId || !appSecret || platformsStore.metaLoading"
+                class="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
               >
               >
-                {{ meta.label[0] }}
-              </span>
-              <div>
-                <p class="font-medium text-sm">{{ $t(`platforms.${key}`) }}</p>
-                <p v-if="getStatus(key)?.username" class="text-xs text-gray-400">
-                  @{{ getStatus(key)?.username }}
-                </p>
-                <p v-else-if="getStatus(key)?.error" class="text-xs text-red-400">
-                  {{ getStatus(key)?.error }}
-                </p>
-                <p v-else class="text-xs text-gray-600">{{ $t('settings.notConnected') }}</p>
+                {{ platformsStore.metaLoading ? $t('settings.meta.saving') : $t('settings.meta.saveApp') }}
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <!-- Step 2: OAuth connect -->
+        <div class="p-5" :class="{ 'opacity-40 pointer-events-none': !metaAppConfigured }">
+          <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Step 2 — Connect Accounts</p>
+
+          <!-- Already connected — show summary + manage -->
+          <div v-if="fbConnected || igConnected" class="space-y-3">
+            <div v-if="fbPages.length" class="space-y-1.5">
+              <p class="text-xs text-gray-500">{{ $t('settings.meta.connectedPages') }}</p>
+              <div v-for="page in fbPages" :key="page.id" class="flex items-center gap-2 bg-gray-800/60 rounded-lg px-3 py-2">
+                <img v-if="page.picture" :src="page.picture" class="w-6 h-6 rounded-full" />
+                <span v-else class="w-6 h-6 rounded-full bg-blue-700 flex items-center justify-center text-xs font-bold">f</span>
+                <span class="text-sm">{{ page.name }}</span>
+                <span class="ml-auto w-2 h-2 rounded-full bg-green-400"></span>
+              </div>
+            </div>
+            <div v-if="igAccounts.length" class="space-y-1.5">
+              <p class="text-xs text-gray-500">{{ $t('settings.meta.connectedAccounts') }}</p>
+              <div v-for="account in igAccounts" :key="account.id" class="flex items-center gap-2 bg-gray-800/60 rounded-lg px-3 py-2">
+                <img v-if="account.avatar" :src="account.avatar" class="w-6 h-6 rounded-full" />
+                <span v-else class="w-6 h-6 rounded-full bg-pink-700 flex items-center justify-center text-xs font-bold">I</span>
+                <span class="text-sm">@{{ account.username }}</span>
+                <span class="ml-auto w-2 h-2 rounded-full bg-green-400"></span>
               </div>
               </div>
             </div>
             </div>
-            <div class="flex items-center gap-2">
-              <span class="w-2 h-2 rounded-full" :class="isConnected(key) ? 'bg-green-400' : 'bg-gray-600'"></span>
-              <span class="text-xs" :class="isConnected(key) ? 'text-green-400' : 'text-gray-600'">
-                {{ isConnected(key) ? $t('settings.connected') : $t('settings.notConnected') }}
-              </span>
+            <div class="flex gap-2 pt-2">
+              <button
+                @click="platformsStore.startMetaOAuth()"
+                :disabled="platformsStore.metaLoading"
+                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.meta.reconnect') }}
+              </button>
+              <button
+                @click="confirmDisconnect"
+                :disabled="platformsStore.metaLoading"
+                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.meta.disconnect') }}
+              </button>
             </div>
             </div>
           </div>
           </div>
 
 
-          <div v-if="!isConnected(key)" class="mt-3 bg-gray-800 rounded-lg p-3 text-xs text-gray-400 font-mono">
-            <span v-if="key === 'twitter'">TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET</span>
-            <span v-else-if="key === 'mastodon'">MASTODON_INSTANCE_URL, MASTODON_ACCESS_TOKEN</span>
-            <span v-else-if="key === 'bluesky'">BLUESKY_IDENTIFIER, BLUESKY_APP_PASSWORD</span>
-            <span v-else-if="key === 'linkedin'">LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET</span>
-            <span v-else-if="key === 'instagram'">INSTAGRAM_ACCESS_TOKEN</span>
-            <span v-else-if="key === 'reddit'">REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USERNAME, REDDIT_PASSWORD</span>
-            <span v-else>— {{ $t('settings.envHint') }} —</span>
+          <!-- Not yet connected -->
+          <div v-else>
+            <button
+              @click="platformsStore.startMetaOAuth()"
+              :disabled="!metaAppConfigured || platformsStore.metaLoading"
+              class="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-40 rounded-xl text-sm font-medium transition-colors flex items-center justify-center gap-2"
+            >
+              <span v-if="platformsStore.metaLoading">{{ $t('settings.meta.connecting') }}</span>
+              <span v-else>{{ $t('settings.meta.connectButton') }}</span>
+            </button>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
 
 
+      <!-- ═══════════════════════════════════════════════════════════════════
+           PAGE/ACCOUNT PICKER — shown after OAuth callback
+      ════════════════════════════════════════════════════════════════════ -->
+      <div
+        v-if="showDiscovery"
+        class="bg-gray-900 border border-blue-700 rounded-2xl overflow-hidden"
+      >
+        <div class="p-5 border-b border-gray-800">
+          <p class="font-semibold">{{ $t('settings.meta.discoveryTitle') }}</p>
+          <p class="text-xs text-gray-500 mt-1">{{ $t('settings.meta.discoverySubtitle') }}</p>
+        </div>
+
+        <div class="p-5 space-y-5">
+
+          <!-- Facebook Pages -->
+          <div>
+            <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">{{ $t('settings.meta.pagesHeading') }}</p>
+            <div v-if="discovery.pages.length === 0" class="text-sm text-gray-600">{{ $t('settings.meta.noPages') }}</div>
+            <div v-else class="space-y-2">
+              <label
+                v-for="page in discovery.pages"
+                :key="page.id"
+                class="flex items-center gap-3 bg-gray-800 rounded-xl px-4 py-3 cursor-pointer hover:bg-gray-750 transition-colors"
+              >
+                <input type="checkbox" :value="page.id" v-model="selectedPageIds" class="w-4 h-4 accent-blue-500" />
+                <img v-if="page.picture" :src="page.picture" class="w-8 h-8 rounded-full" />
+                <span v-else class="w-8 h-8 rounded-full bg-blue-700 flex items-center justify-center text-xs font-bold shrink-0">f</span>
+                <span class="text-sm font-medium">{{ page.name }}</span>
+                <span class="ml-auto text-xs text-gray-600 font-mono">{{ page.id }}</span>
+              </label>
+            </div>
+          </div>
+
+          <!-- Instagram Business Accounts -->
+          <div>
+            <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">{{ $t('settings.meta.igHeading') }}</p>
+            <div v-if="discovery.igAccounts.length === 0" class="text-sm text-gray-600">{{ $t('settings.meta.noIgAccounts') }}</div>
+            <div v-else class="space-y-2">
+              <label
+                v-for="account in discovery.igAccounts"
+                :key="account.id"
+                class="flex items-center gap-3 bg-gray-800 rounded-xl px-4 py-3 cursor-pointer hover:bg-gray-750 transition-colors"
+              >
+                <input type="checkbox" :value="account.id" v-model="selectedIgAccountIds" class="w-4 h-4 accent-pink-500" />
+                <img v-if="account.avatar" :src="account.avatar" class="w-8 h-8 rounded-full" />
+                <span v-else class="w-8 h-8 rounded-full bg-pink-700 flex items-center justify-center text-xs font-bold shrink-0">I</span>
+                <div>
+                  <p class="text-sm font-medium">@{{ account.username }}</p>
+                  <p class="text-xs text-gray-600">{{ $t('settings.meta.igLinkedTo') }} {{ pageNameForId(account.pageId) }}</p>
+                </div>
+                <span class="ml-auto text-xs text-gray-600 font-mono">{{ account.id }}</span>
+              </label>
+            </div>
+          </div>
+
+          <!-- Confirm -->
+          <div class="flex items-center justify-between pt-2">
+            <p v-if="selectionError" class="text-xs text-red-400">{{ selectionError }}</p>
+            <span v-else />
+            <button
+              @click="confirmSelection"
+              :disabled="platformsStore.metaLoading"
+              class="px-5 py-2 bg-green-600 hover:bg-green-700 disabled:opacity-40 rounded-xl text-sm font-semibold transition-colors"
+            >
+              {{ platformsStore.metaLoading ? $t('settings.meta.confirmingSelection') : $t('settings.meta.confirmSelection') }}
+            </button>
+          </div>
+        </div>
+      </div>
+
+      <!-- ═══════════════════════════════════════════════════════════════════
+           OTHER PLATFORMS — env-file based
+      ════════════════════════════════════════════════════════════════════ -->
+      <div>
+        <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Other Platforms</p>
+        <div class="space-y-3">
+          <div
+            v-for="(meta, key) in otherPlatforms"
+            :key="key"
+            class="bg-gray-900 border rounded-xl p-4 transition-colors"
+            :class="isConnected(key) ? 'border-gray-700' : 'border-gray-800'"
+          >
+            <div class="flex items-center justify-between">
+              <div class="flex items-center gap-3">
+                <span
+                  class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm"
+                  :style="{ backgroundColor: meta.color }"
+                >
+                  {{ meta.label[0] }}
+                </span>
+                <div>
+                  <p class="font-medium text-sm">{{ $t(`platforms.${key}`) }}</p>
+                  <p v-if="getStatus(key)?.username" class="text-xs text-gray-400">
+                    @{{ getStatus(key)?.username }}
+                  </p>
+                  <p v-else-if="getStatus(key)?.error" class="text-xs text-red-400">
+                    {{ getStatus(key)?.error }}
+                  </p>
+                  <p v-else class="text-xs text-gray-600">{{ $t('settings.notConnected') }}</p>
+                </div>
+              </div>
+              <div class="flex items-center gap-2">
+                <span class="w-2 h-2 rounded-full" :class="isConnected(key) ? 'bg-green-400' : 'bg-gray-600'"></span>
+                <span class="text-xs" :class="isConnected(key) ? 'text-green-400' : 'text-gray-600'">
+                  {{ isConnected(key) ? $t('settings.connected') : $t('settings.notConnected') }}
+                </span>
+              </div>
+            </div>
+
+            <div v-if="!isConnected(key)" class="mt-3 bg-gray-800 rounded-lg p-3 text-xs text-gray-400 font-mono">
+              <span v-if="key === 'twitter'">TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET</span>
+              <span v-else-if="key === 'mastodon'">MASTODON_INSTANCE_URL, MASTODON_ACCESS_TOKEN</span>
+              <span v-else-if="key === 'bluesky'">BLUESKY_IDENTIFIER, BLUESKY_APP_PASSWORD</span>
+              <span v-else-if="key === 'linkedin'">LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET</span>
+              <span v-else-if="key === 'reddit'">REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USERNAME, REDDIT_PASSWORD</span>
+              <span v-else>— {{ $t('settings.envHint') }} —</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- Refresh button -->
       <button
       <button
         @click="platformsStore.fetchStatuses()"
         @click="platformsStore.fetchStatuses()"
-        class="mt-6 w-full py-2 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-sm transition-colors"
+        class="w-full py-2 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-sm transition-colors"
       >
       >
         {{ $t('settings.refreshStatus') }}
         {{ $t('settings.refreshStatus') }}
       </button>
       </button>
+
     </div>
     </div>
   </div>
   </div>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import { onMounted } from 'vue'
+import { ref, computed, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
 import { usePlatformsStore, PLATFORM_META } from '../stores/platforms'
 import { usePlatformsStore, PLATFORM_META } from '../stores/platforms'
 
 
+const route = useRoute()
 const platformsStore = usePlatformsStore()
 const platformsStore = usePlatformsStore()
 
 
+// ─── App credential form state ──────────────────────────────────────────────
+
+const appId = ref('')
+const appSecret = ref('')
+const editingApp = ref(false)
+
+const metaAppConfigured = computed(() => platformsStore.metaCredentials.configured)
+
+async function saveApp() {
+  await platformsStore.saveMetaApp(appId.value, appSecret.value)
+  if (!platformsStore.metaError) {
+    editingApp.value = false
+    appSecret.value = ''
+  }
+}
+
+// ─── Connected platforms derived from statuses ───────────────────────────────
+
+const fbStatus = computed(() => platformsStore.getStatus('facebook'))
+const igStatus = computed(() => platformsStore.getStatus('instagram'))
+const fbConnected = computed(() => fbStatus.value?.connected ?? false)
+const igConnected = computed(() => igStatus.value?.connected ?? false)
+
+// These come from the gateway /api/credentials endpoint (richer than platform-status)
+const fbPages = ref<Array<{ id: string; name: string; picture?: string }>>([])
+const igAccounts = ref<Array<{ id: string; username: string; avatar?: string }>>([])
+
+async function loadMetaConnections() {
+  try {
+    const res = await fetch('/api/credentials')
+    const data = await res.json()
+    fbPages.value = data.facebook?.pages || []
+    igAccounts.value = data.instagram?.accounts || []
+  } catch (_) { /* ignore */ }
+}
+
+// ─── OAuth discovery ─────────────────────────────────────────────────────────
+
+const discovery = computed(() => platformsStore.metaDiscovery || { pages: [], igAccounts: [] })
+const showDiscovery = computed(() => !!(platformsStore.metaDiscovery && (discovery.value.pages.length > 0 || discovery.value.igAccounts.length > 0)))
+
+const selectedPageIds = ref<string[]>([])
+const selectedIgAccountIds = ref<string[]>([])
+const selectionError = ref('')
+
+function pageNameForId(pageId: string): string {
+  return discovery.value.pages.find((p) => p.id === pageId)?.name || pageId
+}
+
+async function confirmSelection() {
+  selectionError.value = ''
+  if (!selectedPageIds.value.length && !selectedIgAccountIds.value.length) {
+    selectionError.value = platformsStore.metaError || 'Select at least one Page or Instagram account.'
+    return
+  }
+  await platformsStore.saveMetaSelection(selectedPageIds.value, selectedIgAccountIds.value)
+  await loadMetaConnections()
+  selectedPageIds.value = []
+  selectedIgAccountIds.value = []
+}
+
+// ─── OAuth error from callback redirect ──────────────────────────────────────
+
+const oauthError = ref<string | null>(null)
+
+// ─── Other platforms (not Meta) ──────────────────────────────────────────────
+
+const otherPlatforms = computed(() => {
+  const skip = new Set(['instagram', 'facebook'])
+  return Object.fromEntries(Object.entries(PLATFORM_META).filter(([k]) => !skip.has(k)))
+})
+
 function isConnected(platform: string) {
 function isConnected(platform: string) {
   return platformsStore.isConnected(platform)
   return platformsStore.isConnected(platform)
 }
 }
@@ -76,5 +358,32 @@ function getStatus(platform: string) {
   return platformsStore.getStatus(platform)
   return platformsStore.getStatus(platform)
 }
 }
 
 
-onMounted(() => platformsStore.fetchStatuses())
+// ─── Disconnect ───────────────────────────────────────────────────────────────
+
+function confirmDisconnect() {
+  if (window.confirm(platformsStore.metaCredentials?.appId ? 'This will disconnect all Facebook Pages and Instagram accounts. Continue?' : '')) {
+    platformsStore.disconnectMeta().then(loadMetaConnections)
+  }
+}
+
+// ─── On mount ────────────────────────────────────────────────────────────────
+
+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')
+  }
+
+  await Promise.all([
+    platformsStore.fetchStatuses(),
+    platformsStore.fetchMetaCredentials(),
+    loadMetaConnections(),
+  ])
+})
 </script>
 </script>