feat: Implement dedicated login page and public/dashboard layouts, refactoring authentication flow and removing the todos page.
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
apps/web/src/routes/(public)/+layout.svelte
Normal file
22
apps/web/src/routes/(public)/+layout.svelte
Normal 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>
|
||||
134
apps/web/src/routes/(public)/home/+page.svelte
Normal file
134
apps/web/src/routes/(public)/home/+page.svelte
Normal 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>
|
||||
24
apps/web/src/routes/(public)/login/+page.server.ts
Normal file
24
apps/web/src/routes/(public)/login/+page.server.ts
Normal 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 {};
|
||||
};
|
||||
312
apps/web/src/routes/(public)/login/+page.svelte
Normal file
312
apps/web/src/routes/(public)/login/+page.svelte
Normal 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>
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user