Compare commits
3 Commits
ba012254db
...
34864742d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34864742d8 | ||
|
|
1fe462764f | ||
|
|
07fc9a8fe3 |
@@ -147,52 +147,56 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> {
|
export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
const storedToken = await prisma.refreshToken.findUnique({
|
// Use a transaction to prevent race conditions
|
||||||
where: { token },
|
return await prisma.$transaction(async (tx) => {
|
||||||
});
|
const storedToken = await tx.refreshToken.findUnique({
|
||||||
|
where: { token },
|
||||||
|
});
|
||||||
|
|
||||||
if (!storedToken) {
|
if (!storedToken) {
|
||||||
throw new AppError(401, 'Token inválido');
|
throw new AppError(401, 'Token inválido');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storedToken.expiresAt < new Date()) {
|
if (storedToken.expiresAt < new Date()) {
|
||||||
await prisma.refreshToken.delete({ where: { id: storedToken.id } });
|
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
|
||||||
throw new AppError(401, 'Token expirado');
|
throw new AppError(401, 'Token expirado');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = verifyToken(token);
|
const payload = verifyToken(token);
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await tx.user.findUnique({
|
||||||
where: { id: payload.userId },
|
where: { id: payload.userId },
|
||||||
include: { tenant: true },
|
include: { tenant: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.active) {
|
if (!user || !user.active) {
|
||||||
throw new AppError(401, 'Usuario no encontrado o desactivado');
|
throw new AppError(401, 'Usuario no encontrado o desactivado');
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.refreshToken.delete({ where: { id: storedToken.id } });
|
// Use deleteMany to avoid error if already deleted (race condition)
|
||||||
|
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
|
||||||
|
|
||||||
const newTokenPayload = {
|
const newTokenPayload = {
|
||||||
userId: user.id,
|
|
||||||
email: user.email,
|
|
||||||
role: user.role,
|
|
||||||
tenantId: user.tenantId,
|
|
||||||
schemaName: user.tenant.schemaName,
|
|
||||||
};
|
|
||||||
|
|
||||||
const accessToken = generateAccessToken(newTokenPayload);
|
|
||||||
const refreshToken = generateRefreshToken(newTokenPayload);
|
|
||||||
|
|
||||||
await prisma.refreshToken.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
token: refreshToken,
|
email: user.email,
|
||||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
role: user.role,
|
||||||
},
|
tenantId: user.tenantId,
|
||||||
});
|
schemaName: user.tenant.schemaName,
|
||||||
|
};
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
const accessToken = generateAccessToken(newTokenPayload);
|
||||||
|
const refreshToken = generateRefreshToken(newTokenPayload);
|
||||||
|
|
||||||
|
await tx.refreshToken.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
token: refreshToken,
|
||||||
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(token: string): Promise<void> {
|
export async function logout(token: string): Promise<void> {
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export default function LoginPage() {
|
|||||||
const response = await login({ email, password });
|
const response = await login({ email, password });
|
||||||
setTokens(response.accessToken, response.refreshToken);
|
setTokens(response.accessToken, response.refreshToken);
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
router.push('/dashboard');
|
|
||||||
|
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
|
||||||
|
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||||
|
|
||||||
|
router.push(seen ? '/dashboard' : '/onboarding');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || 'Error al iniciar sesión');
|
setError(err.response?.data?.message || 'Error al iniciar sesión');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
5
apps/web/app/onboarding/page.tsx
Normal file
5
apps/web/app/onboarding/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import OnboardingScreen from "../../components/onboarding/OnboardingScreen";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <OnboardingScreen />;
|
||||||
|
}
|
||||||
170
apps/web/components/onboarding/OnboardingScreen.tsx
Normal file
170
apps/web/components/onboarding/OnboardingScreen.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding persistence key.
|
||||||
|
* If you later want this to come from env/config, move it to apps/web/config/onboarding.ts
|
||||||
|
*/
|
||||||
|
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
|
||||||
|
|
||||||
|
export default function OnboardingScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isNewUser, setIsNewUser] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const safePush = (path: string) => {
|
||||||
|
// Avoid multiple navigations if user clicks quickly.
|
||||||
|
if (loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
router.push(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||||
|
|
||||||
|
// If the user has already seen onboarding, go to dashboard automatically.
|
||||||
|
if (seen) {
|
||||||
|
setIsNewUser(false);
|
||||||
|
setLoading(true);
|
||||||
|
const t = setTimeout(() => router.push('/dashboard'), 900);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (typeof window !== 'undefined') localStorage.setItem(STORAGE_KEY, '1');
|
||||||
|
setLoading(true);
|
||||||
|
setTimeout(() => router.push('/dashboard'), 700);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (typeof window !== 'undefined') localStorage.removeItem(STORAGE_KEY);
|
||||||
|
location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerStatus = useMemo(() => (isNewUser ? 'Onboarding' : 'Redirección'), [isNewUser]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen relative overflow-hidden bg-white">
|
||||||
|
{/* Grid tech claro */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.05]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(to right, rgba(15,23,42,.2) 1px, transparent 1px), linear-gradient(to bottom, rgba(15,23,42,.2) 1px, transparent 1px)',
|
||||||
|
backgroundSize: '48px 48px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Glow global azul (sutil) */}
|
||||||
|
<div className="absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-blue-500/20 blur-3xl" />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex min-h-screen items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-4xl">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white shadow-xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-9 w-9 rounded-xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<p className="text-sm font-semibold text-slate-800">Horux360</p>
|
||||||
|
<p className="text-xs text-slate-500">Pantalla de inicio</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-xs text-slate-500">{headerStatus}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
{isNewUser ? (
|
||||||
|
<div className="grid gap-8 md:grid-cols-2 md:items-center">
|
||||||
|
{/* Left */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-semibold text-slate-900">
|
||||||
|
Bienvenido a Horux360
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm md:text-base text-slate-600 max-w-md">
|
||||||
|
Revisa este breve video para conocer el flujo. Después podrás continuar.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleContinue}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 disabled:opacity-60 disabled:cursor-not-allowed text-white px-6 py-3 rounded-xl font-semibold shadow-md hover:bg-blue-700 hover:shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
{loading ? 'Cargando…' : 'Continuar'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => safePush('/login')}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-5 py-3 rounded-xl font-medium text-slate-700 border border-slate-300 hover:bg-slate-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Ver más
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-xs text-slate-500">
|
||||||
|
Usuario nuevo: muestra video • Usuario recurrente: redirección automática
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right (video) - elegante sin glow */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="h-1 w-full rounded-t-2xl bg-gradient-to-r from-blue-600/80 via-blue-500/40 to-transparent" />
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50 overflow-hidden">
|
||||||
|
<video src="/video-intro.mp4" controls className="w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-blue-500" />
|
||||||
|
Video introductorio
|
||||||
|
</span>
|
||||||
|
<span>v1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 flex flex-col items-center justify-center text-center">
|
||||||
|
<div className="h-12 w-12 rounded-2xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-blue-500 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-5 text-lg font-semibold text-slate-800">
|
||||||
|
Redirigiendo al dashboard…
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">Usuario recurrente detectado.</p>
|
||||||
|
|
||||||
|
<div className="mt-6 w-full max-w-sm h-2 rounded-full bg-slate-200 overflow-hidden border border-slate-300">
|
||||||
|
<div className="h-full w-2/3 bg-blue-600/80 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="mt-6 text-xs text-slate-500 hover:text-slate-700 underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Ver video otra vez (reset demo)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-xs text-slate-400">
|
||||||
|
Demo UI sin backend • Persistencia local: localStorage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user