panels-origin/web-panel/pages/login.vue
2026-08-25 20:21:49 -05:00

829 lines
18 KiB
Vue

<template>
<div class="panel-login" :class="{ 'is-dark': isDark, 'is-theme-switching': isThemeSwitching }">
<section class="mosaic">
<div class="mosaic-grid" aria-hidden="true">
<svg
v-for="(tone, i) in mosaicCells"
:key="i"
class="mosaic-motif"
:class="tone ? `is-${tone}` : undefined"
viewBox="0 0 164 164"
:style="{ transitionDelay: `${(i % 5) * 35}ms` }"
>
<!-- Mismas proporciones que el logo: celda 76, hueco 12, rx 18 -->
<rect class="petal" x="0" y="0" width="76" height="76" rx="18" />
<rect class="petal" x="88" y="0" width="76" height="76" rx="18" />
<rect class="petal" x="0" y="88" width="76" height="76" rx="18" />
<rect class="petal" x="88" y="88" width="76" height="76" rx="18" />
</svg>
</div>
<div class="mosaic-brand">
<div class="mosaic-brand-mark">
<img src="/brand/panel-icon-symmetric.svg" alt="" width="40" height="40" draggable="false">
<span>PANELS</span>
</div>
<p class="mosaic-brand-expand">
<span>P</span>lataforma de
<span>A</span>dministración de
<span>N</span>egocios,
<span>E</span>jecución,
<span>L</span>ogística y
<span>S</span>eguimiento
</p>
</div>
<div class="mosaic-copy">
<h2>Control total de<br>tus proyectos.</h2>
<p>Obra, personal, nómina y presupuestos<br>en un solo lugar.</p>
</div>
</section>
<section class="panel">
<div class="theme-toggle" role="group" aria-label="Tema">
<button type="button" :class="{ active: !isDark }" aria-label="Tema claro" @click="setDark(false)">
<i class="pi pi-sun" />
</button>
<button type="button" :class="{ active: isDark }" aria-label="Tema oscuro" @click="setDark(true)">
<i class="pi pi-moon" />
</button>
</div>
<form class="form" @submit.prevent="onSubmit">
<header class="form-head">
<h1>Bienvenido de nuevo</h1>
<p>Ingresa tus datos para continuar</p>
</header>
<label class="field">
<span>Usuario</span>
<span class="control">
<i class="pi pi-user" />
<input
v-model="username"
type="text"
name="username"
autocomplete="username"
spellcheck="false"
placeholder="Ingresa tu usuario"
>
</span>
</label>
<label class="field">
<span>Contraseña</span>
<span class="control">
<i class="pi pi-lock" />
<input
v-model="password"
:type="showPassword ? 'text' : 'password'"
name="password"
autocomplete="off"
placeholder="Ingresa tu contraseña"
>
<button
type="button"
class="toggle-pass"
:aria-label="showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'pi pi-eye-slash' : 'pi pi-eye'" />
</button>
</span>
</label>
<div class="form-row">
<label class="remember">
<input v-model="remember" type="checkbox">
<span class="check" />
Recordarme
</label>
<button type="button" class="forgot" @click="onForgot">¿Olvidaste tu contraseña?</button>
</div>
<p v-if="forgotHint" class="hint">{{ forgotHint }}</p>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" class="submit" :disabled="loading">
<span v-if="loading" class="spinner" />
Iniciar sesión
</button>
<p class="secure">
<i class="pi pi-lock" />
Acceso seguro
</p>
</form>
<p class="copyright">© {{ year }} PANELS</p>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: "auth" });
useHead({
title: "Iniciar sesión · PANELS",
link: [
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap",
},
],
});
type MosaicTone = "" | "pale" | "mid" | "strong";
const COLS = 7;
const ROWS = 12;
const CELL_COUNT = COLS * ROWS;
const TONE_STEPS: MosaicTone[] = ["", "pale", "mid", "strong"];
const ACCENT_COUNT = 10;
const INITIAL_ACCENTS: Record<string, MosaicTone> = {
"0-5": "pale",
"1-2": "mid",
"1-6": "strong",
"2-4": "pale",
"3-1": "strong",
"4-5": "pale",
"5-2": "mid",
"5-6": "strong",
"7-3": "pale",
"8-0": "mid",
"8-5": "strong",
"10-2": "pale",
"10-6": "mid",
};
function buildInitialMosaic(): MosaicTone[] {
return Array.from({ length: CELL_COUNT }, (_, i) => {
const row = Math.floor(i / COLS);
const col = i % COLS;
return INITIAL_ACCENTS[`${row}-${col}`] ?? "";
});
}
function toneIndex(tone: MosaicTone): number {
return TONE_STEPS.indexOf(tone);
}
function stepTone(tone: MosaicTone, delta: -1 | 1): MosaicTone {
const next = Math.min(TONE_STEPS.length - 1, Math.max(0, toneIndex(tone) + delta));
return TONE_STEPS[next]!;
}
function shuffleIndices(indices: number[]): number[] {
const next = [...indices];
for (let i = next.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[next[i], next[j]] = [next[j]!, next[i]!];
}
return next;
}
const mosaicCells = ref<MosaicTone[]>(buildInitialMosaic());
let mosaicTimer: ReturnType<typeof setInterval> | null = null;
/** Solo un escalón de tono por celda, pocos cambios por tick → drift suave. */
function driftMosaic() {
const next = [...mosaicCells.value];
const litCount = next.filter(Boolean).length;
const candidates = shuffleIndices(Array.from({ length: CELL_COUNT }, (_, i) => i));
let changed = 0;
const maxChanges = 3;
for (const i of candidates) {
if (changed >= maxChanges) break;
const tone = next[i]!;
const idx = toneIndex(tone);
let delta: -1 | 1 | 0 = 0;
if (litCount > ACCENT_COUNT + 1 && idx > 0) {
delta = -1;
} else if (litCount < ACCENT_COUNT - 1 && idx === 0) {
delta = 1;
} else if (idx === 0) {
if (Math.random() < 0.35) delta = 1;
} else if (idx === TONE_STEPS.length - 1) {
if (Math.random() < 0.55) delta = -1;
} else {
delta = Math.random() < 0.5 ? 1 : -1;
}
if (!delta) continue;
// Evita encender de más si ya hay bastantes acentos.
if (delta === 1 && idx === 0 && litCount >= ACCENT_COUNT + 1) continue;
next[i] = stepTone(tone, delta);
changed++;
}
mosaicCells.value = next;
}
const { login } = useAuth();
const { isDark, setDark: applyTheme } = useTheme();
const username = ref("");
const password = ref("");
const remember = ref(false);
const showPassword = ref(false);
const isThemeSwitching = ref(false);
const error = ref("");
const forgotHint = ref("");
const loading = ref(false);
const year = new Date().getFullYear();
let themeSwitchTimer: ReturnType<typeof setTimeout> | null = null;
onMounted(() => {
const savedUser = localStorage.getItem("panel-login-remember");
if (savedUser) {
username.value = savedUser;
remember.value = true;
}
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) {
mosaicTimer = setInterval(driftMosaic, 1400);
}
});
onBeforeUnmount(() => {
if (mosaicTimer) clearInterval(mosaicTimer);
if (themeSwitchTimer) clearTimeout(themeSwitchTimer);
});
function setDark(value: boolean) {
if (value === isDark.value) return;
// Sin transición al cambiar tema: evita el flash de cuadros claros sobre fondo oscuro.
isThemeSwitching.value = true;
applyTheme(value);
if (themeSwitchTimer) clearTimeout(themeSwitchTimer);
themeSwitchTimer = setTimeout(() => {
isThemeSwitching.value = false;
themeSwitchTimer = null;
}, 50);
}
function onForgot() {
forgotHint.value = "Pide al administrador que restablezca tu acceso.";
}
async function onSubmit() {
error.value = "";
forgotHint.value = "";
if (!username.value.trim() || !password.value) {
error.value = "Ingresa tu usuario y contraseña";
return;
}
loading.value = true;
try {
if (remember.value) localStorage.setItem("panel-login-remember", username.value.trim());
else localStorage.removeItem("panel-login-remember");
await login(username.value.trim(), password.value.trim());
const { user } = useAuth();
const { api } = useApi();
if (user.value?.role === "platform_admin" && user.value?.realm === "platform") {
user.value = null;
try {
await api("/v1/auth/logout", { method: "POST" });
} catch {
/* ignore */
}
window.location.href = "http://localhost:3001/login";
return;
}
if (user.value?.must_change_password) {
await navigateTo("/cambiar-password");
return;
}
await navigateTo("/");
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "No se pudo entrar";
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.panel-login {
--blue: #185ee8;
--text: #0b1127;
--muted: #64748b;
--soft: #94a3b8;
--line: #e1e2e6;
--right-bg: #ffffff;
--left-bg: #ffffff;
--tile: #e8eefc;
--tile-pale: #c8d8fb;
--tile-mid: #8eb4ff;
--tile-strong: #4f86f7;
--input-bg: #ffffff;
--check-bg: #ffffff;
--toggle-bg: #ffffff;
--toggle-line: #e5e7eb;
--toggle-active: #eaf0fe;
--toggle-icon: #94a3b8;
--toggle-icon-active: #334155;
min-height: 100vh;
min-height: 100dvh;
display: grid;
grid-template-columns: minmax(380px, 46fr) minmax(420px, 54fr);
font-family: Inter, ui-sans-serif, system-ui, "Segoe UI", sans-serif;
font-size: 16px;
color: var(--text);
background: var(--right-bg);
overflow: hidden;
box-sizing: border-box;
}
.panel-login *,
.panel-login *::before,
.panel-login *::after {
box-sizing: border-box;
}
.panel-login.is-dark {
--text: #f8fafc;
--muted: #94a3b8;
--soft: #64748b;
--line: #243044;
--right-bg: #0f172a;
--left-bg: #0b1220;
--tile: #1a2744;
--tile-pale: #243b66;
--tile-mid: #2563eb;
--tile-strong: #60a5fa;
--input-bg: #111b2e;
--check-bg: #111b2e;
--toggle-bg: #111b2e;
--toggle-line: #243044;
--toggle-active: #1e3a5f;
--toggle-icon: #64748b;
--toggle-icon-active: #e2e8f0;
}
.mosaic {
position: relative;
background: var(--left-bg);
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 28px 32px 44px;
}
.mosaic-grid {
position: absolute;
inset: -12px;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: min-content;
align-content: start;
gap: 10px;
padding: 10px;
pointer-events: none;
}
.mosaic-motif {
width: 100%;
aspect-ratio: 1;
display: block;
overflow: visible;
filter: drop-shadow(0 0 0 transparent);
transition:
filter 1.35s cubic-bezier(0.4, 0, 0.2, 1);
will-change: filter;
}
.mosaic-motif .petal {
fill: var(--tile);
transition: fill 1.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.mosaic-motif.is-pale .petal {
fill: var(--tile-pale);
}
.mosaic-motif.is-mid .petal {
fill: var(--tile-mid);
}
.mosaic-motif.is-strong .petal {
fill: var(--tile-strong);
}
.mosaic-motif.is-pale {
filter: drop-shadow(0 0 10px rgba(138, 180, 254, 0.28));
}
.mosaic-motif.is-mid {
filter: drop-shadow(0 0 14px rgba(79, 134, 247, 0.4));
}
.mosaic-motif.is-strong {
filter: drop-shadow(0 0 18px rgba(47, 111, 237, 0.55));
}
.panel-login.is-dark .mosaic-motif.is-pale {
filter: drop-shadow(0 0 12px rgba(59, 130, 246, 0.35));
}
.panel-login.is-dark .mosaic-motif.is-mid {
filter: drop-shadow(0 0 16px rgba(59, 130, 246, 0.5));
}
.panel-login.is-dark .mosaic-motif.is-strong {
filter: drop-shadow(0 0 22px rgba(96, 165, 250, 0.65));
}
@media (prefers-reduced-motion: reduce) {
.mosaic-motif,
.mosaic-motif .petal {
transition: none;
}
}
.panel-login.is-theme-switching .mosaic-motif,
.panel-login.is-theme-switching .mosaic-motif .petal {
transition: none !important;
transition-delay: 0s !important;
}
.mosaic-brand,
.mosaic-copy {
position: relative;
z-index: 1;
}
.mosaic-brand {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
max-width: 300px;
}
.mosaic-brand-mark {
display: flex;
align-items: center;
gap: 12px;
}
.mosaic-brand-mark img {
width: 40px;
height: 40px;
display: block;
flex-shrink: 0;
}
.mosaic-brand-mark span {
font-size: 20px;
font-weight: 800;
letter-spacing: 0.06em;
color: var(--text);
}
.mosaic-brand-expand {
margin: 0;
padding-left: 52px;
font-size: 11px;
line-height: 1.45;
font-weight: 500;
color: var(--soft);
}
.mosaic-brand-expand span {
color: var(--muted);
font-weight: 700;
}
.mosaic-copy h2 {
margin: 0 0 12px;
font-size: 36px;
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.03em;
color: var(--text);
}
.mosaic-copy p {
margin: 0;
font-size: 15px;
line-height: 1.5;
color: #475569;
}
.panel-login.is-dark .mosaic-copy p {
color: #94a3b8;
}
.panel {
position: relative;
background: var(--right-bg);
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle {
position: absolute;
top: 24px;
right: 24px;
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
border: 1px solid var(--toggle-line);
border-radius: 999px;
background: var(--toggle-bg);
}
.theme-toggle button {
width: 34px;
height: 30px;
border: 0;
background: transparent;
border-radius: 9px;
color: var(--toggle-icon);
cursor: pointer;
display: grid;
place-items: center;
font-size: 13px;
}
.theme-toggle button.active {
background: var(--toggle-active);
color: var(--toggle-icon-active);
}
.form {
width: 100%;
max-width: 400px;
padding: 0 24px;
}
.form-head {
text-align: center;
margin-bottom: 40px;
}
.form-head h1 {
margin: 0 0 8px;
font-size: 32px;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--text);
}
.form-head p {
margin: 0;
font-size: 15px;
color: var(--muted);
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.field > span:first-child {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.control {
position: relative;
display: block;
}
.control > i {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--soft);
font-size: 14px;
pointer-events: none;
}
.control input {
width: 100%;
height: 48px;
box-sizing: border-box;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--input-bg);
color: var(--text);
padding: 0 44px 0 42px;
font: inherit;
font-size: 14px;
outline: none;
}
.control input::placeholder {
color: var(--soft);
}
.control input:focus {
border-color: var(--blue);
box-shadow: 0 0 0 3px rgba(47, 111, 237, 0.12);
}
.toggle-pass {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
border: 0;
background: transparent;
color: var(--soft);
cursor: pointer;
display: grid;
place-items: center;
font-size: 14px;
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 4px 0 24px;
}
.remember {
position: relative;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text);
cursor: pointer;
user-select: none;
}
.remember input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.check {
width: 16px;
height: 16px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--check-bg);
box-sizing: border-box;
display: grid;
place-items: center;
flex-shrink: 0;
}
.remember input:checked + .check {
background: var(--blue);
border-color: var(--blue);
}
.remember input:checked + .check::after {
content: "";
width: 8px;
height: 5px;
border: 2px solid #fff;
border-top: 0;
border-right: 0;
transform: rotate(-45deg) translate(0, -1px);
}
.forgot {
border: 0;
background: transparent;
color: var(--blue);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 0;
}
.hint,
.error {
margin: -8px 0 16px;
font-size: 13px;
line-height: 1.4;
}
.hint { color: var(--muted); }
.error { color: #dc2626; }
.submit {
width: 100%;
height: 48px;
border: 0;
border-radius: 12px;
background: var(--blue);
color: #fff;
font: inherit;
font-size: 15px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.panel-login button,
.panel-login input {
appearance: none;
-webkit-appearance: none;
}
.submit:hover:not(:disabled) {
background: #1454d0;
}
.control input:-webkit-autofill,
.control input:-webkit-autofill:hover,
.control input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 30px var(--input-bg) inset;
-webkit-text-fill-color: var(--text);
caret-color: var(--text);
}
.submit:disabled {
opacity: 0.72;
cursor: default;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.secure {
margin: 20px 0 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
color: var(--soft);
}
.secure i {
font-size: 12px;
}
.copyright {
position: absolute;
left: 0;
right: 0;
bottom: 28px;
margin: 0;
text-align: center;
font-size: 13px;
color: var(--soft);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 960px) {
.panel-login {
grid-template-columns: 1fr;
overflow: auto;
}
.mosaic {
min-height: 240px;
padding: 24px 24px 28px;
}
.mosaic-copy h2 {
font-size: 28px;
}
.theme-toggle {
top: 16px;
right: 16px;
}
.form {
padding: 72px 24px 80px;
}
.copyright {
position: relative;
bottom: auto;
padding-bottom: 24px;
}
}
</style>