Estaba mal hecho y el síntoma era el `if` sobre `/admin` que acabó metido en el layout de la web pública: la web sabiendo que existe el panel, al revés de como debe ser. Ahora son dos aplicaciones del mismo repositorio. Lo que las separa son tres líneas de `vite.config.ts`: con `APP=panel`, las rutas salen de `src/panel/rutas`, los hooks de `src/panel/hooks.server.ts` y el resultado va a `build-panel/`. El servidor público se compila sin las rutas del panel, así que en `senzapaura.es` no es que `/admin` esté protegido: no existe, y hay una prueba que lo comprueba pidiéndolo. Comparten `src/lib` —esquema, consultas, acceso—, que es lo que evita mantener las migraciones por duplicado. Para que eso funcione, dos cosas que resolvían rutas del sitio salen de en medio: la navegación, a `$lib/navegacion`, y los tipos ambientales, que ahora son dos porque el `PageData` del sitio lleva carrito y favoritos y el del panel no. El precio de otro dominio es que el panel necesita su propia página de entrar: la cookie se emite para el host y no para el dominio padre, que es justo lo que hace que la sesión del panel no viaje en las peticiones de la web. Usa el mismo código de `$lib/server/auth`, con una diferencia: solo manda código a una cuenta que ya administre, y contesta lo mismo exista o no. Si dijera «esa cuenta no administra», el formulario sería una forma cómoda de averiguar cuál sí. Y con dominio propio, el 404 de ayer pasa a ser un 403 con el motivo escrito: esconder el panel de quien ya está en `admin.senzapaura.es` no engaña a nadie, y a esa rama solo llega alguien que administraba y ha dejado de administrar. Dos servidores en las pruebas, uno por proyecto, con su `baseURL`: una prueba del panel no puede pedir por accidente una página del sitio. Aparte, la prueba de contacto otra vez. El arreglo de ayer no valía —comprobar que el texto está escrito no impide que lo borren un instante después—, así que el layout marca con `data-hidratado` el momento en que Svelte toma el control y la prueba espera a eso. Quince pasadas con cuatro trabajadores, sin un fallo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>master
parent
3981f33867
commit
34668a80e3
@ -1,40 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/*
|
||||
* El panel, desde fuera.
|
||||
*
|
||||
* Aquí solo se comprueba la puerta: que sin sesión no se ve nada y que la
|
||||
* dirección de vuelta se conserva. Quién puede pasar una vez dentro lo deciden
|
||||
* `esAdmin` y `exigirAdmin`, y eso se prueba en `administracion.spec.ts`, donde
|
||||
* se pueden montar las tres situaciones sin necesitar un buzón de correo.
|
||||
*/
|
||||
test.describe('El panel', () => {
|
||||
test('sin sesión no se ve, se manda a entrar', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
|
||||
await expect(page).toHaveURL(/\/entrar/);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
});
|
||||
|
||||
test('y se vuelve a donde se iba, no a la portada', async ({ page }) => {
|
||||
await page.goto('/admin?ver=pendientes');
|
||||
|
||||
const url = new URL(page.url());
|
||||
expect(url.pathname).toBe('/entrar');
|
||||
expect(url.searchParams.get('volverA')).toBe('/admin?ver=pendientes');
|
||||
});
|
||||
|
||||
test('el panel no es contenido del sitio, y robots lo dice', async ({ request }) => {
|
||||
const robots = await request.get('/robots.txt');
|
||||
expect(await robots.text()).toContain('Disallow: /admin');
|
||||
});
|
||||
|
||||
test('no hay ningún enlace al panel en la web pública', async ({ page }) => {
|
||||
// No es seguridad —la guarda es el servidor—, es que el panel no forma
|
||||
// parte de la navegación de nadie que venga a leer o a comprar.
|
||||
for (const ruta of ['/', '/musica', '/generos', '/blog']) {
|
||||
await page.goto(ruta);
|
||||
await expect(page.locator('a[href^="/admin"]')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,54 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/*
|
||||
* El panel, desde fuera.
|
||||
*
|
||||
* Corre contra su propio servidor —el proyecto «panel» de la configuración—,
|
||||
* que es otra aplicación compilada aparte. Aquí solo se comprueba la puerta:
|
||||
* quién puede pasar una vez dentro lo deciden `esAdmin` y `exigirAdmin`, y eso
|
||||
* se prueba en `administracion.spec.ts`, donde se pueden montar las tres
|
||||
* situaciones sin necesitar un buzón de correo.
|
||||
*/
|
||||
test.describe('El panel', () => {
|
||||
test('sin sesión no se ve, se manda a entrar', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page).toHaveURL(/\/entrar/);
|
||||
await expect(page.getByRole('heading', { name: 'Panel' })).toBeVisible();
|
||||
await expect(page.getByLabel('Correo')).toBeVisible();
|
||||
});
|
||||
|
||||
test('y se vuelve a donde se iba, no a la portada', async ({ page }) => {
|
||||
await page.goto('/?ver=pendientes');
|
||||
|
||||
const url = new URL(page.url());
|
||||
expect(url.pathname).toBe('/entrar');
|
||||
expect(url.searchParams.get('volverA')).toBe('/?ver=pendientes');
|
||||
});
|
||||
|
||||
test('no dice qué cuenta administra', async ({ page }) => {
|
||||
// La respuesta es la misma exista o no la cuenta. Si dijera «esa cuenta no
|
||||
// administra», el formulario sería una forma cómoda de averiguar cuál sí.
|
||||
await page.goto('/entrar');
|
||||
await page.getByLabel('Correo').fill('nadie-de-aqui@example.com');
|
||||
await page.getByRole('button', { name: 'Mandar el código' }).click();
|
||||
|
||||
await expect(page.getByText(/si esa cuenta administra/i)).toBeVisible();
|
||||
await expect(page.getByLabel('Código')).toBeVisible();
|
||||
});
|
||||
|
||||
test('nada de aquí se indexa', async ({ request }) => {
|
||||
const robots = await request.get('/robots.txt');
|
||||
expect(await robots.text()).toContain('Disallow: /');
|
||||
});
|
||||
|
||||
test('no lleva la navegación del sitio', async ({ page }) => {
|
||||
// Es una herramienta, no una página de la web: sin cabecera de secciones,
|
||||
// sin reproductor y sin pie.
|
||||
await page.goto('/entrar');
|
||||
|
||||
await expect(page.getByRole('navigation', { name: 'Principal' })).toHaveCount(0);
|
||||
await expect(page.getByRole('region', { name: 'Reproductor' })).toHaveCount(0);
|
||||
await expect(page.locator('footer')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@ -1,25 +1,53 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'e2e',
|
||||
/**
|
||||
* Dos servidores y dos proyectos, porque son dos aplicaciones.
|
||||
*
|
||||
* El sitio en 4173 y el panel en 4174. Cada proyecto pone su `baseURL`, así que
|
||||
* una prueba del panel no puede pedir por accidente una página del sitio ni al
|
||||
* revés: es la misma separación que en producción, donde son dos dominios.
|
||||
*/
|
||||
const comun = {
|
||||
/*
|
||||
* WebGL por software. El Chromium de las pruebas no trae aceleración, y sin
|
||||
* esto el fondo animado de la portada no se podría comprobar: solo se vería
|
||||
* su reserva, que es justo lo contrario de lo que hay que probar.
|
||||
*/
|
||||
use: {
|
||||
launchOptions: {
|
||||
args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader']
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'e2e',
|
||||
testMatch: '**/*.e2e.{ts,js}',
|
||||
webServer: {
|
||||
// El build va aparte, en `npm run test:e2e`: en Windows el empaquetador
|
||||
projects: [
|
||||
{
|
||||
name: 'sitio',
|
||||
testIgnore: '**/panel.e2e.ts',
|
||||
use: { ...comun, baseURL: 'http://localhost:4173' }
|
||||
},
|
||||
{
|
||||
name: 'panel',
|
||||
testMatch: '**/panel.e2e.ts',
|
||||
use: { ...comun, baseURL: 'http://localhost:4174' }
|
||||
}
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
// Los builds van aparte, en `npm run test:e2e`: en Windows el empaquetador
|
||||
// sale con SIGSEGV de forma intermitente aunque el bundle esté bien, y
|
||||
// eso tumbaba el arranque del servidor. Lo explica scripts/construir.mjs.
|
||||
command: 'npm run preview',
|
||||
port: 4173,
|
||||
timeout: 180_000,
|
||||
reuseExistingServer: !process.env.CI
|
||||
},
|
||||
{
|
||||
command: 'npm run preview:panel',
|
||||
port: 4174,
|
||||
timeout: 180_000,
|
||||
reuseExistingServer: !process.env.CI
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Ejecuta un comando con `APP=panel`.
|
||||
*
|
||||
* node scripts/como-panel.mjs npx vite build
|
||||
*
|
||||
* En Windows los scripts de npm pasan por cmd, y ahí `APP=panel comando` no
|
||||
* significa nada: cmd se lo toma como el nombre del programa y falla. Esto pone
|
||||
* la variable en el entorno y lanza lo que se le pida, sin traer una
|
||||
* dependencia para tres líneas.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const orden = process.argv.slice(2);
|
||||
|
||||
if (orden.length === 0) {
|
||||
console.error('Falta el comando. Ejemplo: node scripts/como-panel.mjs npx vite build');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const resultado = spawnSync(orden[0], orden.slice(1), {
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
env: { ...process.env, APP: 'panel' }
|
||||
});
|
||||
|
||||
process.exit(resultado.status ?? 1);
|
||||
@ -0,0 +1,22 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ResolvedPathname } from '$app/types';
|
||||
|
||||
/**
|
||||
* Navegacion principal. El orden es el que se pinta en la cabecera.
|
||||
*
|
||||
* Las rutas se resuelven aqui, una a una, y no en la cabecera: `resolve()`
|
||||
* tiene una sobrecarga por ruta y no acepta una union de todas, asi que
|
||||
* pasarle `enlace.href` deja de compilar en cuanto el sitio crece.
|
||||
*
|
||||
* Esta en su archivo y no en `$lib/site` porque `resolve()` se comprueba contra
|
||||
* el mapa de rutas de la aplicacion que compila, y `$lib/site` lo importan las
|
||||
* dos —el sitio y el panel—. Estas rutas solo existen en una.
|
||||
*/
|
||||
export const navegacion: { href: ResolvedPathname; texto: string }[] = [
|
||||
{ href: resolve('/musica'), texto: 'Música' },
|
||||
{ href: resolve('/taller'), texto: 'Taller de letras' },
|
||||
{ href: resolve('/generos'), texto: 'Géneros' },
|
||||
{ href: resolve('/blog'), texto: 'Blog' },
|
||||
{ href: resolve('/bio'), texto: 'Bio' },
|
||||
{ href: resolve('/contacto'), texto: 'Contacto' }
|
||||
];
|
||||
@ -0,0 +1,25 @@
|
||||
import type { Usuario } from '$lib/server/db/schema';
|
||||
|
||||
/**
|
||||
* Los tipos ambientales del panel.
|
||||
*
|
||||
* Hay dos archivos y no uno porque hay dos aplicaciones. `App.Locals` coincide
|
||||
* —las dos resuelven la sesión igual—, pero `App.PageData` no: la del sitio
|
||||
* lleva el carrito, las compras y los favoritos, que es lo que baja su layout
|
||||
* raíz, y aquí lo único que baja de arriba es quién administra.
|
||||
*/
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
/** Cuenta de la sesión actual, o `null` si no ha entrado nadie. */
|
||||
usuario: Usuario | null;
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
/** Quién administra, puesto por el layout del grupo con la guarda. */
|
||||
admin?: { email: string };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@ -0,0 +1,38 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import {
|
||||
borrarCookieSesion,
|
||||
COOKIE_SESION,
|
||||
establecerCookieSesion,
|
||||
validarSesion
|
||||
} from '$lib/server/auth';
|
||||
|
||||
/**
|
||||
* Los hooks del panel.
|
||||
*
|
||||
* Solo la sesión. Los de la web pública —el portón de «muy pronto» y las
|
||||
* redirecciones de las direcciones que se mudaron— no pintan nada aquí: el
|
||||
* portón dejaría al administrador fuera justo mientras la web no está abierta,
|
||||
* que es cuando más falta hace entrar, y aquí no hay ninguna dirección vieja
|
||||
* que redirigir porque el panel no ha estado publicado nunca.
|
||||
*
|
||||
* Es el mismo código de sesión que el sitio, importado de `$lib`, no copiado.
|
||||
* La cookie, en cambio, es de este dominio y solo de este: no se le pone
|
||||
* `domain`, así que la que se emite en `admin.senzapaura.es` no viaja a
|
||||
* `senzapaura.es` ni al revés.
|
||||
*/
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.usuario = null;
|
||||
|
||||
const token = event.cookies.get(COOKIE_SESION);
|
||||
if (token) {
|
||||
const abierta = await validarSesion(token);
|
||||
if (abierta) {
|
||||
event.locals.usuario = abierta.usuario;
|
||||
establecerCookieSesion(event.cookies, token, abierta.expiraEn);
|
||||
} else {
|
||||
borrarCookieSesion(event.cookies);
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
@ -1,29 +1,35 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* El panel.
|
||||
* Lo que envuelve al panel una vez dentro.
|
||||
*
|
||||
* Deliberadamente sobrio y distinto de la web: aquí no se viene a mirar, se
|
||||
* viene a arreglar algo concreto. Lleva la misma paleta —es el mismo sitio—
|
||||
* pero sin la cabecera de navegación, sin el reproductor y sin el pie: nada
|
||||
* de eso ayuda a corregir una ficha, y todo ello ocupa la pantalla.
|
||||
* Deliberadamente sobrio: aquí no se viene a mirar, se viene a arreglar algo
|
||||
* concreto. Lleva la paleta del sitio —es la misma casa— y nada de su
|
||||
* navegación: el menú de Música y Blog no ayuda a corregir una ficha.
|
||||
*
|
||||
* La barra dice siempre quién eres. En una herramienta que edita lo que se
|
||||
* publica, saber con qué cuenta estás no es un adorno.
|
||||
*/
|
||||
import { resolve } from '$app/paths';
|
||||
import { site } from '$lib/site';
|
||||
|
||||
let { data, children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<header class="barra">
|
||||
<a class="marca" href={resolve('/admin')}>
|
||||
<a class="marca" href={resolve('/')}>
|
||||
Panel<span class="sitio">Senza Paura</span>
|
||||
</a>
|
||||
|
||||
<div class="quien">
|
||||
<span class="correo">{data.admin.email}</span>
|
||||
<a href={resolve('/')}>Ver el sitio</a>
|
||||
<!--
|
||||
El sitio está en otro dominio, así que el enlace es absoluto y sale de
|
||||
`site.url`. La regla que pide `resolve()` es para las rutas de esta
|
||||
aplicación; aquí se va fuera a propósito.
|
||||
-->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={site.url}>Ver el sitio</a>
|
||||
<form method="POST" action={resolve('/salir')}>
|
||||
<button type="submit">Salir</button>
|
||||
</form>
|
||||
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* La raíz del panel.
|
||||
*
|
||||
* Solo la hoja de estilos y el icono. Todo lo que envuelve al contenido —la
|
||||
* barra, quién eres, el enlace de salir— vive en el grupo `(dentro)`, que es
|
||||
* el que además comprueba el permiso: la página de entrar no puede llevar una
|
||||
* barra que diga quién eres antes de saberlo.
|
||||
*
|
||||
* La paleta es la misma del sitio a propósito. Es la misma casa, aunque sea
|
||||
* otro dominio y otro proceso.
|
||||
*/
|
||||
import '../../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<!-- El panel no es contenido de nadie: que no acabe en ningún índice. -->
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,177 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
crearCodigoDeAcceso,
|
||||
crearSesion,
|
||||
establecerCookieSesion,
|
||||
normalizarEmail,
|
||||
verificarCodigo
|
||||
} from '$lib/server/auth';
|
||||
import { db } from '$lib/server/db';
|
||||
import { usuario } from '$lib/server/db/schema';
|
||||
import { enviarCodigoDeAcceso } from '$lib/server/correo';
|
||||
import { claveDeCorreo } from '$lib/server/correo-clave';
|
||||
import { comprobarBarreras, enMinutos, limpiarCaducados } from '$lib/server/limite';
|
||||
import { destinoSeguro } from '$lib/server/destino';
|
||||
import { esAdmin } from '$lib/server/administracion';
|
||||
import { esEmail } from '$lib/validacion';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
/**
|
||||
* Entrar al panel.
|
||||
*
|
||||
* Es una página propia y no la del sitio porque el panel vive en otro dominio,
|
||||
* y una cookie de sesión no cruza de `senzapaura.es` a `admin.senzapaura.es`
|
||||
* salvo que se emita para el dominio padre. Emitirla así habría sido menos
|
||||
* trabajo y peor: la sesión del panel viajaría en cada petición de la web
|
||||
* pública. Aquí la cookie es de este host y solo de este.
|
||||
*
|
||||
* El código que hace el trabajo —emitir, verificar, abrir sesión— es el mismo
|
||||
* de `$lib/server/auth`, importado. Lo único propio es a quién se le manda.
|
||||
*/
|
||||
|
||||
/** Lo que se contesta siempre, se haya mandado código o no. */
|
||||
const ENVIADO = 'Si esa cuenta administra el sitio, le hemos mandado un código.';
|
||||
|
||||
export function load({ locals, url }) {
|
||||
if (esAdmin(locals.usuario)) redirect(303, destinoSeguro(url.searchParams.get('volverA'), '/'));
|
||||
|
||||
const email = normalizarEmail(url.searchParams.get('email') ?? '');
|
||||
const reanudar = url.searchParams.get('paso') === 'codigo' && esEmail(email);
|
||||
|
||||
return {
|
||||
volverA: destinoSeguro(url.searchParams.get('volverA'), '/'),
|
||||
paso: reanudar ? ('codigo' as const) : ('email' as const),
|
||||
email: reanudar ? email : ''
|
||||
};
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
/** Paso 1: pedir el código. */
|
||||
solicitar: async ({ request, getClientAddress }) => {
|
||||
const datos = await request.formData();
|
||||
const email = normalizarEmail(String(datos.get('email') ?? ''));
|
||||
const volverA = destinoSeguro(datos.get('volverA'), '/');
|
||||
|
||||
if (!esEmail(email)) {
|
||||
return fail(400, { paso: 'email' as const, email, error: 'Ese correo no parece válido.' });
|
||||
}
|
||||
|
||||
/*
|
||||
* Los mismos frenos que el acceso del sitio. Aquí solo pueden entrar dos o
|
||||
* tres personas, así que un límite bajo no molesta a nadie legítimo, y sin
|
||||
* él este formulario sería un botón para mandar correo a quien sea desde
|
||||
* nuestro dominio.
|
||||
*/
|
||||
const destino = claveDeCorreo(email);
|
||||
const barrera = await comprobarBarreras([
|
||||
{
|
||||
clave: `panel:espera:${destino}`,
|
||||
maximo: 1,
|
||||
ventanaMs: 60 * 1000,
|
||||
mensaje: () => 'Te hemos mandado un código hace un momento. Revisa el correo.'
|
||||
},
|
||||
{
|
||||
clave: `panel:correo-hora:${destino}`,
|
||||
maximo: 5,
|
||||
ventanaMs: 60 * 60 * 1000,
|
||||
mensaje: (espera) =>
|
||||
`Demasiados códigos seguidos. Inténtalo en ${enMinutos(espera)} minutos.`
|
||||
},
|
||||
{
|
||||
clave: `panel:ip:${getClientAddress()}`,
|
||||
maximo: 5,
|
||||
ventanaMs: 15 * 60 * 1000,
|
||||
mensaje: (espera) =>
|
||||
`Demasiados códigos seguidos. Inténtalo en ${enMinutos(espera)} minutos.`
|
||||
}
|
||||
]);
|
||||
|
||||
if (!barrera.permitido) {
|
||||
return fail(429, { paso: 'email' as const, email, error: barrera.mensaje });
|
||||
}
|
||||
|
||||
/*
|
||||
* Solo se manda código a quien ya administra.
|
||||
*
|
||||
* Y la respuesta es la misma se mande o no. Si dijera «esa cuenta no
|
||||
* administra», este formulario sería una forma cómoda de averiguar qué
|
||||
* correo tiene las llaves del sitio, que es la mitad del trabajo de quien
|
||||
* quiere entrar.
|
||||
*
|
||||
* Además, sin esta comprobación la verificación crearía la cuenta al
|
||||
* canjear el código, que es lo que hace el acceso del sitio: aquí eso
|
||||
* significaría dar de alta usuarios desde el panel.
|
||||
*/
|
||||
const [cuenta] = await db.select().from(usuario).where(eq(usuario.email, email)).limit(1);
|
||||
|
||||
if (esAdmin(cuenta)) {
|
||||
try {
|
||||
await enviarCodigoDeAcceso(email, await crearCodigoDeAcceso(email));
|
||||
} catch (causa) {
|
||||
console.error('[panel] No se ha podido enviar el código', causa);
|
||||
return fail(502, {
|
||||
paso: 'email' as const,
|
||||
email,
|
||||
error: 'No hemos podido mandar el código. Inténtalo en un momento.'
|
||||
});
|
||||
}
|
||||
await limpiarCaducados();
|
||||
}
|
||||
|
||||
return { paso: 'codigo' as const, email, volverA, aviso: ENVIADO };
|
||||
},
|
||||
|
||||
/** Paso 2: canjear el código por una sesión. */
|
||||
verificar: async ({ request, cookies }) => {
|
||||
const datos = await request.formData();
|
||||
const email = normalizarEmail(String(datos.get('email') ?? ''));
|
||||
const codigo = String(datos.get('codigo') ?? '').trim();
|
||||
const volverA = destinoSeguro(datos.get('volverA'), '/');
|
||||
|
||||
if (!/^\d{6}$/.test(codigo)) {
|
||||
return fail(400, {
|
||||
paso: 'codigo' as const,
|
||||
email,
|
||||
volverA,
|
||||
error: 'El código son seis dígitos.'
|
||||
});
|
||||
}
|
||||
|
||||
const resultado = await verificarCodigo(email, codigo);
|
||||
|
||||
if (!resultado.ok) {
|
||||
const mensajes = {
|
||||
caducado: 'El código ha caducado o ya se ha usado. Pide uno nuevo.',
|
||||
incorrecto: 'Ese código no es correcto.',
|
||||
agotado: 'Demasiados intentos. Pide un código nuevo.'
|
||||
};
|
||||
return fail(400, {
|
||||
paso: resultado.motivo === 'incorrecto' ? ('codigo' as const) : ('email' as const),
|
||||
email,
|
||||
volverA,
|
||||
error: mensajes[resultado.motivo]
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Cinturón y tirantes: el código solo se manda a quien administra, así que
|
||||
* llegar aquí sin el rol pide haber acertado seis dígitos de un código que
|
||||
* nadie pidió. Aun así se comprueba, porque el día que alguien cambie lo
|
||||
* de arriba esta línea es la que sigue impidiendo abrir la sesión.
|
||||
*/
|
||||
if (!esAdmin(resultado.usuario)) {
|
||||
return fail(403, {
|
||||
paso: 'email' as const,
|
||||
email,
|
||||
volverA,
|
||||
error: 'Esa cuenta no administra el sitio.'
|
||||
});
|
||||
}
|
||||
|
||||
const sesion = await crearSesion(resultado.usuario.id);
|
||||
establecerCookieSesion(cookies, sesion.token, sesion.expiraEn);
|
||||
|
||||
redirect(303, volverA);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Entrar al panel.
|
||||
*
|
||||
* Un formulario centrado y poco más. La página de acceso del sitio tiene su
|
||||
* lámina y su tipografía grande porque es la primera cosa que ve alguien que
|
||||
* llega a comprar; esta la ve una persona que ya sabe a qué viene.
|
||||
*/
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const paso = $derived(form?.paso ?? data.paso);
|
||||
const email = $derived(form?.email ?? data.email);
|
||||
const volverA = $derived(form?.volverA ?? data.volverA);
|
||||
|
||||
/*
|
||||
* En el marcado, `{` abre una expresión de Svelte, así que el patrón del
|
||||
* campo se declara aquí en vez de pelearse con el escapado. Lo mismo hace el
|
||||
* formulario de acceso del sitio.
|
||||
*/
|
||||
const PATRON_CODIGO = '[0-9]{6}';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Entrar · Panel</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<h1>Panel</h1>
|
||||
|
||||
{#if paso === 'email'}
|
||||
<p class="explica">
|
||||
Escribe el correo de la cuenta que administra el sitio. Te mandamos un código de un solo uso.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="?/solicitar" use:enhance>
|
||||
<input type="hidden" name="volverA" value={volverA} />
|
||||
|
||||
<label for="email">Correo</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
value={email}
|
||||
aria-describedby={form?.error ? 'fallo' : undefined}
|
||||
/>
|
||||
|
||||
<button type="submit" class="btn btn--primary">Mandar el código</button>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="explica">
|
||||
{form?.aviso ?? 'Si esa cuenta administra el sitio, le hemos mandado un código.'}
|
||||
</p>
|
||||
|
||||
<form method="POST" action="?/verificar" use:enhance>
|
||||
<input type="hidden" name="volverA" value={volverA} />
|
||||
<input type="hidden" name="email" value={email} />
|
||||
|
||||
<label for="codigo">Código</label>
|
||||
<input
|
||||
id="codigo"
|
||||
name="codigo"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
pattern={PATRON_CODIGO}
|
||||
maxlength="6"
|
||||
required
|
||||
aria-describedby={form?.error ? 'fallo' : undefined}
|
||||
/>
|
||||
|
||||
<button type="submit" class="btn btn--primary">Entrar</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="?/solicitar" use:enhance class="otra">
|
||||
<input type="hidden" name="volverA" value={volverA} />
|
||||
<input type="hidden" name="email" value={email} />
|
||||
<button type="submit" class="enlace">Usar otro correo o pedir otro código</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<p id="fallo" class="fallo" role="alert">{form.error}</p>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: var(--sp-4);
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-2xl);
|
||||
}
|
||||
|
||||
.explica {
|
||||
margin: 0;
|
||||
color: var(--c-text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: var(--boton-relleno);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-boton);
|
||||
background: var(--c-bg-sunken);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: var(--boton-texto);
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--c-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
#codigo {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xl);
|
||||
letter-spacing: 0.35em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button.btn {
|
||||
margin-top: var(--sp-2);
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.otra {
|
||||
margin-top: calc(var(--sp-2) * -1);
|
||||
}
|
||||
|
||||
.enlace {
|
||||
justify-self: start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--c-text-muted);
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.enlace:hover {
|
||||
color: var(--c-accent);
|
||||
}
|
||||
|
||||
.fallo {
|
||||
margin: 0;
|
||||
color: var(--c-danger);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,16 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Un «no entres» para todo el dominio.
|
||||
*
|
||||
* El panel está detrás de una sesión, así que un buscador no vería nada de
|
||||
* dentro; lo que evita esto es que la página de entrar acabe indexada, y con
|
||||
* ella el dominio anunciado en los resultados de búsqueda del sitio.
|
||||
*/
|
||||
export const GET: RequestHandler = () =>
|
||||
new Response('User-agent: *\nDisallow: /\n', {
|
||||
headers: {
|
||||
'content-type': 'text/plain; charset=utf-8',
|
||||
'cache-control': 'public, max-age=86400'
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,19 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { borrarCookieSesion, cerrarSesion, COOKIE_SESION } from '$lib/server/auth';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
/** No hay página de salida: solo la acción. */
|
||||
export function load() {
|
||||
redirect(303, '/entrar');
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ cookies }) => {
|
||||
const token = cookies.get(COOKIE_SESION);
|
||||
// Se borra también del servidor: quitar la cookie sin invalidar la sesión
|
||||
// dejaría el token vivo para quien lo hubiera copiado.
|
||||
if (token) await cerrarSesion(token);
|
||||
borrarCookieSesion(cookies);
|
||||
redirect(303, '/entrar');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,48 @@
|
||||
{
|
||||
// El mismo tsconfig, sobre lo que genera el build del panel.
|
||||
//
|
||||
// Hacen falta dos porque cada aplicación tiene su carpeta de rutas y su
|
||||
// `.svelte-kit`, y ahí dentro está el `./$types` de cada página. El
|
||||
// `exclude` no es cosmético: sin él, esta comprobación entra también en
|
||||
// `src/routes` y resuelve el `./$types` de cada página del sitio contra el
|
||||
// mapa de rutas del panel, donde no existe ninguna. Salían casi doscientos
|
||||
// errores que no lo eran.
|
||||
"extends": "./.svelte-kit-panel/tsconfig.json",
|
||||
"exclude": [
|
||||
"./node_modules/**",
|
||||
"./src/routes/**",
|
||||
"./src/hooks.server.ts",
|
||||
// Los componentes son del sitio: resuelven `/musica`, `/generos` y demás,
|
||||
// que en el panel no existen. Están en `$lib` por costumbre, no porque se
|
||||
// compartan; el panel no usa ninguno. El día que use alguno, habrá que
|
||||
// sacarlo de aquí y quitarle las rutas.
|
||||
"./src/lib/components/**",
|
||||
"./src/lib/navegacion.ts",
|
||||
"./src/lib/audio.ts",
|
||||
// `pista.ts` importa `audio.ts`, y excluir un archivo no lo saca de la
|
||||
// comprobación si algo incluido lo importa. El reproductor es del sitio.
|
||||
"./src/lib/player/**",
|
||||
// Los tipos ambientales del sitio: su `PageData` es la del layout raíz de
|
||||
// la web —usuario, carrito, compras, favoritos—, que aquí no baja nadie.
|
||||
// El panel tiene los suyos en `src/panel/app.d.ts`.
|
||||
"./src/app.d.ts",
|
||||
"./src/service-worker.js",
|
||||
"./src/service-worker/**/*.js",
|
||||
"./src/service-worker.ts",
|
||||
"./src/service-worker/**/*.ts",
|
||||
"./src/service-worker.d.ts",
|
||||
"./src/service-worker/**/*.d.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue