Merge pull request #39 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
This commit is contained in:
@@ -5,6 +5,49 @@
|
|||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
|
<!-- Polyfill BlobBuilder ANTES de qualquer código JavaScript -->
|
||||||
|
<!-- IMPORTANTE: Este script DEVE ser executado antes de qualquer módulo JavaScript -->
|
||||||
|
<script>
|
||||||
|
// Executar IMEDIATAMENTE, de forma síncrona e bloqueante
|
||||||
|
// Não usar IIFE assíncrona, executar direto no escopo global
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Verificar se já foi adicionado
|
||||||
|
if (!window.__blobBuilderPolyfillAdded) {
|
||||||
|
// Implementar BlobBuilder usando Blob moderno
|
||||||
|
function BlobBuilder() {
|
||||||
|
this.parts = [];
|
||||||
|
}
|
||||||
|
BlobBuilder.prototype.append = function(data) {
|
||||||
|
this.parts.push(data);
|
||||||
|
};
|
||||||
|
BlobBuilder.prototype.getBlob = function(contentType) {
|
||||||
|
return new Blob(this.parts, contentType ? { type: contentType } : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Adicionar em TODOS os locais possíveis, SEM verificação
|
||||||
|
// (sobrescrever se necessário para garantir que esteja disponível)
|
||||||
|
window.BlobBuilder = BlobBuilder;
|
||||||
|
window.WebKitBlobBuilder = BlobBuilder;
|
||||||
|
window.MozBlobBuilder = BlobBuilder;
|
||||||
|
window.MSBlobBuilder = BlobBuilder;
|
||||||
|
|
||||||
|
// Adicionar no globalThis também
|
||||||
|
if (typeof globalThis !== 'undefined') {
|
||||||
|
globalThis.BlobBuilder = BlobBuilder;
|
||||||
|
globalThis.WebKitBlobBuilder = BlobBuilder;
|
||||||
|
globalThis.MozBlobBuilder = BlobBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marcar como adicionado
|
||||||
|
window.__blobBuilderPolyfillAdded = true;
|
||||||
|
|
||||||
|
// Log apenas se console está disponível
|
||||||
|
if (console && console.log) {
|
||||||
|
console.log('✅ Polyfill BlobBuilder adicionado globalmente (via app.html)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
|||||||
@@ -153,12 +153,13 @@
|
|||||||
const chamadaQuery = useQuery(api.chamadas.obterChamada, { chamadaId });
|
const chamadaQuery = useQuery(api.chamadas.obterChamada, { chamadaId });
|
||||||
const chamada = $derived(chamadaQuery?.data);
|
const chamada = $derived(chamadaQuery?.data);
|
||||||
const meuPerfil = useQuery(api.auth.getCurrentUser, {});
|
const meuPerfil = useQuery(api.auth.getCurrentUser, {});
|
||||||
|
const configJitsiBackend = useQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||||
|
|
||||||
// Estado derivado do store
|
// Estado derivado do store
|
||||||
const estadoChamada = $derived(get(callState));
|
const estadoChamada = $derived(get(callState));
|
||||||
|
|
||||||
// Configuração Jitsi
|
// Configuração Jitsi (busca do backend primeiro, depois fallback para env vars)
|
||||||
const configJitsi = $derived.by(() => obterConfiguracaoJitsi());
|
const configJitsi = $derived.by(() => obterConfiguracaoJitsi(configJitsiBackend?.data || null));
|
||||||
|
|
||||||
// Handler de erro
|
// Handler de erro
|
||||||
function handleError(message: string, details?: string): void {
|
function handleError(message: string, details?: string): void {
|
||||||
@@ -175,8 +176,68 @@
|
|||||||
if (!browser || JitsiMeetJS) return;
|
if (!browser || JitsiMeetJS) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('🔄 Tentando carregar lib-jitsi-meet...');
|
||||||
|
|
||||||
|
// Polyfill BlobBuilder já deve estar disponível via app.html
|
||||||
|
// Verificar se está disponível antes de carregar a biblioteca
|
||||||
|
if (typeof (window as any).BlobBuilder === 'undefined') {
|
||||||
|
console.warn('⚠️ Polyfill BlobBuilder não encontrado, pode causar erros');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentar carregar o módulo lib-jitsi-meet dinamicamente
|
||||||
|
// Usar import dinâmico para evitar problemas de SSR e permitir carregamento apenas no browser
|
||||||
const module = await import('lib-jitsi-meet');
|
const module = await import('lib-jitsi-meet');
|
||||||
JitsiMeetJS = module.default as unknown as JitsiMeetJSLib;
|
|
||||||
|
console.log('📦 Módulo carregado, verificando exportações...', {
|
||||||
|
hasDefault: !!module.default,
|
||||||
|
hasJitsiMeetJS: !!module.JitsiMeetJS,
|
||||||
|
keys: Object.keys(module)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tentar múltiplas formas de acessar o JitsiMeetJS
|
||||||
|
// A biblioteca pode exportar de diferentes formas dependendo da versão
|
||||||
|
let jitsiModule: unknown = null;
|
||||||
|
|
||||||
|
// Tentativa 1: export default
|
||||||
|
if (module.default) {
|
||||||
|
if (typeof module.default === 'object' && 'init' in module.default) {
|
||||||
|
jitsiModule = module.default;
|
||||||
|
console.log('✅ Encontrado em module.default');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentativa 2: export nomeado JitsiMeetJS
|
||||||
|
if (!jitsiModule && module.JitsiMeetJS) {
|
||||||
|
jitsiModule = module.JitsiMeetJS;
|
||||||
|
console.log('✅ Encontrado em module.JitsiMeetJS');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentativa 3: o próprio módulo pode ser o JitsiMeetJS
|
||||||
|
if (!jitsiModule && typeof module === 'object' && 'init' in module) {
|
||||||
|
jitsiModule = module;
|
||||||
|
console.log('✅ Encontrado no próprio módulo');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jitsiModule) {
|
||||||
|
throw new Error(
|
||||||
|
'Não foi possível encontrar JitsiMeetJS no módulo. ' +
|
||||||
|
'Verifique se lib-jitsi-meet está instalado corretamente.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JitsiMeetJS = jitsiModule as unknown as JitsiMeetJSLib;
|
||||||
|
|
||||||
|
// Verificar se JitsiMeetJS foi inicializado corretamente
|
||||||
|
if (!JitsiMeetJS || !JitsiMeetJS.init || typeof JitsiMeetJS.init !== 'function') {
|
||||||
|
throw new Error('JitsiMeetJS não possui método init válido');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se JitsiConnection existe
|
||||||
|
if (!JitsiMeetJS.JitsiConnection) {
|
||||||
|
throw new Error('JitsiConnection não está disponível no módulo carregado');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔧 Inicializando Jitsi Meet JS...');
|
||||||
|
|
||||||
// Inicializar Jitsi
|
// Inicializar Jitsi
|
||||||
JitsiMeetJS.init({
|
JitsiMeetJS.init({
|
||||||
@@ -188,18 +249,37 @@
|
|||||||
disableThirdPartyRequests: false
|
disableThirdPartyRequests: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configurar nível de log para DEBUG em desenvolvimento
|
// Configurar nível de log
|
||||||
|
if (JitsiMeetJS.setLogLevel && typeof JitsiMeetJS.setLogLevel === 'function') {
|
||||||
|
if (JitsiMeetJS.constants && JitsiMeetJS.constants.logLevels) {
|
||||||
JitsiMeetJS.setLogLevel(JitsiMeetJS.constants.logLevels.INFO);
|
JitsiMeetJS.setLogLevel(JitsiMeetJS.constants.logLevels.INFO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('✅ Jitsi Meet JS carregado e inicializado');
|
console.log('✅ Jitsi Meet JS carregado e inicializado com sucesso');
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error('Erro ao carregar lib-jitsi-meet:', error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('❌ Erro ao carregar lib-jitsi-meet:', error);
|
||||||
|
console.error('Detalhes do erro:', {
|
||||||
|
message: errorMessage,
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verificar se é um erro de módulo não encontrado
|
||||||
|
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Cannot find module')) {
|
||||||
|
handleError(
|
||||||
|
'Biblioteca de vídeo não encontrada',
|
||||||
|
'A biblioteca Jitsi não pôde ser encontrada. Verifique se o pacote "lib-jitsi-meet" está instalado. Se o problema persistir, tente limpar o cache do navegador e recarregar a página.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
handleError(
|
handleError(
|
||||||
'Erro ao carregar biblioteca de vídeo',
|
'Erro ao carregar biblioteca de vídeo',
|
||||||
'Não foi possível carregar a biblioteca necessária para chamadas de vídeo. Por favor, recarregue a página.'
|
`Não foi possível carregar a biblioteca necessária para chamadas de vídeo. Erro: ${errorMessage}. Por favor, recarregue a página e tente novamente.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Inicializar store
|
// Inicializar store
|
||||||
function inicializarStore(): void {
|
function inicializarStore(): void {
|
||||||
@@ -855,6 +935,12 @@
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
|
// Polyfill BlobBuilder já deve estar disponível via app.html
|
||||||
|
// Verificar se está disponível
|
||||||
|
if (typeof (window as any).BlobBuilder === 'undefined') {
|
||||||
|
console.warn('⚠️ Polyfill BlobBuilder não encontrado no onMount');
|
||||||
|
}
|
||||||
|
|
||||||
// Inicializar store primeiro
|
// Inicializar store primeiro
|
||||||
inicializarStore();
|
inicializarStore();
|
||||||
|
|
||||||
|
|||||||
@@ -190,9 +190,51 @@
|
|||||||
const informacoesDispositivo = await obterInformacoesDispositivo();
|
const informacoesDispositivo = await obterInformacoesDispositivo();
|
||||||
coletandoInfo = false;
|
coletandoInfo = false;
|
||||||
|
|
||||||
// Obter tempo sincronizado
|
// Obter tempo sincronizado e aplicar GMT offset (igual ao relógio)
|
||||||
const timestamp = await obterTempoServidor(client);
|
const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
||||||
const sincronizadoComServidor = true; // Sempre true quando usamos obterTempoServidor
|
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
|
||||||
|
const gmtOffset = configRelogio.gmtOffset ?? 0;
|
||||||
|
|
||||||
|
let timestampBase: number;
|
||||||
|
|
||||||
|
if (configRelogio.usarServidorExterno) {
|
||||||
|
try {
|
||||||
|
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||||
|
if (resultado.sucesso && resultado.timestamp) {
|
||||||
|
timestampBase = resultado.timestamp;
|
||||||
|
} else {
|
||||||
|
timestampBase = await obterTempoServidor(client);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao sincronizar com servidor externo:', error);
|
||||||
|
if (configRelogio.fallbackParaPC) {
|
||||||
|
timestampBase = Date.now();
|
||||||
|
} else {
|
||||||
|
timestampBase = await obterTempoServidor(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Usar relógio do PC (sem sincronização com servidor)
|
||||||
|
timestampBase = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplicar GMT offset ao timestamp
|
||||||
|
// Quando GMT é 0, compensar o timezone local do navegador para que o timestamp
|
||||||
|
// represente o horário local (não UTC), evitando que apareça 3 horas a mais
|
||||||
|
let timestamp: number;
|
||||||
|
if (gmtOffset !== 0) {
|
||||||
|
// Aplicar offset configurado
|
||||||
|
timestamp = timestampBase + (gmtOffset * 60 * 60 * 1000);
|
||||||
|
} else {
|
||||||
|
// Quando GMT = 0, ajustar para horário local do navegador
|
||||||
|
// getTimezoneOffset() retorna minutos POSITIVOS para fusos ATRÁS de UTC
|
||||||
|
// Exemplo: Brasil (UTC-3) retorna 180 minutos
|
||||||
|
// Subtrair esses minutos para que o timestamp represente o horário local
|
||||||
|
const timezoneOffset = new Date().getTimezoneOffset(); // Offset em minutos
|
||||||
|
timestamp = timestampBase - (timezoneOffset * 60 * 1000); // Subtrair minutos em milissegundos
|
||||||
|
}
|
||||||
|
// Sincronizado apenas se usar servidor externo e sincronização foi bem-sucedida
|
||||||
|
const sincronizadoComServidor = configRelogio.usarServidorExterno && timestampBase !== Date.now();
|
||||||
|
|
||||||
// Upload da imagem (obrigatória agora)
|
// Upload da imagem (obrigatória agora)
|
||||||
let imagemId: Id<'_storage'> | undefined = undefined;
|
let imagemId: Id<'_storage'> | undefined = undefined;
|
||||||
@@ -275,9 +317,47 @@
|
|||||||
// Se capturou a foto, mostrar modal de confirmação
|
// Se capturou a foto, mostrar modal de confirmação
|
||||||
if (blob && capturandoAutomaticamente) {
|
if (blob && capturandoAutomaticamente) {
|
||||||
capturandoAutomaticamente = false;
|
capturandoAutomaticamente = false;
|
||||||
// Obter data e hora sincronizada do servidor
|
// Obter data e hora sincronizada do servidor com GMT offset (igual ao relógio)
|
||||||
try {
|
try {
|
||||||
const timestamp = await obterTempoServidor(client);
|
const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
||||||
|
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
|
||||||
|
const gmtOffset = configRelogio.gmtOffset ?? 0;
|
||||||
|
|
||||||
|
let timestampBase: number;
|
||||||
|
|
||||||
|
if (configRelogio.usarServidorExterno) {
|
||||||
|
try {
|
||||||
|
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||||
|
if (resultado.sucesso && resultado.timestamp) {
|
||||||
|
timestampBase = resultado.timestamp;
|
||||||
|
} else {
|
||||||
|
timestampBase = await obterTempoServidor(client);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao sincronizar com servidor externo:', error);
|
||||||
|
if (configRelogio.fallbackParaPC) {
|
||||||
|
timestampBase = Date.now();
|
||||||
|
} else {
|
||||||
|
timestampBase = await obterTempoServidor(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Usar relógio do PC (sem sincronização com servidor)
|
||||||
|
timestampBase = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplicar GMT offset ao timestamp
|
||||||
|
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
|
||||||
|
// Quando GMT ≠ 0, aplicar offset configurado ao timestamp
|
||||||
|
let timestamp: number;
|
||||||
|
if (gmtOffset !== 0) {
|
||||||
|
// Aplicar offset configurado
|
||||||
|
timestamp = timestampBase + (gmtOffset * 60 * 60 * 1000);
|
||||||
|
} else {
|
||||||
|
// Quando GMT = 0, manter timestamp UTC puro
|
||||||
|
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
|
||||||
|
timestamp = timestampBase;
|
||||||
|
}
|
||||||
const dataObj = new Date(timestamp);
|
const dataObj = new Date(timestamp);
|
||||||
const data = dataObj.toLocaleDateString('pt-BR');
|
const data = dataObj.toLocaleDateString('pt-BR');
|
||||||
const hora = dataObj.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
const hora = dataObj.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
async function atualizarTempo() {
|
async function atualizarTempo() {
|
||||||
try {
|
try {
|
||||||
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
||||||
|
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
|
||||||
|
// Se não estiver configurado, usar null e tratar como 0
|
||||||
const gmtOffset = config.gmtOffset ?? 0;
|
const gmtOffset = config.gmtOffset ?? 0;
|
||||||
|
|
||||||
let timestampBase: number;
|
let timestampBase: number;
|
||||||
@@ -45,16 +47,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Usar tempo do servidor Convex
|
// Usar relógio do PC (sem sincronização com servidor)
|
||||||
timestampBase = await obterTempoServidor(client);
|
timestampBase = obterTempoPC();
|
||||||
sincronizado = true;
|
sincronizado = false;
|
||||||
usandoServidorExterno = false;
|
usandoServidorExterno = false;
|
||||||
erro = null;
|
erro = 'Usando relógio do PC';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aplicar GMT offset ao timestamp
|
// Aplicar GMT offset ao timestamp
|
||||||
// O timestamp está em UTC, adicionar o offset em horas
|
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
|
||||||
const timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000);
|
// Quando GMT ≠ 0, aplicar offset configurado ao timestamp
|
||||||
|
let timestampAjustado: number;
|
||||||
|
if (gmtOffset !== 0) {
|
||||||
|
// Aplicar offset configurado
|
||||||
|
timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000);
|
||||||
|
} else {
|
||||||
|
// Quando GMT = 0, manter timestamp UTC puro
|
||||||
|
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
|
||||||
|
timestampAjustado = timestampBase;
|
||||||
|
}
|
||||||
tempoAtual = new Date(timestampAjustado);
|
tempoAtual = new Date(timestampAjustado);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao obter tempo:', error);
|
console.error('Erro ao obter tempo:', error);
|
||||||
@@ -120,7 +131,7 @@
|
|||||||
<span class="text-warning">{erro}</span>
|
<span class="text-warning">{erro}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<Clock class="h-4 w-4 text-base-content/50" />
|
<Clock class="h-4 w-4 text-base-content/50" />
|
||||||
<span class="text-base-content/50">Sincronizando...</span>
|
<span class="text-base-content/50">Usando relógio do PC</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface ConfiguracaoJitsi {
|
|||||||
appId: string;
|
appId: string;
|
||||||
roomPrefix: string;
|
roomPrefix: string;
|
||||||
useHttps: boolean;
|
useHttps: boolean;
|
||||||
|
acceptSelfSignedCert?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DispositivoMedia {
|
export interface DispositivoMedia {
|
||||||
@@ -22,9 +23,50 @@ export interface DispositivosDisponiveis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obter configuração do Jitsi baseada em variáveis de ambiente
|
* Obter configuração do Jitsi do backend ou variáveis de ambiente (fallback)
|
||||||
|
*
|
||||||
|
* @param configBackend - Configuração do backend (opcional). Se fornecida, será usada.
|
||||||
|
* @returns Configuração do Jitsi
|
||||||
*/
|
*/
|
||||||
export function obterConfiguracaoJitsi(): ConfiguracaoJitsi {
|
export function obterConfiguracaoJitsi(configBackend?: {
|
||||||
|
domain: string;
|
||||||
|
appId: string;
|
||||||
|
roomPrefix: string;
|
||||||
|
useHttps: boolean;
|
||||||
|
acceptSelfSignedCert?: boolean;
|
||||||
|
} | null): ConfiguracaoJitsi {
|
||||||
|
// Se há configuração do backend e está ativa, usar ela
|
||||||
|
if (configBackend) {
|
||||||
|
return {
|
||||||
|
domain: configBackend.domain,
|
||||||
|
appId: configBackend.appId,
|
||||||
|
roomPrefix: configBackend.roomPrefix,
|
||||||
|
useHttps: configBackend.useHttps,
|
||||||
|
acceptSelfSignedCert: configBackend.acceptSelfSignedCert || false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback para variáveis de ambiente
|
||||||
|
const domain = import.meta.env.VITE_JITSI_DOMAIN || 'localhost:8443';
|
||||||
|
const appId = import.meta.env.VITE_JITSI_APP_ID || 'sgse-app';
|
||||||
|
const roomPrefix = import.meta.env.VITE_JITSI_ROOM_PREFIX || 'sgse';
|
||||||
|
const useHttps = import.meta.env.VITE_JITSI_USE_HTTPS === 'true' || domain.includes(':8443');
|
||||||
|
const acceptSelfSignedCert = import.meta.env.VITE_JITSI_ACCEPT_SELF_SIGNED === 'true';
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
appId,
|
||||||
|
roomPrefix,
|
||||||
|
useHttps,
|
||||||
|
acceptSelfSignedCert
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter configuração do Jitsi de forma síncrona (apenas variáveis de ambiente)
|
||||||
|
* Use esta função quando não houver acesso ao Convex client
|
||||||
|
*/
|
||||||
|
export function obterConfiguracaoJitsiSync(): ConfiguracaoJitsi {
|
||||||
const domain = import.meta.env.VITE_JITSI_DOMAIN || 'localhost:8443';
|
const domain = import.meta.env.VITE_JITSI_DOMAIN || 'localhost:8443';
|
||||||
const appId = import.meta.env.VITE_JITSI_APP_ID || 'sgse-app';
|
const appId = import.meta.env.VITE_JITSI_APP_ID || 'sgse-app';
|
||||||
const roomPrefix = import.meta.env.VITE_JITSI_ROOM_PREFIX || 'sgse';
|
const roomPrefix = import.meta.env.VITE_JITSI_ROOM_PREFIX || 'sgse';
|
||||||
@@ -49,9 +91,19 @@ export function obterHostEPorta(domain: string): { host: string; porta: number }
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gerar nome único para a sala Jitsi
|
* Gerar nome único para a sala Jitsi
|
||||||
|
*
|
||||||
|
* @param conversaId - ID da conversa
|
||||||
|
* @param tipo - Tipo de chamada ('audio' ou 'video')
|
||||||
|
* @param configBackend - Configuração do backend (opcional). Se não fornecida, usa fallback.
|
||||||
*/
|
*/
|
||||||
export function gerarRoomName(conversaId: string, tipo: 'audio' | 'video'): string {
|
export function gerarRoomName(
|
||||||
const config = obterConfiguracaoJitsi();
|
conversaId: string,
|
||||||
|
tipo: 'audio' | 'video',
|
||||||
|
configBackend?: {
|
||||||
|
roomPrefix: string;
|
||||||
|
} | null
|
||||||
|
): string {
|
||||||
|
const config = obterConfiguracaoJitsi(configBackend || undefined);
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const random = Math.random().toString(36).substring(2, 9);
|
const random = Math.random().toString(36).substring(2, 9);
|
||||||
const conversaHash = conversaId.replace(/[^a-zA-Z0-9]/g, '').substring(0, 10);
|
const conversaHash = conversaId.replace(/[^a-zA-Z0-9]/g, '').substring(0, 10);
|
||||||
@@ -61,9 +113,18 @@ export function gerarRoomName(conversaId: string, tipo: 'audio' | 'video'): stri
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Obter URL completa da sala Jitsi
|
* Obter URL completa da sala Jitsi
|
||||||
|
*
|
||||||
|
* @param roomName - Nome da sala Jitsi
|
||||||
|
* @param configBackend - Configuração do backend (opcional). Se não fornecida, usa fallback.
|
||||||
*/
|
*/
|
||||||
export function obterUrlSala(roomName: string): string {
|
export function obterUrlSala(
|
||||||
const config = obterConfiguracaoJitsi();
|
roomName: string,
|
||||||
|
configBackend?: {
|
||||||
|
domain: string;
|
||||||
|
useHttps: boolean;
|
||||||
|
} | null
|
||||||
|
): string {
|
||||||
|
const config = obterConfiguracaoJitsi(configBackend || undefined);
|
||||||
const protocol = config.useHttps ? 'https' : 'http';
|
const protocol = config.useHttps ? 'https' : 'http';
|
||||||
return `${protocol}://${config.domain}/${roomName}`;
|
return `${protocol}://${config.domain}/${roomName}`;
|
||||||
}
|
}
|
||||||
|
|||||||
82
apps/web/src/lib/utils/jitsiPolyfill.ts
Normal file
82
apps/web/src/lib/utils/jitsiPolyfill.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Polyfill global para BlobBuilder
|
||||||
|
* Deve ser executado ANTES de qualquer import de lib-jitsi-meet
|
||||||
|
*
|
||||||
|
* BlobBuilder é uma API antiga dos navegadores que foi substituída pelo construtor Blob
|
||||||
|
* A biblioteca lib-jitsi-meet pode tentar usar BlobBuilder em navegadores modernos
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function adicionarBlobBuilderPolyfill(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// Verificar se já foi adicionado (evitar múltiplas execuções)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if ((window as any).__blobBuilderPolyfillAdded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implementar BlobBuilder usando Blob moderno
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const BlobBuilderClass = class BlobBuilder {
|
||||||
|
private parts: BlobPart[] = [];
|
||||||
|
|
||||||
|
append(data: BlobPart): void {
|
||||||
|
this.parts.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBlob(contentType?: string): Blob {
|
||||||
|
return new Blob(this.parts, contentType ? { type: contentType } : undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Adicionar em todos os possíveis locais onde a biblioteca pode procurar
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const win = window as any;
|
||||||
|
|
||||||
|
// Definir BlobBuilder se não existir
|
||||||
|
if (typeof win.BlobBuilder === 'undefined') {
|
||||||
|
win.BlobBuilder = BlobBuilderClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variantes de navegadores antigos
|
||||||
|
if (typeof win.WebKitBlobBuilder === 'undefined') {
|
||||||
|
win.WebKitBlobBuilder = BlobBuilderClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof win.MozBlobBuilder === 'undefined') {
|
||||||
|
win.MozBlobBuilder = BlobBuilderClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof win.MSBlobBuilder === 'undefined') {
|
||||||
|
win.MSBlobBuilder = BlobBuilderClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar no global scope
|
||||||
|
if (typeof globalThis !== 'undefined') {
|
||||||
|
if (typeof (globalThis as any).BlobBuilder === 'undefined') {
|
||||||
|
(globalThis as any).BlobBuilder = BlobBuilderClass;
|
||||||
|
}
|
||||||
|
if (typeof (globalThis as any).WebKitBlobBuilder === 'undefined') {
|
||||||
|
(globalThis as any).WebKitBlobBuilder = BlobBuilderClass;
|
||||||
|
}
|
||||||
|
if (typeof (globalThis as any).MozBlobBuilder === 'undefined') {
|
||||||
|
(globalThis as any).MozBlobBuilder = BlobBuilderClass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marcar que o polyfill foi adicionado
|
||||||
|
win.__blobBuilderPolyfillAdded = true;
|
||||||
|
|
||||||
|
console.log('✅ Polyfill BlobBuilder adicionado globalmente');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executar imediatamente se estiver no browser
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
adicionarBlobBuilderPolyfill();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -28,13 +28,14 @@
|
|||||||
|
|
||||||
// Parâmetros reativos para queries
|
// Parâmetros reativos para queries
|
||||||
const registrosParams = $derived({
|
const registrosParams = $derived({
|
||||||
funcionarioId: funcionarioIdFiltro || undefined,
|
funcionarioId: funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined,
|
||||||
dataInicio,
|
dataInicio,
|
||||||
dataFim,
|
dataFim,
|
||||||
});
|
});
|
||||||
const estatisticasParams = $derived({
|
const estatisticasParams = $derived({
|
||||||
dataInicio,
|
dataInicio,
|
||||||
dataFim,
|
dataFim,
|
||||||
|
funcionarioId: funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
@@ -176,6 +177,12 @@
|
|||||||
// Inicializar gráfico quando canvas e dados estiverem disponíveis
|
// Inicializar gráfico quando canvas e dados estiverem disponíveis
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (chartCanvas && estatisticas && chartData) {
|
if (chartCanvas && estatisticas && chartData) {
|
||||||
|
// Destruir gráfico anterior se existir
|
||||||
|
if (chartInstance) {
|
||||||
|
chartInstance.destroy();
|
||||||
|
chartInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Aguardar um pouco para garantir que o canvas está renderizado
|
// Aguardar um pouco para garantir que o canvas está renderizado
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
criarGrafico();
|
criarGrafico();
|
||||||
@@ -1748,7 +1755,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Gráfico de Estatísticas -->
|
<!-- Gráfico de Estatísticas -->
|
||||||
{#if estatisticas}
|
|
||||||
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl mb-8">
|
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl mb-8">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
@@ -1760,16 +1766,41 @@
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-80 w-full relative rounded-xl bg-base-200/50 p-4 border border-base-300">
|
<div class="h-80 w-full relative rounded-xl bg-base-200/50 p-4 border border-base-300">
|
||||||
|
{#if estatisticasQuery === undefined || estatisticasQuery?.isLoading}
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center bg-base-200/30 rounded-xl">
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
<span class="text-base-content/70 font-medium">Carregando estatísticas...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if estatisticasQuery?.error}
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center bg-base-200/30 rounded-xl">
|
||||||
|
<div class="alert alert-error shadow-lg">
|
||||||
|
<XCircle class="h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold">Erro ao carregar estatísticas</h3>
|
||||||
|
<div class="text-sm mt-1">{estatisticasQuery.error?.message || String(estatisticasQuery.error) || 'Erro desconhecido'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !estatisticas || !chartData}
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center bg-base-200/30 rounded-xl">
|
||||||
|
<div class="text-center">
|
||||||
|
<FileText class="h-12 w-12 text-base-content/30 mx-auto mb-2" />
|
||||||
|
<p class="text-base-content/70">Nenhuma estatística disponível</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<canvas bind:this={chartCanvas} class="w-full h-full"></canvas>
|
<canvas bind:this={chartCanvas} class="w-full h-full"></canvas>
|
||||||
{#if !chartInstance && estatisticas}
|
{#if !chartInstance && estatisticas && chartData}
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center bg-base-200/30 rounded-xl">
|
||||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Filtros -->
|
<!-- Filtros -->
|
||||||
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl mb-8">
|
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl mb-8">
|
||||||
@@ -1860,7 +1891,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if registrosQuery?.status === 'Loading'}
|
{#if registrosQuery === undefined || registrosQuery?.isLoading}
|
||||||
<div class="flex flex-col items-center justify-center py-16 bg-base-200/50 rounded-xl border border-base-300">
|
<div class="flex flex-col items-center justify-center py-16 bg-base-200/50 rounded-xl border border-base-300">
|
||||||
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
|
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
|
||||||
<span class="text-base-content/70 font-medium">Carregando registros...</span>
|
<span class="text-base-content/70 font-medium">Carregando registros...</span>
|
||||||
@@ -1871,7 +1902,7 @@
|
|||||||
<XCircle class="h-6 w-6" />
|
<XCircle class="h-6 w-6" />
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold">Erro ao carregar registros</h3>
|
<h3 class="font-bold">Erro ao carregar registros</h3>
|
||||||
<div class="text-sm mt-1">{registrosQuery.error.message || 'Erro desconhecido'}</div>
|
<div class="text-sm mt-1">{registrosQuery.error?.message || String(registrosQuery.error) || 'Erro desconhecido'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if !registrosQuery?.data}
|
{:else if !registrosQuery?.data}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
| 'document'
|
| 'document'
|
||||||
| 'teams'
|
| 'teams'
|
||||||
| 'userPlus'
|
| 'userPlus'
|
||||||
| 'clock';
|
| 'clock'
|
||||||
|
| 'video';
|
||||||
type PaletteKey = 'primary' | 'success' | 'secondary' | 'accent' | 'info' | 'error' | 'warning';
|
type PaletteKey = 'primary' | 'success' | 'secondary' | 'accent' | 'info' | 'error' | 'warning';
|
||||||
|
|
||||||
type TiRouteId =
|
type TiRouteId =
|
||||||
@@ -28,7 +29,8 @@
|
|||||||
| '/(dashboard)/ti/notificacoes'
|
| '/(dashboard)/ti/notificacoes'
|
||||||
| '/(dashboard)/ti/monitoramento'
|
| '/(dashboard)/ti/monitoramento'
|
||||||
| '/(dashboard)/ti/configuracoes-ponto'
|
| '/(dashboard)/ti/configuracoes-ponto'
|
||||||
| '/(dashboard)/ti/configuracoes-relogio';
|
| '/(dashboard)/ti/configuracoes-relogio'
|
||||||
|
| '/(dashboard)/ti/configuracoes-jitsi';
|
||||||
|
|
||||||
type FeatureCard = {
|
type FeatureCard = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -202,6 +204,13 @@
|
|||||||
strokeLinecap: 'round',
|
strokeLinecap: 'round',
|
||||||
strokeLinejoin: 'round'
|
strokeLinejoin: 'round'
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
video: [
|
||||||
|
{
|
||||||
|
d: 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z',
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,6 +268,15 @@
|
|||||||
palette: 'secondary',
|
palette: 'secondary',
|
||||||
icon: 'envelope'
|
icon: 'envelope'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Configurações do Jitsi',
|
||||||
|
description:
|
||||||
|
'Configure o servidor Jitsi Meet para chamadas de vídeo e áudio no chat. Ajuste domínio, App ID e prefixo de salas.',
|
||||||
|
ctaLabel: 'Configurar Jitsi',
|
||||||
|
href: '/(dashboard)/ti/configuracoes-jitsi',
|
||||||
|
palette: 'primary',
|
||||||
|
icon: 'video'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Configurações de Ponto',
|
title: 'Configurações de Ponto',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -0,0 +1,876 @@
|
|||||||
|
<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';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||||
|
const configAtual = useQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||||
|
|
||||||
|
// Query condicional para configuração completa
|
||||||
|
const configCompletaQuery = $derived(
|
||||||
|
configAtual?.data?._id ? { configId: configAtual.data._id } : null
|
||||||
|
);
|
||||||
|
const configCompleta = useQuery(
|
||||||
|
api.configuracaoJitsi.obterConfigJitsiCompleta,
|
||||||
|
configCompletaQuery ? configCompletaQuery : 'skip'
|
||||||
|
);
|
||||||
|
|
||||||
|
let domain = $state('');
|
||||||
|
let appId = $state('sgse-app');
|
||||||
|
let roomPrefix = $state('sgse');
|
||||||
|
let useHttps = $state(false);
|
||||||
|
let acceptSelfSignedCert = $state(false);
|
||||||
|
|
||||||
|
// Campos SSH/Docker
|
||||||
|
let sshHost = $state('');
|
||||||
|
let sshPort = $state(22);
|
||||||
|
let sshUsername = $state('');
|
||||||
|
let sshPassword = $state('');
|
||||||
|
let sshKeyPath = $state('');
|
||||||
|
let dockerComposePath = $state('');
|
||||||
|
let jitsiConfigPath = $state('~/.jitsi-meet-cfg');
|
||||||
|
|
||||||
|
let mostrarConfigSSH = $state(false);
|
||||||
|
let processando = $state(false);
|
||||||
|
let testando = $state(false);
|
||||||
|
let testandoSSH = $state(false);
|
||||||
|
let aplicandoServidor = $state(false);
|
||||||
|
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string; detalhes?: string } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
function mostrarMensagem(tipo: 'success' | 'error', texto: string, detalhes?: string) {
|
||||||
|
mensagem = { tipo, texto, detalhes };
|
||||||
|
setTimeout(() => {
|
||||||
|
mensagem = null;
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carregar config existente
|
||||||
|
$effect(() => {
|
||||||
|
if (configAtual?.data) {
|
||||||
|
domain = configAtual.data.domain || '';
|
||||||
|
appId = configAtual.data.appId || 'sgse-app';
|
||||||
|
roomPrefix = configAtual.data.roomPrefix || 'sgse';
|
||||||
|
useHttps = configAtual.data.useHttps ?? false;
|
||||||
|
acceptSelfSignedCert = configAtual.data.acceptSelfSignedCert ?? false;
|
||||||
|
} else if (configAtual === null) {
|
||||||
|
// Se não há configuração, resetar para valores padrão
|
||||||
|
domain = '';
|
||||||
|
appId = 'sgse-app';
|
||||||
|
roomPrefix = 'sgse';
|
||||||
|
useHttps = false;
|
||||||
|
acceptSelfSignedCert = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Carregar configurações SSH/Docker
|
||||||
|
$effect(() => {
|
||||||
|
if (configCompleta?.data) {
|
||||||
|
sshHost = configCompleta.data.sshHost || '';
|
||||||
|
sshPort = configCompleta.data.sshPort || 22;
|
||||||
|
sshUsername = configCompleta.data.sshUsername || '';
|
||||||
|
sshPassword = ''; // Sempre limpar senha por segurança
|
||||||
|
sshKeyPath = configCompleta.data.sshKeyPath || '';
|
||||||
|
dockerComposePath = configCompleta.data.dockerComposePath || '';
|
||||||
|
jitsiConfigPath = configCompleta.data.jitsiConfigPath || '~/.jitsi-meet-cfg';
|
||||||
|
mostrarConfigSSH = !!(configCompleta.data.sshHost || configCompleta.data.sshUsername);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ativar HTTPS automaticamente se domínio contém porta 8443
|
||||||
|
$effect(() => {
|
||||||
|
if (domain.includes(':8443')) {
|
||||||
|
useHttps = true;
|
||||||
|
// Para localhost com porta 8443, geralmente é certificado autoassinado
|
||||||
|
if (domain.includes('localhost')) {
|
||||||
|
acceptSelfSignedCert = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function salvarConfiguracao() {
|
||||||
|
// Validação de campos obrigatórios
|
||||||
|
if (!domain?.trim() || !appId?.trim() || !roomPrefix?.trim()) {
|
||||||
|
mostrarMensagem('error', 'Preencha todos os campos obrigatórios');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validação de roomPrefix (apenas letras, números e hífens)
|
||||||
|
const roomPrefixRegex = /^[a-zA-Z0-9-]+$/;
|
||||||
|
if (!roomPrefixRegex.test(roomPrefix.trim())) {
|
||||||
|
mostrarMensagem('error', 'Prefixo de sala deve conter apenas letras, números e hífens');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentUser?.data) {
|
||||||
|
mostrarMensagem('error', 'Usuário não autenticado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processando = true;
|
||||||
|
try {
|
||||||
|
const resultado = await client.mutation(api.configuracaoJitsi.salvarConfigJitsi, {
|
||||||
|
domain: domain.trim(),
|
||||||
|
appId: appId.trim(),
|
||||||
|
roomPrefix: roomPrefix.trim(),
|
||||||
|
useHttps,
|
||||||
|
acceptSelfSignedCert,
|
||||||
|
configuradoPorId: currentUser.data._id as Id<'usuarios'>,
|
||||||
|
// Configurações SSH/Docker (opcionais)
|
||||||
|
sshHost: sshHost.trim() || undefined,
|
||||||
|
sshPort: sshPort || undefined,
|
||||||
|
sshUsername: sshUsername.trim() || undefined,
|
||||||
|
sshPassword: sshPassword.trim() || undefined,
|
||||||
|
sshKeyPath: sshKeyPath.trim() || undefined,
|
||||||
|
dockerComposePath: dockerComposePath.trim() || undefined,
|
||||||
|
jitsiConfigPath: jitsiConfigPath.trim() || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resultado.sucesso) {
|
||||||
|
mostrarMensagem('success', 'Configuração salva com sucesso!');
|
||||||
|
} else {
|
||||||
|
mostrarMensagem('error', resultado.erro);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('Erro ao salvar configuração:', error);
|
||||||
|
mostrarMensagem('error', errorMessage || 'Erro ao salvar configuração');
|
||||||
|
} finally {
|
||||||
|
processando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testarConexao() {
|
||||||
|
if (!domain?.trim()) {
|
||||||
|
mostrarMensagem('error', 'Preencha o domínio antes de testar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
testando = true;
|
||||||
|
try {
|
||||||
|
const resultado = await client.action(api.configuracaoJitsi.testarConexaoJitsi, {
|
||||||
|
domain: domain.trim(),
|
||||||
|
useHttps,
|
||||||
|
acceptSelfSignedCert
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resultado.sucesso) {
|
||||||
|
const mensagemSucesso = resultado.aviso
|
||||||
|
? `Conexão testada com sucesso! ${resultado.aviso}`
|
||||||
|
: 'Conexão testada com sucesso! Servidor Jitsi está acessível.';
|
||||||
|
mostrarMensagem('success', mensagemSucesso);
|
||||||
|
} else {
|
||||||
|
mostrarMensagem('error', `Erro ao testar conexão: ${resultado.erro}`);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('Erro ao testar conexão:', error);
|
||||||
|
mostrarMensagem('error', errorMessage || 'Erro ao conectar com o servidor Jitsi');
|
||||||
|
} finally {
|
||||||
|
testando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testarConexaoSSH() {
|
||||||
|
if (!sshHost?.trim() || !sshUsername?.trim()) {
|
||||||
|
mostrarMensagem('error', 'Preencha Host e Usuário SSH antes de testar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sshPassword?.trim() && !sshKeyPath?.trim()) {
|
||||||
|
mostrarMensagem('error', 'Preencha a senha SSH ou o caminho da chave antes de testar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
testandoSSH = true;
|
||||||
|
try {
|
||||||
|
const resultado = await client.action(api.actions.jitsiServer.testarConexaoSSH, {
|
||||||
|
sshHost: sshHost.trim(),
|
||||||
|
sshPort: sshPort || 22,
|
||||||
|
sshUsername: sshUsername.trim(),
|
||||||
|
sshPassword: sshPassword.trim() || undefined,
|
||||||
|
sshKeyPath: sshKeyPath.trim() || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resultado.sucesso) {
|
||||||
|
mostrarMensagem('success', resultado.mensagem);
|
||||||
|
} else {
|
||||||
|
mostrarMensagem('error', `Erro ao testar SSH: ${resultado.erro}`);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('Erro ao testar SSH:', error);
|
||||||
|
mostrarMensagem('error', errorMessage || 'Erro ao conectar via SSH');
|
||||||
|
} finally {
|
||||||
|
testandoSSH = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aplicarConfiguracaoServidor() {
|
||||||
|
if (!configAtual?.data?._id) {
|
||||||
|
mostrarMensagem('error', 'Salve a configuração básica antes de aplicar no servidor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sshHost?.trim() || !sshUsername?.trim()) {
|
||||||
|
mostrarMensagem('error', 'Configure o acesso SSH antes de aplicar no servidor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Senha SSH é necessária para aplicar (pode ser a armazenada ou uma nova)
|
||||||
|
if (!sshPassword?.trim() && !sshKeyPath?.trim() && !configCompleta?.data?.sshPasswordHash) {
|
||||||
|
mostrarMensagem(
|
||||||
|
'error',
|
||||||
|
'Forneça a senha SSH ou o caminho da chave para aplicar a configuração'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
'Deseja aplicar essas configurações no servidor Jitsi Docker? Os containers serão reiniciados.'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
aplicandoServidor = true;
|
||||||
|
try {
|
||||||
|
const resultado = await client.action(api.actions.jitsiServer.aplicarConfiguracaoServidor, {
|
||||||
|
configId: configAtual.data._id,
|
||||||
|
sshPassword: sshPassword.trim() || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resultado.sucesso) {
|
||||||
|
mostrarMensagem('success', resultado.mensagem, resultado.detalhes);
|
||||||
|
// Limpar senha após uso
|
||||||
|
sshPassword = '';
|
||||||
|
} else {
|
||||||
|
mostrarMensagem('error', `Erro ao aplicar configuração: ${resultado.erro}`);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('Erro ao aplicar configuração:', error);
|
||||||
|
mostrarMensagem('error', errorMessage || 'Erro ao aplicar configuração no servidor');
|
||||||
|
} finally {
|
||||||
|
aplicandoServidor = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = $derived(configAtual?.data?.ativo ? 'Configurado' : 'Não configurado');
|
||||||
|
|
||||||
|
const configuradoNoServidor = $derived(configCompleta?.data?.configuradoNoServidor ?? false);
|
||||||
|
|
||||||
|
const isLoading = $derived(configAtual === undefined);
|
||||||
|
const hasError = $derived(configAtual === null && !isLoading);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto max-w-4xl px-4 py-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="bg-primary/10 rounded-xl p-3">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="text-primary h-8 w-8"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-base-content text-3xl font-bold">Configurações do Jitsi Meet</h1>
|
||||||
|
<p class="text-base-content/60 mt-1">
|
||||||
|
Configurar servidor Jitsi para chamadas de vídeo e áudio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensagens -->
|
||||||
|
{#if mensagem}
|
||||||
|
<div
|
||||||
|
class="alert mb-6"
|
||||||
|
class:alert-success={mensagem.tipo === 'success'}
|
||||||
|
class:alert-error={mensagem.tipo === 'error'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
{#if mensagem.tipo === 'success'}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<span>{mensagem.texto}</span>
|
||||||
|
{#if mensagem.detalhes}
|
||||||
|
<details class="mt-2">
|
||||||
|
<summary class="cursor-pointer text-sm opacity-75">Detalhes</summary>
|
||||||
|
<pre
|
||||||
|
class="bg-base-200 mt-2 rounded p-2 font-mono text-xs whitespace-pre-wrap">{mensagem.detalhes}</pre>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="alert alert-info mb-6">
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
<span>Carregando configurações...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
{#if !isLoading}
|
||||||
|
<div class="alert {configAtual?.data?.ativo ? 'alert-success' : 'alert-warning'} mb-6">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
>
|
||||||
|
{#if configAtual?.data?.ativo}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
<strong>Status:</strong>
|
||||||
|
{statusConfig}
|
||||||
|
{#if configAtual?.data?.testadoEm}
|
||||||
|
- Última conexão testada em {new Date(configAtual.data.testadoEm).toLocaleString('pt-BR')}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Formulário -->
|
||||||
|
{#if !isLoading}
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-4">Dados do Servidor Jitsi</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<!-- Domínio -->
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label" for="jitsi-domain">
|
||||||
|
<span class="label-text font-medium">Domínio do Servidor *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="jitsi-domain"
|
||||||
|
type="text"
|
||||||
|
bind:value={domain}
|
||||||
|
placeholder="localhost:8443 ou meet.example.com"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt"
|
||||||
|
>Ex: localhost:8443 (local), meet.example.com (produção)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- App ID -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="jitsi-app-id">
|
||||||
|
<span class="label-text font-medium">App ID *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="jitsi-app-id"
|
||||||
|
type="text"
|
||||||
|
bind:value={appId}
|
||||||
|
placeholder="sgse-app"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Identificador da aplicação Jitsi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Room Prefix -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="jitsi-room-prefix">
|
||||||
|
<span class="label-text font-medium">Prefixo de Sala *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="jitsi-room-prefix"
|
||||||
|
type="text"
|
||||||
|
bind:value={roomPrefix}
|
||||||
|
placeholder="sgse"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Apenas letras, números e hífens</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Opções de Segurança -->
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h3 class="mb-2 font-bold">Configurações de Segurança</h3>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer gap-3">
|
||||||
|
<input type="checkbox" bind:checked={useHttps} class="checkbox checkbox-primary" />
|
||||||
|
<span class="label-text font-medium">Usar HTTPS</span>
|
||||||
|
</label>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt"
|
||||||
|
>Ativado automaticamente se domínio contém :8443. Desmarque para usar HTTP (não
|
||||||
|
recomendado para produção)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={acceptSelfSignedCert}
|
||||||
|
class="checkbox checkbox-warning"
|
||||||
|
/>
|
||||||
|
<span class="label-text font-medium">Aceitar Certificados Autoassinados</span>
|
||||||
|
</label>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt text-warning"
|
||||||
|
>Habilitar apenas para desenvolvimento local com certificados autoassinados. Em
|
||||||
|
produção, use certificados válidos.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configurações SSH/Docker -->
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<h3 class="font-bold">Configuração SSH/Docker (Opcional)</h3>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<span class="label-text text-sm">Configurar servidor via SSH</span>
|
||||||
|
<input type="checkbox" bind:checked={mostrarConfigSSH} class="checkbox checkbox-sm" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if mostrarConfigSSH}
|
||||||
|
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<!-- SSH Host -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="ssh-host">
|
||||||
|
<span class="label-text font-medium">Host SSH *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ssh-host"
|
||||||
|
type="text"
|
||||||
|
bind:value={sshHost}
|
||||||
|
placeholder="192.168.1.100 ou servidor.local"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Endereço do servidor Docker</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSH Port -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="ssh-port">
|
||||||
|
<span class="label-text font-medium">Porta SSH</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ssh-port"
|
||||||
|
type="number"
|
||||||
|
bind:value={sshPort}
|
||||||
|
placeholder="22"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSH Username -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="ssh-username">
|
||||||
|
<span class="label-text font-medium">Usuário SSH *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ssh-username"
|
||||||
|
type="text"
|
||||||
|
bind:value={sshUsername}
|
||||||
|
placeholder="usuario"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSH Password ou Key Path -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="ssh-password">
|
||||||
|
<span class="label-text font-medium">Senha SSH</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ssh-password"
|
||||||
|
type="password"
|
||||||
|
bind:value={sshPassword}
|
||||||
|
placeholder="Deixe vazio para manter senha salva"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Ou use caminho da chave SSH abaixo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSH Key Path -->
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label" for="ssh-key-path">
|
||||||
|
<span class="label-text font-medium">Caminho da Chave SSH</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ssh-key-path"
|
||||||
|
type="text"
|
||||||
|
bind:value={sshKeyPath}
|
||||||
|
placeholder="/home/usuario/.ssh/id_rsa"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Caminho no servidor SSH para a chave privada</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Docker Compose Path -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="docker-compose-path">
|
||||||
|
<span class="label-text font-medium">Caminho Docker Compose</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="docker-compose-path"
|
||||||
|
type="text"
|
||||||
|
bind:value={dockerComposePath}
|
||||||
|
placeholder="/home/usuario/jitsi-docker"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Diretório com docker-compose.yml</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jitsi Config Path -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="jitsi-config-path">
|
||||||
|
<span class="label-text font-medium">Caminho Config Jitsi</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="jitsi-config-path"
|
||||||
|
type="text"
|
||||||
|
bind:value={jitsiConfigPath}
|
||||||
|
placeholder="~/.jitsi-meet-cfg"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Diretório de configurações do Jitsi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Configuração Servidor -->
|
||||||
|
{#if configuradoNoServidor}
|
||||||
|
<div class="alert alert-success mt-4">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
Configuração aplicada no servidor
|
||||||
|
{#if configCompleta?.data?.configuradoNoServidorEm}
|
||||||
|
em {new Date(configCompleta.data.configuradoNoServidorEm).toLocaleString('pt-BR')}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Botões SSH/Docker -->
|
||||||
|
<div class="mt-4 flex gap-3">
|
||||||
|
<button
|
||||||
|
class="btn btn-outline btn-info"
|
||||||
|
onclick={testarConexaoSSH}
|
||||||
|
disabled={testandoSSH || processando || aplicandoServidor}
|
||||||
|
>
|
||||||
|
{#if testandoSSH}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
Testar SSH
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
onclick={aplicarConfiguracaoServidor}
|
||||||
|
disabled={aplicandoServidor ||
|
||||||
|
processando ||
|
||||||
|
testando ||
|
||||||
|
testandoSSH ||
|
||||||
|
!configAtual?.data?._id}
|
||||||
|
>
|
||||||
|
{#if aplicandoServidor}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
Aplicar no Servidor Docker
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Ações -->
|
||||||
|
<div class="card-actions mt-6 justify-end gap-3">
|
||||||
|
<button
|
||||||
|
class="btn btn-outline btn-info"
|
||||||
|
onclick={testarConexao}
|
||||||
|
disabled={testando || processando}
|
||||||
|
>
|
||||||
|
{#if testando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
Testar Conexão
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={salvarConfiguracao}
|
||||||
|
disabled={processando || testando}
|
||||||
|
>
|
||||||
|
{#if processando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
Salvar Configuração
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Exemplos Comuns -->
|
||||||
|
<div class="card bg-base-100 mt-6 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-4">Exemplos de Configuração</h2>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-sm table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ambiente</th>
|
||||||
|
<th>Domínio</th>
|
||||||
|
<th>App ID</th>
|
||||||
|
<th>Prefixo Sala</th>
|
||||||
|
<th>HTTPS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Docker Local</strong></td>
|
||||||
|
<td>localhost:8443</td>
|
||||||
|
<td>sgse-app</td>
|
||||||
|
<td>sgse</td>
|
||||||
|
<td>Sim</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Produção</strong></td>
|
||||||
|
<td>meet.example.com</td>
|
||||||
|
<td>sgse-app</td>
|
||||||
|
<td>sgse</td>
|
||||||
|
<td>Sim</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Desenvolvimento</strong></td>
|
||||||
|
<td>localhost:8000</td>
|
||||||
|
<td>sgse-app</td>
|
||||||
|
<td>sgse-dev</td>
|
||||||
|
<td>Não</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Avisos -->
|
||||||
|
<div class="alert alert-info mt-6">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<strong>Dica:</strong> Para servidor Jitsi Docker local, use
|
||||||
|
<code>localhost:8443</code> com HTTPS habilitado. Para servidor em produção, use o domínio completo
|
||||||
|
do seu servidor Jitsi.
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm">
|
||||||
|
A configuração será aplicada imediatamente após salvar. Usuários precisarão recarregar a
|
||||||
|
página para usar a nova configuração.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aviso sobre Certificados Autoassinados -->
|
||||||
|
{#if acceptSelfSignedCert}
|
||||||
|
<div class="alert alert-warning mt-4">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-bold">Certificados Autoassinados Ativados</p>
|
||||||
|
<p class="mt-1 text-sm">
|
||||||
|
Para certificados autoassinados (desenvolvimento local), os usuários precisarão aceitar o
|
||||||
|
certificado no navegador na primeira conexão. Em produção, use certificados válidos (Let's
|
||||||
|
Encrypt, etc.).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Aviso sobre HTTP -->
|
||||||
|
{#if !useHttps}
|
||||||
|
<div class="alert alert-error mt-4">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-bold">HTTP Ativado (Não Seguro)</p>
|
||||||
|
<p class="mt-1 text-sm">
|
||||||
|
O uso de HTTP não é recomendado para produção. Use HTTPS com certificado válido para
|
||||||
|
garantir segurança nas chamadas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -10,10 +10,20 @@
|
|||||||
let portaNTP = $state(123);
|
let portaNTP = $state(123);
|
||||||
let usarServidorExterno = $state(false);
|
let usarServidorExterno = $state(false);
|
||||||
let fallbackParaPC = $state(true);
|
let fallbackParaPC = $state(true);
|
||||||
let gmtOffset = $state(0);
|
let gmtOffset = $state(-3); // Padrão GMT-3 para Brasília
|
||||||
let processando = $state(false);
|
let processando = $state(false);
|
||||||
let testando = $state(false);
|
let testando = $state(false);
|
||||||
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
|
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
|
||||||
|
let statusSincronizacao = $state<{
|
||||||
|
ultimaSincronizacao: number | null;
|
||||||
|
offsetSegundos: number | null;
|
||||||
|
usandoServidorExterno: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Estados para os relógios
|
||||||
|
let timestampOriginal = $state<number | null>(null);
|
||||||
|
let timestampUTC = $state<number | null>(null);
|
||||||
|
let intervaloRelogio: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (configQuery?.data) {
|
if (configQuery?.data) {
|
||||||
@@ -21,10 +31,159 @@
|
|||||||
portaNTP = configQuery.data.portaNTP || 123;
|
portaNTP = configQuery.data.portaNTP || 123;
|
||||||
usarServidorExterno = configQuery.data.usarServidorExterno || false;
|
usarServidorExterno = configQuery.data.usarServidorExterno || false;
|
||||||
fallbackParaPC = configQuery.data.fallbackParaPC !== undefined ? configQuery.data.fallbackParaPC : true;
|
fallbackParaPC = configQuery.data.fallbackParaPC !== undefined ? configQuery.data.fallbackParaPC : true;
|
||||||
gmtOffset = configQuery.data.gmtOffset ?? 0;
|
gmtOffset = configQuery.data.gmtOffset ?? -3; // Padrão GMT-3 para Brasília
|
||||||
|
|
||||||
|
// Atualizar status de sincronização
|
||||||
|
statusSincronizacao = {
|
||||||
|
ultimaSincronizacao: configQuery.data.ultimaSincronizacao ?? null,
|
||||||
|
offsetSegundos: configQuery.data.offsetSegundos ?? null,
|
||||||
|
usandoServidorExterno: configQuery.data.usarServidorExterno || false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Função para obter tempo sincronizado
|
||||||
|
async function obterTempoSincronizado() {
|
||||||
|
try {
|
||||||
|
if (usarServidorExterno) {
|
||||||
|
// Se usar servidor externo, sincronizar com NTP
|
||||||
|
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||||
|
if (resultado.sucesso) {
|
||||||
|
timestampUTC = resultado.timestamp; // Timestamp UTC da fonte
|
||||||
|
// Calcular o timestamp original (antes do ajuste GMT)
|
||||||
|
timestampOriginal = timestampUTC;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Se não usar servidor externo, usar tempo local do PC (igual ao relógio de ponto)
|
||||||
|
timestampUTC = Date.now();
|
||||||
|
timestampOriginal = timestampUTC;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao obter tempo sincronizado:', error);
|
||||||
|
// Fallback: usar tempo local
|
||||||
|
timestampUTC = Date.now();
|
||||||
|
timestampOriginal = timestampUTC;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inicializar e atualizar relógios periodicamente
|
||||||
|
$effect(() => {
|
||||||
|
// Obter tempo inicial quando configuração mudar
|
||||||
|
if (configQuery?.data) {
|
||||||
|
obterTempoSincronizado();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar a cada segundo
|
||||||
|
intervaloRelogio = setInterval(() => {
|
||||||
|
if (timestampUTC !== null) {
|
||||||
|
// Incrementar em 1 segundo
|
||||||
|
timestampUTC += 1000;
|
||||||
|
timestampOriginal = timestampUTC;
|
||||||
|
} else {
|
||||||
|
// Se ainda não temos timestamp, tentar obter novamente
|
||||||
|
obterTempoSincronizado();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Limpar intervalo quando componente for destruído
|
||||||
|
return () => {
|
||||||
|
if (intervaloRelogio) {
|
||||||
|
clearInterval(intervaloRelogio);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Funções para formatar os relógios
|
||||||
|
function formatarRelogio(timestamp: number | null, ajusteGMT: number = 0): string {
|
||||||
|
if (!timestamp) return '--:--:--';
|
||||||
|
// Aplicar GMT offset ao timestamp
|
||||||
|
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
|
||||||
|
// Quando GMT ≠ 0, aplicar offset configurado ao timestamp
|
||||||
|
let timestampAjustado: number;
|
||||||
|
if (ajusteGMT !== 0) {
|
||||||
|
// Aplicar offset configurado
|
||||||
|
timestampAjustado = timestamp + (ajusteGMT * 60 * 60 * 1000);
|
||||||
|
} else {
|
||||||
|
// Quando GMT = 0, manter timestamp UTC puro
|
||||||
|
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
|
||||||
|
timestampAjustado = timestamp;
|
||||||
|
}
|
||||||
|
const data = new Date(timestampAjustado);
|
||||||
|
return data.toLocaleTimeString('pt-BR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatarDataRelogio(timestamp: number | null, ajusteGMT: number = 0): string {
|
||||||
|
if (!timestamp) return '--/--/----';
|
||||||
|
// Aplicar GMT offset ao timestamp
|
||||||
|
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleDateString() fazer a conversão automática
|
||||||
|
// Quando GMT ≠ 0, aplicar offset configurado ao timestamp
|
||||||
|
let timestampAjustado: number;
|
||||||
|
if (ajusteGMT !== 0) {
|
||||||
|
// Aplicar offset configurado
|
||||||
|
timestampAjustado = timestamp + (ajusteGMT * 60 * 60 * 1000);
|
||||||
|
} else {
|
||||||
|
// Quando GMT = 0, manter timestamp UTC puro
|
||||||
|
// O toLocaleDateString() converterá automaticamente para o timezone local do navegador
|
||||||
|
timestampAjustado = timestamp;
|
||||||
|
}
|
||||||
|
const data = new Date(timestampAjustado);
|
||||||
|
return data.toLocaleDateString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para formatar timestamp ISO
|
||||||
|
function formatarTimestampISO(timestamp: number | null): string {
|
||||||
|
if (!timestamp) return '--';
|
||||||
|
return new Date(timestamp).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para obter nome do fuso horário baseado no offset
|
||||||
|
function obterNomeFusoHorario(offset: number): string {
|
||||||
|
const fusos: Record<number, string> = {
|
||||||
|
'-12': 'IDLW (Linha Internacional de Data Oeste)',
|
||||||
|
'-11': 'HST (Hawaii-Aleutian Standard Time)',
|
||||||
|
'-10': 'HST (Hawaii Standard Time)',
|
||||||
|
'-9': 'AKST (Alaska Standard Time)',
|
||||||
|
'-8': 'PST (Pacific Standard Time)',
|
||||||
|
'-7': 'MST (Mountain Standard Time)',
|
||||||
|
'-6': 'CST (Central Standard Time)',
|
||||||
|
'-5': 'EST (Eastern Standard Time)',
|
||||||
|
'-4': 'AST (Atlantic Standard Time)',
|
||||||
|
'-3': 'BRT (Brasília Time) / ART (Argentina Time)',
|
||||||
|
'-2': 'GST (South Georgia Standard Time)',
|
||||||
|
'-1': 'AZOT (Azores Standard Time)',
|
||||||
|
'0': 'UTC / GMT (Greenwich Mean Time)',
|
||||||
|
'1': 'CET (Central European Time)',
|
||||||
|
'2': 'EET (Eastern European Time)',
|
||||||
|
'3': 'MSK (Moscow Standard Time)',
|
||||||
|
'4': 'GST (Gulf Standard Time)',
|
||||||
|
'5': 'PKT (Pakistan Standard Time)',
|
||||||
|
'6': 'BST (Bangladesh Standard Time)',
|
||||||
|
'7': 'ICT (Indochina Time)',
|
||||||
|
'8': 'CST (China Standard Time)',
|
||||||
|
'9': 'JST (Japan Standard Time)',
|
||||||
|
'10': 'AEST (Australian Eastern Standard Time)',
|
||||||
|
'11': 'SBT (Solomon Islands Time)',
|
||||||
|
'12': 'NZST (New Zealand Standard Time)',
|
||||||
|
};
|
||||||
|
return fusos[offset.toString()] || `UTC${offset >= 0 ? '+' : ''}${offset}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para calcular diferença em horas e minutos
|
||||||
|
function calcularDiferencaFuso(offset: number): string {
|
||||||
|
const horas = Math.abs(offset);
|
||||||
|
const minutos = 0; // Offset em horas completas
|
||||||
|
return `${horas}h ${minutos}m ${offset < 0 ? 'atrás' : 'à frente'} de UTC`;
|
||||||
|
}
|
||||||
|
|
||||||
function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
|
function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
|
||||||
mensagem = { tipo, texto };
|
mensagem = { tipo, texto };
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -55,6 +214,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
mostrarMensagem('success', 'Configuração salva com sucesso!');
|
mostrarMensagem('success', 'Configuração salva com sucesso!');
|
||||||
|
|
||||||
|
// Recarregar configuração para atualizar status
|
||||||
|
// A query será atualizada automaticamente pelo useQuery
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao salvar configuração:', error);
|
console.error('Erro ao salvar configuração:', error);
|
||||||
mostrarMensagem('error', error instanceof Error ? error.message : 'Erro ao salvar configuração');
|
mostrarMensagem('error', error instanceof Error ? error.message : 'Erro ao salvar configuração');
|
||||||
@@ -69,10 +231,37 @@
|
|||||||
try {
|
try {
|
||||||
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||||
if (resultado.sucesso) {
|
if (resultado.sucesso) {
|
||||||
|
// Atualizar status de sincronização
|
||||||
|
statusSincronizacao = {
|
||||||
|
ultimaSincronizacao: Date.now(),
|
||||||
|
offsetSegundos: resultado.offsetSegundos,
|
||||||
|
usandoServidorExterno: resultado.usandoServidorExterno,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Atualizar timestamps dos relógios
|
||||||
|
timestampUTC = resultado.timestamp;
|
||||||
|
timestampOriginal = resultado.timestamp;
|
||||||
|
|
||||||
|
// Calcular horário atual com GMT offset
|
||||||
|
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
|
||||||
|
let timestampAjustado: number;
|
||||||
|
if (gmtOffset !== 0) {
|
||||||
|
timestampAjustado = timestampUTC + (gmtOffset * 60 * 60 * 1000);
|
||||||
|
} else {
|
||||||
|
// Quando GMT = 0, manter timestamp UTC puro
|
||||||
|
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
|
||||||
|
timestampAjustado = timestampUTC;
|
||||||
|
}
|
||||||
|
const horarioAtual = new Date(timestampAjustado).toLocaleTimeString('pt-BR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
mostrarMensagem(
|
mostrarMensagem(
|
||||||
'success',
|
'success',
|
||||||
resultado.usandoServidorExterno
|
resultado.usandoServidorExterno
|
||||||
? `Sincronização bem-sucedida! Offset: ${resultado.offsetSegundos}s`
|
? `Sincronização bem-sucedida! Offset: ${resultado.offsetSegundos}s | Horário atual: ${horarioAtual}`
|
||||||
: 'Usando relógio do PC (servidor externo não disponível)'
|
: 'Usando relógio do PC (servidor externo não disponível)'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -85,6 +274,24 @@
|
|||||||
testando = false;
|
testando = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Função para atualizar relógios manualmente
|
||||||
|
async function atualizarRelogios() {
|
||||||
|
await obterTempoSincronizado();
|
||||||
|
mostrarMensagem('success', 'Relógios atualizados!');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatarDataHora(timestamp: number | null): string {
|
||||||
|
if (!timestamp) return 'Nunca';
|
||||||
|
return new Date(timestamp).toLocaleString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||||
@@ -117,6 +324,134 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Relógios em Tempo Real -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="card-title">Relógios Sincronizados</h2>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline btn-info"
|
||||||
|
onclick={atualizarRelogios}
|
||||||
|
disabled={testando || processando}
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4" />
|
||||||
|
Atualizar Relógios
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/70 mb-6">
|
||||||
|
Visualização em tempo real dos horários sincronizados. O primeiro relógio mostra o horário original (UTC) da fonte de sincronismo escolhida, e o segundo mostra o horário ajustado conforme o GMT configurado.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Relógio Original (UTC) -->
|
||||||
|
<div class="bg-gradient-to-br from-blue-500/10 to-blue-600/10 rounded-xl p-6 border-2 border-blue-500/30">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-sm font-semibold text-blue-600 uppercase tracking-wide mb-2">
|
||||||
|
Horário Original (UTC)
|
||||||
|
</div>
|
||||||
|
<div class="text-5xl font-bold text-blue-700 mb-2 font-mono">
|
||||||
|
{formatarRelogio(timestampOriginal, 0)}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-blue-600/80 mb-1">
|
||||||
|
{formatarDataRelogio(timestampOriginal, 0)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="divider my-3 opacity-30"></div>
|
||||||
|
|
||||||
|
<!-- Detalhes do Fuso Horário -->
|
||||||
|
<div class="text-left space-y-2 mt-4">
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-semibold text-blue-700">Fuso Horário:</span>
|
||||||
|
<span class="text-blue-600/90 ml-1">UTC / GMT (Greenwich Mean Time)</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-semibold text-blue-700">Offset UTC:</span>
|
||||||
|
<span class="text-blue-600/90 ml-1">±00:00 (Coordenado Universal)</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-semibold text-blue-700">Fonte:</span>
|
||||||
|
<span class="text-blue-600/90 ml-1">
|
||||||
|
{statusSincronizacao?.usandoServidorExterno ? `Servidor NTP (${servidorNTP})` : 'Relógio do PC'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if statusSincronizacao?.offsetSegundos !== null && statusSincronizacao?.offsetSegundos !== undefined}
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-semibold text-blue-700">Offset Calculado:</span>
|
||||||
|
<span class="text-blue-600/90 ml-1">
|
||||||
|
{statusSincronizacao.offsetSegundos > 0 ? '+' : ''}{statusSincronizacao.offsetSegundos}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if timestampOriginal}
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-semibold text-blue-700">Timestamp UTC:</span>
|
||||||
|
<span class="text-blue-600/90 ml-1 font-mono text-[10px]">
|
||||||
|
{formatarTimestampISO(timestampOriginal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-semibold text-blue-700">Formato Recebido:</span>
|
||||||
|
<span class="text-blue-600/90 ml-1">ISO 8601 (UTC)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Relógio com GMT Ajustado -->
|
||||||
|
<div class="bg-gradient-to-br from-primary/10 to-primary/20 rounded-xl p-6 border-2 border-primary/30">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-sm font-semibold text-primary uppercase tracking-wide mb-2">
|
||||||
|
Horário com GMT {gmtOffset >= 0 ? '+' : ''}{gmtOffset}
|
||||||
|
</div>
|
||||||
|
<div class="text-5xl font-bold text-primary mb-2 font-mono">
|
||||||
|
{formatarRelogio(timestampUTC, gmtOffset)}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-primary/80 mb-1">
|
||||||
|
{formatarDataRelogio(timestampUTC, gmtOffset)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="divider my-3 opacity-30"></div>
|
||||||
|
|
||||||
|
<!-- Detalhes do Fuso Horário Aplicado -->
|
||||||
|
<div class="text-left space-y-2 mt-4">
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-semibold text-primary">Fuso Horário:</span>
|
||||||
|
<span class="text-primary/90 ml-1">{obterNomeFusoHorario(gmtOffset)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-semibold text-primary">Offset Configurado:</span>
|
||||||
|
<span class="text-primary/90 ml-1">
|
||||||
|
GMT{gmtOffset >= 0 ? '+' : ''}{gmtOffset} ({calcularDiferencaFuso(gmtOffset)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-semibold text-primary">Ajuste Aplicado:</span>
|
||||||
|
<span class="text-primary/90 ml-1">
|
||||||
|
{gmtOffset >= 0 ? '+' : ''}{gmtOffset}:00 UTC
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if timestampUTC}
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-semibold text-primary">Timestamp Local:</span>
|
||||||
|
<span class="text-primary/90 ml-1 font-mono text-[10px]">
|
||||||
|
{formatarTimestampISO(gmtOffset !== 0 ? timestampUTC + (gmtOffset * 60 * 60 * 1000) : timestampUTC)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-semibold text-primary">Status:</span>
|
||||||
|
<span class="text-primary/90 ml-1">Ajuste aplicado em tempo real</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Formulário -->
|
<!-- Formulário -->
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -226,6 +561,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Status de Sincronização -->
|
||||||
|
{#if statusSincronizacao}
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h2 class="card-title mb-4">Status de Sincronização</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="stat bg-base-200 rounded-lg p-4">
|
||||||
|
<div class="stat-title text-xs">Última Sincronização</div>
|
||||||
|
<div class="stat-value text-lg">
|
||||||
|
{formatarDataHora(statusSincronizacao.ultimaSincronizacao)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-base-200 rounded-lg p-4">
|
||||||
|
<div class="stat-title text-xs">Offset Calculado</div>
|
||||||
|
<div class="stat-value text-lg">
|
||||||
|
{statusSincronizacao.offsetSegundos !== null
|
||||||
|
? `${statusSincronizacao.offsetSegundos > 0 ? '+' : ''}${statusSincronizacao.offsetSegundos}s`
|
||||||
|
: 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-base-200 rounded-lg p-4">
|
||||||
|
<div class="stat-title text-xs">Fonte de Tempo</div>
|
||||||
|
<div class="stat-value text-lg">
|
||||||
|
{statusSincronizacao.usandoServidorExterno ? 'Servidor NTP' : 'Relógio do PC'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Ações -->
|
<!-- Ações -->
|
||||||
<div class="card-actions justify-end mt-6 gap-3">
|
<div class="card-actions justify-end mt-6 gap-3">
|
||||||
{#if usarServidorExterno}
|
{#if usarServidorExterno}
|
||||||
@@ -272,7 +635,15 @@
|
|||||||
específica.
|
específica.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm mt-1">
|
<p class="text-sm mt-1">
|
||||||
Servidores NTP recomendados: pool.ntp.org, time.google.com, time.windows.com
|
Servidores NTP recomendados: pool.ntp.org, time.google.com, time.windows.com, ntp.br
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mt-2">
|
||||||
|
<strong>Como funciona:</strong> O servidor NTP configurado é mapeado para uma API HTTP que retorna UTC.
|
||||||
|
O GMT offset configurado é então aplicado no frontend para exibir o horário correto.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mt-1">
|
||||||
|
<strong>Importante:</strong> Todos os servidores NTP retornam tempo em UTC. O GMT offset é aplicado
|
||||||
|
apenas uma vez no frontend para ajustar ao fuso horário local (ex: GMT-3 para Brasília).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import Sidebar from "$lib/components/Sidebar.svelte";
|
import Sidebar from "$lib/components/Sidebar.svelte";
|
||||||
import { createSvelteAuthClient } from "@mmailaender/convex-better-auth-svelte/svelte";
|
import { createSvelteAuthClient } from "@mmailaender/convex-better-auth-svelte/svelte";
|
||||||
import { authClient } from "$lib/auth";
|
import { authClient } from "$lib/auth";
|
||||||
|
// Importar polyfill ANTES de qualquer outro código que possa usar Jitsi
|
||||||
|
import "$lib/utils/jitsiPolyfill";
|
||||||
|
|
||||||
const { children } = $props();
|
const { children } = $props();
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,10 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
dedupe: ["lucide-svelte"],
|
dedupe: ["lucide-svelte"],
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ["lib-jitsi-meet"], // Excluir para permitir carregamento dinâmico no browser
|
||||||
|
},
|
||||||
|
ssr: {
|
||||||
|
noExternal: [], // lib-jitsi-meet não funciona no SSR, deve ser carregada apenas no browser
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
24
bun.lock
24
bun.lock
@@ -78,9 +78,11 @@
|
|||||||
"@convex-dev/better-auth": "^0.9.7",
|
"@convex-dev/better-auth": "^0.9.7",
|
||||||
"@convex-dev/rate-limiter": "^0.3.0",
|
"@convex-dev/rate-limiter": "^0.3.0",
|
||||||
"@dicebear/avataaars": "^9.2.4",
|
"@dicebear/avataaars": "^9.2.4",
|
||||||
|
"@types/ssh2": "^1.15.5",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"convex": "catalog:",
|
"convex": "catalog:",
|
||||||
"nodemailer": "^7.0.10",
|
"nodemailer": "^7.0.10",
|
||||||
|
"ssh2": "^1.17.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sgse-app/eslint-config": "*",
|
"@sgse-app/eslint-config": "*",
|
||||||
@@ -616,6 +618,8 @@
|
|||||||
|
|
||||||
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
|
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
|
||||||
|
|
||||||
|
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
|
||||||
|
|
||||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/type-utils": "8.46.3", "@typescript-eslint/utils": "8.46.3", "@typescript-eslint/visitor-keys": "8.46.3", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.3", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw=="],
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/type-utils": "8.46.3", "@typescript-eslint/utils": "8.46.3", "@typescript-eslint/visitor-keys": "8.46.3", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.3", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw=="],
|
||||||
@@ -664,6 +668,8 @@
|
|||||||
|
|
||||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||||
|
|
||||||
|
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
||||||
|
|
||||||
"asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="],
|
"asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="],
|
||||||
|
|
||||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||||
@@ -680,6 +686,8 @@
|
|||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="],
|
||||||
|
|
||||||
|
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
||||||
|
|
||||||
"better-auth": ["better-auth@1.3.27", "", { "dependencies": { "@better-auth/core": "1.3.27", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.19", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.5" } }, "sha512-SwiGAJ7yU6dBhNg0NdV1h5M8T5sa7/AszZVc4vBfMDrLLmvUfbt9JoJ0uRUJUEdKRAAxTyl9yA+F3+GhtAD80w=="],
|
"better-auth": ["better-auth@1.3.27", "", { "dependencies": { "@better-auth/core": "1.3.27", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.19", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.5" } }, "sha512-SwiGAJ7yU6dBhNg0NdV1h5M8T5sa7/AszZVc4vBfMDrLLmvUfbt9JoJ0uRUJUEdKRAAxTyl9yA+F3+GhtAD80w=="],
|
||||||
|
|
||||||
"better-call": ["better-call@1.0.19", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw=="],
|
"better-call": ["better-call@1.0.19", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw=="],
|
||||||
@@ -692,6 +700,8 @@
|
|||||||
|
|
||||||
"browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="],
|
"browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="],
|
||||||
|
|
||||||
|
"buildcheck": ["buildcheck@0.0.6", "", {}, "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A=="],
|
||||||
|
|
||||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||||
|
|
||||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
@@ -730,6 +740,8 @@
|
|||||||
|
|
||||||
"core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="],
|
"core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="],
|
||||||
|
|
||||||
|
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
||||||
@@ -1060,6 +1072,8 @@
|
|||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"nan": ["nan@2.23.1", "", {}, "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
"nanostores": ["nanostores@1.0.1", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="],
|
"nanostores": ["nanostores@1.0.1", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="],
|
||||||
@@ -1190,6 +1204,8 @@
|
|||||||
|
|
||||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||||
|
|
||||||
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
@@ -1218,6 +1234,8 @@
|
|||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
|
||||||
|
|
||||||
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
|
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
|
||||||
|
|
||||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||||
@@ -1284,6 +1302,8 @@
|
|||||||
|
|
||||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-eFC5XzLmgXJfnAK3UMTmVECCwuBcORrWdewoiXBnUm934DY6QN8YowC/srhNnROMpaKaqNeRpoB5FxCww3eteQ=="],
|
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-eFC5XzLmgXJfnAK3UMTmVECCwuBcORrWdewoiXBnUm934DY6QN8YowC/srhNnROMpaKaqNeRpoB5FxCww3eteQ=="],
|
||||||
|
|
||||||
|
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||||
|
|
||||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
|
|
||||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||||
@@ -1370,6 +1390,8 @@
|
|||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
"@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||||
@@ -1398,6 +1420,8 @@
|
|||||||
|
|
||||||
"@convex-dev/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.38.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.38.0", "@typescript-eslint/tsconfig-utils": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ=="],
|
"@convex-dev/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.38.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.38.0", "@typescript-eslint/tsconfig-utils": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ=="],
|
||||||
|
|
||||||
|
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||||
|
|||||||
4
packages/backend/convex/_generated/api.d.ts
vendored
4
packages/backend/convex/_generated/api.d.ts
vendored
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as actions_email from "../actions/email.js";
|
import type * as actions_email from "../actions/email.js";
|
||||||
|
import type * as actions_jitsiServer from "../actions/jitsiServer.js";
|
||||||
import type * as actions_linkPreview from "../actions/linkPreview.js";
|
import type * as actions_linkPreview from "../actions/linkPreview.js";
|
||||||
import type * as actions_pushNotifications from "../actions/pushNotifications.js";
|
import type * as actions_pushNotifications from "../actions/pushNotifications.js";
|
||||||
import type * as actions_smtp from "../actions/smtp.js";
|
import type * as actions_smtp from "../actions/smtp.js";
|
||||||
@@ -22,6 +23,7 @@ import type * as chamadas from "../chamadas.js";
|
|||||||
import type * as chamados from "../chamados.js";
|
import type * as chamados from "../chamados.js";
|
||||||
import type * as chat from "../chat.js";
|
import type * as chat from "../chat.js";
|
||||||
import type * as configuracaoEmail from "../configuracaoEmail.js";
|
import type * as configuracaoEmail from "../configuracaoEmail.js";
|
||||||
|
import type * as configuracaoJitsi from "../configuracaoJitsi.js";
|
||||||
import type * as configuracaoPonto from "../configuracaoPonto.js";
|
import type * as configuracaoPonto from "../configuracaoPonto.js";
|
||||||
import type * as configuracaoRelogio from "../configuracaoRelogio.js";
|
import type * as configuracaoRelogio from "../configuracaoRelogio.js";
|
||||||
import type * as contratos from "../contratos.js";
|
import type * as contratos from "../contratos.js";
|
||||||
@@ -65,6 +67,7 @@ import type {
|
|||||||
|
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
"actions/email": typeof actions_email;
|
"actions/email": typeof actions_email;
|
||||||
|
"actions/jitsiServer": typeof actions_jitsiServer;
|
||||||
"actions/linkPreview": typeof actions_linkPreview;
|
"actions/linkPreview": typeof actions_linkPreview;
|
||||||
"actions/pushNotifications": typeof actions_pushNotifications;
|
"actions/pushNotifications": typeof actions_pushNotifications;
|
||||||
"actions/smtp": typeof actions_smtp;
|
"actions/smtp": typeof actions_smtp;
|
||||||
@@ -78,6 +81,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
chamados: typeof chamados;
|
chamados: typeof chamados;
|
||||||
chat: typeof chat;
|
chat: typeof chat;
|
||||||
configuracaoEmail: typeof configuracaoEmail;
|
configuracaoEmail: typeof configuracaoEmail;
|
||||||
|
configuracaoJitsi: typeof configuracaoJitsi;
|
||||||
configuracaoPonto: typeof configuracaoPonto;
|
configuracaoPonto: typeof configuracaoPonto;
|
||||||
configuracaoRelogio: typeof configuracaoRelogio;
|
configuracaoRelogio: typeof configuracaoRelogio;
|
||||||
contratos: typeof contratos;
|
contratos: typeof contratos;
|
||||||
|
|||||||
424
packages/backend/convex/actions/jitsiServer.ts
Normal file
424
packages/backend/convex/actions/jitsiServer.ts
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
"use node";
|
||||||
|
|
||||||
|
import { action } from "../_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { api, internal } from "../_generated/api";
|
||||||
|
import { Client } from "ssh2";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { decryptSMTPPasswordNode } from "./utils/nodeCrypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para configuração SSH
|
||||||
|
*/
|
||||||
|
interface SSHConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
keyPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executar comando via SSH
|
||||||
|
*/
|
||||||
|
async function executarComandoSSH(
|
||||||
|
config: SSHConfig,
|
||||||
|
comando: string
|
||||||
|
): Promise<{ sucesso: boolean; output: string; erro?: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const conn = new Client();
|
||||||
|
let output = "";
|
||||||
|
let errorOutput = "";
|
||||||
|
|
||||||
|
conn.on("ready", () => {
|
||||||
|
conn.exec(comando, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
conn.end();
|
||||||
|
resolve({ sucesso: false, output: "", erro: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream
|
||||||
|
.on("close", (code: number | null, signal: string | null) => {
|
||||||
|
conn.end();
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ sucesso: true, output: output.trim() });
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
sucesso: false,
|
||||||
|
output: output.trim(),
|
||||||
|
erro: `Comando retornou código ${code}${signal ? ` (signal: ${signal})` : ""}. ${errorOutput}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("data", (data: Buffer) => {
|
||||||
|
output += data.toString();
|
||||||
|
})
|
||||||
|
.stderr.on("data", (data: Buffer) => {
|
||||||
|
errorOutput += data.toString();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).on("error", (err) => {
|
||||||
|
resolve({ sucesso: false, output: "", erro: err.message });
|
||||||
|
}).connect({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
username: config.username,
|
||||||
|
password: config.password,
|
||||||
|
privateKey: config.keyPath ? readFileSync(config.keyPath) : undefined,
|
||||||
|
readyTimeout: 10000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ler arquivo via SSH
|
||||||
|
*/
|
||||||
|
async function lerArquivoSSH(
|
||||||
|
config: SSHConfig,
|
||||||
|
caminho: string
|
||||||
|
): Promise<{ sucesso: boolean; conteudo?: string; erro?: string }> {
|
||||||
|
const comando = `cat "${caminho}" 2>&1`;
|
||||||
|
const resultado = await executarComandoSSH(config, comando);
|
||||||
|
|
||||||
|
if (!resultado.sucesso) {
|
||||||
|
return { sucesso: false, erro: resultado.erro || "Erro ao ler arquivo" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sucesso: true, conteudo: resultado.output };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escrever arquivo via SSH
|
||||||
|
*/
|
||||||
|
async function escreverArquivoSSH(
|
||||||
|
config: SSHConfig,
|
||||||
|
caminho: string,
|
||||||
|
conteudo: string
|
||||||
|
): Promise<{ sucesso: boolean; erro?: string }> {
|
||||||
|
// Escapar conteúdo para shell
|
||||||
|
const conteudoEscapado = conteudo
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\$/g, "\\$")
|
||||||
|
.replace(/`/g, "\\`");
|
||||||
|
|
||||||
|
const comando = `cat > "${caminho}" << 'JITSI_CONFIG_EOF'
|
||||||
|
${conteudo}
|
||||||
|
JITSI_CONFIG_EOF`;
|
||||||
|
|
||||||
|
const resultado = await executarComandoSSH(config, comando);
|
||||||
|
|
||||||
|
if (!resultado.sucesso) {
|
||||||
|
return { sucesso: false, erro: resultado.erro || "Erro ao escrever arquivo" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sucesso: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplicar configurações do Jitsi no servidor Docker via SSH
|
||||||
|
*/
|
||||||
|
export const aplicarConfiguracaoServidor = action({
|
||||||
|
args: {
|
||||||
|
configId: v.id("configuracaoJitsi"),
|
||||||
|
sshPassword: v.optional(v.string()), // Senha SSH (se não usar chave)
|
||||||
|
},
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
sucesso: v.literal(true),
|
||||||
|
mensagem: v.string(),
|
||||||
|
detalhes: v.optional(v.string()),
|
||||||
|
}),
|
||||||
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||||
|
),
|
||||||
|
handler: async (ctx, args): Promise<
|
||||||
|
| { sucesso: true; mensagem: string; detalhes?: string }
|
||||||
|
| { sucesso: false; erro: string }
|
||||||
|
> => {
|
||||||
|
try {
|
||||||
|
// Buscar configuração
|
||||||
|
const config = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||||
|
|
||||||
|
if (!config || config._id !== args.configId) {
|
||||||
|
return { sucesso: false as const, erro: "Configuração não encontrada" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se tem configurações SSH
|
||||||
|
const configFull = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsiCompleta, {
|
||||||
|
configId: args.configId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configFull || !configFull.sshHost) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: "Configurações SSH não estão definidas. Configure o servidor SSH primeiro.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configurar SSH
|
||||||
|
let sshPasswordDecrypted: string | undefined = undefined;
|
||||||
|
|
||||||
|
// Se senha foi fornecida, usar ela. Caso contrário, tentar descriptografar a armazenada
|
||||||
|
if (args.sshPassword) {
|
||||||
|
sshPasswordDecrypted = args.sshPassword;
|
||||||
|
} else if (configFull.sshPasswordHash && configFull.sshPasswordHash !== "********") {
|
||||||
|
// Tentar descriptografar senha armazenada
|
||||||
|
try {
|
||||||
|
sshPasswordDecrypted = await decryptSMTPPasswordNode(configFull.sshPasswordHash);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: "Não foi possível descriptografar a senha SSH armazenada. Forneça a senha novamente.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshConfig: SSHConfig = {
|
||||||
|
host: configFull.sshHost,
|
||||||
|
port: configFull.sshPort || 22,
|
||||||
|
username: configFull.sshUsername || "",
|
||||||
|
password: sshPasswordDecrypted,
|
||||||
|
keyPath: configFull.sshKeyPath || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sshConfig.username) {
|
||||||
|
return { sucesso: false as const, erro: "Usuário SSH não configurado" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sshConfig.password && !sshConfig.keyPath) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: "Senha SSH ou caminho da chave deve ser fornecido",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = configFull.jitsiConfigPath || "~/.jitsi-meet-cfg";
|
||||||
|
const dockerComposePath = configFull.dockerComposePath || ".";
|
||||||
|
|
||||||
|
// Extrair host e porta do domain
|
||||||
|
const [host, portStr] = configFull.domain.split(":");
|
||||||
|
const port = portStr ? parseInt(portStr, 10) : configFull.useHttps ? 443 : 80;
|
||||||
|
const protocol = configFull.useHttps ? "https" : "http";
|
||||||
|
|
||||||
|
const detalhes: string[] = [];
|
||||||
|
|
||||||
|
// 1. Atualizar arquivo .env do docker-compose
|
||||||
|
if (dockerComposePath) {
|
||||||
|
const envContent = `# Configuração Jitsi - Atualizada automaticamente pelo SGSE
|
||||||
|
CONFIG=${basePath}
|
||||||
|
TZ=America/Recife
|
||||||
|
ENABLE_LETSENCRYPT=0
|
||||||
|
HTTP_PORT=${protocol === "https" ? 8000 : port}
|
||||||
|
HTTPS_PORT=${configFull.useHttps ? port : 8443}
|
||||||
|
PUBLIC_URL=${protocol}://${host}${portStr ? `:${port}` : ""}
|
||||||
|
DOMAIN=${host}
|
||||||
|
ENABLE_AUTH=0
|
||||||
|
ENABLE_GUESTS=1
|
||||||
|
ENABLE_TRANSCRIPTION=0
|
||||||
|
ENABLE_RECORDING=0
|
||||||
|
ENABLE_PREJOIN_PAGE=0
|
||||||
|
START_AUDIO_MUTED=0
|
||||||
|
START_VIDEO_MUTED=0
|
||||||
|
ENABLE_XMPP_WEBSOCKET=0
|
||||||
|
ENABLE_P2P=1
|
||||||
|
MAX_NUMBER_OF_PARTICIPANTS=10
|
||||||
|
RESOLUTION_WIDTH=1280
|
||||||
|
RESOLUTION_HEIGHT=720
|
||||||
|
JWT_APP_ID=${configFull.appId}
|
||||||
|
JWT_APP_SECRET=
|
||||||
|
`;
|
||||||
|
|
||||||
|
const envPath = `${dockerComposePath}/.env`;
|
||||||
|
const resultadoEnv = await escreverArquivoSSH(sshConfig, envPath, envContent);
|
||||||
|
|
||||||
|
if (!resultadoEnv.sucesso) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: `Erro ao atualizar .env: ${resultadoEnv.erro}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
detalhes.push(`✓ Arquivo .env atualizado: ${envPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Atualizar configuração do Prosody
|
||||||
|
const prosodyConfigPath = `${basePath}/prosody/config/${host}.cfg.lua`;
|
||||||
|
const prosodyContent = `-- Configuração Prosody para ${host}
|
||||||
|
-- Gerada automaticamente pelo SGSE
|
||||||
|
|
||||||
|
VirtualHost "${host}"
|
||||||
|
authentication = "anonymous"
|
||||||
|
modules_enabled = {
|
||||||
|
"bosh";
|
||||||
|
"ping";
|
||||||
|
"speakerstats";
|
||||||
|
"turncredentials";
|
||||||
|
"presence";
|
||||||
|
"conference_duration";
|
||||||
|
}
|
||||||
|
c2s_require_encryption = false
|
||||||
|
allow_anonymous_s2s = false
|
||||||
|
|
||||||
|
Component "conference.${host}" "muc"
|
||||||
|
storage = "memory"
|
||||||
|
muc_room_locking = false
|
||||||
|
muc_room_default_public_jids = true
|
||||||
|
|
||||||
|
Component "jitsi-videobridge.${host}"
|
||||||
|
component_secret = ""
|
||||||
|
|
||||||
|
Component "focus.${host}"
|
||||||
|
component_secret = ""
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resultadoProsody = await escreverArquivoSSH(sshConfig, prosodyConfigPath, prosodyContent);
|
||||||
|
|
||||||
|
if (!resultadoProsody.sucesso) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: `Erro ao atualizar Prosody: ${resultadoProsody.erro}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
detalhes.push(`✓ Configuração Prosody atualizada: ${prosodyConfigPath}`);
|
||||||
|
|
||||||
|
// 3. Atualizar configuração do Jicofo
|
||||||
|
const jicofoConfigPath = `${basePath}/jicofo/sip-communicator.properties`;
|
||||||
|
const jicofoContent = `# Configuração Jicofo
|
||||||
|
# Gerada automaticamente pelo SGSE
|
||||||
|
org.jitsi.jicofo.BRIDGE_MUC=JvbBrewery@internal.${host}
|
||||||
|
org.jitsi.jicofo.jid=XMPP_USER@${host}
|
||||||
|
org.jitsi.jicofo.BRIDGE_MUC_JID=MUC_BRIDGE_JID@internal.${host}
|
||||||
|
org.jitsi.jicofo.app.ID=${configFull.appId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resultadoJicofo = await escreverArquivoSSH(sshConfig, jicofoConfigPath, jicofoContent);
|
||||||
|
|
||||||
|
if (!resultadoJicofo.sucesso) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: `Erro ao atualizar Jicofo: ${resultadoJicofo.erro}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
detalhes.push(`✓ Configuração Jicofo atualizada: ${jicofoConfigPath}`);
|
||||||
|
|
||||||
|
// 4. Atualizar configuração do JVB
|
||||||
|
const jvbConfigPath = `${basePath}/jvb/sip-communicator.properties`;
|
||||||
|
const jvbContent = `# Configuração JVB (Jitsi Video Bridge)
|
||||||
|
# Gerada automaticamente pelo SGSE
|
||||||
|
org.jitsi.videobridge.AUTHORIZED_SOURCE_REGEXP=.*@${host}/.*
|
||||||
|
org.jitsi.videobridge.xmpp.user.shard.HOSTNAME=${host}
|
||||||
|
org.jitsi.videobridge.xmpp.user.shard.DOMAIN=auth.${host}
|
||||||
|
org.jitsi.videobridge.xmpp.user.shard.USERNAME=jvb
|
||||||
|
org.jitsi.videobridge.xmpp.user.shard.MUC_JIDS=JvbBrewery@internal.${host}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resultadoJvb = await escreverArquivoSSH(sshConfig, jvbConfigPath, jvbContent);
|
||||||
|
|
||||||
|
if (!resultadoJvb.sucesso) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: `Erro ao atualizar JVB: ${resultadoJvb.erro}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
detalhes.push(`✓ Configuração JVB atualizada: ${jvbConfigPath}`);
|
||||||
|
|
||||||
|
// 5. Reiniciar containers Docker
|
||||||
|
if (dockerComposePath) {
|
||||||
|
const resultadoRestart = await executarComandoSSH(
|
||||||
|
sshConfig,
|
||||||
|
`cd "${dockerComposePath}" && docker-compose restart 2>&1 || docker compose restart 2>&1`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resultadoRestart.sucesso) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: `Erro ao reiniciar containers: ${resultadoRestart.erro}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
detalhes.push(`✓ Containers Docker reiniciados`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar timestamp de configuração no servidor
|
||||||
|
await ctx.runMutation(internal.configuracaoJitsi.marcarConfiguradoNoServidor, {
|
||||||
|
configId: args.configId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sucesso: true as const,
|
||||||
|
mensagem: "Configurações aplicadas com sucesso no servidor Jitsi",
|
||||||
|
detalhes: detalhes.join("\n"),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: `Erro ao aplicar configurações: ${errorMessage}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testar conexão SSH
|
||||||
|
*/
|
||||||
|
export const testarConexaoSSH = action({
|
||||||
|
args: {
|
||||||
|
sshHost: v.string(),
|
||||||
|
sshPort: v.optional(v.number()),
|
||||||
|
sshUsername: v.string(),
|
||||||
|
sshPassword: v.optional(v.string()),
|
||||||
|
sshKeyPath: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
returns: v.union(
|
||||||
|
v.object({ sucesso: v.literal(true), mensagem: v.string() }),
|
||||||
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||||
|
),
|
||||||
|
handler: async (ctx, args): Promise<
|
||||||
|
| { sucesso: true; mensagem: string }
|
||||||
|
| { sucesso: false; erro: string }
|
||||||
|
> => {
|
||||||
|
try {
|
||||||
|
if (!args.sshPassword && !args.sshKeyPath) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: "Senha SSH ou caminho da chave deve ser fornecido",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshConfig: SSHConfig = {
|
||||||
|
host: args.sshHost,
|
||||||
|
port: args.sshPort || 22,
|
||||||
|
username: args.sshUsername,
|
||||||
|
password: args.sshPassword || undefined,
|
||||||
|
keyPath: args.sshKeyPath || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tentar executar um comando simples
|
||||||
|
const resultado = await executarComandoSSH(sshConfig, "echo 'SSH_OK'");
|
||||||
|
|
||||||
|
if (resultado.sucesso && resultado.output.includes("SSH_OK")) {
|
||||||
|
return {
|
||||||
|
sucesso: true as const,
|
||||||
|
mensagem: "Conexão SSH estabelecida com sucesso",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: resultado.erro || "Falha ao estabelecer conexão SSH",
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: `Erro ao testar SSH: ${errorMessage}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -19,11 +19,25 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gerar nome único para a sala Jitsi
|
* Gerar nome único para a sala Jitsi
|
||||||
|
* Usa configuração do backend se disponível, senão usa padrão 'sgse'
|
||||||
*/
|
*/
|
||||||
function gerarRoomName(conversaId: Id<'conversas'>, tipo: 'audio' | 'video'): string {
|
async function gerarRoomName(
|
||||||
|
ctx: QueryCtx | MutationCtx,
|
||||||
|
conversaId: Id<'conversas'>,
|
||||||
|
tipo: 'audio' | 'video'
|
||||||
|
): Promise<string> {
|
||||||
|
// Buscar configuração Jitsi ativa
|
||||||
|
const configJitsi = await ctx.db
|
||||||
|
.query('configuracaoJitsi')
|
||||||
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const roomPrefix = configJitsi?.roomPrefix || 'sgse';
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const random = Math.random().toString(36).substring(2, 9);
|
const random = Math.random().toString(36).substring(2, 9);
|
||||||
return `sgse-${tipo}-${conversaId.replace('conversas|', '')}-${timestamp}-${random}`;
|
const conversaHash = conversaId.replace('conversas|', '').replace(/[^a-zA-Z0-9]/g, '').substring(0, 10);
|
||||||
|
|
||||||
|
return `${roomPrefix}-${tipo}-${conversaHash}-${timestamp}-${random}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,7 +110,7 @@ export const criarChamada = mutation({
|
|||||||
if (!conversa) throw new Error('Conversa não encontrada');
|
if (!conversa) throw new Error('Conversa não encontrada');
|
||||||
|
|
||||||
// Gerar nome único da sala
|
// Gerar nome único da sala
|
||||||
const roomName = gerarRoomName(args.conversaId, args.tipo);
|
const roomName = await gerarRoomName(ctx, args.conversaId, args.tipo);
|
||||||
|
|
||||||
// Criar chamada
|
// Criar chamada
|
||||||
const chamadaId = await ctx.db.insert('chamadas', {
|
const chamadaId = await ctx.db.insert('chamadas', {
|
||||||
|
|||||||
372
packages/backend/convex/configuracaoJitsi.ts
Normal file
372
packages/backend/convex/configuracaoJitsi.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import { v } from "convex/values";
|
||||||
|
import { mutation, query, action, internalMutation } from "./_generated/server";
|
||||||
|
import { registrarAtividade } from "./logsAtividades";
|
||||||
|
import { api, internal } from "./_generated/api";
|
||||||
|
import { encryptSMTPPassword } from "./auth/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter configuração de Jitsi ativa
|
||||||
|
*/
|
||||||
|
export const obterConfigJitsi = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const config = await ctx.db
|
||||||
|
.query("configuracaoJitsi")
|
||||||
|
.withIndex("by_ativo", (q) => q.eq("ativo", true))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: config._id,
|
||||||
|
domain: config.domain,
|
||||||
|
appId: config.appId,
|
||||||
|
roomPrefix: config.roomPrefix,
|
||||||
|
useHttps: config.useHttps,
|
||||||
|
acceptSelfSignedCert: config.acceptSelfSignedCert ?? false, // Default para false se não existir
|
||||||
|
ativo: config.ativo,
|
||||||
|
testadoEm: config.testadoEm,
|
||||||
|
atualizadoEm: config.atualizadoEm,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter configuração completa de Jitsi (incluindo SSH, mas sem senha)
|
||||||
|
*/
|
||||||
|
export const obterConfigJitsiCompleta = query({
|
||||||
|
args: {
|
||||||
|
configId: v.id("configuracaoJitsi"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const config = await ctx.db.get(args.configId);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: config._id,
|
||||||
|
domain: config.domain,
|
||||||
|
appId: config.appId,
|
||||||
|
roomPrefix: config.roomPrefix,
|
||||||
|
useHttps: config.useHttps,
|
||||||
|
acceptSelfSignedCert: config.acceptSelfSignedCert ?? false,
|
||||||
|
ativo: config.ativo,
|
||||||
|
testadoEm: config.testadoEm,
|
||||||
|
atualizadoEm: config.atualizadoEm,
|
||||||
|
configuradoEm: config.configuradoEm,
|
||||||
|
// Configurações SSH (sem senha)
|
||||||
|
sshHost: config.sshHost,
|
||||||
|
sshPort: config.sshPort,
|
||||||
|
sshUsername: config.sshUsername,
|
||||||
|
sshPasswordHash: config.sshPasswordHash ? "********" : undefined, // Mascarar
|
||||||
|
sshKeyPath: config.sshKeyPath,
|
||||||
|
dockerComposePath: config.dockerComposePath,
|
||||||
|
jitsiConfigPath: config.jitsiConfigPath,
|
||||||
|
configuradoNoServidor: config.configuradoNoServidor ?? false,
|
||||||
|
configuradoNoServidorEm: config.configuradoNoServidorEm,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salvar configuração de Jitsi (apenas TI_MASTER)
|
||||||
|
*/
|
||||||
|
export const salvarConfigJitsi = mutation({
|
||||||
|
args: {
|
||||||
|
domain: v.string(),
|
||||||
|
appId: v.string(),
|
||||||
|
roomPrefix: v.string(),
|
||||||
|
useHttps: v.boolean(),
|
||||||
|
acceptSelfSignedCert: v.boolean(),
|
||||||
|
configuradoPorId: v.id("usuarios"),
|
||||||
|
// Opcionais: configurações SSH/Docker
|
||||||
|
sshHost: v.optional(v.string()),
|
||||||
|
sshPort: v.optional(v.number()),
|
||||||
|
sshUsername: v.optional(v.string()),
|
||||||
|
sshPassword: v.optional(v.string()), // Senha nova (será criptografada)
|
||||||
|
sshKeyPath: v.optional(v.string()),
|
||||||
|
dockerComposePath: v.optional(v.string()),
|
||||||
|
jitsiConfigPath: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
returns: v.union(
|
||||||
|
v.object({ sucesso: v.literal(true), configId: v.id("configuracaoJitsi") }),
|
||||||
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Validar domínio (deve ser não vazio)
|
||||||
|
if (!args.domain || args.domain.trim().length === 0) {
|
||||||
|
return { sucesso: false as const, erro: "Domínio não pode estar vazio" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar appId (deve ser não vazio)
|
||||||
|
if (!args.appId || args.appId.trim().length === 0) {
|
||||||
|
return { sucesso: false as const, erro: "App ID não pode estar vazio" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar roomPrefix (deve ser não vazio e alfanumérico)
|
||||||
|
if (!args.roomPrefix || args.roomPrefix.trim().length === 0) {
|
||||||
|
return { sucesso: false as const, erro: "Prefixo de sala não pode estar vazio" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar formato do roomPrefix (apenas letras, números e hífens)
|
||||||
|
const roomPrefixRegex = /^[a-zA-Z0-9-]+$/;
|
||||||
|
if (!roomPrefixRegex.test(args.roomPrefix.trim())) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: "Prefixo de sala deve conter apenas letras, números e hífens",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar config ativa anterior para manter senha SSH se não fornecida
|
||||||
|
const configAtiva = await ctx.db
|
||||||
|
.query("configuracaoJitsi")
|
||||||
|
.withIndex("by_ativo", (q) => q.eq("ativo", true))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
// Desativar config anterior
|
||||||
|
const configsAntigas = await ctx.db
|
||||||
|
.query("configuracaoJitsi")
|
||||||
|
.withIndex("by_ativo", (q) => q.eq("ativo", true))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const config of configsAntigas) {
|
||||||
|
await ctx.db.patch(config._id, { ativo: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar senha SSH: usar nova senha se fornecida, senão manter a atual
|
||||||
|
let sshPasswordHash: string | undefined = undefined;
|
||||||
|
if (args.sshPassword && args.sshPassword.trim().length > 0) {
|
||||||
|
// Nova senha fornecida, criptografar
|
||||||
|
sshPasswordHash = await encryptSMTPPassword(args.sshPassword);
|
||||||
|
} else if (configAtiva && configAtiva.sshPasswordHash) {
|
||||||
|
// Senha não fornecida, manter a atual (já criptografada)
|
||||||
|
sshPasswordHash = configAtiva.sshPasswordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar nova config
|
||||||
|
const configId = await ctx.db.insert("configuracaoJitsi", {
|
||||||
|
domain: args.domain.trim(),
|
||||||
|
appId: args.appId.trim(),
|
||||||
|
roomPrefix: args.roomPrefix.trim(),
|
||||||
|
useHttps: args.useHttps,
|
||||||
|
acceptSelfSignedCert: args.acceptSelfSignedCert,
|
||||||
|
ativo: true,
|
||||||
|
configuradoPor: args.configuradoPorId,
|
||||||
|
atualizadoEm: Date.now(),
|
||||||
|
// Configurações SSH/Docker
|
||||||
|
sshHost: args.sshHost?.trim() || undefined,
|
||||||
|
sshPort: args.sshPort || undefined,
|
||||||
|
sshUsername: args.sshUsername?.trim() || undefined,
|
||||||
|
sshPasswordHash: sshPasswordHash,
|
||||||
|
sshKeyPath: args.sshKeyPath?.trim() || undefined,
|
||||||
|
dockerComposePath: args.dockerComposePath?.trim() || undefined,
|
||||||
|
jitsiConfigPath: args.jitsiConfigPath?.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log de atividade
|
||||||
|
await registrarAtividade(
|
||||||
|
ctx,
|
||||||
|
args.configuradoPorId,
|
||||||
|
"configurar",
|
||||||
|
"jitsi",
|
||||||
|
JSON.stringify({ domain: args.domain, appId: args.appId }),
|
||||||
|
configId
|
||||||
|
);
|
||||||
|
|
||||||
|
return { sucesso: true as const, configId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation interna para atualizar testadoEm
|
||||||
|
*/
|
||||||
|
export const atualizarTestadoEm = internalMutation({
|
||||||
|
args: {
|
||||||
|
configId: v.id("configuracaoJitsi"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.configId, {
|
||||||
|
testadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testar conexão com servidor Jitsi
|
||||||
|
*/
|
||||||
|
export const testarConexaoJitsi = action({
|
||||||
|
args: {
|
||||||
|
domain: v.string(),
|
||||||
|
useHttps: v.boolean(),
|
||||||
|
acceptSelfSignedCert: v.optional(v.boolean()),
|
||||||
|
},
|
||||||
|
returns: v.union(
|
||||||
|
v.object({ sucesso: v.literal(true), aviso: v.optional(v.string()) }),
|
||||||
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||||
|
),
|
||||||
|
handler: async (ctx, args): Promise<{ sucesso: true; aviso?: string } | { sucesso: false; erro: string }> => {
|
||||||
|
// Validações básicas
|
||||||
|
if (!args.domain || args.domain.trim().length === 0) {
|
||||||
|
return { sucesso: false as const, erro: "Domínio não pode estar vazio" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const protocol = args.useHttps ? "https" : "http";
|
||||||
|
// Extrair host e porta do domain
|
||||||
|
const [host, portStr] = args.domain.split(":");
|
||||||
|
const port = portStr ? parseInt(portStr, 10) : args.useHttps ? 443 : 80;
|
||||||
|
const url = `${protocol}://${host}:${port}/http-bind`;
|
||||||
|
|
||||||
|
// Tentar fazer uma requisição HTTP para verificar se o servidor está acessível
|
||||||
|
// Nota: No ambiente Node.js do Convex, podemos usar fetch
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 segundos de timeout
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/xml",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// Qualquer resposta indica que o servidor está acessível
|
||||||
|
// Não precisamos verificar o status code exato, apenas se há resposta
|
||||||
|
if (response.status >= 200 && response.status < 600) {
|
||||||
|
// Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm
|
||||||
|
const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||||
|
|
||||||
|
if (configAtiva) {
|
||||||
|
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
|
||||||
|
configId: configAtiva._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sucesso: true as const, aviso: undefined };
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: `Servidor retornou status ${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (fetchError: unknown) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
const errorMessage =
|
||||||
|
fetchError instanceof Error ? fetchError.message : String(fetchError);
|
||||||
|
|
||||||
|
// Se for erro de timeout
|
||||||
|
if (errorMessage.includes("aborted") || errorMessage.includes("timeout")) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: "Timeout: Servidor não respondeu em 5 segundos",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é erro de certificado SSL autoassinado
|
||||||
|
const isSSLError =
|
||||||
|
errorMessage.includes("CERTIFICATE_VERIFY_FAILED") ||
|
||||||
|
errorMessage.includes("self signed certificate") ||
|
||||||
|
errorMessage.includes("self-signed certificate") ||
|
||||||
|
errorMessage.includes("certificate") ||
|
||||||
|
errorMessage.includes("SSL") ||
|
||||||
|
errorMessage.includes("certificate verify failed");
|
||||||
|
|
||||||
|
// Se for erro de certificado e aceitar autoassinado está configurado
|
||||||
|
if (isSSLError && args.acceptSelfSignedCert) {
|
||||||
|
// Aceitar como sucesso se configurado para aceitar certificados autoassinados
|
||||||
|
// (o servidor está acessível, apenas o certificado não é confiável)
|
||||||
|
// Nota: No cliente (navegador), o usuário ainda precisará aceitar o certificado manualmente
|
||||||
|
const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||||
|
|
||||||
|
if (configAtiva) {
|
||||||
|
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
|
||||||
|
configId: configAtiva._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sucesso: true as const,
|
||||||
|
aviso: "Servidor acessível com certificado autoassinado. No navegador, você precisará aceitar o certificado manualmente na primeira conexão."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Para servidores Jitsi, pode ser normal receber erro 405 (Method Not Allowed)
|
||||||
|
// para GET em /http-bind, pois esse endpoint espera POST (BOSH)
|
||||||
|
// Isso indica que o servidor está acessível, apenas não aceita GET
|
||||||
|
if (errorMessage.includes("405") || errorMessage.includes("Method Not Allowed")) {
|
||||||
|
// Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm
|
||||||
|
const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||||
|
|
||||||
|
if (configAtiva) {
|
||||||
|
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
|
||||||
|
configId: configAtiva._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sucesso: true as const, aviso: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se for erro de certificado SSL e não está configurado para aceitar
|
||||||
|
if (isSSLError) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: `Erro de certificado SSL: O servidor está usando um certificado não confiável (provavelmente autoassinado). Para desenvolvimento local, habilite "Aceitar Certificados Autoassinados" nas configurações de segurança. Em produção, use um certificado válido (ex: Let's Encrypt).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: `Erro ao conectar: ${errorMessage}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: errorMessage || "Erro ao conectar com o servidor Jitsi",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marcar que a configuração foi testada com sucesso
|
||||||
|
*/
|
||||||
|
export const marcarConfigTestada = mutation({
|
||||||
|
args: {
|
||||||
|
configId: v.id("configuracaoJitsi"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.configId, {
|
||||||
|
testadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation interna para marcar que a configuração foi aplicada no servidor
|
||||||
|
*/
|
||||||
|
export const marcarConfiguradoNoServidor = internalMutation({
|
||||||
|
args: {
|
||||||
|
configId: v.id("configuracaoJitsi"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.configId, {
|
||||||
|
configuradoNoServidor: true,
|
||||||
|
configuradoNoServidorEm: Date.now(),
|
||||||
|
configuradoEm: Date.now(),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -10,13 +10,18 @@ import { api, internal } from './_generated/api';
|
|||||||
export const obterConfiguracao = query({
|
export const obterConfiguracao = query({
|
||||||
args: {},
|
args: {},
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const config = await ctx.db
|
// Buscar todas as configurações e pegar a mais recente (por atualizadoEm)
|
||||||
|
const configs = await ctx.db
|
||||||
.query('configuracaoRelogio')
|
.query('configuracaoRelogio')
|
||||||
.withIndex('by_ativo', (q) => q.eq('usarServidorExterno', true))
|
.collect();
|
||||||
.first();
|
|
||||||
|
// Pegar a configuração mais recente (ordenar por atualizadoEm desc)
|
||||||
|
const config = configs.length > 0
|
||||||
|
? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
// Retornar configuração padrão
|
// Retornar configuração padrão (GMT-3 para Brasília)
|
||||||
return {
|
return {
|
||||||
servidorNTP: 'pool.ntp.org',
|
servidorNTP: 'pool.ntp.org',
|
||||||
portaNTP: 123,
|
portaNTP: 123,
|
||||||
@@ -24,13 +29,13 @@ export const obterConfiguracao = query({
|
|||||||
fallbackParaPC: true,
|
fallbackParaPC: true,
|
||||||
ultimaSincronizacao: null,
|
ultimaSincronizacao: null,
|
||||||
offsetSegundos: null,
|
offsetSegundos: null,
|
||||||
gmtOffset: 0,
|
gmtOffset: -3, // GMT-3 para Brasília
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
gmtOffset: config.gmtOffset ?? 0,
|
gmtOffset: config.gmtOffset ?? -3, // Padrão GMT-3 para Brasília se não configurado
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -64,11 +69,14 @@ export const salvarConfiguracao = mutation({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buscar configuração existente
|
// Buscar configuração existente (pegar a mais recente)
|
||||||
const configExistente = await ctx.db
|
const configs = await ctx.db
|
||||||
.query('configuracaoRelogio')
|
.query('configuracaoRelogio')
|
||||||
.withIndex('by_ativo', (q) => q.eq('usarServidorExterno', args.usarServidorExterno))
|
.collect();
|
||||||
.first();
|
|
||||||
|
const configExistente = configs.length > 0
|
||||||
|
? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
if (configExistente) {
|
if (configExistente) {
|
||||||
// Atualizar configuração existente
|
// Atualizar configuração existente
|
||||||
@@ -77,7 +85,7 @@ export const salvarConfiguracao = mutation({
|
|||||||
portaNTP: args.portaNTP,
|
portaNTP: args.portaNTP,
|
||||||
usarServidorExterno: args.usarServidorExterno,
|
usarServidorExterno: args.usarServidorExterno,
|
||||||
fallbackParaPC: args.fallbackParaPC,
|
fallbackParaPC: args.fallbackParaPC,
|
||||||
gmtOffset: args.gmtOffset ?? 0,
|
gmtOffset: args.gmtOffset ?? -3, // Padrão GMT-3 para Brasília
|
||||||
atualizadoPor: usuario._id as Id<'usuarios'>,
|
atualizadoPor: usuario._id as Id<'usuarios'>,
|
||||||
atualizadoEm: Date.now(),
|
atualizadoEm: Date.now(),
|
||||||
});
|
});
|
||||||
@@ -89,7 +97,7 @@ export const salvarConfiguracao = mutation({
|
|||||||
portaNTP: args.portaNTP,
|
portaNTP: args.portaNTP,
|
||||||
usarServidorExterno: args.usarServidorExterno,
|
usarServidorExterno: args.usarServidorExterno,
|
||||||
fallbackParaPC: args.fallbackParaPC,
|
fallbackParaPC: args.fallbackParaPC,
|
||||||
gmtOffset: args.gmtOffset ?? 0,
|
gmtOffset: args.gmtOffset ?? -3, // Padrão GMT-3 para Brasília
|
||||||
atualizadoPor: usuario._id as Id<'usuarios'>,
|
atualizadoPor: usuario._id as Id<'usuarios'>,
|
||||||
atualizadoEm: Date.now(),
|
atualizadoEm: Date.now(),
|
||||||
});
|
});
|
||||||
@@ -131,16 +139,75 @@ export const sincronizarTempo = action({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tentar obter tempo de um servidor NTP público via HTTP
|
// Tentar obter tempo de um servidor NTP público via HTTP
|
||||||
// Nota: Esta é uma aproximação. Para NTP real, seria necessário usar uma biblioteca específica
|
// Nota: NTP real requer protocolo UDP na porta 123, aqui usamos APIs HTTP que retornam UTC
|
||||||
|
// O GMT offset será aplicado no frontend
|
||||||
try {
|
try {
|
||||||
// Usar API pública de tempo como fallback
|
const servidorNTP = config.servidorNTP || 'pool.ntp.org';
|
||||||
const response = await fetch('https://worldtimeapi.org/api/timezone/America/Recife');
|
let serverTime: number;
|
||||||
|
|
||||||
|
// Mapear servidores NTP conhecidos para APIs HTTP que retornam UTC
|
||||||
|
// Todos os servidores NTP retornam UTC, então usamos APIs que retornam UTC
|
||||||
|
if (servidorNTP.includes('pool.ntp.org') || servidorNTP.includes('ntp.org') || servidorNTP.includes('ntp.br')) {
|
||||||
|
// pool.ntp.org e servidores .org/.br - usar API que retorna UTC
|
||||||
|
const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Falha ao obter tempo do servidor');
|
throw new Error('Falha ao obter tempo do servidor');
|
||||||
}
|
}
|
||||||
|
const data = (await response.json()) as { unixtime: number; datetime: string };
|
||||||
|
// unixtime está em segundos, converter para milissegundos
|
||||||
|
serverTime = data.unixtime * 1000;
|
||||||
|
} else if (servidorNTP.includes('time.google.com') || servidorNTP.includes('google')) {
|
||||||
|
// Google NTP - usar API que retorna UTC
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Falha ao obter tempo');
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { unixtime: number };
|
||||||
|
serverTime = data.unixtime * 1000;
|
||||||
|
} catch {
|
||||||
|
// Fallback para outra API UTC
|
||||||
|
const response = await fetch('https://timeapi.io/api/Time/current/zone?timeZone=UTC');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Falha ao obter tempo do servidor');
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { unixTime: number };
|
||||||
|
serverTime = data.unixTime * 1000;
|
||||||
|
}
|
||||||
|
} else if (servidorNTP.includes('time.windows.com') || servidorNTP.includes('windows')) {
|
||||||
|
// Windows NTP - usar API que retorna UTC
|
||||||
|
const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Falha ao obter tempo do servidor');
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { unixtime: number };
|
||||||
|
serverTime = data.unixtime * 1000;
|
||||||
|
} else {
|
||||||
|
// Para outros servidores NTP, usar API genérica que retorna UTC
|
||||||
|
// Tentar worldtimeapi primeiro
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Falha ao obter tempo');
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { unixtime: number };
|
||||||
|
serverTime = data.unixtime * 1000;
|
||||||
|
} catch {
|
||||||
|
// Fallback para timeapi.io
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://timeapi.io/api/Time/current/zone?timeZone=UTC');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Falha ao obter tempo');
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { unixTime: number };
|
||||||
|
serverTime = data.unixTime * 1000;
|
||||||
|
} catch {
|
||||||
|
// Último fallback: usar tempo do servidor Convex (já está em UTC)
|
||||||
|
serverTime = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as { datetime: string };
|
|
||||||
const serverTime = new Date(data.datetime).getTime();
|
|
||||||
const localTime = Date.now();
|
const localTime = Date.now();
|
||||||
const offsetSegundos = Math.floor((serverTime - localTime) / 1000);
|
const offsetSegundos = Math.floor((serverTime - localTime) / 1000);
|
||||||
|
|
||||||
@@ -160,24 +227,27 @@ export const sincronizarTempo = action({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sucesso: true,
|
sucesso: true,
|
||||||
timestamp: serverTime,
|
timestamp: serverTime, // Retorna UTC (sem GMT offset aplicado)
|
||||||
usandoServidorExterno: true,
|
usandoServidorExterno: true,
|
||||||
offsetSegundos,
|
offsetSegundos,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Se falhar e fallbackParaPC estiver ativo, usar tempo local
|
// Sempre usar fallback como última opção, mesmo se desabilitado
|
||||||
if (config.fallbackParaPC) {
|
// Isso evita que o sistema trave completamente se o servidor externo não estiver disponível
|
||||||
|
const aviso = config.fallbackParaPC
|
||||||
|
? 'Falha ao sincronizar com servidor externo, usando relógio do PC'
|
||||||
|
: 'Falha ao sincronizar com servidor externo. Fallback desabilitado, mas usando relógio do PC como última opção.';
|
||||||
|
|
||||||
|
console.warn('Erro ao sincronizar tempo com servidor externo:', error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sucesso: true,
|
sucesso: true,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
usandoServidorExterno: false,
|
usandoServidorExterno: false,
|
||||||
offsetSegundos: 0,
|
offsetSegundos: 0,
|
||||||
aviso: 'Falha ao sincronizar com servidor externo, usando relógio do PC',
|
aviso,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Falha ao sincronizar tempo e fallback desabilitado');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -369,38 +369,23 @@ export const registrarPonto = mutation({
|
|||||||
throw new Error('Configuração de ponto não encontrada');
|
throw new Error('Configuração de ponto não encontrada');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obter configuração de ponto para GMT offset (buscar configuração ativa)
|
// Converter timestamp para data/hora
|
||||||
const configPonto = await ctx.db
|
// O timestamp pode vir ajustado com GMT offset do frontend (se GMT !== 0)
|
||||||
.query('configuracaoPonto')
|
// ou em UTC puro (se GMT === 0). Usamos UTC methods para extrair os valores
|
||||||
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
// diretamente do timestamp recebido, seja ele ajustado ou não
|
||||||
.first();
|
const dataObj = new Date(args.timestamp);
|
||||||
|
// Usar UTC methods porque:
|
||||||
|
// - Se GMT === 0: timestamp está em UTC puro, métodos UTC extraem corretamente
|
||||||
|
// - Se GMT !== 0: timestamp já vem ajustado do frontend, métodos UTC extraem o horário ajustado
|
||||||
|
const hora = dataObj.getUTCHours();
|
||||||
|
const minuto = dataObj.getUTCMinutes();
|
||||||
|
const segundo = dataObj.getUTCSeconds();
|
||||||
|
|
||||||
// Converter timestamp para data/hora com ajuste de GMT
|
// Obter data no formato YYYY-MM-DD usando UTC
|
||||||
// O timestamp está em UTC, precisamos aplicar o GMT offset
|
const ano = dataObj.getUTCFullYear();
|
||||||
const gmtOffset = configPonto?.gmtOffset ?? 0;
|
const mes = String(dataObj.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const dia = String(dataObj.getUTCDate()).padStart(2, '0');
|
||||||
// Calcular horário ajustado manualmente a partir de UTC
|
const data = `${ano}-${mes}-${dia}`;
|
||||||
const dataUTC = new Date(args.timestamp);
|
|
||||||
let hora = dataUTC.getUTCHours() + gmtOffset;
|
|
||||||
const minuto = dataUTC.getUTCMinutes();
|
|
||||||
const segundo = dataUTC.getUTCSeconds();
|
|
||||||
|
|
||||||
// Ajustar hora se ultrapassar os limites do dia
|
|
||||||
let diasOffset = 0;
|
|
||||||
if (hora >= 24) {
|
|
||||||
hora = hora - 24;
|
|
||||||
diasOffset = 1;
|
|
||||||
} else if (hora < 0) {
|
|
||||||
hora = hora + 24;
|
|
||||||
diasOffset = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calcular data ajustada
|
|
||||||
const dataAjustada = new Date(args.timestamp);
|
|
||||||
if (diasOffset !== 0) {
|
|
||||||
dataAjustada.setUTCDate(dataAjustada.getUTCDate() + diasOffset);
|
|
||||||
}
|
|
||||||
const data = dataAjustada.toISOString().split('T')[0]!; // YYYY-MM-DD
|
|
||||||
|
|
||||||
// Verificar se já existe registro no mesmo minuto
|
// Verificar se já existe registro no mesmo minuto
|
||||||
const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined
|
const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined
|
||||||
@@ -544,7 +529,7 @@ export const registrarPonto = mutation({
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
configPonto?.validarLocalizacao !== false &&
|
config.validarLocalizacao !== false &&
|
||||||
args.informacoesDispositivo?.latitude &&
|
args.informacoesDispositivo?.latitude &&
|
||||||
args.informacoesDispositivo?.longitude
|
args.informacoesDispositivo?.longitude
|
||||||
) {
|
) {
|
||||||
@@ -553,7 +538,7 @@ export const registrarPonto = mutation({
|
|||||||
usuario.funcionarioId,
|
usuario.funcionarioId,
|
||||||
args.informacoesDispositivo.latitude,
|
args.informacoesDispositivo.latitude,
|
||||||
args.informacoesDispositivo.longitude,
|
args.informacoesDispositivo.longitude,
|
||||||
configPonto?.toleranciaDistanciaMetros ?? 100
|
config.toleranciaDistanciaMetros ?? 100
|
||||||
);
|
);
|
||||||
|
|
||||||
validacaoGeofencing = geofencing;
|
validacaoGeofencing = geofencing;
|
||||||
@@ -822,6 +807,7 @@ export const obterEstatisticas = query({
|
|||||||
args: {
|
args: {
|
||||||
dataInicio: v.string(), // YYYY-MM-DD
|
dataInicio: v.string(), // YYYY-MM-DD
|
||||||
dataFim: v.string(), // YYYY-MM-DD
|
dataFim: v.string(), // YYYY-MM-DD
|
||||||
|
funcionarioId: v.optional(v.id('funcionarios')),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const usuario = await getCurrentUserFunction(ctx);
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
@@ -831,11 +817,16 @@ export const obterEstatisticas = query({
|
|||||||
|
|
||||||
// TODO: Verificar permissão (RH ou TI)
|
// TODO: Verificar permissão (RH ou TI)
|
||||||
|
|
||||||
const registros = await ctx.db
|
let registros = await ctx.db
|
||||||
.query('registrosPonto')
|
.query('registrosPonto')
|
||||||
.withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim))
|
.withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Filtrar por funcionário se fornecido
|
||||||
|
if (args.funcionarioId) {
|
||||||
|
registros = registros.filter((r) => r.funcionarioId === args.funcionarioId);
|
||||||
|
}
|
||||||
|
|
||||||
const totalRegistros = registros.length;
|
const totalRegistros = registros.length;
|
||||||
const dentroDoPrazo = registros.filter((r) => r.dentroDoPrazo).length;
|
const dentroDoPrazo = registros.filter((r) => r.dentroDoPrazo).length;
|
||||||
const foraDoPrazo = totalRegistros - dentroDoPrazo;
|
const foraDoPrazo = totalRegistros - dentroDoPrazo;
|
||||||
|
|||||||
@@ -708,6 +708,30 @@ export default defineSchema({
|
|||||||
atualizadoEm: v.number(),
|
atualizadoEm: v.number(),
|
||||||
}).index("by_ativo", ["ativo"]),
|
}).index("by_ativo", ["ativo"]),
|
||||||
|
|
||||||
|
// Configuração de Jitsi Meet
|
||||||
|
configuracaoJitsi: defineTable({
|
||||||
|
domain: v.string(), // Domínio do servidor Jitsi (ex: "localhost:8443" ou "meet.example.com")
|
||||||
|
appId: v.string(), // ID da aplicação Jitsi
|
||||||
|
roomPrefix: v.string(), // Prefixo para nomes de salas
|
||||||
|
useHttps: v.boolean(), // Usar HTTPS
|
||||||
|
acceptSelfSignedCert: v.optional(v.boolean()), // Aceitar certificados autoassinados (útil para desenvolvimento)
|
||||||
|
// Configurações SSH/Docker para configuração automática do servidor
|
||||||
|
sshHost: v.optional(v.string()), // Host SSH para acesso ao servidor Docker (ex: "192.168.1.100" ou "servidor.local")
|
||||||
|
sshPort: v.optional(v.number()), // Porta SSH (padrão: 22)
|
||||||
|
sshUsername: v.optional(v.string()), // Usuário SSH
|
||||||
|
sshPasswordHash: v.optional(v.string()), // Hash da senha SSH (criptografada)
|
||||||
|
sshKeyPath: v.optional(v.string()), // Caminho para chave SSH (alternativa à senha)
|
||||||
|
dockerComposePath: v.optional(v.string()), // Caminho do docker-compose.yml (ex: "/home/user/jitsi-docker")
|
||||||
|
jitsiConfigPath: v.optional(v.string()), // Caminho base das configurações Jitsi (ex: "~/.jitsi-meet-cfg")
|
||||||
|
ativo: v.boolean(), // Configuração ativa
|
||||||
|
testadoEm: v.optional(v.number()), // Timestamp do último teste de conexão
|
||||||
|
configuradoEm: v.optional(v.number()), // Timestamp da última configuração do servidor Docker
|
||||||
|
configuradoNoServidor: v.optional(v.boolean()), // Indica se a configuração foi aplicada no servidor
|
||||||
|
configuradoNoServidorEm: v.optional(v.number()), // Timestamp de quando foi configurado no servidor
|
||||||
|
configuradoPor: v.id("usuarios"), // Usuário que configurou
|
||||||
|
atualizadoEm: v.number(), // Timestamp de atualização
|
||||||
|
}).index("by_ativo", ["ativo"]),
|
||||||
|
|
||||||
// Fila de Emails
|
// Fila de Emails
|
||||||
notificacoesEmail: defineTable({
|
notificacoesEmail: defineTable({
|
||||||
destinatario: v.string(), // email
|
destinatario: v.string(), // email
|
||||||
|
|||||||
@@ -25,8 +25,10 @@
|
|||||||
"@convex-dev/better-auth": "^0.9.7",
|
"@convex-dev/better-auth": "^0.9.7",
|
||||||
"@convex-dev/rate-limiter": "^0.3.0",
|
"@convex-dev/rate-limiter": "^0.3.0",
|
||||||
"@dicebear/avataaars": "^9.2.4",
|
"@dicebear/avataaars": "^9.2.4",
|
||||||
|
"@types/ssh2": "^1.15.5",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"convex": "catalog:",
|
"convex": "catalog:",
|
||||||
"nodemailer": "^7.0.10"
|
"nodemailer": "^7.0.10",
|
||||||
|
"ssh2": "^1.17.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user