refactor: Remove dedicated role management page and update authentication, roles, and permission handling across backend and frontend.

This commit is contained in:
2025-12-05 14:29:34 -03:00
parent c8d717b315
commit 69f32a342c
16 changed files with 358 additions and 958 deletions

View File

@@ -2,19 +2,16 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
const {
children,
requireAuth = true,
allowedRoles = [],
maxLevel = 3,
redirectTo = '/'
}: {
children: Snippet;
requireAuth?: boolean;
allowedRoles?: string[];
maxLevel?: number;
redirectTo?: string;
} = $props();
@@ -72,13 +69,6 @@
}
}
// Verificar nível
if (currentUser.data.role?.nivel && currentUser.data.role.nivel > maxLevel) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
return;
}
// Se chegou aqui, permitir acesso
hasAccess = true;
isChecking = false;

View File

@@ -1,6 +1,6 @@
import type { Id } from '@sgse-app/backend/convex/betterAuth/_generated/dataModel';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
interface Usuario {
_id: string;
@@ -11,8 +11,7 @@ interface Usuario {
role: {
_id: string;
nome: string;
nivel: number;
setor?: string;
admin?: boolean;
};
primeiroAcesso: boolean;
avatar?: string;
@@ -56,7 +55,7 @@ class AuthStore {
}
get isAdmin() {
return this.state.usuario?.role.nivel === 0;
return this.state.usuario?.role.admin === true;
}
get isTI() {

View File

@@ -19,11 +19,16 @@
let modalNovoPerfilAberto = $state(false);
let nomeNovoPerfil = $state('');
let descricaoNovoPerfil = $state('');
let setorNovoPerfil = $state('');
let nivelNovoPerfil = $state(3);
let roleParaDuplicar = $state<Id<'roles'> | ''>('');
let criandoNovoPerfil = $state(false);
// Estado para modal de edição
let modalEditarPerfilAberto = $state(false);
let roleParaEditar = $state<Id<'roles'> | null>(null);
let descricaoEditarPerfil = $state('');
let editandoPerfil = $state(false);
let excluindoPerfil = $state(false);
// Controla quais recursos estão expandidos (mostrando as ações) por perfil
// Formato: { "roleId-recurso": true/false }
let recursosExpandidos: Record<string, boolean> = $state({});
@@ -74,13 +79,81 @@
$effect(() => {
if (rolesFiltradas && catalogoQuery.data) {
for (const roleRow of rolesFiltradas) {
if (roleRow.nivel > 1) {
if (roleRow.admin !== true) {
carregarPermissoesRole(roleRow._id);
}
}
}
});
function abrirModalEditar(role: { _id: Id<'roles'>; nome: string; descricao: string }) {
roleParaEditar = role._id;
descricaoEditarPerfil = role.descricao;
modalEditarPerfilAberto = true;
}
function fecharModalEditar() {
modalEditarPerfilAberto = false;
roleParaEditar = null;
}
async function editarPerfil() {
if (!roleParaEditar) return;
editandoPerfil = true;
try {
const resultado = await client.mutation(api.roles.editar, {
roleId: roleParaEditar,
descricao: descricaoEditarPerfil.trim()
});
if (resultado.sucesso) {
mostrarMensagem('success', 'Perfil atualizado com sucesso!');
fecharModalEditar();
} else {
const mapaErros: Record<string, string> = {
nome_ja_utilizado: 'Já existe um perfil com este identificador.',
nome_invalido: 'Informe um nome válido com pelo menos 3 caracteres.',
sem_permissao: 'Você não possui permissão para editar perfis.',
role_nao_encontrada: 'Perfil não encontrado.'
};
mostrarMensagem('error', mapaErros[resultado.erro] ?? resultado.erro);
}
} catch (error: unknown) {
const mensagemErro = error instanceof Error ? error.message : 'Erro ao editar perfil.';
mostrarMensagem('error', mensagemErro);
} finally {
editandoPerfil = false;
}
}
async function excluirPerfil(roleId: Id<'roles'>) {
if (!confirm('Tem certeza que deseja excluir este perfil? Esta ação não pode ser desfeita.')) {
return;
}
excluindoPerfil = true;
try {
const resultado = await client.mutation(api.roles.excluir, { roleId });
if (resultado.sucesso) {
mostrarMensagem('success', 'Perfil excluído com sucesso!');
} else {
const mapaErros: Record<string, string> = {
sem_permissao: 'Você não possui permissão para excluir perfis.',
role_nao_encontrada: 'Perfil não encontrado.',
role_possui_usuarios: 'Não é possível excluir um perfil que possui usuários vinculados.'
};
mostrarMensagem('error', mapaErros[resultado.erro] ?? resultado.erro);
}
} catch (error: unknown) {
const mensagemErro = error instanceof Error ? error.message : 'Erro ao excluir perfil.';
mostrarMensagem('error', mensagemErro);
} finally {
excluindoPerfil = false;
}
}
async function toggleAcao(roleId: Id<'roles'>, recurso: string, acao: string, conceder: boolean) {
try {
salvando = true;
@@ -130,9 +203,7 @@
let podeSalvarNovoPerfil = $derived.by(() => {
const nome = nomeNovoPerfil.trim();
const nivel = Number(nivelNovoPerfil);
const nivelValido = Number.isFinite(nivel) && nivel >= 0 && nivel <= 10;
return nome.length >= 3 && nivelValido && !criandoNovoPerfil;
return nome.length >= 3 && !criandoNovoPerfil;
});
let roleDuplicacaoSelecionada = $derived.by(() => {
@@ -158,8 +229,6 @@
function abrirModalNovoPerfil() {
nomeNovoPerfil = '';
descricaoNovoPerfil = '';
setorNovoPerfil = '';
nivelNovoPerfil = 3;
roleParaDuplicar = '';
modalNovoPerfilAberto = true;
}
@@ -173,16 +242,12 @@
const nome = nomeNovoPerfil.trim();
const descricao = descricaoNovoPerfil.trim();
const setor = setorNovoPerfil.trim();
const nivel = Math.min(Math.max(Math.round(Number(nivelNovoPerfil)), 0), 10);
criandoNovoPerfil = true;
try {
const resultado = await client.mutation(api.roles.criar, {
nome,
descricao,
nivel,
setor: setor.length > 0 ? setor : undefined,
copiarDeRoleId: roleParaDuplicar || undefined
});
@@ -211,7 +276,7 @@
}
</script>
<ProtectedRoute allowedRoles={['ti_master', 'admin']} maxLevel={1}>
<ProtectedRoute allowedRoles={['ti_master', 'admin']}>
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
@@ -492,10 +557,8 @@
<div class="min-w-[200px] flex-1">
<div class="mb-2 flex items-center gap-3">
<h2 class="card-title text-2xl">{roleRow.descricao}</h2>
<div class="badge badge-lg badge-primary">
Nível {roleRow.nivel}
</div>
{#if roleRow.nivel <= 1}
{#if roleRow.admin}
<div class="badge badge-lg badge-error">Admin</div>
<div class="badge badge-lg badge-success gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -513,15 +576,63 @@
</svg>
Acesso Total
</div>
{:else}
<div class="badge badge-lg badge-info">Usuário</div>
{/if}
</div>
<p class="text-base-content/60 text-sm">
<span class="bg-base-200 rounded px-2 py-1 font-mono">{roleRow.nome}</span>
</p>
</div>
<div class="flex gap-2">
<button
type="button"
class="btn btn-sm btn-ghost"
onclick={() => abrirModalEditar(roleRow)}
disabled={salvando}
aria-label="Editar perfil"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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>
</button>
<button
type="button"
class="btn btn-sm btn-ghost text-error"
onclick={() => excluirPerfil(roleRow._id)}
disabled={salvando || excluindoPerfil}
aria-label="Excluir perfil"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
{#if roleRow.nivel <= 1}
{#if roleRow.admin}
<div class="alert alert-success shadow-md">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -680,38 +791,6 @@
></textarea>
</div>
<div class="form-control">
<label class="label" for="setor-novo-perfil">
<span class="label-text font-semibold">Setor (opcional)</span>
</label>
<input
id="setor-novo-perfil"
type="text"
class="input input-bordered"
placeholder="Informe o setor ou departamento responsável"
bind:value={setorNovoPerfil}
maxlength={40}
/>
</div>
<div class="form-control">
<label class="label" for="nivel-novo-perfil">
<span class="label-text font-semibold">Nível de acesso (0 a 10)</span>
</label>
<input
id="nivel-novo-perfil"
type="number"
class="input input-bordered"
min={0}
max={10}
step={1}
bind:value={nivelNovoPerfil}
/>
<span class="text-base-content/60 mt-1 text-xs">
Níveis menores representam maior privilégio (ex.: 0 = administrativo).
</span>
</div>
<div class="form-control md:col-span-2">
<label class="label" for="duplicar-novo-perfil">
<span class="label-text font-semibold">Duplicar permissões de</span>
@@ -725,7 +804,8 @@
{#if rolesQuery.data}
{#each rolesQuery.data as roleDuplicavel (roleDuplicavel._id)}
<option value={roleDuplicavel._id}>
{roleDuplicavel.descricao} nível {roleDuplicavel.nivel}
{roleDuplicavel.descricao}
{roleDuplicavel.admin ? '(Admin)' : ''}
</option>
{/each}
{/if}
@@ -800,4 +880,66 @@
</form>
</dialog>
{/if}
{#if modalEditarPerfilAberto}
<dialog class="modal modal-open">
<div class="modal-box bg-base-100 w-full max-w-lg overflow-hidden rounded-2xl p-0 shadow-2xl">
<div
class="from-primary via-primary/85 to-secondary/80 text-base-100 relative bg-linear-to-r px-8 py-6"
>
<button
type="button"
class="btn btn-circle btn-ghost btn-sm text-base-100/80 hover:text-base-100 absolute top-4 right-4"
onclick={fecharModalEditar}
aria-label="Fechar"
>
</button>
<div class="space-y-2 pr-10">
<h3 class="text-2xl font-black tracking-tight">Editar perfil de acesso</h3>
<p class="text-base-100/80 text-sm">Altere as informações do perfil selecionado.</p>
</div>
</div>
<div class="space-y-6 px-8 py-6">
<div class="form-control">
<label class="label" for="descricao-editar-perfil">
<span class="label-text font-semibold">Nome do perfil *</span>
</label>
<input
id="descricao-editar-perfil"
type="text"
class="input input-bordered input-primary"
placeholder="Ex.: Financeiro, RH, Supervisão de Campo"
bind:value={descricaoEditarPerfil}
maxlength={100}
/>
</div>
<div class="flex flex-wrap justify-end gap-3">
<button type="button" class="btn" onclick={fecharModalEditar} disabled={editandoPerfil}>
Cancelar
</button>
<button
type="button"
class="btn btn-primary"
disabled={editandoPerfil || descricaoEditarPerfil.trim().length < 3}
onclick={editarPerfil}
>
{#if editandoPerfil}
<span class="loading loading-spinner"></span>
Salvando...
{:else}
Salvar alterações
{/if}
</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={fecharModalEditar} aria-label="Fechar modal">
fechar
</button>
</form>
</dialog>
{/if}
</ProtectedRoute>

View File

@@ -1,786 +0,0 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { AlertTriangle, Building2, Info, Shield, Users } from 'lucide-svelte';
import { resolve } from '$app/paths';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import StatsCard from '$lib/components/ti/StatsCard.svelte';
type Role = {
_id: Id<'roles'>;
_creationTime: number;
nome: string;
descricao: string;
nivel: number;
setor?: string;
};
const client = useConvexClient();
const rolesQuery = useQuery(api.roles.listar, {});
let roles = $derived(rolesQuery?.data ?? []);
let carregando = $derived(rolesQuery === undefined);
let busca = $state('');
let filtroSetor = $state('');
let filtroNivel = $state<number | ''>('');
let roleSelecionada = $state<Role | null>(null);
let modalDetalhesAberto = $state(false);
let setoresDisponiveis = $derived.by(() => {
const setores = new Set<string>();
roles.forEach((r) => {
if (r.setor) setores.add(r.setor);
});
return Array.from(setores).sort();
});
// Estatísticas
let stats = $derived.by(() => {
if (carregando) return null;
const nivelMaximo = roles.filter((r) => r.nivel === 0).length;
const nivelAdministrativo = roles.filter((r) => r.nivel === 1).length;
const niveisLegado = roles.filter((r) => r.nivel > 1).length;
return {
total: roles.length,
nivelMaximo,
nivelAdministrativo,
niveisLegado,
comSetor: roles.filter((r) => r.setor).length
};
});
let rolesFiltradas = $derived.by(() => {
let resultado = roles;
// Filtro por busca (nome ou descrição)
if (busca.trim()) {
const buscaLower = busca.toLowerCase();
resultado = resultado.filter(
(r) =>
r.nome.toLowerCase().includes(buscaLower) ||
r.descricao.toLowerCase().includes(buscaLower)
);
}
// Filtro por setor
if (filtroSetor) {
resultado = resultado.filter((r) => r.setor === filtroSetor);
}
// Filtro por nível
if (filtroNivel !== '') {
if (filtroNivel === 0 || filtroNivel === 1) {
resultado = resultado.filter((r) => r.nivel === filtroNivel);
} else {
// Qualquer outro valor é considerado legado
resultado = resultado.filter((r) => r.nivel > 1);
}
}
return resultado.sort((a, b) => {
// Ordenar por nível primeiro (menor nível = maior privilégio)
if (a.nivel !== b.nivel) return a.nivel - b.nivel;
// Depois por nome
return a.nome.localeCompare(b.nome);
});
});
function obterCorNivel(nivel: number): string {
if (nivel === 0) return 'badge-error';
if (nivel === 1) return 'badge-warning';
// Níveis > 1 são considerados legado
return 'badge-ghost';
}
function obterTextoNivel(nivel: number): string {
if (nivel === 0) return 'Máximo';
if (nivel === 1) return 'Administrativo';
return `Legado (${nivel})`;
}
function obterCorCardNivel(nivel: number): string {
if (nivel === 0) return 'border-l-4 border-error';
if (nivel === 1) return 'border-l-4 border-warning';
return 'border-l-4 border-base-300';
}
function abrirDetalhes(role: Role) {
roleSelecionada = role;
modalDetalhesAberto = true;
}
function fecharDetalhes() {
modalDetalhesAberto = false;
roleSelecionada = null;
}
function formatarData(timestamp: number): string {
try {
return format(new Date(timestamp), 'dd/MM/yyyy HH:mm', { locale: ptBR });
} catch {
return 'Data inválida';
}
}
function limparFiltros() {
busca = '';
filtroSetor = '';
filtroNivel = '';
}
let temFiltrosAtivos = $derived(
busca.trim() !== '' || filtroSetor !== '' || filtroNivel !== ''
);
</script>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={1}>
<div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Header -->
<div class="mb-8 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="bg-primary/10 rounded-xl p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div>
<div>
<h1 class="text-base-content text-3xl font-bold">Gestão de Perfis</h1>
<p class="text-base-content/60 mt-1">
Visualize e gerencie os perfis de acesso do sistema
</p>
</div>
</div>
</div>
<!-- Estatísticas -->
{#if stats}
<div class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard title="Total de Perfis" value={stats.total} Icon={Users} color="primary" />
<StatsCard
title="Nível Máximo (0)"
value={stats.nivelMaximo}
description="Acesso total ao sistema"
Icon={Shield}
color="error"
/>
<StatsCard
title="Nível Administrativo (1)"
value={stats.nivelAdministrativo}
description="Perfis administrativos com acesso total"
Icon={AlertTriangle}
color="warning"
/>
<StatsCard
title="Perfis com Setor"
value={stats.comSetor}
description={stats.total > 0
? ((stats.comSetor / stats.total) * 100).toFixed(0) + '% do total'
: '0%'}
Icon={Building2}
color="secondary"
/>
</div>
{#if stats.niveisLegado > 0}
<div class="alert alert-warning mb-6">
<Info class="h-5 w-5" />
<div>
<h3 class="font-bold">Perfis com níveis legados</h3>
<p class="text-sm">
Existem {stats.niveisLegado} perfis com nível acima de 1. Esses perfis continuarão sendo
tratados como nível 1 (administrativo) após a migração.
</p>
</div>
</div>
{/if}
{/if}
<!-- Filtros -->
{#if !carregando && roles.length > 0}
<div class="card bg-base-100 border-base-300 mb-6 border shadow-xl">
<div class="card-body">
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
<h2 class="card-title text-lg">Filtros de Busca</h2>
</div>
{#if temFiltrosAtivos}
<button
type="button"
class="btn btn-sm btn-outline btn-error"
onclick={limparFiltros}
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
Limpar Filtros
</button>
{/if}
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<!-- Busca -->
<div class="form-control">
<label class="label" for="busca">
<span class="label-text font-medium">Buscar</span>
</label>
<div class="relative">
<input
id="busca"
type="text"
bind:value={busca}
placeholder="Buscar por nome ou descrição..."
class="input input-bordered w-full pl-10"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/40 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 transform"
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>
</div>
</div>
<!-- Setor -->
<div class="form-control">
<label class="label" for="filtro-setor">
<span class="label-text font-medium">Setor</span>
</label>
<select id="filtro-setor" bind:value={filtroSetor} class="select select-bordered">
<option value="">Todos os setores</option>
{#each setoresDisponiveis as setor}
<option value={setor}>{setor}</option>
{/each}
</select>
</div>
<!-- Nível -->
<div class="form-control">
<label class="label" for="filtro-nivel">
<span class="label-text font-medium">Nível de Acesso</span>
</label>
<select id="filtro-nivel" bind:value={filtroNivel} class="select select-bordered">
<option value="">Todos os níveis</option>
<option value={0}>Máximo (0)</option>
<option value={1}>Alto (1)</option>
<option value={2}>Médio (2)</option>
<option value={3}>Baixo (3+)</option>
</select>
</div>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="text-base-content/60 text-sm">
<span class="text-base-content font-medium">{rolesFiltradas.length}</span>
de
<span class="text-base-content font-medium">{roles.length}</span>
perfil(is)
{#if temFiltrosAtivos}
<span class="badge badge-primary badge-sm ml-2">Filtrado</span>
{/if}
</div>
</div>
</div>
</div>
{/if}
<!-- Lista de Perfis -->
{#if carregando}
<div class="flex items-center justify-center py-20">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if roles.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/30 h-16 w-16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<h3 class="mt-4 text-xl font-semibold">Nenhum perfil encontrado</h3>
<p class="text-base-content/60 mt-2">Não há perfis cadastrados no sistema.</p>
</div>
{:else if rolesFiltradas.length === 0}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-16 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/30 mb-4 h-16 w-16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 class="mt-4 text-xl font-semibold">Nenhum perfil encontrado</h3>
<p class="text-base-content/60 mt-2">
Nenhum perfil corresponde aos filtros aplicados.
</p>
{#if temFiltrosAtivos}
<button class="btn btn-primary btn-sm mt-4" onclick={limparFiltros}>
Limpar Filtros
</button>
{/if}
</div>
</div>
</div>
{:else}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each rolesFiltradas as role}
<div
class="card bg-base-100 border-base-300 cursor-pointer border shadow-xl transition-all duration-300 hover:shadow-2xl {obterCorCardNivel(
role.nivel
)} hover:scale-[1.02]"
onclick={() => abrirDetalhes(role)}
>
<div class="card-body">
<div class="mb-4 flex items-start justify-between">
<div class="flex-1">
<h2 class="card-title mb-1 text-lg">{role.descricao}</h2>
<div class="badge {obterCorNivel(role.nivel)} badge-sm">
{obterTextoNivel(role.nivel)}
</div>
</div>
<div class="bg-base-200 rounded-lg p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-6 w-6"
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>
</div>
</div>
<div class="space-y-3 text-sm">
<div class="bg-base-200 flex items-center gap-2 rounded-lg p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/40 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>
<span class="text-base-content/60 font-medium">Nome técnico:</span>
<code class="bg-base-100 rounded px-2 py-1 font-mono text-xs">{role.nome}</code>
</div>
{#if role.setor}
<div class="bg-base-200 flex items-center gap-2 rounded-lg p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/40 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<span class="text-base-content/60 font-medium">Setor:</span>
<span class="font-medium">{role.setor}</span>
</div>
{/if}
<div class="bg-base-200 flex items-center gap-2 rounded-lg p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/40 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-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>
<span class="text-base-content/60 font-medium">Nível:</span>
<span class="text-lg font-bold">{role.nivel}</span>
</div>
</div>
<div class="card-actions border-base-300 mt-4 justify-end border-t pt-4">
<button
class="btn btn-sm btn-primary btn-outline"
onclick={(e) => {
e.stopPropagation();
abrirDetalhes(role);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 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>
Ver Detalhes
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Modal Detalhes -->
{#if modalDetalhesAberto && roleSelecionada}
<div class="modal modal-open">
<div class="modal-box max-w-3xl">
<div class="mb-6 flex items-center justify-between">
<h3 class="text-2xl font-bold">Detalhes do Perfil</h3>
<button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={fecharDetalhes}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="space-y-6">
<!-- Header do Perfil -->
<div class="card from-primary/10 to-secondary/10 border-primary/20 border bg-linear-to-r">
<div class="card-body">
<div class="flex items-start justify-between">
<div class="flex-1">
<h2 class="mb-2 text-2xl font-bold">
{roleSelecionada.descricao}
</h2>
<div class="flex items-center gap-3">
<div class="badge {obterCorNivel(roleSelecionada.nivel)} badge-lg">
{obterTextoNivel(roleSelecionada.nivel)}
</div>
<span class="text-base-content/60 text-sm">Nível {roleSelecionada.nivel}</span>
</div>
</div>
<div class="bg-base-100 rounded-lg p-3 shadow-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
</div>
</div>
</div>
<!-- Informações Principais -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="card bg-base-100 border-base-300 border">
<div class="card-body">
<label class="label">
<span class="label-text flex items-center gap-2 font-semibold">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-5 w-5"
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>
Nome Técnico
</span>
</label>
<code class="bg-base-200 mt-2 block rounded-lg px-4 py-3 font-mono text-sm"
>{roleSelecionada.nome}</code
>
</div>
</div>
<div class="card bg-base-100 border-base-300 border">
<div class="card-body">
<label class="label">
<span class="label-text flex items-center gap-2 font-semibold">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
Setor
</span>
</label>
<p class="mt-2 text-lg font-medium">
{#if roleSelecionada.setor}
{roleSelecionada.setor}
{:else}
<span class="text-base-content/40 italic">Não especificado</span>
{/if}
</p>
</div>
</div>
</div>
<!-- Nível de Acesso -->
<div class="card bg-base-100 border-base-300 border">
<div class="card-body">
<label class="label">
<span class="label-text flex items-center gap-2 font-semibold">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-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>
Nível de Acesso
</span>
</label>
<div class="mt-4">
<div class="mb-3 flex items-center gap-4">
<span class="text-4xl font-bold">{roleSelecionada.nivel}</span>
<div class="badge {obterCorNivel(roleSelecionada.nivel)} badge-lg">
{obterTextoNivel(roleSelecionada.nivel)}
</div>
</div>
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span class="text-sm">
{roleSelecionada.nivel === 0 &&
'Acesso total irrestrito ao sistema. Pode realizar todas as operações sem restrições.'}
{roleSelecionada.nivel === 1 &&
'Acesso alto com algumas restrições. Pode realizar a maioria das operações administrativas.'}
{roleSelecionada.nivel === 2 &&
'Acesso médio com permissões configuráveis. Pode realizar operações padrão do sistema.'}
{roleSelecionada.nivel >= 3 &&
'Acesso limitado com permissões específicas. Operações restritas conforme configuração.'}
</span>
</div>
</div>
</div>
</div>
<!-- Data de Criação -->
<div class="card bg-base-100 border-base-300 border">
<div class="card-body">
<label class="label">
<span class="label-text flex items-center gap-2 font-semibold">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Data de Criação
</span>
</label>
<p class="mt-2 text-lg font-medium">
{formatarData(roleSelecionada._creationTime)}
</p>
</div>
</div>
<!-- Informação sobre Permissões -->
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div>
<h4 class="mb-1 font-semibold">Configuração de Permissões</h4>
<p class="text-sm">
Para configurar permissões específicas deste perfil, acesse o <a
href={resolve('/ti/painel-permissoes')}
class="link link-primary font-semibold">Painel de Permissões</a
>.
</p>
</div>
</div>
</div>
<div class="modal-action mt-6">
<button type="button" class="btn" onclick={fecharDetalhes}> Fechar </button>
<a href={resolve('/ti/painel-permissoes')} class="btn btn-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 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.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Configurar Permissões
</a>
</div>
</div>
<div class="modal-backdrop" onclick={fecharDetalhes}></div>
</div>
{/if}
</ProtectedRoute>

View File

@@ -21,13 +21,9 @@
role: {
_id: Id<'roles'>;
_creationTime?: number;
criadoPor?: Id<'usuarios'>;
customizado?: boolean;
descricao: string;
editavel?: boolean;
nome: string;
nivel: number;
setor?: string;
admin?: boolean;
erro?: boolean;
};
funcionario?: {
@@ -278,7 +274,7 @@
}
</script>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={1}>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']}>
<main class="container mx-auto max-w-7xl px-4 py-6">
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">

View File

@@ -16,9 +16,8 @@
type RoleUsuario = {
_id: Id<'roles'>;
nome: string;
nivel: number;
admin?: boolean;
descricao: string;
setor?: string;
erro?: boolean;
};
@@ -265,11 +264,6 @@
resultado = resultado.filter((u) => u.matricula.toLowerCase().includes(buscaMatricula));
}
// Filtro por setor
if (filtroSetor) {
resultado = resultado.filter((u) => u.role.setor === filtroSetor);
}
// Filtro por status
if (filtroStatus !== 'todos') {
if (filtroStatus === 'ativo') {
@@ -326,7 +320,6 @@
function limparFiltros() {
filtroNome = '';
filtroMatricula = '';
filtroSetor = '';
filtroStatus = 'todos';
filtroDataCriacaoInicio = '';
filtroDataCriacaoFim = '';
@@ -535,7 +528,7 @@
}
</script>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={1}>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']}>
<div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">

View File

@@ -421,10 +421,7 @@ export const obterChamado = query({
throw new Error('Chamado não encontrado');
}
const podeVer =
ticket.solicitanteId === usuario._id ||
ticket.responsavelId === usuario._id ||
ticket.setorResponsavel === usuario.setor;
const podeVer = ticket.solicitanteId === usuario._id || ticket.responsavelId === usuario._id;
if (!podeVer) {
throw new Error('Acesso negado ao chamado');
@@ -524,7 +521,6 @@ export const atribuirResponsavel = mutation({
await ctx.db.patch(ticket._id, {
responsavelId: args.responsavelId,
setorResponsavel: responsavel.setor,
atualizadoEm: agora
});

View File

@@ -2056,8 +2056,7 @@ export const obterUsuariosOnline = query({
email: u.email,
fotoPerfil: u.fotoPerfil,
statusPresenca: u.statusPresenca,
statusMensagem: u.statusMensagem,
setor: u.setor
statusMensagem: u.statusMensagem
}));
}
});
@@ -2101,8 +2100,7 @@ export const listarTodosUsuarios = query({
fotoPerfil: u.fotoPerfil,
fotoPerfilUrl,
statusPresenca: u.statusPresenca,
statusMensagem: u.statusMensagem,
setor: u.setor
statusMensagem: u.statusMensagem
};
})
);

View File

@@ -4,7 +4,7 @@ import { getCurrentUserFunction } from './auth';
/**
* Retorna as permissões do usuário atual para o frontend filtrar o menu localmente
* Retorna:
* - isMaster: true se o usuário é TI Master ou Admin (nível <= 1)
* - isMaster: true se o usuário é Admin
* - permissions: Set de strings no formato "recurso.acao" (ex: "funcionarios.listar")
*/
export const getUserPermissions = query({
@@ -20,8 +20,8 @@ export const getUserPermissions = query({
return { isMaster: false, permissions: [] };
}
// Se for TI Master ou Admin (nivel <= 1), retorna flag de master
if (role.nivel <= 1) {
// Se for Admin, retorna flag de master
if (role.admin === true) {
return { isMaster: true, permissions: [] };
}

View File

@@ -340,10 +340,10 @@ export const verificarAlertasInternal = internalMutation({
// Criar notificação no chat se configurado
if (alerta.notifyByChat) {
// Buscar roles administrativas (nível <= 1) e filtrar usuários por roleId
// Buscar roles administrativas (admin === true) e filtrar usuários por roleId
const rolesAdminOuTi = await ctx.db
.query('roles')
.filter((q) => q.lte(q.field('nivel'), 1))
.filter((q) => q.eq(q.field('admin'), true))
.collect();
const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id));
@@ -368,7 +368,7 @@ export const verificarAlertasInternal = internalMutation({
// Buscar usuários administradores/TI para receber o alerta por email
const rolesAdminOuTi = await ctx.db
.query('roles')
.filter((q) => q.lte(q.field('nivel'), 1))
.filter((q) => q.eq(q.field('admin'), true))
.collect();
const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id));

View File

@@ -633,8 +633,8 @@ export const verificarAcao = query({
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;
// Admins têm acesso total
if (role.admin === true) return null;
// Encontrar permissão
const permissao = await ctx.db
@@ -665,7 +665,7 @@ export const assertPermissaoAcaoAtual = internalQuery({
const role = await ctx.db.get(usuarioAtual.roleId);
if (!role) throw new Error('acesso_negado');
if (role.nivel <= 1) return null;
if (role.admin === true) return null;
const permissao = await ctx.db
.query('permissoes')

View File

@@ -25,8 +25,7 @@ export const buscarPorId = query({
_id: v.id('roles'),
nome: v.string(),
descricao: v.string(),
nivel: v.number(),
setor: v.optional(v.string())
admin: v.optional(v.boolean())
}),
v.null()
),
@@ -49,8 +48,6 @@ export const criar = mutation({
args: {
nome: v.string(),
descricao: v.string(),
nivel: v.number(),
setor: v.optional(v.string()),
copiarDeRoleId: v.optional(v.id('roles'))
},
returns: v.union(
@@ -64,7 +61,7 @@ export const criar = mutation({
}
const roleAtual = await ctx.db.get(usuarioAtual.roleId);
if (!roleAtual || roleAtual.nivel > 1) {
if (!roleAtual || roleAtual.admin !== true) {
return { sucesso: false as const, erro: 'sem_permissao' };
}
@@ -98,20 +95,12 @@ export const criar = mutation({
permissoesParaCopiar = permissoesOrigem.map((item) => item.permissaoId);
}
// Agora só existem níveis 0 e 1.
// 0 = máximo (acesso total), 1 = administrativo (também com acesso total).
// Qualquer valor informado diferente de 0 é normalizado para 1.
const nivelNormalizado = Math.round(args.nivel) <= 0 ? 0 : 1;
const setor = args.setor?.trim();
// Novos perfis criados NÃO são admin por padrão
// O campo admin só pode ser alterado posteriormente por um admin existente
const roleId = await ctx.db.insert('roles', {
nome: nomeNormalizado,
descricao: args.descricao.trim() || args.nome.trim(),
nivel: nivelNormalizado,
setor: setor && setor.length > 0 ? setor : undefined,
customizado: true,
criadoPor: usuarioAtual._id,
editavel: true
admin: false
});
if (permissoesParaCopiar.length > 0) {
@@ -128,21 +117,140 @@ export const criar = mutation({
});
/**
* Migração de níveis de roles para o novo modelo (apenas 0 e 1).
* - Mantém níveis 0 e 1 como estão.
* - Converte qualquer nível > 1 para 1.
* Editar uma role existente
* Apenas admins podem editar roles
*/
export const migrarNiveisRoles = internalMutation({
export const editar = mutation({
args: {
roleId: v.id('roles'),
nome: v.optional(v.string()),
descricao: v.optional(v.string())
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const usuarioAtual = await getCurrentUserFunction(ctx);
if (!usuarioAtual) {
return { sucesso: false as const, erro: 'nao_autenticado' };
}
const roleAtual = await ctx.db.get(usuarioAtual.roleId);
if (!roleAtual || roleAtual.admin !== true) {
return { sucesso: false as const, erro: 'sem_permissao' };
}
const roleParaEditar = await ctx.db.get(args.roleId);
if (!roleParaEditar) {
return { sucesso: false as const, erro: 'role_nao_encontrada' };
}
// Se estiver alterando o nome, verificar se já existe
if (args.nome) {
const nomeNormalizado = slugify(args.nome);
if (!nomeNormalizado) {
return { sucesso: false as const, erro: 'nome_invalido' };
}
if (nomeNormalizado !== roleParaEditar.nome) {
const existente = await ctx.db
.query('roles')
.withIndex('by_nome', (q) => q.eq('nome', nomeNormalizado))
.unique();
if (existente) {
return { sucesso: false as const, erro: 'nome_ja_utilizado' };
}
}
await ctx.db.patch(args.roleId, { nome: nomeNormalizado });
}
if (args.descricao !== undefined) {
await ctx.db.patch(args.roleId, { descricao: args.descricao.trim() });
}
return { sucesso: true as const };
}
});
/**
* Excluir uma role
* Apenas admins podem excluir roles
* Não pode excluir role que tenha usuários vinculados
*/
export const excluir = mutation({
args: {
roleId: v.id('roles')
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const usuarioAtual = await getCurrentUserFunction(ctx);
if (!usuarioAtual) {
return { sucesso: false as const, erro: 'nao_autenticado' };
}
const roleAtual = await ctx.db.get(usuarioAtual.roleId);
if (!roleAtual || roleAtual.admin !== true) {
return { sucesso: false as const, erro: 'sem_permissao' };
}
const roleParaExcluir = await ctx.db.get(args.roleId);
if (!roleParaExcluir) {
return { sucesso: false as const, erro: 'role_nao_encontrada' };
}
// Verificar se existem usuários vinculados
const usuariosVinculados = await ctx.db
.query('usuarios')
.withIndex('by_role', (q) => q.eq('roleId', args.roleId))
.first();
if (usuariosVinculados) {
return { sucesso: false as const, erro: 'role_possui_usuarios' };
}
// Excluir permissões vinculadas primeiro
const permissoesVinculadas = await ctx.db
.query('rolePermissoes')
.withIndex('by_role', (q) => q.eq('roleId', args.roleId))
.collect();
for (const permissao of permissoesVinculadas) {
await ctx.db.delete(permissao._id);
}
// Excluir a role
await ctx.db.delete(args.roleId);
return { sucesso: true as const };
}
});
/**
* Migração de roles para o novo modelo com campo admin.
* - Perfis com nivel === 0 tornam-se admin: true
* - Todos os outros tornam-se admin: false
*/
export const migrarParaAdmin = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const roles = await ctx.db.query('roles').collect();
for (const role of roles) {
if (role.nivel <= 1) continue;
// Se já tem o campo admin definido, pula
if (role.admin !== undefined) continue;
// Perfis que eram nivel 0 (ti_master, admin) tornam-se admin: true
const isAdmin = (role as unknown as { nivel?: number }).nivel === 0;
await ctx.db.patch(role._id, {
nivel: 1
admin: isAdmin
});
}

View File

@@ -1524,7 +1524,7 @@ export const dispararAlertasInternos = internalMutation({
const rolesTi = await ctx.db
.query('roles')
.withIndex('by_nivel', (q) => q.lte('nivel', 1))
.filter((q) => q.eq(q.field('admin'), true))
.collect();
const usuariosNotificados: Id<'usuarios'>[] = [];

View File

@@ -172,13 +172,7 @@ export const seedCreateRoles = internalMutation({
returns: v.null(),
handler: async (ctx) => {
console.log('🔐 Criando roles...');
const ensureRole = async (
nome: string,
descricao: string,
nivel: number,
setor?: string,
editavel?: boolean
) => {
const ensureRole = async (nome: string, descricao: string, admin: boolean) => {
const existing = await ctx.db
.query('roles')
.withIndex('by_nome', (q) => q.eq('nome', nome))
@@ -190,23 +184,20 @@ export const seedCreateRoles = internalMutation({
const id = await ctx.db.insert('roles', {
nome,
descricao,
nivel,
setor,
customizado: false,
editavel
admin
});
console.log(` ✅ Role criada: ${nome}`);
return id;
};
// Níveis agora são apenas 0 e 1.
// 0 = máximo, 1 = administrativo (ambos com acesso total).
await ensureRole('ti_master', 'TI Master', 0, 'ti', false);
await ensureRole('admin', 'Administrador Geral', 1, 'administrativo', true);
await ensureRole('ti_usuario', 'TI Usuário', 1, 'ti', true);
await ensureRole('rh', 'Recursos Humanos', 1, 'recursos_humanos', false);
await ensureRole('financeiro', 'Financeiro', 1, 'financeiro', false);
await ensureRole('usuario', 'Usuário Padrão', 1, undefined, false);
// admin: true = acesso total ao sistema
// admin: false = permissões via rolePermissoes
await ensureRole('ti_master', 'TI Master', true);
await ensureRole('admin', 'Administrador Geral', true);
await ensureRole('ti_usuario', 'TI Usuário', false);
await ensureRole('rh', 'Recursos Humanos', false);
await ensureRole('financeiro', 'Financeiro', false);
await ensureRole('usuario', 'Usuário Padrão', false);
// Encadeia próximas etapas
await ctx.scheduler.runAfter(0, internal.seed.seedCreateSimbolos, {});
await ctx.scheduler.runAfter(0, internal.seed.seedCreatePermissoesBase, {});

View File

@@ -26,7 +26,6 @@ export const authTables = {
fotoPerfil: v.optional(v.id('_storage')),
avatar: v.optional(v.string()), // URL do avatar gerado (ex: DiceBear)
setor: v.optional(v.string()),
statusMensagem: v.optional(v.string()), // max 100 chars
statusPresenca: v.optional(
v.union(
@@ -53,16 +52,8 @@ export const authTables = {
roles: defineTable({
nome: v.string(), // "admin", "ti_master", "ti_usuario", "usuario_avancado", "usuario"
descricao: v.string(),
nivel: v.number(), // 0 = admin, 1 = ti_master, 2 = ti_usuario, 3+ = customizado
setor: v.optional(v.string()), // "ti", "rh", "financeiro", etc.
customizado: v.optional(v.boolean()), // se é um perfil customizado criado por TI_MASTER
criadoPor: v.optional(v.id('usuarios')), // usuário TI_MASTER que criou este perfil
editavel: v.optional(v.boolean()) // se pode ser editado (false para roles fixas)
})
.index('by_nome', ['nome'])
.index('by_nivel', ['nivel'])
.index('by_setor', ['setor'])
.index('by_customizado', ['customizado']),
admin: v.optional(v.boolean()) // true = acesso total ao sistema, false/undefined = permissões via rolePermissoes
}).index('by_nome', ['nome']),
permissoes: defineTable({
nome: v.string(), // "funcionarios.criar", "simbolos.editar", etc.

View File

@@ -223,7 +223,7 @@ export const listar = query({
_id: usuario.roleId,
descricao: 'Perfil não encontrado' as const,
nome: 'erro_role_ausente' as const,
nivel: 999 as const,
admin: false as const,
erro: true as const
},
funcionario,
@@ -237,11 +237,6 @@ export const listar = query({
continue;
}
// Filtrar por setor
if (args.setor && role.setor !== args.setor) {
continue;
}
// Buscar funcionário associado
let funcionario;
if (usuario.funcionarioId) {
@@ -264,18 +259,11 @@ export const listar = query({
}
}
// Construir objeto role - incluir _creationTime se existir (campo automático do Convex)
const roleObj = {
_id: role._id,
descricao: role.descricao,
nome: role.nome,
nivel: role.nivel,
...(role.criadoPor !== undefined && { criadoPor: role.criadoPor }),
...(role.customizado !== undefined && {
customizado: role.customizado
}),
...(role.editavel !== undefined && { editavel: role.editavel }),
...(role.setor !== undefined && { setor: role.setor })
admin: role.admin ?? false
};
const matriculaUsuario = await obterMatriculaUsuario(ctx, usuario);
@@ -514,7 +502,6 @@ export const atualizarPerfil = mutation({
if (args.fotoPerfil !== undefined) updates.fotoPerfil = args.fotoPerfil;
if (args.avatar !== undefined) updates.avatar = args.avatar;
if (args.setor !== undefined) updates.setor = args.setor;
if (args.statusMensagem !== undefined) updates.statusMensagem = args.statusMensagem;
if (args.statusPresenca !== undefined) {
updates.statusPresenca = args.statusPresenca;
@@ -610,7 +597,6 @@ export const obterPerfil = query({
fotoPerfil: usuarioAtual.fotoPerfil,
fotoPerfilUrl,
avatar: usuarioAtual.avatar,
setor: usuarioAtual.setor,
statusMensagem: usuarioAtual.statusMensagem,
statusPresenca: usuarioAtual.statusPresenca,
notificacoesAtivadas: usuarioAtual.notificacoesAtivadas ?? true,
@@ -941,7 +927,6 @@ export const editarUsuario = mutation({
if (args.nome !== undefined) updates.nome = args.nome;
if (args.email !== undefined) updates.email = args.email;
if (args.roleId !== undefined) updates.roleId = args.roleId;
if (args.setor !== undefined) updates.setor = args.setor;
await ctx.db.patch(args.usuarioId, updates);
@@ -987,10 +972,7 @@ export const criarAdminMaster = mutation({
const roleId = await ctx.db.insert('roles', {
nome: 'ti_master',
descricao: 'TI Master',
nivel: 0,
setor: 'ti',
customizado: false,
editavel: false
admin: true
});
roleTIMaster = await ctx.db.get(roleId);
}