From 32696e86f53d1b3a0672c071a91db06dc0b5877d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Domingo=20Agust=C3=AD?= Date: Thu, 24 Sep 2026 13:59:56 +0200 Subject: [PATCH 1/2] feat: portal de producto con cuentas, configurador e integridad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saneamiento HMAC/listados privados, landing OKLCH, login, configurador, absorción, descarga con hash y netinstall con portal_url/live. Plantillas sin placeholders que disparen secret scanning. --- .env.example | 14 + .gitguardian.yaml | 10 + .github/workflows/build-iso.yml | 11 +- .github/workflows/ci.yml | 34 ++ configs/developer.json | 12 +- configs/minimal.json | 30 ++ configs/vm-luks.json | 37 ++ docs/ARCHITECTURE.md | 10 +- docs/INSTALL.md | 30 +- docs/ROADMAP.md | 24 +- netboot/ipxe/neubat.ipxe | 19 +- portal/frontend/src/App.tsx | 30 +- portal/frontend/src/components/Layout.tsx | 57 ++- portal/frontend/src/index.css | 149 ++++--- portal/frontend/src/lib/api.ts | 69 +++- portal/frontend/src/lib/auth.tsx | 47 +++ portal/frontend/src/pages/AccountPage.tsx | 200 ++++++++++ portal/frontend/src/pages/AdminPage.tsx | 4 +- portal/frontend/src/pages/ConfigurePage.tsx | 365 ++++++++++++++++++ portal/frontend/src/pages/DownloadPage.tsx | 150 +++++++ portal/frontend/src/pages/HomePage.test.tsx | 47 +-- portal/frontend/src/pages/HomePage.tsx | 106 ++--- .../frontend/src/pages/LandingPage.test.tsx | 33 ++ portal/frontend/src/pages/LandingPage.tsx | 98 +++++ portal/frontend/src/types.ts | 54 +++ portal/lib/archinstall.js | 59 +++ portal/lib/auth.js | 19 + portal/lib/db.js | 17 +- portal/lib/users.js | 216 +++++++++++ portal/routes/account.js | 193 +++++++++ portal/routes/admin.js | 15 +- portal/routes/auth.js | 50 +++ portal/routes/install.js | 61 ++- portal/routes/status.js | 15 +- portal/server.js | 14 +- portal/tests/app.test.js | 2 + portal/tests/lib/db.test.js | 54 +++ portal/tests/routes/auth.test.js | 62 +++ portal/tests/routes/status.test.js | 45 ++- portal/tests/setup.js | 2 + scripts/10-partition.sh | 46 ++- scripts/20-archinstall.sh | 52 ++- scripts/30-postinstall.sh | 120 ++++-- scripts/35-snapper.sh | 8 +- scripts/neubat-absorb.sh | 78 ++++ scripts/neubat-install.sh | 3 +- tests/vm/neubat_vm_test.py | 142 +++++-- 47 files changed, 2562 insertions(+), 351 deletions(-) create mode 100644 .gitguardian.yaml create mode 100644 configs/minimal.json create mode 100644 configs/vm-luks.json create mode 100644 portal/frontend/src/lib/auth.tsx create mode 100644 portal/frontend/src/pages/AccountPage.tsx create mode 100644 portal/frontend/src/pages/ConfigurePage.tsx create mode 100644 portal/frontend/src/pages/DownloadPage.tsx create mode 100644 portal/frontend/src/pages/LandingPage.test.tsx create mode 100644 portal/frontend/src/pages/LandingPage.tsx create mode 100644 portal/lib/archinstall.js create mode 100644 portal/lib/auth.js create mode 100644 portal/lib/users.js create mode 100644 portal/routes/account.js create mode 100644 portal/routes/auth.js create mode 100644 portal/tests/routes/auth.test.js create mode 100755 scripts/neubat-absorb.sh diff --git a/.env.example b/.env.example index f64937d..a70117d 100644 --- a/.env.example +++ b/.env.example @@ -6,12 +6,26 @@ ADMIN_TOKEN=cambia-este-token-por-uno-largo-y-aleatorio # Puerto expuesto del portal en el host NEUBAT_PORT=3000 +# URL pública del portal (iPXE / neubat_portal_url) +# NEUBAT_PUBLIC_URL=http://portal.example.com:3000 + # Mirror base para el netboot iPXE (puede apuntar a una caché local HTTP) NEUBAT_MIRROR_BASE=https://geo.mirror.pkgbuild.com/iso/latest +# Live NEUBAT con hook de autoinstalación (directorio o URL base) +# NEUBAT_LIVE_DIR=./out/live +# NEUBAT_LIVE_BASE=http://portal.example.com:3000/live +# NEUBAT_USE_LIVE=1 + # Secreto compartido para firmar/verificar configuraciones con HMAC-SHA256. # Debe coincidir con el valor usado por el instalador (NEUBAT_HMAC_SECRET). NEUBAT_HMAC_SECRET=cambia-este-secreto-por-una-cadena-larga-y-aleatoria +# Usar archinstall como motor cuando esté en el live (0/1) +# NEUBAT_USE_ARCHINSTALL=0 + +# Release de la ISO en el portal (/descargar) +# NEUBAT_RELEASE_TAG=v1.0.0 + # Puerto de la caché opcional de paquetes pacman (docker compose --profile cache up -d) NEUBAT_CACHE_PORT=8090 diff --git a/.gitguardian.yaml b/.gitguardian.yaml new file mode 100644 index 0000000..a540317 --- /dev/null +++ b/.gitguardian.yaml @@ -0,0 +1,10 @@ +# GitGuardian — plantillas de instalación (no son secretos de producción) +version: 2 +path_exclusions: + - configs/*.json +secret_exclusions: + - name: Install profile placeholders + matches: + - name: Generic Password + paths: + - configs/ diff --git a/.github/workflows/build-iso.yml b/.github/workflows/build-iso.yml index 5cbbf4d..d496c31 100644 --- a/.github/workflows/build-iso.yml +++ b/.github/workflows/build-iso.yml @@ -28,19 +28,26 @@ jobs: # Eliminar prefijo 'v' si existe para el nombre del artefacto TAG="${TAG#v}" make build-iso TAG="${TAG}" + cd out + sha256sum "neubat-${TAG}-x86_64.iso" > "neubat-${TAG}-x86_64.iso.sha256" + cat "neubat-${TAG}-x86_64.iso.sha256" - name: Upload ISO artifact uses: actions/upload-artifact@v4 with: name: neubat-iso - path: out/*.iso + path: | + out/*.iso + out/*.sha256 retention-days: 7 - name: Create GitHub Release if: github.ref_type == 'tag' uses: softprops/action-gh-release@v2 with: - files: out/*.iso + files: | + out/*.iso + out/*.sha256 generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8cd71b..b51ea3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,40 @@ jobs: - name: Run frontend tests run: cd portal/frontend && npm test + - name: Install axe-core for a11y smoke + run: cd portal/frontend && npm install --no-save axe-core jsdom + + - name: Accessibility smoke (axe on landing markup) + run: | + cd portal/frontend + node <<'NODE' + const { JSDOM } = require('jsdom'); + const fs = require('fs'); + const axeSource = fs.readFileSync(require.resolve('axe-core/axe.js'), 'utf8'); + const html = `NEUBAT + + +
+

NEUBAT: tu Arch, tu ISO, tu red

+

Configura desde el navegador una instalación desatendida.

+ Configurar instalación
+ + `; + const dom = new JSDOM(html, { runScripts: 'dangerously', pretendToBeVisual: true }); + const { window } = dom; + window.eval(axeSource); + window.axe.run(window.document, { runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] }) + .then((results) => { + const serious = results.violations.filter(v => ['critical', 'serious'].includes(v.impact)); + if (serious.length) { + console.error(JSON.stringify(serious, null, 2)); + process.exit(1); + } + console.log('axe: sin violaciones critical/serious'); + }) + .catch((err) => { console.error(err); process.exit(1); }); + NODE + build-frontend: runs-on: ubuntu-latest needs: validate diff --git a/configs/developer.json b/configs/developer.json index 487ca43..64983f9 100644 --- a/configs/developer.json +++ b/configs/developer.json @@ -16,12 +16,7 @@ "python-poetry", "go", "rust", - "code", - "jetbrains-toolbox", - "postman-bin", - "insomnia", "github-cli", - "gitlab-runner", "kubectl", "helm", "minikube", @@ -30,6 +25,13 @@ "git", "okular" ], + "aur_packages": [ + "code", + "jetbrains-toolbox", + "postman-bin", + "insomnia", + "gitlab-runner" + ], "services": [ "NetworkManager", "sshd", diff --git a/configs/minimal.json b/configs/minimal.json new file mode 100644 index 0000000..ee6857c --- /dev/null +++ b/configs/minimal.json @@ -0,0 +1,30 @@ +{ + "version": "1.0.0", + "hostname": "neubat-min", + "username": "neubat", + "password": "", + "disk": "/dev/sda", + "desktop": "none", + "packages": [ + "vim", + "htop" + ], + "aur_packages": [], + "services": [ + "NetworkManager", + "sshd" + ], + "timezone": "Europe/Madrid", + "locale": "es_ES.UTF-8", + "keyboard": "es", + "encryption": { + "enabled": false, + "method": "keyfile", + "passphrase": "", + "cipher": "aes-xts-plain64", + "key_size": 512 + }, + "snapshots": { + "enabled": false + } +} diff --git a/configs/vm-luks.json b/configs/vm-luks.json new file mode 100644 index 0000000..abce2eb --- /dev/null +++ b/configs/vm-luks.json @@ -0,0 +1,37 @@ +{ + "version": "1.0.0", + "hostname": "neubat-vm-luks", + "username": "neubat", + "password": "", + "disk": "/dev/nvme0n1", + "desktop": "none", + "packages": [ + "htop", + "git", + "curl", + "openssh" + ], + "services": [ + "NetworkManager", + "sshd" + ], + "timezone": "Europe/Madrid", + "locale": "es_ES.UTF-8", + "keyboard": "es", + "encryption": { + "enabled": true, + "method": "keyfile", + "passphrase": "", + "cipher": "aes-xts-plain64", + "key_size": 512 + }, + "snapshots": { + "enabled": true, + "cleanup": { + "hourly": 2, + "daily": 3, + "weekly": 1, + "monthly": 1 + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d80158b..cb6e7b6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,13 +35,13 @@ | Capa | Componente | Tecnología | Función | |------|------------|------------|---------| -| **Presentación** | Portal web | Node.js + Express, SPA vanilla | Interfaz usuario, generación de configs | +| **Presentación** | Portal web | Node.js + Express, SPA React + Vite + shadcn/ui | Interfaz usuario, generación de configs | | **Persistencia** | DB JSON | `portal/data/installations.json` | Registro y seguimiento de instalaciones | -| **Distribución** | Arranque por red | iPXE + HTTP (mirror Arch) | Arranque sin medios físicos | +| **Distribución** | Arranque por red | iPXE + HTTP (mirror Arch / live NEUBAT) | Arranque sin medios físicos | | **Fallback** | GRUB loopback | GRUB2 + ISO en disco | Arranque de ISO sin reescribir USB | -| **Instalación** | Script maestro | Bash + pacstrap | Sistema base desatendido | -| **Configuración** | Módulos de fases | Bash (00–40) + JSON | Personalización por token | -| **Post-instalación** | Portal local | systemd + Node.js | Portal en el sistema instalado, URL única | +| **Instalación** | Script maestro | Bash + archinstall (cuando disponible) + pacstrap | Sistema base desatendido | +| **Configuración** | Módulos de fases | Bash (00–50) + JSON | Personalización por token | +| **Post-instalación** | Portal local + Ansible | systemd + Node.js + Ansible | Portal en el sistema instalado, URL única | ## Secuencia de una instalación diff --git a/docs/INSTALL.md b/docs/INSTALL.md index d2e0cea..a08dad2 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -109,20 +109,22 @@ make release ### 5.1 Crear la instalación -Desde la web (`http:///`) o por API: +Desde la web (`http:///configurar`) o por API: ```bash curl -X POST http://:3000/api/install \ -H 'Content-Type: application/json' \ - -d '{"profile":"production","hostname":"mi-equipo"}' + -d '{"profile":"production","hostname":"mi-equipo","desktop":"hyprland"}' ``` Respuesta: `token`, `config_url`, `boot_url`. +Cuenta de usuario: registro en `/cuenta`. Absorción del sistema actual: genera un código en la cuenta y ejecuta `scripts/neubat-absorb.sh --code … --portal …`. + ### 5.2 Arrancar la máquina destino -- **Por red (recomendado):** encadenar iPXE a `http://:3000/boot/`, o usar `netboot/ipxe/neubat.ipxe` (menú interactivo). -- **ISO híbrida autoinstalable:** descarga `neubat-1.0.0-x86_64.iso` desde la [release v1.0.0](https://github.com/Alexendros/neubat/releases/tag/v1.0.0) y arranca la máquina pasando el token por kernel cmdline: +- **Por red (recomendado):** encadenar iPXE a `http://:3000/boot/`. El script incluye `neubat_token`, `neubat_profile` y `neubat_portal_url`. Para cero toques, publica el live NEUBAT en `NEUBAT_LIVE_DIR` (servido en `/live`) y define `NEUBAT_USE_LIVE=1` o `NEUBAT_LIVE_BASE`. Sin live, el mirror Arch arranca pero requiere ejecutar el instalador a mano o usar la ISO NEUBAT. +- **ISO híbrida autoinstalable:** en `/descargar` el portal verifica SHA-256 antes de guardar. También desde la [release](https://github.com/Alexendros/neubat/releases) con el `.sha256` generado por CI. Arranque: ``` neubat_token= neubat_profile=production neubat_portal_url=http://:3000 @@ -148,19 +150,21 @@ bash scripts/neubat-install.sh [perfil] |------|--------|--------| | 0 | `00-preinstall.sh` | root, Internet, UEFI, herramientas live | | 0b | `20-archinstall.sh` | Descarga config por token o usa perfil local | -| 1 | `10-partition.sh` | GPT: EFI 512M + raíz btrfs + home btrfs + swap 4G | +| 1 | `10-partition.sh` | GPT: EFI 1 GiB + raíz btrfs + home btrfs + swap 4G | | 2 | `20-archinstall.sh` | Mirrors (reflector) + pacstrap + fstab | -| 3 | `30-postinstall.sh` | chroot: locale, usuarios, GRUB, yay, `/etc/neubat-release` | +| 3 | `30-postinstall.sh` | chroot: locale, usuarios, systemd-boot, yay, `/etc/neubat-release` | | 4 | `30-postinstall.sh` | Desktop y paquetes/servicios de la configuración | -| 5 | `40-portal-deploy.sh` | Portal local + `~/NEUBAT-URL.txt` | -| 6 | maestro | Notificación al portal, resumen y reinicio | +| 5 | `35-snapper.sh` | Snapper + snap-pac si `snapshots.enabled` | +| 6 | `40-portal-deploy.sh` | Portal local + `~/NEUBAT-URL.txt` | +| 7 | `50-firstboot-ansible.sh` | Ansible first-boot | +| 8 | maestro | Notificación al portal, resumen y reinicio | ## 6. Esquema de particionado | Partición | Tamaño | FS | Montaje | |-----------|--------|-----|---------| -| p1 (ESP) | 512 MiB | FAT32 | `/boot/efi` | -| p2 (raíz) | 30 GiB (20 GiB si disco < 64 GiB) | btrfs (zstd, noatime) | `/` | +| p1 (ESP) | 1 GiB | FAT32 | `/boot` | +| p2 (raíz) | 19–29 GiB (según tamaño del disco) | btrfs (zstd, noatime) | `/` | | p3 (home) | resto − 4 GiB | btrfs (zstd, noatime) | `/home` | | p4 (swap) | 4 GiB | swap | — | @@ -168,7 +172,7 @@ Los nombres de partición se resuelven con `part_name()` (soporta `/dev/sda1` y ## 6.1 Cifrado de disco LUKS (Fase 6) -NEUBAT puede cifrar las particiones de **raíz** y **home** con LUKS2. La partición EFI (`/boot/efi`) permanece descifrada porque el firmware UEFI debe poder leer el cargador de arranque. +NEUBAT puede cifrar las particiones de **raíz** y **home** con LUKS2. La partición EFI (`/boot`) permanece descifrada porque el firmware UEFI debe poder leer el cargador de arranque (systemd-boot). ### Modos de arranque @@ -215,8 +219,8 @@ Cuando uses `method: "keyfile"`, rota la llave tras el primer arranque: ```bash # Añade una passphrase y elimina el keyfile del slot 0 sudo cryptsetup luksAddKey /dev/nvme0n1p2 -sudo cryptsetup luksRemoveKey /dev/nvme0n1p2 /boot/luks-keyfile -sudo rm /boot/luks-keyfile +sudo cryptsetup luksRemoveKey /dev/nvme0n1p2 /etc/cryptsetup-keys.d/neubat_root.key +sudo rm /etc/cryptsetup-keys.d/neubat_root.key /etc/cryptsetup-keys.d/neubat_home.key ``` Para TPM2 o FIDO2, consulta `systemd-cryptenroll` (fuera del alcance del MVP). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 82e15f0..5d5b871 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # NEUBAT — Roadmap y próximos pasos -## Estado actual (22 de septiembre de 2026) +## Estado actual (24 de septiembre de 2026) | Prioridad | Tarea | Responsable | Estimación | Estado | |-----------|-------|-------------|------------|--------| @@ -16,21 +16,32 @@ | P1 | Rediseño GUI-UX con React + shadcn/ui | Frontend | 10h | ✅ Hecho (20-sep-2026) | | P2 | Soporte de cifrado LUKS en particionado | Dev | 4h | ✅ Hecho (22-sep-2026, PR #17) | | P3 | Snapshots btrfs automáticos pre/post actualización | Dev | 3h | ✅ Hecho (22-sep-2026, PR #18) | -| P2 | Firma y verificación de configuraciones (HMAC) | Dev | 3h | ✅ Hecho (22-sep-2026, PR #18) | +| P2 | Firma y verificación de configuraciones (HMAC) | Dev | 3h | ✅ Hecho (22-sep-2026, PR #18); extendedido a encryption/snapshots (24-sep-2026) | | P3 | Métricas de instalación reportadas al portal | Dev | 2h | ✅ Hecho (22-sep-2026, PR #18) | +| P1 | Listado de instalaciones solo con ADMIN_TOKEN | Dev | 1h | ✅ Hecho (24-sep-2026) | -## Próximos pasos sugeridos +## Objetivos de producto (implementados 24-sep-2026) + +| Fase | Objetivo | Estado | +|------|----------|--------| +| 1 | Saneamiento (HMAC encryption/snapshots, listados privados, docs) | ✅ | +| 2 | Sitio de presentación, tokens OKLCH, WCAG 2.2 AA + axe en CI | ✅ | +| 3 | Panel de usuario con login, perfiles y recomendaciones | ✅ | +| 4 | Configurador (paquetes, WM/escritorio, locale) | ✅ | +| 5 | Motor `archinstall` (opcional) + scripts para LUKS/snapper/portal/AUR | ✅ | +| 6 | Absorber configuración del sistema actual (`scripts/neubat-absorb.sh`) | ✅ | +| 7 | ISO con verificación automática de hash al descargar | ✅ | +| 8 | Netinstall por URL con `neubat_portal_url` y live en `/live` | ✅ | + +## Próximos pasos técnicos | Prioridad | Tarea | Motivación | Estimación | |-----------|-------|------------|------------| | P1 | **Validación end-to-end de LUKS + snapper + HMAC en VM** | Confirmar que las nuevas fases funcionan juntas en un flujo real de instalación | 2h | -| P2 | **Exponer encryption/snapshots en el frontend** | El formulario web actual no deja elegir cifrado ni snapshots; solo se pueden configurar por API | 4h | -| P2 | **CI: añadir `make build-frontend` al workflow** | Garantizar que el build de producción nunca se rompa | 30 min | | P2 | **Rotación automática del keyfile LUKS** | Tras el primer arranque, reemplazar el keyfile de `/boot` por una passphrase o enrolar TPM2/FIDO2 | 3h | | P3 | **Servidor iPXE propio con imágenes cacheadas** | Independencia del mirror upstream de Arch y arranques más rápidos/repetibles | 6h | | P3 | **Perfiles como paquetes versionados (`neubat-profile-*`)** | Distribuir perfiles por separado y permitir comunidad/contribuciones | 8h | | P3 | **Métricas por fase de instalación** | Reportar duración de cada fase (particionado, pacstrap, chroot, etc.) para diagnóstico | 3h | -| P3 | **Integrar Proton Mail app en perfil production** | Cliente de correo cifrado; requiere AUR (`proton-mail`) | 1h | ## Ideas a evaluar @@ -38,3 +49,4 @@ - Instalaciones remotas con consola serie y watchdog. - Dashboard en tiempo real de instalaciones en curso (WebSockets). - Notificaciones por correo/Telegram al completar una instalación. +- Integrar Proton Mail app en perfil production (AUR). diff --git a/netboot/ipxe/neubat.ipxe b/netboot/ipxe/neubat.ipxe index b898a8f..7b5c9ca 100644 --- a/netboot/ipxe/neubat.ipxe +++ b/netboot/ipxe/neubat.ipxe @@ -8,6 +8,12 @@ dhcp || shell +# Portal público (sobrescribir con NEUBAT_PUBLIC_URL en el despliegue) +isset portal-url || set portal-url http://portal.neubat.local:3000 +# Live NEUBAT con hook (recomendado). Si no hay live local, usar mirror Arch. +isset neubat-live || set neubat-live ${portal-url}/live +isset arch-mirror || set arch-mirror https://geo.mirror.pkgbuild.com/iso/latest + :menu menu NEUBAT - Instalacion por Red item --gap -- ---------------- Opciones ---------------- @@ -32,10 +38,15 @@ set neubat_profile minimal goto boot :boot -# Kernel y initramfs oficiales de Arch (mirror geolocalizado) -set base-url https://geo.mirror.pkgbuild.com/iso/latest -kernel ${base-url}/arch/boot/x86_64/vmlinuz-linux initrd=initramfs-linux.img archiso_http_srv=${base-url}/arch/ ip=dhcp net.ifnames=0 console=ttyS0 neubat_profile=${neubat_profile} -initrd ${base-url}/arch/boot/x86_64/initramfs-linux.img +# Preferir live NEUBAT (incluye neubat-autoinstall). Fallback: mirror Arch. +kernel ${neubat-live}/boot/x86_64/vmlinuz-linux initrd=initramfs-linux.img archiso_http_srv=${neubat-live}/ ip=dhcp net.ifnames=0 console=ttyS0 neubat_profile=${neubat_profile} neubat_portal_url=${portal-url} || goto arch_fallback +initrd ${neubat-live}/boot/x86_64/initramfs-linux.img || goto arch_fallback +boot || goto arch_fallback + +:arch_fallback +echo Live NEUBAT no disponible; usando mirror Arch (instalacion manual o con token)... +kernel ${arch-mirror}/arch/boot/x86_64/vmlinuz-linux initrd=initramfs-linux.img archiso_http_srv=${arch-mirror}/arch/ ip=dhcp net.ifnames=0 console=ttyS0 neubat_profile=${neubat_profile} neubat_portal_url=${portal-url} +initrd ${arch-mirror}/arch/boot/x86_64/initramfs-linux.img boot || goto failed :failed diff --git a/portal/frontend/src/App.tsx b/portal/frontend/src/App.tsx index c4929ee..ba2c5af 100644 --- a/portal/frontend/src/App.tsx +++ b/portal/frontend/src/App.tsx @@ -1,18 +1,30 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { Layout } from '@/components/Layout'; -import { HomePage } from '@/pages/HomePage'; +import { AuthProvider } from '@/lib/auth'; +import { LandingPage } from '@/pages/LandingPage'; +import { ConfigurePage } from '@/pages/ConfigurePage'; +import { AccountPage } from '@/pages/AccountPage'; +import { DownloadPage } from '@/pages/DownloadPage'; import { AdminPage } from '@/pages/AdminPage'; +import { HomePage } from '@/pages/HomePage'; function App() { return ( - - - - } /> - } /> - - - + + + + + } /> + } /> + } /> + } /> + } /> + {/* Compatibilidad: formulario clásico */} + } /> + + + + ); } diff --git a/portal/frontend/src/components/Layout.tsx b/portal/frontend/src/components/Layout.tsx index 33c773b..45db6bf 100644 --- a/portal/frontend/src/components/Layout.tsx +++ b/portal/frontend/src/components/Layout.tsx @@ -1,33 +1,65 @@ import { Brand } from './Brand'; import { Button } from '@/components/ui/button'; import { Link, useLocation } from 'react-router-dom'; -import { BookOpen, LayoutDashboard, Shield } from 'lucide-react'; +import { BookOpen, Download, Shield, UserRound, Wand2 } from 'lucide-react'; +import { useAuth } from '@/lib/auth'; +import { useEffect } from 'react'; export function Layout({ children }: { children: React.ReactNode }) { const location = useLocation(); const isAdmin = location.pathname.startsWith('/admin'); + const { user } = useAuth(); + + useEffect(() => { + const titles: Record = { + '/': 'NEUBAT — Arch Linux personalizado', + '/configurar': 'Configurar instalación · NEUBAT', + '/cuenta': 'Cuenta · NEUBAT', + '/descargar': 'Descargar ISO · NEUBAT', + '/admin': 'Administración · NEUBAT', + }; + document.title = titles[location.pathname] || 'NEUBAT'; + }, [location.pathname]); return ( -
+
+ + Saltar al contenido +
-
-
{children}
+
+ {children} +
- NEUBAT v1.0.0 · GPL-3.0 · Wiki + NEUBAT v1.0.0 · GPL-3.0 ·{' '} + + Wiki +
); diff --git a/portal/frontend/src/index.css b/portal/frontend/src/index.css index 2b6549c..92ca97a 100644 --- a/portal/frontend/src/index.css +++ b/portal/frontend/src/index.css @@ -8,24 +8,24 @@ --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-mono: ui-monospace, "Fira Code", Consolas, monospace; - --color-background: #0b0f17; - --color-foreground: #e6edf7; - --color-card: #141b2a; - --color-card-foreground: #e6edf7; - --color-popover: #141b2a; - --color-popover-foreground: #e6edf7; - --color-primary: #38bdf8; - --color-primary-foreground: #020617; - --color-secondary: #1e2740; - --color-secondary-foreground: #e6edf7; - --color-muted: #1e2740; - --color-muted-foreground: #94a3b8; - --color-accent: #818cf8; - --color-accent-foreground: #ffffff; - --color-destructive: #ef4444; - --color-border: #283350; - --color-input: #283350; - --color-ring: #38bdf8; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); --radius-sm: 0.375rem; --radius-md: 0.5rem; @@ -33,48 +33,51 @@ --radius-xl: 0.875rem; } +/* Tema claro: contraste AA/AAA sobre fondo claro */ :root { - color-scheme: dark; - --background: #0b0f17; - --foreground: #e6edf7; - --card: #141b2a; - --card-foreground: #e6edf7; - --popover: #141b2a; - --popover-foreground: #e6edf7; - --primary: #38bdf8; - --primary-foreground: #020617; - --secondary: #1e2740; - --secondary-foreground: #e6edf7; - --muted: #1e2740; - --muted-foreground: #94a3b8; - --accent: #818cf8; - --accent-foreground: #ffffff; - --destructive: #ef4444; - --border: #283350; - --input: #283350; - --ring: #38bdf8; + color-scheme: light; + --background: oklch(0.985 0.01 250); + --foreground: oklch(0.22 0.03 255); + --card: oklch(1 0 0); + --card-foreground: oklch(0.22 0.03 255); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.22 0.03 255); + --primary: oklch(0.45 0.14 240); + --primary-foreground: oklch(0.99 0.01 250); + --secondary: oklch(0.94 0.02 250); + --secondary-foreground: oklch(0.28 0.04 255); + --muted: oklch(0.94 0.015 250); + --muted-foreground: oklch(0.42 0.03 255); + --accent: oklch(0.55 0.16 285); + --accent-foreground: oklch(0.99 0.01 285); + --destructive: oklch(0.5 0.2 25); + --border: oklch(0.88 0.02 250); + --input: oklch(0.88 0.02 250); + --ring: oklch(0.45 0.14 240); --radius: 0.625rem; } +/* Tema oscuro por defecto en la app */ .dark { - --background: #0b0f17; - --foreground: #e6edf7; - --card: #141b2a; - --card-foreground: #e6edf7; - --popover: #141b2a; - --popover-foreground: #e6edf7; - --primary: #38bdf8; - --primary-foreground: #020617; - --secondary: #1e2740; - --secondary-foreground: #e6edf7; - --muted: #1e2740; - --muted-foreground: #94a3b8; - --accent: #818cf8; - --accent-foreground: #ffffff; - --destructive: #ef4444; - --border: #283350; - --input: #283350; - --ring: #38bdf8; + color-scheme: dark; + --background: oklch(0.16 0.025 255); + --foreground: oklch(0.94 0.015 250); + --card: oklch(0.2 0.03 255); + --card-foreground: oklch(0.94 0.015 250); + --popover: oklch(0.2 0.03 255); + --popover-foreground: oklch(0.94 0.015 250); + --primary: oklch(0.78 0.12 220); + --primary-foreground: oklch(0.18 0.03 255); + --secondary: oklch(0.26 0.035 255); + --secondary-foreground: oklch(0.94 0.015 250); + --muted: oklch(0.26 0.035 255); + --muted-foreground: oklch(0.72 0.03 250); + --accent: oklch(0.7 0.14 285); + --accent-foreground: oklch(0.99 0.01 285); + --destructive: oklch(0.65 0.2 25); + --border: oklch(0.32 0.04 255); + --input: oklch(0.32 0.04 255); + --ring: oklch(0.78 0.12 220); } @layer base { @@ -92,6 +95,36 @@ display: flex; flex-direction: column; } + :focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +.skip-link { + position: absolute; + left: 1rem; + top: -100%; + z-index: 100; + padding: 0.5rem 1rem; + background: var(--primary); + color: var(--primary-foreground); + border-radius: var(--radius); + font-weight: 600; +} + +.skip-link:focus { + top: 1rem; } ::-webkit-scrollbar { @@ -100,14 +133,14 @@ } ::-webkit-scrollbar-track { - background: #141b2a; + background: var(--card); } ::-webkit-scrollbar-thumb { - background: #283350; + background: var(--border); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { - background: #475569; + background: var(--muted-foreground); } diff --git a/portal/frontend/src/lib/api.ts b/portal/frontend/src/lib/api.ts index b48edf8..f8c85de 100644 --- a/portal/frontend/src/lib/api.ts +++ b/portal/frontend/src/lib/api.ts @@ -1,15 +1,29 @@ -import type { HealthResponse, InstallRequest, InstallResponse, Installation } from '@/types'; +import type { + HealthResponse, + InstallRequest, + InstallResponse, + Installation, + Recommendation, + ReleaseInfo, + SavedConfig, + SystemCopy, + User, +} from '@/types'; const API_BASE = ''; async function fetchJson(path: string, init?: RequestInit): Promise { const res = await fetch(`${API_BASE}${path}`, { - headers: { 'Content-Type': 'application/json' }, + credentials: 'include', ...init, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers || {}), + }, }); const data = await res.json().catch(() => ({})); if (!res.ok) { - throw new Error(data.error || `Error ${res.status}`); + throw new Error((data as { error?: string }).error || `Error ${res.status}`); } return data as T; } @@ -23,7 +37,10 @@ export const api = { body: JSON.stringify(body), }), - installations: () => fetchJson('/api/installations'), + adminInstallations: (adminToken: string) => + fetchJson('/api/admin/installations', { + headers: { Authorization: `Bearer ${adminToken}` }, + }), complete: (token: string, status: Installation['status'], hostname?: string) => fetchJson<{ success: boolean }>('/api/complete', { @@ -49,4 +66,48 @@ export const api = { method: 'POST', headers: { Authorization: `Bearer ${adminToken}` }, }), + + register: (email: string, password: string, display_name?: string) => + fetchJson<{ success: boolean; user: User }>('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ email, password, display_name }), + }), + + login: (email: string, password: string) => + fetchJson<{ success: boolean; user: User }>('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }), + + logout: () => + fetchJson<{ success: boolean }>('/api/auth/logout', { method: 'POST' }), + + me: () => fetchJson<{ user: User }>('/api/auth/me'), + + recommendations: () => + fetchJson<{ recommendations: Recommendation[] }>('/api/account/recommendations'), + + configs: () => fetchJson<{ configs: SavedConfig[] }>('/api/account/configs'), + + saveConfig: (body: Record) => + fetchJson<{ success: boolean; config: SavedConfig }>('/api/account/configs', { + method: 'POST', + body: JSON.stringify(body), + }), + + copies: () => + fetchJson<{ copies: SystemCopy[]; note: string }>('/api/account/copies'), + + absorbCode: () => + fetchJson<{ code: string; expires_in_seconds: number; usage: string }>( + '/api/account/absorb-code', + { method: 'POST' } + ), + + confirmCopy: (id: string) => + fetchJson<{ success: boolean; copy: SystemCopy }>(`/api/account/copies/${id}/confirm`, { + method: 'POST', + }), + + releases: () => fetchJson('/api/account/releases'), }; diff --git a/portal/frontend/src/lib/auth.tsx b/portal/frontend/src/lib/auth.tsx new file mode 100644 index 0000000..325945d --- /dev/null +++ b/portal/frontend/src/lib/auth.tsx @@ -0,0 +1,47 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { api } from '@/lib/api'; +import type { User } from '@/types'; + +interface AuthContextValue { + user: User | null; + loading: boolean; + refresh: () => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const data = await api.me(); + setUser(data.user); + } catch { + setUser(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const logout = useCallback(async () => { + await api.logout(); + setUser(null); + }, []); + + const value = useMemo(() => ({ user, loading, refresh, logout }), [user, loading, refresh, logout]); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth requiere AuthProvider'); + return ctx; +} diff --git a/portal/frontend/src/pages/AccountPage.tsx b/portal/frontend/src/pages/AccountPage.tsx new file mode 100644 index 0000000..7531887 --- /dev/null +++ b/portal/frontend/src/pages/AccountPage.tsx @@ -0,0 +1,200 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '@/lib/api'; +import { useAuth } from '@/lib/auth'; +import type { SavedConfig, SystemCopy } from '@/types'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; + +export function AccountPage() { + const { user, loading, refresh, logout } = useAuth(); + const [mode, setMode] = useState<'login' | 'register'>('login'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [error, setError] = useState(null); + const [configs, setConfigs] = useState([]); + const [copies, setCopies] = useState([]); + const [copyNote, setCopyNote] = useState(''); + const [absorbUsage, setAbsorbUsage] = useState(null); + + useEffect(() => { + if (!user) return; + api.configs().then((r) => setConfigs(r.configs)).catch(() => {}); + api.copies().then((r) => { + setCopies(r.copies); + setCopyNote(r.note); + }).catch(() => {}); + }, [user]); + + async function submitAuth(e: React.FormEvent) { + e.preventDefault(); + setError(null); + try { + if (mode === 'login') await api.login(email, password); + else await api.register(email, password, displayName || undefined); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error de autenticación'); + } + } + + async function createAbsorbCode() { + const r = await api.absorbCode(); + setAbsorbUsage(r.usage); + } + + if (loading) { + return

Cargando sesión…

; + } + + if (!user) { + return ( +
+

Cuenta NEUBAT

+ + + {mode === 'login' ? 'Iniciar sesión' : 'Crear cuenta'} + + Guarda configuraciones, copias del sistema y recomendaciones en tu perfil. + + + +
+ {mode === 'register' && ( +
+ + setDisplayName(e.target.value)} /> +
+ )} +
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ {error && ( +

+ {error} +

+ )} + +
+ +
+
+
+ ); + } + + return ( +
+
+
+

Hola, {user.display_name}

+

{user.email}

+
+
+ + +
+
+ + + + Configuraciones guardadas + + + {configs.length === 0 ? ( +

Aún no hay configuraciones.

+ ) : ( + configs.map((c) => ( +
+
+
{c.name}
+
+ {c.desktop || c.profile} · {c.packages?.length || 0} paquetes +
+
+ {c.profile} +
+ )) + )} +
+
+ + + + Copias del sistema + {copyNote} + + + + {absorbUsage && ( +

+ {absorbUsage} +

+ )} + {copies.map((c) => ( +
+
+
{c.desktop}
+
+ {c.packages.length} paquetes · {c.status} +
+
+ {c.status === 'pending_confirmation' && ( + + )} +
+ ))} +
+
+
+ ); +} diff --git a/portal/frontend/src/pages/AdminPage.tsx b/portal/frontend/src/pages/AdminPage.tsx index 79102a1..c13adc0 100644 --- a/portal/frontend/src/pages/AdminPage.tsx +++ b/portal/frontend/src/pages/AdminPage.tsx @@ -51,7 +51,7 @@ export function AdminPage() { setLoading(true); setError(null); try { - const data = await api.installations(); + const data = await api.adminInstallations(token); setInstallations(data); setLoggedIn(true); } catch (err) { @@ -65,7 +65,7 @@ export function AdminPage() { async function refresh() { setLoading(true); try { - const data = await api.installations(); + const data = await api.adminInstallations(token); setInstallations(data); } catch (err) { setError(err instanceof Error ? err.message : 'Error cargando datos'); diff --git a/portal/frontend/src/pages/ConfigurePage.tsx b/portal/frontend/src/pages/ConfigurePage.tsx new file mode 100644 index 0000000..4d3b383 --- /dev/null +++ b/portal/frontend/src/pages/ConfigurePage.tsx @@ -0,0 +1,365 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '@/lib/api'; +import { useAuth } from '@/lib/auth'; +import type { InstallRequest, InstallResponse, Recommendation } from '@/types'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { CheckCircle, Copy, History, Shield, Terminal } from 'lucide-react'; + +const DESKTOPS = [ + { value: 'none', label: 'Sin escritorio' }, + { value: 'kde', label: 'KDE Plasma' }, + { value: 'gnome', label: 'GNOME' }, + { value: 'xfce', label: 'Xfce' }, + { value: 'hyprland', label: 'Hyprland' }, + { value: 'sway', label: 'Sway' }, + { value: 'i3', label: 'i3' }, + { value: 'niri', label: 'niri' }, +]; + +const PACKAGE_GROUPS: Record = { + base: ['base-devel', 'git', 'vim', 'htop', 'reflector'], + red: ['networkmanager', 'openssh', 'wireguard-tools'], + desarrollo: ['nodejs', 'npm', 'python', 'go', 'rust'], + multimedia: ['firefox', 'vlc', 'pipewire', 'wireplumber'], +}; + +export function ConfigurePage() { + const { user } = useAuth(); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [enableEncryption, setEnableEncryption] = useState(true); + const [encryptionMethod, setEncryptionMethod] = useState<'keyfile' | 'prompt'>('keyfile'); + const [enableSnapshots, setEnableSnapshots] = useState(true); + const [desktop, setDesktop] = useState('kde'); + const [selectedPackages, setSelectedPackages] = useState(['git', 'htop']); + const [packageQuery, setPackageQuery] = useState(''); + const [recommendations, setRecommendations] = useState([]); + const [saveName, setSaveName] = useState(''); + + useEffect(() => { + api.recommendations().then((r) => setRecommendations(r.recommendations)).catch(() => {}); + }, []); + + const catalog = useMemo(() => Object.values(PACKAGE_GROUPS).flat(), []); + const filteredCatalog = catalog.filter((p) => p.includes(packageQuery.toLowerCase())); + + function togglePackage(pkg: string) { + setSelectedPackages((prev) => + prev.includes(pkg) ? prev.filter((x) => x !== pkg) : [...prev, pkg] + ); + } + + function applyRecommendation(rec: Recommendation) { + if (rec.desktop) setDesktop(rec.desktop); + if (rec.packages?.length) setSelectedPackages(rec.packages); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + setError(null); + setResult(null); + + const form = new FormData(e.currentTarget); + const extra = ((form.get('packages_extra') as string) || '') + .split(/\s+/) + .filter(Boolean); + const body: InstallRequest = { + profile: (form.get('profile') as string) || 'base', + hostname: (form.get('hostname') as string) || undefined, + username: (form.get('username') as string) || undefined, + desktop, + packages: [...new Set([...selectedPackages, ...extra])], + locale: (form.get('locale') as string) || 'es_ES.UTF-8', + keyboard: (form.get('keyboard') as string) || 'es', + timezone: (form.get('timezone') as string) || 'Europe/Madrid', + }; + if (enableEncryption) { + body.encryption = { + enabled: true, + method: encryptionMethod === 'prompt' ? 'interactive' : 'keyfile', + }; + } + if (enableSnapshots) body.snapshots = { enabled: true }; + + try { + const data = await api.install(body); + setResult(data); + if (user && saveName.trim()) { + await api.saveConfig({ name: saveName.trim(), ...body }); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Error desconocido'); + } finally { + setSubmitting(false); + } + } + + function copy(text: string) { + navigator.clipboard.writeText(text); + } + + const base = window.location.origin; + + return ( +
+
+
+

+ Configurar instalación +

+

+ Elige escritorio, paquetes y opciones. Genera una URL iPXE o guarda el perfil en tu cuenta. +

+ {!user && ( +

+ Puedes generar una instalación sin sesión.{' '} + + Inicia sesión + {' '} + para guardar configuraciones y absorciones. +

+ )} +
+ + + + + + Formulario + + Los campos alimentan archinstall y los scripts NEUBAT. + + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ Paquetes del repositorio + + setPackageQuery(e.target.value)} + placeholder="firefox, git…" + /> +
+ {filteredCatalog.map((pkg) => { + const on = selectedPackages.includes(pkg); + return ( + + ); + })} +
+
+ + +
+
+ +
+

+ + Opciones avanzadas +

+
+ setEnableEncryption(e.target.checked)} + className="mt-1 h-4 w-4" + /> +
+ + {enableEncryption && ( + + )} +
+
+
+ setEnableSnapshots(e.target.checked)} + className="mt-1 h-4 w-4" + /> + +
+
+ + {user && ( +
+ + setSaveName(e.target.value)} + placeholder="mi-laptop-hyprland" + /> +
+ )} + + +
+ + {error && ( +
+ {error} +
+ )} + + {result && ( +
+
+ + Instalación creada +
+ + + +

+ También puedes{' '} + + descargar la ISO + {' '} + con verificación de hash. +

+
+ )} +
+
+
+ + +
+ ); +} + +function CopyField({ label, value, onCopy }: { label: string; value: string; onCopy: (v: string) => void }) { + return ( +
+ +
+ {value} + +
+
+ ); +} diff --git a/portal/frontend/src/pages/DownloadPage.tsx b/portal/frontend/src/pages/DownloadPage.tsx new file mode 100644 index 0000000..31d3345 --- /dev/null +++ b/portal/frontend/src/pages/DownloadPage.tsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import type { ReleaseInfo } from '@/types'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Download, ShieldCheck } from 'lucide-react'; + +async function sha256Hex(buffer: ArrayBuffer): Promise { + const hash = await crypto.subtle.digest('SHA-256', buffer); + return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +function parseSha256File(text: string, fileNameHint: string): string | null { + const lines = text.trim().split(/\r?\n/); + for (const line of lines) { + const m = line.match(/^([0-9a-f]{64})\s+\*?(\S+)/i); + if (m) { + if (!fileNameHint || m[2].includes(fileNameHint) || m[2].endsWith('.iso')) { + return m[1].toLowerCase(); + } + } + const only = line.trim().match(/^[0-9a-f]{64}$/i); + if (only) return only[0].toLowerCase(); + } + return null; +} + +export function DownloadPage() { + const [releases, setReleases] = useState(null); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + api.releases().then(setReleases).catch((err) => setError(err.message)); + }, []); + + async function downloadVerified(isoUrl: string, shaUrl: string, fileName: string) { + setBusy(true); + setError(null); + setStatus('Descargando suma de verificación…'); + try { + const shaRes = await fetch(shaUrl); + if (!shaRes.ok) throw new Error(`No se pudo obtener el hash (${shaRes.status})`); + const shaText = await shaRes.text(); + const expected = parseSha256File(shaText, fileName); + if (!expected) throw new Error('No se encontró un SHA-256 válido en el fichero de sumas'); + + setStatus('Descargando ISO… esto puede tardar'); + const isoRes = await fetch(isoUrl); + if (!isoRes.ok) throw new Error(`Descarga de ISO fallida (${isoRes.status})`); + const buffer = await isoRes.arrayBuffer(); + + setStatus('Comprobando SHA-256…'); + const actual = await sha256Hex(buffer); + if (actual !== expected) { + throw new Error(`Hash no coincide. Esperado ${expected.slice(0, 12)}…, obtenido ${actual.slice(0, 12)}…`); + } + + const blob = new Blob([buffer], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); + setStatus('ISO verificada y guardada.'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error en la descarga'); + setStatus(null); + } finally { + setBusy(false); + } + } + + return ( +
+
+

Descargar ISO

+

+ El navegador descarga el fichero, calcula SHA-256 con crypto.subtle y solo entonces + ofrece guardarlo. No hace falta ejecutar sha256sum a mano. +

+
+ + {error && ( +

+ {error} +

+ )} + {status && ( +

+ {status} +

+ )} + + + + + + ISO NEUBAT (híbrida autoinstalable) + + + Incluye el hook de autoinstalación. Versión {releases?.neubat.version || '…'}. + + + + + + + + + + ISO oficial Arch Linux + + Base del live. Útil para instalación manual con los scripts; el hash se toma del mirror oficial. + + + + + + +
+ ); +} diff --git a/portal/frontend/src/pages/HomePage.test.tsx b/portal/frontend/src/pages/HomePage.test.tsx index 28182b7..27bdea3 100644 --- a/portal/frontend/src/pages/HomePage.test.tsx +++ b/portal/frontend/src/pages/HomePage.test.tsx @@ -3,40 +3,44 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { BrowserRouter } from 'react-router-dom'; import { HomePage } from './HomePage'; +import { AuthProvider } from '@/lib/auth'; function Wrapper({ children }: { children: React.ReactNode }) { - return {children}; + return ( + + {children} + + ); } describe('HomePage', () => { beforeEach(() => { - globalThis.fetch = vi.fn(); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ error: 'Sin sesión' }), + }); }); afterEach(() => { vi.restoreAllMocks(); }); - it('renderiza el formulario y la lista de instalaciones', async () => { - (globalThis.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => [ - { token: 'abc123', profile: 'base', status: 'pending', created_at: new Date().toISOString() }, - ], - }); - + it('renderiza el formulario de instalación', () => { render(, { wrapper: Wrapper }); expect(screen.getByRole('button', { name: /Generar instalación/i })).toBeInTheDocument(); - - await waitFor(() => { - expect(screen.getByText('base')).toBeInTheDocument(); - }); + expect(screen.getByRole('heading', { name: /Nueva instalación/i })).toBeInTheDocument(); + expect(screen.queryByText(/Instalaciones recientes/i)).not.toBeInTheDocument(); }); it('crea una instalación y muestra resultados', async () => { - (globalThis.fetch as any) - .mockResolvedValueOnce({ ok: true, json: async () => [] }) + (globalThis.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: 'Sin sesión' }), + }) .mockResolvedValueOnce({ ok: true, json: async () => ({ @@ -47,8 +51,7 @@ describe('HomePage', () => { boot_url: '/boot/tokentest', message: 'Creada', }), - }) - .mockResolvedValueOnce({ ok: true, json: async () => [] }); + }); render(, { wrapper: Wrapper }); @@ -60,10 +63,10 @@ describe('HomePage', () => { expect(screen.getByText(/boot\/tokentest/i)).toBeInTheDocument(); }); - // Verifica que el POST incluye encryption y snapshots por defecto - const calls = (globalThis.fetch as any).mock.calls; - const postCall = calls.find((c: any[]) => c[1]?.method === 'POST'); - const body = JSON.parse(postCall[1].body); + const calls = (globalThis.fetch as ReturnType).mock.calls; + const postCall = calls.find((c: unknown[]) => (c[1] as RequestInit | undefined)?.method === 'POST'); + expect(postCall).toBeTruthy(); + const body = JSON.parse((postCall![1] as RequestInit).body as string); expect(body.encryption).toEqual({ enabled: true, method: 'keyfile' }); expect(body.snapshots).toEqual({ enabled: true }); }); diff --git a/portal/frontend/src/pages/HomePage.tsx b/portal/frontend/src/pages/HomePage.tsx index 0aa1022..0ecaaa9 100644 --- a/portal/frontend/src/pages/HomePage.tsx +++ b/portal/frontend/src/pages/HomePage.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { api } from '@/lib/api'; -import type { Installation, InstallRequest, InstallResponse } from '@/types'; +import type { InstallRequest, InstallResponse } from '@/types'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; @@ -12,20 +12,9 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Badge } from '@/components/ui/badge'; -import { Skeleton } from '@/components/ui/skeleton'; -import { CheckCircle, Copy, Server, Terminal, Wifi, Shield, History } from 'lucide-react'; - -const statusColors: Record = { - pending: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20', - downloaded: 'bg-violet-500/10 text-violet-400 border-violet-500/20', - completed: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', - failed: 'bg-red-500/10 text-red-400 border-red-500/20', -}; +import { CheckCircle, Copy, Terminal, Wifi, Shield, History } from 'lucide-react'; export function HomePage() { - const [installations, setInstallations] = useState([]); - const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); @@ -33,21 +22,6 @@ export function HomePage() { const [encryptionMethod, setEncryptionMethod] = useState<'keyfile' | 'passphrase'>('keyfile'); const [enableSnapshots, setEnableSnapshots] = useState(true); - useEffect(() => { - loadInstallations(); - }, []); - - async function loadInstallations() { - try { - const data = await api.installations(); - setInstallations(data.slice().reverse()); - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - } - async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setSubmitting(true); @@ -74,7 +48,6 @@ export function HomePage() { try { const data = await api.install(body); setResult(data); - loadInstallations(); } catch (err) { setError(err instanceof Error ? err.message : 'Error desconocido'); } finally { @@ -90,9 +63,11 @@ export function HomePage() { return (
-
+
-

Nueva instalación

+

+ Nueva instalación +

Configura el sistema, obtén tu URL única y arranca por iPXE. Sin USB, sin intervención.

@@ -112,7 +87,7 @@ export function HomePage() {