feat: Implement dedicated login page and public/dashboard layouts, refactoring authentication flow and removing the todos page.

This commit is contained in:
2025-12-12 14:22:28 -03:00
parent b47a317c33
commit b771322b24
18 changed files with 665 additions and 802 deletions

View File

@@ -2,8 +2,9 @@
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { loginModalStore } from '$lib/stores/loginModal.svelte';
import { TriangleAlert } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
interface Props {
recurso: string;
@@ -34,7 +35,10 @@
verificando = false;
permitido = false;
const currentPath = window.location.pathname;
loginModalStore.open(currentPath);
goto(`${resolve('/login')}?redirect=${encodeURIComponent(currentPath)}`, {
replaceState: true,
noScroll: true
});
return;
}

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { resolve } from '$app/paths';
const currentYear = new Date().getFullYear();
</script>
<footer class="bg-base-200 text-base-content border-base-300 border-t">
<div class="container mx-auto px-4 py-10">
<div class="grid grid-cols-1 gap-8 text-center md:grid-cols-3 md:text-left">
<div>
<h3 class="text-primary mb-4 text-lg font-bold">SGSE</h3>
<p class="mx-auto max-w-xs text-sm opacity-75 md:mx-0">
Sistema de Gestão de Secretaria<br />
Simplificando processos e conectando pessoas.
</p>
</div>
<div>
<h3 class="mb-4 text-lg font-bold">Links Úteis</h3>
<ul class="space-y-2 text-sm opacity-75">
<li>
<a
href="https://www.pe.gov.br/"
target="_blank"
class="hover:text-primary transition-colors">Portal do Governo</a
>
</li>
<li>
<a href={resolve('/abrir-chamado')} class="hover:text-primary transition-colors"
>Suporte</a
>
</li>
</ul>
</div>
<div>
<h3 class="mb-4 text-lg font-bold">Contato</h3>
<p class="text-sm opacity-75">
Secretaria de Educação<br />
Recife - PE
</p>
</div>
</div>
<div class="divider mt-8 mb-4"></div>
<div class="flex flex-col items-center justify-between text-sm opacity-60 md:flex-row">
<p>&copy; {currentYear} Governo de Pernambuco. Todos os direitos reservados.</p>
<p class="mt-2 md:mt-0">Desenvolvido com tecnologia de ponta.</p>
</div>
</div>
</footer>

View File

@@ -1,7 +1,20 @@
<script lang="ts">
import logo from '$lib/assets/logo_governo_PE.png';
import { page } from '$app/state';
</script>
<div class="navbar bg-base-200 w-76 p-4 shadow-sm">
<img src={logo} alt="Logo" class="" />
</div>
<header class="sticky top-0 z-50 w-full border-b backdrop-blur-md bg-base-100/90 border-base-200 shadow-sm transition-all duration-300">
<div class="container mx-auto px-4 h-16 flex items-center justify-between">
<a href="/" class="flex items-center gap-3 group transition-transform hover:scale-[1.02]">
<img src={logo} alt="Logo Governo PE" class="h-10 w-auto object-contain drop-shadow-sm" />
<div class="flex flex-col hidden sm:flex">
<span class="text-xs font-bold text-primary tracking-wider uppercase">Governo de</span>
<span class="text-lg font-extrabold -mt-1 tracking-tight text-base-content leading-none">Pernambuco</span>
</div>
</a>
<nav class="flex items-center gap-2">
<!-- Links can be added here based on auth state or specific requirements -->
</nav>
</div>
</header>

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { loginModalStore } from '$lib/stores/loginModal.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
interface MenuProtectionProps {
menuPath: string;
@@ -23,16 +23,8 @@
let temPermissao = $state(false);
let motivoNegacao = $state('');
// Query para verificar permissões (só executa se o usuário estiver autenticado)
// Usuário atual (para autenticação básica)
const currentUser = useQuery(api.auth.getCurrentUser, {});
let permissaoQuery = $derived(
currentUser?.data
? useQuery(api.menuPermissoes.verificarAcesso, {
usuarioId: currentUser.data._id as Id<'usuarios'>,
menuPath: menuPath
})
: null
);
onMount(() => {
verificarPermissoes();
@@ -43,13 +35,6 @@
verificarPermissoes();
});
$effect(() => {
// Re-verificar quando a query carregar
if (permissaoQuery?.data) {
verificarPermissoes();
}
});
function verificarPermissoes() {
// Dashboard e abertura de chamados são públicos
if (menuPath === '/' || menuPath === '/abrir-chamado') {
@@ -64,43 +49,18 @@
temPermissao = false;
motivoNegacao = 'auth_required';
// Abrir modal de login e salvar rota de redirecionamento
// Redirecionar para a página de login e salvar rota de redirecionamento
const currentPath = window.location.pathname;
loginModalStore.open(currentPath);
// NÃO redirecionar, apenas mostrar o modal
// O usuário verá a mensagem "Verificando permissões..." enquanto o modal está aberto
goto(`${resolve('/login')}?redirect=${encodeURIComponent(currentPath)}`, {
replaceState: true,
noScroll: true
});
return;
}
// Se está autenticado, verificar permissões
if (permissaoQuery?.data) {
const permissao = permissaoQuery.data;
// Se não pode acessar
if (!permissao.podeAcessar) {
verificando = false;
temPermissao = false;
motivoNegacao = 'access_denied';
return;
}
// Se requer gravação mas não tem permissão
if (requireGravar && !permissao.podeGravar) {
verificando = false;
temPermissao = false;
motivoNegacao = 'write_denied';
return;
}
// Tem permissão!
verificando = false;
temPermissao = true;
} else if (permissaoQuery?.error) {
verificando = false;
temPermissao = false;
motivoNegacao = 'error';
}
// Se está autenticado, permitir acesso (component está sem verificação de menu específica no momento)
verificando = false;
temPermissao = true;
}
</script>

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useConvexClient, useQuery } from 'convex-svelte';
import { useQuery } from 'convex-svelte';
import type { FunctionReference } from 'convex/server';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import {
Home,
User,
@@ -37,18 +36,6 @@
import ChatWidget from '$lib/components/chat/ChatWidget.svelte';
import NotificationBell from '$lib/components/chat/NotificationBell.svelte';
import PresenceManager from '$lib/components/chat/PresenceManager.svelte';
import { loginModalStore } from '$lib/stores/loginModal.svelte';
import { obterIPPublico } from '$lib/utils/deviceInfo';
interface GPSLocation {
latitude?: number;
longitude?: number;
precisao?: number;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
}
interface MenuItemPermission {
recurso: string;
@@ -214,11 +201,6 @@
const { children }: { children: Snippet } = $props();
let currentPath = $derived(page.url.pathname);
let matricula = $state('');
let senha = $state('');
let erroLogin = $state('');
let carregandoLogin = $state(false);
let showAboutModal = $state(false);
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>, {});
@@ -226,11 +208,11 @@
// Filtrar menu baseado nas permissões do usuário
function filterSubmenusByPermissions(
items: SubMenuItem[],
items: readonly SubMenuItem[],
isMaster: boolean,
permissionsSet: Set<string>
): SubMenuItem[] {
if (isMaster) return items;
if (isMaster) return [...items];
return items.filter((item) => {
if (!item.permission) return true;
@@ -240,11 +222,11 @@
}
function filterMenuByPermissions(
items: MenuItem[],
items: readonly MenuItem[],
isMaster: boolean,
permissionsSet: Set<string>
): MenuItem[] {
if (isMaster) return items;
if (isMaster) return [...items];
const filtered: MenuItem[] = [];
@@ -283,8 +265,6 @@
return filterMenuByPermissions(MENU_STRUCTURE, data.isMaster, permissionsSet);
});
const convexClient = useConvexClient();
const iconMap: Record<string, IconType> = {
Home,
User,
@@ -368,20 +348,9 @@
return null;
});
function openLoginModal() {
loginModalStore.open();
matricula = '';
senha = '';
erroLogin = '';
carregandoLogin = false;
}
function closeLoginModal() {
loginModalStore.close();
matricula = '';
senha = '';
erroLogin = '';
carregandoLogin = false;
function goToLogin(redirectTo?: string) {
const target = redirectTo || currentPath || '/';
goto(`${resolve('/login')}?redirect=${encodeURIComponent(target)}`);
}
function openAboutModal() {
@@ -392,152 +361,6 @@
showAboutModal = false;
}
async function handleLogin(e: Event) {
e.preventDefault();
erroLogin = '';
carregandoLogin = true;
// Obter IP público e userAgent (rápido, não bloqueia)
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : undefined;
// Obter IP público com timeout curto (não bloquear login)
const ipPublicoPromise = obterIPPublico().catch(() => undefined);
const ipPublicoTimeout = new Promise<undefined>(
(resolve) => setTimeout(() => resolve(undefined), 2000) // Timeout de 2 segundos
);
const ipPublico = await Promise.race([ipPublicoPromise, ipPublicoTimeout]);
// Função para coletar GPS em background (não bloqueia login)
async function coletarGPS(): Promise<GPSLocation> {
try {
const { obterLocalizacaoRapida } = await import('$lib/utils/deviceInfo');
// Usar versão rápida com timeout curto (3 segundos máximo)
const gpsPromise = obterLocalizacaoRapida();
const gpsTimeout = new Promise<GPSLocation>((resolve) =>
setTimeout(() => resolve({}), 3000)
);
return await Promise.race([gpsPromise, gpsTimeout]);
} catch (err) {
console.warn('Erro ao obter GPS (não bloqueia login):', err);
return {};
}
}
// Iniciar coleta de GPS em background (não esperar)
const gpsPromise = coletarGPS();
const result = await authClient.signIn.email(
{ email: matricula.trim(), password: senha },
{
onError: async (ctx) => {
// Registrar tentativa de login falha
try {
// Tentar obter GPS se já estiver disponível (não esperar)
let localizacaoGPS: GPSLocation = {};
try {
localizacaoGPS = await Promise.race([
gpsPromise,
new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
]);
} catch {
// Ignorar se GPS não estiver pronto
}
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
matriculaOuEmail: matricula.trim(),
sucesso: false,
motivoFalha: ctx.error?.message || 'Erro desconhecido',
userAgent: userAgent,
ipAddress: ipPublico,
latitudeGPS: localizacaoGPS.latitude,
longitudeGPS: localizacaoGPS.longitude,
precisaoGPS: localizacaoGPS.precisao,
enderecoGPS: localizacaoGPS.endereco,
cidadeGPS: localizacaoGPS.cidade,
estadoGPS: localizacaoGPS.estado,
paisGPS: localizacaoGPS.pais
});
} catch (err) {
console.error('Erro ao registrar tentativa de login falha:', err);
}
alert(ctx.error.message);
}
}
);
if (result.data) {
// Registrar tentativa de login bem-sucedida
// Fazer de forma assíncrona para não bloquear o login
// Não tentamos buscar getCurrentUser aqui porque pode causar timeout
// O useQuery no componente já busca o usuário automaticamente quando a sessão estiver pronta
(async () => {
try {
// Aguardar um pouco para o usuário ser sincronizado no Convex
await new Promise((resolve) => setTimeout(resolve, 500));
// Tentar obter GPS se já estiver disponível (não esperar)
let localizacaoGPS: GPSLocation = {};
try {
localizacaoGPS = await Promise.race([
gpsPromise,
new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
]);
} catch {
// Ignorar se GPS não estiver pronto
}
// Buscar o usuário no Convex usando getCurrentUser
// (o typesafe FunctionReference pode falhar aqui; tipamos o retorno e mantemos a chamada)
const usuario = (await convexClient.query(
api.auth.getCurrentUser as unknown as FunctionReference<'query'>,
{}
)) as { _id?: Id<'usuarios'> } | null;
if (usuario && usuario._id) {
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
usuarioId: usuario._id,
matriculaOuEmail: matricula.trim(),
sucesso: true,
userAgent: userAgent,
ipAddress: ipPublico,
latitudeGPS: localizacaoGPS.latitude,
longitudeGPS: localizacaoGPS.longitude,
precisaoGPS: localizacaoGPS.precisao,
enderecoGPS: localizacaoGPS.endereco,
cidadeGPS: localizacaoGPS.cidade,
estadoGPS: localizacaoGPS.estado,
paisGPS: localizacaoGPS.pais
});
} else {
// Se não encontrou o usuário, registrar sem usuarioId (será atualizado depois)
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
matriculaOuEmail: matricula.trim(),
sucesso: true,
userAgent: userAgent,
ipAddress: ipPublico,
latitudeGPS: localizacaoGPS.latitude,
longitudeGPS: localizacaoGPS.longitude,
precisaoGPS: localizacaoGPS.precisao,
enderecoGPS: localizacaoGPS.endereco,
cidadeGPS: localizacaoGPS.cidade,
estadoGPS: localizacaoGPS.estado,
paisGPS: localizacaoGPS.pais
});
}
} catch (err) {
console.error('Erro ao registrar tentativa de login:', err);
// Não bloquear o login se houver erro ao registrar
}
})();
closeLoginModal();
goto(resolve('/'));
} else {
erroLogin = 'Erro ao fazer login';
}
carregandoLogin = false;
}
async function handleLogout() {
const result = await authClient.signOut();
if (result.error) {
@@ -647,7 +470,7 @@
<button
type="button"
class="btn btn-primary btn-sm rounded-full px-6"
onclick={() => openLoginModal()}
onclick={() => goToLogin()}
>
Entrar
</button>
@@ -737,7 +560,7 @@
exact: sub.exact
})}
<li>
<a href={resolve(sub.link)} class={getMenuClasses(isSubActive, true)}>
<a href={resolve(sub.link as any)} class={getMenuClasses(isSubActive, true)}>
<span>{sub.label}</span>
</a>
</li>
@@ -747,7 +570,7 @@
</details>
{:else}
<a
href={resolve(item.link)}
href={resolve(item.link as any)}
aria-current={isActive ? 'page' : undefined}
class={getMenuClasses(isActive)}
>
@@ -789,148 +612,6 @@
</div>
</div>
<!-- Modal de Login -->
{#if loginModalStore.showModal}
<dialog class="modal modal-open">
<div
class="modal-box from-base-100 via-base-100 to-primary/5 relative max-w-md overflow-hidden bg-linear-to-br shadow-2xl backdrop-blur-sm"
>
<!-- Botão de fechar moderno -->
<button
type="button"
class="btn btn-sm btn-circle btn-ghost hover:bg-error/20 hover:text-error absolute top-4 right-4 z-10 transition-all duration-200"
onclick={closeLoginModal}
aria-label="Fechar modal"
>
<XCircle class="h-5 w-5" strokeWidth={2.5} />
</button>
<!-- Decoração de fundo -->
<div class="bg-primary/10 absolute -top-20 -right-20 h-40 w-40 rounded-full blur-3xl"></div>
<div class="bg-primary/5 absolute -bottom-20 -left-20 h-40 w-40 rounded-full blur-3xl"></div>
<div class="relative z-10 p-8">
<!-- Header com logo e título -->
<div class="mb-8 text-center">
<div class="avatar mx-auto mb-5">
<div
class="group ring-primary/20 relative w-24 overflow-hidden rounded-2xl bg-white p-4 shadow-xl ring-2 transition-all duration-300 hover:scale-105 hover:shadow-2xl"
>
<div
class="from-primary/10 absolute inset-0 bg-linear-to-br to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
></div>
<img src={logo} alt="Logo SGSE" class="relative z-10 h-full w-full object-contain" />
</div>
</div>
<h3 class="text-primary mb-2 text-4xl font-bold tracking-tight">Login</h3>
<p class="text-base-content/70 text-sm font-medium">
Acesse o sistema com suas credenciais
</p>
</div>
<!-- Mensagem de erro -->
{#if erroLogin}
<div
class="alert alert-error border-error/30 bg-error/10 mb-6 shadow-lg backdrop-blur-sm"
>
<XCircle class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2.5} />
<span class="font-medium">{erroLogin}</span>
</div>
{/if}
<!-- Formulário -->
<form class="space-y-5" onsubmit={handleLogin}>
<!-- Campo Matrícula/E-mail -->
<div class="form-control">
<label class="label pb-2" for="login-matricula">
<span class="text-primary label-text text-sm font-semibold">Matrícula ou E-mail</span>
</label>
<div class="relative">
<input
id="login-matricula"
type="text"
placeholder="Digite sua matrícula ou e-mail"
class="input input-bordered input-primary focus:border-primary focus:shadow-primary/20 w-full border-2 transition-all duration-200 focus:shadow-lg disabled:opacity-50"
bind:value={matricula}
required
disabled={carregandoLogin}
autocomplete="username"
/>
</div>
</div>
<!-- Campo Senha -->
<div class="form-control">
<label class="label pb-2" for="login-password">
<span class="text-primary label-text text-sm font-semibold">Senha</span>
</label>
<div class="relative">
<input
id="login-password"
type="password"
placeholder="Digite sua senha"
class="input input-bordered input-primary focus:border-primary focus:shadow-primary/20 w-full border-2 transition-all duration-200 focus:shadow-lg disabled:opacity-50"
bind:value={senha}
required
disabled={carregandoLogin}
autocomplete="current-password"
/>
</div>
</div>
<!-- Botão de submit -->
<div class="form-control pt-2">
<button
type="submit"
class="btn btn-primary btn-lg group from-primary via-primary to-primary/90 relative w-full overflow-hidden border-0 bg-linear-to-r shadow-xl transition-all duration-300 hover:scale-[1.02] hover:shadow-2xl disabled:opacity-50"
disabled={carregandoLogin}
>
<!-- Efeito de brilho animado -->
<div
class="absolute inset-0 -translate-x-full bg-linear-to-r from-transparent via-white/30 to-transparent transition-transform duration-1000 group-hover:translate-x-full"
></div>
{#if carregandoLogin}
<span class="loading loading-spinner loading-sm"></span>
<span class="font-semibold">Entrando...</span>
{:else}
<LogIn
class="h-5 w-5 transition-transform duration-300 group-hover:scale-110"
strokeWidth={2.5}
/>
<span class="font-semibold">Entrar</span>
{/if}
</button>
</div>
<!-- Links auxiliares -->
<div class="space-y-3 pt-4 text-center">
<a
href={resolve('/abrir-chamado')}
class="link link-primary block text-sm font-medium transition-all duration-200 hover:scale-105"
onclick={closeLoginModal}
>
Abrir Chamado
</a>
<a
href={resolve('/esqueci-senha')}
class="link link-secondary block text-sm font-medium transition-all duration-200 hover:scale-105"
onclick={closeLoginModal}
>
Esqueceu sua senha?
</a>
</div>
</form>
</div>
</div>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<form method="dialog" class="modal-backdrop" onclick={closeLoginModal}>
<button type="button">close</button>
</form>
</dialog>
{/if}
<!-- Modal Sobre -->
{#if showAboutModal}
<dialog class="modal modal-open">

View File

@@ -1,14 +1,21 @@
import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { error, redirect } from '@sveltejs/kit';
import type { FunctionReference } from 'convex/server';
export const load = async ({ locals }) => {
if (!locals.token) {
throw redirect(302, '/login');
}
try {
const client = createConvexHttpClient({ token: locals.token });
const currentUser = await client.query(api.auth.getCurrentUser, {});
const currentUser = await client.query(api.auth.getCurrentUser as FunctionReference<'query'>);
if (!currentUser) {
throw redirect(302, '/login');
}
return { currentUser };
} catch (error) {
console.error('Erro ao carregar usuário atual no layout do dashboard:', error);
// Evita quebrar toda a área logada em caso de falha transitória na API/auth
return { currentUser: null };
} catch {
return error(500, 'Ops! Ocorreu um erro, tente novamente mais tarde.');
}
};

View File

@@ -1,100 +1,17 @@
<script lang="ts">
import { page } from '$app/state';
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import ActionGuard from '$lib/components/ActionGuard.svelte';
import { Toaster } from 'svelte-sonner';
import PushNotificationManager from '$lib/components/PushNotificationManager.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
const { children } = $props();
// Usuário atual e consentimento LGPD
const currentUser = useQuery(api.auth.getCurrentUser, {});
const consentimentoLGPD = useQuery(api.lgpd.verificarConsentimento, { tipo: 'termo_uso' });
// Redirecionar para o termo de consentimento se obrigatório e não aceito
$effect(() => {
const p = page.url.pathname;
// Rotas públicas/que não exigem termo
if (
p === '/' ||
p === '/abrir-chamado' ||
p === '/termo-consentimento' ||
p.startsWith('/privacidade') ||
p.startsWith('/api/')
) {
return;
}
// Precisa estar autenticado para exigir LGPD
if (!currentUser?.data) {
return;
}
// Query ainda carregando ou sem dados
if (!consentimentoLGPD?.data) {
return;
}
const data = consentimentoLGPD.data;
if (data.termoObrigatorio && !data.aceito) {
const redirect = encodeURIComponent(p);
window.location.href = `/termo-consentimento?redirect=${redirect}`;
}
});
// Resolver recurso/ação a partir da rota
const routeAction = $derived.by(() => {
const p = page.url.pathname;
if (p === '/' || p === '/abrir-chamado') return null;
// Funcionários
if (p.startsWith('/recursos-humanos/funcionarios')) {
if (p.includes('/cadastro')) return { recurso: 'funcionarios', acao: 'criar' };
if (p.includes('/excluir')) return { recurso: 'funcionarios', acao: 'excluir' };
if (p.includes('/editar') || p.includes('/funcionarioId'))
return { recurso: 'funcionarios', acao: 'editar' };
return { recurso: 'funcionarios', acao: 'listar' };
}
// Símbolos
if (p.startsWith('/recursos-humanos/simbolos')) {
if (p.includes('/cadastro')) return { recurso: 'simbolos', acao: 'criar' };
if (p.includes('/excluir')) return { recurso: 'simbolos', acao: 'excluir' };
if (p.includes('/editar') || p.includes('/simboloId'))
return { recurso: 'simbolos', acao: 'editar' };
return { recurso: 'simbolos', acao: 'listar' };
}
// Outras áreas (uso genérico: ver)
if (p.startsWith('/financeiro')) return { recurso: 'financeiro', acao: 'ver' };
if (p.startsWith('/controladoria')) return { recurso: 'controladoria', acao: 'ver' };
if (p.startsWith('/licitacoes')) return { recurso: 'licitacoes', acao: 'ver' };
if (p.startsWith('/compras')) return { recurso: 'compras', acao: 'ver' };
if (p.startsWith('/juridico')) return { recurso: 'juridico', acao: 'ver' };
if (p.startsWith('/comunicacao')) return { recurso: 'comunicacao', acao: 'ver' };
if (p.startsWith('/programas-esportivos'))
return { recurso: 'programas_esportivos', acao: 'ver' };
if (p.startsWith('/secretaria-executiva'))
return { recurso: 'secretaria_executiva', acao: 'ver' };
if (p.startsWith('/gestao-pessoas')) return { recurso: 'gestao_pessoas', acao: 'ver' };
return null;
});
</script>
{#if routeAction}
<ActionGuard recurso={routeAction.recurso} acao={routeAction.acao}>
<div class="flex flex-col">
<Sidebar>
<main id="container-central" class="w-full max-w-none px-3 py-4 lg:px-4">
{@render children()}
</main>
</ActionGuard>
{:else}
<main id="container-central" class="w-full max-w-none px-3 py-4 lg:px-4">
{@render children()}
</main>
{/if}
</Sidebar>
</div>
<!-- Toast Notifications (Sonner) -->
<Toaster position="top-right" richColors closeButton expand={true} />

View File

@@ -6,7 +6,6 @@
import { resolve } from '$app/paths';
import { UserPlus, Mail, Clock, Award, TrendingUp, Zap, Users, Database } from 'lucide-svelte';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import { loginModalStore } from '$lib/stores/loginModal.svelte';
// Queries para dados do dashboard
const statsQuery = useQuery(api.dashboard.getStats, {});
@@ -36,7 +35,12 @@
// Se for erro de autenticação, abrir modal de login automaticamente
if (error === 'auth_required') {
loginModalStore.open(route || to.url.pathname);
const redirectTo = route || to.url.pathname;
goto(`${resolve('/login')}?redirect=${encodeURIComponent(redirectTo)}`, {
replaceState: true,
noScroll: true
});
return;
}
// Limpar URL usando SvelteKit (após router estar inicializado)
@@ -65,7 +69,12 @@
const route = urlParams.get('route') || urlParams.get('redirect') || '';
if (error === 'auth_required') {
loginModalStore.open(route || window.location.pathname);
const redirectTo = route || window.location.pathname;
goto(`${resolve('/login')}?redirect=${encodeURIComponent(redirectTo)}`, {
replaceState: true,
noScroll: true
});
return;
}
}

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import Footer from '$lib/components/Footer.svelte';
import Header from '$lib/components/Header.svelte';
import { Toaster } from 'svelte-sonner';
let { children } = $props();
</script>
<div
class="bg-base-100 text-base-content selection:bg-primary selection:text-primary-content flex min-h-screen flex-col font-sans"
>
<Header />
<main class="w-full flex-1">
{@render children()}
</main>
<Footer />
<!-- Toast Notifications (Sonner) -->
<Toaster position="top-right" richColors closeButton expand={true} />
</div>

View File

@@ -0,0 +1,134 @@
<script lang="ts">
import { ArrowRight, CheckCircle2, ShieldCheck, Zap, Users, BarChart3 } from 'lucide-svelte';
import { resolve } from '$app/paths';
import logo from '$lib/assets/logo_governo_PE.png';
</script>
<svelte:head>
<title>Home - SGSE</title>
<meta name="description" content="Sistema de Gestão de Secretaria - Governo de Pernambuco" />
</svelte:head>
<div class="flex flex-col">
<!-- Hero Section -->
<section class="relative overflow-hidden bg-base-100 pt-16 pb-32 lg:pt-32 lg:pb-48">
<div class="absolute top-0 left-0 w-full h-full overflow-hidden z-0">
<div class="absolute -top-[30%] -right-[10%] w-[70%] h-[70%] rounded-full bg-primary/5 blur-[100px]"></div>
<div class="absolute bottom-[10%] -left-[10%] w-[50%] h-[50%] rounded-full bg-secondary/5 blur-[100px]"></div>
</div>
<div class="container mx-auto px-4 relative z-10">
<div class="max-w-4xl mx-auto text-center">
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary font-medium text-sm mb-8 animate-fade-in-up">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
Sistema de Gestão de Secretaria
</div>
<h1 class="text-5xl md:text-7xl font-extrabold tracking-tight mb-8 text-base-content leading-tight">
Simplificando a <span class="bg-clip-text text-transparent bg-gradient-to-r from-primary to-secondary">Gestão Pública</span>
</h1>
<p class="text-xl md:text-2xl text-base-content/70 mb-12 max-w-2xl mx-auto leading-relaxed">
Uma plataforma unificada para otimizar processos, conectar departamentos e garantir eficiência na administração pública.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href={resolve('/login')} class="btn btn-primary btn-lg gap-2 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all hover:-translate-y-1">
Acessar Sistema
<ArrowRight class="w-5 h-5" />
</a>
<a href="#recursos" class="btn btn-ghost btn-lg gap-2">
Conheça os Recursos
</a>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section id="recursos" class="py-24 bg-base-200/50">
<div class="container mx-auto px-4">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold mb-4">Recursos Principais</h2>
<p class="text-lg text-base-content/70 max-w-2xl mx-auto">
Ferramentas desenvolvidas especificamente para atender às necessidades da gestão secretaria.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each [
{
icon: Zap,
title: 'Agilidade nos Processos',
description: 'Automatize tarefas repetitivas e reduza o tempo de tramitação de documentos.'
},
{
icon: ShieldCheck,
title: 'Segurança de Dados',
description: 'Proteção avançada para garantir a integridade e confidencialidade das informações.'
},
{
icon: Users,
title: 'Gestão de Pessoas',
description: 'Ferramentas integradas para acompanhamento e desenvolvimento dos servidores.'
},
{
icon: BarChart3,
title: 'Relatórios Inteligentes',
description: 'Dashboards interativos para tomada de decisão baseada em dados reais.'
},
{
icon: CheckCircle2,
title: 'Controle de Ativos',
description: 'Rastreamento completo de bens e recursos da secretaria.'
},
{
icon: Users,
title: 'Colaboração em Tempo Real',
description: 'Conecte equipes e facilite a comunicação interna entre departamentos.'
}
] as feature}
<div class="card bg-base-100 shadow-sm hover:shadow-md transition-shadow duration-300 border border-base-200">
<div class="card-body">
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
<feature.icon class="w-6 h-6" />
</div>
<h3 class="card-title text-xl mb-2">{feature.title}</h3>
<p class="text-base-content/70">{feature.description}</p>
</div>
</div>
{/each}
</div>
</div>
</section>
<!-- Call to Action -->
<section class="py-24 relative overflow-hidden">
<div class="absolute inset-0 bg-primary/5"></div>
<div class="container mx-auto px-4 relative z-10">
<div class="bg-base-100 rounded-3xl p-8 md:p-16 text-center shadow-xl border border-base-200 max-w-5xl mx-auto">
<h2 class="text-3xl md:text-5xl font-bold mb-6">Pronto para começar?</h2>
<p class="text-xl text-base-content/70 mb-10 max-w-2xl mx-auto">
Acesse o portal e tenha todo o controle da secretaria na palma da sua mão.
</p>
<a href={resolve('/login')} class="btn btn-primary btn-lg shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all hover:-translate-y-1">
Fazer Login Agora
</a>
</div>
</div>
</section>
</div>
<style>
/* Custom animations if needed */
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out forwards;
}
</style>

View File

@@ -0,0 +1,24 @@
import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { redirect } from '@sveltejs/kit';
import type { FunctionReference } from 'convex/server';
export const load = async ({ locals, url }) => {
try {
const client = createConvexHttpClient({ token: locals.token });
const currentUser = await client.query(api.auth.getCurrentUser as FunctionReference<'query'>);
if (currentUser) {
const redirectTo = url.searchParams.get('redirect');
if (redirectTo && redirectTo.startsWith('/')) {
throw redirect(302, redirectTo);
}
throw redirect(302, '/');
}
} catch (error) {
// Se houver falha transitória na API/auth, ainda assim permitir renderizar a página de login.
console.error('Erro ao validar sessão na página de login:', error);
}
return {};
};

View File

@@ -0,0 +1,312 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient } from 'convex-svelte';
import type { FunctionReference } from 'convex/server';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import logo from '$lib/assets/logo_governo_PE.png';
import { authClient } from '$lib/auth';
import { obterIPPublico } from '$lib/utils/deviceInfo';
import { LogIn, XCircle } from 'lucide-svelte';
interface GPSLocation {
latitude?: number;
longitude?: number;
precisao?: number;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
}
let matricula = $state('');
let senha = $state('');
let erroLogin = $state('');
let carregandoLogin = $state(false);
const convexClient = useConvexClient();
const redirectAfterLogin = $derived.by(() => {
const redirectTo = page.url.searchParams.get('redirect');
return redirectTo && redirectTo.startsWith('/') ? redirectTo : '/';
});
async function handleLogin(e: Event) {
e.preventDefault();
erroLogin = '';
carregandoLogin = true;
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : undefined;
// Obter IP público com timeout curto (não bloquear login)
const ipPublicoPromise = obterIPPublico().catch(() => undefined);
const ipPublicoTimeout = new Promise<undefined>((resolve) =>
setTimeout(() => resolve(undefined), 2000)
);
const ipPublico = await Promise.race([ipPublicoPromise, ipPublicoTimeout]);
// Função para coletar GPS em background (não bloqueia login)
async function coletarGPS(): Promise<GPSLocation> {
try {
const { obterLocalizacaoRapida } = await import('$lib/utils/deviceInfo');
const gpsPromise = obterLocalizacaoRapida();
const gpsTimeout = new Promise<GPSLocation>((resolve) =>
setTimeout(() => resolve({}), 3000)
);
return await Promise.race([gpsPromise, gpsTimeout]);
} catch (err) {
console.warn('Erro ao obter GPS (não bloqueia login):', err);
return {};
}
}
const gpsPromise = coletarGPS();
const result = await authClient.signIn.email(
{ email: matricula.trim(), password: senha },
{
onError: async (ctx) => {
try {
let localizacaoGPS: GPSLocation = {};
try {
localizacaoGPS = await Promise.race([
gpsPromise,
new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
]);
} catch {
// ignorar
}
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
matriculaOuEmail: matricula.trim(),
sucesso: false,
motivoFalha: ctx.error?.message || 'Erro desconhecido',
userAgent,
ipAddress: ipPublico,
latitudeGPS: localizacaoGPS.latitude,
longitudeGPS: localizacaoGPS.longitude,
precisaoGPS: localizacaoGPS.precisao,
enderecoGPS: localizacaoGPS.endereco,
cidadeGPS: localizacaoGPS.cidade,
estadoGPS: localizacaoGPS.estado,
paisGPS: localizacaoGPS.pais
});
} catch (err) {
console.error('Erro ao registrar tentativa de login falha:', err);
}
erroLogin = ctx.error?.message || 'Erro ao fazer login';
}
}
);
if (result.data) {
// Registrar tentativa de login bem-sucedida sem bloquear o redirect
(async () => {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
let localizacaoGPS: GPSLocation = {};
try {
localizacaoGPS = await Promise.race([
gpsPromise,
new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
]);
} catch {
// ignorar
}
const usuario = (await convexClient.query(
api.auth.getCurrentUser as unknown as FunctionReference<'query'>,
{}
)) as { _id?: Id<'usuarios'> } | null;
if (usuario && usuario._id) {
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
usuarioId: usuario._id,
matriculaOuEmail: matricula.trim(),
sucesso: true,
userAgent,
ipAddress: ipPublico,
latitudeGPS: localizacaoGPS.latitude,
longitudeGPS: localizacaoGPS.longitude,
precisaoGPS: localizacaoGPS.precisao,
enderecoGPS: localizacaoGPS.endereco,
cidadeGPS: localizacaoGPS.cidade,
estadoGPS: localizacaoGPS.estado,
paisGPS: localizacaoGPS.pais
});
} else {
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
matriculaOuEmail: matricula.trim(),
sucesso: true,
userAgent,
ipAddress: ipPublico,
latitudeGPS: localizacaoGPS.latitude,
longitudeGPS: localizacaoGPS.longitude,
precisaoGPS: localizacaoGPS.precisao,
enderecoGPS: localizacaoGPS.endereco,
cidadeGPS: localizacaoGPS.cidade,
estadoGPS: localizacaoGPS.estado,
paisGPS: localizacaoGPS.pais
});
}
} catch (err) {
console.error('Erro ao registrar tentativa de login:', err);
}
})();
await goto(resolve(redirectAfterLogin as any), { replaceState: true });
} else {
erroLogin = result.error?.message || 'Erro ao fazer login';
}
carregandoLogin = false;
}
</script>
<main
class="relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-[#0f172a]"
>
<!-- Animated Background Elements -->
<div class="absolute inset-0 h-full w-full">
<div
class="bg-primary/20 absolute top-[-10%] left-[-10%] h-[40%] w-[40%] animate-pulse rounded-full blur-[120px]"
></div>
<div
class="bg-secondary/20 absolute right-[-10%] bottom-[-10%] h-[40%] w-[40%] animate-pulse rounded-full blur-[120px] delay-700"
></div>
</div>
<!-- Glass Card -->
<div class="relative z-10 w-full max-w-md p-6">
<div
class="relative overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-8 shadow-2xl ring-1 ring-white/10 backdrop-blur-xl transition-all duration-300"
>
<!-- Decorative Top Line -->
<div
class="via-primary absolute top-0 left-0 h-1 w-full bg-linear-to-r from-transparent to-transparent opacity-50"
></div>
<!-- Header -->
<div class="mb-10 text-center">
<div
class="mb-6 inline-flex items-center justify-center rounded-2xl bg-white/5 p-4 shadow-inner ring-1 ring-white/10"
>
<img src={logo} alt="Logo SGSE" class="h-12 w-auto object-contain" />
</div>
<h1 class="mb-2 font-sans text-3xl font-bold tracking-tight text-white">
Bem-vindo de volta
</h1>
<p class="text-sm font-medium text-gray-400">
Entre com suas credenciais para acessar o sistema
</p>
</div>
<!-- Error Message -->
{#if erroLogin}
<div
class="mb-6 flex items-center gap-3 rounded-lg border border-red-500/20 bg-red-500/10 p-4 text-red-200 backdrop-blur-md"
>
<XCircle class="h-5 w-5 shrink-0" />
<span class="text-sm font-medium">{erroLogin}</span>
</div>
{/if}
<!-- Form -->
<form class="space-y-6" onsubmit={handleLogin}>
<div class="space-y-2">
<label
for="login-matricula"
class="text-xs font-semibold tracking-wider text-gray-400 uppercase"
>
Matrícula ou E-mail
</label>
<div class="group relative">
<input
id="login-matricula"
type="text"
class="focus:border-primary/50 focus:ring-primary/20 w-full rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-white placeholder-gray-500 transition-all duration-300 focus:bg-black/40 focus:ring-2 focus:outline-none"
placeholder="Digite sua identificação"
bind:value={matricula}
required
disabled={carregandoLogin}
autocomplete="username"
/>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label
for="login-password"
class="text-xs font-semibold tracking-wider text-gray-400 uppercase"
>
Senha
</label>
<a
href={resolve('/esqueci-senha')}
class="text-primary hover:text-primary-focus text-xs font-medium transition-colors"
>
Esqueceu a senha?
</a>
</div>
<div class="group relative">
<input
id="login-password"
type="password"
class="focus:border-primary/50 focus:ring-primary/20 w-full rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-white placeholder-gray-500 transition-all duration-300 focus:bg-black/40 focus:ring-2 focus:outline-none"
placeholder="Digite sua senha"
bind:value={senha}
required
disabled={carregandoLogin}
autocomplete="current-password"
/>
</div>
</div>
<button
type="submit"
class="group bg-primary hover:bg-primary-focus hover:shadow-primary/25 relative w-full overflow-hidden rounded-xl px-4 py-3.5 text-sm font-bold text-white shadow-lg transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
disabled={carregandoLogin}
>
<div class="relative z-10 flex items-center justify-center gap-2">
{#if carregandoLogin}
<span
class="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white"
></span>
<span>Autenticando...</span>
{:else}
<span>Entrar no Sistema</span>
<LogIn class="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1" />
{/if}
</div>
<!-- Shine Effect -->
<div
class="absolute inset-0 -translate-x-full bg-linear-to-r from-transparent via-white/20 to-transparent transition-transform duration-1000 group-hover:translate-x-full"
></div>
</button>
</form>
<!-- Footer Links -->
<div class="mt-8 text-center">
<p class="text-sm text-gray-500">
Precisa de ajuda?
<a
href={resolve('/abrir-chamado')}
class="font-medium text-gray-300 decoration-1 transition-colors hover:text-white hover:underline"
>
Abrir um chamado
</a>
</p>
</div>
</div>
<!-- Footer Info -->
<div class="mt-8 text-center text-xs text-gray-600">
<p>© {new Date().getFullYear()} Governo de Pernambuco. Todos os direitos reservados.</p>
</div>
</div>
</main>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import '../app.css';
import Sidebar from '$lib/components/Sidebar.svelte';
import { createSvelteAuthClient } from '@mmailaender/convex-better-auth-svelte/svelte';
import { authClient } from '$lib/auth';
// Importar polyfill ANTES de qualquer outro código que possa usar Jitsi
@@ -50,8 +49,4 @@
});
</script>
<div>
<div class="flex">
<Sidebar>{@render children()}</Sidebar>
</div>
</div>
{@render children()}

View File

@@ -1,153 +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';
let newTodoText = $state('');
let isAdding = $state(false);
let addError = $state<Error | null>(null);
let togglingId = $state<Id<'todos'> | null>(null);
let toggleError = $state<Error | null>(null);
let deletingId = $state<Id<'todos'> | null>(null);
let deleteError = $state<Error | null>(null);
const client = useConvexClient();
const todosQuery = useQuery(api.todos.getAll, {});
async function handleAddTodo(event: SubmitEvent) {
event.preventDefault();
const text = newTodoText.trim();
if (!text || isAdding) return;
isAdding = true;
addError = null;
try {
await client.mutation(api.todos.create, { text });
newTodoText = '';
} catch (err) {
console.error('Failed to add todo:', err);
addError = err instanceof Error ? err : new Error(String(err));
} finally {
isAdding = false;
}
}
async function handleToggleTodo(id: Id<'todos'>, completed: boolean) {
if (togglingId === id || deletingId === id) return;
togglingId = id;
toggleError = null;
try {
await client.mutation(api.todos.toggle, { id, completed: !completed });
} catch (err) {
console.error('Failed to toggle todo:', err);
toggleError = err instanceof Error ? err : new Error(String(err));
} finally {
if (togglingId === id) {
togglingId = null;
}
}
}
async function handleDeleteTodo(id: Id<'todos'>) {
if (togglingId === id || deletingId === id) return;
deletingId = id;
deleteError = null;
try {
await client.mutation(api.todos.deleteTodo, { id });
} catch (err) {
console.error('Failed to delete todo:', err);
deleteError = err instanceof Error ? err : new Error(String(err));
} finally {
if (deletingId === id) {
deletingId = null;
}
}
}
const canAdd = $derived(!isAdding && newTodoText.trim().length > 0);
const isLoadingTodos = $derived(todosQuery.isLoading);
const todos = $derived(todosQuery.data ?? []);
const hasTodos = $derived(todos.length > 0);
</script>
<div class="p-4">
<h1 class="mb-4 text-xl">Todos (Convex)</h1>
<form onsubmit={handleAddTodo} class="mb-4 flex gap-2">
<input
type="text"
bind:value={newTodoText}
placeholder="New task..."
disabled={isAdding}
class="flex-grow p-1"
/>
<button
type="submit"
disabled={!canAdd}
class="rounded bg-blue-500 px-3 py-1 text-white disabled:opacity-50"
>
{#if isAdding}Adding...{:else}Add{/if}
</button>
</form>
{#if isLoadingTodos}
<p>Loading...</p>
{:else if !hasTodos}
<p>No todos yet.</p>
{:else}
<ul class="space-y-1">
{#each todos as todo (todo._id)}
{@const isTogglingThis = togglingId === todo._id}
{@const isDeletingThis = deletingId === todo._id}
{@const isDisabled = isTogglingThis || isDeletingThis}
<li class="flex items-center justify-between p-2" class:opacity-50={isDisabled}>
<div class="flex items-center gap-2">
<input
type="checkbox"
id={`todo-${todo._id}`}
checked={todo.completed}
onchange={() => handleToggleTodo(todo._id, todo.completed)}
disabled={isDisabled}
/>
<label for={`todo-${todo._id}`} class:line-through={todo.completed}>
{todo.text}
</label>
</div>
<button
type="button"
onclick={() => handleDeleteTodo(todo._id)}
disabled={isDisabled}
aria-label="Delete todo"
class="px-1 text-red-500 disabled:opacity-50"
>
{#if isDeletingThis}Deleting...{:else}X{/if}
</button>
</li>
{/each}
</ul>
{/if}
{#if todosQuery.error}
<p class="mt-4 text-red-500">
Error loading: {todosQuery.error?.message ?? 'Unknown error'}
</p>
{/if}
{#if addError}
<p class="mt-4 text-red-500">
Error adding: {addError.message ?? 'Unknown error'}
</p>
{/if}
{#if toggleError}
<p class="mt-4 text-red-500">
Error updating: {toggleError.message ?? 'Unknown error'}
</p>
{/if}
{#if deleteError}
<p class="mt-4 text-red-500">
Error deleting: {deleteError.message ?? 'Unknown error'}
</p>
{/if}
</div>