chore: baseline plantilla-proyectos como base del CRM

This commit is contained in:
Aduanasoft
2026-07-14 09:03:52 -06:00
commit c3d0eedc8d
469 changed files with 69739 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import { Toaster } from 'svelte-sonner';
import { page } from '$app/stores';
import { handleApiError } from '$lib/utils/error-handler';
import KeyboardManager from '$lib/components/keyboard/KeyboardManager.svelte';
import HelpDrawer from '$lib/components/help/HelpDrawer.svelte';
let { children } = $props();
// Detectar errores de CUALQUIER página (layout o page)
$effect(() => {
const pageData = $page.data as any;
if (pageData?.error) {
handleApiError(pageData.error);
}
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children?.()}
<Toaster richColors position="top-right" />
<KeyboardManager />
<HelpDrawer />
<!-- Hidden Global Search Input for Shortcuts -->
<input
id="global-search-input"
type="text"
class="sr-only"
placeholder="Global Search..."
onfocus={() => console.log('Global Search Focused')}
/>

View File

@@ -0,0 +1,40 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getAuthTokens, authenticatedFetch, clearAuthTokens } from '$lib/server/api';
export const load: PageServerLoad = async ({ cookies, fetch }) => {
const { accessToken } = getAuthTokens(cookies);
// Si hay token, validar que sea válido antes de redirigir
if (accessToken) {
try {
// Verificar si el token es válido usando authenticatedFetch
const response = await authenticatedFetch(
'v1/auth/me',
{},
cookies,
fetch
);
// Solo redirigir al dashboard si el token es válido
if (response.ok) {
throw redirect(303, '/dashboard');
} else {
// Token inválido, limpiar cookies y mostrar la página pública
clearAuthTokens(cookies);
}
} catch (error) {
// Si es un redirect, re-lanzarlo
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
}
// Para otros errores, limpiar cookies y continuar
clearAuthTokens(cookies);
}
}
// Si no está autenticado, mostrar la página principal pública
return {
isAuthenticated: false
};
};

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { LogIn, LayoutDashboard } from 'lucide-svelte';
</script>
<svelte:head>
<title>Mi Aplicación</title>
</svelte:head>
<div class="min-h-screen flex flex-col bg-background text-foreground">
<!-- NAV -->
<header class="border-b px-6 py-4 flex items-center justify-between">
<span class="font-bold text-lg tracking-tight">Mi Aplicación</span>
<a
href="/login"
class="inline-flex items-center gap-2 text-sm font-medium px-4 py-2 rounded-lg border hover:bg-accent transition-colors"
>
<LogIn class="h-4 w-4" />
Iniciar sesión
</a>
</header>
<!-- HERO -->
<main class="flex-1 flex flex-col items-center justify-center text-center px-6 py-24 gap-6">
<div class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-foreground border rounded-full px-4 py-1.5">
Plantilla base · Workspace SaaS
</div>
<h1 class="text-4xl sm:text-5xl font-extrabold tracking-tight max-w-2xl leading-tight">
Bienvenido a tu nueva aplicación
</h1>
<p class="text-muted-foreground text-lg max-w-lg leading-relaxed">
Esta es la landing page de la plantilla. Reemplaza este contenido con la
propuesta de valor de tu producto.
</p>
<div class="flex flex-wrap items-center justify-center gap-3 mt-2">
<a
href="/login"
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-primary text-primary-foreground font-semibold text-sm shadow hover:opacity-90 transition"
>
<LayoutDashboard class="h-4 w-4" />
Ir al dashboard
</a>
</div>
</main>
<!-- FOOTER -->
<footer class="border-t px-6 py-4 text-center text-xs text-muted-foreground">
Mi Aplicación · Construido sobre la plantilla Workspace
</footer>
</div>

View File

@@ -0,0 +1,62 @@
/**
* Endpoint server-side para el refresh silencioso del access token.
*
* Flujo de seguridad:
* 1. El cliente llama a POST /api-sveltekit/auth/silent-refresh con credentials:'include'
* (las cookies HttpOnly se envían automáticamente, sin que JS las lea).
* 2. Este servidor lee el refresh_token de la cookie HttpOnly.
* 3. Llama al backend FastAPI /v1/auth/refresh con el refresh_token.
* 4. Si es exitoso, actualiza las cookies HttpOnly con los nuevos tokens.
* 5. Retorna solo el access_token al cliente (el refresh_token permanece en HttpOnly).
*
* De este modo el refresh_token NUNCA toca el código JavaScript del cliente.
*/
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { getServerApiUrl, setAuthTokens } from '$lib/server/api';
import { clearAccessTokenCookies } from '$lib/server/access-token-cookie';
export const POST = async ({ cookies, fetch }: RequestEvent) => {
const refreshToken = cookies.get('refresh_token');
if (!refreshToken) {
return json({ error: 'No refresh token available' }, { status: 401 });
}
try {
const baseUrl = getServerApiUrl();
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (!response.ok) {
// El refresh token expiró o fue invalidado por Keycloak (sesión terminada).
// Limpiar las cookies para que el servidor redirigir al login en la siguiente carga.
cookies.delete('refresh_token', { path: '/' });
clearAccessTokenCookies(cookies);
cookies.delete('active_company_id', { path: '/' });
const status = response.status === 401 ? 401 : 400;
return json({ error: 'Refresh token expired or invalid' }, { status });
}
const data = (await response.json()) as {
access_token: string;
refresh_token?: string;
expires_in?: number;
};
// Actualizar las cookies HttpOnly con los nuevos tokens
setAuthTokens(cookies, data.access_token, data.refresh_token);
// Devolver solo el access_token al cliente
return json({ access_token: data.access_token });
} catch (error) {
console.error('[silent-refresh] Error inesperado:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -0,0 +1,98 @@
/**
* Endpoint server-side para cambiar de tenant sin exponer el refresh_token al cliente.
*
* Dos modos:
* - { tenant_id } → flujo SSO relay: solo actualiza cookie sso_tenant_id (override de tenant)
* - { tenant_slug } → flujo login clásico: re-emite tokens KC para el nuevo tenant
*/
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestEvent } from '@sveltejs/kit';
import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api';
export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
const body = await request.json();
const { tenant_id, tenant_slug } = body as { tenant_id?: number; tenant_slug?: string };
if (!tenant_id && !tenant_slug) {
return json({ error: 'tenant_id or tenant_slug is required' }, { status: 400 });
}
const { accessToken, refreshToken } = getAuthTokens(cookies);
if (!accessToken) {
return json({ error: 'Not authenticated' }, { status: 401 });
}
// Modo SSO relay: validar acceso vía Hub y actualizar cookie de override
if (tenant_id) {
try {
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (!tenantsRes.ok) {
return json({ error: 'Could not validate tenant access' }, { status: 403 });
}
const tenants: { id: number }[] = await tenantsRes.json();
const hasAccess = tenants.some((t) => t.id === tenant_id);
if (!hasAccess) {
return json({ error: 'Access denied to tenant' }, { status: 403 });
}
const { isSecureContext } = await import('$lib/server/workspace-auth');
const isProduction = isSecureContext();
cookies.set('sso_tenant_id', String(tenant_id), {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
cookies.set('sso_tenant_pub', String(tenant_id), {
path: '/',
httpOnly: false,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
cookies.delete('active_company_id', { path: '/' });
return json({ ok: true });
} catch (error) {
console.error('[switch-tenant] SSO mode error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}
// Modo login clásico: re-emitir tokens KC para el nuevo tenant
if (!refreshToken) {
return json({ error: 'Not authenticated' }, { status: 401 });
}
try {
const baseUrl = getServerApiUrl();
const response = await fetch(`${baseUrl}v1/auth/switch-tenant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
return json({ error: err.detail || 'Switch failed' }, { status: response.status });
}
const data = await response.json();
setAuthTokens(cookies, data.access_token, data.refresh_token);
cookies.delete('active_company_id', { path: '/' });
cookies.delete('sso_tenant_id', { path: '/' });
cookies.delete('sso_tenant_pub', { path: '/' });
return json({ ok: true });
} catch (error) {
console.error('[switch-tenant] Classic mode error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -0,0 +1,49 @@
/**
* API route proxy para obtener las compañías del usuario
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
export const GET: RequestHandler = async ({ cookies, fetch }) => {
const token = getAccessTokenFromCookies(cookies);
if (!token) {
// Limpiar cualquier cookie de compañía si no hay autenticación
cookies.delete('active_company_id', { path: '/' });
return json({ error: 'No authenticated' }, { status: 401 });
}
// Configurar la URL de la API
let apiUrl = process.env.INTERNAL_API_URL;
if (!apiUrl) {
apiUrl = process.env.VITE_API_URL;
// Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR)
apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend');
}
// Normalizar la URL
const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`;
try {
const response = await fetch(`${baseUrl}v1/auth/my-companies`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
// Si la autenticación falló, limpiar la cookie de compañía
if (response.status === 401) {
cookies.delete('active_company_id', { path: '/' });
}
return json({ error: 'Failed to fetch companies' }, { status: response.status });
}
const companies = await response.json();
return json(companies);
} catch (error) {
console.error('Error fetching companies:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -0,0 +1,29 @@
/**
* API route para establecer la compañía activa en una cookie
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies, request }) => {
try {
const { companyId } = await request.json();
if (!companyId || typeof companyId !== 'number') {
return json({ error: 'Invalid company ID' }, { status: 400 });
}
// Establecer la cookie desde el servidor
cookies.set('active_company_id', companyId.toString(), {
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 días
sameSite: 'lax',
httpOnly: false, // Permitir acceso desde JavaScript
secure: process.env.NODE_ENV === 'production'
});
return json({ success: true, companyId });
} catch (error) {
console.error('Error setting active company:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -0,0 +1,46 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
import {
extractAllowedSystemsFromToken,
fetchAllowedSystems,
isValidSystem,
mergeTokenClaims,
setActiveSystemCookie
} from '$lib/server/system-gate';
export const POST: RequestHandler = async ({ cookies, request }) => {
try {
const { system } = await request.json();
if (!isValidSystem(system)) {
return json({ error: 'Sistema inválido' }, { status: 400 });
}
const accessToken = getAccessTokenFromCookies(cookies);
if (!accessToken) {
return json({ error: 'No autenticado' }, { status: 401 });
}
const tokenClaims = mergeTokenClaims(null, accessToken);
let allowedSystems = extractAllowedSystemsFromToken(tokenClaims);
if (allowedSystems.length === 0) {
const rawCompanyId = cookies.get('active_company_id');
const companyId = rawCompanyId ? Number.parseInt(rawCompanyId, 10) : NaN;
if (Number.isFinite(companyId)) {
allowedSystems = await fetchAllowedSystems(cookies, fetch, companyId);
}
}
if (!allowedSystems.includes(system)) {
return json({ error: 'No tienes acceso a ese sistema' }, { status: 403 });
}
setActiveSystemCookie(cookies, system);
return json({ success: true, system });
} catch {
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -0,0 +1,132 @@
import { redirect, isRedirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
import {
clearWorkspaceReturnPath,
getWorkspaceLoginUrl,
readWorkspaceReturnPath,
storeReturnPath,
} from '$lib/server/workspace-auth';
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
// Obtener el código y state de los query params
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const errorParam = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
if (errorParam) {
console.error('❌ [Callback Server] KC auth error:', errorParam, errorDescription);
// login_required means no KC session exists yet → send to Workspace login.
// Preserve the intended destination through the detour so /login can pick it up.
if (state) {
try {
const stateObj = JSON.parse(state);
const returnPath = stateObj.redirect_url;
if (returnPath && returnPath.startsWith('/') && returnPath !== '/login') {
storeReturnPath(cookies, returnPath);
}
} catch { /* ignore malformed state */ }
}
throw redirect(303, getWorkspaceLoginUrl(url.origin));
}
if (!code) {
console.error('❌ [Callback Server] No se recibió código de autorización');
throw redirect(303, getWorkspaceLoginUrl(url.origin));
}
try {
// Intercambiar código por tokens usando el backend de Keycloak
// En el servidor (SSR), usar KEYCLOAK_URL que apunta a http://keycloak:8080
// En producción o fuera de Docker, usar VITE_KEYCLOAK_URL como fallback
const KEYCLOAK_URL = process.env.KEYCLOAK_URL || process.env.VITE_KEYCLOAK_URL || 'http://localhost:8080';
const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || process.env.VITE_KEYCLOAK_REALM || 'master';
const KEYCLOAK_CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || process.env.VITE_KEYCLOAK_CLIENT_ID || 'app-backend';
const KEYCLOAK_CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || '';
// La redirect_uri debe coincidir exactamente con la registrada en Keycloak.
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost porque
// ORIGIN env var apunta a localhost en producción (usa SITE_URL como fallback).
const { resolveSystemBaseUrl } = await import('$lib/server/workspace-auth');
const redirectUri = `${resolveSystemBaseUrl(url.origin)}/auth/callback`;
const tokenEndpoint = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`;
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: redirectUri,
client_id: KEYCLOAK_CLIENT_ID,
...(KEYCLOAK_CLIENT_SECRET && { client_secret: KEYCLOAK_CLIENT_SECRET })
});
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
});
if (!tokenResponse.ok) {
const errorData = await tokenResponse.text();
console.error('❌ [Callback Server] Error al intercambiar código:', errorData);
throw new Error('Error al obtener tokens');
}
const tokens = await tokenResponse.json();
// Establecer las cookies en el servidor
// access_token → NO HttpOnly (el cliente JS lo usa para el header Authorization)
// refresh_token → HttpOnly (el JS nunca lo lee; el servidor lo gestiona)
const { isSecureContext } = await import('$lib/server/workspace-auth');
const isProduction = isSecureContext();
setAccessTokenCookies(cookies, tokens.access_token, {
secure: isProduction,
maxAge: 60 * 60 * 24 * 7 // 7 días
});
if (tokens.refresh_token) {
cookies.set('refresh_token', tokens.refresh_token, {
path: '/',
httpOnly: true, // *** HttpOnly: nunca expuesto a JS ***
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30 // 30 días
});
}
if (tokens.id_token) {
cookies.set('id_token', tokens.id_token, {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7
});
}
// Obtener la URL de redirección del state o ir al dashboard
let redirectTo = readWorkspaceReturnPath(cookies, '/dashboard');
if (state) {
try {
const stateObj = JSON.parse(state);
redirectTo = stateObj.redirect_url || '/dashboard';
} catch (e) {
console.warn('⚠️ [Callback Server] No se pudo obtener redirect_url del state');
}
}
clearWorkspaceReturnPath(cookies);
// Redirigir a la página de destino
throw redirect(303, redirectTo);
} catch (err: any) {
if (isRedirect(err)) throw err;
console.error('❌ [Callback Server] Error procesando autenticación:', err);
throw redirect(303, getWorkspaceLoginUrl(url.origin));
}
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
</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 class="text-center">
<h2 class="text-3xl font-bold text-gray-900 mb-4">
Procesando autenticación...
</h2>
<div class="flex justify-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
<p class="mt-4 text-gray-600">
Espera un momento mientras completamos tu inicio de sesión...
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getWorkspaceLoginUrl } from '$lib/server/workspace-auth';
/**
* KC redirects here after completing the logout flow.
* This URL is covered by the app's registered wildcard in KC (e.g. mi-app.dominio.com/*).
* We then send the user to workspace login so it can apply myApps() launcher logic.
*/
export const GET: RequestHandler = async ({ request, url }) => {
const systemBaseUrl = url.origin;
throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true }));
};

View File

@@ -0,0 +1,212 @@
/**
* SSO auto-login page for Mi Aplicación.
* The Hub App Launcher redirects here with ?relay=<token> after generating a relay token.
* This server-side load function exchanges the relay token for KC tokens via the
* Hub backend, sets HttpOnly cookies, and redirects to /dashboard.
*/
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth';
import { isValidSystem, setActiveSystemCookie } from '$lib/server/system-gate';
// Disable client-side rendering to prevent SvelteKit from making a second
// __data.json request that would consume the one-time relay token twice.
export const csr = false;
export const load: PageServerLoad = async ({ url, cookies }) => {
const relayToken = url.searchParams.get('relay');
const requestedSystem = url.searchParams.get('active_system');
console.log('[SSO] relay token presente:', !!relayToken, '| active_system:', requestedSystem ?? '(none)');
if (!relayToken) {
redirectToWorkspaceLogin(cookies, url);
}
// Limpiar sesión anterior para que el nuevo usuario reciba sus propias cookies.
// No se omite el exchange aunque haya token existente — la sesión podría ser
// de otro usuario (ej: juan que hace logout e ingresa como lal17).
// La única excepción es si el relay token ya fue consumido (lo maneja el error handler).
{
const { clearAccessTokenCookies } = await import('$lib/server/access-token-cookie');
clearAccessTokenCookies(cookies);
cookies.delete('refresh_token', { path: '/' });
cookies.delete('id_token', { path: '/' });
cookies.delete('active_company_id', { path: '/' });
}
// SSO exchange must call the Hub that GENERATED the relay token.
// This fetch runs server-side (inside the Docker container), so we must use
// INTERNAL_HUB_URL (host.docker.internal) when available — "localhost" inside
// a container never reaches the host where the workspace Hub is running.
const hubUrl = (
process.env.INTERNAL_HUB_URL ||
process.env.HUB_URL ||
process.env.VITE_HUB_URL ||
'http://localhost:8001'
).replace(/\/+$/, '');
const baseUrl = `${hubUrl}/`;
let response: Response;
try {
response = await fetch(`${baseUrl}api/v1/auth/sso-exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relay_token: relayToken }),
});
} catch (err) {
redirectToWorkspaceLogin(cookies, url);
}
if (!response.ok) {
const body = await response.json().catch(() => ({}));
const detail: string = body?.detail || 'sso_exchange_failed';
console.error('[SSO] exchange falló:', response.status, detail);
// If the token is "invalid/used", a concurrent request may have already
// succeeded and set cookies. Redirect to /dashboard — if the session is
// valid it will load; if not, the dashboard layout will redirect to /login.
const tokenAlreadyUsed =
detail.toLowerCase().includes('inválido') ||
detail.toLowerCase().includes('invalido') ||
detail.toLowerCase().includes('invalid') ||
detail.toLowerCase().includes('used') ||
detail.toLowerCase().includes('expired');
if (tokenAlreadyUsed) {
throw redirect(303, '/dashboard');
}
redirectToWorkspaceLogin(cookies, url);
}
let tokens: Record<string, unknown>;
try {
tokens = await response.json();
} catch (err) {
console.error('[SSO] exchange devolvió body no-JSON (status 200):', err);
redirectToWorkspaceLogin(cookies, url);
}
if (!tokens.access_token || typeof tokens.access_token !== 'string') {
console.error('[SSO] exchange exitoso pero access_token faltante o inválido:', tokens);
redirectToWorkspaceLogin(cookies, url);
}
console.log('[SSO] exchange exitoso, tokens recibidos:', {
hasAccessToken: !!tokens.access_token,
accessTokenLen: (tokens.access_token as string).length,
hasRefreshToken: !!tokens.refresh_token,
tenant_id: tokens.tenant_id,
tenant_slug: tokens.tenant_slug,
});
// ── Refresh proactivo ────────────────────────────────────────────────────
// Los tokens del relay fueron emitidos por KC via el browser (iss=IP:8085).
// El Hub backend valida contra KC interno (hub-keycloak:8080) → issuer mismatch → 401.
// Refrescando aquí: Mi Aplicación backend → Hub → KC interno → iss=hub-keycloak:8080 → válido.
if (typeof tokens.refresh_token === 'string') {
try {
const internalApiUrl = (
process.env.INTERNAL_API_URL ||
process.env.VITE_API_URL ||
'http://backend:8000/api/'
).replace(/\/+$/, '');
const refreshRes = await fetch(`${internalApiUrl}/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: tokens.refresh_token }),
});
if (refreshRes.ok) {
const refreshed = await refreshRes.json().catch(() => ({}));
if (refreshed.access_token && refreshed.refresh_token) {
tokens = { ...tokens, ...refreshed };
console.log('[SSO] tokens refrescados exitosamente (iss normalizado)');
}
} else {
console.warn('[SSO] refresh proactivo falló (status', refreshRes.status, ') — usando tokens originales del relay');
}
} catch (err) {
console.warn('[SSO] refresh proactivo error (non-blocking):', err);
}
}
const { isSecureContext } = await import('$lib/server/workspace-auth');
const isProduction = isSecureContext();
console.log('[SSO] ORIGIN-based secure context:', isProduction);
// access_token — NO HttpOnly (Bearer desde JS); fragmentado si el JWT supera ~4KB
setAccessTokenCookies(cookies, tokens.access_token as string, {
secure: isProduction,
maxAge: 60 * 60 * 24 * 7,
});
// refresh_token — HttpOnly (never exposed to JS)
if (typeof tokens.refresh_token === 'string') {
cookies.set('refresh_token', tokens.refresh_token, {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
});
}
// id_token — requerido para id_token_hint en el logout de Keycloak.
// Puede venir del refresh proactivo o del exchange original.
if (typeof tokens.id_token === 'string') {
cookies.set('id_token', tokens.id_token, {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
}
// sso_tenant_id — HttpOnly cookie con el tenant seleccionado.
// El backend lo pasa como X-Tenant-Override en Hub /auth/me para que
// devuelva el tenant correcto aunque el KC token tenga otro tenant baked in.
if (typeof tokens.tenant_id === 'number') {
cookies.set('sso_tenant_id', String(tokens.tenant_id), {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
// sso_tenant_pub — companion no-HttpOnly para que el cliente JS pueda
// leer el tenant override e incluirlo como X-Tenant-Override en fetch directo al backend.
// No es un secreto (solo un ID numérico; Hub valida UserTenant en cada request).
cookies.set('sso_tenant_pub', String(tokens.tenant_id), {
path: '/',
httpOnly: false,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
}
console.log('[SSO] cookies configuradas, preparando redirect a /dashboard con active_system:', requestedSystem ?? '(none)');
// Ejecutar lazy-link server-side: crear UserTenant si hay invite pendiente.
// Se llama con el Bearer token recién obtenido. Best-effort, no bloquea el SSO.
try {
const internalApiUrl = (
process.env.INTERNAL_API_URL ||
process.env.VITE_API_URL ||
'http://backend:8000/api/'
).replace(/\/+$/, '');
await fetch(`${internalApiUrl}/v1/auth/lazy-link`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${tokens.access_token as string}`,
'Content-Type': 'application/json',
},
}).catch(() => {});
} catch { /* non-blocking */ }
if (isValidSystem(requestedSystem)) {
setActiveSystemCookie(cookies, requestedSystem);
}
throw redirect(303, '/dashboard');
};

View File

@@ -0,0 +1,11 @@
<script lang="ts">
// This page is never rendered — the server-side load always redirects.
// It exists only to satisfy SvelteKit's file-based routing requirement.
</script>
<div class="flex min-h-screen items-center justify-center">
<div class="flex flex-col items-center gap-4 text-center">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
<p class="text-sm text-slate-500">Iniciando sesión automáticamente…</p>
</div>
</div>

View File

@@ -0,0 +1,73 @@
import { env } from '$env/dynamic/private';
import type { LayoutServerLoad } from './$types';
import {
validateAuth,
getAuthTokens,
getUserCompanies,
clearAuthTokens
} from '$lib/server/api';
import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth';
import { resolveActiveCompanyId } from '$lib/server/system-gate';
import { fetchMyApps } from '$lib/server/workspace-apps';
const DEV_LOCAL_AUTH = (env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true';
export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) {
redirectToWorkspaceLogin(cookies, url);
}
try {
// my-companies primero: ejecuta get_current_user y provisiona tenant/usuario si aplica.
const companies = await getUserCompanies(cookies, fetch);
const userData = await validateAuth(cookies, fetch, undefined);
const activeCompanyId = resolveActiveCompanyId(cookies, companies);
// Modo local: no hay Hub. Saltar fetch de tenants/apps (evita timeouts por navegación).
let userTenants: { id: number; name: string; slug: string }[] = [];
let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null };
if (!DEV_LOCAL_AUTH) {
try {
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const tenantOverride = cookies.get('sso_tenant_id');
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {})
}
});
if (tenantsRes.ok) {
userTenants = await tenantsRes.json();
}
} catch {
// No bloquear el dashboard si falla la carga de tenants
}
const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken;
myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id'));
}
return {
authenticated: true,
user: { ...userData, token: accessToken },
companies,
activeCompanyId: activeCompanyId ?? undefined,
userTenants,
workspaceApps: myApps.apps,
appRouting: myApps.routing,
error: undefined
};
} catch (error) {
// Si es un redirect, re-lanzarlo sin tocar las cookies
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
}
// Cualquier otro error: limpiar token y redirigir al login
clearAuthTokens(cookies);
redirectToWorkspaceLogin(cookies, url);
}
};

View File

@@ -0,0 +1,220 @@
<script lang="ts">
import { setContext, onMount, onDestroy } from 'svelte';
import { invalidateAll, goto } from '$app/navigation';
import type { LayoutData } from './$types';
import AppSidebar from '$lib/components/sidebar/app-sidebar.svelte';
import AppLauncher from '$lib/components/sidebar/app-launcher.svelte';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { companyStore } from '$lib/stores/company.svelte';
import SessionTimeoutWarning from '$lib/components/session-timeout-warning.svelte';
import { page } from '$app/state';
import { browser } from '$app/environment';
import {
createSessionManager,
destroySessionManager,
SESSION_EXPIRED_EVENT
} from '$lib/session-manager';
import type { SessionExpiredDetail } from '$lib/session-manager';
import { authStore, loadSidebarPermsCache, markPermissionsHydrated, logout, getKeycloakInstance } from '$lib/auth';
import { get } from 'svelte/store';
import LicenseErrorScreen from '$lib/components/license-error-screen.svelte';
import { systemStore, type SystemType } from '$lib/stores/system.svelte';
import { workspaceAppsStore } from '$lib/stores/workspace-apps.svelte';
type LicenseError = { type: string; message: string; status: number };
let { data, children }: { data: LayoutData & { licenseError?: LicenseError }; children: any } = $props();
// Hacer disponible el usuario en el contexto para los componentes hijos.
// El sidebar ya lee de page.data directamente; este contexto lo usan otros componentes.
setContext('user', data.user);
setContext('userTenants', data.userTenants ?? []);
// Actualizar el contexto reactive al cambiar data.user (post invalidateAll)
$effect(() => { setContext('user', data.user); });
// ── Sembrar el authStore desde data.user ───────────────────────────────
// Se ejecuta SÍNCRONAMENTE en el script (SSR + hidratación), no en onMount,
// para que `$currentUser` ya esté poblado en el primer render de las páginas
// hijas. Evita el flash de "Acceso restringido" al refrescar cuando las páginas
// derivan `canView` a partir de `$currentUser`.
function syncAuthStoreFromData() {
if (!data.user) return;
if (data.user.token) {
authStore.setToken(data.user.token);
authStore.setAuthenticated(true);
}
const resolvedAvatarUrl =
data.user.workspaceAvatarUrl ||
data.user.workspace_avatar_url ||
data.user.avatarUrl ||
data.user.avatar_url ||
data.user.legacyAvatarUrl ||
data.user.legacy_avatar_url ||
null;
authStore.setUser({
id: data.user.sub ?? data.user.id ?? '',
username: data.user.preferred_username ?? data.user.username ?? '',
email: data.user.email,
name: data.user.name,
avatarUrl: resolvedAvatarUrl,
workspaceAvatarUrl: data.user.workspaceAvatarUrl ?? data.user.workspace_avatar_url ?? null,
legacyAvatarUrl: data.user.legacyAvatarUrl ?? data.user.legacy_avatar_url ?? data.user.avatar_url ?? null,
tenantId: data.user.tenant_id,
roles: data.user.roles ?? [],
permissions: data.user.permissions ?? [],
allowedSystems: data.allowedSystems ?? data.user.allowedSystems ?? []
});
}
syncAuthStoreFromData();
systemStore.initialize(
(data.allowedSystems ?? []) as SystemType[],
(data.activeSystem ?? null) as string | null
);
workspaceAppsStore.initialize(data.workspaceApps ?? [], data.appRouting ?? null);
// Pre-popular permisos desde localStorage en el frame síncrono, antes del
// primer render, para que el sidebar muestre los ítems completos sin parpadeo.
// Esto cubre la segunda visita y tabs nuevas; la primera visita no tiene cache.
if (browser) {
const _preUserId = data.user?.sub ?? (data.user as any)?.id ?? null;
const _preCidRaw = (() => { try { return localStorage.getItem('activeCompanyId'); } catch { return null; } })();
const _preCid = _preCidRaw !== null ? parseInt(_preCidRaw) : NaN;
if (_preUserId && Number.isFinite(_preCid)) {
const _cached = loadSidebarPermsCache(_preUserId, _preCid);
if (_cached) {
const _u = get(authStore).user;
if (_u) {
authStore.setUser({
..._u,
permissions: _cached.permissions,
roles: Array.from(new Set([...(_u.roles ?? []), ..._cached.roles])),
allowedSystems: _cached.allowedSystems.length > 0
? (_cached.allowedSystems as SystemType[])
: _u.allowedSystems,
tenantId: _cached.tenantId ?? _u.tenantId
});
markPermissionsHydrated();
}
}
}
}
// NOTA: el $effect que re-corría syncAuthStoreFromData() en cada cambio de
// data.user fue eliminado porque destruía los permisos del store en cada
// navegación. El SSR no es fuente de verdad para `permissions`/`roles`
// (los carga el cliente vía syncCompanyPermissions en companyStore), así
// que reaplicar `data.user.permissions ?? []` los reseteaba a `[]`.
//
// Ahora: la sincronización síncrona de arriba cubre la hidratación inicial
// (SSR + refresh). El refresco ante un cambio de compañía se hace de forma
// explícita en `handleCompanyChange` más abajo, donde sí cambian sistemas
// y apps. La identidad del usuario no cambia entre rutas ni entre
// compañías, así que no requiere refresco reactivo.
// ── Manejar expiración de sesión ────────────────────────────────────────
function handleSessionExpired(e: Event) {
const { reason } = (e as CustomEvent<SessionExpiredDetail>).detail;
console.info(`[Dashboard] Sesión expirada (motivo: ${reason}) — cerrando sesión`);
void logout();
}
onMount(() => {
// ── Inicializar el SessionManager ─────────────────────────────────────
if (data.user?.token) {
const mgr = createSessionManager({
refreshBeforeExpirySeconds: 60, // Refrescar 60s antes de que expire
idleTimeoutMs: 30 * 60 * 1000, // Idle timeout: 30 minutos
warningBeforeIdleMs: 5 * 60 * 1000, // Advertencia: 5 min antes del idle
ssoCheckIntervalMs: 5 * 60 * 1000, // Verificar sesión SSO cada 5 min
getKeycloakInstance,
onTokenRefreshed: (newToken) => {
authStore.setToken(newToken);
},
onSessionExpired: (reason) => {
void logout();
}
});
mgr.start(data.user.token);
}
// ── Escuchar evento global de expiración de sesión ────────────────────
window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
// ── Inicializar compañías ─────────────────────────────────────────────
if (data.companies) {
const activeCompanyId = (data as { activeCompanyId?: number }).activeCompanyId;
void (async () => {
await companyStore.initialize(data.companies, activeCompanyId);
// Releer en cliente: el SSR puede haber quedado desfasado respecto al provisionamiento.
await companyStore.loadCompanies(undefined, activeCompanyId);
// Garantiza que el flag se levante incluso si no hay compañía activa
// (en ese caso `syncCompanyPermissions` nunca corre).
markPermissionsHydrated();
})();
} else {
markPermissionsHydrated();
}
// ── Escuchar cambios de compañía y recargar datos ─────────────────────
const handleCompanyChange = async () => {
await invalidateAll();
// Re-poblar sistemas y apps con la nueva data tras invalidateAll.
// Antes lo hacía el $effect que reaccionaba a data.user, ahora se
// hace de forma explícita solo en el cambio de compañía (única vez
// que esos datos realmente cambian). No tocamos authStore aquí:
// identidad no cambia entre compañías, y los permisos los maneja
// syncCompanyPermissions que se dispara dentro de setActiveCompany.
systemStore.initialize(
(data.allowedSystems ?? []) as SystemType[],
(data.activeSystem ?? null) as string | null
);
workspaceAppsStore.initialize(data.workspaceApps ?? [], data.appRouting ?? null);
await goto(`${page.url.pathname}${page.url.search}${page.url.hash}`, { invalidateAll: true });
};
window.addEventListener('companyChanged', handleCompanyChange);
return () => {
window.removeEventListener('companyChanged', handleCompanyChange);
window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
};
});
onDestroy(() => {
destroySessionManager();
});
</script>
{#if data.licenseError}
<LicenseErrorScreen error={data.licenseError} />
{:else}
<Sidebar.Provider>
<AppSidebar />
<Sidebar.Inset class="overflow-x-hidden">
<header
class="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"
>
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1" />
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
</div>
{#if workspaceAppsStore.hasApps}
<div class="ml-auto px-4">
<AppLauncher />
</div>
{/if}
</header>
<div
id="dashboard-main-content"
class="flex flex-1 flex-col min-h-0 gap-4 overflow-y-auto overflow-x-hidden p-4 pt-0"
>
{@render children?.()}
</div>
</Sidebar.Inset>
</Sidebar.Provider>
<SessionTimeoutWarning />
{/if}

View File

@@ -0,0 +1,16 @@
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async ({ data }) => {
// Pasar los datos del servidor al cliente
return {
user: data.user,
companies: data.companies,
authenticated: data.authenticated,
userTenants: data.userTenants ?? [],
activeCompanyId: data.activeCompanyId,
activeSystem: data.activeSystem ?? null,
allowedSystems: data.allowedSystems ?? [],
workspaceApps: data.workspaceApps ?? [],
appRouting: data.appRouting ?? ''
};
};

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { companyStore } from '$lib/stores/company.svelte';
import { LayoutDashboard, Building2, User } from 'lucide-svelte';
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold tracking-tight text-foreground flex items-center gap-2">
<LayoutDashboard class="h-6 w-6" />
Dashboard
</h1>
<p class="text-muted-foreground text-sm mt-1">
Plantilla base — agrega tus módulos aquí.
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Compañía activa -->
<div class="rounded-xl border bg-card p-5 flex items-start gap-4">
<div class="p-2 rounded-lg bg-primary/10 text-primary">
<Building2 class="h-5 w-5" />
</div>
<div>
<p class="text-xs text-muted-foreground font-medium uppercase tracking-wide">Compañía activa</p>
<p class="text-lg font-semibold mt-0.5">
{companyStore.activeCompany?.name ?? '—'}
</p>
</div>
</div>
<!-- Placeholder módulo 1 -->
<a
href="/dashboard/account"
class="rounded-xl border bg-card p-5 flex items-start gap-4 hover:bg-accent transition-colors group"
>
<div class="p-2 rounded-lg bg-muted group-hover:bg-primary/10 transition-colors">
<User class="h-5 w-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div>
<p class="text-sm font-medium">Mi cuenta</p>
<p class="text-xs text-muted-foreground mt-0.5">Perfil y configuración</p>
</div>
</a>
</div>
</div>

View File

@@ -0,0 +1,6 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const { user } = await parent();
return { user };
};

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { User } from 'lucide-svelte';
import * as Card from '$lib/components/ui/card';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2">
<User class="h-6 w-6" />
Mi cuenta
</h1>
<p class="text-muted-foreground text-sm mt-1">Perfil y configuración personal.</p>
</div>
<Card.Root class="max-w-lg">
<Card.Header>
<Card.Title>Perfil</Card.Title>
</Card.Header>
<Card.Content class="space-y-2 text-sm">
{#if data.user}
<div><span class="text-muted-foreground">Nombre:</span> {data.user.name ?? data.user.preferred_username ?? '—'}</div>
<div><span class="text-muted-foreground">Email:</span> {data.user.email ?? '—'}</div>
{:else}
<p class="text-muted-foreground">No hay datos de usuario disponibles.</p>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,5 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {};
};

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { Shield } from 'lucide-svelte';
import * as Card from '$lib/components/ui/card';
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2">
<Shield class="h-6 w-6" />
Roles y permisos
</h1>
<p class="text-muted-foreground text-sm mt-1">
Gestión de roles y control de acceso.
</p>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Roles del sistema</Card.Title>
<Card.Description>Implementa aquí la gestión de roles y permisos de tu proyecto.</Card.Description>
</Card.Header>
<Card.Content>
<p class="text-sm text-muted-foreground">Sección en construcción.</p>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { Settings2 } from 'lucide-svelte';
import * as Card from '$lib/components/ui/card';
</script>
<svelte:head>
<title>Configuración General</title>
</svelte:head>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2">
<Settings2 class="h-6 w-6" />
Configuración General
</h1>
<p class="text-muted-foreground text-sm mt-1">
Ajustes globales de la aplicación.
</p>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Configuración del sistema</Card.Title>
<Card.Description>Agrega aquí los ajustes de configuración de tu proyecto.</Card.Description>
</Card.Header>
<Card.Content>
<p class="text-sm text-muted-foreground">Sección en construcción.</p>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1 @@
export const ssr = false;

View File

@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = () => {
throw redirect(303, '/dashboard');
};

View File

@@ -0,0 +1,5 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {};
};

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { Users } from 'lucide-svelte';
import * as Card from '$lib/components/ui/card';
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2">
<Users class="h-6 w-6" />
Usuarios
</h1>
<p class="text-muted-foreground text-sm mt-1">Gestión de usuarios y accesos.</p>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Usuarios del sistema</Card.Title>
<Card.Description>Implementa aquí la gestión de usuarios de tu proyecto.</Card.Description>
</Card.Header>
<Card.Content>
<p class="text-sm text-muted-foreground">Sección en construcción.</p>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1 @@
<a href="/demo/paraglide">paraglide</a>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { setLocale } from '$lib/paraglide/runtime';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { m } from '$lib/i18n/messages';
</script>
<h1>{m.hello_world({ name: 'SvelteKit User' })}</h1>
<div>
<button onclick={() => setLocale('en')}>en</button>
<button onclick={() => setLocale('es')}>es</button>
</div><p>
If you use VSCode, install the <a href="https://marketplace.visualstudio.com/items?itemName=inlang.vs-code-extension" target="_blank">Sherlock i18n extension</a> for a better i18n experience.
</p>

View File

@@ -0,0 +1,85 @@
import { redirect, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { getServerApiUrl, getAuthTokens } from '$lib/server/api';
import { redirectToKeycloakLogin } from '$lib/server/workspace-auth';
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
const code = url.searchParams.get('code')?.toUpperCase().trim() ?? '';
const step = url.searchParams.get('step') ?? '';
// Paso 3: usuario volvió de Keycloak, consumir el código
if (code && step === 'consume') {
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) {
// Sesión KC expiró entre redirecciones — volver a auth
redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`);
}
const apiUrl = getServerApiUrl();
// Consumir el código
const consumeRes = await fetch(`${apiUrl}v1/core/invite-codes/consume/${code}`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!consumeRes.ok) {
const body = await consumeRes.json().catch(() => ({}));
return {
step: 'preview',
code,
codeInfo: null,
error: body?.detail ?? 'No se pudo canjear el código. Intenta de nuevo.'
};
}
const result = await consumeRes.json();
return { step: 'success', code, result, error: null, codeInfo: null };
}
// Paso 2: hay código en la URL (viene de validar), mostrar preview
if (code) {
const apiUrl = getServerApiUrl();
const validateRes = await fetch(`${apiUrl}v1/core/invite-codes/validate/${code}`);
if (!validateRes.ok) {
return { step: 'input', code: '', error: 'Código inválido, expirado o agotado.', codeInfo: null };
}
const codeInfo = await validateRes.json();
return { step: 'preview', code, codeInfo, error: null };
}
return { step: 'input', code: '', error: null, codeInfo: null };
};
export const actions: Actions = {
// Valida el código y redirige a la URL con ?code=XXX para el preview
validate: async ({ request }) => {
const data = await request.formData();
const code = (data.get('code') as string ?? '').toUpperCase().trim();
if (!code) return fail(422, { error: 'Ingresa un código de invitación.' });
redirect(303, `/join?code=${code}`);
},
// Inicia el join: si hay sesión, consume; si no, va a KC login
join: async ({ request, cookies, url, fetch }) => {
const data = await request.formData();
const code = (data.get('code') as string ?? '').toUpperCase().trim();
if (!code) return fail(422, { error: 'Código inválido.' });
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) {
// Redirigir a Keycloak; al volver, el callback irá a /join?code=XXX&step=consume
redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`);
}
// Si ya hay sesión, consumir directamente vía redirect a step=consume
redirect(303, `/join?code=${code}&step=consume`);
}
};

View File

@@ -0,0 +1,149 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
const error = $derived(form?.error ?? data.error);
</script>
<div class="flex min-h-screen items-center justify-center bg-background px-4">
<div class="w-full max-w-sm">
<!-- Logo / marca -->
<div class="mb-8 flex flex-col items-center gap-3 text-center">
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg">
<svg class="h-7 w-7" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Mi Aplicación</p>
<h1 class="text-xl font-bold">Unirse a una empresa</h1>
<p class="mt-1 text-sm text-muted-foreground">Ingresa el código que te compartió tu administrador.</p>
</div>
</div>
<!-- PASO: INPUT DEL CÓDIGO -->
{#if data.step === 'input'}
<form method="POST" action="?/validate" use:enhance class="space-y-4">
<div class="space-y-2">
<input
name="code"
type="text"
value={data.code}
placeholder="Ej: HD96V79A"
maxlength="16"
autocomplete="off"
spellcheck="false"
class="w-full rounded-xl border bg-card px-4 py-3 text-center font-mono text-xl font-bold uppercase tracking-widest shadow-sm outline-none ring-primary transition focus:ring-2 {error ? 'border-destructive' : 'border-border'}"
/>
{#if error}
<p class="text-center text-sm text-destructive">{error}</p>
{/if}
</div>
<button
type="submit"
class="w-full rounded-xl bg-primary py-3 text-sm font-semibold text-primary-foreground shadow transition hover:bg-primary/90 active:scale-[0.98]"
>
Verificar código →
</button>
</form>
<!-- PASO: PREVIEW — datos del código -->
{:else if data.step === 'preview' && data.codeInfo}
<div class="space-y-4">
<!-- tarjeta con info del código -->
<div class="rounded-xl border bg-card p-4 shadow-sm space-y-3">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground uppercase tracking-wide">Código</span>
<code class="font-mono font-bold tracking-widest">{data.code}</code>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground uppercase tracking-wide">Workspace</span>
<span class="font-medium">{data.codeInfo.tenant_slug}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground uppercase tracking-wide">Rol asignado</span>
<span class="inline-flex items-center rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-semibold text-primary">
{data.codeInfo.role}
</span>
</div>
{#if data.codeInfo.remaining_uses !== null}
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground uppercase tracking-wide">Usos restantes</span>
<span class="text-sm font-medium">{data.codeInfo.remaining_uses}</span>
</div>
{/if}
{#if data.codeInfo.expires_at}
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground uppercase tracking-wide">Vence</span>
<span class="text-sm">
{new Date(data.codeInfo.expires_at).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
</div>
{/if}
</div>
{#if error}
<p class="rounded-lg bg-destructive/10 px-4 py-2 text-center text-sm text-destructive border border-destructive/20">
{error}
</p>
{/if}
<form method="POST" action="?/join" use:enhance class="space-y-2">
<input type="hidden" name="code" value={data.code} />
<button
type="submit"
class="w-full rounded-xl bg-primary py-3 text-sm font-semibold text-primary-foreground shadow transition hover:bg-primary/90 active:scale-[0.98]"
>
Unirse a la empresa →
</button>
</form>
<a
href="/join"
class="block text-center text-sm text-muted-foreground hover:underline"
>
Usar otro código
</a>
</div>
<!-- PASO: PREVIEW sin codeInfo — error de validación -->
{:else if data.step === 'preview'}
<div class="space-y-4 text-center">
<p class="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive border border-destructive/20">
{error ?? 'Código inválido, expirado o agotado. Verifica con tu administrador.'}
</p>
<a href="/join" class="block text-sm text-primary hover:underline">← Intentar con otro código</a>
</div>
<!-- PASO: ÉXITO -->
{:else if data.step === 'success'}
<div class="space-y-5 text-center">
<div class="flex justify-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 text-green-600">
<svg class="h-8 w-8" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<div>
<h2 class="text-lg font-bold">¡Bienvenido!</h2>
<p class="mt-1 text-sm text-muted-foreground">
Te uniste al workspace <strong>{data.result?.tenant_slug}</strong>
{#if data.result?.company_id}
como <strong>{data.result?.role}</strong>.
{/if}
</p>
</div>
<a
href="/dashboard"
class="block w-full rounded-xl bg-primary py-3 text-sm font-semibold text-primary-foreground shadow transition hover:bg-primary/90"
>
Ir al dashboard →
</a>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,117 @@
import { redirect, fail, isRedirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { Actions, PageServerLoad } from './$types';
import { clearAuthTokens, getAuthTokens } from '$lib/server/api';
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
import {
getWorkspaceLoginUrl,
readWorkspaceReturnPath,
clearWorkspaceReturnPath,
storeReturnPath,
redirectToKeycloakAuthorization,
redirectToKeycloakLogin,
getHubBackendUrl,
isSecureContext,
} from '$lib/server/workspace-auth';
const DEV_LOCAL_AUTH = (env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true';
export const load: PageServerLoad = async ({ cookies, url }) => {
// Modo local: mostrar el form de login sin redirigir al workspace
if (DEV_LOCAL_AUTH) {
// Solo limpiar si ya hay token (re-login explícito), no en cada carga
const { accessToken } = getAuthTokens(cookies);
if (accessToken) clearAuthTokens(cookies);
return { devMode: true };
}
// Relay SSO: el Hub App Launcher redirige aquí con ?relay=UUID4.
const relayToken = url.searchParams.get('relay');
if (relayToken) {
try {
const hubBackendUrl = getHubBackendUrl();
const exchangeRes = await fetch(`${hubBackendUrl}/api/v1/auth/sso-exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relay_token: relayToken })
});
if (exchangeRes.ok) {
const data = await exchangeRes.json();
const isProduction = isSecureContext();
setAccessTokenCookies(cookies, data.access_token, {
secure: isProduction,
maxAge: 60 * 60 * 24 * 7
});
if (data.refresh_token) {
cookies.set('refresh_token', data.refresh_token, {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30
});
}
const redirectTo =
url.searchParams.get('redirect') ||
readWorkspaceReturnPath(cookies, '/dashboard');
clearWorkspaceReturnPath(cookies);
throw redirect(303, redirectTo);
}
} catch (err) {
if (isRedirect(err)) throw err;
}
}
clearAuthTokens(cookies);
if (url.searchParams.get('sso_verified') === '1') {
const existingReturnPath = readWorkspaceReturnPath(cookies, '');
const intendedPath =
existingReturnPath && existingReturnPath !== '/login'
? existingReturnPath
: (url.searchParams.get('redirect') || '/dashboard');
storeReturnPath(cookies, intendedPath);
redirectToKeycloakAuthorization(url.origin, intendedPath);
}
const redirectParam = url.searchParams.get('redirect');
if (redirectParam) {
const existingReturnPath = readWorkspaceReturnPath(cookies, '');
const intendedPath =
existingReturnPath && existingReturnPath !== '/login'
? existingReturnPath
: redirectParam;
storeReturnPath(cookies, intendedPath);
redirectToKeycloakLogin(url.origin, intendedPath);
}
storeReturnPath(cookies, '/dashboard');
throw redirect(303, getWorkspaceLoginUrl(url.origin));
};
export const actions: Actions = {
dev_login: async ({ cookies, request }) => {
if (!DEV_LOCAL_AUTH) throw redirect(303, '/login');
const BACKEND_URL = env.BACKEND_URL || 'http://backend:8000';
const res = await fetch(`${BACKEND_URL}/api/v1/auth/dev-login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!res.ok) {
return fail(500, { error: 'No se pudo generar el token local. Verifica que DEV_LOCAL_AUTH=true en el backend.' });
}
const { access_token } = await res.json();
setAccessTokenCookies(cookies, access_token, {
secure: false,
maxAge: 60 * 60 * 8
});
throw redirect(303, '/dashboard');
}
};

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import type { PageData } from './$types';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { LogIn, Terminal } from 'lucide-svelte';
let { data }: { data: PageData } = $props();
</script>
{#if data.devMode}
<div class="min-h-screen flex items-center justify-center bg-muted/40 p-4">
<Card.Root class="w-full max-w-sm">
<Card.Header class="text-center pb-2">
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Terminal class="h-6 w-6 text-primary" />
</div>
<Card.Title class="text-xl">Modo desarrollo</Card.Title>
<Card.Description>
Login local activo — sin Keycloak ni Hub.
</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<form method="POST" action="?/dev_login">
<Button type="submit" class="w-full">
<LogIn class="mr-2 h-4 w-4" />
Entrar como dev
</Button>
</form>
<p class="text-xs text-center text-muted-foreground">
Para usar el workspace, quita <code class="font-mono">DEV_LOCAL_AUTH=true</code> del entorno.
</p>
</Card.Content>
</Card.Root>
</div>
{/if}

View File

@@ -0,0 +1,37 @@
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
import { clearAccessTokenCookies } from '$lib/server/access-token-cookie';
import {
buildKeycloakLogoutUrl,
clearWorkspaceReturnPath,
getWorkspaceLoginUrl
} from '$lib/server/workspace-auth';
export const POST: RequestHandler = async ({ cookies, url }) => {
const systemBaseUrl = url.origin;
const idToken = cookies.get('id_token');
// Eliminar todas las cookies de autenticación
clearAccessTokenCookies(cookies);
cookies.delete('refresh_token', { path: '/' });
cookies.delete('id_token', { path: '/' });
cookies.delete('active_company_id', { path: '/' });
cookies.delete('active_system', { path: '/' });
cookies.delete('sso_tenant_id', { path: '/' });
cookies.delete('sso_tenant_pub', { path: '/' });
clearWorkspaceReturnPath(cookies);
// En modo local no hay Keycloak ni Hub — ir directo al login local.
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
throw redirect(303, '/login');
}
// Sin id_token_hint KC rechaza post_logout_redirect_uri no registrado.
if (!idToken) {
throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true }));
}
throw redirect(303, buildKeycloakLogoutUrl(systemBaseUrl, idToken));
};

View File

@@ -0,0 +1,13 @@
import { page } from '@vitest/browser/context';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render h1', async () => {
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,404 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { onMount } from 'svelte';
// Parámetros del URL — se rellenan desde el link de invitación
let inviteToken = $state('');
let inviteTenantSlug = $state('');
let inviteEmail = $state('');
let isInviteFlow = $state(false);
// Estado del check previo
let checkLoading = $state(false);
let checkDone = $state(false);
let userExists = $state(false);
let checkError = $state('');
onMount(async () => {
const params = new URL(window.location.href).searchParams;
inviteToken = params.get('invite_token') ?? '';
inviteTenantSlug = params.get('tenant') ?? '';
inviteEmail = params.get('email') ?? '';
isInviteFlow = Boolean(inviteToken && inviteTenantSlug);
if (isInviteFlow) {
formData.tenant_slug = inviteTenantSlug;
if (inviteEmail) formData.email = inviteEmail;
// Verificar si el usuario ya existe y validar el token
await checkInvite();
}
});
async function checkInvite() {
checkLoading = true;
checkError = '';
try {
const qs = new URLSearchParams({
invite_token: inviteToken,
tenant_slug: inviteTenantSlug,
email: inviteEmail,
});
const resp = await api.get<{
email: string;
role: string;
user_exists: boolean;
username?: string;
first_name?: string;
last_name?: string;
}>(`/v1/auth/register/check?${qs}`);
if (resp.error) {
checkError = resp.error || 'Token de invitación inválido o expirado';
return;
}
const data = resp.data!;
userExists = data.user_exists ?? false;
if (data.first_name) formData.first_name = data.first_name;
if (data.last_name) formData.last_name = data.last_name;
if (data.username) formData.username = data.username;
checkDone = true;
} catch {
checkError = 'No se pudo validar la invitación. Verifica el enlace.';
} finally {
checkLoading = false;
}
}
let formData = $state({
username: '',
email: '',
password: '',
confirmPassword: '',
first_name: '',
last_name: '',
tenant_slug: 'aduanasoft'
});
let loading = $state(false);
let error = $state('');
let passwordError = $state('');
let success = $state(false);
async function handleRegister(e: Event) {
e.preventDefault();
error = '';
passwordError = '';
if (!userExists) {
if (formData.password !== formData.confirmPassword) {
passwordError = 'Las contraseñas no coinciden';
return;
}
if (formData.password.length < 8) {
passwordError = 'La contraseña debe tener al menos 8 caracteres';
return;
}
}
loading = true;
try {
const payload: Record<string, string> = {
username: formData.username,
email: formData.email,
password: formData.password || 'placeholder',
first_name: formData.first_name,
last_name: formData.last_name,
tenant_slug: formData.tenant_slug,
};
if (inviteToken) {
payload.invite_token = inviteToken;
}
const response = await api.auth.register(payload as any);
if (response.error) {
throw new Error(response.error);
}
success = true;
setTimeout(() => {
goto(`/login?tenant=${encodeURIComponent(formData.tenant_slug)}`);
}, 3000);
} 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>
<!-- Banner de invitación -->
{#if isInviteFlow}
<div class="mt-4 rounded-md bg-blue-50 border border-blue-200 p-4">
<div class="flex">
<svg class="h-5 w-5 text-blue-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<div>
<p class="text-sm font-medium text-blue-800">Invitación válida</p>
<p class="text-sm text-blue-700 mt-1">
Estás registrándote en <strong>{inviteTenantSlug}</strong>.
El enlace caduca en 48 horas y es de un solo uso.
</p>
</div>
</div>
</div>
{/if}
<!-- Verificando token -->
{#if isInviteFlow && checkLoading}
<div class="mt-8 rounded-lg bg-white px-6 py-8 shadow text-center">
<p class="text-gray-600">Verificando invitación...</p>
</div>
<!-- Token inválido -->
{:else if isInviteFlow && checkError}
<div class="mt-8 rounded-lg bg-white px-6 py-8 shadow">
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm font-medium text-red-800">Invitación inválida</p>
<p class="text-sm text-red-700 mt-1">{checkError}</p>
</div>
<a href="/" class="mt-4 inline-block text-sm text-blue-600 hover:underline">
Volver al inicio
</a>
</div>
<!-- Pantalla de éxito -->
{:else if success}
<div class="mt-8 rounded-lg bg-white px-6 py-8 shadow text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900">¡Cuenta creada!</h3>
<p class="mt-2 text-sm text-gray-600">
{#if userExists}
Tu cuenta ha sido vinculada a <strong>{inviteTenantSlug}</strong>.
{:else}
Te hemos enviado un correo de verificación a <strong>{formData.email}</strong>.
Confirma tu email antes de iniciar sesión.
{/if}
</p>
<p class="mt-1 text-xs text-gray-500">Redirigiendo al login...</p>
<a
href="/login"
class="mt-6 inline-block rounded-md bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
Ir al login
</a>
</div>
<!-- Formulario (solo si token válido o no es flujo de invite) -->
{:else if !isInviteFlow || (isInviteFlow && checkDone)}
<!-- 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}>
<!-- Si ya existe: solo confirmar nombre -->
{#if userExists}
<div class="rounded-md bg-amber-50 border border-amber-200 p-4">
<p class="text-sm text-amber-800">
Ya tienes una cuenta en el sistema. Al confirmar quedarás vinculado a
<strong>{inviteTenantSlug}</strong>.
</p>
</div>
{/if}
<!-- Username -->
{#if !userExists}
<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"
pattern="[a-z0-9.\-]+"
title="Solo letras minúsculas, números, puntos o guiones"
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"
/>
<p class="mt-1 text-xs text-gray-500">Solo letras minúsculas, números, puntos y guiones.</p>
</div>
{/if}
<!-- 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
readonly={isInviteFlow && Boolean(inviteEmail)}
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 {isInviteFlow && inviteEmail ? 'bg-gray-50 text-gray-500 cursor-not-allowed' : ''}"
placeholder="usuario@ejemplo.com"
/>
{#if isInviteFlow && inviteEmail}
<p class="mt-1 text-xs text-gray-500">El email está fijado por la invitación.</p>
{/if}
</div>
<!-- Nombre y Apellido -->
{#if !userExists}
<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>
{/if}
<!-- Tenant -->
<div>
<label for="tenant_slug" class="block text-sm font-medium text-gray-700">
Empresa
</label>
{#if isInviteFlow}
<input
type="text"
id="tenant_slug"
value={formData.tenant_slug}
readonly
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm bg-gray-50 text-gray-500 cursor-not-allowed"
/>
<p class="mt-1 text-xs text-gray-500">Fijado por la invitación.</p>
{:else}
<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>
</select>
{/if}
</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 ? (userExists ? 'Vinculando...' : 'Registrando...') : (userExists ? 'Confirmar y unirme' : '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>
{/if}
</div>
</div>