diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index f451f53..cef1387 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -21,6 +21,18 @@ const enforceUppercase = (data: T): T => { const API_URL = import.meta.env.VITE_API_URL || '/api'; +// Limpa o estado LOCAL da inbox (/wa-inbox): a instância ativa selecionada e o +// cache de chats por instância (inboxCache:v1:*). Sem isso, o próximo usuário +// (ex.: superadmin após outro login) herda a sessão e vê chats antigos de outra. +const clearInboxLocalState = () => { + try { + localStorage.removeItem('activeInstanceId'); + Object.keys(localStorage) + .filter(k => k.startsWith('inboxCache:')) + .forEach(k => localStorage.removeItem(k)); + } catch { /* ignore */ } +}; + // Returns headers always including the JWT token stored after login let _sessionExpiredHandled = false; const apiFetch = async (url: string, options: RequestInit = {}) => { @@ -38,6 +50,7 @@ const apiFetch = async (url: string, options: RequestInit = {}) => { try { ['SCOREODONTO_AUTH_TOKEN', 'SCOREODONTO_USER_DATA', 'SCOREODONTO_WORKSPACES', 'SCOREODONTO_ACTIVE_WORKSPACE', 'SCOREODONTO_PREVIEW_ROLE'] .forEach(k => localStorage.removeItem(k)); + clearInboxLocalState(); } catch { /* ignore */ } window.location.href = '/?session=expired'; window.location.reload(); @@ -119,6 +132,7 @@ export const HybridBackend = { localStorage.removeItem('SCOREODONTO_WORKSPACES'); localStorage.removeItem('SCOREODONTO_ACTIVE_WORKSPACE'); localStorage.removeItem('SCOREODONTO_PREVIEW_ROLE'); + clearInboxLocalState(); // não vazar instância/chats do /wa-inbox p/ o próximo login window.location.href = '/'; window.location.reload(); }, @@ -393,6 +407,7 @@ export const HybridBackend = { 'SCOREODONTO_CACHE_NOTIFICACOES', ]; TENANT_CACHE_KEYS.forEach(k => localStorage.removeItem(k)); + clearInboxLocalState(); // troca de clínica não deve carregar a inbox da anterior console.log(`[SECURITY] Cache cleared for tenant switch to: ${workspaceId}`); // ─── Set new active workspace and reload to flush React state completely ─── localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(workspace)); @@ -1506,9 +1521,9 @@ export const HybridBackend = { // Acesso agregado do usuário logado às áreas do WhatsApp (para esconder menu/botões). // Fail-open: se o endpoint estiver indisponível, não esconde nada (backend é a autoridade). - getWaAccess: async (): Promise<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean }> => { + getWaAccess: async (): Promise<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean; signature?: string | null }> => { const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; - const open = { isOwner: false, inbox: true, secretaria: true, delete_msg: true }; + const open = { isOwner: false, inbox: true, secretaria: true, delete_msg: true, signature: null }; if (!clinicaId) return open; try { const res = await apiFetch(`${API_URL}/nw/access?clinicaId=${encodeURIComponent(clinicaId)}`, {}); diff --git a/frontend/views/newwhats/store/instanceStore.ts b/frontend/views/newwhats/store/instanceStore.ts index 169b46f..81d966d 100644 --- a/frontend/views/newwhats/store/instanceStore.ts +++ b/frontend/views/newwhats/store/instanceStore.ts @@ -4,10 +4,13 @@ */ import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' -import { instanceApi } from '../services/chatApiService' +import { instanceApi, accountsApi } from '../services/chatApiService' import { socketService } from '../services/socketService' import { notify } from './notificationStore' +// Registra os listeners do socket UMA vez por sessão (evita acúmulo entre mounts). +let _instanceListenersReady = false + export type InstanceStatus = 'DISCONNECTED' | 'CONNECTING' | 'QR_PENDING' | 'CONNECTED' | 'BANNED' export interface Instance { @@ -16,6 +19,15 @@ export interface Instance { status: InstanceStatus phone: string | null avatar: string | null + // Metadados do seletor multi-conta (vêm de /api/nw/accounts): + role?: string | null // papel (sec_numbers): doctor/clinic/… + label?: string | null // rótulo livre ("Consultt Clinic – Recepção") + area?: string | null + notes?: string | null + clinicaId?: string | null // workspace da conta (null = conta própria) + ownerEmail?: string | null + ownerNome?: string | null + own?: boolean // true = número da própria conta do usuário } interface InstanceState { @@ -85,10 +97,24 @@ export const useInstanceStore = create()( loadInstances: async () => { set((s) => { s.loading = true }) try { - const data = await instanceApi.list() - const list: Instance[] = (Array.isArray(data) ? data : (data.instances ?? [])).map((i: any) => ({ - ...i, - avatar: i.avatar ?? null, + // Fonte: /api/nw/accounts — inclui os números PRÓPRIOS e os LIBERADOS pelo + // dono (can_inbox), cada um com papel/rótulo. Assim o seletor da inbox mostra + // a caixa própria E a da clínica sem trocar de login. + const data = await accountsApi.list() + const list: Instance[] = data.map((a) => ({ + id: a.instanceId, + name: a.name || a.phone || a.instanceId, + status: (a.status as InstanceStatus) || 'DISCONNECTED', + phone: a.phone ?? null, + avatar: a.avatar ?? null, + role: a.role ?? null, + label: a.label ?? null, + area: a.area ?? null, + notes: a.notes ?? null, + clinicaId: a.clinicaId ?? null, + ownerEmail: a.ownerEmail ?? null, + ownerNome: a.ownerNome ?? null, + own: a.own ?? true, })) set((s) => { s.loading = false @@ -104,6 +130,13 @@ export const useInstanceStore = create()( } return fromDb }) + // Segurança: se a instância ativa persistida NÃO está na lista acessível + // (ex.: superadmin após outro login, ou acesso revogado), reseta — para + // não abrir a inbox de uma sessão que este usuário não pode ver. + if (s.activeInstanceId && !list.some((i) => i.id === s.activeInstanceId)) { + s.activeInstanceId = null + try { localStorage.removeItem('activeInstanceId') } catch { /* ignore */ } + } }) } catch { set((s) => { s.loading = false }) @@ -139,6 +172,8 @@ export const useInstanceStore = create()( // ── Socket ──────────────────────────────────────────────────────────────── initSocketListeners: () => { + if (_instanceListenersReady) return + _instanceListenersReady = true socketService.on('instance:status', ({ instanceId, status,