refactor: Remove dedicated role management page and update authentication, roles, and permission handling across backend and frontend.
This commit is contained in:
@@ -2,19 +2,16 @@
|
|||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { useQuery } from 'convex-svelte';
|
import { useQuery } from 'convex-svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
children,
|
children,
|
||||||
requireAuth = true,
|
requireAuth = true,
|
||||||
allowedRoles = [],
|
allowedRoles = [],
|
||||||
maxLevel = 3,
|
|
||||||
redirectTo = '/'
|
redirectTo = '/'
|
||||||
}: {
|
}: {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
requireAuth?: boolean;
|
requireAuth?: boolean;
|
||||||
allowedRoles?: string[];
|
allowedRoles?: string[];
|
||||||
maxLevel?: number;
|
|
||||||
redirectTo?: string;
|
redirectTo?: string;
|
||||||
} = $props();
|
} = $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
|
// Se chegou aqui, permitir acesso
|
||||||
hasAccess = true;
|
hasAccess = true;
|
||||||
isChecking = false;
|
isChecking = false;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Id } from '@sgse-app/backend/convex/betterAuth/_generated/dataModel';
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
|
||||||
interface Usuario {
|
interface Usuario {
|
||||||
_id: string;
|
_id: string;
|
||||||
@@ -11,8 +11,7 @@ interface Usuario {
|
|||||||
role: {
|
role: {
|
||||||
_id: string;
|
_id: string;
|
||||||
nome: string;
|
nome: string;
|
||||||
nivel: number;
|
admin?: boolean;
|
||||||
setor?: string;
|
|
||||||
};
|
};
|
||||||
primeiroAcesso: boolean;
|
primeiroAcesso: boolean;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
@@ -56,7 +55,7 @@ class AuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get isAdmin() {
|
get isAdmin() {
|
||||||
return this.state.usuario?.role.nivel === 0;
|
return this.state.usuario?.role.admin === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isTI() {
|
get isTI() {
|
||||||
|
|||||||
@@ -19,11 +19,16 @@
|
|||||||
let modalNovoPerfilAberto = $state(false);
|
let modalNovoPerfilAberto = $state(false);
|
||||||
let nomeNovoPerfil = $state('');
|
let nomeNovoPerfil = $state('');
|
||||||
let descricaoNovoPerfil = $state('');
|
let descricaoNovoPerfil = $state('');
|
||||||
let setorNovoPerfil = $state('');
|
|
||||||
let nivelNovoPerfil = $state(3);
|
|
||||||
let roleParaDuplicar = $state<Id<'roles'> | ''>('');
|
let roleParaDuplicar = $state<Id<'roles'> | ''>('');
|
||||||
let criandoNovoPerfil = $state(false);
|
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
|
// Controla quais recursos estão expandidos (mostrando as ações) por perfil
|
||||||
// Formato: { "roleId-recurso": true/false }
|
// Formato: { "roleId-recurso": true/false }
|
||||||
let recursosExpandidos: Record<string, boolean> = $state({});
|
let recursosExpandidos: Record<string, boolean> = $state({});
|
||||||
@@ -74,13 +79,81 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (rolesFiltradas && catalogoQuery.data) {
|
if (rolesFiltradas && catalogoQuery.data) {
|
||||||
for (const roleRow of rolesFiltradas) {
|
for (const roleRow of rolesFiltradas) {
|
||||||
if (roleRow.nivel > 1) {
|
if (roleRow.admin !== true) {
|
||||||
carregarPermissoesRole(roleRow._id);
|
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) {
|
async function toggleAcao(roleId: Id<'roles'>, recurso: string, acao: string, conceder: boolean) {
|
||||||
try {
|
try {
|
||||||
salvando = true;
|
salvando = true;
|
||||||
@@ -130,9 +203,7 @@
|
|||||||
|
|
||||||
let podeSalvarNovoPerfil = $derived.by(() => {
|
let podeSalvarNovoPerfil = $derived.by(() => {
|
||||||
const nome = nomeNovoPerfil.trim();
|
const nome = nomeNovoPerfil.trim();
|
||||||
const nivel = Number(nivelNovoPerfil);
|
return nome.length >= 3 && !criandoNovoPerfil;
|
||||||
const nivelValido = Number.isFinite(nivel) && nivel >= 0 && nivel <= 10;
|
|
||||||
return nome.length >= 3 && nivelValido && !criandoNovoPerfil;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let roleDuplicacaoSelecionada = $derived.by(() => {
|
let roleDuplicacaoSelecionada = $derived.by(() => {
|
||||||
@@ -158,8 +229,6 @@
|
|||||||
function abrirModalNovoPerfil() {
|
function abrirModalNovoPerfil() {
|
||||||
nomeNovoPerfil = '';
|
nomeNovoPerfil = '';
|
||||||
descricaoNovoPerfil = '';
|
descricaoNovoPerfil = '';
|
||||||
setorNovoPerfil = '';
|
|
||||||
nivelNovoPerfil = 3;
|
|
||||||
roleParaDuplicar = '';
|
roleParaDuplicar = '';
|
||||||
modalNovoPerfilAberto = true;
|
modalNovoPerfilAberto = true;
|
||||||
}
|
}
|
||||||
@@ -173,16 +242,12 @@
|
|||||||
|
|
||||||
const nome = nomeNovoPerfil.trim();
|
const nome = nomeNovoPerfil.trim();
|
||||||
const descricao = descricaoNovoPerfil.trim();
|
const descricao = descricaoNovoPerfil.trim();
|
||||||
const setor = setorNovoPerfil.trim();
|
|
||||||
const nivel = Math.min(Math.max(Math.round(Number(nivelNovoPerfil)), 0), 10);
|
|
||||||
|
|
||||||
criandoNovoPerfil = true;
|
criandoNovoPerfil = true;
|
||||||
try {
|
try {
|
||||||
const resultado = await client.mutation(api.roles.criar, {
|
const resultado = await client.mutation(api.roles.criar, {
|
||||||
nome,
|
nome,
|
||||||
descricao,
|
descricao,
|
||||||
nivel,
|
|
||||||
setor: setor.length > 0 ? setor : undefined,
|
|
||||||
copiarDeRoleId: roleParaDuplicar || undefined
|
copiarDeRoleId: roleParaDuplicar || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,7 +276,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ProtectedRoute allowedRoles={['ti_master', 'admin']} maxLevel={1}>
|
<ProtectedRoute allowedRoles={['ti_master', 'admin']}>
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div class="breadcrumbs mb-4 text-sm">
|
<div class="breadcrumbs mb-4 text-sm">
|
||||||
<ul>
|
<ul>
|
||||||
@@ -492,10 +557,8 @@
|
|||||||
<div class="min-w-[200px] flex-1">
|
<div class="min-w-[200px] flex-1">
|
||||||
<div class="mb-2 flex items-center gap-3">
|
<div class="mb-2 flex items-center gap-3">
|
||||||
<h2 class="card-title text-2xl">{roleRow.descricao}</h2>
|
<h2 class="card-title text-2xl">{roleRow.descricao}</h2>
|
||||||
<div class="badge badge-lg badge-primary">
|
{#if roleRow.admin}
|
||||||
Nível {roleRow.nivel}
|
<div class="badge badge-lg badge-error">Admin</div>
|
||||||
</div>
|
|
||||||
{#if roleRow.nivel <= 1}
|
|
||||||
<div class="badge badge-lg badge-success gap-1">
|
<div class="badge badge-lg badge-success gap-1">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -513,15 +576,63 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Acesso Total
|
Acesso Total
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="badge badge-lg badge-info">Usuário</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-base-content/60 text-sm">
|
<p class="text-base-content/60 text-sm">
|
||||||
<span class="bg-base-200 rounded px-2 py-1 font-mono">{roleRow.nome}</span>
|
<span class="bg-base-200 rounded px-2 py-1 font-mono">{roleRow.nome}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{#if roleRow.nivel <= 1}
|
{#if roleRow.admin}
|
||||||
<div class="alert alert-success shadow-md">
|
<div class="alert alert-success shadow-md">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -680,38 +791,6 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</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">
|
<div class="form-control md:col-span-2">
|
||||||
<label class="label" for="duplicar-novo-perfil">
|
<label class="label" for="duplicar-novo-perfil">
|
||||||
<span class="label-text font-semibold">Duplicar permissões de</span>
|
<span class="label-text font-semibold">Duplicar permissões de</span>
|
||||||
@@ -725,7 +804,8 @@
|
|||||||
{#if rolesQuery.data}
|
{#if rolesQuery.data}
|
||||||
{#each rolesQuery.data as roleDuplicavel (roleDuplicavel._id)}
|
{#each rolesQuery.data as roleDuplicavel (roleDuplicavel._id)}
|
||||||
<option value={roleDuplicavel._id}>
|
<option value={roleDuplicavel._id}>
|
||||||
{roleDuplicavel.descricao} — nível {roleDuplicavel.nivel}
|
{roleDuplicavel.descricao}
|
||||||
|
{roleDuplicavel.admin ? '(Admin)' : ''}
|
||||||
</option>
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -800,4 +880,66 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
{/if}
|
{/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>
|
</ProtectedRoute>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -21,13 +21,9 @@
|
|||||||
role: {
|
role: {
|
||||||
_id: Id<'roles'>;
|
_id: Id<'roles'>;
|
||||||
_creationTime?: number;
|
_creationTime?: number;
|
||||||
criadoPor?: Id<'usuarios'>;
|
|
||||||
customizado?: boolean;
|
|
||||||
descricao: string;
|
descricao: string;
|
||||||
editavel?: boolean;
|
|
||||||
nome: string;
|
nome: string;
|
||||||
nivel: number;
|
admin?: boolean;
|
||||||
setor?: string;
|
|
||||||
erro?: boolean;
|
erro?: boolean;
|
||||||
};
|
};
|
||||||
funcionario?: {
|
funcionario?: {
|
||||||
@@ -278,7 +274,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<main class="container mx-auto max-w-7xl px-4 py-6">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div class="breadcrumbs mb-4 text-sm">
|
<div class="breadcrumbs mb-4 text-sm">
|
||||||
|
|||||||
@@ -16,9 +16,8 @@
|
|||||||
type RoleUsuario = {
|
type RoleUsuario = {
|
||||||
_id: Id<'roles'>;
|
_id: Id<'roles'>;
|
||||||
nome: string;
|
nome: string;
|
||||||
nivel: number;
|
admin?: boolean;
|
||||||
descricao: string;
|
descricao: string;
|
||||||
setor?: string;
|
|
||||||
erro?: boolean;
|
erro?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -265,11 +264,6 @@
|
|||||||
resultado = resultado.filter((u) => u.matricula.toLowerCase().includes(buscaMatricula));
|
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
|
// Filtro por status
|
||||||
if (filtroStatus !== 'todos') {
|
if (filtroStatus !== 'todos') {
|
||||||
if (filtroStatus === 'ativo') {
|
if (filtroStatus === 'ativo') {
|
||||||
@@ -326,7 +320,6 @@
|
|||||||
function limparFiltros() {
|
function limparFiltros() {
|
||||||
filtroNome = '';
|
filtroNome = '';
|
||||||
filtroMatricula = '';
|
filtroMatricula = '';
|
||||||
filtroSetor = '';
|
|
||||||
filtroStatus = 'todos';
|
filtroStatus = 'todos';
|
||||||
filtroDataCriacaoInicio = '';
|
filtroDataCriacaoInicio = '';
|
||||||
filtroDataCriacaoFim = '';
|
filtroDataCriacaoFim = '';
|
||||||
@@ -535,7 +528,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<div class="container mx-auto max-w-7xl px-4 py-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
|||||||
@@ -421,10 +421,7 @@ export const obterChamado = query({
|
|||||||
throw new Error('Chamado não encontrado');
|
throw new Error('Chamado não encontrado');
|
||||||
}
|
}
|
||||||
|
|
||||||
const podeVer =
|
const podeVer = ticket.solicitanteId === usuario._id || ticket.responsavelId === usuario._id;
|
||||||
ticket.solicitanteId === usuario._id ||
|
|
||||||
ticket.responsavelId === usuario._id ||
|
|
||||||
ticket.setorResponsavel === usuario.setor;
|
|
||||||
|
|
||||||
if (!podeVer) {
|
if (!podeVer) {
|
||||||
throw new Error('Acesso negado ao chamado');
|
throw new Error('Acesso negado ao chamado');
|
||||||
@@ -524,7 +521,6 @@ export const atribuirResponsavel = mutation({
|
|||||||
|
|
||||||
await ctx.db.patch(ticket._id, {
|
await ctx.db.patch(ticket._id, {
|
||||||
responsavelId: args.responsavelId,
|
responsavelId: args.responsavelId,
|
||||||
setorResponsavel: responsavel.setor,
|
|
||||||
atualizadoEm: agora
|
atualizadoEm: agora
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2056,8 +2056,7 @@ export const obterUsuariosOnline = query({
|
|||||||
email: u.email,
|
email: u.email,
|
||||||
fotoPerfil: u.fotoPerfil,
|
fotoPerfil: u.fotoPerfil,
|
||||||
statusPresenca: u.statusPresenca,
|
statusPresenca: u.statusPresenca,
|
||||||
statusMensagem: u.statusMensagem,
|
statusMensagem: u.statusMensagem
|
||||||
setor: u.setor
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2101,8 +2100,7 @@ export const listarTodosUsuarios = query({
|
|||||||
fotoPerfil: u.fotoPerfil,
|
fotoPerfil: u.fotoPerfil,
|
||||||
fotoPerfilUrl,
|
fotoPerfilUrl,
|
||||||
statusPresenca: u.statusPresenca,
|
statusPresenca: u.statusPresenca,
|
||||||
statusMensagem: u.statusMensagem,
|
statusMensagem: u.statusMensagem
|
||||||
setor: u.setor
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getCurrentUserFunction } from './auth';
|
|||||||
/**
|
/**
|
||||||
* Retorna as permissões do usuário atual para o frontend filtrar o menu localmente
|
* Retorna as permissões do usuário atual para o frontend filtrar o menu localmente
|
||||||
* Retorna:
|
* 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")
|
* - permissions: Set de strings no formato "recurso.acao" (ex: "funcionarios.listar")
|
||||||
*/
|
*/
|
||||||
export const getUserPermissions = query({
|
export const getUserPermissions = query({
|
||||||
@@ -20,8 +20,8 @@ export const getUserPermissions = query({
|
|||||||
return { isMaster: false, permissions: [] };
|
return { isMaster: false, permissions: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se for TI Master ou Admin (nivel <= 1), retorna flag de master
|
// Se for Admin, retorna flag de master
|
||||||
if (role.nivel <= 1) {
|
if (role.admin === true) {
|
||||||
return { isMaster: true, permissions: [] };
|
return { isMaster: true, permissions: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -340,10 +340,10 @@ export const verificarAlertasInternal = internalMutation({
|
|||||||
|
|
||||||
// Criar notificação no chat se configurado
|
// Criar notificação no chat se configurado
|
||||||
if (alerta.notifyByChat) {
|
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
|
const rolesAdminOuTi = await ctx.db
|
||||||
.query('roles')
|
.query('roles')
|
||||||
.filter((q) => q.lte(q.field('nivel'), 1))
|
.filter((q) => q.eq(q.field('admin'), true))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id));
|
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
|
// Buscar usuários administradores/TI para receber o alerta por email
|
||||||
const rolesAdminOuTi = await ctx.db
|
const rolesAdminOuTi = await ctx.db
|
||||||
.query('roles')
|
.query('roles')
|
||||||
.filter((q) => q.lte(q.field('nivel'), 1))
|
.filter((q) => q.eq(q.field('admin'), true))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id));
|
const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id));
|
||||||
|
|||||||
@@ -633,8 +633,8 @@ export const verificarAcao = query({
|
|||||||
const role = await ctx.db.get(usuario.roleId);
|
const role = await ctx.db.get(usuario.roleId);
|
||||||
if (!role) throw new Error('acesso_negado');
|
if (!role) throw new Error('acesso_negado');
|
||||||
|
|
||||||
// Níveis administrativos têm acesso total
|
// Admins têm acesso total
|
||||||
if (role.nivel <= 1) return null;
|
if (role.admin === true) return null;
|
||||||
|
|
||||||
// Encontrar permissão
|
// Encontrar permissão
|
||||||
const permissao = await ctx.db
|
const permissao = await ctx.db
|
||||||
@@ -665,7 +665,7 @@ export const assertPermissaoAcaoAtual = internalQuery({
|
|||||||
|
|
||||||
const role = await ctx.db.get(usuarioAtual.roleId);
|
const role = await ctx.db.get(usuarioAtual.roleId);
|
||||||
if (!role) throw new Error('acesso_negado');
|
if (!role) throw new Error('acesso_negado');
|
||||||
if (role.nivel <= 1) return null;
|
if (role.admin === true) return null;
|
||||||
|
|
||||||
const permissao = await ctx.db
|
const permissao = await ctx.db
|
||||||
.query('permissoes')
|
.query('permissoes')
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ export const buscarPorId = query({
|
|||||||
_id: v.id('roles'),
|
_id: v.id('roles'),
|
||||||
nome: v.string(),
|
nome: v.string(),
|
||||||
descricao: v.string(),
|
descricao: v.string(),
|
||||||
nivel: v.number(),
|
admin: v.optional(v.boolean())
|
||||||
setor: v.optional(v.string())
|
|
||||||
}),
|
}),
|
||||||
v.null()
|
v.null()
|
||||||
),
|
),
|
||||||
@@ -49,8 +48,6 @@ export const criar = mutation({
|
|||||||
args: {
|
args: {
|
||||||
nome: v.string(),
|
nome: v.string(),
|
||||||
descricao: v.string(),
|
descricao: v.string(),
|
||||||
nivel: v.number(),
|
|
||||||
setor: v.optional(v.string()),
|
|
||||||
copiarDeRoleId: v.optional(v.id('roles'))
|
copiarDeRoleId: v.optional(v.id('roles'))
|
||||||
},
|
},
|
||||||
returns: v.union(
|
returns: v.union(
|
||||||
@@ -64,7 +61,7 @@ export const criar = mutation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const roleAtual = await ctx.db.get(usuarioAtual.roleId);
|
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' };
|
return { sucesso: false as const, erro: 'sem_permissao' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,20 +95,12 @@ export const criar = mutation({
|
|||||||
permissoesParaCopiar = permissoesOrigem.map((item) => item.permissaoId);
|
permissoesParaCopiar = permissoesOrigem.map((item) => item.permissaoId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agora só existem níveis 0 e 1.
|
// Novos perfis criados NÃO são admin por padrão
|
||||||
// 0 = máximo (acesso total), 1 = administrativo (também com acesso total).
|
// O campo admin só pode ser alterado posteriormente por um admin existente
|
||||||
// Qualquer valor informado diferente de 0 é normalizado para 1.
|
|
||||||
const nivelNormalizado = Math.round(args.nivel) <= 0 ? 0 : 1;
|
|
||||||
const setor = args.setor?.trim();
|
|
||||||
|
|
||||||
const roleId = await ctx.db.insert('roles', {
|
const roleId = await ctx.db.insert('roles', {
|
||||||
nome: nomeNormalizado,
|
nome: nomeNormalizado,
|
||||||
descricao: args.descricao.trim() || args.nome.trim(),
|
descricao: args.descricao.trim() || args.nome.trim(),
|
||||||
nivel: nivelNormalizado,
|
admin: false
|
||||||
setor: setor && setor.length > 0 ? setor : undefined,
|
|
||||||
customizado: true,
|
|
||||||
criadoPor: usuarioAtual._id,
|
|
||||||
editavel: true
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (permissoesParaCopiar.length > 0) {
|
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).
|
* Editar uma role existente
|
||||||
* - Mantém níveis 0 e 1 como estão.
|
* Apenas admins podem editar roles
|
||||||
* - Converte qualquer nível > 1 para 1.
|
|
||||||
*/
|
*/
|
||||||
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: {},
|
args: {},
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const roles = await ctx.db.query('roles').collect();
|
const roles = await ctx.db.query('roles').collect();
|
||||||
|
|
||||||
for (const role of roles) {
|
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, {
|
await ctx.db.patch(role._id, {
|
||||||
nivel: 1
|
admin: isAdmin
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1524,7 +1524,7 @@ export const dispararAlertasInternos = internalMutation({
|
|||||||
|
|
||||||
const rolesTi = await ctx.db
|
const rolesTi = await ctx.db
|
||||||
.query('roles')
|
.query('roles')
|
||||||
.withIndex('by_nivel', (q) => q.lte('nivel', 1))
|
.filter((q) => q.eq(q.field('admin'), true))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
const usuariosNotificados: Id<'usuarios'>[] = [];
|
const usuariosNotificados: Id<'usuarios'>[] = [];
|
||||||
|
|||||||
@@ -172,13 +172,7 @@ export const seedCreateRoles = internalMutation({
|
|||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
console.log('🔐 Criando roles...');
|
console.log('🔐 Criando roles...');
|
||||||
const ensureRole = async (
|
const ensureRole = async (nome: string, descricao: string, admin: boolean) => {
|
||||||
nome: string,
|
|
||||||
descricao: string,
|
|
||||||
nivel: number,
|
|
||||||
setor?: string,
|
|
||||||
editavel?: boolean
|
|
||||||
) => {
|
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query('roles')
|
.query('roles')
|
||||||
.withIndex('by_nome', (q) => q.eq('nome', nome))
|
.withIndex('by_nome', (q) => q.eq('nome', nome))
|
||||||
@@ -190,23 +184,20 @@ export const seedCreateRoles = internalMutation({
|
|||||||
const id = await ctx.db.insert('roles', {
|
const id = await ctx.db.insert('roles', {
|
||||||
nome,
|
nome,
|
||||||
descricao,
|
descricao,
|
||||||
nivel,
|
admin
|
||||||
setor,
|
|
||||||
customizado: false,
|
|
||||||
editavel
|
|
||||||
});
|
});
|
||||||
console.log(` ✅ Role criada: ${nome}`);
|
console.log(` ✅ Role criada: ${nome}`);
|
||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Níveis agora são apenas 0 e 1.
|
// admin: true = acesso total ao sistema
|
||||||
// 0 = máximo, 1 = administrativo (ambos com acesso total).
|
// admin: false = permissões via rolePermissoes
|
||||||
await ensureRole('ti_master', 'TI Master', 0, 'ti', false);
|
await ensureRole('ti_master', 'TI Master', true);
|
||||||
await ensureRole('admin', 'Administrador Geral', 1, 'administrativo', true);
|
await ensureRole('admin', 'Administrador Geral', true);
|
||||||
await ensureRole('ti_usuario', 'TI Usuário', 1, 'ti', true);
|
await ensureRole('ti_usuario', 'TI Usuário', false);
|
||||||
await ensureRole('rh', 'Recursos Humanos', 1, 'recursos_humanos', false);
|
await ensureRole('rh', 'Recursos Humanos', false);
|
||||||
await ensureRole('financeiro', 'Financeiro', 1, 'financeiro', false);
|
await ensureRole('financeiro', 'Financeiro', false);
|
||||||
await ensureRole('usuario', 'Usuário Padrão', 1, undefined, false);
|
await ensureRole('usuario', 'Usuário Padrão', false);
|
||||||
// Encadeia próximas etapas
|
// Encadeia próximas etapas
|
||||||
await ctx.scheduler.runAfter(0, internal.seed.seedCreateSimbolos, {});
|
await ctx.scheduler.runAfter(0, internal.seed.seedCreateSimbolos, {});
|
||||||
await ctx.scheduler.runAfter(0, internal.seed.seedCreatePermissoesBase, {});
|
await ctx.scheduler.runAfter(0, internal.seed.seedCreatePermissoesBase, {});
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const authTables = {
|
|||||||
|
|
||||||
fotoPerfil: v.optional(v.id('_storage')),
|
fotoPerfil: v.optional(v.id('_storage')),
|
||||||
avatar: v.optional(v.string()), // URL do avatar gerado (ex: DiceBear)
|
avatar: v.optional(v.string()), // URL do avatar gerado (ex: DiceBear)
|
||||||
setor: v.optional(v.string()),
|
|
||||||
statusMensagem: v.optional(v.string()), // max 100 chars
|
statusMensagem: v.optional(v.string()), // max 100 chars
|
||||||
statusPresenca: v.optional(
|
statusPresenca: v.optional(
|
||||||
v.union(
|
v.union(
|
||||||
@@ -53,16 +52,8 @@ export const authTables = {
|
|||||||
roles: defineTable({
|
roles: defineTable({
|
||||||
nome: v.string(), // "admin", "ti_master", "ti_usuario", "usuario_avancado", "usuario"
|
nome: v.string(), // "admin", "ti_master", "ti_usuario", "usuario_avancado", "usuario"
|
||||||
descricao: v.string(),
|
descricao: v.string(),
|
||||||
nivel: v.number(), // 0 = admin, 1 = ti_master, 2 = ti_usuario, 3+ = customizado
|
admin: v.optional(v.boolean()) // true = acesso total ao sistema, false/undefined = permissões via rolePermissoes
|
||||||
setor: v.optional(v.string()), // "ti", "rh", "financeiro", etc.
|
}).index('by_nome', ['nome']),
|
||||||
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']),
|
|
||||||
|
|
||||||
permissoes: defineTable({
|
permissoes: defineTable({
|
||||||
nome: v.string(), // "funcionarios.criar", "simbolos.editar", etc.
|
nome: v.string(), // "funcionarios.criar", "simbolos.editar", etc.
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export const listar = query({
|
|||||||
_id: usuario.roleId,
|
_id: usuario.roleId,
|
||||||
descricao: 'Perfil não encontrado' as const,
|
descricao: 'Perfil não encontrado' as const,
|
||||||
nome: 'erro_role_ausente' as const,
|
nome: 'erro_role_ausente' as const,
|
||||||
nivel: 999 as const,
|
admin: false as const,
|
||||||
erro: true as const
|
erro: true as const
|
||||||
},
|
},
|
||||||
funcionario,
|
funcionario,
|
||||||
@@ -237,11 +237,6 @@ export const listar = query({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtrar por setor
|
|
||||||
if (args.setor && role.setor !== args.setor) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buscar funcionário associado
|
// Buscar funcionário associado
|
||||||
let funcionario;
|
let funcionario;
|
||||||
if (usuario.funcionarioId) {
|
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 = {
|
const roleObj = {
|
||||||
_id: role._id,
|
_id: role._id,
|
||||||
descricao: role.descricao,
|
descricao: role.descricao,
|
||||||
nome: role.nome,
|
nome: role.nome,
|
||||||
nivel: role.nivel,
|
admin: role.admin ?? false
|
||||||
...(role.criadoPor !== undefined && { criadoPor: role.criadoPor }),
|
|
||||||
...(role.customizado !== undefined && {
|
|
||||||
customizado: role.customizado
|
|
||||||
}),
|
|
||||||
...(role.editavel !== undefined && { editavel: role.editavel }),
|
|
||||||
...(role.setor !== undefined && { setor: role.setor })
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const matriculaUsuario = await obterMatriculaUsuario(ctx, usuario);
|
const matriculaUsuario = await obterMatriculaUsuario(ctx, usuario);
|
||||||
@@ -514,7 +502,6 @@ export const atualizarPerfil = mutation({
|
|||||||
|
|
||||||
if (args.fotoPerfil !== undefined) updates.fotoPerfil = args.fotoPerfil;
|
if (args.fotoPerfil !== undefined) updates.fotoPerfil = args.fotoPerfil;
|
||||||
if (args.avatar !== undefined) updates.avatar = args.avatar;
|
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.statusMensagem !== undefined) updates.statusMensagem = args.statusMensagem;
|
||||||
if (args.statusPresenca !== undefined) {
|
if (args.statusPresenca !== undefined) {
|
||||||
updates.statusPresenca = args.statusPresenca;
|
updates.statusPresenca = args.statusPresenca;
|
||||||
@@ -610,7 +597,6 @@ export const obterPerfil = query({
|
|||||||
fotoPerfil: usuarioAtual.fotoPerfil,
|
fotoPerfil: usuarioAtual.fotoPerfil,
|
||||||
fotoPerfilUrl,
|
fotoPerfilUrl,
|
||||||
avatar: usuarioAtual.avatar,
|
avatar: usuarioAtual.avatar,
|
||||||
setor: usuarioAtual.setor,
|
|
||||||
statusMensagem: usuarioAtual.statusMensagem,
|
statusMensagem: usuarioAtual.statusMensagem,
|
||||||
statusPresenca: usuarioAtual.statusPresenca,
|
statusPresenca: usuarioAtual.statusPresenca,
|
||||||
notificacoesAtivadas: usuarioAtual.notificacoesAtivadas ?? true,
|
notificacoesAtivadas: usuarioAtual.notificacoesAtivadas ?? true,
|
||||||
@@ -941,7 +927,6 @@ export const editarUsuario = mutation({
|
|||||||
if (args.nome !== undefined) updates.nome = args.nome;
|
if (args.nome !== undefined) updates.nome = args.nome;
|
||||||
if (args.email !== undefined) updates.email = args.email;
|
if (args.email !== undefined) updates.email = args.email;
|
||||||
if (args.roleId !== undefined) updates.roleId = args.roleId;
|
if (args.roleId !== undefined) updates.roleId = args.roleId;
|
||||||
if (args.setor !== undefined) updates.setor = args.setor;
|
|
||||||
|
|
||||||
await ctx.db.patch(args.usuarioId, updates);
|
await ctx.db.patch(args.usuarioId, updates);
|
||||||
|
|
||||||
@@ -987,10 +972,7 @@ export const criarAdminMaster = mutation({
|
|||||||
const roleId = await ctx.db.insert('roles', {
|
const roleId = await ctx.db.insert('roles', {
|
||||||
nome: 'ti_master',
|
nome: 'ti_master',
|
||||||
descricao: 'TI Master',
|
descricao: 'TI Master',
|
||||||
nivel: 0,
|
admin: true
|
||||||
setor: 'ti',
|
|
||||||
customizado: false,
|
|
||||||
editavel: false
|
|
||||||
});
|
});
|
||||||
roleTIMaster = await ctx.db.get(roleId);
|
roleTIMaster = await ctx.db.get(roleId);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user