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

@@ -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>