feat(auth): add user registration and response DTOs, implement registration endpoint in AuthService

feat(auth): create registration route and integrate with AuthService
feat(auth): implement user registration logic in AuthService with Keycloak integration
fix(config): add Keycloak admin credentials to settings
fix(middleware): update tenant middleware to include new auth routes
chore(docs): remove outdated testing guide and add project architecture documentation
feat(frontend): implement user registration page and integrate with API
feat(frontend): create login page with tenant selection and error handling
refactor(frontend): update layout to use custom auth store and improve loading states
This commit is contained in:
2025-10-19 21:05:07 -05:00
parent 58a2f0ade6
commit f460c2b8aa
16 changed files with 559 additions and 1090 deletions

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { initKeycloak, authStore } from '$lib/auth';
import { initAuth, authStore as customAuthStore } from '$lib/auth';
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
@@ -8,8 +8,8 @@
let initialized = $state(false);
onMount(async () => {
// Inicializar Keycloak al cargar la aplicación
await initKeycloak();
// Usar auth-custom que es instantáneo (sin peticiones de red)
await initAuth();
initialized = true;
});
</script>
@@ -18,7 +18,7 @@
<link rel="icon" href={favicon} />
</svelte:head>
{#if initialized && !$authStore.isLoading}
{#if initialized}
{@render children?.()}
{:else}
<div class="flex min-h-screen items-center justify-center bg-gray-50">

View File

@@ -1,16 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { isAuthenticated, currentUser, login, logout } from '$lib/auth';
import { isAuthenticated, currentUser, logout } from '$lib/auth';
import { api } from '$lib/api';
let licenseInfo: any = $state(null);
let loadingLicense = $state(false);
onMount(async () => {
// Si está autenticado, cargar información de licencia
// La autenticación ya se inicializó en +layout.svelte
// Solo cargar información de licencia si está autenticado
if ($isAuthenticated && $currentUser?.tenantId) {
await loadLicenseInfo();
loadLicenseInfo(); // Sin await - carga en background
}
});
@@ -24,7 +25,7 @@
}
async function handleLogin() {
await login();
goto('/login');
}
async function handleLogout() {
@@ -50,14 +51,14 @@
</div>
<button
onclick={handleLogout}
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-red-600"
>
Cerrar Sesión
</button>
{:else}
<button
onclick={handleLogin}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Iniciar Sesión
</button>
@@ -81,9 +82,15 @@
Ideal para maquilas, empresas IMMEX y agentes aduanales.
</p>
<div class="mt-10 flex items-center justify-center gap-x-6">
<a
href="/register"
class="rounded-md border-2 border-blue-600 px-6 py-3 text-base font-semibold text-blue-600 hover:bg-blue-50 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Registrarse
</a>
<button
onclick={handleLogin}
class="rounded-md bg-blue-600 px-6 py-3 text-base font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
class="rounded-md bg-blue-600 px-6 py-3 text-base font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Iniciar Sesión
</button>

View File

@@ -1,24 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/auth';
onMount(async () => {
// Esperar a que Keycloak procese el callback
await new Promise(resolve => setTimeout(resolve, 1000));
// Redirigir al home
if ($authStore.isAuthenticated) {
goto('/');
} else {
goto('/?error=auth_failed');
}
});
</script>
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="text-center">
<div class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
<p class="text-gray-600">Procesando autenticación...</p>
</div>
</div>

View File

@@ -0,0 +1,120 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { login } from '$lib/auth';
let username = $state('demo');
let password = $state('demo123');
let tenantSlug = $state('aduanasoft');
let error = $state('');
let loading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
loading = true;
const result = await login({
username,
password,
tenant_slug: tenantSlug
});
loading = false;
if (result.success) {
goto('/');
} else {
error = result.error || 'Error de autenticación';
}
}
</script>
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Anexo76
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Inicia sesión en tu cuenta
</p>
</div>
<form class="mt-8 space-y-6" onsubmit={handleSubmit}>
{#if error}
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
{error}
</h3>
</div>
</div>
</div>
{/if}
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="tenant-slug" class="sr-only">Tenant</label>
<input
id="tenant-slug"
name="tenant"
type="text"
required
bind:value={tenantSlug}
disabled={loading}
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Tenant (empresa)"
/>
</div>
<div>
<label for="username" class="sr-only">Usuario</label>
<input
id="username"
name="username"
type="text"
required
bind:value={username}
disabled={loading}
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Usuario"
/>
</div>
<div>
<label for="password" class="sr-only">Contraseña</label>
<input
id="password"
name="password"
type="password"
required
bind:value={password}
disabled={loading}
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Contraseña"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if loading}
Iniciando sesión...
{:else}
Iniciar sesión
{/if}
</button>
</div>
<div class="text-center">
<p class="text-xs text-gray-500">
Credenciales de prueba:<br />
Usuario: demo | Contraseña: demo123 | Tenant: aduanasoft
</p>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,237 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api';
let formData = $state({
username: '',
email: '',
password: '',
confirmPassword: '',
first_name: '',
last_name: '',
tenant_slug: 'aduanasoft' // Por defecto
});
let loading = $state(false);
let error = $state('');
let passwordError = $state('');
async function handleRegister(e: Event) {
e.preventDefault();
error = '';
passwordError = '';
// Validar que las contraseñas coincidan
if (formData.password !== formData.confirmPassword) {
passwordError = 'Las contraseñas no coinciden';
return;
}
// Validar longitud de contraseña
if (formData.password.length < 8) {
passwordError = 'La contraseña debe tener al menos 8 caracteres';
return;
}
loading = true;
try {
const response = await api.auth.register({
username: formData.username,
email: formData.email,
password: formData.password,
first_name: formData.first_name,
last_name: formData.last_name,
tenant_slug: formData.tenant_slug
});
if (response.error) {
throw new Error(response.error);
}
// Registro exitoso, redirigir al login
alert(`¡Registro exitoso! Bienvenido ${response.data.username}`);
goto('/login');
} catch (err: any) {
error = err.message || 'Error al registrar usuario';
} finally {
loading = false;
}
}
function handleCancel() {
goto('/');
}
</script>
<div class="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="mx-auto max-w-md">
<!-- Header -->
<div class="text-center">
<h2 class="text-3xl font-bold tracking-tight text-gray-900">Crear cuenta</h2>
<p class="mt-2 text-sm text-gray-600">
¿Ya tienes una cuenta?
<a href="/login" class="font-medium text-blue-600 hover:text-blue-500">
Inicia sesión
</a>
</p>
</div>
<!-- Formulario de registro -->
<div class="mt-8">
<div class="rounded-lg bg-white px-6 py-8 shadow">
<form class="space-y-6" onsubmit={handleRegister}>
<!-- Username -->
<div>
<label for="username" class="block text-sm font-medium text-gray-700">
Usuario
</label>
<input
type="text"
id="username"
bind:value={formData.username}
required
minlength="3"
maxlength="50"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
placeholder="usuario123"
/>
</div>
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
id="email"
bind:value={formData.email}
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
placeholder="usuario@ejemplo.com"
/>
</div>
<!-- Nombre -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700">
Nombre
</label>
<input
type="text"
id="first_name"
bind:value={formData.first_name}
required
minlength="2"
maxlength="50"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
placeholder="Juan"
/>
</div>
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700">
Apellido
</label>
<input
type="text"
id="last_name"
bind:value={formData.last_name}
required
minlength="2"
maxlength="50"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
placeholder="Pérez"
/>
</div>
</div>
<!-- Contraseña -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700">
Contraseña
</label>
<input
type="password"
id="password"
bind:value={formData.password}
required
minlength="8"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
placeholder="Mínimo 8 caracteres"
/>
</div>
<!-- Confirmar contraseña -->
<div>
<label for="confirmPassword" class="block text-sm font-medium text-gray-700">
Confirmar contraseña
</label>
<input
type="password"
id="confirmPassword"
bind:value={formData.confirmPassword}
required
minlength="8"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
placeholder="Repite tu contraseña"
/>
{#if passwordError}
<p class="mt-1 text-sm text-red-600">{passwordError}</p>
{/if}
</div>
<!-- Tenant -->
<div>
<label for="tenant_slug" class="block text-sm font-medium text-gray-700">
Empresa
</label>
<select
id="tenant_slug"
bind:value={formData.tenant_slug}
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
>
<option value="aduanasoft">AduanaSoft</option>
<!-- Agregar más tenants aquí -->
</select>
</div>
<!-- Errores -->
{#if error}
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{error}</p>
</div>
{/if}
<!-- Botones -->
<div class="flex gap-4">
<button
type="button"
onclick={handleCancel}
disabled={loading}
class="flex-1 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={loading}
class="flex-1 rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Registrando...' : 'Registrarse'}
</button>
</div>
</form>
<!-- Info adicional -->
<div class="mt-6 border-t border-gray-200 pt-6">
<p class="text-xs text-gray-500">
Al registrarte, aceptas nuestros términos de servicio y política de privacidad.
</p>
</div>
</div>
</div>
</div>
</div>