Преглед на файлове

Include Tiktok Intergration

Benjamin Harris преди 1 месец
родител
ревизия
7d8ca6982e

+ 55 - 2
README.md

@@ -6,7 +6,7 @@ A self-hosted, local-first social media management platform. Aggregate feeds fro
 
 ## Features
 
-- **Unified Feed** — Pull feeds from Twitter/X, Mastodon, Bluesky, LinkedIn, Instagram, Facebook, Reddit, YouTube and Pinterest into a single TweetDeck-style dashboard
+- **Unified Feed** — Pull feeds from Twitter/X, Mastodon, Bluesky, LinkedIn, Instagram, Facebook, Reddit, YouTube, Pinterest and TikTok into a single TweetDeck-style dashboard
 - **Cross-post** — Write once, publish to multiple platforms simultaneously with per-account targeting
 - **Scheduler** — Schedule posts for a specific date/time; BullMQ handles retries with idempotent delivery (no duplicate posts)
 - **Per-account Timezone** — Each account stores its own timezone; the compose view converts scheduled times correctly
@@ -59,6 +59,7 @@ A self-hosted, local-first social media management platform. Aggregate feeds fro
 | `instagram` | 3005 | Instagram Graph API |
 | `facebook` | 3006 | Facebook Pages Graph API |
 | `pinterest` | 3008 | Pinterest API v5 |
+| `tiktok` | 3007 | TikTok Content Posting API |
 | `mongodb` | 27018 | Database |
 | `redis` | 6379 | Cache & job queue |
 | `messageBroker` | 5672 / 15672 | RabbitMQ (legacy, largely unused) |
@@ -147,7 +148,7 @@ Paste your API key and click **Connect & Set Active**. Keys are stored AES-256-G
 | Facebook | Free | ✅ | ✅ | Facebook Page required (personal timelines not supported) |
 | YouTube | Free | ✅ | ❌ | Subscription feed only; publishing in pipeline |
 | Pinterest | Free | ✅ | ✅ | OAuth via Settings UI; boards as destinations; image required for pins |
-| TikTok | Free | — | — | In pipeline |
+| TikTok | Free | ✅ | ✅ | OAuth 2.0 PKCE via Settings UI; video required for posts; auto token refresh |
 | Google Business | Free | — | — | In pipeline |
 
 ---
@@ -298,6 +299,58 @@ Each selected board now appears as a destination in the Compose view.
 
 ---
 
+## TikTok Setup
+
+TikTok uses OAuth 2.0 with PKCE (Proof Key for Code Exchange). Tokens are stored AES-256-GCM encrypted in MongoDB and auto-refreshed when they expire (~24 h access token, ~365 day refresh token).
+
+### TikTok Prerequisites
+
+- A [TikTok Developer account](https://developers.tiktok.com/)
+- A TikTok user account
+
+### Create a TikTok App
+
+1. Go to [developers.tiktok.com](https://developers.tiktok.com/) and sign in
+2. Click **Manage Apps** → **Create an app**
+3. Fill in the app name, description, and category
+4. Under **Products**, add **Login Kit** and **Content Posting API**
+
+### TikTok Redirect URI and Scopes
+
+In your app's **Login Kit** settings, add to **Redirect URIs**:
+
+```text
+http://localhost:8081/api/auth/tiktok/callback
+```
+
+**Required scopes (request these in Login Kit):**
+
+| Scope | Used for |
+| --- | --- |
+| `user.info.basic` | Display connected username |
+| `video.list` | Fetch your TikTok feed |
+| `video.publish` | Publish video posts |
+
+After submitting for review, note your **Client Key** and **Client Secret** from the app dashboard.
+
+### Connect TikTok from Settings
+
+1. Open <http://localhost:8081/settings>
+2. In the **TikTok** card, enter your Client Key and Client Secret → **Save App Credentials**
+3. Click **Connect with TikTok**
+4. Authorise the app on TikTok's OAuth page
+5. You'll be redirected back to Settings — the account is now connected
+
+### TikTok Posting Notes
+
+- **Video is required** — TikTok does not support text-only posts. Always attach a video URL in Compose when posting to TikTok.
+- The post content becomes the video caption (max 2,200 characters).
+- Posts are published via TikTok's `PULL_FROM_URL` API — TikTok fetches the video from your media server asynchronously.
+- The access token auto-refreshes in the background; no manual re-authentication is needed until the refresh token expires (~1 year).
+- Tokens are stored encrypted — no `.env` editing required.
+
+---
+
 ## Adding a New Language
 
 1. Create `ui/src/locales/xx.ts` (copy `en.ts` and translate)

+ 16 - 0
docker-compose.yml

@@ -196,6 +196,20 @@ services:
       - messageBroker
       - mongodb
 
+  tiktok:
+    build:
+      context: ./services
+      dockerfile: tiktok/Dockerfile
+    volumes:
+      - tiktok_modules:/services/tiktok/node_modules
+    restart: unless-stopped
+    env_file: .env
+    networks:
+      - socialMediaManagerNetwork
+    depends_on:
+      - messageBroker
+      - mongodb
+
   feed-aggregator:
     build:
       context: ./services
@@ -215,6 +229,7 @@ services:
       - bluesky
       - instagram
       - pinterest
+      - tiktok
       - facebook
 
   scheduler:
@@ -273,6 +288,7 @@ volumes:
   instagram_modules:
   facebook_modules:
   pinterest_modules:
+  tiktok_modules:
   feed_aggregator_modules:
   scheduler_modules:
   ui_modules:

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

@@ -16,6 +16,7 @@ const PLATFORM_SERVICES = {
   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',
+  tiktok:    process.env.TIKTOK_SERVICE_URL    || 'http://tiktok:3007',
 };
 
 const log = createLogger('feed-aggregator');

+ 133 - 1
services/gateway/server.js

@@ -1325,15 +1325,140 @@ app.delete('/credentials/pinterest', async () => {
   return { success: true };
 });
 
+// ─── TikTok OAuth ─────────────────────────────────────────────────────────────
+
+const TIKTOK_AUTH_URL = 'https://www.tiktok.com/v2/auth/authorize/';
+const TIKTOK_TOKEN_URL = 'https://open.tiktokapis.com/v2/oauth/token/';
+const TIKTOK_API = 'https://open.tiktokapis.com/v2';
+
+app.post('/credentials/tiktok-app', async (request, reply) => {
+  const { clientKey, clientSecret } = request.body || {};
+  if (!clientKey || !clientSecret) return reply.code(400).send({ error: 'clientKey and clientSecret are required' });
+  await setCredentials('tiktok_app', { clientKey, clientSecret: encryptToken(clientSecret) });
+  log.info({ action: 'tiktok_app_save', outcome: 'success' });
+  return { success: true };
+});
+
+app.get('/credentials/tiktok-app', async () => {
+  const cred = await getCredentials('tiktok_app');
+  if (!cred?.clientKey) return { configured: false };
+  return { configured: true, clientKey: cred.clientKey, clientSecretHint: `****${decryptToken(cred.clientSecret).slice(-4)}` };
+});
+
+app.get('/auth/tiktok/init', async (request, reply) => {
+  const cred = await getCredentials('tiktok_app');
+  if (!cred?.clientKey) return reply.code(400).send({ error: 'Save your TikTok Client Key and Secret first' });
+
+  const codeVerifier = crypto.randomBytes(32).toString('base64url');
+  const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
+  const state = crypto.randomBytes(16).toString('hex');
+
+  // Persist PKCE verifier for the callback
+  const db = await getDb();
+  await db.collection('platform_credentials').updateOne(
+    { _id: 'tiktok_pkce' },
+    { $set: { codeVerifier, state, createdAt: new Date() } },
+    { upsert: true }
+  );
+
+  const redirectUri = `${APP_BASE_URL}/api/auth/tiktok/callback`;
+  const scopes = 'user.info.basic,video.list,video.publish';
+  const params = new URLSearchParams({
+    client_key: cred.clientKey,
+    scope: scopes,
+    response_type: 'code',
+    redirect_uri: redirectUri,
+    state,
+    code_challenge: codeChallenge,
+    code_challenge_method: 'S256',
+  });
+  return { url: `${TIKTOK_AUTH_URL}?${params.toString()}` };
+});
+
+app.get('/auth/tiktok/callback', async (request, reply) => {
+  const { code, state, error: oauthError, error_description } = request.query;
+
+  if (oauthError) {
+    const msg = error_description || oauthError;
+    return reply.redirect(`${APP_BASE_URL}/settings?tiktok_error=${encodeURIComponent(msg)}`);
+  }
+  if (!code) {
+    return reply.redirect(`${APP_BASE_URL}/settings?tiktok_error=no_code`);
+  }
+
+  try {
+    const db = await getDb();
+    const pkce = await db.collection('platform_credentials').findOne({ _id: 'tiktok_pkce' });
+    if (!pkce?.codeVerifier) throw new Error('PKCE state not found — try connecting again');
+    if (state && pkce.state && state !== pkce.state) throw new Error('OAuth state mismatch');
+
+    const appCred = await getCredentials('tiktok_app');
+    if (!appCred?.clientKey) throw new Error('App credentials not configured');
+    const clientSecret = decryptToken(appCred.clientSecret);
+
+    const redirectUri = `${APP_BASE_URL}/api/auth/tiktok/callback`;
+    const tokenRes = await axios.post(
+      TIKTOK_TOKEN_URL,
+      new URLSearchParams({
+        client_key: appCred.clientKey,
+        client_secret: clientSecret,
+        code,
+        grant_type: 'authorization_code',
+        redirect_uri: redirectUri,
+        code_verifier: pkce.codeVerifier,
+      }).toString(),
+      { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15000 }
+    );
+
+    const { access_token, refresh_token, expires_in, refresh_expires_in, open_id } = tokenRes.data;
+    const tokenExpiry = new Date(Date.now() + (expires_in || 86400) * 1000).toISOString();
+    const refreshExpiry = new Date(Date.now() + (refresh_expires_in || 31536000) * 1000).toISOString();
+
+    const userRes = await axios.get(`${TIKTOK_API}/user/info/`, {
+      headers: { Authorization: `Bearer ${access_token}` },
+      params: { fields: 'open_id,display_name,avatar_url,username' },
+      timeout: 10000,
+    });
+    const user = userRes.data?.data?.user || {};
+
+    await setCredentials('tiktok', {
+      openId: open_id || user.open_id,
+      username: user.username || user.display_name,
+      displayName: user.display_name,
+      avatar: user.avatar_url || null,
+      accessToken: encryptToken(access_token),
+      refreshToken: refresh_token ? encryptToken(refresh_token) : null,
+      tokenExpiry,
+      refreshExpiry,
+    });
+
+    // Clean up PKCE temp state
+    await db.collection('platform_credentials').deleteOne({ _id: 'tiktok_pkce' });
+
+    log.info({ action: 'tiktok_oauth_callback', username: user.username || user.display_name, outcome: 'success' });
+    reply.redirect(`${APP_BASE_URL}/settings?tiktok_connected=1`);
+  } catch (err) {
+    const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
+    log.error({ action: 'tiktok_oauth_callback', outcome: 'failure', err: msg });
+    reply.redirect(`${APP_BASE_URL}/settings?tiktok_error=${encodeURIComponent(msg)}`);
+  }
+});
+
+app.delete('/credentials/tiktok', async () => {
+  await deleteCredentials('tiktok');
+  return { success: true };
+});
+
 // ─── Credential Status ────────────────────────────────────────────────────────
 
 // Aggregate connection status for all DB-managed platforms
 app.get('/credentials', async () => {
-  const [metaApp, fb, ig, pinterest] = await Promise.all([
+  const [metaApp, fb, ig, pinterest, tiktok] = await Promise.all([
     getCredentials('meta_app'),
     getCredentials('facebook'),
     getCredentials('instagram'),
     getCredentials('pinterest'),
+    getCredentials('tiktok'),
   ]);
 
   const fbPages = (fb?.pages || []).filter((p) => p.selected);
@@ -1356,6 +1481,12 @@ app.get('/credentials', async () => {
       boards: pinterestBoards.map(({ id, name, privacy }) => ({ id, name, privacy })),
       allBoards: (pinterest?.boards || []).map(({ id, name, privacy, selected }) => ({ id, name, privacy, selected })),
     },
+    tiktok: {
+      connected: !!(tiktok?.accessToken),
+      username: tiktok?.username || null,
+      displayName: tiktok?.displayName || null,
+      avatar: tiktok?.avatar || null,
+    },
   };
 });
 
@@ -1372,6 +1503,7 @@ const INDUSTRY_DEFAULTS = {
   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]],
+  tiktok:    [[2,18],[3,18],[4,18],[5,12],[0,14]],
 };
 const DEFAULT_SLOTS = [[2,9],[3,9],[4,9],[2,12],[3,12]];
 

+ 1 - 0
services/scheduler/index.js

@@ -18,6 +18,7 @@ const PLATFORM_SERVICES = {
   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',
+  tiktok:    process.env.TIKTOK_SERVICE_URL    || 'http://tiktok:3007',
 };
 
 const log = createLogger('scheduler');

+ 7 - 0
services/tiktok/Dockerfile

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

+ 192 - 0
services/tiktok/index.js

@@ -0,0 +1,192 @@
+require('dotenv').config();
+const axios = require('axios');
+const BasePlatformService = require('./utils/BasePlatformService');
+const { getDb } = require('./utils/MongoDBConnector');
+const { encryptToken, decryptToken, warnIfNoKey } = require('./utils/crypto');
+
+const TIKTOK_API = 'https://open.tiktokapis.com/v2';
+const TOKEN_URL = `${TIKTOK_API}/oauth/token/`;
+
+class TikTokService extends BasePlatformService {
+  constructor() {
+    super('tiktok');
+  }
+
+  async _getAccount() {
+    try {
+      const db = await getDb();
+      const [cred, appCred] = await Promise.all([
+        db.collection('platform_credentials').findOne({ _id: 'tiktok' }),
+        db.collection('platform_credentials').findOne({ _id: 'tiktok_app' }),
+      ]);
+
+      if (!cred?.accessToken || !appCred?.clientKey) return null;
+
+      // Auto-refresh if access token is expired or expires within 5 minutes
+      if (cred.tokenExpiry && new Date(cred.tokenExpiry) < new Date(Date.now() + 5 * 60 * 1000)) {
+        if (cred.refreshToken && cred.refreshExpiry && new Date(cred.refreshExpiry) > new Date()) {
+          try {
+            const clientSecret = decryptToken(appCred.clientSecret);
+            const refreshRes = await axios.post(
+              TOKEN_URL,
+              new URLSearchParams({
+                client_key: appCred.clientKey,
+                client_secret: clientSecret,
+                grant_type: 'refresh_token',
+                refresh_token: decryptToken(cred.refreshToken),
+              }).toString(),
+              { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15000 }
+            );
+            const { access_token, refresh_token: newRefresh, expires_in, refresh_expires_in } = refreshRes.data;
+            const tokenExpiry = new Date(Date.now() + (expires_in || 86400) * 1000).toISOString();
+            const refreshExpiry = newRefresh
+              ? new Date(Date.now() + (refresh_expires_in || 31536000) * 1000).toISOString()
+              : cred.refreshExpiry;
+
+            await db.collection('platform_credentials').updateOne(
+              { _id: 'tiktok' },
+              {
+                $set: {
+                  accessToken: encryptToken(access_token),
+                  refreshToken: newRefresh ? encryptToken(newRefresh) : cred.refreshToken,
+                  tokenExpiry,
+                  refreshExpiry,
+                  updatedAt: new Date(),
+                },
+              }
+            );
+
+            this.app.log.info({ action: 'token_refresh', platform: 'tiktok', outcome: 'success' });
+            return { ...cred, accessToken: access_token };
+          } catch (err) {
+            this.app.log.warn({ action: 'token_refresh', platform: 'tiktok', outcome: 'failure', err: err.message });
+          }
+        }
+      }
+
+      return { ...cred, accessToken: decryptToken(cred.accessToken) };
+    } catch (_) {}
+    return null;
+  }
+
+  async getStatus() {
+    const account = await this._getAccount();
+    if (!account?.accessToken) {
+      return { connected: false, platform: 'tiktok', error: 'Not connected — use Settings to connect via TikTok OAuth' };
+    }
+    try {
+      const res = await axios.get(`${TIKTOK_API}/user/info/`, {
+        headers: { Authorization: `Bearer ${account.accessToken}` },
+        params: { fields: 'open_id,display_name,avatar_url,username' },
+        timeout: 10000,
+      });
+      const user = res.data?.data?.user || {};
+      return {
+        connected: true,
+        platform: 'tiktok',
+        username: user.username || user.display_name,
+        displayName: user.display_name,
+        avatar: user.avatar_url,
+      };
+    } catch (err) {
+      return { connected: false, platform: 'tiktok', error: err.response?.data?.error?.message || err.message };
+    }
+  }
+
+  async fetchFeed({ limit = 20 } = {}) {
+    const account = await this._getAccount();
+    if (!account?.accessToken) throw new Error('TikTok not connected');
+
+    const res = await axios.post(
+      `${TIKTOK_API}/video/list/`,
+      { max_count: Math.min(Number(limit), 20) },
+      {
+        headers: {
+          Authorization: `Bearer ${account.accessToken}`,
+          'Content-Type': 'application/json; charset=UTF-8',
+        },
+        params: { fields: 'id,title,video_description,share_url,view_count,like_count,comment_count,share_count,create_time,cover_image_url' },
+        timeout: 15000,
+      }
+    );
+
+    const videos = res.data?.data?.videos || [];
+    const items = videos.map((v) =>
+      this.normalizeFeedItem({
+        originalId: v.id,
+        author: {
+          name: account.displayName || account.username || 'TikTok',
+          username: account.username || '',
+          avatar: account.avatar || null,
+        },
+        content: v.video_description || v.title || '',
+        media: v.cover_image_url ? [{ url: v.cover_image_url, type: 'image' }] : [],
+        metrics: {
+          likes: v.like_count || 0,
+          comments: v.comment_count || 0,
+          shares: v.share_count || 0,
+          views: v.view_count || 0,
+        },
+        url: v.share_url || '',
+        createdAt: v.create_time ? new Date(v.create_time * 1000) : new Date(),
+      })
+    );
+
+    try {
+      const db = await getDb();
+      const col = db.collection('feeds');
+      for (const item of items) {
+        await col.updateOne(
+          { platform: 'tiktok', originalId: item.originalId },
+          { $set: item },
+          { upsert: true }
+        );
+      }
+    } catch (err) {
+      this.app.log.error({ action: 'feed_write', platform: 'tiktok', outcome: 'failure', err: err.message });
+    }
+
+    return items;
+  }
+
+  async publishPost({ content, videoUrl } = {}) {
+    const account = await this._getAccount();
+    if (!account?.accessToken) throw new Error('TikTok not connected');
+    if (!videoUrl) throw new Error('TikTok requires a video URL — text-only posts are not supported');
+
+    const body = {
+      post_info: {
+        title: (content || '').slice(0, 2200),
+        privacy_level: 'PUBLIC_TO_EVERYONE',
+        disable_duet: false,
+        disable_comment: false,
+        disable_stitch: false,
+      },
+      source_info: {
+        source: 'PULL_FROM_URL',
+        video_url: videoUrl,
+      },
+    };
+
+    try {
+      const res = await axios.post(`${TIKTOK_API}/post/publish/video/init/`, body, {
+        headers: {
+          Authorization: `Bearer ${account.accessToken}`,
+          'Content-Type': 'application/json; charset=UTF-8',
+        },
+        timeout: 30000,
+      });
+      const publishId = res.data?.data?.publish_id;
+      this.app.log.info({ action: 'publish_post', platform: 'tiktok', publishId, outcome: 'success' });
+      return { publishId };
+    } catch (err) {
+      const msg = err.response?.data?.error?.message || err.message;
+      this.app.log.error({ action: 'publish_post', platform: 'tiktok', outcome: 'failure', err: msg });
+      throw new Error(msg);
+    }
+  }
+}
+
+const service = new TikTokService();
+warnIfNoKey('tiktok');
+service.start(process.env.PORT || 3007);

+ 20 - 0
services/tiktok/package.json

@@ -0,0 +1,20 @@
+{
+  "name": "tiktok-service",
+  "version": "1.0.0",
+  "description": "TikTok 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"
+  }
+}

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

@@ -274,6 +274,28 @@ export default {
       timezoneAuto: 'Use browser timezone',
     },
 
+    tiktok: {
+      sectionTitle: 'TikTok',
+      sectionSubtitle: 'Connect your TikTok account to publish videos.',
+      clientKeyLabel: 'Client Key',
+      clientSecretLabel: 'Client Secret',
+      clientKeyPlaceholder: 'Your TikTok App Client Key',
+      clientSecretPlaceholder: 'Your TikTok App Client Secret',
+      saveApp: 'Save App Credentials',
+      saving: 'Saving...',
+      appConfigured: 'App credentials saved',
+      connectButton: 'Connect with TikTok',
+      connecting: 'Redirecting to TikTok...',
+      reconnect: 'Reconnect',
+      disconnect: 'Disconnect',
+      disconnectConfirm: 'This will disconnect your TikTok account. Continue?',
+      connectedAs: 'Connected as',
+      videoOnly: 'TikTok only supports video posts. Select a video in Compose to publish.',
+      errorTitle: 'TikTok OAuth Error',
+      getAppHelp: 'Get your credentials from',
+      devPortal: 'developers.tiktok.com',
+    },
+
     pinterest: {
       sectionTitle: 'Pinterest',
       sectionSubtitle: 'Connect your Pinterest account to create pins on your boards.',
@@ -406,5 +428,6 @@ export default {
     reddit: 'Reddit',
     youtube: 'YouTube',
     pinterest: 'Pinterest',
+    tiktok: 'TikTok',
   },
 }

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

@@ -274,6 +274,28 @@ export default {
       timezoneAuto: 'Tarayıcı saat dilimini kullan',
     },
 
+    tiktok: {
+      sectionTitle: 'TikTok',
+      sectionSubtitle: 'Video yayınlamak için TikTok hesabını bağla.',
+      clientKeyLabel: 'İstemci Anahtarı',
+      clientSecretLabel: 'İstemci Gizli Anahtarı',
+      clientKeyPlaceholder: 'TikTok Uygulama İstemci Anahtarın',
+      clientSecretPlaceholder: 'TikTok Uygulama Gizli Anahtarın',
+      saveApp: 'Uygulama Bilgilerini Kaydet',
+      saving: 'Kaydediliyor...',
+      appConfigured: 'Uygulama bilgileri kaydedildi',
+      connectButton: 'TikTok ile Bağlan',
+      connecting: 'TikTok\'a yönlendiriliyor...',
+      reconnect: 'Yeniden Bağlan',
+      disconnect: 'Bağlantıyı Kes',
+      disconnectConfirm: 'Bu işlem TikTok hesabının bağlantısını keser. Devam edilsin mi?',
+      connectedAs: 'Bağlı hesap',
+      videoOnly: 'TikTok yalnızca video gönderiyi destekler. Yayınlamak için Oluştur\'da bir video seçin.',
+      errorTitle: 'TikTok OAuth Hatası',
+      getAppHelp: 'Kimlik bilgilerini buradan al',
+      devPortal: 'developers.tiktok.com',
+    },
+
     pinterest: {
       sectionTitle: 'Pinterest',
       sectionSubtitle: 'Pinterest hesabını bağlayarak panolarına pin oluştur.',
@@ -406,5 +428,6 @@ export default {
     reddit: 'Reddit',
     youtube: 'YouTube',
     pinterest: 'Pinterest',
+    tiktok: 'TikTok',
   },
 }

+ 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', 'pinterest']))
+  const activePlatforms = ref<Set<string>>(new Set(['twitter', 'mastodon', 'bluesky', 'linkedin', 'instagram', 'facebook', 'pinterest', 'tiktok']))
   const activePageIds = ref<Set<string>>(new Set())
   const activeIgAccountIds = ref<Set<string>>(new Set())
   const activeTag = ref<string | null>(null)

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

@@ -68,6 +68,7 @@ export const PLATFORM_META: Record<string, { label: string; color: string; icon:
   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' },
+  tiktok:    { label: 'TikTok',     color: '#EE1D52', icon: 'fa-brands fa-tiktok' },
 }
 
 export const usePlatformsStore = defineStore('platforms', () => {
@@ -131,6 +132,8 @@ export const usePlatformsStore = defineStore('platforms', () => {
       connectedIgAccounts.value = data.instagram?.accounts || []
       connectedPinterestBoards.value = data.pinterest?.boards || []
       allPinterestBoards.value = data.pinterest?.allBoards || []
+      tiktokConnected.value = data.tiktok?.connected ?? false
+      tiktokUsername.value = data.tiktok?.username || null
     } catch (_) { /* ignore */ }
   }
 
@@ -199,6 +202,68 @@ export const usePlatformsStore = defineStore('platforms', () => {
     }
   }
 
+  // ─── TikTok ───────────────────────────────────────────────────────────────
+
+  interface TikTokCredentials {
+    configured: boolean
+    clientKey?: string
+    clientSecretHint?: string
+  }
+
+  const tiktokCredentials = ref<TikTokCredentials>({ configured: false })
+  const tiktokLoading = ref(false)
+  const tiktokError = ref<string | null>(null)
+  const tiktokConnected = ref(false)
+  const tiktokUsername = ref<string | null>(null)
+
+  async function fetchTikTokCredentials() {
+    try {
+      const res = await axios.get('/api/credentials/tiktok-app')
+      tiktokCredentials.value = res.data
+    } catch (err) {
+      console.error('TikTok credentials fetch error:', err)
+    }
+  }
+
+  async function saveTikTokApp(clientKey: string, clientSecret: string) {
+    tiktokLoading.value = true
+    tiktokError.value = null
+    try {
+      await axios.post('/api/credentials/tiktok-app', { clientKey, clientSecret })
+      tiktokCredentials.value = { configured: true, clientKey, clientSecretHint: `****${clientSecret.slice(-4)}` }
+    } catch (err: any) {
+      tiktokError.value = err.response?.data?.error || 'Failed to save app credentials'
+    } finally {
+      tiktokLoading.value = false
+    }
+  }
+
+  async function startTikTokOAuth() {
+    tiktokLoading.value = true
+    tiktokError.value = null
+    try {
+      const res = await axios.get('/api/auth/tiktok/init')
+      window.location.href = res.data.url
+    } catch (err: any) {
+      tiktokError.value = err.response?.data?.error || 'Failed to start OAuth'
+      tiktokLoading.value = false
+    }
+  }
+
+  async function disconnectTikTok() {
+    tiktokLoading.value = true
+    try {
+      await axios.delete('/api/credentials/tiktok')
+      tiktokConnected.value = false
+      tiktokUsername.value = null
+      await fetchStatuses()
+    } catch (err) {
+      console.error('TikTok disconnect error:', err)
+    } finally {
+      tiktokLoading.value = false
+    }
+  }
+
   // ─── Platform status ──────────────────────────────────────────────────────
 
   async function fetchStatuses() {
@@ -307,5 +372,7 @@ export const usePlatformsStore = defineStore('platforms', () => {
     connectedPinterestBoards, allPinterestBoards,
     fetchPinterestCredentials, savePinterestApp, startPinterestOAuth,
     savePinterestBoards, disconnectPinterest,
+    tiktokCredentials, tiktokLoading, tiktokError, tiktokConnected, tiktokUsername,
+    fetchTikTokCredentials, saveTikTokApp, startTikTokOAuth, disconnectTikTok,
   }
 })

+ 158 - 2
ui/src/views/Settings.vue

@@ -308,6 +308,121 @@
 
       </div>
 
+      <!-- ═══════════════════════════════════════════════════════════════════
+           TIKTOK — 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:#EE1D52">T</span>
+          <div>
+            <p class="font-semibold">{{ $t('settings.tiktok.sectionTitle') }}</p>
+            <p class="text-xs text-gray-500 mt-0.5">{{ $t('settings.tiktok.sectionSubtitle') }}</p>
+          </div>
+        </div>
+
+        <!-- OAuth error banner -->
+        <div v-if="tiktokOauthError" 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.tiktok.errorTitle') }}:</strong> {{ tiktokOauthError }}</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 — TikTok Developer App</p>
+
+          <div v-if="tiktokAppConfigured" class="flex items-center justify-between">
+            <div class="flex items-center gap-2 text-sm text-green-400">
+              <span>✓</span>
+              <span>{{ $t('settings.tiktok.appConfigured') }}</span>
+              <span class="text-gray-600 font-mono text-xs">({{ platformsStore.tiktokCredentials.clientKey }})</span>
+            </div>
+            <button @click="editingTikTokApp = !editingTikTokApp" 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="!tiktokAppConfigured || editingTikTokApp" class="space-y-3 mt-2">
+            <div>
+              <label class="block text-xs text-gray-400 mb-1">{{ $t('settings.tiktok.clientKeyLabel') }}</label>
+              <input
+                v-model="tiktokClientKey"
+                type="text"
+                :placeholder="$t('settings.tiktok.clientKeyPlaceholder')"
+                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-pink-500"
+              />
+            </div>
+            <div>
+              <label class="block text-xs text-gray-400 mb-1">{{ $t('settings.tiktok.clientSecretLabel') }}</label>
+              <input
+                v-model="tiktokClientSecret"
+                type="password"
+                :placeholder="tiktokAppConfigured ? platformsStore.tiktokCredentials.clientSecretHint : $t('settings.tiktok.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-pink-500"
+              />
+            </div>
+            <div class="flex items-center justify-between">
+              <p class="text-xs text-gray-600">
+                {{ $t('settings.tiktok.getAppHelp') }}
+                <a href="https://developers.tiktok.com/" target="_blank" rel="noopener" class="text-pink-400 hover:text-pink-300 underline">
+                  {{ $t('settings.tiktok.devPortal') }}
+                </a>
+              </p>
+              <button
+                @click="saveTikTokApp"
+                :disabled="!tiktokClientKey || !tiktokClientSecret || platformsStore.tiktokLoading"
+                class="px-4 py-1.5 bg-pink-600 hover:bg-pink-700 disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
+              >
+                {{ platformsStore.tiktokLoading ? $t('settings.tiktok.saving') : $t('settings.tiktok.saveApp') }}
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <!-- Step 2: OAuth connect -->
+        <div class="p-5" :class="{ 'opacity-40 pointer-events-none': !tiktokAppConfigured }">
+          <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Step 2 — Connect Account</p>
+
+          <!-- Connected -->
+          <div v-if="platformsStore.tiktokConnected" class="space-y-4">
+            <div class="flex items-center gap-3 bg-gray-800/60 rounded-lg px-4 py-3">
+              <span class="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0" style="background:#EE1D52">T</span>
+              <div>
+                <p class="text-sm font-medium text-white">{{ platformsStore.tiktokUsername || $t('settings.tiktok.connectedAs') }}</p>
+                <p class="text-xs text-gray-500">{{ $t('settings.tiktok.connectedAs') }}</p>
+              </div>
+            </div>
+            <p class="text-xs text-gray-600">{{ $t('settings.tiktok.videoOnly') }}</p>
+            <div class="flex gap-2">
+              <button
+                @click="platformsStore.startTikTokOAuth()"
+                :disabled="platformsStore.tiktokLoading"
+                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.tiktok.reconnect') }}</button>
+              <button
+                @click="confirmTikTokDisconnect"
+                :disabled="platformsStore.tiktokLoading"
+                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.tiktok.disconnect') }}</button>
+            </div>
+          </div>
+
+          <!-- Not yet connected -->
+          <div v-else>
+            <button
+              @click="platformsStore.startTikTokOAuth()"
+              :disabled="!tiktokAppConfigured || platformsStore.tiktokLoading"
+              class="w-full py-2.5 bg-pink-600 hover:bg-pink-700 disabled:opacity-40 rounded-xl text-sm font-medium transition-colors flex items-center justify-center gap-2"
+            >
+              <span v-if="platformsStore.tiktokLoading">{{ $t('settings.tiktok.connecting') }}</span>
+              <span v-else>{{ $t('settings.tiktok.connectButton') }}</span>
+            </button>
+          </div>
+        </div>
+
+      </div>
+
       <!-- ═══════════════════════════════════════════════════════════════════
            PAGE/ACCOUNT PICKER — shown after OAuth callback
       ════════════════════════════════════════════════════════════════════ -->
@@ -961,10 +1076,33 @@ function confirmPinterestDisconnect() {
   }
 }
 
+// ─── TikTok ──────────────────────────────────────────────────────────────────
+
+const tiktokClientKey = ref('')
+const tiktokClientSecret = ref('')
+const editingTikTokApp = ref(false)
+const tiktokOauthError = ref<string | null>(null)
+
+const tiktokAppConfigured = computed(() => platformsStore.tiktokCredentials.configured)
+
+async function saveTikTokApp() {
+  await platformsStore.saveTikTokApp(tiktokClientKey.value, tiktokClientSecret.value)
+  if (!platformsStore.tiktokError) {
+    editingTikTokApp.value = false
+    tiktokClientSecret.value = ''
+  }
+}
+
+function confirmTikTokDisconnect() {
+  if (window.confirm(t('settings.tiktok.disconnectConfirm'))) {
+    platformsStore.disconnectTikTok().then(loadMetaConnections)
+  }
+}
+
 // ─── Other platforms (not Meta) ──────────────────────────────────────────────
 
 const otherPlatforms = computed(() => {
-  const skip = new Set(['instagram', 'facebook', 'pinterest'])
+  const skip = new Set(['instagram', 'facebook', 'pinterest', 'tiktok'])
   return Object.fromEntries(Object.entries(PLATFORM_META).filter(([k]) => !skip.has(k)))
 })
 
@@ -1047,12 +1185,22 @@ const allConnectedAccounts = computed((): ProfileAccount[] => {
   const accounts: ProfileAccount[] = []
 
   for (const [platform, meta] of Object.entries(PLATFORM_META)) {
-    if (platform === 'facebook' || platform === 'instagram' || platform === 'pinterest') continue
+    if (platform === 'facebook' || platform === 'instagram' || platform === 'pinterest' || platform === 'tiktok') continue
     if (platformsStore.isConnected(platform)) {
       accounts.push({ key: platform, label: t(`platforms.${platform}`), platform, color: meta.color, avatar: null })
     }
   }
 
+  if (platformsStore.tiktokConnected) {
+    accounts.push({
+      key: 'tiktok',
+      label: platformsStore.tiktokUsername ? `@${platformsStore.tiktokUsername}` : 'TikTok',
+      platform: 'tiktok',
+      color: PLATFORM_META.tiktok.color,
+      avatar: null,
+    })
+  }
+
   for (const page of platformsStore.connectedPages) {
     accounts.push({ key: `facebook:${page.id}`, label: page.name, platform: 'facebook', color: PLATFORM_META.facebook.color, avatar: page.picture || null })
   }
@@ -1204,11 +1352,19 @@ onMounted(async () => {
     pinterestOauthError.value = decodeURIComponent(String(route.query.pinterest_error))
     window.history.replaceState({}, '', '/settings')
   }
+  if (route.query.tiktok_connected) {
+    window.history.replaceState({}, '', '/settings')
+  }
+  if (route.query.tiktok_error) {
+    tiktokOauthError.value = decodeURIComponent(String(route.query.tiktok_error))
+    window.history.replaceState({}, '', '/settings')
+  }
 
   await Promise.all([
     platformsStore.fetchStatuses(),
     platformsStore.fetchMetaCredentials(),
     platformsStore.fetchPinterestCredentials(),
+    platformsStore.fetchTikTokCredentials(),
     loadMetaConnections(),
     platformsStore.fetchTokenExpiry(),
     aiStore.fetchConfig(),