chore: baseline plantilla-proyectos como base del CRM
This commit is contained in:
37
frontend/src/routes/+layout.svelte
Normal file
37
frontend/src/routes/+layout.svelte
Normal 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')}
|
||||
/>
|
||||
40
frontend/src/routes/+page.server.ts
Normal file
40
frontend/src/routes/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
54
frontend/src/routes/+page.svelte
Normal file
54
frontend/src/routes/+page.svelte
Normal 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>
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
132
frontend/src/routes/auth/callback/+page.server.ts
Normal file
132
frontend/src/routes/auth/callback/+page.server.ts
Normal 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));
|
||||
}
|
||||
};
|
||||
20
frontend/src/routes/auth/callback/+page.svelte
Normal file
20
frontend/src/routes/auth/callback/+page.svelte
Normal 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>
|
||||
13
frontend/src/routes/auth/post-logout/+server.ts
Normal file
13
frontend/src/routes/auth/post-logout/+server.ts
Normal 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 }));
|
||||
};
|
||||
212
frontend/src/routes/auth/sso/+page.server.ts
Normal file
212
frontend/src/routes/auth/sso/+page.server.ts
Normal 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');
|
||||
};
|
||||
11
frontend/src/routes/auth/sso/+page.svelte
Normal file
11
frontend/src/routes/auth/sso/+page.svelte
Normal 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>
|
||||
73
frontend/src/routes/dashboard/+layout.server.ts
Normal file
73
frontend/src/routes/dashboard/+layout.server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
220
frontend/src/routes/dashboard/+layout.svelte
Normal file
220
frontend/src/routes/dashboard/+layout.svelte
Normal 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}
|
||||
16
frontend/src/routes/dashboard/+layout.ts
Normal file
16
frontend/src/routes/dashboard/+layout.ts
Normal 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 ?? ''
|
||||
};
|
||||
};
|
||||
45
frontend/src/routes/dashboard/+page.svelte
Normal file
45
frontend/src/routes/dashboard/+page.svelte
Normal 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>
|
||||
6
frontend/src/routes/dashboard/account/+page.server.ts
Normal file
6
frontend/src/routes/dashboard/account/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
const { user } = await parent();
|
||||
return { user };
|
||||
};
|
||||
30
frontend/src/routes/dashboard/account/+page.svelte
Normal file
30
frontend/src/routes/dashboard/account/+page.svelte
Normal 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>
|
||||
5
frontend/src/routes/dashboard/roles/+page.server.ts
Normal file
5
frontend/src/routes/dashboard/roles/+page.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {};
|
||||
};
|
||||
26
frontend/src/routes/dashboard/roles/+page.svelte
Normal file
26
frontend/src/routes/dashboard/roles/+page.svelte
Normal 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>
|
||||
30
frontend/src/routes/dashboard/settings/general/+page.svelte
Normal file
30
frontend/src/routes/dashboard/settings/general/+page.svelte
Normal 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>
|
||||
1
frontend/src/routes/dashboard/settings/general/+page.ts
Normal file
1
frontend/src/routes/dashboard/settings/general/+page.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
6
frontend/src/routes/dashboard/tasks/+page.ts
Normal file
6
frontend/src/routes/dashboard/tasks/+page.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = () => {
|
||||
throw redirect(303, '/dashboard');
|
||||
};
|
||||
5
frontend/src/routes/dashboard/users/+page.server.ts
Normal file
5
frontend/src/routes/dashboard/users/+page.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {};
|
||||
};
|
||||
23
frontend/src/routes/dashboard/users/+page.svelte
Normal file
23
frontend/src/routes/dashboard/users/+page.svelte
Normal 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>
|
||||
1
frontend/src/routes/demo/+page.svelte
Normal file
1
frontend/src/routes/demo/+page.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<a href="/demo/paraglide">paraglide</a>
|
||||
16
frontend/src/routes/demo/paraglide/+page.svelte
Normal file
16
frontend/src/routes/demo/paraglide/+page.svelte
Normal 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>
|
||||
85
frontend/src/routes/join/+page.server.ts
Normal file
85
frontend/src/routes/join/+page.server.ts
Normal 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`);
|
||||
}
|
||||
};
|
||||
149
frontend/src/routes/join/+page.svelte
Normal file
149
frontend/src/routes/join/+page.svelte
Normal 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>
|
||||
117
frontend/src/routes/login/+page.server.ts
Normal file
117
frontend/src/routes/login/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
37
frontend/src/routes/login/+page.svelte
Normal file
37
frontend/src/routes/login/+page.svelte
Normal 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}
|
||||
37
frontend/src/routes/logout/+server.ts
Normal file
37
frontend/src/routes/logout/+server.ts
Normal 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));
|
||||
};
|
||||
13
frontend/src/routes/page.svelte.spec.ts
Normal file
13
frontend/src/routes/page.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
404
frontend/src/routes/register/+page.svelte
Normal file
404
frontend/src/routes/register/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user