fix(wa-inbox): não vazar sessão/chats de outro usuário (limpa estado local no logout)

O /wa-inbox persistia activeInstanceId + inboxCache:v1:* no localStorage e o logout
não limpava — então o próximo login (ex.: superadmin) herdava a instância e via os
chats antigos de outra sessão. Agora: clearInboxLocalState() no logout, na expiração
de sessão e na troca de workspace; e loadInstances reseta activeInstanceId se ele não
estiver na lista de instâncias acessíveis (guarda defensiva).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-06 22:21:25 +02:00
parent b1ddfbf349
commit f343d8e7a7
2 changed files with 57 additions and 7 deletions
+17 -2
View File
@@ -21,6 +21,18 @@ const enforceUppercase = <T extends object>(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)}`, {});
+40 -5
View File
@@ -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<InstanceState>()(
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<InstanceState>()(
}
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<InstanceState>()(
// ── Socket ────────────────────────────────────────────────────────────────
initSocketListeners: () => {
if (_instanceListenersReady) return
_instanceListenersReady = true
socketService.on('instance:status', ({
instanceId,
status,