refactor: remove unused authentication files and dependencies; update package.json to streamline dependencies and improve project structure

This commit is contained in:
2025-10-29 18:57:05 -03:00
parent f219340cd8
commit 1058375a90
29 changed files with 1426 additions and 1542 deletions

View File

@@ -27,14 +27,11 @@
"vite": "^7.1.2" "vite": "^7.1.2"
}, },
"dependencies": { "dependencies": {
"@convex-dev/better-auth": "^0.9.6",
"@dicebear/collection": "^9.2.4", "@dicebear/collection": "^9.2.4",
"@dicebear/core": "^9.2.4", "@dicebear/core": "^9.2.4",
"@internationalized/date": "^3.10.0", "@internationalized/date": "^3.10.0",
"@mmailaender/convex-better-auth-svelte": "^0.2.0",
"@sgse-app/backend": "*", "@sgse-app/backend": "*",
"@tanstack/svelte-form": "^1.19.2", "@tanstack/svelte-form": "^1.19.2",
"better-auth": "1.3.27",
"convex": "^1.28.0", "convex": "^1.28.0",
"convex-svelte": "^0.0.11", "convex-svelte": "^0.0.11",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",

View File

@@ -1,7 +0,0 @@
import { createAuthClient } from "better-auth/client";
import { convexClient } from "@convex-dev/better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: "http://localhost:5173",
plugins: [convexClient()],
});

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import { authStore } from "$lib/stores/auth.svelte";
import { loginModalStore } from "$lib/stores/loginModal.svelte";
interface Props {
recurso: string;
acao: string;
children?: any;
}
let { recurso, acao, children }: Props = $props();
let verificando = $state(true);
let permitido = $state(false);
const permissaoQuery = $derived(
authStore.usuario
? useQuery(api.permissoesAcoes.verificarAcao, {
usuarioId: authStore.usuario._id as Id<"usuarios">,
recurso,
acao,
})
: null
);
$effect(() => {
if (!authStore.autenticado) {
verificando = false;
permitido = false;
const currentPath = window.location.pathname;
loginModalStore.open(currentPath);
return;
}
if (permissaoQuery?.error) {
verificando = false;
permitido = false;
} else if (permissaoQuery && !permissaoQuery.isLoading) {
// Backend retorna null quando permitido
verificando = false;
permitido = true;
}
});
</script>
{#if verificando}
<div class="flex items-center justify-center min-h-screen">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-4 text-base-content/70">Verificando permissões...</p>
</div>
</div>
{:else if permitido}
{@render children?.()}
{:else}
<div class="flex items-center justify-center min-h-screen">
<div class="text-center">
<div class="p-4 bg-error/10 rounded-full inline-block mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 text-error"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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"
/>
</svg>
</div>
<h2 class="text-2xl font-bold text-base-content mb-2">Acesso Negado</h2>
<p class="text-base-content/70">
Você não tem permissão para acessar esta ação.
</p>
</div>
</div>
{/if}

View File

@@ -1,84 +1,66 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/state"; import { page } from "$app/state";
import MenuProtection from "$lib/components/MenuProtection.svelte"; import ActionGuard from "$lib/components/ActionGuard.svelte";
const { children } = $props(); const { children } = $props();
// Mapa de rotas para verificação de permissões // Resolver recurso/ação a partir da rota
const ROUTE_PERMISSIONS: Record<string, { path: string; requireGravar?: boolean }> = { const routeAction = $derived.by(() => {
// Recursos Humanos const p = page.url.pathname;
"/recursos-humanos": { path: "/recursos-humanos" }, if (p === "/" || p === "/solicitar-acesso") return null;
"/recursos-humanos/funcionarios": { path: "/recursos-humanos/funcionarios" },
"/recursos-humanos/funcionarios/cadastro": { path: "/recursos-humanos/funcionarios", requireGravar: true },
"/recursos-humanos/funcionarios/excluir": { path: "/recursos-humanos/funcionarios", requireGravar: true },
"/recursos-humanos/funcionarios/relatorios": { path: "/recursos-humanos/funcionarios" },
"/recursos-humanos/simbolos": { path: "/recursos-humanos/simbolos" },
"/recursos-humanos/simbolos/cadastro": { path: "/recursos-humanos/simbolos", requireGravar: true },
// Outros menus
"/financeiro": { path: "/financeiro" },
"/controladoria": { path: "/controladoria" },
"/licitacoes": { path: "/licitacoes" },
"/compras": { path: "/compras" },
"/juridico": { path: "/juridico" },
"/comunicacao": { path: "/comunicacao" },
"/programas-esportivos": { path: "/programas-esportivos" },
"/secretaria-executiva": { path: "/secretaria-executiva" },
"/gestao-pessoas": { path: "/gestao-pessoas" },
"/ti": { path: "/ti" },
};
// Obter configuração para a rota atual // Funcionários
const getCurrentRouteConfig = $derived.by(() => { if (p.startsWith("/recursos-humanos/funcionarios")) {
const currentPath = page.url.pathname; if (p.includes("/cadastro"))
return { recurso: "funcionarios", acao: "criar" };
// Verificar correspondência exata if (p.includes("/excluir"))
if (ROUTE_PERMISSIONS[currentPath]) { return { recurso: "funcionarios", acao: "excluir" };
return ROUTE_PERMISSIONS[currentPath]; if (p.includes("/editar") || p.includes("/funcionarioId"))
return { recurso: "funcionarios", acao: "editar" };
return { recurso: "funcionarios", acao: "listar" };
} }
// Verificar rotas dinâmicas (com [id]) // Símbolos
if (currentPath.includes("/editar") || currentPath.includes("/funcionarioId") || currentPath.includes("/simboloId")) { if (p.startsWith("/recursos-humanos/simbolos")) {
// Extrair o caminho base if (p.includes("/cadastro"))
if (currentPath.includes("/funcionarios/")) { return { recurso: "simbolos", acao: "criar" };
return { path: "/recursos-humanos/funcionarios", requireGravar: true }; if (p.includes("/excluir"))
} return { recurso: "simbolos", acao: "excluir" };
if (currentPath.includes("/simbolos/")) { if (p.includes("/editar") || p.includes("/simboloId"))
return { path: "/recursos-humanos/simbolos", requireGravar: true }; return { recurso: "simbolos", acao: "editar" };
} return { recurso: "simbolos", acao: "listar" };
} }
// Rotas públicas (Dashboard, Solicitar Acesso, etc) // Outras áreas (uso genérico: ver)
if (currentPath === "/" || currentPath === "/solicitar-acesso") { if (p.startsWith("/financeiro"))
return null; return { recurso: "financeiro", acao: "ver" };
} if (p.startsWith("/controladoria"))
return { recurso: "controladoria", acao: "ver" };
// Para qualquer outra rota dentro do dashboard, verificar o primeiro segmento if (p.startsWith("/licitacoes"))
const segments = currentPath.split("/").filter(Boolean); return { recurso: "licitacoes", acao: "ver" };
if (segments.length > 0) { if (p.startsWith("/compras")) return { recurso: "compras", acao: "ver" };
const firstSegment = "/" + segments[0]; if (p.startsWith("/juridico")) return { recurso: "juridico", acao: "ver" };
if (ROUTE_PERMISSIONS[firstSegment]) { if (p.startsWith("/comunicacao"))
return ROUTE_PERMISSIONS[firstSegment]; return { recurso: "comunicacao", acao: "ver" };
} if (p.startsWith("/programas-esportivos"))
} return { recurso: "programas_esportivos", acao: "ver" };
if (p.startsWith("/secretaria-executiva"))
return { recurso: "secretaria_executiva", acao: "ver" };
if (p.startsWith("/gestao-pessoas"))
return { recurso: "gestao_pessoas", acao: "ver" };
return null; return null;
}); });
</script> </script>
{#if getCurrentRouteConfig} {#if routeAction}
<MenuProtection menuPath={getCurrentRouteConfig.path} requireGravar={getCurrentRouteConfig.requireGravar || false}> <ActionGuard recurso={routeAction.recurso} acao={routeAction.acao}>
<main <main id="container-central" class="w-full max-w-none px-3 lg:px-4 py-4">
id="container-central"
class="w-full max-w-none px-3 lg:px-4 py-4"
>
{@render children()} {@render children()}
</main> </main>
</MenuProtection> </ActionGuard>
{:else} {:else}
<main <main id="container-central" class="w-full max-w-none px-3 lg:px-4 py-4">
id="container-central"
class="w-full max-w-none px-3 lg:px-4 py-4"
>
{@render children()} {@render children()}
</main> </main>
{/if} {/if}

View File

@@ -4,16 +4,46 @@
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte"; import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
type RoleRow = {
_id: Id<"roles">;
_creationTime: number;
nome: string;
descricao: string;
nivel: number;
setor?: string;
customizado: boolean;
editavel?: boolean;
criadoPor?: Id<"usuarios">;
};
const client = useConvexClient(); const client = useConvexClient();
// Buscar matriz de permissões // Carregar lista de roles e catálogo de recursos/ações
const matrizQuery = useQuery(api.menuPermissoes.obterMatrizPermissoes, {}); const rolesQuery = useQuery(api.roles.listar, {});
const catalogoQuery = useQuery(api.permissoesAcoes.listarRecursosEAcoes, {});
let salvando = $state(false); let salvando = $state(false);
let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>(null); let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>(
null
);
let busca = $state(""); let busca = $state("");
let filtroRole = $state(""); let filtroRole = $state("");
let expandido: Record<string, boolean> = $state({});
// Cache de permissões por role
let permissoesPorRole: Record<
string,
Array<{ recurso: string; acoes: Array<string> }>
> = $state({});
async function carregarPermissoesRole(roleId: Id<"roles">) {
if (permissoesPorRole[roleId]) return;
const dados = await client.query(
api.permissoesAcoes.listarPermissoesAcoesPorRole,
{ roleId }
);
permissoesPorRole[roleId] = dados;
}
function mostrarMensagem(tipo: "success" | "error", texto: string) { function mostrarMensagem(tipo: "success" | "error", texto: string) {
mensagem = { tipo, texto }; mensagem = { tipo, texto };
@@ -22,89 +52,50 @@
}, 3000); }, 3000);
} }
const dadosFiltrados = $derived.by(() => { const rolesFiltradas = $derived.by(() => {
if (!matrizQuery.data) return []; if (!rolesQuery.data) return [];
let rs: Array<RoleRow> = rolesQuery.data as Array<RoleRow>;
let resultado = matrizQuery.data; if (filtroRole)
rs = rs.filter((r: RoleRow) => r._id === (filtroRole as any));
// Filtrar por role
if (filtroRole) {
resultado = resultado.filter(r => r.role._id === filtroRole);
}
// Filtrar por busca
if (busca.trim()) { if (busca.trim()) {
const buscaLower = busca.toLowerCase(); const b = busca.toLowerCase();
resultado = resultado.map(roleData => ({ rs = rs.filter(
...roleData, (r: RoleRow) =>
permissoes: roleData.permissoes.filter(p => r.descricao.toLowerCase().includes(b) ||
p.menuNome.toLowerCase().includes(buscaLower) || r.nome.toLowerCase().includes(b)
p.menuPath.toLowerCase().includes(buscaLower) );
)
})).filter(roleData => roleData.permissoes.length > 0);
} }
return rs;
return resultado;
}); });
async function atualizarPermissao( async function toggleAcao(
roleId: Id<"roles">, roleId: Id<"roles">,
menuPath: string, recurso: string,
campo: "podeAcessar" | "podeConsultar" | "podeGravar", acao: string,
valor: boolean conceder: boolean
) { ) {
try { try {
salvando = true; salvando = true;
await client.mutation(api.permissoesAcoes.atualizarPermissaoAcao, {
// Buscar a permissão atual
const roleData = matrizQuery.data?.find((r) => r.role._id === roleId);
const permissaoAtual = roleData?.permissoes.find((p) => p.menuPath === menuPath);
if (!permissaoAtual) {
throw new Error("Permissão não encontrada");
}
// Inicializar com valores atuais
let podeAcessar = permissaoAtual.podeAcessar;
let podeConsultar = permissaoAtual.podeConsultar;
let podeGravar = permissaoAtual.podeGravar;
// Aplicar lógica de dependências baseada no campo alterado
if (campo === "podeAcessar") {
podeAcessar = valor;
// Se desmarcou "Acessar", desmarcar tudo
if (!valor) {
podeConsultar = false;
podeGravar = false;
}
// Se marcou "Acessar", manter os outros valores como estão
} else if (campo === "podeConsultar") {
podeConsultar = valor;
// Se marcou "Consultar", marcar "Acessar" automaticamente
if (valor) {
podeAcessar = true;
} else {
// Se desmarcou "Consultar", desmarcar "Gravar"
podeGravar = false;
}
} else if (campo === "podeGravar") {
podeGravar = valor;
// Se marcou "Gravar", marcar "Consultar" e "Acessar" automaticamente
if (valor) {
podeAcessar = true;
podeConsultar = true;
}
// Se desmarcou "Gravar", manter os outros como estão
}
await client.mutation(api.menuPermissoes.atualizarPermissao, {
roleId, roleId,
menuPath, recurso,
podeAcessar, acao,
podeConsultar, conceder,
podeGravar,
}); });
// Atualizar cache local
const atual = permissoesPorRole[roleId] || [];
const entry = atual.find((e) => e.recurso === recurso);
if (entry) {
const set = new Set(entry.acoes);
if (conceder) set.add(acao);
else set.delete(acao);
entry.acoes = Array.from(set);
} else {
permissoesPorRole[roleId] = [
...atual,
{ recurso, acoes: conceder ? [acao] : [] },
];
}
mostrarMensagem("success", "Permissão atualizada com sucesso!"); mostrarMensagem("success", "Permissão atualizada com sucesso!");
} catch (e: any) { } catch (e: any) {
mostrarMensagem("error", e.message || "Erro ao atualizar permissão"); mostrarMensagem("error", e.message || "Erro ao atualizar permissão");
@@ -113,16 +104,10 @@
} }
} }
async function inicializarPermissoes(roleId: Id<"roles">) { function isConcedida(roleId: Id<"roles">, recurso: string, acao: string) {
try { const dados = permissoesPorRole[roleId];
salvando = true; const entry = dados?.find((e) => e.recurso === recurso);
await client.mutation(api.menuPermissoes.inicializarPermissoesRole, { roleId }); return entry ? entry.acoes.includes(acao) : false;
mostrarMensagem("success", "Permissões inicializadas!");
} catch (e: any) {
mostrarMensagem("error", e.message || "Erro ao inicializar permissões");
} finally {
salvando = false;
}
} }
</script> </script>
@@ -132,8 +117,19 @@
<ul> <ul>
<li> <li>
<a href="/" class="text-primary hover:text-primary-focus"> <a href="/" class="text-primary hover:text-primary-focus">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg> </svg>
Dashboard Dashboard
</a> </a>
@@ -149,17 +145,43 @@
<div class="mb-6"> <div class="mb-6">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
<div class="p-3 bg-primary/10 rounded-xl"> <div class="p-3 bg-primary/10 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h1 class="text-3xl font-bold text-base-content">Gerenciar Permissões de Acesso</h1> <h1 class="text-3xl font-bold text-base-content">
<p class="text-base-content/60 mt-1">Configure as permissões de acesso aos menus do sistema por função</p> Gerenciar Permissões de Acesso
</h1>
<p class="text-base-content/60 mt-1">
Configure as permissões de acesso aos menus do sistema por função
</p>
</div> </div>
<button class="btn btn-ghost gap-2" onclick={() => goto("/ti")}> <button class="btn btn-ghost gap-2" onclick={() => goto("/ti")}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /> 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="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg> </svg>
Voltar Voltar
</button> </button>
@@ -168,14 +190,38 @@
<!-- Alertas --> <!-- Alertas -->
{#if mensagem} {#if mensagem}
<div class="alert mb-6 shadow-lg" class:alert-success={mensagem.tipo === "success"} class:alert-error={mensagem.tipo === "error"}> <div
class="alert mb-6 shadow-lg"
class:alert-success={mensagem.tipo === "success"}
class:alert-error={mensagem.tipo === "error"}
>
{#if mensagem.tipo === "success"} {#if mensagem.tipo === "success"}
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"> <svg
<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" /> xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
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> </svg>
{:else} {:else}
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"> <svg
<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" /> xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<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"
/>
</svg> </svg>
{/if} {/if}
<span class="font-semibold">{mensagem.texto}</span> <span class="font-semibold">{mensagem.texto}</span>
@@ -189,13 +235,13 @@
<!-- Busca por menu --> <!-- Busca por menu -->
<div class="form-control"> <div class="form-control">
<label class="label" for="busca"> <label class="label" for="busca">
<span class="label-text font-semibold">Buscar Menu</span> <span class="label-text font-semibold">Buscar Perfil</span>
</label> </label>
<div class="relative"> <div class="relative">
<input <input
id="busca" id="busca"
type="text" type="text"
placeholder="Digite o nome ou caminho do menu..." placeholder="Digite o nome/descrição do perfil..."
class="input input-bordered w-full pr-10" class="input input-bordered w-full pr-10"
bind:value={busca} bind:value={busca}
/> />
@@ -227,10 +273,10 @@
bind:value={filtroRole} bind:value={filtroRole}
> >
<option value="">Todos os perfis</option> <option value="">Todos os perfis</option>
{#if matrizQuery.data} {#if rolesQuery.data}
{#each matrizQuery.data as roleData} {#each rolesQuery.data as roleRow}
<option value={roleData.role._id}> <option value={roleRow._id}>
{roleData.role.descricao} ({roleData.role.nome}) {roleRow.descricao} ({roleRow.nome})
</option> </option>
{/each} {/each}
{/if} {/if}
@@ -272,8 +318,18 @@
<!-- Informações sobre o sistema de permissões --> <!-- Informações sobre o sistema de permissões -->
<div class="alert alert-info mb-6 shadow-lg"> <div class="alert alert-info mb-6 shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"> <svg
<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" /> xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<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"
/>
</svg> </svg>
<div> <div>
<h3 class="font-bold text-lg">Como funciona o sistema de permissões:</h3> <h3 class="font-bold text-lg">Como funciona o sistema de permissões:</h3>
@@ -281,9 +337,13 @@
<div> <div>
<h4 class="font-semibold text-sm">Tipos de Permissão:</h4> <h4 class="font-semibold text-sm">Tipos de Permissão:</h4>
<ul class="text-sm mt-1 space-y-1"> <ul class="text-sm mt-1 space-y-1">
<li><strong>Acessar:</strong> Visualizar menu e acessar página</li> <li>
<strong>Acessar:</strong> Visualizar menu e acessar página
</li>
<li><strong>Consultar:</strong> Ver dados (requer "Acessar")</li> <li><strong>Consultar:</strong> Ver dados (requer "Acessar")</li>
<li><strong>Gravar:</strong> Criar/editar/excluir (requer "Consultar")</li> <li>
<strong>Gravar:</strong> Criar/editar/excluir (requer "Consultar")
</li>
</ul> </ul>
</div> </div>
<div> <div>
@@ -291,27 +351,39 @@
<ul class="text-sm mt-1 space-y-1"> <ul class="text-sm mt-1 space-y-1">
<li><strong>Admin e TI:</strong> Acesso total automático</li> <li><strong>Admin e TI:</strong> Acesso total automático</li>
<li><strong>Dashboard:</strong> Público para todos</li> <li><strong>Dashboard:</strong> Público para todos</li>
<li><strong>Perfil Customizado:</strong> Permissões personalizadas</li> <li>
<strong>Perfil Customizado:</strong> Permissões personalizadas
</li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Matriz de Permissões --> <!-- Matriz de Permissões por Ação -->
{#if matrizQuery.isLoading} {#if rolesQuery.isLoading || catalogoQuery.isLoading}
<div class="flex justify-center items-center py-12"> <div class="flex justify-center items-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span> <span class="loading loading-spinner loading-lg text-primary"></span>
</div> </div>
{:else if matrizQuery.error} {:else if rolesQuery.error}
<div class="alert alert-error"> <div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"> <svg
<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" /> xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<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"
/>
</svg> </svg>
<span>Erro ao carregar permissões: {matrizQuery.error.message}</span> <span>Erro ao carregar perfis: {rolesQuery.error.message}</span>
</div> </div>
{:else if matrizQuery.data} {:else if rolesQuery.data && catalogoQuery.data}
{#if dadosFiltrados.length === 0} {#if rolesFiltradas.length === 0}
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body items-center text-center"> <div class="card-body items-center text-center">
<svg <svg
@@ -330,7 +402,9 @@
</svg> </svg>
<h3 class="text-xl font-bold mt-4">Nenhum resultado encontrado</h3> <h3 class="text-xl font-bold mt-4">Nenhum resultado encontrado</h3>
<p class="text-base-content/60"> <p class="text-base-content/60">
{busca ? `Não foram encontrados menus com "${busca}"` : "Nenhuma permissão corresponde aos filtros aplicados"} {busca
? `Não foram encontrados perfis com "${busca}"`
: "Nenhum perfil corresponde aos filtros aplicados"}
</p> </p>
<button <button
class="btn btn-primary btn-sm mt-4" class="btn btn-primary btn-sm mt-4"
@@ -345,158 +419,118 @@
</div> </div>
{/if} {/if}
{#each dadosFiltrados as roleData} {#each rolesFiltradas as roleRow}
<div class="card bg-base-100 shadow-xl mb-6"> <div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between mb-4 flex-wrap gap-4"> <div class="flex items-center justify-between mb-4 flex-wrap gap-4">
<div class="flex-1 min-w-[200px]"> <div class="flex-1 min-w-[200px]">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
<h2 class="card-title text-2xl">{roleData.role.descricao}</h2> <h2 class="card-title text-2xl">{roleRow.descricao}</h2>
<div class="badge badge-lg badge-primary">Nível {roleData.role.nivel}</div> <div class="badge badge-lg badge-primary">
{#if roleData.role.nivel <= 1} Nível {roleRow.nivel}
</div>
{#if roleRow.nivel <= 1}
<div class="badge badge-lg badge-success gap-1"> <div class="badge badge-lg badge-success gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<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" /> xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
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> </svg>
Acesso Total Acesso Total
</div> </div>
{/if} {/if}
</div> </div>
<p class="text-sm text-base-content/60"> <p class="text-sm text-base-content/60">
<span class="font-mono bg-base-200 px-2 py-1 rounded">{roleData.role.nome}</span> <span class="font-mono bg-base-200 px-2 py-1 rounded"
>{roleRow.nome}</span
>
</p> </p>
</div> </div>
{#if roleData.role.nivel > 1} {#if roleRow.nivel > 1}
<button <button
class="btn btn-sm btn-outline btn-primary" class="btn btn-sm btn-outline"
onclick={() => inicializarPermissoes(roleData.role._id)} onclick={async () => {
disabled={salvando} expandido[roleRow._id] = !expandido[roleRow._id];
if (expandido[roleRow._id])
await carregarPermissoesRole(roleRow._id);
}}
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> {expandido[roleRow._id] ? "Recolher" : "Expandir"}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Inicializar Permissões
</button> </button>
{/if} {/if}
</div> </div>
{#if roleData.role.nivel <= 1} {#if roleRow.nivel <= 1}
<div class="alert alert-success shadow-md"> <div class="alert alert-success shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"> <svg
<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" /> xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
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> </svg>
<div> <div>
<h3 class="font-bold">Perfil Administrativo</h3> <h3 class="font-bold">Perfil Administrativo</h3>
<div class="text-sm">Este perfil possui acesso total ao sistema automaticamente, sem necessidade de configuração manual.</div> <div class="text-sm">
Este perfil possui acesso total ao sistema automaticamente,
sem necessidade de configuração manual.
</div> </div>
</div> </div>
{:else}
<div class="stats stats-vertical lg:stats-horizontal shadow mb-4 w-full">
<div class="stat">
<div class="stat-title">Total de Menus</div>
<div class="stat-value text-primary">{roleData.permissoes.length}</div>
</div> </div>
<div class="stat"> {:else if expandido[roleRow._id]}
<div class="stat-title">Com Acesso</div>
<div class="stat-value text-info">{roleData.permissoes.filter(p => p.podeAcessar).length}</div>
</div>
<div class="stat">
<div class="stat-title">Pode Consultar</div>
<div class="stat-value text-success">{roleData.permissoes.filter(p => p.podeConsultar).length}</div>
</div>
<div class="stat">
<div class="stat-title">Pode Gravar</div>
<div class="stat-value text-warning">{roleData.permissoes.filter(p => p.podeGravar).length}</div>
</div>
</div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table table-zebra table-sm"> <table class="table table-zebra table-sm">
<thead class="bg-base-200"> <thead class="bg-base-200">
<tr> <tr>
<th class="w-1/3">Menu</th> <th class="w-1/3">Recurso</th>
<th class="text-center"> <th class="text-center">Ver</th>
<div class="flex items-center justify-center gap-1"> <th class="text-center">Listar</th>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <th class="text-center">Criar</th>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <th class="text-center">Editar</th>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> <th class="text-center">Excluir</th>
</svg>
Acessar
</div>
</th>
<th class="text-center">
<div class="flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Consultar
</div>
</th>
<th class="text-center">
<div class="flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Gravar
</div>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each roleData.permissoes as permissao} {#each catalogoQuery.data as item}
<tr class="hover"> <tr class="hover">
<td> <td>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="font-semibold">{permissao.menuNome}</span> <span class="font-semibold">{item.recurso}</span>
<span class="text-xs text-base-content/60">{permissao.menuPath}</span>
</div> </div>
</td> </td>
{#each ["ver", "listar", "criar", "editar", "excluir"] as ac}
<td class="text-center"> <td class="text-center">
<input <input
type="checkbox" type="checkbox"
class="checkbox checkbox-primary" class="checkbox checkbox-primary"
checked={permissao.podeAcessar} checked={isConcedida(roleRow._id, item.recurso, ac)}
disabled={salvando} disabled={salvando}
onchange={(e) => onchange={(e) =>
atualizarPermissao( toggleAcao(
roleData.role._id, roleRow._id,
permissao.menuPath, item.recurso,
"podeAcessar", ac,
e.currentTarget.checked
)}
/>
</td>
<td class="text-center">
<input
type="checkbox"
class="checkbox checkbox-info"
checked={permissao.podeConsultar}
disabled={salvando || !permissao.podeAcessar}
onchange={(e) =>
atualizarPermissao(
roleData.role._id,
permissao.menuPath,
"podeConsultar",
e.currentTarget.checked
)}
/>
</td>
<td class="text-center">
<input
type="checkbox"
class="checkbox checkbox-success"
checked={permissao.podeGravar}
disabled={salvando || !permissao.podeConsultar}
onchange={(e) =>
atualizarPermissao(
roleData.role._id,
permissao.menuPath,
"podeGravar",
e.currentTarget.checked e.currentTarget.checked
)} )}
/> />
</td> </td>
{/each}
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -508,4 +542,3 @@
{/each} {/each}
{/if} {/if}
</ProtectedRoute> </ProtectedRoute>

View File

@@ -1,123 +1,6 @@
<script lang="ts"> <script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte"; import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
const client = useConvexClient();
let matriculaBusca = $state("");
let usuarioEncontrado = $state<any>(null);
let buscando = $state(false);
let salvando = $state(false);
let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>(null);
// Buscar permissões personalizadas do usuário
const permissoesQuery = $derived(
usuarioEncontrado
? useQuery(api.menuPermissoes.listarPermissoesPersonalizadas, {
matricula: usuarioEncontrado.matricula,
})
: null
);
// Buscar menus disponíveis
const menusQuery = useQuery(api.menuPermissoes.listarMenus, {});
async function buscarUsuario() {
if (!matriculaBusca.trim()) {
mensagem = { tipo: "error", texto: "Digite uma matrícula para buscar" };
return;
}
try {
buscando = true;
const usuario = await client.query(api.menuPermissoes.buscarUsuarioPorMatricula, {
matricula: matriculaBusca.trim(),
});
if (usuario) {
usuarioEncontrado = usuario;
mensagem = null;
} else {
usuarioEncontrado = null;
mensagem = { tipo: "error", texto: "Usuário não encontrado com esta matrícula" };
}
} catch (e: any) {
mensagem = { tipo: "error", texto: e.message || "Erro ao buscar usuário" };
} finally {
buscando = false;
}
}
async function atualizarPermissao(
menuPath: string,
campo: "podeAcessar" | "podeConsultar" | "podeGravar",
valor: boolean
) {
if (!usuarioEncontrado) return;
try {
salvando = true;
// Obter permissão atual do menu
const permissaoAtual = permissoesQuery?.data?.find((p) => p.menuPath === menuPath);
let podeAcessar = valor;
let podeConsultar = false;
let podeGravar = false;
// Aplicar lógica de dependências
if (campo === "podeGravar" && valor) {
podeAcessar = true;
podeConsultar = true;
podeGravar = true;
} else if (campo === "podeConsultar" && valor) {
podeAcessar = true;
podeConsultar = true;
podeGravar = permissaoAtual?.podeGravar || false;
} else if (campo === "podeAcessar" && !valor) {
podeAcessar = false;
podeConsultar = false;
podeGravar = false;
} else if (campo === "podeConsultar" && !valor) {
podeAcessar = permissaoAtual?.podeAcessar !== undefined ? permissaoAtual.podeAcessar : false;
podeConsultar = false;
podeGravar = false;
} else if (campo === "podeGravar" && !valor) {
podeAcessar = permissaoAtual?.podeAcessar !== undefined ? permissaoAtual.podeAcessar : false;
podeConsultar = permissaoAtual?.podeConsultar !== undefined ? permissaoAtual.podeConsultar : false;
podeGravar = false;
} else if (permissaoAtual) {
podeAcessar = permissaoAtual.podeAcessar;
podeConsultar = permissaoAtual.podeConsultar;
podeGravar = permissaoAtual.podeGravar;
}
await client.mutation(api.menuPermissoes.atualizarPermissaoPersonalizada, {
matricula: usuarioEncontrado.matricula,
menuPath,
podeAcessar,
podeConsultar,
podeGravar,
});
mensagem = { tipo: "success", texto: "Permissão personalizada atualizada!" };
setTimeout(() => {
mensagem = null;
}, 3000);
} catch (e: any) {
mensagem = { tipo: "error", texto: e.message || "Erro ao atualizar permissão" };
} finally {
salvando = false;
}
}
function limparBusca() {
matriculaBusca = "";
usuarioEncontrado = null;
mensagem = null;
}
</script> </script>
<ProtectedRoute allowedRoles={["admin", "ti"]} maxLevel={1}> <ProtectedRoute allowedRoles={["admin", "ti"]} maxLevel={1}>
@@ -126,8 +9,19 @@
<ul> <ul>
<li> <li>
<a href="/" class="text-primary hover:text-primary-focus"> <a href="/" class="text-primary hover:text-primary-focus">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg> </svg>
Dashboard Dashboard
</a> </a>
@@ -143,241 +37,71 @@
<div class="mb-6"> <div class="mb-6">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
<div class="p-3 bg-info/10 rounded-xl"> <div class="p-3 bg-info/10 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-info"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h1 class="text-3xl font-bold text-base-content">Personalizar Permissões por Matrícula</h1> <h1 class="text-3xl font-bold text-base-content">
<p class="text-base-content/60 mt-1">Configure permissões específicas para usuários individuais</p> Funcionalidade descontinuada
</h1>
<p class="text-base-content/60 mt-1">
Agora as permissões são configuradas por ação em cada perfil no painel
de permissões.
</p>
</div> </div>
<button class="btn btn-ghost gap-2" onclick={() => goto("/ti")}> <button class="btn btn-ghost gap-2" onclick={() => goto("/ti")}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /> 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="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg> </svg>
Voltar Voltar
</button> </button>
</div> </div>
</div> </div>
<div class="alert alert-info shadow-lg">
<!-- Alertas --> <svg
{#if mensagem} xmlns="http://www.w3.org/2000/svg"
<div class="alert mb-6 shadow-lg" class:alert-success={mensagem.tipo === "success"} class:alert-error={mensagem.tipo === "error"}> class="stroke-current shrink-0 h-6 w-6"
{#if mensagem.tipo === "success"} fill="none"
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"> 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>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<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" />
</svg>
{/if}
<span class="font-semibold">{mensagem.texto}</span>
</div>
{/if}
<!-- Card de Busca -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">Buscar Usuário</h2>
<p class="text-sm text-base-content/60">Digite a matrícula do usuário para personalizar suas permissões</p>
<div class="flex gap-4 mt-4">
<div class="form-control flex-1">
<label class="label" for="matricula-busca">
<span class="label-text font-semibold">Matrícula</span>
</label>
<input
id="matricula-busca"
type="text"
class="input input-bordered input-primary w-full"
placeholder="Digite a matrícula..."
bind:value={matriculaBusca}
disabled={buscando}
onkeydown={(e) => e.key === "Enter" && buscarUsuario()}
/>
</div>
<div class="flex items-end gap-2">
<button
class="btn btn-primary"
onclick={buscarUsuario}
disabled={buscando || !matriculaBusca.trim()}
> >
{#if buscando} <path
<span class="loading loading-spinner loading-sm"></span> stroke-linecap="round"
{:else} stroke-linejoin="round"
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> stroke-width="2"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
{/if} <span>
Buscar A personalização por usuário foi substituída por <strong
</button> >permissões por ação</strong
>
{#if usuarioEncontrado} por perfil. Utilize o
<button class="btn btn-ghost" onclick={limparBusca}> <a href="/ti/painel-permissoes" class="link link-primary"
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> >Painel de Permissões</a
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> > para configurar.
</svg>
Limpar
</button>
{/if}
</div>
</div>
</div>
</div>
<!-- Informações do Usuário -->
{#if usuarioEncontrado}
<div class="card bg-gradient-to-br from-info/10 to-info/5 shadow-xl mb-6 border-2 border-info/20">
<div class="card-body">
<div class="flex items-center gap-4">
<div class="avatar placeholder">
<div class="bg-info text-info-content rounded-full w-16">
<span class="text-2xl font-bold">{usuarioEncontrado.nome.charAt(0)}</span>
</div>
</div>
<div class="flex-1">
<h3 class="text-xl font-bold">{usuarioEncontrado.nome}</h3>
<div class="flex gap-4 mt-1 text-sm">
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<strong>Matrícula:</strong> {usuarioEncontrado.matricula}
</span>
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<strong>Email:</strong> {usuarioEncontrado.email}
</span> </span>
</div> </div>
</div>
<div class="text-right">
<div class="badge badge-primary badge-lg">
Nível {usuarioEncontrado.role.nivel}
</div>
<p class="text-sm mt-1">{usuarioEncontrado.role.descricao}</p>
<div class="badge mt-2" class:badge-success={usuarioEncontrado.ativo} class:badge-error={!usuarioEncontrado.ativo}>
{usuarioEncontrado.ativo ? "Ativo" : "Inativo"}
</div>
</div>
</div>
</div>
</div>
<!-- Tabela de Permissões -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Permissões Personalizadas</h2>
<div class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<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" />
</svg>
<div>
<p class="text-sm">
<strong>Permissões personalizadas sobrepõem as permissões da função.</strong><br />
Configure apenas os menus que deseja personalizar para este usuário.
</p>
</div>
</div>
{#if menusQuery.isLoading}
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if menusQuery.data}
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<thead class="bg-base-200">
<tr>
<th class="w-1/3">Menu</th>
<th class="text-center">
<div class="flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Acessar
</div>
</th>
<th class="text-center">
<div class="flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Consultar
</div>
</th>
<th class="text-center">
<div class="flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Gravar
</div>
</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody>
{#each menusQuery.data as menu}
{@const permissao = permissoesQuery?.data?.find((p) => p.menuPath === menu.path)}
<tr class="hover">
<td>
<div class="flex flex-col">
<span class="font-semibold">{menu.nome}</span>
<span class="text-xs text-base-content/60">{menu.path}</span>
</div>
</td>
<td class="text-center">
<input
type="checkbox"
class="checkbox checkbox-primary"
checked={permissao?.podeAcessar || false}
disabled={salvando}
onchange={(e) =>
atualizarPermissao(menu.path, "podeAcessar", e.currentTarget.checked)}
/>
</td>
<td class="text-center">
<input
type="checkbox"
class="checkbox checkbox-info"
checked={permissao?.podeConsultar || false}
disabled={salvando || !permissao?.podeAcessar}
onchange={(e) =>
atualizarPermissao(menu.path, "podeConsultar", e.currentTarget.checked)}
/>
</td>
<td class="text-center">
<input
type="checkbox"
class="checkbox checkbox-success"
checked={permissao?.podeGravar || false}
disabled={salvando || !permissao?.podeConsultar}
onchange={(e) =>
atualizarPermissao(menu.path, "podeGravar", e.currentTarget.checked)}
/>
</td>
<td class="text-center">
{#if permissao}
<div class="badge badge-warning badge-sm">Personalizado</div>
{:else}
<div class="badge badge-ghost badge-sm">Padrão da Função</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
{/if}
</ProtectedRoute> </ProtectedRoute>

View File

@@ -1,3 +0,0 @@
import { createSvelteKitHandler } from "@mmailaender/convex-better-auth-svelte/sveltekit";
export const { GET, POST } = createSvelteKitHandler();

View File

@@ -10,7 +10,6 @@
"typescript": "^5.9.2" "typescript": "^5.9.2"
}, },
"dependencies": { "dependencies": {
"convex": "^1.28.0", "convex": "^1.28.0"
"better-auth": "1.3.27"
} }
} }

View File

@@ -28,10 +28,10 @@ import type * as limparPerfisAntigos from "../limparPerfisAntigos.js";
import type * as logsAcesso from "../logsAcesso.js"; import type * as logsAcesso from "../logsAcesso.js";
import type * as logsAtividades from "../logsAtividades.js"; import type * as logsAtividades from "../logsAtividades.js";
import type * as logsLogin from "../logsLogin.js"; import type * as logsLogin from "../logsLogin.js";
import type * as menuPermissoes from "../menuPermissoes.js";
import type * as migrarUsuariosAdmin from "../migrarUsuariosAdmin.js"; import type * as migrarUsuariosAdmin from "../migrarUsuariosAdmin.js";
import type * as monitoramento from "../monitoramento.js"; import type * as monitoramento from "../monitoramento.js";
import type * as perfisCustomizados from "../perfisCustomizados.js"; import type * as perfisCustomizados from "../perfisCustomizados.js";
import type * as permissoesAcoes from "../permissoesAcoes.js";
import type * as roles from "../roles.js"; import type * as roles from "../roles.js";
import type * as seed from "../seed.js"; import type * as seed from "../seed.js";
import type * as simbolos from "../simbolos.js"; import type * as simbolos from "../simbolos.js";
@@ -76,10 +76,10 @@ declare const fullApi: ApiFromModules<{
logsAcesso: typeof logsAcesso; logsAcesso: typeof logsAcesso;
logsAtividades: typeof logsAtividades; logsAtividades: typeof logsAtividades;
logsLogin: typeof logsLogin; logsLogin: typeof logsLogin;
menuPermissoes: typeof menuPermissoes;
migrarUsuariosAdmin: typeof migrarUsuariosAdmin; migrarUsuariosAdmin: typeof migrarUsuariosAdmin;
monitoramento: typeof monitoramento; monitoramento: typeof monitoramento;
perfisCustomizados: typeof perfisCustomizados; perfisCustomizados: typeof perfisCustomizados;
permissoesAcoes: typeof permissoesAcoes;
roles: typeof roles; roles: typeof roles;
seed: typeof seed; seed: typeof seed;
simbolos: typeof simbolos; simbolos: typeof simbolos;

View File

@@ -1,6 +1,12 @@
import { v } from "convex/values"; import { v } from "convex/values";
import { mutation, query } from "./_generated/server"; import { mutation, query } from "./_generated/server";
import { hashPassword, verifyPassword, generateToken, validarMatricula, validarSenha } from "./auth/utils"; import {
hashPassword,
verifyPassword,
generateToken,
validarMatricula,
validarSenha,
} from "./auth/utils";
import { registrarLogin } from "./logsLogin"; import { registrarLogin } from "./logsLogin";
import { Id } from "./_generated/dataModel"; import { Id } from "./_generated/dataModel";
@@ -10,7 +16,7 @@ import { Id } from "./_generated/dataModel";
async function verificarBloqueioUsuario(ctx: any, usuarioId: Id<"usuarios">) { async function verificarBloqueioUsuario(ctx: any, usuarioId: Id<"usuarios">) {
const bloqueio = await ctx.db const bloqueio = await ctx.db
.query("bloqueiosUsuarios") .query("bloqueiosUsuarios")
.withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioId)) .withIndex("by_usuario", (q: any) => q.eq("usuarioId", usuarioId))
.filter((q: any) => q.eq(q.field("ativo"), true)) .filter((q: any) => q.eq(q.field("ativo"), true))
.first(); .first();
@@ -102,7 +108,9 @@ export const login = mutation({
} else { } else {
usuario = await ctx.db usuario = await ctx.db
.query("usuarios") .query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matriculaOuEmail)) .withIndex("by_matricula", (q) =>
q.eq("matricula", args.matriculaOuEmail)
)
.first(); .first();
} }
@@ -122,7 +130,10 @@ export const login = mutation({
} }
// Verificar se usuário está bloqueado // Verificar se usuário está bloqueado
if (usuario.bloqueado || (await verificarBloqueioUsuario(ctx, usuario._id))) { if (
usuario.bloqueado ||
(await verificarBloqueioUsuario(ctx, usuario._id))
) {
await registrarLogin(ctx, { await registrarLogin(ctx, {
usuarioId: usuario._id, usuarioId: usuario._id,
matriculaOuEmail: args.matriculaOuEmail, matriculaOuEmail: args.matriculaOuEmail,
@@ -172,7 +183,9 @@ export const login = mutation({
userAgent: args.userAgent, userAgent: args.userAgent,
}); });
const minutosRestantes = Math.ceil((TEMPO_BLOQUEIO - tempoDecorrido) / 60000); const minutosRestantes = Math.ceil(
(TEMPO_BLOQUEIO - tempoDecorrido) / 60000
);
return { return {
sucesso: false as const, sucesso: false as const,
erro: `Conta temporariamente bloqueada. Tente novamente em ${minutosRestantes} minutos.`, erro: `Conta temporariamente bloqueada. Tente novamente em ${minutosRestantes} minutos.`,
@@ -192,7 +205,8 @@ export const login = mutation({
if (!senhaValida) { if (!senhaValida) {
// Incrementar tentativas // Incrementar tentativas
const novasTentativas = tempoDecorrido > TEMPO_BLOQUEIO ? 1 : tentativasRecentes + 1; const novasTentativas =
tempoDecorrido > TEMPO_BLOQUEIO ? 1 : tentativasRecentes + 1;
await ctx.db.patch(usuario._id, { await ctx.db.patch(usuario._id, {
tentativasLogin: novasTentativas, tentativasLogin: novasTentativas,
@@ -367,7 +381,10 @@ export const verificarSessao = query({
.first(); .first();
if (!sessao || !sessao.ativo) { if (!sessao || !sessao.ativo) {
return { valido: false as const, motivo: "Sessão não encontrada ou inativa" }; return {
valido: false as const,
motivo: "Sessão não encontrada ou inativa",
};
} }
// Verificar se sessão expirou // Verificar se sessão expirou
@@ -380,7 +397,10 @@ export const verificarSessao = query({
// Buscar usuário // Buscar usuário
const usuario = await ctx.db.get(sessao.usuarioId); const usuario = await ctx.db.get(sessao.usuarioId);
if (!usuario || !usuario.ativo) { if (!usuario || !usuario.ativo) {
return { valido: false as const, motivo: "Usuário não encontrado ou inativo" }; return {
valido: false as const,
motivo: "Usuário não encontrado ou inativo",
};
} }
// Buscar role // Buscar role
@@ -511,4 +531,3 @@ export const alterarSenha = mutation({
return { sucesso: true as const }; return { sucesso: true as const };
}, },
}); });

View File

@@ -1,54 +0,0 @@
import { createClient, type GenericCtx } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import { components } from "./_generated/api";
import { type DataModel } from "./_generated/dataModel";
import { query } from "./_generated/server";
import { betterAuth } from "better-auth";
import schema from "./betterAuth/schema";
// Configurações de ambiente para produção
const siteUrl = process.env.SITE_URL || process.env.CONVEX_SITE_URL || "http://localhost:5173";
const authSecret = process.env.BETTER_AUTH_SECRET;
// The component client has methods needed for integrating Convex with Better Auth,
// as well as helper methods for general use.
export const authComponent = createClient<DataModel>(components.betterAuth, {
local: {
schema: schema as any,
},
});
export const createAuth = (
ctx: GenericCtx<DataModel>,
{ optionsOnly } = { optionsOnly: false }
) => {
return betterAuth({
// Secret para criptografia de tokens - OBRIGATÓRIO em produção
secret: authSecret,
// disable logging when createAuth is called just to generate options.
// this is not required, but there's a lot of noise in logs without it.
logger: {
disabled: optionsOnly,
},
baseURL: siteUrl,
database: authComponent.adapter(ctx),
// Configure simple, non-verified email/password to get started
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
plugins: [
// The Convex plugin is required for Convex compatibility
convex(),
],
});
};
// Example function for getting the current user
// Feel free to edit, omit, etc.
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
return authComponent.getAuthUser(ctx as any);
},
});

View File

@@ -1,13 +0,0 @@
import { createApi } from "@convex-dev/better-auth";
import schema from "./schema";
import { createAuth } from "../auth";
export const {
create,
findOne,
findMany,
updateOne,
updateMany,
deleteOne,
deleteMany,
} = createApi(schema, createAuth);

View File

@@ -1,5 +0,0 @@
import { createAuth } from "../auth";
import { getStaticAuth } from "@convex-dev/better-auth";
// Export a static instance for Better Auth schema generation
export const auth = getStaticAuth(createAuth);

View File

@@ -1,5 +0,0 @@
import { defineComponent } from "convex/server";
const component = defineComponent("betterAuth");
export default component;

View File

@@ -1,70 +0,0 @@
// This file is auto-generated. Do not edit this file manually.
// To regenerate the schema, run:
// `npx @better-auth/cli generate --output undefined -y`
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export const tables = {
user: defineTable({
name: v.string(),
email: v.string(),
emailVerified: v.boolean(),
image: v.optional(v.union(v.null(), v.string())),
createdAt: v.number(),
updatedAt: v.number(),
userId: v.optional(v.union(v.null(), v.string())),
})
.index("email_name", ["email","name"])
.index("name", ["name"])
.index("userId", ["userId"]),
session: defineTable({
expiresAt: v.number(),
token: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
ipAddress: v.optional(v.union(v.null(), v.string())),
userAgent: v.optional(v.union(v.null(), v.string())),
userId: v.string(),
})
.index("expiresAt", ["expiresAt"])
.index("expiresAt_userId", ["expiresAt","userId"])
.index("token", ["token"])
.index("userId", ["userId"]),
account: defineTable({
accountId: v.string(),
providerId: v.string(),
userId: v.string(),
accessToken: v.optional(v.union(v.null(), v.string())),
refreshToken: v.optional(v.union(v.null(), v.string())),
idToken: v.optional(v.union(v.null(), v.string())),
accessTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
refreshTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
scope: v.optional(v.union(v.null(), v.string())),
password: v.optional(v.union(v.null(), v.string())),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("accountId", ["accountId"])
.index("accountId_providerId", ["accountId","providerId"])
.index("providerId_userId", ["providerId","userId"])
.index("userId", ["userId"]),
verification: defineTable({
identifier: v.string(),
value: v.string(),
expiresAt: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("expiresAt", ["expiresAt"])
.index("identifier", ["identifier"]),
jwks: defineTable({
publicKey: v.string(),
privateKey: v.string(),
createdAt: v.number(),
}),
};
const schema = defineSchema(tables);
export default schema;

View File

@@ -1,7 +1,4 @@
import { defineApp } from "convex/server"; import { defineApp } from "convex/server";
import betterAuth from "./betterAuth/convex.config";
const app = defineApp(); const app = defineApp();
app.use(betterAuth);
export default app; export default app;

View File

@@ -1,7 +1,14 @@
import { v } from "convex/values"; import { v } from "convex/values";
import { mutation, query, action, internalMutation } from "./_generated/server"; import {
mutation,
query,
action,
internalMutation,
internalQuery,
} from "./_generated/server";
import { Id } from "./_generated/dataModel"; import { Id } from "./_generated/dataModel";
import { renderizarTemplate } from "./templatesMensagens"; import { renderizarTemplate } from "./templatesMensagens";
import { internal } from "./_generated/api";
/** /**
* Enfileirar email para envio * Enfileirar email para envio
@@ -15,7 +22,10 @@ export const enfileirarEmail = mutation({
templateId: v.optional(v.id("templatesMensagens")), templateId: v.optional(v.id("templatesMensagens")),
enviadoPorId: v.id("usuarios"), enviadoPorId: v.id("usuarios"),
}, },
returns: v.object({ sucesso: v.boolean(), emailId: v.optional(v.id("notificacoesEmail")) }), returns: v.object({
sucesso: v.boolean(),
emailId: v.optional(v.id("notificacoesEmail")),
}),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Validar email // Validar email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -51,7 +61,10 @@ export const enviarEmailComTemplate = mutation({
variaveis: v.any(), // Record<string, string> variaveis: v.any(), // Record<string, string>
enviadoPorId: v.id("usuarios"), enviadoPorId: v.id("usuarios"),
}, },
returns: v.object({ sucesso: v.boolean(), emailId: v.optional(v.id("notificacoesEmail")) }), returns: v.object({
sucesso: v.boolean(),
emailId: v.optional(v.id("notificacoesEmail")),
}),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Buscar template // Buscar template
const template = await ctx.db const template = await ctx.db
@@ -90,25 +103,32 @@ export const enviarEmailComTemplate = mutation({
*/ */
export const listarFilaEmails = query({ export const listarFilaEmails = query({
args: { args: {
status: v.optional(v.union( status: v.optional(
v.union(
v.literal("pendente"), v.literal("pendente"),
v.literal("enviando"), v.literal("enviando"),
v.literal("enviado"), v.literal("enviado"),
v.literal("falha") v.literal("falha")
)), )
),
limite: v.optional(v.number()), limite: v.optional(v.number()),
}, },
returns: v.array(v.any()),
handler: async (ctx, args) => { handler: async (ctx, args) => {
let query = ctx.db.query("notificacoesEmail");
if (args.status) { if (args.status) {
query = query.withIndex("by_status", (q) => q.eq("status", args.status)); const emails = await ctx.db
} else { .query("notificacoesEmail")
query = query.withIndex("by_criado_em"); .withIndex("by_status", (q) => q.eq("status", args.status!))
.order("desc")
.take(args.limite ?? 100);
return emails;
} }
const emails = await query.order("desc").take(args.limite || 100); const emails = await ctx.db
.query("notificacoesEmail")
.withIndex("by_criado_em")
.order("desc")
.take(args.limite ?? 100);
return emails; return emails;
}, },
}); });
@@ -144,6 +164,65 @@ export const reenviarEmail = mutation({
* *
* NOTA: Este é um placeholder. Implementação real requer nodemailer. * NOTA: Este é um placeholder. Implementação real requer nodemailer.
*/ */
export const getEmailById = internalQuery({
args: { emailId: v.id("notificacoesEmail") },
returns: v.union(v.any(), v.null()),
handler: async (ctx, args) => {
return await ctx.db.get(args.emailId);
},
});
export const getActiveEmailConfig = internalQuery({
args: {},
returns: v.union(v.any(), v.null()),
handler: async (ctx) => {
return await ctx.db
.query("configuracaoEmail")
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.first();
},
});
export const markEmailEnviando = internalMutation({
args: { emailId: v.id("notificacoesEmail") },
returns: v.null(),
handler: async (ctx, args) => {
const email = await ctx.db.get(args.emailId);
await ctx.db.patch(args.emailId, {
status: "enviando",
tentativas: ((email as any)?.tentativas || 0) + 1,
ultimaTentativa: Date.now(),
});
return null;
},
});
export const markEmailEnviado = internalMutation({
args: { emailId: v.id("notificacoesEmail") },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.emailId, {
status: "enviado",
enviadoEm: Date.now(),
});
return null;
},
});
export const markEmailFalha = internalMutation({
args: { emailId: v.id("notificacoesEmail"), erro: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const email = await ctx.db.get(args.emailId);
await ctx.db.patch(args.emailId, {
status: "falha",
erroDetalhes: args.erro,
tentativas: ((email as any)?.tentativas || 0) + 1,
});
return null;
},
});
export const enviarEmailAction = action({ export const enviarEmailAction = action({
args: { args: {
emailId: v.id("notificacoesEmail"), emailId: v.id("notificacoesEmail"),
@@ -154,8 +233,8 @@ export const enviarEmailAction = action({
try { try {
// Buscar email da fila // Buscar email da fila
const email = await ctx.runQuery(async (ctx) => { const email = await ctx.runQuery(internal.email.getEmailById, {
return await ctx.db.get(args.emailId); emailId: args.emailId,
}); });
if (!email) { if (!email) {
@@ -163,52 +242,41 @@ export const enviarEmailAction = action({
} }
// Buscar configuração SMTP // Buscar configuração SMTP
const config = await ctx.runQuery(async (ctx) => { const config = await ctx.runQuery(
return await ctx.db internal.email.getActiveEmailConfig,
.query("configuracaoEmail") {}
.withIndex("by_ativo", (q) => q.eq("ativo", true)) );
.first();
});
if (!config) { if (!config) {
return { sucesso: false, erro: "Configuração de email não encontrada" }; return { sucesso: false, erro: "Configuração de email não encontrada" };
} }
// Marcar como enviando // Marcar como enviando
await ctx.runMutation(async (ctx) => { await ctx.runMutation(internal.email.markEmailEnviando, {
await ctx.db.patch(args.emailId, { emailId: args.emailId,
status: "enviando",
tentativas: (email.tentativas || 0) + 1,
ultimaTentativa: Date.now(),
});
}); });
// TODO: Enviar email real com nodemailer aqui // TODO: Enviar email real com nodemailer aqui
console.log("⚠️ AVISO: Envio de email simulado (nodemailer não instalado)"); console.log(
console.log(" Para:", email.destinatario); "⚠️ AVISO: Envio de email simulado (nodemailer não instalado)"
console.log(" Assunto:", email.assunto); );
console.log(" Para:", (email as any).destinatario);
console.log(" Assunto:", (email as any).assunto);
// Simular delay de envio // Simular delay de envio
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
// Marcar como enviado // Marcar como enviado
await ctx.runMutation(async (ctx) => { await ctx.runMutation(internal.email.markEmailEnviado, {
await ctx.db.patch(args.emailId, { emailId: args.emailId,
status: "enviado",
enviadoEm: Date.now(),
});
}); });
return { sucesso: true }; return { sucesso: true };
} catch (error: any) { } catch (error: any) {
// Marcar como falha // Marcar como falha
await ctx.runMutation(async (ctx) => { await ctx.runMutation(internal.email.markEmailFalha, {
const email = await ctx.db.get(args.emailId); emailId: args.emailId,
await ctx.db.patch(args.emailId, { erro: error.message || "Erro ao enviar email",
status: "falha",
erroDetalhes: error.message || "Erro desconhecido",
tentativas: (email?.tentativas || 0) + 1,
});
}); });
return { sucesso: false, erro: error.message || "Erro ao enviar email" }; return { sucesso: false, erro: error.message || "Erro ao enviar email" };
@@ -221,6 +289,7 @@ export const enviarEmailAction = action({
*/ */
export const processarFilaEmails = internalMutation({ export const processarFilaEmails = internalMutation({
args: {}, args: {},
returns: v.object({ processados: v.number() }),
handler: async (ctx) => { handler: async (ctx) => {
// Buscar emails pendentes (max 10 por execução) // Buscar emails pendentes (max 10 por execução)
const emailsPendentes = await ctx.db const emailsPendentes = await ctx.db
@@ -255,5 +324,3 @@ export const processarFilaEmails = internalMutation({
return { processados }; return { processados };
}, },
}); });

View File

@@ -1,18 +1,49 @@
import { v } from "convex/values"; import { v } from "convex/values";
import { query, mutation } from "./_generated/server"; import { query, mutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { simboloTipo } from "./schema"; import { simboloTipo } from "./schema";
// Validadores para campos opcionais // Validadores para campos opcionais
const sexoValidator = v.optional(v.union(v.literal("masculino"), v.literal("feminino"), v.literal("outro"))); const sexoValidator = v.optional(
const estadoCivilValidator = v.optional(v.union(v.literal("solteiro"), v.literal("casado"), v.literal("divorciado"), v.literal("viuvo"), v.literal("uniao_estavel"))); v.union(v.literal("masculino"), v.literal("feminino"), v.literal("outro"))
const grauInstrucaoValidator = v.optional(v.union(v.literal("fundamental"), v.literal("medio"), v.literal("superior"), v.literal("pos_graduacao"), v.literal("mestrado"), v.literal("doutorado"))); );
const grupoSanguineoValidator = v.optional(v.union(v.literal("A"), v.literal("B"), v.literal("AB"), v.literal("O"))); const estadoCivilValidator = v.optional(
const fatorRHValidator = v.optional(v.union(v.literal("positivo"), v.literal("negativo"))); v.union(
const aposentadoValidator = v.optional(v.union(v.literal("nao"), v.literal("funape_ipsep"), v.literal("inss"))); v.literal("solteiro"),
v.literal("casado"),
v.literal("divorciado"),
v.literal("viuvo"),
v.literal("uniao_estavel")
)
);
const grauInstrucaoValidator = v.optional(
v.union(
v.literal("fundamental"),
v.literal("medio"),
v.literal("superior"),
v.literal("pos_graduacao"),
v.literal("mestrado"),
v.literal("doutorado")
)
);
const grupoSanguineoValidator = v.optional(
v.union(v.literal("A"), v.literal("B"), v.literal("AB"), v.literal("O"))
);
const fatorRHValidator = v.optional(
v.union(v.literal("positivo"), v.literal("negativo"))
);
const aposentadoValidator = v.optional(
v.union(v.literal("nao"), v.literal("funape_ipsep"), v.literal("inss"))
);
export const getAll = query({ export const getAll = query({
args: {}, args: {},
handler: async (ctx) => { handler: async (ctx) => {
// Autorização: listar funcionários
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "funcionarios",
acao: "listar",
});
const funcionarios = await ctx.db.query("funcionarios").collect(); const funcionarios = await ctx.db.query("funcionarios").collect();
// Retornar apenas os campos necessários para listagem // Retornar apenas os campos necessários para listagem
return funcionarios.map((f: any) => ({ return funcionarios.map((f: any) => ({
@@ -40,6 +71,11 @@ export const getAll = query({
export const getById = query({ export const getById = query({
args: { id: v.id("funcionarios") }, args: { id: v.id("funcionarios") },
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Autorização: ver funcionário
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "funcionarios",
acao: "ver",
});
return await ctx.db.get(args.id); return await ctx.db.get(args.id);
}, },
}); });
@@ -140,6 +176,11 @@ export const create = mutation({
}, },
returns: v.id("funcionarios"), returns: v.id("funcionarios"),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Autorização: criar
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "funcionarios",
acao: "criar",
});
// Unicidade: CPF // Unicidade: CPF
const cpfExists = await ctx.db const cpfExists = await ctx.db
.query("funcionarios") .query("funcionarios")
@@ -260,6 +301,11 @@ export const update = mutation({
}, },
returns: v.null(), returns: v.null(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Autorização: editar
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "funcionarios",
acao: "editar",
});
// Unicidade: CPF (excluindo o próprio registro) // Unicidade: CPF (excluindo o próprio registro)
const cpfExists = await ctx.db const cpfExists = await ctx.db
.query("funcionarios") .query("funcionarios")
@@ -288,6 +334,11 @@ export const remove = mutation({
args: { id: v.id("funcionarios") }, args: { id: v.id("funcionarios") },
returns: v.null(), returns: v.null(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Autorização: excluir
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "funcionarios",
acao: "excluir",
});
// TODO: Talvez queiramos também remover os arquivos do storage // TODO: Talvez queiramos também remover os arquivos do storage
await ctx.db.delete(args.id); await ctx.db.delete(args.id);
return null; return null;
@@ -298,6 +349,10 @@ export const remove = mutation({
export const getFichaCompleta = query({ export const getFichaCompleta = query({
args: { id: v.id("funcionarios") }, args: { id: v.id("funcionarios") },
handler: async (ctx, args) => { handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "funcionarios",
acao: "ver",
});
const funcionario = await ctx.db.get(args.id); const funcionario = await ctx.db.get(args.id);
if (!funcionario) { if (!funcionario) {
return null; return null;
@@ -308,11 +363,13 @@ export const getFichaCompleta = query({
return { return {
...funcionario, ...funcionario,
simbolo: simbolo ? { simbolo: simbolo
? {
nome: simbolo.nome, nome: simbolo.nome,
descricao: simbolo.descricao, descricao: simbolo.descricao,
valor: simbolo.valor, valor: simbolo.valor,
} : null, }
: null,
}; };
}, },
}); });

View File

@@ -13,7 +13,7 @@ export const listarTodosRoles = query({
descricao: v.string(), descricao: v.string(),
nivel: v.number(), nivel: v.number(),
setor: v.optional(v.string()), setor: v.optional(v.string()),
customizado: v.boolean(), customizado: v.optional(v.boolean()),
editavel: v.optional(v.boolean()), editavel: v.optional(v.boolean()),
_creationTime: v.number(), _creationTime: v.number(),
}) })
@@ -91,7 +91,8 @@ export const limparPerfisAntigos = internalMutation({
deveManter = true; deveManter = true;
perfisCorretos.set("ti_master", true); perfisCorretos.set("ti_master", true);
} else { } else {
motivo = role.nivel !== 0 motivo =
role.nivel !== 0
? "TI_MASTER deve ser nível 0, este é nível " + role.nivel ? "TI_MASTER deve ser nível 0, este é nível " + role.nivel
: "TI_MASTER duplicado"; : "TI_MASTER duplicado";
} }
@@ -102,7 +103,8 @@ export const limparPerfisAntigos = internalMutation({
deveManter = true; deveManter = true;
perfisCorretos.set("admin", true); perfisCorretos.set("admin", true);
} else { } else {
motivo = role.nivel !== 2 motivo =
role.nivel !== 2
? "ADMIN deve ser nível 2, este é nível " + role.nivel ? "ADMIN deve ser nível 2, este é nível " + role.nivel
: "ADMIN duplicado"; : "ADMIN duplicado";
} }
@@ -113,14 +115,16 @@ export const limparPerfisAntigos = internalMutation({
deveManter = true; deveManter = true;
perfisCorretos.set("ti_usuario", true); perfisCorretos.set("ti_usuario", true);
} else { } else {
motivo = role.nivel !== 2 motivo =
role.nivel !== 2
? "TI_USUARIO deve ser nível 2, este é nível " + role.nivel ? "TI_USUARIO deve ser nível 2, este é nível " + role.nivel
: "TI_USUARIO duplicado"; : "TI_USUARIO duplicado";
} }
} }
// Perfis genéricos antigos (remover) // Perfis genéricos antigos (remover)
else if (role.nome === "ti") { else if (role.nome === "ti") {
motivo = "Perfil genérico 'ti' obsoleto - usar 'ti_master' ou 'ti_usuario'"; motivo =
"Perfil genérico 'ti' obsoleto - usar 'ti_master' ou 'ti_usuario'";
} }
// Outros perfis específicos de setores (manter se forem nível >= 2) // Outros perfis específicos de setores (manter se forem nível >= 2)
else if ( else if (
@@ -157,7 +161,9 @@ export const limparPerfisAntigos = internalMutation({
descricao: role.descricao, descricao: role.descricao,
nivel: role.nivel, nivel: role.nivel,
}); });
console.log(`✅ MANTIDO: ${role.nome} (${role.descricao}) - Nível ${role.nivel}`); console.log(
`✅ MANTIDO: ${role.nome} (${role.descricao}) - Nível ${role.nivel}`
);
} else { } else {
// Verificar se há usuários usando este perfil // Verificar se há usuários usando este perfil
const usuariosComRole = await ctx.db const usuariosComRole = await ctx.db
@@ -286,5 +292,3 @@ export const verificarNiveisIncorretos = query({
return problemas; return problemas;
}, },
}); });

View File

@@ -7,7 +7,7 @@ import { Doc, Id } from "./_generated/dataModel";
* Use em todas as mutations que modificam dados * Use em todas as mutations que modificam dados
*/ */
export async function registrarAtividade( export async function registrarAtividade(
ctx: QueryCtx | MutationCtx, ctx: MutationCtx,
usuarioId: Id<"usuarios">, usuarioId: Id<"usuarios">,
acao: string, acao: string,
recurso: string, recurso: string,
@@ -37,21 +37,34 @@ export const listarAtividades = query({
limite: v.optional(v.number()), limite: v.optional(v.number()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
let query = ctx.db.query("logsAtividades"); let atividades;
// Aplicar filtros
if (args.usuarioId) { if (args.usuarioId) {
query = query.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId)); atividades = await ctx.db
.query("logsAtividades")
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId!))
.order("desc")
.take(args.limite || 100);
} else if (args.acao) { } else if (args.acao) {
query = query.withIndex("by_acao", (q) => q.eq("acao", args.acao)); atividades = await ctx.db
.query("logsAtividades")
.withIndex("by_acao", (q) => q.eq("acao", args.acao!))
.order("desc")
.take(args.limite || 100);
} else if (args.recurso) { } else if (args.recurso) {
query = query.withIndex("by_recurso", (q) => q.eq("recurso", args.recurso)); atividades = await ctx.db
.query("logsAtividades")
.withIndex("by_recurso", (q) => q.eq("recurso", args.recurso!))
.order("desc")
.take(args.limite || 100);
} else { } else {
query = query.withIndex("by_timestamp"); atividades = await ctx.db
.query("logsAtividades")
.withIndex("by_timestamp")
.order("desc")
.take(args.limite || 100);
} }
let atividades = await query.order("desc").take(args.limite || 100);
// Filtrar por range de datas se fornecido // Filtrar por range de datas se fornecido
if (args.dataInicio || args.dataFim) { if (args.dataInicio || args.dataFim) {
atividades = atividades.filter((log) => { atividades = atividades.filter((log) => {
@@ -155,5 +168,3 @@ export const obterHistoricoRecurso = query({
return atividadesComUsuarios; return atividadesComUsuarios;
}, },
}); });

View File

@@ -6,7 +6,7 @@ import { Doc, Id } from "./_generated/dataModel";
* Helper para registrar tentativas de login * Helper para registrar tentativas de login
*/ */
export async function registrarLogin( export async function registrarLogin(
ctx: QueryCtx | MutationCtx, ctx: MutationCtx,
dados: { dados: {
usuarioId?: Id<"usuarios">; usuarioId?: Id<"usuarios">;
matriculaOuEmail: string; matriculaOuEmail: string;
@@ -170,14 +170,18 @@ export const obterEstatisticasLogin = query({
// Logins por horário (hora do dia) // Logins por horário (hora do dia)
const porHorario: Record<number, number> = {}; const porHorario: Record<number, number> = {};
logs.filter((l) => l.sucesso).forEach((log) => { logs
.filter((l) => l.sucesso)
.forEach((log) => {
const hora = new Date(log.timestamp).getHours(); const hora = new Date(log.timestamp).getHours();
porHorario[hora] = (porHorario[hora] || 0) + 1; porHorario[hora] = (porHorario[hora] || 0) + 1;
}); });
// Browser mais usado // Browser mais usado
const porBrowser: Record<string, number> = {}; const porBrowser: Record<string, number> = {};
logs.filter((l) => l.sucesso).forEach((log) => { logs
.filter((l) => l.sucesso)
.forEach((log) => {
if (log.browser) { if (log.browser) {
porBrowser[log.browser] = (porBrowser[log.browser] || 0) + 1; porBrowser[log.browser] = (porBrowser[log.browser] || 0) + 1;
} }
@@ -185,7 +189,9 @@ export const obterEstatisticasLogin = query({
// Dispositivos mais usados // Dispositivos mais usados
const porDevice: Record<string, number> = {}; const porDevice: Record<string, number> = {};
logs.filter((l) => l.sucesso).forEach((log) => { logs
.filter((l) => l.sucesso)
.forEach((log) => {
if (log.device) { if (log.device) {
porDevice[log.device] = (porDevice[log.device] || 0) + 1; porDevice[log.device] = (porDevice[log.device] || 0) + 1;
} }
@@ -231,4 +237,3 @@ export const verificarIPSuspeito = query({
}; };
}, },
}); });

View File

@@ -1,528 +0,0 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
/**
* Lista de menus do sistema
*/
export const MENUS_SISTEMA = [
{ path: "/recursos-humanos", nome: "Recursos Humanos", descricao: "Gestão de funcionários e símbolos" },
{ path: "/recursos-humanos/funcionarios", nome: "Funcionários", descricao: "Cadastro e gestão de funcionários" },
{ path: "/recursos-humanos/simbolos", nome: "Símbolos", descricao: "Cadastro e gestão de símbolos" },
{ path: "/financeiro", nome: "Financeiro", descricao: "Gestão financeira" },
{ path: "/controladoria", nome: "Controladoria", descricao: "Controle e auditoria" },
{ path: "/licitacoes", nome: "Licitações", descricao: "Gestão de licitações" },
{ path: "/compras", nome: "Compras", descricao: "Gestão de compras" },
{ path: "/juridico", nome: "Jurídico", descricao: "Departamento jurídico" },
{ path: "/comunicacao", nome: "Comunicação", descricao: "Gestão de comunicação" },
{ path: "/programas-esportivos", nome: "Programas Esportivos", descricao: "Gestão de programas esportivos" },
{ path: "/secretaria-executiva", nome: "Secretaria Executiva", descricao: "Secretaria executiva" },
{ path: "/gestao-pessoas", nome: "Gestão de Pessoas", descricao: "Gestão de recursos humanos" },
{ path: "/ti", nome: "Tecnologia da Informação", descricao: "TI e suporte técnico" },
{ path: "/ti/painel-administrativo", nome: "Painel Administrativo TI", descricao: "Painel de administração do sistema" },
] as const;
/**
* Listar todas as permissões de menu para uma role
*/
export const listarPorRole = query({
args: { roleId: v.id("roles") },
returns: v.array(
v.object({
_id: v.id("menuPermissoes"),
roleId: v.id("roles"),
menuPath: v.string(),
podeAcessar: v.boolean(),
podeConsultar: v.boolean(),
podeGravar: v.boolean(),
})
),
handler: async (ctx, args) => {
return await ctx.db
.query("menuPermissoes")
.withIndex("by_role", (q) => q.eq("roleId", args.roleId))
.collect();
},
});
/**
* Verificar se um usuário tem permissão para acessar um menu
* Prioridade: Permissão personalizada > Permissão da role
*/
export const verificarAcesso = query({
args: {
usuarioId: v.id("usuarios"),
menuPath: v.string(),
},
returns: v.object({
podeAcessar: v.boolean(),
podeConsultar: v.boolean(),
podeGravar: v.boolean(),
motivo: v.optional(v.string()),
}),
handler: async (ctx, args) => {
// Buscar o usuário
const usuario = await ctx.db.get(args.usuarioId);
if (!usuario) {
return {
podeAcessar: false,
podeConsultar: false,
podeGravar: false,
motivo: "Usuário não encontrado",
};
}
// Verificar se o usuário está ativo
if (!usuario.ativo) {
return {
podeAcessar: false,
podeConsultar: false,
podeGravar: false,
motivo: "Usuário inativo",
};
}
// Buscar a role do usuário
const role = await ctx.db.get(usuario.roleId);
if (!role) {
return {
podeAcessar: false,
podeConsultar: false,
podeGravar: false,
motivo: "Role não encontrada",
};
}
// Apenas TI_MASTER (nível 0) tem acesso total irrestrito
// Admin, TI_USUARIO e outros (nível >= 1) têm permissões configuráveis
if (role.nivel === 0) {
return {
podeAcessar: true,
podeConsultar: true,
podeGravar: true,
};
}
// Dashboard e Solicitar Acesso são públicos
if (args.menuPath === "/" || args.menuPath === "/solicitar-acesso") {
return {
podeAcessar: true,
podeConsultar: true,
podeGravar: false,
};
}
// 1. Verificar se existe permissão personalizada para este usuário
const permissaoPersonalizada = await ctx.db
.query("menuPermissoesPersonalizadas")
.withIndex("by_usuario_and_menu", (q) =>
q.eq("usuarioId", args.usuarioId).eq("menuPath", args.menuPath)
)
.first();
if (permissaoPersonalizada) {
return {
podeAcessar: permissaoPersonalizada.podeAcessar,
podeConsultar: permissaoPersonalizada.podeConsultar,
podeGravar: permissaoPersonalizada.podeGravar,
};
}
// 2. Se não houver permissão personalizada, verificar permissão da role
const permissaoRole = await ctx.db
.query("menuPermissoes")
.withIndex("by_role_and_menu", (q) =>
q.eq("roleId", usuario.roleId).eq("menuPath", args.menuPath)
)
.first();
if (!permissaoRole) {
return {
podeAcessar: false,
podeConsultar: false,
podeGravar: false,
motivo: "Sem permissão configurada para este menu",
};
}
return {
podeAcessar: permissaoRole.podeAcessar,
podeConsultar: permissaoRole.podeConsultar,
podeGravar: permissaoRole.podeGravar,
};
},
});
/**
* Atualizar ou criar permissão de menu para uma role
*/
export const atualizarPermissao = mutation({
args: {
roleId: v.id("roles"),
menuPath: v.string(),
podeAcessar: v.boolean(),
podeConsultar: v.boolean(),
podeGravar: v.boolean(),
},
returns: v.id("menuPermissoes"),
handler: async (ctx, args) => {
// Verificar se já existe uma permissão
const existente = await ctx.db
.query("menuPermissoes")
.withIndex("by_role_and_menu", (q) =>
q.eq("roleId", args.roleId).eq("menuPath", args.menuPath)
)
.first();
if (existente) {
// Atualizar permissão existente
await ctx.db.patch(existente._id, {
podeAcessar: args.podeAcessar,
podeConsultar: args.podeConsultar,
podeGravar: args.podeGravar,
});
return existente._id;
} else {
// Criar nova permissão
return await ctx.db.insert("menuPermissoes", {
roleId: args.roleId,
menuPath: args.menuPath,
podeAcessar: args.podeAcessar,
podeConsultar: args.podeConsultar,
podeGravar: args.podeGravar,
});
}
},
});
/**
* Remover permissão de menu
*/
export const removerPermissao = mutation({
args: {
permissaoId: v.id("menuPermissoes"),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete(args.permissaoId);
return null;
},
});
/**
* Inicializar permissões padrão para uma role
*/
export const inicializarPermissoesRole = mutation({
args: {
roleId: v.id("roles"),
},
returns: v.null(),
handler: async (ctx, args) => {
// Buscar a role
const role = await ctx.db.get(args.roleId);
if (!role) {
throw new Error("Role não encontrada");
}
// Admin e TI não precisam de permissões específicas (acesso total)
if (role.nivel <= 1) {
return null;
}
// Para outras roles, criar permissões básicas (apenas consulta)
for (const menu of MENUS_SISTEMA) {
// Verificar se já existe permissão
const existente = await ctx.db
.query("menuPermissoes")
.withIndex("by_role_and_menu", (q) =>
q.eq("roleId", args.roleId).eq("menuPath", menu.path)
)
.first();
if (!existente) {
// Criar permissão padrão (sem acesso)
await ctx.db.insert("menuPermissoes", {
roleId: args.roleId,
menuPath: menu.path,
podeAcessar: false,
podeConsultar: false,
podeGravar: false,
});
}
}
return null;
},
});
/**
* Listar todos os menus do sistema
*/
export const listarMenus = query({
args: {},
returns: v.array(
v.object({
path: v.string(),
nome: v.string(),
descricao: v.string(),
})
),
handler: async (ctx) => {
return MENUS_SISTEMA.map((menu) => ({
path: menu.path,
nome: menu.nome,
descricao: menu.descricao,
}));
},
});
/**
* Obter matriz de permissões (role x menu) para o painel de controle
*/
export const obterMatrizPermissoes = query({
args: {},
returns: v.array(
v.object({
role: v.object({
_id: v.id("roles"),
nome: v.string(),
nivel: v.number(),
descricao: v.string(),
}),
permissoes: v.array(
v.object({
menuPath: v.string(),
menuNome: v.string(),
podeAcessar: v.boolean(),
podeConsultar: v.boolean(),
podeGravar: v.boolean(),
permissaoId: v.optional(v.id("menuPermissoes")),
})
),
})
),
handler: async (ctx) => {
// Buscar todas as roles
// TI_MASTER (nível 0) aparece mas não é editável
// Admin, TI_USUARIO e outros (nível >= 1) são configuráveis
const roles = await ctx.db.query("roles").collect();
const matriz = [];
for (const role of roles) {
const permissoes = [];
for (const menu of MENUS_SISTEMA) {
// Buscar permissão específica
const permissao = await ctx.db
.query("menuPermissoes")
.withIndex("by_role_and_menu", (q) =>
q.eq("roleId", role._id).eq("menuPath", menu.path)
)
.first();
// Admin e TI têm acesso total automático
if (role.nivel <= 1) {
permissoes.push({
menuPath: menu.path,
menuNome: menu.nome,
podeAcessar: true,
podeConsultar: true,
podeGravar: true,
permissaoId: permissao?._id,
});
} else {
permissoes.push({
menuPath: menu.path,
menuNome: menu.nome,
podeAcessar: permissao?.podeAcessar ?? false,
podeConsultar: permissao?.podeConsultar ?? false,
podeGravar: permissao?.podeGravar ?? false,
permissaoId: permissao?._id,
});
}
}
matriz.push({
role: {
_id: role._id,
nome: role.nome,
nivel: role.nivel,
descricao: role.descricao,
},
permissoes,
});
}
return matriz;
},
});
/**
* Criar ou atualizar permissão personalizada por matrícula
*/
export const atualizarPermissaoPersonalizada = mutation({
args: {
matricula: v.string(),
menuPath: v.string(),
podeAcessar: v.boolean(),
podeConsultar: v.boolean(),
podeGravar: v.boolean(),
},
returns: v.union(v.id("menuPermissoesPersonalizadas"), v.null()),
handler: async (ctx, args) => {
// Buscar usuário pela matrícula
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.first();
if (!usuario) {
throw new Error("Usuário não encontrado com esta matrícula");
}
// Verificar se já existe permissão personalizada
const existente = await ctx.db
.query("menuPermissoesPersonalizadas")
.withIndex("by_usuario_and_menu", (q) =>
q.eq("usuarioId", usuario._id).eq("menuPath", args.menuPath)
)
.first();
if (existente) {
// Atualizar permissão existente
await ctx.db.patch(existente._id, {
podeAcessar: args.podeAcessar,
podeConsultar: args.podeConsultar,
podeGravar: args.podeGravar,
});
return existente._id;
} else {
// Criar nova permissão
return await ctx.db.insert("menuPermissoesPersonalizadas", {
usuarioId: usuario._id,
matricula: args.matricula,
menuPath: args.menuPath,
podeAcessar: args.podeAcessar,
podeConsultar: args.podeConsultar,
podeGravar: args.podeGravar,
});
}
},
});
/**
* Remover permissão personalizada
*/
export const removerPermissaoPersonalizada = mutation({
args: {
permissaoId: v.id("menuPermissoesPersonalizadas"),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete(args.permissaoId);
return null;
},
});
/**
* Listar permissões personalizadas de um usuário por matrícula
*/
export const listarPermissoesPersonalizadas = query({
args: {
matricula: v.string(),
},
returns: v.array(
v.object({
_id: v.id("menuPermissoesPersonalizadas"),
menuPath: v.string(),
menuNome: v.string(),
podeAcessar: v.boolean(),
podeConsultar: v.boolean(),
podeGravar: v.boolean(),
})
),
handler: async (ctx, args) => {
// Buscar usuário
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.first();
if (!usuario) {
return [];
}
// Buscar permissões personalizadas
const permissoes = await ctx.db
.query("menuPermissoesPersonalizadas")
.withIndex("by_usuario", (q) => q.eq("usuarioId", usuario._id))
.collect();
// Mapear com nomes dos menus
return permissoes.map((p) => {
const menu = MENUS_SISTEMA.find((m) => m.path === p.menuPath);
return {
_id: p._id,
menuPath: p.menuPath,
menuNome: menu?.nome || p.menuPath,
podeAcessar: p.podeAcessar,
podeConsultar: p.podeConsultar,
podeGravar: p.podeGravar,
};
});
},
});
/**
* Buscar usuário por matrícula para o painel de personalização
*/
export const buscarUsuarioPorMatricula = query({
args: {
matricula: v.string(),
},
returns: v.union(
v.object({
_id: v.id("usuarios"),
matricula: v.string(),
nome: v.string(),
email: v.string(),
role: v.object({
nome: v.string(),
nivel: v.number(),
descricao: v.string(),
}),
ativo: v.boolean(),
}),
v.null()
),
handler: async (ctx, args) => {
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.first();
if (!usuario) {
return null;
}
const role = await ctx.db.get(usuario.roleId);
if (!role) {
return null;
}
return {
_id: usuario._id,
matricula: usuario.matricula,
nome: usuario.nome,
email: usuario.email,
role: {
nome: role.nome,
nivel: role.nivel,
descricao: role.descricao,
},
ativo: usuario.ativo,
};
},
});

View File

@@ -1,12 +1,15 @@
import { v } from "convex/values"; import { v } from "convex/values";
import { mutation, query } from "./_generated/server"; import { mutation, query } from "./_generated/server";
import { registrarAtividade } from "./logsAtividades"; import { registrarAtividade } from "./logsAtividades";
import { api } from "./_generated/api";
import { Id } from "./_generated/dataModel";
/** /**
* Listar todos os perfis customizados * Listar todos os perfis customizados
*/ */
export const listarPerfisCustomizados = query({ export const listarPerfisCustomizados = query({
args: {}, args: {},
returns: v.array(v.any()),
handler: async (ctx) => { handler: async (ctx) => {
const perfis = await ctx.db.query("perfisCustomizados").collect(); const perfis = await ctx.db.query("perfisCustomizados").collect();
@@ -42,6 +45,16 @@ export const obterPerfilComPermissoes = query({
args: { args: {
perfilId: v.id("perfisCustomizados"), perfilId: v.id("perfisCustomizados"),
}, },
returns: v.union(
v.object({
perfil: v.any(),
role: v.any(),
permissoes: v.array(v.any()),
menuPermissoes: v.array(v.any()),
usuarios: v.array(v.any()),
}),
v.null()
),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const perfil = await ctx.db.get(args.perfilId); const perfil = await ctx.db.get(args.perfilId);
if (!perfil) { if (!perfil) {
@@ -99,20 +112,31 @@ export const criarPerfilCustomizado = mutation({
criadoPorId: v.id("usuarios"), criadoPorId: v.id("usuarios"),
}, },
returns: v.union( returns: v.union(
v.object({ sucesso: v.literal(true), perfilId: v.id("perfisCustomizados") }), v.object({
sucesso: v.literal(true),
perfilId: v.id("perfisCustomizados"),
}),
v.object({ sucesso: v.literal(false), erro: v.string() }) v.object({ sucesso: v.literal(false), erro: v.string() })
), ),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Validar nível (deve ser >= 3) // Validar nível (deve ser >= 3)
if (args.nivel < 3) { if (args.nivel < 3) {
return { sucesso: false as const, erro: "Perfis customizados devem ter nível >= 3" }; return {
sucesso: false as const,
erro: "Perfis customizados devem ter nível >= 3",
};
} }
// Verificar se nome já existe // Verificar se nome já existe
const roles = await ctx.db.query("roles").collect(); const roles = await ctx.db.query("roles").collect();
const nomeExiste = roles.some((r) => r.nome.toLowerCase() === args.nome.toLowerCase()); const nomeExiste = roles.some(
(r) => r.nome.toLowerCase() === args.nome.toLowerCase()
);
if (nomeExiste) { if (nomeExiste) {
return { sucesso: false as const, erro: "Já existe um perfil com este nome" }; return {
sucesso: false as const,
erro: "Já existe um perfil com este nome",
};
} }
// Criar role correspondente // Criar role correspondente
@@ -130,7 +154,7 @@ export const criarPerfilCustomizado = mutation({
// Copiar permissões gerais // Copiar permissões gerais
const permissoesClonar = await ctx.db const permissoesClonar = await ctx.db
.query("rolePermissoes") .query("rolePermissoes")
.withIndex("by_role", (q) => q.eq("roleId", args.clonarDeRoleId)) .withIndex("by_role", (q) => q.eq("roleId", args.clonarDeRoleId!))
.collect(); .collect();
for (const perm of permissoesClonar) { for (const perm of permissoesClonar) {
@@ -143,7 +167,7 @@ export const criarPerfilCustomizado = mutation({
// Copiar permissões de menu // Copiar permissões de menu
const menuPermsClonar = await ctx.db const menuPermsClonar = await ctx.db
.query("menuPermissoes") .query("menuPermissoes")
.withIndex("by_role", (q) => q.eq("roleId", args.clonarDeRoleId)) .withIndex("by_role", (q) => q.eq("roleId", args.clonarDeRoleId!))
.collect(); .collect();
for (const menuPerm of menuPermsClonar) { for (const menuPerm of menuPermsClonar) {
@@ -321,7 +345,10 @@ export const clonarPerfil = mutation({
criadoPorId: v.id("usuarios"), criadoPorId: v.id("usuarios"),
}, },
returns: v.union( returns: v.union(
v.object({ sucesso: v.literal(true), perfilId: v.id("perfisCustomizados") }), v.object({
sucesso: v.literal(true),
perfilId: v.id("perfisCustomizados"),
}),
v.object({ sucesso: v.literal(false), erro: v.string() }) v.object({ sucesso: v.literal(false), erro: v.string() })
), ),
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -330,17 +357,80 @@ export const clonarPerfil = mutation({
return { sucesso: false as const, erro: "Perfil origem não encontrado" }; return { sucesso: false as const, erro: "Perfil origem não encontrado" };
} }
// Criar novo perfil clonando o original // Verificar se nome já existe
const resultado = await criarPerfilCustomizado(ctx, { const roles = await ctx.db.query("roles").collect();
const nomeExiste = roles.some(
(r) => r.nome.toLowerCase() === args.novoNome.toLowerCase()
);
if (nomeExiste) {
return {
sucesso: false as const,
erro: "Já existe um perfil com este nome",
};
}
// Criar role correspondente
const roleId = await ctx.db.insert("roles", {
nome: args.novoNome.toLowerCase().replace(/\s+/g, "_"),
descricao: args.novaDescricao,
nivel: perfilOrigem.nivel,
customizado: true,
criadoPor: args.criadoPorId,
editavel: true,
});
// Copiar permissões gerais do perfil de origem
const permissoesClonar = await ctx.db
.query("rolePermissoes")
.withIndex("by_role", (q) => q.eq("roleId", perfilOrigem.roleId))
.collect();
for (const perm of permissoesClonar) {
await ctx.db.insert("rolePermissoes", {
roleId,
permissaoId: perm.permissaoId,
});
}
// Copiar permissões de menu
const menuPermsClonar = await ctx.db
.query("menuPermissoes")
.withIndex("by_role", (q) => q.eq("roleId", perfilOrigem.roleId))
.collect();
for (const menuPerm of menuPermsClonar) {
await ctx.db.insert("menuPermissoes", {
roleId,
menuPath: menuPerm.menuPath,
podeAcessar: menuPerm.podeAcessar,
podeConsultar: menuPerm.podeConsultar,
podeGravar: menuPerm.podeGravar,
});
}
// Criar perfil customizado
const perfilId = await ctx.db.insert("perfisCustomizados", {
nome: args.novoNome, nome: args.novoNome,
descricao: args.novaDescricao, descricao: args.novaDescricao,
nivel: perfilOrigem.nivel, nivel: perfilOrigem.nivel,
clonarDeRoleId: perfilOrigem.roleId, roleId,
criadoPorId: args.criadoPorId, criadoPor: args.criadoPorId,
criadoEm: Date.now(),
atualizadoEm: Date.now(),
}); });
return resultado; // Log de atividade
await registrarAtividade(
ctx as any,
args.criadoPorId,
"criar",
"perfis",
JSON.stringify({
perfilId,
nome: args.novoNome,
nivel: perfilOrigem.nivel,
}),
perfilId
);
return { sucesso: true as const, perfilId };
}, },
}); });

View File

@@ -0,0 +1,210 @@
import { query, mutation, internalQuery } from "./_generated/server";
import { v } from "convex/values";
// Catálogo base de recursos e ações
// Ajuste/expanda conforme os módulos disponíveis no sistema
export const CATALOGO_RECURSOS = [
{
recurso: "funcionarios",
acoes: ["dashboard", "ver", "listar", "criar", "editar", "excluir"],
},
{
recurso: "simbolos",
acoes: ["dashboard", "ver", "listar", "criar", "editar", "excluir"],
},
] as const;
export const listarRecursosEAcoes = query({
args: {},
returns: v.array(
v.object({
recurso: v.string(),
acoes: v.array(v.string()),
})
),
handler: async () => {
return CATALOGO_RECURSOS.map((r) => ({
recurso: r.recurso,
acoes: [...r.acoes],
}));
},
});
export const listarPermissoesAcoesPorRole = query({
args: { roleId: v.id("roles") },
returns: v.array(
v.object({
recurso: v.string(),
acoes: v.array(v.string()),
})
),
handler: async (ctx, args) => {
// Buscar vínculos permissao<-role
const rolePerms = await ctx.db
.query("rolePermissoes")
.withIndex("by_role", (q) => q.eq("roleId", args.roleId))
.collect();
// Carregar documentos de permissões
const actionsByResource: Record<string, Set<string>> = {};
for (const rp of rolePerms) {
const perm = await ctx.db.get(rp.permissaoId);
if (!perm) continue;
const set = (actionsByResource[perm.recurso] ||= new Set<string>());
set.add(perm.acao);
}
// Normalizar para todos os recursos do catálogo
const result: Array<{ recurso: string; acoes: Array<string> }> = [];
for (const item of CATALOGO_RECURSOS) {
const granted = Array.from(
actionsByResource[item.recurso] ?? new Set<string>()
);
result.push({ recurso: item.recurso, acoes: granted });
}
return result;
},
});
export const atualizarPermissaoAcao = mutation({
args: {
roleId: v.id("roles"),
recurso: v.string(),
acao: v.string(),
conceder: v.boolean(),
},
returns: v.null(),
handler: async (ctx, args) => {
// Garantir documento de permissão (recurso+acao)
let permissao = await ctx.db
.query("permissoes")
.withIndex("by_recurso_e_acao", (q) =>
q.eq("recurso", args.recurso).eq("acao", args.acao)
)
.first();
if (!permissao) {
const nome = `${args.recurso}.${args.acao}`;
const descricao = `Permite ${args.acao} em ${args.recurso}`;
const id = await ctx.db.insert("permissoes", {
nome,
descricao,
recurso: args.recurso,
acao: args.acao,
});
permissao = await ctx.db.get(id);
}
if (!permissao) return null;
// Verificar vínculo atual
const existente = await ctx.db
.query("rolePermissoes")
.withIndex("by_role", (q) => q.eq("roleId", args.roleId))
.collect();
const vinculo = existente.find((rp) => rp.permissaoId === permissao!._id);
if (args.conceder) {
if (!vinculo) {
await ctx.db.insert("rolePermissoes", {
roleId: args.roleId,
permissaoId: permissao._id,
});
}
} else {
if (vinculo) {
await ctx.db.delete(vinculo._id);
}
}
return null;
},
});
export const verificarAcao = query({
args: {
usuarioId: v.id("usuarios"),
recurso: v.string(),
acao: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const usuario = await ctx.db.get(args.usuarioId);
if (!usuario) throw new Error("acesso_negado");
const role = await ctx.db.get(usuario.roleId);
if (!role) throw new Error("acesso_negado");
// Níveis administrativos têm acesso total
if (role.nivel <= 1) return null;
// Encontrar permissão
const permissao = await ctx.db
.query("permissoes")
.withIndex("by_recurso_e_acao", (q) =>
q.eq("recurso", args.recurso).eq("acao", args.acao)
)
.first();
if (!permissao) throw new Error("acesso_negado");
const hasLink = await ctx.db
.query("rolePermissoes")
.withIndex("by_role", (q) => q.eq("roleId", usuario.roleId))
.collect();
const permitido = hasLink.some((rp) => rp.permissaoId === permissao!._id);
if (!permitido) throw new Error("acesso_negado");
return null;
},
});
export const assertPermissaoAcaoAtual = internalQuery({
args: {
recurso: v.string(),
acao: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
let usuarioAtual: any = null;
if (identity && identity.email) {
usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
}
if (!usuarioAtual) {
const sessaoAtiva = await ctx.db
.query("sessoes")
.filter((q) => q.eq(q.field("ativo"), true))
.order("desc")
.first();
if (sessaoAtiva) {
usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
}
}
if (!usuarioAtual) throw new Error("acesso_negado");
const role: any = await ctx.db.get(usuarioAtual.roleId as any);
if (!role) throw new Error("acesso_negado");
if ((role as any).nivel <= 1) return null;
const permissao = await ctx.db
.query("permissoes")
.withIndex("by_recurso_e_acao", (q) =>
q.eq("recurso", args.recurso).eq("acao", args.acao)
)
.first();
if (!permissao) throw new Error("acesso_negado");
const links = await ctx.db
.query("rolePermissoes")
.withIndex("by_role", (q) => q.eq("roleId", (role as any)._id as any))
.collect();
const ok = links.some((rp) => rp.permissaoId === permissao!._id);
if (!ok) throw new Error("acesso_negado");
return null;
},
});

View File

@@ -14,7 +14,7 @@ export const listar = query({
descricao: v.string(), descricao: v.string(),
nivel: v.number(), nivel: v.number(),
setor: v.optional(v.string()), setor: v.optional(v.string()),
customizado: v.boolean(), customizado: v.optional(v.boolean()),
editavel: v.optional(v.boolean()), editavel: v.optional(v.boolean()),
criadoPor: v.optional(v.id("usuarios")), criadoPor: v.optional(v.id("usuarios")),
}) })
@@ -45,4 +45,3 @@ export const buscarPorId = query({
return await ctx.db.get(args.roleId); return await ctx.db.get(args.roleId);
}, },
}); });

View File

@@ -1,7 +1,5 @@
import { defineSchema, defineTable } from "convex/server"; import { defineSchema, defineTable } from "convex/server";
import { Infer, v } from "convex/values"; import { Infer, v } from "convex/values";
import { tables } from "./betterAuth/schema";
import { cidrv4 } from "better-auth";
export const simboloTipo = v.union( export const simboloTipo = v.union(
v.literal("cargo_comissionado"), v.literal("cargo_comissionado"),
@@ -245,6 +243,7 @@ export default defineSchema({
acao: v.string(), // "criar", "ler", "editar", "excluir" acao: v.string(), // "criar", "ler", "editar", "excluir"
}) })
.index("by_recurso", ["recurso"]) .index("by_recurso", ["recurso"])
.index("by_recurso_e_acao", ["recurso", "acao"])
.index("by_nome", ["nome"]), .index("by_nome", ["nome"]),
rolePermissoes: defineTable({ rolePermissoes: defineTable({

View File

@@ -370,6 +370,55 @@ export const seedDatabase = internalMutation({
}); });
console.log(" ✅ Admin criado (matrícula: 2000, senha: Admin@123)"); console.log(" ✅ Admin criado (matrícula: 2000, senha: Admin@123)");
// 2.1 Criar catálogo de permissões por ação e conceder a Admin/TI
console.log("🔐 Criando permissões por ação...");
const CATALOGO_RECURSOS = [
{ recurso: "dashboard", acoes: ["ver"] },
{
recurso: "funcionarios",
acoes: ["ver", "listar", "criar", "editar", "excluir"],
},
{
recurso: "simbolos",
acoes: ["ver", "listar", "criar", "editar", "excluir"],
},
{
recurso: "usuarios",
acoes: ["ver", "listar", "criar", "editar", "excluir"],
},
{
recurso: "perfis",
acoes: ["ver", "listar", "criar", "editar", "excluir"],
},
] as const;
const permissaoKeyToId = new Map<string, string>();
for (const item of CATALOGO_RECURSOS) {
for (const acao of item.acoes) {
const nome = `${item.recurso}.${acao}`;
const id = await ctx.db.insert("permissoes", {
nome,
descricao: `Permite ${acao} em ${item.recurso}`,
recurso: item.recurso,
acao,
});
permissaoKeyToId.set(nome, id);
}
}
console.log(`${permissaoKeyToId.size} permissões criadas`);
// Conceder todas permissões a Admin e TI
const rolesParaConceder = [roleAdmin, roleTIUsuario, roleTIMaster];
for (const roleId of rolesParaConceder) {
for (const [, permId] of permissaoKeyToId) {
await ctx.db.insert("rolePermissoes", {
roleId: roleId as any,
permissaoId: permId as any,
});
}
}
console.log(" ✅ Todas as permissões concedidas a Admin e TI");
// 3. Inserir símbolos // 3. Inserir símbolos
console.log("📝 Inserindo símbolos..."); console.log("📝 Inserindo símbolos...");
const simbolosMap = new Map<string, string>(); const simbolosMap = new Map<string, string>();
@@ -393,7 +442,9 @@ export const seedDatabase = internalMutation({
for (const funcionario of funcionariosData) { for (const funcionario of funcionariosData) {
const simboloId = simbolosMap.get(funcionario.simboloNome); const simboloId = simbolosMap.get(funcionario.simboloNome);
if (!simboloId) { if (!simboloId) {
console.error(` ❌ Símbolo não encontrado: ${funcionario.simboloNome}`); console.error(
` ❌ Símbolo não encontrado: ${funcionario.simboloNome}`
);
continue; continue;
} }
@@ -436,7 +487,9 @@ export const seedDatabase = internalMutation({
criadoEm: Date.now(), criadoEm: Date.now(),
atualizadoEm: Date.now(), atualizadoEm: Date.now(),
}); });
console.log(` ✅ Usuário criado: ${funcionario.nome} (senha: Mudar@123)`); console.log(
` ✅ Usuário criado: ${funcionario.nome} (senha: Mudar@123)`
);
} }
// 6. Inserir solicitações de acesso // 6. Inserir solicitações de acesso
@@ -462,28 +515,32 @@ export const seedDatabase = internalMutation({
codigo: "USUARIO_BLOQUEADO", codigo: "USUARIO_BLOQUEADO",
nome: "Usuário Bloqueado", nome: "Usuário Bloqueado",
titulo: "Sua conta foi bloqueada", titulo: "Sua conta foi bloqueada",
corpo: "Sua conta no SGSE foi bloqueada.\\n\\nMotivo: {{motivo}}\\n\\nPara mais informações, entre em contato com a TI.", corpo:
"Sua conta no SGSE foi bloqueada.\\n\\nMotivo: {{motivo}}\\n\\nPara mais informações, entre em contato com a TI.",
variaveis: ["motivo"], variaveis: ["motivo"],
}, },
{ {
codigo: "USUARIO_DESBLOQUEADO", codigo: "USUARIO_DESBLOQUEADO",
nome: "Usuário Desbloqueado", nome: "Usuário Desbloqueado",
titulo: "Sua conta foi desbloqueada", titulo: "Sua conta foi desbloqueada",
corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.", corpo:
"Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.",
variaveis: [], variaveis: [],
}, },
{ {
codigo: "SENHA_RESETADA", codigo: "SENHA_RESETADA",
nome: "Senha Resetada", nome: "Senha Resetada",
titulo: "Sua senha foi resetada", titulo: "Sua senha foi resetada",
corpo: "Sua senha foi resetada pela equipe de TI.\\n\\nNova senha temporária: {{senha}}\\n\\nPor favor, altere sua senha no próximo login.", corpo:
"Sua senha foi resetada pela equipe de TI.\\n\\nNova senha temporária: {{senha}}\\n\\nPor favor, altere sua senha no próximo login.",
variaveis: ["senha"], variaveis: ["senha"],
}, },
{ {
codigo: "PERMISSAO_ALTERADA", codigo: "PERMISSAO_ALTERADA",
nome: "Permissão Alterada", nome: "Permissão Alterada",
titulo: "Suas permissões foram atualizadas", titulo: "Suas permissões foram atualizadas",
corpo: "Suas permissões de acesso ao sistema foram atualizadas.\\n\\nPara verificar suas novas permissões, acesse o menu de perfil.", corpo:
"Suas permissões de acesso ao sistema foram atualizadas.\\n\\nPara verificar suas novas permissões, acesse o menu de perfil.",
variaveis: [], variaveis: [],
}, },
{ {
@@ -497,7 +554,8 @@ export const seedDatabase = internalMutation({
codigo: "BEM_VINDO", codigo: "BEM_VINDO",
nome: "Boas-vindas", nome: "Boas-vindas",
titulo: "Bem-vindo ao SGSE", titulo: "Bem-vindo ao SGSE",
corpo: "Olá {{nome}},\\n\\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\\n\\nSuas credenciais de acesso:\\nMatrícula: {{matricula}}\\nSenha temporária: {{senha}}\\n\\nPor favor, altere sua senha no primeiro acesso.\\n\\nEquipe de TI", corpo:
"Olá {{nome}},\\n\\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\\n\\nSuas credenciais de acesso:\\nMatrícula: {{matricula}}\\nSenha temporária: {{senha}}\\n\\nPor favor, altere sua senha no primeiro acesso.\\n\\nEquipe de TI",
variaveis: ["nome", "matricula", "senha"], variaveis: ["nome", "matricula", "senha"],
}, },
]; ];
@@ -584,11 +642,15 @@ export const clearDatabase = internalMutation({
console.log(`${menuPermissoes.length} menu-permissões removidas`); console.log(`${menuPermissoes.length} menu-permissões removidas`);
// Limpar menu-permissões personalizadas // Limpar menu-permissões personalizadas
const menuPermissoesPersonalizadas = await ctx.db.query("menuPermissoesPersonalizadas").collect(); const menuPermissoesPersonalizadas = await ctx.db
.query("menuPermissoesPersonalizadas")
.collect();
for (const mpp of menuPermissoesPersonalizadas) { for (const mpp of menuPermissoesPersonalizadas) {
await ctx.db.delete(mpp._id); await ctx.db.delete(mpp._id);
} }
console.log(`${menuPermissoesPersonalizadas.length} menu-permissões personalizadas removidas`); console.log(
`${menuPermissoesPersonalizadas.length} menu-permissões personalizadas removidas`
);
// Limpar role-permissões // Limpar role-permissões
const rolePermissoes = await ctx.db.query("rolePermissoes").collect(); const rolePermissoes = await ctx.db.query("rolePermissoes").collect();
@@ -615,4 +677,3 @@ export const clearDatabase = internalMutation({
return null; return null;
}, },
}); });

View File

@@ -3,6 +3,7 @@ import { mutation, query } from "./_generated/server";
import { hashPassword, generateToken } from "./auth/utils"; import { hashPassword, generateToken } from "./auth/utils";
import { registrarAtividade } from "./logsAtividades"; import { registrarAtividade } from "./logsAtividades";
import { Id } from "./_generated/dataModel"; import { Id } from "./_generated/dataModel";
import { api } from "./_generated/api";
/** /**
* Criar novo usuário (apenas TI) * Criar novo usuário (apenas TI)
@@ -106,9 +107,7 @@ export const listar = query({
// Filtrar por matrícula // Filtrar por matrícula
if (args.matricula) { if (args.matricula) {
usuarios = usuarios.filter((u) => usuarios = usuarios.filter((u) => u.matricula.includes(args.matricula!));
u.matricula.includes(args.matricula!)
);
} }
// Filtrar por ativo // Filtrar por ativo
@@ -386,13 +385,16 @@ export const atualizarPerfil = mutation({
if (args.avatar !== undefined) updates.avatar = args.avatar; if (args.avatar !== undefined) updates.avatar = args.avatar;
if (args.fotoPerfil !== undefined) updates.fotoPerfil = args.fotoPerfil; if (args.fotoPerfil !== undefined) updates.fotoPerfil = args.fotoPerfil;
if (args.setor !== undefined) updates.setor = args.setor; if (args.setor !== undefined) updates.setor = args.setor;
if (args.statusMensagem !== undefined) updates.statusMensagem = args.statusMensagem; if (args.statusMensagem !== undefined)
updates.statusMensagem = args.statusMensagem;
if (args.statusPresenca !== undefined) { if (args.statusPresenca !== undefined) {
updates.statusPresenca = args.statusPresenca; updates.statusPresenca = args.statusPresenca;
updates.ultimaAtividade = Date.now(); updates.ultimaAtividade = Date.now();
} }
if (args.notificacoesAtivadas !== undefined) updates.notificacoesAtivadas = args.notificacoesAtivadas; if (args.notificacoesAtivadas !== undefined)
if (args.somNotificacao !== undefined) updates.somNotificacao = args.somNotificacao; updates.notificacoesAtivadas = args.notificacoesAtivadas;
if (args.somNotificacao !== undefined)
updates.somNotificacao = args.somNotificacao;
await ctx.db.patch(usuarioAtual._id, updates); await ctx.db.patch(usuarioAtual._id, updates);
@@ -405,6 +407,31 @@ export const atualizarPerfil = mutation({
*/ */
export const obterPerfil = query({ export const obterPerfil = query({
args: {}, args: {},
returns: v.union(
v.object({
_id: v.id("usuarios"),
nome: v.string(),
email: v.string(),
matricula: v.string(),
avatar: v.optional(v.string()),
fotoPerfil: v.optional(v.id("_storage")),
fotoPerfilUrl: v.union(v.string(), v.null()),
setor: v.optional(v.string()),
statusMensagem: v.optional(v.string()),
statusPresenca: v.optional(
v.union(
v.literal("online"),
v.literal("offline"),
v.literal("ausente"),
v.literal("externo"),
v.literal("em_reuniao")
)
),
notificacoesAtivadas: v.boolean(),
somNotificacao: v.boolean(),
}),
v.null()
),
handler: async (ctx) => { handler: async (ctx) => {
console.log("=== DEBUG obterPerfil ==="); console.log("=== DEBUG obterPerfil ===");
@@ -422,7 +449,10 @@ export const obterPerfil = query({
.withIndex("by_email", (q) => q.eq("email", identity.email!)) .withIndex("by_email", (q) => q.eq("email", identity.email!))
.first(); .first();
console.log("Usuário encontrado por email:", usuarioAtual ? "SIM" : "NÃO"); console.log(
"Usuário encontrado por email:",
usuarioAtual ? "SIM" : "NÃO"
);
} }
// SE NÃO ENCONTROU, BUSCAR POR SESSÃO ATIVA (Sistema customizado) // SE NÃO ENCONTROU, BUSCAR POR SESSÃO ATIVA (Sistema customizado)
@@ -438,7 +468,10 @@ export const obterPerfil = query({
if (sessaoAtiva) { if (sessaoAtiva) {
usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId); usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
console.log("Usuário da sessão encontrado:", usuarioAtual ? "SIM" : "NÃO"); console.log(
"Usuário da sessão encontrado:",
usuarioAtual ? "SIM" : "NÃO"
);
} }
} }
@@ -447,7 +480,10 @@ export const obterPerfil = query({
// Listar todos os usuários para debug // Listar todos os usuários para debug
const todosUsuarios = await ctx.db.query("usuarios").collect(); const todosUsuarios = await ctx.db.query("usuarios").collect();
console.log("Total de usuários no banco:", todosUsuarios.length); console.log("Total de usuários no banco:", todosUsuarios.length);
console.log("Emails cadastrados:", todosUsuarios.map(u => u.email)); console.log(
"Emails cadastrados:",
todosUsuarios.map((u) => u.email)
);
return null; return null;
} }
@@ -542,6 +578,7 @@ export const listarParaChat = query({
*/ */
export const uploadFotoPerfil = mutation({ export const uploadFotoPerfil = mutation({
args: {}, args: {},
returns: v.string(),
handler: async (ctx) => { handler: async (ctx) => {
// TENTAR BETTER AUTH PRIMEIRO // TENTAR BETTER AUTH PRIMEIRO
const identity = await ctx.auth.getUserIdentity(); const identity = await ctx.auth.getUserIdentity();
@@ -743,7 +780,8 @@ export const resetarSenhaUsuario = mutation({
// Helper para gerar senha temporária // Helper para gerar senha temporária
function gerarSenhaTemporaria(): string { function gerarSenhaTemporaria(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%"; const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%";
let senha = ""; let senha = "";
for (let i = 0; i < 12; i++) { for (let i = 0; i < 12; i++) {
senha += chars.charAt(Math.floor(Math.random() * chars.length)); senha += chars.charAt(Math.floor(Math.random() * chars.length));
@@ -811,6 +849,116 @@ export const editarUsuario = mutation({
}, },
}); });
/**
* Criar/Promover usuário Admin Master (TI_MASTER - nível 0)
*/
export const criarAdminMaster = mutation({
args: {
matricula: v.string(),
nome: v.string(),
email: v.string(),
senha: v.optional(v.string()),
},
returns: v.union(
v.object({
sucesso: v.literal(true),
usuarioId: v.id("usuarios"),
senhaTemporaria: v.string(),
}),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// Garantir que a role TI_MASTER exista (nível 0)
let roleTIMaster = await ctx.db
.query("roles")
.withIndex("by_nome", (q) => q.eq("nome", "ti_master"))
.first();
if (!roleTIMaster) {
const roleId = await ctx.db.insert("roles", {
nome: "ti_master",
descricao: "TI Master",
nivel: 0,
setor: "ti",
customizado: false,
editavel: false,
});
roleTIMaster = await ctx.db.get(roleId);
}
if (!roleTIMaster) {
return {
sucesso: false as const,
erro: "Falha ao garantir role TI Master",
};
}
// Se já existir usuário por matrícula, promove/atualiza
const existentePorMatricula = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.first();
const senhaTemporaria = args.senha || gerarSenhaTemporaria();
const senhaHash = await hashPassword(senhaTemporaria);
if (existentePorMatricula) {
await ctx.db.patch(existentePorMatricula._id, {
nome: args.nome,
email: args.email,
senhaHash,
roleId: roleTIMaster._id,
ativo: true,
primeiroAcesso: true,
atualizadoEm: Date.now(),
});
return {
sucesso: true as const,
usuarioId: existentePorMatricula._id,
senhaTemporaria,
};
}
// Verificar se email já existe
const existentePorEmail = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", args.email))
.first();
if (existentePorEmail) {
// Promove usuário existente por email
await ctx.db.patch(existentePorEmail._id, {
matricula: args.matricula,
nome: args.nome,
senhaHash,
roleId: roleTIMaster._id,
ativo: true,
primeiroAcesso: true,
atualizadoEm: Date.now(),
});
return {
sucesso: true as const,
usuarioId: existentePorEmail._id,
senhaTemporaria,
};
}
// Criar novo usuário TI Master
const usuarioId = await ctx.db.insert("usuarios", {
matricula: args.matricula,
senhaHash,
nome: args.nome,
email: args.email,
roleId: roleTIMaster._id,
ativo: true,
primeiroAcesso: true,
criadoEm: Date.now(),
atualizadoEm: Date.now(),
});
return { sucesso: true as const, usuarioId, senhaTemporaria };
},
});
/** /**
* Desativar usuário logicamente (soft delete - apenas TI_MASTER) * Desativar usuário logicamente (soft delete - apenas TI_MASTER)
*/ */
@@ -875,7 +1023,11 @@ export const criarUsuarioCompleto = mutation({
enviarEmailBoasVindas: v.optional(v.boolean()), enviarEmailBoasVindas: v.optional(v.boolean()),
}, },
returns: v.union( returns: v.union(
v.object({ sucesso: v.literal(true), usuarioId: v.id("usuarios"), senhaTemporaria: v.string() }), v.object({
sucesso: v.literal(true),
usuarioId: v.id("usuarios"),
senhaTemporaria: v.string(),
}),
v.object({ sucesso: v.literal(false), erro: v.string() }) v.object({ sucesso: v.literal(false), erro: v.string() })
), ),
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -934,3 +1086,85 @@ export const criarUsuarioCompleto = mutation({
}, },
}); });
/**
* Criar (ou garantir) um usuário ADMIN padrão
*/
export const criarAdminPadrao = mutation({
args: {
matricula: v.optional(v.string()),
nome: v.optional(v.string()),
email: v.optional(v.string()),
senha: v.optional(v.string()),
},
returns: v.object({
sucesso: v.boolean(),
usuarioId: v.optional(v.id("usuarios")),
}),
handler: async (ctx, args) => {
const matricula = args.matricula ?? "0000";
const nome = args.nome ?? "Administrador Geral";
const email = args.email ?? "admin@sgse.pe.gov.br";
const senha = args.senha ?? "Admin@123";
// Garantir role ADMIN (nível 2)
let roleAdmin = await ctx.db
.query("roles")
.withIndex("by_nome", (q) => q.eq("nome", "admin"))
.first();
if (!roleAdmin) {
const roleId = await ctx.db.insert("roles", {
nome: "admin",
descricao: "Administrador Geral",
nivel: 2,
setor: "administrativo",
customizado: false,
editavel: true,
});
roleAdmin = await ctx.db.get(roleId);
}
if (!roleAdmin) return { sucesso: false };
// Verificar se já existe por matrícula ou email
const existentePorMatricula = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", matricula))
.first();
const existentePorEmail = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", email))
.first();
const senhaHash = await hashPassword(senha);
if (existentePorMatricula || existentePorEmail) {
const alvo = existentePorMatricula ?? existentePorEmail!;
await ctx.db.patch(alvo._id, {
matricula,
nome,
email,
senhaHash,
roleId: roleAdmin._id,
ativo: true,
primeiroAcesso: false,
atualizadoEm: Date.now(),
});
return { sucesso: true, usuarioId: alvo._id };
}
const usuarioId = await ctx.db.insert("usuarios", {
matricula,
senhaHash,
nome,
email,
roleId: roleAdmin._id,
ativo: true,
primeiroAcesso: false,
criadoEm: Date.now(),
atualizadoEm: Date.now(),
});
return { sucesso: true, usuarioId };
},
});

View File

@@ -13,9 +13,7 @@
"typescript": "^5.9.2" "typescript": "^5.9.2"
}, },
"dependencies": { "dependencies": {
"@convex-dev/better-auth": "^0.9.6",
"@dicebear/avataaars": "^9.2.4", "@dicebear/avataaars": "^9.2.4",
"better-auth": "1.3.27",
"convex": "^1.28.0" "convex": "^1.28.0"
} }
} }