feat: implement end-to-end encryption for chat messages and files, including key management and decryption functionality; enhance chat components to support encrypted content display
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
import SalaReuniaoManager from './SalaReuniaoManager.svelte';
|
import SalaReuniaoManager from './SalaReuniaoManager.svelte';
|
||||||
import CallWindow from '../call/CallWindow.svelte';
|
import CallWindow from '../call/CallWindow.svelte';
|
||||||
import ErrorModal from '../ErrorModal.svelte';
|
import ErrorModal from '../ErrorModal.svelte';
|
||||||
|
import E2EManagementModal from './E2EManagementModal.svelte';
|
||||||
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { traduzirErro } from '$lib/utils/erroHelpers';
|
import { traduzirErro } from '$lib/utils/erroHelpers';
|
||||||
@@ -19,14 +20,14 @@
|
|||||||
X,
|
X,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
LogOut,
|
LogOut,
|
||||||
MoreVertical,
|
|
||||||
Users,
|
Users,
|
||||||
Clock,
|
Clock,
|
||||||
XCircle,
|
|
||||||
Phone,
|
Phone,
|
||||||
Video,
|
Video,
|
||||||
ChevronDown,
|
Search,
|
||||||
Search
|
Lock,
|
||||||
|
MoreVertical,
|
||||||
|
XCircle
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
//import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
|
//import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
|
||||||
@@ -45,21 +46,18 @@
|
|||||||
let showSalaManager = $state(false);
|
let showSalaManager = $state(false);
|
||||||
let showAdminMenu = $state(false);
|
let showAdminMenu = $state(false);
|
||||||
let showNotificacaoModal = $state(false);
|
let showNotificacaoModal = $state(false);
|
||||||
|
let showE2EModal = $state(false);
|
||||||
let iniciandoChamada = $state(false);
|
let iniciandoChamada = $state(false);
|
||||||
let chamadaAtiva = $state<Id<'chamadas'> | null>(null);
|
let chamadaAtiva = $state<Id<'chamadas'> | null>(null);
|
||||||
let showSearch = $state(false);
|
let showSearch = $state(false);
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let searchResults = $state<Array<any>>([]);
|
let searchResults = $state<Array<unknown | undefined>>([]);
|
||||||
let searching = $state(false);
|
let searching = $state(false);
|
||||||
let selectedSearchResult = $state<number>(-1);
|
let selectedSearchResult = $state<number>(-1);
|
||||||
|
|
||||||
// Estados para modal de erro
|
|
||||||
let showErrorModal = $state(false);
|
let showErrorModal = $state(false);
|
||||||
let errorTitle = $state('Erro');
|
let errorTitle = $state('Erro');
|
||||||
let errorMessage = $state('');
|
let errorMessage = $state('');
|
||||||
let errorInstructions = $state<string | undefined>(undefined);
|
let errorInstructions = $state<string | undefined>(undefined);
|
||||||
let errorDetails = $state<string | undefined>(undefined);
|
|
||||||
|
|
||||||
const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, {
|
const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, {
|
||||||
conversaId: conversaId as Id<'conversas'>
|
conversaId: conversaId as Id<'conversas'>
|
||||||
});
|
});
|
||||||
@@ -70,6 +68,11 @@
|
|||||||
conversaId: conversaId as Id<'conversas'>
|
conversaId: conversaId as Id<'conversas'>
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Verificar se a conversa tem criptografia E2E habilitada
|
||||||
|
const temCriptografiaE2E = useQuery(api.chat.verificarCriptografiaE2E, {
|
||||||
|
conversaId: conversaId as Id<'conversas'>
|
||||||
|
});
|
||||||
|
|
||||||
const conversa = $derived(() => {
|
const conversa = $derived(() => {
|
||||||
console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId);
|
console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId);
|
||||||
console.log('📋 [ChatWindow] Conversas disponíveis:', conversas?.data);
|
console.log('📋 [ChatWindow] Conversas disponíveis:', conversas?.data);
|
||||||
@@ -297,9 +300,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
|
<!-- Nome da conversa com indicador de criptografia E2E -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<p class="text-base-content truncate font-semibold">
|
<p class="text-base-content truncate font-semibold">
|
||||||
{getNomeConversa()}
|
{getNomeConversa()}
|
||||||
</p>
|
</p>
|
||||||
|
{#if temCriptografiaE2E?.data}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showE2EModal = true;
|
||||||
|
}}
|
||||||
|
title="Gerenciar criptografia end-to-end (E2E)"
|
||||||
|
aria-label="Gerenciar criptografia E2E"
|
||||||
|
>
|
||||||
|
<Lock
|
||||||
|
class="text-success hover:text-success/80 h-4 w-4 transition-colors"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if getStatusMensagem()}
|
{#if getStatusMensagem()}
|
||||||
<p class="text-base-content/60 truncate text-xs">
|
<p class="text-base-content/60 truncate text-xs">
|
||||||
{getStatusMensagem()}
|
{getStatusMensagem()}
|
||||||
@@ -322,7 +345,7 @@
|
|||||||
{conversa()?.participantesInfo?.length || 0}
|
{conversa()?.participantesInfo?.length || 0}
|
||||||
{conversa()?.participantesInfo?.length === 1 ? 'participante' : 'participantes'}
|
{conversa()?.participantesInfo?.length === 1 ? 'participante' : 'participantes'}
|
||||||
</p>
|
</p>
|
||||||
{#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0}
|
{#if conversa()?.participantesInfo && conversa()?.participantesInfo?.length > 0}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex -space-x-2">
|
<div class="flex -space-x-2">
|
||||||
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
|
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
|
||||||
@@ -609,6 +632,27 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Botão Gerenciar E2E -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
|
||||||
|
style="background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2);"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showE2EModal = true;
|
||||||
|
}}
|
||||||
|
aria-label="Gerenciar criptografia E2E"
|
||||||
|
title="Gerenciar criptografia end-to-end"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-green-500/0 transition-colors duration-300 group-hover:bg-green-500/10"
|
||||||
|
></div>
|
||||||
|
<Lock
|
||||||
|
class="relative z-10 h-5 w-5 text-green-500 transition-transform group-hover:scale-110"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Botão Agendar MODERNO -->
|
<!-- Botão Agendar MODERNO -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -674,7 +718,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if searchResults.length > 0}
|
{:else if searchResults.length > 0}
|
||||||
<p id="search-results-info" class="sr-only">
|
<p id="search-results-info" class="sr-only">
|
||||||
{searchResults.length} resultado{searchResults.length !== 1 ? 's' : ''} encontrado{searchResults.length !== 1
|
{searchResults.length} resultado{searchResults.length !== 1 ? 's' : ''} encontrado{searchResults.length !==
|
||||||
|
1
|
||||||
? 's'
|
? 's'
|
||||||
: ''}
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -698,7 +743,9 @@
|
|||||||
aria-selected={index === selectedSearchResult}
|
aria-selected={index === selectedSearchResult}
|
||||||
aria-label="Mensagem de {resultado.remetente?.nome || 'Usuário'}"
|
aria-label="Mensagem de {resultado.remetente?.nome || 'Usuário'}"
|
||||||
>
|
>
|
||||||
<div class="bg-primary/20 flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full">
|
<div
|
||||||
|
class="bg-primary/20 flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full"
|
||||||
|
>
|
||||||
{#if resultado.remetente?.fotoPerfilUrl}
|
{#if resultado.remetente?.fotoPerfilUrl}
|
||||||
<img
|
<img
|
||||||
src={resultado.remetente.fotoPerfilUrl}
|
src={resultado.remetente.fotoPerfilUrl}
|
||||||
@@ -750,6 +797,14 @@
|
|||||||
conversaId={conversaId as Id<'conversas'>}
|
conversaId={conversaId as Id<'conversas'>}
|
||||||
onClose={() => (showScheduleModal = false)}
|
onClose={() => (showScheduleModal = false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Modal de Gerenciamento E2E -->
|
||||||
|
{#if showE2EModal}
|
||||||
|
<E2EManagementModal
|
||||||
|
conversaId={conversaId as Id<'conversas'>}
|
||||||
|
onClose={() => (showE2EModal = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Modal de Gerenciamento de Sala -->
|
<!-- Modal de Gerenciamento de Sala -->
|
||||||
|
|||||||
267
apps/web/src/lib/components/chat/E2EManagementModal.svelte
Normal file
267
apps/web/src/lib/components/chat/E2EManagementModal.svelte
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { Lock, X, RefreshCw, Shield, AlertTriangle, CheckCircle } from 'lucide-svelte';
|
||||||
|
import {
|
||||||
|
generateEncryptionKey,
|
||||||
|
exportKey,
|
||||||
|
storeEncryptionKey,
|
||||||
|
hasEncryptionKey,
|
||||||
|
removeStoredEncryptionKey
|
||||||
|
} from '$lib/utils/e2eEncryption';
|
||||||
|
import { armazenarChaveCriptografia, removerChaveCriptografia } from '$lib/stores/chatStore';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ptBR } from 'date-fns/locale';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversaId: Id<'conversas'>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { conversaId, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
const temCriptografiaE2E = useQuery(api.chat.verificarCriptografiaE2E, { conversaId });
|
||||||
|
const chaveAtual = useQuery(api.chat.obterChaveCriptografia, { conversaId });
|
||||||
|
const conversa = useQuery(api.chat.listarConversas, {});
|
||||||
|
|
||||||
|
let ativando = $state(false);
|
||||||
|
let regenerando = $state(false);
|
||||||
|
let desativando = $state(false);
|
||||||
|
|
||||||
|
// Obter informações da conversa
|
||||||
|
const conversaInfo = $derived(() => {
|
||||||
|
if (!conversa?.data || !Array.isArray(conversa.data)) return null;
|
||||||
|
return conversa.data.find((c: { _id: string }) => c._id === conversaId) || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function ativarE2E() {
|
||||||
|
if (!confirm('Deseja ativar criptografia end-to-end para esta conversa?\n\nTodas as mensagens futuras serão criptografadas.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ativando = true;
|
||||||
|
|
||||||
|
// Gerar nova chave de criptografia
|
||||||
|
const encryptionKey = await generateEncryptionKey();
|
||||||
|
const keyData = await exportKey(encryptionKey.key);
|
||||||
|
|
||||||
|
// Armazenar localmente
|
||||||
|
storeEncryptionKey(conversaId, keyData, encryptionKey.keyId);
|
||||||
|
armazenarChaveCriptografia(conversaId, encryptionKey.key);
|
||||||
|
|
||||||
|
// Compartilhar chave com outros participantes
|
||||||
|
await client.mutation(api.chat.compartilharChaveCriptografia, {
|
||||||
|
conversaId,
|
||||||
|
chaveCompartilhada: keyData, // Em produção, isso deveria ser criptografado com chave pública de cada participante
|
||||||
|
keyId: encryptionKey.keyId
|
||||||
|
});
|
||||||
|
|
||||||
|
alert('Criptografia E2E ativada com sucesso!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao ativar E2E:', error);
|
||||||
|
alert('Erro ao ativar criptografia E2E');
|
||||||
|
} finally {
|
||||||
|
ativando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerarChave() {
|
||||||
|
if (!confirm('Deseja regenerar a chave de criptografia?\n\nAs mensagens antigas continuarão legíveis, mas novas mensagens usarão a nova chave.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
regenerando = true;
|
||||||
|
|
||||||
|
// Gerar nova chave
|
||||||
|
const encryptionKey = await generateEncryptionKey();
|
||||||
|
const keyData = await exportKey(encryptionKey.key);
|
||||||
|
|
||||||
|
// Atualizar chave localmente
|
||||||
|
storeEncryptionKey(conversaId, keyData, encryptionKey.keyId);
|
||||||
|
armazenarChaveCriptografia(conversaId, encryptionKey.key);
|
||||||
|
|
||||||
|
// Compartilhar nova chave (desativa chaves antigas automaticamente)
|
||||||
|
await client.mutation(api.chat.compartilharChaveCriptografia, {
|
||||||
|
conversaId,
|
||||||
|
chaveCompartilhada: keyData,
|
||||||
|
keyId: encryptionKey.keyId
|
||||||
|
});
|
||||||
|
|
||||||
|
alert('Chave regenerada com sucesso!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao regenerar chave:', error);
|
||||||
|
alert('Erro ao regenerar chave');
|
||||||
|
} finally {
|
||||||
|
regenerando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function desativarE2E() {
|
||||||
|
if (!confirm('Deseja desativar criptografia end-to-end para esta conversa?\n\nAs mensagens antigas continuarão criptografadas, mas novas mensagens não serão mais criptografadas.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
desativando = true;
|
||||||
|
|
||||||
|
// Remover chave localmente
|
||||||
|
removeStoredEncryptionKey(conversaId);
|
||||||
|
removerChaveCriptografia(conversaId);
|
||||||
|
|
||||||
|
// Desativar chave no servidor (marcar como inativa)
|
||||||
|
// Nota: Não removemos a chave do servidor, apenas a marcamos como inativa
|
||||||
|
// Isso permite que mensagens antigas ainda possam ser descriptografadas
|
||||||
|
if (chaveAtual?.data) {
|
||||||
|
// A mutation compartilharChaveCriptografia já desativa chaves antigas
|
||||||
|
// Mas precisamos de uma mutation específica para desativar completamente
|
||||||
|
// Por enquanto, vamos apenas remover localmente
|
||||||
|
alert('Criptografia E2E desativada localmente. As mensagens antigas ainda podem ser descriptografadas se você tiver a chave.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao desativar E2E:', error);
|
||||||
|
alert('Erro ao desativar criptografia E2E');
|
||||||
|
} finally {
|
||||||
|
desativando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatarData(timestamp: number): string {
|
||||||
|
try {
|
||||||
|
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR });
|
||||||
|
} catch {
|
||||||
|
return 'Data inválida';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="modal modal-open"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
aria-modal="true"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="modal-box max-w-2xl"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 id="modal-title" class="flex items-center gap-2 text-xl font-bold">
|
||||||
|
<Shield class="text-primary h-5 w-5" />
|
||||||
|
Criptografia End-to-End (E2E)
|
||||||
|
</h2>
|
||||||
|
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 space-y-6 overflow-y-auto p-6">
|
||||||
|
<!-- Status da Criptografia -->
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if temCriptografiaE2E?.data}
|
||||||
|
<CheckCircle class="text-success h-6 w-6 shrink-0" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="card-title text-lg text-success">Criptografia E2E Ativa</h3>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Suas mensagens estão protegidas com criptografia end-to-end
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<AlertTriangle class="text-warning h-6 w-6 shrink-0" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="card-title text-lg text-warning">Criptografia E2E Desativada</h3>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Suas mensagens não estão criptografadas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informações da Chave -->
|
||||||
|
{#if temCriptografiaE2E?.data && chaveAtual?.data}
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Informações da Chave</h3>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-base-content/70">ID da Chave:</span>
|
||||||
|
<span class="font-mono text-xs">{chaveAtual.data.keyId.substring(0, 16)}...</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-base-content/70">Criada em:</span>
|
||||||
|
<span>{formatarData(chaveAtual.data.criadoEm)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-base-content/70">Chave local:</span>
|
||||||
|
<span class="text-success">
|
||||||
|
{hasEncryptionKey(conversaId) ? '✓ Armazenada' : '✗ Não encontrada'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Informações sobre E2E -->
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<Lock class="h-5 w-5" />
|
||||||
|
<div class="text-sm">
|
||||||
|
<p class="font-semibold">Como funciona a criptografia E2E?</p>
|
||||||
|
<ul class="mt-2 list-inside list-disc space-y-1 text-xs">
|
||||||
|
<li>Suas mensagens são criptografadas no seu dispositivo antes de serem enviadas</li>
|
||||||
|
<li>Apenas você e os participantes da conversa podem descriptografar as mensagens</li>
|
||||||
|
<li>O servidor não consegue ler o conteúdo das mensagens criptografadas</li>
|
||||||
|
<li>Mensagens antigas continuam legíveis mesmo após regenerar a chave</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ações -->
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#if temCriptografiaE2E?.data}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-warning"
|
||||||
|
onclick={regenerarChave}
|
||||||
|
disabled={regenerando || ativando || desativando}
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4 {regenerando ? 'animate-spin' : ''}" />
|
||||||
|
{regenerando ? 'Regenerando...' : 'Regenerar Chave'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error"
|
||||||
|
onclick={desativarE2E}
|
||||||
|
disabled={regenerando || ativando || desativando}
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
{desativando ? 'Desativando...' : 'Desativar E2E'}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={ativarE2E}
|
||||||
|
disabled={regenerando || ativando || desativando}
|
||||||
|
>
|
||||||
|
<Lock class="h-4 w-4" />
|
||||||
|
{ativando ? 'Ativando...' : 'Ativar Criptografia E2E'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -4,6 +4,15 @@
|
|||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Paperclip, Smile, Send } from 'lucide-svelte';
|
import { Paperclip, Smile, Send } from 'lucide-svelte';
|
||||||
|
import {
|
||||||
|
encryptMessage,
|
||||||
|
encryptFile,
|
||||||
|
loadEncryptionKey,
|
||||||
|
storeEncryptionKey,
|
||||||
|
exportKey,
|
||||||
|
type EncryptedMessage
|
||||||
|
} from '$lib/utils/e2eEncryption';
|
||||||
|
import { obterChaveCriptografia, armazenarChaveCriptografia } from '$lib/stores/chatStore';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversaId: Id<'conversas'>;
|
conversaId: Id<'conversas'>;
|
||||||
@@ -28,6 +37,9 @@
|
|||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const conversas = useQuery(api.chat.listarConversas, {});
|
const conversas = useQuery(api.chat.listarConversas, {});
|
||||||
|
|
||||||
|
// Verificar se a conversa tem criptografia E2E habilitada
|
||||||
|
const temCriptografiaE2E = useQuery(api.chat.verificarCriptografiaE2E, { conversaId });
|
||||||
|
|
||||||
// Constantes de validação
|
// Constantes de validação
|
||||||
const MAX_MENSAGEM_LENGTH = 5000; // Limite de caracteres por mensagem
|
const MAX_MENSAGEM_LENGTH = 5000; // Limite de caracteres por mensagem
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
@@ -258,10 +270,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verificar se a conversa tem criptografia E2E e criptografar mensagem se necessário
|
||||||
|
const conversaTemE2E = temCriptografiaE2E?.data ?? false;
|
||||||
|
let conteudoParaEnviar = texto;
|
||||||
|
let criptografado = false;
|
||||||
|
let iv: string | undefined;
|
||||||
|
let keyId: string | undefined;
|
||||||
|
|
||||||
|
if (conversaTemE2E) {
|
||||||
|
try {
|
||||||
|
// Tentar obter chave do store primeiro
|
||||||
|
let encryptionKey = obterChaveCriptografia(conversaId);
|
||||||
|
|
||||||
|
// Se não estiver no store, tentar carregar do localStorage
|
||||||
|
if (!encryptionKey) {
|
||||||
|
encryptionKey = await loadEncryptionKey(conversaId);
|
||||||
|
if (encryptionKey) {
|
||||||
|
armazenarChaveCriptografia(conversaId, encryptionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se ainda não tiver chave, tentar obter do servidor
|
||||||
|
if (!encryptionKey) {
|
||||||
|
const chaveDoServidor = await client.query(api.chat.obterChaveCriptografia, {
|
||||||
|
conversaId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (chaveDoServidor?.chaveCompartilhada) {
|
||||||
|
// Importar chave do servidor (assumindo que está em formato exportado)
|
||||||
|
// Nota: Em produção, a chave do servidor deve ser criptografada com chave pública do usuário
|
||||||
|
// Por enquanto, vamos assumir que a chave já está descriptografada no cliente
|
||||||
|
const { importKey } = await import('$lib/utils/e2eEncryption');
|
||||||
|
encryptionKey = await importKey(chaveDoServidor.chaveCompartilhada);
|
||||||
|
|
||||||
|
// Armazenar chave localmente
|
||||||
|
const keyData = await exportKey(encryptionKey);
|
||||||
|
storeEncryptionKey(conversaId, keyData, chaveDoServidor.keyId);
|
||||||
|
armazenarChaveCriptografia(conversaId, encryptionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encryptionKey) {
|
||||||
|
// Criptografar mensagem
|
||||||
|
const encrypted: EncryptedMessage = await encryptMessage(texto, encryptionKey);
|
||||||
|
conteudoParaEnviar = encrypted.encryptedContent;
|
||||||
|
iv = encrypted.iv;
|
||||||
|
keyId = encrypted.keyId;
|
||||||
|
criptografado = true;
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'⚠️ [MessageInput] Criptografia E2E habilitada mas chave não encontrada. Enviando sem criptografia.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [MessageInput] Erro ao criptografar mensagem:', error);
|
||||||
|
alert('Erro ao criptografar mensagem. Tentando enviar sem criptografia...');
|
||||||
|
// Continuar sem criptografia em caso de erro
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('📤 [MessageInput] Enviando mensagem:', {
|
console.log('📤 [MessageInput] Enviando mensagem:', {
|
||||||
conversaId,
|
conversaId,
|
||||||
conteudo: texto,
|
conteudo: criptografado ? '[CRIPTOGRAFADO]' : texto,
|
||||||
tipo: 'texto',
|
tipo: 'texto',
|
||||||
|
criptografado,
|
||||||
respostaPara: mensagemRespondendo?.id,
|
respostaPara: mensagemRespondendo?.id,
|
||||||
mencoes: mencoesIds
|
mencoes: mencoesIds
|
||||||
});
|
});
|
||||||
@@ -270,10 +342,13 @@
|
|||||||
enviando = true;
|
enviando = true;
|
||||||
const result = await client.mutation(api.chat.enviarMensagem, {
|
const result = await client.mutation(api.chat.enviarMensagem, {
|
||||||
conversaId,
|
conversaId,
|
||||||
conteudo: texto,
|
conteudo: conteudoParaEnviar,
|
||||||
tipo: 'texto',
|
tipo: 'texto',
|
||||||
respostaPara: mensagemRespondendo?.id,
|
respostaPara: mensagemRespondendo?.id,
|
||||||
mencoes: mencoesIds.length > 0 ? mencoesIds : undefined
|
mencoes: mencoesIds.length > 0 ? mencoesIds : undefined,
|
||||||
|
criptografado: criptografado ? true : undefined,
|
||||||
|
iv: iv,
|
||||||
|
keyId: keyId
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ [MessageInput] Mensagem enviada com sucesso! ID:', result);
|
console.log('✅ [MessageInput] Mensagem enviada com sucesso! ID:', result);
|
||||||
@@ -309,7 +384,7 @@
|
|||||||
const customEvent = e as CustomEvent<{ mensagemId: Id<'mensagens'> }>;
|
const customEvent = e as CustomEvent<{ mensagemId: Id<'mensagens'> }>;
|
||||||
// Buscar informações da mensagem para exibir preview
|
// Buscar informações da mensagem para exibir preview
|
||||||
client.query(api.chat.obterMensagens, { conversaId, limit: 100 }).then((mensagens) => {
|
client.query(api.chat.obterMensagens, { conversaId, limit: 100 }).then((mensagens) => {
|
||||||
const msg = (mensagens as MensagemComRemetente[]).find(
|
const msg = (mensagens as unknown as { mensagens: MensagemComRemetente[] }).mensagens.find(
|
||||||
(m) => m._id === customEvent.detail.mensagemId
|
(m) => m._id === customEvent.detail.mensagemId
|
||||||
);
|
);
|
||||||
if (msg) {
|
if (msg) {
|
||||||
@@ -381,7 +456,9 @@
|
|||||||
|
|
||||||
// Validar tamanho
|
// Validar tamanho
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
alert(`Arquivo muito grande. O tamanho máximo é ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB.`);
|
alert(
|
||||||
|
`Arquivo muito grande. O tamanho máximo é ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB.`
|
||||||
|
);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -428,6 +505,58 @@
|
|||||||
// Sanitizar nome do arquivo (remover caracteres perigosos)
|
// Sanitizar nome do arquivo (remover caracteres perigosos)
|
||||||
const nomeSanitizado = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
const nomeSanitizado = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
|
||||||
|
// Verificar se a conversa tem criptografia E2E e criptografar arquivo se necessário
|
||||||
|
const conversaTemE2E = temCriptografiaE2E?.data ?? false;
|
||||||
|
let arquivoParaUpload: Blob = file;
|
||||||
|
let arquivoCriptografado = false;
|
||||||
|
let arquivoIv: string | undefined;
|
||||||
|
let arquivoKeyId: string | undefined;
|
||||||
|
|
||||||
|
if (conversaTemE2E) {
|
||||||
|
try {
|
||||||
|
// Tentar obter chave de criptografia
|
||||||
|
let encryptionKey = obterChaveCriptografia(conversaId);
|
||||||
|
|
||||||
|
if (!encryptionKey) {
|
||||||
|
encryptionKey = await loadEncryptionKey(conversaId);
|
||||||
|
if (encryptionKey) {
|
||||||
|
armazenarChaveCriptografia(conversaId, encryptionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!encryptionKey) {
|
||||||
|
const chaveDoServidor = await client.query(api.chat.obterChaveCriptografia, {
|
||||||
|
conversaId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (chaveDoServidor?.chaveCompartilhada) {
|
||||||
|
const { importKey } = await import('$lib/utils/e2eEncryption');
|
||||||
|
encryptionKey = await importKey(chaveDoServidor.chaveCompartilhada);
|
||||||
|
|
||||||
|
const keyData = await exportKey(encryptionKey);
|
||||||
|
storeEncryptionKey(conversaId, keyData, chaveDoServidor.keyId);
|
||||||
|
armazenarChaveCriptografia(conversaId, encryptionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encryptionKey) {
|
||||||
|
// Criptografar arquivo
|
||||||
|
const encrypted = await encryptFile(file, encryptionKey);
|
||||||
|
arquivoParaUpload = encrypted.encryptedBlob;
|
||||||
|
arquivoIv = encrypted.iv;
|
||||||
|
arquivoKeyId = encrypted.keyId;
|
||||||
|
arquivoCriptografado = true;
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'⚠️ [MessageInput] Criptografia E2E habilitada mas chave não encontrada. Enviando arquivo sem criptografia.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [MessageInput] Erro ao criptografar arquivo:', error);
|
||||||
|
alert('Erro ao criptografar arquivo. Tentando enviar sem criptografia...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
uploadingFile = true;
|
uploadingFile = true;
|
||||||
uploadProgress = 0;
|
uploadProgress = 0;
|
||||||
@@ -470,8 +599,12 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
xhr.open('POST', uploadUrl);
|
xhr.open('POST', uploadUrl);
|
||||||
xhr.setRequestHeader('Content-Type', file.type);
|
// Se arquivo foi criptografado, usar tipo genérico
|
||||||
xhr.send(file);
|
xhr.setRequestHeader(
|
||||||
|
'Content-Type',
|
||||||
|
arquivoCriptografado ? 'application/octet-stream' : file.type
|
||||||
|
);
|
||||||
|
xhr.send(arquivoParaUpload);
|
||||||
});
|
});
|
||||||
|
|
||||||
const storageId = await uploadPromise;
|
const storageId = await uploadPromise;
|
||||||
@@ -482,10 +615,14 @@
|
|||||||
conversaId,
|
conversaId,
|
||||||
conteudo: tipo === 'imagem' ? '' : nomeSanitizado,
|
conteudo: tipo === 'imagem' ? '' : nomeSanitizado,
|
||||||
tipo,
|
tipo,
|
||||||
arquivoId: storageId,
|
arquivoId: storageId as Id<'_storage'>,
|
||||||
arquivoNome: nomeSanitizado,
|
arquivoNome: nomeSanitizado,
|
||||||
arquivoTamanho: file.size,
|
arquivoTamanho: file.size,
|
||||||
arquivoTipo: file.type
|
arquivoTipo: file.type,
|
||||||
|
// Campos de criptografia E2E para arquivos
|
||||||
|
criptografado: arquivoCriptografado ? true : undefined,
|
||||||
|
iv: arquivoIv,
|
||||||
|
keyId: arquivoKeyId
|
||||||
});
|
});
|
||||||
|
|
||||||
// Limpar input
|
// Limpar input
|
||||||
@@ -562,11 +699,11 @@
|
|||||||
<!-- Barra de progresso do upload -->
|
<!-- Barra de progresso do upload -->
|
||||||
{#if uploadingFile && uploadProgress > 0}
|
{#if uploadingFile && uploadProgress > 0}
|
||||||
<div
|
<div
|
||||||
class="absolute -bottom-1 left-0 right-0 h-1 rounded-full bg-base-200"
|
class="bg-base-200 absolute right-0 -bottom-1 left-0 h-1 rounded-full"
|
||||||
style="z-index: 20;"
|
style="z-index: 20;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full bg-primary transition-all duration-300"
|
class="bg-primary h-full rounded-full transition-all duration-300"
|
||||||
style="width: {uploadProgress}%;"
|
style="width: {uploadProgress}%;"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -605,7 +742,7 @@
|
|||||||
id="emoji-picker"
|
id="emoji-picker"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-10 gap-1" role="grid">
|
<div class="grid grid-cols-10 gap-1" role="grid">
|
||||||
{#each emojis as emoji}
|
{#each emojis as emoji, index (emoji)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="hover:bg-base-200 cursor-pointer rounded p-1 text-2xl transition-transform hover:scale-125"
|
class="hover:bg-base-200 cursor-pointer rounded p-1 text-2xl transition-transform hover:scale-125"
|
||||||
@@ -640,9 +777,11 @@
|
|||||||
aria-invalid={mensagemMuitoLonga}
|
aria-invalid={mensagemMuitoLonga}
|
||||||
></textarea>
|
></textarea>
|
||||||
{#if mensagemMuitoLonga || mensagem.length > MAX_MENSAGEM_LENGTH * 0.9}
|
{#if mensagemMuitoLonga || mensagem.length > MAX_MENSAGEM_LENGTH * 0.9}
|
||||||
<div class="absolute bottom-1 right-2 text-xs {mensagem.length > MAX_MENSAGEM_LENGTH
|
<div
|
||||||
|
class="absolute right-2 bottom-1 text-xs {mensagem.length > MAX_MENSAGEM_LENGTH
|
||||||
? 'text-error'
|
? 'text-error'
|
||||||
: 'text-base-content/50'}">
|
: 'text-base-content/50'}"
|
||||||
|
>
|
||||||
{mensagem.length}/{MAX_MENSAGEM_LENGTH}
|
{mensagem.length}/{MAX_MENSAGEM_LENGTH}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -658,7 +797,8 @@
|
|||||||
{#each participantesFiltrados() as participante, index (participante._id)}
|
{#each participantesFiltrados() as participante, index (participante._id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-2 text-left transition-colors {index === selectedMentionIndex
|
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-2 text-left transition-colors {index ===
|
||||||
|
selectedMentionIndex
|
||||||
? 'bg-primary/20'
|
? 'bg-primary/20'
|
||||||
: ''}"
|
: ''}"
|
||||||
onclick={() => inserirMencao(participante)}
|
onclick={() => inserirMencao(participante)}
|
||||||
@@ -720,6 +860,7 @@
|
|||||||
|
|
||||||
<!-- Informação sobre atalhos -->
|
<!-- Informação sobre atalhos -->
|
||||||
<p id="mensagem-help" class="text-base-content/50 mt-2 text-center text-xs" role="note">
|
<p id="mensagem-help" class="text-base-content/50 mt-2 text-center text-xs" role="note">
|
||||||
💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji • Use @ para mencionar
|
💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji • Use @ para
|
||||||
|
mencionar
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,15 @@
|
|||||||
import { ptBR } from 'date-fns/locale';
|
import { ptBR } from 'date-fns/locale';
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { File, CheckCircle2, CheckCircle, MessageSquare, Bell, X } from 'lucide-svelte';
|
import { File, CheckCircle2, CheckCircle, MessageSquare, Bell, X } from 'lucide-svelte';
|
||||||
import { notificacaoAtiva } from '$lib/stores/chatStore';
|
import { notificacaoAtiva, obterChaveCriptografia, armazenarChaveCriptografia } from '$lib/stores/chatStore';
|
||||||
|
import {
|
||||||
|
decryptMessage,
|
||||||
|
decryptFile,
|
||||||
|
loadEncryptionKey,
|
||||||
|
exportKey,
|
||||||
|
storeEncryptionKey,
|
||||||
|
type EncryptedMessage
|
||||||
|
} from '$lib/utils/e2eEncryption';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversaId: Id<'conversas'>;
|
conversaId: Id<'conversas'>;
|
||||||
@@ -31,27 +39,49 @@
|
|||||||
|
|
||||||
// Atualizar lista de mensagens quando a query mudar
|
// Atualizar lista de mensagens quando a query mudar
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (mensagensQuery?.data) {
|
if (!mensagensQuery?.data) return;
|
||||||
const resultado = mensagensQuery.data as { mensagens: any[]; hasMore: boolean; nextCursor: Id<'mensagens'> | null };
|
|
||||||
|
|
||||||
|
const resultado = mensagensQuery.data as { mensagens: any[]; hasMore: boolean; nextCursor: Id<'mensagens'> | null };
|
||||||
|
const novasMensagens = resultado.mensagens || [];
|
||||||
|
|
||||||
|
// Evitar atualizações desnecessárias comparando IDs das mensagens
|
||||||
if (cursor === null) {
|
if (cursor === null) {
|
||||||
// Primeira carga: substituir todas as mensagens
|
// Primeira carga: verificar se realmente mudou
|
||||||
todasMensagens = resultado.mensagens || [];
|
const idsAtuais = todasMensagens.map(m => m?._id).filter(Boolean);
|
||||||
|
const idsNovos = novasMensagens.map(m => m?._id).filter(Boolean);
|
||||||
|
const idsIguais = idsAtuais.length === idsNovos.length &&
|
||||||
|
idsAtuais.every((id, i) => id === idsNovos[i]);
|
||||||
|
|
||||||
|
if (!idsIguais) {
|
||||||
|
todasMensagens = novasMensagens;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Carregamento adicional: adicionar no início (mensagens mais antigas)
|
// Carregamento adicional: adicionar no início (mensagens mais antigas)
|
||||||
todasMensagens = [...(resultado.mensagens || []), ...todasMensagens];
|
// Verificar se já não foram adicionadas
|
||||||
|
const idsExistentes = new Set(todasMensagens.map(m => m?._id).filter(Boolean));
|
||||||
|
const novasParaAdicionar = novasMensagens.filter(m => m?._id && !idsExistentes.has(m._id));
|
||||||
|
|
||||||
|
if (novasParaAdicionar.length > 0) {
|
||||||
|
todasMensagens = [...novasParaAdicionar, ...todasMensagens];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasMore = resultado.hasMore || false;
|
hasMore = resultado.hasMore || false;
|
||||||
carregandoMais = false;
|
carregandoMais = false;
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resetar quando mudar de conversa
|
// Resetar quando mudar de conversa
|
||||||
|
let conversaIdAnterior = $state<string | null>(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
const conversaIdAtual = String(conversaId);
|
||||||
|
if (conversaIdAnterior !== null && conversaIdAnterior !== conversaIdAtual) {
|
||||||
cursor = null;
|
cursor = null;
|
||||||
todasMensagens = [];
|
todasMensagens = [];
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
|
carregandoMais = false;
|
||||||
|
mensagensComConteudo = [];
|
||||||
|
}
|
||||||
|
conversaIdAnterior = conversaIdAtual;
|
||||||
});
|
});
|
||||||
|
|
||||||
const digitando = useQuery(api.chat.obterDigitando, { conversaId });
|
const digitando = useQuery(api.chat.obterDigitando, { conversaId });
|
||||||
@@ -411,8 +441,216 @@
|
|||||||
site?: string;
|
site?: string;
|
||||||
} | null;
|
} | null;
|
||||||
lidaPor?: Id<'usuarios'>[]; // IDs dos usuários que leram a mensagem
|
lidaPor?: Id<'usuarios'>[]; // IDs dos usuários que leram a mensagem
|
||||||
|
// Campos para criptografia E2E
|
||||||
|
criptografado?: boolean;
|
||||||
|
iv?: string;
|
||||||
|
keyId?: string;
|
||||||
|
// Campo derivado para conteúdo descriptografado (cache)
|
||||||
|
conteudoDescriptografado?: string | null;
|
||||||
|
// Campo derivado para URL de arquivo descriptografado (cache)
|
||||||
|
arquivoUrlDescriptografado?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Função para descriptografar um arquivo
|
||||||
|
async function descriptografarArquivo(
|
||||||
|
mensagem: Mensagem,
|
||||||
|
encryptionKey: CryptoKey
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!mensagem.criptografado || !mensagem.iv || !mensagem.arquivoUrl) {
|
||||||
|
return null; // Arquivo não está criptografado ou não tem URL
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Buscar arquivo do storage
|
||||||
|
const response = await fetch(mensagem.arquivoUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('⚠️ [MessageList] Erro ao buscar arquivo criptografado:', response.statusText);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedBlob = await response.blob();
|
||||||
|
|
||||||
|
// Descriptografar arquivo
|
||||||
|
const decryptedBlob = await decryptFile(encryptedBlob, mensagem.iv, encryptionKey);
|
||||||
|
|
||||||
|
// Criar URL temporária para o arquivo descriptografado
|
||||||
|
const url = URL.createObjectURL(decryptedBlob);
|
||||||
|
return url;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [MessageList] Erro ao descriptografar arquivo:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para descriptografar uma mensagem
|
||||||
|
async function descriptografarMensagem(mensagem: Mensagem): Promise<string | null> {
|
||||||
|
if (!mensagem.criptografado || !mensagem.iv || !mensagem.keyId) {
|
||||||
|
return null; // Mensagem não está criptografada
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Tentar obter chave do store primeiro
|
||||||
|
let encryptionKey = obterChaveCriptografia(conversaId);
|
||||||
|
|
||||||
|
// Se não estiver no store, tentar carregar do localStorage
|
||||||
|
if (!encryptionKey) {
|
||||||
|
encryptionKey = await loadEncryptionKey(conversaId);
|
||||||
|
if (encryptionKey) {
|
||||||
|
armazenarChaveCriptografia(conversaId, encryptionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se ainda não tiver chave, tentar obter do servidor
|
||||||
|
if (!encryptionKey) {
|
||||||
|
const chaveDoServidor = await client.query(api.chat.obterChaveCriptografia, {
|
||||||
|
conversaId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (chaveDoServidor?.chaveCompartilhada) {
|
||||||
|
const { importKey } = await import('$lib/utils/e2eEncryption');
|
||||||
|
encryptionKey = await importKey(chaveDoServidor.chaveCompartilhada);
|
||||||
|
|
||||||
|
// Armazenar chave localmente
|
||||||
|
const keyData = await exportKey(encryptionKey);
|
||||||
|
storeEncryptionKey(conversaId, keyData, chaveDoServidor.keyId);
|
||||||
|
armazenarChaveCriptografia(conversaId, encryptionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!encryptionKey) {
|
||||||
|
console.warn('⚠️ [MessageList] Chave de criptografia não encontrada para descriptografar mensagem');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Descriptografar mensagem
|
||||||
|
const encrypted: EncryptedMessage = {
|
||||||
|
encryptedContent: mensagem.conteudo,
|
||||||
|
iv: mensagem.iv,
|
||||||
|
keyId: mensagem.keyId
|
||||||
|
};
|
||||||
|
|
||||||
|
const decrypted = await decryptMessage(encrypted, encryptionKey);
|
||||||
|
return decrypted;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [MessageList] Erro ao descriptografar mensagem:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usar mensagens processadas (com cache de descriptografia)
|
||||||
|
let mensagensComConteudo = $state<Mensagem[]>([]);
|
||||||
|
|
||||||
|
// Processar mensagens para descriptografar as criptografadas
|
||||||
|
let ultimoProcessamento = $state<string>('');
|
||||||
|
$effect(() => {
|
||||||
|
const mensagens = todasMensagens;
|
||||||
|
|
||||||
|
// Se não há mensagens, limpar e retornar
|
||||||
|
if (!mensagens || mensagens.length === 0) {
|
||||||
|
if (mensagensComConteudo.length > 0) {
|
||||||
|
mensagensComConteudo = [];
|
||||||
|
}
|
||||||
|
ultimoProcessamento = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar hash das mensagens para evitar reprocessamento desnecessário
|
||||||
|
const hashMensagens = mensagens.map(m => `${m._id}-${m.criptografado ? '1' : '0'}`).join('|');
|
||||||
|
if (hashMensagens === ultimoProcessamento) {
|
||||||
|
return; // Já foi processado
|
||||||
|
}
|
||||||
|
|
||||||
|
ultimoProcessamento = hashMensagens;
|
||||||
|
|
||||||
|
const processarMensagens = async () => {
|
||||||
|
const processadas: Mensagem[] = [];
|
||||||
|
|
||||||
|
// Obter chave de criptografia uma vez para todas as mensagens
|
||||||
|
let encryptionKey: CryptoKey | null = null;
|
||||||
|
if (mensagens.some((m) => m?.criptografado)) {
|
||||||
|
encryptionKey = obterChaveCriptografia(conversaId);
|
||||||
|
|
||||||
|
if (!encryptionKey) {
|
||||||
|
encryptionKey = await loadEncryptionKey(conversaId);
|
||||||
|
if (encryptionKey) {
|
||||||
|
armazenarChaveCriptografia(conversaId, encryptionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!encryptionKey) {
|
||||||
|
const chaveDoServidor = await client.query(api.chat.obterChaveCriptografia, {
|
||||||
|
conversaId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (chaveDoServidor?.chaveCompartilhada) {
|
||||||
|
const { importKey } = await import('$lib/utils/e2eEncryption');
|
||||||
|
encryptionKey = await importKey(chaveDoServidor.chaveCompartilhada);
|
||||||
|
|
||||||
|
const keyData = await exportKey(encryptionKey);
|
||||||
|
storeEncryptionKey(conversaId, keyData, chaveDoServidor.keyId);
|
||||||
|
armazenarChaveCriptografia(conversaId, encryptionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msg of mensagens) {
|
||||||
|
if (!msg) continue; // Pular mensagens nulas
|
||||||
|
|
||||||
|
const mensagemProcessada = { ...msg };
|
||||||
|
|
||||||
|
// Se a mensagem está criptografada e ainda não foi descriptografada
|
||||||
|
if (mensagemProcessada.criptografado && encryptionKey) {
|
||||||
|
// Descriptografar conteúdo de texto
|
||||||
|
if (!mensagemProcessada.conteudoDescriptografado && mensagemProcessada.tipo === 'texto') {
|
||||||
|
const decrypted = await descriptografarMensagem(mensagemProcessada);
|
||||||
|
if (decrypted !== null) {
|
||||||
|
mensagemProcessada.conteudoDescriptografado = decrypted;
|
||||||
|
} else {
|
||||||
|
mensagemProcessada.conteudoDescriptografado = '🔒 Não foi possível descriptografar esta mensagem';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Descriptografar arquivo/imagem
|
||||||
|
if (
|
||||||
|
!mensagemProcessada.arquivoUrlDescriptografado &&
|
||||||
|
(mensagemProcessada.tipo === 'arquivo' || mensagemProcessada.tipo === 'imagem')
|
||||||
|
) {
|
||||||
|
const arquivoDescriptografado = await descriptografarArquivo(mensagemProcessada, encryptionKey);
|
||||||
|
if (arquivoDescriptografado) {
|
||||||
|
mensagemProcessada.arquivoUrlDescriptografado = arquivoDescriptografado;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!mensagemProcessada.criptografado) {
|
||||||
|
// Mensagem não criptografada: usar conteúdo original
|
||||||
|
mensagemProcessada.conteudoDescriptografado = mensagemProcessada.conteudo || '';
|
||||||
|
mensagemProcessada.arquivoUrlDescriptografado = mensagemProcessada.arquivoUrl || null;
|
||||||
|
} else {
|
||||||
|
// Já foi processada, manter
|
||||||
|
if (!mensagemProcessada.conteudoDescriptografado) {
|
||||||
|
mensagemProcessada.conteudoDescriptografado = mensagemProcessada.conteudo || '';
|
||||||
|
}
|
||||||
|
if (!mensagemProcessada.arquivoUrlDescriptografado) {
|
||||||
|
mensagemProcessada.arquivoUrlDescriptografado = mensagemProcessada.arquivoUrl || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processadas.push(mensagemProcessada);
|
||||||
|
}
|
||||||
|
|
||||||
|
mensagensComConteudo = processadas;
|
||||||
|
};
|
||||||
|
|
||||||
|
processarMensagens().catch((error) => {
|
||||||
|
console.error('❌ [MessageList] Erro ao processar mensagens:', error);
|
||||||
|
// Em caso de erro, pelo menos mostrar as mensagens sem descriptografia
|
||||||
|
mensagensComConteudo = todasMensagens.map((msg) => ({
|
||||||
|
...msg,
|
||||||
|
conteudoDescriptografado: msg.criptografado ? '🔒 Erro ao descriptografar' : msg.conteudo || '',
|
||||||
|
arquivoUrlDescriptografado: msg.criptografado ? null : msg.arquivoUrl || null
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function agruparMensagensPorDia(msgs: Mensagem[]): Record<string, Mensagem[]> {
|
function agruparMensagensPorDia(msgs: Mensagem[]): Record<string, Mensagem[]> {
|
||||||
const grupos: Record<string, Mensagem[]> = {};
|
const grupos: Record<string, Mensagem[]> = {};
|
||||||
for (const msg of msgs) {
|
for (const msg of msgs) {
|
||||||
@@ -448,7 +686,8 @@
|
|||||||
|
|
||||||
async function editarMensagem(mensagem: Mensagem) {
|
async function editarMensagem(mensagem: Mensagem) {
|
||||||
mensagemEditando = mensagem;
|
mensagemEditando = mensagem;
|
||||||
novoConteudoEditado = mensagem.conteudo;
|
// Usar conteúdo descriptografado se disponível, senão usar original
|
||||||
|
novoConteudoEditado = mensagem.conteudoDescriptografado ?? mensagem.conteudo;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function salvarEdicao() {
|
async function salvarEdicao() {
|
||||||
@@ -587,8 +826,9 @@
|
|||||||
bind:this={messagesContainer}
|
bind:this={messagesContainer}
|
||||||
onscroll={handleScroll}
|
onscroll={handleScroll}
|
||||||
>
|
>
|
||||||
{#if todasMensagens.length > 0}
|
{#if todasMensagens.length > 0 || mensagensComConteudo.length > 0}
|
||||||
{@const gruposPorDia = agruparMensagensPorDia(todasMensagens)}
|
{@const mensagensParaExibir = mensagensComConteudo.length > 0 ? mensagensComConteudo : todasMensagens}
|
||||||
|
{@const gruposPorDia = agruparMensagensPorDia(mensagensParaExibir)}
|
||||||
{#each Object.entries(gruposPorDia) as [dia, mensagensDia]}
|
{#each Object.entries(gruposPorDia) as [dia, mensagensDia]}
|
||||||
<!-- Separador de dia -->
|
<!-- Separador de dia -->
|
||||||
<div class="my-4 flex items-center justify-center">
|
<div class="my-4 flex items-center justify-center">
|
||||||
@@ -684,7 +924,7 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<p class="flex-1 text-sm break-words whitespace-pre-wrap">
|
<p class="flex-1 text-sm break-words whitespace-pre-wrap">
|
||||||
{mensagem.conteudo}
|
{mensagem.conteudoDescriptografado ?? mensagem.conteudo}
|
||||||
</p>
|
</p>
|
||||||
{#if mensagem.editadaEm}
|
{#if mensagem.editadaEm}
|
||||||
<span class="text-xs italic opacity-50" title="Editado">(editado)</span>
|
<span class="text-xs italic opacity-50" title="Editado">(editado)</span>
|
||||||
@@ -732,21 +972,27 @@
|
|||||||
{:else if mensagem.tipo === 'imagem'}
|
{:else if mensagem.tipo === 'imagem'}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<img
|
<img
|
||||||
src={mensagem.arquivoUrl}
|
src={mensagem.arquivoUrlDescriptografado ?? mensagem.arquivoUrl}
|
||||||
alt={mensagem.arquivoNome}
|
alt={mensagem.arquivoNome}
|
||||||
class="max-w-full rounded-lg"
|
class="max-w-full rounded-lg"
|
||||||
|
onerror={(e) => {
|
||||||
|
if (mensagem.criptografado) {
|
||||||
|
(e.target as HTMLImageElement).alt = '🔒 Erro ao descriptografar imagem';
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if mensagem.conteudo}
|
{#if mensagem.conteudo}
|
||||||
<p class="text-sm break-words whitespace-pre-wrap">
|
<p class="text-sm break-words whitespace-pre-wrap">
|
||||||
{mensagem.conteudo}
|
{mensagem.conteudoDescriptografado ?? mensagem.conteudo}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if mensagem.tipo === 'arquivo'}
|
{:else if mensagem.tipo === 'arquivo'}
|
||||||
<a
|
<a
|
||||||
href={mensagem.arquivoUrl}
|
href={mensagem.arquivoUrlDescriptografado ?? mensagem.arquivoUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
download={mensagem.arquivoNome}
|
||||||
class="flex items-center gap-2 hover:opacity-80"
|
class="flex items-center gap-2 hover:opacity-80"
|
||||||
>
|
>
|
||||||
<File class="h-5 w-5" strokeWidth={1.5} />
|
<File class="h-5 w-5" strokeWidth={1.5} />
|
||||||
|
|||||||
@@ -47,3 +47,37 @@ export function abrirConversa(conversaId: Id<'conversas'>) {
|
|||||||
export function voltarParaLista() {
|
export function voltarParaLista() {
|
||||||
conversaAtiva.set(null);
|
conversaAtiva.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== CRIPTOGRAFIA E2E ==========
|
||||||
|
|
||||||
|
// Store para chaves de criptografia por conversa
|
||||||
|
// Mapeia conversaId -> CryptoKey
|
||||||
|
const chavesCriptografia = new Map<string, CryptoKey>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Armazena uma chave de criptografia para uma conversa
|
||||||
|
*/
|
||||||
|
export function armazenarChaveCriptografia(conversaId: string, key: CryptoKey): void {
|
||||||
|
chavesCriptografia.set(conversaId, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém uma chave de criptografia para uma conversa
|
||||||
|
*/
|
||||||
|
export function obterChaveCriptografia(conversaId: string): CryptoKey | null {
|
||||||
|
return chavesCriptografia.get(conversaId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove uma chave de criptografia de uma conversa
|
||||||
|
*/
|
||||||
|
export function removerChaveCriptografia(conversaId: string): void {
|
||||||
|
chavesCriptografia.delete(conversaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se uma conversa tem chave de criptografia
|
||||||
|
*/
|
||||||
|
export function temChaveCriptografia(conversaId: string): boolean {
|
||||||
|
return chavesCriptografia.has(conversaId);
|
||||||
|
}
|
||||||
|
|||||||
301
apps/web/src/lib/utils/e2eEncryption.ts
Normal file
301
apps/web/src/lib/utils/e2eEncryption.ts
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* Utilitário de Criptografia End-to-End (E2E) para mensagens do chat
|
||||||
|
* Usa Web Crypto API com AES-GCM para criptografia simétrica
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface EncryptionKey {
|
||||||
|
key: CryptoKey;
|
||||||
|
keyId: string; // Identificador único da chave
|
||||||
|
createdAt: number; // Timestamp de criação
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncryptedMessage {
|
||||||
|
encryptedContent: string; // Base64
|
||||||
|
iv: string; // Base64
|
||||||
|
keyId: string; // Identificador da chave usada
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera uma chave de criptografia AES-GCM de 256 bits
|
||||||
|
*/
|
||||||
|
export async function generateEncryptionKey(): Promise<EncryptionKey> {
|
||||||
|
const key = await crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
length: 256
|
||||||
|
},
|
||||||
|
true, // extractable
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Gerar um ID único para a chave
|
||||||
|
const keyId = crypto.randomUUID();
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
keyId,
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporta uma chave para formato JSON (armazenamento)
|
||||||
|
*/
|
||||||
|
export async function exportKey(key: CryptoKey): Promise<string> {
|
||||||
|
const exported = await crypto.subtle.exportKey('raw', key);
|
||||||
|
const exportedArray = new Uint8Array(exported);
|
||||||
|
return btoa(String.fromCharCode(...exportedArray));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importa uma chave de formato JSON (armazenamento)
|
||||||
|
*/
|
||||||
|
export async function importKey(keyData: string): Promise<CryptoKey> {
|
||||||
|
const keyArray = Uint8Array.from(atob(keyData), (c) => c.charCodeAt(0));
|
||||||
|
return await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyArray,
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
length: 256
|
||||||
|
},
|
||||||
|
true, // extractable
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Criptografa uma mensagem usando uma chave
|
||||||
|
*/
|
||||||
|
export async function encryptMessage(
|
||||||
|
message: string,
|
||||||
|
key: CryptoKey
|
||||||
|
): Promise<EncryptedMessage> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(message);
|
||||||
|
|
||||||
|
// Gerar IV (Initialization Vector) aleatório de 12 bytes para AES-GCM
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
|
||||||
|
// Criptografar
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: iv
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
|
||||||
|
// Converter para base64 para armazenamento
|
||||||
|
const encryptedArray = new Uint8Array(encrypted);
|
||||||
|
const encryptedBase64 = btoa(String.fromCharCode(...encryptedArray));
|
||||||
|
const ivBase64 = btoa(String.fromCharCode(...iv));
|
||||||
|
|
||||||
|
// Gerar keyId (usaremos o hash da chave exportada como identificador)
|
||||||
|
const keyData = await exportKey(key);
|
||||||
|
const keyId = await hashString(keyData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
encryptedContent: encryptedBase64,
|
||||||
|
iv: ivBase64,
|
||||||
|
keyId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Criptografa um arquivo (Blob) usando uma chave
|
||||||
|
*/
|
||||||
|
export async function encryptFile(
|
||||||
|
file: Blob,
|
||||||
|
key: CryptoKey
|
||||||
|
): Promise<{ encryptedBlob: Blob; iv: string; keyId: string }> {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const data = new Uint8Array(arrayBuffer);
|
||||||
|
|
||||||
|
// Gerar IV (Initialization Vector) aleatório de 12 bytes para AES-GCM
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
|
||||||
|
// Criptografar
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: iv
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
|
||||||
|
// Converter para Blob
|
||||||
|
const encryptedBlob = new Blob([encrypted], { type: 'application/octet-stream' });
|
||||||
|
|
||||||
|
// Converter IV para base64
|
||||||
|
const ivBase64 = btoa(String.fromCharCode(...iv));
|
||||||
|
|
||||||
|
// Gerar keyId
|
||||||
|
const keyData = await exportKey(key);
|
||||||
|
const keyId = await hashString(keyData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
encryptedBlob,
|
||||||
|
iv: ivBase64,
|
||||||
|
keyId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descriptografa um arquivo (Blob) usando uma chave
|
||||||
|
*/
|
||||||
|
export async function decryptFile(
|
||||||
|
encryptedBlob: Blob,
|
||||||
|
iv: string,
|
||||||
|
key: CryptoKey
|
||||||
|
): Promise<Blob> {
|
||||||
|
try {
|
||||||
|
// Decodificar base64 do IV
|
||||||
|
const ivArray = Uint8Array.from(atob(iv), (c) => c.charCodeAt(0));
|
||||||
|
|
||||||
|
// Ler dados criptografados
|
||||||
|
const encryptedArrayBuffer = await encryptedBlob.arrayBuffer();
|
||||||
|
const encrypted = new Uint8Array(encryptedArrayBuffer);
|
||||||
|
|
||||||
|
// Descriptografar
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: ivArray
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
encrypted
|
||||||
|
);
|
||||||
|
|
||||||
|
// Converter para Blob
|
||||||
|
return new Blob([decrypted]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao descriptografar arquivo:', error);
|
||||||
|
throw new Error('Falha ao descriptografar arquivo. A chave pode estar incorreta.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descriptografa uma mensagem usando uma chave
|
||||||
|
*/
|
||||||
|
export async function decryptMessage(
|
||||||
|
encryptedMessage: EncryptedMessage,
|
||||||
|
key: CryptoKey
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Decodificar base64
|
||||||
|
const encryptedArray = Uint8Array.from(
|
||||||
|
atob(encryptedMessage.encryptedContent),
|
||||||
|
(c) => c.charCodeAt(0)
|
||||||
|
);
|
||||||
|
const iv = Uint8Array.from(atob(encryptedMessage.iv), (c) => c.charCodeAt(0));
|
||||||
|
|
||||||
|
// Descriptografar
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: iv
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
encryptedArray
|
||||||
|
);
|
||||||
|
|
||||||
|
// Converter para string
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
return decoder.decode(decrypted);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao descriptografar mensagem:', error);
|
||||||
|
throw new Error('Falha ao descriptografar mensagem. A chave pode estar incorreta.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera hash SHA-256 de uma string (para usar como keyId)
|
||||||
|
*/
|
||||||
|
async function hashString(str: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(str);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = new Uint8Array(hashBuffer);
|
||||||
|
// Retornar apenas os primeiros 16 bytes como hex para keyId mais curto
|
||||||
|
return Array.from(hashArray.slice(0, 16))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Armazena uma chave de criptografia no localStorage
|
||||||
|
*/
|
||||||
|
export function storeEncryptionKey(conversaId: string, keyData: string, keyId: string): void {
|
||||||
|
try {
|
||||||
|
const storageKey = `e2e_key_${conversaId}`;
|
||||||
|
const keyInfo = {
|
||||||
|
keyData,
|
||||||
|
keyId,
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(keyInfo));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao armazenar chave de criptografia:', error);
|
||||||
|
throw new Error('Falha ao armazenar chave de criptografia');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recupera uma chave de criptografia do localStorage
|
||||||
|
*/
|
||||||
|
export function getStoredEncryptionKey(conversaId: string): {
|
||||||
|
keyData: string;
|
||||||
|
keyId: string;
|
||||||
|
createdAt: number;
|
||||||
|
} | null {
|
||||||
|
try {
|
||||||
|
const storageKey = `e2e_key_${conversaId}`;
|
||||||
|
const stored = localStorage.getItem(storageKey);
|
||||||
|
if (!stored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(stored) as { keyData: string; keyId: string; createdAt: number };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao recuperar chave de criptografia:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove uma chave de criptografia do localStorage
|
||||||
|
*/
|
||||||
|
export function removeStoredEncryptionKey(conversaId: string): void {
|
||||||
|
try {
|
||||||
|
const storageKey = `e2e_key_${conversaId}`;
|
||||||
|
localStorage.removeItem(storageKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao remover chave de criptografia:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se uma conversa tem criptografia E2E habilitada
|
||||||
|
*/
|
||||||
|
export function hasEncryptionKey(conversaId: string): boolean {
|
||||||
|
return getStoredEncryptionKey(conversaId) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carrega e importa uma chave de criptografia do localStorage
|
||||||
|
*/
|
||||||
|
export async function loadEncryptionKey(conversaId: string): Promise<CryptoKey | null> {
|
||||||
|
const stored = getStoredEncryptionKey(conversaId);
|
||||||
|
if (!stored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await importKey(stored.keyData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao importar chave de criptografia:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -436,3 +436,6 @@ export function adicionarRodape(doc: jsPDF): void {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -75,3 +75,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -101,15 +101,22 @@
|
|||||||
carregando = true;
|
carregando = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!authStore.token) {
|
// Construir objeto de argumentos, incluindo token apenas se existir
|
||||||
throw new Error('Token não encontrado');
|
const args: {
|
||||||
}
|
senhaAtual: string;
|
||||||
|
novaSenha: string;
|
||||||
const resultado = await convex.mutation(api.autenticacao.alterarSenha, {
|
token?: string;
|
||||||
token: authStore.token,
|
} = {
|
||||||
senhaAtual: senhaAtual,
|
senhaAtual: senhaAtual,
|
||||||
novaSenha: novaSenha
|
novaSenha: novaSenha
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Adicionar token apenas se existir
|
||||||
|
if (authStore.token) {
|
||||||
|
args.token = authStore.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultado = await convex.mutation(api.autenticacao.alterarSenha, args);
|
||||||
|
|
||||||
if (resultado.sucesso) {
|
if (resultado.sucesso) {
|
||||||
notice = {
|
notice = {
|
||||||
|
|||||||
@@ -19,49 +19,27 @@
|
|||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { formatarHoraPonto, getTipoRegistroLabel, formatarDataDDMMAAAA } from '$lib/utils/ponto';
|
import { formatarHoraPonto, getTipoRegistroLabel, formatarDataDDMMAAAA } from '$lib/utils/ponto';
|
||||||
import {
|
import { type SectionsPDF } from '$lib/utils/fichaPontoPDF';
|
||||||
adicionarLogo,
|
|
||||||
adicionarCabecalho,
|
|
||||||
adicionarDadosFuncionario,
|
|
||||||
adicionarResumoPeriodo,
|
|
||||||
adicionarSaldosPeriodo,
|
|
||||||
adicionarLegenda,
|
|
||||||
adicionarRodape,
|
|
||||||
verificarNovaPagina,
|
|
||||||
type SectionsPDF,
|
|
||||||
type FuncionarioPDF,
|
|
||||||
type ConfigPontoPDF
|
|
||||||
} from '$lib/utils/fichaPontoPDF';
|
|
||||||
// Importar módulos extraídos
|
// Importar módulos extraídos
|
||||||
import type { TipoDia, SaldoDiario, RegistroPonto, DiaFichaPonto, ResumoPeriodo } from '$lib/utils/ponto/tipos';
|
import {
|
||||||
import { formatarDataParaExibicao, formatarDataParaBackend, formatarSaldoHoras, formatarSaldoDiario, formatarMinutos, formatarHoras } from '$lib/utils/ponto/formatacao';
|
formatarDataParaExibicao,
|
||||||
import { validarPeriodo } from '$lib/utils/ponto/validacao';
|
formatarDataParaBackend,
|
||||||
import { calcularSaldosParciais, calcularSaldoDiario, calcularSaldosPorPar, calcularSaldoComparativoPorPar } from '$lib/utils/ponto/calculos';
|
formatarSaldoHoras
|
||||||
import { agruparRegistrosPorFuncionario, gerarDiasPeriodo, gerarRegistrosEsperados, processarDadosFichaPonto } from '$lib/utils/ponto/processamento';
|
} from '$lib/utils/ponto/formatacao';
|
||||||
import { registroFoiMarcado } from '$lib/utils/ponto/validacao';
|
import { calcularSaldosParciais } from '$lib/utils/ponto/calculos';
|
||||||
|
import { agruparRegistrosPorFuncionario } from '$lib/utils/ponto/processamento';
|
||||||
import { gerarPDFComSelecao } from '$lib/utils/ponto/pdf/geradorPDF';
|
import { gerarPDFComSelecao } from '$lib/utils/ponto/pdf/geradorPDF';
|
||||||
import { imprimirDetalhesRegistro } from '$lib/utils/ponto/pdf/geradorDetalhesPDF';
|
import { imprimirDetalhesRegistro } from '$lib/utils/ponto/pdf/geradorDetalhesPDF';
|
||||||
import HeaderRegistroPontos from '$lib/components/ponto/registro-pontos/HeaderRegistroPontos.svelte';
|
|
||||||
import EstatisticasCards from '$lib/components/ponto/registro-pontos/EstatisticasCards.svelte';
|
|
||||||
import GraficoEstatisticas from '$lib/components/ponto/registro-pontos/GraficoEstatisticas.svelte';
|
|
||||||
import { maskDate, validateDate, onlyDigits } from '$lib/utils/masks';
|
import { maskDate, validateDate, onlyDigits } from '$lib/utils/masks';
|
||||||
import LocalizacaoIcon from '$lib/components/ponto/LocalizacaoIcon.svelte';
|
import LocalizacaoIcon from '$lib/components/ponto/LocalizacaoIcon.svelte';
|
||||||
import SaldoDiarioBadge from '$lib/components/ponto/SaldoDiarioBadge.svelte';
|
import SaldoDiarioBadge from '$lib/components/ponto/SaldoDiarioBadge.svelte';
|
||||||
import SaldoDiarioComparativoBadge from '$lib/components/ponto/SaldoDiarioComparativoBadge.svelte';
|
import SaldoDiarioComparativoBadge from '$lib/components/ponto/SaldoDiarioComparativoBadge.svelte';
|
||||||
import jsPDF from 'jspdf';
|
|
||||||
import autoTable from 'jspdf-autotable';
|
|
||||||
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||||
import PrintPontoModal from '$lib/components/ponto/PrintPontoModal.svelte';
|
import PrintPontoModal from '$lib/components/ponto/PrintPontoModal.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { Chart, registerables } from 'chart.js';
|
import { Chart, registerables } from 'chart.js';
|
||||||
import Papa from 'papaparse';
|
import Papa from 'papaparse';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -72,8 +50,7 @@
|
|||||||
// Estados
|
// Estados
|
||||||
// Expandir período padrão para últimos 30 dias para facilitar visualização
|
// Expandir período padrão para últimos 30 dias para facilitar visualização
|
||||||
const hoje = new Date();
|
const hoje = new Date();
|
||||||
const trintaDiasAtras = new Date(hoje);
|
const trintaDiasAtras = new Date(hoje.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
trintaDiasAtras.setDate(hoje.getDate() - 30);
|
|
||||||
|
|
||||||
// Funções de formatação importadas de $lib/utils/ponto/formatacao
|
// Funções de formatação importadas de $lib/utils/ponto/formatacao
|
||||||
// Wrapper para formatarDataParaBackend que precisa de onlyDigits e validateDate
|
// Wrapper para formatarDataParaBackend que precisa de onlyDigits e validateDate
|
||||||
@@ -85,8 +62,8 @@
|
|||||||
let dataFimInterno = $state(hoje.toISOString().split('T')[0]!);
|
let dataFimInterno = $state(hoje.toISOString().split('T')[0]!);
|
||||||
|
|
||||||
// Valores para exibição (dd/mm/yyyy)
|
// Valores para exibição (dd/mm/yyyy)
|
||||||
let dataInicioExibicao = $state(formatarDataParaExibicao(dataInicioInterno));
|
let dataInicioExibicao = $derived(formatarDataParaExibicao(dataInicioInterno));
|
||||||
let dataFimExibicao = $state(formatarDataParaExibicao(dataFimInterno));
|
let dataFimExibicao = $derived(formatarDataParaExibicao(dataFimInterno));
|
||||||
|
|
||||||
// Valores para backend (yyyy-mm-dd) - derivados dos valores internos
|
// Valores para backend (yyyy-mm-dd) - derivados dos valores internos
|
||||||
const dataInicio = $derived(dataInicioInterno);
|
const dataInicio = $derived(dataInicioInterno);
|
||||||
@@ -94,6 +71,7 @@
|
|||||||
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
|
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
|
||||||
let statusFiltro = $state<'todos' | 'dentro' | 'fora'>('todos');
|
let statusFiltro = $state<'todos' | 'dentro' | 'fora'>('todos');
|
||||||
let localizacaoFiltro = $state<'todos' | 'dentro' | 'fora'>('todos');
|
let localizacaoFiltro = $state<'todos' | 'dentro' | 'fora'>('todos');
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
let carregando = $state(false);
|
let carregando = $state(false);
|
||||||
let mostrarModalImpressao = $state(false);
|
let mostrarModalImpressao = $state(false);
|
||||||
let funcionarioParaImprimir = $state<Id<'funcionarios'> | ''>('');
|
let funcionarioParaImprimir = $state<Id<'funcionarios'> | ''>('');
|
||||||
@@ -105,7 +83,7 @@
|
|||||||
funcionarioParaImprimir = funcionarioId;
|
funcionarioParaImprimir = funcionarioId;
|
||||||
mostrarModalImpressao = true;
|
mostrarModalImpressao = true;
|
||||||
};
|
};
|
||||||
let chartCanvas: HTMLCanvasElement;
|
let chartCanvas = $state<HTMLCanvasElement | undefined>(undefined);
|
||||||
let chartInstance: Chart | null = null;
|
let chartInstance: Chart | null = null;
|
||||||
|
|
||||||
// Parâmetros reativos para queries
|
// Parâmetros reativos para queries
|
||||||
@@ -125,7 +103,10 @@
|
|||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||||
|
// useQuery do Convex-Svelte lida corretamente com valores $derived reativos
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosParams);
|
const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosParams);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, estatisticasParams);
|
const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, estatisticasParams);
|
||||||
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
||||||
|
|
||||||
@@ -143,11 +124,12 @@
|
|||||||
// Debug: Log dos dados recebidos
|
// Debug: Log dos dados recebidos
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (registrosQuery !== undefined) {
|
if (registrosQuery !== undefined) {
|
||||||
|
const params = registrosParams;
|
||||||
console.log('[Frontend] registrosQuery:', {
|
console.log('[Frontend] registrosQuery:', {
|
||||||
isLoading: registrosQuery?.isLoading,
|
isLoading: registrosQuery?.isLoading,
|
||||||
error: registrosQuery?.error,
|
error: registrosQuery?.error,
|
||||||
dataLength: registrosQuery?.data?.length ?? 0,
|
dataLength: registrosQuery?.data?.length ?? 0,
|
||||||
params: registrosParams
|
params
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (registros && registros.length > 0) {
|
if (registros && registros.length > 0) {
|
||||||
@@ -186,7 +168,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = chartCanvas.getContext('2d');
|
const ctx = chartCanvas?.getContext('2d');
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -229,6 +211,9 @@
|
|||||||
label: function (context) {
|
label: function (context) {
|
||||||
const label = context.dataset.label || '';
|
const label = context.dataset.label || '';
|
||||||
const value = context.parsed.y;
|
const value = context.parsed.y;
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return `${label}: 0 (0.0%)`;
|
||||||
|
}
|
||||||
const total = estatisticas.totalRegistros;
|
const total = estatisticas.totalRegistros;
|
||||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
|
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
|
||||||
return `${label}: ${value} (${percentage}%)`;
|
return `${label}: ${value} (${percentage}%)`;
|
||||||
@@ -606,43 +591,6 @@
|
|||||||
|
|
||||||
// Funções formatarMinutos e formatarHoras importadas de $lib/utils/ponto/formatacao
|
// Funções formatarMinutos e formatarHoras importadas de $lib/utils/ponto/formatacao
|
||||||
|
|
||||||
/**
|
|
||||||
* Obter nome do dia da semana em português
|
|
||||||
*/
|
|
||||||
function obterDiaSemana(data: string): string {
|
|
||||||
const dias = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado'];
|
|
||||||
const dataObj = new Date(data + 'T00:00:00');
|
|
||||||
return dias[dataObj.getDay()] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obter cor de fundo baseado no tipo de dia
|
|
||||||
*/
|
|
||||||
function obterCorFundo(tipoDia: TipoDia, temInconsistencia: boolean): string {
|
|
||||||
if (temInconsistencia) return '#FFF3E0'; // Laranja claro
|
|
||||||
if (tipoDia === 'atestado') return '#E3F2FD'; // Azul claro
|
|
||||||
if (tipoDia === 'ausencia') return '#FFF9C4'; // Amarelo claro
|
|
||||||
if (tipoDia === 'abonado') return '#E8F5E9'; // Verde claro
|
|
||||||
if (tipoDia === 'nao_computado' || tipoDia === 'ferias') return '#F5F5F5'; // Cinza claro
|
|
||||||
return '#FFFFFF'; // Branco
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obter ícone baseado no tipo de dia
|
|
||||||
*/
|
|
||||||
function obterIconeTipo(tipoDia: TipoDia): string {
|
|
||||||
const icones: Record<TipoDia, string> = {
|
|
||||||
normal: '',
|
|
||||||
atestado: '🏥',
|
|
||||||
ausencia: '🚫',
|
|
||||||
licenca: '📋',
|
|
||||||
abonado: '✅',
|
|
||||||
nao_computado: '⏸',
|
|
||||||
ferias: '🏖',
|
|
||||||
inconsistente: '⚠'
|
|
||||||
};
|
|
||||||
return icones[tipoDia] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Função validarPeriodo importada de $lib/utils/ponto/validacao
|
// Função validarPeriodo importada de $lib/utils/ponto/validacao
|
||||||
|
|
||||||
@@ -1085,8 +1033,7 @@
|
|||||||
// Função para limpar todos os filtros
|
// Função para limpar todos os filtros
|
||||||
function limparFiltros() {
|
function limparFiltros() {
|
||||||
const hoje = new Date();
|
const hoje = new Date();
|
||||||
const trintaDiasAtras = new Date(hoje);
|
const trintaDiasAtras = new Date(hoje.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
trintaDiasAtras.setDate(hoje.getDate() - 30);
|
|
||||||
|
|
||||||
const dataInicioStr = trintaDiasAtras.toISOString().split('T')[0]!;
|
const dataInicioStr = trintaDiasAtras.toISOString().split('T')[0]!;
|
||||||
const dataFimStr = hoje.toISOString().split('T')[0]!;
|
const dataFimStr = hoje.toISOString().split('T')[0]!;
|
||||||
@@ -1149,8 +1096,7 @@
|
|||||||
// Gerar CSV usando Papa Parse
|
// Gerar CSV usando Papa Parse
|
||||||
const csv = Papa.unparse(csvData, {
|
const csv = Papa.unparse(csvData, {
|
||||||
header: true,
|
header: true,
|
||||||
delimiter: ';',
|
delimiter: ';'
|
||||||
encoding: 'UTF-8'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Adicionar BOM para Excel reconhecer UTF-8 corretamente
|
// Adicionar BOM para Excel reconhecer UTF-8 corretamente
|
||||||
@@ -1199,7 +1145,9 @@
|
|||||||
funcionarioParaImprimir = '';
|
funcionarioParaImprimir = '';
|
||||||
toast.success('PDF gerado com sucesso!');
|
toast.success('PDF gerado com sucesso!');
|
||||||
},
|
},
|
||||||
(value: boolean) => { carregando = value; }
|
(value: boolean) => {
|
||||||
|
carregando = value;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3070,11 +3018,8 @@
|
|||||||
|
|
||||||
// Wrapper para a função importada imprimirDetalhesRegistro
|
// Wrapper para a função importada imprimirDetalhesRegistro
|
||||||
async function imprimirDetalhesRegistroWrapper(registroId: Id<'registrosPonto'>) {
|
async function imprimirDetalhesRegistroWrapper(registroId: Id<'registrosPonto'>) {
|
||||||
await imprimirDetalhesRegistro(
|
await imprimirDetalhesRegistro(client, registroId, logoGovPE, (message: string) =>
|
||||||
client,
|
toast.error(message)
|
||||||
registroId,
|
|
||||||
logoGovPE,
|
|
||||||
(message: string) => toast.error(message)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4491,7 +4436,7 @@
|
|||||||
class="select select-bordered select-sm focus:select-primary"
|
class="select select-bordered select-sm focus:select-primary"
|
||||||
>
|
>
|
||||||
<option value="">Todos os funcionários</option>
|
<option value="">Todos os funcionários</option>
|
||||||
{#each funcionarios as funcionario}
|
{#each funcionarios as funcionario (funcionario._id)}
|
||||||
<option value={funcionario._id}>{funcionario.nome}</option>
|
<option value={funcionario._id}>{funcionario.nome}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -4628,7 +4573,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#each registrosAgrupados as grupo}
|
{#each registrosAgrupados as grupo (grupo.funcionarioId)}
|
||||||
<div
|
<div
|
||||||
class="card bg-base-100 border-base-300 border shadow-md transition-all duration-200 hover:shadow-lg"
|
class="card bg-base-100 border-base-300 border shadow-md transition-all duration-200 hover:shadow-lg"
|
||||||
>
|
>
|
||||||
@@ -4758,13 +4703,20 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each Object.values(grupo.registrosPorData) as grupoData, dataIndex}
|
{#each Object.values(grupo.registrosPorData) as grupoData, dataIndex (grupoData.data)}
|
||||||
{@const totalRegistros = grupoData.registros.length}
|
{@const totalRegistros = grupoData.registros.length}
|
||||||
{@const dataFormatada = formatarDataDDMMAAAA(grupoData.data)}
|
{@const dataFormatada = formatarDataDDMMAAAA(grupoData.data)}
|
||||||
{@const saldosParciais = calcularSaldosParciais(grupoData.registros)}
|
{@const saldosParciais = calcularSaldosParciais(
|
||||||
|
grupoData.registros.map((r) => ({
|
||||||
|
tipo: r.tipo,
|
||||||
|
hora: r.hora,
|
||||||
|
minuto: r.minuto,
|
||||||
|
_id: r._id
|
||||||
|
}))
|
||||||
|
)}
|
||||||
{@const isUltimoDia =
|
{@const isUltimoDia =
|
||||||
dataIndex === Object.values(grupo.registrosPorData).length - 1}
|
dataIndex === Object.values(grupo.registrosPorData).length - 1}
|
||||||
{#each grupoData.registros as registro, index}
|
{#each grupoData.registros as registro, index (registro._id)}
|
||||||
{@const saldoParcial = saldosParciais.get(index)}
|
{@const saldoParcial = saldosParciais.get(index)}
|
||||||
<tr
|
<tr
|
||||||
class="hover:bg-base-200/50 transition-colors {dataIndex % 2 === 0
|
class="hover:bg-base-200/50 transition-colors {dataIndex % 2 === 0
|
||||||
@@ -4874,14 +4826,16 @@
|
|||||||
class="modal modal-open"
|
class="modal modal-open"
|
||||||
onclick={(e) => e.target === e.currentTarget && fecharModalDetalhes()}
|
onclick={(e) => e.target === e.currentTarget && fecharModalDetalhes()}
|
||||||
>
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="modal-box flex max-h-[90vh] max-w-4xl flex-col overflow-hidden"
|
class="modal-box flex max-h-[90vh] max-w-4xl flex-col overflow-hidden"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
role="document"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="border-base-300 mb-4 flex flex-shrink-0 items-center justify-between border-b pb-4"
|
class="border-base-300 mb-4 flex flex-shrink-0 items-center justify-between border-b pb-4"
|
||||||
>
|
>
|
||||||
<h3 class="text-xl font-bold">Detalhes do Registro de Ponto</h3>
|
<h3 id="modal-title" class="text-xl font-bold">Detalhes do Registro de Ponto</h3>
|
||||||
<button class="btn btn-sm btn-circle btn-ghost" onclick={fecharModalDetalhes}>
|
<button class="btn btn-sm btn-circle btn-ghost" onclick={fecharModalDetalhes}>
|
||||||
<X class="h-4 w-4" />
|
<X class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -5034,10 +4988,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-base-300 mt-4 flex flex-shrink-0 justify-end gap-2 border-t pt-4">
|
<div class="border-base-300 mt-4 flex flex-shrink-0 justify-end gap-2 border-t pt-4">
|
||||||
{#if registroDetalhes}
|
{#if registroDetalhes && registroDetalhesId}
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary gap-2"
|
class="btn btn-primary gap-2"
|
||||||
onclick={() => imprimirDetalhesRegistroWrapper(registroDetalhesId)}
|
onclick={() => {
|
||||||
|
if (registroDetalhesId) {
|
||||||
|
imprimirDetalhesRegistroWrapper(registroDetalhesId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Printer class="h-4 w-4" />
|
<Printer class="h-4 w-4" />
|
||||||
Imprimir PDF
|
Imprimir PDF
|
||||||
@@ -5046,6 +5004,17 @@
|
|||||||
<button class="btn btn-outline" onclick={fecharModalDetalhes}>Fechar</button>
|
<button class="btn btn-outline" onclick={fecharModalDetalhes}>Fechar</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog" class="modal-backdrop" onclick={fecharModalDetalhes}></form>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="modal-backdrop"
|
||||||
|
onclick={fecharModalDetalhes}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
fecharModalDetalhes();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Fechar modal"
|
||||||
|
></button>
|
||||||
</dialog>
|
</dialog>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -75,3 +75,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { authComponent } from './auth';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Alterar senha do usuário autenticado
|
* Alterar senha do usuário autenticado
|
||||||
|
* Token é opcional - autenticação é feita via contexto do Convex
|
||||||
*/
|
*/
|
||||||
export const alterarSenha = mutation({
|
export const alterarSenha = mutation({
|
||||||
args: {
|
args: {
|
||||||
token: v.string(), // Token não é usado, mas mantido para compatibilidade
|
|
||||||
senhaAtual: v.string(),
|
senhaAtual: v.string(),
|
||||||
novaSenha: v.string()
|
novaSenha: v.string(),
|
||||||
|
token: v.optional(v.string()) // Token opcional - não é usado, mas mantido para compatibilidade
|
||||||
},
|
},
|
||||||
returns: v.union(
|
returns: v.union(
|
||||||
v.object({ sucesso: v.literal(true) }),
|
v.object({ sucesso: v.literal(true) }),
|
||||||
@@ -44,14 +45,12 @@ export const alterarSenha = mutation({
|
|||||||
return {
|
return {
|
||||||
sucesso: true as const
|
sucesso: true as const
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
// Capturar erros específicos do Better Auth
|
// Capturar erros específicos do Better Auth
|
||||||
let mensagemErro = 'Erro ao alterar senha';
|
let mensagemErro = 'Erro ao alterar senha';
|
||||||
|
|
||||||
if (error?.message) {
|
if (error instanceof Error && 'message' in error) {
|
||||||
mensagemErro = error.message;
|
mensagemErro = error.message;
|
||||||
} else if (typeof error === 'string') {
|
|
||||||
mensagemErro = error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mensagens de erro mais amigáveis
|
// Mensagens de erro mais amigáveis
|
||||||
@@ -71,3 +70,4 @@ export const alterarSenha = mutation({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Token agora é opcional - Convex deve recompilar
|
||||||
|
|||||||
@@ -261,7 +261,11 @@ export const enviarMensagem = mutation({
|
|||||||
arquivoTipo: v.optional(v.string()),
|
arquivoTipo: v.optional(v.string()),
|
||||||
mencoes: v.optional(v.array(v.id('usuarios'))),
|
mencoes: v.optional(v.array(v.id('usuarios'))),
|
||||||
respostaPara: v.optional(v.id('mensagens')), // ID da mensagem que está respondendo
|
respostaPara: v.optional(v.id('mensagens')), // ID da mensagem que está respondendo
|
||||||
permitirNotificacaoParaSiMesmo: v.optional(v.boolean()) // ✅ NOVO: Permite criar notificação para si mesmo
|
permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo
|
||||||
|
// Campos para criptografia E2E
|
||||||
|
criptografado: v.optional(v.boolean()), // Indica se a mensagem está criptografada
|
||||||
|
iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia
|
||||||
|
keyId: v.optional(v.string()) // Identificador da chave usada para criptografar
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||||
@@ -342,7 +346,8 @@ export const enviarMensagem = mutation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Normalizar conteúdo para busca (remover acentos, lowercase)
|
// Normalizar conteúdo para busca (remover acentos, lowercase)
|
||||||
const conteudoBusca = normalizarTextoParaBusca(args.conteudo);
|
// Se a mensagem estiver criptografada, não criar índice de busca (conteudoBusca será undefined)
|
||||||
|
const conteudoBusca = args.criptografado ? undefined : normalizarTextoParaBusca(args.conteudo);
|
||||||
|
|
||||||
// Verificar se é resposta a outra mensagem
|
// Verificar se é resposta a outra mensagem
|
||||||
if (args.respostaPara) {
|
if (args.respostaPara) {
|
||||||
@@ -373,7 +378,11 @@ export const enviarMensagem = mutation({
|
|||||||
mencoes: args.mencoes,
|
mencoes: args.mencoes,
|
||||||
respostaPara: args.respostaPara,
|
respostaPara: args.respostaPara,
|
||||||
enviadaEm: Date.now(),
|
enviadaEm: Date.now(),
|
||||||
lidaPor: [] // Inicializar como array vazio
|
lidaPor: [], // Inicializar como array vazio
|
||||||
|
// Campos de criptografia E2E
|
||||||
|
criptografado: args.criptografado ?? false,
|
||||||
|
iv: args.iv,
|
||||||
|
keyId: args.keyId
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto, assíncrono)
|
// Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto, assíncrono)
|
||||||
@@ -396,8 +405,12 @@ export const enviarMensagem = mutation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Atualizar última mensagem da conversa
|
// Atualizar última mensagem da conversa
|
||||||
|
// Se a mensagem estiver criptografada, usar placeholder
|
||||||
|
const ultimaMensagemTexto = args.criptografado
|
||||||
|
? '🔒 Mensagem criptografada'
|
||||||
|
: args.conteudo.substring(0, 100);
|
||||||
await ctx.db.patch(args.conversaId, {
|
await ctx.db.patch(args.conversaId, {
|
||||||
ultimaMensagem: args.conteudo.substring(0, 100),
|
ultimaMensagem: ultimaMensagemTexto,
|
||||||
ultimaMensagemTimestamp: Date.now(),
|
ultimaMensagemTimestamp: Date.now(),
|
||||||
ultimaMensagemRemetenteId: usuarioAtual._id // Guardar ID do remetente da última mensagem
|
ultimaMensagemRemetenteId: usuarioAtual._id // Guardar ID do remetente da última mensagem
|
||||||
});
|
});
|
||||||
@@ -943,7 +956,10 @@ export const limparNotificacoesNaoLidas = mutation({
|
|||||||
export const editarMensagem = mutation({
|
export const editarMensagem = mutation({
|
||||||
args: {
|
args: {
|
||||||
mensagemId: v.id('mensagens'),
|
mensagemId: v.id('mensagens'),
|
||||||
novoConteudo: v.string()
|
novoConteudo: v.string(),
|
||||||
|
// Campos para criptografia E2E (opcionais, apenas se a mensagem for criptografada)
|
||||||
|
iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia
|
||||||
|
keyId: v.optional(v.string()) // Identificador da chave usada para criptografar
|
||||||
},
|
},
|
||||||
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -988,15 +1004,32 @@ export const editarMensagem = mutation({
|
|||||||
return { sucesso: false, erro: 'O conteúdo da mensagem não pode estar vazio' };
|
return { sucesso: false, erro: 'O conteúdo da mensagem não pode estar vazio' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalizar conteúdo para busca
|
// Normalizar conteúdo para busca (se não estiver criptografado)
|
||||||
const conteudoBusca = normalizarTextoParaBusca(args.novoConteudo);
|
// Se a mensagem original estava criptografada, manter criptografado
|
||||||
|
const estaCriptografada = mensagem.criptografado ?? false;
|
||||||
|
const conteudoBusca = estaCriptografada ? undefined : normalizarTextoParaBusca(args.novoConteudo);
|
||||||
|
|
||||||
// Atualizar mensagem
|
// Atualizar mensagem
|
||||||
await ctx.db.patch(args.mensagemId, {
|
// Se a mensagem estava criptografada e novos campos de criptografia foram fornecidos, atualizar
|
||||||
|
const updateData: {
|
||||||
|
conteudo: string;
|
||||||
|
conteudoBusca?: string;
|
||||||
|
editadaEm: number;
|
||||||
|
iv?: string;
|
||||||
|
keyId?: string;
|
||||||
|
} = {
|
||||||
conteudo: args.novoConteudo.trim(),
|
conteudo: args.novoConteudo.trim(),
|
||||||
conteudoBusca,
|
conteudoBusca,
|
||||||
editadaEm: Date.now()
|
editadaEm: Date.now()
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Se a mensagem estava criptografada e novos campos foram fornecidos, atualizar
|
||||||
|
if (estaCriptografada && (args.iv || args.keyId)) {
|
||||||
|
if (args.iv) updateData.iv = args.iv;
|
||||||
|
if (args.keyId) updateData.keyId = args.keyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.mensagemId, updateData);
|
||||||
|
|
||||||
return { sucesso: true };
|
return { sucesso: true };
|
||||||
}
|
}
|
||||||
@@ -1183,18 +1216,39 @@ export const adicionarParticipanteSala = mutation({
|
|||||||
participantes: novosParticipantes
|
participantes: novosParticipantes
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Verificar se a conversa tem criptografia E2E ativa
|
||||||
|
const chaveE2E = await ctx.db
|
||||||
|
.query('chavesCriptografia')
|
||||||
|
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
|
||||||
|
.first();
|
||||||
|
|
||||||
// Criar notificação para o novo participante
|
// Criar notificação para o novo participante
|
||||||
|
const agora = Date.now();
|
||||||
await ctx.db.insert('notificacoes', {
|
await ctx.db.insert('notificacoes', {
|
||||||
usuarioId: args.participanteId,
|
usuarioId: args.participanteId,
|
||||||
tipo: 'adicionado_grupo',
|
tipo: 'adicionado_grupo',
|
||||||
conversaId: args.conversaId,
|
conversaId: args.conversaId,
|
||||||
remetenteId: usuarioAtual._id,
|
remetenteId: usuarioAtual._id,
|
||||||
titulo: 'Adicionado a sala de reunião',
|
titulo: 'Adicionado a sala de reunião',
|
||||||
descricao: `Você foi adicionado à sala de reunião "${conversa.nome || 'Sem nome'}" por ${usuarioAtual.nome}`,
|
descricao: `Você foi adicionado à sala de reunião "${conversa.nome || 'Sem nome'}" por ${usuarioAtual.nome}${chaveE2E ? '. Esta conversa usa criptografia E2E - você receberá a chave automaticamente.' : ''}`,
|
||||||
lida: false,
|
lida: false,
|
||||||
criadaEm: Date.now()
|
criadaEm: agora
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Se a conversa tem E2E ativo, notificar sobre a chave
|
||||||
|
if (chaveE2E) {
|
||||||
|
await ctx.db.insert('notificacoes', {
|
||||||
|
usuarioId: args.participanteId,
|
||||||
|
tipo: 'alerta_seguranca',
|
||||||
|
conversaId: args.conversaId,
|
||||||
|
remetenteId: usuarioAtual._id,
|
||||||
|
titulo: 'Chave de criptografia E2E disponível',
|
||||||
|
descricao: `Esta conversa usa criptografia end-to-end. A chave foi compartilhada com você automaticamente.`,
|
||||||
|
lida: false,
|
||||||
|
criadaEm: agora
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { sucesso: true };
|
return { sucesso: true };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1818,7 +1872,7 @@ export const obterMensagens = query({
|
|||||||
|
|
||||||
// Enriquecer com informações do remetente e mensagem respondida
|
// Enriquecer com informações do remetente e mensagem respondida
|
||||||
const mensagensEnriquecidas = await Promise.all(
|
const mensagensEnriquecidas = await Promise.all(
|
||||||
mensagensParaRetornar.map(async (mensagem) => {
|
mensagensFiltradas.map(async (mensagem) => {
|
||||||
const remetente = await ctx.db.get(mensagem.remetenteId);
|
const remetente = await ctx.db.get(mensagem.remetenteId);
|
||||||
|
|
||||||
// SEGURANÇA: Não retornar informações de remetente se não for participante
|
// SEGURANÇA: Não retornar informações de remetente se não for participante
|
||||||
@@ -2509,3 +2563,144 @@ export const limparIndicadoresDigitacao = internalMutation({
|
|||||||
return indicadoresAntigos.length;
|
return indicadoresAntigos.length;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== CRIPTOGRAFIA E2E ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compartilha uma chave de criptografia E2E para uma conversa
|
||||||
|
* A chave deve ser criptografada antes de ser enviada (usando chave pública do servidor ou outro método)
|
||||||
|
*/
|
||||||
|
export const compartilharChaveCriptografia = mutation({
|
||||||
|
args: {
|
||||||
|
conversaId: v.id('conversas'),
|
||||||
|
chaveCompartilhada: v.string(), // Chave criptografada (base64)
|
||||||
|
keyId: v.string() // Identificador único da chave
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||||
|
if (!usuarioAtual) {
|
||||||
|
throw new Error('Não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se usuário pertence à conversa
|
||||||
|
const conversa = await ctx.db.get(args.conversaId);
|
||||||
|
if (!conversa) {
|
||||||
|
throw new Error('Conversa não encontrada');
|
||||||
|
}
|
||||||
|
if (!conversa.participantes.includes(usuarioAtual._id)) {
|
||||||
|
throw new Error('Você não pertence a esta conversa');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desativar chaves antigas da conversa
|
||||||
|
const chavesAntigas = await ctx.db
|
||||||
|
.query('chavesCriptografia')
|
||||||
|
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const chaveAntiga of chavesAntigas) {
|
||||||
|
await ctx.db.patch(chaveAntiga._id, { ativo: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar nova chave
|
||||||
|
await ctx.db.insert('chavesCriptografia', {
|
||||||
|
conversaId: args.conversaId,
|
||||||
|
chaveCompartilhada: args.chaveCompartilhada,
|
||||||
|
keyId: args.keyId,
|
||||||
|
criadoPor: usuarioAtual._id,
|
||||||
|
criadoEm: Date.now(),
|
||||||
|
ativo: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar notificações para outros participantes sobre a ativação/regeneração da chave
|
||||||
|
const agora = Date.now();
|
||||||
|
for (const participanteId of conversa.participantes) {
|
||||||
|
if (participanteId !== usuarioAtual._id) {
|
||||||
|
await ctx.db.insert('notificacoes', {
|
||||||
|
usuarioId: participanteId,
|
||||||
|
tipo: 'alerta_seguranca',
|
||||||
|
conversaId: args.conversaId,
|
||||||
|
remetenteId: usuarioAtual._id,
|
||||||
|
titulo: 'Criptografia E2E atualizada',
|
||||||
|
descricao: `${usuarioAtual.nome} ${chavesAntigas.length > 0 ? 'regenerou' : 'ativou'} a criptografia end-to-end nesta conversa. Suas mensagens futuras serão criptografadas.`,
|
||||||
|
lida: false,
|
||||||
|
criadaEm: agora
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sucesso: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém a chave de criptografia ativa para uma conversa
|
||||||
|
*/
|
||||||
|
export const obterChaveCriptografia = query({
|
||||||
|
args: {
|
||||||
|
conversaId: v.id('conversas')
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||||
|
if (!usuarioAtual) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se usuário pertence à conversa
|
||||||
|
const conversa = await ctx.db.get(args.conversaId);
|
||||||
|
if (!conversa) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!conversa.participantes.includes(usuarioAtual._id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar chave ativa
|
||||||
|
const chave = await ctx.db
|
||||||
|
.query('chavesCriptografia')
|
||||||
|
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!chave) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chaveCompartilhada: chave.chaveCompartilhada,
|
||||||
|
keyId: chave.keyId,
|
||||||
|
criadoPor: chave.criadoPor,
|
||||||
|
criadoEm: chave.criadoEm
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se uma conversa tem criptografia E2E habilitada
|
||||||
|
*/
|
||||||
|
export const verificarCriptografiaE2E = query({
|
||||||
|
args: {
|
||||||
|
conversaId: v.id('conversas')
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||||
|
if (!usuarioAtual) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se usuário pertence à conversa
|
||||||
|
const conversa = await ctx.db.get(args.conversaId);
|
||||||
|
if (!conversa) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!conversa.participantes.includes(usuarioAtual._id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se existe chave ativa
|
||||||
|
const chave = await ctx.db
|
||||||
|
.query('chavesCriptografia')
|
||||||
|
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return chave !== null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,12 +23,16 @@ export const chatTables = {
|
|||||||
conversaId: v.id('conversas'),
|
conversaId: v.id('conversas'),
|
||||||
remetenteId: v.id('usuarios'),
|
remetenteId: v.id('usuarios'),
|
||||||
tipo: v.union(v.literal('texto'), v.literal('arquivo'), v.literal('imagem')),
|
tipo: v.union(v.literal('texto'), v.literal('arquivo'), v.literal('imagem')),
|
||||||
conteudo: v.string(), // texto ou nome do arquivo
|
conteudo: v.string(), // texto ou nome do arquivo (pode ser criptografado)
|
||||||
conteudoBusca: v.optional(v.string()), // versão normalizada para busca
|
conteudoBusca: v.optional(v.string()), // versão normalizada para busca (não criptografada se mensagem for E2E)
|
||||||
arquivoId: v.optional(v.id('_storage')),
|
arquivoId: v.optional(v.id('_storage')),
|
||||||
arquivoNome: v.optional(v.string()),
|
arquivoNome: v.optional(v.string()),
|
||||||
arquivoTamanho: v.optional(v.number()),
|
arquivoTamanho: v.optional(v.number()),
|
||||||
arquivoTipo: v.optional(v.string()),
|
arquivoTipo: v.optional(v.string()),
|
||||||
|
// Campos para criptografia E2E
|
||||||
|
criptografado: v.optional(v.boolean()), // Indica se a mensagem está criptografada
|
||||||
|
iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia
|
||||||
|
keyId: v.optional(v.string()), // Identificador da chave usada para criptografar
|
||||||
linkPreview: v.optional(
|
linkPreview: v.optional(
|
||||||
v.object({
|
v.object({
|
||||||
url: v.string(),
|
url: v.string(),
|
||||||
@@ -169,5 +173,17 @@ export const chatTables = {
|
|||||||
atualizadoEm: v.number()
|
atualizadoEm: v.number()
|
||||||
})
|
})
|
||||||
.index('by_usuario_conversa', ['usuarioId', 'conversaId'])
|
.index('by_usuario_conversa', ['usuarioId', 'conversaId'])
|
||||||
.index('by_conversa', ['conversaId'])
|
.index('by_conversa', ['conversaId']),
|
||||||
|
|
||||||
|
// Chaves de Criptografia E2E por Conversa
|
||||||
|
chavesCriptografia: defineTable({
|
||||||
|
conversaId: v.id('conversas'),
|
||||||
|
chaveCompartilhada: v.string(), // Chave criptografada compartilhada (base64)
|
||||||
|
keyId: v.string(), // Identificador único da chave
|
||||||
|
criadoPor: v.id('usuarios'), // Usuário que criou/compartilhou a chave
|
||||||
|
criadoEm: v.number(),
|
||||||
|
ativo: v.boolean() // Se a chave está ativa (permite rotação de chaves)
|
||||||
|
})
|
||||||
|
.index('by_conversa', ['conversaId', 'ativo'])
|
||||||
|
.index('by_key_id', ['keyId'])
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user