Răsfoiți Sursa

Workspace isolation — multi-business support with per-workspace data segregation

Every workspace gets its own credentials, competitors (up to 5), hashtag
groups/stats, drafts, posts, post_metrics, media files, account profiles,
content calendars, and bulk draft batches.

Backend:
- preHandler hook injects request.workspaceId from X-Workspace-Id header
- credId(ws, type) helper prefixes all platform_credentials _ids
- GET/POST/PUT/DELETE /workspaces routes with cascade-delete on removal
- Startup migration stamps existing documents with workspaceId: 'default'
  and re-keys old platform_credentials _ids to 'default:type' format
- All ~150 collection query sites updated to filter by workspaceId

Frontend:
- useWorkspaceStore (Pinia setup store) — CRUD + axios global header injection
- Workspace switcher dropdown in NavBar (🏢 chip, active badge, manage link)
- Workspace Management card in Settings with create/rename/delete/switch UI
- en.ts + tr.ts workspace i18n keys
- App.vue calls workspaceStore.init() on mount to hydrate header from localStorage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris 3 săptămâni în urmă
părinte
comite
ad28b56bee

Fișier diff suprimat deoarece este prea mare
+ 263 - 110
services/gateway/server.js


+ 5 - 0
ui/src/App.vue

@@ -8,5 +8,10 @@
 </template>
 
 <script setup lang="ts">
+import { onMounted } from 'vue'
 import NavBar from './components/NavBar.vue'
+import { useWorkspaceStore } from './stores/workspace'
+
+const workspaceStore = useWorkspaceStore()
+onMounted(() => workspaceStore.init())
 </script>

+ 91 - 27
ui/src/components/NavBar.vue

@@ -1,15 +1,15 @@
 <template>
   <nav class="bg-gray-900 border-b border-gray-800 px-6 py-3 flex items-center justify-between">
-    <router-link to="/dashboard" class="text-white font-bold text-lg tracking-tight">
+    <router-link to="/dashboard" class="text-white font-bold text-lg tracking-tight shrink-0">
       📡 SocialManager
     </router-link>
 
-    <div class="flex items-center gap-1">
+    <div class="flex items-center gap-1 overflow-x-auto">
       <router-link
         v-for="link in navLinks"
         :key="link.to"
         :to="link.to"
-        class="px-4 py-2 rounded-lg text-sm font-medium transition-colors"
+        class="px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap"
         :class="$route.path === link.to
           ? 'bg-gray-800 text-white'
           : 'text-gray-400 hover:text-white hover:bg-gray-800'"
@@ -18,47 +18,99 @@
       </router-link>
     </div>
 
-    <!-- Dil seçici -->
-    <div class="relative">
-      <button
-        @click="showLangMenu = !showLangMenu"
-        class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
-      >
-        <span>{{ currentLocale.flag }}</span>
-        <span>{{ currentLocale.code.toUpperCase() }}</span>
-        <span class="text-xs opacity-50">▾</span>
-      </button>
+    <div class="flex items-center gap-2 shrink-0">
+      <!-- Workspace switcher -->
+      <div class="relative">
+        <button
+          @click="showWorkspaceMenu = !showWorkspaceMenu"
+          class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm text-gray-400 hover:text-white hover:bg-gray-800 transition-colors max-w-[160px]"
+          :title="$t('workspace.switchLabel')"
+        >
+          <span class="text-xs">🏢</span>
+          <span class="truncate">{{ activeWorkspace?.name || $t('workspace.defaultName') }}</span>
+          <span class="text-xs opacity-50 shrink-0">▾</span>
+        </button>
 
-      <div
-        v-if="showLangMenu"
-        class="absolute right-0 top-full mt-1 bg-gray-800 border border-gray-700 rounded-xl overflow-hidden shadow-xl z-50 min-w-[140px]"
-      >
+        <div
+          v-if="showWorkspaceMenu"
+          class="absolute right-0 top-full mt-1 bg-gray-800 border border-gray-700 rounded-xl overflow-hidden shadow-xl z-50 min-w-[200px]"
+        >
+          <div class="px-3 py-2 border-b border-gray-700 text-xs text-gray-500 uppercase tracking-wider">
+            {{ $t('workspace.label') }}
+          </div>
+          <button
+            v-for="ws in workspaces"
+            :key="ws._id"
+            @click="switchWorkspace(ws._id)"
+            class="flex items-center gap-2 w-full px-4 py-2.5 text-sm transition-colors hover:bg-gray-700 text-left"
+            :class="ws._id === activeWorkspaceId ? 'text-white font-medium' : 'text-gray-400'"
+          >
+            <span class="flex-1 truncate">{{ ws.name || $t('workspace.defaultName') }}</span>
+            <span v-if="ws._id === activeWorkspaceId" class="text-violet-400 text-xs shrink-0">{{ $t('workspace.activeLabel') }}</span>
+          </button>
+          <div class="border-t border-gray-700">
+            <router-link
+              to="/settings#workspaces"
+              @click="showWorkspaceMenu = false"
+              class="flex items-center gap-2 w-full px-4 py-2.5 text-sm text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
+            >
+              <span class="text-xs">⚙️</span>
+              <span>{{ $t('workspace.manage') }}</span>
+            </router-link>
+          </div>
+        </div>
+      </div>
+
+      <!-- Language switcher -->
+      <div class="relative">
         <button
-          v-for="loc in SUPPORTED_LOCALES"
-          :key="loc.code"
-          @click="setLocale(loc.code)"
-          class="flex items-center gap-2 w-full px-4 py-2.5 text-sm transition-colors hover:bg-gray-700"
-          :class="locale === loc.code ? 'text-white font-medium' : 'text-gray-400'"
+          @click="showLangMenu = !showLangMenu"
+          class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
         >
-          <span>{{ loc.flag }}</span>
-          <span>{{ loc.label }}</span>
-          <span v-if="locale === loc.code" class="ml-auto text-blue-400 text-xs">✓</span>
+          <span>{{ currentLocale.flag }}</span>
+          <span>{{ currentLocale.code.toUpperCase() }}</span>
+          <span class="text-xs opacity-50">▾</span>
         </button>
+
+        <div
+          v-if="showLangMenu"
+          class="absolute right-0 top-full mt-1 bg-gray-800 border border-gray-700 rounded-xl overflow-hidden shadow-xl z-50 min-w-[140px]"
+        >
+          <button
+            v-for="loc in SUPPORTED_LOCALES"
+            :key="loc.code"
+            @click="setLocale(loc.code)"
+            class="flex items-center gap-2 w-full px-4 py-2.5 text-sm transition-colors hover:bg-gray-700"
+            :class="locale === loc.code ? 'text-white font-medium' : 'text-gray-400'"
+          >
+            <span>{{ loc.flag }}</span>
+            <span>{{ loc.label }}</span>
+            <span v-if="locale === loc.code" class="ml-auto text-blue-400 text-xs">✓</span>
+          </button>
+        </div>
       </div>
     </div>
   </nav>
 
-  <!-- Overlay to close menu -->
-  <div v-if="showLangMenu" class="fixed inset-0 z-40" @click="showLangMenu = false" />
+  <!-- Overlays to close menus -->
+  <div v-if="showLangMenu || showWorkspaceMenu" class="fixed inset-0 z-40" @click="closeMenus" />
 </template>
 
 <script setup lang="ts">
 import { ref, computed } from 'vue'
 import { useI18n } from 'vue-i18n'
+import { useRouter } from 'vue-router'
 import { SUPPORTED_LOCALES } from '../locales'
+import { useWorkspaceStore } from '../stores/workspace'
 
 const { locale } = useI18n()
+const router = useRouter()
 const showLangMenu = ref(false)
+const showWorkspaceMenu = ref(false)
+
+const workspaceStore = useWorkspaceStore()
+const { workspaces, activeWorkspaceId } = workspaceStore
+const activeWorkspace = computed(() => workspaceStore.activeWorkspace)
 
 const navLinks = [
   { to: '/dashboard', label: 'nav.feed' },
@@ -75,9 +127,21 @@ const currentLocale = computed(
   () => SUPPORTED_LOCALES.find((l) => l.code === locale.value) ?? SUPPORTED_LOCALES[0]
 )
 
+function closeMenus() {
+  showLangMenu.value = false
+  showWorkspaceMenu.value = false
+}
+
 function setLocale(code: string) {
   locale.value = code
   localStorage.setItem('locale', code)
   showLangMenu.value = false
 }
+
+async function switchWorkspace(id: string) {
+  await workspaceStore.setActive(id)
+  showWorkspaceMenu.value = false
+  // Reload current route so stores re-fetch with the new workspace header
+  router.go(0)
+}
 </script>

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

@@ -662,4 +662,29 @@ export default {
     pinterest: 'Pinterest',
     tiktok: 'TikTok',
   },
+
+  workspace: {
+    label: 'Workspace',
+    switchLabel: 'Switch workspace',
+    defaultName: 'Default Workspace',
+    addNew: 'New Workspace',
+    manage: 'Manage Workspaces',
+    nameLabel: 'Workspace Name',
+    namePlaceholder: 'e.g. Acme Corp',
+    create: 'Create',
+    creating: 'Creating…',
+    rename: 'Rename',
+    renaming: 'Saving…',
+    delete: 'Delete',
+    deleting: 'Deleting…',
+    confirmDelete: 'Delete this workspace and ALL its data (credentials, posts, competitors, drafts)? This cannot be undone.',
+    deleteBlocked: 'Cannot delete the only workspace.',
+    settingsTitle: 'Workspaces',
+    settingsSubtitle: 'Each workspace has its own credentials, competitors, and content — perfect for managing multiple businesses.',
+    activeLabel: 'Active',
+    noName: 'Unnamed workspace',
+    createError: 'Failed to create workspace',
+    deleteError: 'Failed to delete workspace',
+    renameError: 'Failed to rename workspace',
+  },
 }

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

@@ -662,4 +662,29 @@ export default {
     pinterest: 'Pinterest',
     tiktok: 'TikTok',
   },
+
+  workspace: {
+    label: 'Çalışma Alanı',
+    switchLabel: 'Çalışma alanı değiştir',
+    defaultName: 'Varsayılan Çalışma Alanı',
+    addNew: 'Yeni Çalışma Alanı',
+    manage: 'Çalışma Alanlarını Yönet',
+    nameLabel: 'Çalışma Alanı Adı',
+    namePlaceholder: 'örn. Acme A.Ş.',
+    create: 'Oluştur',
+    creating: 'Oluşturuluyor…',
+    rename: 'Yeniden Adlandır',
+    renaming: 'Kaydediliyor…',
+    delete: 'Sil',
+    deleting: 'Siliniyor…',
+    confirmDelete: 'Bu çalışma alanı ve TÜM verileri (kimlik bilgileri, gönderiler, rakipler, taslaklar) silinsin mi? Bu işlem geri alınamaz.',
+    deleteBlocked: 'Tek çalışma alanı silinemez.',
+    settingsTitle: 'Çalışma Alanları',
+    settingsSubtitle: 'Her çalışma alanının kendine özgü kimlik bilgileri, rakipleri ve içerikleri vardır — birden fazla işletmeyi yönetmek için idealdir.',
+    activeLabel: 'Aktif',
+    noName: 'Adsız çalışma alanı',
+    createError: 'Çalışma alanı oluşturulamadı',
+    deleteError: 'Çalışma alanı silinemedi',
+    renameError: 'Çalışma alanı adı değiştirilemedi',
+  },
 }

+ 69 - 0
ui/src/stores/workspace.ts

@@ -0,0 +1,69 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import axios from 'axios'
+
+export interface Workspace {
+  _id: string
+  name: string
+  createdAt: string
+}
+
+const STORAGE_KEY = 'activeWorkspaceId'
+
+export const useWorkspaceStore = defineStore('workspace', () => {
+  const workspaces = ref<Workspace[]>([])
+  const activeWorkspaceId = ref<string>(localStorage.getItem(STORAGE_KEY) || 'default')
+  const loading = ref(false)
+  const error = ref('')
+
+  const activeWorkspace = computed(
+    () => workspaces.value.find((w) => w._id === activeWorkspaceId.value) ?? null
+  )
+
+  function applyHeader(id: string) {
+    axios.defaults.headers.common['X-Workspace-Id'] = id
+  }
+
+  async function init() {
+    applyHeader(activeWorkspaceId.value)
+    try {
+      const res = await axios.get('/api/workspaces')
+      workspaces.value = res.data.workspaces ?? []
+      // If the stored workspace no longer exists, fall back to first available
+      if (workspaces.value.length && !workspaces.value.find((w) => w._id === activeWorkspaceId.value)) {
+        await setActive(workspaces.value[0]._id)
+      }
+    } catch {
+      // Non-fatal — default workspace still works even without the list
+    }
+  }
+
+  async function setActive(id: string) {
+    activeWorkspaceId.value = id
+    localStorage.setItem(STORAGE_KEY, id)
+    applyHeader(id)
+  }
+
+  async function create(name: string): Promise<Workspace> {
+    const res = await axios.post('/api/workspaces', { name })
+    const ws: Workspace = res.data
+    workspaces.value.push(ws)
+    return ws
+  }
+
+  async function rename(id: string, name: string) {
+    await axios.put(`/api/workspaces/${id}`, { name })
+    const ws = workspaces.value.find((w) => w._id === id)
+    if (ws) ws.name = name
+  }
+
+  async function remove(id: string) {
+    await axios.delete(`/api/workspaces/${id}`)
+    workspaces.value = workspaces.value.filter((w) => w._id !== id)
+    if (activeWorkspaceId.value === id && workspaces.value.length) {
+      await setActive(workspaces.value[0]._id)
+    }
+  }
+
+  return { workspaces, activeWorkspaceId, activeWorkspace, loading, error, init, setActive, create, rename, remove }
+})

+ 162 - 0
ui/src/views/Settings.vue

@@ -1415,6 +1415,95 @@
         </div>
       </div>
 
+      <!-- ═══════════════════════════════════════════════════════════════════
+           WORKSPACE MANAGEMENT
+      ════════════════════════════════════════════════════════════════════ -->
+      <div id="workspaces" class="bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden">
+        <div class="p-5 border-b border-gray-800 flex items-center gap-3">
+          <span class="w-8 h-8 rounded-full bg-violet-600 flex items-center justify-center text-white text-sm font-bold">🏢</span>
+          <div>
+            <p class="font-semibold">{{ $t('workspace.settingsTitle') }}</p>
+            <p class="text-xs text-gray-500 mt-0.5">{{ $t('workspace.settingsSubtitle') }}</p>
+          </div>
+        </div>
+
+        <div class="p-5 space-y-3">
+          <!-- Existing workspaces -->
+          <div
+            v-for="ws in workspaceStore.workspaces"
+            :key="ws._id"
+            class="flex items-center gap-3 p-3 rounded-xl border"
+            :class="ws._id === workspaceStore.activeWorkspaceId
+              ? 'border-violet-600 bg-violet-950/30'
+              : 'border-gray-800 bg-gray-800/40'"
+          >
+            <div class="flex-1 min-w-0">
+              <div v-if="renamingId !== ws._id" class="flex items-center gap-2">
+                <span class="text-sm font-medium truncate">{{ ws.name || $t('workspace.defaultName') }}</span>
+                <span v-if="ws._id === workspaceStore.activeWorkspaceId" class="text-xs px-1.5 py-0.5 rounded bg-violet-600 text-white">{{ $t('workspace.activeLabel') }}</span>
+              </div>
+              <div v-else class="flex items-center gap-2">
+                <input
+                  v-model="renameValue"
+                  type="text"
+                  class="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-2 py-1 text-sm focus:outline-none focus:border-violet-500"
+                  @keydown.enter="saveRename(ws._id)"
+                  @keydown.escape="renamingId = null"
+                />
+                <button @click="saveRename(ws._id)" class="text-xs px-2 py-1 bg-violet-600 hover:bg-violet-500 rounded text-white transition-colors">{{ wsRenaming ? $t('workspace.renaming') : $t('workspace.rename') }}</button>
+                <button @click="renamingId = null" class="text-xs px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-gray-300 transition-colors">✕</button>
+              </div>
+            </div>
+            <div class="flex items-center gap-1 shrink-0">
+              <button
+                v-if="ws._id !== workspaceStore.activeWorkspaceId"
+                @click="switchTo(ws._id)"
+                class="text-xs px-2.5 py-1 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 hover:text-white transition-colors"
+              >
+                Switch
+              </button>
+              <button
+                @click="startRename(ws)"
+                class="text-xs px-2.5 py-1 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 hover:text-white transition-colors"
+              >
+                {{ $t('workspace.rename') }}
+              </button>
+              <button
+                v-if="workspaceStore.workspaces.length > 1"
+                @click="deleteWorkspace(ws._id)"
+                class="text-xs px-2.5 py-1 bg-red-900/50 hover:bg-red-800 rounded-lg text-red-400 hover:text-red-200 transition-colors"
+              >
+                {{ wsDeleting === ws._id ? $t('workspace.deleting') : $t('workspace.delete') }}
+              </button>
+            </div>
+          </div>
+
+          <!-- Error -->
+          <p v-if="wsError" class="text-xs text-red-400">{{ wsError }}</p>
+
+          <!-- Create new workspace -->
+          <div class="pt-2 border-t border-gray-800">
+            <p class="text-xs text-gray-500 mb-2">{{ $t('workspace.addNew') }}</p>
+            <div class="flex gap-2">
+              <input
+                v-model="newWsName"
+                type="text"
+                :placeholder="$t('workspace.namePlaceholder')"
+                class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-violet-500"
+                @keydown.enter="createWorkspace"
+              />
+              <button
+                @click="createWorkspace"
+                :disabled="!newWsName.trim() || wsCreating"
+                class="px-4 py-2 bg-violet-600 hover:bg-violet-500 disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
+              >
+                {{ wsCreating ? $t('workspace.creating') : $t('workspace.create') }}
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+
       <!-- Refresh button -->
       <button
         @click="platformsStore.fetchStatuses()"
@@ -1435,14 +1524,87 @@ import axios from 'axios'
 import { usePlatformsStore, PLATFORM_META } from '../stores/platforms'
 import { useAiStore, PROVIDER_MODELS } from '../stores/ai'
 import { useHashtagStore, type HashtagGroup } from '../stores/hashtags'
+import { useWorkspaceStore, type Workspace } from '../stores/workspace'
 import { COMMON_TIMEZONES } from '../utils/timezone'
+import { useRouter } from 'vue-router'
 
 const { t } = useI18n()
 
 const route = useRoute()
+const router = useRouter()
 const platformsStore = usePlatformsStore()
 const aiStore = useAiStore()
 const hashtagStore = useHashtagStore()
+const workspaceStore = useWorkspaceStore()
+
+// ─── Workspace management ────────────────────────────────────────────────────
+
+const newWsName = ref('')
+const wsCreating = ref(false)
+const wsDeleting = ref<string | null>(null)
+const wsRenaming = ref(false)
+const wsError = ref('')
+const renamingId = ref<string | null>(null)
+const renameValue = ref('')
+
+async function createWorkspace() {
+  if (!newWsName.value.trim() || wsCreating.value) return
+  wsCreating.value = true
+  wsError.value = ''
+  try {
+    const ws = await workspaceStore.create(newWsName.value.trim())
+    newWsName.value = ''
+    await workspaceStore.setActive(ws._id)
+    router.go(0)
+  } catch {
+    wsError.value = t('workspace.createError')
+  } finally {
+    wsCreating.value = false
+  }
+}
+
+async function deleteWorkspace(id: string) {
+  if (workspaceStore.workspaces.length <= 1) {
+    wsError.value = t('workspace.deleteBlocked')
+    return
+  }
+  if (!confirm(t('workspace.confirmDelete'))) return
+  wsDeleting.value = id
+  wsError.value = ''
+  try {
+    const wasActive = id === workspaceStore.activeWorkspaceId
+    await workspaceStore.remove(id)
+    if (wasActive) router.go(0)
+  } catch {
+    wsError.value = t('workspace.deleteError')
+  } finally {
+    wsDeleting.value = null
+  }
+}
+
+function startRename(ws: Workspace) {
+  renamingId.value = ws._id
+  renameValue.value = ws.name
+}
+
+async function saveRename(id: string) {
+  if (!renameValue.value.trim() || wsRenaming.value) return
+  wsRenaming.value = true
+  wsError.value = ''
+  try {
+    await workspaceStore.rename(id, renameValue.value.trim())
+    renamingId.value = null
+  } catch {
+    wsError.value = t('workspace.renameError')
+  } finally {
+    wsRenaming.value = false
+  }
+}
+
+async function switchTo(id: string) {
+  await workspaceStore.setActive(id)
+  router.go(0)
+}
 
 // ─── App credential form state ──────────────────────────────────────────────
 

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff