diff --git a/.dockerignore b/.dockerignore index c9b5ab6..27510b7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,24 +1,24 @@ -# No enviar al contexto de build lo que se regenera o no hace falta en la imagen. +# Keep out of the build context anything that is regenerated or not needed in the image. node_modules .next .git .gitignore -# Secretos: NUNCA deben acabar en la imagen (se inyectan por entorno en runtime). +# Secrets: must NEVER end up in the image (they are injected via the environment at runtime). .env .env.* !.env.example -# Documentación y herramientas de desarrollo. +# Documentation and development tooling. docs *.md .claude .agents skills-lock.json -# Datos locales: copias de seguridad (volcados con datos personales), adjuntos de -# desarrollo y claves. `COPY . .` los metería en el builder y en su caché. `**/` cubre -# también los subdirectorios. +# Local data: backups (dumps with personal data), development attachments and keys. +# `COPY . .` would put them in the builder and its cache. `**/` also covers +# subdirectories. backups **/*.sql.gz **/*.tar.gz @@ -26,12 +26,12 @@ uploads data **/*.pem -# Lo que la imagen no usa: tests, cobertura y scripts que se ejecutan en el host. +# Things the image does not use: tests, coverage and scripts that run on the host. coverage test scripts install.sh -# Logs y temporales +# Logs and temporary files npm-debug.log* *.tsbuildinfo diff --git a/.env.example b/.env.example index ebcd4d9..b8694f3 100644 --- a/.env.example +++ b/.env.example @@ -1,51 +1,62 @@ -# Plantilla de variables de entorno de orgtable. -# En una instalación real, install.sh genera un .env con secretos aleatorios. -# Para desarrollo local, copia este archivo a .env y ajusta los valores. +# orgtable environment variable template. +# In a real installation, install.sh generates a .env with random secrets. +# For local development, copy this file to .env and adjust the values. # --- PostgreSQL -------------------------------------------------------------- POSTGRES_USER=orgtable POSTGRES_PASSWORD=changeme POSTGRES_DB=orgtable -# URL de conexión que usa la app. En docker-compose el host es "postgres" (nombre -# del servicio). En desarrollo local suele ser localhost con el puerto mapeado. +# Connection URL used by the app. In docker-compose the host is "postgres" (the service +# name). In local development it is usually localhost with the mapped port. DATABASE_URL=postgresql://orgtable:changeme@postgres:5432/orgtable -# --- Aplicación -------------------------------------------------------------- -# Secreto para firmar las sesiones JWT. OBLIGATORIO, mínimo 16 caracteres: sin él (o -# con uno más corto) la app arranca, pero no puede crear sesiones y el login y el -# asistente inicial fallan. Genera uno con: openssl rand -hex 32 +# --- Application ------------------------------------------------------------- +# Secret used to sign the JWT sessions. REQUIRED, at least 16 characters: without it (or +# with a shorter one) the app starts, but it cannot create sessions and both login and +# the setup wizard fail. Generate one with: openssl rand -hex 32 SESSION_SECRET= -# Puerto expuesto en el host. Admite también «127.0.0.1:3000» para publicar la app solo -# en local, detrás de un proxy inverso (ver docs/self-hosting.md). +# Port exposed on the host. Also accepts "127.0.0.1:3000" to publish the app on +# localhost only, behind a reverse proxy (see docs/self-hosting.md). APP_PORT=3000 -# Zona horaria de las fechas que formatea el servidor (IANA). Por defecto Atlantic/Canary. +# Time zone (IANA) for dates formatted by the server. Defaults to Atlantic/Canary. # ORGTABLE_TIMEZONE=Atlantic/Canary -# Número de proxies de confianza delante de la app (entero >= 0). Con 0 (por defecto) se -# ignora X-Forwarded-For y el límite de intentos de login va solo por email. Con N > 0 -# se toma la IP N-ésima de X-Forwarded-For contando por la derecha (1 = un solo proxy -# inverso, p. ej. Caddy o nginx). Ver docs/self-hosting.md. +# Number of trusted proxies in front of the app (integer >= 0). With 0 (the default) +# X-Forwarded-For is ignored and the login attempt limit is per email only. With N > 0 +# the N-th IP of X-Forwarded-For counting from the right is used (1 = a single reverse +# proxy, e.g. Caddy or nginx). See docs/self-hosting.md. # TRUSTED_PROXY_HOPS=0 -# Directorio donde se guardan los adjuntos de las tarjetas. En docker-compose es un -# volumen persistente montado en /data/uploads (valor por defecto). En desarrollo -# local, si no se define, se usa ./uploads. Ver docs/attachments.md. +# Directory where card attachments are stored. In docker-compose it is a persistent +# volume mounted at /data/uploads (the default). In local development, if unset, +# ./uploads is used. See docs/attachments.md. # ORGTABLE_UPLOAD_DIR=/data/uploads -# --- Tiempo real (opcional) -------------------------------------------------- -# Sincronización en vivo entre usuarios. Por defecto funciona en proceso (un único -# contenedor de app), sin configuración. Si despliegas VARIAS réplicas de la app tras -# un balanceador, pon esto a 1 para propagar los eventos entre instancias mediante -# PostgreSQL LISTEN/NOTIFY (no hace falta Redis). Ver docs/realtime.md. +# --- Real time (optional) ---------------------------------------------------- +# Live sync between users. By default it works in-process (a single app container), +# with no configuration. If you deploy SEVERAL app replicas behind a load balancer, set +# this to 1 to propagate events between instances via PostgreSQL LISTEN/NOTIFY (no +# Redis needed). See docs/realtime.md. # ORGTABLE_REALTIME_PG_NOTIFY=0 -# --- Recordatorios de vencimiento (opcional) --------------------------------- -# Si defines un secreto, se habilita el endpoint POST /api/cron/reminders, que genera -# notificaciones de tarjetas que vencen pronto o están vencidas. Lo debe disparar un -# programador EXTERNO (cron del sistema, etc.) enviando el secreto como Bearer: +# --- Due-date reminders (optional) ------------------------------------------- +# Setting a secret enables the POST /api/cron/reminders endpoint, which creates +# notifications for cards that are due soon or overdue. It must be triggered by an +# EXTERNAL scheduler (system cron, etc.) sending the secret as a Bearer token: # curl -X POST -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/reminders -# install.sh lo genera solo; a mano: openssl rand -hex 32. Ver docs/reminders.md. +# install.sh generates it for you; by hand: openssl rand -hex 32. See docs/reminders.md. # CRON_SECRET= + +# --- Privacy and open source (optional) -------------------------------------- +# Days the audit log is kept (it includes IPs if TRUSTED_PROXY_HOPS > 0). It is purged +# by POST /api/cron/maintenance, with the same CRON_SECRET; schedule it once a day. +# Integer >= 1. See docs/privacy.md. +# ORGTABLE_AUDIT_RETENTION_DAYS=90 + +# "Source code" link shown by the app. The AGPL (section 13) requires offering the source +# to whoever uses the app over a network: if you deploy a MODIFIED version, point this +# at your fork. Empty = the original repository. +# ORGTABLE_SOURCE_URL=https://github.com/Aimarmndz/orgtable diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b36d7cc..64d1c15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: branches: [main] pull_request: -# Si llegan pushes seguidos al mismo ref, cancela la ejecución anterior. +# If several pushes arrive for the same ref, cancel the previous run. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -14,8 +14,8 @@ jobs: build-and-test: runs-on: ubuntu-latest - # PostgreSQL efímero para los tests de integración. La base de datos se llama - # `orgtable_test` (el guardia de los tests exige que acabe en `_test`). + # Ephemeral PostgreSQL for the integration tests. The database is called + # `orgtable_test` (the test guard requires the name to end in `_test`). services: postgres: image: postgres:17 @@ -32,41 +32,50 @@ jobs: --health-retries 10 env: - # Apunta al Postgres de servicio. El build no se conecta (rutas dinámicas); los - # tests de integración sí, y `prisma migrate deploy` crea el esquema ahí. + # Points at the service Postgres. The build does not connect (dynamic routes); the + # integration tests do, and `prisma migrate deploy` creates the schema there. DATABASE_URL: postgresql://orgtable:orgtable@localhost:5432/orgtable_test steps: - uses: actions/checkout@v5 - # shellcheck viene instalado en ubuntu-latest. Los scripts de operación tocan datos - # de producción (backups, restauración), así que un error de shell no puede pasar. + # shellcheck ships with ubuntu-latest. The ops scripts touch production data + # (backups, restore), so a shell error must not slip through. - name: Shellcheck run: shellcheck scripts/*.sh install.sh docker-entrypoint.sh - # El contenedor app solo recibe las variables listadas en `environment:`. Si alguna - # se cae del compose, la función que depende de ella deja de funcionar en Docker sin - # ningún error (p. ej. sin CRON_SECRET los recordatorios responden siempre 503). + # The app container only receives the variables listed under `environment:`. If one + # is dropped from the compose file, the feature that depends on it silently stops + # working in Docker (e.g. without CRON_SECRET reminders always answer 503). - name: Check compose passes env vars to app run: | - config="$(CRON_SECRET=probe TRUSTED_PROXY_HOPS=7 ORGTABLE_TIMEZONE=Europe/Madrid docker compose config)" + config="$(CRON_SECRET=probe TRUSTED_PROXY_HOPS=7 ORGTABLE_TIMEZONE=Europe/Madrid \ + ORGTABLE_AUDIT_RETENTION_DAYS=45 ORGTABLE_SOURCE_URL=https://example.org/fork \ + docker compose config)" grep -q 'CRON_SECRET: probe' <<<"$config" grep -q 'TRUSTED_PROXY_HOPS: "7"' <<<"$config" grep -q 'ORGTABLE_TIMEZONE: Europe/Madrid' <<<"$config" - - # La licencia vigente es la de LICENSE (ADR 0016). Falla si vuelve a aparecer la - # antigua (AGPL) u «open source» fuera del ADR sustituido, del registro de decisiones y - # del test que comprueba precisamente que no aparecen en la interfaz. - - name: Check license references + grep -q 'ORGTABLE_AUDIT_RETENTION_DAYS: "45"' <<<"$config" + grep -q 'ORGTABLE_SOURCE_URL: https://example.org/fork' <<<"$config" + + # The current license is AGPL-3.0-or-later with an attribution term (section 7(b)) + # at the end of LICENSE (see docs/DECISIONES.md, 2026-10-14). Fails if package.json + # and LICENSE stop matching, if the attribution term is lost, or if leftovers of + # the proprietary license reappear outside the historical record. + - name: Check license run: | - if git grep -n -E 'AGPL|[Oo]pen[ -][Ss]ource' -- \ + grep -q '"license": "AGPL-3.0-or-later"' package.json + head -n 1 LICENSE | grep -q 'GNU AFFERO GENERAL PUBLIC LICENSE' + grep -q 'ADDITIONAL TERMS UNDER SECTION 7(b) OF THE GNU AGPL' LICENSE + grep -q 'Created by Aimar (github.com/Aimarmndz)' LICENSE + if git grep -n -E 'MARA-KASSA|UNLICENSED|[Tt]odos los derechos reservados' -- \ ':!docs/decisions/0005-agpl.md' ':!docs/decisions/0016-licencia-propietaria.md' \ - ':!docs/DECISIONES.md' ':!.github/workflows/ci.yml' ':!test/metadata.test.ts'; then - echo "Hay referencias a una licencia que no es la vigente (ver LICENSE)." >&2 + ':!docs/DECISIONES.md' ':!.github/workflows/ci.yml' ':!CLAUDE.md'; then + echo "Leftovers of the previous license remain (the current one is in LICENSE)." >&2 exit 1 fi - # Instala la versión de pnpm fijada en `packageManager` (package.json). + # Installs the pnpm version pinned in `packageManager` (package.json). - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v5 @@ -77,7 +86,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # Falla si hay vulnerabilidades altas o críticas en las dependencias de producción. + # Fails on high or critical vulnerabilities in production dependencies. - name: Audit dependencies run: pnpm audit --prod --audit-level high diff --git a/.gitignore b/.gitignore index 6c06753..49dd0e5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,11 +20,11 @@ # production /build -# adjuntos subidos en desarrollo (en producción es un volumen Docker) +# attachments uploaded in development (a Docker volume in production) /uploads /data -# backups locales de la base de datos +# local database backups /backups *.sql.gz @@ -38,14 +38,14 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files: se ignoran los reales (contienen secretos), pero se versiona la plantilla +# env files: the real ones are ignored (they hold secrets), the template is versioned .env* !.env.example # vercel .vercel -# herramientas de desarrollo locales (skills de Claude Code, no parte del proyecto) +# local development tooling (Claude Code skills, not part of the project) .claude/ .agents/ skills-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2d101d5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the +project uses [Semantic Versioning](https://semver.org/). While it is `0.x`, a minor +version may include breaking changes; they are flagged under "Changed". The reasoning +behind each decision is in [`docs/DECISIONES.md`](./docs/DECISIONES.md). + +## [0.2.0] — 2026-10-14 + +### Added + +- Optional open sign-up per instance (`/register`), with consent and anti-abuse limits. + Closed by default. +- Operator legal details in *Admin › Instance* and public privacy, terms, cookies and + legal notice pages. +- Redesigned settings: profile, password change, open sessions, appearance, personal + data download and account deletion. +- Scheduled cleanup `POST /api/cron/maintenance` and the + `ORGTABLE_AUDIT_RETENTION_DAYS` variable. +- Account menu in the top bar. Boards show their cards and members. +- Per-instance `robots.txt`, Open Graph and a custom icon. +- **English and Spanish interface**, English by default. The language comes from the + picker (footer and Settings › Appearance), the account's preference or the browser. + Notifications are written in each recipient's language. Translations live in + `locales//*.json`, and adding a language is documented in `locales/README.md`. +- `pnpm test` checks that every language has the same messages, placeholders and plural + forms as English (`test/i18n.test.ts`). +- README with screenshots and GIFs, a Spanish README (`README.es.md`) and + `docs/privacy.md`. + +### Changed + +- **License: AGPL-3.0-or-later** with an attribution term (section 7(b)). It replaces the + previous proprietary license. +- Bricolage Grotesque and Figtree typefaces (OFL) instead of Cabinet Grotesk and Satoshi. +- `ORGTABLE_SOURCE_URL` sets the "Source code" link. If you deploy a modified version, + point it to your fork. +- The code, tests, documentation and commit messages are in English. The public REST API + answers in English. +- `next` 16.3.8 and patched `sharp` and `source-map-js` (security advisories). +- The `20261014171630_registration_and_legal` and `20261014175506_user_locale` + migrations are applied automatically on startup. + +## [0.1.0] + +Boards, lists and cards with drag and drop, multi-user with roles, real time, +attachments, search, calendar, notifications, public API with tokens, card collaboration, +due-date reminders and filters. Details in [`docs/roadmap.md`](./docs/roadmap.md). diff --git a/CLAUDE.md b/CLAUDE.md index b6e7570..bdfd0a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,142 +1,143 @@ @AGENTS.md -# Contexto del proyecto y decisiones +# Project context and decisions -orgtable es un clon de Trello self-hosted (Next.js 16, Prisma 7, -PostgreSQL). Este archivo es el contexto operativo para agentes y contribuidores: -resume el estado, las convenciones y las decisiones tomadas en cada hito. Las -decisiones técnicas con su porqué completo están en `docs/decisions/` (ADRs); aquí va -el resumen de una línea con el enlace. +orgtable is a self-hosted, open-source Kanban app, a Trello alternative (Next.js 16, +Prisma 7, PostgreSQL). This file is the working context for agents and contributors: it +summarizes the current state, the conventions and the decisions behind each feature. The +technical decisions with their full rationale are in `docs/DECISIONES.md` (the live +decision log) and, for the earlier architecture decisions, in `docs/decisions/` (ADRs); +here each one gets a one-line summary. -## Convenciones +## Conventions -- **Idioma**: el código, los comentarios y la documentación (`docs/`) van en español. - Los mensajes de commit van en inglés y detallados. -- **Autoría de commits**: NUNCA añadir `Co-Authored-By: Claude …` ni ningún coautor de - IA en los commits (instrucción explícita del propietario; **anula** cualquier valor por - defecto del entorno). Solo se incluyen coautores **humanos** cuando se pidan - expresamente. -- **Seguridad**: postura y auditoría en `docs/security.md` (+ `SECURITY.md` raíz). Auditoría - adversarial multi-agente (workflow `orgtable-security-audit`) + `pnpm audit`. Fixes clave: - IDOR de `moveCard` (la tarjeta debe pertenecer al tablero de destino, `cardBelongsToBoard`), - `SESSION_SECRET`/`DATABASE_URL` exigidos, cabeceras de seguridad en `next.config.ts`, - Next 16.3.6 (overrides de seguridad en `pnpm-workspace.yaml`). Tests de autorización en `test/integration/` (IDOR, 403/404). -- **Tests/CI**: dos niveles. **Unitarios** (sin BD ni red) en `test/` con `pnpm test`. - **Integración** en `test/integration/` (`pnpm test:integration`): invocan los route - handlers de `app/api/v1/**` y los servicios contra una PostgreSQL real cuyo nombre debe - acabar en `_test` (guardia en `test/integration/helpers.ts`); `vitest.integration.config.ts` - aliasa `next/cache`/`next/headers`/`server-only`. La lógica de creación de cuentas vive en - `app/lib/services/users.ts` para ser testeable. GitHub Actions (`.github/workflows/ci.yml`) - levanta un servicio Postgres y corre lint + typecheck + unit + migrate deploy + integración - + build en cada push/PR. Ver `docs/testing.md`. -- **Arquitectura**: Server Actions (`app/lib/actions/`) + Data Access Layer - (`app/lib/dal.ts`) + Prisma. Toda mutación valida con zod, autoriza por el DAL y - revalida la ruta. Ver `docs/architecture.md` y `docs/api/server-actions.md`. -- **Autorización**: el servidor es la única autoridad; la UI solo oculta controles. - Ver `docs/authorization.md`. -- **Next.js 16 / Prisma 7**: hay cambios de ruptura respecto a versiones anteriores - (ver `AGENTS.md`). `params`/`searchParams` son asíncronos; el middleware se llama - `proxy.ts`; la URL de la BD va en `prisma.config.ts` + driver adapter, no en el - esquema. +- **Language**: code, comments, documentation (`docs/`) and commit messages are in + English. Technical decisions are recorded in `docs/DECISIONES.md`, also in English (the + file name is kept). The interface is bilingual (English by default, plus Spanish); all + visible text lives in `locales/` (see `locales/README.md`). +- **Commit authorship**: NEVER add `Co-Authored-By: Claude …` or any AI co-author to + commits (explicit instruction from the owner; it **overrides** any environment default). + Only **human** co-authors are added, and only when explicitly requested. Every commit + carries `Signed-off-by` (DCO, `git commit -s`). +- **Security**: posture and audit in `docs/security.md` (plus the root `SECURITY.md`). + Adversarial multi-agent audit + `pnpm audit`. Key fixes: the `moveCard` IDOR (the card + must belong to the destination board, `cardBelongsToBoard`), `SESSION_SECRET` and + `DATABASE_URL` are required, security headers in `next.config.ts`, security overrides in + `pnpm-workspace.yaml`. Authorization tests in `test/integration/` (IDOR, 403/404). +- **Tests/CI**: two levels. **Unit** tests (no database or network) in `test/` with + `pnpm test`, including the translation check `test/i18n.test.ts` (same keys, + placeholders and plural forms in every language). **Integration** tests in + `test/integration/` (`pnpm test:integration`): they call the route handlers in + `app/api/v1/**` and the services against a real PostgreSQL database whose name must end + in `_test` (guard in `test/integration/helpers.ts`); `vitest.integration.config.ts` + aliases `next/cache`, `next/headers` and `server-only`. Account creation lives in + `app/lib/services/users.ts` so it can be tested. GitHub Actions + (`.github/workflows/ci.yml`) starts a Postgres service and runs lint + typecheck + unit + tests + `migrate deploy` + integration tests + build on every push and PR. See + `docs/testing.md`. +- **Architecture**: Server Actions (`app/lib/actions/`) + Data Access Layer + (`app/lib/dal.ts`) + Prisma. Every mutation validates with zod, authorizes through the + DAL and revalidates the route. See `docs/architecture.md` and + `docs/api/server-actions.md`. +- **Authorization**: the server is the only authority; the UI only hides controls. See + `docs/authorization.md`. +- **Next.js 16 / Prisma 7**: there are breaking changes compared with earlier versions + (see `AGENTS.md`). `params`/`searchParams` are asynchronous; the middleware is called + `proxy.ts`; the database URL lives in `prisma.config.ts` + a driver adapter, not in the + schema. -## Estado por hitos +## Status -Ver `docs/roadmap.md` para el detalle. Hitos 0–13 completados. **Próximos hitos -planificados (14–20)**, con alcance y puntos de anclaje en el roadmap, listos para -arrancar: 14 PWA/móvil, 15 automatizaciones (reglas tipo Butler), 16 vistas de tabla y -swimlanes, 17 plantillas/duplicar/archivar, 18 actividad e historial de tarjeta, 19 webhooks -e integraciones, 20 i18n + accesibilidad. Diferido al backlog: email/Web Push + preferencias, -búsqueda full-text, ampliar superficie API + rate-limit + OpenAPI. Tras el 20: SSO/OIDC, 2FA, -observabilidad, backups. +The shipped features and the planned work are in `docs/roadmap.md`. Planned themes: PWA +and mobile, automations (Butler-style rules), table and swimlane views, templates / +duplicate / archive, card activity history, webhooks and integrations, accessibility. +Smaller items (email and Web Push, full-text search, a wider API with rate limiting and +OpenAPI) and later ideas (SSO/OIDC, 2FA, observability, scheduled backups) are listed +there too. -## Decisiones (resumen) +## Decisions (summary) -- ADR 0001 — Prisma como ORM. -- ADR 0002 — Fractional indexing para el orden de listas/tarjetas. -- ADR 0003 — Credenciales de BD en `.env`. -- ADR 0004 — Sin Redis en el MVP (entra con tiempo real / rate-limiting). -- ADR 0005 — Licencia inicial; sustituida por la ADR 0016. -- ADR 0006 — Autorización por rol con guardas explícitas (no `forbidden()` - experimental). Roles de tablero OWNER/EDITOR/VIEWER en `app/lib/roles.ts`. -- ADR 0007 — PostgreSQL `LISTEN/NOTIFY` (no Redis) para el pub/sub del tiempo real. -- ADR 0008 — Tabla `Session` para revocación inmediata de sesiones. -- ADR 0009 — Rate limiting en memoria (sin Redis) para el login. -- ADR 0010 — Adjuntos en disco local (volumen), no S3. -- ADR 0011 — Búsqueda con `ILIKE` acotada en el servidor (sin motor de búsqueda externo). -- ADR 0012 — Notificaciones in-app sobre el bus pub/sub (sin email ni cola). -- ADR 0013 — API pública con tokens personales hasheados (Bearer, reutiliza el RBAC). -- ADR 0014 — Colaboración en la tarjeta: asignados (solo miembros) y comentarios (hilo plano, cascade). -- ADR 0015 — Recordatorios de vencimiento por endpoint cron externo (secreto, sin scheduler en proceso). -- ADR 0016 — Licencia propietaria (MARA-KASSA SL): la vigente es `LICENSE`. +- ADR 0001 — Prisma as the ORM. +- ADR 0002 — Fractional indexing for the order of lists and cards. +- ADR 0003 — Database credentials in `.env`. +- ADR 0004 — No Redis in the MVP (it would come with real time / rate limiting). +- ADR 0005 — Initial license (historical). +- ADR 0006 — Role-based authorization with explicit guards (not the experimental + `forbidden()`). Board roles OWNER/EDITOR/VIEWER in `app/lib/roles.ts`. +- ADR 0007 — PostgreSQL `LISTEN/NOTIFY` (not Redis) for real-time pub/sub. +- ADR 0008 — `Session` table for immediate session revocation. +- ADR 0009 — In-memory rate limiting (no Redis) for sign-in. +- ADR 0010 — Attachments on local disk (a volume), not S3. +- ADR 0011 — Search with `ILIKE`, scoped on the server (no external search engine). +- ADR 0012 — In-app notifications over the pub/sub bus (no email, no queue). +- ADR 0013 — Public API with hashed personal tokens (Bearer, reuses the RBAC). +- ADR 0014 — Card collaboration: assignees (members only) and comments (flat thread, + cascade delete). +- ADR 0015 — Due-date reminders through an external cron endpoint (secret, no in-process + scheduler). +- ADR 0016 — Proprietary license (historical, superseded). +- Current license: AGPL-3.0-or-later with a section 7(b) attribution term (`LICENSE`); see + `docs/DECISIONES.md`. Later decisions (fonts, open sign-up and GDPR, bilingual + interface…) are recorded there. -## Notas de hito +## Feature notes -- **Hito 3 (multiusuario)**: el esquema ya tenía `BoardMember`/`BoardRole`/`User.role` - desde el Hito 0, así que no hizo falta migración. Se añadieron las guardas de rol en - todas las acciones, el panel de miembros por tablero y `/admin/users` para la gestión - de cuentas. No hay registro abierto: el admin crea las cuentas. -- **Hito 4 (tiempo real)**: SSE en `/api/boards/[id]/stream` + bus pub/sub - (`app/lib/realtime/`). Toda mutación llama `revalidateBoard()` (revalida + publica). - Sesiones respaldadas por la tabla `Session` (el JWT lleva `sid`); revocación inmediata. - Migración `20260605100000_sessions` escrita a mano (no había BD local para - `migrate dev`); aplicar con `prisma migrate deploy` (lo hace el entrypoint de Docker). -- **Hito 5 (operación)**: rate-limit del login (`app/lib/rate-limit.ts`, en memoria) + - auditoría (`app/lib/audit.ts`, `/admin/audit`); adjuntos en disco - (`app/lib/storage.ts`, volumen `/data/uploads`, descarga por route handler con - autorización); export/import de tableros (JSON); scripts `scripts/{backup,restore,update}.sh`. - Migraciones a mano: `..._audit_log` y `..._attachments`. Todas las migraciones - escritas a mano se aplican con `migrate deploy`; al haber BD, `migrate dev` no detecta - deriva porque coinciden esquema y SQL. -- **Rediseño visual (Editorial cálido)**: el cromo pasó de "Trello colorido" a una - estética editorial cálida (arena/papel/espresso + acento terracota). Fuentes Cabinet - Grotesk + Satoshi auto-alojadas en `app/fonts` (Fontshare, vía `next/font/local`). - Solo presentación; sin cambios de lógica. Tokens en `app/globals.css`; ver `DESIGN.md`. -- **Hito 6 (búsqueda)**: `searchForUser` en `app/lib/queries.ts` (ILIKE acotado por - acceso), página `/search`, caja en la navbar (`app/components/search-box.tsx`) y - deep-link `?card=` para abrir una tarjeta. Sin migración (solo lectura). Ver - `docs/search.md`. -- **Hito 7 (calendario)**: conmutador Tablero/Calendario en `BoardView` (estado local, - no en la URL). `app/components/board/calendar-view.tsx` coloca las tarjetas con - vencimiento en una rejilla mensual; al pulsarlas reutiliza el modal de tarjeta. - Indexación por `dueDate.slice(0,10)` para evitar desfases de zona horaria. Sin - migración. Ver `docs/calendar.md`. -- **Hito 8 (notificaciones)**: modelo `Notification` (migración `..._notifications`, - generada con `prisma migrate dev`). `notify()` en `app/lib/notifications.ts` escribe y - publica por un canal **por usuario** (`user:`) añadido al bus de tiempo real - (`app/lib/realtime/bus.ts`); stream SSE `/api/notifications/stream` + campana - (`app/components/notifications-bell.tsx`) en la navbar. Eventos: añadido/quitado/cambio - de rol en un tablero (en `actions/members.ts`). Best-effort como la auditoría. Ver +- **Multi-user**: `BoardMember`/`BoardRole`/`User.role` have been in the schema from the + start. Role guards in every action, a members panel per board and `/admin/users` for + account management. Open sign-up is optional per instance (off by default); otherwise + the admin creates the accounts. +- **Real time**: SSE at `/api/boards/[id]/stream` + a pub/sub bus (`app/lib/realtime/`). + Every mutation calls `revalidateBoard()` (revalidates + publishes). Sessions are backed + by the `Session` table (the JWT carries `sid`), so revocation is immediate. +- **Operations**: sign-in rate limiting (`app/lib/rate-limit.ts`, in memory) + audit log + (`app/lib/audit.ts`, `/admin/audit`); attachments on disk (`app/lib/storage.ts`, volume + `/data/uploads`, downloaded through an authorizing route handler); board export/import + (JSON); `scripts/{backup,restore,update}.sh`. +- **Visual design** (warm editorial): sand/paper/espresso with a terracotta accent. + Typefaces Bricolage Grotesque + Figtree + Geist Mono (SIL OFL 1.1, loaded with + `next/font/google`). Tokens in `app/globals.css`; see `DESIGN.md`. +- **Search**: `searchForUser` in `app/lib/queries.ts` (ILIKE scoped by access), `/search` + page, a search box in the navbar (`app/components/search-box.tsx`) and a `?card=` + deep-link that opens a card. See `docs/search.md`. +- **Calendar**: Board/Calendar switch in `BoardView` (local state, not in the URL). + `app/components/board/calendar-view.tsx` places cards with a due date on a monthly grid + and reuses the card modal. Indexed by `dueDate.slice(0,10)` to avoid time-zone shifts. + Dragging a card to another day calls `setCardDueDate`. See `docs/calendar.md`. +- **Notifications**: `Notification` model. `notify()` in `app/lib/notifications.ts` writes + and publishes on a **per-user** channel (`user:`) of the real-time bus + (`app/lib/realtime/bus.ts`); SSE stream `/api/notifications/stream` + the bell + (`app/components/notifications-bell.tsx`). Best-effort, like the audit log. See `docs/notifications.md`. -- **Hito 9 (API pública)**: modelo `ApiToken` (migración `..._api_tokens`). REST - `/api/v1/**` para boards/lists/cards; helpers en `app/lib/api/` (`auth.ts` Bearer→usuario - + rol por tablero, `tokens.ts` hash SHA-256, `respond.ts`, `serialize.ts`). Reutiliza el - RBAC (`canEditBoard`/`canManageBoard`) y `revalidateBoard` (tiempo real). Gestión de - tokens en `/settings`. Ver `docs/api/public-api.md`. NOTA dev: regenerar el cliente - Prisma (`prisma migrate dev`/`generate`) mientras el dev server está vivo lo deja con un - cliente obsoleto (errores `findMany of undefined`); hay que **reiniciar `next dev`** tras - una migración. -- **Hito 10 (colaboración en la tarjeta)**: modelos `CardAssignee` y `Comment` (migración - `..._card_collaboration`). Acciones `actions/assignees.ts` y `actions/comments.ts`; - lógica testeable en `services/assignees.ts` (`assignMemberToCard`, solo miembros) y - `services/cards.ts` (`cardContext`). UI en `card-modal.tsx` (secciones Asignados y - Comentarios) y avatares en `card-item.tsx`. Notifica `card.assigned`/`comment.added`. - Tests en `test/integration/collaboration.int.test.ts`. Ver `docs/card-collaboration.md`. -- **Hito 11 (recordatorios)**: `Notification.cardId` (migración `..._notification_card_ref`) - para deep-link y dedup. `services/reminders.ts` (`generateDueReminders(now)`, política - de avisos en `docs/reminders.md`) disparado por `POST /api/cron/reminders` (secreto - Bearer, `timingSafeEqual`, 503 si no hay `CRON_SECRET`). Arrastre en el calendario con - `setCardDueDate`. Tests en `test/integration/reminders.int.test.ts`. Email/prefs → - backlog. Ver `docs/reminders.md`. -- **Hito 12 (filtros de tablero)**: `app/lib/board-filters.ts` (puro, `cardMatchesFilters`, - test unitario), `FilterBar` desplegable en la cabecera; `BoardView` filtra las tarjetas y - desactiva el arrastre con `sortingDisabled` (propagado a `ListColumn`/`CardItem`). Solo - presentación, sin migración. Full-text → backlog (ILIKE basta, ADR 0011). Ver `docs/filters.md`. -- **Hito 13 (scopes/caducidad de tokens)**: columnas `scope` (full/read) y `expiresAt` en - `ApiToken` (migración `..._api_token_scopes`). `authenticateApiToken` devuelve `scope` y - rechaza caducados; `isReadOnly(auth)` da 403 en cada handler de escritura. Selección en - `/settings`. Tests `test/integration/api-scopes.int.test.ts`. Ampliar superficie API + - rate-limit + OpenAPI → backlog. Ver `docs/api/public-api.md`. -- **Migraciones (a partir del Hito 7)**: ya hay BD de desarrollo local (Postgres en el - puerto 5433, contenedor `orgtable-dev-pg`), así que las nuevas migraciones se generan - con `prisma migrate dev` en vez de a mano. Se siguen aplicando con `migrate deploy` en - el arranque del contenedor. +- **Public API**: `ApiToken` model. REST `/api/v1/**` for boards, lists and cards; helpers + in `app/lib/api/` (`auth.ts` Bearer → user + board role, `tokens.ts` SHA-256 hash, + `respond.ts`, `serialize.ts`). Reuses the RBAC (`canEditBoard`/`canManageBoard`) and + `revalidateBoard` (real time). Tokens have a `scope` (full/read) and an optional + `expiresAt`; `isReadOnly(auth)` returns 403 in every write handler. Token management in + `/settings`. The API answers in English only. See `docs/api/public-api.md`. +- **Card collaboration**: `CardAssignee` and `Comment` models. Actions in + `actions/assignees.ts` and `actions/comments.ts`; testable logic in + `services/assignees.ts` (`assignMemberToCard`, members only) and `services/cards.ts` + (`cardContext`). Notifies `card.assigned` and `comment.added`. See + `docs/card-collaboration.md`. +- **Reminders**: `Notification.cardId` for deep-links and deduplication. + `services/reminders.ts` (`generateDueReminders(now)`) is triggered by + `POST /api/cron/reminders` (Bearer secret, `timingSafeEqual`, 503 when `CRON_SECRET` is + not set). See `docs/reminders.md`. +- **Board filters**: `app/lib/board-filters.ts` (pure, `cardMatchesFilters`, unit tested) + and a `FilterBar` in the board header; `BoardView` filters the cards and disables + dragging with `sortingDisabled`. See `docs/filters.md`. +- **Open sign-up, legal pages and GDPR**: operator details under Admin › Instance feed the + `/legal/*` pages; everyone can export or delete their account from Settings › Privacy + and data; `POST /api/cron/maintenance` deletes expired data. See `docs/privacy.md`. +- **Bilingual interface**: messages in `locales//.json`, English as reference + and fallback. Request language: `locale` cookie, then `Accept-Language`, then English. + `User.locale` stores the account's choice (restores the cookie on sign-in and sets the + language of that person's notifications). zod schemas carry message keys. Language + picker in the footer of the public pages and in Settings › Appearance. +- **Migrations**: generated with `prisma migrate dev` against the local development + database (Postgres on port 5433, container `orgtable-dev-pg`) and applied with + `migrate deploy` when the container starts. Some early migrations were written by hand; + their SQL matches the schema, so `migrate dev` detects no drift. +- **Dev note**: regenerating the Prisma client (`prisma migrate dev`/`generate`) while the + dev server is running leaves it with a stale client (`findMany of undefined` errors); + **restart `next dev`** after a migration. diff --git a/DESIGN.md b/DESIGN.md index cffb893..71a2f9c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,111 +1,128 @@ # Design -Sistema visual de orgtable. Estética: **Editorial cálido**. El cromo vive en neutros -cálidos (arena, papel, espresso) con un único acento **terracota**; el color del -contenido (fondos de tablero y etiquetas) usa una paleta apagada y cohesiva. Tokens en -OKLCH, definidos en `app/globals.css` (Tailwind 4 `@theme`), con temas claro y oscuro. +orgtable's visual system. Aesthetic: **warm editorial**. The chrome lives in warm +neutrals (sand, paper, espresso) with a single **terracotta** accent; content color +(board backgrounds and labels) uses a muted, cohesive palette. Tokens are in OKLCH, +defined in `app/globals.css` (Tailwind 4 `@theme`), with light and dark themes. ## Color -Estrategia: **Restrained en el cromo, apagado-cohesivo en el contenido**. La interfaz -base es cálida y neutra; el único acento es la terracota (saturación < 80%). Sin morado -neón, sin glows, sin negro puro (el "ink" es un espresso cálido). +Strategy: **restrained in the chrome, muted and cohesive in the content**. The base +interface is warm and neutral; the only accent is terracotta (saturation < 80%). No neon +purple, no glows, no pure black (the "ink" is a warm espresso). -### Neutros (cromo) +### Neutrals (chrome) -| Rol | Claro (OKLCH) | Oscuro (OKLCH) | Uso | +| Role | Light (OKLCH) | Dark (OKLCH) | Use | |-----|---------------|----------------|-----| -| `--bg` | 0.965 0.013 74 | 0.205 0.012 52 | Fondo de la app (arena / carbón cálido) | -| `--surface` | 0.992 0.005 80 | 0.25 0.014 52 | Tarjetas, paneles, modales (papel) | +| `--bg` | 0.965 0.013 74 | 0.205 0.012 52 | App background (sand / warm charcoal) | +| `--surface` | 0.992 0.005 80 | 0.25 0.014 52 | Cards, panels, modals (paper) | | `--surface-2` | 0.94 0.015 74 | 0.30 0.016 52 | Inputs, hovers | -| `--column` | 0.95 0.014 72 | 0.23 0.013 52 | Fondo de columnas del tablero | -| `--ink` | 0.27 0.022 52 | 0.95 0.012 75 | Texto principal (≥4.5:1) | -| `--ink-muted` | 0.49 0.026 55 | 0.73 0.02 68 | Texto secundario (≥4.5:1) | -| `--border` | 0.88 0.016 72 | 0.36 0.016 55 | Bordes sutiles, divisores | +| `--column` | 0.95 0.014 72 | 0.23 0.013 52 | Board column background | +| `--ink` | 0.27 0.022 52 | 0.95 0.012 75 | Main text (≥4.5:1) | +| `--ink-muted` | 0.49 0.026 55 | 0.73 0.02 68 | Secondary text (≥4.5:1) | +| `--border` | 0.88 0.016 72 | 0.36 0.016 55 | Subtle borders, dividers | -### Acento e intención +### Accent and intent -| Rol | Claro | Oscuro | Uso | +| Role | Light | Dark | Use | |-----|-------|--------|-----| -| `--accent` | 0.56 0.125 42 | 0.68 0.115 45 | Acción primaria, foco, selección (terracota) | -| `--accent-ink` | 0.99 0.01 80 | 0.2 0.02 50 | Texto sobre acento | -| `--danger` | 0.52 0.18 28 | 0.71 0.16 30 | Borrar, error | -| `--success` | 0.48 0.1 145 | 0.72 0.1 150 | Confirmaciones | +| `--accent` | 0.56 0.125 42 | 0.68 0.115 45 | Primary action, focus, selection (terracotta) | +| `--accent-ink` | 0.99 0.01 80 | 0.2 0.02 50 | Text on accent | +| `--danger` | 0.52 0.18 28 | 0.71 0.16 30 | Delete, error | +| `--success` | 0.48 0.1 145 | 0.72 0.1 150 | Confirmations | -### Colores de tablero / etiqueta (contenido) +### Board and label colors (content) -Paleta editorial **apagada** (clay, oliva, ciruela, ocre, ladrillo, pizarra), no -vibrante, en `app/lib/colors.ts`. Tableros con L≤0.54 y etiquetas con L≈0.52–0.57 para -que el texto blanco encima sea legible. Sobre un fondo de tablero coloreado, las -columnas son papel con un punto de translucidez (`bg-column/95`) y las tarjetas son -`--surface` sólido con una **regla lateral** tintada con el color de su primera etiqueta. +A **muted** editorial palette (clay, olive, plum, ochre, brick, slate), not vibrant, in +`app/lib/colors.ts`. Boards use L≤0.54 and labels L≈0.52–0.57 so white text on top is +legible. On a colored board background, columns are paper with a touch of translucency +(`bg-column/95`) and cards are solid `--surface` with a **side rule** tinted with the +color of their first label. ## Typography -Dos familias con carácter (auto-alojadas en `app/fonts`, Fontshare; ver `app/layout.tsx`): +Two families with character, both under the **SIL OFL 1.1** license (Google Fonts). +`next/font` downloads them at build time and serves them from the app itself (see +`app/layout.tsx`): -- **Cabinet Grotesk** (`--font-display`, utilidad `font-display`): titulares y la marca. - Tracking ceñido (`tracking-tight`), pesos 700–900. Jerarquía por peso y color, no por - tamaños desmedidos. -- **Satoshi** (`--font-sans`): interfaz y cuerpo, pesos 400/500/700. -- **Geist Mono** (`--font-mono`): números tabulares, metadatos, contadores. +- **Bricolage Grotesque** (`--font-display`, `font-display` utility): headings and the + brand. Tight tracking (`tracking-tight`), weights 700–800. Hierarchy through weight and + color, not oversized type. +- **Figtree** (`--font-sans`): interface and body, weights 400/500/600/700. +- **Geist Mono** (`--font-mono`): tabular numbers, metadata, counters. -Banned: Inter y serifas genéricas. Escala fija en rem, ratio ~1.2. `text-wrap: balance` -en titulares. Mayúsculas solo en micro-etiquetas (≤2 palabras, p. ej. cabeceras de -columna y de sección). +Only fonts that can be redistributed with the code (OFL or similar) are allowed: the +repository is public and AGPL. Satoshi and Cabinet Grotesk (Fontshare, ITF FFL) were +removed for that reason. -| Token | Tamaño / peso / familia | Uso | +Banned: Inter and generic serifs. Fixed rem scale, ratio ~1.2. `text-wrap: balance` on +headings. Uppercase only for micro-labels (≤2 words, e.g. column and section headers). + +| Token | Size / weight / family | Use | |-------|-------------------------|-----| -| Display de página | 1.875–3rem / 700–800 / Cabinet | "Tus tableros", título de tablero, login | -| Subtítulo | 1.25rem / 700 / Cabinet | Título de modal | -| Cuerpo | 0.875rem / 400–500 / Satoshi | Texto de tarjeta, formularios | -| Micro-etiqueta | 0.625–0.75rem / 600 / Satoshi (mayús.) | Cabeceras de columna/sección | +| Page display | 1.875–3rem / 700–800 / Bricolage | "Your boards", board title, login | +| Subtitle | 1.25rem / 700 / Bricolage | Modal title | +| Body | 0.875rem / 400–500 / Figtree | Card text, forms | +| Micro-label | 0.625–0.75rem / 600 / Figtree (uppercase) | Column/section headers | + +Copy must fit every language: Spanish strings are often longer than English ones, so +labels and buttons must not rely on a fixed width. ## Components -Vocabulario consistente. Cada control define `default / hover / focus-visible / active / +Consistent vocabulary. Every control defines `default / hover / focus-visible / active / disabled`. -- **Botón primario**: fondo `--accent`, texto `--accent-ink`, radio 8px, sin glow; - feedback táctil `active:translate-y-px`; foco con anillo `--accent`. -- **Botón sutil/ghost/danger**: transparente o `--surface-2`; mismo feedback táctil. -- **Input**: fondo `--surface`, borde `--border`, label encima, error debajo; foco con - borde + anillo `--accent`. -- **Tarjeta (card)**: `--surface`, radio 8px, borde 1px, sombra tintada ≤8px; regla - lateral de 3px con el color de la primera etiqueta; etiquetas como `punto + NOMBRE`. - Si tiene checklist, una **barra de progreso** slim en la cara muestra el avance de un - vistazo (acento mientras avanza, `--success` al completarse) + contador `done/total`. -- **Columna**: papel translúcido sobre el fondo del tablero, radio 16px, cabecera en - versalitas. -- **Etiqueta**: pastilla con color apagado de la paleta, radio completo, texto legible. -- **Modal**: ``/portal; backdrop oscurecido; escala+fade de entrada; sombra - difusa amplia tintada al ink. -- **Empty state**: enseña qué hacer (no "no hay nada"), con icono SVG (nunca emoji). - -Radios: inputs/botones 8px, tarjetas 8px, columnas 16px, modales/paneles 16px, -pastillas/etiquetas completo. +- **Primary button**: `--accent` background, `--accent-ink` text, 8px radius, no glow; + tactile feedback `active:translate-y-px`; focus with an `--accent` ring. +- **Subtle/ghost/danger button**: transparent or `--surface-2`; same tactile feedback. +- **Input**: `--surface` background, `--border` border, label above, error below; focus + with border + `--accent` ring. +- **Card**: `--surface`, 8px radius, 1px border, tinted shadow ≤8px; 3px side rule in the + color of the first label; labels shown as `dot + NAME`. If it has a checklist, a slim + **progress bar** on the face shows progress at a glance (accent while in progress, + `--success` when complete) + a `done/total` counter. +- **Column**: translucent paper over the board background, 16px radius, small-caps + header. +- **Label**: pill in a muted palette color, full radius, legible text. +- **Modal**: ``/portal; darkened backdrop; scale + fade on entry; wide diffuse + shadow tinted with the ink color. +- **Empty state**: shows what to do (not "there is nothing here"), with an SVG icon + (never an emoji). + +Radii: inputs/buttons 8px, cards 8px, columns 16px, modals/panels 16px, pills/labels +full. ## Layout -- **App shell**: barra superior fina translúcida (logo display, nombre de instancia, - tema, usuario) + área de contenido. -- **Login**: split asimétrico — panel de marca terracota (display grande) a un lado, - formulario al otro; colapsa a una columna por debajo de `lg`. -- **Columnas**: flex horizontal, ancho fijo (~18rem), scroll-x; tarjetas en flex - vertical con scroll-y interno. -- **Tableros (índice)**: grid `repeat(auto-fill, minmax(15rem, 1fr))`, cada tablero con - su color apagado y un degradado sutil para legibilidad del título. -- **Z-index semántico**: textura(0) → contenido(10) → barra(20) → modal-backdrop(30) → +- **App shell**: thin translucent top bar with the brand and the instance name; on the + right, search, notifications, theme and the **account menu** (initials in a terracotta + circle) with profile, sessions, privacy, administration and sign out. It is a native + `popover` anchored below the button. +- **Access** (login, sign-up): asymmetric split with the terracotta brand panel + (`BrandPanel`, with a board thumbnail) on one side and the form on the other; it + collapses to one column below `lg`. Below it, the public footer with the legal links, + the language picker and the attribution. +- **Settings and legal pages**: side navigation (a column from `md`, a scrollable row on + mobile) and content in `--surface` cards with a 16px radius. Long text uses + `.legal-prose` (in `globals.css`) with a maximum measure of ~68ch. +- **Columns**: horizontal flex, fixed width (~18rem), scroll-x; cards in a vertical flex + with internal scroll-y. +- **Boards (index)**: grid `repeat(auto-fill, minmax(15rem, 1fr))`, each board in its + muted color, a subtle gradient for title legibility, the card count and the members' + initials when shared. +- **Semantic z-index**: texture(0) → content(10) → bar(20) → modal-backdrop(30) → modal(40) → toast(50). ## Motion -150–250 ms, `ease-out`. La motion comunica estado (hover, arrastre, apertura de modal, -feedback táctil de botón), nunca decora. `prefers-reduced-motion`: crossfade o -instantáneo. Sin secuencias de carga orquestadas. +150–250 ms, `ease-out`. Motion communicates state (hover, drag, modal opening, button +tactile feedback), never decoration. `prefers-reduced-motion`: crossfade or instant. No +orchestrated loading sequences. -## Textura +## Texture -Grano de papel sutil global: capa `body::before` fija, `pointer-events: none`, mezcla -`multiply` (claro) / `screen` (oscuro) a baja opacidad. Solo en pseudo-elemento fijo — -nunca en contenedores con scroll, para no repintar la GPU al desplazar. +Subtle global paper grain: a fixed `body::before` layer, `pointer-events: none`, +`multiply` (light) / `screen` (dark) blending at low opacity. Only on a fixed +pseudo-element — never on scrolling containers, so the GPU does not repaint on scroll. diff --git a/Dockerfile b/Dockerfile index 4ee23bc..12b8c56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,20 @@ # syntax=docker/dockerfile:1 -# orgtable — imagen de producción multi-stage. Ver docs/docker.md. -# Se construye localmente en el servidor del usuario (docker compose build). +# orgtable — multi-stage production image. See docs/docker.md. +# Built locally on the user's server (docker compose build). -# --- Base con pnpm ------------------------------------------------------------- -# pnpm llega por Corepack, fijado a la versión de `packageManager` en package.json, -# para que la imagen instale con el mismo gestor y versión que el lockfile. +# --- Base with pnpm ------------------------------------------------------------ +# pnpm comes from Corepack, pinned to the `packageManager` version in package.json, +# so the image installs with the same package manager and version as the lockfile. FROM node:22-alpine AS base ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 RUN corepack enable # --- Stage 1: deps ----------------------------------------------------------- -# Instala dependencias en Alpine para que los engines nativos de Prisma (el -# schema-engine de Migrate) se descarguen para linux-musl, no para glibc. -# pnpm-workspace.yaml lleva la lista `allowBuilds`: sin ella pnpm no ejecuta los -# scripts de instalación de Prisma y faltaría el schema-engine. +# Install dependencies on Alpine so Prisma's native engines (Migrate's schema-engine) +# are downloaded for linux-musl, not glibc. +# pnpm-workspace.yaml holds the `allowBuilds` list: without it pnpm does not run +# Prisma's install scripts and the schema-engine would be missing. FROM base AS deps WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ @@ -25,11 +25,12 @@ FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -# DATABASE_URL ficticia: prisma.config.ts exige resolver esta variable al cargar la -# config (Prisma 7 lanza error si falta), pero ni `generate` ni el build conectan a -# la BD —las páginas son dinámicas (auth) y no la consultan en build—. +# Dummy DATABASE_URL: prisma.config.ts must resolve this variable when loading the +# config (Prisma 7 throws if it is missing), but neither `generate` nor the build +# connect to the database (pages are dynamic because of auth and do not query it at +# build time). ENV DATABASE_URL="postgresql://build:build@localhost:5432/build" -# Genera el Prisma Client (query engine WASM, sin binario). +# Generate the Prisma Client (WASM query engine, no binary). RUN pnpm exec prisma generate RUN pnpm build @@ -39,38 +40,38 @@ WORKDIR /app ENV NODE_ENV=production ENV PORT=3000 ENV HOSTNAME=0.0.0.0 -# Directorio de adjuntos. En producción es un volumen montado (ver docker-compose.yml); -# se crea aquí y se cede al usuario sin privilegios para que pueda escribir. +# Attachments directory. In production it is a mounted volume (see docker-compose.yml); +# it is created here and handed to the unprivileged user so it can write to it. ENV ORGTABLE_UPLOAD_DIR=/data/uploads -# openssl: requerido por el schema-engine de Prisma para `migrate deploy`. +# openssl: required by Prisma's schema-engine for `migrate deploy`. RUN apk add --no-cache openssl -# Usuario sin privilegios. +# Unprivileged user. RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -S nextjs -# Directorio de adjuntos escribible por el usuario (lo monta un volumen en producción). +# Attachments directory writable by that user (a volume is mounted here in production). RUN mkdir -p /data/uploads && chown -R nextjs:nodejs /data -# Aplicación Next.js standalone (servidor Node mínimo + assets). +# Standalone Next.js app (minimal Node server + assets). COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public -# node_modules completo: el CLI de Prisma (`migrate deploy` en el entrypoint) necesita -# sus dependencias transitivas (p. ej. `effect`, que usa @prisma/config) y el cliente -# generado. El trazado de `output: standalone` solo incluye lo que importa la app, no -# el CLI, así que copiamos el árbol completo. (Optimizable a futuro con un stage -# prod-deps; ver docs/docker.md.) +# Full node_modules: the Prisma CLI (`migrate deploy` in the entrypoint) needs its +# transitive dependencies (e.g. `effect`, used by @prisma/config) and the generated +# client. `output: standalone` tracing only includes what the app imports, not the CLI, +# so the whole tree is copied. (Could be slimmed down later with a prod-deps stage; see +# docs/docker.md.) COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/package.json ./package.json COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -# El código queda de root y de solo lectura para `nextjs`: un fallo en la app no puede -# reescribir server.js, .next ni node_modules. Un `chown -R /app` además duplicaría -# node_modules en otra capa de cientos de MB. Solo la caché de Next necesita escritura. +# The code stays owned by root and read-only for `nextjs`: a bug in the app cannot +# rewrite server.js, .next or node_modules. A `chown -R /app` would also duplicate +# node_modules in another layer of hundreds of MB. Only Next's cache needs to be writable. RUN mkdir -p .next/cache && chown nextjs:nodejs .next/cache USER nextjs diff --git a/LICENSE b/LICENSE index a04186f..720782d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,19 +1,722 @@ -Copyright (c) 2026 MARA-KASSA SL (CIF B22605018) + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 -Todos los derechos reservados. + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. -Este software, su codigo fuente, su documentacion y cualquier otro activo contenido en este repositorio son propiedad exclusiva de MARA-KASSA SL. + Preamble -Queda expresamente prohibida la reproduccion, distribucion, comunicacion publica, modificacion, descompilacion, ingenieria inversa o cualquier otra forma de explotacion, total o parcial, de este software por cualquier medio o procedimiento, sin la autorizacion previa y por escrito de MARA-KASSA SL. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. -El acceso a este repositorio y a su contenido esta restringido a las personas autorizadas expresamente por MARA-KASSA SL, ya sea en su condicion de empleados, colaboradores o mediante acuerdo de licencia especifico. + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. -CESION DE DERECHOS POR APORTACIONES + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. -Toda persona que contribuya a este repositorio, ya sea en su condicion de empleado de MARA-KASSA SL o en su condicion de colaborador voluntario y no remunerado, es decir, a titulo gratuito y sin contraprestacion economica de ningun tipo, cede a MARA-KASSA SL, de forma exclusiva, irrevocable, mundial y por el plazo maximo permitido por la legislacion aplicable, la totalidad de los derechos de explotacion sobre sus aportaciones, incluyendo sin limitacion los derechos de uso, reproduccion, distribucion, comunicacion publica y transformacion, asi como el derecho a sublicenciar dichas aportaciones y a explotarlas en cualquier soporte, formato o modalidad, presentes o futuros. + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. -La ausencia de remuneracion no genera en favor del colaborador ningun derecho de titularidad, cotitularidad, regalia, participacion, compensacion o reclamacion sobre el software ni sobre su explotacion. El colaborador o empleado garantiza que sus aportaciones son originales y que no infringen derechos de terceros, y renuncia, en la medida permitida por la ley, al ejercicio de cualquier derecho que pudiera impedir a MARA-KASSA SL el uso, la modificacion o la explotacion de dichas aportaciones. Los derechos morales irrenunciables reconocidos por la ley permanecen en su autor. + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. -La realizacion de cualquier aportacion a este repositorio implica la aceptacion integra de las condiciones anteriores. + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. -Este software se distribuye "tal cual", sin garantias de ningun tipo, expresas o implicitas, incluyendo pero sin limitarse a las garantias de comercializacion, idoneidad para un proposito particular y no vulneracion de derechos de terceros. En ningun caso MARA-KASSA SL sera responsable de dano alguno derivado del uso de este software. + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. + +------------------------------------------------------------------------ + + ADDITIONAL TERMS UNDER SECTION 7(b) OF THE GNU AGPL + + orgtable + Copyright (C) 2026 Aimar (https://github.com/Aimarmndz) + + This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or (at +your option) any later version, supplemented by the additional terms +below. The additional terms are made under section 7(b) of the GNU +Affero General Public License version 3 ("the License") and apply to +every copy of the program and of any work based on it, whether or not +it has been modified. Terms not defined here have the meaning given to +them in the License. + + 1. Author attribution. The following author attribution must be +preserved, in the language of the user interface or in English: + + Created by Aimar (github.com/Aimarmndz) + + 2. Where it must appear. This program's user interface displays the +above attribution as part of its Appropriate Legal Notices, in two +places: + + a) the footer of its public web pages (sign-in, sign-up and the + legal pages); and + + b) the "About" section of the user settings page. + + Any covered work that you convey, or that you make available for +users to interact with remotely through a computer network (section 13 +of the License), must keep displaying the attribution in those places, +or, if your version no longer has one of those screens, in the closest +equivalent place where its interactive user interface displays its +Appropriate Legal Notices. The attribution must be legible and must not +be removed, hidden, obscured or altered, except to translate the words +"Created by" or to turn the text into a hyperlink to +https://github.com/Aimarmndz. + + 3. What this term does not require. You may add your own copyright +notices and attributions next to the one above, and you are encouraged +to state that your version is modified. You are not required to display +any logo, trademark or advertising, nor to show the attribution on +every screen. A work with no interactive user interface only has to +keep the attribution in its source code and documentation. + + 4. No endorsement and no trademark licence. Keeping the attribution +does not imply that the author endorses your version, and these terms +grant no right to use the author's name, or any name or logo of this +project, to promote a modified version, beyond what the attribution +itself requires. + + 5. Relationship to the License. These are additional terms permitted +by section 7(b) of the License; they must be kept together with the +License in every copy of the program and of any work based on it. If any +of them is held to be a "further restriction" within the meaning of +section 7, section 7 lets you remove that term, and only that term; the +License and the remaining additional terms continue to apply in full. diff --git a/PRODUCT.md b/PRODUCT.md index 033d7b5..5fc9191 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -6,47 +6,49 @@ product ## Users -Personas técnicas que auto-hospedan sus propias herramientas (desarrolladores, -equipos pequeños, entusiastas del self-hosting). Trabajan en el navegador, sobre todo -en escritorio. Su tarea principal es capturar trabajo y moverlo entre estados en -tableros Kanban: crear tarjetas rápido, arrastrarlas entre listas, abrir el detalle -para añadir contexto (descripción, etiquetas, fechas, checklists). +Technical people who self-host their own tools (developers, small teams, self-hosting +enthusiasts). They work in the browser, mostly on desktop. Their main task is capturing +work and moving it between states on Kanban boards: creating cards quickly, dragging +them between lists, and opening the details to add context (description, labels, dates, +checklists). ## Product Purpose -orgtable es un clon de Trello self-hosted: tableros → listas → tarjetas -con arrastrar y soltar. El éxito es que se sienta tan fluido y familiar como Trello, -pero que sea tuyo (tus datos, tu servidor). El producto desaparece detrás de la tarea. +orgtable is a self-hosted Trello alternative: boards → lists → cards with drag and drop. +Success means it feels as fluid and familiar as Trello, while being yours (your data, +your server). The product disappears behind the task. ## Brand Personality -Familiar, colorido y directo. Alegre sin ser infantil, con la confianza de una -herramienta de producto pulida. La voz es concreta y en español, sin jerga de -marketing. +Familiar, colorful and direct. Cheerful without being childish, with the confidence of a +polished product tool. The voice is concrete, with no marketing jargon. The interface is +available in English (the default) and Spanish, and the copy in every language follows +the same voice. ## Anti-references -- UI corporativa gris y sin vida (estilo Jira pesado). -- Dashboards SaaS genéricos: tarjetas-métrica, gradientes decorativos, eyebrows en - mayúsculas con tracking. -- El look "hecho por IA": grises lavados sobre casi-blancos, bordes laterales de color - como acento, sombras difusas pegadas a bordes de 1px. -- Recargado o skeuomórfico. +- Gray, lifeless corporate UI (heavy Jira style). +- Generic SaaS dashboards: metric cards, decorative gradients, uppercase tracked + eyebrows. +- The "made by AI" look: washed-out grays on near-whites, colored side borders as + accents, diffuse shadows stuck to 1px borders. +- Cluttered or skeuomorphic. ## Design Principles -- **Familiaridad ganada.** El patrón Kanban de Trello es el lenguaje del producto; no - se reinventa, se ejecuta bien. -- **El color vive en el contenido.** Los fondos de tablero y las etiquetas llevan el - color; el cromo de la interfaz se mantiene neutro. -- **El tablero es el héroe.** Cabeceras y navegación se apartan para que el contenido - mande. -- **Fluido por defecto.** Crear, arrastrar y mover ocurren al instante (UI optimista), - sin recargas ni esperas. +- **Earned familiarity.** Trello's Kanban pattern is the product's language; it is not + reinvented, it is executed well. +- **Color lives in the content.** Board backgrounds and labels carry the color; the + interface chrome stays neutral. +- **The board is the hero.** Headers and navigation step aside so the content leads. +- **Fluid by default.** Creating, dragging and moving happen instantly (optimistic UI), + with no reloads or waiting. ## Accessibility & Inclusion -- Contraste AA: texto de cuerpo ≥4.5:1. Las etiquetas de color nunca son el único - indicador (siempre llevan nombre o texto). -- Soporte de tema claro y oscuro con un selector persistente. -- `prefers-reduced-motion` respetado en todas las transiciones. +- AA contrast: body text ≥4.5:1. Color labels are never the only indicator (they always + carry a name or text). +- Light and dark themes with a persistent picker. +- `prefers-reduced-motion` is respected in every transition. +- Language picker (English and Spanish) in the footer of the public pages and in + Settings › Appearance. diff --git a/README.es.md b/README.es.md new file mode 100644 index 0000000..89b5e26 --- /dev/null +++ b/README.es.md @@ -0,0 +1,226 @@ +
+ +# orgtable + +### Tu trabajo, en columnas que respiran. + +Tableros Kanban de código abierto: tableros, listas y tarjetas con arrastrar y soltar,
+en tiempo real y con tus datos donde tú decidas. Una alternativa a Trello que puedes alojar tú. + +[![CI](https://github.com/Aimarmndz/orgtable/actions/workflows/ci.yml/badge.svg)](https://github.com/Aimarmndz/orgtable/actions/workflows/ci.yml) +[![Licencia: AGPL-3.0-or-later](https://img.shields.io/badge/licencia-AGPL--3.0--or--later-9b411e)](./LICENSE) +[![Next.js 16](https://img.shields.io/badge/Next.js-16-black)](https://nextjs.org) +[![PostgreSQL 17](https://img.shields.io/badge/PostgreSQL-17-336791)](https://www.postgresql.org) +[![Docker](https://img.shields.io/badge/self--hosted-Docker-2496ed)](./docs/self-hosting.md) + +🇬🇧 [English](./README.md) · 🇪🇸 **Español** + +[Funciones](#qué-es) · [Instalación](#instalación) · [Privacidad](#privacidad) · [Documentación](./docs/index.md) · [Licencia](#créditos-y-licencia) + +
+ +Un tablero de orgtable: se arrastra la tarjeta «Team photos» de la lista In progress a Review y después se abre el detalle de «Build the home page» (la interfaz de las capturas está en inglés), con etiquetas, asignados, descripción, vencimiento y checklist + +Sin montaje: la app real con datos de ejemplo, grabada en el navegador. + +
+ +> La documentación técnica de [`docs/`](./docs/index.md) está en inglés. Este archivo es la +> versión en español del README principal. + +## Qué es + +orgtable es una aplicación de tableros Kanban. Puedes usarla en una instancia pública con +registro gratuito o levantar la tuya en cualquier servidor con Docker, con un solo comando. + + +- 🗂️ **Tableros, listas y tarjetas** con arrastrar y soltar, con ratón y con teclado. + Cada tarjeta tiene etiquetas, vencimiento, checklists, adjuntos, asignados y comentarios. +- ⚡ **Tiempo real.** Lo que mueve una persona lo ven las demás al instante, sin recargar. +- 👥 **Equipos.** Roles por tablero (propietario, editor y lector), notificaciones y + recordatorios de vencimiento. +- 🔎 **Búsqueda, filtros y calendario**, este último con arrastre para cambiar fechas. +- 🔌 **API REST** con tokens personales, de solo lectura o de escritura, con caducidad + opcional. +- 🔐 **Registro abierto opcional y RGPD de serie.** Páginas legales que se adaptan a cada + instancia. Cada persona descarga o borra sus datos desde los ajustes. +- 🌍 **En inglés y en español.** La interfaz sigue el idioma del navegador y se puede + cambiar desde el pie de página o en *Ajustes › Apariencia*. Las traducciones son + bienvenidas: mira [`locales/README.md`](./locales/README.md). +- 🌗 **Claro y oscuro**, accesible con teclado y lector de pantalla, cómodo en el móvil. +- 🐳 **Autoalojable.** El instalador levanta Docker, genera los secretos y un asistente + crea la cuenta de administrador. + +## La app + + + + + + + + + + + + + + +
Dos navegadores con el mismo tablero: en el de la izquierda Lucía mueve la tarjeta «Monthly newsletter» a To do y en el de la derecha Marcos la ve aparecer al momento
Tiempo real. Lucía mueve una tarjeta y Marcos la ve al instante.
Detalle de una tarjeta en tema oscuro con etiquetas, asignados, descripción, vencimiento y una checklist a la mitad
Detalle de tarjeta, en tema oscuro.
Vista de calendario de octubre con las tarjetas colocadas en su día de vencimiento
Calendario. Arrastra para cambiar el vencimiento.
Índice de tableros con su color, el número de tarjetas y las iniciales de quien está dentro, y el menú de la cuenta
Tus tableros, con quién está en cada uno.
Página de ajustes con navegación lateral: perfil, contraseña y sesiones, tokens de API, apariencia, privacidad y datos, y acerca de
Ajustes. Perfil, sesiones, tokens y tus datos.
El mismo tablero en tema oscuro
Tema oscuro, en carbón cálido.
+ +
+En el móvil +
+

El tablero en un móvil de 390 px de ancho, con las columnas que se desplazan en horizontal

+
+ +## Requisitos + +**Para alojarlo en un servidor:** + +- Linux con acceso a internet. +- Docker y Docker Compose v2 (`docker compose`). Si falta Docker, el instalador lo + instala. +- Un puerto libre, por defecto el `3000`. +- Para un uso real: un dominio y un proxy inverso con HTTPS (Caddy o nginx). + +**Para desarrollar:** + +- Node.js 22, la versión que usan la CI y la imagen Docker. +- pnpm 11, activado con `corepack enable`. La versión exacta está en `packageManager` de + `package.json`. No uses npm ni yarn. +- Docker, para la base de datos PostgreSQL de desarrollo. + +## Instalación + +### En un servidor + +```bash +curl -sSL https://raw.githubusercontent.com/Aimarmndz/orgtable/main/install.sh | bash +``` + +El instalador detecta o instala Docker, genera los secretos (`.env` con permisos `600`) +y levanta la app. Después abre `http://:3000` y completa el asistente +inicial, que crea la cuenta de administrador. + +Por HTTP, la contraseña y la sesión viajan sin cifrar. Para un uso real, pon HTTPS delante +siguiendo [`docs/self-hosting.md`](./docs/self-hosting.md#https-and-domain-reverse-proxy). +La guía completa y la instalación manual están en +[`docs/installation.md`](./docs/installation.md). + +### En local, para desarrollar + +```bash +git clone https://github.com/Aimarmndz/orgtable.git && cd orgtable +corepack enable +pnpm install + +# PostgreSQL de desarrollo en el puerto 5433 +docker run -d --name orgtable-dev-pg \ + -e POSTGRES_USER=orgtable -e POSTGRES_PASSWORD=devpass -e POSTGRES_DB=orgtable \ + -p 5433:5432 postgres:17-alpine + +# .env mínimo: la app exige SESSION_SECRET (16 caracteres como mínimo) +printf 'DATABASE_URL=postgresql://orgtable:devpass@localhost:5433/orgtable\nSESSION_SECRET=%s\n' \ + "$(openssl rand -hex 32)" > .env + +pnpm exec prisma migrate dev # aplica las migraciones +pnpm exec prisma generate # genera el cliente de Prisma +pnpm dev # http://localhost:3000 +``` + +Más detalles en [`docs/contributing.md`](./docs/contributing.md). + +## Comandos + +| Comando | Qué hace | +|---|---| +| `pnpm dev` | Servidor de desarrollo con recarga en caliente. | +| `pnpm build` / `pnpm start` | Compila para producción y sirve la build. | +| `pnpm lint` | ESLint. | +| `pnpm typecheck` | Comprobación de tipos (`tsc --noEmit`). | +| `pnpm test` | Tests unitarios (sin base de datos). | +| `pnpm test:integration` | Tests de integración contra una PostgreSQL real cuyo nombre acabe en `_test` (ver [`docs/testing.md`](./docs/testing.md)). | +| `pnpm exec prisma migrate dev` | Crea y aplica migraciones en desarrollo. | +| `pnpm exec prisma migrate deploy` | Aplica las migraciones pendientes. El contenedor lo hace solo al arrancar. | +| `scripts/backup.sh` · `scripts/restore.sh` · `scripts/update.sh` | Copia de seguridad, restauración y actualización de una instalación con Docker (ver [`docs/operations.md`](./docs/operations.md)). | +| `POST /api/cron/reminders` · `POST /api/cron/maintenance` | Recordatorios de vencimiento y limpieza de datos caducados. Los dispara un cron externo con `CRON_SECRET` (ver [`docs/operations.md`](./docs/operations.md#scheduled-tasks)). | + +## Variables de entorno + +La referencia de cada variable está en +[`docs/installation.md`](./docs/installation.md#environment-variables) y la plantilla +comentada, en [`.env.example`](./.env.example). + +- **Obligatorias:** `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `DATABASE_URL`, + `SESSION_SECRET`. +- **Opcionales:** `APP_PORT`, `CRON_SECRET`, `TRUSTED_PROXY_HOPS` y los ajustes + `ORGTABLE_*`: `ORGTABLE_TIMEZONE`, `ORGTABLE_UPLOAD_DIR`, `ORGTABLE_REALTIME_PG_NOTIFY`, + `ORGTABLE_AUDIT_RETENTION_DAYS` y `ORGTABLE_SOURCE_URL`. + +## Despliegue + +La imagen se construye en el propio servidor con Docker Compose: app y PostgreSQL, sin +exponer puertos de la base de datos. Al arrancar aplica las migraciones. + +- Operar en producción, HTTPS y proxy inverso: [`docs/self-hosting.md`](./docs/self-hosting.md). +- Copias de seguridad, restauración, actualizaciones y cron: + [`docs/operations.md`](./docs/operations.md). +- La imagen y la orquestación: [`docs/docker.md`](./docs/docker.md). +- Abrir la instancia al público con registro gratuito: la lista de comprobación está en + [`docs/privacy.md`](./docs/privacy.md#checklist-for-opening-a-public-instance). + +## Privacidad + +orgtable guarda lo necesario para que tengas una cuenta y tus tableros funcionen. No tiene +analíticas, ni anuncios, ni cookies de seguimiento: solo una cookie de sesión (y otra de +idioma si eliges uno). + +- **El responsable de los datos es quien opera cada instancia.** Rellena sus datos en + *Administración › Instancia* y las páginas `/legal/privacidad`, `/legal/condiciones`, + `/legal/cookies` y `/legal/aviso-legal` los muestran. Lo que falte aparece como «sin + indicar», nunca inventado. +- **Cada persona ejerce sus derechos sola**, desde *Ajustes › Privacidad y datos*: descarga + todos sus datos en JSON o borra su cuenta con sus tableros y adjuntos. +- **Plazos cortos y automáticos.** Las sesiones caducan a los 7 días, las notificaciones + se borran a los 180 y el registro de seguridad a los 90 (configurable). + +Detalle técnico y base legal en [`docs/privacy.md`](./docs/privacy.md). + +## Documentación + +Toda la documentación está en [`docs/`](./docs/), con índice en +[`docs/index.md`](./docs/index.md): arquitectura, base de datos, autenticación y +autorización, [API pública](./docs/api/public-api.md), +[registro de decisiones](./docs/DECISIONES.md) y [cómo contribuir](./docs/contributing.md). +Está en inglés, igual que el código. + +## Contribuir + +Las aportaciones son bienvenidas. Lee antes [`docs/contributing.md`](./docs/contributing.md). +El código, los comentarios, la documentación y los commits van en inglés. Cada commit +lleva `Signed-off-by` ([DCO](https://developercertificate.org/), con `git commit -s`). + +Las traducciones son especialmente bienvenidas: añadir un idioma consiste en copiar una +carpeta y traducir archivos JSON, sin tocar código. Mira +[`locales/README.md`](./locales/README.md). + +Los problemas de seguridad van por [`SECURITY.md`](./SECURITY.md), nunca en issues +públicas. + +## Créditos y licencia + +Creado por **Aimar** ([github.com/Aimarmndz](https://github.com/Aimarmndz)). Hecho con +[Next.js](https://nextjs.org), [React](https://react.dev), +[Prisma](https://www.prisma.io), [PostgreSQL](https://www.postgresql.org), +[dnd-kit](https://dndkit.com) y [Tailwind CSS](https://tailwindcss.com). Usa las +tipografías [Bricolage Grotesque](https://fonts.google.com/specimen/Bricolage+Grotesque), +[Figtree](https://fonts.google.com/specimen/Figtree) y +[Geist Mono](https://fonts.google.com/specimen/Geist+Mono), todas con licencia SIL OFL 1.1. + +Licencia **GNU AGPL-3.0-or-later** con un término adicional de la sección 7(b): las +versiones modificadas tienen que conservar la atribución «Created by Aimar +(github.com/Aimarmndz)» en el pie de las páginas públicas y en «Acerca de» de los ajustes. +Puedes usarlo, modificarlo y alojarlo, también con fines comerciales. Si lo ofreces como +servicio, tienes que publicar tu código bajo la misma licencia. Ver [`LICENSE`](./LICENSE). diff --git a/README.md b/README.md index ae60c82..bf77731 100644 --- a/README.md +++ b/README.md @@ -2,142 +2,219 @@ # orgtable -**Tableros Kanban self-hosted — una alternativa a Trello.** +### Your work, in columns that breathe. -[![CI](https://github.com/Aimarmndz/orgtable/actions/workflows/ci.yml/badge.svg)](https://github.com/Aimarmndz/orgtable/actions/workflows/ci.yml) -[![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org) -[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-17-336791.svg)](https://www.postgresql.org) - - - ---- - -## Qué es +Open-source Kanban boards: boards, lists and cards with drag and drop,
+in real time, with your data wherever you decide. A Trello alternative you can host yourself. -orgtable es una aplicación de tableros Kanban (tableros → listas → tarjetas con -arrastrar y soltar) que **se aloja en tu propio servidor**. Se instala con un solo -comando: Docker se autolevanta y un asistente de configuración inicial te guía para -crear la cuenta de administrador. - -- 🗂️ **Tableros, listas y tarjetas** con arrastrar y soltar (ratón y teclado), detalle - de tarjeta con etiquetas, fechas, checklists, adjuntos, asignados y comentarios. -- 👥 **Multiusuario** con roles por tablero, tiempo real y notificaciones. -- 🔎 **Búsqueda, filtros y vista de calendario**, con recordatorios de vencimiento. -- 🔌 **API REST** con tokens personales. -- 🐳 **Self-hosted con Docker**: tus datos, tu servidor. El instalador genera las - contraseñas de la base de datos por ti. - -Estado: hitos 0–13 completados; los siguientes están en la -[hoja de ruta](./docs/roadmap.md). - -| Capa | Tecnología | -|------|-----------| -| Framework | [Next.js 16](https://nextjs.org) (App Router) + React 19 | -| Lenguaje | TypeScript 5 | -| Estilos | Tailwind CSS 4 | -| Base de datos | PostgreSQL 17 | -| ORM | Prisma 7 (driver adapter `@prisma/adapter-pg`) | -| Sesiones | JWT con `jose` (cookie httpOnly) respaldado por tabla `Session` | -| Arrastrar y soltar | `@dnd-kit` + `fractional-indexing` | -| Despliegue | Docker + Docker Compose | +[![CI](https://github.com/Aimarmndz/orgtable/actions/workflows/ci.yml/badge.svg)](https://github.com/Aimarmndz/orgtable/actions/workflows/ci.yml) +[![License: AGPL-3.0-or-later](https://img.shields.io/badge/license-AGPL--3.0--or--later-9b411e)](./LICENSE) +[![Next.js 16](https://img.shields.io/badge/Next.js-16-black)](https://nextjs.org) +[![PostgreSQL 17](https://img.shields.io/badge/PostgreSQL-17-336791)](https://www.postgresql.org) +[![Docker](https://img.shields.io/badge/self--hosted-Docker-2496ed)](./docs/self-hosting.md) -## Requisitos +🇬🇧 **English** · 🇪🇸 [Español](./README.es.md) -**Para instalarlo en un servidor:** +[Features](#what-it-is) · [Installation](#installation) · [Privacy](#privacy) · [Documentation](./docs/index.md) · [License](#credits-and-license) -- Linux con acceso a internet. -- Docker y Docker Compose v2 (`docker compose`). Si falta Docker, el instalador lo - instala. -- Un puerto libre (por defecto el `3000`). -- Para un uso real: un dominio y un proxy inverso con HTTPS (Caddy o nginx). +
-**Para desarrollar:** +An orgtable board: the card “Team photos” is dragged from the In progress list to Review, then the details of “Build the home page” open, with labels, assignees, description, due date and a checklist -- Node.js 22 (la versión que usan el CI y la imagen Docker). -- pnpm 11, activado con `corepack enable` (la versión exacta está en `packageManager` - de `package.json`). No uses npm ni yarn. -- Docker, para la base de datos PostgreSQL de desarrollo. +No editing: the real app with sample data, recorded in the browser. -## Instalación + -### En un servidor +## What it is + +orgtable is a Kanban board app. Use it on a public instance with free sign-up, or run your +own on any server with Docker in a single command. + + +- 🗂️ **Boards, lists and cards** with drag and drop, by mouse and by keyboard. Cards have + labels, due dates, checklists, attachments, assignees and comments. +- ⚡ **Real time.** What one person moves, everyone else sees instantly, without reloading. +- 👥 **Teams.** Per-board roles (owner, editor and viewer), notifications and due-date + reminders. +- 🔎 **Search, filters and a calendar view**, where you drag cards to change their due date. +- 🔌 **REST API** with personal tokens, read-only or read-write, with optional expiry. +- 🔐 **Optional open sign-up and GDPR built in.** Legal pages adapt to each instance, and + everyone can download or delete their own data from the settings. +- 🌍 **English and Spanish.** The interface follows your browser language, and you can + change it from the footer or from *Settings › Appearance*. Translations are welcome: see + [`locales/README.md`](./locales/README.md). +- 🌗 **Light and dark themes**, accessible by keyboard and screen reader, comfortable on + mobile. +- 🐳 **Self-hostable.** The installer sets up Docker and generates the secrets, and a + wizard creates the admin account. + +## The app + + + + + + + + + + + + + + +
Two browsers on the same board: on the left Lucía moves the card “Monthly newsletter” to To do, and on the right Marcos sees it appear immediately
Real time. Lucía moves a card and Marcos sees it instantly.
Card details in the dark theme, with labels, assignees, description, due date and a half-done checklist
Card details, in the dark theme.
October calendar view with each card placed on its due date
Calendar. Drag a card to change its due date.
Board index with each board's color, card count and the initials of its members, plus the account menu
Your boards, with who is on each one.
Settings page with side navigation: profile, password and sessions, API tokens, appearance, privacy and data, and about
Settings. Profile, sessions, tokens and your data.
The same board in the dark theme
Dark theme, in warm charcoal.
+ +
+On mobile +
+

The board on a 390 px wide phone, with columns that scroll horizontally

+
+ +## Requirements + +**To host it on a server:** + +- Linux with internet access. +- Docker and Docker Compose v2 (`docker compose`). If Docker is missing, the installer + installs it. +- A free port, `3000` by default. +- For real use: a domain and a reverse proxy with HTTPS (Caddy or nginx). + +**To develop:** + +- Node.js 22, the version used by CI and the Docker image. +- pnpm 11, enabled with `corepack enable`. The exact version is in the `packageManager` + field of `package.json`. Do not use npm or yarn. +- Docker, for the development PostgreSQL database. + +## Installation + +### On a server ```bash -curl -sSL https://raw.githubusercontent.com/aimarmndzz/orgtable/main/install.sh | bash +curl -sSL https://raw.githubusercontent.com/Aimarmndz/orgtable/main/install.sh | bash ``` -El instalador detecta o instala Docker, genera los secretos (`.env` con permisos `600`) -y levanta la app. Después abre `http://:3000` y completa el -asistente inicial. Por HTTP la contraseña y la sesión viajan sin cifrar: para un uso -real, pon HTTPS delante siguiendo -[`docs/self-hosting.md`](./docs/self-hosting.md#https-y-dominio-reverse-proxy). -Guía completa e instalación manual en [`docs/installation.md`](./docs/installation.md). +The installer detects or installs Docker, generates the secrets (`.env` with `600` +permissions) and starts the app. Then open `http://:3000` and complete the +setup wizard, which creates the admin account. -### En local, para desarrollar +Over plain HTTP, the password and the session travel unencrypted. For real use, put HTTPS +in front of it following +[`docs/self-hosting.md`](./docs/self-hosting.md#https-and-domain-reverse-proxy). The full +guide and the manual installation are in [`docs/installation.md`](./docs/installation.md). + +### Locally, for development ```bash -git clone https://github.com/aimarmndzz/orgtable.git && cd orgtable +git clone https://github.com/Aimarmndz/orgtable.git && cd orgtable corepack enable pnpm install -# PostgreSQL de desarrollo en el puerto 5433 +# Development PostgreSQL on port 5433 docker run -d --name orgtable-dev-pg \ -e POSTGRES_USER=orgtable -e POSTGRES_PASSWORD=devpass -e POSTGRES_DB=orgtable \ -p 5433:5432 postgres:17-alpine -# .env mínimo: la app exige SESSION_SECRET (16 caracteres como mínimo) +# Minimal .env: the app requires SESSION_SECRET (at least 16 characters) printf 'DATABASE_URL=postgresql://orgtable:devpass@localhost:5433/orgtable\nSESSION_SECRET=%s\n' \ "$(openssl rand -hex 32)" > .env -pnpm exec prisma migrate dev # aplica las migraciones -pnpm exec prisma generate # genera el cliente de Prisma +pnpm exec prisma migrate dev # applies the migrations +pnpm exec prisma generate # generates the Prisma client pnpm dev # http://localhost:3000 ``` -Más detalles en [`docs/contributing.md`](./docs/contributing.md). +More details in [`docs/contributing.md`](./docs/contributing.md). -## Comandos +## Commands -| Comando | Qué hace | +| Command | What it does | |---|---| -| `pnpm dev` | Servidor de desarrollo con recarga en caliente. | -| `pnpm build` / `pnpm start` | Compila para producción y sirve la build. | +| `pnpm dev` | Development server with hot reload. | +| `pnpm build` / `pnpm start` | Builds for production and serves the build. | | `pnpm lint` | ESLint. | -| `pnpm typecheck` | Comprobación de tipos (`tsc --noEmit`). | -| `pnpm test` | Tests unitarios (sin base de datos). | -| `pnpm test:integration` | Tests de integración contra una PostgreSQL real cuyo nombre acabe en `_test` (ver [`docs/testing.md`](./docs/testing.md)). | -| `pnpm exec prisma migrate dev` | Crea y aplica migraciones en desarrollo. | -| `pnpm exec prisma migrate deploy` | Aplica las migraciones pendientes (lo hace solo el contenedor al arrancar). | -| `scripts/backup.sh` · `scripts/restore.sh` · `scripts/update.sh` | Copia de seguridad, restauración y actualización de una instalación con Docker (ver [`docs/operations.md`](./docs/operations.md)). | +| `pnpm typecheck` | Type check (`tsc --noEmit`). | +| `pnpm test` | Unit tests (no database). | +| `pnpm test:integration` | Integration tests against a real PostgreSQL database whose name ends in `_test` (see [`docs/testing.md`](./docs/testing.md)). | +| `pnpm exec prisma migrate dev` | Creates and applies migrations in development. | +| `pnpm exec prisma migrate deploy` | Applies pending migrations. The container does this on startup. | +| `scripts/backup.sh` · `scripts/restore.sh` · `scripts/update.sh` | Backup, restore and update of a Docker installation (see [`docs/operations.md`](./docs/operations.md)). | +| `POST /api/cron/reminders` · `POST /api/cron/maintenance` | Due-date reminders and cleanup of expired data. Triggered by an external cron job with `CRON_SECRET` (see [`docs/operations.md`](./docs/operations.md#scheduled-tasks)). | -## Variables de entorno +## Environment variables -La referencia de cada variable está en -[`docs/installation.md`](./docs/installation.md#variables-de-entorno) y la plantilla -comentada en [`.env.example`](./.env.example). +Every variable is documented in +[`docs/installation.md`](./docs/installation.md#environment-variables), and the commented +template is [`.env.example`](./.env.example). -- Obligatorias: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `DATABASE_URL`, +- **Required:** `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `DATABASE_URL`, `SESSION_SECRET`. -- Opcionales: `APP_PORT`, `CRON_SECRET`, `TRUSTED_PROXY_HOPS`, `ORGTABLE_TIMEZONE`, - `ORGTABLE_UPLOAD_DIR`, `ORGTABLE_REALTIME_PG_NOTIFY`. +- **Optional:** `APP_PORT`, `CRON_SECRET`, `TRUSTED_PROXY_HOPS` and the `ORGTABLE_*` + settings: `ORGTABLE_TIMEZONE`, `ORGTABLE_UPLOAD_DIR`, `ORGTABLE_REALTIME_PG_NOTIFY`, + `ORGTABLE_AUDIT_RETENTION_DAYS` and `ORGTABLE_SOURCE_URL`. + +## Deployment + +Docker Compose builds the image on the server itself: the app plus PostgreSQL, with no +database port exposed. Migrations are applied on startup. + +- Running in production, HTTPS and reverse proxy: [`docs/self-hosting.md`](./docs/self-hosting.md). +- Backups, restore, updates and cron: [`docs/operations.md`](./docs/operations.md). +- The image and the orchestration: [`docs/docker.md`](./docs/docker.md). +- Opening the instance to the public with free sign-up: the checklist is in + [`docs/privacy.md`](./docs/privacy.md#checklist-for-opening-a-public-instance). + +## Privacy + +orgtable keeps only what your account and boards need. There are no analytics, no ads and +no tracking cookies: just one session cookie (plus a language cookie when you pick a +language). + +- **Whoever runs an instance is the data controller.** They enter their details under + *Admin › Instance*, and the pages `/legal/privacy`, `/legal/terms`, + `/legal/cookies` and `/legal/notice` show them. Anything missing appears as "not + provided", never made up. +- **Everyone exercises their own rights** from *Settings › Privacy and data*: download all + their data as JSON, or delete their account together with their boards and attachments. +- **Short, automatic retention.** Sessions expire after 7 days, notifications are deleted + after 180 days and the security log after 90 (configurable). + +Technical details and legal basis in [`docs/privacy.md`](./docs/privacy.md). + +## Documentation + +All documentation lives in [`docs/`](./docs/), indexed in +[`docs/index.md`](./docs/index.md): architecture, database, authentication and +authorization, the [public API](./docs/api/public-api.md), the +[decision log](./docs/DECISIONES.md) and [how to contribute](./docs/contributing.md). -## Despliegue +## Contributing -La imagen se construye en el propio servidor con Docker Compose (app + PostgreSQL, sin -puertos de la base de datos expuestos) y aplica las migraciones al arrancar. +Contributions are welcome. Please read [`docs/contributing.md`](./docs/contributing.md) +first. Code, comments, docs and commit messages are in English. Every commit carries a +`Signed-off-by` line ([DCO](https://developercertificate.org/), with `git commit -s`). -- Operar en producción, HTTPS y proxy inverso: [`docs/self-hosting.md`](./docs/self-hosting.md). -- Copias de seguridad, restauración y actualizaciones: [`docs/operations.md`](./docs/operations.md). -- La imagen y la orquestación: [`docs/docker.md`](./docs/docker.md). +Translations are especially welcome: adding a language means copying one folder and +translating JSON files, with no code knowledge needed. See +[`locales/README.md`](./locales/README.md). -## Documentación +Report security issues through [`SECURITY.md`](./SECURITY.md), never in public issues. -Toda la documentación vive en [`docs/`](./docs/) (índice en -[`docs/index.md`](./docs/index.md)): arquitectura, base de datos, autenticación y -autorización, [decisiones de arquitectura (ADRs)](./docs/decisions/) y -[cómo contribuir](./docs/contributing.md). +## Credits and license -## Licencia +Created by **Aimar** ([github.com/Aimarmndz](https://github.com/Aimarmndz)). Built with +[Next.js](https://nextjs.org), [React](https://react.dev), +[Prisma](https://www.prisma.io), [PostgreSQL](https://www.postgresql.org), +[dnd-kit](https://dndkit.com) and [Tailwind CSS](https://tailwindcss.com). It uses the +typefaces [Bricolage Grotesque](https://fonts.google.com/specimen/Bricolage+Grotesque), +[Figtree](https://fonts.google.com/specimen/Figtree) and +[Geist Mono](https://fonts.google.com/specimen/Geist+Mono), all under the SIL OFL 1.1. -Software propietario de MARA-KASSA SL. Todos los derechos reservados: ver -[`LICENSE`](./LICENSE). +Licensed under the **GNU AGPL-3.0-or-later** with an additional term under section 7(b): +modified versions must keep the attribution "Created by Aimar (github.com/Aimarmndz)" in +the footer of the public pages and in the "About" section of the settings. You may use, +modify and host it, commercially too. If you offer it as a service, you must publish your +source code under the same license. See [`LICENSE`](./LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 99f7038..76e373e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,31 +1,29 @@ -# Política de seguridad +# Security policy -## Versiones con soporte +## Supported versions -orgtable se desarrolla en la rama `main` y no publica versiones numeradas: las -correcciones de seguridad llegan a `main`. Mantén tu instalación al día con -`scripts/update.sh`, que hace una copia de seguridad y actualiza a lo último de `main` -(ver [`docs/operations.md`](./docs/operations.md#actualizaciones)). +orgtable is developed on the `main` branch, and security fixes land on `main`. Keep your +installation up to date with `scripts/update.sh`, which takes a backup and updates to the +latest `main` (see [`docs/operations.md`](./docs/operations.md#updates)). -## Informar de una vulnerabilidad +## Reporting a vulnerability -**No** abras un issue público para un problema de seguridad. +**Do not** open a public issue for a security problem. - +Use GitHub private vulnerability reporting: the repository's **Security** tab → +**Report a vulnerability** +([direct link](https://github.com/Aimarmndz/orgtable/security/advisories/new)). It only +works while the repository is public; while it is private, contact the author through +their profile, [github.com/Aimarmndz](https://github.com/Aimarmndz). -Mientras no haya un canal publicado, contacta directamente con el propietario del -repositorio. + -Incluye, si puedes: la versión o el commit afectado, una descripción, los pasos para -reproducirlo y el impacto. +If you can, include the affected version or commit, a description, the steps to reproduce +it and the impact. -## Postura y endurecimiento +## Security posture and hardening -La postura de seguridad del proyecto, el resultado de la última auditoría y las -recomendaciones de endurecimiento están en [`docs/security.md`](./docs/security.md). -La guía de operación (TLS/proxy inverso, copias de seguridad, secretos) está en -[`docs/self-hosting.md`](./docs/self-hosting.md) y -[`docs/operations.md`](./docs/operations.md). +The project's security posture, the result of the latest audit and the hardening +recommendations are in [`docs/security.md`](./docs/security.md). The operations guide +(TLS/reverse proxy, backups, secrets) is in [`docs/self-hosting.md`](./docs/self-hosting.md) +and [`docs/operations.md`](./docs/operations.md). diff --git a/app/(app)/admin/admin-nav.tsx b/app/(app)/admin/admin-nav.tsx new file mode 100644 index 0000000..a38cd62 --- /dev/null +++ b/app/(app)/admin/admin-nav.tsx @@ -0,0 +1,36 @@ +import Link from "next/link"; +import { getI18n } from "@/app/i18n/server"; + +const TABS = [ + { href: "/admin/users", key: "users" }, + { href: "/admin/instance", key: "instance" }, + { href: "/admin/audit", key: "audit" }, +] as const; + +/** Tabs of the administration pages. */ +export async function AdminNav({ current }: { current: (typeof TABS)[number]["href"] }) { + const { m } = await getI18n(); + return ( + + ); +} diff --git a/app/(app)/admin/audit/page.tsx b/app/(app)/admin/audit/page.tsx index 713436d..c8fe419 100644 --- a/app/(app)/admin/audit/page.tsx +++ b/app/(app)/admin/audit/page.tsx @@ -1,93 +1,70 @@ import type { Metadata } from "next"; -import Link from "next/link"; import { requireAdmin } from "@/app/lib/dal"; +import { AdminNav } from "../admin-nav"; import { getRecentAuditLogs } from "@/app/lib/queries"; import type { AuditAction } from "@/app/lib/audit"; +import { getI18n } from "@/app/i18n/server"; import { formatDateTime } from "@/app/lib/format-date"; +import { plural } from "@/app/i18n/format"; -export const metadata: Metadata = { - title: "Auditoría", - description: "Eventos recientes de seguridad y operación de la instancia.", -}; - -// Etiquetas legibles para cada acción auditada. -const ACTION_LABELS: Record = { - "setup.completed": "Configuración inicial completada", - "auth.login": "Inicio de sesión", - "auth.login_failed": "Inicio de sesión fallido", - "auth.login_rate_limited": "Login bloqueado por límite de intentos", - "auth.logout": "Cierre de sesión", - "user.created": "Usuario creado", - "user.deleted": "Usuario borrado", - "user.role_changed": "Rol de usuario cambiado", - "user.password_reset": "Contraseña restablecida", - "user.sessions_revoked": "Sesiones cerradas", - "board.created": "Tablero creado", - "board.deleted": "Tablero borrado", - "member.added": "Miembro añadido", - "member.removed": "Miembro quitado", - "member.role_changed": "Rol de miembro cambiado", - "apitoken.created": "Token de API creado", - "apitoken.revoked": "Token de API revocado", -}; +export async function generateMetadata(): Promise { + const { m } = await getI18n(); + return { title: m.admin.audit.meta.title, description: m.admin.audit.meta.description }; +} -// Acciones de riesgo que conviene resaltar. +// Risky actions worth highlighting. const WARN_ACTIONS = new Set([ "auth.login_failed", "auth.login_rate_limited", + "auth.register_rate_limited", "user.deleted", + "account.deleted", "board.deleted", ]); -function actionLabel(action: string): string { - return ACTION_LABELS[action as AuditAction] ?? action; -} - export default async function AdminAuditPage() { await requireAdmin(); + const { intl, m } = await getI18n(); + const t = m.admin.audit; + // Typed against AuditAction: adding an action without its label in locales/en/admin.json + // fails the type check. Rows written by an older or newer version may hold an action + // that is no longer listed: those show the raw code. + const labels: Record = t.actions; + const actionLabel = (action: string): string => + Object.hasOwn(labels, action) ? labels[action as AuditAction] : action; const logs = await getRecentAuditLogs(); return (
-
+

- Auditoría + {t.title}

- +

- Los {logs.length} eventos de seguridad y operación más recientes. + {plural(intl, t.intro, logs.length)}

{logs.length === 0 ? ( -

Todavía no hay eventos.

+

{t.empty}

) : ( - // overflow-x-auto: en móvil la tabla se desplaza en vez de recortarse. + // overflow-x-auto: on mobile the table scrolls instead of being clipped.
- - - - + + + + {logs.map((log) => (
CuándoAcciónQuiénDetalle{t.table.when}{t.table.action}{t.table.who}{t.table.detail}
- {formatDateTime(log.createdAt)} + {formatDateTime(log.createdAt, intl)} string; + hint: (t: LegalText) => string; + maxLength: number; +}[] = [ + { + name: "operatorName", + autoComplete: "organization", + maxLength: 120, + hint: (t) => t.requiredToOpen, + }, + { + name: "operatorEmail", + type: "email", + autoComplete: "email", + maxLength: 254, + hint: (t) => t.requiredToOpen, + }, + { name: "operatorLegalId", maxLength: 40, hint: (t) => t.optional }, + { + name: "operatorAddress", + autoComplete: "street-address", + maxLength: 200, + hint: (t) => t.optional, + }, + { + name: "hostingDescription", + placeholder: (t) => t.hostingPlaceholder, + maxLength: 200, + hint: (t) => t.hostingHint, + }, + { + name: "backupRetentionDays", + type: "number", + maxLength: 4, + hint: (t) => t.backupHint, + }, +]; + +export function InstanceSettingsForm({ initial }: { initial: Values }) { + const [state, action, pending] = useActionState( + saveInstanceSettings, + {}, + ); + const feedbackId = useId(); + const bad = (f: string) => (!pending && state.field === f) || undefined; + const { m } = useI18n(); + const t = m.admin.instance; + + return ( +
+
+

+ {t.general} +

+
+ + +
+
+ +
+

+ {t.registration.title} +

+

{t.registration.description}

+
+ {t.registration.title} + {[ + { + value: "closed", + title: t.registration.closedTitle, + text: t.registration.closedText, + }, + { + value: "open", + title: t.registration.openTitle, + text: t.registration.openText, + }, + ].map((o) => ( + + ))} +
+
+ +
+ +

+ {rich(t.legal.description, { + link: (chunk) => ( + + {chunk} + + ), + })} +

+
+ {LEGAL_FIELDS.map((f) => ( +
+ + +

+ {f.hint(t.legal)} +

+
+ ))} +
+
+ + {pending ? ( +
+ ) : state.error ? ( + + ) : ( +
+ {state.message && ( +

+ {state.message} +

+ )} +
+ )} + +
+ +
+ + ); +} diff --git a/app/(app)/admin/instance/page.tsx b/app/(app)/admin/instance/page.tsx new file mode 100644 index 0000000..27b3c86 --- /dev/null +++ b/app/(app)/admin/instance/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import { requireAdmin } from "@/app/lib/dal"; +import { getLegalInfo } from "@/app/lib/services/instance"; +import { AdminNav } from "../admin-nav"; +import { InstanceSettingsForm } from "./instance-settings-form"; +import { getI18n } from "@/app/i18n/server"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const { m } = await getI18n(); + return { title: m.admin.instance.meta.title, description: m.admin.instance.meta.description }; +} + +export default async function AdminInstancePage() { + await requireAdmin(); + const [{ m }, info] = await Promise.all([getI18n(), getLegalInfo()]); + const t = m.admin.instance; + + return ( +
+
+
+

{t.title}

+ +
+

+ {t.intro} +

+ {info ? ( + + ) : ( +

{t.notConfigured}

+ )} +
+
+ ); +} diff --git a/app/(app)/admin/users/create-user-form.tsx b/app/(app)/admin/users/create-user-form.tsx index 0f71cbd..11b5797 100644 --- a/app/(app)/admin/users/create-user-form.tsx +++ b/app/(app)/admin/users/create-user-form.tsx @@ -4,9 +4,10 @@ import { useActionState, useEffect, useId, useRef } from "react"; import { createUser, type CreateUserState } from "@/app/lib/actions/admin"; import { Button } from "@/app/components/ui/button"; import { Input } from "@/app/components/ui/input"; +import { useI18n } from "@/app/i18n/client"; -// Alta de usuario por el administrador. useActionState da feedback inline; al crear -// con éxito se limpia el formulario. Ver app/lib/actions/admin.ts. +// Account creation by an administrator. useActionState gives inline feedback; the form +// is cleared after a successful creation. See app/lib/actions/admin.ts. export function CreateUserForm() { const ref = useRef(null); const errorId = useId(); @@ -14,8 +15,10 @@ export function CreateUserForm() { createUser, {}, ); + const { m } = useI18n(); + const t = m.admin.users.create; - // Limpia el formulario cuando el alta tiene éxito (llega un message). + // Clear the form when the creation succeeds (a message arrives). useEffect(() => { if (state.message) ref.current?.reset(); }, [state.message]); @@ -26,13 +29,11 @@ export function CreateUserForm() { action={action} className="rounded-xl border border-border bg-surface p-5" > -

Crear cuenta

-

- No hay registro abierto: aquí das de alta a las personas de tu instancia. -

+

{t.title}

+

{t.intro}

- {/* role="alert" para que el lector de pantalla lo anuncie. Se retira mientras se - envía: así un segundo error idéntico se vuelve a insertar y se anuncia de nuevo. */} + {/* role="alert" so screen readers announce it. Removed while submitting, so a + second identical error is inserted and announced again. */} {state.error && !pending && (

)} - {/* Región siempre montada: los lectores de pantalla solo anuncian los cambios - dentro de un role="status" que ya existía antes del mensaje. */} + {/* Always-mounted region: screen readers only announce changes inside a + role="status" that existed before the message. */}

{state.message && (

@@ -55,7 +56,7 @@ export function CreateUserForm() {

); diff --git a/app/(app)/admin/users/page.tsx b/app/(app)/admin/users/page.tsx index 20fa3e0..31568a6 100644 --- a/app/(app)/admin/users/page.tsx +++ b/app/(app)/admin/users/page.tsx @@ -1,33 +1,24 @@ import type { Metadata } from "next"; -import Link from "next/link"; import { requireAdmin } from "@/app/lib/dal"; +import { AdminNav } from "../admin-nav"; import { getAllUsers } from "@/app/lib/queries"; +import { getI18n } from "@/app/i18n/server"; import { formatDate } from "@/app/lib/format-date"; import { CreateUserForm } from "./create-user-form"; import { UserRowActions } from "./user-row-actions"; -export const metadata: Metadata = { - title: "Usuarios", - description: "Altas, roles y contraseñas de las cuentas de la instancia.", -}; +export async function generateMetadata(): Promise { + const { m } = await getI18n(); + return { title: m.admin.users.meta.title, description: m.admin.users.meta.description }; +} -// Mensajes que devuelven las acciones por fila vía ?ok=… / ?error=… (ver admin.ts). -const OK_MESSAGES: Record = { - role: "Rol actualizado.", - password: - "Contraseña restablecida. Se han cerrado las sesiones del usuario y revocado sus tokens de API.", - delete: "Usuario borrado.", - revoked: "Sesiones cerradas.", -}; -const ERROR_MESSAGES: Record = { - "self-role": "No puedes cambiar tu propio rol.", - "self-delete": "No puedes borrar tu propia cuenta.", - "owns-boards": - "Ese usuario es propietario de tableros. Transfiérelos o bórralos antes de eliminar la cuenta.", - password: "La contraseña debe tener al menos 8 caracteres.", - invalid: "Datos no válidos.", - "not-found": "Ese usuario ya no existe.", -}; +/** + * Message for a ?ok=… / ?error=… code returned by the row actions (see actions/admin.ts). + * The codes are the keys of m.admin.users.ok / .errors; an unknown code shows nothing. + */ +function messageFor(messages: Record, code: string | undefined) { + return code && Object.hasOwn(messages, code) ? messages[code] : undefined; +} export default async function AdminUsersPage({ searchParams, @@ -35,47 +26,41 @@ export default async function AdminUsersPage({ searchParams: Promise<{ ok?: string; error?: string }>; }) { const admin = await requireAdmin(); + const { intl, m } = await getI18n(); + const t = m.admin.users; const [{ ok, error }, users] = await Promise.all([ searchParams, getAllUsers(), ]); + const okMessage = messageFor(t.ok, ok); + const errorMessage = messageFor(t.errors, error); return (
-
+

- Usuarios + {t.title}

- +

- Administra las cuentas de tu instancia: altas, roles y contraseñas. + {t.intro}

- {ok && OK_MESSAGES[ok] && ( + {okMessage && (

- {OK_MESSAGES[ok]} + {okMessage}

)} - {error && ERROR_MESSAGES[error] && ( + {errorMessage && (

- {ERROR_MESSAGES[error]} + {errorMessage}

)} @@ -83,16 +68,16 @@ export default async function AdminUsersPage({
- {/* overflow-x-auto (no hidden): en móvil la tabla se desplaza y la columna - Acciones sigue alcanzable; también recorta las esquinas redondeadas. */} + {/* overflow-x-auto (not hidden): on mobile the table scrolls and the Actions + column stays reachable; it also clips the rounded corners. */}
- - - - + + + + @@ -103,21 +88,21 @@ export default async function AdminUsersPage({ {u.name} {u.role === "ADMIN" && ( - Admin + {t.adminBadge} )}
{u.email}
UsuarioTablerosAltaAcciones{t.table.user}{t.table.boards}{t.table.joined}{t.table.actions}
- {u._count.boards} + {u._count.boards} · - + {u._count.memberships} - {formatDate(u.createdAt)} + {formatDate(u.createdAt, intl)} - Tú + {t.you} ); @@ -46,10 +50,10 @@ export function UserRowActions({ defaultValue={role} onChange={(e) => e.currentTarget.form?.requestSubmit()} className="h-8 rounded-lg border border-border bg-surface px-2 text-xs text-ink outline-none focus-visible:border-accent" - aria-label="Rol global" + aria-label={t.roleLabel} > - - + + @@ -65,20 +69,20 @@ export function UserRowActions({ type="password" required minLength={8} - placeholder="Nueva contraseña" - aria-label={`Nueva contraseña para ${userName}`} + placeholder={t.newPassword} + aria-label={fmt(t.newPasswordFor, { name: userName })} autoComplete="new-password" className="h-8 w-40 text-xs" /> ) : ( @@ -87,7 +91,7 @@ export function UserRowActions({ onClick={() => setResetting(true)} className="rounded-lg px-2 py-1 text-xs text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink" > - Restablecer contraseña + {t.resetPassword} )} @@ -96,23 +100,23 @@ export function UserRowActions({
{ - if (!confirm("¿Borrar esta cuenta? Esta acción no se puede deshacer.")) + if (!confirm(t.deleteConfirm)) e.preventDefault(); }} > diff --git a/app/(app)/board/[id]/page.tsx b/app/(app)/board/[id]/page.tsx index d8d1e18..228768a 100644 --- a/app/(app)/board/[id]/page.tsx +++ b/app/(app)/board/[id]/page.tsx @@ -11,9 +11,10 @@ import { BoardRealtime } from "@/app/components/board/board-realtime"; import type { BoardMemberDTO } from "@/app/components/board/members-panel"; import type { BoardDTO } from "@/app/lib/types"; import type { BoardRole } from "@/app/lib/roles"; +import { getI18n } from "@/app/i18n/server"; -// El nombre sale de la misma guarda de acceso que la página: sin acceso, título -// genérico (la página redirige o da 404, pero el se resuelve aparte). +// The name comes from the same access guard as the page: without access, a generic +// title (the page redirects or 404s, but the <title> is resolved separately). export async function generateMetadata({ params, }: { @@ -22,7 +23,9 @@ export async function generateMetadata({ const { id } = await params; const user = await requireUser(); const title = await getBoardTitleForUser(id, user.id); - return { title: title ?? "Tablero" }; + if (title) return { title }; + const { m } = await getI18n(); + return { title: m.boards.board.metaTitle }; } export default async function BoardPage({ @@ -30,15 +33,15 @@ export default async function BoardPage({ }: { params: Promise<{ id: string }>; }) { - // En Next.js 16 `params` es asíncrono. El deep-link ?card=… lo lee BoardView de la URL - // (useSearchParams). Ver docs/search.md. + // In Next.js 16 `params` is async. BoardView reads the ?card=… deep link from the URL + // (useSearchParams). See docs/search.md. const { id } = await params; const user = await requireUser(); const board = await getBoardWithLists(id, user.id); if (!board) notFound(); - // Miembros del tablero y rol del visor (para autorizar la UI). El propietario es - // siempre miembro OWNER; si por algún motivo no apareciera, caemos a VIEWER. + // Board members and the viewer's role (to authorize the UI). The owner is always an + // OWNER member; if for some reason they were missing, fall back to VIEWER. const memberData = await getBoardMembers(id); const members: BoardMemberDTO[] = (memberData?.members ?? []).map((m) => ({ id: m.user.id, @@ -50,7 +53,7 @@ export default async function BoardPage({ const viewerRole: BoardRole = members.find((m) => m.id === user.id)?.role ?? "VIEWER"; - // Proyectamos a un DTO mínimo y serializable (las fechas a ISO string). + // Project to a minimal, serializable DTO (dates as ISO strings). const dto: BoardDTO = { id: board.id, title: board.title, diff --git a/app/(app)/boards/delete-board-button.tsx b/app/(app)/boards/delete-board-button.tsx index 85e3758..7efcc48 100644 --- a/app/(app)/boards/delete-board-button.tsx +++ b/app/(app)/boards/delete-board-button.tsx @@ -3,13 +3,15 @@ import { useEffect, useRef, useState } from "react"; import { useFormStatus } from "react-dom"; import { deleteBoard } from "@/app/lib/actions/boards"; +import { useI18n } from "@/app/i18n/client"; +import { fmt } from "@/app/i18n/format"; -// Borrar un tablero elimina en cascada listas, tarjetas y adjuntos, así que pide un -// segundo clic en línea («¿Borrar? Sí / No») en vez de window.confirm, que no se puede -// estilar y en táctil bastaba un toque en la esquina para perderlo todo. -// Mientras confirma queda siempre visible (no depende del hover de la tarjeta). -// Al cancelar, el formulario se desmonta con el foco dentro; sin devolverlo al ✕ caería -// al <body> y quien usa teclado o lector de pantalla perdería su sitio en la rejilla. +// Deleting a board cascades to its lists, cards and attachments, so it asks for a +// second inline click ("Delete? Yes / No") instead of window.confirm, which cannot be +// styled; on touch screens a single tap on the corner was enough to lose everything. +// While confirming it stays visible (it does not depend on the card's hover). +// On cancel the form unmounts with focus inside; without moving focus back to the ✕ it +// would fall to <body> and keyboard or screen reader users would lose their place. export function DeleteBoardButton({ boardId, title, @@ -19,10 +21,11 @@ export function DeleteBoardButton({ title: string; className: string; }) { + const { m } = useI18n(); const [confirming, setConfirming] = useState(false); const triggerRef = useRef<HTMLButtonElement>(null); - // Solo se devuelve el foco tras cancelar: en el montaje inicial cada tarjeta de la - // rejilla tiene su ✕ y ninguno debe robar el foco. + // Focus is only restored after cancelling: on the initial mount every board in the + // grid has its own ✕ and none of them should steal focus. const restoreFocus = useRef(false); useEffect(() => { @@ -44,7 +47,7 @@ export function DeleteBoardButton({ type="button" onClick={() => setConfirming(true)} className={className} - aria-label={`Borrar tablero ${title}`} + aria-label={fmt(m.boards.index.deleteBoard, { title })} > ✕ </button> @@ -60,32 +63,33 @@ export function DeleteBoardButton({ className="flex h-7 items-center gap-1 rounded-md bg-black/60 pl-2 pr-0.5 text-xs font-medium text-white" > <input type="hidden" name="boardId" value={boardId} /> - <span>¿Borrar?</span> + <span>{m.boards.confirm.deletePrompt}</span> <ConfirmButton title={title} /> <button type="button" - // El foco va a «No»: si se pulsa Intro por inercia, no se borra nada. + // Focus goes to "No": pressing Enter out of habit deletes nothing. autoFocus onClick={cancel} className="rounded px-1.5 py-1 hover:bg-white/20" > - No + {m.boards.confirm.no} </button> </form> ); } function ConfirmButton({ title }: { title: string }) { - // Evita el doble envío mientras la acción borra y redirige. + const { m } = useI18n(); + // Prevents a double submit while the action deletes and redirects. const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending} - aria-label={`Sí, borrar el tablero ${title}`} + aria-label={fmt(m.boards.index.confirmDeleteBoard, { title })} className="rounded px-1.5 py-1 hover:bg-white/20 disabled:opacity-60" > - Sí + {m.boards.confirm.yes} </button> ); } diff --git a/app/(app)/boards/import-board-form.tsx b/app/(app)/boards/import-board-form.tsx index 7af8b24..94392b9 100644 --- a/app/(app)/boards/import-board-form.tsx +++ b/app/(app)/boards/import-board-form.tsx @@ -3,10 +3,12 @@ import { useActionState } from "react"; import { importBoard, type ImportState } from "@/app/lib/actions/boards"; import { Button } from "@/app/components/ui/button"; +import { useI18n } from "@/app/i18n/client"; -// Importa un tablero desde un fichero JSON exportado. useActionState da feedback inline; -// al importar con éxito, la acción redirige al tablero nuevo. Ver docs/operations.md. +// Imports a board from an exported JSON file. useActionState gives inline feedback; on +// success the action redirects to the new board. See docs/operations.md. export function ImportBoardForm() { + const { m } = useI18n(); const [state, action, pending] = useActionState<ImportState, FormData>( importBoard, {}, @@ -19,14 +21,14 @@ export function ImportBoardForm() { name="file" accept="application/json,.json" required - aria-label="Fichero JSON del tablero a importar" + aria-label={m.boards.import.fileLabel} className="max-w-full text-sm text-ink-muted file:mr-2 file:rounded-lg file:border-0 file:bg-surface-2 file:px-3 file:py-1.5 file:text-sm file:text-ink hover:file:bg-border/40" /> <Button type="submit" variant="subtle" disabled={pending}> - {pending ? "Importando…" : "Importar tablero"} + {pending ? m.boards.import.pending : m.boards.import.submit} </Button> - {/* role="alert" para que el lector de pantalla lo anuncie. Se retira mientras se - envía: así un segundo error idéntico se vuelve a insertar y se anuncia de nuevo. */} + {/* role="alert" so the screen reader announces it. It is removed while submitting, + so a second identical error is inserted again and announced again. */} {state.error && !pending && ( <p role="alert" diff --git a/app/(app)/boards/page.tsx b/app/(app)/boards/page.tsx index 71a55d0..c6b585f 100644 --- a/app/(app)/boards/page.tsx +++ b/app/(app)/boards/page.tsx @@ -1,50 +1,76 @@ import type { Metadata } from "next"; import Link from "next/link"; import { requireUser } from "@/app/lib/dal"; -import { getBoardsForUser } from "@/app/lib/queries"; +import { getBoardSummariesForUser } from "@/app/lib/queries"; +import { initials } from "@/app/lib/initials"; import { createBoard } from "@/app/lib/actions/boards"; import { boardBackgroundFor } from "@/app/lib/colors"; import { Button } from "@/app/components/ui/button"; import { Input } from "@/app/components/ui/input"; import { ImportBoardForm } from "./import-board-form"; import { DeleteBoardButton } from "./delete-board-button"; +import { getI18n } from "@/app/i18n/server"; +import { fmt, plural } from "@/app/i18n/format"; -export const metadata: Metadata = { title: "Tus tableros" }; +export async function generateMetadata(): Promise<Metadata> { + const { m } = await getI18n(); + return { title: m.boards.index.metaTitle }; +} -// Acciones de esquina de cada tablero. En escritorio aparecen al pasar el ratón o al -// llegar con el teclado; en pantallas sin hover (táctiles) se ven siempre, porque si no -// no hay forma de descubrirlas. +// Corner actions of each board. On desktop they appear on hover or when reached with +// the keyboard; on screens without hover (touch) they are always visible, otherwise +// there would be no way to discover them. const CORNER_ACTION = "grid size-7 place-items-center rounded-md bg-black/20 text-white/90 opacity-0 transition hover:bg-black/40 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100"; export default async function BoardsPage() { const user = await requireUser(); - const boards = await getBoardsForUser(user.id); + const boards = await getBoardSummariesForUser(user.id); + const { m, intl } = await getI18n(); + const t = m.boards.index; return ( <div className="relative z-10 mx-auto w-full max-w-6xl overflow-y-auto p-6 sm:p-8"> <h1 className="font-display text-3xl font-bold tracking-tight text-balance"> - Tus tableros + {t.title} </h1> <p className="mt-1.5 text-sm text-ink-muted"> - Organiza tu trabajo en listas y tarjetas. + {t.subtitle} </p> - <form action={createBoard} className="mt-7 flex gap-2"> + <form action={createBoard} className="mt-7 flex flex-wrap gap-2"> <Input name="title" required maxLength={120} - aria-label="Nombre del nuevo tablero" - placeholder="Nombre del nuevo tablero" - className="max-w-xs" + aria-label={t.newBoardName} + placeholder={t.newBoardName} + className="min-w-0 flex-1 sm:max-w-xs sm:flex-none" /> - <Button type="submit">Crear tablero</Button> + <Button type="submit">{t.createBoard}</Button> </form> - <div className="mt-3"> - <ImportBoardForm /> - </div> + {/* Importing is rare: collapsed so it does not compete with "Create board". */} + <details className="group/import mt-3 text-sm"> + <summary className="inline-flex cursor-pointer list-none items-center gap-1.5 rounded-lg px-1 py-1 text-ink-muted transition-colors hover:text-ink [&::-webkit-details-marker]:hidden"> + <svg + className="size-3.5 transition-transform group-open/import:rotate-90" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2.5" + strokeLinecap="round" + strokeLinejoin="round" + aria-hidden="true" + > + <path d="M9 6l6 6-6 6" /> + </svg> + {t.importToggle} + </summary> + <div className="mt-2 rounded-xl border border-border bg-surface p-3"> + <ImportBoardForm /> + </div> + </details> {boards.length === 0 ? ( <div className="mt-20 flex flex-col items-center text-center"> @@ -64,11 +90,10 @@ export default async function BoardsPage() { </svg> </div> <p className="mt-5 font-display text-lg font-bold tracking-tight"> - Todavía no tienes tableros + {t.emptyTitle} </p> <p className="mt-1.5 max-w-xs text-sm text-ink-muted"> - Crea el primero con el campo de arriba y empieza a organizar tu - trabajo en listas y tarjetas. + {t.emptyText} </p> </div> ) : ( @@ -78,7 +103,7 @@ export default async function BoardsPage() { <Link href={`/board/${board.id}`} style={{ background: boardBackgroundFor(board) }} - className="relative flex h-32 flex-col justify-end overflow-hidden rounded-2xl p-4 text-white shadow-[0_4px_16px_-8px_oklch(0.27_0.022_52/0.3)] ring-1 ring-inset ring-black/10 transition-transform duration-150 ease-out hover:-translate-y-0.5" + className="relative flex h-36 flex-col justify-end overflow-hidden rounded-2xl p-4 text-white shadow-[0_4px_16px_-8px_oklch(0.27_0.022_52/0.3)] ring-1 ring-inset ring-black/10 transition-transform duration-150 ease-out hover:-translate-y-0.5" > <span aria-hidden @@ -87,13 +112,48 @@ export default async function BoardsPage() { <span className="relative font-display text-lg font-bold leading-tight tracking-tight drop-shadow-sm"> {board.title} </span> + <span className="relative mt-2 flex items-center justify-between gap-2 text-xs font-medium text-white/85"> + <span> + {plural(intl, t.cardCount, board.cardCount)} + </span> + {/* Only when shared: on a personal board your own face is redundant. */} + {board.members.length > 1 && ( + <span className="flex -space-x-1.5"> + {/* aria-label does not work on a span without a role: the name goes as hidden text. */} + <span className="sr-only"> + {plural(intl, t.sharedWith, board.members.length, { + names: new Intl.ListFormat(intl, { + type: "conjunction", + }).format(board.members.map((member) => member.name)), + })} + </span> + {board.members.slice(0, 4).map((member) => ( + <span + key={member.id} + aria-hidden + className="grid size-6 place-items-center rounded-full bg-white/90 text-[10px] font-bold text-ink ring-2 ring-black/10" + > + {initials(member.name)} + </span> + ))} + {board.members.length > 4 && ( + <span + aria-hidden + className="grid size-6 place-items-center rounded-full bg-black/30 text-[10px] font-bold text-white ring-2 ring-black/10" + > + +{board.members.length - 4} + </span> + )} + </span> + )} + </span> </Link> <div className="absolute right-2 top-2 flex gap-1"> <a href={`/api/boards/${board.id}/export`} className={CORNER_ACTION} - aria-label={`Exportar tablero ${board.title}`} - title="Exportar a JSON" + aria-label={fmt(t.exportBoard, { title: board.title })} + title={t.exportTitle} > <svg className="size-4" @@ -106,8 +166,8 @@ export default async function BoardsPage() { <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /> </svg> </a> - {/* Solo el dueño puede borrarlo (deleteBoard lo exige); a los - miembros no se les enseña un botón que no hace nada. */} + {/* Only the owner can delete it (deleteBoard requires it); members + are not shown a button that does nothing. */} {board.ownerId === user.id && ( <DeleteBoardButton boardId={board.id} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index e325607..f24fede 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -9,15 +9,15 @@ import { getNotificationsForUser, getUnreadNotificationCount, } from "@/app/lib/queries"; -import { logout } from "@/app/lib/actions/auth"; import { ThemeToggle } from "@/app/components/theme-toggle"; import { SearchBox } from "@/app/components/search-box"; import { NotificationsBell } from "@/app/components/notifications-bell"; -import { Button } from "@/app/components/ui/button"; +import { AccountMenu } from "@/app/components/account-menu"; +import { getI18n } from "@/app/i18n/server"; -// Todo el área autenticada depende de la sesión y de la base de datos: nunca se -// prerenderiza en build (la consulta de setup ocurre antes del acceso a cookies, así -// que hay que marcarlo explícitamente para que el build de Docker no toque la DB). +// The whole signed-in area depends on the session and the database, so it is never +// prerendered at build time. The setup query runs before any cookie access, so this must +// be explicit or the Docker build would try to reach the DB. export const dynamic = "force-dynamic"; export default async function AppLayout({ @@ -27,7 +27,8 @@ export default async function AppLayout({ }) { if (!(await isSetupComplete())) redirect("/setup"); const user = await requireUser(); - const [config, notifications, unreadCount] = await Promise.all([ + const [{ m }, config, notifications, unreadCount] = await Promise.all([ + getI18n(), getInstanceConfig(), getNotificationsForUser(user.id), getUnreadNotificationCount(user.id), @@ -40,17 +41,17 @@ export default async function AppLayout({ <span className="grid size-7 shrink-0 place-items-center rounded-md bg-accent font-display text-sm font-extrabold text-accent-ink"> o </span> - {/* title: el nombre admite 60 caracteres y en móvil se trunca. */} + {/* title: the name allows 60 characters and is truncated on mobile. */} <span - title={config?.instanceName ?? "orgtable"} + title={config?.instanceName ?? m.common.appName} className="truncate font-display font-bold tracking-tight" > - {config?.instanceName ?? "orgtable"} + {config?.instanceName ?? m.common.appName} </span> </Link> - {/* Búsqueda — centrada y solo en pantallas medianas hacia arriba; en móvil se - llega a /search desde el icono de la derecha. */} + {/* Search: centred, and only from medium screens up; on mobile /search is + reached from the icon on the right. */} <div className="mx-6 hidden max-w-sm flex-1 sm:block"> <SearchBox /> </div> @@ -58,7 +59,7 @@ export default async function AppLayout({ <div className="flex shrink-0 items-center gap-0.5 sm:gap-1"> <Link href="/search" - aria-label="Buscar" + aria-label={m.nav.search.label} className="grid size-9 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink sm:hidden" > <svg @@ -75,28 +76,6 @@ export default async function AppLayout({ <path d="M21 21l-4.3-4.3" /> </svg> </Link> - {user.role === "ADMIN" && ( - <Link - href="/admin/users" - aria-label="Administración" - className="grid size-9 place-items-center rounded-lg text-sm font-medium text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink sm:flex sm:size-auto sm:px-3 sm:py-1.5" - > - {/* En móvil solo cabe el icono; el texto aparece desde sm. */} - <svg - className="size-[18px] sm:hidden" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - aria-hidden="true" - > - <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" /> - </svg> - <span className="hidden sm:inline">Administración</span> - </Link> - )} <NotificationsBell notifications={notifications.map((n) => ({ ...n, @@ -104,34 +83,12 @@ export default async function AppLayout({ }))} unreadCount={unreadCount} /> - <Link - href="/settings" - aria-label="Ajustes" - className="grid size-9 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink" - > - <svg - className="size-[18px]" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - aria-hidden="true" - > - <circle cx="12" cy="12" r="3" /> - <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /> - </svg> - </Link> <ThemeToggle /> - <span className="mx-2 hidden text-sm text-ink-muted sm:inline"> - {user.email} - </span> - <form action={logout}> - <Button variant="ghost" type="submit"> - Salir - </Button> - </form> + <AccountMenu + name={user.name} + email={user.email} + isAdmin={user.role === "ADMIN"} + /> </div> </header> <main className="flex flex-1 flex-col overflow-hidden">{children}</main> diff --git a/app/(app)/search/page.tsx b/app/(app)/search/page.tsx index 5810c90..1994a35 100644 --- a/app/(app)/search/page.tsx +++ b/app/(app)/search/page.tsx @@ -5,11 +5,18 @@ import { searchForUser, type SearchResults } from "@/app/lib/queries"; import { boardBackgroundFor } from "@/app/lib/colors"; import { SearchBox } from "@/app/components/search-box"; import { firstParam } from "@/app/lib/search-params"; +import { getI18n } from "@/app/i18n/server"; +import { fmt } from "@/app/i18n/format"; export const dynamic = "force-dynamic"; -// La consulta NO va en el título: acabaría en el historial y en las pestañas. -export const metadata: Metadata = { title: "Búsqueda" }; +const MIN_QUERY_LENGTH = 2; + +// The query is NOT part of the title: it would end up in the history and in tabs. +export async function generateMetadata(): Promise<Metadata> { + const { m } = await getI18n(); + return { title: m.search.meta.title }; +} export default async function SearchPage({ searchParams, @@ -17,26 +24,28 @@ export default async function SearchPage({ searchParams: Promise<{ q?: string | string[] }>; }) { const user = await requireUser(); + const { m } = await getI18n(); + const t = m.search; const query = (firstParam((await searchParams).q) ?? "").trim(); const results: SearchResults = - query.length >= 2 + query.length >= MIN_QUERY_LENGTH ? await searchForUser(user.id, query) : { boards: [], cards: [] }; const total = results.boards.length + results.cards.length; return ( <div className="relative z-10 mx-auto w-full max-w-3xl overflow-y-auto p-6 sm:p-8"> - <h1 className="font-display text-3xl font-bold tracking-tight">Búsqueda</h1> + <h1 className="font-display text-3xl font-bold tracking-tight">{t.title}</h1> - {/* En móvil la barra de la navbar se oculta; aquí siempre hay una grande. */} + {/* On mobile the top bar's box is hidden; there is always a big one here. */} <div className="mt-5"> <SearchBox defaultValue={query} autoFocus size="lg" /> </div> - {query.length < 2 ? ( + {query.length < MIN_QUERY_LENGTH ? ( <p className="mt-8 text-sm text-ink-muted"> - Escribe al menos 2 caracteres para buscar en tus tableros y tarjetas. + {fmt(t.tooShort, { min: MIN_QUERY_LENGTH })} </p> ) : total === 0 ? ( <div className="mt-16 flex flex-col items-center text-center"> @@ -56,19 +65,16 @@ export default async function SearchPage({ </svg> </div> <p className="mt-5 font-display text-lg font-bold tracking-tight"> - Sin resultados para «{query}» - </p> - <p className="mt-1.5 max-w-xs text-sm text-ink-muted"> - Prueba con otras palabras. Solo se buscan tableros y tarjetas a los - que tienes acceso. + {fmt(t.noResults, { query })} </p> + <p className="mt-1.5 max-w-xs text-sm text-ink-muted">{t.noResultsHint}</p> </div> ) : ( <div className="mt-8 space-y-10"> {results.boards.length > 0 && ( <section> <h2 className="text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Tableros · {results.boards.length} + {fmt(t.boards, { count: results.boards.length })} </h2> <ul className="mt-3 space-y-1.5"> {results.boards.map((b) => ( @@ -86,7 +92,7 @@ export default async function SearchPage({ {b.title} </span> <span className="shrink-0 text-xs text-ink-muted"> - Tablero + {t.boardBadge} </span> </Link> </li> @@ -98,7 +104,7 @@ export default async function SearchPage({ {results.cards.length > 0 && ( <section> <h2 className="text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Tarjetas · {results.cards.length} + {fmt(t.cards, { count: results.cards.length })} </h2> <ul className="mt-3 space-y-1.5"> {results.cards.map((c) => ( diff --git a/app/(app)/settings/account-forms.tsx b/app/(app)/settings/account-forms.tsx new file mode 100644 index 0000000..b94d938 --- /dev/null +++ b/app/(app)/settings/account-forms.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useActionState, useId } from "react"; +import { + changePassword, + deleteAccount, + updateProfile, + type AccountFormState, +} from "@/app/lib/actions/account"; +import { Button } from "@/app/components/ui/button"; +import { Input } from "@/app/components/ui/input"; +import { useI18n } from "@/app/i18n/client"; + +// Forms for your own account in /settings. Each uses useActionState: the action returns +// an error (with the field it refers to) or a success message. + +/** Form error (role="alert") or confirmation (role="status"). */ +function FormFeedback({ + state, + pending, + id, +}: { + state: AccountFormState; + pending: boolean; + id: string; +}) { + // Removed while submitting, so a second identical error is announced again. + if (pending) return <div role="status" id={id} />; + if (state.error) { + return ( + <p + role="alert" + id={id} + className="rounded-lg border border-danger/20 bg-danger/10 px-3 py-2 text-sm text-danger" + > + {state.error} + </p> + ); + } + return ( + <div role="status" id={id}> + {state.message && ( + <p className="rounded-lg border border-success/25 bg-success/10 px-3 py-2 text-sm text-success"> + {state.message} + </p> + )} + </div> + ); +} + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: React.ReactNode; + children: (ids: { inputId: string; hintId?: string }) => React.ReactNode; +}) { + const inputId = useId(); + const hintId = useId(); + return ( + <div className="flex flex-col gap-1.5"> + <label htmlFor={inputId} className="text-sm font-medium"> + {label} + </label> + {children({ inputId, hintId: hint ? hintId : undefined })} + {hint && ( + <p id={hintId} className="text-xs text-ink-muted"> + {hint} + </p> + )} + </div> + ); +} + +function describedBy(...ids: (string | undefined | false)[]): string | undefined { + const list = ids.filter(Boolean).join(" "); + return list || undefined; +} + +export function ProfileForm({ name, email }: { name: string; email: string }) { + const [state, action, pending] = useActionState<AccountFormState, FormData>( + updateProfile, + {}, + ); + const { m } = useI18n(); + const t = m.settings.profile; + const feedbackId = useId(); + const bad = (f: string) => (!pending && state.field === f) || undefined; + + return ( + <form action={action} className="space-y-4"> + <div className="grid gap-4 sm:grid-cols-2"> + <Field label={t.name}> + {({ inputId }) => ( + <Input + id={inputId} + name="name" + required + maxLength={60} + autoComplete="name" + defaultValue={state.values?.name ?? name} + aria-invalid={bad("name")} + aria-describedby={describedBy(bad("name") && feedbackId)} + /> + )} + </Field> + <Field label={t.email}> + {({ inputId }) => ( + <Input + id={inputId} + name="email" + type="email" + required + maxLength={254} + autoComplete="email" + defaultValue={state.values?.email ?? email} + aria-invalid={bad("email")} + aria-describedby={describedBy(bad("email") && feedbackId)} + /> + )} + </Field> + </div> + <Field label={t.currentPassword} hint={t.currentPasswordHint}> + {({ inputId, hintId }) => ( + <Input + id={inputId} + name="currentPassword" + type="password" + autoComplete="current-password" + className="sm:max-w-xs" + aria-invalid={bad("currentPassword")} + aria-describedby={describedBy(hintId, bad("currentPassword") && feedbackId)} + /> + )} + </Field> + <FormFeedback state={state} pending={pending} id={feedbackId} /> + <div className="flex justify-end"> + <Button type="submit" disabled={pending}> + {pending ? m.common.actions.saving : t.save} + </Button> + </div> + </form> + ); +} + +export function PasswordForm() { + const [state, action, pending] = useActionState<AccountFormState, FormData>( + changePassword, + {}, + ); + const t = useI18n().m.settings.security; + const feedbackId = useId(); + const bad = (f: string) => (!pending && state.field === f) || undefined; + + return ( + <form action={action} className="space-y-4"> + <div className="grid gap-4 sm:grid-cols-3"> + <Field label={t.currentPassword}> + {({ inputId }) => ( + <Input + id={inputId} + name="currentPassword" + type="password" + required + autoComplete="current-password" + aria-invalid={bad("currentPassword")} + aria-describedby={describedBy(bad("currentPassword") && feedbackId)} + /> + )} + </Field> + <Field label={t.newPassword} hint={t.newPasswordHint}> + {({ inputId, hintId }) => ( + <Input + id={inputId} + name="newPassword" + type="password" + required + minLength={8} + autoComplete="new-password" + aria-invalid={bad("newPassword")} + aria-describedby={describedBy(hintId, bad("newPassword") && feedbackId)} + /> + )} + </Field> + <Field label={t.confirmPassword}> + {({ inputId }) => ( + <Input + id={inputId} + name="confirmPassword" + type="password" + required + autoComplete="new-password" + aria-invalid={bad("confirmPassword")} + aria-describedby={describedBy(bad("confirmPassword") && feedbackId)} + /> + )} + </Field> + </div> + <FormFeedback state={state} pending={pending} id={feedbackId} /> + <div className="flex justify-end"> + <Button type="submit" variant="subtle" disabled={pending}> + {pending ? t.submitting : t.submit} + </Button> + </div> + </form> + ); +} + +export function DeleteAccountForm() { + const [state, action, pending] = useActionState<AccountFormState, FormData>( + deleteAccount, + {}, + ); + const t = useI18n().m.settings.data.delete; + const feedbackId = useId(); + const bad = !pending && state.field === "password"; + + return ( + <form action={action} className="space-y-3"> + <div className="flex flex-col gap-3 sm:flex-row sm:items-end"> + <div className="flex-1"> + <Field label={t.confirmPassword}> + {({ inputId }) => ( + <Input + id={inputId} + name="password" + type="password" + required + autoComplete="current-password" + aria-invalid={bad || undefined} + aria-describedby={bad ? feedbackId : undefined} + /> + )} + </Field> + </div> + <Button + type="submit" + disabled={pending} + className="bg-danger text-accent-ink hover:bg-danger/90" + > + {pending ? t.submitting : t.submit} + </Button> + </div> + <FormFeedback state={state} pending={pending} id={feedbackId} /> + </form> + ); +} diff --git a/app/(app)/settings/create-token-form.tsx b/app/(app)/settings/create-token-form.tsx index 84c4516..e640eb2 100644 --- a/app/(app)/settings/create-token-form.tsx +++ b/app/(app)/settings/create-token-form.tsx @@ -4,16 +4,20 @@ import { useActionState, useRef, useState } from "react"; import { createApiToken, type CreateTokenState } from "@/app/lib/actions/tokens"; import { Button } from "@/app/components/ui/button"; import { Input } from "@/app/components/ui/input"; +import { useI18n } from "@/app/i18n/client"; +import { fmt, plural } from "@/app/i18n/format"; -// Alta de un token de API (Hito 9). useActionState devuelve el token EN CLARO una sola -// vez; se muestra con un botón de copiar y un aviso de que no se volverá a ver. +// Creates an API token. useActionState returns the token IN CLEAR exactly once; it is +// shown with a copy button and a warning that it won't be shown again. export function CreateTokenForm() { const [state, action, pending] = useActionState<CreateTokenState, FormData>( createApiToken, {}, ); - // El resultado de copiar va ligado al token al que se refiere: al crear otro token - // vuelve solo a «idle» sin necesidad de un efecto. + const { m, intl } = useI18n(); + const t = m.settings.tokens; + // The copy result is tied to the token it refers to: creating another token resets it + // to "idle" without an effect. const [copy, setCopy] = useState<{ token: string; status: "copied" | "error"; @@ -30,9 +34,9 @@ export function CreateTokenForm() { setCopy({ token, status: "copied" }); setTimeout(() => setCopy(null), 2000); } catch { - // Por http (contexto no seguro) el portapapeles no existe o se rechaza. El token - // solo se muestra una vez, así que se avisa y se deja seleccionado para copiarlo - // a mano. Nunca se registra el token. + // Over http (insecure context) the clipboard is missing or refused. The token is + // only shown once, so warn and leave it selected to copy by hand. The token is + // never logged. setCopy({ token, status: "error" }); if (codeRef.current) { window.getSelection()?.selectAllChildren(codeRef.current); @@ -42,10 +46,10 @@ export function CreateTokenForm() { return ( <div> - {/* El recuadro existe siempre (sin estilo hasta que hay token) para que la región - role="status" esté montada antes del aviso. Esa región solo lleva el texto - corto: si envolviera el token y el botón, el lector de pantalla leería el - token entero cada vez que cambiara (p. ej. al pasar a «Copiado»). */} + {/* The box always exists (unstyled until there is a token) so the role="status" + region is mounted before the message. That region only holds the short text: + if it wrapped the token and the button, screen readers would read the whole + token every time it changed (e.g. when switching to "Copied"). */} <div className={ state.token @@ -56,10 +60,10 @@ export function CreateTokenForm() { <div role="status"> {state.token && ( <> - <p className="text-sm font-medium">Token «{state.name}» creado</p> - <p className="mt-1 text-xs text-ink-muted"> - Cópialo ahora: por seguridad no se volverá a mostrar. + <p className="text-sm font-medium"> + {fmt(t.form.created, { name: state.name ?? "" })} </p> + <p className="mt-1 text-xs text-ink-muted">{t.form.copyNow}</p> </> )} </div> @@ -77,16 +81,15 @@ export function CreateTokenForm() { onClick={copyToken} className="shrink-0" > - {copyStatus === "copied" ? "Copiado" : "Copiar"} + {copyStatus === "copied" ? t.form.copied : t.form.copy} </Button> </div> )} - {/* Hermano de la región role="status", no hijo: dos regiones vivas anidadas - pueden anunciar el aviso dos veces. */} + {/* Sibling of the role="status" region, not a child: two nested live regions + may announce the message twice. */} {state.token && copyStatus === "error" && ( <p role="alert" className="mt-2 text-xs text-danger"> - No se pudo copiar automáticamente: selecciona el token y cópialo a - mano. + {t.form.copyFailed} </p> )} </div> @@ -94,37 +97,37 @@ export function CreateTokenForm() { <form action={action} className="flex flex-wrap items-end gap-2"> <label className="min-w-48 flex-1 text-sm"> <span className="block text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Nombre del token + {t.form.name} </span> <Input name="name" required maxLength={60} - placeholder="p. ej. Script de backup" + placeholder={t.form.namePlaceholder} defaultValue={state.values?.name} className="mt-1.5" /> </label> <label className="text-sm"> <span className="block text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Permisos + {t.form.scope} </span> - {/* Tras un error se conserva lo elegido. La key remonta el <select>: React - no actualiza el defaultValue de uno ya montado y el reset automático del - formulario lo devolvería al valor inicial. */} + {/* After an error the choice is kept. The key remounts the <select>: React does + not update the defaultValue of a mounted one, and the form's automatic + reset would bring it back to the initial value. */} <select key={state.values?.scope ?? "full"} name="scope" defaultValue={state.values?.scope ?? "full"} className="mt-1.5 h-9 rounded-lg border border-border bg-surface px-2.5 text-sm text-ink outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent/25" > - <option value="full">Lectura y escritura</option> - <option value="read">Solo lectura</option> + <option value="full">{t.scopeFull}</option> + <option value="read">{t.scopeRead}</option> </select> </label> <label className="text-sm"> <span className="block text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Caduca + {t.form.expiry} </span> <select key={state.values?.expiry ?? "0"} @@ -132,19 +135,19 @@ export function CreateTokenForm() { defaultValue={state.values?.expiry ?? "0"} className="mt-1.5 h-9 rounded-lg border border-border bg-surface px-2.5 text-sm text-ink outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent/25" > - <option value="0">Nunca</option> - <option value="30">30 días</option> - <option value="90">90 días</option> - <option value="365">1 año</option> + <option value="0">{t.form.expiryNever}</option> + <option value="30">{plural(intl, t.form.expiryDays, 30)}</option> + <option value="90">{plural(intl, t.form.expiryDays, 90)}</option> + <option value="365">{t.form.expiryYear}</option> </select> </label> <Button type="submit" disabled={pending}> - {pending ? "Creando…" : "Crear token"} + {pending ? t.form.submitting : t.form.submit} </Button> </form> - {/* role="alert" para que el lector de pantalla lo anuncie. Se retira mientras se - envía: así un segundo error idéntico se vuelve a insertar y se anuncia de nuevo. */} + {/* role="alert" so screen readers announce it. Removed while submitting, so a + second identical error is inserted and announced again. */} {state.error && !pending && ( <p role="alert" diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 91a9726..237faa3 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -1,103 +1,380 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { requireUser } from "@/app/lib/dal"; +import { requireUser, verifySession } from "@/app/lib/dal"; +import { prisma } from "@/app/lib/prisma"; import { getApiTokensForUser } from "@/app/lib/queries"; import { revokeApiToken } from "@/app/lib/actions/tokens"; +import { revokeOtherSessions, revokeSession } from "@/app/lib/actions/account"; +import { listActiveSessions } from "@/app/lib/services/account"; +import { getI18n } from "@/app/i18n/server"; +import { fmt, plural } from "@/app/i18n/format"; +import { rich } from "@/app/i18n/rich"; +import { LanguagePicker } from "@/app/components/language-picker"; import { formatDate } from "@/app/lib/format-date"; +import { describeUserAgent } from "@/app/lib/user-agent"; +import { + APP_VERSION, + AUTHOR_NAME, + AUTHOR_URL, + LICENSE_NAME, + LICENSE_URL, + sourceCodeUrl, +} from "@/app/lib/site"; import { CreateTokenForm } from "./create-token-form"; +import { DeleteAccountForm, PasswordForm, ProfileForm } from "./account-forms"; +import { ThemePicker } from "./theme-picker"; export const dynamic = "force-dynamic"; -export const metadata: Metadata = { - title: "Ajustes", - description: "Tu cuenta y tus tokens de API.", -}; +export async function generateMetadata(): Promise<Metadata> { + const { m } = await getI18n(); + return { title: m.settings.meta.title, description: m.settings.meta.description }; +} -// Fuera del componente: leer la hora dentro del render rompería la regla de pureza. +// Outside the component: reading the clock during render would break the purity rule. function isExpired(d: Date | null): boolean { return d !== null && d.getTime() < Date.now(); } +function now(): Date { + return new Date(); +} + +// The ids are URL fragments that other pages link to (/settings#data from the privacy +// policy, redirects in actions/account.ts), so they stay as they are in every language. +const SECTIONS = [ + { id: "profile", key: "profile", group: "account", icon: "user" }, + { id: "security", key: "security", group: "account", icon: "lock" }, + { id: "api", key: "api", group: "account", icon: "code" }, + { id: "appearance", key: "appearance", group: "account", icon: "moon" }, + { id: "data", key: "data", group: "data", icon: "download" }, + { id: "about", key: "about", group: "data", icon: "info" }, +] as const; + +const LINK = "text-accent underline-offset-2 hover:underline"; +const SUBHEADING = + "mb-2.5 text-[11px] font-semibold uppercase tracking-wider text-ink-muted"; + +const ICONS: Record<(typeof SECTIONS)[number]["icon"], React.ReactNode> = { + user: ( + <> + <circle cx="12" cy="8" r="4" /> + <path d="M4 21a8 8 0 0 1 16 0" /> + </> + ), + lock: ( + <> + <rect x="4" y="11" width="16" height="10" rx="2" /> + <path d="M8 11V7a4 4 0 0 1 8 0v4" /> + </> + ), + code: <path d="M8 9l-4 3 4 3M16 9l4 3-4 3M14 5l-4 14" />, + moon: <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />, + download: <path d="M12 3v12M7 10l5 5 5-5M5 21h14" />, + info: ( + <> + <circle cx="12" cy="12" r="9" /> + <path d="M12 11v6M12 7.5v.5" /> + </> + ), +}; + +function Section({ + id, + title, + description, + children, + tone = "default", +}: { + id: string; + title: string; + description?: React.ReactNode; + children: React.ReactNode; + tone?: "default" | "plain"; +}) { + return ( + <section + id={id} + aria-labelledby={`${id}-title`} + className={ + tone === "plain" + ? "scroll-mt-20" + : "scroll-mt-20 rounded-2xl border border-border bg-surface p-5 shadow-[0_1px_2px_oklch(0.27_0.022_52/0.04)] sm:p-7" + } + > + <h2 id={`${id}-title`} className="font-display text-xl font-bold tracking-tight"> + {title} + </h2> + {description && ( + <p className="mt-1 max-w-prose text-sm text-ink-muted">{description}</p> + )} + <div className="mt-5">{children}</div> + </section> + ); +} export default async function SettingsPage() { const user = await requireUser(); - const tokens = await getApiTokensForUser(user.id); + const { intl, m } = await getI18n(); + const t = m.settings; + const session = await verifySession(); + const [tokens, sessions, ownedBoards] = await Promise.all([ + getApiTokensForUser(user.id), + listActiveSessions(user.id, now()), + prisma.board.count({ where: { ownerId: user.id } }), + ]); + const otherSessions = sessions.filter((s) => s.id !== session?.sessionId).length; + const groups = [...new Set(SECTIONS.map((s) => s.group))]; return ( - <div className="relative z-10 mx-auto w-full max-w-3xl overflow-y-auto p-6 sm:p-8"> - <h1 className="font-display text-3xl font-bold tracking-tight">Ajustes</h1> - <p className="mt-1.5 text-sm text-ink-muted"> - Tu cuenta: {user.name} · {user.email} - </p> - - <section className="mt-8"> - <h2 className="font-display text-lg font-bold tracking-tight"> - Tokens de API - </h2> - <p className="mt-1 max-w-prose text-sm text-ink-muted"> - Crea tokens para usar la API de orgtable desde scripts u otras - aplicaciones. Un token actúa <strong>como tú</strong>: tiene tu mismo - acceso a los tableros. Guárdalo en un lugar seguro; solo se muestra una - vez. Consulta la{" "} - <Link - href="https://github.com/Aimarmndz/orgtable/blob/main/docs/api/public-api.md" - className="text-accent underline-offset-2 hover:underline" + <div className="relative z-10 flex-1 overflow-y-auto"> + <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6 md:flex-row md:gap-10 md:py-10"> + <nav aria-label={t.navLabel} className="shrink-0 md:w-56"> + <h1 className="font-display text-3xl font-extrabold tracking-tight">{t.title}</h1> + <p className="mt-1 truncate text-sm text-ink-muted" title={user.email}> + {user.name} + </p> + {/* On mobile the links scroll horizontally; from md up, a column. */} + <div className="-mx-4 mt-5 flex gap-1 overflow-x-auto px-4 md:mx-0 md:block md:overflow-visible md:px-0"> + {groups.map((group) => ( + <div key={group} className="contents md:mt-5 md:block md:first:mt-0"> + <p className="mb-1.5 hidden px-2.5 text-[11px] font-semibold uppercase tracking-wider text-ink-muted md:block"> + {t.groups[group]} + </p> + <ul className="flex shrink-0 gap-1 md:flex-col"> + {SECTIONS.filter((s) => s.group === group).map((s) => ( + <li key={s.id}> + <a + href={`#${s.id}`} + className="flex items-center gap-2.5 whitespace-nowrap rounded-lg px-2.5 py-2 text-sm font-medium text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent" + > + <svg + className="size-[18px] shrink-0" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + aria-hidden="true" + > + {ICONS[s.icon]} + </svg> + {t.sections[s.key]} + </a> + </li> + ))} + </ul> + </div> + ))} + </div> + </nav> + + <div className="flex min-w-0 flex-1 flex-col gap-5"> + <Section + id="profile" + title={t.sections.profile} + description={t.profile.description} > - documentación de la API - </Link> - . - </p> + <ProfileForm name={user.name} email={user.email} /> + </Section> - <div className="mt-5"> - <CreateTokenForm /> - </div> + <Section + id="security" + title={t.sections.security} + description={t.security.description} + > + <PasswordForm /> - {tokens.length === 0 ? ( - <p className="mt-6 text-sm text-ink-muted"> - Aún no has creado ningún token. - </p> - ) : ( - <ul className="mt-6 divide-y divide-border overflow-hidden rounded-xl border border-border"> - {tokens.map((t) => ( - <li - key={t.id} - className="flex items-center gap-3 bg-surface px-4 py-3" - > - <div className="min-w-0 flex-1"> - <p className="flex items-center gap-2"> - <span className="truncate text-sm font-medium"> - {t.name} - </span> - <span className="shrink-0 rounded-full bg-surface-2 px-2 py-0.5 text-[11px] font-medium text-ink-muted"> - {t.scope === "read" ? "Solo lectura" : "Escritura"} - </span> - {isExpired(t.expiresAt) && ( - <span className="shrink-0 rounded-full bg-danger/10 px-2 py-0.5 text-[11px] font-medium text-danger"> - Caducado - </span> + <h3 className={`mt-8 ${SUBHEADING}`}>{t.security.sessionsTitle}</h3> + <ul className="divide-y divide-border overflow-hidden rounded-xl border border-border"> + {sessions.map((s) => { + const current = s.id === session?.sessionId; + return ( + <li key={s.id} className="flex items-center gap-3 bg-surface px-4 py-3"> + <div className="min-w-0 flex-1"> + <p className="flex flex-wrap items-center gap-2 text-sm font-medium"> + {describeUserAgent(s.userAgent, t.security)} + {current && ( + <span className="rounded-full bg-success/10 px-2 py-0.5 text-[11px] font-semibold text-success"> + {t.security.thisSession} + </span> + )} + </p> + <p className="mt-0.5 font-mono text-xs text-ink-muted"> + {fmt(t.security.sessionDates, { + started: formatDate(s.createdAt, intl), + expires: formatDate(s.expiresAt, intl), + })} + </p> + </div> + {!current && ( + <form action={revokeSession}> + <input type="hidden" name="sessionId" value={s.id} /> + <button className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-danger transition-colors hover:bg-danger/10"> + {t.security.signOut} + </button> + </form> )} - </p> - <p className="mt-0.5 font-mono text-xs text-ink-muted"> - {t.prefix}… · creado {formatDate(t.createdAt)} ·{" "} - {t.expiresAt - ? `caduca ${formatDate(t.expiresAt)}` - : "no caduca"}{" "} - ·{" "} - {t.lastUsedAt - ? `usado ${formatDate(t.lastUsedAt)}` - : "sin usar"} - </p> - </div> - <form action={revokeApiToken}> - <input type="hidden" name="id" value={t.id} /> - <button className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-danger transition-colors hover:bg-danger/10"> - Revocar - </button> - </form> - </li> - ))} - </ul> - )} - </section> + </li> + ); + })} + </ul> + {otherSessions > 0 && ( + <form action={revokeOtherSessions} className="mt-3"> + <button className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-danger transition-colors hover:bg-danger/10"> + {plural(intl, t.security.signOutOthers, otherSessions)} + </button> + </form> + )} + </Section> + + <Section + id="api" + title={t.sections.api} + description={rich(t.tokens.description, { + strong: (chunk) => <strong>{chunk}</strong>, + link: (chunk) => ( + <a href={`${sourceCodeUrl()}/blob/main/docs/api/public-api.md`} className={LINK}> + {chunk} + </a> + ), + })} + > + <CreateTokenForm /> + + {tokens.length === 0 ? ( + <p className="mt-6 text-sm text-ink-muted">{t.tokens.empty}</p> + ) : ( + <ul className="mt-6 divide-y divide-border overflow-hidden rounded-xl border border-border"> + {tokens.map((tk) => ( + <li key={tk.id} className="flex items-center gap-3 bg-surface px-4 py-3"> + <div className="min-w-0 flex-1"> + <p className="flex items-center gap-2"> + <span className="truncate text-sm font-medium">{tk.name}</span> + <span className="shrink-0 rounded-full bg-surface-2 px-2 py-0.5 text-[11px] font-medium text-ink-muted"> + {tk.scope === "read" ? t.tokens.scopeRead : t.tokens.scopeWrite} + </span> + {isExpired(tk.expiresAt) && ( + <span className="shrink-0 rounded-full bg-danger/10 px-2 py-0.5 text-[11px] font-medium text-danger"> + {t.tokens.expired} + </span> + )} + </p> + <p className="mt-0.5 font-mono text-xs text-ink-muted"> + {tk.prefix}… · {fmt(t.tokens.created, { date: formatDate(tk.createdAt, intl) })} ·{" "} + {tk.expiresAt + ? fmt(t.tokens.expires, { date: formatDate(tk.expiresAt, intl) }) + : t.tokens.neverExpires}{" "} + ·{" "} + {tk.lastUsedAt + ? fmt(t.tokens.used, { date: formatDate(tk.lastUsedAt, intl) }) + : t.tokens.neverUsed} + </p> + </div> + <form action={revokeApiToken}> + <input type="hidden" name="id" value={tk.id} /> + <button className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-danger transition-colors hover:bg-danger/10"> + {t.tokens.revoke} + </button> + </form> + </li> + ))} + </ul> + )} + </Section> + + <Section + id="appearance" + title={t.sections.appearance} + description={t.appearance.description} + > + <h3 className={SUBHEADING}>{t.appearance.theme}</h3> + <p className="-mt-1.5 mb-3 text-sm text-ink-muted">{t.appearance.themeHint}</p> + <ThemePicker /> + + <h3 className={`mt-8 ${SUBHEADING}`}>{m.common.language.label}</h3> + <p className="-mt-1.5 mb-3 text-sm text-ink-muted">{m.common.language.description}</p> + <LanguagePicker variant="cards" /> + </Section> + + <Section + id="data" + title={t.sections.data} + description={rich(t.data.description, { + link: (chunk) => ( + <Link href="/legal/privacy" className={LINK}> + {chunk} + </Link> + ), + })} + > + <div className="flex flex-col gap-4 rounded-xl bg-surface-2 p-4 sm:flex-row sm:items-center"> + <div className="flex-1"> + <p className="text-sm font-semibold">{t.data.export.title}</p> + <p className="mt-0.5 text-sm text-ink-muted">{t.data.export.description}</p> + </div> + {/* A plain link (no fetch): the browser downloads the file with its name. */} + <a + href="/api/account/export" + download + className="inline-flex items-center justify-center gap-2 rounded-lg border border-border bg-surface px-4 py-2 text-sm font-medium transition hover:bg-bg active:translate-y-px" + > + {t.data.export.button} + </a> + </div> + + <div className="mt-4 rounded-xl border border-danger/35 p-4 sm:p-5"> + <h3 className="text-sm font-bold text-danger">{t.data.delete.title}</h3> + <p className="mt-1 mb-4 text-sm text-ink-muted"> + {ownedBoards === 0 + ? t.data.delete.descriptionNoBoards + : plural(intl, t.data.delete.descriptionWithBoards, ownedBoards)} + </p> + <DeleteAccountForm /> + </div> + </Section> + + <Section id="about" title={t.sections.about} tone="plain"> + <div className="flex flex-col gap-2 text-sm text-ink-muted sm:flex-row sm:flex-wrap sm:items-center sm:justify-between"> + <p> + {rich( + t.about.line, + { + version: (chunk) => <span className="font-mono">{chunk}</span>, + source: (chunk) => ( + <a href={sourceCodeUrl()} className={LINK}> + {chunk} + </a> + ), + license: (chunk) => ( + <a href={LICENSE_URL} className={LINK}> + {chunk} + </a> + ), + }, + { version: APP_VERSION, license: LICENSE_NAME }, + )} + </p> + {/* Attribution required by LICENSE (section 7(b) terms): derivatives must keep + it. Same message as the public footer; the licence allows translating the + words "Created by". */} + <p> + {rich( + m.legal.footer.createdBy, + { + link: (chunk) => ( + <a href={AUTHOR_URL} className="text-accent underline-offset-2 hover:underline"> + {chunk} + </a> + ), + }, + { author: AUTHOR_NAME }, + )} + </p> + </div> + </Section> + </div> + </div> </div> ); } diff --git a/app/(app)/settings/theme-picker.tsx b/app/(app)/settings/theme-picker.tsx new file mode 100644 index 0000000..a674c2c --- /dev/null +++ b/app/(app)/settings/theme-picker.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { useI18n } from "@/app/i18n/client"; + +type Theme = "light" | "dark" | "system"; + +const OPTIONS: Theme[] = ["light", "dark", "system"]; + +// Same key and values as the anti-flicker script in app/layout.tsx and ThemeToggle: +// "light", "dark" or nothing (= follow the system). +function readTheme(): Theme { + try { + const t = localStorage.getItem("theme"); + return t === "light" || t === "dark" ? t : "system"; + } catch { + // localStorage may be missing (strict private mode): follow the system. + return "system"; + } +} + +const listeners = new Set<() => void>(); +function subscribe(cb: () => void) { + listeners.add(cb); + window.addEventListener("storage", cb); + return () => { + listeners.delete(cb); + window.removeEventListener("storage", cb); + }; +} + +function applyTheme(theme: Theme) { + try { + if (theme === "system") localStorage.removeItem("theme"); + else localStorage.setItem("theme", theme); + } catch { + // Without storage the choice lasts until reload; it is applied anyway. + } + const dark = + theme === "dark" || + (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); + document.documentElement.classList.toggle("dark", dark); + listeners.forEach((l) => l()); +} + +/** Three-option theme picker (the top bar button only toggles light/dark). */ +export function ThemePicker() { + const t = useI18n().m.settings.appearance; + // There is no localStorage on the server: render "System" and fix it on hydration. + const current = useSyncExternalStore(subscribe, readTheme, () => "system" as Theme); + + return ( + <fieldset> + <legend className="sr-only">{t.theme}</legend> + <div className="grid gap-3 sm:grid-cols-3"> + {OPTIONS.map((value) => ( + <label + key={value} + className="flex cursor-pointer items-start gap-3 rounded-xl border border-border bg-surface p-3.5 transition-colors hover:bg-surface-2 has-[:checked]:border-accent has-[:checked]:bg-accent/5 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-accent" + > + <input + type="radio" + name="theme" + value={value} + checked={current === value} + onChange={() => applyTheme(value)} + className="mt-1 accent-accent" + /> + <span> + <span className="block text-sm font-semibold">{t.themes[value].label}</span> + <span className="block text-xs text-ink-muted">{t.themes[value].hint}</span> + </span> + </label> + ))} + </div> + </fieldset> + ); +} diff --git a/app/(auth)/brand-panel.tsx b/app/(auth)/brand-panel.tsx new file mode 100644 index 0000000..74cbeb8 --- /dev/null +++ b/app/(auth)/brand-panel.tsx @@ -0,0 +1,62 @@ +import { getI18n } from "@/app/i18n/server"; + +// Brand panel of the sign-in and sign-up screens. Wide screens only; on mobile each form +// carries its own compact brand mark. Terracotta accent and large display type. +export async function BrandPanel() { + const { m } = await getI18n(); + const t = m.auth.brand; + const columns = [ + { title: t.sample.todo, cards: [t.sample.todoCard1, t.sample.todoCard2] }, + { title: t.sample.done, cards: [t.sample.doneCard1] }, + ]; + return ( + <aside className="relative hidden flex-col justify-between overflow-hidden bg-accent p-12 text-accent-ink lg:flex"> + {/* Subtle concentric arcs as background texture (kept understated on purpose). */} + <div + aria-hidden + className="pointer-events-none absolute -right-40 -top-40 size-[34rem] rounded-full border border-accent-ink/15" + /> + <div + aria-hidden + className="pointer-events-none absolute -bottom-52 -left-32 size-[40rem] rounded-full border border-accent-ink/10" + /> + + <div className="relative flex items-center gap-2.5"> + <span className="grid size-8 place-items-center rounded-lg bg-accent-ink/15 font-display text-lg font-extrabold"> + o + </span> + <span className="font-display text-lg font-bold tracking-tight">orgtable</span> + </div> + + <div className="relative max-w-md"> + <h2 className="font-display text-5xl font-extrabold leading-[1.05] tracking-tight text-balance"> + {t.headline} + </h2> + <p className="mt-5 max-w-sm leading-relaxed text-accent-ink/80">{t.intro}</p> + + {/* Thumbnail of a board: previews the product without screenshots or illustrations. */} + <div aria-hidden className="mt-9 grid max-w-sm grid-cols-2 gap-2.5"> + {columns.map((col) => ( + <div key={col.title} className="flex flex-col gap-2 rounded-xl bg-accent-ink/12 p-2.5"> + <span className="px-1 text-[10px] font-semibold uppercase tracking-wider opacity-80"> + {col.title} + </span> + {col.cards.map((c) => ( + <span + key={c} + className="rounded-lg bg-surface px-2.5 py-2 text-sm font-medium text-ink shadow-sm" + > + {c} + </span> + ))} + </div> + ))} + </div> + </div> + + <p className="relative font-mono text-xs uppercase tracking-widest text-accent-ink/60"> + {t.keywords} + </p> + </aside> + ); +} diff --git a/app/(auth)/login/login-form.tsx b/app/(auth)/login/login-form.tsx index 679a330..c199b21 100644 --- a/app/(auth)/login/login-form.tsx +++ b/app/(auth)/login/login-form.tsx @@ -4,16 +4,19 @@ import { useActionState } from "react"; import { login, type AuthState } from "@/app/lib/actions/auth"; import { Button } from "@/app/components/ui/button"; import { Input } from "@/app/components/ui/input"; +import { useI18n } from "@/app/i18n/client"; export function LoginForm() { const [state, action, pending] = useActionState<AuthState, FormData>( login, {}, ); + const { m } = useI18n(); + const t = m.auth.login; return ( <form action={action} className="w-full max-w-sm"> - {/* Marca compacta (visible sobre todo en móvil, donde no hay panel lateral). */} + {/* Compact brand mark (mostly for mobile, where there is no side panel). */} <div className="flex items-center gap-2.5 lg:hidden"> <span className="grid size-8 place-items-center rounded-lg bg-accent font-display text-lg font-extrabold text-accent-ink"> o @@ -24,14 +27,14 @@ export function LoginForm() { </div> <h1 className="mt-8 font-display text-3xl font-bold tracking-tight lg:mt-0"> - Bienvenido de nuevo + {t.title} </h1> <p className="mt-2 text-sm text-ink-muted"> - Accede a tu instancia para seguir con tus tableros. + {t.subtitle} </p> - {/* role="alert" para que el lector de pantalla lo anuncie. Se retira mientras se - envía: así un segundo error idéntico se vuelve a insertar y se anuncia de nuevo. */} + {/* role="alert" so screen readers announce it. It is removed while submitting, so an + identical second error is inserted again and announced again. */} {state.error && !pending && ( <p role="alert" @@ -44,7 +47,7 @@ export function LoginForm() { <div className="mt-7 space-y-4"> <div className="flex flex-col gap-1.5"> <label htmlFor="email" className="text-sm font-medium"> - Email + {m.auth.fields.email} </label> <Input id="email" @@ -52,13 +55,13 @@ export function LoginForm() { type="email" required autoComplete="email" - placeholder="tu@correo.com" + placeholder={m.auth.fields.emailPlaceholder} defaultValue={state.values?.email} /> </div> <div className="flex flex-col gap-1.5"> <label htmlFor="password" className="text-sm font-medium"> - Contraseña + {m.auth.fields.password} </label> <Input id="password" @@ -73,10 +76,10 @@ export function LoginForm() { <Button type="submit" disabled={pending} className="mt-7 w-full"> {pending ? ( - "Entrando…" + t.submitting ) : ( <> - Entrar + {t.submit} <svg className="size-4" viewBox="0 0 24 24" diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index b226af7..d6777e1 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,59 +1,67 @@ import type { Metadata } from "next"; +import Link from "next/link"; import { redirect } from "next/navigation"; import { isSetupComplete, verifySession } from "@/app/lib/dal"; +import { isRegistrationOpen } from "@/app/lib/services/instance"; +import { PublicFooter } from "@/app/components/public-footer"; +import { getI18n } from "@/app/i18n/server"; +import { rich } from "@/app/i18n/rich"; +import { BrandPanel } from "../brand-panel"; import { LoginForm } from "./login-form"; export const dynamic = "force-dynamic"; -export const metadata: Metadata = { title: "Iniciar sesión" }; +export async function generateMetadata(): Promise<Metadata> { + const { m } = await getI18n(); + return { title: m.auth.login.metaTitle }; +} -export default async function LoginPage() { - // Si aún no se ha configurado la instancia, el primer paso es el wizard. +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ account?: string }>; +}) { + // If the instance has not been set up yet, the wizard comes first. if (!(await isSetupComplete())) redirect("/setup"); if (await verifySession()) redirect("/boards"); + const [registrationOpen, { account }, { m }] = await Promise.all([ + isRegistrationOpen(), + searchParams, + getI18n(), + ]); return ( <main className="grid min-h-[100dvh] flex-1 lg:grid-cols-[1.1fr_1fr]"> - {/* Panel de marca — solo en pantallas anchas. Acento terracota, display grande. */} - <aside className="relative hidden flex-col justify-between overflow-hidden bg-accent p-12 text-accent-ink lg:flex"> - {/* Arcos concéntricos sutiles como textura de fondo (no decorativa en exceso). */} - <div - aria-hidden - className="pointer-events-none absolute -right-40 -top-40 size-[34rem] rounded-full border border-accent-ink/15" - /> - <div - aria-hidden - className="pointer-events-none absolute -bottom-52 -left-32 size-[40rem] rounded-full border border-accent-ink/10" - /> - - <div className="relative flex items-center gap-2.5"> - <span className="grid size-8 place-items-center rounded-lg bg-accent-ink/15 font-display text-lg font-extrabold"> - o - </span> - <span className="font-display text-lg font-bold tracking-tight"> - orgtable - </span> - </div> + <BrandPanel /> - <div className="relative max-w-md"> - <h2 className="font-display text-5xl font-extrabold leading-[1.05] tracking-tight text-balance"> - Tu trabajo, en columnas que respiran. - </h2> - <p className="mt-5 max-w-sm leading-relaxed text-accent-ink/80"> - Tableros Kanban self-hosted. Tus datos en tu servidor, sin terceros - mirando. - </p> + <div className="flex flex-col p-6 sm:p-10"> + <div className="flex flex-1 flex-col items-center justify-center"> + {/* After deleting the account from the settings (actions/account.ts). */} + {account === "deleted" && ( + <p + role="status" + className="mb-6 w-full max-w-sm rounded-lg border border-success/25 bg-success/10 px-3 py-2 text-sm text-success" + > + {m.auth.login.accountDeleted} + </p> + )} + <LoginForm /> + {registrationOpen && ( + <p className="mt-6 w-full max-w-sm text-sm text-ink-muted"> + {rich(m.auth.login.noAccount, { + link: (c) => ( + <Link + href="/register" + className="font-semibold text-accent underline-offset-2 hover:underline" + > + {c} + </Link> + ), + })} + </p> + )} </div> - - {/* Sin mención a la licencia: la vigente es propietaria (LICENSE). */} - <p className="relative font-mono text-xs uppercase tracking-widest text-accent-ink/60"> - Kanban · Self-hosted - </p> - </aside> - - {/* Formulario */} - <div className="flex items-center justify-center p-6 sm:p-10"> - <LoginForm /> + <PublicFooter className="mt-10" /> </div> </main> ); diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..bdc5b0a --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,57 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { isSetupComplete, verifySession } from "@/app/lib/dal"; +import { getLegalInfo, isRegistrationOpen } from "@/app/lib/services/instance"; +import { PublicFooter } from "@/app/components/public-footer"; +import { getI18n } from "@/app/i18n/server"; +import { BrandPanel } from "../brand-panel"; +import { RegisterForm } from "./register-form"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise<Metadata> { + const { m } = await getI18n(); + return { title: m.auth.register.metaTitle, description: m.auth.register.metaDescription }; +} + +export default async function RegisterPage() { + if (!(await isSetupComplete())) redirect("/setup"); + if (await verifySession()) redirect("/boards"); + const [open, info, { m }] = await Promise.all([ + isRegistrationOpen(), + getLegalInfo(), + getI18n(), + ]); + const t = m.auth.register; + + return ( + <main className="grid min-h-[100dvh] flex-1 lg:grid-cols-[1.1fr_1fr]"> + <BrandPanel /> + + <div className="flex flex-col p-6 sm:p-10"> + <div className="flex flex-1 flex-col items-center justify-center"> + {open && info?.operatorName ? ( + <RegisterForm operatorName={info.operatorName} /> + ) : ( + <div className="w-full max-w-sm"> + <h1 className="font-display text-3xl font-bold tracking-tight"> + {t.closedTitle} + </h1> + <p className="mt-3 text-sm leading-relaxed text-ink-muted"> + {t.closedBody} + </p> + <Link + href="/login" + className="mt-6 inline-flex rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-ink transition hover:bg-accent-hover" + > + {t.closedCta} + </Link> + </div> + )} + </div> + <PublicFooter className="mt-10" /> + </div> + </main> + ); +} diff --git a/app/(auth)/register/register-form.tsx b/app/(auth)/register/register-form.tsx new file mode 100644 index 0000000..f080505 --- /dev/null +++ b/app/(auth)/register/register-form.tsx @@ -0,0 +1,143 @@ +"use client"; + +import Link from "next/link"; +import { useActionState } from "react"; +import { register, type RegisterState } from "@/app/lib/actions/register"; +import { Button } from "@/app/components/ui/button"; +import { Input } from "@/app/components/ui/input"; +import { useI18n } from "@/app/i18n/client"; +import { fmt } from "@/app/i18n/format"; +import { rich } from "@/app/i18n/rich"; + +export function RegisterForm({ operatorName }: { operatorName: string }) { + const [state, action, pending] = useActionState<RegisterState, FormData>(register, {}); + const invalid = (f: RegisterState["field"]) => (state.field === f && !pending) || undefined; + const { m } = useI18n(); + const t = m.auth.register; + const legalLink = (href: string, text: string) => ( + <Link href={href} className="font-medium text-accent underline underline-offset-2"> + {text} + </Link> + ); + + return ( + <form action={action} className="w-full max-w-sm"> + <div className="flex items-center gap-2.5 lg:hidden"> + <span className="grid size-8 place-items-center rounded-lg bg-accent font-display text-lg font-extrabold text-accent-ink"> + o + </span> + <span className="font-display text-lg font-bold tracking-tight">orgtable</span> + </div> + + <h1 className="mt-8 font-display text-3xl font-bold tracking-tight lg:mt-0"> + {t.title} + </h1> + <p className="mt-2 text-sm text-ink-muted"> + {rich(t.subtitle, { + link: (c) => ( + <Link href="/login" className="font-semibold text-accent underline-offset-2 hover:underline"> + {c} + </Link> + ), + })} + </p> + + {/* Removed while submitting, so an identical second error is announced again. */} + {state.error && !pending && ( + <p + role="alert" + id="register-error" + className="mt-5 rounded-lg border border-danger/20 bg-danger/10 px-3 py-2 text-sm text-danger" + > + {state.error} + </p> + )} + + <div className="mt-7 space-y-4"> + <div className="flex flex-col gap-1.5"> + <label htmlFor="name" className="text-sm font-medium"> + {m.auth.fields.name} + </label> + <Input + id="name" + name="name" + required + maxLength={60} + autoComplete="name" + defaultValue={state.values?.name} + aria-invalid={invalid("name")} + aria-describedby={invalid("name") ? "register-error" : undefined} + /> + </div> + <div className="flex flex-col gap-1.5"> + <label htmlFor="email" className="text-sm font-medium"> + {m.auth.fields.email} + </label> + <Input + id="email" + name="email" + type="email" + required + maxLength={254} + autoComplete="email" + placeholder={m.auth.fields.emailPlaceholder} + defaultValue={state.values?.email} + aria-invalid={invalid("email")} + aria-describedby={invalid("email") ? "register-error" : undefined} + /> + </div> + <div className="flex flex-col gap-1.5"> + <label htmlFor="password" className="text-sm font-medium"> + {m.auth.fields.password} + </label> + <Input + id="password" + name="password" + type="password" + required + minLength={8} + autoComplete="new-password" + aria-invalid={invalid("password")} + aria-describedby={invalid("password") ? "register-error password-hint" : "password-hint"} + /> + <p id="password-hint" className="text-xs text-ink-muted"> + {t.passwordHint} + </p> + </div> + + {/* Honeypot for bots: off screen and out of the tab order. People neither see nor + fill it; if it arrives with text, the sign-up is rejected. */} + <div aria-hidden="true" className="absolute -left-[10000px] size-px overflow-hidden"> + <label htmlFor="website">{t.honeypot}</label> + <input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" /> + </div> + + <label className="flex items-start gap-2.5 text-sm leading-relaxed"> + <input + type="checkbox" + name="acceptTerms" + required + className="mt-1 size-4 shrink-0 accent-accent" + aria-invalid={invalid("acceptTerms")} + /> + <span> + {rich(t.acceptTerms, { + privacy: (c) => legalLink("/legal/privacy", c), + terms: (c) => legalLink("/legal/terms", c), + })} + </span> + </label> + </div> + + <Button type="submit" disabled={pending} className="mt-7 w-full"> + {pending ? t.submitting : t.submit} + </Button> + + {/* First layer of information (art. 13 GDPR): the essentials next to the form, the + rest in the policy linked above. */} + <p className="mt-5 text-xs leading-relaxed text-ink-muted"> + {fmt(t.firstLayer, { operator: operatorName })} + </p> + </form> + ); +} diff --git a/app/(public)/legal/cookies/page.tsx b/app/(public)/legal/cookies/page.tsx new file mode 100644 index 0000000..8a38b0a --- /dev/null +++ b/app/(public)/legal/cookies/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from "next"; +import { getLegalInfo } from "@/app/lib/services/instance"; +import { SESSION_MAX_AGE_DAYS } from "@/app/lib/session"; +import { getI18n } from "@/app/i18n/server"; +import { plural } from "@/app/i18n/format"; +import { LegalHeading } from "../legal-data"; + +export async function generateMetadata(): Promise<Metadata> { + const { m } = await getI18n(); + return { title: m.legal.cookies.title, description: m.legal.cookies.metaDescription }; +} + +// Only strictly necessary storage (art. 22.2 LSSI), which is why there is no consent +// banner. If analytics is ever added, a banner is needed BEFORE loading it. +// The `locale` row must match the cookie set in app/lib/actions/locale.ts (one year). +export default async function CookiesPage() { + const [info, { m, intl }] = await Promise.all([getLegalInfo(), getI18n()]); + const t = m.legal.cookies; + const rows = [ + { + name: "session", + ...t.session, + duration: plural(intl, t.session.duration, SESSION_MAX_AGE_DAYS), + }, + { name: "locale", ...t.locale }, + { name: "theme", ...t.theme }, + ]; + + return ( + <> + <LegalHeading title={t.title} info={info} /> + + <p>{t.intro}</p> + + <div className="table-scroll"> + <table> + <thead> + <tr> + <th scope="col">{t.colName}</th> + <th scope="col">{t.colType}</th> + <th scope="col">{t.colPurpose}</th> + <th scope="col">{t.colDuration}</th> + </tr> + </thead> + <tbody> + {rows.map((r) => ( + <tr key={r.name}> + <td> + <code>{r.name}</code> + </td> + <td>{r.type}</td> + <td>{r.purpose}</td> + <td>{r.duration}</td> + </tr> + ))} + </tbody> + </table> + </div> + + <p>{t.outro}</p> + </> + ); +} diff --git a/app/(public)/legal/layout.tsx b/app/(public)/legal/layout.tsx new file mode 100644 index 0000000..311d1ad --- /dev/null +++ b/app/(public)/legal/layout.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; +import { verifySession } from "@/app/lib/dal"; +import { PublicFooter } from "@/app/components/public-footer"; +import { getI18n } from "@/app/i18n/server"; +import { LegalNav } from "./legal-nav"; + +// Public legal pages (/legal/*): readable without a session, also from sign-up. The text +// describes what THIS code does and the controller's details come from the instance +// settings (/admin/instance), so they hold for the official instance and for any +// self-hosted one. See docs/privacy.md. + +export const dynamic = "force-dynamic"; + +export default async function LegalLayout({ children }: { children: React.ReactNode }) { + const [session, { m }] = await Promise.all([verifySession(), getI18n()]); + return ( + <div className="flex min-h-[100dvh] flex-1 flex-col"> + <header className="relative z-20 flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border bg-surface/80 px-4 backdrop-blur-sm sm:px-6"> + <Link href="/" className="flex items-center gap-2.5 rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"> + <span className="grid size-7 place-items-center rounded-md bg-accent font-display text-sm font-extrabold text-accent-ink"> + o + </span> + <span className="font-display font-bold tracking-tight">orgtable</span> + </Link> + <Link + href={session ? "/boards" : "/login"} + className="rounded-lg px-3 py-1.5 text-sm font-semibold text-accent transition-colors hover:bg-surface-2" + > + {session ? m.legal.layout.goToBoards : m.legal.layout.signIn} + </Link> + </header> + <div className="relative z-10 mx-auto flex w-full max-w-5xl flex-1 flex-col gap-8 px-4 py-10 sm:px-6 md:flex-row md:gap-12 md:py-14"> + <LegalNav /> + <article className="legal-prose min-w-0 max-w-[68ch] flex-1">{children}</article> + </div> + <PublicFooter className="border-t border-border px-4 py-5" /> + </div> + ); +} diff --git a/app/(public)/legal/legal-data.tsx b/app/(public)/legal/legal-data.tsx new file mode 100644 index 0000000..fdc2061 --- /dev/null +++ b/app/(public)/legal/legal-data.tsx @@ -0,0 +1,107 @@ +import { Fragment, type ReactNode } from "react"; +import { formatDate } from "@/app/lib/format-date"; +import { fmt } from "@/app/i18n/format"; +import { rich, type RichTags } from "@/app/i18n/rich"; +import { getI18n } from "@/app/i18n/server"; +import type { LegalInfo } from "@/app/lib/services/instance"; + +// Shared pieces of the legal pages. Whatever the operator has not filled in is shown as +// such ("not provided") instead of being made up: a legal text must not state false data. + +export async function NotProvided() { + const { m } = await getI18n(); + return <span className="text-ink-muted italic">{m.legal.shared.notProvided}</span>; +} + +/** + * Like rich(), but some {placeholders} are filled with React nodes (the operator's data or + * <NotProvided />). Instance data never goes through rich(): a value typed by the admin + * that happens to look like a <tag> must stay plain text. + */ +export function richWith( + template: string, + nodes: Record<string, ReactNode>, + tags: RichTags = {}, +): ReactNode { + const names = Object.keys(nodes); + if (names.length === 0) return rich(template, tags); + const parts = template.split(new RegExp(`\\{(${names.join("|")})\\}`)); + // split() with a capture group alternates text and placeholder names. + return ( + <> + {parts.map((part, i) => ( + <Fragment key={i}>{i % 2 === 0 ? rich(part, tags) : nodes[part]}</Fragment> + ))} + </> + ); +} + +export async function LegalHeading({ + title, + info, +}: { + title: string; + info: LegalInfo | null; +}) { + const { m, intl } = await getI18n(); + const t = m.legal.shared; + return ( + <header className="mb-8"> + <h1 className="font-display text-4xl font-extrabold leading-[1.05] tracking-tight text-balance sm:text-5xl"> + {title} + </h1> + <p className="mt-3 text-sm text-ink-muted"> + {info ? fmt(t.appliesToNamed, { instance: info.instanceName }) : t.appliesTo} + {info?.legalUpdatedAt ? ( + <> {fmt(t.lastUpdated, { date: formatDate(info.legalUpdatedAt, intl) })}</> + ) : null} + </p> + </header> + ); +} + +/** Controller card: who operates the instance and how to contact them. */ +export async function OperatorCard({ info }: { info: LegalInfo | null }) { + const { m } = await getI18n(); + const t = m.legal.shared.operator; + const rows: { label: string; value: ReactNode }[] = [ + { label: t.controller, value: info?.operatorName ?? <NotProvided /> }, + { + label: t.contact, + value: info?.operatorEmail ? ( + <a href={`mailto:${info.operatorEmail}`}>{info.operatorEmail}</a> + ) : ( + <NotProvided /> + ), + }, + { label: t.taxId, value: info?.operatorLegalId ?? <NotProvided /> }, + { label: t.address, value: info?.operatorAddress ?? <NotProvided /> }, + { label: t.hosting, value: info?.hostingDescription ?? <NotProvided /> }, + ]; + return ( + <dl className="not-prose grid gap-x-6 gap-y-4 rounded-2xl border border-border bg-surface p-5 text-sm sm:grid-cols-2"> + {rows.map((r) => ( + <div key={r.label}> + <dt className="text-[11px] font-semibold uppercase tracking-wider text-ink-muted"> + {r.label} + </dt> + <dd className="mt-0.5 break-words">{r.value}</dd> + </div> + ))} + </dl> + ); +} + +/** Warning shown while the admin has not filled in the instance's legal details. */ +export async function MissingLegalInfoNotice({ info }: { info: LegalInfo | null }) { + if (info?.operatorName && info.operatorEmail) return null; + const { m } = await getI18n(); + return ( + <p + role="note" + className="not-prose mb-8 rounded-xl border border-danger/25 bg-danger/10 px-4 py-3 text-sm text-danger" + > + {m.legal.shared.missingInfo} + </p> + ); +} diff --git a/app/(public)/legal/legal-nav.tsx b/app/(public)/legal/legal-nav.tsx new file mode 100644 index 0000000..b1102bd --- /dev/null +++ b/app/(public)/legal/legal-nav.tsx @@ -0,0 +1,45 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useI18n } from "@/app/i18n/client"; + +// Side index of the legal pages. A Client Component only to mark the current page. +export function LegalNav() { + const pathname = usePathname(); + const { m } = useI18n(); + const t = m.legal.nav; + const pages = [ + { href: "/legal/privacy", label: t.privacy }, + { href: "/legal/terms", label: t.terms }, + { href: "/legal/cookies", label: t.cookies }, + { href: "/legal/notice", label: t.legalNotice }, + ]; + return ( + <nav aria-label={t.label} className="shrink-0 md:w-48"> + <p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-ink-muted"> + {t.heading} + </p> + <ul className="flex flex-wrap gap-1 md:flex-col"> + {pages.map((p) => { + const current = pathname === p.href; + return ( + <li key={p.href}> + <Link + href={p.href} + aria-current={current ? "page" : undefined} + className={`block rounded-lg px-2.5 py-1.5 text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${ + current + ? "bg-surface-2 font-semibold text-ink" + : "text-ink-muted hover:bg-surface-2 hover:text-ink" + }`} + > + {p.label} + </Link> + </li> + ); + })} + </ul> + </nav> + ); +} diff --git a/app/(public)/legal/notice/page.tsx b/app/(public)/legal/notice/page.tsx new file mode 100644 index 0000000..fb0811d --- /dev/null +++ b/app/(public)/legal/notice/page.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { getLegalInfo } from "@/app/lib/services/instance"; +import { getI18n } from "@/app/i18n/server"; +import { rich } from "@/app/i18n/rich"; +import { LegalHeading, MissingLegalInfoNotice, OperatorCard } from "../legal-data"; + +export async function generateMetadata(): Promise<Metadata> { + const { m } = await getI18n(); + return { title: m.legal.notice.title, description: m.legal.notice.metaDescription }; +} + +// Information about the site owner (art. 10 LSSI). The details are the same as the +// controller's in the privacy policy: they are edited in /admin/instance. +export default async function LegalNoticePage() { + const [info, { m }] = await Promise.all([getLegalInfo(), getI18n()]); + const t = m.legal.notice; + return ( + <> + <LegalHeading title={t.title} info={info} /> + <MissingLegalInfoNotice info={info} /> + + <h2>{t.owner.heading}</h2> + <OperatorCard info={info} /> + + <h2>{t.purpose.heading}</h2> + <p> + {rich(t.purpose.body, { + terms: (c) => <Link href="/legal/terms">{c}</Link>, + privacy: (c) => <Link href="/legal/privacy">{c}</Link>, + })} + </p> + + <h2>{t.ip.heading}</h2> + <p>{t.ip.body}</p> + </> + ); +} diff --git a/app/(public)/legal/privacy/page.tsx b/app/(public)/legal/privacy/page.tsx new file mode 100644 index 0000000..9da2779 --- /dev/null +++ b/app/(public)/legal/privacy/page.tsx @@ -0,0 +1,150 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { getLegalInfo } from "@/app/lib/services/instance"; +import { + auditRetentionDays, + ENDED_SESSION_RETENTION_DAYS, + NOTIFICATION_RETENTION_DAYS, +} from "@/app/lib/services/maintenance"; +import { SESSION_MAX_AGE_DAYS } from "@/app/lib/session"; +import { parseTrustedProxyHops } from "@/app/lib/client-ip"; +import { getI18n } from "@/app/i18n/server"; +import { plural } from "@/app/i18n/format"; +import { rich } from "@/app/i18n/rich"; +import { + LegalHeading, + MissingLegalInfoNotice, + NotProvided, + OperatorCard, + richWith, +} from "../legal-data"; + +export async function generateMetadata(): Promise<Metadata> { + const { m } = await getI18n(); + return { title: m.legal.privacy.title, description: m.legal.privacy.metaDescription }; +} + +const b = (t: string) => <strong>{t}</strong>; + +// Retention periods come from the same constants used by the cleanup job +// (services/maintenance.ts) and the session length in session.ts: if the code changes, +// the text changes with it. +export default async function PrivacyPage() { + const [info, { m, intl }] = await Promise.all([getLegalInfo(), getI18n()]); + const t = m.legal.privacy; + const auditDays = auditRetentionDays(); + // Without trusted proxies the app does not know the real IP and does not store it + // (client-ip.ts). + const storesIp = parseTrustedProxyHops(process.env.TRUSTED_PROXY_HOPS) > 0; + const dataRows = [ + t.data.account, + t.data.acceptance, + t.data.content, + t.data.notifications, + t.data.sessions, + t.data.tokens, + { + data: storesIp ? t.data.audit.dataWithIp : t.data.audit.dataWithoutIp, + purpose: t.data.audit.purpose, + basis: t.data.audit.basis, + }, + ]; + const settingsLink = (anchor: string, text: string) => ( + <Link href={`/settings#${anchor}`}>{text}</Link> + ); + + return ( + <> + <LegalHeading title={t.title} info={info} /> + <MissingLegalInfoNotice info={info} /> + + <h2>{t.summary.heading}</h2> + <p>{rich(t.summary.body, { link: (c) => settingsLink("data", c) })}</p> + + <h2>{t.controller.heading}</h2> + <p>{t.controller.body}</p> + <OperatorCard info={info} /> + + <h2>{t.data.heading}</h2> + <div className="table-scroll"> + <table> + <thead> + <tr> + <th scope="col">{t.data.colData}</th> + <th scope="col">{t.data.colPurpose}</th> + <th scope="col">{t.data.colBasis}</th> + </tr> + </thead> + <tbody> + {dataRows.map((row) => ( + <tr key={row.data}> + <td>{row.data}</td> + <td>{row.purpose}</td> + <td>{row.basis}</td> + </tr> + ))} + </tbody> + </table> + </div> + <p>{t.data.noProfiling}</p> + + <h2>{t.retention.heading}</h2> + <ul> + <li>{t.retention.account}</li> + <li> + {plural(intl, t.retention.sessionLength, SESSION_MAX_AGE_DAYS)}{" "} + {plural(intl, t.retention.endedSessions, ENDED_SESSION_RETENTION_DAYS)} + </li> + <li>{plural(intl, t.retention.notifications, NOTIFICATION_RETENTION_DAYS)}</li> + <li>{plural(intl, t.retention.audit, auditDays)}</li> + <li> + {info?.backupRetentionDays != null + ? info.backupRetentionDays === 0 + ? t.retention.backupsNone + : plural(intl, t.retention.backupsDays, info.backupRetentionDays) + : richWith(t.retention.backupsUnknown, { notProvided: <NotProvided /> })} + </li> + </ul> + + <h2>{t.recipients.heading}</h2> + <ul> + <li>{rich(t.recipients.boardMembers, { b })}</li> + <li>{rich(t.recipients.admin, { b })}</li> + <li> + {richWith( + t.recipients.hosting, + { hosting: info?.hostingDescription ?? <NotProvided /> }, + { b }, + )} + </li> + </ul> + <p>{t.recipients.outsideEea}</p> + + <h2>{t.cookies.heading}</h2> + <p> + {rich(t.cookies.body, { + code: (c) => <code>{c}</code>, + link: (c) => <Link href="/legal/cookies">{c}</Link>, + })} + </p> + + <h2>{t.rights.heading}</h2> + <p>{t.rights.intro}</p> + <ul> + <li>{rich(t.rights.access, { b, link: (c) => settingsLink("data", c) })}</li> + <li>{rich(t.rights.rectification, { b, link: (c) => settingsLink("profile", c) })}</li> + <li>{rich(t.rights.erasure, { b, link: (c) => settingsLink("data", c) })}</li> + <li>{rich(t.rights.objection, { b })}</li> + </ul> + <p> + {rich(t.rights.complaint, { link: (c) => <a href="https://www.aepd.es">{c}</a> })} + </p> + + <h2>{t.security.heading}</h2> + <p>{t.security.body}</p> + + <h2>{t.changes.heading}</h2> + <p>{rich(t.changes.body, { link: (c) => settingsLink("about", c) })}</p> + </> + ); +} diff --git a/app/(public)/legal/terms/page.tsx b/app/(public)/legal/terms/page.tsx new file mode 100644 index 0000000..f038c0a --- /dev/null +++ b/app/(public)/legal/terms/page.tsx @@ -0,0 +1,81 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { getLegalInfo } from "@/app/lib/services/instance"; +import { LICENSE_NAME, sourceCodeUrl } from "@/app/lib/site"; +import { getI18n } from "@/app/i18n/server"; +import { fmt } from "@/app/i18n/format"; +import { rich } from "@/app/i18n/rich"; +import { LegalHeading, MissingLegalInfoNotice, NotProvided, richWith } from "../legal-data"; + +export async function generateMetadata(): Promise<Metadata> { + const { m } = await getI18n(); + return { title: m.legal.terms.title, description: m.legal.terms.metaDescription }; +} + +export default async function TermsPage() { + const [info, { m }] = await Promise.all([getLegalInfo(), getI18n()]); + const t = m.legal.terms; + + return ( + <> + <LegalHeading title={t.title} info={info} /> + <MissingLegalInfoNotice info={info} /> + + <h2>{t.provider.heading}</h2> + <p> + {richWith(fmt(t.provider.body, { license: LICENSE_NAME }), { + operator: info?.operatorName ?? <NotProvided />, + })} + </p> + + <h2>{t.service.heading}</h2> + <p> + {rich(t.service.body, { link: (c) => <Link href="/legal/privacy">{c}</Link> })} + </p> + + <h2>{t.account.heading}</h2> + <ul> + <li>{t.account.age}</li> + <li>{t.account.responsibility}</li> + <li>{t.account.deletion}</li> + </ul> + + <h2>{t.content.heading}</h2> + <p>{t.content.ownership}</p> + <p>{t.content.prohibitedIntro}</p> + <ul> + <li>{t.content.illegal}</li> + <li>{t.content.malware}</li> + <li>{t.content.spam}</li> + <li>{t.content.abuse}</li> + </ul> + <p>{t.content.enforcement}</p> + + <h2>{t.availability.heading}</h2> + <p>{t.availability.body}</p> + + <h2>{t.changes.heading}</h2> + <p>{t.changes.body}</p> + + <h2>{t.software.heading}</h2> + <p> + {rich( + t.software.body, + { link: (c) => <a href={sourceCodeUrl()}>{c}</a> }, + { license: LICENSE_NAME }, + )} + </p> + + <h2>{t.contact.heading}</h2> + <p> + {richWith(t.contact.body, { + contact: info?.operatorEmail ? ( + <a href={`mailto:${info.operatorEmail}`}>{info.operatorEmail}</a> + ) : ( + <NotProvided /> + ), + })} + </p> + </> + ); +} diff --git a/app/api/account/export/route.ts b/app/api/account/export/route.ts new file mode 100644 index 0000000..7fc868b --- /dev/null +++ b/app/api/account/export/route.ts @@ -0,0 +1,28 @@ +import { verifySession } from "@/app/lib/dal"; +import { exportUserData } from "@/app/lib/services/account"; +import { recordAudit } from "@/app/lib/audit"; + +// Download of the user's personal data as JSON (rights of access and portability, GDPR +// arts. 15 and 20). What it contains: services/account.ts (exportUserData). Browser +// session only: API tokens cannot pull the full dump of an account. + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET() { + const session = await verifySession(); + if (!session) return new Response("Unauthorized", { status: 401 }); + + const data = await exportUserData(session.userId); + if (!data) return new Response("Unauthorized", { status: 401 }); + + await recordAudit({ action: "account.exported", userId: session.userId }); + const date = new Date().toISOString().slice(0, 10); + return new Response(JSON.stringify(data, null, 2), { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="orgtable-my-data-${date}.json"`, + "Cache-Control": "private, no-store", + }, + }); +} diff --git a/app/api/attachments/[id]/route.ts b/app/api/attachments/[id]/route.ts index da6a537..6553671 100644 --- a/app/api/attachments/[id]/route.ts +++ b/app/api/attachments/[id]/route.ts @@ -2,9 +2,9 @@ import { verifySession } from "@/app/lib/dal"; import { prisma } from "@/app/lib/prisma"; import { readAttachmentFile } from "@/app/lib/storage"; -// Sirve el binario de un adjunto con autorización (Hito 5). No usamos /public porque -// los adjuntos son privados: cualquier miembro del tablero puede descargarlos, nadie -// más. Ver docs/attachments.md. +// Serves an attachment's file, with authorization. Not under /public because +// attachments are private: any board member can download them, nobody else. See +// docs/attachments.md. export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -18,8 +18,8 @@ export async function GET( const session = await verifySession(); if (!session) return new Response("Unauthorized", { status: 401 }); - // Una sola consulta acotada por membresía: un adjunto que no existe y uno de un tablero - // ajeno dan el MISMO 404, para no revelar qué ids existen (igual que la API v1). + // A single query scoped by membership: a missing attachment and one from someone else's + // board give the SAME 404, so as not to reveal which ids exist (like API v1). const attachment = await prisma.attachment.findFirst({ where: { id, @@ -40,15 +40,15 @@ export async function GET( } catch (err) { const code = isErrnoException(err) ? err.code : undefined; if (code === "ENOENT") { - // La fila existe pero el fichero no: inconsistencia entre BD y volumen. - console.warn("[attachments] fichero no encontrado en disco", { + // The row exists but the file does not: database and volume are out of sync. + console.warn("[attachments] file not found on disk", { id, storedName: attachment.storedName, }); return new Response("Not found", { status: 404 }); } - // Permisos, E/S del volumen…: es un fallo del servidor, no un «no existe». - console.error("[attachments] no se pudo leer el adjunto", { + // Permissions, volume I/O…: a server failure, not a "does not exist". + console.error("[attachments] could not read the attachment", { id, storedName: attachment.storedName, code, @@ -57,7 +57,7 @@ export async function GET( return new Response("Internal Server Error", { status: 500 }); } - // filename* (RFC 5987) preserva nombres con acentos o espacios. + // filename* (RFC 5987) preserves names with accents or spaces. const disposition = `attachment; filename*=UTF-8''${encodeURIComponent( attachment.filename, )}`; diff --git a/app/api/boards/[id]/export/route.ts b/app/api/boards/[id]/export/route.ts index 7b08764..f42599a 100644 --- a/app/api/boards/[id]/export/route.ts +++ b/app/api/boards/[id]/export/route.ts @@ -1,10 +1,10 @@ import { requireUser } from "@/app/lib/dal"; import { getBoardWithLists } from "@/app/lib/queries"; -// Exporta un tablero completo a JSON (Hito 5): listas, tarjetas, etiquetas, fechas y -// checklists. Las etiquetas se referencian por nombre dentro de cada tarjeta para poder -// re-vincularlas al importar. Los binarios de los adjuntos NO se incluyen (solo viven en -// disco); ver docs/operations.md. Cualquier miembro del tablero puede exportar. +// Exports a full board as JSON: lists, cards, labels, dates and checklists. Labels are +// referenced by name inside each card so they can be relinked on import. Attachment +// files are NOT included (they only live on disk); see docs/operations.md. Any board +// member can export. export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -71,7 +71,7 @@ export async function GET( .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") - .slice(0, 40) || "tablero"; + .slice(0, 40) || "board"; return new Response(JSON.stringify(data, null, 2), { headers: { diff --git a/app/api/boards/[id]/stream/route.ts b/app/api/boards/[id]/stream/route.ts index b2468ee..77974fd 100644 --- a/app/api/boards/[id]/stream/route.ts +++ b/app/api/boards/[id]/stream/route.ts @@ -3,10 +3,10 @@ import { prisma } from "@/app/lib/prisma"; import { subscribeBoard, type BoardEvent } from "@/app/lib/realtime/bus"; import { createSseResponse, isSessionActive } from "@/app/lib/realtime/sse"; -// Stream de eventos (SSE) de un tablero para la sincronización en vivo (Hito 4). El -// navegador abre un EventSource a esta ruta (ver app/components/board/board-realtime.tsx); -// cada vez que alguien muta el tablero, las Server Actions publican un evento en el bus -// y aquí lo reenviamos para que el cliente haga router.refresh(). Ver docs/realtime.md. +// Event stream (SSE) of a board for live sync. The browser opens an EventSource to this +// route (see app/components/board/board-realtime.tsx); whenever someone changes the board, +// the Server Actions publish an event on the bus and we forward it here so the client +// calls router.refresh(). See docs/realtime.md. export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -17,8 +17,8 @@ export async function GET( ) { const { id: boardId } = await params; - // Autorización: sesión válida + ser miembro del tablero. Devolvemos códigos HTTP - // (no redirect) porque es una conexión de datos, no una navegación. + // Authorization: valid session + board membership. HTTP status codes (no redirect) + // because this is a data connection, not a navigation. const session = await verifySession(); if (!session) return new Response("Unauthorized", { status: 401 }); const { userId, sessionId } = session; @@ -30,13 +30,13 @@ export async function GET( return createSseResponse({ signal: request.signal, - label: "tablero", + label: "board", subscribe: (send) => subscribeBoard(boardId, (event: BoardEvent) => { send(`event: change\ndata: ${JSON.stringify(event)}\n\n`); }), - // En cada latido: si la sesión se revocó o la persona dejó de ser miembro, se corta - // el stream (ADR 0008: la revocación es inmediata, también para el tiempo real). + // On every heartbeat: if the session was revoked or the person is no longer a member, + // the stream is cut (ADR 0008: revocation is immediate, real time included). isStillAuthorized: async () => { if (!(await isSessionActive(sessionId))) return false; const stillMember = await prisma.boardMember.findUnique({ diff --git a/app/api/cron/maintenance/route.ts b/app/api/cron/maintenance/route.ts new file mode 100644 index 0000000..ca49502 --- /dev/null +++ b/app/api/cron/maintenance/route.ts @@ -0,0 +1,22 @@ +import { purgeExpiredData } from "@/app/lib/services/maintenance"; +import { apiOk } from "@/app/lib/api/respond"; +import { rejectUnauthorizedCron } from "@/app/lib/api/cron"; + +// Purge of expired data: audit log older than ORGTABLE_AUDIT_RETENTION_DAYS, sessions +// ended or expired more than 30 days ago and notifications older than 180 days. Once a +// day is enough. Same secret as the reminders: +// +// curl -X POST -H "Authorization: Bearer $CRON_SECRET" https://your-instance/api/cron/maintenance +// +// See docs/privacy.md (retention periods) and docs/operations.md (how to schedule it). + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise<Response> { + const rejected = rejectUnauthorizedCron(request); + if (rejected) return rejected; + + const result = await purgeExpiredData(new Date()); + return apiOk(result); +} diff --git a/app/api/cron/reminders/route.ts b/app/api/cron/reminders/route.ts index 9a92806..56f6932 100644 --- a/app/api/cron/reminders/route.ts +++ b/app/api/cron/reminders/route.ts @@ -1,39 +1,22 @@ -import { timingSafeEqual } from "node:crypto"; import { generateDueReminders } from "@/app/lib/services/reminders"; -import { apiError, apiOk } from "@/app/lib/api/respond"; +import { apiOk } from "@/app/lib/api/respond"; +import { rejectUnauthorizedCron } from "@/app/lib/api/cron"; -// Endpoint de recordatorios de vencimiento (Hito 11). Pensado para que lo dispare un -// programador EXTERNO (cron del sistema, `docker compose` con un sidecar, GitHub Actions -// schedule, etc.), no un timer en proceso — coherente con no añadir infraestructura -// (ADR 0004). Se protege con un secreto compartido en `Authorization: Bearer <CRON_SECRET>`. +// Due-date reminders endpoint. Meant to be triggered by an EXTERNAL scheduler (system +// cron, a `docker compose` sidecar, a GitHub Actions schedule, etc.), not an in-process +// timer, in line with not adding infrastructure (ADR 0004). Protected by a shared secret +// in `Authorization: Bearer <CRON_SECRET>`. // -// curl -X POST -H "Authorization: Bearer $CRON_SECRET" https://tu-instancia/api/cron/reminders +// curl -X POST -H "Authorization: Bearer $CRON_SECRET" https://your-instance/api/cron/reminders // -// Ver docs/reminders.md y docs/decisions/0015-reminders-cron.md. +// See docs/reminders.md and docs/decisions/0015-reminders-cron.md. export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -// Comparación en tiempo constante para no filtrar el secreto por el tiempo de respuesta. -function secretMatches(provided: string, expected: string): boolean { - const a = Buffer.from(provided); - const b = Buffer.from(expected); - if (a.length !== b.length) return false; - return timingSafeEqual(a, b); -} - export async function POST(request: Request): Promise<Response> { - const expected = process.env.CRON_SECRET; - if (!expected) { - // Mensaje genérico: no revelamos detalles de configuración al cliente. - return apiError(503, "Servicio no disponible."); - } - - const header = request.headers.get("authorization") ?? ""; - const provided = header.startsWith("Bearer ") ? header.slice(7) : ""; - if (!provided || !secretMatches(provided, expected)) { - return apiError(401, "Secreto de cron no válido."); - } + const rejected = rejectUnauthorizedCron(request); + if (rejected) return rejected; const result = await generateDueReminders(new Date()); return apiOk(result); diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 02ab68b..a9b8122 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,6 +1,6 @@ -// Endpoint de health check usado por el healthcheck de docker-compose para saber -// cuándo el contenedor `app` está listo. No toca la base de datos a propósito: -// solo confirma que el servidor Next responde. +// Health check endpoint used by the docker-compose healthcheck to know when the `app` +// container is ready. It deliberately does not touch the database: it only confirms +// that the Next server responds. export async function GET() { return Response.json({ status: "ok" }); } diff --git a/app/api/notifications/stream/route.ts b/app/api/notifications/stream/route.ts index 4882fd8..03c5318 100644 --- a/app/api/notifications/stream/route.ts +++ b/app/api/notifications/stream/route.ts @@ -2,29 +2,29 @@ import { verifySession } from "@/app/lib/dal"; import { subscribeUser, type UserEvent } from "@/app/lib/realtime/bus"; import { createSseResponse, isSessionActive } from "@/app/lib/realtime/sse"; -// Stream SSE de notificaciones del usuario en sesión (Hito 8). El navegador abre un -// EventSource (ver app/components/notifications-bell.tsx); cuando llega una notificación -// nueva, `notify()` publica en el bus y aquí reenviamos un evento para que el cliente -// haga router.refresh() y la campana se actualice. Mismo patrón que el stream de -// tablero. Ver app/api/boards/[id]/stream/route.ts y docs/notifications.md. +// SSE stream of the signed-in user's notifications. The browser opens an EventSource +// (see app/components/notifications-bell.tsx); when a new notification arrives, +// `notify()` publishes on the bus and we forward an event here so the client calls +// router.refresh() and the bell updates. Same pattern as the board stream. See +// app/api/boards/[id]/stream/route.ts and docs/notifications.md. export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export async function GET(request: Request) { - // Es una conexión de datos, no una navegación: devolvemos 401 (no redirect). + // A data connection, not a navigation: answer 401 (no redirect). const session = await verifySession(); if (!session) return new Response("Unauthorized", { status: 401 }); const { userId, sessionId } = session; return createSseResponse({ signal: request.signal, - label: "notificaciones", + label: "notifications", subscribe: (send) => subscribeUser(userId, (event: UserEvent) => { send(`event: notification\ndata: ${JSON.stringify(event)}\n\n`); }), - // Una sesión revocada deja de recibir avisos en el siguiente latido. + // A revoked session stops receiving events on the next heartbeat. isStillAuthorized: () => isSessionActive(sessionId), }); } diff --git a/app/api/v1/boards/[id]/lists/route.ts b/app/api/v1/boards/[id]/lists/route.ts index b9aac3e..8d5a42b 100644 --- a/app/api/v1/boards/[id]/lists/route.ts +++ b/app/api/v1/boards/[id]/lists/route.ts @@ -4,25 +4,25 @@ import { canEditBoard } from "@/app/lib/roles"; import { listTitleSchema } from "@/app/lib/validators"; import { createListAtEnd } from "@/app/lib/services/lists"; import { authenticateApiToken, apiBoardRole, isReadOnly } from "@/app/lib/api/auth"; -import { apiError, apiOk } from "@/app/lib/api/respond"; +import { apiError, apiOk, apiValidationError } from "@/app/lib/api/respond"; import { serializeListSummary } from "@/app/lib/api/serialize"; -// API pública v1 — listas de un tablero (Hito 9). Ver docs/api/public-api.md. +// Public API v1: lists of a board. See docs/api/public-api.md. export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -/** GET /api/v1/boards/:id/lists — las listas del tablero, en orden. */ +/** GET /api/v1/boards/:id/lists: the board's lists, in order. */ export async function GET( request: Request, { params }: { params: Promise<{ id: string }> }, ) { const auth = await authenticateApiToken(request); - if (!auth) return apiError(401, "Token de API no válido o ausente."); + if (!auth) return apiError(401, "Missing or invalid API token."); const { id: boardId } = await params; const role = await apiBoardRole(auth.userId, boardId); - if (!role) return apiError(404, "Tablero no encontrado."); + if (!role) return apiError(404, "Board not found."); const lists = await prisma.list.findMany({ where: { boardId }, @@ -32,26 +32,26 @@ export async function GET( return apiOk({ lists: lists.map(serializeListSummary) }); } -/** POST /api/v1/boards/:id/lists — crea una lista al final (requiere editor). */ +/** POST /api/v1/boards/:id/lists: creates a list at the end (requires editor). */ export async function POST( request: Request, { params }: { params: Promise<{ id: string }> }, ) { const auth = await authenticateApiToken(request); - if (!auth) return apiError(401, "Token de API no válido o ausente."); - if (isReadOnly(auth)) return apiError(403, "Token de solo lectura."); + if (!auth) return apiError(401, "Missing or invalid API token."); + if (isReadOnly(auth)) return apiError(403, "Read-only token."); const { id: boardId } = await params; const role = await apiBoardRole(auth.userId, boardId); - if (!role) return apiError(404, "Tablero no encontrado."); + if (!role) return apiError(404, "Board not found."); if (!canEditBoard(role)) { - return apiError(403, "Necesitas permiso de edición en el tablero."); + return apiError(403, "You need edit permission on this board."); } const body = await request.json().catch(() => null); const parsed = listTitleSchema.safeParse((body as { title?: unknown })?.title); if (!parsed.success) { - return apiError(422, parsed.error.issues[0]?.message ?? "Datos no válidos."); + return apiValidationError(parsed.error.issues[0]); } const list = await createListAtEnd({ boardId, title: parsed.data }); diff --git a/app/api/v1/boards/[id]/route.ts b/app/api/v1/boards/[id]/route.ts index c839d21..4b81365 100644 --- a/app/api/v1/boards/[id]/route.ts +++ b/app/api/v1/boards/[id]/route.ts @@ -7,52 +7,52 @@ import { recordAudit } from "@/app/lib/audit"; import { boardTitleSchema } from "@/app/lib/validators"; import { deleteBoardWithAttachments } from "@/app/lib/services/attachments"; import { authenticateApiToken, apiBoardRole, isReadOnly } from "@/app/lib/api/auth"; -import { apiError, apiOk } from "@/app/lib/api/respond"; +import { apiError, apiOk, apiValidationError } from "@/app/lib/api/respond"; import { serializeBoardFull, serializeBoardSummary, } from "@/app/lib/api/serialize"; -// API pública v1 — un tablero (Hito 9). Ver docs/api/public-api.md. +// Public API v1: a single board. See docs/api/public-api.md. export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -/** GET /api/v1/boards/:id — el tablero con sus listas y tarjetas. */ +/** GET /api/v1/boards/:id: the board with its lists and cards. */ export async function GET( request: Request, { params }: { params: Promise<{ id: string }> }, ) { const auth = await authenticateApiToken(request); - if (!auth) return apiError(401, "Token de API no válido o ausente."); + if (!auth) return apiError(401, "Missing or invalid API token."); const { id } = await params; - // getBoardWithLists ya acota por acceso (dueño o miembro); null = sin acceso. + // getBoardWithLists already scopes by access (owner or member); null = no access. const board = await getBoardWithLists(id, auth.userId); - if (!board) return apiError(404, "Tablero no encontrado."); + if (!board) return apiError(404, "Board not found."); return apiOk(serializeBoardFull(board)); } -/** PATCH /api/v1/boards/:id — renombra el tablero (solo propietario). */ +/** PATCH /api/v1/boards/:id: renames the board (owner only). */ export async function PATCH( request: Request, { params }: { params: Promise<{ id: string }> }, ) { const auth = await authenticateApiToken(request); - if (!auth) return apiError(401, "Token de API no válido o ausente."); - if (isReadOnly(auth)) return apiError(403, "Token de solo lectura."); + if (!auth) return apiError(401, "Missing or invalid API token."); + if (isReadOnly(auth)) return apiError(403, "Read-only token."); const { id } = await params; const role = await apiBoardRole(auth.userId, id); - if (!role) return apiError(404, "Tablero no encontrado."); + if (!role) return apiError(404, "Board not found."); if (!canManageBoard(role)) { - return apiError(403, "Necesitas ser propietario del tablero."); + return apiError(403, "You need to be the board owner."); } const body = await request.json().catch(() => null); const parsed = boardTitleSchema.safeParse((body as { title?: unknown })?.title); if (!parsed.success) { - return apiError(422, parsed.error.issues[0]?.message ?? "Datos no válidos."); + return apiValidationError(parsed.error.issues[0]); } const board = await prisma.board.update({ @@ -63,20 +63,20 @@ export async function PATCH( return apiOk(serializeBoardSummary(board)); } -/** DELETE /api/v1/boards/:id — borra el tablero (solo propietario). */ +/** DELETE /api/v1/boards/:id: deletes the board (owner only). */ export async function DELETE( request: Request, { params }: { params: Promise<{ id: string }> }, ) { const auth = await authenticateApiToken(request); - if (!auth) return apiError(401, "Token de API no válido o ausente."); - if (isReadOnly(auth)) return apiError(403, "Token de solo lectura."); + if (!auth) return apiError(401, "Missing or invalid API token."); + if (isReadOnly(auth)) return apiError(403, "Read-only token."); const { id } = await params; const role = await apiBoardRole(auth.userId, id); - if (!role) return apiError(404, "Tablero no encontrado."); + if (!role) return apiError(404, "Board not found."); if (!canManageBoard(role)) { - return apiError(403, "Necesitas ser propietario del tablero."); + return apiError(403, "You need to be the board owner."); } await deleteBoardWithAttachments({ id }); @@ -88,7 +88,7 @@ export async function DELETE( detail: "api", }); revalidatePath("/boards"); - // Avisa a las pestañas abiertas del tablero (SSE), igual que la Server Action. + // Tells the board's open tabs (SSE), like the Server Action does. revalidateBoard(id); return apiOk({ deleted: true }); } diff --git a/app/api/v1/boards/route.ts b/app/api/v1/boards/route.ts index 781e1a2..0a42b4f 100644 --- a/app/api/v1/boards/route.ts +++ b/app/api/v1/boards/route.ts @@ -4,33 +4,33 @@ import { getBoardsForUser } from "@/app/lib/queries"; import { recordAudit } from "@/app/lib/audit"; import { boardTitleSchema } from "@/app/lib/validators"; import { authenticateApiToken, isReadOnly } from "@/app/lib/api/auth"; -import { apiError, apiOk } from "@/app/lib/api/respond"; +import { apiError, apiOk, apiValidationError } from "@/app/lib/api/respond"; import { serializeBoardSummary } from "@/app/lib/api/serialize"; -// API pública v1 — colección de tableros (Hito 9). Ver docs/api/public-api.md. +// Public API v1: board collection. See docs/api/public-api.md. export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -/** GET /api/v1/boards — tableros a los que el usuario del token tiene acceso. */ +/** GET /api/v1/boards: boards the token's user has access to. */ export async function GET(request: Request) { const auth = await authenticateApiToken(request); - if (!auth) return apiError(401, "Token de API no válido o ausente."); + if (!auth) return apiError(401, "Missing or invalid API token."); const boards = await getBoardsForUser(auth.userId); return apiOk({ boards: boards.map(serializeBoardSummary) }); } -/** POST /api/v1/boards — crea un tablero (el usuario queda como propietario). */ +/** POST /api/v1/boards: creates a board (the user becomes its owner). */ export async function POST(request: Request) { const auth = await authenticateApiToken(request); - if (!auth) return apiError(401, "Token de API no válido o ausente."); - if (isReadOnly(auth)) return apiError(403, "Token de solo lectura."); + if (!auth) return apiError(401, "Missing or invalid API token."); + if (isReadOnly(auth)) return apiError(403, "Read-only token."); const body = await request.json().catch(() => null); const parsed = boardTitleSchema.safeParse((body as { title?: unknown })?.title); if (!parsed.success) { - return apiError(422, parsed.error.issues[0]?.message ?? "Datos no válidos."); + return apiValidationError(parsed.error.issues[0]); } const board = await prisma.board.create({ diff --git a/app/api/v1/cards/[id]/route.ts b/app/api/v1/cards/[id]/route.ts index 11ede8f..7d5fd4c 100644 --- a/app/api/v1/cards/[id]/route.ts +++ b/app/api/v1/cards/[id]/route.ts @@ -3,11 +3,11 @@ import { revalidateBoard } from "@/app/lib/realtime/notify"; import { canEditBoard } from "@/app/lib/roles"; import { apiCardUpdateSchema } from "@/app/lib/validators"; import { authenticateApiToken, apiBoardRole, isReadOnly } from "@/app/lib/api/auth"; -import { apiError, apiOk } from "@/app/lib/api/respond"; +import { apiError, apiOk, apiValidationError } from "@/app/lib/api/respond"; import { serializeCard } from "@/app/lib/api/serialize"; import { deleteCardWithAttachments } from "@/app/lib/services/attachments"; -// API pública v1 — una tarjeta (Hito 9). Ver docs/api/public-api.md. +// Public API v1: a single card. See docs/api/public-api.md. export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -26,7 +26,7 @@ const cardInclude = { }, }; -// Carga la tarjeta con sus relaciones y el boardId de su lista (para autorizar). +// Loads the card with its relations and its list's boardId (for authorization). async function loadCard(id: string) { return prisma.card.findUnique({ where: { id }, @@ -34,48 +34,48 @@ async function loadCard(id: string) { }); } -/** GET /api/v1/cards/:id — el detalle de una tarjeta. */ +/** GET /api/v1/cards/:id: a card's details. */ export async function GET( request: Request, { params }: { params: Promise<{ id: string }> }, ) { const auth = await authenticateApiToken(request); - if (!auth) return apiError(401, "Token de API no válido o ausente."); + if (!auth) return apiError(401, "Missing or invalid API token."); const { id } = await params; const card = await loadCard(id); - if (!card) return apiError(404, "Tarjeta no encontrada."); + if (!card) return apiError(404, "Card not found."); const role = await apiBoardRole(auth.userId, card.list.boardId); - if (!role) return apiError(404, "Tarjeta no encontrada."); + if (!role) return apiError(404, "Card not found."); return apiOk(serializeCard(card)); } -/** PATCH /api/v1/cards/:id — actualiza título, descripción o vencimiento (editor). */ +/** PATCH /api/v1/cards/:id: updates title, description or due date (editor). */ export async function PATCH( request: Request, { params }: { params: Promise<{ id: string }> }, ) { const auth = await authenticateApiToken(request); - if (!auth) return apiError(401, "Token de API no válido o ausente."); - if (isReadOnly(auth)) return apiError(403, "Token de solo lectura."); + if (!auth) return apiError(401, "Missing or invalid API token."); + if (isReadOnly(auth)) return apiError(403, "Read-only token."); const { id } = await params; const existing = await prisma.card.findUnique({ where: { id }, select: { list: { select: { boardId: true } } }, }); - if (!existing) return apiError(404, "Tarjeta no encontrada."); + if (!existing) return apiError(404, "Card not found."); const role = await apiBoardRole(auth.userId, existing.list.boardId); - if (!role) return apiError(404, "Tarjeta no encontrada."); + if (!role) return apiError(404, "Card not found."); if (!canEditBoard(role)) { - return apiError(403, "Necesitas permiso de edición en el tablero."); + return apiError(403, "You need edit permission on this board."); } const body = await request.json().catch(() => null); const parsed = apiCardUpdateSchema.safeParse(body); if (!parsed.success) { - return apiError(422, parsed.error.issues[0]?.message ?? "Datos no válidos."); + return apiValidationError(parsed.error.issues[0]); } const data: { @@ -88,7 +88,7 @@ export async function PATCH( data.description = parsed.data.description || null; } if (parsed.data.dueDate !== undefined) { - // apiDueDateSchema ya garantiza una fecha ISO válida (o null): nada de caer a null. + // apiDueDateSchema already guarantees a valid ISO date (or null): no silent fallback to null. data.dueDate = parsed.data.dueDate ? new Date(parsed.data.dueDate) : null; } @@ -98,25 +98,25 @@ export async function PATCH( return apiOk(card ? serializeCard(card) : { id }); } -/** DELETE /api/v1/cards/:id — borra una tarjeta (editor). */ +/** DELETE /api/v1/cards/:id: deletes a card (editor). */ export async function DELETE( request: Request, { params }: { params: Promise<{ id: string }> }, ) { const auth = await authenticateApiToken(request); - if (!auth) return apiError(401, "Token de API no válido o ausente."); - if (isReadOnly(auth)) return apiError(403, "Token de solo lectura."); + if (!auth) return apiError(401, "Missing or invalid API token."); + if (isReadOnly(auth)) return apiError(403, "Read-only token."); const { id } = await params; const existing = await prisma.card.findUnique({ where: { id }, select: { list: { select: { boardId: true } } }, }); - if (!existing) return apiError(404, "Tarjeta no encontrada."); + if (!existing) return apiError(404, "Card not found."); const role = await apiBoardRole(auth.userId, existing.list.boardId); - if (!role) return apiError(404, "Tarjeta no encontrada."); + if (!role) return apiError(404, "Card not found."); if (!canEditBoard(role)) { - return apiError(403, "Necesitas permiso de edición en el tablero."); + return apiError(403, "You need edit permission on this board."); } await deleteCardWithAttachments(id); diff --git a/app/api/v1/lists/[id]/cards/route.ts b/app/api/v1/lists/[id]/cards/route.ts index 3ca7667..6f0617d 100644 --- a/app/api/v1/lists/[id]/cards/route.ts +++ b/app/api/v1/lists/[id]/cards/route.ts @@ -4,15 +4,15 @@ import { canEditBoard } from "@/app/lib/roles"; import { apiCardCreateSchema } from "@/app/lib/validators"; import { createCardAtEnd } from "@/app/lib/services/cards"; import { authenticateApiToken, apiBoardRole, isReadOnly } from "@/app/lib/api/auth"; -import { apiError, apiOk } from "@/app/lib/api/respond"; +import { apiError, apiOk, apiValidationError } from "@/app/lib/api/respond"; import { serializeCard } from "@/app/lib/api/serialize"; -// API pública v1 — tarjetas de una lista (Hito 9). Ver docs/api/public-api.md. +// Public API v1: cards of a list. See docs/api/public-api.md. export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -// Relaciones que componen una tarjeta completa en la respuesta. +// Relations that make up a full card in the response. const cardInclude = { labels: { include: { label: true } }, checklists: { @@ -27,22 +27,22 @@ const cardInclude = { }, }; -/** GET /api/v1/lists/:id/cards — las tarjetas de la lista, en orden. */ +/** GET /api/v1/lists/:id/cards: the list's cards, in order. */ export async function GET( request: Request, { params }: { params: Promise<{ id: string }> }, ) { const auth = await authenticateApiToken(request); - if (!auth) return apiError(401, "Token de API no válido o ausente."); + if (!auth) return apiError(401, "Missing or invalid API token."); const { id: listId } = await params; const list = await prisma.list.findUnique({ where: { id: listId }, select: { boardId: true }, }); - if (!list) return apiError(404, "Lista no encontrada."); + if (!list) return apiError(404, "List not found."); const role = await apiBoardRole(auth.userId, list.boardId); - if (!role) return apiError(404, "Lista no encontrada."); + if (!role) return apiError(404, "List not found."); const cards = await prisma.card.findMany({ where: { listId }, @@ -52,34 +52,34 @@ export async function GET( return apiOk({ cards: cards.map(serializeCard) }); } -/** POST /api/v1/lists/:id/cards — crea una tarjeta al final (requiere editor). */ +/** POST /api/v1/lists/:id/cards: creates a card at the end (requires editor). */ export async function POST( request: Request, { params }: { params: Promise<{ id: string }> }, ) { const auth = await authenticateApiToken(request); - if (!auth) return apiError(401, "Token de API no válido o ausente."); - if (isReadOnly(auth)) return apiError(403, "Token de solo lectura."); + if (!auth) return apiError(401, "Missing or invalid API token."); + if (isReadOnly(auth)) return apiError(403, "Read-only token."); const { id: listId } = await params; const list = await prisma.list.findUnique({ where: { id: listId }, select: { boardId: true }, }); - if (!list) return apiError(404, "Lista no encontrada."); + if (!list) return apiError(404, "List not found."); const role = await apiBoardRole(auth.userId, list.boardId); - if (!role) return apiError(404, "Lista no encontrada."); + if (!role) return apiError(404, "List not found."); if (!canEditBoard(role)) { - return apiError(403, "Necesitas permiso de edición en el tablero."); + return apiError(403, "You need edit permission on this board."); } const body = await request.json().catch(() => null); const parsed = apiCardCreateSchema.safeParse(body); if (!parsed.success) { - return apiError(422, parsed.error.issues[0]?.message ?? "Datos no válidos."); + return apiValidationError(parsed.error.issues[0]); } - // apiDueDateSchema ya garantiza una fecha ISO válida (o null/ausente). + // apiDueDateSchema already guarantees a valid ISO date (or null/absent). const { id } = await createCardAtEnd({ listId, title: parsed.data.title, diff --git a/app/components/account-menu.tsx b/app/components/account-menu.tsx new file mode 100644 index 0000000..e32a239 --- /dev/null +++ b/app/components/account-menu.tsx @@ -0,0 +1,90 @@ +import Link from "next/link"; +import { logout } from "@/app/lib/actions/auth"; +import { initials } from "@/app/lib/initials"; +import { fmt } from "@/app/i18n/format"; +import { getI18n } from "@/app/i18n/server"; + +// Account menu in the top bar: who you are, settings, privacy, administration (admins +// only) and sign out. Uses the browser's `popover` API: it opens and closes without our +// own JavaScript, closes on outside click or Escape, and the button exposes its state to +// screen readers. No "use client": it is plain HTML. + +const ITEM = + "flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm text-ink transition-colors hover:bg-surface-2 focus-visible:bg-surface-2 focus-visible:outline-none"; + +function Icon({ d }: { d: React.ReactNode }) { + return ( + <svg + className="size-4 shrink-0 text-ink-muted" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + aria-hidden="true" + > + {d} + </svg> + ); +} + +export async function AccountMenu({ + name, + email, + isAdmin, +}: { + name: string; + email: string; + isAdmin: boolean; +}) { + const { m } = await getI18n(); + return ( + <> + <button + type="button" + popoverTarget="account-menu" + aria-label={fmt(m.nav.account.label, { name })} + className="ml-1 grid size-9 place-items-center rounded-full bg-accent text-xs font-bold text-accent-ink transition hover:bg-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent [anchor-name:--account-menu]" + > + {initials(name)} + </button> + <div + id="account-menu" + popover="auto" + className="account-menu m-0 w-64 rounded-xl border border-border bg-surface p-1.5 text-ink shadow-[0_16px_40px_-12px_oklch(0.27_0.022_52/0.35)]" + > + <div className="px-2.5 pb-2 pt-1.5"> + <p className="truncate text-sm font-semibold">{name}</p> + <p className="truncate text-xs text-ink-muted">{email}</p> + </div> + <div className="my-1 h-px bg-border" /> + <Link href="/settings#profile" className={ITEM}> + <Icon d={<><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></>} /> + {m.nav.account.profile} + </Link> + <Link href="/settings#security" className={ITEM}> + <Icon d={<><rect x="4" y="11" width="16" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>} /> + {m.nav.account.security} + </Link> + <Link href="/settings#data" className={ITEM}> + <Icon d={<path d="M12 3v12M7 10l5 5 5-5M5 21h14" />} /> + {m.nav.account.privacy} + </Link> + {isAdmin && ( + <Link href="/admin/users" className={ITEM}> + <Icon d={<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />} /> + {m.nav.account.admin} + </Link> + )} + <div className="my-1 h-px bg-border" /> + <form action={logout}> + <button type="submit" className={ITEM}> + <Icon d={<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" />} /> + {m.nav.account.signOut} + </button> + </form> + </div> + </> + ); +} diff --git a/app/components/board/add-card-form.tsx b/app/components/board/add-card-form.tsx index 935a6bb..73d0568 100644 --- a/app/components/board/add-card-form.tsx +++ b/app/components/board/add-card-form.tsx @@ -2,6 +2,8 @@ import { useRef } from "react"; import { createCard } from "@/app/lib/actions/cards"; +import { useI18n } from "@/app/i18n/client"; +import { fmt } from "@/app/i18n/format"; export function AddCardForm({ listId, @@ -10,10 +12,11 @@ export function AddCardForm({ listId: string; listTitle: string; }) { + const { m } = useI18n(); const ref = useRef<HTMLFormElement>(null); - // Guarda contra el doble envío (Enter repetido o doble clic): mientras la acción - // anterior no termina, los envíos nuevos se descartan. Es una ref y no estado para - // que el bloqueo sea inmediato, sin esperar a un nuevo render. + // Guard against double submits (repeated Enter or double click): while the previous + // action has not finished, new submits are dropped. A ref rather than state so the + // lock is immediate, without waiting for a re-render. const pending = useRef(false); return ( @@ -38,9 +41,9 @@ export function AddCardForm({ name="title" required maxLength={500} - placeholder="+ Añadir tarjeta" - // El placeholder desaparece al escribir y no sirve de etiqueta accesible. - aria-label={`Nueva tarjeta en ${listTitle}`} + placeholder={m.boards.card.addPlaceholder} + // The placeholder disappears when typing and is not an accessible label. + aria-label={fmt(m.boards.card.newIn, { list: listTitle })} className="w-full rounded-lg bg-transparent px-2 py-1.5 text-sm text-ink transition-colors placeholder:text-ink-muted hover:bg-black/5 focus:bg-surface focus:shadow-sm focus:outline-none focus:ring-2 focus:ring-accent/25 dark:hover:bg-white/5" /> </form> diff --git a/app/components/board/add-list-form.tsx b/app/components/board/add-list-form.tsx index ea86aaf..5232f0b 100644 --- a/app/components/board/add-list-form.tsx +++ b/app/components/board/add-list-form.tsx @@ -2,12 +2,14 @@ import { useRef } from "react"; import { createList } from "@/app/lib/actions/lists"; +import { useI18n } from "@/app/i18n/client"; export function AddListForm({ boardId }: { boardId: string }) { + const { m } = useI18n(); const ref = useRef<HTMLFormElement>(null); - // Guarda contra el doble envío (Enter repetido o doble clic): mientras la acción - // anterior no termina, los envíos nuevos se descartan. Es una ref y no estado para - // que el bloqueo sea inmediato, sin esperar a un nuevo render. + // Guard against double submits (repeated Enter or double click): while the previous + // action has not finished, new submits are dropped. A ref rather than state so the + // lock is immediate, without waiting for a re-render. const pending = useRef(false); return ( @@ -32,9 +34,9 @@ export function AddListForm({ boardId }: { boardId: string }) { name="title" required maxLength={120} - placeholder="+ Añadir lista" - // El placeholder desaparece al escribir y no sirve de etiqueta accesible. - aria-label="Nombre de la nueva lista" + placeholder={m.boards.list.addPlaceholder} + // The placeholder disappears when typing and is not an accessible label. + aria-label={m.boards.list.newName} className="w-full rounded-xl bg-white/25 px-3 py-2.5 text-sm text-white transition-colors placeholder:text-white/80 hover:bg-white/35 focus:bg-surface focus:text-ink focus:outline-none focus:ring-2 focus:ring-white/60 focus:placeholder:text-ink-muted" /> </form> diff --git a/app/components/board/board-perms.tsx b/app/components/board/board-perms.tsx index 9df9ec0..853fd35 100644 --- a/app/components/board/board-perms.tsx +++ b/app/components/board/board-perms.tsx @@ -4,15 +4,15 @@ import { createContext, useContext } from "react"; import type { BoardRole } from "@/app/lib/roles"; import { canEditBoard, canManageBoard } from "@/app/lib/roles"; -// Permisos del visor sobre el tablero actual, expuestos por contexto para que los -// componentes cliente (columnas, tarjetas, modal) oculten los controles de escritura -// sin pasar props por todos los niveles. La autorización REAL la hace el servidor en -// cada Server Action; esto es solo UI. Ver app/lib/roles.ts y docs/authorization.md. +// The viewer's permissions on the current board, exposed through context so Client +// Components (columns, cards, modal) can hide write controls without prop drilling. The +// REAL authorization happens on the server in every Server Action; this is UI only. See +// app/lib/roles.ts and docs/authorization.md. export type BoardPerms = { role: BoardRole; - canEdit: boolean; // EDITOR u OWNER: crear/editar/mover contenido. - canManage: boolean; // OWNER: miembros, etiquetas, renombrar/borrar. + canEdit: boolean; // EDITOR or OWNER: create/edit/move content. + canManage: boolean; // OWNER: members, labels, rename/delete. }; const BoardPermsContext = createContext<BoardPerms>({ diff --git a/app/components/board/board-realtime.tsx b/app/components/board/board-realtime.tsx index ea6b2af..3060ed4 100644 --- a/app/components/board/board-realtime.tsx +++ b/app/components/board/board-realtime.tsx @@ -8,32 +8,31 @@ import { scheduleRefresh, } from "@/app/lib/realtime/reconnect"; -// Sincronización en vivo del tablero (Hito 4). Abre un EventSource al stream SSE del -// tablero; cuando llega un evento "change" (otra persona movió algo), pide a Next que -// vuelva a renderizar la página del servidor con router.refresh(). El propio BoardView -// se resincroniza cuando le llega el `board` nuevo. Los eventos emitidos durante un -// corte se pierden: por eso al reconectar también se refresca, y si el navegador deja -// de reintentar se crea otra conexión (ver app/lib/realtime/reconnect.ts). Ver -// app/api/boards/[id]/stream/route.ts y docs/realtime.md. +// Live board sync. Opens an EventSource to the board's SSE stream; when a "change" event +// arrives (someone else moved something), asks Next to re-render the server page with +// router.refresh(). BoardView itself resyncs when it receives the new `board`. Events +// emitted during an outage are lost: that is why it also refreshes on reconnect, and if +// the browser stops retrying a new connection is created (see +// app/lib/realtime/reconnect.ts). See app/api/boards/[id]/stream/route.ts and +// docs/realtime.md. export function BoardRealtime({ boardId }: { boardId: string }) { const router = useRouter(); useEffect(() => { const url = `/api/boards/${boardId}/stream`; - // Pequeño debounce: si llegan varios eventos seguidos (p. ej. un arrastre), se - // hace un solo refresh. Es compartido con la campana de notificaciones: en esta - // página los dos streams reconectan a la vez y un refresh ya repinta ambos. + // Small debounce: if several events arrive in a row (e.g. a drag), only one refresh + // runs. It is shared with the notifications bell: on this page both streams + // reconnect at once and a single refresh repaints both. const onChange = () => scheduleRefresh(() => router.refresh()); return keepEventSourceOpen({ create: () => new EventSource(url), events: { change: onChange }, - // Tras un corte, trae lo que cambió mientras tanto (el servidor no lo reenvía). + // After an outage, fetch what changed meanwhile (the server does not resend it). onReconnect: onChange, - // CLOSED: se pregunta el estado al endpoint. Solo con 401/403 (sesión o acceso - // revocados) se refresca para que la página redirija a /login o /boards; ante un - // 502 del proxy durante un reinicio refrescar navegaría a su página de error, así - // que solo se reintenta. + // CLOSED: ask the endpoint for its status. Only on 401/403 (session or access + // revoked) refresh so the page redirects to /login or /boards; on a proxy 502 + // during a restart, refreshing would navigate to its error page, so just retry. probe: (signal) => probeStatus(url, signal), onAccessDenied: onChange, }); diff --git a/app/components/board/board-view.tsx b/app/components/board/board-view.tsx index e21d62c..2251aa7 100644 --- a/app/components/board/board-view.tsx +++ b/app/components/board/board-view.tsx @@ -24,7 +24,7 @@ import { import { generateKeyBetween } from "fractional-indexing"; import type { BoardDTO, ListDTO, CardDTO } from "@/app/lib/types"; import type { BoardRole } from "@/app/lib/roles"; -import { canEditBoard, canManageBoard, BOARD_ROLE_LABELS } from "@/app/lib/roles"; +import { canEditBoard, canManageBoard } from "@/app/lib/roles"; import { moveCard } from "@/app/lib/actions/cards"; import { reorderList } from "@/app/lib/actions/lists"; import { boardBackgroundFor } from "@/app/lib/colors"; @@ -47,12 +47,8 @@ import { import { BoardPermsProvider } from "./board-perms"; import { MembersPanel, type BoardMemberDTO } from "./members-panel"; import { CardModal } from "../card/card-modal"; - -// Instrucciones para lectores de pantalla (dnd-kit las trae en inglés). -const screenReaderInstructions: ScreenReaderInstructions = { - draggable: - "Pulsa Espacio para coger el elemento, las flechas para moverlo, Espacio para soltarlo y Escape para cancelar. Intro abre la tarjeta.", -}; +import { useI18n } from "@/app/i18n/client"; +import { fmt } from "@/app/i18n/format"; export function BoardView({ board, @@ -65,25 +61,26 @@ export function BoardView({ members: BoardMemberDTO[]; currentUserId: string; }) { + const { m } = useI18n(); const router = useRouter(); const pathname = usePathname(); const [lists, setLists] = useState<ListDTO[]>(board.lists); const [activeId, setActiveId] = useState<string | null>(null); - // Listas tal como estaban al empezar el arrastre, para deshacer el movimiento - // optimista si se cancela (Escape) o si el servidor lo rechaza. + // Lists as they were when the drag started, to undo the optimistic move if it is + // cancelled (Escape) or the server rejects it. const [dragSnapshot, setDragSnapshot] = useState<ListDTO[] | null>(null); - // Aviso si el servidor no guarda un movimiento: sin él, la tarjeta o la lista vuelve - // a su sitio sin explicación (el calendario avisa igual con dropError). + // Warning when the server does not save a move: without it, the card or list jumps + // back with no explanation (the calendar warns the same way with dropError). const [moveError, setMoveError] = useState<string | null>(null); - // Tarjeta abierta: se deriva de la URL (?card=…), no de un estado inicial. Así el - // deep-link de /search o de una notificación abre la tarjeta aunque ya estés en este - // tablero (Next no remonta el componente si solo cambian los searchParams). + // Open card: derived from the URL (?card=…), not from initial state. That way a deep + // link from /search or a notification opens the card even if you are already on this + // board (Next does not remount the component when only searchParams change). const searchParams = useSearchParams(); const selectedCardId = searchParams.get("card"); const [membersOpen, setMembersOpen] = useState(false); - // Vista activa: tablero (columnas) o calendario mensual (Hito 7). + // Active view: board (columns) or monthly calendar. const [view, setView] = useState<"board" | "calendar">("board"); - // Filtros del tablero (Hito 12): solo presentación. `now` estable para "vencidas". + // Board filters: presentation only. Stable `now` for "overdue". const [filters, setFilters] = useState(EMPTY_FILTERS); const [now] = useState(() => Date.now()); @@ -91,21 +88,20 @@ export function BoardView({ const canManage = canManageBoard(viewerRole); const filtering = filtersActive(filters); - // Resincroniza con el servidor cuando llegan datos nuevos (patrón "ajustar estado - // durante el render", sin useEffect): `board` solo cambia de referencia con datos - // frescos del servidor. + // Resync with the server when new data arrives ("adjust state during render" pattern, + // no useEffect): `board` only changes reference with fresh server data. const [syncedBoard, setSyncedBoard] = useState(board); if (board !== syncedBoard) { setSyncedBoard(board); setLists(board.lists); - // Si llegan datos del servidor en mitad de un arrastre, la instantánea ya es vieja: - // cancelar debe volver a estos datos, no a los de antes. + // If server data arrives mid-drag, the snapshot is stale: cancelling must go back + // to this data, not the previous one. if (dragSnapshot) setDragSnapshot(board.lists); } - // Teclado además de puntero: con el foco en una tarjeta o lista, Espacio la coge, - // las flechas la mueven y Espacio la suelta (accesibilidad sin ratón). Intro no coge: - // queda para abrir la tarjeta (ver DRAG_KEYBOARD_CODES). + // Keyboard as well as pointer: with focus on a card or list, Space picks it up, the + // arrows move it and Space drops it (mouse-free accessibility). Enter does not pick + // up: it is kept for opening the card (see DRAG_KEYBOARD_CODES). const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { @@ -113,8 +109,8 @@ export function BoardView({ keyboardCodes: DRAG_KEYBOARD_CODES, }), ); - // dnd-kit genera ids de accesibilidad con un contador global que no coincide entre - // servidor y cliente (error de hidratación en aria-describedby); useId es estable. + // dnd-kit generates accessibility ids from a global counter that differs between + // server and client (hydration error on aria-describedby); useId is stable. const dndId = useId(); const isList = (id: string) => lists.some((l) => l.id === id); @@ -134,9 +130,9 @@ export function BoardView({ ? lists.flatMap((l) => l.cards).find((c) => c.id === selectedCardId) : undefined; - // Abrir/cerrar la tarjeta cambia solo el ?card de la URL con la History API nativa, - // que Next sincroniza con useSearchParams sin pedir la página al servidor. Al cerrar - // se quita siempre, para que volver a pulsar la misma notificación la abra de nuevo. + // Opening/closing the card only changes the URL's ?card with the native History API, + // which Next syncs with useSearchParams without fetching the page from the server. It + // is always removed on close, so clicking the same notification again reopens it. const openCard = (cardId: string) => { window.history.replaceState( null, @@ -148,29 +144,38 @@ export function BoardView({ window.history.replaceState(null, "", pathname); }; - // Nombre legible de un elemento arrastrable/soltable para los anuncios accesibles. + // Screen reader texts: dnd-kit's defaults are English-only, so they come from the + // messages. + const dnd = m.boards.dnd; + const screenReaderInstructions: ScreenReaderInstructions = { + draggable: dnd.instructions, + }; + // Readable name of a draggable/droppable item for the accessible announcements. const describe = (id: UniqueIdentifier | undefined): string => { if (id === undefined) return ""; const key = String(id); const list = lists.find((l) => l.id === key); - if (list) return `la lista «${list.title}»`; + if (list) return fmt(dnd.listName, { title: list.title }); const card = lists.flatMap((l) => l.cards).find((c) => c.id === key); - return card ? `la tarjeta «${card.title}»` : ""; + return card ? fmt(dnd.cardName, { title: card.title }) : ""; }; const announcements: Announcements = { - onDragStart: ({ active }) => `Has cogido ${describe(active.id)}.`, + onDragStart: ({ active }) => fmt(dnd.pickedUp, { item: describe(active.id) }), onDragOver: ({ over }) => - over ? `Sobre ${describe(over.id)}.` : "Fuera de cualquier lista.", + over ? fmt(dnd.over, { target: describe(over.id) }) : dnd.outside, onDragEnd: ({ active, over }) => over - ? `Has soltado ${describe(active.id)} sobre ${describe(over.id)}.` - : `Has soltado ${describe(active.id)} fuera de cualquier lista; no se mueve.`, + ? fmt(dnd.dropped, { + item: describe(active.id), + target: describe(over.id), + }) + : fmt(dnd.droppedOutside, { item: describe(active.id) }), onDragCancel: ({ active }) => - `Movimiento cancelado; ${describe(active.id)} vuelve a su sitio.`, + fmt(dnd.cancelled, { item: describe(active.id) }), }; - // Deshace un movimiento optimista que el servidor no guardó, avisa y pide los datos - // reales. + // Undoes an optimistic move the server did not save, warns, and fetches the real + // data. const revertTo = (snapshot: ListDTO[], message: string) => { setLists(snapshot); setMoveError(message); @@ -183,8 +188,8 @@ export function BoardView({ setDragSnapshot(lists); } - // Escape (teclado) o arrastre abortado: handleDragOver puede haber movido la tarjeta - // a otra lista en el estado local; se vuelve a la instantánea. + // Escape (keyboard) or aborted drag: handleDragOver may have moved the card to another + // list in local state; go back to the snapshot. function handleDragCancel() { setActiveId(null); if (dragSnapshot) setLists(dragSnapshot); @@ -235,18 +240,17 @@ export function BoardView({ setActiveId(null); setDragSnapshot(null); if (!over) { - // Soltado fuera de cualquier zona: nada que guardar, se deshace lo que movió - // handleDragOver. + // Dropped outside any zone: nothing to save; undo what handleDragOver moved. setLists(snapshot); return; } const activeIdStr = String(active.id); const overIdStr = String(over.id); - // Reordenar listas (columnas) + // Reorder lists (columns) if (isList(activeIdStr)) { const oldIndex = lists.findIndex((l) => l.id === activeIdStr); - // Defensa: si `over` fuese una tarjeta, cuenta como su lista. + // Defensive: if `over` were a card, it counts as its list. const overListId = containerOf(overIdStr); const newIndex = lists.findIndex((l) => l.id === overListId); if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return; @@ -261,17 +265,16 @@ export function BoardView({ ); void persistOptimistic( () => reorderList({ listId: activeIdStr, position }), - () => - revertTo(snapshot, "No se pudo mover la lista. Inténtalo de nuevo."), + () => revertTo(snapshot, m.boards.board.moveListFailed), { - message: "[tablero] fallo al reordenar la lista", + message: "[board] failed to reorder the list", context: { listId: activeIdStr }, }, ); return; } - // Mover/reordenar tarjeta + // Move/reorder a card const container = containerOf(activeIdStr); if (!container) return; let working = lists; @@ -314,10 +317,9 @@ export function BoardView({ ); void persistOptimistic( () => moveCard({ cardId: activeIdStr, toListId: container, position }), - () => - revertTo(snapshot, "No se pudo mover la tarjeta. Inténtalo de nuevo."), + () => revertTo(snapshot, m.boards.board.moveCardFailed), { - message: "[tablero] fallo al mover la tarjeta", + message: "[board] failed to move the card", context: { cardId: activeIdStr, toListId: container }, }, ); @@ -352,7 +354,7 @@ export function BoardView({ onChange={setFilters} /> )} - {/* Conmutador Tablero / Calendario */} + {/* Board / Calendar switch */} <div className="flex items-center rounded-lg bg-white/20 p-0.5 backdrop-blur-sm"> <button onClick={() => setView("board")} @@ -363,7 +365,7 @@ export function BoardView({ : "text-white hover:bg-white/10" }`} > - Tablero + {m.boards.board.viewBoard} </button> <button onClick={() => setView("calendar")} @@ -374,11 +376,11 @@ export function BoardView({ : "text-white hover:bg-white/10" }`} > - Calendario + {m.boards.board.viewCalendar} </button> </div> <span className="rounded-full bg-white/20 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm"> - {BOARD_ROLE_LABELS[viewerRole]} + {m.boards.roles[viewerRole]} </span> <button onClick={() => setMembersOpen(true)} @@ -396,14 +398,14 @@ export function BoardView({ <circle cx="9" cy="7" r="4" /> <path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" /> </svg> - {canManage ? "Miembros" : "Ver miembros"} + {canManage ? m.boards.board.members : m.boards.board.viewMembers} <span className="tabular-nums opacity-80"> · {members.length} </span> </button> </div> - {/* Fila propia (basis-full) sobre fondo de superficie: el fondo del tablero - puede ser cualquier color y el rojo necesita contraste. */} + {/* Own row (basis-full) on a surface background: the board background can be + any colour and the red needs contrast. */} {moveError && ( <p role="alert" diff --git a/app/components/board/calendar-view.tsx b/app/components/board/calendar-view.tsx index d375bbe..0ade78c 100644 --- a/app/components/board/calendar-view.tsx +++ b/app/components/board/calendar-view.tsx @@ -25,40 +25,36 @@ import { DRAG_KEYBOARD_CODES, } from "@/app/lib/board-collision"; import { persistOptimistic } from "@/app/lib/optimistic"; +import { useI18n } from "@/app/i18n/client"; +import { fmt, plural } from "@/app/i18n/format"; -// Vista de calendario mensual (Hito 7) con arrastre para reprogramar (Hito 11): coloca las -// tarjetas con fecha de vencimiento en la rejilla del mes; al pulsar una se abre su modal y -// (si puedes editar) arrastrarla a otro día cambia su vencimiento. Ver docs/calendar.md. +// Monthly calendar view with drag to reschedule: places the cards that have a due date +// on the month grid; clicking one opens its modal and (if you can edit) dragging it to +// another day changes its due date. See docs/calendar.md. type DayCard = { id: string; title: string; - dueKey: string; // YYYY-MM-DD (parte de fecha del dueDate, sin zona horaria) - color: string | null; // color de la primera etiqueta, si la hay + dueKey: string; // YYYY-MM-DD (date part of dueDate, no time zone) + color: string | null; // colour of the first label, if any listTitle: string; }; -const WEEKDAYS = ["lun", "mar", "mié", "jue", "vie", "sáb", "dom"]; - -// Instrucciones para lectores de pantalla (dnd-kit las trae en inglés). -const screenReaderInstructions: ScreenReaderInstructions = { - draggable: - "Pulsa Espacio para coger la tarjeta, las flechas para cambiar de día, Espacio para soltarla y Escape para cancelar. Intro abre la tarjeta.", -}; - -// Día de una clave YYYY-MM-DD para los anuncios («24 de septiembre»). En UTC porque la -// clave es una fecha de calendario, no un instante. -const dayNameFormat = new Intl.DateTimeFormat("es-ES", { - day: "numeric", - month: "long", - timeZone: "UTC", -}); +// Short weekday names in the language `intl`, starting on Monday (the week always starts +// on Monday, whatever the language). 2024-01-01 was a Monday; UTC so the local zone +// cannot shift the day. +function weekdayNames(intl: string): string[] { + const format = new Intl.DateTimeFormat(intl, { weekday: "short", timeZone: "UTC" }); + return Array.from({ length: 7 }, (_, i) => + format.format(new Date(Date.UTC(2024, 0, 1 + i))), + ); +} -// Espacio siempre coge o suelta una pastilla arrastrable (DRAG_KEYBOARD_CODES), así que -// nunca debe abrirla. dnd-kit solo cancela el keydown, pero un <button> nativo se activa -// en el keyup de Espacio (Firefox lo hace aunque el keydown esté cancelado): cancelar -// también el keyup evita que coger o soltar la pastilla abra además el modal. Intro no -// necesita esto: el botón se activa en el keydown, que dnd-kit ya cancela al soltar. +// Space always picks up or drops a draggable chip (DRAG_KEYBOARD_CODES), so it must never +// open it. dnd-kit only cancels the keydown, but a native <button> activates on the Space +// keyup (Firefox does even when the keydown was cancelled): cancelling the keyup too +// stops picking up or dropping the chip from also opening the modal. Enter does not need +// this: the button activates on keydown, which dnd-kit already cancels when dropping. export function preventSpaceActivation(e: { code: string; preventDefault: () => void; @@ -66,19 +62,19 @@ export function preventSpaceActivation(e: { if (e.code === "Space") e.preventDefault(); } -// Suscripción vacía para saber, con useSyncExternalStore, si ya estamos en el navegador. +// Empty subscription to know, with useSyncExternalStore, whether we are in the browser. const subscribeNothing = () => () => {}; -// Clave de día local YYYY-MM-DD. Se compara con dueDate.slice(0,10) (la fecha que el -// usuario eligió), evitando desfases de zona horaria. +// Local day key YYYY-MM-DD. Compared with dueDate.slice(0,10) (the date the user +// picked), avoiding time zone shifts. function dayKey(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( d.getDate(), ).padStart(2, "0")}`; } -// Contenido visual de la pastilla, compartido por la pastilla real y la copia flotante -// del DragOverlay. +// Visual content of the chip, shared by the real chip and the DragOverlay's floating +// copy. function ChipContent({ card }: { card: DayCard }) { return ( <> @@ -101,23 +97,24 @@ function CardChip({ canEdit: boolean; onOpen: (id: string) => void; }) { + const { m } = useI18n(); const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: card.id, disabled: !canEdit, - // dnd-kit anuncia por defecto «draggable» (en inglés). - attributes: { roleDescription: "tarjeta" }, + // dnd-kit announces "draggable" by default. + attributes: { roleDescription: m.boards.card.roleDescription }, }); - // Sin transform: la pastilla original se queda en su día (atenuada) y lo que sigue al - // puntero es la copia del DragOverlay; moverla con transform la recortaba el - // overflow-y-auto del calendario. + // No transform: the original chip stays on its day (dimmed) and what follows the + // pointer is the DragOverlay copy; moving it with transform got it clipped by the + // calendar's overflow-y-auto. return ( <button ref={setNodeRef} {...(canEdit ? attributes : {})} {...(canEdit ? listeners : {})} onClick={() => onOpen(card.id)} - // Solo si se puede arrastrar: a quien solo puede ver, Espacio le sigue abriendo la - // tarjeta como en cualquier botón. + // Only when draggable: for read-only viewers Space still opens the card, as on any + // button. onKeyUp={canEdit ? preventSpaceActivation : undefined} title={`${card.title} · ${card.listTitle}`} className={`flex items-center gap-1.5 rounded-md border border-border bg-surface px-1.5 py-1 text-left text-xs leading-tight transition-colors hover:border-accent/50 ${ @@ -184,14 +181,16 @@ export function CalendarView({ onOpenCard: (cardId: string) => void; canEdit: boolean; }) { - // Mes mostrado (primer día del mes). Arranca en el mes actual. + const { m, intl } = useI18n(); + const t = m.boards.calendar; + // Month shown (first day of the month). Starts on the current month. const [month, setMonth] = useState(() => { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), 1); }); - // Teclado además de puntero: Espacio coge la pastilla, las flechas la llevan al día - // vecino y Espacio la suelta. Intro no coge: sigue abriendo la tarjeta. + // Keyboard as well as pointer: Space picks up the chip, the arrows move it to the + // neighbouring day and Space drops it. Enter does not pick up: it still opens the card. const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { @@ -199,13 +198,13 @@ export function CalendarView({ keyboardCodes: DRAG_KEYBOARD_CODES, }), ); - // Id estable entre servidor y cliente para los atributos de accesibilidad de dnd-kit. + // Id stable between server and client for dnd-kit's accessibility attributes. const dndId = useId(); const [activeId, setActiveId] = useState<string | null>(null); - // Aviso si el servidor no guardó la nueva fecha (la tarjeta se queda en su día). + // Warning if the server did not save the new date (the card stays on its day). const [dropError, setDropError] = useState<string | null>(null); - // El DragOverlay va en un portal a document.body, que solo existe en el navegador: en - // el servidor y durante la hidratación esto vale false y no se monta. + // The DragOverlay goes in a portal to document.body, which only exists in the browser: + // on the server and during hydration this is false and it is not mounted. const inBrowser = useSyncExternalStore( subscribeNothing, () => true, @@ -214,7 +213,7 @@ export function CalendarView({ const todayKey = useMemo(() => dayKey(new Date()), []); - // Tarjetas con fecha, indexadas por día. Las que no tienen fecha quedan fuera. + // Cards with a date, indexed by day. Those without a date are left out. const { byDay, undated } = useMemo(() => { const map = new Map<string, DayCard[]>(); let undated = 0; @@ -240,9 +239,9 @@ export function CalendarView({ return { byDay: map, undated }; }, [lists]); - // Rejilla de 6 semanas (42 días) empezando en el lunes de la semana del día 1. + // 6-week grid (42 days) starting on the Monday of the week of the 1st. const days = useMemo(() => { - const firstWeekday = (month.getDay() + 6) % 7; // lun=0 … dom=6 + const firstWeekday = (month.getDay() + 6) % 7; // Mon=0 … Sun=6 const gridStart = new Date( month.getFullYear(), month.getMonth(), @@ -262,10 +261,22 @@ export function CalendarView({ }); }, [month]); - const monthLabel = new Intl.DateTimeFormat("es-ES", { + const monthLabel = new Intl.DateTimeFormat(intl, { month: "long", year: "numeric", }).format(month); + const weekdays = useMemo(() => weekdayNames(intl), [intl]); + // Day of a YYYY-MM-DD key for the announcements ("September 24"). In UTC because the + // key is a calendar date, not an instant. + const dayNameFormat = useMemo( + () => + new Intl.DateTimeFormat(intl, { + day: "numeric", + month: "long", + timeZone: "UTC", + }), + [intl], + ); const goToToday = () => { const now = new Date(); @@ -282,17 +293,21 @@ export function CalendarView({ lists.flatMap((l) => l.cards).find((c) => c.id === String(id))?.title ?? ""; const dayName = (id: UniqueIdentifier) => dayNameFormat.format(new Date(`${String(id)}T00:00:00Z`)); + // Screen reader texts: dnd-kit's defaults are English-only, so they come from the + // messages. + const screenReaderInstructions: ScreenReaderInstructions = { + draggable: t.instructions, + }; const announcements: Announcements = { - onDragStart: ({ active }) => - `Has cogido la tarjeta «${cardTitle(active.id)}».`, + onDragStart: ({ active }) => fmt(t.pickedUp, { title: cardTitle(active.id) }), onDragOver: ({ over }) => - over ? `Sobre el ${dayName(over.id)}.` : "Fuera del calendario.", + over ? fmt(t.over, { day: dayName(over.id) }) : t.outside, onDragEnd: ({ active, over }) => over - ? `Has soltado la tarjeta «${cardTitle(active.id)}» en el ${dayName(over.id)}.` - : `Has soltado la tarjeta «${cardTitle(active.id)}» fuera del calendario; no cambia.`, + ? fmt(t.dropped, { title: cardTitle(active.id), day: dayName(over.id) }) + : fmt(t.droppedOutside, { title: cardTitle(active.id) }), onDragCancel: ({ active }) => - `Movimiento cancelado; la tarjeta «${cardTitle(active.id)}» sigue en su día.`, + fmt(t.cancelled, { title: cardTitle(active.id) }), }; function handleDragStart(event: DragStartEvent) { @@ -300,8 +315,8 @@ export function CalendarView({ setDropError(null); } - // Soltar una tarjeta en un día reprograma su vencimiento; el tablero se resincroniza - // por el tiempo real tras revalidar. + // Dropping a card on a day reschedules its due date; the board resyncs through + // realtime after revalidation. function handleDragEnd(event: DragEndEvent) { setActiveId(null); const { active, over } = event; @@ -315,9 +330,9 @@ export function CalendarView({ if (current === newKey) return; void persistOptimistic( () => setCardDueDate({ cardId, dueDate: newKey }), - () => setDropError("No se pudo cambiar la fecha. Inténtalo de nuevo."), + () => setDropError(t.dueDateFailed), { - message: "[calendario] fallo al cambiar la fecha de vencimiento", + message: "[calendar] failed to change the due date", context: { cardId, dueDate: newKey }, }, ); @@ -326,13 +341,13 @@ export function CalendarView({ return ( <div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-4"> <div className="mx-auto flex w-full max-w-5xl flex-1 flex-col rounded-2xl border border-border bg-surface/90 p-3 shadow-[0_2px_8px_-4px_oklch(0.27_0.022_52/0.2)] backdrop-blur-sm sm:p-4"> - {/* Cabecera: mes + navegación */} + {/* Header: month + navigation */} <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 px-1 pb-3"> <h2 className="font-display text-lg font-bold tracking-tight first-letter:uppercase"> {monthLabel} </h2> - {/* Fila propia bajo el mes y la navegación (order-last basis-full), como el aviso - del tablero: entre los dos, a 375 px le quedaban unos 10 px y se salía. */} + {/* Own row below the month and the navigation (order-last basis-full), like the + board's warning: between the two, at 375 px it had about 10 px and overflowed. */} {dropError && ( <p role="alert" className="order-last basis-full text-xs text-danger"> {dropError} @@ -341,7 +356,7 @@ export function CalendarView({ <div className="flex items-center gap-1"> <button onClick={() => shift(-1)} - aria-label="Mes anterior" + aria-label={t.previousMonth} className="grid size-8 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink" > <svg @@ -361,11 +376,11 @@ export function CalendarView({ onClick={goToToday} className="rounded-lg px-2.5 py-1 text-xs font-medium text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink" > - Hoy + {t.today} </button> <button onClick={() => shift(1)} - aria-label="Mes siguiente" + aria-label={t.nextMonth} className="grid size-8 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink" > <svg @@ -384,9 +399,9 @@ export function CalendarView({ </div> </div> - {/* Cabecera de días de la semana */} + {/* Weekday header */} <div className="grid grid-cols-7 gap-1 px-1 pb-1"> - {WEEKDAYS.map((w) => ( + {weekdays.map((w) => ( <div key={w} className="text-center text-[11px] font-semibold uppercase tracking-wide text-ink-muted" @@ -396,7 +411,7 @@ export function CalendarView({ ))} </div> - {/* Rejilla de días (con arrastre para reprogramar si puedes editar) */} + {/* Day grid (with drag to reschedule if you can edit) */} <DndContext id={dndId} sensors={sensors} @@ -426,12 +441,12 @@ export function CalendarView({ </DayCell> ))} </div> - {/* En un portal a document.body: el panel lleva backdrop-blur, y backdrop-filter - convierte al panel en el bloque contenedor de los descendientes fixed. Dentro - de él, la copia (fixed, con coordenadas de la ventana) salía desplazada por la - cabecera y recortada por el overflow-y-auto. El portal conserva el contexto de - React, así que sigue dentro del DndContext. Montado siempre (no solo con - activeCard) para que termine la animación de soltar. */} + {/* In a portal to document.body: the panel has backdrop-blur, and + backdrop-filter makes the panel the containing block of fixed descendants. + Inside it, the copy (fixed, with viewport coordinates) was offset by the + header and clipped by overflow-y-auto. The portal keeps the React context, + so it stays inside the DndContext. Always mounted (not only with activeCard) + so the drop animation can finish. */} {inBrowser && createPortal( <DragOverlay> @@ -447,10 +462,7 @@ export function CalendarView({ {undated > 0 && ( <p className="px-1 pt-3 text-xs text-ink-muted"> - {undated}{" "} - {undated === 1 - ? "tarjeta sin fecha de vencimiento (no aparece en el calendario)." - : "tarjetas sin fecha de vencimiento (no aparecen en el calendario)."} + {plural(intl, t.undated, undated)} </p> )} </div> diff --git a/app/components/board/card-item.tsx b/app/components/board/card-item.tsx index e2c74cc..d65a541 100644 --- a/app/components/board/card-item.tsx +++ b/app/components/board/card-item.tsx @@ -7,8 +7,10 @@ import type { CardDTO } from "@/app/lib/types"; import { labelBackground } from "@/app/lib/colors"; import { formatDueDay, isOverdue } from "@/app/lib/board-filters"; import { useBoardPerms } from "./board-perms"; +import { useI18n } from "@/app/i18n/client"; +import { fmt } from "@/app/i18n/format"; -// Suscripción vacía: solo se usa useSyncExternalStore para saber si ya se hidrató. +// Empty subscription: useSyncExternalStore is only used to know whether we hydrated. const subscribeNothing = () => () => {}; export function CardItem({ @@ -24,6 +26,7 @@ export function CardItem({ overlay?: boolean; sortingDisabled?: boolean; }) { + const { m, intl } = useI18n(); const { canEdit } = useBoardPerms(); const draggable = canEdit && !sortingDisabled; const { @@ -37,11 +40,11 @@ export function CardItem({ id: card.id, data: { type: "card", listId }, disabled: !draggable, - // dnd-kit anuncia por defecto «sortable» (en inglés). - attributes: { roleDescription: "tarjeta" }, + // dnd-kit announces "sortable" by default. + attributes: { roleDescription: m.boards.card.roleDescription }, }); - // Regla lateral tintada con el color de la primera etiqueta (o el borde si no hay). + // Side rule tinted with the first label's colour (or the border if there is none). const firstLabel = card.labels[0]; const style = { transform: CSS.Translate.toString(transform), @@ -57,12 +60,12 @@ export function CardItem({ 0, ); const attachCount = card.attachments.length; - // Date.now() se lee una vez al montar (no en cada render) para no romper la pureza - // del render; el badge de "vencida" no necesita actualizarse al segundo. + // Date.now() is read once on mount (not on every render) to keep rendering pure; the + // "overdue" badge does not need to update every second. const [now] = useState(() => Date.now()); - // "Vencida" depende del día LOCAL del navegador, que el servidor no conoce: se calcula - // solo tras hidratar (en el servidor y durante la hidratación vale false), para que el - // HTML del servidor y el primer render del cliente coincidan. + // "Overdue" depends on the browser's LOCAL day, which the server does not know: it is + // only computed after hydration (false on the server and during hydration), so the + // server HTML and the client's first render match. const hydrated = useSyncExternalStore( subscribeNothing, () => true, @@ -77,21 +80,21 @@ export function CardItem({ style={style} {...(draggable ? attributes : {})} {...(draggable ? listeners : {})} - // Siempre enfocable y anunciada como botón (también para lectores o con filtros, - // cuando dnd-kit no pone sus atributos). La copia flotante no recibe foco. Sin - // aria-label: el nombre sale del contenido (etiquetas, título, vencimiento...) y un - // aria-label con solo el título lo taparía. + // Always focusable and announced as a button (also for viewers or with filters, + // when dnd-kit does not add its attributes). The floating copy gets no focus. No + // aria-label: the name comes from the content (labels, title, due date...) and an + // aria-label with only the title would hide it. role="button" tabIndex={overlay ? -1 : 0} onClick={() => onOpen?.(card.id)} onKeyDown={(e) => { - // Primero dnd-kit (Espacio coge la tarjeta si se puede arrastrar; si la coge, - // marca el evento con preventDefault). Durante un arrastre con teclado, Intro - // suelta la tarjeta y no debe abrirla. + // dnd-kit first (Space picks up the card if it is draggable; if it does, it marks + // the event with preventDefault). During a keyboard drag, Enter drops the card + // and must not open it. if (draggable) listeners?.onKeyDown?.(e); if (e.defaultPrevented || isDragging || overlay) return; if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); // Espacio desplazaría la página. + e.preventDefault(); // Space would scroll the page. onOpen?.(card.id); } }} @@ -126,7 +129,7 @@ export function CardItem({ aria-valuenow={done} aria-valuemin={0} aria-valuemax={total} - aria-label={`Subtareas: ${done} de ${total} completadas`} + aria-label={fmt(m.boards.card.subtasks, { done, total })} > <div className={`h-full rounded-full transition-[width] duration-200 ease-out ${ @@ -162,9 +165,9 @@ export function CardItem({ <rect x="3" y="4" width="18" height="18" rx="2" /> <path d="M16 2v4M8 2v4M3 10h18" /> </svg> - {formatDueDay(due)} - {/* El rojo solo se ve: el lector de pantalla necesita el estado en texto. */} - {overdue && <span className="sr-only">, vencida</span>} + {formatDueDay(due, intl)} + {/* Red is only visual: the screen reader needs the state as text. */} + {overdue && <span className="sr-only">{m.boards.card.overdue}</span>} </span> )} {attachCount > 0 && ( diff --git a/app/components/board/filter-bar.tsx b/app/components/board/filter-bar.tsx index 6fd8ff2..f1d0ffd 100644 --- a/app/components/board/filter-bar.tsx +++ b/app/components/board/filter-bar.tsx @@ -10,16 +10,18 @@ import { } from "@/app/lib/board-filters"; import type { LabelDTO } from "@/app/lib/types"; import type { BoardMemberDTO } from "./members-panel"; +import { useI18n } from "@/app/i18n/client"; -const DUE_OPTIONS: { value: DueFilter; label: string }[] = [ - { value: "any", label: "Cualquiera" }, - { value: "overdue", label: "Vencidas" }, - { value: "due", label: "Con fecha" }, - { value: "none", label: "Sin fecha" }, -]; +// Message key of each due-date option, in display order. +const DUE_OPTIONS = [ + { value: "any", key: "dueAny" }, + { value: "overdue", key: "dueOverdue" }, + { value: "due", key: "dueSet" }, + { value: "none", key: "dueNone" }, +] as const satisfies readonly { value: DueFilter; key: string }[]; -// Barra de filtros del tablero (Hito 12). Solo afecta a la presentación: oculta las -// tarjetas que no encajan; no cambia datos. Ver docs/filters.md. +// Board filter bar. Presentation only: it hides the cards that do not match; it does not +// change any data. See docs/filters.md. export function FilterBar({ labels, members, @@ -31,6 +33,8 @@ export function FilterBar({ filters: BoardFilters; onChange: (f: BoardFilters) => void; }) { + const { m } = useI18n(); + const t = m.boards.filters; const [open, setOpen] = useState(false); const idPrefix = useId(); @@ -79,7 +83,7 @@ export function FilterBar({ > <path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" /> </svg> - Filtrar + {t.button} {count > 0 && ( <span className="grid min-w-4 place-items-center rounded-full bg-accent px-1 font-mono text-[10px] font-bold text-accent-ink tabular-nums"> {count} @@ -94,22 +98,22 @@ export function FilterBar({ aria-hidden onClick={() => setOpen(false)} /> - {/* Anclado a la izquierda a cualquier ancho: «Filtrar» es el primer botón de su - grupo, así que a su derecha siempre caben los 288 px (con la cabecera en una - fila, el resto del grupo es más ancho; partida, el grupo empieza en el borde). - Anclado a la derecha se salía por la izquierda al partirse la cabecera. */} + {/* Anchored to the left at any width: "Filter" is the first button of its + group, so the 288 px always fit to its right (with the header on one row the + rest of the group is wider; when it wraps, the group starts at the edge). + Anchored to the right it overflowed on the left when the header wrapped. */} <div role="dialog" - aria-label="Filtros" + aria-label={t.dialog} className="absolute left-0 top-11 z-40 w-72 max-w-[calc(100vw-2rem)] space-y-4 rounded-xl border border-border bg-surface p-4 text-ink shadow-[0_24px_48px_-24px_oklch(0.27_0.022_52/0.4)]" > - {/* Etiquetas */} + {/* Labels */} <div> <h3 id={`${idPrefix}-labels`} className="text-xs font-semibold uppercase tracking-wide text-ink-muted" > - Etiquetas + {t.labels} </h3> {labels.length > 0 ? ( <div @@ -120,9 +124,9 @@ export function FilterBar({ {labels.map((l) => { const on = filters.labelIds.includes(l.id); const color = labelBackground(l.color); - // Sin seleccionar: texto de tinta sobre la superficie con el borde del - // color (antes texto blanco con opacity-55, ~2,2:1 de contraste). - // Seleccionada: rellena del color, con ✓ y anillo. + // Unselected: ink text on the surface with a border in the colour + // (it used to be white text with opacity-55, ~2.2:1 contrast). + // Selected: filled with the colour, with a ✓ and a ring. return ( <button key={l.id} @@ -147,17 +151,17 @@ export function FilterBar({ })} </div> ) : ( - <p className="mt-1 text-xs text-ink-muted">Sin etiquetas.</p> + <p className="mt-1 text-xs text-ink-muted">{t.noLabels}</p> )} </div> - {/* Asignados */} + {/* Assignees */} <div> <h3 id={`${idPrefix}-assignees`} className="text-xs font-semibold uppercase tracking-wide text-ink-muted" > - Asignados + {t.assignees} </h3> <div role="group" @@ -185,13 +189,13 @@ export function FilterBar({ </div> </div> - {/* Vencimiento */} + {/* Due date */} <div> <h3 id={`${idPrefix}-due`} className="text-xs font-semibold uppercase tracking-wide text-ink-muted" > - Vencimiento + {t.due} </h3> <div role="group" @@ -210,7 +214,7 @@ export function FilterBar({ : "border-border text-ink-muted hover:text-ink" }`} > - {o.label} + {t[o.key]} </button> ))} </div> @@ -221,7 +225,7 @@ export function FilterBar({ onClick={() => onChange(EMPTY_FILTERS)} className="w-full rounded-lg border border-border py-1.5 text-sm font-medium text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink" > - Limpiar filtros + {t.clear} </button> )} </div> diff --git a/app/components/board/list-column.tsx b/app/components/board/list-column.tsx index 0c22284..c493df4 100644 --- a/app/components/board/list-column.tsx +++ b/app/components/board/list-column.tsx @@ -12,6 +12,8 @@ import { deleteList } from "@/app/lib/actions/lists"; import { CardItem } from "./card-item"; import { AddCardForm } from "./add-card-form"; import { useBoardPerms } from "./board-perms"; +import { useI18n } from "@/app/i18n/client"; +import { fmt } from "@/app/i18n/format"; export function ListColumn({ list, @@ -22,15 +24,16 @@ export function ListColumn({ onOpenCard: (cardId: string) => void; sortingDisabled?: boolean; }) { + const { m } = useI18n(); const { canEdit } = useBoardPerms(); - // Borrar una lista se lleva todas sus tarjetas (cascada) y no se puede deshacer: el ✕ - // pide una segunda confirmación en línea («¿Borrar? Sí / No») en vez de borrar al - // primer clic, que además cae junto al asa de arrastre. + // Deleting a list takes all its cards with it (cascade) and cannot be undone: the ✕ + // asks for a second inline confirmation ("Delete? Yes / No") instead of deleting on + // the first click, which also lands right next to the drag handle. const [confirmingDelete, setConfirmingDelete] = useState(false); - // Al cancelar, el formulario se desmonta con el foco dentro; sin devolverlo al ✕ - // caería al <body> y quien usa teclado perdería su sitio en el tablero (mismo patrón - // que DeleteBoardButton). Solo tras cancelar: al montar el tablero cada columna - // tiene su ✕ y ninguno debe robar el foco. + // On cancel the form unmounts with focus inside; without moving focus back to the ✕ + // it would fall to <body> and keyboard users would lose their place on the board (same + // pattern as DeleteBoardButton). Only after cancelling: when the board mounts every + // column has its ✕ and none of them should steal focus. const deleteTriggerRef = useRef<HTMLButtonElement>(null); const restoreFocus = useRef(false); @@ -56,8 +59,8 @@ export function ListColumn({ id: list.id, data: { type: "list" }, disabled: !canEdit || sortingDisabled, - // dnd-kit anuncia por defecto «sortable» (en inglés). - attributes: { roleDescription: "lista" }, + // dnd-kit announces "sortable" by default. + attributes: { roleDescription: m.boards.list.roleDescription }, }); const style = { @@ -91,7 +94,7 @@ export function ListColumn({ action={deleteList} onKeyDown={(e) => { if (e.key === "Escape") { - // Que el Escape no llegue a otros manejadores del tablero. + // Keep the Escape from reaching other board handlers. e.stopPropagation(); cancelDelete(); } @@ -99,24 +102,24 @@ export function ListColumn({ className="flex items-center gap-1 text-xs font-medium" > <input type="hidden" name="listId" value={list.id} /> - <span className="text-ink-muted">¿Borrar?</span> + <span className="text-ink-muted">{m.boards.confirm.deletePrompt}</span> <button type="submit" className="rounded px-1.5 py-0.5 text-danger transition-colors hover:bg-danger/10" - // Sin cifra: con filtros activos `list.cards` solo trae las visibles, pero - // el borrado se lleva en cascada también las ocultas. - aria-label={`Sí, borrar la lista ${list.title} y todas sus tarjetas`} + // No count: with active filters `list.cards` only holds the visible ones, + // but the cascade also deletes the hidden ones. + aria-label={fmt(m.boards.list.confirmDelete, { title: list.title })} > - Sí + {m.boards.confirm.yes} </button> <button type="button" autoFocus onClick={cancelDelete} className="rounded px-1.5 py-0.5 text-ink-muted transition-colors hover:bg-black/5 hover:text-ink dark:hover:bg-white/10" - aria-label="No, conservar la lista" + aria-label={m.boards.list.keep} > - No + {m.boards.confirm.no} </button> </form> ) : ( @@ -125,7 +128,7 @@ export function ListColumn({ type="button" onClick={() => setConfirmingDelete(true)} className="grid size-6 place-items-center rounded text-ink-muted transition-colors hover:bg-black/5 hover:text-danger dark:hover:bg-white/10" - aria-label={`Borrar lista ${list.title}`} + aria-label={fmt(m.boards.list.delete, { title: list.title })} > ✕ </button> diff --git a/app/components/board/members-panel.tsx b/app/components/board/members-panel.tsx index 2d04359..d573a71 100644 --- a/app/components/board/members-panel.tsx +++ b/app/components/board/members-panel.tsx @@ -7,13 +7,11 @@ import { removeBoardMember, type MemberActionState, } from "@/app/lib/actions/members"; -import { - BOARD_ROLE_LABELS, - ASSIGNABLE_BOARD_ROLES, - type BoardRole, -} from "@/app/lib/roles"; +import { ASSIGNABLE_BOARD_ROLES, type BoardRole } from "@/app/lib/roles"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { useI18n } from "@/app/i18n/client"; +import { fmt } from "@/app/i18n/format"; export type BoardMemberDTO = { id: string; @@ -23,9 +21,9 @@ export type BoardMemberDTO = { isOwner: boolean; }; -// Panel de gestión de miembros de un tablero (Hito 3). Solo lo abre el propietario. -// Invitar usa useActionState (feedback inline); cambiar rol y quitar usan formularios -// simples (la autorización la resuelve el servidor). Ver docs/authorization.md. +// Board members panel. Everyone can open it; only the owner gets the controls. Inviting +// uses useActionState (inline feedback); changing a role and removing use plain forms +// (the server handles authorization). See docs/authorization.md. export function MembersPanel({ boardId, members, @@ -37,13 +35,15 @@ export function MembersPanel({ canManage: boolean; onClose: () => void; }) { + const { m } = useI18n(); + const t = m.boards.members; const [state, action, pending] = useActionState<MemberActionState, FormData>( addBoardMember, {}, ); - // Rol con el que se remonta el selector tras un error. `values.role` es lo que se envió, - // sin validar: solo se reutiliza si es un rol asignable; si no, vuelve al por defecto. + // Role the select remounts with after an error. `values.role` is what was sent, + // unvalidated: it is only reused if it is an assignable role; otherwise the default. const inviteRole = ASSIGNABLE_BOARD_ROLES.find((r) => r === state.values?.role) ?? "EDITOR"; @@ -51,10 +51,10 @@ export function MembersPanel({ const titleId = useId(); const descriptionId = useId(); - // <dialog> nativo abierto con showModal(): el navegador pone el foco dentro, lo atrapa - // (el resto de la página queda inerte) y Escape dispara `cancel`. Al desmontar se cierra - // y se devuelve el foco a mano a quien lo tenía, porque el navegador no lo restaura si - // el <dialog> se quita del DOM en vez de cerrarse. + // Native <dialog> opened with showModal(): the browser moves focus inside, traps it + // (the rest of the page becomes inert) and Escape fires `cancel`. On unmount it is + // closed and focus is returned by hand to whoever had it, because the browser does not + // restore it if the <dialog> is removed from the DOM instead of being closed. useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; @@ -72,14 +72,14 @@ export function MembersPanel({ aria-labelledby={titleId} aria-describedby={descriptionId} onCancel={(e) => { - // El cierre lo decide el padre (desmonta el panel); así no queda un <dialog> - // cerrado pero montado. + // The parent decides on closing (it unmounts the panel), so no closed <dialog> + // stays mounted. e.preventDefault(); onClose(); }} onClick={(e) => { - // El <dialog> no tiene relleno: un clic cuyo destino es el propio <dialog> solo - // puede venir del fondo (::backdrop). + // The <dialog> has no padding: a click whose target is the <dialog> itself can + // only come from the backdrop (::backdrop). if (e.target === e.currentTarget) onClose(); }} className="mx-auto mt-8 max-h-[calc(100dvh-4rem)] w-[calc(100%-2rem)] max-w-lg overflow-y-auto rounded-2xl border border-border bg-surface text-ink shadow-[0_32px_64px_-24px_oklch(0.27_0.022_52/0.45)] backdrop:bg-black/50 backdrop:motion-safe:animate-[overlay-in_150ms_ease-out] sm:mt-12" @@ -92,49 +92,47 @@ export function MembersPanel({ id={titleId} className="font-display text-xl font-bold leading-snug tracking-tight" > - Miembros + {t.title} </h2> <p id={descriptionId} className="mt-1 text-sm text-ink-muted"> - {canManage - ? "Invita a personas y elige qué pueden hacer en este tablero." - : "Personas con acceso a este tablero."} + {canManage ? t.descriptionManage : t.descriptionView} </p> </div> <button onClick={onClose} - aria-label="Cerrar" + aria-label={m.common.actions.close} className="grid size-8 shrink-0 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink" > ✕ </button> </div> - {/* Invitar (solo el propietario) */} + {/* Invite (owner only) */} {canManage && ( <form action={action} className="mt-5"> <input type="hidden" name="boardId" value={boardId} /> <div className="flex flex-wrap items-end gap-2"> <label className="flex-1 text-sm"> <span className="block text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Email + {t.email} </span> <Input name="email" type="email" required defaultValue={state.values?.email} - placeholder="persona@ejemplo.com" + placeholder={t.emailPlaceholder} className="mt-1.5" /> </label> <label className="text-sm"> <span className="block text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Rol + {t.role} </span> - {/* React restablece el formulario a sus valores por defecto tras la acción, - pero no actualiza el defaultValue de un <select> ya montado: la key lo - remonta con lo elegido para que un error no devuelva «Lector» a «Editor» - y la invitación siguiente dé más permisos de los queridos. */} + {/* React resets the form to its default values after the action, but does + not update the defaultValue of an already mounted <select>: the key + remounts it with the chosen role so an error does not turn "Viewer" back + into "Editor" and the next invite grants more access than intended. */} <select key={inviteRole} name="role" @@ -143,17 +141,17 @@ export function MembersPanel({ > {ASSIGNABLE_BOARD_ROLES.map((r) => ( <option key={r} value={r}> - {BOARD_ROLE_LABELS[r]} + {m.boards.roles[r]} </option> ))} </select> </label> <Button type="submit" disabled={pending}> - {pending ? "Añadiendo…" : "Invitar"} + {pending ? t.inviting : t.invite} </Button> </div> - {/* role="alert" para que el lector de pantalla lo anuncie. Se retira mientras se - envía: así un segundo error idéntico se vuelve a insertar y se anuncia de nuevo. */} + {/* role="alert" so the screen reader announces it. It is removed while + submitting, so a second identical error is inserted and announced again. */} {state.error && !pending && ( <p role="alert" @@ -162,8 +160,8 @@ export function MembersPanel({ {state.error} </p> )} - {/* Región viva siempre montada: los lectores de pantalla solo anuncian los - cambios dentro de una región que ya existía. */} + {/* Live region always mounted: screen readers only announce changes inside a + region that already existed. */} <div role="status"> {state.message && ( <p className="mt-2 rounded-lg bg-success/10 px-3 py-2 text-sm text-success"> @@ -174,47 +172,47 @@ export function MembersPanel({ </form> )} - {/* Lista de miembros */} + {/* Member list */} <ul className="mt-6 divide-y divide-border"> - {members.map((m) => ( - <li key={m.id} className="flex items-center gap-3 py-3"> + {members.map((member) => ( + <li key={member.id} className="flex items-center gap-3 py-3"> <div className="grid size-9 shrink-0 place-items-center rounded-full bg-surface-2 text-sm font-semibold uppercase"> - {m.name.charAt(0)} + {member.name.charAt(0)} </div> <div className="min-w-0 flex-1"> - <p className="truncate text-sm font-medium">{m.name}</p> - <p className="truncate text-xs text-ink-muted">{m.email}</p> + <p className="truncate text-sm font-medium">{member.name}</p> + <p className="truncate text-xs text-ink-muted">{member.email}</p> </div> - {m.isOwner || !canManage ? ( + {member.isOwner || !canManage ? ( <span className="rounded-full bg-surface-2 px-2.5 py-1 text-xs font-medium text-ink-muted"> - {BOARD_ROLE_LABELS[m.role]} + {m.boards.roles[member.role]} </span> ) : ( <div className="flex items-center gap-1.5"> <form action={updateBoardMemberRole}> <input type="hidden" name="boardId" value={boardId} /> - <input type="hidden" name="userId" value={m.id} /> + <input type="hidden" name="userId" value={member.id} /> <select name="role" - defaultValue={m.role} + defaultValue={member.role} onChange={(e) => e.currentTarget.form?.requestSubmit()} className="h-8 rounded-lg border border-border bg-surface px-2 text-xs text-ink outline-none focus-visible:border-accent" - aria-label={`Rol de ${m.name}`} + aria-label={fmt(t.roleOf, { name: member.name })} > {ASSIGNABLE_BOARD_ROLES.map((r) => ( <option key={r} value={r}> - {BOARD_ROLE_LABELS[r]} + {m.boards.roles[r]} </option> ))} </select> </form> <form action={removeBoardMember}> <input type="hidden" name="boardId" value={boardId} /> - <input type="hidden" name="userId" value={m.id} /> + <input type="hidden" name="userId" value={member.id} /> <button className="grid size-8 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-danger/10 hover:text-danger" - aria-label={`Quitar a ${m.name}`} + aria-label={fmt(t.remove, { name: member.name })} > ✕ </button> diff --git a/app/components/card/card-modal-helpers.ts b/app/components/card/card-modal-helpers.ts index 37fd380..d2390fe 100644 --- a/app/components/card/card-modal-helpers.ts +++ b/app/components/card/card-modal-helpers.ts @@ -1,15 +1,16 @@ -// Lógica pura del modal de tarjeta, separada del componente para poder probarla con -// Vitest en entorno node (el componente importa Server Actions y no se carga fuera de -// Next). +// Pure logic of the card modal, kept apart from the component so it can be tested with +// Vitest in a node environment (the component imports Server Actions and cannot load +// outside Next). import { unstable_rethrow } from "next/navigation"; /** - * Fecha y hora de un comentario. `timeZone` sin definir = zona del navegador. El - * componente solo la llama tras hidratar: en el servidor la zona sería la del - * contenedor (normalmente UTC) y el texto no coincidiría con el del cliente. + * Date and time of a comment, in the language `intl` (a BCP 47 tag). An undefined + * `timeZone` means the browser's zone. The component only calls it after hydration: on + * the server the zone would be the container's (usually UTC) and the text would not + * match the client's. */ -export function formatCommentTime(iso: string, timeZone?: string): string { - return new Intl.DateTimeFormat("es-ES", { +export function formatCommentTime(iso: string, intl: string, timeZone?: string): string { + return new Intl.DateTimeFormat(intl, { dateStyle: "medium", timeStyle: "short", timeZone, @@ -17,20 +18,21 @@ export function formatCommentTime(iso: string, timeZone?: string): string { } /** - * Vencimiento legible. `dueDate` es una fecha de calendario guardada a medianoche - * UTC: formatearla en otra zona la movería al día anterior al oeste de Greenwich. + * Readable due date, in the language `intl`. `dueDate` is a calendar date stored at + * midnight UTC: formatting it in another zone would move it to the previous day west of + * Greenwich. */ -export function formatDueDate(iso: string): string { - return new Intl.DateTimeFormat("es-ES", { +export function formatDueDate(iso: string, intl: string): string { + return new Intl.DateTimeFormat(intl, { dateStyle: "long", timeZone: "UTC", }).format(new Date(iso)); } /** - * Ejecuta una acción de tipo toggle y devuelve el mensaje de error para la interfaz - * (o `null` si fue bien). Los errores internos de Next (redirect a /login cuando la - * sesión caduca, notFound) se relanzan para que el router los gestione. + * Runs a toggle-style action and returns the error message for the UI (or `null` if it + * went well). Next's internal errors (redirect to /login when the session expires, + * notFound) are rethrown so the router handles them. */ export async function runToggle( action: () => Promise<{ ok: boolean }>, @@ -42,7 +44,7 @@ export async function runToggle( return result.ok ? null : failMessage; } catch (err) { unstable_rethrow(err); - console.error(`${context} falló`, err); + console.error(`${context} failed`, err); return failMessage; } } diff --git a/app/components/card/card-modal.tsx b/app/components/card/card-modal.tsx index f5b49a1..4bad13f 100644 --- a/app/components/card/card-modal.tsx +++ b/app/components/card/card-modal.tsx @@ -55,27 +55,17 @@ import { formatDueDate, runToggle, } from "./card-modal-helpers"; +import { useI18n } from "@/app/i18n/client"; +import { fmt } from "@/app/i18n/format"; -// Los botones de borrar solo aparecen al pasar el ratón, pero deben verse también al -// recibir el foco con teclado y siempre en pantallas táctiles (sin hover). +// Delete buttons only appear on hover, but they must also show when they receive +// keyboard focus, and always on touch screens (no hover). const revealOnHover = "rounded opacity-0 transition hover:text-danger group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-accent/40 pointer-coarse:opacity-100"; -// Nombres en español para el lector de pantalla; el Record obliga a cubrir cada color. -const LABEL_COLOR_NAMES: Record<LabelColor, string> = { - green: "verde", - lime: "lima", - amber: "ámbar", - red: "rojo", - purple: "morado", - blue: "azul", - teal: "turquesa", - pink: "rosa", -}; - const noopSubscribe = () => () => {}; -/** `false` en el servidor y durante la hidratación; `true` después. */ +/** `false` on the server and during hydration; `true` afterwards. */ function useHydrated(): boolean { return useSyncExternalStore( noopSubscribe, @@ -108,6 +98,8 @@ export function CardModal({ currentUserId: string; onClose: () => void; }) { + const { m, intl } = useI18n(); + const t = m.boards.cardModal; const { canEdit, canManage } = useBoardPerms(); const [, startTransition] = useTransition(); const [labelError, setLabelError] = useState<string | null>(null); @@ -120,10 +112,10 @@ export function CardModal({ FormData >(updateCard, {}); - // <dialog> nativo con showModal(): el navegador vuelve inerte el resto de la página - // (el Tab no sale al tablero), enfoca el primer control y, al cerrar, devuelve el - // foco a la tarjeta que lo abrió. Layout effect para que close() ocurra antes de - // que React quite el nodo: si se retira abierto, el foco no se restaura. + // Native <dialog> with showModal(): the browser makes the rest of the page inert (Tab + // does not escape to the board), focuses the first control and, on close, returns + // focus to the card that opened it. A layout effect so close() runs before React + // removes the node: if it is removed while open, focus is not restored. useLayoutEffect(() => { const dialog = dialogRef.current; if (!dialog) return; @@ -140,13 +132,13 @@ export function CardModal({ ); return ( - // El <dialog> ocupa toda la ventana y hace de contenedor con scroll; el panel va - // dentro. Un clic cuyo destino es el propio <dialog> cae fuera del panel. + // The <dialog> fills the window and acts as the scroll container; the panel sits + // inside. A click whose target is the <dialog> itself landed outside the panel. <dialog ref={dialogRef} aria-labelledby={titleId} onCancel={(e) => { - // Escape: cierra React (desmonta), no el navegador por su cuenta. + // Escape: React closes it (unmounts), not the browser on its own. e.preventDefault(); onClose(); }} @@ -168,17 +160,17 @@ export function CardModal({ </h2> <button onClick={onClose} - aria-label="Cerrar" + aria-label={m.common.actions.close} className="grid size-8 shrink-0 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink" > ✕ </button> </div> - {/* Etiquetas */} + {/* Labels */} <section className="mt-6"> <h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Etiquetas + {t.labels} </h3> {canEdit ? ( boardLabels.length > 0 ? ( @@ -198,15 +190,15 @@ export function CardModal({ cardId: card.id, labelId: l.id, }), - "No se pudo cambiar la etiqueta.", + t.labelToggleFailed, "toggleCardLabel", ), ); }) } style={{ background: labelBackground(l.color) }} - // Sin opacity-55 en el no seleccionado: bajaba el texto blanco a - // ~2,2:1. El estado se distingue por el ✓, el anillo y aria-pressed. + // No opacity-55 when unselected: it dropped the white text to + // ~2.2:1. The state shows through the ✓, the ring and aria-pressed. className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium text-white transition ${ on ? "ring-2 ring-ink/40 ring-offset-1 ring-offset-surface" @@ -221,7 +213,7 @@ export function CardModal({ </div> ) : ( <p className="mt-2 text-sm text-ink-muted"> - Aún no hay etiquetas en este tablero. + {t.noBoardLabels} </p> ) ) : card.labels.length > 0 ? ( @@ -237,10 +229,10 @@ export function CardModal({ ))} </div> ) : ( - <p className="mt-2 text-sm text-ink-muted">Sin etiquetas.</p> + <p className="mt-2 text-sm text-ink-muted">{t.noLabels}</p> )} - {/* El aviso va junto a lo que falló: el modal tiene scroll y uno arriba del - todo no se vería. Se borra en cuanto otro cambio de etiqueta sale bien. */} + {/* The warning sits next to what failed: the modal scrolls and one at the very + top would go unseen. It clears as soon as another label change succeeds. */} {labelError && ( <p role="alert" @@ -252,7 +244,7 @@ export function CardModal({ {canManage && <NewLabelForm boardId={boardId} />} </section> - {/* Asignados */} + {/* Assignees */} <AssigneesSection cardId={card.id} assignees={card.assignees} @@ -260,7 +252,7 @@ export function CardModal({ canEdit={canEdit} /> - {/* Descripción + fecha */} + {/* Description + due date */} {canEdit ? ( <form action={detailsAction} className="mt-6 space-y-4"> <input type="hidden" name="cardId" value={card.id} /> @@ -269,25 +261,25 @@ export function CardModal({ htmlFor={descId} className="block text-xs font-semibold uppercase tracking-wide text-ink-muted" > - Descripción + {t.description} </label> <Textarea id={descId} name="description" rows={4} maxLength={CARD_DESCRIPTION_MAX} - // Si no se guardó, se rehidrata con lo enviado: React resetea el - // formulario al acabar la acción y, si no, se perdería lo escrito. + // If it was not saved, it is refilled with what was sent: React resets + // the form when the action ends and the text would otherwise be lost. defaultValue={details.description ?? card.description ?? ""} aria-describedby={details.error ? detailsErrorId : undefined} - placeholder="Añade más detalle…" + placeholder={t.descriptionPlaceholder} className="mt-2 resize-y" /> </div> <div className="flex flex-wrap items-end gap-3"> <label className="text-sm"> <span className="block text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Vencimiento + {t.dueDate} </span> <Input type="date" @@ -297,7 +289,7 @@ export function CardModal({ /> </label> <Button type="submit" variant="subtle" disabled={detailsPending}> - {detailsPending ? "Guardando…" : "Guardar cambios"} + {detailsPending ? m.common.actions.saving : t.saveChanges} </Button> </div> {details.error && ( @@ -314,18 +306,18 @@ export function CardModal({ <div className="mt-6 space-y-4"> <div> <h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Descripción + {t.description} </h3> <p className="mt-2 whitespace-pre-wrap text-sm text-ink-muted"> - {card.description || "Sin descripción."} + {card.description || t.noDescription} </p> </div> {card.dueDate && ( <p className="text-sm"> <span className="text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Vencimiento:{" "} + {t.dueDateInline}{" "} </span> - <time dateTime={dueValue}>{formatDueDate(card.dueDate)}</time> + <time dateTime={dueValue}>{formatDueDate(card.dueDate, intl)}</time> </p> )} </div> @@ -334,10 +326,10 @@ export function CardModal({ {/* Checklists */} <section className="mt-6"> <h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Checklists + {t.checklists} {total > 0 && ( <span className="ml-1.5 font-normal normal-case tracking-normal"> - · {done} de {total} completadas + {fmt(t.checklistProgress, { done, total })} </span> )} </h3> @@ -354,14 +346,14 @@ export function CardModal({ {canEdit && <NewChecklistForm cardId={card.id} />} </section> - {/* Adjuntos */} + {/* Attachments */} <AttachmentsSection cardId={card.id} attachments={card.attachments} canEdit={canEdit} /> - {/* Comentarios */} + {/* Comments */} <CommentsSection cardId={card.id} comments={card.comments} @@ -370,7 +362,7 @@ export function CardModal({ currentUserId={currentUserId} /> - {/* Borrar (solo quien puede editar) */} + {/* Delete (only for people who can edit) */} {canEdit && ( <div className="mt-8 flex justify-end border-t border-border pt-4"> <DeleteCardForm cardId={card.id} onDeleted={onClose} /> @@ -381,8 +373,8 @@ export function CardModal({ ); } -// Borrar es irreversible: el primer clic solo pide confirmación en línea (sin -// window.confirm, que bloquea y no sigue el estilo de la app). +// Deleting is irreversible: the first click only asks for inline confirmation (no +// window.confirm, which blocks and does not follow the app's style). function DeleteCardForm({ cardId, onDeleted, @@ -390,13 +382,15 @@ function DeleteCardForm({ cardId: string; onDeleted: () => void; }) { + const { m } = useI18n(); + const t = m.boards.cardModal; const [confirming, setConfirming] = useState(false); const askRef = useRef<HTMLButtonElement>(null); const cancelRef = useRef<HTMLButtonElement>(null); const wasConfirming = useRef(false); - // El botón pulsado desaparece al cambiar de paso: se mueve el foco al control - // equivalente para que el teclado no se quede en <body>. + // The pressed button disappears when the step changes: focus moves to the equivalent + // control so the keyboard does not end up on <body>. useEffect(() => { if (confirming) cancelRef.current?.focus(); else if (wasConfirming.current) askRef.current?.focus(); @@ -411,7 +405,7 @@ function DeleteCardForm({ type="button" onClick={() => setConfirming(true)} > - Borrar tarjeta + {t.deleteCard} </Button> ); } @@ -425,9 +419,9 @@ function DeleteCardForm({ className="flex flex-wrap items-center justify-end gap-2" > <input type="hidden" name="cardId" value={cardId} /> - <span className="text-sm text-ink">¿Borrar la tarjeta?</span> + <span className="text-sm text-ink">{t.deleteCardPrompt}</span> <Button variant="danger" type="submit"> - Sí, borrar + {t.confirmDeleteCard} </Button> <Button ref={cancelRef} @@ -435,13 +429,15 @@ function DeleteCardForm({ type="button" onClick={() => setConfirming(false)} > - No + {m.boards.confirm.no} </Button> </form> ); } function NewLabelForm({ boardId }: { boardId: string }) { + const { m } = useI18n(); + const t = m.boards.cardModal; const ref = useRef<HTMLFormElement>(null); const [color, setColor] = useState<LabelColor>(LABEL_COLOR_KEYS[0]); @@ -462,7 +458,7 @@ function NewLabelForm({ boardId }: { boardId: string }) { type="button" key={k} onClick={() => setColor(k)} - aria-label={`Color ${LABEL_COLOR_NAMES[k]}`} + aria-label={fmt(t.labelColor, { color: t.colors[k] })} aria-pressed={color === k} style={{ background: LABEL_COLORS[k] }} className={`size-5 rounded-full transition ${ @@ -477,12 +473,12 @@ function NewLabelForm({ boardId }: { boardId: string }) { name="name" required maxLength={40} - placeholder="Nueva etiqueta" - aria-label="Nombre de la nueva etiqueta" + placeholder={t.newLabel} + aria-label={t.newLabelName} className={`${smallInput} min-w-32 flex-1`} /> <Button type="submit" variant="subtle" className="px-3 py-1.5"> - Añadir + {t.add} </Button> </form> ); @@ -497,8 +493,10 @@ function ChecklistBlock({ canEdit: boolean; startTransition: TransitionStartFunction; }) { + const { m } = useI18n(); + const t = m.boards.cardModal; const itemRef = useRef<HTMLFormElement>(null); - // Error propio de cada checklist, para que salga junto al ítem que falló. + // Each checklist has its own error, so it shows next to the item that failed. const [itemError, setItemError] = useState<string | null>(null); const total = checklist.items.length; const done = checklist.items.filter((i) => i.done).length; @@ -512,7 +510,7 @@ function ChecklistBlock({ <form action={deleteChecklist}> <input type="hidden" name="checklistId" value={checklist.id} /> <button className="text-xs text-ink-muted transition-colors hover:text-danger"> - Eliminar + {t.deleteChecklist} </button> </form> )} @@ -539,14 +537,14 @@ function ChecklistBlock({ await runToggle( () => toggleChecklistItem({ itemId: it.id, done: !it.done }), - "No se pudo actualizar el ítem.", + t.itemToggleFailed, "toggleChecklistItem", ), ); }) } disabled={!canEdit} - aria-label={it.done ? "Marcar como pendiente" : "Marcar como hecho"} + aria-label={it.done ? t.markNotDone : t.markDone} className={`grid size-4 shrink-0 place-items-center rounded border text-[10px] text-white transition-colors ${ it.done ? "border-success bg-success" @@ -567,7 +565,7 @@ function ChecklistBlock({ <input type="hidden" name="itemId" value={it.id} /> <button className={`text-xs text-ink-muted ${revealOnHover}`} - aria-label="Borrar ítem" + aria-label={t.deleteItem} > ✕ </button> @@ -600,8 +598,8 @@ function ChecklistBlock({ name="text" required maxLength={200} - placeholder="+ Añadir ítem" - aria-label={`Nuevo ítem en ${checklist.title}`} + placeholder={t.addItemPlaceholder} + aria-label={fmt(t.newItemIn, { checklist: checklist.title })} className={smallInput} /> </form> @@ -619,6 +617,8 @@ function AttachmentsSection({ attachments: AttachmentDTO[]; canEdit: boolean; }) { + const { m } = useI18n(); + const t = m.boards.cardModal; const ref = useRef<HTMLFormElement>(null); const firstRender = useRef(true); const [state, action, pending] = useActionState<AttachmentState, FormData>( @@ -626,7 +626,7 @@ function AttachmentsSection({ {}, ); - // Limpia el formulario tras una subida correcta (sin error). + // Clears the form after a successful upload (no error). useEffect(() => { if (firstRender.current) { firstRender.current = false; @@ -638,7 +638,7 @@ function AttachmentsSection({ return ( <section className="mt-6"> <h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Adjuntos + {t.attachments} {attachments.length > 0 && ( <span className="ml-1.5 font-normal normal-case tracking-normal"> · {attachments.length} @@ -679,7 +679,7 @@ function AttachmentsSection({ <input type="hidden" name="attachmentId" value={a.id} /> <button className={`text-xs text-ink-muted ${revealOnHover}`} - aria-label={`Borrar adjunto ${a.filename}`} + aria-label={fmt(t.deleteAttachment, { filename: a.filename })} > ✕ </button> @@ -689,7 +689,7 @@ function AttachmentsSection({ ))} </ul> ) : ( - <p className="mt-2 text-sm text-ink-muted">No hay adjuntos.</p> + <p className="mt-2 text-sm text-ink-muted">{t.noAttachments}</p> )} {canEdit && ( @@ -703,11 +703,11 @@ function AttachmentsSection({ type="file" name="file" required - aria-label="Archivo para adjuntar a la tarjeta" + aria-label={t.attachFile} className="max-w-full text-sm text-ink-muted file:mr-2 file:rounded-lg file:border-0 file:bg-surface-2 file:px-3 file:py-1.5 file:text-sm file:text-ink hover:file:bg-border/40" /> <Button type="submit" variant="subtle" disabled={pending}> - {pending ? "Subiendo…" : "Adjuntar"} + {pending ? t.uploading : t.attach} </Button> </form> )} @@ -725,6 +725,8 @@ function AttachmentsSection({ } function NewChecklistForm({ cardId }: { cardId: string }) { + const { m } = useI18n(); + const t = m.boards.cardModal; const ref = useRef<HTMLFormElement>(null); return ( @@ -740,18 +742,18 @@ function NewChecklistForm({ cardId }: { cardId: string }) { <input name="title" maxLength={80} - placeholder="Nueva checklist" - aria-label="Título de la nueva checklist" + placeholder={t.newChecklist} + aria-label={t.newChecklistTitle} className={`${smallInput} flex-1`} /> <Button type="submit" variant="subtle"> - Añadir checklist + {t.addChecklist} </Button> </form> ); } -// Avatar simple: círculo con la inicial del nombre. Sin imágenes (no las tenemos). +// Simple avatar: a circle with the name's initial. No images (we don't have any). function Avatar({ name, size = 7 }: { name: string; size?: 6 | 7 | 8 }) { const cls = { 6: "size-6 text-[11px]", 7: "size-7 text-xs", 8: "size-8 text-sm" }; return ( @@ -775,13 +777,15 @@ function AssigneesSection({ members: BoardMemberDTO[]; canEdit: boolean; }) { + const { m } = useI18n(); + const t = m.boards.cardModal; const assignedIds = new Set(assignees.map((a) => a.id)); const unassigned = members.filter((m) => !assignedIds.has(m.id)); return ( <section className="mt-6"> <h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Asignados + {t.assignees} </h3> {assignees.length > 0 ? ( @@ -799,7 +803,7 @@ function AssigneesSection({ <input type="hidden" name="userId" value={a.id} /> <button className="text-ink-muted transition-colors hover:text-danger" - aria-label={`Quitar a ${a.name}`} + aria-label={fmt(t.unassign, { name: a.name })} > ✕ </button> @@ -809,7 +813,7 @@ function AssigneesSection({ ))} </div> ) : ( - <p className="mt-2 text-sm text-ink-muted">Nadie asignado.</p> + <p className="mt-2 text-sm text-ink-muted">{t.nobodyAssigned}</p> )} {canEdit && unassigned.length > 0 && ( @@ -842,15 +846,17 @@ function CommentsSection({ canManage: boolean; currentUserId: string; }) { + const { m, intl } = useI18n(); + const t = m.boards.cardModal; const ref = useRef<HTMLFormElement>(null); - // Con ?card= el modal se renderiza en el servidor, cuya zona horaria no es la del - // navegador: la hora se pinta solo tras hidratar para no provocar un desajuste. + // With ?card= the modal renders on the server, whose time zone is not the browser's: + // the time is only painted after hydration to avoid a mismatch. const hydrated = useHydrated(); return ( <section className="mt-6"> <h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Comentarios + {t.comments} {comments.length > 0 && ( <span className="ml-1.5 font-normal normal-case tracking-normal"> · {comments.length} @@ -870,7 +876,7 @@ function CommentsSection({ dateTime={c.createdAt} className="text-[11px] text-ink-muted" > - {hydrated ? formatCommentTime(c.createdAt) : ""} + {hydrated ? formatCommentTime(c.createdAt, intl) : ""} </time> </p> <p className="mt-0.5 whitespace-pre-wrap break-words text-sm"> @@ -882,7 +888,7 @@ function CommentsSection({ <input type="hidden" name="commentId" value={c.id} /> <button className={`text-xs text-ink-muted ${revealOnHover}`} - aria-label="Borrar comentario" + aria-label={t.deleteComment} > ✕ </button> @@ -893,7 +899,7 @@ function CommentsSection({ </ul> ) : ( <p className="mt-2 text-sm text-ink-muted"> - Sé el primero en comentar. + {t.noComments} </p> )} @@ -912,13 +918,13 @@ function CommentsSection({ rows={2} required maxLength={2000} - placeholder="Escribe un comentario…" - aria-label="Nuevo comentario" + placeholder={t.commentPlaceholder} + aria-label={t.newComment} className="resize-y" /> <div className="mt-2 flex justify-end"> <Button type="submit" variant="subtle"> - Comentar + {t.comment} </Button> </div> </form> diff --git a/app/components/language-picker.tsx b/app/components/language-picker.tsx new file mode 100644 index 0000000..01d2640 --- /dev/null +++ b/app/components/language-picker.tsx @@ -0,0 +1,63 @@ +import { setLocale } from "@/app/lib/actions/locale"; +import { allLanguages } from "@/app/i18n/config"; +import { getI18n } from "@/app/i18n/server"; + +// Language picker without JavaScript: a form with one submit button per language. The +// current one is marked with aria-current. New languages appear here automatically once +// they are registered in locales/index.ts. +export async function LanguagePicker({ variant = "inline" }: { variant?: "inline" | "cards" }) { + const { locale, m } = await getI18n(); + const languages = allLanguages(); + + if (variant === "cards") { + return ( + <form action={setLocale} className="grid gap-3 sm:grid-cols-3"> + {languages.map((l) => { + const current = l.code === locale; + return ( + <button + key={l.code} + type="submit" + name="locale" + value={l.code} + aria-current={current ? "true" : undefined} + lang={l.code} + className={`flex items-center justify-between rounded-xl border p-3.5 text-left text-sm font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${ + current + ? "border-accent bg-accent/5" + : "border-border bg-surface hover:bg-surface-2" + }`} + > + {l.name} + {current && ( + <svg className="size-4 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> + <path d="M5 12l5 5L20 7" /> + </svg> + )} + </button> + ); + })} + </form> + ); + } + + return ( + <form action={setLocale} aria-label={m.common.language.switchTo} className="inline-flex items-center gap-1"> + {languages.map((l, i) => ( + <span key={l.code} className="inline-flex items-center gap-1"> + {i > 0 && <span aria-hidden="true">·</span>} + <button + type="submit" + name="locale" + value={l.code} + lang={l.code} + aria-current={l.code === locale ? "true" : undefined} + className="rounded-sm underline-offset-2 transition-colors hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent aria-[current=true]:font-semibold aria-[current=true]:text-ink" + > + {l.name} + </button> + </span> + ))} + </form> + ); +} diff --git a/app/components/notifications-bell.tsx b/app/components/notifications-bell.tsx index 4c13ba1..b71adbd 100644 --- a/app/components/notifications-bell.tsx +++ b/app/components/notifications-bell.tsx @@ -16,6 +16,9 @@ import { markNotificationRead, markAllNotificationsRead, } from "@/app/lib/actions/notifications"; +import { useI18n } from "@/app/i18n/client"; +import { fmt } from "@/app/i18n/format"; +import type { Messages } from "@/app/i18n/config"; export type NotificationDTO = { id: string; @@ -28,20 +31,24 @@ export type NotificationDTO = { createdAt: string; // ISO }; -// Tiempo relativo legible en español. Depende de la hora y la zona del navegador, así que -// solo se pinta tras montar (ver useMounted): en el SSR daría otro texto y React -// avisaría de un desajuste de hidratación. -function relTime(iso: string): string { +// Readable relative time. It depends on the browser's clock and time zone, so it is only +// rendered after mounting (see useMounted): during SSR it would produce different text +// and React would warn about a hydration mismatch. +function relTime( + iso: string, + t: Messages["nav"]["notifications"]["time"], + intl: string, +): string { const then = new Date(iso).getTime(); const min = Math.floor((Date.now() - then) / 60000); - if (min < 1) return "ahora"; - if (min < 60) return `hace ${min} min`; + if (min < 1) return t.now; + if (min < 60) return fmt(t.minutes, { count: min }); const h = Math.floor(min / 60); - if (h < 24) return `hace ${h} h`; + if (h < 24) return fmt(t.hours, { count: h }); const d = Math.floor(h / 24); - if (d === 1) return "ayer"; - if (d < 7) return `hace ${d} d`; - return new Intl.DateTimeFormat("es-ES", { + if (d === 1) return t.yesterday; + if (d < 7) return fmt(t.days, { count: d }); + return new Intl.DateTimeFormat(intl, { day: "numeric", month: "short", }).format(then); @@ -49,7 +56,7 @@ function relTime(iso: string): string { const noopSubscribe = () => () => {}; -// false en el servidor y en la hidratación, true en el cliente ya montado. +// false on the server and during hydration, true once mounted on the client. function useMounted(): boolean { return useSyncExternalStore( noopSubscribe, @@ -58,10 +65,10 @@ function useMounted(): boolean { ); } -// Campana de notificaciones (Hito 8). Recibe los datos del servidor (renderizados en el -// layout) y se mantiene viva con un EventSource al stream del usuario: cuando llega una -// notificación nueva, hace router.refresh() para repintar con datos frescos. El estado -// de "abierto" es local y sobrevive al refresh. Ver docs/notifications.md. +// Notifications bell. Gets its data from the server (rendered in the layout) and stays +// live with an EventSource on the user's stream: when a new notification arrives it calls +// router.refresh() to repaint with fresh data. The "open" state is local and survives the +// refresh. Titles and bodies are stored data, shown as they are. See docs/notifications.md. export function NotificationsBell({ notifications, unreadCount, @@ -70,31 +77,33 @@ export function NotificationsBell({ unreadCount: number; }) { const router = useRouter(); + const { m, intl } = useI18n(); + const t = m.nav.notifications; const [open, setOpen] = useState(false); const [, startTransition] = useTransition(); const mounted = useMounted(); - // Entrega en vivo: refresca (con un pequeño debounce) cuando el bus avisa. El - // debounce es compartido con BoardRealtime (scheduleRefresh): en la página del tablero - // los dos streams caen y vuelven a la vez, y un solo refresh ya repinta ambos. + // Live delivery: refresh (slightly debounced) when the bus says so. The debounce is + // shared with BoardRealtime (scheduleRefresh): on the board page both streams drop and + // come back together, and a single refresh repaints both. useEffect(() => { const url = "/api/notifications/stream"; const onEvent = () => scheduleRefresh(() => router.refresh()); return keepEventSourceOpen({ create: () => new EventSource(url), events: { notification: onEvent }, - // Al reconectar, trae las notificaciones que llegaron durante el corte (el - // servidor no las reenvía). Ver app/lib/realtime/reconnect.ts. + // On reconnect, fetch the notifications that arrived during the outage (the server + // does not resend them). See app/lib/realtime/reconnect.ts. onReconnect: onEvent, - // CLOSED: se pregunta el estado al endpoint. Solo con 401 (sesión revocada) se - // refresca para que el layout redirija a /login; ante un 502 del proxy durante un - // reinicio refrescar navegaría a su página de error, así que solo se reintenta. + // CLOSED: ask the endpoint for its status. Only a 401 (revoked session) refreshes, + // so the layout redirects to /login; on a proxy 502 during a restart a refresh would + // land on its error page, so it just retries. probe: (signal) => probeStatus(url, signal), onAccessDenied: onEvent, }); }, [router]); - // Cierra con Escape (setOpen es estable, no hace falta ref). + // Close on Escape (setOpen is stable, no ref needed). useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); @@ -106,14 +115,14 @@ export function NotificationsBell({ const openItem = (n: NotificationDTO) => { setOpen(false); if (!n.read) { - // La acción llama a refresh() de next/cache: el contador se actualiza también - // cuando además se navega al tablero (antes solo se refrescaba sin tablero). + // The action calls refresh() from next/cache, so the counter also updates when we + // navigate to the board as well. startTransition(async () => { await markNotificationRead(n.id); }); } if (n.boardId) { - // Si la notificación apunta a una tarjeta, la abre directamente (deep-link ?card=). + // If the notification points to a card, open it directly (?card= deep link). const href = n.cardId ? `/board/${n.boardId}?card=${n.cardId}` : `/board/${n.boardId}`; @@ -135,9 +144,7 @@ export function NotificationsBell({ type="button" onClick={() => setOpen((v) => !v)} aria-label={ - unreadCount > 0 - ? `Notificaciones (${unreadCount} sin leer)` - : "Notificaciones" + unreadCount > 0 ? fmt(t.labelUnread, { count: unreadCount }) : t.title } aria-expanded={open} className="relative grid size-9 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent" @@ -164,7 +171,7 @@ export function NotificationsBell({ {open && ( <> - {/* Capa para cerrar al pulsar fuera. */} + {/* Backdrop that closes the panel on outside click. */} <div className="fixed inset-0 z-30" aria-hidden @@ -172,26 +179,26 @@ export function NotificationsBell({ /> <div role="dialog" - aria-label="Notificaciones" + aria-label={t.title} className="absolute right-0 top-11 z-40 w-80 max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-border bg-surface shadow-[0_24px_48px_-24px_oklch(0.27_0.022_52/0.4)]" > <div className="flex items-center justify-between border-b border-border px-4 py-3"> <h2 className="font-display text-sm font-bold tracking-tight"> - Notificaciones + {t.title} </h2> {unreadCount > 0 && ( <button onClick={markAll} className="text-xs font-medium text-accent transition-colors hover:text-accent-hover" > - Marcar todas + {t.markAll} </button> )} </div> {notifications.length === 0 ? ( <p className="px-4 py-10 text-center text-sm text-ink-muted"> - No tienes notificaciones. + {t.empty} </p> ) : ( <ul className="max-h-96 divide-y divide-border overflow-y-auto"> @@ -222,7 +229,7 @@ export function NotificationsBell({ dateTime={n.createdAt} className="mt-1 block text-[11px] text-ink-muted/80" > - {mounted ? relTime(n.createdAt) : ""} + {mounted ? relTime(n.createdAt, t.time, intl) : ""} </time> </span> </button> diff --git a/app/components/public-footer.tsx b/app/components/public-footer.tsx new file mode 100644 index 0000000..78d5333 --- /dev/null +++ b/app/components/public-footer.tsx @@ -0,0 +1,62 @@ +import Link from "next/link"; +import { + AUTHOR_NAME, + AUTHOR_URL, + LICENSE_NAME, + LICENSE_URL, + sourceCodeUrl, +} from "@/app/lib/site"; +import { getI18n } from "@/app/i18n/server"; +import { rich } from "@/app/i18n/rich"; +import { LanguagePicker } from "./language-picker"; + +// Footer of the public pages (sign-in, sign-up, setup wizard and legal pages). LICENSE +// (section 7(b) terms) requires showing "Created by Aimar" here: only the words "Created +// by" may be translated, so do not remove it in a derived version. The source code link +// fulfils section 13 of the AGPL. +export async function PublicFooter({ className = "" }: { className?: string }) { + const { m } = await getI18n(); + const t = m.legal.footer; + const linkClass = + "rounded-sm underline-offset-2 transition-colors hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"; + return ( + <footer + className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 text-xs text-ink-muted ${className}`} + > + <nav aria-label={t.navLabel} className="contents"> + <Link href="/legal/privacy" className={linkClass}> + {t.privacy} + </Link> + <Link href="/legal/terms" className={linkClass}> + {t.terms} + </Link> + <Link href="/legal/cookies" className={linkClass}> + {t.cookies} + </Link> + <Link href="/legal/notice" className={linkClass}> + {t.legalNotice} + </Link> + </nav> + <a href={sourceCodeUrl()} className={linkClass}> + {t.sourceCode} + </a> + <a href={LICENSE_URL} className={linkClass}> + {LICENSE_NAME} + </a> + <span> + {rich( + t.createdBy, + { + link: (name) => ( + <a href={AUTHOR_URL} className={linkClass}> + {name} + </a> + ), + }, + { author: AUTHOR_NAME }, + )} + </span> + <LanguagePicker /> + </footer> + ); +} diff --git a/app/components/search-box.tsx b/app/components/search-box.tsx index 3ad88ba..fc37405 100644 --- a/app/components/search-box.tsx +++ b/app/components/search-box.tsx @@ -1,6 +1,8 @@ -// Caja de búsqueda: un formulario GET que navega a /search?q=… No necesita JavaScript -// (mejora progresiva); el servidor renderiza los resultados. Ver docs/search.md. -export function SearchBox({ +import { getI18n } from "@/app/i18n/server"; + +// Search box: a GET form that navigates to /search?q=… It needs no JavaScript +// (progressive enhancement); the server renders the results. See docs/search.md. +export async function SearchBox({ defaultValue = "", autoFocus = false, size = "sm", @@ -9,6 +11,7 @@ export function SearchBox({ autoFocus?: boolean; size?: "sm" | "lg"; }) { + const { m } = await getI18n(); const big = size === "lg"; return ( <form action="/search" role="search" className="relative"> @@ -30,8 +33,8 @@ export function SearchBox({ name="q" defaultValue={defaultValue} autoFocus={autoFocus} - placeholder="Buscar tarjetas y tableros…" - aria-label="Buscar" + placeholder={m.nav.search.placeholder} + aria-label={m.nav.search.label} maxLength={120} className={`w-full rounded-lg border border-border bg-surface pl-9 pr-3 text-sm text-ink outline-none transition-colors placeholder:text-ink-muted/70 focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent/25 ${ big ? "py-2.5" : "h-9" diff --git a/app/components/theme-toggle.tsx b/app/components/theme-toggle.tsx index 353d580..ed8f3fd 100644 --- a/app/components/theme-toggle.tsx +++ b/app/components/theme-toggle.tsx @@ -1,9 +1,13 @@ "use client"; -// Alterna el tema claro/oscuro añadiendo la clase `dark` en <html> y persiste la -// elección en localStorage. No necesita estado de React: los dos iconos se muestran -// según la clase `dark` vía CSS (`dark:block` / `dark:hidden`). +import { useI18n } from "@/app/i18n/client"; + +// Toggles the light/dark theme by adding the `dark` class to <html> and saves the choice +// in localStorage. No React state needed: CSS shows one icon or the other from the `dark` +// class (`dark:block` / `dark:hidden`). export function ThemeToggle() { + const { m } = useI18n(); + function toggle() { const next = !document.documentElement.classList.contains("dark"); document.documentElement.classList.toggle("dark", next); @@ -14,7 +18,7 @@ export function ThemeToggle() { <button type="button" onClick={toggle} - aria-label="Cambiar entre tema claro y oscuro" + aria-label={m.nav.theme.toggle} className="grid size-9 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent" > <svg diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx index 73747cf..28cc9e4 100644 --- a/app/components/ui/button.tsx +++ b/app/components/ui/button.tsx @@ -1,7 +1,7 @@ import type { ComponentProps } from "react"; -// Botón con vocabulario de variantes consistente en toda la app (ver DESIGN.md). -// Sin "use client": es solo presentación, válido en componentes de servidor y cliente. +// Button with a consistent set of variants across the app (see DESIGN.md). +// No "use client": it is presentation only, valid in both Server and Client Components. const VARIANTS = { primary: "bg-accent text-accent-ink hover:bg-accent-hover", diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/fonts/CabinetGrotesk-700.woff2 b/app/fonts/CabinetGrotesk-700.woff2 deleted file mode 100644 index fc4f32d..0000000 Binary files a/app/fonts/CabinetGrotesk-700.woff2 and /dev/null differ diff --git a/app/fonts/CabinetGrotesk-800.woff2 b/app/fonts/CabinetGrotesk-800.woff2 deleted file mode 100644 index b490cff..0000000 Binary files a/app/fonts/CabinetGrotesk-800.woff2 and /dev/null differ diff --git a/app/fonts/CabinetGrotesk-900.woff2 b/app/fonts/CabinetGrotesk-900.woff2 deleted file mode 100644 index 9dcb132..0000000 Binary files a/app/fonts/CabinetGrotesk-900.woff2 and /dev/null differ diff --git a/app/fonts/Satoshi-400.woff2 b/app/fonts/Satoshi-400.woff2 deleted file mode 100644 index 81c40ab..0000000 Binary files a/app/fonts/Satoshi-400.woff2 and /dev/null differ diff --git a/app/fonts/Satoshi-500.woff2 b/app/fonts/Satoshi-500.woff2 deleted file mode 100644 index ffd0ac9..0000000 Binary files a/app/fonts/Satoshi-500.woff2 and /dev/null differ diff --git a/app/fonts/Satoshi-700.woff2 b/app/fonts/Satoshi-700.woff2 deleted file mode 100644 index 0a8db7a..0000000 Binary files a/app/fonts/Satoshi-700.woff2 and /dev/null differ diff --git a/app/fonts/Satoshi-900.woff2 b/app/fonts/Satoshi-900.woff2 deleted file mode 100644 index 64492d5..0000000 Binary files a/app/fonts/Satoshi-900.woff2 and /dev/null differ diff --git a/app/globals.css b/app/globals.css index 2d3c476..e02a6f5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,6 +1,6 @@ @import "tailwindcss"; -/* Modo oscuro por clase en <html> (toggle manual + preferencia del sistema). */ +/* Dark mode via a class on <html> (manual toggle + system preference). */ @custom-variant dark (&:where(.dark, .dark *)); @theme inline { @@ -16,18 +16,18 @@ --color-accent-ink: var(--accent-ink); --color-danger: var(--danger); --color-success: var(--success); - --font-sans: var(--font-satoshi); - --font-display: var(--font-cabinet); + --font-sans: var(--font-figtree); + --font-display: var(--font-bricolage); --font-mono: var(--font-geist-mono); } /* ─────────────────────────────────────────────────────────────────────────── - Estética: EDITORIAL CÁLIDO. Cromo en neutros cálidos (arena, papel, espresso) - con un único acento terracota. El color del contenido (tableros, etiquetas) - vive en una paleta apagada y cohesiva (app/lib/colors.ts). Tokens en OKLCH. + Look: WARM EDITORIAL. Chrome in warm neutrals (sand, paper, espresso) with a + single terracotta accent. Content colour (boards, labels) lives in a muted, + cohesive palette (app/lib/colors.ts). Tokens in OKLCH. ─────────────────────────────────────────────────────────────────────────── */ -/* Tema claro (por defecto) — papel cálido sobre arena */ +/* Light theme (default): warm paper on sand */ :root { --bg: oklch(0.965 0.013 74); --surface: oklch(0.992 0.005 80); @@ -39,13 +39,13 @@ --accent: oklch(0.49 0.13 40); --accent-hover: oklch(0.44 0.13 40); --accent-ink: oklch(0.99 0.01 80); - /* danger y success oscurecidos lo justo para 4,5:1 (WCAG AA) también sobre su propio - fondo al 10-15 % (avisos, insignia de vencida). Lo comprueba test/contrast.test.ts. */ + /* danger and success are darkened just enough for 4.5:1 (WCAG AA), also on their own + 10-15 % background (notices, overdue badge). Checked by test/contrast.test.ts. */ --danger: oklch(0.52 0.18 28); --success: oklch(0.48 0.1 145); } -/* Tema oscuro — carbón cálido (espresso), mismo acento terracota más luminoso */ +/* Dark theme: warm charcoal (espresso), same terracotta accent, brighter */ .dark { --bg: oklch(0.205 0.012 52); --surface: oklch(0.25 0.014 52); @@ -67,15 +67,15 @@ body { font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif; } -/* Titulares con la display (Cabinet Grotesk): carácter editorial, tracking ceñido. - Se aplica con la utilidad `font-display`; aquí solo afinamos el render. */ +/* Headings in the display face (Bricolage Grotesque): editorial character, tight + tracking. Applied with the `font-display` utility; this only tunes the rendering. */ .font-display { font-feature-settings: "ss01"; letter-spacing: -0.02em; } -/* Grano de papel sutil: capa fija, sin eventos de puntero y sin repintar al - hacer scroll (no se aplica a contenedores scrollables). Aporta textura cálida. */ +/* Subtle paper grain: a fixed layer, without pointer events and without repainting on + scroll (never applied to scrollable containers). Adds warm texture. */ body::before { content: ""; position: fixed; @@ -91,7 +91,7 @@ body::before { opacity: 0.18; } -/* Scrollbars discretas (las columnas del tablero scrollean a menudo). */ +/* Discreet scrollbars (board columns scroll often). */ @media (pointer: fine) { * { scrollbar-width: thin; @@ -99,7 +99,7 @@ body::before { } } -/* Respeta la preferencia de movimiento reducido en toda la app. */ +/* Honour the reduced-motion preference across the app. */ @media (prefers-reduced-motion: reduce) { *, *::before, @@ -129,3 +129,86 @@ body::before { transform: translateY(0) scale(1); } } + +/* Long-form text of the legal pages (/legal/*). No typography plugin: a few rules + wrapped in :where() (specificity 0), so any Tailwind utility on a given element wins + without fighting them. */ +.legal-prose { + font-size: 0.9375rem; + line-height: 1.7; +} +.legal-prose :where(h2) { + margin: 2.25rem 0 0.6rem; + font-family: var(--font-display); + font-size: 1.375rem; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.015em; + text-wrap: balance; +} +.legal-prose :where(p, ul, dl, .table-scroll) { + margin: 0.75rem 0; +} +.legal-prose :where(ul) { + padding-left: 1.25rem; + list-style: disc; +} +.legal-prose :where(li + li) { + margin-top: 0.35rem; +} +.legal-prose :where(a) { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} +.legal-prose :where(code) { + font-family: var(--font-mono); + font-size: 0.85em; + padding: 0.1em 0.35em; + border-radius: 0.3rem; + background: var(--surface-2); +} +.legal-prose :where(.table-scroll) { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 0.75rem; +} +.legal-prose :where(table) { + width: 100%; + min-width: 34rem; + border-collapse: collapse; + font-size: 0.875rem; + line-height: 1.5; +} +.legal-prose :where(th, td) { + padding: 0.6rem 0.85rem; + text-align: left; + vertical-align: top; +} +.legal-prose :where(th) { + background: var(--surface-2); + font-weight: 700; +} +.legal-prose :where(tr + tr td, tbody tr td) { + border-top: 1px solid var(--border); +} + +/* Account menu (native popover, app/components/account-menu.tsx). Anchored under its + button with anchor positioning; browsers without support fall back to the top right, + which is where the button is. */ +.account-menu { + position: fixed; + inset: auto; + top: 3.75rem; + right: 0.75rem; +} +@supports (position-anchor: --account-menu) { + .account-menu { + position-anchor: --account-menu; + top: calc(anchor(bottom) + 0.4rem); + right: anchor(right); + } +} +.account-menu:popover-open { + animation: dialog-in 160ms ease-out; +} diff --git a/app/i18n/client.tsx b/app/i18n/client.tsx new file mode 100644 index 0000000..127eb4b --- /dev/null +++ b/app/i18n/client.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; +import type { Messages } from "./config"; + +// Messages for Client Components. The layout (a Server Component) resolves the language +// and passes the dictionary down as plain data; nothing here imports the dictionaries, so +// the client bundle only carries the language in use. + +type ClientI18n = { locale: string; intl: string; m: Messages }; + +const I18nContext = createContext<ClientI18n | null>(null); + +export function I18nProvider({ + locale, + intl, + messages, + children, +}: { + locale: string; + intl: string; + messages: Messages; + children: ReactNode; +}) { + return ( + <I18nContext.Provider value={{ locale, intl, m: messages }}>{children}</I18nContext.Provider> + ); +} + +/** Current language and messages. Must be used inside I18nProvider. */ +export function useI18n(): ClientI18n { + const value = useContext(I18nContext); + if (!value) throw new Error("useI18n() needs an <I18nProvider> above it"); + return value; +} diff --git a/app/i18n/config.ts b/app/i18n/config.ts new file mode 100644 index 0000000..4465ec8 --- /dev/null +++ b/app/i18n/config.ts @@ -0,0 +1,92 @@ +import "server-only"; +import { LANGUAGES } from "@/locales"; + +// Server-side view of the language registry (locales/index.ts). Client Components never +// import this file: it pulls every dictionary in. They get what they need as props. + +export type Locale = keyof typeof LANGUAGES; +export const LOCALES = Object.keys(LANGUAGES) as Locale[]; +export const DEFAULT_LOCALE: Locale = "en"; + +/** Cookie that stores the chosen language. */ +export const LOCALE_COOKIE = "locale"; + +/** English messages: the reference shape and the fallback for missing translations. */ +export type Messages = (typeof LANGUAGES)["en"]["messages"]; + +export type LanguageInfo = { code: Locale; name: string; intl: string }; + +export function isLocale(value: unknown): value is Locale { + return typeof value === "string" && Object.hasOwn(LANGUAGES, value); +} + +export function languageInfo(locale: Locale): LanguageInfo { + const { name, intl } = LANGUAGES[locale].language; + return { code: locale, name, intl }; +} + +export function allLanguages(): LanguageInfo[] { + return LOCALES.map(languageInfo); +} + +/** + * Best supported language for an Accept-Language header, honouring q-values + * ("es-ES,es;q=0.9,en;q=0.8" → "es"). Falls back to DEFAULT_LOCALE. + */ +export function negotiateLocale(acceptLanguage: string | null | undefined): Locale { + if (!acceptLanguage) return DEFAULT_LOCALE; + const ranked = acceptLanguage + .split(",") + .map((part, index) => { + const [tag, ...params] = part.trim().split(";"); + const q = params.find((p) => p.trim().startsWith("q=")); + const quality = q ? Number(q.trim().slice(2)) : 1; + return { tag: tag.trim().toLowerCase(), quality, index }; + }) + .filter((r) => r.tag && Number.isFinite(r.quality) && r.quality > 0) + .sort((a, b) => b.quality - a.quality || a.index - b.index); + // Registered codes may carry a region in upper case ("pt-BR"); browsers vary. + const byLowerCase = new Map(LOCALES.map((l) => [l.toLowerCase(), l])); + for (const { tag } of ranked) { + // Exact code first ("pt-br"), then the base language ("pt"). + const exact = byLowerCase.get(tag) ?? byLowerCase.get(tag.split("-")[0]); + if (exact) return exact; + } + return DEFAULT_LOCALE; +} + +type Tree = { [key: string]: string | Tree }; + +/** `base` with every message present in `override` replaced: missing ones stay English. */ +function withFallback(base: Tree, override: unknown): Tree { + if (!override || typeof override !== "object") return base; + const out: Tree = {}; + for (const [key, value] of Object.entries(base)) { + const o = (override as Record<string, unknown>)[key]; + out[key] = + typeof value === "string" + ? typeof o === "string" && o !== "" + ? o + : value + : withFallback(value, o); + } + return out; +} + +const cache = new Map<Locale, Messages>(); + +/** Messages of a language, completed with English where a translation is missing. */ +export function messagesFor(locale: Locale): Messages { + let messages = cache.get(locale); + if (!messages) { + messages = + locale === "en" + ? LANGUAGES.en.messages + : (withFallback( + LANGUAGES.en.messages as unknown as Tree, + LANGUAGES[locale].messages, + ) as unknown as Messages); + cache.set(locale, messages); + } + return messages; +} diff --git a/app/i18n/cookie.ts b/app/i18n/cookie.ts new file mode 100644 index 0000000..eb092ed --- /dev/null +++ b/app/i18n/cookie.ts @@ -0,0 +1,20 @@ +import "server-only"; +import { cookies, headers } from "next/headers"; +import { LOCALE_COOKIE, type Locale } from "./config"; + +const ONE_YEAR_S = 365 * 24 * 60 * 60; + +/** + * Stores the interface language in its cookie. Only callable from Server Actions and Route + * Handlers (Next.js only lets those write cookies). Secure when the request came over + * HTTPS, like the session cookie, so the first run over plain http://IP:3000 still works. + */ +export async function setLocaleCookie(locale: Locale): Promise<void> { + const proto = (await headers()).get("x-forwarded-proto")?.split(",")[0]?.trim(); + (await cookies()).set(LOCALE_COOKIE, locale, { + path: "/", + maxAge: ONE_YEAR_S, + sameSite: "lax", + secure: proto?.toLowerCase() === "https", + }); +} diff --git a/app/i18n/format.ts b/app/i18n/format.ts new file mode 100644 index 0000000..af13040 --- /dev/null +++ b/app/i18n/format.ts @@ -0,0 +1,37 @@ +// Message helpers shared by server and client. Messages are plain JSON (strings and plural +// objects), so they can travel from Server to Client Components as props. + +/** A message with plural forms, picked with Intl.PluralRules ("one", "other"…). */ +export type Plural = { one: string; other: string; zero?: string; few?: string; many?: string }; + +export type Params = Record<string, string | number>; + +/** Replaces {name} placeholders. A missing parameter stays visible as {name}. */ +export function fmt(template: string, params: Params = {}): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => + key in params ? String(params[key]) : match, + ); +} + +/** + * Picks the plural form for `count` in the language `intl` (a BCP 47 tag such as "es-ES") + * and fills {count} plus any other parameters. Falls back to "other". + */ +export function plural(intl: string, forms: Plural, count: number, params: Params = {}): string { + const category = new Intl.PluralRules(intl).select(count); + const template = (forms as Record<string, string | undefined>)[category] ?? forms.other; + return fmt(template, { count, ...params }); +} + +/** + * Message at a dotted path ("errors.validation.titleRequired"), or undefined. Used for + * zod issues, whose `message` is a key so that schemas stay language-independent. + */ +export function messageAt(messages: unknown, path: string): string | undefined { + let node: unknown = messages; + for (const key of path.split(".")) { + if (!node || typeof node !== "object" || !Object.hasOwn(node, key)) return undefined; + node = (node as Record<string, unknown>)[key]; + } + return typeof node === "string" ? node : undefined; +} diff --git a/app/i18n/rich.tsx b/app/i18n/rich.tsx new file mode 100644 index 0000000..5b2a33d --- /dev/null +++ b/app/i18n/rich.tsx @@ -0,0 +1,26 @@ +import { Fragment, type ReactNode } from "react"; +import { fmt, type Params } from "./format"; + +// Messages with markup, without HTML in the translations: translators write +// "Read the <link>privacy policy</link>" and the code decides what <link> renders. +// Tags are simple names, not nested. An unknown tag is rendered as plain text. + +export type RichTags = Record<string, (chunk: string) => ReactNode>; + +export function rich(template: string, tags: RichTags, params: Params = {}): ReactNode { + const text = fmt(template, params); + const parts: ReactNode[] = []; + const re = /<(\w+)>([\s\S]*?)<\/\1>/g; + let last = 0; + let match: RegExpExecArray | null; + let i = 0; + while ((match = re.exec(text))) { + if (match.index > last) parts.push(text.slice(last, match.index)); + const [, tag, chunk] = match; + const render = tags[tag]; + parts.push(<Fragment key={i++}>{render ? render(chunk) : chunk}</Fragment>); + last = re.lastIndex; + } + if (last < text.length) parts.push(text.slice(last)); + return <>{parts}</>; +} diff --git a/app/i18n/server.ts b/app/i18n/server.ts new file mode 100644 index 0000000..a6d3916 --- /dev/null +++ b/app/i18n/server.ts @@ -0,0 +1,46 @@ +import "server-only"; +import { cache } from "react"; +import { cookies, headers } from "next/headers"; +import { + DEFAULT_LOCALE, + LOCALE_COOKIE, + isLocale, + languageInfo, + messagesFor, + negotiateLocale, + type Locale, + type Messages, +} from "./config"; +import { messageAt } from "./format"; + +/** + * Language of the current request: the `locale` cookie (set by the language picker and on + * sign-in from the account's preference), else the browser's Accept-Language, else English. + */ +export const getLocale = cache(async (): Promise<Locale> => { + const fromCookie = (await cookies()).get(LOCALE_COOKIE)?.value; + if (isLocale(fromCookie)) return fromCookie; + return negotiateLocale((await headers()).get("accept-language")); +}); + +export type I18n = { locale: Locale; intl: string; m: Messages }; + +/** Language and messages for Server Components and Server Actions. */ +export const getI18n = cache(async (): Promise<I18n> => { + const locale = await getLocale(); + return { locale, intl: languageInfo(locale).intl, m: messagesFor(locale) }; +}); + +/** Messages in a given language, for text generated for someone else (notifications). */ +export function i18nFor(locale: string | null | undefined): I18n { + const l = isLocale(locale) ? locale : DEFAULT_LOCALE; + return { locale: l, intl: languageInfo(l).intl, m: messagesFor(l) }; +} + +/** + * Text of a zod issue. Schemas use message keys ("errors.validation.titleRequired"); a + * message that is not a key (zod's own defaults) falls back to the generic error. + */ +export function issueMessage(m: Messages, issue: { message: string } | undefined): string { + return (issue && messageAt(m, issue.message)) ?? m.common.genericError; +} diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..05e9a20 --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> + <rect width="64" height="64" rx="14" fill="#9b411e"/> + <circle cx="32" cy="34" r="12.5" fill="none" stroke="#fffbf4" stroke-width="7.5"/> +</svg> diff --git a/app/layout.tsx b/app/layout.tsx index 6ca29a8..81ce997 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,49 +1,55 @@ import type { Metadata } from "next"; +import { getI18n } from "@/app/i18n/server"; +import { I18nProvider } from "@/app/i18n/client"; import { headers } from "next/headers"; -import localFont from "next/font/local"; -import { Geist_Mono } from "next/font/google"; +import { Bricolage_Grotesque, Figtree, Geist_Mono } from "next/font/google"; import "./globals.css"; -// Satoshi (Fontshare) — sans de interfaz y cuerpo. Auto-alojada en app/fonts para no -// depender de ningún CDN en runtime (instancia self-hosted). Ver DESIGN.md. -const satoshi = localFont({ - variable: "--font-satoshi", +// Every font is OFL (Google Fonts). next/font downloads them at build time and serves them +// from the app itself: nothing is requested from a CDN at runtime. The previous ones +// (Satoshi and Cabinet Grotesk, from Fontshare) forbid redistributing the files in a public +// repository, which is incompatible with publishing the code under the AGPL. See DESIGN.md. + +// Figtree: sans-serif for the interface and body text. +const figtree = Figtree({ + variable: "--font-figtree", + subsets: ["latin", "latin-ext"], display: "swap", - src: [ - { path: "./fonts/Satoshi-400.woff2", weight: "400", style: "normal" }, - { path: "./fonts/Satoshi-500.woff2", weight: "500", style: "normal" }, - { path: "./fonts/Satoshi-700.woff2", weight: "700", style: "normal" }, - { path: "./fonts/Satoshi-900.woff2", weight: "900", style: "normal" }, - ], }); -// Cabinet Grotesk (Fontshare) — display para titulares con carácter editorial. -const cabinet = localFont({ - variable: "--font-cabinet", +// Bricolage Grotesque: display face for headings and the brand, with editorial character. +const bricolage = Bricolage_Grotesque({ + variable: "--font-bricolage", + subsets: ["latin", "latin-ext"], display: "swap", - src: [ - { path: "./fonts/CabinetGrotesk-700.woff2", weight: "700", style: "normal" }, - { path: "./fonts/CabinetGrotesk-800.woff2", weight: "800", style: "normal" }, - { path: "./fonts/CabinetGrotesk-900.woff2", weight: "900", style: "normal" }, - ], }); -// Geist Mono — solo para números tabulares, metadatos y detalles técnicos. +// Geist Mono: only for tabular numbers, metadata and technical details. const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"], }); -// Cada página exporta su propio `title` y la plantilla le añade el nombre de la app, para -// que cada ruta tenga un título distinto (WCAG 2.4.2). La descripción no menciona la -// licencia: la vigente es la propietaria de LICENSE (ver docs/DECISIONES.md). -export const metadata: Metadata = { - title: { template: "%s · orgtable", default: "orgtable" }, - description: "Tableros Kanban self-hosted, una alternativa a Trello", -}; +// Each page exports its own `title` and the template appends the app name, so every route +// has a distinct title (WCAG 2.4.2). The description follows the visitor's language. +export async function generateMetadata(): Promise<Metadata> { + const { m, intl } = await getI18n(); + return { + title: { template: "%s · orgtable", default: "orgtable" }, + description: m.common.tagline, + applicationName: "orgtable", + openGraph: { + type: "website", + siteName: "orgtable", + title: "orgtable", + description: m.common.tagline, + locale: intl.replace("-", "_"), + }, + }; +} -// Se ejecuta antes del primer paint para aplicar el tema guardado (o la preferencia -// del sistema) sin parpadeo. Debe ir inline en <head>. +// Runs before the first paint to apply the saved theme (or the system preference) without +// a flash. It must be inline in <head>. const themeScript = `(function(){try{var t=localStorage.getItem('theme');var m=window.matchMedia('(prefers-color-scheme: dark)').matches;if(t==='dark'||(t!=='light'&&m)){document.documentElement.classList.add('dark')}}catch(e){}})();`; export default async function RootLayout({ @@ -51,19 +57,24 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - // Nonce de la CSP (lo genera proxy.ts). El script del tema es inline y sin él la CSP - // lo bloquearía: la página parpadearía en claro antes de pasar a oscuro. + // CSP nonce (generated by proxy.ts). The theme script is inline and the CSP would block + // it without the nonce: the page would flash light before switching to dark. const nonce = (await headers()).get("x-nonce") ?? undefined; + const { locale, intl, m } = await getI18n(); return ( <html - lang="es" + lang={locale} suppressHydrationWarning - className={`${satoshi.variable} ${cabinet.variable} ${geistMono.variable} h-full antialiased`} + className={`${figtree.variable} ${bricolage.variable} ${geistMono.variable} h-full antialiased`} > <head> <script nonce={nonce} dangerouslySetInnerHTML={{ __html: themeScript }} /> </head> - <body className="flex min-h-full flex-col bg-bg text-ink">{children}</body> + <body className="flex min-h-full flex-col bg-bg text-ink"> + <I18nProvider locale={locale} intl={intl} messages={m}> + {children} + </I18nProvider> + </body> </html> ); } diff --git a/app/lib/actions/account.ts b/app/lib/actions/account.ts new file mode 100644 index 0000000..4b21645 --- /dev/null +++ b/app/lib/actions/account.ts @@ -0,0 +1,196 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { requireUser, verifySession } from "@/app/lib/dal"; +import { + changeOwnPassword, + deleteOwnAccount, + revokeOtherSessions as revokeOtherSessionsService, + revokeOwnSession, + updateOwnProfile, +} from "@/app/lib/services/account"; +import { recordAudit } from "@/app/lib/audit"; +import { getI18n, issueMessage } from "@/app/i18n/server"; +import { plural } from "@/app/i18n/format"; +import { + changePasswordSchema, + deleteAccountSchema, + profileSchema, +} from "@/app/lib/validators"; + +// /settings actions on one's own account. The logic lives in services/account.ts; here the +// user is resolved, input validated, the event audited and the page revalidated. See +// docs/privacy.md. + +export type AccountFormState = { + error?: string; + // Field the error refers to, to mark it with aria-invalid. + field?: string; + message?: string; + values?: Record<string, string>; +}; + +function text(formData: FormData, key: string): string { + const v = formData.get(key); + return typeof v === "string" ? v : ""; +} + +/** The current session is required: the actions need its id so as not to revoke it. */ +async function requireSessionId(): Promise<string> { + const session = await verifySession(); + if (!session) redirect("/login"); + return session.sessionId; +} + +export async function updateProfile( + _prev: AccountFormState, + formData: FormData, +): Promise<AccountFormState> { + const user = await requireUser(); + const { m } = await getI18n(); + const values = { name: text(formData, "name"), email: text(formData, "email") }; + const parsed = profileSchema.safeParse({ + name: formData.get("name"), + email: formData.get("email"), + currentPassword: text(formData, "currentPassword"), + }); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + return { + error: issueMessage(m, issue), + field: String(issue?.path[0] ?? ""), + values, + }; + } + + const result = await updateOwnProfile(user.id, parsed.data); + if (!result.ok) { + if (result.error === "wrong_password") { + return { + error: m.errors.account.emailChangeNeedsPassword, + field: "currentPassword", + values, + }; + } + if (result.error === "email_taken") { + return { error: m.errors.account.emailTaken, field: "email", values }; + } + redirect("/login"); + } + + await recordAudit({ + action: "account.updated", + userId: user.id, + detail: result.emailChanged ? "name and email" : "name", + }); + revalidatePath("/", "layout"); + return { message: m.errors.account.profileSaved }; +} + +export async function changePassword( + _prev: AccountFormState, + formData: FormData, +): Promise<AccountFormState> { + const user = await requireUser(); + const sessionId = await requireSessionId(); + const { m, intl } = await getI18n(); + const parsed = changePasswordSchema.safeParse({ + currentPassword: text(formData, "currentPassword"), + newPassword: text(formData, "newPassword"), + confirmPassword: text(formData, "confirmPassword"), + }); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + return { error: issueMessage(m, issue), field: String(issue?.path[0] ?? "") }; + } + + const result = await changeOwnPassword(user.id, { + currentPassword: parsed.data.currentPassword, + newPassword: parsed.data.newPassword, + keepSessionId: sessionId, + }); + if (!result.ok) { + if (result.error === "wrong_password") { + return { error: m.errors.account.wrongCurrentPassword, field: "currentPassword" }; + } + redirect("/login"); + } + + await recordAudit({ + action: "account.password_changed", + userId: user.id, + detail: `other sessions revoked: ${result.revokedSessions}`, + }); + revalidatePath("/settings"); + return { + message: + result.revokedSessions > 0 + ? plural(intl, m.errors.account.passwordChangedSessionsClosed, result.revokedSessions) + : m.errors.account.passwordChanged, + }; +} + +/** Revokes one of one's own sessions from the list of open sessions. */ +export async function revokeSession(formData: FormData): Promise<void> { + const user = await requireUser(); + const currentId = await requireSessionId(); + const sessionId = text(formData, "sessionId"); + // The current one is closed with "Sign out", which also deletes the cookie. + if (sessionId && sessionId !== currentId) { + const count = await revokeOwnSession(user.id, sessionId); + if (count > 0) { + await recordAudit({ action: "account.sessions_revoked", userId: user.id, detail: "1" }); + } + } + revalidatePath("/settings"); + redirect("/settings#security"); +} + +export async function revokeOtherSessions(): Promise<void> { + const user = await requireUser(); + const currentId = await requireSessionId(); + const count = await revokeOtherSessionsService(user.id, currentId); + if (count > 0) { + await recordAudit({ + action: "account.sessions_revoked", + userId: user.id, + detail: String(count), + }); + } + revalidatePath("/settings"); + redirect("/settings#security"); +} + +export async function deleteAccount( + _prev: AccountFormState, + formData: FormData, +): Promise<AccountFormState> { + const user = await requireUser(); + const { m } = await getI18n(); + const parsed = deleteAccountSchema.safeParse({ password: text(formData, "password") }); + if (!parsed.success) { + return { error: issueMessage(m, parsed.error.issues[0]), field: "password" }; + } + + const result = await deleteOwnAccount(user.id, parsed.data.password); + if (!result.ok) { + if (result.error === "wrong_password") { + return { error: m.errors.account.wrongPassword, field: "password" }; + } + if (result.error === "last_admin") { + return { error: m.errors.account.lastAdmin }; + } + redirect("/login"); + } + + // The audit row has no userId: the account no longer exists and must not stay linked. + await recordAudit({ + action: "account.deleted", + detail: `boards deleted: ${result.boardsDeleted}`, + }); + // Sessions were deleted in cascade; the cookie is no longer valid, but it is removed anyway. + (await cookies()).delete("session"); + redirect("/login?account=deleted"); +} diff --git a/app/lib/actions/admin.ts b/app/lib/actions/admin.ts index cb2d2c7..c08b77f 100644 --- a/app/lib/actions/admin.ts +++ b/app/lib/actions/admin.ts @@ -11,24 +11,26 @@ import { } from "@/app/lib/services/users"; import { revokeAllUserSessions } from "@/app/lib/session"; import { recordAudit } from "@/app/lib/audit"; +import { getI18n, issueMessage } from "@/app/i18n/server"; +import { fmt } from "@/app/i18n/format"; import { createUserSchema, globalRoleSchema, resetPasswordSchema, } from "@/app/lib/validators"; -// Administración de usuarios de la instancia (Hito 3). Solo el ADMIN global puede -// crear cuentas, cambiar el rol global, restablecer contraseñas o borrar usuarios. -// No hay registro abierto: el administrador da de alta a las personas. Ver -// docs/authorization.md y docs/admin.md. +// User administration of the instance. Only the global ADMIN can create accounts, change +// the global role, reset passwords or delete users. Accounts are created here unless the +// instance has open registration (actions/register.ts). See docs/authorization.md and +// docs/admin.md. export type CreateUserState = { error?: string; - // Campo al que se refiere el error, para marcarlo (aria-invalid) en el formulario. + // Field the error refers to, to mark it (aria-invalid) in the form. field?: "email"; message?: string; - // Lo tecleado, para rellenar de nuevo el formulario tras un error. NUNCA la - // contraseña: no debe viajar de vuelta al navegador. + // What was typed, to refill the form after an error. NEVER the password: it must not + // travel back to the browser. values?: { name: string; email: string; role: string }; }; @@ -37,12 +39,13 @@ function formText(formData: FormData, key: string): string { return typeof v === "string" ? v : ""; } -/** Da de alta una cuenta de usuario. Devuelve estado para useActionState. */ +/** Creates a user account. Returns state for useActionState. */ export async function createUser( _prev: CreateUserState, formData: FormData, ): Promise<CreateUserState> { const admin = await requireAdmin(); + const { m } = await getI18n(); const values = { name: formText(formData, "name"), email: formText(formData, "email"), @@ -57,16 +60,16 @@ export async function createUser( }); if (!parsed.success) { return { - error: parsed.error.issues[0]?.message ?? "Datos no válidos.", + error: issueMessage(m, parsed.error.issues[0]), values, }; } - // La lógica de creación (normalizar email, rechazar duplicados, hashear) vive en el - // servicio reutilizable y testeable; aquí solo autorizamos y auditamos. + // The creation logic (normalise the email, reject duplicates, hash) lives in the reusable, + // testable service; here we only authorise and audit. const result = await createUserAccount(parsed.data); if (!result.ok) { - return { error: "Ya existe un usuario con ese email.", field: "email", values }; + return { error: m.errors.admin.emailTaken, field: "email", values }; } await recordAudit({ action: "user.created", @@ -76,35 +79,35 @@ export async function createUser( detail: `${result.user.email} (${parsed.data.role})`, }); revalidatePath("/admin/users"); - return { message: `Cuenta creada para ${parsed.data.name}.` }; + return { message: fmt(m.errors.admin.userCreated, { name: parsed.data.name }) }; } -// Las acciones por fila (cambiar rol, restablecer contraseña, borrar) devuelven el -// resultado mediante una redirección con query (?ok=… / ?error=…) que la página -// /admin/users traduce a un mensaje. Así evitamos un componente cliente por fila. +// Per-row actions (change role, reset password, delete) return their result through a +// redirect with a query (?ok=… / ?error=…) that the /admin/users page turns into a +// message. That avoids a client component per row. function backToUsers(params: Record<string, string>): never { const qs = new URLSearchParams(params).toString(); redirect(`/admin/users${qs ? `?${qs}` : ""}`); } -// Id del usuario objetivo: `String(null)` daría "null" y la acción seguiría adelante. +// Target user id: `String(null)` would give "null" and the action would carry on. function readUserId(formData: FormData): string { const userId = formData.get("userId"); if (typeof userId !== "string" || userId === "") backToUsers({ error: "invalid" }); return userId; } -/** Cambia el rol global (ADMIN/MEMBER). No puedes cambiar tu propio rol. */ +/** Changes the global role (ADMIN/MEMBER). You cannot change your own role. */ export async function updateUserRole(formData: FormData): Promise<void> { const admin = await requireAdmin(); const userId = readUserId(formData); const parsed = globalRoleSchema.safeParse(formData.get("role")); if (!parsed.success) backToUsers({ error: "invalid" }); - // No puedes degradarte a ti mismo (evita quedarte sin acceso de admin). + // You cannot demote yourself (avoids losing admin access). if (userId === admin.id) backToUsers({ error: "self-role" }); - // Se mira el count antes de auditar: con un id inexistente no hay nada que registrar. + // The count is checked before auditing: with an unknown id there is nothing to record. const { count } = await prisma.user.updateMany({ where: { id: userId }, data: { role: parsed.data }, @@ -121,11 +124,11 @@ export async function updateUserRole(formData: FormData): Promise<void> { backToUsers({ ok: "role" }); } -/** Restablece la contraseña de un usuario (el admin teclea la nueva). */ +/** Resets a user's password (the admin types the new one). */ export async function resetUserPassword(formData: FormData): Promise<void> { const admin = await requireAdmin(); - // El userId se valida antes: si falta, el error del esquema sería el de la - // contraseña y el aviso («al menos 8 caracteres») no describiría el problema. + // The userId is validated first: if it were missing, the schema error would be the + // password one and the message ("at least 8 characters") would not describe the problem. const userId = readUserId(formData); const parsed = resetPasswordSchema.safeParse({ userId, @@ -133,9 +136,9 @@ export async function resetUserPassword(formData: FormData): Promise<void> { }); if (!parsed.success) backToUsers({ error: "password" }); - // Al cambiar la contraseña se retira todo acceso vigente del usuario: sus sesiones - // (navegador) y sus tokens de API, que no dependen de la sesión. Si la cuenta estaba - // comprometida, un token creado por el atacante seguiría abriendo /api/v1. + // Changing the password withdraws all of the user's current access: their sessions + // (browser) and their API tokens, which do not depend on the session. If the account was + // compromised, a token created by the attacker would keep opening /api/v1. const result = await resetPasswordAndRevokeAccess( parsed.data.userId, parsed.data.password, @@ -146,17 +149,17 @@ export async function resetUserPassword(formData: FormData): Promise<void> { userId: admin.id, targetType: "user", targetId: parsed.data.userId, - detail: `tokens de API revocados: ${result.revokedTokens}`, + detail: `API tokens revoked: ${result.revokedTokens}`, }); revalidatePath("/admin/users"); backToUsers({ ok: "password" }); } -/** Cierra todas las sesiones activas de un usuario (revocación inmediata). */ +/** Revokes all of a user's active sessions (immediate revocation). */ export async function revokeUserSessions(formData: FormData): Promise<void> { const admin = await requireAdmin(); const userId = readUserId(formData); - // Tener 0 sesiones activas es válido, así que la existencia se comprueba aparte. + // Having 0 active sessions is valid, so existence is checked separately. const exists = await prisma.user.findUnique({ where: { id: userId }, select: { id: true }, @@ -174,9 +177,9 @@ export async function revokeUserSessions(formData: FormData): Promise<void> { } /** - * Borra una cuenta. No puedes borrarte a ti mismo. Si el usuario es propietario de - * algún tablero, se bloquea: borrarlo arrastraría sus tableros en cascada. El admin - * debe transferir o borrar esos tableros primero. + * Deletes an account. You cannot delete yourself. If the user owns any board, it is + * refused: deleting them would cascade to their boards. The admin must transfer or delete + * those boards first. */ export async function deleteUser(formData: FormData): Promise<void> { const admin = await requireAdmin(); @@ -184,8 +187,8 @@ export async function deleteUser(formData: FormData): Promise<void> { if (userId === admin.id) backToUsers({ error: "self-delete" }); - // La comprobación de tableros propios va dentro del DELETE (ver el servicio): así la - // cascada nunca borra filas Attachment dejando sus ficheros huérfanos en disco. + // The owned-boards check is part of the DELETE itself (see the service): that way the + // cascade never deletes Attachment rows leaving their files orphaned on disk. const result = await deleteUserAccount(userId); if (!result.ok) { backToUsers({ diff --git a/app/lib/actions/assignees.ts b/app/lib/actions/assignees.ts index ba674ae..3ea22bb 100644 --- a/app/lib/actions/assignees.ts +++ b/app/lib/actions/assignees.ts @@ -6,11 +6,12 @@ import { requireBoardAccess } from "@/app/lib/dal"; import { canEditBoard } from "@/app/lib/roles"; import { cardContext } from "@/app/lib/services/cards"; import { assignMemberToCard } from "@/app/lib/services/assignees"; -import { notify } from "@/app/lib/notifications"; +import { notify, recipientI18n } from "@/app/lib/notifications"; +import { fmt } from "@/app/i18n/format"; -// Asignar/quitar miembros de una tarjeta (Hito 10). Requiere permiso de edición. Solo se -// puede asignar a personas que YA son miembros del tablero (el servidor lo verifica). Ver -// docs/card-collaboration.md y docs/authorization.md. +// Assign/unassign members on a card. Requires edit permission. Only people who ALREADY are +// board members can be assigned (the server checks it). See docs/card-collaboration.md and +// docs/authorization.md. export async function assignMember(formData: FormData): Promise<void> { const cardId = String(formData.get("cardId")); @@ -20,17 +21,18 @@ export async function assignMember(formData: FormData): Promise<void> { const { user, role } = await requireBoardAccess(ctx.boardId); if (!canEditBoard(role)) return; - // La invariante (debe ser miembro) + idempotencia viven en el servicio testeable. + // The invariant (must be a member) + idempotency live in the testable service. const result = await assignMemberToCard(ctx.boardId, cardId, userId); if (!result.assigned) return; - // Notifica al asignado, salvo que se asigne a sí mismo. + // Notify the assignee, in their language, unless they assigned themselves. if (userId !== user.id) { + const { m } = await recipientI18n(userId); await notify({ userId, type: "card.assigned", - title: `Te han asignado «${ctx.cardTitle}»`, - body: `En el tablero «${ctx.boardTitle}».`, + title: fmt(m.notifications.cardAssigned.title, { card: ctx.cardTitle }), + body: fmt(m.notifications.cardAssigned.body, { board: ctx.boardTitle }), boardId: ctx.boardId, cardId, }); diff --git a/app/lib/actions/attachments.ts b/app/lib/actions/attachments.ts index 9f4c771..a39b9dd 100644 --- a/app/lib/actions/attachments.ts +++ b/app/lib/actions/attachments.ts @@ -9,9 +9,11 @@ import { deleteAttachmentFile, MAX_ATTACHMENT_BYTES, } from "@/app/lib/storage"; +import { getI18n } from "@/app/i18n/server"; +import { fmt } from "@/app/i18n/format"; -// Adjuntos de tarjeta (Hito 5). Subir/borrar requiere rol EDITOR; la descarga la sirve -// el route handler app/api/attachments/[id] con su propia autorización. Ver +// Card attachments. Uploading/deleting requires the EDITOR role; downloads are served by the +// route handler app/api/attachments/[id] with its own authorisation. See // docs/attachments.md. export type AttachmentState = { error?: string }; @@ -28,18 +30,23 @@ export async function uploadAttachment( _prev: AttachmentState, formData: FormData, ): Promise<AttachmentState> { + const { m } = await getI18n(); const cardId = String(formData.get("cardId")); const boardId = await boardIdForCard(cardId); - if (!boardId) return { error: "Tarjeta no encontrada." }; + if (!boardId) return { error: m.errors.attachments.cardNotFound }; const { role } = await requireBoardAccess(boardId); - if (!canEditBoard(role)) return { error: "No tienes permiso para adjuntar." }; + if (!canEditBoard(role)) return { error: m.errors.attachments.noPermission }; const file = formData.get("file"); if (!(file instanceof File) || file.size === 0) { - return { error: "Selecciona un archivo." }; + return { error: m.errors.attachments.noFile }; } if (file.size > MAX_ATTACHMENT_BYTES) { - return { error: "El archivo supera el límite de 10 MB." }; + return { + error: fmt(m.errors.attachments.tooLarge, { + megabytes: MAX_ATTACHMENT_BYTES / (1024 * 1024), + }), + }; } const { storedName, size } = await saveAttachmentFile(file); @@ -47,15 +54,16 @@ export async function uploadAttachment( await prisma.attachment.create({ data: { cardId, - filename: file.name.slice(0, 255) || "archivo", + filename: file.name.slice(0, 255) || "file", storedName, mimeType: (file.type || "application/octet-stream").slice(0, 128), size, }, }); } catch (err) { - // Sin fila no hay forma de llegar al fichero (p. ej. la tarjeta se borró mientras se - // subía): se borra para no dejar un huérfano en disco y se relanza el error original. + // Without a row there is no way to reach the file (e.g. the card was deleted during the + // upload): it is deleted so as not to leave an orphan on disk, and the original error is + // rethrown. await deleteAttachmentFile(storedName); throw err; } diff --git a/app/lib/actions/auth.ts b/app/lib/actions/auth.ts index 26cabb2..ac03678 100644 --- a/app/lib/actions/auth.ts +++ b/app/lib/actions/auth.ts @@ -1,6 +1,8 @@ "use server"; import { redirect } from "next/navigation"; +import { isLocale } from "@/app/i18n/config"; +import { setLocaleCookie } from "@/app/i18n/cookie"; import { prisma } from "@/app/lib/prisma"; import { verifyPasswordOrDummy } from "@/app/lib/auth"; import { createSession, deleteSession } from "@/app/lib/session"; @@ -8,31 +10,35 @@ import { verifySession } from "@/app/lib/dal"; import { loginSchema } from "@/app/lib/validators"; import { rateLimit, resetRateLimit } from "@/app/lib/rate-limit"; import { recordAudit, clientIp } from "@/app/lib/audit"; +import { getI18n } from "@/app/i18n/server"; +import { plural } from "@/app/i18n/format"; -// `values` devuelve lo tecleado (nunca la contraseña) para repintar el formulario: React -// 19 resetea los formularios no controlados tras la acción y, sin esto, un error borraba -// el email. +// `values` returns what was typed (never the password) to repaint the form: React 19 +// resets uncontrolled forms after the action and, without this, an error cleared the email. export type AuthState = { error?: string; values?: { email: string } }; -// Hasta 10 intentos de login por IP cada 10 minutos (anti fuerza bruta). Solo se aplica -// si hay IP fiable (TRUSTED_PROXY_HOPS > 0, ver app/lib/client-ip.ts); sin ella no se -// agrupa a todos en un bucket común, que dejaría el login bloqueado para todo el mundo. +// Up to 10 login attempts per IP every 10 minutes (anti brute force). Only applied when +// there is a trustworthy IP (TRUSTED_PROXY_HOPS > 0, see app/lib/client-ip.ts); without one, +// everybody is not lumped into a shared bucket, which would lock the login for everyone. const LOGIN_LIMIT = 10; const LOGIN_WINDOW_MS = 10 * 60 * 1000; -// Límite por cuenta, independiente de la IP: frena los ataques repartidos entre muchas -// IPs. Cuenta los intentos fallidos (también para emails que no existen, para no revelar -// cuáles existen) y se reinicia tras un login correcto. Riesgo aceptado: quien conozca un -// email puede bloquear esa cuenta hasta 15 minutos. Ver docs/decisions/0009. +// Per-account limit, independent of the IP: slows down attacks spread over many IPs. It +// counts failed attempts (also for emails that do not exist, so as not to reveal which do) +// and is reset after a successful login. Accepted risk: whoever knows an email can lock +// that account for up to 15 minutes. See docs/decisions/0009. const EMAIL_LIMIT = 20; const EMAIL_WINDOW_MS = 15 * 60 * 1000; -function tooManyAttempts( +async function tooManyAttempts( retryAfterSec: number, values: AuthState["values"], -): AuthState { +): Promise<AuthState> { + const { m, intl } = await getI18n(); return { - error: `Demasiados intentos. Inténtalo de nuevo en ${retryAfterSec} s.`, + error: plural(intl, m.errors.auth.tooManyAttempts, retryAfterSec, { + seconds: retryAfterSec, + }), values, }; } @@ -48,10 +54,10 @@ export async function login( if (ip) { const limit = rateLimit(`login:${ip}`, LOGIN_LIMIT, LOGIN_WINDOW_MS); if (!limit.ok) { - // Una fila por ventana bloqueada, no una por intento: un ataque sostenido no debe - // llenar AuditLog justo cuando el límite lo está conteniendo. + // One row per blocked window, not one per attempt: a sustained attack must not fill + // AuditLog precisely while the limit is holding it back. if (limit.firstBlock) { - await recordAudit({ action: "auth.login_rate_limited", ip, detail: "límite por IP" }); + await recordAudit({ action: "auth.login_rate_limited", ip, detail: "IP limit" }); } return tooManyAttempts(limit.retryAfterSec, values); } @@ -62,12 +68,12 @@ export async function login( password: formData.get("password"), }); if (!parsed.success) { - return { error: "Revisa el email y la contraseña.", values }; + return { error: (await getI18n()).m.errors.auth.checkCredentials, values }; } const email = parsed.data.email.toLowerCase(); - // Se cuenta ANTES de verificar (sin carreras entre peticiones simultáneas) y se borra - // si el login es correcto, así que en la práctica solo suman los fallos. + // Counted BEFORE verifying (no races between simultaneous requests) and cleared if the + // login succeeds, so in practice only failures add up. const emailKey = `login-email:${email}`; const emailLimit = rateLimit(emailKey, EMAIL_LIMIT, EMAIL_WINDOW_MS); if (!emailLimit.ok) { @@ -75,34 +81,38 @@ export async function login( await recordAudit({ action: "auth.login_rate_limited", ip, - detail: "límite por cuenta", + detail: "account limit", }); } return tooManyAttempts(emailLimit.retryAfterSec, values); } const user = await prisma.user.findUnique({ where: { email } }); - // Si el usuario no existe se verifica contra un hash ficticio del mismo coste, para no - // filtrar por tiempo si un email está registrado. + // If the user does not exist, a dummy hash of the same cost is verified, so as not to leak + // through timing whether an email is registered. const valid = await verifyPasswordOrDummy( parsed.data.password, user?.passwordHash ?? null, ); if (!user || !valid) { - // Sin el email tecleado: podría ser de un tercero (o una contraseña pegada en el - // campo equivocado) y acabaría visible en /admin/audit. Si la cuenta existe basta - // con su userId. + // Without the typed email: it could belong to someone else (or be a password pasted + // into the wrong field) and would end up visible in /admin/audit. If the account exists, + // its userId is enough. await recordAudit({ action: "auth.login_failed", userId: user?.id ?? null, ip, }); - return { error: "Credenciales incorrectas.", values }; + return { error: (await getI18n()).m.errors.auth.invalidCredentials, values }; } resetRateLimit(emailKey); await createSession(user.id); + // The account's language wins over the browser's on any device it signs in from. + if (isLocale(user.locale)) { + await setLocaleCookie(user.locale); + } await recordAudit({ action: "auth.login", userId: user.id, ip }); redirect("/boards"); } diff --git a/app/lib/actions/boards.ts b/app/lib/actions/boards.ts index 1150026..2bc5ffe 100644 --- a/app/lib/actions/boards.ts +++ b/app/lib/actions/boards.ts @@ -10,14 +10,18 @@ import { recordAudit } from "@/app/lib/audit"; import { boardTitleSchema, boardImportSchema } from "@/app/lib/validators"; import { positionAtEnd } from "@/app/lib/ordering"; import { deleteBoardWithAttachments } from "@/app/lib/services/attachments"; +import { getI18n } from "@/app/i18n/server"; +import { fmt } from "@/app/i18n/format"; + +const IMPORT_MAX_MB = 5; export async function createBoard(formData: FormData): Promise<void> { const user = await requireUser(); const parsed = boardTitleSchema.safeParse(formData.get("title")); if (!parsed.success) return; - // El creador queda como dueño y, a la vez, como miembro OWNER del tablero (la - // autorización siempre se comprueba contra board_members). + // The creator is the owner and, at the same time, an OWNER member of the board + // (authorisation is always checked against board_members). const board = await prisma.board.create({ data: { title: parsed.data, @@ -53,37 +57,38 @@ export async function renameBoard(formData: FormData): Promise<void> { export type ImportState = { error?: string }; /** - * Importa un tablero desde el JSON que produce GET /api/boards/[id]/export. Crea un - * tablero NUEVO del que el importador es propietario, regenerando ids y posiciones. No - * incluye adjuntos (sus binarios no viajan en el export). Ver docs/operations.md. + * Imports a board from the JSON produced by GET /api/boards/[id]/export. Creates a NEW board + * owned by the importer, regenerating ids and positions. Attachments are not included + * (their binaries do not travel in the export). See docs/operations.md. */ export async function importBoard( _prev: ImportState, formData: FormData, ): Promise<ImportState> { const user = await requireUser(); + const { m } = await getI18n(); const file = formData.get("file"); if (!(file instanceof File) || file.size === 0) { - return { error: "Selecciona un archivo JSON." }; + return { error: m.errors.boards.importNoFile }; } - if (file.size > 5 * 1024 * 1024) { - return { error: "El archivo es demasiado grande (máx. 5 MB)." }; + if (file.size > IMPORT_MAX_MB * 1024 * 1024) { + return { error: fmt(m.errors.boards.importTooLarge, { megabytes: IMPORT_MAX_MB }) }; } let raw: unknown; try { raw = JSON.parse(await file.text()); } catch { - return { error: "El archivo no es JSON válido." }; + return { error: m.errors.boards.importInvalidJson }; } const parsed = boardImportSchema.safeParse(raw); if (!parsed.success) { - return { error: "El archivo no tiene el formato de un tablero de orgtable." }; + return { error: m.errors.boards.importInvalidFormat }; } const b = parsed.data.board; - // Colores ya saneados por boardImportSchema: fuera de la paleta → null / "blue". + // Colours already sanitised by boardImportSchema: outside the palette → null / "blue". const background = b.background ?? null; let newBoardId = ""; @@ -99,7 +104,7 @@ export async function importBoard( }); newBoardId = board.id; - // Etiquetas: una por nombre (se ignoran duplicados); color saneado en el esquema. + // Labels: one per name (duplicates are ignored); colour sanitised by the schema. const labelIdByName = new Map<string, string>(); for (const lab of b.labels ?? []) { if (labelIdByName.has(lab.name)) continue; @@ -130,7 +135,7 @@ export async function importBoard( }, }); - // Re-vincula etiquetas por nombre (sin duplicar). + // Re-link labels by name (without duplicates). const seenLabels = new Set<string>(); for (const name of card.labels ?? []) { const labelId = labelIdByName.get(name); @@ -184,8 +189,8 @@ export async function importBoard( export async function deleteBoard(formData: FormData): Promise<void> { const user = await requireUser(); const boardId = String(formData.get("boardId")); - // Solo el dueño puede borrar el tablero (deleteMany no falla si no coincide). El - // servicio borra también los ficheros de los adjuntos. + // Only the owner can delete the board (deleteMany does not fail if nothing matches). The + // service also deletes the attachment files. const count = await deleteBoardWithAttachments({ id: boardId, ownerId: user.id }); if (count > 0) { await recordAudit({ @@ -194,7 +199,7 @@ export async function deleteBoard(formData: FormData): Promise<void> { targetType: "board", targetId: boardId, }); - // Avisa a las pestañas abiertas del tablero (SSE) para que dejen de mostrarlo. + // Tell the board's open tabs (SSE) so that they stop showing it. revalidateBoard(boardId); } revalidatePath("/boards"); diff --git a/app/lib/actions/cards.ts b/app/lib/actions/cards.ts index 9c9232c..82071a4 100644 --- a/app/lib/actions/cards.ts +++ b/app/lib/actions/cards.ts @@ -14,27 +14,29 @@ import { import { cardBelongsToBoard, createCardAtEnd } from "@/app/lib/services/cards"; import { deleteCardWithAttachments } from "@/app/lib/services/attachments"; import { isValidPositionKey } from "@/app/lib/ordering"; +import { getI18n } from "@/app/i18n/server"; +import { fmt } from "@/app/i18n/format"; const setCardDueDateSchema = z.object({ cardId: z.string().min(1), dueDate: dueDateSchema, }); -// La posición llega del cliente (drag & drop): se valida antes de cualquier consulta, -// porque una clave inválida guardada haría fallar para siempre las altas en esa lista. +// The position comes from the client (drag & drop): it is validated before any query, +// because an invalid key once stored would make every new card in that list fail forever. const moveCardSchema = z.object({ cardId: z.string().min(1), toListId: z.string().min(1), position: z.string().refine(isValidPositionKey), }); -/** "YYYY-MM-DD" → medianoche UTC (fecha de calendario); vacío o null → sin fecha. */ +/** "YYYY-MM-DD" → midnight UTC (calendar date); empty or null → no date. */ function toDueDate(value: string | null): Date | null { return value ? new Date(value) : null; } -// Helper: dado un listId, devuelve el boardId al que pertenece (para autorizar y -// revalidar). Devuelve null si la lista no existe. +// Given a listId, returns the boardId it belongs to (to authorise and revalidate). Returns +// null if the list does not exist. async function boardIdForList(listId: string): Promise<string | null> { const list = await prisma.list.findUnique({ where: { id: listId }, @@ -57,9 +59,9 @@ export async function createCard(formData: FormData): Promise<void> { } /** - * Resultado de updateCard para useActionState. Si no se guarda, devuelve también lo que - * envió el usuario: React resetea el formulario no controlado al acabar la acción y, sin - * rehidratarlo con esto, el texto escrito se perdería. + * Result of updateCard for useActionState. When nothing is saved, it also returns what the + * user sent: React resets the uncontrolled form when the action ends and, without + * rehydrating it with this, the typed text would be lost. */ export type CardDetailsState = { error?: string; @@ -67,15 +69,16 @@ export type CardDetailsState = { dueDate?: string; }; -/** Actualiza la descripción y/o la fecha de vencimiento de una tarjeta (Hito 2). */ +/** Updates a card's description and/or due date. */ export async function updateCard( _prev: CardDetailsState, formData: FormData, ): Promise<CardDetailsState> { + const { m } = await getI18n(); const cardId = String(formData.get("cardId")); - // El navegador envía los saltos de línea del textarea como \r\n, pero su maxLength los - // cuenta como un carácter: sin normalizar, una descripción que en la UI mide 5000 - // llegaría con más y se rechazaría. + // The browser sends the textarea's line breaks as \r\n, but its maxLength counts them as + // one character: without normalising, a description that measures 5000 in the UI would + // arrive longer and be rejected. const description = normalizeNewlines(String(formData.get("description") ?? "")); const dueDate = String(formData.get("dueDate") ?? "").trim(); const submitted = { description, dueDate }; @@ -84,22 +87,22 @@ export async function updateCard( where: { id: cardId }, select: { description: true, list: { select: { boardId: true } } }, }); - if (!card) return { error: "La tarjeta ya no existe.", ...submitted }; + if (!card) return { error: m.errors.cards.notFound, ...submitted }; const { role } = await requireBoardAccess(card.list.boardId); if (!canEditBoard(role)) { - return { error: "No tienes permiso para editar esta tarjeta.", ...submitted }; + return { error: m.errors.cards.noPermission, ...submitted }; } - // Si algo no valida no se guarda NADA (ni la descripción): guardar solo una parte - // perdería en silencio lo que el usuario escribió en la otra. + // If anything fails validation NOTHING is saved (not even the description): saving only + // one part would silently lose what the user wrote in the other. const parsedDue = dueDateSchema.safeParse(dueDate); if (!parsedDue.success) { - return { error: "La fecha de vencimiento no es válida.", ...submitted }; + return { error: m.errors.cards.invalidDueDate, ...submitted }; } - // Una descripción que no ha cambiado no se valida ni se reescribe: las anteriores al - // límite pueden pasar de CARD_DESCRIPTION_MAX y, si no, ya no se podría cambiar el - // vencimiento de esa tarjeta desde el modal (el formulario reenvía la descripción). + // An unchanged description is neither validated nor rewritten: those older than the limit + // may exceed CARD_DESCRIPTION_MAX and, otherwise, that card's due date could no longer be + // changed from the modal (the form resends the description). const unchanged = description.trim() === normalizeNewlines(card.description ?? "").trim(); let newDescription: { description: string | null } | undefined; @@ -107,7 +110,10 @@ export async function updateCard( const parsedDescription = cardDetailsSchema.shape.description.safeParse(description); if (!parsedDescription.success) { return { - error: `La descripción no puede superar ${CARD_DESCRIPTION_MAX} caracteres (tiene ${description.trim().length}).`, + error: fmt(m.errors.cards.descriptionTooLong, { + max: CARD_DESCRIPTION_MAX, + length: description.trim().length, + }), ...submitted, }; } @@ -127,8 +133,8 @@ function normalizeNewlines(text: string): string { } /** - * Cambia SOLO la fecha de vencimiento (sin tocar la descripción). La usa el arrastre en - * la vista de calendario (Hito 11). `dueDate` es "YYYY-MM-DD" o null para quitarla. + * Changes ONLY the due date (without touching the description). Used by dragging in the + * calendar view. `dueDate` is "YYYY-MM-DD", or null to clear it. */ export async function setCardDueDate(input: { cardId: string; @@ -184,9 +190,8 @@ export async function deleteCard(formData: FormData): Promise<void> { } /** - * Persiste el movimiento de una tarjeta tras drag & drop: puede cambiar de lista y/o - * de posición. El cliente calcula la nueva clave de posición (fractional indexing). - * Un único UPDATE de la fila movida. + * Persists a card move after drag & drop: it may change list and/or position. The client + * computes the new position key (fractional indexing). A single UPDATE of the moved row. */ export async function moveCard(input: { cardId: string; @@ -201,10 +206,10 @@ export async function moveCard(input: { const { role } = await requireBoardAccess(boardId); if (!canEditBoard(role)) return { ok: false }; - // Seguridad (IDOR): la tarjeta debe pertenecer YA al MISMO tablero de destino. Sin esta - // comprobación, un editor podría mover a su tablero una tarjeta de OTRO tablero al que no - // tiene acceso, conociendo solo su id. No se mueven tarjetas entre tableros (la UI tampoco - // lo permite: el arrastre es dentro de un único tablero). + // Security (IDOR): the card must ALREADY belong to the SAME destination board. Without + // this check, an editor could move into their board a card from ANOTHER board they have + // no access to, knowing only its id. Cards are not moved between boards (the UI does not + // allow it either: dragging happens within a single board). if (!(await cardBelongsToBoard(cardId, boardId))) return { ok: false }; await prisma.card.update({ diff --git a/app/lib/actions/checklists.ts b/app/lib/actions/checklists.ts index 7c511f0..bf623d4 100644 --- a/app/lib/actions/checklists.ts +++ b/app/lib/actions/checklists.ts @@ -74,7 +74,7 @@ export async function addChecklistItem(formData: FormData): Promise<void> { revalidateBoard(boardId); } -/** Marca/desmarca un ítem de checklist. */ +/** Checks/unchecks a checklist item. */ export async function toggleChecklistItem(input: { itemId: string; done: boolean; diff --git a/app/lib/actions/comments.ts b/app/lib/actions/comments.ts index 5c41ab7..98a3203 100644 --- a/app/lib/actions/comments.ts +++ b/app/lib/actions/comments.ts @@ -7,10 +7,11 @@ import { canEditBoard, canManageBoard } from "@/app/lib/roles"; import { cardContext } from "@/app/lib/services/cards"; import { commentTextSchema } from "@/app/lib/validators"; import { notify } from "@/app/lib/notifications"; +import { i18nFor } from "@/app/i18n/server"; +import { fmt } from "@/app/i18n/format"; -// Comentarios de tarjeta (Hito 10). Comentar requiere permiso de edición (los lectores -// no comentan). Borrar lo puede hacer el autor o el propietario del tablero. Ver -// docs/card-collaboration.md. +// Card comments. Commenting requires edit permission (viewers do not comment). The author +// or the board owner can delete a comment. See docs/card-collaboration.md. export async function addComment(formData: FormData): Promise<void> { const cardId = String(formData.get("cardId")); @@ -26,16 +27,16 @@ export async function addComment(formData: FormData): Promise<void> { data: { cardId, authorId: user.id, text: parsed.data }, }); - // Avisa a los asignados de la tarjeta (menos al propio autor) que sigan siendo miembros - // del tablero. Expulsar ya borra sus asignaciones (services/members.ts), pero pueden - // quedar filas huérfanas de antes de ese cambio y no deben recibir el texto del - // comentario. + // Notify the card's assignees (except the author) who are still board members, each in + // their own language. Removing a member already deletes their assignments + // (services/members.ts), but orphan rows from before that change may remain and must not + // receive the comment's text. const assignees = await prisma.cardAssignee.findMany({ where: { cardId, user: { memberships: { some: { boardId: ctx.boardId } } }, }, - select: { userId: true }, + select: { userId: true, user: { select: { locale: true } } }, }); await Promise.all( assignees @@ -44,7 +45,9 @@ export async function addComment(formData: FormData): Promise<void> { notify({ userId: a.userId, type: "comment.added", - title: `Nuevo comentario en «${ctx.cardTitle}»`, + title: fmt(i18nFor(a.user.locale).m.notifications.commentAdded.title, { + card: ctx.cardTitle, + }), body: parsed.data.slice(0, 140), boardId: ctx.boardId, cardId, @@ -66,7 +69,7 @@ export async function deleteComment(formData: FormData): Promise<void> { if (!comment) return; const boardId = comment.card.list.boardId; const { user, role } = await requireBoardAccess(boardId); - // Solo el autor o el propietario del tablero. + // Only the author or the board owner. if (comment.authorId !== user.id && !canManageBoard(role)) return; await prisma.comment.delete({ where: { id: commentId } }); diff --git a/app/lib/actions/instance.ts b/app/lib/actions/instance.ts new file mode 100644 index 0000000..bd5fd51 --- /dev/null +++ b/app/lib/actions/instance.ts @@ -0,0 +1,75 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireAdmin } from "@/app/lib/dal"; +import { updateInstanceSettings } from "@/app/lib/services/instance"; +import { instanceSettingsSchema } from "@/app/lib/validators"; +import { recordAudit } from "@/app/lib/audit"; +import { getI18n, issueMessage } from "@/app/i18n/server"; + +// Instance settings (/admin/instance): name, open registration and the operator's legal +// details. Global ADMIN only. See docs/privacy.md. + +export type InstanceSettingsState = { + error?: string; + field?: string; + message?: string; + // What was sent, to repaint the form after an error: React 19 resets uncontrolled forms + // after each action and what was typed would be lost. + values?: Record<string, string>; +}; + +const FIELDS = [ + "instanceName", + "registrationOpen", + "operatorName", + "operatorEmail", + "operatorLegalId", + "operatorAddress", + "hostingDescription", + "backupRetentionDays", +] as const; + +export async function saveInstanceSettings( + _prev: InstanceSettingsState, + formData: FormData, +): Promise<InstanceSettingsState> { + const admin = await requireAdmin(); + const { m } = await getI18n(); + const raw = Object.fromEntries( + FIELDS.map((f) => { + const v = formData.get(f); + return [f, typeof v === "string" ? v : ""]; + }), + ); + const parsed = instanceSettingsSchema.safeParse(raw); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + return { + error: issueMessage(m, issue), + field: String(issue?.path[0] ?? ""), + values: raw, + }; + } + + const result = await updateInstanceSettings(parsed.data); + if (!result.ok) { + if (result.error === "legal_info_required") { + return { + error: m.errors.instance.legalInfoRequired, + field: "registrationOpen", + values: raw, + }; + } + return { error: m.errors.instance.notConfigured, values: raw }; + } + + await recordAudit({ + action: "instance.updated", + userId: admin.id, + detail: `registration ${parsed.data.registrationOpen ? "open" : "closed"}${result.legalChanged ? ", legal details changed" : ""}`, + }); + // The name shows in the bar of the whole app; registration, in /login and /register. + revalidatePath("/", "layout"); + return { message: m.errors.instance.saved }; +} diff --git a/app/lib/actions/labels.ts b/app/lib/actions/labels.ts index 91a33d4..f3d09a7 100644 --- a/app/lib/actions/labels.ts +++ b/app/lib/actions/labels.ts @@ -27,7 +27,7 @@ export async function createLabel(formData: FormData): Promise<void> { if (!canManageBoard(role)) return; const name = String(formData.get("name") ?? "").trim().slice(0, 40); if (!name) return; - // Un color fuera de la paleta (incluidas claves del prototipo) se guarda como "blue". + // A colour outside the palette (prototype keys included) is stored as "blue". const color = labelColorSchema.parse(formData.get("color") ?? undefined); await prisma.label.create({ data: { boardId, name, color } }); @@ -48,7 +48,7 @@ export async function deleteLabel(formData: FormData): Promise<void> { revalidateBoard(label.boardId); } -/** Asigna o quita una etiqueta a una tarjeta (toggle). */ +/** Adds or removes a label on a card (toggle). */ export async function toggleCardLabel(input: { cardId: string; labelId: string; @@ -60,7 +60,7 @@ export async function toggleCardLabel(input: { if (!boardId) return { ok: false }; const { role } = await requireBoardAccess(boardId); if (!canEditBoard(role)) return { ok: false }; - // Seguridad (IDOR): la etiqueta debe ser del MISMO tablero que la tarjeta. Ver + // Security (IDOR): the label must belong to the SAME board as the card. See // labelBelongsToBoard. if (!(await labelBelongsToBoard(labelId, boardId))) return { ok: false }; diff --git a/app/lib/actions/lists.ts b/app/lib/actions/lists.ts index 7b286ee..c3424e4 100644 --- a/app/lib/actions/lists.ts +++ b/app/lib/actions/lists.ts @@ -10,7 +10,7 @@ import { isValidPositionKey } from "@/app/lib/ordering"; import { createListAtEnd } from "@/app/lib/services/lists"; import { deleteListWithAttachments } from "@/app/lib/services/attachments"; -// La posición llega del cliente: ver moveCardSchema en actions/cards.ts. +// The position comes from the client: see moveCardSchema in actions/cards.ts. const reorderListSchema = z.object({ listId: z.string().min(1), position: z.string().refine(isValidPositionKey), @@ -53,16 +53,15 @@ export async function deleteList(formData: FormData): Promise<void> { const { role } = await requireBoardAccess(list.boardId); if (!canEditBoard(role)) return; - // Las tarjetas se borran en cascada (onDelete: Cascade en el esquema); los ficheros - // de sus adjuntos los borra el servicio. + // Cards are deleted in cascade (onDelete: Cascade in the schema); the service deletes + // their attachment files. await deleteListWithAttachments(listId); revalidateBoard(list.boardId); } /** - * Persiste el reordenamiento de una lista tras un drag & drop. El cliente calcula la - * nueva clave de posición (fractional indexing) y la envía; aquí solo validamos el - * acceso y actualizamos una única fila. + * Persists a list reorder after drag & drop. The client computes the new position key + * (fractional indexing) and sends it; here we only check access and update a single row. */ export async function reorderList(input: { listId: string; diff --git a/app/lib/actions/locale.ts b/app/lib/actions/locale.ts new file mode 100644 index 0000000..2bd4e75 --- /dev/null +++ b/app/lib/actions/locale.ts @@ -0,0 +1,25 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { prisma } from "@/app/lib/prisma"; +import { verifySession } from "@/app/lib/dal"; +import { isLocale } from "@/app/i18n/config"; +import { setLocaleCookie } from "@/app/i18n/cookie"; + +// Language picker (footer of the public pages and Settings › Appearance). Works without +// JavaScript: each language is a submit button. The choice goes into a cookie (also for +// visitors without an account) and, when signed in, into User.locale so that +// notifications for this person are written in it. + +export async function setLocale(formData: FormData): Promise<void> { + const locale = formData.get("locale"); + if (!isLocale(locale)) return; + + await setLocaleCookie(locale); + + const session = await verifySession(); + if (session) { + await prisma.user.update({ where: { id: session.userId }, data: { locale } }); + } + revalidatePath("/", "layout"); +} diff --git a/app/lib/actions/members.ts b/app/lib/actions/members.ts index 071681c..d2f0ee7 100644 --- a/app/lib/actions/members.ts +++ b/app/lib/actions/members.ts @@ -3,19 +3,23 @@ import { prisma } from "@/app/lib/prisma"; import { revalidateBoard } from "@/app/lib/realtime/notify"; import { requireBoardAccess } from "@/app/lib/dal"; -import { canManageBoard, BOARD_ROLE_LABELS } from "@/app/lib/roles"; +import { canManageBoard } from "@/app/lib/roles"; import { recordAudit } from "@/app/lib/audit"; -import { notify } from "@/app/lib/notifications"; +import { notify, recipientI18n } from "@/app/lib/notifications"; +import { getI18n, i18nFor, issueMessage } from "@/app/i18n/server"; +import { fmt } from "@/app/i18n/format"; import { removeMemberFromBoard } from "@/app/lib/services/members"; import { addMemberSchema, assignableBoardRoleSchema } from "@/app/lib/validators"; -// Gestión de miembros de un tablero (Hito 3). Solo el PROPIETARIO (OWNER) puede -// invitar, cambiar el rol o quitar miembros. No hay registro abierto: solo se puede -// invitar a usuarios que ya existen (el administrador los crea, ver actions/admin.ts). -// Ver docs/authorization.md. +// Board member management. Only the OWNER can invite, change the role of or remove members. +// Only existing users can be invited (created by the administrator, see actions/admin.ts, +// or through open registration). See docs/authorization.md. +// +// Notifications are written in the recipient's language; the messages returned to the +// form, in the actor's. -// `values` devuelve lo que se envió cuando hay error: React resetea el formulario tras la -// acción, y sin esto el campo de email se vaciaría justo cuando hay que corregirlo. +// `values` returns what was sent when there is an error: React resets the form after the +// action, and without this the email field would be cleared just when it needs fixing. export type MemberActionState = { error?: string; message?: string; @@ -31,7 +35,7 @@ function submittedValues(formData: FormData): { email: string; role: string } { }; } -/** Invita a un usuario existente (por email) a un tablero con un rol (EDITOR/VIEWER). */ +/** Invites an existing user (by email) to a board with a role (EDITOR/VIEWER). */ export async function addBoardMember( _prev: MemberActionState, formData: FormData, @@ -39,8 +43,9 @@ export async function addBoardMember( const boardId = String(formData.get("boardId")); const values = submittedValues(formData); const { user: actor, role: actorRole } = await requireBoardAccess(boardId); + const { m } = await getI18n(); if (!canManageBoard(actorRole)) { - return { error: "Solo el propietario puede gestionar los miembros.", values }; + return { error: m.errors.members.ownerOnly, values }; } const parsed = addMemberSchema.safeParse({ @@ -49,21 +54,17 @@ export async function addBoardMember( }); if (!parsed.success) { return { - error: parsed.error.issues[0]?.message ?? "Datos no válidos.", + error: issueMessage(m, parsed.error.issues[0]), values, }; } const user = await prisma.user.findUnique({ where: { email: parsed.data.email.toLowerCase() }, - select: { id: true, name: true }, + select: { id: true, name: true, locale: true }, }); if (!user) { - return { - error: - "No existe ningún usuario con ese email. Pídele al administrador que cree la cuenta primero.", - values, - }; + return { error: m.errors.members.userNotFound, values }; } const existing = await prisma.boardMember.findUnique({ @@ -71,7 +72,7 @@ export async function addBoardMember( select: { userId: true }, }); if (existing) { - return { error: `${user.name} ya es miembro de este tablero.`, values }; + return { error: fmt(m.errors.members.alreadyMember, { name: user.name }), values }; } await prisma.boardMember.create({ @@ -84,25 +85,29 @@ export async function addBoardMember( targetId: boardId, detail: `${user.id} (${parsed.data.role})`, }); - // Notifica al invitado (Hito 8). + // Notify the invitee, in their language. const board = await prisma.board.findUnique({ where: { id: boardId }, select: { title: true }, }); + const forInvitee = i18nFor(user.locale).m.notifications; await notify({ userId: user.id, type: "member.added", - title: `Te han añadido a «${board?.title ?? "un tablero"}»`, - body: `Como ${BOARD_ROLE_LABELS[parsed.data.role]}.`, + title: fmt(forInvitee.memberAdded.title, { board: board?.title ?? forInvitee.someBoard }), + body: fmt(forInvitee.memberAdded.body, { role: forInvitee.roles[parsed.data.role] }), boardId, }); revalidateBoard(boardId); return { - message: `${user.name} añadido como ${BOARD_ROLE_LABELS[parsed.data.role]}.`, + message: fmt(m.errors.members.added, { + name: user.name, + role: m.notifications.roles[parsed.data.role], + }), }; } -/** Cambia el rol de un miembro (no se puede cambiar el del propietario del tablero). */ +/** Changes a member's role (the board owner's cannot be changed). */ export async function updateBoardMemberRole(formData: FormData): Promise<void> { const boardId = String(formData.get("boardId")); const userId = String(formData.get("userId")); @@ -112,16 +117,16 @@ export async function updateBoardMemberRole(formData: FormData): Promise<void> { const parsed = assignableBoardRoleSchema.safeParse(formData.get("role")); if (!parsed.success) return; - // El propietario del tablero (Board.ownerId) siempre conserva el rol OWNER. + // The board owner (Board.ownerId) always keeps the OWNER role. const board = await prisma.board.findUnique({ where: { id: boardId }, select: { ownerId: true, title: true }, }); if (!board || board.ownerId === userId) return; - // Solo cuenta si el usuario ES miembro y su rol cambia de verdad: con un userId ajeno o - // el mismo rol no hay nada que auditar ni notificar (evita avisos falsos que, además, - // revelarían el título del tablero a quien no es miembro). + // It only counts if the user IS a member and their role really changes: with an unrelated + // userId or the same role there is nothing to audit or notify (avoids false notifications + // that, moreover, would reveal the board title to a non-member). const { count } = await prisma.boardMember.updateMany({ where: { boardId, userId, role: { not: parsed.data } }, data: { role: parsed.data }, @@ -134,17 +139,20 @@ export async function updateBoardMemberRole(formData: FormData): Promise<void> { targetId: boardId, detail: `${userId} → ${parsed.data}`, }); + const { m } = await recipientI18n(userId); await notify({ userId, type: "member.role_changed", - title: `Tu rol en «${board.title}» ha cambiado`, - body: `Ahora eres ${BOARD_ROLE_LABELS[parsed.data]}.`, + title: fmt(m.notifications.memberRoleChanged.title, { board: board.title }), + body: fmt(m.notifications.memberRoleChanged.body, { + role: m.notifications.roles[parsed.data], + }), boardId, }); revalidateBoard(boardId); } -/** Quita a un miembro del tablero (no se puede quitar al propietario). */ +/** Removes a member from the board (the owner cannot be removed). */ export async function removeBoardMember(formData: FormData): Promise<void> { const boardId = String(formData.get("boardId")); const userId = String(formData.get("userId")); @@ -157,7 +165,8 @@ export async function removeBoardMember(formData: FormData): Promise<void> { }); if (!board || board.ownerId === userId) return; - // Si no era miembro (userId ajeno o expulsión repetida) no se audita ni se notifica. + // If they were not a member (unrelated userId or repeated removal) nothing is audited or + // notified. const removed = await removeMemberFromBoard(boardId, userId); if (removed === 0) return; await recordAudit({ @@ -167,11 +176,12 @@ export async function removeBoardMember(formData: FormData): Promise<void> { targetId: boardId, detail: userId, }); - // Sin boardId: el usuario ya no tiene acceso, así que la notificación no enlaza. + // No boardId: the user no longer has access, so the notification has no link. + const { m } = await recipientI18n(userId); await notify({ userId, type: "member.removed", - title: `Te han quitado de «${board.title}»`, + title: fmt(m.notifications.memberRemoved.title, { board: board.title }), }); revalidateBoard(boardId); } diff --git a/app/lib/actions/notifications.ts b/app/lib/actions/notifications.ts index d92fcd6..939c2c4 100644 --- a/app/lib/actions/notifications.ts +++ b/app/lib/actions/notifications.ts @@ -4,14 +4,14 @@ import { refresh } from "next/cache"; import { prisma } from "@/app/lib/prisma"; import { requireUser } from "@/app/lib/dal"; -// Acciones sobre las notificaciones propias (Hito 8). La autorización es implícita: el -// WHERE siempre incluye `userId` del usuario en sesión, así que nadie puede marcar como -// leídas las de otra persona. Ver docs/notifications.md. +// Actions on one's own notifications. Authorisation is implicit: the WHERE always includes +// the signed-in user's `userId`, so nobody can mark someone else's as read. See +// docs/notifications.md. // -// Ambas llaman a refresh(): el contador vive en el layout compartido y, sin refrescar -// el router desde el servidor, no se repinta al navegar a un tablero tras marcarla. +// Both call refresh(): the counter lives in the shared layout and, without refreshing the +// router from the server, it is not repainted when navigating to a board after marking. -/** Marca una notificación propia como leída. */ +/** Marks one of one's own notifications as read. */ export async function markNotificationRead(id: string): Promise<void> { const user = await requireUser(); await prisma.notification.updateMany({ @@ -21,7 +21,7 @@ export async function markNotificationRead(id: string): Promise<void> { refresh(); } -/** Marca todas las notificaciones propias como leídas. */ +/** Marks all of one's own notifications as read. */ export async function markAllNotificationsRead(): Promise<void> { const user = await requireUser(); await prisma.notification.updateMany({ diff --git a/app/lib/actions/register.ts b/app/lib/actions/register.ts new file mode 100644 index 0000000..5e8d6de --- /dev/null +++ b/app/lib/actions/register.ts @@ -0,0 +1,117 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { createUserAccount } from "@/app/lib/services/users"; +import { isRegistrationOpen } from "@/app/lib/services/instance"; +import { createSession } from "@/app/lib/session"; +import { getI18n, issueMessage } from "@/app/i18n/server"; +import { plural } from "@/app/i18n/format"; +import { registerSchema } from "@/app/lib/validators"; +import { rateLimit } from "@/app/lib/rate-limit"; +import { recordAudit, clientIp } from "@/app/lib/audit"; + +// Open registration (/register). It only works if the admin opened it in /admin/instance +// and the instance has an operator and a contact (without them the privacy policy would be +// incomplete). New accounts are always MEMBER. See docs/privacy.md and +// docs/authentication.md. + +export type RegisterState = { + error?: string; + field?: "name" | "email" | "password" | "acceptTerms"; + // What was typed, to repaint the form after an error. Never the password. + values?: { name: string; email: string }; +}; + +// Per IP: 5 sign-ups per hour. Only with a trustworthy IP (TRUSTED_PROXY_HOPS > 0); without +// one, everybody is not lumped into a shared bucket. +const IP_LIMIT = 5; +const IP_WINDOW_MS = 60 * 60 * 1000; +// Instance-wide cap: slows down a wave of bots even without a trustworthy IP or when they +// come from many IPs. Accepted risk: during an attack, real people cannot sign up either +// until the hour passes (visible in /admin/audit as auth.register_rate_limited). +const GLOBAL_LIMIT = 30; +const GLOBAL_WINDOW_MS = 60 * 60 * 1000; + +function text(formData: FormData, key: string, max: number): string { + const v = formData.get(key); + return typeof v === "string" ? v.slice(0, max) : ""; +} + +export async function register( + _prev: RegisterState, + formData: FormData, +): Promise<RegisterState> { + const values = { name: text(formData, "name", 60), email: text(formData, "email", 254) }; + const { locale, intl, m } = await getI18n(); + + if (!(await isRegistrationOpen())) { + return { error: m.errors.register.closed, values }; + } + + // Honeypot field: invisible to people (and out of the tab order), bots that fill in + // everything write in it. The answer is a generic error, without hints. + if (text(formData, "website", 200) !== "") { + return { error: m.errors.register.failed, values }; + } + + const parsed = registerSchema.safeParse({ + name: formData.get("name"), + email: formData.get("email"), + password: formData.get("password"), + acceptTerms: formData.get("acceptTerms") ?? undefined, + }); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const field = issue?.path[0]; + return { + error: issueMessage(m, issue), + field: + field === "name" || field === "email" || field === "password" || field === "acceptTerms" + ? field + : undefined, + values, + }; + } + + // Counted after validating: a typo does not use up anyone's quota. What counts is each + // sign-up (or attempt with an already registered email) with valid data. + const ip = await clientIp(); + const limits = [ + ...(ip ? [rateLimit(`register:${ip}`, IP_LIMIT, IP_WINDOW_MS)] : []), + rateLimit("register:global", GLOBAL_LIMIT, GLOBAL_WINDOW_MS), + ]; + const blocked = limits.find((l) => !l.ok); + if (blocked) { + if (blocked.firstBlock) { + await recordAudit({ action: "auth.register_rate_limited", ip }); + } + const minutes = Math.ceil(blocked.retryAfterSec / 60); + return { + error: plural(intl, m.errors.register.rateLimited, minutes, { minutes }), + values, + }; + } + + const result = await createUserAccount({ + name: parsed.data.name, + email: parsed.data.email, + password: parsed.data.password, + role: "MEMBER", + termsAcceptedAt: new Date(), + locale, + }); + if (!result.ok) { + // Reveals that the email already has an account. Without email verification there is + // no way to avoid it without breaking sign-up; the sign-up limit slows down mass + // enumeration. + return { + error: m.errors.register.emailTaken, + field: "email", + values, + }; + } + + await createSession(result.user.id); + await recordAudit({ action: "auth.registered", userId: result.user.id, ip }); + redirect("/boards"); +} diff --git a/app/lib/actions/setup.ts b/app/lib/actions/setup.ts index 8b86f57..b9abe6e 100644 --- a/app/lib/actions/setup.ts +++ b/app/lib/actions/setup.ts @@ -10,9 +10,10 @@ import { completeInstanceSetup, type SetupOutcome, } from "@/app/lib/services/setup"; +import { getI18n, issueMessage } from "@/app/i18n/server"; -// `values` devuelve lo tecleado (nunca la contraseña) para repintar el formulario tras un -// error: React 19 resetea los formularios no controlados después de la acción. +// `values` returns what was typed (never the password) to repaint the form after an error: +// React 19 resets uncontrolled forms after the action. export type SetupState = { error?: string; values?: { instanceName: string; name: string; email: string }; @@ -22,11 +23,12 @@ export async function completeSetup( _prev: SetupState, formData: FormData, ): Promise<SetupState> { - // Con el setup ya hecho se corta aquí, antes de validar y de gastar ~200 ms de bcrypt: - // el ID de la acción sigue en el bundle de /setup y se puede invocar a mano. redirect() - // lanza NEXT_REDIRECT, así que va fuera de cualquier try/catch. La comprobación - // definitiva sigue dentro de la transacción (completeInstanceSetup). + // With setup already done it stops here, before validating and spending ~200 ms of + // bcrypt: the action ID is still in the /setup bundle and can be invoked by hand. + // redirect() throws NEXT_REDIRECT, so it stays outside any try/catch. The definitive check + // is still inside the transaction (completeInstanceSetup). if (await isSetupComplete()) redirect("/login"); + const { m } = await getI18n(); const text = (key: string) => { const v = formData.get(key); @@ -46,7 +48,7 @@ export async function completeSetup( }); if (!parsed.success) { return { - error: parsed.error.issues[0]?.message ?? "Datos no válidos.", + error: issueMessage(m, parsed.error.issues[0]), values, }; } @@ -58,18 +60,18 @@ export async function completeSetup( try { outcome = await completeInstanceSetup({ instanceName, name, email, passwordHash }); } catch (e) { - // BD caída, migraciones sin aplicar, email duplicado…: el usuario ve un mensaje - // genérico, pero la causa queda en el log (sin FormData ni email). - console.error("[setup] fallo al completar la configuración", describeSetupError(e)); + // Database down, migrations not applied, duplicate email…: the user sees a generic + // message, but the cause goes to the log (without FormData or email). + console.error("[setup] could not complete the setup", describeSetupError(e)); outcome = { ok: false, reason: "ERROR" }; } - // redirect() se llama FUERA del try/catch: lanza una excepción interna de Next - // (NEXT_REDIRECT) que no debe ser capturada por el catch de arriba. + // redirect() is called OUTSIDE the try/catch: it throws an internal Next exception + // (NEXT_REDIRECT) that must not be caught by the catch above. if (!outcome.ok) { if (outcome.reason === "ALREADY_SETUP") redirect("/login"); return { - error: "No se pudo completar la configuración. Inténtalo de nuevo.", + error: m.errors.setup.failed, values, }; } @@ -83,8 +85,8 @@ export async function completeSetup( redirect("/boards"); } -// Código y campos de Prisma (p. ej. P2002 en `email`) y mensaje, que no incluye los -// valores enviados salvo en errores de validación, donde solo se deja el tipo. +// Prisma code and fields (e.g. P2002 on `email`) and message, which does not include the +// submitted values except in validation errors, where only the type is kept. function describeSetupError(e: unknown): Record<string, unknown> { if (!(e instanceof Error)) return { error: String(e).slice(0, 200) }; const code = "code" in e ? e.code : undefined; diff --git a/app/lib/actions/tokens.ts b/app/lib/actions/tokens.ts index 0a5fca3..9990e43 100644 --- a/app/lib/actions/tokens.ts +++ b/app/lib/actions/tokens.ts @@ -11,6 +11,7 @@ import { apiTokenExpirySchema, } from "@/app/lib/validators"; import { generateApiToken } from "@/app/lib/api/tokens"; +import { getI18n, issueMessage } from "@/app/i18n/server"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -20,16 +21,16 @@ const createTokenSchema = z.object({ expiry: apiTokenExpirySchema, }); -// Gestión de los tokens de API propios (Hito 9). Crear devuelve el token EN CLARO una -// sola vez (en el estado del formulario); revocar lo borra. La autorización es -// implícita: todo va contra el usuario en sesión. Ver docs/api/public-api.md. +// Management of one's own API tokens. Creating returns the token IN CLEAR only once (in the +// form state); revoking deletes it. Authorisation is implicit: everything runs against the +// signed-in user. See docs/api/public-api.md. export type CreateTokenState = { error?: string; - // Token en claro recién creado: se muestra una vez y no se vuelve a poder ver. + // Newly created token in clear: shown once and never again. token?: string; name?: string; - // Lo elegido, para rellenar de nuevo el formulario tras un error. + // What was chosen, to refill the form after an error. values?: { name: string; scope: string; expiry: string }; }; @@ -43,8 +44,9 @@ export async function createApiToken( formData: FormData, ): Promise<CreateTokenState> { const user = await requireUser(); - // Nombre, permisos y caducidad se validan juntos: un valor no reconocido es un error - // y no se crea nada (antes caía en silencio a escritura y sin caducidad). + const { m } = await getI18n(); + // Name, scope and expiry are validated together: an unrecognised value is an error and + // nothing is created (it used to fall back silently to read-write and no expiry). const values = { name: formText(formData, "name"), scope: formText(formData, "scope"), @@ -53,14 +55,15 @@ export async function createApiToken( const parsed = createTokenSchema.safeParse(values); if (!parsed.success) { const issue = parsed.error.issues[0]; - // Los enums de validators.ts no llevan mensaje propio (zod lo daría en inglés). + // The scope/expiry enums in validators.ts carry no message of their own: the field + // says which one failed. const field = issue?.path[0]; const error = field === "scope" - ? "Permisos no válidos." + ? m.errors.tokens.invalidScope : field === "expiry" - ? "Caducidad no válida." - : (issue?.message ?? "Datos no válidos."); + ? m.errors.tokens.invalidExpiry + : issueMessage(m, issue); return { error, values }; } const { name, scope } = parsed.data; @@ -90,7 +93,7 @@ export async function createApiToken( return { token, name }; } -/** Revoca (borra) un token propio. El WHERE incluye el userId: nadie revoca el de otro. */ +/** Revokes (deletes) one's own token. The WHERE includes the userId: nobody revokes another's. */ export async function revokeApiToken(formData: FormData): Promise<void> { const user = await requireUser(); const id = String(formData.get("id")); diff --git a/app/lib/api/auth.ts b/app/lib/api/auth.ts index 0be3eb3..7db9fd8 100644 --- a/app/lib/api/auth.ts +++ b/app/lib/api/auth.ts @@ -3,16 +3,16 @@ import { prisma } from "@/app/lib/prisma"; import type { BoardRole } from "@/app/lib/roles"; import { hashToken } from "./tokens"; -// Autenticación y autorización de la API pública (Hito 9). A diferencia del DAL (que usa -// cookies y redirige), aquí trabajamos con `Authorization: Bearer <token>` y devolvemos -// códigos HTTP. El servidor sigue siendo la única autoridad: el rol del tablero se -// vuelve a comprobar contra board_members en cada petición. Ver docs/api/public-api.md. +// Authentication and authorization for the public API. Unlike the DAL (cookies and +// redirects), this works with `Authorization: Bearer <token>` and returns HTTP codes. The +// server is still the only authority: the board role is checked again against +// board_members on every request. See docs/api/public-api.md. export type ApiAuth = { userId: string; scope: string }; /** - * Resuelve el usuario a partir de la cabecera Authorization. Devuelve `{ userId, scope }` - * o null si no hay token válido o está CADUCADO. De paso, marca `lastUsedAt` (best-effort). + * Resolves the user from the Authorization header. Returns `{ userId, scope }`, or null + * when there is no valid token or it has EXPIRED. Also stamps `lastUsedAt` (best-effort). */ export async function authenticateApiToken( request: Request, @@ -27,16 +27,16 @@ export async function authenticateApiToken( select: { id: true, userId: true, scope: true, expiresAt: true }, }); if (!record) return null; - // Token caducado: deja de valer (no se borra; el usuario lo verá expirado en /settings). + // Expired token: no longer valid (not deleted; the user sees it as expired in /settings). if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null; - // Marca de último uso; nunca debe hacer fallar la petición, pero el fallo se registra: - // lastUsedAt es lo que mira el usuario para detectar tokens usados sin su permiso, y - // congelarlo en silencio lo engañaría. Nunca se registra el token ni su hash. + // Last-use stamp; it must never fail the request, but a failure is logged: lastUsedAt is + // what users check to spot tokens used without their consent, and silently freezing it + // would mislead them. Neither the token nor its hash is ever logged. await prisma.apiToken .update({ where: { id: record.id }, data: { lastUsedAt: new Date() } }) .catch((err: unknown) => - console.error("[api-auth] no se pudo actualizar lastUsedAt", { + console.error("[api-auth] could not update lastUsedAt", { tokenId: record.id, error: err instanceof Error ? err.message : String(err), }), @@ -45,12 +45,12 @@ export async function authenticateApiToken( return { userId: record.userId, scope: record.scope }; } -/** true si el token es de solo lectura (no puede escribir). */ +/** true if the token is read-only (cannot write). */ export function isReadOnly(auth: ApiAuth): boolean { return auth.scope === "read"; } -/** Rol del usuario en un tablero, o null si no es miembro (= sin acceso). */ +/** The user's role on a board, or null if not a member (= no access). */ export async function apiBoardRole( userId: string, boardId: string, diff --git a/app/lib/api/cron.ts b/app/lib/api/cron.ts new file mode 100644 index 0000000..dca9635 --- /dev/null +++ b/app/lib/api/cron.ts @@ -0,0 +1,30 @@ +import "server-only"; +import { timingSafeEqual } from "node:crypto"; +import { apiError } from "@/app/lib/api/respond"; + +// Authorization of the cron endpoints (/api/cron/*). They are triggered by an EXTERNAL +// scheduler (system cron, a sidecar, GitHub Actions…), not an in-process timer (ADR 0015), +// with the shared secret in `Authorization: Bearer <CRON_SECRET>`. + +// Constant-time comparison so the response time does not leak the secret. +function secretMatches(provided: string, expected: string): boolean { + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** null if the request carries the right secret; otherwise, the matching error response. */ +export function rejectUnauthorizedCron(request: Request): Response | null { + const expected = process.env.CRON_SECRET; + if (!expected) { + // Generic message: configuration details are not revealed to the client. + return apiError(503, "Service unavailable."); + } + const header = request.headers.get("authorization") ?? ""; + const provided = header.startsWith("Bearer ") ? header.slice(7) : ""; + if (!provided || !secretMatches(provided, expected)) { + return apiError(401, "Invalid cron secret."); + } + return null; +} diff --git a/app/lib/api/respond.ts b/app/lib/api/respond.ts index 0f757de..7fff1ec 100644 --- a/app/lib/api/respond.ts +++ b/app/lib/api/respond.ts @@ -1,7 +1,11 @@ import "server-only"; +import { messagesFor } from "@/app/i18n/config"; +import { messageAt } from "@/app/i18n/format"; -// Respuestas JSON uniformes para la API pública (Hito 9). Los errores siempre tienen la -// forma `{ "error": "mensaje" }`; los datos van tal cual. Ver docs/api/public-api.md. +// Uniform JSON responses for the public API. Errors always have the shape +// `{ "error": "message" }`; data is returned as is. See docs/api/public-api.md. +// +// The API answers in English only: it is for programs, not people. export function apiError(status: number, message: string): Response { return Response.json({ error: message }, { status }); @@ -10,3 +14,15 @@ export function apiError(status: number, message: string): Response { export function apiOk(data: unknown, status = 200): Response { return Response.json(data, { status }); } + +/** + * 422 for the first zod issue. The shared schemas use message keys + * ("errors.validation.titleRequired"), turned into the English text here; a message that + * is not a key is zod's own default, already in English, and is returned as is. + */ +export function apiValidationError(issue: { message: string } | undefined): Response { + const message = issue + ? (messageAt(messagesFor("en"), issue.message) ?? issue.message) + : "Invalid data."; + return apiError(422, message); +} diff --git a/app/lib/api/serialize.ts b/app/lib/api/serialize.ts index 5b37e06..4180271 100644 --- a/app/lib/api/serialize.ts +++ b/app/lib/api/serialize.ts @@ -1,9 +1,8 @@ import "server-only"; -// Serialización a JSON de la API pública (Hito 9). Define la forma estable que ven los -// clientes (fechas como ISO string, etiquetas aplanadas). Los tipos de entrada describen -// solo los campos que se leen: los resultados de Prisma encajan estructuralmente aunque -// traigan campos extra. Ver docs/api/public-api.md. +// JSON serialization for the public API. Defines the stable shape clients see (dates as +// ISO strings, flattened labels). The input types describe only the fields that are read: +// Prisma results fit structurally even with extra fields. See docs/api/public-api.md. type LabelRow = { id: string; name: string; color: string }; diff --git a/app/lib/api/tokens.ts b/app/lib/api/tokens.ts index dac56a8..55274c2 100644 --- a/app/lib/api/tokens.ts +++ b/app/lib/api/tokens.ts @@ -1,33 +1,32 @@ import "server-only"; import { randomBytes, createHash } from "node:crypto"; -// Generación y hash de tokens de acceso personal (Hito 9). El token se entrega al -// usuario UNA vez; en la base de datos solo vive su hash SHA-256. Ver -// docs/decisions/0013-api-tokens.md. +// Generation and hashing of personal access tokens. The token is shown to the user ONCE; +// only its SHA-256 hash is stored in the database. See docs/decisions/0013-api-tokens.md. const PREFIX = "orgt_"; export type GeneratedToken = { - /** Token completo en claro. Solo se muestra una vez; nunca se persiste. */ + /** Full plain-text token. Shown only once; never persisted. */ token: string; - /** Hash SHA-256 (hex) que sí se guarda y por el que se busca al autenticar. */ + /** SHA-256 hash (hex) that is stored and looked up on authentication. */ hash: string; - /** Primeros caracteres, para que el usuario identifique el token en la lista. */ + /** First characters, so the user can identify the token in the list. */ prefix: string; }; -/** SHA-256 en hexadecimal de un token. Determinista: misma entrada → mismo hash. */ +/** Hex SHA-256 of a token. Deterministic: same input → same hash. */ export function hashToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } -/** Crea un token nuevo: 24 bytes aleatorios en base64url con el prefijo `orgt_`. */ +/** Creates a new token: 24 random bytes in base64url with the `orgt_` prefix. */ export function generateApiToken(): GeneratedToken { const token = PREFIX + randomBytes(24).toString("base64url"); return { token, hash: hashToken(token), - // Prefijo visible (p. ej. "orgt_Ab3kQ9x"): suficiente para reconocerlo sin revelarlo. + // Visible prefix (e.g. "orgt_Ab3kQ9x"): enough to recognise it without revealing it. prefix: token.slice(0, PREFIX.length + 7), }; } diff --git a/app/lib/audit.ts b/app/lib/audit.ts index 890ab86..95344f7 100644 --- a/app/lib/audit.ts +++ b/app/lib/audit.ts @@ -3,11 +3,10 @@ import { headers } from "next/headers"; import { prisma } from "@/app/lib/prisma"; import { parseTrustedProxyHops, pickClientIp } from "@/app/lib/client-ip"; -// Registro de auditoría (Hito 5). `recordAudit` escribe una fila por evento relevante -// de seguridad u operación. Está pensado para NO romper nunca la operación principal: -// si la escritura falla no se relanza, pero se deja constancia en el log del servidor -// (sin detail, ip ni datos personales). Ver el modelo AuditLog en prisma/schema.prisma -// y la vista en /admin/audit (docs/operations.md). +// Audit log. `recordAudit` writes one row per relevant security or operations event. It +// must NEVER break the main operation: a failed write is not rethrown, but it is recorded +// in the server log (without detail, ip or personal data). See the AuditLog model in +// prisma/schema.prisma and the view at /admin/audit (docs/operations.md). export type AuditAction = | "setup.completed" @@ -15,6 +14,14 @@ export type AuditAction = | "auth.login_failed" | "auth.login_rate_limited" | "auth.logout" + | "auth.registered" + | "auth.register_rate_limited" + | "account.updated" + | "account.password_changed" + | "account.sessions_revoked" + | "account.exported" + | "account.deleted" + | "instance.updated" | "user.created" | "user.deleted" | "user.role_changed" @@ -29,10 +36,10 @@ export type AuditAction = | "apitoken.revoked"; /** - * IP del cliente según X-Forwarded-For y TRUSTED_PROXY_HOPS (ver app/lib/client-ip.ts). - * null si no hay proxies de confianza declarados: desde una Server Action no se puede - * leer la IP del socket, y la cabecera sin proxy delante la escribe el propio cliente. - * X-Real-IP no se usa por lo mismo. + * Client IP from X-Forwarded-For and TRUSTED_PROXY_HOPS (see app/lib/client-ip.ts). null + * when no trusted proxies are declared: a Server Action cannot read the socket's IP, and + * without a proxy in front the client writes the header itself. X-Real-IP is not used for + * the same reason. */ export async function clientIp(): Promise<string | null> { const hops = parseTrustedProxyHops(process.env.TRUSTED_PROXY_HOPS); @@ -61,9 +68,9 @@ export async function recordAudit(params: { }, }); } catch (error) { - // Best-effort: no se relanza para no hacer fallar la acción que audita, pero perder - // un evento de seguridad en silencio no es aceptable. - console.error("[audit] no se pudo escribir en AuditLog", { + // Best-effort: not rethrown so the audited action does not fail, but silently losing + // a security event is not acceptable. + console.error("[audit] could not write to AuditLog", { action: params.action, targetType: params.targetType, targetId: params.targetId, @@ -73,8 +80,8 @@ export async function recordAudit(params: { } /** - * Resumen del error para el log. El mensaje de PrismaClientValidationError reproduce los - * argumentos de la consulta (detail, ip…), así que en ese caso solo se registra el tipo. + * Error summary for the log. PrismaClientValidationError's message echoes the query + * arguments (detail, ip…), so in that case only the type is logged. */ function describeError(error: unknown): string { if (!(error instanceof Error)) return String(error).slice(0, 200); diff --git a/app/lib/auth.ts b/app/lib/auth.ts index 1273eff..a892ffb 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -1,16 +1,16 @@ import "server-only"; import bcrypt from "bcryptjs"; -// Hashing de contraseñas con bcryptjs (puro JS, sin compilación nativa: cero -// fricción en la imagen Alpine). Ver docs/authentication.md. +// Password hashing with bcryptjs (pure JS, no native build: zero friction in the Alpine +// image). See docs/authentication.md. export const SALT_ROUNDS = 12; -// Hash bcrypt REAL (60 caracteres, coste SALT_ROUNDS) de un UUID aleatorio que no se -// guardó: ninguna contraseña coincide con él. Sirve para gastar el mismo tiempo cuando el -// email no existe. Tiene que ser un hash válido: con un literal mal formado (el anterior -// medía 59) bcrypt.compare devuelve false al instante y el tiempo de respuesta delata si -// una cuenta existe. Es un literal para no pagar un hashSync al importar el módulo. +// REAL bcrypt hash (60 characters, cost SALT_ROUNDS) of a random UUID that was never +// stored: no password matches it. It burns the same time when the email does not exist. +// It must be a valid hash: with a malformed literal (the previous one was 59 long) +// bcrypt.compare returns false instantly and the response time reveals whether an account +// exists. It is a literal to avoid paying for a hashSync when the module is imported. export const DUMMY_PASSWORD_HASH = "$2b$12$llgSC9FXycV4gh6.tWFq..r2hz9NXMksFmfWuDrIuN7lf.C.qa5pq"; @@ -26,9 +26,9 @@ export async function verifyPassword( } /** - * Verifica la contraseña contra `hash` o, si no hay cuenta (`null`), contra el hash - * ficticio, que siempre da false tras el mismo trabajo de bcrypt. Así el login tarda lo - * mismo exista o no el email. + * Checks the password against `hash` or, when there is no account (`null`), against the + * dummy hash, which always gives false after the same bcrypt work. That way sign-in takes + * the same time whether or not the email exists. */ export async function verifyPasswordOrDummy( password: string, diff --git a/app/lib/board-collision.ts b/app/lib/board-collision.ts index c0ee950..c78fba7 100644 --- a/app/lib/board-collision.ts +++ b/app/lib/board-collision.ts @@ -1,6 +1,6 @@ -// Detección de colisiones y navegación por teclado del arrastre del tablero, en un -// módulo PURO (sin React ni DOM) para poder testearlo con rectángulos sintéticos -// (test/board-collision.test.ts). Ver docs/drag-and-drop.md. +// Collision detection and keyboard navigation for board dragging, in a PURE module (no +// React, no DOM) so it can be tested with synthetic rectangles +// (test/board-collision.test.ts). See docs/drag-and-drop.md. import { closestCorners, KeyboardCode, @@ -17,10 +17,10 @@ function isListDroppable(c: Typed): boolean { } /** - * `closestCorners`, pero al arrastrar una LISTA solo considera otras listas. Sin el - * filtro, el rectángulo flotante de la lista (bajo, arriba de la columna) queda más - * cerca de la primera tarjeta de la columna que de la propia columna, `over` resultaba - * ser una tarjeta y el reordenado de listas se descartaba en casi todos los casos. + * `closestCorners`, but when dragging a LIST it only considers other lists. Without the + * filter, the list's floating rectangle (short, at the top of the column) is closer to + * the column's first card than to the column itself, `over` turned out to be a card and + * list reordering was discarded almost every time. */ export const boardCollisionDetection: CollisionDetection = (args) => { if (args.active.data.current?.type !== "list") return closestCorners(args); @@ -31,8 +31,8 @@ export const boardCollisionDetection: CollisionDetection = (args) => { }; /** - * Teclas del `KeyboardSensor`. Espacio coge y suelta; Intro NO coge (queda libre para - * abrir la tarjeta), pero sí suelta. Escape cancela. + * `KeyboardSensor` keys. Space picks up and drops; Enter does NOT pick up (it stays free + * to open the card) but does drop. Escape cancels. */ export const DRAG_KEYBOARD_CODES: KeyboardCodes = { start: [KeyboardCode.Space], @@ -42,7 +42,7 @@ export const DRAG_KEYBOARD_CODES: KeyboardCodes = { type Rect = { left: number; top: number; width: number; right: number }; -/** Columna más cercana a `refLeft` en la dirección pedida (`dir` = -1 izquierda, 1 derecha). */ +/** Column closest to `refLeft` in the given direction (`dir` = -1 left, 1 right). */ function neighborColumn( columns: { rect: Rect }[], refLeft: number, @@ -62,16 +62,16 @@ function neighborColumn( } /** - * Coordenadas del teclado para el tablero. + * Keyboard coordinates for the board. * - * - LISTA: `sortableKeyboardCoordinates` mezcla tarjetas y columnas y su propia primera - * tarjeta queda «a la derecha» (8 px de margen), así que la primera flecha no hacía - * nada; aquí la flecha salta directamente a la columna vecina. - * - TARJETA con izquierda/derecha: para `sortableKeyboardCoordinates` la propia columna - * de la tarjeta queda «a la izquierda» (su borde está 8 px antes que el de la tarjeta), - * así que la flecha izquierda devolvía la tarjeta a su columna y nunca la movía. Aquí - * se salta a la columna vecina y, dentro de ella, a la tarjeta más cercana en vertical - * (o a la columna si está vacía). Arriba/abajo siguen con las de `@dnd-kit/sortable`. + * - LIST: `sortableKeyboardCoordinates` mixes cards and columns, and the list's own first + * card counts as "to the right" (8 px margin), so the first arrow press did nothing; + * here the arrow jumps straight to the neighbouring column. + * - CARD with left/right: for `sortableKeyboardCoordinates` the card's own column counts + * as "to the left" (its edge is 8 px before the card's), so the left arrow put the card + * back in its column and never moved it. Here it jumps to the neighbouring column and, + * within it, to the vertically closest card (or the column itself if empty). Up/down + * still use `@dnd-kit/sortable`'s coordinates. */ export const boardKeyboardCoordinates: KeyboardCoordinateGetter = ( event, @@ -86,8 +86,7 @@ export const boardKeyboardCoordinates: KeyboardCoordinateGetter = ( return sortableKeyboardCoordinates(event, args); } if (!right && !left) { - // Las listas solo se mueven en horizontal: arriba/abajo no hacen nada (y no - // desplazan la página). + // Lists only move horizontally: up/down do nothing (and do not scroll the page). if (event.code === KeyboardCode.Up || event.code === KeyboardCode.Down) { event.preventDefault(); } @@ -108,8 +107,8 @@ export const boardKeyboardCoordinates: KeyboardCoordinateGetter = ( return target ? { x: target.rect.left, y: target.rect.top } : undefined; } - // Tarjeta: la referencia es el borde de SU columna (la que contiene su centro), no el - // de la tarjeta, para que la propia columna nunca cuente como vecina. + // Card: the reference is the edge of ITS column (the one containing its centre), not + // the card's, so its own column never counts as a neighbour. const centerX = collisionRect.left + collisionRect.width / 2; const own = columns.find( (c) => centerX >= c.rect.left && centerX < c.rect.right, @@ -140,10 +139,10 @@ export const boardKeyboardCoordinates: KeyboardCoordinateGetter = ( }; /** - * Coordenadas del teclado para el calendario: cada flecha lleva la pastilla a la celda - * vecina (día anterior/siguiente, misma columna una semana antes/después). Las de - * `@dnd-kit/sortable` no sirven aquí porque la pastilla no es un elemento ordenable - * (no devuelven nada) y las de por defecto avanzan 25 px por pulsación. + * Keyboard coordinates for the calendar: each arrow moves the chip to the neighbouring + * cell (previous/next day, same column one week earlier/later). `@dnd-kit/sortable`'s + * don't work here because the chip is not a sortable item (they return nothing), and the + * defaults move 25 px per key press. */ export const calendarKeyboardCoordinates: KeyboardCoordinateGetter = ( event, @@ -156,7 +155,7 @@ export const calendarKeyboardCoordinates: KeyboardCoordinateGetter = ( [KeyboardCode.Up]: { x: 0, y: -1 }, }[event.code]; if (!dir) return undefined; - event.preventDefault(); // las flechas desplazarían la página + event.preventDefault(); // the arrows would scroll the page const { collisionRect, droppableContainers, droppableRects, over } = context; if (!collisionRect) return undefined; @@ -164,7 +163,7 @@ export const calendarKeyboardCoordinates: KeyboardCoordinateGetter = ( .getEnabled() .map((c) => droppableRects.get(c.id)) .filter((r) => r !== undefined); - // Celda actual: la que está debajo o, si no hay, la que contiene la pastilla. + // Current cell: the one underneath or, failing that, the one containing the chip. const current = (over ? droppableRects.get(over.id) : undefined) ?? cells.find( @@ -180,7 +179,7 @@ export const calendarKeyboardCoordinates: KeyboardCoordinateGetter = ( for (const r of cells) { const dx = r.left - current.left; const dy = r.top - current.top; - // Misma fila (para izquierda/derecha) o misma columna (para arriba/abajo). + // Same row (for left/right) or same column (for up/down). const aligned = dir.x !== 0 ? Math.abs(dy) < current.height / 2 @@ -193,7 +192,7 @@ export const calendarKeyboardCoordinates: KeyboardCoordinateGetter = ( } } if (!best) return undefined; - // Conserva el desplazamiento de la pastilla dentro de la celda. + // Keep the chip's offset within the cell. return { x: best.left + (collisionRect.left - current.left), y: best.top + (collisionRect.top - current.top), diff --git a/app/lib/board-filters.ts b/app/lib/board-filters.ts index e74c166..98af1c2 100644 --- a/app/lib/board-filters.ts +++ b/app/lib/board-filters.ts @@ -1,13 +1,13 @@ -// Filtros de tablero (Hito 12), lógica PURA y compartida cliente/servidor (sin -// "server-only": el cliente la usa para ocultar tarjetas y se testea directamente). El -// filtrado es solo de presentación; no cambia datos. Ver docs/filters.md. +// Board filters: PURE logic shared by client and server (no "server-only": the client +// uses it to hide cards and it is tested directly). Filtering is presentation only; it +// does not change any data. See docs/filters.md. import type { CardDTO } from "@/app/lib/types"; export type DueFilter = "any" | "overdue" | "due" | "none"; export type BoardFilters = { - labelIds: string[]; // la tarjeta debe tener AL MENOS una de estas etiquetas - assigneeIds: string[]; // … y al menos uno de estos asignados + labelIds: string[]; // the card must have AT LEAST one of these labels + assigneeIds: string[]; // … and at least one of these assignees due: DueFilter; }; @@ -17,16 +17,16 @@ export const EMPTY_FILTERS: BoardFilters = { due: "any", }; -/** ¿Hay algún filtro activo? Si no, no se filtra nada (ni se desactiva el arrastre). */ +/** Is any filter active? If not, nothing is filtered (and dragging stays enabled). */ export function filtersActive(f: BoardFilters): boolean { return f.labelIds.length > 0 || f.assigneeIds.length > 0 || f.due !== "any"; } /** - * ¿Está vencida? El `dueDate` representa un DÍA (se elige con `<input type="date">` y se - * guarda a medianoche UTC), así que se compara su parte de fecha con el día LOCAL de - * `now`, sin horas: la tarjeta que vence hoy no está vencida hasta mañana. Comparar el - * instante con `Date.now()` la marcaba vencida desde las 00:00 UTC del mismo día. + * Is it overdue? `dueDate` represents a DAY (picked with `<input type="date">` and stored + * at midnight UTC), so its date part is compared with the LOCAL day of `now`, ignoring + * the time: a card due today is not overdue until tomorrow. Comparing the instant with + * `Date.now()` marked it overdue from 00:00 UTC on the same day. */ export function isOverdue(dueDate: string, now: number): boolean { const today = new Date(now); @@ -34,31 +34,40 @@ export function isOverdue(dueDate: string, now: number): boolean { return dueDate.slice(0, 10) < todayDay; } -// Formateador de módulo (se crea una vez). `timeZone: "UTC"` porque el dueDate es una -// fecha de calendario guardada a medianoche UTC: formatearla en la zona local mostraba -// el día anterior al oeste de Greenwich y descuadraba la hidratación servidor/cliente. -const dueDayFormat = new Intl.DateTimeFormat("es-ES", { - day: "numeric", - month: "short", - timeZone: "UTC", -}); +// One formatter per language, created once (building an Intl.DateTimeFormat per card +// is not free). `timeZone: "UTC"` because dueDate is a calendar date stored at midnight +// UTC: formatting it in the local zone showed the previous day west of Greenwich and +// broke server/client hydration. +const dueDayFormats = new Map<string, Intl.DateTimeFormat>(); -/** Día de vencimiento corto para las tarjetas («24 sept»), igual en cualquier zona. */ -export function formatDueDay(dueDate: string): string { - return dueDayFormat.format(new Date(dueDate)); +/** + * Short due day for cards ("Sep 24", "24 sept"), the same in any time zone. `intl` is + * the language's BCP 47 tag (`useI18n().intl`). + */ +export function formatDueDay(dueDate: string, intl: string): string { + let format = dueDayFormats.get(intl); + if (!format) { + format = new Intl.DateTimeFormat(intl, { + day: "numeric", + month: "short", + timeZone: "UTC", + }); + dueDayFormats.set(intl, format); + } + return format.format(new Date(dueDate)); } function matchesDue(card: CardDTO, due: DueFilter, now: number): boolean { if (due === "any") return true; if (due === "none") return card.dueDate === null; if (!card.dueDate) return false; - if (due === "due") return true; // tiene vencimiento + if (due === "due") return true; // has a due date return isOverdue(card.dueDate, now); } /** - * ¿La tarjeta pasa TODOS los filtros activos? Dentro de cada dimensión (etiquetas, - * asignados) la lógica es OR (al menos una coincide); entre dimensiones es AND. + * Does the card pass ALL active filters? Within each dimension (labels, assignees) the + * logic is OR (at least one matches); across dimensions it is AND. */ export function cardMatchesFilters( card: CardDTO, diff --git a/app/lib/client-ip.ts b/app/lib/client-ip.ts index b62936f..7c300f7 100644 --- a/app/lib/client-ip.ts +++ b/app/lib/client-ip.ts @@ -1,32 +1,32 @@ import { isIP } from "node:net"; -// Obtención de la IP del cliente para el rate limit del login y la auditoría. Sin -// next/headers para poder probarla con tests unitarios; la usa clientIp() en -// app/lib/audit.ts. Ver docs/decisions/0009-in-memory-rate-limit.md. +// Client IP for the sign-in rate limit and the audit log. No next/headers so it can be +// unit-tested; clientIp() in app/lib/audit.ts uses it. See +// docs/decisions/0009-in-memory-rate-limit.md. // -// X-Forwarded-For la controla el cliente: cada proxy AÑADE la IP que ve al final de la -// lista, pero lo que venía antes lo escribió quien hizo la petición. Además, Next solo -// rellena la cabecera si no viene (`??=`), así que con la app expuesta sin proxy el -// cliente decide la cabecera entera. Por eso solo se confía en ella si se declaran los -// proxies de confianza que hay delante (TRUSTED_PROXY_HOPS) y se toma el valor que puso -// el más externo de ellos, contando desde la derecha. +// The client controls X-Forwarded-For: each proxy APPENDS the IP it sees, but whatever +// came before was written by the requester. Also, Next only fills the header when it is +// absent (`??=`), so with the app exposed without a proxy the client decides the whole +// header. Hence it is only trusted when the trusted proxies in front are declared +// (TRUSTED_PROXY_HOPS), taking the value set by the outermost of them, counting from the +// right. /** - * Número de proxies de confianza delante de la app. Vacío o sin definir = 0 (no se usa - * X-Forwarded-For). Un valor que no sea un entero >= 0 es un error de configuración. + * Number of trusted proxies in front of the app. Empty or unset = 0 (X-Forwarded-For is + * not used). A value that is not an integer >= 0 is a configuration error. */ export function parseTrustedProxyHops(raw: string | undefined): number { if (raw === undefined || raw.trim() === "") return 0; const value = raw.trim(); if (!/^\d+$/.test(value)) { throw new Error( - `TRUSTED_PROXY_HOPS debe ser un entero >= 0 (recibido: "${value.slice(0, 20)}").`, + `TRUSTED_PROXY_HOPS must be an integer >= 0 (got: "${value.slice(0, 20)}").`, ); } return Number(value); } -/** Por qué no se obtuvo IP aunque hay proxies de confianza declarados. */ +/** Why no IP was obtained even though trusted proxies are declared. */ export type ClientIpProblem = "missing-header" | "too-few-values" | "not-an-ip"; export type ClientIpResult = @@ -34,11 +34,11 @@ export type ClientIpResult = | { ip: null; problem: ClientIpProblem | null }; /** - * IP del cliente según X-Forwarded-For y el número de proxies de confianza (`hops`). - * Con hops = N toma el N-ésimo valor contando desde la derecha: el que añadió el proxy - * de confianza más externo. Sin IP si hops es 0 (sin problema: es la configuración por - * defecto), si la cabecera no llega, si tiene menos valores que N (la petición no pasó - * por todos los proxies) o si el valor no es una IP. Función pura. + * Client IP from X-Forwarded-For and the number of trusted proxies (`hops`). With + * hops = N it takes the N-th value from the right: the one appended by the outermost + * trusted proxy. No IP when hops is 0 (not a problem: it is the default), when the header + * is missing, when it has fewer than N values (the request did not go through every + * proxy) or when the value is not an IP. Pure function. */ export function resolveClientIp(xff: string | null, hops: number): ClientIpResult { if (hops <= 0) return { ip: null, problem: null }; @@ -50,10 +50,10 @@ export function resolveClientIp(xff: string | null, hops: number): ClientIpResul } /** - * Algunos proxies (Azure Application Gateway, IIS ARR) escriben la IP con el puerto de - * origen: `1.2.3.4:5678` o `[2001:db8::1]:443`. Sin quitarlo, isIP lo rechaza y el login - * se queda sin límite por IP; y usarlo tal cual daría una clave distinta por conexión. - * No se corta a ciegas por el último `:`, porque partiría una IPv6 sin corchetes. + * Some proxies (Azure Application Gateway, IIS ARR) write the IP with the source port: + * `1.2.3.4:5678` or `[2001:db8::1]:443`. Without stripping it, isIP rejects it and sign-in + * loses its per-IP limit; using it as is would give a different key per connection. It + * is not blindly cut at the last `:`, which would split an IPv6 without brackets. */ function normalizeIp(value: string): string | null { if (isIP(value)) return value; @@ -65,20 +65,20 @@ function normalizeIp(value: string): string | null { } const PROBLEM_DETAIL: Record<ClientIpProblem, string> = { - "missing-header": "la petición no trae X-Forwarded-For", - "too-few-values": "X-Forwarded-For trae menos valores que proxies declarados", - "not-an-ip": "el valor de X-Forwarded-For que corresponde no es una IP", + "missing-header": "the request has no X-Forwarded-For", + "too-few-values": "X-Forwarded-For has fewer values than declared proxies", + "not-an-ip": "the matching X-Forwarded-For value is not an IP", }; -// Un aviso por motivo y proceso: basta para que el operador lo vea en el log sin -// repetirlo en cada login. +// One warning per reason and process: enough for the operator to see it in the log +// without repeating it on every sign-in. const warnedProblems = new Set<ClientIpProblem>(); /** - * Como resolveClientIp, pero avisa en el log (una vez por motivo y proceso) cuando hay - * proxies declarados y no se obtiene IP: el login se queda sin límite por IP y la - * auditoría sin IP, y sin el aviso el operador creería tenerlos activos. El aviso no - * incluye la cabecera ni ninguna IP (son datos personales). + * Like resolveClientIp, but warns in the log (once per reason and process) when proxies + * are declared and no IP is obtained: sign-in loses its per-IP limit and the audit log its + * IP, and without the warning the operator would believe both are active. The warning + * includes neither the header nor any IP (they are personal data). */ export function pickClientIp(xff: string | null, hops: number): string | null { const { ip, problem } = resolveClientIp(xff, hops); @@ -86,9 +86,9 @@ export function pickClientIp(xff: string | null, hops: number): string | null { warnedProblems.add(problem); const values = xff ? xff.split(",").length : 0; console.warn( - `[client-ip] TRUSTED_PROXY_HOPS=${hops} pero ${PROBLEM_DETAIL[problem]} ` + - `(${values} valores): el login no tiene límite por IP y la auditoría no guarda ` + - `la IP. Revisa la configuración del proxy. Solo se avisa una vez por motivo y proceso.`, + `[client-ip] TRUSTED_PROXY_HOPS=${hops} but ${PROBLEM_DETAIL[problem]} ` + + `(${values} values): sign-in has no per-IP limit and the audit log does not store ` + + `the IP. Check the proxy configuration. Warned only once per reason and process.`, ); } return ip; diff --git a/app/lib/colors.ts b/app/lib/colors.ts index 5b576da..c8f3477 100644 --- a/app/lib/colors.ts +++ b/app/lib/colors.ts @@ -1,7 +1,7 @@ -// Paletas de color del CONTENIDO (no del cromo): fondos de tablero y etiquetas. -// Estética editorial cálida: tonos apagados y sofisticados (clay, oliva, ciruela, -// ocre, ladrillo, pizarra) que conviven con el acento terracota. Luminosidad baja -// (L≤0.54) para que el texto blanco encima sea legible. Ver DESIGN.md. +// CONTENT colour palettes (not the chrome): board backgrounds and labels. +// Warm editorial look: muted, refined tones (clay, olive, plum, ochre, brick, slate) +// that sit well with the terracotta accent. Low lightness (L≤0.54) so white text on +// top stays readable. See DESIGN.md. export const BOARD_COLORS = { blue: "oklch(0.5 0.07 248)", @@ -15,27 +15,27 @@ export const BOARD_COLORS = { export type BoardColor = keyof typeof BOARD_COLORS; export const BOARD_COLOR_KEYS = Object.keys(BOARD_COLORS) as BoardColor[]; -/** Color de tablero determinista a partir de un id (para asignar al crear). */ +/** Deterministic board colour from an id (assigned on creation). */ export function pickBoardColor(seed: string): BoardColor { let h = 0; for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0; return BOARD_COLOR_KEYS[h % BOARD_COLOR_KEYS.length]; } -/** Fondo de un tablero: su color elegido o, si no tiene, uno estable según su id. */ +/** A board's background: its chosen colour or, if none, a stable one based on its id. */ export function boardBackgroundFor(b: { id: string; background: string | null; }): string { - // Object.hasOwn y no `in`: `in` acepta claves del prototipo ('constructor', - // 'toString'…) y devolvería una función en lugar de un color. + // Object.hasOwn rather than `in`: `in` accepts prototype keys ('constructor', + // 'toString'…) and would return a function instead of a colour. if (b.background && Object.hasOwn(BOARD_COLORS, b.background)) { return BOARD_COLORS[b.background as BoardColor]; } return BOARD_COLORS[pickBoardColor(b.id)]; } -// Etiquetas: paleta editorial apagada, L ~0.52-0.57 → texto blanco legible encima. +// Labels: muted editorial palette, L ~0.52-0.57 → white text readable on top. export const LABEL_COLORS = { green: "oklch(0.53 0.09 150)", lime: "oklch(0.56 0.1 128)", @@ -51,7 +51,7 @@ export type LabelColor = keyof typeof LABEL_COLORS; export const LABEL_COLOR_KEYS = Object.keys(LABEL_COLORS) as LabelColor[]; export function labelBackground(color: string | null | undefined): string { - // Object.hasOwn, no `in`: ver boardBackgroundFor. + // Object.hasOwn, not `in`: see boardBackgroundFor. const key = color && Object.hasOwn(LABEL_COLORS, color) ? (color as LabelColor) : "blue"; return LABEL_COLORS[key]; diff --git a/app/lib/csp.ts b/app/lib/csp.ts index 3a42ed9..2a988c1 100644 --- a/app/lib/csp.ts +++ b/app/lib/csp.ts @@ -1,17 +1,17 @@ -// Content-Security-Policy de las páginas HTML. La genera proxy.ts en cada petición con -// un nonce nuevo; Next lee el nonce de la cabecera y lo aplica a sus propios scripts -// (ver node_modules/next/dist/docs/01-app/02-guides/content-security-policy.md). +// Content-Security-Policy for HTML pages. proxy.ts builds it on every request with a new +// nonce; Next reads the nonce from the header and applies it to its own scripts +// (see node_modules/next/dist/docs/01-app/02-guides/content-security-policy.md). // -// Decisiones (docs/DECISIONES.md, 2026-09-24): -// - Sin `upgrade-insecure-requests`: orgtable se instala a menudo por http://IP:3000; -// esa directiva pasaría todos los recursos a https y la app no cargaría. -// - `style-src-attr 'unsafe-inline'`: React pinta atributos `style` en el HTML del -// servidor (color de fondo del tablero, transformaciones de dnd-kit) y un nonce no -// cubre atributos. Los elementos <style>/<link> sí exigen nonce o mismo origen. -// - Solo en desarrollo: `'unsafe-eval'` (React reconstruye trazas de error con eval) y -// estilos inline sin nonce (Turbopack y el overlay de errores inyectan <style> sin -// él). Si hay nonce, el navegador ignora 'unsafe-inline', por eso se quita el nonce. -// El build de producción no genera ninguna violación (verificado con Playwright). +// Decisions (docs/DECISIONES.md, 2026-09-24): +// - No `upgrade-insecure-requests`: orgtable is often installed at http://IP:3000; that +// directive would move every resource to https and the app would not load. +// - `style-src-attr 'unsafe-inline'`: React renders `style` attributes in the server HTML +// (board background colour, dnd-kit transforms) and a nonce does not cover attributes. +// <style>/<link> elements still require a nonce or the same origin. +// - Development only: `'unsafe-eval'` (React rebuilds error stacks with eval) and inline +// styles without a nonce (Turbopack and the error overlay inject <style> without one). +// When a nonce is present the browser ignores 'unsafe-inline', hence the nonce is +// dropped. The production build triggers no violations (checked with Playwright). export function buildContentSecurityPolicy(nonce: string, isDev: boolean): string { const styleSources = isDev ? "'self' 'unsafe-inline'" : `'self' 'nonce-${nonce}'`; @@ -32,7 +32,7 @@ export function buildContentSecurityPolicy(nonce: string, isDev: boolean): strin return directives.join("; "); } -/** Nonce aleatorio de 128 bits en base64, uno por petición. */ +/** Random 128-bit nonce in base64, one per request. */ export function generateNonce(): string { const bytes = crypto.getRandomValues(new Uint8Array(16)); return btoa(String.fromCharCode(...bytes)); diff --git a/app/lib/dal.ts b/app/lib/dal.ts index 69d2e47..462fc61 100644 --- a/app/lib/dal.ts +++ b/app/lib/dal.ts @@ -5,11 +5,11 @@ import { redirect } from "next/navigation"; import { decrypt } from "@/app/lib/session"; import { prisma } from "@/app/lib/prisma"; -// Data Access Layer: única fuente de verdad SEGURA para "quién es el usuario" y -// "está la instancia configurada". Cada función se memoiza por request con cache() -// de React, así que llamarlas varias veces en el mismo render no repite la consulta. -// El proxy.ts hace un chequeo OPTIMISTA (solo mira si existe la cookie); la -// validación criptográfica real ocurre aquí. Ver docs/authentication.md. +// Data Access Layer: the single SECURE source of truth for "who is the user" and "is the +// instance set up". Each function is memoised per request with React's cache(), so +// calling them several times in one render does not repeat the query. proxy.ts does an +// OPTIMISTIC check (it only looks for the cookie); the real cryptographic validation +// happens here. See docs/authentication.md. const INSTANCE_ID = "singleton"; @@ -18,9 +18,9 @@ export const verifySession = cache(async () => { const payload = await decrypt(token); if (!payload?.userId || !payload.sid) return null; - // Validación contra el almacén de sesiones: aunque el JWT sea válido, la sesión - // puede haber sido revocada (logout, reset de contraseña, cierre forzado) o haber - // expirado. Una sola consulta, memoizada por request con cache(). + // Check against the session store: even with a valid JWT, the session may have been + // revoked (sign-out, password reset, forced sign-out) or have expired. A single query, + // memoised per request with cache(). const session = await prisma.session.findUnique({ where: { id: payload.sid }, select: { userId: true, revokedAt: true, expiresAt: true }, @@ -39,7 +39,7 @@ export const getCurrentUser = cache(async () => { return prisma.user.findUnique({ where: { id: session.userId } }); }); -/** Exige sesión válida; redirige a /login si no la hay. Para usar en layouts/páginas. */ +/** Requires a valid session; redirects to /login otherwise. For layouts and pages. */ export const requireUser = cache(async () => { const user = await getCurrentUser(); if (!user) redirect("/login"); @@ -50,19 +50,19 @@ export const getInstanceConfig = cache(async () => { return prisma.instanceConfig.findUnique({ where: { id: INSTANCE_ID } }); }); -/** true si el setup wizard ya se completó. */ +/** true once the setup wizard has been completed. */ export const isSetupComplete = cache(async () => { const config = await getInstanceConfig(); return Boolean(config?.setupCompletedAt); }); /** - * Exige que el usuario actual sea miembro del tablero. Redirige a /boards si no - * tiene acceso. Toda mutación sobre un tablero (listas, tarjetas...) pasa por aquí. + * Requires the current user to be a member of the board. Redirects to /boards without + * access. Every board mutation (lists, cards…) goes through here. * - * Devuelve también el ROL del usuario en el tablero (OWNER/EDITOR/VIEWER): la - * comprobación de "qué puede hacer" con ese rol se hace en cada Server Action con los - * ayudantes de @/app/lib/roles (canEditBoard, canManageBoard). Ver docs/authorization.md. + * Also returns the user's ROLE on the board (OWNER/EDITOR/VIEWER): "what can they do" + * with that role is checked in each Server Action with the helpers in @/app/lib/roles + * (canEditBoard, canManageBoard). See docs/authorization.md. */ export const requireBoardAccess = cache(async (boardId: string) => { const user = await requireUser(); @@ -74,9 +74,9 @@ export const requireBoardAccess = cache(async (boardId: string) => { }); /** - * Exige que el usuario actual sea administrador GLOBAL de la instancia (User.role = - * ADMIN). Para el panel de administración (/admin). Redirige a /boards si no lo es; - * a /login si no hay sesión (vía requireUser). + * Requires the current user to be a GLOBAL administrator of the instance (User.role = + * ADMIN). For the administration panel (/admin). Redirects to /boards if not, and to + * /login without a session (via requireUser). */ export const requireAdmin = cache(async () => { const user = await requireUser(); diff --git a/app/lib/format-date.ts b/app/lib/format-date.ts index f204b1f..742980b 100644 --- a/app/lib/format-date.ts +++ b/app/lib/format-date.ts @@ -1,39 +1,41 @@ import "server-only"; -// Formateo de fechas EN EL SERVIDOR (Server Components). El proceso de Node corre en la -// zona del contenedor (normalmente UTC), así que sin `timeZone` explícita un evento de -// las 10:15 en Canarias se mostraba como 9:15, y cerca de medianoche cambiaba de día. -// La zona sale de ORGTABLE_TIMEZONE (IANA, p. ej. "Europe/Madrid") porque es una app -// self-hosted: cada instalación tiene la suya. Por defecto, Atlantic/Canary. +// Date formatting ON THE SERVER (Server Components). The Node process runs in the +// container's time zone (usually UTC), so without an explicit `timeZone` an event at 10:15 +// in the Canary Islands showed as 9:15, and near midnight it changed day. The zone comes +// from ORGTABLE_TIMEZONE (IANA, e.g. "Europe/Madrid") because the app is self-hosted: each +// installation has its own. Defaults to Atlantic/Canary. // -// NO usar para vencimientos (dueDate): son fechas de calendario guardadas a medianoche -// UTC y se formatean con timeZone "UTC". +// Do NOT use for due dates (dueDate): they are calendar dates stored at midnight UTC and +// are formatted with timeZone "UTC". -const LOCALE = "es-ES"; const DEFAULT_TIME_ZONE = "Atlantic/Canary"; -/** Zona horaria de la instancia. Lanza si ORGTABLE_TIMEZONE no es una zona IANA válida. */ +/** Time zone of the instance. Throws if ORGTABLE_TIMEZONE is not a valid IANA zone. */ export function instanceTimeZone(): string { const configured = process.env.ORGTABLE_TIMEZONE?.trim(); const timeZone = configured || DEFAULT_TIME_ZONE; try { - // Intl lanza RangeError con una zona desconocida: es la validación más fiable. - new Intl.DateTimeFormat(LOCALE, { timeZone }); + // Intl throws RangeError for an unknown zone: the most reliable validation. + new Intl.DateTimeFormat("en", { timeZone }); } catch (err) { - // Sin valor por defecto silencioso: una zona mal escrita mostraría horas falsas. + // No silent default: a misspelt zone would show wrong times. throw new Error( - `ORGTABLE_TIMEZONE no es una zona horaria válida: "${timeZone}". ` + - `Usa un nombre IANA, p. ej. "Europe/Madrid" o "Atlantic/Canary".`, + `ORGTABLE_TIMEZONE is not a valid time zone: "${timeZone}". ` + + `Use an IANA name, e.g. "Europe/Madrid" or "Atlantic/Canary".`, { cause: err }, ); } return timeZone; } -/** Fecha y hora con segundos y la abreviatura de la zona (p. ej. «15/07/2026, 10:15:00 WEST»). */ -export function formatDateTime(d: Date): string { - return new Intl.DateTimeFormat(LOCALE, { - // dateStyle/timeStyle no admiten timeZoneName, por eso se listan los campos. +/** + * Date and time with seconds and the zone abbreviation (e.g. "15/07/2026, 10:15:00 WEST"). + * `intl` is the BCP 47 tag of the reader's language (getI18n().intl). + */ +export function formatDateTime(d: Date, intl: string): string { + return new Intl.DateTimeFormat(intl, { + // dateStyle/timeStyle do not allow timeZoneName, hence the fields are listed. day: "2-digit", month: "2-digit", year: "numeric", @@ -45,9 +47,9 @@ export function formatDateTime(d: Date): string { }).format(d); } -/** Solo la fecha, en la zona de la instancia (p. ej. «16 jul 2026»). */ -export function formatDate(d: Date): string { - return new Intl.DateTimeFormat(LOCALE, { +/** Date only, in the instance's time zone (e.g. "16 Jul 2026"). */ +export function formatDate(d: Date, intl: string): string { + return new Intl.DateTimeFormat(intl, { dateStyle: "medium", timeZone: instanceTimeZone(), }).format(d); diff --git a/app/lib/initials.ts b/app/lib/initials.ts new file mode 100644 index 0000000..3e21060 --- /dev/null +++ b/app/lib/initials.ts @@ -0,0 +1,6 @@ +/** Initials for an avatar without a photo: first letter of the first two words. */ +export function initials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean); + const letters = words.slice(0, 2).map((w) => Array.from(w)[0] ?? ""); + return letters.join("").toUpperCase() || "?"; +} diff --git a/app/lib/limits.ts b/app/lib/limits.ts index a83821e..c35a038 100644 --- a/app/lib/limits.ts +++ b/app/lib/limits.ts @@ -1,6 +1,6 @@ -// Límites de longitud compartidos por la UI y el servidor. Módulo sin dependencias a -// propósito: los componentes de cliente importan de aquí y no de validators.ts, que -// arrastraría zod al bundle del navegador (y usa Buffer, que allí no existe). +// Length limits shared by the UI and the server. Deliberately dependency-free: Client +// Components import from here rather than from validators.ts, which would drag zod into +// the browser bundle (and uses Buffer, which does not exist there). -// Límite de la descripción de una tarjeta, el mismo en la UI, la API y el import. +// Maximum card description length, the same in the UI, the API and the import. export const CARD_DESCRIPTION_MAX = 5000; diff --git a/app/lib/notifications.ts b/app/lib/notifications.ts index 458ffe9..c9ac117 100644 --- a/app/lib/notifications.ts +++ b/app/lib/notifications.ts @@ -1,12 +1,17 @@ import "server-only"; import { prisma } from "@/app/lib/prisma"; import { publishUserEvent } from "@/app/lib/realtime/bus"; +import { i18nFor, type I18n } from "@/app/i18n/server"; -// Notificaciones in-app (Hito 8). `notify` escribe una fila y publica un evento en el -// bus para que la pestaña del destinatario actualice su campana al instante. Igual que -// la auditoría, es best-effort: nunca debe romper la acción que la origina. El texto se -// precalcula en español (la app es monolingüe). Ver docs/notifications.md y el modelo -// Notification en prisma/schema.prisma. +// In-app notifications. `notify` writes a row and publishes an event on the bus so that the +// recipient's tab updates its bell at once. Like the audit log, it is best-effort: it must +// never break the action that triggers it. See docs/notifications.md and the Notification +// model in prisma/schema.prisma. +// +// The text is rendered when the notification is created, in the RECIPIENT's language +// (User.locale, English when unset), with the templates in locales/<lang>/notifications.json. +// It is stored as text: if the person changes language later, existing notifications keep +// the language they were written in. export type NotificationType = | "member.added" @@ -17,9 +22,22 @@ export type NotificationType = | "due.reminder"; /** - * Crea la notificación y avisa en vivo. Devuelve true si la fila quedó guardada (aunque - * falle el aviso en vivo: la campana la verá al recargar) y false si no se pudo crear, - * para que quien cuente avisos (el cron de recordatorios) no informe de uno inexistente. + * Messages in the language of the person a notification is for. Callers that already + * loaded the user's `locale` use i18nFor(locale) directly and skip this query. + */ +export async function recipientI18n(userId: string): Promise<I18n> { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + return i18nFor(user?.locale); +} + +/** + * Creates the notification and sends the live update. Returns true if the row was stored + * (even if the live update fails: the bell will see it on reload) and false if it could not + * be created, so that whoever counts notifications (the reminders cron) does not report one + * that does not exist. */ export async function notify(params: { userId: string; @@ -41,9 +59,9 @@ export async function notify(params: { }, }); } catch (error) { - // Best-effort: una notificación que falla no debe tumbar la mutación principal. Se - // registra sin título ni cuerpo, que llevan contenido del usuario. - console.error("[notify] fallo al crear la notificación", { + // Best-effort: a failed notification must not bring down the main mutation. Logged + // without title or body, which carry user content. + console.error("[notify] could not create the notification", { type: params.type, userId: params.userId, boardId: params.boardId, @@ -55,7 +73,7 @@ export async function notify(params: { try { await publishUserEvent(params.userId); } catch (error) { - console.error("[notify] fallo al publicar el aviso en vivo", { + console.error("[notify] could not publish the live update", { type: params.type, userId: params.userId, code: errorCode(error), @@ -64,13 +82,13 @@ export async function notify(params: { return true; } -// Código de error de Prisma (P2003…) o nombre del error, sin el mensaje completo, que -// puede incluir valores de la consulta. +// Prisma error code (P2003…) or error name, without the full message, which may include +// values from the query. function errorCode(error: unknown): string { if (typeof error === "object" && error !== null) { const code = (error as { code?: unknown }).code; if (typeof code === "string") return code; if (error instanceof Error) return error.name; } - return "desconocido"; + return "unknown"; } diff --git a/app/lib/optimistic.ts b/app/lib/optimistic.ts index 602334a..b719c66 100644 --- a/app/lib/optimistic.ts +++ b/app/lib/optimistic.ts @@ -1,15 +1,15 @@ -// Persistencia de cambios optimistas del cliente (arrastre en tablero y calendario). -// Módulo sin React para poder testearlo directamente (test/optimistic.test.ts). +// Persists optimistic client changes (dragging on the board and the calendar). +// React-free so it can be tested directly (test/optimistic.test.ts). import { unstable_rethrow } from "next/navigation"; /** - * Ejecuta la Server Action de un cambio que la UI ya ha aplicado y llama a `onFailure` - * si el servidor lo rechaza (`{ ok: false }`) o la llamada falla (red, excepción), para - * que la UI deshaga el cambio o avise. Antes se hacía `void accion(...)`: el resultado - * se ignoraba y la UI se quedaba con un estado que no existía en la base de datos. + * Runs the Server Action for a change the UI has already applied and calls `onFailure` + * if the server rejects it (`{ ok: false }`) or the call fails (network, exception), so + * the UI can undo the change or warn. It used to be `void action(...)`: the result was + * ignored and the UI kept a state that did not exist in the database. * - * Las redirecciones y `notFound()` de Next (p. ej. sesión caducada → /login) se - * relanzan sin tocar: el router de Next las recoge como rechazo no gestionado y navega. + * Next's redirects and `notFound()` (e.g. expired session → /login) are rethrown + * untouched: Next's router picks them up as an unhandled rejection and navigates. */ export async function persistOptimistic( action: () => Promise<{ ok: boolean }>, diff --git a/app/lib/ordering.ts b/app/lib/ordering.ts index b886631..241124c 100644 --- a/app/lib/ordering.ts +++ b/app/lib/ordering.ts @@ -1,29 +1,29 @@ import "server-only"; import { generateKeyBetween } from "fractional-indexing"; -// Ayudantes de ordenación por fractional indexing. La posición de cada lista/tarjeta -// es una clave string lexicográfica; mover un elemento solo actualiza su propia fila. -// Ver docs/decisions/0002-fractional-indexing.md. +// Fractional indexing ordering helpers. Each list/card position is a lexicographic +// string key; moving an item only updates its own row. +// See docs/decisions/0002-fractional-indexing.md. -/** Clave para añadir un elemento al final, dada la posición del último (o null si vacío). */ +/** Key to append an item at the end, given the last position (or null if empty). */ export function positionAtEnd(lastPosition: string | null): string { return generateKeyBetween(lastPosition, null); } -// Alfabeto base62 de la librería y un tope de longitud. Las claves crecen al insertar una -// y otra vez en el MISMO hueco (≈1 carácter cada 6 inserciones): con un tope de 64 se -// llegaba a las 373 inserciones con un uso normal y el arrastre empezaba a fallar. 1024 -// da para unas 6000 en el mismo hueco, impide cadenas de megabytes y queda muy por debajo -// del máximo de una entrada del índice btree (listId, position) de Card (≈2700 bytes). +// The library's base62 alphabet plus a length cap. Keys grow when inserting again and +// again into the SAME gap (≈1 character every 6 insertions): with a cap of 64, normal use +// reached 373 insertions and dragging started to fail. 1024 allows about 6000 in the same +// gap, prevents megabyte-long strings and stays well below the maximum size of an entry +// in Card's (listId, position) btree index (≈2700 bytes). const POSITION_KEY_MAX = 1024; const POSITION_KEY_SHAPE = new RegExp(`^[A-Za-z][0-9A-Za-z]{0,${POSITION_KEY_MAX - 1}}$`); /** - * ¿Es `key` una clave de posición que la librería sabrá extender? Las Server Actions de - * arrastre (moveCard, reorderList) reciben la clave del cliente: si se guardara una clave - * inválida ('x', 'a00'…), `positionAtEnd` lanzaría para siempre al crear en esa lista o - * tablero. La regex cubre lo que la librería no valida (acepta 'a0 ', con espacio) y - * `generateKeyBetween` cubre la estructura (parte entera y fracción sin '0' final). + * Is `key` a position key the library can extend? The drag Server Actions (moveCard, + * reorderList) receive the key from the client: if an invalid key ('x', 'a00'…) were + * stored, `positionAtEnd` would throw forever when creating in that list or board. The + * regex covers what the library does not validate (it accepts 'a0 ', with a space) and + * `generateKeyBetween` covers the structure (integer part, fraction without a trailing '0'). */ export function isValidPositionKey(key: string): boolean { if (!POSITION_KEY_SHAPE.test(key)) return false; @@ -31,7 +31,7 @@ export function isValidPositionKey(key: string): boolean { generateKeyBetween(key, null); return true; } catch { - // La librería lanza con claves mal formadas: aquí eso significa «no válida». + // The library throws on malformed keys: here that simply means "not valid". return false; } } diff --git a/app/lib/prisma.ts b/app/lib/prisma.ts index 785bde1..7b9d74e 100644 --- a/app/lib/prisma.ts +++ b/app/lib/prisma.ts @@ -3,54 +3,53 @@ import { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import type { PoolConfig } from "pg"; -// Singleton de PrismaClient guardado en globalThis también en producción. En desarrollo, -// el HMR recarga los módulos y abriría un pool nuevo en cada recarga. En producción, -// Turbopack evalúa este módulo una vez por capa (route handlers por un lado, RSC/SSR por -// otro): sin globalThis habría dos pools y el doble de conexiones a Postgres. bus.ts y -// rate-limit.ts usan el mismo patrón. +// PrismaClient singleton kept in globalThis, in production too. In development, HMR +// reloads modules and would open a new pool on every reload. In production, Turbopack +// evaluates this module once per layer (route handlers on one side, RSC/SSR on the +// other): without globalThis there would be two pools and twice the Postgres +// connections. bus.ts and rate-limit.ts use the same pattern. // -// En Prisma 7 la conexión directa a la base de datos se hace a través de un driver -// adapter (aquí @prisma/adapter-pg sobre node-postgres). DATABASE_URL se lee de -// process.env en runtime (inyectada por docker-compose o por el .env en dev), nunca -// se hornea en el build. +// In Prisma 7 the direct database connection goes through a driver adapter (here +// @prisma/adapter-pg over node-postgres). DATABASE_URL is read from process.env at runtime +// (injected by docker-compose or by .env in dev), never baked into the build. const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; /** - * Configuración del pool de node-postgres. Con los valores por defecto de pg - * (`connectionTimeoutMillis: 0`) una petición que no encuentra conexión libre espera - * para siempre; Prisma 7 ya no aplica el `connect_timeout` de 5 s que traía Prisma 6. - * Se exporta para que test/integration/prisma-pool.int.test.ts la ejercite. + * node-postgres pool configuration. With pg's defaults (`connectionTimeoutMillis: 0`) a + * request that finds no free connection waits forever; Prisma 7 no longer applies the 5 s + * `connect_timeout` that Prisma 6 had. Exported so that + * test/integration/prisma-pool.int.test.ts can exercise it. */ export function buildPoolConfig(connectionString: string | undefined): PoolConfig { return { connectionString, - // Esperar una conexión libre del pool (o abrir una nueva) falla a los 5 s. + // Waiting for a free pool connection (or opening a new one) fails after 5 s. connectionTimeoutMillis: 5_000, - // Las conexiones inactivas se cierran a los 10 s: el valor por defecto de pg-pool, - // explícito para que el test lo vigile y no cambie sin querer. + // Idle connections close after 10 s: pg-pool's default, made explicit so the test + // guards it against accidental changes. idleTimeoutMillis: 10_000, }; } function createPrismaClient() { const url = process.env.DATABASE_URL; - // Falla rápido y claro si falta DATABASE_URL: sin ella, el adapter caería en silencio a - // los defaults de libpq (PGHOST/localhost…), conectando a una BD no deseada. No se valida - // durante `next build` (NEXT_PHASE), que no necesita la BD. + // Fails fast and clearly when DATABASE_URL is missing: without it, the adapter would + // silently fall back to libpq defaults (PGHOST/localhost…) and connect to an unintended + // database. Not checked during `next build` (NEXT_PHASE), which needs no database. if (!url && process.env.NEXT_PHASE !== "phase-production-build") { throw new Error( - "DATABASE_URL no está configurado. Define la cadena de conexión a PostgreSQL.", + "DATABASE_URL is not set. Define the PostgreSQL connection string.", ); } const adapter = new PrismaPg(buildPoolConfig(url), { - // El adapter ya escucha el evento 'error' del pool (no tumba el proceso), pero sin - // este manejador el fallo de una conexión inactiva (Postgres reiniciado, red cortada) - // solo iba a su log de depuración. Se registra solo el mensaje: nunca la cadena de - // conexión, que lleva la contraseña. + // The adapter already listens to the pool's 'error' event (it does not crash the + // process), but without this handler a failing idle connection (Postgres restarted, + // network cut) only reached its debug log. Only the message is logged: never the + // connection string, which holds the password. onPoolError: (err) => - console.error("[prisma] error en una conexión inactiva del pool de pg:", err.message), + console.error("[prisma] error on an idle pg pool connection:", err.message), }); return new PrismaClient({ adapter }); } diff --git a/app/lib/queries.ts b/app/lib/queries.ts index 5d4f30e..2742137 100644 --- a/app/lib/queries.ts +++ b/app/lib/queries.ts @@ -3,8 +3,8 @@ import { cache } from "react"; import type { Prisma } from "@prisma/client"; import { prisma } from "@/app/lib/prisma"; -// Consultas de lectura. La autorización (qué tableros puede ver un usuario) se -// expresa en el propio WHERE: dueño o miembro del tablero. +// Read queries. Authorization (which boards a user can see) is expressed in the WHERE +// itself: owner or member of the board. export async function getBoardsForUser(userId: string) { return prisma.board.findMany({ @@ -15,9 +15,37 @@ export async function getBoardsForUser(userId: string) { }); } -// --- Hito 3: multiusuario --- +/** + * The user's boards with what their tile on /boards shows: how many cards they hold and + * who else is in them. Separate from getBoardsForUser so the shape returned by the public + * API does not change. + */ +export async function getBoardSummariesForUser(userId: string) { + const boards = await prisma.board.findMany({ + where: { + OR: [{ ownerId: userId }, { members: { some: { userId } } }], + }, + orderBy: { updatedAt: "desc" }, + select: { + id: true, + title: true, + background: true, + ownerId: true, + lists: { select: { _count: { select: { cards: true } } } }, + members: { + orderBy: { addedAt: "asc" }, + select: { user: { select: { id: true, name: true } } }, + }, + }, + }); + return boards.map(({ lists, members, ...b }) => ({ + ...b, + cardCount: lists.reduce((n, l) => n + l._count.cards, 0), + members: members.map((m) => m.user), + })); +} -/** Todos los usuarios de la instancia, para el panel de administración. */ +/** Every user of the instance, for the administration panel. */ export async function getAllUsers() { return prisma.user.findMany({ orderBy: { createdAt: "asc" }, @@ -32,7 +60,7 @@ export async function getAllUsers() { }); } -/** Eventos de auditoría más recientes, para el panel /admin/audit. */ +/** Most recent audit events, for the /admin/audit panel. */ export async function getRecentAuditLogs(limit = 200) { return prisma.auditLog.findMany({ orderBy: { createdAt: "desc" }, @@ -51,8 +79,8 @@ export async function getRecentAuditLogs(limit = 200) { } /** - * Miembros de un tablero (con datos del usuario) más el ownerId, para el panel de - * miembros. El propietario se marca a partir de ownerId. + * Members of a board (with user data) plus the ownerId, for the members panel. The owner + * is identified from ownerId. */ export async function getBoardMembers(boardId: string) { const board = await prisma.board.findUnique({ @@ -71,9 +99,7 @@ export async function getBoardMembers(boardId: string) { return board; } -// --- Hito 8: notificaciones --- - -/** Notificaciones del usuario, más recientes primero (para la campana de la navbar). */ +/** The user's notifications, newest first (for the navbar bell). */ export async function getNotificationsForUser(userId: string, limit = 20) { return prisma.notification.findMany({ where: { userId }, @@ -92,14 +118,12 @@ export async function getNotificationsForUser(userId: string, limit = 20) { }); } -/** Cuántas notificaciones sin leer tiene el usuario (badge de la campana). */ +/** How many unread notifications the user has (bell badge). */ export async function getUnreadNotificationCount(userId: string) { return prisma.notification.count({ where: { userId, read: false } }); } -// --- Hito 9: API pública (tokens de acceso) --- - -/** Tokens de API del usuario (sin el hash). Para la página /settings. */ +/** The user's API tokens (without the hash). For the /settings page. */ export async function getApiTokensForUser(userId: string) { return prisma.apiToken.findMany({ where: { userId }, @@ -116,8 +140,6 @@ export async function getApiTokensForUser(userId: string) { }); } -// --- Hito 6: búsqueda global --- - export type SearchResults = { boards: { id: string; title: string; background: string | null }[]; cards: { @@ -131,18 +153,18 @@ export type SearchResults = { }; /** - * Búsqueda global acotada a lo que el usuario puede ver: tableros donde es dueño o - * miembro, y tarjetas dentro de esos tableros. Coincidencia por subcadena insensible - * a mayúsculas (ILIKE, vía Prisma `contains` + `mode: "insensitive"`). El filtro de - * acceso se repite en el WHERE de cada consulta, igual que en getBoardsForUser: el - * servidor es la única autoridad, nunca confiamos en el cliente. Ver docs/search.md. + * Global search limited to what the user can see: boards they own or are a member of, and + * cards in those boards. Case-insensitive substring match (ILIKE, via Prisma `contains` + + * `mode: "insensitive"`). The access filter is repeated in each query's WHERE, as in + * getBoardsForUser: the server is the only authority, the client is never trusted. See + * docs/search.md. */ export async function searchForUser( userId: string, query: string, ): Promise<SearchResults> { const q = query.trim(); - // Evita consultas con un único carácter (demasiado ruido y carga). + // Skips single-character queries (too much noise and load). if (q.length < 2) return { boards: [], cards: [] }; const [boards, cards] = await Promise.all([ @@ -208,8 +230,8 @@ export async function searchForUser( }; } -// Guarda de acceso de la página del tablero: dueño o miembro. La comparten la página y -// su <title> para que el título nunca revele el nombre de un tablero ajeno. +// Access guard of the board page: owner or member. Shared by the page and its <title> so +// the title never reveals the name of someone else's board. function boardAccessWhere( boardId: string, userId: string, @@ -220,7 +242,7 @@ function boardAccessWhere( }; } -/** Título del tablero si el usuario tiene acceso; null si no. Para generateMetadata. */ +/** Board title if the user has access; null otherwise. For generateMetadata. */ export const getBoardTitleForUser = cache( async (boardId: string, userId: string): Promise<string | null> => { const board = await prisma.board.findFirst({ diff --git a/app/lib/rate-limit.ts b/app/lib/rate-limit.ts index 5c57441..1dae162 100644 --- a/app/lib/rate-limit.ts +++ b/app/lib/rate-limit.ts @@ -1,13 +1,13 @@ import "server-only"; -// Limitador de tasa en memoria (ventana fija). Suficiente para el despliegue estándar -// de una sola instancia: protege el login de fuerza bruta sin dependencias externas ni -// Redis. Para multi-instancia el conteo sería por réplica (límite documentado en -// docs/operations.md). El Map se guarda en globalThis para sobrevivir al HMR. - -// `blocked` marca que la ventana ya rechazó algún intento: sirve para avisar solo del -// PRIMER bloqueo (firstBlock) y no escribir una fila de auditoría por cada intento -// rechazado durante un ataque sostenido. +// In-memory rate limiter (fixed window). Enough for the standard single-instance +// deployment: it protects sign-in from brute force with no external dependencies or Redis. +// With several instances the count would be per replica (limitation documented in +// docs/operations.md). The Map lives in globalThis to survive HMR. + +// `blocked` marks that the window has already rejected an attempt: it lets callers report +// only the FIRST block (firstBlock) instead of writing an audit row for every attempt +// rejected during a sustained attack. type Bucket = { count: number; resetAt: number; blocked?: boolean }; const g = globalThis as unknown as { @@ -19,13 +19,13 @@ g.__orgtableRateBuckets = buckets; export type RateLimitResult = { ok: boolean; retryAfterSec: number; - /** true solo en el primer intento rechazado de cada ventana. */ + /** true only on the first rejected attempt of each window. */ firstBlock: boolean; }; /** - * Cuenta un intento para `key`. Permite hasta `limit` por ventana de `windowMs`. - * Devuelve { ok: false, retryAfterSec } cuando se supera el límite. + * Counts an attempt for `key`. Allows up to `limit` per `windowMs` window. + * Returns { ok: false, retryAfterSec } when the limit is exceeded. */ export function rateLimit( key: string, @@ -34,7 +34,7 @@ export function rateLimit( ): RateLimitResult { const now = Date.now(); - // Limpieza perezosa para que el Map no crezca sin fin en instancias longevas. + // Lazy cleanup so the Map does not grow forever on long-lived instances. if (buckets.size > 5000) { for (const [k, b] of buckets) if (b.resetAt <= now) buckets.delete(k); } @@ -57,7 +57,7 @@ export function rateLimit( return { ok: true, retryAfterSec: 0, firstBlock: false }; } -/** Olvida los intentos de `key` (p. ej. tras un login correcto). */ +/** Forgets the attempts for `key` (e.g. after a successful sign-in). */ export function resetRateLimit(key: string): void { buckets.delete(key); } diff --git a/app/lib/realtime/bus.ts b/app/lib/realtime/bus.ts index f4af816..dfe0b8d 100644 --- a/app/lib/realtime/bus.ts +++ b/app/lib/realtime/bus.ts @@ -3,37 +3,37 @@ import { EventEmitter } from "node:events"; import { Client } from "pg"; import { z } from "zod"; -// Bus de publicación/suscripción para la sincronización en vivo (Hito 4). +// Publish/subscribe bus for live sync. // -// Por defecto es EN PROCESO (un EventEmitter): suficiente para el despliegue estándar -// de orgtable (un único contenedor de app, ver docker-compose.yml). Las Server Actions -// publican un evento por tablero tras mutar; las conexiones SSE (app/api/boards/[id]/ -// stream) se suscriben y reenvían "refresca" al navegador. +// By default it is IN-PROCESS (an EventEmitter): enough for orgtable's standard deployment +// (a single app container, see docker-compose.yml). Server Actions publish one event per +// board after mutating; SSE connections (app/api/boards/[id]/stream) subscribe and forward +// "refresh" to the browser. // -// Para despliegues MULTI-INSTANCIA hay un puente opcional sobre PostgreSQL -// LISTEN/NOTIFY (no Redis: ya dependemos de Postgres; ver ADR 0007). Se activa con -// ORGTABLE_REALTIME_PG_NOTIFY=1. Cada instancia escucha el canal y reemite localmente -// los eventos de OTRAS instancias (ignora los propios, ya emitidos en el acto). +// For MULTI-INSTANCE deployments there is an optional bridge over PostgreSQL LISTEN/NOTIFY +// (not Redis: Postgres is already a dependency; see ADR 0007). Enabled with +// ORGTABLE_REALTIME_PG_NOTIFY=1. Each instance listens on the channel and re-emits locally +// the events of OTHER instances (its own were already emitted on the spot). const CHANNEL = "orgtable_realtime"; const PG_NOTIFY_ENABLED = process.env.ORGTABLE_REALTIME_PG_NOTIFY === "1"; -// Singletons sobre globalThis: en desarrollo Next recarga módulos en caliente (HMR) y -// crearía emisores/clientes duplicados. En producción se crean una sola vez. +// Singletons on globalThis: in development Next hot-reloads modules (HMR) and would create +// duplicate emitters/clients. In production they are created only once. const g = globalThis as unknown as { __orgtableBus?: EventEmitter; __orgtableInstanceId?: string; - // Cliente LISTEN activo (solo cuando el LISTEN ya se ha confirmado). + // Active LISTEN client (only once the LISTEN has been confirmed). __orgtablePgClient?: Client; - // Arranque en curso: publishCrossInstance lo espera en vez de perder el evento. + // Start-up in progress: publishCrossInstance waits for it instead of losing the event. __orgtablePgBridgePromise?: Promise<void>; - // Único temporizador de reconexión y número de intentos fallidos seguidos (backoff). + // Single reconnect timer and number of consecutive failed attempts (backoff). __orgtablePgReconnectTimer?: ReturnType<typeof setTimeout>; __orgtablePgReconnectAttempt?: number; }; const emitter = g.__orgtableBus ?? new EventEmitter(); -// Sin límite de listeners: puede haber muchas conexiones SSE simultáneas. +// No listener limit: there can be many simultaneous SSE connections. emitter.setMaxListeners(0); g.__orgtableBus = emitter; @@ -42,8 +42,8 @@ const INSTANCE_ID = g.__orgtableInstanceId = INSTANCE_ID; export type BoardEvent = { type: "board:changed"; boardId: string; at: number }; -// Evento por usuario (Hito 8): un "ping" para que la pestaña recargue sus -// notificaciones. No lleva el contenido, solo avisa de que hay algo nuevo. +// Per-user event: a "ping" so the tab reloads its notifications. It carries no content, +// it only signals that there is something new. export type UserEvent = { type: "notification:new"; userId: string; at: number }; const channelFor = (boardId: string) => `board:${boardId}`; @@ -67,7 +67,7 @@ function emitLocalUser(userId: string): void { emitter.emit(channelForUser(userId), event); } -/** Suscribe a los cambios de un tablero. Devuelve la función para cancelar. */ +/** Subscribes to a board's changes. Returns the unsubscribe function. */ export function subscribeBoard( boardId: string, handler: (event: BoardEvent) => void, @@ -77,7 +77,7 @@ export function subscribeBoard( return () => emitter.off(channelFor(boardId), handler); } -/** Suscribe a los eventos de un usuario (notificaciones). Devuelve cómo cancelar. */ +/** Subscribes to a user's events (notifications). Returns the unsubscribe function. */ export function subscribeUser( userId: string, handler: (event: UserEvent) => void, @@ -87,14 +87,14 @@ export function subscribeUser( return () => emitter.off(channelForUser(userId), handler); } -/** Publica que un tablero cambió. Lo llaman las Server Actions tras cada mutación. */ +/** Publishes that a board changed. Server Actions call it after every mutation. */ export async function publishBoardEvent(boardId: string): Promise<void> { emitLocal(boardId); if (PG_NOTIFY_ENABLED) { await publishCrossInstance({ kind: "board", id: boardId }).catch( (err: unknown) => { - // Un fallo del puente nunca debe romper la mutación: el modo en proceso sigue. - console.error("[realtime] no se pudo publicar en el puente LISTEN/NOTIFY", { + // A bridge failure must never break the mutation: in-process delivery still works. + console.error("[realtime] could not publish on the LISTEN/NOTIFY bridge", { kind: "board", id: boardId, }, err); @@ -103,14 +103,14 @@ export async function publishBoardEvent(boardId: string): Promise<void> { } } -/** Publica que un usuario tiene una notificación nueva (entrega en vivo). */ +/** Publishes that a user has a new notification (live delivery). */ export async function publishUserEvent(userId: string): Promise<void> { emitLocalUser(userId); if (PG_NOTIFY_ENABLED) { await publishCrossInstance({ kind: "user", id: userId }).catch( (err: unknown) => { - // Best-effort: el modo en proceso ya emitió localmente. - console.error("[realtime] no se pudo publicar en el puente LISTEN/NOTIFY", { + // Best-effort: in-process delivery has already emitted locally. + console.error("[realtime] could not publish on the LISTEN/NOTIFY bridge", { kind: "user", id: userId, }, err); @@ -119,13 +119,13 @@ export async function publishUserEvent(userId: string): Promise<void> { } } -// --- Puente opcional PostgreSQL LISTEN/NOTIFY (multi-instancia) --- +// --- Optional PostgreSQL LISTEN/NOTIFY bridge (multi-instance) --- const RECONNECT_MIN_MS = 1_000; const RECONNECT_MAX_MS = 30_000; -// Cualquiera con acceso a la BD puede hacer NOTIFY en el canal: el payload se valida en -// vez de fiarse de un cast (un payload sin id emitiría en "board:undefined"). +// Anyone with database access can NOTIFY on the channel: the payload is validated rather +// than trusted through a cast (a payload without an id would emit on "board:undefined"). const bridgePayloadSchema = z.object({ kind: z.enum(["board", "user"]), id: z.string().min(1), @@ -133,13 +133,13 @@ const bridgePayloadSchema = z.object({ }); export type BridgePayload = z.infer<typeof bridgePayloadSchema>; -/** Parsea y valida el payload de un NOTIFY. Devuelve null si no es válido. */ +/** Parses and validates a NOTIFY payload. Returns null if it is invalid. */ export function parseBridgePayload(raw: string): BridgePayload | null { let data: unknown; try { data = JSON.parse(raw); } catch { - // No es JSON: se trata igual que una estructura inválida. + // Not JSON: treated the same as an invalid structure. return null; } const parsed = bridgePayloadSchema.safeParse(data); @@ -150,13 +150,13 @@ function handleBridgeNotification(raw: string | undefined): void { if (!raw) return; const data = parseBridgePayload(raw); if (!data) { - // Sin volcar el payload: puede venir de cualquiera con acceso a la BD. + // The payload is not dumped: it may come from anyone with database access. console.warn( - `[realtime] NOTIFY descartado: no es JSON o no tiene { kind, id, origin } válidos (${raw.length} caracteres)`, + `[realtime] NOTIFY discarded: not JSON or no valid { kind, id, origin } (${raw.length} characters)`, ); return; } - // Ignora los eventos originados en esta instancia: ya se emitieron localmente. + // Ignores events from this instance: they were already emitted locally. if (data.origin === INSTANCE_ID) return; if (data.kind === "user") emitLocalUser(data.id); else emitLocal(data.id); @@ -164,13 +164,13 @@ function handleBridgeNotification(raw: string | undefined): void { function closeClient(client: Client): void { client.end().catch((err: unknown) => { - console.error("[realtime] no se pudo cerrar la conexión del puente LISTEN/NOTIFY", err); + console.error("[realtime] could not close the LISTEN/NOTIFY bridge connection", err); }); } -// La conexión del puente cayó: se cierra (si no, cada reintento abriría otra conexión -// hasta agotar las de Postgres) y se programa la reconexión. Solo actúa si `client` es -// el activo, para que un "error" seguido de "end" no lo haga dos veces. +// The bridge connection dropped: close it (otherwise each retry would open another +// connection until Postgres runs out) and schedule a reconnect. Only acts when `client` is +// the active one, so an "error" followed by "end" does not do it twice. function dropPgClient(client: Client): void { if (g.__orgtablePgClient !== client) return; g.__orgtablePgClient = undefined; @@ -178,8 +178,8 @@ function dropPgClient(client: Client): void { scheduleReconnect(); } -// Reconexión con backoff exponencial (1 s → 30 s) y un único temporizador. Si ya no -// queda ningún suscriptor no se programa: la próxima suscripción arranca el puente. +// Reconnect with exponential backoff (1 s → 30 s) and a single timer. Not scheduled when +// no subscriber is left: the next subscription starts the bridge. function scheduleReconnect(): void { if (g.__orgtablePgReconnectTimer) return; if (emitter.eventNames().length === 0) return; @@ -188,11 +188,11 @@ function scheduleReconnect(): void { g.__orgtablePgReconnectAttempt = attempt + 1; const timer = setTimeout(() => { g.__orgtablePgReconnectTimer = undefined; - // Todos se desuscribieron durante la espera: no hace falta conexión. + // Everyone unsubscribed during the wait: no connection needed. if (emitter.eventNames().length === 0) return; void ensurePgBridge(); }, delay); - // No mantiene vivo el proceso solo por este reintento. + // Does not keep the process alive just for this retry. timer.unref?.(); g.__orgtablePgReconnectTimer = timer; } @@ -204,12 +204,12 @@ async function startPgBridge(): Promise<void> { client = current; current.on("notification", (msg) => handleBridgeNotification(msg.payload)); current.on("error", (err) => { - console.error("[realtime] error en la conexión del puente LISTEN/NOTIFY", err); + console.error("[realtime] error on the LISTEN/NOTIFY bridge connection", err); dropPgClient(current); }); current.on("end", () => { if (g.__orgtablePgClient === current) { - console.error("[realtime] la conexión del puente LISTEN/NOTIFY se cerró"); + console.error("[realtime] the LISTEN/NOTIFY bridge connection closed"); } dropPgClient(current); }); @@ -218,8 +218,8 @@ async function startPgBridge(): Promise<void> { g.__orgtablePgClient = current; g.__orgtablePgReconnectAttempt = 0; } catch (err) { - // El error de pg no incluye la contraseña; DATABASE_URL no se registra nunca. - console.error("[realtime] no se pudo iniciar el puente LISTEN/NOTIFY", err); + // pg's error does not include the password; DATABASE_URL is never logged. + console.error("[realtime] could not start the LISTEN/NOTIFY bridge", err); if (client) closeClient(client); scheduleReconnect(); } @@ -228,7 +228,7 @@ async function startPgBridge(): Promise<void> { function ensurePgBridge(): Promise<void> { if (!PG_NOTIFY_ENABLED) return Promise.resolve(); if (g.__orgtablePgClient) return Promise.resolve(); - // Con una reconexión programada se respeta el backoff: no se abre otra conexión. + // With a reconnect scheduled the backoff is honoured: no other connection is opened. if (g.__orgtablePgReconnectTimer) return Promise.resolve(); if (!g.__orgtablePgBridgePromise) { g.__orgtablePgBridgePromise = startPgBridge().finally(() => { @@ -242,8 +242,8 @@ async function publishCrossInstance(target: { kind: "board" | "user"; id: string; }): Promise<void> { - // Espera un arranque en curso: antes, mientras el estado era "starting", el evento - // se descartaba sin publicarse. + // Waits for a start-up in progress: previously, while the state was "starting", the + // event was dropped without being published. await ensurePgBridge(); const client = g.__orgtablePgClient; if (!client) return; diff --git a/app/lib/realtime/notify.ts b/app/lib/realtime/notify.ts index c470994..df3b0df 100644 --- a/app/lib/realtime/notify.ts +++ b/app/lib/realtime/notify.ts @@ -2,10 +2,9 @@ import "server-only"; import { revalidatePath } from "next/cache"; import { publishBoardEvent } from "./bus"; -// Helper único que toda mutación de tablero llama tras escribir en la base de datos: -// (1) revalida la caché de la ruta del tablero para quien recargue, y (2) publica un -// evento en el bus para que las pestañas conectadas por SSE se refresquen al instante. -// Ver app/lib/realtime/bus.ts y docs/realtime.md. +// Single helper every board mutation calls after writing to the database: (1) revalidates +// the board route cache for whoever reloads, and (2) publishes an event on the bus so tabs +// connected over SSE refresh at once. See app/lib/realtime/bus.ts and docs/realtime.md. export function revalidateBoard(boardId: string): void { revalidatePath(`/board/${boardId}`); void publishBoardEvent(boardId); diff --git a/app/lib/realtime/reconnect.ts b/app/lib/realtime/reconnect.ts index e187177..4ed2c56 100644 --- a/app/lib/realtime/reconnect.ts +++ b/app/lib/realtime/reconnect.ts @@ -1,25 +1,24 @@ -// Resincronización al reconectar un EventSource (tablero y campana de notificaciones). +// Resync when an EventSource reconnects (board and notification bell). // -// El servidor no reenvía los eventos perdidos (no hay Last-Event-ID ni historial): lo -// que cambió mientras la conexión estaba caída (suspensión del portátil, reinicio del -// servidor, corte de red) no llega nunca. Por eso, cada vez que el EventSource vuelve a -// abrirse DESPUÉS de la primera vez, se pide un refresh para traer el estado actual. -// En la primera apertura no hace falta: la página acaba de renderizarse con datos frescos. - -// Un solo refresco por pestaña. router.refresh() vuelve a renderizar todo el árbol de -// la ruta, layout incluido: en /board/[id] la campana (layout) y BoardRealtime (página) -// tienen cada uno su EventSource, que el servidor corta a la vez (vida máxima de 15 min, -// reinicio, suspensión del portátil). Con un debounce por componente cada reconexión -// costaba dos renders completos del tablero; con este temporizador de módulo las -// peticiones de ambos dentro de la ventana se agrupan en uno. +// The server does not resend missed events (no Last-Event-ID or history): whatever changed +// while the connection was down (laptop sleep, server restart, network cut) never arrives. +// So every time the EventSource opens again AFTER the first time, a refresh is requested +// to fetch the current state. Not needed on the first open: the page was just rendered +// with fresh data. + +// A single refresh per tab. router.refresh() re-renders the whole route tree, layout +// included: on /board/[id] the bell (layout) and BoardRealtime (page) each have their own +// EventSource, which the server cuts at the same time (15 min maximum lifetime, restart, +// laptop sleep). With a per-component debounce each reconnect cost two full board renders; +// with this module-level timer, requests from both within the window are merged. export const REFRESH_DEBOUNCE_MS = 200; let pendingRefresh: ReturnType<typeof setTimeout> | null = null; /** - * Programa un refresh agrupado con los que pida cualquier componente en los próximos - * REFRESH_DEBOUNCE_MS. No se cancela al desmontar: el temporizador es compartido y - * cancelarlo anularía el refresh que pidió el otro componente; un router.refresh() - * tras desmontar solo refresca la ruta actual, sin efectos dañinos. + * Schedules a refresh merged with those requested by any component in the next + * REFRESH_DEBOUNCE_MS. Not cancelled on unmount: the timer is shared and cancelling it + * would drop the refresh the other component asked for; a router.refresh() after unmount + * only refreshes the current route, with no harmful effects. */ export function scheduleRefresh(refresh: () => void): void { if (pendingRefresh) clearTimeout(pendingRefresh); @@ -29,7 +28,7 @@ export function scheduleRefresh(refresh: () => void): void { }, REFRESH_DEBOUNCE_MS); } -/** Devuelve el manejador de "open": ignora la primera apertura y avisa en las siguientes. */ +/** Returns the "open" handler: ignores the first open and signals the following ones. */ export function createReconnectRefresher(onReconnect: () => void): () => void { let opened = false; return () => { @@ -38,37 +37,37 @@ export function createReconnectRefresher(onReconnect: () => void): () => void { }; } -// Reintento cuando el navegador se rinde. EventSource reconecta por su cuenta si la -// respuesta fue 200 o la conexión se cortó; ante cualquier otro estado (p. ej. el 502 -// de un proxy TLS mientras scripts/update.sh reinicia la app, o un 401/403) queda en -// CLOSED para siempre y el tiempo real se perdía sin aviso hasta recargar la pestaña. -// Por eso se crea un EventSource nuevo con backoff exponencial de 3 s a 30 s. +// Retry when the browser gives up. EventSource reconnects on its own if the response was +// 200 or the connection was cut; on any other status (e.g. a TLS proxy's 502 while +// scripts/update.sh restarts the app, or a 401/403) it stays CLOSED forever and live +// updates were silently lost until the tab was reloaded. Hence a new EventSource is +// created with exponential backoff from 3 s to 30 s. // -// EventSource no expone el estado HTTP, así que al quedar CLOSED se pregunta al mismo -// endpoint (probeStatus) y solo se avisa de acceso denegado ante 401/403. No se puede -// hacer router.refresh() a ciegas: en Next 16, si la petición RSC del refresh recibe -// una respuesta no OK o falla por red, el router hace una navegación completa (ver -// fetch-server-response.js, doMpaNavigation) y la pestaña acababa en la página -// «502 Bad Gateway» del proxy, perdiendo lo que no se hubiera guardado. +// EventSource does not expose the HTTP status, so once CLOSED the same endpoint is asked +// (probeStatus) and access denied is only reported on 401/403. A blind router.refresh() +// is not possible: in Next 16, if the refresh's RSC request gets a non-OK response or a +// network error, the router does a full navigation (see fetch-server-response.js, +// doMpaNavigation) and the tab ended up on the proxy's "502 Bad Gateway" page, losing +// anything unsaved. export const RETRY_BASE_MS = 3_000; export const RETRY_MAX_MS = 30_000; -// Plazo máximo de la comprobación. fetch no tiene timeout en el navegador: si el proxy -// retiene la conexión mientras la app arranca, o verifySession espera una conexión del -// pool durante el mismo fallo de BD que dio el 500, la comprobación no respondería -// nunca y, como el reintento se programa al terminarla, no se recrearía la fuente. +// Maximum time for the check. fetch has no timeout in the browser: if the proxy holds the +// connection while the app starts, or verifySession waits for a pool connection during the +// same database failure that caused the 500, the check would never answer and, since the +// retry is scheduled when it ends, the source would never be recreated. export const PROBE_TIMEOUT_MS = 10_000; -// Valor de EventSource.CLOSED. Se usa el literal porque en Node (tests) no existe el -// global EventSource. +// Value of EventSource.CLOSED. The literal is used because the EventSource global does +// not exist in Node (tests). const CLOSED = 2; -/** Espera antes del reintento número `attempt` (0, 1, 2…): 3 s, 6 s, 12 s, 24 s, 30 s… */ +/** Wait before retry number `attempt` (0, 1, 2…): 3 s, 6 s, 12 s, 24 s, 30 s… */ export function retryDelay(attempt: number): number { return Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS); } -/** Lo que el helper usa de un EventSource (permite probarlo sin navegador). */ +/** What the helper uses from an EventSource (lets it be tested without a browser). */ export type EventSourceLike = { readonly readyState: number; addEventListener(type: string, listener: () => void): void; @@ -77,15 +76,15 @@ export type EventSourceLike = { }; /** - * Estado HTTP del endpoint SSE, o null si no hubo respuesta (error de red, servidor - * caído, o `signal` abortada por el plazo o el desmontaje). Es un GET normal que se - * aborta en cuanto llegan las cabeceras: con un 200 el stream se abre y se cierra al - * momento (el servidor limpia la suscripción por el abort). No se usa HEAD porque en - * App Router se deriva del GET y abriría igualmente el stream. + * HTTP status of the SSE endpoint, or null without a response (network error, server + * down, or `signal` aborted by the timeout or unmount). It is a plain GET aborted as soon + * as the headers arrive: with a 200 the stream opens and closes at once (the server + * cleans up the subscription on abort). HEAD is not used because in the App Router it is + * derived from GET and would open the stream anyway. */ export async function probeStatus(url: string, signal?: AbortSignal): Promise<number | null> { const controller = new AbortController(); - // Se encadena a mano en vez de con AbortSignal.any, que Safari solo tiene desde 17.4. + // Chained by hand instead of AbortSignal.any, which Safari only has since 17.4. const forwardAbort = () => controller.abort(); if (signal?.aborted) return null; signal?.addEventListener("abort", forwardAbort, { once: true }); @@ -97,9 +96,9 @@ export async function probeStatus(url: string, signal?: AbortSignal): Promise<nu }); return res.status; } catch (err) { - // Sin respuesta no hay estado que interpretar: el llamador reintenta con backoff. - // Solo se toleran el fallo de red (TypeError de fetch) y el abort pedido desde - // fuera; cualquier otro error es un bug. + // No response, no status to interpret: the caller retries with backoff. Only a + // network failure (fetch's TypeError) and an abort requested from outside are + // tolerated; any other error is a bug. if (err instanceof TypeError || controller.signal.aborted) return null; throw err; } finally { @@ -109,19 +108,19 @@ export async function probeStatus(url: string, signal?: AbortSignal): Promise<nu } /** - * Mantiene un EventSource abierto mientras el componente esté montado y devuelve la - * función de limpieza para el efecto. - * - `events`: manejadores por nombre de evento SSE. - * - `onReconnect`: se llama en cada apertura salvo la primera (también en las fuentes - * recreadas), para traer lo perdido durante el corte. - * - `probe`: cuando el navegador deja la fuente en CLOSED, averigua el estado HTTP del - * endpoint (probeStatus en la app; inyectable para probarlo sin navegador). Recibe - * una señal que se aborta al pasar PROBE_TIMEOUT_MS o al desmontar; aunque `probe` la - * ignore, pasado el plazo se reintenta como ante un error de red. - * - `onAccessDenied`: solo si `probe` da 401/403 (sesión o acceso revocados); con un - * `router.refresh()` la página redirige y el desmontaje cancela el reintento. Ante un - * 5xx o un error de red no se llama: solo se reintenta, y al reabrirse `onReconnect` - * ya resincroniza con el servidor sano. + * Keeps an EventSource open while the component is mounted and returns the cleanup + * function for the effect. + * - `events`: handlers by SSE event name. + * - `onReconnect`: called on every open except the first (recreated sources included), + * to fetch what was missed during the outage. + * - `probe`: when the browser leaves the source CLOSED, finds out the endpoint's HTTP + * status (probeStatus in the app; injectable to test without a browser). It gets a + * signal aborted after PROBE_TIMEOUT_MS or on unmount; even if `probe` ignores it, the + * retry happens after the timeout as on a network error. + * - `onAccessDenied`: only when `probe` gives 401/403 (session or access revoked); with a + * `router.refresh()` the page redirects and unmounting cancels the retry. Not called on + * a 5xx or a network error: it just retries, and on reopening `onReconnect` resyncs with + * the healthy server. */ export function keepEventSourceOpen(options: { create: () => EventSourceLike; @@ -131,8 +130,8 @@ export function keepEventSourceOpen(options: { onAccessDenied: () => void; }): () => void { const { create, events, onReconnect, probe, onAccessDenied } = options; - // Una sola instancia para todas las fuentes: la primera apertura de una fuente - // recreada sí es una reconexión. + // A single instance for all sources: the first open of a recreated source is a + // reconnect. const refreshOnReopen = createReconnectRefresher(onReconnect); let source: EventSourceLike | null = null; let retryTimer: ReturnType<typeof setTimeout> | null = null; @@ -163,8 +162,8 @@ export function keepEventSourceOpen(options: { attempt += 1; const controller = new AbortController(); probeController = controller; - // El plazo compite con la comprobación en vez de fiarse de que `probe` respete la - // señal: el reintento tiene que programarse pase lo que pase. + // The timeout races the check instead of trusting `probe` to honour the signal: the + // retry must be scheduled no matter what. const timedOut = new Promise<null>((resolve) => { probeTimer = setTimeout(() => { controller.abort(); @@ -172,18 +171,18 @@ export function keepEventSourceOpen(options: { }, PROBE_TIMEOUT_MS); }); const checked = probe(controller.signal).catch((err: unknown) => { - // Abortada por el plazo o el desmontaje: ya se sabe por qué, no es un fallo. + // Aborted by the timeout or unmount: the reason is known, it is not a failure. if (controller.signal.aborted) return null; - // Si no se sabe por qué se cerró, no se refresca (podría navegar a una página - // de error): se reintenta igual que ante un 5xx. - console.error("[realtime] no se pudo comprobar por qué se cerró el stream", err); + // If it is unknown why it closed, do not refresh (it could navigate to an error + // page): retry as on a 5xx. + console.error("[realtime] could not check why the stream closed", err); return null; }); void Promise.race([checked, timedOut]).then((status) => { if (probeTimer) clearTimeout(probeTimer); probeTimer = null; probeController = null; - // El componente pudo desmontarse mientras se comprobaba. + // The component may have unmounted during the check. if (stopped) return; if (status === 401 || status === 403) onAccessDenied(); retryTimer = setTimeout(connect, delay); @@ -208,7 +207,7 @@ export function keepEventSourceOpen(options: { stopped = true; if (retryTimer) clearTimeout(retryTimer); retryTimer = null; - // Corta la comprobación en curso para no dejar la petición colgada tras desmontar. + // Aborts the ongoing check so the request is not left hanging after unmount. if (probeTimer) clearTimeout(probeTimer); probeTimer = null; probeController?.abort(); diff --git a/app/lib/realtime/sse.ts b/app/lib/realtime/sse.ts index 5a02fd7..78d394d 100644 --- a/app/lib/realtime/sse.ts +++ b/app/lib/realtime/sse.ts @@ -1,15 +1,15 @@ import "server-only"; import { prisma } from "@/app/lib/prisma"; -// Ciclo de vida común de los streams SSE (tablero y notificaciones). Vive aparte para -// que las dos rutas compartan la misma limpieza: antes cada una tenía su copia y ambas -// dejaban el intervalo y la suscripción colgados si `enqueue` fallaba. Ver docs/realtime.md. +// Shared lifecycle of the SSE streams (board and notifications). Kept separate so both +// routes share the same cleanup: each used to have its own copy and both left the interval +// and the subscription dangling when `enqueue` failed. See docs/realtime.md. -/** Latido para proxies y, a la vez, momento en que se revalida el acceso. */ +/** Heartbeat for proxies and, at the same time, when access is checked again. */ export const HEARTBEAT_MS = 25_000; /** - * Vida máxima de una conexión. Al cerrarla, EventSource reconecta y la ruta vuelve a - * autorizar desde cero con la cookie (verifySession), no solo con la sesión guardada. + * Maximum lifetime of a connection. When it closes, EventSource reconnects and the route + * authorizes from scratch with the cookie (verifySession), not just the stored session. */ export const MAX_STREAM_MS = 15 * 60_000; @@ -17,13 +17,14 @@ const SSE_HEADERS = { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", - // Desactiva el buffering de nginx para que los eventos lleguen al instante. + // Disables nginx buffering so events arrive at once. "X-Accel-Buffering": "no", } as const; /** - * true si la sesión sigue activa (ni revocada ni caducada). Se consulta directamente la - * tabla porque fuera de la petición inicial no hay cookies: verifySession no sirve aquí. + * true if the session is still active (neither revoked nor expired). Queries the table + * directly because there are no cookies outside the initial request: verifySession does + * not work here. */ export async function isSessionActive(sessionId: string): Promise<boolean> { const session = await prisma.session.findUnique({ @@ -39,17 +40,18 @@ export async function isSessionActive(sessionId: string): Promise<boolean> { export function createSseResponse(opts: { signal: AbortSignal; - /** Etiqueta para los logs (sin datos personales), p. ej. "tablero". */ + /** Label for the logs (no personal data), e.g. "board". */ label: string; - /** Se suscribe al bus y devuelve cómo cancelar la suscripción. */ + /** Subscribes to the bus and returns how to unsubscribe. */ subscribe: (send: (chunk: string) => void) => () => void; - /** Se llama en cada latido; si devuelve false, el stream se cierra. */ + /** Called on every heartbeat; when it returns false, the stream closes. */ isStillAuthorized: () => Promise<boolean>; }): Response { const { signal, label, subscribe, isStillAuthorized } = opts; - // El cliente pudo irse mientras se autorizaba (los await previos): si el abort ya - // ocurrió, el listener de "abort" nunca se dispararía y la suscripción quedaría viva. + // The client may have left during authorization (the earlier awaits): if the abort + // already happened, the "abort" listener would never fire and the subscription would + // stay alive. if (signal.aborted) return new Response(null, { status: 204 }); const encoder = new TextEncoder(); @@ -60,8 +62,8 @@ export function createSseResponse(opts: { let unsubscribe: (() => void) | undefined; let controllerRef: ReadableStreamDefaultController<Uint8Array> | undefined; - // Idempotente: la llaman el abort, cancel(), un enqueue fallido, la revalidación y la - // vida máxima; cualquiera puede llegar primero. + // Idempotent: called by the abort, cancel(), a failed enqueue, the access check and the + // maximum lifetime; any of them may come first. const cleanup = () => { if (cleaned) return; cleaned = true; @@ -71,9 +73,9 @@ export function createSseResponse(opts: { try { controllerRef?.close(); } catch (err) { - // Tras cancel() o un error del stream, close() lanza: ya no hay nada que cerrar. + // After cancel() or a stream error, close() throws: there is nothing left to close. if (!(err instanceof TypeError)) { - console.error(`[realtime] error al cerrar el stream SSE de ${label}`, err); + console.error(`[realtime] error closing the ${label} SSE stream`, err); } } }; @@ -83,7 +85,7 @@ export function createSseResponse(opts: { try { controllerRef.enqueue(encoder.encode(chunk)); } catch { - // El consumidor ya no lee (stream cerrado o cancelado): se libera todo. + // The consumer no longer reads (stream closed or cancelled): release everything. cleanup(); } }; @@ -96,9 +98,9 @@ export function createSseResponse(opts: { if (!ok) cleanup(); }) .catch((err: unknown) => { - // Sin poder comprobar el acceso no se sigue emitiendo: el cliente reconecta y - // la ruta vuelve a autorizar. - console.error(`[realtime] no se pudo revalidar el acceso al stream de ${label}`, err); + // Without being able to check access, emitting stops: the client reconnects and + // the route authorizes again. + console.error(`[realtime] could not recheck access to the ${label} stream`, err); cleanup(); }) .finally(() => { @@ -109,7 +111,7 @@ export function createSseResponse(opts: { const stream = new ReadableStream<Uint8Array>({ start(controller) { controllerRef = controller; - // Comentario inicial: abre el stream y fija el intervalo de reconexión del cliente. + // Initial comment: opens the stream and sets the client's reconnect interval. send(": connected\n\n"); send("retry: 3000\n\n"); diff --git a/app/lib/roles.ts b/app/lib/roles.ts index 8a207ad..73b4c2e 100644 --- a/app/lib/roles.ts +++ b/app/lib/roles.ts @@ -1,44 +1,39 @@ -// Modelo de roles y permisos, compartido entre servidor y cliente (NO lleva -// "server-only" a propósito: los componentes cliente lo importan para ocultar los -// controles de escritura a quien no puede usarlos). La autorización REAL vive en el -// servidor (cada Server Action vuelve a comprobar el rol); esto es solo para la UI y -// para no duplicar la jerarquía de roles. Ver docs/authorization.md. - -// Rol del usuario DENTRO de un tablero concreto (tabla board_members). +// Role and permission model, shared by server and client (deliberately NOT +// "server-only": Client Components import it to hide write controls from people who +// cannot use them). The REAL authorization lives on the server (every Server Action +// checks the role again); this is only for the UI and to avoid duplicating the role +// hierarchy. See docs/authorization.md. +// +// Role names shown in the UI live in the messages: `m.boards.roles[role]`. + +// The user's role WITHIN a given board (board_members table). export type BoardRole = "OWNER" | "EDITOR" | "VIEWER"; -// Rol GLOBAL del usuario en la instancia (tabla users). +// The user's GLOBAL role in the instance (users table). export type GlobalRole = "ADMIN" | "MEMBER"; -// Jerarquía: VIEWER < EDITOR < OWNER. Un rol "incluye" a todos los de menor rango. +// Hierarchy: VIEWER < EDITOR < OWNER. A role "includes" every lower-ranked one. const BOARD_RANK: Record<BoardRole, number> = { VIEWER: 0, EDITOR: 1, OWNER: 2, }; -/** true si `role` tiene al menos el rango de `min`. */ +/** true if `role` ranks at least as high as `min`. */ export function atLeastBoardRole(role: BoardRole, min: BoardRole): boolean { return BOARD_RANK[role] >= BOARD_RANK[min]; } -/** ¿Puede crear/editar/mover contenido (listas, tarjetas, checklists)? EDITOR u OWNER. */ +/** Can create/edit/move content (lists, cards, checklists)? EDITOR or OWNER. */ export function canEditBoard(role: BoardRole): boolean { return atLeastBoardRole(role, "EDITOR"); } -/** ¿Puede administrar el tablero (miembros, etiquetas, renombrar, borrar)? Solo OWNER. */ +/** Can administer the board (members, labels, rename, delete)? OWNER only. */ export function canManageBoard(role: BoardRole): boolean { return role === "OWNER"; } -// Etiquetas legibles para la interfaz (en español, como el resto de la UI). -export const BOARD_ROLE_LABELS: Record<BoardRole, string> = { - OWNER: "Propietario", - EDITOR: "Editor", - VIEWER: "Lector", -}; - -// Roles que un propietario puede asignar al invitar/editar miembros (no se asigna -// OWNER desde la UI: el propietario es el creador del tablero). +// Roles an owner can grant when inviting or editing members (OWNER is never granted +// from the UI: the owner is whoever created the board). export const ASSIGNABLE_BOARD_ROLES: BoardRole[] = ["EDITOR", "VIEWER"]; diff --git a/app/lib/search-params.ts b/app/lib/search-params.ts index d1beb51..d172545 100644 --- a/app/lib/search-params.ts +++ b/app/lib/search-params.ts @@ -1,9 +1,9 @@ /** - * Primer valor de un parámetro de búsqueda de la URL. + * First value of a URL search parameter. * - * En Next, `searchParams` da un array cuando la clave se repite (`?q=a&q=b`), aunque el - * tipo de la página diga `string`. Tratarlo como string rompía `/search` (`q.trim()` no - * existe en un array). Nos quedamos con el primero, que es lo que haría un formulario. + * In Next, `searchParams` yields an array when the key is repeated (`?q=a&q=b`), even + * though the page type says `string`. Treating it as a string broke `/search` + * (`q.trim()` does not exist on an array). We keep the first one, as a form would. */ export function firstParam( value: string | string[] | undefined, diff --git a/app/lib/services/account.ts b/app/lib/services/account.ts new file mode 100644 index 0000000..07f8251 --- /dev/null +++ b/app/lib/services/account.ts @@ -0,0 +1,333 @@ +import "server-only"; +import { prisma } from "@/app/lib/prisma"; +import { hashPassword, verifyPassword } from "@/app/lib/auth"; +import { deleteBoardWithAttachments } from "@/app/lib/services/attachments"; +import { isUniqueViolation } from "@/app/lib/services/users"; + +// What each person does with THEIR OWN account from /settings: profile, password, +// sessions and the GDPR rights (access and portability via the export, erasure via +// deletion). No request context or auditing, like services/users.ts: the Server Action +// resolves who the user is and records the event. See docs/privacy.md. + +export type UpdateProfileResult = + | { ok: true; emailChanged: boolean } + | { ok: false; error: "not_found" | "wrong_password" | "email_taken" }; + +/** + * Changes the name and, if requested, the email. Changing the email requires the current + * password: with a stolen session (an unlocked laptop) the account could be diverted to + * another address. + */ +export async function updateOwnProfile( + userId: string, + input: { name: string; email: string; currentPassword: string }, +): Promise<UpdateProfileResult> { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { email: true, passwordHash: true }, + }); + if (!user) return { ok: false, error: "not_found" }; + + const email = input.email.trim().toLowerCase(); + const emailChanged = email !== user.email; + if (emailChanged && !(await verifyPassword(input.currentPassword, user.passwordHash))) { + return { ok: false, error: "wrong_password" }; + } + + try { + await prisma.user.update({ + where: { id: userId }, + data: { name: input.name, email }, + }); + } catch (e) { + if (isUniqueViolation(e)) return { ok: false, error: "email_taken" }; + throw e; + } + return { ok: true, emailChanged }; +} + +export type ChangePasswordResult = + | { ok: true; revokedSessions: number } + | { ok: false; error: "not_found" | "wrong_password" }; + +/** + * Changes the password after checking the current one and closes the OTHER sessions: if + * someone else had the password, they lose access immediately. The session making the + * change stays open. API tokens are kept: the person creates them and they do not depend + * on the password (the admin reset does delete them, since it implies a compromised + * account). + */ +export async function changeOwnPassword( + userId: string, + input: { currentPassword: string; newPassword: string; keepSessionId: string }, +): Promise<ChangePasswordResult> { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { passwordHash: true }, + }); + if (!user) return { ok: false, error: "not_found" }; + if (!(await verifyPassword(input.currentPassword, user.passwordHash))) { + return { ok: false, error: "wrong_password" }; + } + + const passwordHash = await hashPassword(input.newPassword); + const [, revoked] = await prisma.$transaction([ + prisma.user.update({ where: { id: userId }, data: { passwordHash } }), + prisma.session.updateMany({ + where: { userId, revokedAt: null, id: { not: input.keepSessionId } }, + data: { revokedAt: new Date() }, + }), + ]); + return { ok: true, revokedSessions: revoked.count }; +} + +/** Active sessions (not revoked and not expired), most recent first. */ +export async function listActiveSessions(userId: string, now: Date) { + return prisma.session.findMany({ + where: { userId, revokedAt: null, expiresAt: { gt: now } }, + select: { id: true, userAgent: true, createdAt: true, expiresAt: true }, + orderBy: { createdAt: "desc" }, + }); +} + +/** Closes one of the user's own sessions. Returns 0 if missing or someone else's. */ +export async function revokeOwnSession( + userId: string, + sessionId: string, +): Promise<number> { + const { count } = await prisma.session.updateMany({ + where: { id: sessionId, userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + return count; +} + +/** Closes all of the user's own sessions except the current one. */ +export async function revokeOtherSessions( + userId: string, + keepSessionId: string, +): Promise<number> { + const { count } = await prisma.session.updateMany({ + where: { userId, revokedAt: null, id: { not: keepSessionId } }, + data: { revokedAt: new Date() }, + }); + return count; +} + +export type DeleteOwnAccountResult = + | { ok: true; boardsDeleted: number } + | { ok: false; error: "not_found" | "wrong_password" | "last_admin" }; + +/** + * Deletes the account and everything hanging off it (right to erasure, GDPR art. 17): + * + * - The boards it created, with their lists, cards and the FILES of their attachments + * (deleteBoardWithAttachments, which also cleans up the disk). + * - Via schema cascade: sessions, tokens, memberships, assignments, comments and + * notifications. + * - In the audit log, rows are left without a user (onDelete: SetNull) and the `detail` + * of rows targeting the user, which could contain their email, is cleared. + * + * The last ADMIN account cannot be deleted: the instance would be left with nobody to + * administer it. The check locks the admin rows (FOR UPDATE) so that two admins deleting + * themselves at the same time cannot leave the instance with none. + */ +export async function deleteOwnAccount( + userId: string, + password: string, +): Promise<DeleteOwnAccountResult> { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { passwordHash: true, role: true }, + }); + if (!user) return { ok: false, error: "not_found" }; + if (!(await verifyPassword(password, user.passwordHash))) { + return { ok: false, error: "wrong_password" }; + } + if (user.role === "ADMIN") { + const otherAdmins = await prisma.user.count({ + where: { role: "ADMIN", id: { not: userId } }, + }); + if (otherAdmins === 0) return { ok: false, error: "last_admin" }; + } + + // Boards one by one: each deletion locks its tree and then removes the files (see + // services/attachments.ts). Retried in case one is created meanwhile (another tab or + // the API), so the final DELETE with `boards: none` does not fail because of that race. + let boardsDeleted = 0; + for (let attempt = 0; attempt < 3; attempt++) { + const owned = await prisma.board.findMany({ + where: { ownerId: userId }, + select: { id: true }, + }); + for (const b of owned) { + boardsDeleted += await deleteBoardWithAttachments({ id: b.id, ownerId: userId }); + } + + const result = await prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT 1 FROM "User" WHERE role = 'ADMIN' FOR UPDATE`; + if (user.role === "ADMIN") { + const others = await tx.user.count({ + where: { role: "ADMIN", id: { not: userId } }, + }); + if (others === 0) return "last_admin" as const; + } + await tx.auditLog.updateMany({ + where: { targetType: "user", targetId: userId }, + data: { detail: null }, + }); + const { count } = await tx.user.deleteMany({ + where: { id: userId, boards: { none: {} } }, + }); + return count > 0 ? ("deleted" as const) : ("owns_boards" as const); + }); + + if (result === "last_admin") return { ok: false, error: "last_admin" }; + if (result === "deleted") return { ok: true, boardsDeleted }; + } + throw new Error("Could not delete the account: new boards keep appearing"); +} + +/** + * Personal data export (rights of access and portability, GDPR arts. 15 and 20) as + * machine-readable JSON. Includes everything the instance stores about the person or that + * they created; for other people's boards, only their membership and what they wrote. + * Never includes hashes (password, tokens) or attachment binaries. + */ +export async function exportUserData(userId: string) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + name: true, + email: true, + role: true, + createdAt: true, + updatedAt: true, + termsAcceptedAt: true, + }, + }); + if (!user) return null; + + const [ownedBoards, memberships, comments, assigned, notifications, sessions, tokens, audit] = + await Promise.all([ + prisma.board.findMany({ + where: { ownerId: userId }, + orderBy: { createdAt: "asc" }, + select: { + id: true, + title: true, + description: true, + background: true, + createdAt: true, + labels: { select: { name: true, color: true } }, + lists: { + orderBy: { position: "asc" }, + select: { + title: true, + cards: { + orderBy: { position: "asc" }, + select: { + title: true, + description: true, + dueDate: true, + createdAt: true, + labels: { select: { label: { select: { name: true } } } }, + checklists: { + orderBy: { position: "asc" }, + select: { + title: true, + items: { + orderBy: { position: "asc" }, + select: { text: true, done: true }, + }, + }, + }, + attachments: { + select: { filename: true, mimeType: true, size: true, createdAt: true }, + }, + }, + }, + }, + }, + }, + }), + prisma.boardMember.findMany({ + where: { userId }, + select: { role: true, addedAt: true, board: { select: { id: true, title: true } } }, + }), + prisma.comment.findMany({ + where: { authorId: userId }, + orderBy: { createdAt: "asc" }, + select: { + text: true, + createdAt: true, + card: { select: { title: true, list: { select: { boardId: true } } } }, + }, + }), + prisma.cardAssignee.findMany({ + where: { userId }, + select: { card: { select: { title: true, list: { select: { boardId: true } } } } }, + }), + prisma.notification.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: { type: true, title: true, body: true, read: true, createdAt: true }, + }), + prisma.session.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: { userAgent: true, createdAt: true, expiresAt: true, revokedAt: true }, + }), + prisma.apiToken.findMany({ + where: { userId }, + select: { + name: true, + prefix: true, + scope: true, + createdAt: true, + expiresAt: true, + lastUsedAt: true, + }, + }), + prisma.auditLog.findMany({ + where: { OR: [{ userId }, { targetType: "user", targetId: userId }] }, + orderBy: { createdAt: "asc" }, + select: { action: true, ip: true, createdAt: true }, + }), + ]); + + return { + orgtable: { type: "account", version: 1, exportedAt: new Date().toISOString() }, + account: user, + ownedBoards: ownedBoards.map((b) => ({ + ...b, + lists: b.lists.map((l) => ({ + ...l, + cards: l.cards.map((c) => ({ ...c, labels: c.labels.map((x) => x.label.name) })), + })), + })), + memberships: memberships.map((m) => ({ + boardId: m.board.id, + boardTitle: m.board.title, + role: m.role, + addedAt: m.addedAt, + })), + comments: comments.map((c) => ({ + text: c.text, + createdAt: c.createdAt, + cardTitle: c.card.title, + boardId: c.card.list.boardId, + })), + assignedCards: assigned.map((a) => ({ + cardTitle: a.card.title, + boardId: a.card.list.boardId, + })), + notifications, + sessions, + apiTokens: tokens, + securityLog: audit, + }; +} + +export type UserDataExport = NonNullable<Awaited<ReturnType<typeof exportUserData>>>; diff --git a/app/lib/services/assignees.ts b/app/lib/services/assignees.ts index f66eb3b..a8f4ffb 100644 --- a/app/lib/services/assignees.ts +++ b/app/lib/services/assignees.ts @@ -1,9 +1,9 @@ import "server-only"; import { prisma } from "@/app/lib/prisma"; -// Invariante de negocio de la asignación (Hito 10): una tarjeta solo se puede asignar a -// alguien que YA es miembro del tablero. Aislado aquí para reutilizarlo desde la Server -// Action y poder testearlo directamente. NO autoriza al actor: eso lo hace la acción. +// Assignment business invariant: a card can only be assigned to someone who is ALREADY a +// member of the board. Isolated here so the Server Action can reuse it and it can be +// tested directly. It does NOT authorize the actor: the action does that. export type AssignResult = { assigned: boolean; diff --git a/app/lib/services/attachments.ts b/app/lib/services/attachments.ts index 2f74ca1..54f2ed4 100644 --- a/app/lib/services/attachments.ts +++ b/app/lib/services/attachments.ts @@ -2,27 +2,27 @@ import "server-only"; import { prisma } from "@/app/lib/prisma"; import { deleteAttachmentFile } from "@/app/lib/storage"; -// Borrado de tarjetas, listas y tableros JUNTO con los binarios de sus adjuntos. El -// onDelete: Cascade del esquema borra las filas Attachment, pero no toca el disco: sin -// esto, los ficheros que el usuario cree borrados se quedaban en /data/uploads para -// siempre. Patrón común, dentro de una transacción: bloquear con `FOR UPDATE` las filas -// padre, leer los storedName y borrar en BD; SOLO después del commit, borrar los ficheros. -// Si la transacción falla no se toca el disco; si falla un unlink, la fila ya no existe y -// deleteAttachmentFile lo registra sin lanzar. Ver docs/attachments.md. +// Deletes cards, lists and boards TOGETHER with their attachment binaries. The schema's +// onDelete: Cascade removes the Attachment rows but does not touch the disk: without this, +// files the user believes deleted stayed in /data/uploads forever. Shared pattern, inside +// a transaction: lock the parent rows with `FOR UPDATE`, read the storedNames and delete +// in the DB; ONLY after the commit, delete the files. If the transaction fails the disk +// is untouched; if an unlink fails, the row is already gone and deleteAttachmentFile logs +// it without throwing. See docs/attachments.md. // -// Por qué el bloqueo: en READ COMMITTED, una subida (uploadAttachment) que insertara su -// fila entre el findMany y el delete no estaría en la lista leída, pero el delete -// esperaría a su commit y la borraría en cascada: fichero huérfano y subida «correcta». -// Insertar un Attachment toma FOR KEY SHARE sobre su Card (y crear una Card o una List -// lo toma sobre su padre), que choca con FOR UPDATE. Así, con los padres bloqueados, una -// subida en curso termina antes de que leamos (y su fichero entra en la lista), y una -// posterior espera, falla por la FK tras el borrado y uploadAttachment borra su fichero. +// Why the lock: under READ COMMITTED, an upload (uploadAttachment) inserting its row +// between the findMany and the delete would not be in the list read, but the delete would +// wait for its commit and cascade-delete it: orphaned file and a "successful" upload. +// Inserting an Attachment takes FOR KEY SHARE on its Card (and creating a Card or a List +// takes it on its parent), which conflicts with FOR UPDATE. So, with the parents locked, +// an in-flight upload finishes before we read (and its file makes the list), and a later +// one waits, fails on the FK after the delete, and uploadAttachment removes its file. async function removeFiles(storedNames: string[]): Promise<void> { await Promise.allSettled(storedNames.map((name) => deleteAttachmentFile(name))); } -/** Borra una tarjeta y los ficheros de sus adjuntos. */ +/** Deletes a card and its attachment files. */ export async function deleteCardWithAttachments(cardId: string): Promise<void> { const storedNames = await prisma.$transaction(async (tx) => { await tx.$queryRaw`SELECT 1 FROM "Card" WHERE id = ${cardId} FOR UPDATE`; @@ -36,10 +36,10 @@ export async function deleteCardWithAttachments(cardId: string): Promise<void> { await removeFiles(storedNames); } -/** Borra una lista (sus tarjetas caen en cascada) y los ficheros de sus adjuntos. */ +/** Deletes a list (its cards cascade) and their attachment files. */ export async function deleteListWithAttachments(listId: string): Promise<void> { const storedNames = await prisma.$transaction(async (tx) => { - // Primero la lista (frena altas de tarjetas) y luego sus tarjetas (frena subidas). + // First the list (blocks new cards), then its cards (blocks uploads). await tx.$queryRaw`SELECT 1 FROM "List" WHERE id = ${listId} FOR UPDATE`; await tx.$queryRaw`SELECT 1 FROM "Card" WHERE "listId" = ${listId} FOR UPDATE`; const rows = await tx.attachment.findMany({ @@ -53,18 +53,18 @@ export async function deleteListWithAttachments(listId: string): Promise<void> { } /** - * Borra un tablero y los ficheros de sus adjuntos. `ownerId` restringe el borrado al - * dueño (lo usa la Server Action, que no comprueba el rol antes); la API ya autorizó y - * pasa solo el id. Devuelve cuántos tableros se borraron (0 o 1). + * Deletes a board and its attachment files. `ownerId` restricts the deletion to the owner + * (used by the Server Action, which does not check the role beforehand); the API has + * already authorized and passes only the id. Returns how many boards were deleted (0 or 1). */ export async function deleteBoardWithAttachments(where: { id: string; ownerId?: string; }): Promise<number> { const { count, storedNames } = await prisma.$transaction(async (tx) => { - // De arriba abajo: tablero (frena altas de listas), listas (frena altas de tarjetas) - // y tarjetas (frena subidas). Se bloquea aunque luego no sea el dueño: solo dura la - // transacción y deleteMany no borrará nada. + // Top down: board (blocks new lists), lists (blocks new cards) and cards (blocks + // uploads). Locks even if the caller turns out not to be the owner: it only lasts for + // the transaction and deleteMany will delete nothing. await tx.$queryRaw`SELECT 1 FROM "Board" WHERE id = ${where.id} FOR UPDATE`; await tx.$queryRaw`SELECT 1 FROM "List" WHERE "boardId" = ${where.id} FOR UPDATE`; await tx.$queryRaw` @@ -77,7 +77,7 @@ export async function deleteBoardWithAttachments(where: { const deleted = await tx.board.deleteMany({ where }); return { count: deleted.count, - // Si no se borró nada (no es el dueño), no se toca ningún fichero. + // If nothing was deleted (not the owner), no file is touched. storedNames: deleted.count > 0 ? rows.map((r) => r.storedName) : [], }; }); diff --git a/app/lib/services/cards.ts b/app/lib/services/cards.ts index 048c6d9..95868b9 100644 --- a/app/lib/services/cards.ts +++ b/app/lib/services/cards.ts @@ -2,9 +2,9 @@ import "server-only"; import { prisma } from "@/app/lib/prisma"; import { positionAtEnd } from "@/app/lib/ordering"; -// Contexto de una tarjeta (su tablero) para autorizar y notificar. Aislado aquí para -// reutilizarlo desde las Server Actions de comentarios y asignaciones (Hito 10) y para -// poder testearlo. Devuelve null si la tarjeta no existe. +// A card's context (its board) for authorization and notifications. Isolated here so the +// comment and assignee Server Actions can reuse it and so it can be tested. Returns null +// if the card does not exist. export type CardContext = { cardId: string; cardTitle: string; @@ -13,8 +13,8 @@ export type CardContext = { }; /** - * ¿La tarjeta pertenece a este tablero? Lo usa moveCard para impedir mover a tu tablero una - * tarjeta de OTRO al que no tienes acceso (IDOR). Aislado aquí para poder testearlo. + * Does the card belong to this board? moveCard uses it to prevent moving into your board a + * card from ANOTHER board you have no access to (IDOR). Isolated here so it can be tested. */ export async function cardBelongsToBoard( cardId: string, @@ -48,12 +48,12 @@ export async function cardContext( } /** - * Crea una tarjeta al final de la lista. Leer la última posición y crear son dos pasos: - * sin bloqueo, dos altas simultáneas (dos usuarios o Promise.all contra la API) leían la - * misma última clave y guardaban la MISMA posición, y luego generateKeyBetween(a, a) - * rompía el arrastre en esa lista. El `FOR UPDATE` sobre la fila de la lista serializa - * las altas de esa lista (las de otras listas no esperan). No se usa un índice único - * (listId, position) porque los movimientos concurrentes también colisionan y fallarían. + * Creates a card at the end of the list. Reading the last position and creating are two + * steps: without a lock, two concurrent creates (two users, or Promise.all against the + * API) read the same last key and stored the SAME position, and then + * generateKeyBetween(a, a) broke drag and drop in that list. `FOR UPDATE` on the list row + * serializes creates within that list (other lists do not wait). There is no unique + * index on (listId, position) because concurrent moves also collide and would fail. */ export async function createCardAtEnd(data: { listId: string; diff --git a/app/lib/services/checklists.ts b/app/lib/services/checklists.ts index 8525e93..1eebc10 100644 --- a/app/lib/services/checklists.ts +++ b/app/lib/services/checklists.ts @@ -2,11 +2,11 @@ import "server-only"; import { prisma } from "@/app/lib/prisma"; import { positionAtEnd } from "@/app/lib/ordering"; -// Altas al final de checklists e ítems con la fila padre bloqueada (`FOR UPDATE`), para -// que dos altas simultáneas no guarden la misma posición. Mismo razonamiento que -// createCardAtEnd (app/lib/services/cards.ts). +// Appends checklists and items with the parent row locked (`FOR UPDATE`), so two +// concurrent creates do not store the same position. Same reasoning as createCardAtEnd +// (app/lib/services/cards.ts). -/** Crea un checklist al final de la tarjeta. */ +/** Creates a checklist at the end of the card. */ export async function createChecklistAtEnd(data: { cardId: string; title: string; @@ -29,7 +29,7 @@ export async function createChecklistAtEnd(data: { }); } -/** Crea un ítem al final del checklist. */ +/** Creates an item at the end of the checklist. */ export async function createChecklistItemAtEnd(data: { checklistId: string; text: string; diff --git a/app/lib/services/instance.ts b/app/lib/services/instance.ts new file mode 100644 index 0000000..5a0c780 --- /dev/null +++ b/app/lib/services/instance.ts @@ -0,0 +1,107 @@ +import "server-only"; +import { prisma } from "@/app/lib/prisma"; + +// Instance settings the admin manages from /admin/instance: open or closed registration +// and the legal details of whoever operates the instance. No request context or +// authorization (the Server Action provides it), same as services/users.ts. See +// docs/privacy.md. + +const INSTANCE_ID = "singleton"; + +export type LegalInfo = { + instanceName: string; + operatorName: string | null; + operatorEmail: string | null; + operatorLegalId: string | null; + operatorAddress: string | null; + hostingDescription: string | null; + backupRetentionDays: number | null; + legalUpdatedAt: Date | null; + registrationOpen: boolean; +}; + +/** Legal details and registration state. null if the instance is not configured yet. */ +export async function getLegalInfo(): Promise<LegalInfo | null> { + return prisma.instanceConfig.findUnique({ + where: { id: INSTANCE_ID }, + select: { + instanceName: true, + operatorName: true, + operatorEmail: true, + operatorLegalId: true, + operatorAddress: true, + hostingDescription: true, + backupRetentionDays: true, + legalUpdatedAt: true, + registrationOpen: true, + }, + }); +} + +/** + * true if the privacy policy has the minimum the GDPR requires (art. 13.1.a): who the + * controller is and how to contact them. Without it, registration cannot be opened. + */ +export function hasRequiredLegalInfo( + info: Pick<LegalInfo, "operatorName" | "operatorEmail">, +): boolean { + return Boolean(info.operatorName && info.operatorEmail); +} + +export async function isRegistrationOpen(): Promise<boolean> { + const info = await getLegalInfo(); + // Re-checked here and not only on save: if someone clears the legal details directly + // in the DB, registration closes itself instead of accepting sign-ups without a policy. + return Boolean(info?.registrationOpen && hasRequiredLegalInfo(info)); +} + +export type InstanceSettingsInput = { + instanceName: string; + registrationOpen: boolean; + operatorName: string | null; + operatorEmail: string | null; + operatorLegalId: string | null; + operatorAddress: string | null; + hostingDescription: string | null; + backupRetentionDays: number | null; +}; + +export type UpdateInstanceResult = + | { ok: true; legalChanged: boolean } + | { ok: false; error: "not_configured" | "legal_info_required" }; + +const LEGAL_FIELDS = [ + "operatorName", + "operatorEmail", + "operatorLegalId", + "operatorAddress", + "hostingDescription", + "backupRetentionDays", +] as const; + +/** + * Saves the settings. Opening registration requires a controller and a contact. If any + * legal detail changes, `legalUpdatedAt` is set to now: it is the "last updated" date of + * the legal pages, so it can never lag behind what they say. + */ +export async function updateInstanceSettings( + input: InstanceSettingsInput, +): Promise<UpdateInstanceResult> { + const current = await prisma.instanceConfig.findUnique({ + where: { id: INSTANCE_ID }, + }); + if (!current) return { ok: false, error: "not_configured" }; + if (input.registrationOpen && !hasRequiredLegalInfo(input)) { + return { ok: false, error: "legal_info_required" }; + } + + const legalChanged = LEGAL_FIELDS.some((f) => current[f] !== input[f]); + await prisma.instanceConfig.update({ + where: { id: INSTANCE_ID }, + data: { + ...input, + ...(legalChanged ? { legalUpdatedAt: new Date() } : {}), + }, + }); + return { ok: true, legalChanged }; +} diff --git a/app/lib/services/labels.ts b/app/lib/services/labels.ts index 93879ef..224484d 100644 --- a/app/lib/services/labels.ts +++ b/app/lib/services/labels.ts @@ -2,10 +2,10 @@ import "server-only"; import { prisma } from "@/app/lib/prisma"; /** - * ¿La etiqueta pertenece a este tablero? toggleCardLabel autoriza por el tablero de la - * TARJETA; sin esta comprobación, un editor de A podría vincular una etiqueta de B (con - * solo conocer su id) y exponer su nombre y color a los miembros de A y en el export. - * Aislado aquí para poder testearlo. Un id inexistente también da false. + * Does the label belong to this board? toggleCardLabel authorizes by the CARD's board; + * without this check, an editor of A could attach a label from B (just by knowing its id) + * and expose its name and color to A's members and in the export. Isolated here so it + * can be tested. A nonexistent id also returns false. */ export async function labelBelongsToBoard( labelId: string, diff --git a/app/lib/services/lists.ts b/app/lib/services/lists.ts index 0a2ba01..272fb31 100644 --- a/app/lib/services/lists.ts +++ b/app/lib/services/lists.ts @@ -3,9 +3,9 @@ import { prisma } from "@/app/lib/prisma"; import { positionAtEnd } from "@/app/lib/ordering"; /** - * Crea una lista al final del tablero. Bloquea la fila del tablero (`FOR UPDATE`) para - * que dos altas simultáneas no lean la misma última posición y guarden la misma clave. - * Mismo razonamiento que createCardAtEnd (app/lib/services/cards.ts). + * Creates a list at the end of the board. Locks the board row (`FOR UPDATE`) so two + * concurrent creates do not read the same last position and store the same key. + * Same reasoning as createCardAtEnd (app/lib/services/cards.ts). */ export async function createListAtEnd(data: { boardId: string; diff --git a/app/lib/services/maintenance.ts b/app/lib/services/maintenance.ts new file mode 100644 index 0000000..daf7f8c --- /dev/null +++ b/app/lib/services/maintenance.ts @@ -0,0 +1,62 @@ +import "server-only"; +import { prisma } from "@/app/lib/prisma"; + +// Periodic cleanup of data that must not be kept forever (storage limitation principle, +// GDPR art. 5.1.e). Triggered by an external cron via POST /api/cron/maintenance, like +// the reminders (ADR 0015). The retention periods are in docs/privacy.md and shown by the +// privacy policy, so they come from here and are not repeated by hand elsewhere. + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Days the audit log (with IPs) is kept. Configurable, 90 by default. */ +export function auditRetentionDays(): number { + const raw = process.env.ORGTABLE_AUDIT_RETENTION_DAYS; + if (raw === undefined || raw === "") return 90; + const n = Number(raw); + if (!Number.isInteger(n) || n < 1) { + // A typo must not turn into "delete everything" or "never delete". + throw new Error( + `ORGTABLE_AUDIT_RETENTION_DAYS must be an integer >= 1 (got: "${raw}")`, + ); + } + return n; +} + +/** Days that closed or expired sessions are kept before being deleted. */ +export const ENDED_SESSION_RETENTION_DAYS = 30; + +/** Days a notification is kept, read or not. */ +export const NOTIFICATION_RETENTION_DAYS = 180; + +export type MaintenanceResult = { + auditLogsDeleted: number; + sessionsDeleted: number; + notificationsDeleted: number; +}; + +export async function purgeExpiredData(now: Date): Promise<MaintenanceResult> { + const auditCutoff = new Date(now.getTime() - auditRetentionDays() * DAY_MS); + const sessionCutoff = new Date(now.getTime() - ENDED_SESSION_RETENTION_DAYS * DAY_MS); + const notificationCutoff = new Date( + now.getTime() - NOTIFICATION_RETENTION_DAYS * DAY_MS, + ); + + const [audit, sessions, notifications] = await prisma.$transaction([ + prisma.auditLog.deleteMany({ where: { createdAt: { lt: auditCutoff } } }), + prisma.session.deleteMany({ + where: { + OR: [ + { expiresAt: { lt: sessionCutoff } }, + { revokedAt: { lt: sessionCutoff } }, + ], + }, + }), + prisma.notification.deleteMany({ where: { createdAt: { lt: notificationCutoff } } }), + ]); + + return { + auditLogsDeleted: audit.count, + sessionsDeleted: sessions.count, + notificationsDeleted: notifications.count, + }; +} diff --git a/app/lib/services/members.ts b/app/lib/services/members.ts index b08a3c0..29f6497 100644 --- a/app/lib/services/members.ts +++ b/app/lib/services/members.ts @@ -1,16 +1,17 @@ import "server-only"; import { prisma } from "@/app/lib/prisma"; -// Expulsar a un miembro de un tablero. Aislado de la Server Action para poder testearlo sin -// sesión. NO autoriza al actor ni protege al propietario: eso lo hace removeBoardMember. +// Removes a member from a board. Isolated from the Server Action so it can be tested +// without a session. It does NOT authorize the actor or protect the owner: +// removeBoardMember does that. // -// Además de la fila BoardMember se borran sus asignaciones en las tarjetas del tablero: si -// quedaran, el expulsado seguiría recibiendo por la campana el título de las tarjetas y el -// texto de los comentarios (comment.added, due.reminder) de un tablero al que ya no tiene -// acceso. Van en una transacción para no dejar nunca una de las dos mitades. +// Besides the BoardMember row, their assignments on the board's cards are deleted: if they +// remained, the removed user would keep receiving through the bell the card titles and +// comment text (comment.added, due.reminder) of a board they no longer have access to. +// Both run in one transaction so one half is never left behind. // -// Devuelve cuántas membresías se borraron (0 si el usuario no era miembro), para que la -// acción no audite ni notifique una expulsión que no ha ocurrido. +// Returns how many memberships were deleted (0 if the user was not a member), so the +// action does not audit or notify a removal that did not happen. export async function removeMemberFromBoard( boardId: string, userId: string, diff --git a/app/lib/services/reminders.ts b/app/lib/services/reminders.ts index 46c18bd..52649b5 100644 --- a/app/lib/services/reminders.ts +++ b/app/lib/services/reminders.ts @@ -1,31 +1,41 @@ import "server-only"; import { prisma } from "@/app/lib/prisma"; import { notify } from "@/app/lib/notifications"; +import { i18nFor } from "@/app/i18n/server"; +import { fmt } from "@/app/i18n/format"; -// Recordatorios de vencimiento (Hito 11). Genera notificaciones para las tarjetas con -// fecha de vencimiento que vencen en las próximas 24 h o vencieron hace poco, a las -// personas que las tienen asignadas y siguen siendo miembros del tablero. Se invoca desde -// un endpoint cron protegido (app/api/cron/reminders) que dispara un programador externo. -// `now` se inyecta para poder testearlo. Ver docs/reminders.md y +// Due-date reminders. Creates notifications for cards whose due date falls within the next +// 24 h or passed recently, for the people assigned to them who are still board members. It +// is called from a protected cron endpoint (app/api/cron/reminders) triggered by an +// external scheduler. `now` is injected so it can be tested. See docs/reminders.md and // docs/decisions/0015-reminders-cron.md. // -// Política de avisos: -// - «Vence pronto»: como mucho uno al día por tarjeta y persona. -// - «Tarea vencida»: uno solo en total, y solo si venció en los últimos -// OVERDUE_WINDOW_DAYS días. Sin ese tope, una tarjeta olvidada avisaría a diario para -// siempre y la pasada crecería sin límite. +// Notification policy: +// - "Due soon": at most one per day per card and person. +// - "Overdue": a single one in total, and only if it fell due within the last +// OVERDUE_WINDOW_DAYS days. Without that cap, a forgotten card would notify every day +// forever and each run would grow without bound. // -// Los días se cuentan en UTC: dueDate es una fecha de calendario («YYYY-MM-DD», que se -// guarda como medianoche UTC), así que usar la zona del servidor desplazaría el día. +// Days are counted in UTC: dueDate is a calendar date ("YYYY-MM-DD", stored as midnight +// UTC), so using the server's time zone would shift the day. +// +// Each notification is written in the recipient's language (User.locale). const DAY_MS = 24 * 60 * 60 * 1000; const OVERDUE_WINDOW_DAYS = 7; -// Fecha de calendario: se formatea en UTC para que salga el mismo día que se eligió. -const dueFormatter = new Intl.DateTimeFormat("es-ES", { - dateStyle: "medium", - timeZone: "UTC", -}); +// Calendar date: formatted in UTC so that it shows the same day that was picked. One +// formatter per language, reused across the run. +const dueFormatters = new Map<string, Intl.DateTimeFormat>(); + +function formatDueDate(intl: string, date: Date): string { + let formatter = dueFormatters.get(intl); + if (!formatter) { + formatter = new Intl.DateTimeFormat(intl, { dateStyle: "medium", timeZone: "UTC" }); + dueFormatters.set(intl, formatter); + } + return formatter.format(date); +} function utcDayStart(d: Date): Date { return new Date( @@ -42,7 +52,7 @@ export async function generateDueReminders( todayStart.getTime() - OVERDUE_WINDOW_DAYS * DAY_MS, ); - // Tarjetas con asignados que vencen hasta dentro de 24 h o vencieron dentro de la ventana. + // Cards with assignees due within the next 24 h or that fell due within the window. const cards = await prisma.card.findMany({ where: { dueDate: { gte: overdueFloor, lte: soon }, @@ -58,7 +68,7 @@ export async function generateDueReminders( board: { select: { members: { select: { userId: true } } } }, }, }, - assignees: { select: { userId: true } }, + assignees: { select: { userId: true, user: { select: { locale: true } } } }, }, }); @@ -66,13 +76,13 @@ export async function generateDueReminders( for (const card of cards) { if (!card.dueDate) continue; const overdue = card.dueDate < todayStart; - // Pasa a «vencida» al empezar el día siguiente al del vencimiento. Cualquier aviso - // posterior a ese instante ya es el aviso de vencida. + // It becomes "overdue" when the day after the due date starts. Any notification after + // that instant is already the overdue one. const dedupSince = overdue ? new Date(utcDayStart(card.dueDate).getTime() + DAY_MS) : todayStart; - // Solo miembros actuales: una asignación huérfana (de alguien expulsado) no debe - // seguir enviando el título de la tarjeta. + // Current members only: an orphan assignment (of someone removed from the board) must + // not keep sending the card's title. const memberIds = new Set(card.list.board.members.map((m) => m.userId)); for (const a of card.assignees) { if (!memberIds.has(a.userId)) continue; @@ -87,13 +97,13 @@ export async function generateDueReminders( }); if (already) continue; + const { m, intl } = i18nFor(a.user.locale); + const t = m.notifications.dueReminder; const ok = await notify({ userId: a.userId, type: "due.reminder", - title: overdue - ? `Tarea vencida: «${card.title}»` - : `Vence pronto: «${card.title}»`, - body: `Vencimiento: ${dueFormatter.format(card.dueDate)}.`, + title: fmt(overdue ? t.overdueTitle : t.soonTitle, { card: card.title }), + body: fmt(t.body, { date: formatDueDate(intl, card.dueDate) }), boardId: card.list.boardId, cardId: card.id, }); diff --git a/app/lib/services/setup.ts b/app/lib/services/setup.ts index 5d3243f..47993b2 100644 --- a/app/lib/services/setup.ts +++ b/app/lib/services/setup.ts @@ -1,12 +1,12 @@ import "server-only"; import { prisma } from "@/app/lib/prisma"; -// Lógica del asistente de primer arranque (/setup), separada de la Server Action para -// poder probarla sin request (ver test/integration/setup.int.test.ts). +// First-run wizard logic (/setup), separated from the Server Action so it can be tested +// without a request (see test/integration/setup.int.test.ts). const INSTANCE_ID = "singleton"; -// Clave del advisory lock que serializa el setup (cualquier texto fijo sirve: hashtext -// lo convierte en el entero que pide pg_advisory_xact_lock). +// Advisory lock key that serializes setup (any fixed text works: hashtext turns it into +// the integer pg_advisory_xact_lock expects). const SETUP_LOCK_KEY = "orgtable:setup"; export type SetupOutcome = @@ -14,13 +14,13 @@ export type SetupOutcome = | { ok: false; reason: "ALREADY_SETUP" }; /** - * Crea el administrador y marca la instancia como configurada, una sola vez. + * Creates the administrator and marks the instance as configured, exactly once. * - * Comprobar `setupCompletedAt` dentro de la transacción NO basta: con READ COMMITTED dos - * envíos simultáneos leen los dos "sin configurar", cada uno crea su ADMIN y el upsert - * de la segunda pasa como UPDATE. SELECT … FOR UPDATE tampoco sirve porque la fila de - * InstanceConfig aún no existe. El advisory lock de transacción hace que la segunda - * espere a que la primera confirme y, al comprobar, vea el setup ya hecho. + * Checking `setupCompletedAt` inside the transaction is NOT enough: under READ COMMITTED + * two concurrent submissions both read "not configured", each creates its ADMIN and the + * second upsert goes through as an UPDATE. SELECT ... FOR UPDATE does not help either + * because the InstanceConfig row does not exist yet. The transaction-level advisory lock + * makes the second wait for the first to commit and, when it checks, see setup done. */ export async function completeInstanceSetup(input: { instanceName: string; diff --git a/app/lib/services/users.ts b/app/lib/services/users.ts index 3d45bf2..d23f540 100644 --- a/app/lib/services/users.ts +++ b/app/lib/services/users.ts @@ -3,16 +3,20 @@ import { prisma } from "@/app/lib/prisma"; import { hashPassword } from "@/app/lib/auth"; import type { GlobalRole } from "@/app/lib/roles"; -// Lógica de negocio de las cuentas de usuario, SIN contexto de request (cookies/sesión) -// ni autorización ni auditoría: de eso se encargan las Server Actions que la llaman -// (app/lib/actions/admin.ts, setup.ts). Aislarla así la hace reutilizable y, sobre todo, -// testeable de forma directa contra la base de datos (ver test/integration/users.int.test.ts). +// User account business logic, WITHOUT request context (cookies/session), authorization +// or auditing: the Server Actions that call it handle those (app/lib/actions/admin.ts, +// setup.ts). Isolating it this way makes it reusable and, above all, directly testable +// against the database (see test/integration/users.int.test.ts). export type NewUserInput = { name: string; email: string; password: string; role: GlobalRole; + // Only for open registration (/register): when they accepted the policy and terms. + termsAcceptedAt?: Date; + // Interface language at sign-up (open registration), for their notifications. + locale?: string; }; export type CreateUserResult = @@ -20,9 +24,9 @@ export type CreateUserResult = | { ok: false; error: "email_taken" }; /** - * Crea una cuenta: normaliza el email a minúsculas, rechaza duplicados y guarda la - * contraseña SIEMPRE hasheada (bcrypt, nunca en claro). Idempotencia de email garantizada - * además por el índice único de la columna. + * Creates an account: lowercases the email, rejects duplicates and ALWAYS stores the + * password hashed (bcrypt, never in plain text). Email uniqueness is also enforced by + * the column's unique index. */ export async function createUserAccount( input: NewUserInput, @@ -36,11 +40,31 @@ export async function createUserAccount( if (exists) return { ok: false, error: "email_taken" }; const passwordHash = await hashPassword(input.password); - const user = await prisma.user.create({ - data: { email, name: input.name, passwordHash, role: input.role }, - select: { id: true, email: true }, - }); - return { ok: true, user }; + try { + const user = await prisma.user.create({ + data: { + email, + name: input.name, + passwordHash, + role: input.role, + termsAcceptedAt: input.termsAcceptedAt ?? null, + locale: input.locale ?? null, + }, + select: { id: true, email: true }, + }); + return { ok: true, user }; + } catch (e) { + // Two concurrent sign-ups with the same email both pass the check above and the + // second hits the unique index. With open registration this is easy (double click), + // so it is answered as a normal duplicate instead of a 500. + if (isUniqueViolation(e)) return { ok: false, error: "email_taken" }; + throw e; + } +} + +/** Prisma error for a unique index violation (P2002). */ +export function isUniqueViolation(e: unknown): boolean { + return e instanceof Error && "code" in e && e.code === "P2002"; } export type ResetPasswordResult = @@ -48,13 +72,13 @@ export type ResetPasswordResult = | { ok: false; error: "not_found" }; /** - * Restablece la contraseña de un usuario y le retira TODO acceso vigente: revoca sus - * sesiones y borra sus tokens de API. Los tokens no dependen de la tabla Session, así - * que revocar solo sesiones dejaría viva la API (/api/v1) a quien hubiera creado un - * token con la cuenta comprometida. Borrar es la semántica de revocación del ADR 0013. + * Resets a user's password and removes ALL their current access: revokes their sessions + * and deletes their API tokens. Tokens do not depend on the Session table, so revoking + * only sessions would leave the API (/api/v1) open to whoever created a token with the + * compromised account. Deleting is the revocation semantics of ADR 0013. * - * Se comprueba que el usuario existe ANTES de hashear (bcrypt es caro) y los tres - * cambios van en una transacción: o se aplican todos o ninguno. + * The user's existence is checked BEFORE hashing (bcrypt is expensive) and the three + * changes run in one transaction: either all apply or none. */ export async function resetPasswordAndRevokeAccess( userId: string, @@ -75,7 +99,7 @@ export async function resetPasswordAndRevokeAccess( }), prisma.apiToken.deleteMany({ where: { userId } }), ]); - // El usuario pudo borrarse entre la comprobación y la transacción. + // The user may have been deleted between the check and the transaction. if (updated.count === 0) return { ok: false, error: "not_found" }; return { ok: true, revokedTokens: tokens.count }; } @@ -85,11 +109,11 @@ export type DeleteUserResult = | { ok: false; error: "not_found" | "owns_boards" }; /** - * Borra una cuenta que NO es propietaria de ningún tablero. La condición va en el mismo - * DELETE (`boards: { none: {} }`) y no en una consulta previa: si el usuario creara un - * tablero entre la comprobación y el borrado, la cascada por ownerId se llevaría sus - * listas, tarjetas y filas Attachment, dejando los binarios huérfanos en el volumen de - * adjuntos. Así la cascada nunca alcanza adjuntos. + * Deletes an account that owns NO boards. The condition lives in the DELETE itself + * (`boards: { none: {} }`) rather than in a prior query: if the user created a board + * between the check and the deletion, the ownerId cascade would take its lists, cards + * and Attachment rows, leaving orphaned binaries in the attachments volume. This way the + * cascade never reaches attachments. */ export async function deleteUserAccount( userId: string, diff --git a/app/lib/session.ts b/app/lib/session.ts index f477a66..aead1ce 100644 --- a/app/lib/session.ts +++ b/app/lib/session.ts @@ -3,25 +3,28 @@ import { SignJWT, jwtVerify } from "jose"; import { cookies, headers } from "next/headers"; import { prisma } from "@/app/lib/prisma"; -// Sesiones respaldadas por servidor (Hito 4). La cookie sigue siendo un JWT firmado -// con SESSION_SECRET (httpOnly), pero ahora el token lleva el id de una fila de la -// tabla `Session` (sid). El Data Access Layer (app/lib/dal.ts) valida el JWT y además -// comprueba que esa sesión existe, no está revocada y no ha expirado. Esto permite -// REVOCACIÓN INMEDIATA (logout, restablecer contraseña, cerrar en todos los -// dispositivos). Ver docs/authentication.md y docs/decisions/0008-session-store.md. +// Server-backed sessions. The cookie is a JWT signed with SESSION_SECRET (httpOnly) that +// carries the id of a row in the `Session` table (sid). The Data Access Layer +// (app/lib/dal.ts) validates the JWT and also checks that the session exists, is not +// revoked and has not expired. That allows IMMEDIATE REVOCATION (sign-out, password +// reset, sign out everywhere). See docs/authentication.md and +// docs/decisions/0008-session-store.md. const COOKIE_NAME = "session"; -const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 días +// Exported for the privacy policy, which states how long a session lasts. +export const SESSION_MAX_AGE_DAYS = 7; +const MAX_AGE_MS = SESSION_MAX_AGE_DAYS * 24 * 60 * 60 * 1000; -// La clave se deriva en el momento de usarse (no al importar el módulo, para no romper el -// build) y se EXIGE: sin SESSION_SECRET, `TextEncoder().encode(undefined)` daría una clave -// vacía/predecible con la que se podrían FORJAR sesiones. Fallar ruidosamente es lo seguro. +// The key is derived when used (not on module import, so the build does not break) and +// is REQUIRED: without SESSION_SECRET, `TextEncoder().encode(undefined)` would give an +// empty/predictable key with which sessions could be FORGED. Failing loudly is the safe +// choice. function getKey(): Uint8Array { const secret = process.env.SESSION_SECRET; if (!secret || secret.length < 16) { throw new Error( - "SESSION_SECRET no está configurado o es demasiado corto (mínimo 16 caracteres). " + - "Genera uno con: openssl rand -hex 32", + "SESSION_SECRET is not set or is too short (at least 16 characters). " + + "Generate one with: openssl rand -hex 32", ); } return new TextEncoder().encode(secret); @@ -41,10 +44,10 @@ export async function decrypt( token: string | undefined, ): Promise<SessionPayload | null> { if (!token) return null; - // getKey() va FUERA del try: un SESSION_SECRET ausente es un fallo de configuración y - // debe romper la petición con su mensaje, no convertirse en "sin sesión" (lo que - // mandaría a /login sin dejar rastro). Sin cookie ni se llega aquí, así que /login y - // /setup siguen funcionando para visitantes anónimos. + // getKey() goes OUTSIDE the try: a missing SESSION_SECRET is a configuration error and + // must fail the request with its message, not turn into "no session" (which would send + // to /login without a trace). Without a cookie this point is never reached, so /login + // and /setup still work for anonymous visitors. const key = getKey(); try { const { payload } = await jwtVerify(token, key, { @@ -55,25 +58,25 @@ export async function decrypt( } return { userId: payload.userId, sid: payload.sid }; } catch { - // Token inválido, expirado o manipulado. + // Invalid, expired or tampered token. return null; } } /** - * true si la petición llegó por HTTPS. Next rellena X-Forwarded-Proto con el esquema real - * del socket cuando no viene de un proxy (`??=` en base-server), y un proxy TLS la fija a - * "https". Se mira el primer valor por si hay varios proxies encadenados. + * true if the request arrived over HTTPS. Next fills X-Forwarded-Proto with the socket's + * real scheme when no proxy set it (`??=` in base-server), and a TLS proxy sets it to + * "https". The first value is used in case several proxies are chained. */ function isHttpsRequest(h: Headers): boolean { const proto = h.get("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase(); return proto === "https"; } -/** Crea una fila de sesión y guarda el JWT (con su sid) en la cookie httpOnly. */ +/** Creates a session row and stores the JWT (with its sid) in the httpOnly cookie. */ export async function createSession(userId: string): Promise<void> { - // Se valida la clave ANTES de crear la fila: sin SESSION_SECRET no queda una sesión - // huérfana en la BD. + // The key is checked BEFORE creating the row: without SESSION_SECRET no orphan session + // is left in the database. getKey(); const expiresAt = new Date(Date.now() + MAX_AGE_MS); const requestHeaders = await headers(); @@ -87,10 +90,10 @@ export async function createSession(userId: string): Promise<void> { const cookieStore = await cookies(); cookieStore.set(COOKIE_NAME, token, { httpOnly: true, - // Secure según el esquema REAL de la petición, no NODE_ENV: con NODE_ENV el primer - // arranque documentado (http://IP:3000) quedaba inutilizable, porque el navegador - // descarta una cookie Secure recibida por HTTP. Detrás de un proxy TLS la cookie sí - // lleva Secure. Falsear X-Forwarded-Proto solo perjudica al propio cliente. + // Secure follows the request's REAL scheme, not NODE_ENV: with NODE_ENV the documented + // first start (http://IP:3000) was unusable, because browsers drop a Secure cookie + // received over HTTP. Behind a TLS proxy the cookie is Secure. Faking + // X-Forwarded-Proto only harms the client itself. secure: isHttpsRequest(requestHeaders), sameSite: "lax", expires: expiresAt, @@ -98,7 +101,7 @@ export async function createSession(userId: string): Promise<void> { }); } -/** Cierra la sesión actual: revoca su fila y borra la cookie. */ +/** Ends the current session: revokes its row and deletes the cookie. */ export async function deleteSession(): Promise<void> { const cookieStore = await cookies(); const token = cookieStore.get(COOKIE_NAME)?.value; @@ -113,9 +116,9 @@ export async function deleteSession(): Promise<void> { } /** - * Revoca TODAS las sesiones activas de un usuario (cerrar en todos los dispositivos). - * Lo usa el admin al restablecer una contraseña o forzar el cierre de sesión. El - * efecto es inmediato: la siguiente petición de ese usuario fallará en el DAL. + * Revokes ALL active sessions of a user (sign out on every device). Used by the admin + * when resetting a password or forcing a sign-out. Takes effect at once: that user's next + * request fails in the DAL. */ export async function revokeAllUserSessions(userId: string): Promise<void> { await prisma.session.updateMany({ diff --git a/app/lib/site.ts b/app/lib/site.ts new file mode 100644 index 0000000..adcc4d6 --- /dev/null +++ b/app/lib/site.ts @@ -0,0 +1,37 @@ +import packageJson from "@/package.json"; + +// Project data the interface shows as the AGPL "Appropriate Legal Notices" (footer of the +// public pages and "About" in settings). The attribution is an additional term of LICENSE +// (section 7(b)): it cannot be removed or changed in derived versions. See +// docs/DECISIONES.md (2026-10-14). + +export const APP_VERSION: string = packageJson.version; + +export const AUTHOR_NAME = "Aimar"; +export const AUTHOR_URL = "https://github.com/Aimarmndz"; + +export const LICENSE_NAME = "AGPL-3.0-or-later"; +export const LICENSE_URL = "https://www.gnu.org/licenses/agpl-3.0.html"; + +// Source code of THIS instance. The AGPL (section 13) requires offering it to whoever uses +// the app over a network: if you deploy a modified version, point ORGTABLE_SOURCE_URL to +// your fork. Read on every render (not at build time) so the Docker image honours it. +const DEFAULT_SOURCE_URL = "https://github.com/Aimarmndz/orgtable"; + +export function sourceCodeUrl(): string { + const raw = process.env.ORGTABLE_SOURCE_URL?.trim(); + if (!raw) return DEFAULT_SOURCE_URL; + // It ends up in an href: only http(s), so a bad value never becomes "javascript:". It + // fails with the reason instead of silently falling back to the original repository, + // which would leave a modified instance without offering its own code. + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error(`ORGTABLE_SOURCE_URL is not a valid URL: "${raw}"`); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error(`ORGTABLE_SOURCE_URL must start with https:// or http:// ("${raw}")`); + } + return url.toString(); +} diff --git a/app/lib/storage.ts b/app/lib/storage.ts index e73e181..c0f4e4d 100644 --- a/app/lib/storage.ts +++ b/app/lib/storage.ts @@ -3,32 +3,32 @@ import { mkdir, writeFile, unlink, readFile } from "node:fs/promises"; import path from "node:path"; import { randomUUID } from "node:crypto"; -// Almacenamiento en disco de los adjuntos (Hito 5). El binario vive en un directorio -// configurable (ORGTABLE_UPLOAD_DIR); en producción es un volumen persistente (ver -// docker-compose.yml). En la base de datos solo guardamos metadatos. Ver -// docs/attachments.md y la decisión en docs/decisions/0010-local-file-storage.md. +// On-disk storage of attachments. The binary lives in a configurable directory +// (ORGTABLE_UPLOAD_DIR); in production it is a persistent volume (see docker-compose.yml). +// The database only holds metadata. See docs/attachments.md and the decision in +// docs/decisions/0010-local-file-storage.md. -// `turbopackIgnore`: sin él, Turbopack no puede resolver `process.cwd()` en el build y -// traza el proyecto entero (docs, test, Dockerfile, ./uploads) hacia .next/standalone. +// `turbopackIgnore`: without it, Turbopack cannot resolve `process.cwd()` at build time and +// traces the whole project (docs, test, Dockerfile, ./uploads) into .next/standalone. const UPLOAD_DIR = process.env.ORGTABLE_UPLOAD_DIR || path.join(/* turbopackIgnore: true */ process.cwd(), "uploads"); -// Límite de tamaño por archivo: 10 MB. +// Size limit per file: 10 MB. export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; -/** Ruta absoluta del fichero a partir de su nombre almacenado, a prueba de traversal. */ +/** Absolute path of the file from its stored name, safe against path traversal. */ function storedPath(storedName: string): string { - // basename descarta cualquier componente de ruta: nunca se sale de UPLOAD_DIR. + // basename drops any path component: it never leaves UPLOAD_DIR. return path.join(UPLOAD_DIR, path.basename(storedName)); } -/** Guarda un File en disco con un nombre aleatorio. Devuelve el nombre y el tamaño. */ +/** Saves a File to disk under a random name. Returns the name and the size. */ export async function saveAttachmentFile( file: File, ): Promise<{ storedName: string; size: number }> { await mkdir(UPLOAD_DIR, { recursive: true }); - // Conserva la extensión original (saneada) para que el tipo se reconozca al descargar. + // Keeps the original (sanitised) extension so the type is recognised on download. const ext = path.extname(file.name).slice(0, 16).replace(/[^.a-zA-Z0-9]/g, ""); const storedName = `${randomUUID()}${ext}`; const buffer = Buffer.from(await file.arrayBuffer()); @@ -36,16 +36,16 @@ export async function saveAttachmentFile( return { storedName, size: buffer.length }; } -/** Lee el binario de un adjunto (para servirlo con autorización desde el route handler). */ +/** Reads an attachment's binary (served with authorization by the route handler). */ export async function readAttachmentFile(storedName: string): Promise<Buffer> { return readFile(storedPath(storedName)); } /** - * Borra el fichero de disco. Nunca lanza: quien la llama ya ha borrado la fila, y un - * throw aquí rompería la acción y se saltaría la revalidación. Que el fichero ya no - * exista (ENOENT) es normal; cualquier otro error (permisos, volumen de solo lectura…) - * deja un binario huérfano, así que se registra para que el operador lo vea. + * Deletes the file from disk. Never throws: the caller has already deleted the row, and a + * throw here would break the action and skip revalidation. A file that no longer exists + * (ENOENT) is normal; any other error (permissions, read-only volume…) leaves an orphan + * binary, so it is logged for the operator to see. */ export async function deleteAttachmentFile(storedName: string): Promise<void> { try { @@ -53,6 +53,6 @@ export async function deleteAttachmentFile(storedName: string): Promise<void> { } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") return; - console.error("[storage] no se pudo borrar el adjunto", { storedName, code }); + console.error("[storage] could not delete the attachment", { storedName, code }); } } diff --git a/app/lib/types.ts b/app/lib/types.ts index 4ddc1b0..235a366 100644 --- a/app/lib/types.ts +++ b/app/lib/types.ts @@ -1,5 +1,5 @@ -// Tipos de transferencia (DTO) que viajan del servidor a los componentes cliente del -// tablero. Serializables (sin objetos Date: las fechas viajan como ISO string). +// Data transfer types (DTOs) sent from the server to the board's Client Components. +// Serializable: no Date objects, dates travel as ISO strings. export type LabelDTO = { id: string; diff --git a/app/lib/user-agent.ts b/app/lib/user-agent.ts new file mode 100644 index 0000000..39b4e57 --- /dev/null +++ b/app/lib/user-agent.ts @@ -0,0 +1,45 @@ +// Readable browser name from a user-agent, for the session list in /settings ("Firefox on +// macOS"). Minimal heuristic without dependencies: order matters because nearly every +// browser includes "Safari" and every Chromium includes "Chrome". + +import { fmt } from "@/app/i18n/format"; + +const BROWSERS: [RegExp, string][] = [ + [/Edg(A|iOS)?\//, "Edge"], + [/OPR\/|Opera/, "Opera"], + [/Firefox\/|FxiOS\//, "Firefox"], + [/Chrome\/|CriOS\//, "Chrome"], + [/Safari\//, "Safari"], +]; + +const SYSTEMS: [RegExp, string][] = [ + [/iPhone|iPad|iPod/, "iOS"], + [/Android/, "Android"], + [/Windows/, "Windows"], + [/Mac OS X|Macintosh/, "macOS"], + [/CrOS/, "ChromeOS"], + [/Linux/, "Linux"], +]; + +/** Browser and operating system detected in a user-agent; null for what is not recognised. */ +export function parseUserAgent(ua: string | null): { browser: string | null; os: string | null } { + if (!ua) return { browser: null, os: null }; + return { + browser: BROWSERS.find(([re]) => re.test(ua))?.[1] ?? null, + os: SYSTEMS.find(([re]) => re.test(ua))?.[1] ?? null, + }; +} + +/** + * Readable description in the reader's language. `text.browserOnOs` is a template with + * {browser} and {os} ("{browser} on {os}"); `text.unknownBrowser` is used when neither is + * recognised. Pass `m.settings.security`, which has both. + */ +export function describeUserAgent( + ua: string | null, + text: { browserOnOs: string; unknownBrowser: string }, +): string { + const { browser, os } = parseUserAgent(ua); + if (browser && os) return fmt(text.browserOnOs, { browser, os }); + return browser ?? os ?? text.unknownBrowser; +} diff --git a/app/lib/validators.ts b/app/lib/validators.ts index ae67a32..326c878 100644 --- a/app/lib/validators.ts +++ b/app/lib/validators.ts @@ -2,118 +2,136 @@ import { z } from "zod"; import { BOARD_COLOR_KEYS, LABEL_COLOR_KEYS } from "@/app/lib/colors"; import { CARD_DESCRIPTION_MAX } from "@/app/lib/limits"; -// Esquemas de validación compartidos (zod). Se usan en las Server Actions para -// validar la entrada antes de tocar la base de datos. +// Shared validation schemas (zod), used by the Server Actions and the API to validate input +// before touching the database. +// +// Human messages are KEYS into locales/<lang>/errors.json ("errors.validation.*"), not +// text: the schemas stay language-independent. Server Actions turn them into the user's +// language with issueMessage(m, issue) (app/i18n/server.ts); the API, into English with +// apiValidationError (app/lib/api/respond.ts). export const loginSchema = z.object({ - email: z.email("Email no válido"), - password: z.string().min(1, "La contraseña es obligatoria"), + email: z.email("errors.validation.emailInvalid"), + password: z.string().min(1, "errors.validation.passwordRequired"), }); -// Contraseña reutilizable (setup wizard, alta y reset por el administrador). bcrypt solo -// usa los primeros 72 BYTES: más allá, dos contraseñas con el mismo prefijo serían -// equivalentes, así que se rechazan en vez de recortarlas en silencio. Se cuentan bytes -// UTF-8, no caracteres (una «ñ» ocupa 2). loginSchema no aplica este límite para no -// bloquear a quien ya tenga guardada una contraseña más larga. +// Reusable password (setup wizard, account creation and reset by the administrator, +// registration). bcrypt only uses the first 72 BYTES: beyond that, two passwords with the +// same prefix would be equivalent, so they are rejected instead of silently truncated. +// UTF-8 bytes are counted, not characters (an "ñ" takes 2). loginSchema does not apply this +// limit so as not to lock out anyone who already has a longer password stored. const passwordSchema = z .string() - .min(8, "La contraseña debe tener al menos 8 caracteres") - .max(200) + .min(8, "errors.validation.passwordTooShort") + .max(200, "errors.validation.passwordTooLong") .refine( (p) => Buffer.byteLength(p, "utf8") <= 72, - "Máximo 72 bytes (unos 72 caracteres sin tildes ni emojis)", + "errors.validation.passwordTooLong", ); export const setupSchema = z.object({ instanceName: z .string() .trim() - .min(1, "El nombre de la instancia es obligatorio") - .max(60, "Máximo 60 caracteres"), - name: z.string().trim().min(1, "Tu nombre es obligatorio").max(60), - email: z.email("Email no válido"), + .min(1, "errors.validation.instanceNameRequired") + .max(60, "errors.validation.maxChars60"), + name: z + .string() + .trim() + .min(1, "errors.validation.yourNameRequired") + .max(60, "errors.validation.maxChars60"), + email: z.email("errors.validation.emailInvalid"), password: passwordSchema, }); export const boardTitleSchema = z .string() .trim() - .min(1, "El título es obligatorio") - .max(120, "Máximo 120 caracteres"); + .min(1, "errors.validation.titleRequired") + .max(120, "errors.validation.maxChars120"); export const listTitleSchema = z .string() .trim() - .min(1, "El título es obligatorio") - .max(120); + .min(1, "errors.validation.titleRequired") + .max(120, "errors.validation.maxChars120"); export const cardTitleSchema = z .string() .trim() - .min(1, "El título es obligatorio") - .max(500); + .min(1, "errors.validation.titleRequired") + .max(500, "errors.validation.maxChars500"); -// El límite vive en limits.ts para que la UI lo use sin importar zod; se reexporta -// para que los esquemas y las acciones sigan tomándolo de aquí. +// The limit lives in limits.ts so that the UI can use it without importing zod; it is +// re-exported so that schemas and actions keep taking it from here. export { CARD_DESCRIPTION_MAX }; -// Vencimiento que envía la UI (input date y arrastre en el calendario): "YYYY-MM-DD", o -// vacío/null para quitarlo. z.iso.date() rechaza fechas imposibles como 2026-02-31; antes -// una fecha no parseable se convertía en null y BORRABA el vencimiento sin avisar. +// Due date sent by the UI (date input and drag in the calendar): "YYYY-MM-DD", or +// empty/null to clear it. z.iso.date() rejects impossible dates such as 2026-02-31; an +// unparseable date used to become null and silently CLEAR the due date. export const dueDateSchema = z.union([z.literal(""), z.null(), z.iso.date()]); -// Actualización de descripción + vencimiento desde el modal de tarjeta (updateCard). +// Description + due date update from the card modal (updateCard). export const cardDetailsSchema = z.object({ description: z.string().trim().max(CARD_DESCRIPTION_MAX), dueDate: dueDateSchema, }); -// Colores de etiqueta y de fondo de tablero. Se validan contra la paleta con z.enum (no con -// el operador `in`, que acepta claves del prototipo como 'constructor'). `.catch` mantiene -// la tolerancia de antes: un color desconocido cae al valor por defecto en vez de fallar. +// Label and board background colours. Validated against the palette with z.enum (not with +// the `in` operator, which accepts prototype keys such as 'constructor'). `.catch` keeps +// the old tolerance: an unknown colour falls back to the default instead of failing. export const labelColorSchema = z.enum(LABEL_COLOR_KEYS).catch("blue"); const boardBackgroundSchema = z.enum(BOARD_COLOR_KEYS).nullable().catch(null); -// Hito 10: comentarios de tarjeta. +// Card comments. export const commentTextSchema = z .string() .trim() - .min(1, "El comentario no puede estar vacío") - .max(2000, "Máximo 2000 caracteres"); + .min(1, "errors.validation.commentRequired") + .max(2000, "errors.validation.maxChars2000"); -// --- Hito 3: multiusuario --- +// --- Board members and instance roles --- -// Roles que el propietario puede asignar a un miembro de un tablero (OWNER no se -// asigna desde la UI: es el creador del tablero). Ver app/lib/roles.ts. -export const assignableBoardRoleSchema = z.enum(["EDITOR", "VIEWER"]); +// Roles the owner can give a board member (OWNER is not assigned from the UI: it is the +// board's creator). See app/lib/roles.ts. +export const assignableBoardRoleSchema = z.enum(["EDITOR", "VIEWER"], { + error: "errors.validation.roleInvalid", +}); -// Invitar a un miembro existente a un tablero por email. +// Invite an existing user to a board by email. export const addMemberSchema = z.object({ - email: z.email("Email no válido"), + email: z.email("errors.validation.emailInvalid"), role: assignableBoardRoleSchema, }); -// Rol global del usuario en la instancia. -export const globalRoleSchema = z.enum(["ADMIN", "MEMBER"]); +// Global role of the user in the instance. +export const globalRoleSchema = z.enum(["ADMIN", "MEMBER"], { + error: "errors.validation.roleInvalid", +}); -// Alta de usuario por el administrador (no hay registro abierto: el admin crea las -// cuentas). Ver docs/authorization.md. +// Account creation by the administrator. Open registration, when the instance allows it, +// uses registerSchema. See docs/authorization.md. export const createUserSchema = z.object({ - name: z.string().trim().min(1, "El nombre es obligatorio").max(60), - email: z.email("Email no válido"), + name: z + .string() + .trim() + .min(1, "errors.validation.nameRequired") + .max(60, "errors.validation.maxChars60"), + email: z.email("errors.validation.emailInvalid"), password: passwordSchema, role: globalRoleSchema, }); -// Restablecer la contraseña de un usuario (por el administrador). +// Reset a user's password (by the administrator). export const resetPasswordSchema = z.object({ userId: z.string().min(1), password: passwordSchema, }); -// --- Hito 5: importación de tableros (JSON) --- -// Esquema del fichero que produce GET /api/boards/[id]/export. Tolerante con el sobre -// `orgtable` (opcional) pero estricto con la forma y los límites de tamaño. +// --- Board import (JSON) --- +// Schema of the file produced by GET /api/boards/[id]/export. Lenient with the `orgtable` +// envelope (optional) but strict with the shape and the size limits. importBoard answers +// any failure with a single message, so these schemas carry no messages of their own. const importChecklistItemSchema = z.object({ text: z.string().max(200), @@ -155,25 +173,25 @@ export const boardImportSchema = z.object({ }), }); -// --- Hito 9: API pública --- -// Cuerpos JSON de las peticiones de la API. Reutilizan los mismos límites que las -// Server Actions (cardTitleSchema, etc.). Ver docs/api/public-api.md. +// --- Public API --- +// JSON request bodies of the API. They reuse the same limits as the Server Actions +// (cardTitleSchema, etc.). See docs/api/public-api.md. export const apiTokenNameSchema = z .string() .trim() - .min(1, "El nombre es obligatorio") - .max(60, "Máximo 60 caracteres"); + .min(1, "errors.validation.nameRequired") + .max(60, "errors.validation.maxChars60"); -// Alcance del token: "full" (lectura+escritura) o "read" (solo lectura). Hito 13. +// Token scope: "full" (read + write) or "read" (read only). export const apiTokenScopeSchema = z.enum(["full", "read"]); -// Caducidad elegida en /settings: nunca o un número de días. +// Expiry chosen in /settings: never, or a number of days. export const apiTokenExpirySchema = z.enum(["0", "30", "90", "365"]); -// Vencimiento en la API: "YYYY-MM-DD" (lo documentado) o ISO 8601 completo con zona -// ("2026-07-01T09:00:00Z" / "+02:00"). null lo quita. Una cadena vacía o no reconocible da -// 422 en lugar de guardarse como null (antes borraba el vencimiento y respondía 200). +// Due date in the API: "YYYY-MM-DD" (the documented form) or full ISO 8601 with an offset +// ("2026-07-01T09:00:00Z" / "+02:00"). null clears it. An empty or unrecognised string +// gives 422 instead of being stored as null (it used to clear the due date and answer 200). export const apiDueDateSchema = z.union([ z.iso.date(), z.iso.datetime({ offset: true }), @@ -192,5 +210,92 @@ export const apiCardUpdateSchema = z dueDate: apiDueDateSchema.nullable().optional(), }) .refine((d) => Object.keys(d).length > 0, { - message: "No hay campos que actualizar.", + message: "errors.validation.noFieldsToUpdate", + }); + +// --- Open registration and own account (see docs/privacy.md) --------------------------- + +const personNameSchema = z + .string() + .trim() + .min(1, "errors.validation.nameRequired") + .max(60, "errors.validation.maxChars60"); + +// The consent checkbox arrives as "on" when ticked and is absent otherwise: consent has to +// be an affirmative act (GDPR art. 7), never a default value. +export const registerSchema = z.object({ + name: personNameSchema, + email: z.email("errors.validation.emailInvalid").max(254, "errors.validation.maxChars254"), + password: passwordSchema, + acceptTerms: z.literal("on", { + error: "errors.validation.acceptTermsRequired", + }), +}); + +export const profileSchema = z.object({ + name: personNameSchema, + email: z.email("errors.validation.emailInvalid").max(254, "errors.validation.maxChars254"), + // Only needed when the email changes; the service checks it. + currentPassword: z.string().max(200, "errors.validation.maxChars200"), +}); + +export const changePasswordSchema = z + .object({ + currentPassword: z + .string() + .min(1, "errors.validation.currentPasswordRequired") + .max(200, "errors.validation.maxChars200"), + newPassword: passwordSchema, + confirmPassword: z.string(), + }) + .refine((d) => d.newPassword === d.confirmPassword, { + message: "errors.validation.passwordsDontMatch", + path: ["confirmPassword"], }); + +export const deleteAccountSchema = z.object({ + password: z + .string() + .min(1, "errors.validation.passwordToConfirm") + .max(200, "errors.validation.maxChars200"), +}); + +// Optional form text: empty or whitespace-only is stored as null. One key per limit +// because a message key carries no parameters. +const optionalText = (max: 40 | 120 | 200) => + z + .string() + .trim() + .max(max, `errors.validation.maxChars${max}`) + .transform((v) => (v === "" ? null : v)); + +export const instanceSettingsSchema = z.object({ + instanceName: z + .string() + .trim() + .min(1, "errors.validation.instanceNameRequired") + .max(60, "errors.validation.maxChars60"), + registrationOpen: z.enum(["open", "closed"]).transform((v) => v === "open"), + operatorName: optionalText(120), + operatorEmail: z + .string() + .trim() + .max(254, "errors.validation.maxChars254") + .transform((v) => (v === "" ? null : v)) + .pipe(z.email("errors.validation.contactEmailInvalid").nullable()), + operatorLegalId: optionalText(40), + operatorAddress: optionalText(200), + hostingDescription: optionalText(200), + backupRetentionDays: z + .string() + .trim() + .transform((v) => (v === "" ? null : Number(v))) + .pipe( + z + .number({ error: "errors.validation.backupDaysNumber" }) + .int("errors.validation.backupDaysInteger") + .min(0, "errors.validation.backupDaysNegative") + .max(3650, "errors.validation.backupDaysMax") + .nullable(), + ), +}); diff --git a/app/page.tsx b/app/page.tsx index ab0c732..751a901 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,12 +1,12 @@ import { redirect } from "next/navigation"; import { isSetupComplete, verifySession } from "@/app/lib/dal"; -// Depende del estado de la base de datos y de la sesión: nunca debe prerenderizarse -// en build (además, en el build de Docker la DB no existe). +// Depends on the database and the session, so it must never be prerendered at build +// time (the Docker build has no database either). export const dynamic = "force-dynamic"; -// Punto de entrada: decide a dónde mandar al visitante según el estado de la -// instancia y la sesión. +// Entry point: sends the visitor where they belong given the instance state and the +// session. export default async function Home() { if (!(await isSetupComplete())) redirect("/setup"); if (!(await verifySession())) redirect("/login"); diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..60e1f41 --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,20 @@ +import type { MetadataRoute } from "next"; +import { isRegistrationOpen } from "@/app/lib/services/instance"; + +// A private instance (sign-up closed) has nothing to index, so search engines are asked to +// stay out. With open sign-up only the public pages are allowed (sign-in, sign-up and +// legal pages); boards require a session anyway. +export const dynamic = "force-dynamic"; + +export default async function robots(): Promise<MetadataRoute.Robots> { + if (!(await isRegistrationOpen())) { + return { rules: { userAgent: "*", disallow: "/" } }; + } + return { + rules: { + userAgent: "*", + allow: ["/login", "/register", "/legal/"], + disallow: "/", + }, + }; +} diff --git a/app/setup/page.tsx b/app/setup/page.tsx index 34c8e7e..095cef7 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -1,19 +1,27 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { isSetupComplete } from "@/app/lib/dal"; +import { PublicFooter } from "@/app/components/public-footer"; +import { getI18n } from "@/app/i18n/server"; import { SetupForm } from "./setup-form"; export const dynamic = "force-dynamic"; -export const metadata: Metadata = { title: "Configuración inicial" }; +export async function generateMetadata(): Promise<Metadata> { + const { m } = await getI18n(); + return { title: m.auth.setup.metaTitle }; +} export default async function SetupPage() { - // Si ya se completó el setup, el wizard queda bloqueado para siempre. + // Once setup is complete, the wizard is locked for good. if (await isSetupComplete()) redirect("/login"); return ( - <main className="flex flex-1 items-center justify-center p-6"> - <SetupForm /> + <main className="flex min-h-[100dvh] flex-1 flex-col p-6"> + <div className="flex flex-1 items-center justify-center"> + <SetupForm /> + </div> + <PublicFooter className="mt-10" /> </main> ); } diff --git a/app/setup/setup-form.tsx b/app/setup/setup-form.tsx index 628073e..860c8c3 100644 --- a/app/setup/setup-form.tsx +++ b/app/setup/setup-form.tsx @@ -4,12 +4,15 @@ import { useActionState } from "react"; import { completeSetup, type SetupState } from "@/app/lib/actions/setup"; import { Button } from "@/app/components/ui/button"; import { Input } from "@/app/components/ui/input"; +import { useI18n } from "@/app/i18n/client"; export function SetupForm() { const [state, action, pending] = useActionState<SetupState, FormData>( completeSetup, {}, ); + const { m } = useI18n(); + const t = m.auth.setup; return ( <form @@ -26,14 +29,14 @@ export function SetupForm() { </div> <h1 className="mt-7 font-display text-2xl font-bold tracking-tight"> - Configura tu instancia + {t.title} </h1> <p className="mt-1 text-sm text-ink-muted"> - Este asistente solo aparece una vez. Crea tu cuenta de administrador. + {t.subtitle} </p> - {/* role="alert" para que el lector de pantalla lo anuncie. Se retira mientras se - envía: así un segundo error idéntico se vuelve a insertar y se anuncia de nuevo. */} + {/* role="alert" so screen readers announce it. It is removed while submitting, so an + identical second error is inserted again and announced again. */} {state.error && !pending && ( <p role="alert" @@ -45,16 +48,16 @@ export function SetupForm() { <fieldset className="mt-6"> <legend className="text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Instancia + {t.instanceLegend} </legend> <label className="mt-3 block text-sm font-medium"> - Nombre de la instancia + {t.instanceName} <Input name="instanceName" type="text" required maxLength={60} - placeholder="Mi equipo" + placeholder={t.instanceNamePlaceholder} defaultValue={state.values?.instanceName} className="mt-1.5" /> @@ -63,11 +66,11 @@ export function SetupForm() { <fieldset className="mt-6"> <legend className="text-xs font-semibold uppercase tracking-wide text-ink-muted"> - Administrador + {t.adminLegend} </legend> <div className="mt-3 space-y-4"> <label className="block text-sm font-medium"> - Tu nombre + {t.yourName} <Input name="name" type="text" @@ -79,7 +82,7 @@ export function SetupForm() { /> </label> <label className="block text-sm font-medium"> - Email + {m.auth.fields.email} <Input name="email" type="email" @@ -90,7 +93,7 @@ export function SetupForm() { /> </label> <label className="block text-sm font-medium"> - Contraseña + {m.auth.fields.password} <Input name="password" type="password" @@ -100,14 +103,14 @@ export function SetupForm() { className="mt-1.5" /> <span className="mt-1 block text-xs text-ink-muted"> - Mínimo 8 caracteres. + {t.passwordHint} </span> </label> </div> </fieldset> <Button type="submit" disabled={pending} className="mt-6 w-full"> - {pending ? "Creando…" : "Crear y entrar"} + {pending ? t.submitting : t.submit} </Button> </form> ); diff --git a/docker-compose.yml b/docker-compose.yml index a8cf744..e13e5a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ -# orgtable — orquestación de producción. La imagen `app` se construye localmente. -# Las variables vienen de .env (lo genera install.sh con secretos aleatorios). -# Ver docs/installation.md y docs/docker.md. +# orgtable — production orchestration. The `app` image is built locally. +# Variables come from .env (install.sh generates it with random secrets). +# See docs/installation.md and docs/docker.md. services: postgres: @@ -19,7 +19,7 @@ services: retries: 10 networks: - orgtable - # Postgres NO se expone al host: solo es accesible desde la red interna. + # Postgres is NOT exposed to the host: it is only reachable from the internal network. app: build: @@ -33,28 +33,34 @@ services: DATABASE_URL: ${DATABASE_URL} SESSION_SECRET: ${SESSION_SECRET} NODE_ENV: production - # Directorio de adjuntos (volumen persistente). Ver docs/attachments.md. + # Attachments directory (persistent volume). See docs/attachments.md. ORGTABLE_UPLOAD_DIR: ${ORGTABLE_UPLOAD_DIR:-/data/uploads} - # Tiempo real entre varias réplicas (0 = en proceso). Ver docs/realtime.md. + # Real time across several replicas (0 = in-process). See docs/realtime.md. ORGTABLE_REALTIME_PG_NOTIFY: ${ORGTABLE_REALTIME_PG_NOTIFY:-0} - # Secreto del endpoint de recordatorios. Vacío = endpoint desactivado (503). El - # contenedor solo ve las variables listadas aquí: sin esta línea, el CRON_SECRET del - # .env no llegaría a la app. Ver docs/reminders.md. + # Secret for the reminders endpoint. Empty = endpoint disabled (503). The container + # only sees the variables listed here: without this line, the CRON_SECRET in .env + # would never reach the app. See docs/reminders.md. CRON_SECRET: ${CRON_SECRET:-} - # Proxies de confianza delante de la app (0 = se ignora X-Forwarded-For y el límite - # de login va por email). Ver docs/self-hosting.md. + # Trusted proxies in front of the app (0 = X-Forwarded-For is ignored and the login + # rate limit is per email). See docs/self-hosting.md. TRUSTED_PROXY_HOPS: ${TRUSTED_PROXY_HOPS:-0} - # Zona horaria de las fechas que formatea el servidor. Ver docs/installation.md. + # Time zone for dates formatted by the server. See docs/installation.md. ORGTABLE_TIMEZONE: ${ORGTABLE_TIMEZONE:-Atlantic/Canary} + # Days the audit log is kept before POST /api/cron/maintenance deletes it. + # Empty = 90. See docs/privacy.md. + ORGTABLE_AUDIT_RETENTION_DAYS: ${ORGTABLE_AUDIT_RETENTION_DAYS:-} + # The app's "Source code" link (AGPL section 13). Empty = the original repository; + # if you deploy a modified version, put your fork here. + ORGTABLE_SOURCE_URL: ${ORGTABLE_SOURCE_URL:-} volumes: - uploads:/data/uploads ports: - # APP_PORT admite también «127.0.0.1:3000» para publicar la app solo en local - # (detrás de un proxy inverso). Ver docs/self-hosting.md. + # APP_PORT also accepts "127.0.0.1:3000" to publish the app on localhost only + # (behind a reverse proxy). See docs/self-hosting.md. - "${APP_PORT:-3000}:3000" healthcheck: - # 127.0.0.1 (no "localhost"): dentro del contenedor localhost resuelve a IPv6 - # ::1, pero el servidor standalone escucha en IPv4 0.0.0.0. + # 127.0.0.1 (not "localhost"): inside the container localhost resolves to IPv6 + # ::1, but the standalone server listens on IPv4 0.0.0.0. test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/health || exit 1"] interval: 10s timeout: 5s diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 8828be3..f627582 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,12 +1,12 @@ #!/bin/sh -# Entrypoint del contenedor `app`. Aplica las migraciones pendientes de Prisma -# (idempotente: `migrate deploy` solo ejecuta las que faltan) y luego arranca el -# servidor Next.js. Ver docs/docker.md. +# Entrypoint of the `app` container. Applies pending Prisma migrations (idempotent: +# `migrate deploy` only runs the missing ones) and then starts the Next.js server. +# See docs/docker.md. set -e -echo "[orgtable] Aplicando migraciones de la base de datos..." -# Invocamos el CLI por ruta directa (no `npx`) para no depender de symlinks de .bin. +echo "[orgtable] Applying database migrations..." +# Call the CLI by its direct path (not `npx`) so we do not depend on .bin symlinks. node node_modules/prisma/build/index.js migrate deploy -echo "[orgtable] Arrancando el servidor..." +echo "[orgtable] Starting the server..." exec "$@" diff --git a/docs/DECISIONES.md b/docs/DECISIONES.md index 07cc4da..598ebf4 100644 --- a/docs/DECISIONES.md +++ b/docs/DECISIONES.md @@ -1,1991 +1,2384 @@ -# Registro de decisiones +# Decision log -Decisiones técnicas del proyecto, las más recientes arriba. Cada entrada lleva fecha, -contexto, decisión, alternativas descartadas y consecuencias. Las decisiones de -arquitectura anteriores a este registro (ADR 0001–0015) siguen en -[`docs/decisions/`](./decisions/); este archivo es el registro vivo a partir de -2026-09-24. +Technical decisions of the project, most recent first. Each entry has a date, context, +decision, alternatives considered and consequences. Architecture decisions made before +this log (ADR 0001–0015) remain in [`docs/decisions/`](./decisions/); this file is the +living log from 2026-09-24 onwards. --- -## 2026-09-24 — Flechas izquierda/derecha de una tarjeta con el teclado +## 2026-10-14 — English routes, anchors and parameters; repository fully in English + +**Context.** Translating the interface left some Spanish behind in places users see: + +- the legal routes `/legal/privacidad`, `/legal/condiciones` and `/legal/aviso-legal`; +- the settings anchors `#perfil`, `#seguridad`, `#apariencia`, `#datos` and `#acerca`; +- the `/login?cuenta=borrada` parameter after an account is deleted. + +The `locale` cookie was also written in two places with different flags: the language +picker set `secure` on HTTPS, sign-in did not. + +**Decision.** +- Use English paths and anchors in every language: + - `/legal/privacy`, `/legal/terms`, `/legal/cookies` and `/legal/notice`; + - `#profile`, `#security`, `#api`, `#appearance`, `#data` and `#about`; + - `/login?account=deleted`. + + URLs are identifiers, not interface text. These routes had never been released, so + no redirects are needed. +- One helper, `setLocaleCookie()` in `app/i18n/cookie.ts`, writes the cookie for both + callers: one year, `SameSite=Lax`, and `Secure` when the request came over HTTPS, like + the session cookie. +- "Created by" in Settings › About uses the same message as the public footer, so the + attribution reads the same in both places. LICENSE 7(b) allows translating these two + words. +- The rest of the repository was translated as well: + - code comments and tests, with English expectations in tests by default and new tests + for each language; + - scripts and their operator messages, configuration and CI comments; + - the demo data used for the README media; + - documentation, this log and the ADRs. + + The README is English, with a Spanish `README.es.md`, and the roadmap no longer uses + internal milestone numbering. + +**Alternatives considered.** +- **Localized URLs** (`/legal/privacidad` for Spanish, `/legal/privacy` for English). + Same pages under two names, links that change with the language, and nothing gained, + because the language does not come from the URL. +- **Keeping the old routes with redirects.** Nothing was ever published under them. + +**Consequences.** +- The README screenshots and GIFs were retaken in English (`scripts/readme-media/`). +- Notifications stored before this change keep their Spanish text. +- Integration tests now cover the request language, the cookie, the account preference + and per-recipient notifications (`test/integration/locale.int.test.ts` and others). -**Contexto.** Probando en el navegador el build final, una tarjeta cogida con Espacio no -se movía con la flecha izquierda. `sortableKeyboardCoordinates` filtra los destinos «a la -izquierda» comparando con el borde de la tarjeta, y el borde de su propia columna está -8 px antes: la columna propia salía como destino más cercano y la tarjeta volvía a su -sitio. Hacia la derecha funcionaba porque la columna propia nunca queda a la derecha. +--- + +## 2026-10-14 — Bilingual interface (English and Spanish), easy to translate + +**Context.** The owner is preparing the public release. A Spanish-only interface, with +internal milestone wording, is not professional for a public project. The app has to +support several languages, and anyone should be able to contribute a translation without +knowing the code. + +**Decision.** +- **Messages in JSON, one folder per language** (`locales/<code>/<area>.json`), which + translators and tools such as Weblate or Crowdin already know. + - English (`en`) is the reference and the fallback: a missing message shows in English, + so a partial translation never breaks the app. + - Adding a language means copying `en/`, translating it and adding one line to + `locales/index.ts`. The guide is in `locales/README.md`. +- **Values are plain data.** Strings carry `{placeholders}`, plurals are objects + (`{ one, other, … }`, chosen with `Intl.PluralRules`), and markup uses simple tags + (`<link>…</link>`) rendered by `rich()`. With no functions inside, the dictionary of the + current language travels from the root layout to Client Components as props + (`I18nProvider`), and the client bundle never imports the dictionaries. +- **Language of a request**: the `locale` cookie set by the picker, else + `Accept-Language`, else English. `User.locale` (migration `..._user_locale`) stores the + account's choice: it restores the cookie on sign-in and decides the language of the + notifications written for that person. +- **zod schemas carry message keys** (`"errors.validation.titleRequired"`), resolved with + `issueMessage(m, issue)`, so they stay language-independent. +- **The public REST API answers in English only.** It is for programs. +- **CI check** (`test/i18n.test.ts`). Every registered language must have the same keys + as English, the same placeholders and tags, and the plural forms its grammar needs. +- **Language picker** with one submit button per language, which works without + JavaScript. It sits in the footer of the public pages and in Settings › Appearance. +- **No dependency.** About 150 lines of our own code. + +**Alternatives considered.** +- **next-intl.** Mature (ICU, middleware), but it is a dependency for what native `Intl` + plus a few helpers already cover. Its locale-prefixed routes (`/en/...`) would also break + every existing link and the deep-links of notifications. +- **Messages as TypeScript objects.** Type-safe for developers, but harder for + translators, and invisible to translation tools. +- **Language in the URL.** Better for SEO, but almost everything is behind sign-in. The + few public pages work with the cookie and Accept-Language. + +**Consequences.** +- Every visible text in the app moves to `locales/`. The work is split by area: auth and + legal; shell, settings and admin; boards; server messages and notifications. +- Notifications already stored stay in the language they were written in. +- Following the owner's decision, everything in this repository is in English from now on: + code comments, tests, documentation and commits. This deliberately departs from the + owner's global rule of Spanish documentation, for this repository only. + +--- + +## 2026-10-14 — Next 16.3.8 and patched sharp and source-map-js + +**Context.** Running the CI locally before pushing, `pnpm audit --prod --audit-level high` +failed on three new advisories published after the last review: + +- GHSA-cjq9-62q9-8jv4: SSRF in Next's image optimization (`next` < 16.3.8); +- GHSA-wq5f-xc86-pv6w: `sharp` < 0.35.5, through `next`; +- GHSA-68fv-2mgg-jv7q: `source-map-js` < 1.2.2, through `postcss`. + +The same step would also fail on `main`. + +**Decision.** +- Bump `next` and `eslint-config-next` to 16.3.8. These are patch releases. +- Add two `overrides` in `pnpm-workspace.yaml`: `sharp@<0.35.5` and + `source-map-js@<1.2.2`. Even with next 16.3.8, the lockfile kept the old transitive + versions although the ranges allow the patched ones. + +**Alternatives considered.** +- **next 16.4.x.** A minor release with more changes than a security fix needs. It goes in + its own commit after reading its changelog. +- **Ignoring the advisories.** The Next.js one affects any deployment that serves images. + +**Consequences.** `pnpm audit --prod --audit-level high` passes again; only the +previously documented ignored advisory remains. Remove the two overrides once next ships +the patched versions itself. + +--- + +## 2026-10-14 — Own icon, instance-aware robots.txt and Open Graph + +**Context.** The favicon was the default triangle from the Next template, and `public/` +held the template's SVGs (Next and Vercel logos), unused. A public repo should not carry +other companies' brands. There was no `robots.txt` either: a private instance could end +up indexed starting from its login page. + +**Decision.** +- `app/icon.svg` with the brand's "o" on terracotta. `app/favicon.ico` and the template + SVGs are deleted. +- Dynamic `app/robots.ts`. With registration closed it asks for `Disallow: /`. With + registration open it allows only `/login`, `/register` and `/legal/`. `/robots.txt` is + public in `proxy.ts`. +- Basic Open Graph metadata in the root layout: name, description and language. + +**Alternatives considered.** +- **Static `robots.txt`.** It would have to serve a private instance and a public one + equally. +- **Sitemap.** It needs the instance's absolute public URL, which the app does not know. + One more variable (`ORGTABLE_PUBLIC_URL`) for three public pages is not worth it for now. +- **Open Graph image.** Left for when the official instance and its domain exist. + +**Consequences.** Test of `robots()` in `registration.int.test.ts`. + +--- + +## 2026-10-14 — Visual README with real screenshots and GIF + +**Context.** To publish the project, the README had to show the app, like vibe-gambling's +does: main GIF, gallery, features, privacy and credits. Without losing the fixed structure +(what it is, requirements, installation, commands, variables and deployment). + +**Decision.** +- **Screenshots and GIF of the real app**, recorded with Playwright (headless Brave) on a + production build and a separate database (`orgtable_demo`) with sample data: fictional + people with `@example.com` emails, which is a reserved domain. The GIFs are assembled + with Pillow using a shared palette and no dithering: 0.5 MB for the main one and 0.1 MB + for the realtime one. +- Screenshots in **WebP** at 1600 px (30–55 KB each) in `docs/screenshots/`. +- `README.md` in Spanish, like the rest of the documentation, and a shorter `README.en.md` + for people arriving from outside. +- The link to the official public instance stays as a `TODO` in the README: its URL does + not exist yet. + +**Alternatives considered.** +- **Mockups instead of screenshots.** They go stale and do not prove the app works. +- **MP4 video in the README.** GitHub only plays it if it is uploaded as an issue + attachment, not from the repo. +- **Recording on the development DB.** It would mix real test data with the demo. + +**Consequences.** If the interface changes a lot, the screenshots have to be regenerated +with the scripts in `scripts/readme-media/`: demo seeding, Playwright, and GIF and WebP +assembly. Their instructions are in their README. `playwright-core` is installed +separately so it is not added to the project's dependencies. + +--- + +## 2026-10-14 — Account menu in the navbar and boards with context + +**Context.** The top bar piled up, loose, the Administration link, the settings gear, the +email and "Sign out" ("Salir"). On mobile they did not fit and the instance name got cut +off. On the index, board cards only showed the title, and importing from JSON competed in +prominence with "Create board". + +**Decision.** +- The bar keeps search, notifications, theme and a **button with the initials** that + opens a menu with name and email, links to profile, password and sessions, privacy and + data, administration (admins only) and "Sign out" ("Cerrar sesión"). It uses the + browser's `popover` API: no custom JavaScript, closing on outside click or Escape. It is + anchored with anchor positioning, and where that is not supported it falls back to the + top right. +- The cards on `/boards` show the number of cards and, if the board is shared, the + initials of up to 4 members. The names are included as hidden text for screen readers. + It uses a new query, `getBoardSummariesForUser`, so as not to change what + `GET /api/v1/boards` returns. +- Import moves into a collapsed `<details>`. + +**Alternatives considered.** +- **Menu as a client component with its own focus management.** More code for what the + native popover already solves. +- **Keeping the email visible in the bar.** It takes space on every screen and is + already in the menu. + +**Consequences.** `app/lib/initials.ts` with its test. Checked at 375 and 1280 px. + +--- + +## 2026-10-14 — Redesigned settings and accounts in each person's hands + +**Context.** With registration open, every person has to be able to manage their account +without asking the admin. `/settings` only had API tokens: you could not change your name, +email or password, see your sessions, or exercise your GDPR rights. + +**Decision.** `/settings` gets a side navigation (a scrollable row on mobile), designed +first on a design canvas, with these sections: + +- **Profile.** Changing the email requires the current password, so that a stolen session + cannot hijack the account. +- **Password and sessions.** Changing the password signs out the other sessions and keeps + the current one. Tokens are kept: the person creates them themselves. Open sessions are + listed with a readable browser name (`app/lib/user-agent.ts`) and can be closed one by + one or all at once. +- **API tokens** (unchanged) and **Appearance**: light, dark or system, with the same + `localStorage` key as the anti-flicker script. +- **Privacy and data.** + - JSON export (`/api/account/export`, browser session only) with everything the person + owns and everything they wrote on other people's boards, without hashes. + - Account deletion with password: it deletes the person's own boards along with their + attachment files, the rest goes by cascade, and the audit `detail` that named the + person is emptied. + - The last ADMIN account cannot be deleted. The check locks the admin rows with + `FOR UPDATE`, with a concurrency test. +- **About**: version, source code, license and the license attribution. + +The logic lives in `services/account.ts`, without request context, like the rest of the +services. + +**Alternatives considered.** +- **Transferring shared boards instead of deleting them.** It is kinder to the team, but + requires choosing to whom, and the app has no ownership transfer. For now the warning + says how many boards are deleted. Left as an improvement. +- **Also exporting through the API with a token.** A leaked token would pull the full + account dump. Export stays limited to the browser session. + +**Consequences.** Tests in `test/integration/account.int.test.ts`: profile, password, +sessions, export, deletion with attachments, last admin and two admins at once. Other +people's notifications may keep the deleted person's name until they expire (180 days). +The privacy policy says so. + +--- + +## 2026-10-14 — Open registration, legal pages and data cleanup (GDPR) + +**Context.** The owner is going to host a public instance with free registration and wants +anyone to be able to run their own. Until now only the admin created accounts, and there +was no privacy policy, terms or retention periods. + +**Decision.** +- **Optional registration per instance.** `InstanceConfig.registrationOpen`, closed by + default. It is opened in `/admin/instance` and only if the controller and the contact + email are set, which art. 13 GDPR requires. `isRegistrationOpen()` checks it again on + every sign-up. New accounts are MEMBER. +- **Consent.** Mandatory checkbox, unchecked by default, with links to the policy and the + terms. `User.termsAcceptedAt` is stored. Next to the form there is a first information + layer: controller, purpose and rights. +- **Anti-abuse without external services.** + - Trap field (honeypot). + - 5 sign-ups per hour per IP, if there is a trusted proxy. + - 30 per hour across the whole instance, counted after validation so that a typo does + not use up quota. + - A concurrent sign-up with the same email (P2002) responds like a normal duplicate, + not with a 500. +- **Operator legal details** (`InstanceConfig.operator*`, hosting, backup days) editable + by the admin. The pages `/legal/{privacidad,condiciones,cookies,aviso-legal}` describe + what this code does, with the periods taken from the same constants as the cleanup. + Anything missing is shown as "not specified", never made up. That way they work for the + official instance and for any self-hosted instance. +- **Cleanup** via `POST /api/cron/maintenance` (same `CRON_SECRET` as the reminders, + shared authorization in `app/lib/api/cron.ts`): + - audit log after `ORGTABLE_AUDIT_RETENTION_DAYS` days (90 by default); + - closed or expired sessions after 30 days; + - notifications after 180 days. +- **Public footer** with legal links, source code (`ORGTABLE_SOURCE_URL`, validated) and + the license attribution, on login, registration, setup and legal pages. +- **Minimum age of 14** in the terms: it is the age of digital consent in Spain (art. 7 + LOPDGDD). + +**Alternatives considered.** +- **Email verification.** It needs SMTP, which is still in the backlog. Without it, + registration reveals whether an email already has an account. The sign-up limit slows + down mass enumeration. +- **External CAPTCHA (Turnstile, hCaptcha).** It adds a third party that sees every + visitor and forces expanding the policy and the CSP. It stays as an option if real spam + shows up. +- **Fixed legal texts in Markdown.** They would be false as soon as someone else hosted + their instance or changed a period. +- **Cookie banner.** There is only strictly necessary technical storage (art. 22.2 LSSI). + A banner is unnecessary as long as there is no analytics. + +**Consequences.** +- Migration `20261014171630_registration_and_legal`, new columns only. +- For the cleanup to run, the maintenance cron has to be scheduled, like the reminders + one (`docs/operations.md`). +- Tests in `test/integration/registration.int.test.ts` and `maintenance.int.test.ts`. +- The legal texts were drafted by Claude, not a lawyer: a professional should review them + before opening the public instance. +- The official instance's operator details are filled in by the owner in + `/admin/instance`. They are not in the repo. + +--- + +## 2026-10-14 — OFL fonts instead of the Fontshare ones + +**Context.** The interface used Satoshi and Cabinet Grotesk, from Fontshare, with the +`.woff2` files inside the repo (`app/fonts`). Their license, the ITF Free Font License 2.0 +(read from the `FFL.txt` included in the official download), forbids distributing the +files "through another [...] repository [...] publicly accessible servers" and giving them +to third parties. Publishing the repo under the AGPL, and having others run their own +instance, is exactly that. + +**Decision.** They are replaced by **Bricolage Grotesque** (headings) and **Figtree** +(interface), both under the SIL OFL 1.1, loaded with `next/font/google` as was already done +with Geist Mono. Next downloads them at build time and serves them from the app itself: at +runtime there are no requests to Google and the CSP does not change. `app/fonts` is +deleted. I chose them for their closeness to the previous character: a display grotesque +with editorial personality and a warm geometric sans for body text. They were tried out +first on the design canvas. + +**Alternatives considered.** +- **Keeping Fontshare, loading the fonts from its API.** The license also forbids making + them available to third parties from a service, and it would add an external CDN to an + app meant to be hosted without dependencies. +- **Copying the OFL `.woff2` files into the repo.** It works just as well, but + `next/font/google` was already in use and avoids maintaining binary files. + +**Consequences.** +- The Docker build needs internet access to download the fonts. It already needed it for + Geist Mono, so nothing changes. +- Earlier commits still contain the Fontshare `.woff2` files. If the repository is made + public with its full history, those files remain accessible. Removing them requires + rewriting history (`git filter-repo`) and force-pushing. That is destructive and is the + owner's call. + +--- + +## 2026-10-14 — AGPL-3.0-or-later license with attribution + +**Context.** The owner wants to publish orgtable as open source: anyone can use, modify +and host it, as long as they keep it open and give credit. Until now the proprietary +license of MARA-KASSA SL (ADR 0016) applied. The repository was transferred the same day +to the personal account `Aimarmndz`. + +**Decision.** `LICENSE` becomes the official GNU AGPL-3.0 text (downloaded from gnu.org, +unchanged) followed by additional terms under section 7(b), the same ones used by +vibe-gambling, another project by the author: + +- Copyright holder: "Aimar (https://github.com/Aimarmndz)", with the "or any later + version" option. +- Anyone who distributes the app or offers a modified version over a network must keep + "Created by Aimar (github.com/Aimarmndz)" in the footer of the public pages (login, + registration, legal pages) and in "About" in the settings. +- `package.json` declares `AGPL-3.0-or-later`. The CI step that prevented mentioning the + AGPL now checks the opposite: that `LICENSE` and `package.json` match, that the + attribution term is still there and that no remnants of the proprietary license are left. +- Contributions are *inbound = outbound* with DCO (`git commit -s`), without assignment of + rights. The assignment clause goes away, which was of doubtful validity anyway. + +**Alternatives considered.** +- **MIT or Apache-2.0.** They give credit, but do not require keeping the code open: a + third party could offer orgtable as closed SaaS. That is what the owner wants to avoid. +- **GPL-3.0.** It does not cover network use: hosting a modified version without + distributing it would not require publishing the code. The AGPL (section 13) does. +- **AGPL without 7(b) terms.** The copyright notice is already kept in the code, but + nothing would require the credit to be visible in the interface of someone else's + instance. + +**Consequences.** +- The AGPL requires every instance to offer its source code to its network users. The app + links to the code from the footer and from the settings; whoever modifies the app must + point that link to their own code (`ORGTABLE_SOURCE_URL` variable). +- Copies distributed while the proprietary license applied do not change license. From + now on the AGPL applies to all history that is published. +- Ownership: the previous license was in the name of MARA-KASSA SL and the new one in the + name of Aimar, as the owner asked ("giving me credit"). If the rights belong to the + company, the assignment to Aimar should be put in writing, or the company should be + listed as the holder alongside the attribution. That is the owner's call. + +--- + +## 2026-09-24 — Moving a card left/right with the keyboard arrows + +**Context.** While testing the final build in the browser, a card picked up with Space did +not move with the left arrow. `sortableKeyboardCoordinates` filters the "to the left" +targets by comparing against the card's edge, and the edge of its own column is 8 px +earlier: the card's own column came out as the closest target and the card went back to +its place. Moving right worked because the card's own column is never to the right. -**Decisión.** `boardKeyboardCoordinates` resuelve también izquierda/derecha para las -tarjetas: toma como referencia el borde de la columna que contiene el centro de la -tarjeta, salta a la columna vecina y, dentro de ella, a la tarjeta más cercana en -vertical, o a la columna si está vacía. Arriba/abajo siguen con `@dnd-kit/sortable`. +**Decision.** `boardKeyboardCoordinates` also resolves left/right for cards: it takes as +reference the edge of the column containing the card's center, jumps to the neighboring +column and, within it, to the vertically closest card, or to the column if it is empty. +Up/down still use `@dnd-kit/sortable`. -**Alternativas descartadas.** Envolver `droppableContainers` para quitar la columna -propia antes de llamar a `sortableKeyboardCoordinates`: depende de la forma interna de -`DroppableContainersMap` y seguiría eligiendo destinos por esquinas en vez de por -columna. +**Alternatives considered.** Wrapping `droppableContainers` to remove the card's own +column before calling `sortableKeyboardCoordinates`: it depends on the internal shape of +`DroppableContainersMap` and would still pick targets by corners instead of by column. -**Consecuencias.** Tests con rectángulos sintéticos en `test/board-collision.test.ts` -(fallan con el código anterior). Verificado con Playwright: la tarjeta pasa a una -columna vacía a la izquierda y vuelve a la derecha, y el cambio queda en la base de -datos. +**Consequences.** Tests with synthetic rectangles in `test/board-collision.test.ts` (they +fail with the previous code). Verified with Playwright: the card moves to an empty column +on the left and back to the right, and the change is persisted in the database. --- -## 2026-09-24 — Restaurar adjuntos sin borrar antes de comprobar el archivo - -**Contexto.** El procedimiento de `docs/operations.md` para restaurar los adjuntos era -`rm -rf /data/uploads/* && tar xzf - -C /data`: borraba todos los adjuntos antes de leer -el archivo, sin comprobarlo y sin copia previa, justo lo que `scripts/restore.sh` evita -con la base de datos. Con un `.tar.gz` truncado se perdían los adjuntos actuales y solo -quedaba lo que `tar` llegara a extraer. Además, `/*` no incluye los ficheros ocultos. - -**Decisión.** Solo se cambia la documentación, con tres pasos: `gzip -t` y `tar tzf` -del archivo en el host, `scripts/backup.sh` como copia previa, y extracción a -`/data/uploads/.restore` dentro del propio volumen. Los adjuntos actuales solo se -sustituyen si `tar` termina bien (`set -e`), y con `find` se incluyen los ocultos. El -temporal va dentro del volumen porque `/data/uploads` es el punto de montaje y no se -puede borrar ni renombrar. - -**Alternativas descartadas.** -- Una opción en `scripts/restore.sh` o un `scripts/restore-uploads.sh` con - confirmación, copia previa y parada o arranque de la app con `trap`. Es lo más - coherente con la base de datos, pero toca los scripts de operación y sus tests, que - quedan fuera de este lote. Queda como mejora. -- Mantener el comando con solo un aviso delante: seguía borrando antes de comprobar. -- Extraer fuera del volumen (p. ej. `/tmp`) y copiar después: la copia puede fallar a - medias con los adjuntos ya borrados, y dentro del mismo volumen `mv` es un - renombrado. - -**Consecuencias.** Se probó contra la imagen de la app, en un volumen temporal: con un -archivo truncado `tar` falla, los adjuntos anteriores (ocultos incluidos) siguen -intactos y solo queda `.restore`, que el siguiente intento borra; con un archivo bueno -se sustituyen todos. El procedimiento sigue siendo manual: nada obliga a hacer los pasos -1 y 2. -## 2026-09-24 — Espacio sobre una pastilla del calendario no abre la tarjeta - -**Contexto.** La pastilla del calendario es un `<button>` con `onClick` que abre la -tarjeta y el `KeyboardSensor` de dnd-kit, que coge y suelta con Espacio. dnd-kit solo -cancela el keydown, pero un botón nativo se activa en el keyup de Espacio (Firefox lo -hace aunque el keydown esté cancelado): coger o soltar la pastilla podía abrir además el -modal. La tarjeta del tablero no lo sufre porque es un `div role="button"` que gestiona -el teclado a mano. - -**Decisión.** Si la pastilla se puede arrastrar (`canEdit`), su `onKeyUp` cancela Espacio -(`preventSpaceActivation` en `app/components/board/calendar-view.tsx`). Sin permiso de -edición no se toca: Espacio sigue abriendo la tarjeta. Intro no cambia: el botón se -activa en el keydown, que dnd-kit ya cancela al soltar. - -**Alternativas descartadas.** Convertir la pastilla en un `div role="button"` con la -misma lógica de teclado que `CardItem`: es más coherente con el tablero, pero es un -cambio mayor (foco, activación y atributos a mano) para el mismo efecto. - -**Consecuencias.** Test unitario en `test/calendar-view.test.ts` (Espacio se cancela, -Intro y las flechas no). El comportamiento en Firefox solo se comprueba a mano. +## 2026-09-24 — Restoring attachments without deleting before checking the archive + +**Context.** The procedure in `docs/operations.md` for restoring attachments was +`rm -rf /data/uploads/* && tar xzf - -C /data`: it deleted all attachments before reading +the archive, without checking it and without a prior backup, exactly what +`scripts/restore.sh` avoids for the database. With a truncated `.tar.gz`, the current +attachments were lost and only whatever `tar` managed to extract remained. Also, `/*` does +not include hidden files. + +**Decision.** Only the documentation changes, with three steps: `gzip -t` and `tar tzf` +on the archive on the host, `scripts/backup.sh` as a prior backup, and extraction to +`/data/uploads/.restore` inside the volume itself. The current attachments are only +replaced if `tar` finishes successfully (`set -e`), and `find` is used so hidden files are +included. The temporary directory lives inside the volume because `/data/uploads` is the +mount point and cannot be deleted or renamed. + +**Alternatives considered.** +- An option in `scripts/restore.sh` or a `scripts/restore-uploads.sh` with confirmation, + prior backup and stopping/starting the app with `trap`. It is the most consistent with + the database, but it touches the operations scripts and their tests, which are outside + this batch. Left as an improvement. +- Keeping the command with just a warning in front of it: it still deleted before + checking. +- Extracting outside the volume (e.g. `/tmp`) and copying afterwards: the copy can fail + halfway with the attachments already deleted, whereas inside the same volume `mv` is a + rename. + +**Consequences.** Tested against the app image, on a temporary volume: with a truncated +archive `tar` fails, the previous attachments (hidden ones included) stay intact and only +`.restore` is left, which the next attempt deletes; with a good archive they are all +replaced. The procedure is still manual: nothing forces steps 1 and 2. +## 2026-09-24 — Space on a calendar chip does not open the card + +**Context.** The calendar chip is a `<button>` with an `onClick` that opens the card, plus +dnd-kit's `KeyboardSensor`, which picks up and drops with Space. dnd-kit only cancels the +keydown, but a native button activates on the Space keyup (Firefox does so even when the +keydown was cancelled): picking up or dropping the chip could also open the modal. The +board card does not suffer from this because it is a `div role="button"` that handles the +keyboard by hand. + +**Decision.** If the chip is draggable (`canEdit`), its `onKeyUp` cancels Space +(`preventSpaceActivation` in `app/components/board/calendar-view.tsx`). Without edit +permission it is left alone: Space still opens the card. Enter does not change: the button +activates on keydown, which dnd-kit already cancels on drop. + +**Alternatives considered.** Turning the chip into a `div role="button"` with the same +keyboard logic as `CardItem`: it is more consistent with the board, but it is a bigger +change (focus, activation and attributes by hand) for the same effect. + +**Consequences.** Unit test in `test/calendar-view.test.ts` (Space is cancelled, Enter and +the arrows are not). The Firefox behavior is only checked by hand. --- -## 2026-09-24 — Copia flotante del calendario en un portal a `document.body` - -**Contexto.** El `DragOverlay` del calendario se pintaba dentro del panel, que lleva -`backdrop-blur-sm`. `backdrop-filter` convierte ese panel en el bloque contenedor de sus -descendientes `position: fixed`, y dnd-kit coloca la copia con `fixed` y coordenadas de la -ventana. La copia salía desplazada (lo que miden las cabeceras de la app y del tablero), -la animación de soltar volvía a un punto equivocado y el `overflow-y-auto` la recortaba. - -**Decisión.** En `app/components/board/calendar-view.tsx`, el `DragOverlay` se pinta con -`createPortal(…, document.body)`. Se monta solo en el navegador (`useSyncExternalStore` -con instantánea de servidor `false`, el mismo patrón que `CardItem`) y siempre, no solo -con una tarjeta activa, para que termine la animación de soltar. El portal conserva el -contexto de React, así que sigue dentro del `DndContext`. - -**Alternativas descartadas.** -- Quitar `backdrop-blur-sm` del panel: cambia el diseño (decisión del usuario) y - cualquier `transform`, `filter` o `will-change` futuro en un ancestro lo volvería a - romper. -- Sacar el `DragOverlay` del panel pero dentro del contenedor con scroll: no protege - frente a otros ancestros con esas propiedades. - -**Consecuencias.** La copia sigue al puntero sin recortes. Test -`test/calendar-view.test.ts`: el render de servidor no toca `document` (falla si el -portal se monta sin la guarda). El posicionamiento real solo se comprueba a mano en el -navegador. -## 2026-09-24 — Aviso de error retirado al enviar en usuario, token e importar - -**Contexto.** `create-user-form.tsx`, `create-token-form.tsx` e `import-board-form.tsx` -dejaban el aviso `role="alert"` montado entre envíos. Un segundo error idéntico (crear dos -veces un usuario con un email existente, importar dos veces el mismo JSON inválido) no -cambiaba el DOM y el lector de pantalla no lo anunciaba. - -**Decisión.** El mismo patrón que `login-form.tsx` y el panel de miembros: -`state.error && !pending`. En crear usuario, `aria-describedby` del email solo apunta al -aviso mientras está visible, para no referenciar un id inexistente durante el envío. - -**Alternativas descartadas.** Añadir un contador o una key al aviso para forzar que se -reinserte: más estado para lo mismo que ya hace `pending`. - -**Consecuencias.** Mientras se envía no se ve el error anterior (el botón ya dice -«Creando…»/«Importando…»). `test/form-error-reannounce.test.ts` lo comprueba en el HTML -de los tres formularios. +## 2026-09-24 — Calendar drag overlay in a portal to `document.body` + +**Context.** The calendar's `DragOverlay` was rendered inside the panel, which has +`backdrop-blur-sm`. `backdrop-filter` makes that panel the containing block of its +`position: fixed` descendants, and dnd-kit positions the overlay with `fixed` and viewport +coordinates. The overlay came out offset (by the height of the app and board headers), the +drop animation returned to the wrong point and `overflow-y-auto` clipped it. + +**Decision.** In `app/components/board/calendar-view.tsx`, the `DragOverlay` is rendered +with `createPortal(…, document.body)`. It is mounted only in the browser +(`useSyncExternalStore` with a server snapshot of `false`, the same pattern as `CardItem`) +and always, not only while a card is active, so that the drop animation finishes. The +portal keeps the React context, so it stays inside the `DndContext`. + +**Alternatives considered.** +- Removing `backdrop-blur-sm` from the panel: it changes the design (the user's decision), + and any future `transform`, `filter` or `will-change` on an ancestor would break it + again. +- Moving the `DragOverlay` out of the panel but inside the scroll container: it does not + protect against other ancestors with those properties. + +**Consequences.** The overlay follows the pointer without clipping. Test +`test/calendar-view.test.ts`: the server render does not touch `document` (it fails if the +portal is mounted without the guard). The actual positioning is only checked by hand in +the browser. +## 2026-09-24 — Error notice removed on submit in user, token and import forms + +**Context.** `create-user-form.tsx`, `create-token-form.tsx` and `import-board-form.tsx` +left the `role="alert"` notice mounted between submissions. A second identical error +(creating a user with an existing email twice, importing the same invalid JSON twice) did +not change the DOM and the screen reader did not announce it. + +**Decision.** The same pattern as `login-form.tsx` and the members panel: +`state.error && !pending`. In create user, the email's `aria-describedby` only points to +the notice while it is visible, so it does not reference a non-existent id during +submission. + +**Alternatives considered.** Adding a counter or a key to the notice to force it to be +reinserted: more state for what `pending` already does. + +**Consequences.** While submitting, the previous error is not shown (the button already +says "Creating…"/"Importing…"). `test/form-error-reannounce.test.ts` checks it in the HTML +of the three forms. --- -## 2026-09-24 — Panel de miembros: rol al invitar tras un error, error repetido y fondo +## 2026-09-24 — Members panel: invite role after an error, repeated error and backdrop -**Contexto.** Tres fallos del formulario de invitar y del `<dialog>` de miembros +**Context.** Three bugs in the invite form and the members `<dialog>` (`app/components/board/members-panel.tsx`): -- Tras un error («no existe ninguna cuenta…») el rol volvía a Editor sin avisar: React - restablece el formulario tras la acción, pero no aplica el nuevo `defaultValue` a un - `<select>` ya montado. Al corregir el email, la persona entraba con escritura aunque se - hubiera elegido Lector. -- El aviso `role="alert"` seguía montado entre envíos: un segundo error con el mismo - texto no cambiaba el DOM y el lector de pantalla no lo anunciaba. -- El fundido del `::backdrop` se veía con movimiento reducido: la regla - `prefers-reduced-motion` de `globals.css` no cubre `::backdrop`. - -**Decisión.** -- `<select name="role" key={inviteRole}>`: la key remonta el select con el rol devuelto. - `inviteRole` solo reutiliza `values.role` si está en `ASSIGNABLE_BOARD_ROLES`; si no - (p. ej. OWNER), vuelve a Editor. Es el mismo patrón que `create-token-form.tsx`. -- El aviso se pinta con `state.error && !pending`, como `login-form.tsx` y - `setup-form.tsx`: se retira mientras se envía y un error idéntico se vuelve a insertar. -- `backdrop:motion-safe:animate-[overlay-in_150ms_ease-out]`, igual que `card-modal.tsx`. - -**Alternativas descartadas.** -- `<select>` controlado con `useState`: habría que sincronizarlo con `state.values` en un - efecto y resetearlo al invitar con éxito; la key lo resuelve sin estado nuevo. -- Añadir `*::backdrop` a la regla de `prefers-reduced-motion` de `globals.css`: toca un - archivo compartido cuando `motion-safe:` basta. - -**Consecuencias.** El remontaje por key no tiene test automático: sin jsdom, -`renderToStaticMarkup` ya marcaba el rol correcto antes del arreglo. -`test/members-panel.test.ts` cubre la vuelta a Editor con un rol no asignable, la -retirada del aviso mientras se envía y la clase del fondo. -## 2026-09-24 — Confirmar el borrado de una lista sin anunciar cuántas tarjetas tiene - -**Contexto.** El botón «Sí» de la confirmación de borrar lista -(`app/components/board/list-column.tsx`) anunciaba «Sí, borrar la lista X y sus N -tarjetas» con `list.cards.length`. Con filtros activos, `BoardView` pasa a `ListColumn` -solo las tarjetas visibles, así que una lista con 12 tarjetas y una visible anunciaba «y -sus 1 tarjetas», y al confirmar se borraban las 12 en cascada. - -**Decisión.** El `aria-label` dice «Sí, borrar la lista X y todas sus tarjetas», sin -cifra. El contador visible de la cabecera sigue mostrando las tarjetas filtradas. - -**Alternativas descartadas.** Pasar a `ListColumn` una prop `totalCards` desde las listas -sin filtrar de `board-view.tsx` y concordar singular/plural: da la cifra exacta, pero -obliga a tocar `BoardView` y su construcción de listas filtradas para un texto que solo -oye el lector de pantalla; «todas» ya deja claro el alcance del borrado. - -**Consecuencias.** El lector de pantalla no dice cuántas tarjetas se borran; quien quiera -saberlo tiene el contador (sin filtros). Test en `test/list-column-delete.test.ts`. -## 2026-09-24 — Tiempo real: plazo máximo para la comprobación del stream CLOSED - -**Contexto.** `keepEventSourceOpen` programaba el reintento al terminar `probe()`, y -`probeStatus` hacía un `fetch` sin timeout (el del navegador no tiene). Si la -comprobación no respondía (un proxy que retiene la conexión mientras la app arranca, o -`verifySession` esperando una conexión del pool durante el mismo fallo de BD que dio el -500), no se recreaba nunca la fuente y el tiempo real se perdía hasta recargar. El -desmontaje tampoco abortaba la comprobación en curso. - -**Decisión.** `PROBE_TIMEOUT_MS` (10 s) en `app/lib/realtime/reconnect.ts`. `probe` -recibe una `AbortSignal` y un temporizador compite con ella (`Promise.race`): al vencer -aborta la señal y se trata como «sin estado» (reintento con backoff, sin -`onAccessDenied`). El desmontaje aborta la señal. `probeStatus(url, signal)` devuelve -`null` si la señal se aborta. - -**Alternativas descartadas.** -- Solo `AbortSignal.timeout` dentro de `probeStatus`: el reintento seguiría dependiendo - de que el `probe` inyectado respete la señal. -- `AbortSignal.any` para encadenar señales: Safari no lo tiene hasta la 17.4; se - encadena con un listener. - -**Consecuencias.** Con un servidor colgado, cada reintento tarda hasta 10 s más que el -backoff. Tests en `test/realtime-reconnect.test.ts`: `probe` que no resuelve nunca, -aborto al desmontar y `probeStatus` con la señal abortada contra un servidor HTTP real. +- After an error ("no account exists…") the role silently reverted to Editor: React + resets the form after the action, but does not apply the new `defaultValue` to an + already mounted `<select>`. After correcting the email, the person joined with write + access even if Viewer had been chosen. +- The `role="alert"` notice stayed mounted between submissions: a second error with the + same text did not change the DOM and the screen reader did not announce it. +- The `::backdrop` fade was visible with reduced motion: the `prefers-reduced-motion` rule + in `globals.css` does not cover `::backdrop`. + +**Decision.** +- `<select name="role" key={inviteRole}>`: the key remounts the select with the returned + role. `inviteRole` only reuses `values.role` if it is in `ASSIGNABLE_BOARD_ROLES`; + otherwise (e.g. OWNER), it falls back to Editor. It is the same pattern as + `create-token-form.tsx`. +- The notice is rendered with `state.error && !pending`, like `login-form.tsx` and + `setup-form.tsx`: it is removed while submitting and an identical error is inserted + again. +- `backdrop:motion-safe:animate-[overlay-in_150ms_ease-out]`, same as `card-modal.tsx`. + +**Alternatives considered.** +- A controlled `<select>` with `useState`: it would have to be synced with `state.values` + in an effect and reset after a successful invite; the key solves it without new state. +- Adding `*::backdrop` to the `prefers-reduced-motion` rule in `globals.css`: it touches a + shared file when `motion-safe:` is enough. + +**Consequences.** The key-based remount has no automated test: without jsdom, +`renderToStaticMarkup` already marked the correct role before the fix. +`test/members-panel.test.ts` covers the fallback to Editor with a non-assignable role, the +removal of the notice while submitting and the backdrop class. +## 2026-09-24 — Confirming a list deletion without announcing how many cards it has + +**Context.** The "Yes" button of the delete-list confirmation +(`app/components/board/list-column.tsx`) announced "Yes, delete list X and its N cards" +using `list.cards.length`. With active filters, `BoardView` passes `ListColumn` only the +visible cards, so a list with 12 cards and one visible announced "and its 1 cards", and on +confirming all 12 were deleted by cascade. + +**Decision.** The `aria-label` says "Yes, delete list X and all its cards", with no number. +The visible counter in the header still shows the filtered cards. + +**Alternatives considered.** Passing `ListColumn` a `totalCards` prop from the unfiltered +lists in `board-view.tsx` and handling singular/plural agreement: it gives the exact +number, but forces touching `BoardView` and how it builds the filtered lists for a text +only the screen reader hears; "all" ("todas") already makes the scope of the deletion +clear. + +**Consequences.** The screen reader does not say how many cards are deleted; anyone who +wants to know has the counter (without filters). Test in `test/list-column-delete.test.ts`. +## 2026-09-24 — Realtime: maximum time for the CLOSED stream check + +**Context.** `keepEventSourceOpen` scheduled the retry when `probe()` finished, and +`probeStatus` did a `fetch` with no timeout (the browser's has none). If the check never +answered (a proxy holding the connection while the app starts up, or `verifySession` +waiting for a pool connection during the same DB failure that caused the 500), the source +was never recreated and realtime was lost until a reload. Unmounting did not abort the +in-flight check either. + +**Decision.** `PROBE_TIMEOUT_MS` (10 s) in `app/lib/realtime/reconnect.ts`. `probe` +receives an `AbortSignal` and a timer races it (`Promise.race`): when it fires it aborts +the signal and the result is treated as "no status" (retry with backoff, no +`onAccessDenied`). Unmounting aborts the signal. `probeStatus(url, signal)` returns +`null` if the signal is aborted. + +**Alternatives considered.** +- Only `AbortSignal.timeout` inside `probeStatus`: the retry would still depend on the + injected `probe` honouring the signal. +- `AbortSignal.any` to chain signals: Safari lacks it until 17.4; they are chained with a + listener instead. + +**Consequences.** With a hung server, each retry takes up to 10 s longer than the +backoff. Tests in `test/realtime-reconnect.test.ts`: a `probe` that never resolves, abort +on unmount, and `probeStatus` with the signal aborted against a real HTTP server. --- -## 2026-09-24 — Tiempo real: un solo refresh por pestaña para el tablero y la campana +## 2026-09-24 — Realtime: a single refresh per tab for the board and the notification bell -**Contexto.** En `/board/[id]` la campana (layout) y `BoardRealtime` (página) tienen -cada uno su `EventSource` y su debounce. `router.refresh()` repinta todo el árbol, -layout incluido, y los dos streams caen y vuelven a la vez (vida máxima de 15 min, -reinicio, suspensión): cada reconexión hacía dos renders completos del tablero. La -consecuencia «un `router.refresh()` por reconexión» de la entrada «Resincronizar el -tablero y la campana al reconectar el EventSource» no se cumplía en esa página. +**Context.** On `/board/[id]` the notification bell (layout) and `BoardRealtime` (page) +each have their own `EventSource` and debounce. `router.refresh()` repaints the whole +tree, layout included, and both streams drop and come back at the same time (15 min +maximum lifetime, restart, sleep): every reconnection caused two full renders of the +board. The consequence "one `router.refresh()` per reconnection" from the entry "Resync +the board and the bell when the EventSource reconnects" did not hold on +that page. -**Decisión.** `scheduleRefresh` en `app/lib/realtime/reconnect.ts`: un temporizador de -módulo (200 ms) compartido por los dos componentes para eventos, reconexiones y acceso -denegado, en lugar del temporizador local de cada uno. No se cancela al desmontar: -anularía el refresh que pidió el otro componente, y un `router.refresh()` tras -desmontar solo refresca la ruta actual. +**Decision.** `scheduleRefresh` in `app/lib/realtime/reconnect.ts`: a module-level timer +(200 ms) shared by both components for events, reconnections and access denied, instead +of each one's local timer. It is not cancelled on unmount: that would cancel the refresh +requested by the other component, and a `router.refresh()` after unmounting only +refreshes the current route. -**Alternativas descartadas.** Que la campana no refresque si hay un `BoardRealtime` -montado: el layout no sabe qué página tiene debajo, y fuera del tablero sí hace falta. +**Alternatives considered.** Have the bell skip the refresh when a `BoardRealtime` is +mounted: the layout does not know which page is below it, and outside the board the +refresh is needed. -**Consecuencias.** Un solo `router.refresh()` por reconexión y pestaña, también cada -15 min. La redirección tras una revocación llega unos 200 ms más tarde. Test de -agrupación en `test/realtime-reconnect.test.ts`. +**Consequences.** A single `router.refresh()` per reconnection and tab, also every 15 min. +The redirect after a revocation arrives about 200 ms later. Grouping test in +`test/realtime-reconnect.test.ts`. --- -## 2026-09-24 — Tiempo real: no refrescar a ciegas cuando el stream queda CLOSED - -**Contexto.** Al quedar `CLOSED`, `BoardRealtime` y `NotificationsBell` hacían -`router.refresh()`. La premisa de la entrada «Recrear el EventSource cuando el -navegador deja de reintentar» («la página no redirigía») era falsa: en Next 16, si la -petición RSC de un refresh recibe una respuesta no OK o falla por red, el router hace -una navegación completa (`fetch-server-response.js`, `doMpaNavigation`). Con el 502 -del proxy durante `scripts/update.sh`, o un 500 por un fallo pasajero de la BD, cada -pestaña abierta acababa en la página de error y se perdía lo que no se hubiera guardado. - -**Decisión.** `keepEventSourceOpen` recibe `probe` y `onAccessDenied` en lugar de -`onClosed`. Al quedar `CLOSED`, `probeStatus(url)` hace un GET al mismo endpoint que se -aborta en cuanto llegan las cabeceras; solo con 401/403 se llama a `onAccessDenied` -(refresh para redirigir a `/login` o `/boards`). Ante un 5xx, un error de red o un fallo -de la propia comprobación solo se reintenta con backoff; al reabrirse, `onReconnect` -resincroniza con el servidor ya sano. - -**Alternativas descartadas.** -- `HEAD` para la comprobación: en App Router se deriva del GET y abriría el stream igual. -- Que las rutas SSE respondan 200 con un `event: revoked` antes de cerrar: más limpio, - pero toca `sse.ts` y las dos rutas, y no cubre el 401 de una reconexión. - -**Consecuencias.** Cada `CLOSED` cuesta una petición extra al endpoint (con 200, el -stream se abre y se cierra al momento). Tests en `test/realtime-reconnect.test.ts` -(5xx/red no avisan, 401/403 sí, desmontaje durante la comprobación y `probeStatus` -contra un servidor HTTP real). Descrito en `docs/realtime.md`. -## 2026-09-24 — `CARD_DESCRIPTION_MAX` en un módulo sin dependencias (`limits.ts`) - -**Contexto.** El modal de tarjeta importaba `CARD_DESCRIPTION_MAX` de -`app/lib/validators.ts` para el `maxLength` del textarea. Era el único componente -`"use client"` que importaba `validators.ts`, así que zod entraba en el bundle del -cliente del tablero solo por un número. Además, `validators.ts` usa `Buffer` en -`passwordSchema`, que no existe en el navegador. - -**Decisión.** La constante vive en `app/lib/limits.ts`, sin importaciones. El modal la -importa de ahí y `validators.ts` la importa y la reexporta, así que los esquemas, las -acciones y los tests siguen usando `validators.ts` sin cambios. Un test unitario -(`test/client-imports.test.ts`) falla si un componente `"use client"` importa zod o -`validators.ts`, o si `limits.ts` gana importaciones. - -**Alternativas descartadas.** Duplicar el `5000` en el modal: dos fuentes de verdad que -se pueden desincronizar. Dividir `validators.ts` en esquemas de cliente y de servidor: -más cambio del necesario para una constante. Comprobar solo con `pnpm build`: no deja -nada que impida que vuelva a pasar. - -**Consecuencias.** Los límites que necesite la UI se añaden a `limits.ts`. El test solo -mira las importaciones directas: una cadena indirecta hacia zod no la detecta. +## 2026-09-24 — Realtime: do not refresh blindly when the stream ends up CLOSED + +**Context.** When the stream ended up `CLOSED`, `BoardRealtime` and `NotificationsBell` +called `router.refresh()`. The premise of the entry "Recreate the EventSource when the +browser stops retrying" ("the page did not redirect") was false: in Next 16, if the RSC +request of a refresh gets a non-OK response or fails at the network level, the router +does a full navigation (`fetch-server-response.js`, `doMpaNavigation`). With the proxy's +502 during `scripts/update.sh`, or a 500 from a transient DB failure, every open tab +ended up on the error page and any unsaved work was lost. + +**Decision.** `keepEventSourceOpen` receives `probe` and `onAccessDenied` instead of +`onClosed`. When the stream is `CLOSED`, `probeStatus(url)` sends a GET to the same +endpoint, aborted as soon as the headers arrive; only on 401/403 is `onAccessDenied` +called (refresh to redirect to `/login` or `/boards`). On a 5xx, a network error or a +failure of the check itself it only retries with backoff; when the stream reopens, +`onReconnect` resyncs with the now-healthy server. + +**Alternatives considered.** +- `HEAD` for the check: in the App Router it is derived from the GET and would open the + stream anyway. +- Have the SSE routes answer 200 with an `event: revoked` before closing: cleaner, but it + touches `sse.ts` and both routes, and does not cover the 401 of a reconnection. + +**Consequences.** Each `CLOSED` costs one extra request to the endpoint (with a 200, the +stream opens and closes immediately). Tests in `test/realtime-reconnect.test.ts` +(5xx/network do not notify, 401/403 do, unmount during the check, and `probeStatus` +against a real HTTP server). Described in `docs/realtime.md`. +## 2026-09-24 — `CARD_DESCRIPTION_MAX` in a dependency-free module (`limits.ts`) + +**Context.** The card modal imported `CARD_DESCRIPTION_MAX` from `app/lib/validators.ts` +for the textarea's `maxLength`. It was the only `"use client"` component importing +`validators.ts`, so zod ended up in the board's client bundle just for a number. Also, +`validators.ts` uses `Buffer` in `passwordSchema`, which does not exist in the browser. + +**Decision.** The constant lives in `app/lib/limits.ts`, with no imports. The modal +imports it from there and `validators.ts` imports and re-exports it, so the schemas, +actions and tests keep using `validators.ts` unchanged. A unit test +(`test/client-imports.test.ts`) fails if a `"use client"` component imports zod or +`validators.ts`, or if `limits.ts` gains imports. + +**Alternatives considered.** Duplicating the `5000` in the modal: two sources of truth +that can drift apart. Splitting `validators.ts` into client and server schemas: more +change than needed for one constant. Checking only with `pnpm build`: leaves nothing in +place to stop it from happening again. + +**Consequences.** Limits the UI needs are added to `limits.ts`. The test only looks at +direct imports: an indirect chain to zod is not detected. --- -## 2026-09-24 — `updateCard` devuelve el error y no revalida descripciones sin cambios - -**Contexto.** `cardDetailsSchema` limita la descripción a `CARD_DESCRIPTION_MAX` (5000), -pero `updateCard` hacía `return;` sin avisar si no validaba, y el textarea del modal no -tenía `maxLength`. Pegar 6000 caracteres no guardaba nada y, al resetear React el -formulario, el texto desaparecía. Además, las tarjetas con descripciones anteriores al -límite (más de 5000) ya no podían cambiar el vencimiento desde el modal, porque el -formulario reenvía la descripción y fallaba todo. - -**Decisión.** -- `updateCard(prev, formData)` devuelve `CardDetailsState` para `useActionState`: `{}` - si guarda, o `{ error, description, dueDate }` con lo enviado para rehidratar el - formulario. El modal muestra el error (`role="alert"`) y desactiva el botón mientras - guarda. -- El textarea lleva `maxLength={CARD_DESCRIPTION_MAX}` (hoy importado de `limits.ts`, ver - la entrada superior). -- El servidor normaliza `\r\n` a `\n` antes de validar: el navegador envía así los - saltos de línea, pero `maxLength` los cuenta como un carácter. -- Si la descripción recibida es igual a la guardada (tras normalizar y recortar), no se - valida su longitud ni se reescribe; solo se actualiza la fecha. Se mantiene «si algo no - valida no se guarda nada». - -**Alternativas descartadas.** Validar y guardar descripción y fecha por separado: -guardaría una parte y perdería la otra en silencio. Un textarea controlado: obliga a -sincronizar a mano con los cambios que llegan por tiempo real. Mover la constante a un -módulo sin zod: toca archivos fuera de este cambio (ver consecuencias). - -**Consecuencias.** La firma de `updateCard`, los mensajes de error y la regla de la -descripción sin cambios están en `docs/card-detail.md`. El problema de importar -`validators.ts` desde el modal se resolvió después (entrada superior). +## 2026-09-24 — `updateCard` returns the error and does not revalidate unchanged descriptions + +**Context.** `cardDetailsSchema` caps the description at `CARD_DESCRIPTION_MAX` (5000), +but `updateCard` did `return;` silently when validation failed, and the modal's textarea +had no `maxLength`. Pasting 6000 characters saved nothing and, when React reset the +form, the text disappeared. Also, cards with descriptions predating the limit (over +5000) could no longer change their due date from the modal, because the form resends +the description and the whole thing failed. + +**Decision.** +- `updateCard(prev, formData)` returns `CardDetailsState` for `useActionState`: `{}` if + it saves, or `{ error, description, dueDate }` with what was sent, to rehydrate the + form. The modal shows the error (`role="alert"`) and disables the button while saving. +- The textarea has `maxLength={CARD_DESCRIPTION_MAX}` (now imported from `limits.ts`, + see the entry above). +- The server normalises `\r\n` to `\n` before validating: the browser sends line breaks + that way, but `maxLength` counts them as one character. +- If the received description equals the stored one (after normalising and trimming), + its length is not validated and it is not rewritten; only the date is updated. The rule + "if anything fails validation, nothing is saved" still holds. + +**Alternatives considered.** Validating and saving description and date separately: it +would save one part and silently lose the other. A controlled textarea: forces manual +syncing with changes arriving via realtime. Moving the constant to a module without zod: +touches files outside this change (see consequences). + +**Consequences.** The `updateCard` signature, the error messages and the +unchanged-description rule are in `docs/card-detail.md`. The problem of importing +`validators.ts` from the modal was solved later (entry above). --- -## 2026-09-24 — IP del cliente con puerto y aviso de proxy mal configurado - -**Contexto.** Con `TRUSTED_PROXY_HOPS > 0`, `pickClientIp` devolvía null si el valor de -X-Forwarded-For no pasaba `isIP` o si la cabecera traía menos valores que proxies -declarados. Un `ip` null desactiva el límite por IP del login y deja `AuditLog.ip` -vacío, sin ningún log. Algunos proxies (Azure Application Gateway, IIS ARR) escriben la -IP con el puerto de origen (`1.2.3.4:5678`, `[2001:db8::1]:443`), así que el operador -creía tener el límite activo y no lo tenía. - -**Decisión.** -- `resolveClientIp` (pura) devuelve la IP o el motivo por el que no la hay - (`missing-header`, `too-few-values`, `not-an-ip`); con hops = 0 no hay motivo. -- Antes de `isIP` se quita el puerto de `a.b.c.d:puerto` y de `[v6]:puerto` con - expresiones estrictas, y el resultado se vuelve a validar. No se corta por el último - `:`, que partiría una IPv6 sin corchetes. -- `pickClientIp` (misma firma, la usa `clientIp()` de `audit.ts`) emite un - `console.warn` una vez por motivo y proceso, con `hops` y el número de valores, sin la - cabecera ni ninguna IP. - -**Alternativas descartadas.** Poner el aviso en `clientIp()` de `app/lib/audit.ts` -(fuera del alcance de este cambio; el efecto queda igual en `pickClientIp`). Limitar el -aviso por minuto: más estado para lo mismo, basta con verlo una vez en el log. Aceptar -el valor con puerto tal cual: daría una clave de rate limit distinta por conexión. - -**Consecuencias.** Los proxies que añaden el puerto funcionan sin tocar la -configuración. Un proxy mal configurado deja un aviso en el log al primer login, -documentado en la tabla de `TRUSTED_PROXY_HOPS` de `docs/operations.md`. +## 2026-09-24 — Client IP with a port, and a warning for a misconfigured proxy + +**Context.** With `TRUSTED_PROXY_HOPS > 0`, `pickClientIp` returned null if the +X-Forwarded-For value did not pass `isIP` or if the header had fewer values than +declared proxies. A null `ip` disables the per-IP login limit and leaves `AuditLog.ip` +empty, without any log. Some proxies (Azure Application Gateway, IIS ARR) write the IP +with the source port (`1.2.3.4:5678`, `[2001:db8::1]:443`), so the operator believed the +limit was active when it was not. + +**Decision.** +- `resolveClientIp` (pure) returns the IP or the reason there is none + (`missing-header`, `too-few-values`, `not-an-ip`); with hops = 0 there is no reason. +- Before `isIP`, the port is stripped from `a.b.c.d:port` and from `[v6]:port` with + strict regular expressions, and the result is validated again. It does not split on the + last `:`, which would break an IPv6 address without brackets. +- `pickClientIp` (same signature, used by `clientIp()` in `audit.ts`) emits a + `console.warn` once per reason and process, with `hops` and the number of values, + without the header or any IP. + +**Alternatives considered.** Putting the warning in `clientIp()` in `app/lib/audit.ts` +(out of scope for this change; the effect is the same in `pickClientIp`). Rate-limiting +the warning per minute: more state for the same result; seeing it once in the log is +enough. Accepting the value with the port as is: it would give a different rate-limit key +per connection. + +**Consequences.** Proxies that append the port work without changing the configuration. +A misconfigured proxy leaves a warning in the log on the first login, documented in the +`TRUSTED_PROXY_HOPS` table in `docs/operations.md`. --- -## 2026-09-24 — Un solo mecanismo de sesión en los tests de integración - -**Contexto.** Al integrar los lotes de la revisión convivían dos formas de simular la -sesión: el stub compartido `test/stubs/next-headers.ts` con `loginAs()` (lote de -autenticación) y un `vi.mock("next/headers")` propio en siete archivos, con el helper -`test/integration/action-session.ts`. Con los dos juntos fallaban 61 tests, porque -`resetDb()` llama a `__clearCookies()` del stub y los mocks locales no lo exportaban. - -**Decisión.** Todos los tests de integración usan el stub compartido y `loginAs()` de -`helpers.ts`. Se borran `action-session.ts` y los tarros de cookies locales; -`realtime.int.test.ts` conserva un envoltorio que devuelve el id de sesión, que necesita -para revocarla. Ninguno sustituye el DAL: los seis que aún lo hacían (`board-metadata`, -`members`, `collaboration`, `tokens`, `admin-users`, `position-collation`) pasan a -`loginAs()`, así que la guarda real de `requireBoardAccess`/`requireAdmin` se ejecuta. -`admin-users` solo sustituye `redirect()` de `next/navigation`, para leer la URL. -`members.int.test.ts` añade un test de que un EDITOR o un VIEWER no puede gestionar -miembros, que con el DAL sustituido no se podía escribir. El unitario -`test/metadata.test.ts` sí sustituye el DAL, porque no tiene BD. - -**Alternativas descartadas.** Que cada mock local reexporte el stub con -`importOriginal`: arregla los fallos, pero deja dos formas de iniciar sesión y dos -secretos de test distintos. Conservar el `vi.mock("@/app/lib/dal")` de los seis archivos -corrigiendo solo sus comentarios: la guarda de las acciones de miembros seguiría sin -probarse. - -**Consecuencias.** 32 archivos y 210 tests de integración en verde. Un test nuevo con -sesión solo necesita `await loginAs(user.id)`; si hace falta otro rol, se siembra el -usuario con ese rol en vez de sustituir el DAL. +## 2026-09-24 — A single session mechanism in the integration tests + +**Context.** When merging the review batches, two ways of faking the session coexisted: +the shared stub `test/stubs/next-headers.ts` with `loginAs()` (authentication batch) and +a separate `vi.mock("next/headers")` in seven files, with the helper +`test/integration/action-session.ts`. With both together, 61 tests failed, because +`resetDb()` calls the stub's `__clearCookies()` and the local mocks did not export it. + +**Decision.** All integration tests use the shared stub and `loginAs()` from +`helpers.ts`. `action-session.ts` and the local cookie jars are deleted; +`realtime.int.test.ts` keeps a wrapper that returns the session id, which it needs to +revoke the session. None of them replaces the DAL: the six that still did +(`board-metadata`, `members`, `collaboration`, `tokens`, `admin-users`, +`position-collation`) switch to `loginAs()`, so the real `requireBoardAccess`/ +`requireAdmin` guard runs. `admin-users` only replaces `redirect()` from +`next/navigation`, to read the URL. `members.int.test.ts` adds a test that an EDITOR or +a VIEWER cannot manage members, which could not be written with the DAL replaced. The +unit test `test/metadata.test.ts` does replace the DAL, because it has no DB. + +**Alternatives considered.** Having each local mock re-export the stub with +`importOriginal`: fixes the failures, but leaves two ways to log in and two different +test secrets. Keeping the `vi.mock("@/app/lib/dal")` in the six files and only fixing +their comments: the guard on the member actions would remain untested. + +**Consequences.** 32 files and 210 integration tests green. A new test with a session +only needs `await loginAs(user.id)`; if another role is needed, the user is seeded with +that role instead of replacing the DAL. --- -## 2026-09-24 — Collation "C" en las columnas de posición - -**Contexto.** Las claves de fractional indexing (base62 `0-9A-Za-z`) solo se ordenan -bien byte a byte. `ORDER BY position` usaba la collation de la BD: en postgres alpine -(musl, la BD de desarrollo) es byte a byte, pero en glibc (imagen Debian del CI, Ubuntu, -servicios gestionados) `en_US.utf8` deja `Zz` después de `a0l`. Resultado: la tarjeta -arrastrada al principio reaparece al final al refrescar, y `createCard`/`createList`/ -`addChecklist`/`addChecklistItem` (que buscan el último con `ORDER BY position DESC`) -generan claves repetidas. Pasa también solo con altas al final: tras `aZ` viene `aa`, que -esa collation ordena antes de `aB`…`aZ`, así que desde el elemento 38 se repite `aa`. - -**Decisión.** -- Migración `20260924000000_position_collate_c` (creada con `migrate dev --create-only` - y escrita a mano): `ALTER COLUMN "position" TYPE TEXT COLLATE "C"` en `List`, `Card`, - `Checklist` y `ChecklistItem`. Reconstruye `Card_listId_position_idx`. -- Comentario en la migración y en `schema.prisma` (`List.position`), porque Prisma no - modela la collation; `prisma migrate diff` no muestra deriva. -- ADR 0002 ampliado en «Consecuencias» (fuente única del porqué y de cómo detectar - claves ya repetidas). -- Test `test/integration/position-collation.int.test.ts`: comprueba la collation de las - cuatro columnas (falla sin el arreglo en cualquier servidor) y el orden/no repetición - de tarjetas y listas con `a0`, `a0V`, `a0l`, `Zz`, y el de checklists e ítems con 37 - altas al final (`a0`…`aa`) (fallan sin el arreglo en BD glibc/ICU; verificado con una - BD temporal ICU y con `ORDER BY ... COLLATE "en-US-x-icu"`). - -**Alternativas descartadas.** -- Ordenar en JS o con `localeCompare`: dispersa la regla por todas las consultas y no - arregla el `findFirst ... DESC` de las altas. -- Crear la BD con `LC_COLLATE=C`: no sirve para instalaciones existentes ni para BD - gestionadas donde no se controla la creación. -- Reasignar en la migración las claves ya repetidas: toca datos de producción; queda - como decisión del propietario, con copia previa (consulta de detección de las cuatro - tablas en el ADR 0002). - -**Consecuencias.** Toda columna de posición nueva debe crearse con `COLLATE "C"` a mano -en su migración. Las instalaciones glibc que ya tengan claves repetidas las conservan -hasta que se reasignen; el orden entre dos claves iguales sigue sin estar definido. +## 2026-09-24 — Collation "C" on the position columns + +**Context.** Fractional indexing keys (base62 `0-9A-Za-z`) only sort correctly byte by +byte. `ORDER BY position` used the DB collation: on postgres alpine (musl, the +development DB) it is byte by byte, but on glibc (the CI's Debian image, Ubuntu, managed +services) `en_US.utf8` puts `Zz` after `a0l`. Result: a card dragged to the top reappears +at the bottom on refresh, and `createCard`/`createList`/`addChecklist`/`addChecklistItem` +(which look up the last one with `ORDER BY position DESC`) generate duplicate keys. It +also happens with appends alone: after `aZ` comes `aa`, which that collation sorts before +`aB`...`aZ`, so from the 38th element on `aa` is repeated. + +**Decision.** +- Migration `20260924000000_position_collate_c` (created with `migrate dev --create-only` + and written by hand): `ALTER COLUMN "position" TYPE TEXT COLLATE "C"` on `List`, `Card`, + `Checklist` and `ChecklistItem`. Rebuilds `Card_listId_position_idx`. +- A comment in the migration and in `schema.prisma` (`List.position`), because Prisma does + not model collation; `prisma migrate diff` shows no drift. +- ADR 0002 extended under "Consequences" (the single source for the why and for how to + detect keys that are already duplicated). +- Test `test/integration/position-collation.int.test.ts`: checks the collation of the four + columns (fails without the fix on any server) and the order/no duplication of cards and + lists with `a0`, `a0V`, `a0l`, `Zz`, and of checklists and items with 37 appends + (`a0`...`aa`) (these fail without the fix on a glibc/ICU DB; verified with a temporary + ICU DB and with `ORDER BY ... COLLATE "en-US-x-icu"`). + +**Alternatives considered.** +- Sorting in JS or with `localeCompare`: scatters the rule across every query and does not + fix the `findFirst ... DESC` used for appends. +- Creating the DB with `LC_COLLATE=C`: does not help existing installations or managed + DBs where creation is not under our control. +- Reassigning already duplicated keys in the migration: touches production data; left as + the owner's decision, with a prior backup (detection query for the four tables in + ADR 0002). + +**Consequences.** Every new position column must be created with `COLLATE "C"` by hand in +its migration. glibc installations that already have duplicate keys keep them until they +are reassigned; the order between two equal keys remains undefined. --- -## 2026-09-24 — CSP fija para los .svg - -**Contexto.** El matcher de `proxy.ts` excluye `.*\.svg$`, así que los SVG no reciben -la CSP con nonce, y `next.config.ts` solo ponía CSP a `/api/**`. Los SVG de `public/` -se servían sin `Content-Security-Policy`: abierto directamente, un SVG es un documento -que puede ejecutar scripts en el origen de la app. - -**Decisión.** Regla `/:file(.*\\.svg)` en `headers()` de `next.config.ts` con -`default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'` (sus estilos en -línea funcionan; scripts, recursos externos y el embebido en marcos no). -`test/next-config.test.ts` resuelve las reglas con `buildCustomRoute` del router de Next -(el mismo compilador que usa en runtime) en vez de comparar prefijos, para entender los -parámetros con regex, y comprueba `/file.svg` e `/icons/logo.svg`. - -**Alternativas descartadas.** -- Quitar la exclusión de `.svg` del matcher de `proxy.ts`: el proxy añadiría a cada SVG - la CSP de página (con nonce) y el coste de ejecutarse en cada petición de imagen. -- Borrar los SVG de `public/` (`file.svg`, `globe.svg`, `next.svg`, `vercel.svg`, - `window.svg`): son restos de la plantilla de Next y nada de `app/` los usa, pero - borrarlos lo decide el propietario. La regla cubre también cualquier SVG futuro. - -**Consecuencias.** Un SVG servido desde la app no puede cargar fuentes ni imágenes -externas ni ejecutar scripts cuando se abre directamente. Cargado con `<img>` un SVG -nunca ejecuta scripts y su CSP no interviene, así que no afecta a su uso normal. - -## 2026-09-24 — Timeout del pool de pg y singleton de Prisma también en producción - -**Contexto.** `PrismaPg` creaba el `pg.Pool` con los valores por defecto: -`connectionTimeoutMillis: 0`, así que con el pool agotado (10 conexiones) la petición -siguiente esperaba para siempre; Prisma 7 ya no aplica el `connect_timeout` de 5 s de -Prisma 6. Además, el cliente solo se guardaba en `globalThis` fuera de producción, y -Turbopack evalúa el módulo una vez por capa (route handlers y RSC/SSR): dos pools. - -**Decisión.** -- `buildPoolConfig()` exportada en `app/lib/prisma.ts`: `connectionTimeoutMillis` - 5 s e `idleTimeoutMillis` 10 s (el valor por defecto de pg-pool, explícito para que - no cambie sin querer). `onPoolError` registra solo el mensaje del error (nunca la - cadena de conexión). El adapter ya escuchaba el evento `error` del pool, así que el - proceso no se caía: el manejador sirve para que el fallo de una conexión inactiva - quede en el log y no solo en el de depuración del adapter. -- `globalForPrisma.prisma = prisma` siempre, como `bus.ts` y `rate-limit.ts`. -- `test/integration/prisma-pool.int.test.ts`: el cliente exportado se crea con - `buildPoolConfig()` y `onPoolError` (se envuelve `PrismaPg` con `vi.doMock`); con - `max: 1` y 200 ms, una consulta mientras una transacción ocupa la conexión falla por - timeout; en producción dos evaluaciones del módulo devuelven el mismo cliente. - -**Alternativas descartadas.** -- `max` configurable por variable de entorno: no hace falta todavía. -- `statement_timeout`: cortaría exportaciones o importaciones grandes sin un valor - medido. -- `idleTimeoutMillis` de 30 s: no hay medida que justifique mantener abiertas las - conexiones inactivas más tiempo que el valor de pg. -- Probar el timeout con los 5 s reales: cada ejecución de la suite tardaría 5 s más; el - cableado ya lo comprueba el test del cliente exportado. - -**Consecuencias.** Con el pool saturado, una petición falla a los 5 s con un error de -timeout en vez de colgarse. Un solo pool por proceso. - -## 2026-09-24 — Títulos por página y login sin mención a la licencia - -**Contexto.** Todas las rutas tenían el mismo `<title>` «orgtable» (WCAG 2.4.2). Además, -la pantalla de login («Open source · AGPL-3.0», «open-source y self-hosted») y la meta -description decían que la app es open source con AGPL, pero la licencia vigente es la -propietaria de `LICENSE` (decisión del propietario, commit 27c1ba9). - -**Decisión.** +## 2026-09-24 — Fixed CSP for .svg files + +**Context.** The `proxy.ts` matcher excludes `.*\.svg$`, so SVGs do not get the nonce +CSP, and `next.config.ts` only set a CSP on `/api/**`. The SVGs in `public/` were served +without `Content-Security-Policy`: opened directly, an SVG is a document that can run +scripts on the app's origin. + +**Decision.** A `/:file(.*\\.svg)` rule in `headers()` in `next.config.ts` with +`default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'` (its inline styles +work; scripts, external resources and framing do not). `test/next-config.test.ts` +resolves the rules with `buildCustomRoute` from the Next router (the same compiler it +uses at runtime) instead of comparing prefixes, so that regex parameters are understood, +and checks `/file.svg` and `/icons/logo.svg`. + +**Alternatives considered.** +- Removing the `.svg` exclusion from the `proxy.ts` matcher: the proxy would add the page + CSP (with nonce) to every SVG, plus the cost of running on every image request. +- Deleting the SVGs in `public/` (`file.svg`, `globe.svg`, `next.svg`, `vercel.svg`, + `window.svg`): they are leftovers from the Next template and nothing in `app/` uses + them, but deleting them is the owner's call. The rule also covers any future SVG. + +**Consequences.** An SVG served by the app cannot load external fonts or images or run +scripts when opened directly. Loaded with `<img>`, an SVG never runs scripts and its CSP +plays no part, so normal use is unaffected. + +## 2026-09-24 — pg pool timeout and Prisma singleton in production too + +**Context.** `PrismaPg` created the `pg.Pool` with the defaults: +`connectionTimeoutMillis: 0`, so with the pool exhausted (10 connections) the next +request waited forever; Prisma 7 no longer applies Prisma 6's 5 s `connect_timeout`. +Also, the client was only stored on `globalThis` outside production, and Turbopack +evaluates the module once per layer (route handlers and RSC/SSR): two pools. + +**Decision.** +- `buildPoolConfig()` exported from `app/lib/prisma.ts`: `connectionTimeoutMillis` 5 s + and `idleTimeoutMillis` 10 s (pg-pool's default, made explicit so it does not change by + accident). `onPoolError` logs only the error message (never the connection string). The + adapter already listened to the pool's `error` event, so the process did not crash: the + handler makes an idle connection's failure show up in the log and not only in the + adapter's debug output. +- `globalForPrisma.prisma = prisma` always, like `bus.ts` and `rate-limit.ts`. +- `test/integration/prisma-pool.int.test.ts`: the exported client is created with + `buildPoolConfig()` and `onPoolError` (`PrismaPg` is wrapped with `vi.doMock`); with + `max: 1` and 200 ms, a query while a transaction holds the connection fails with a + timeout; in production two evaluations of the module return the same client. + +**Alternatives considered.** +- `max` configurable via environment variable: not needed yet. +- `statement_timeout`: would cut off large exports or imports without a measured value. +- A 30 s `idleTimeoutMillis`: no measurement justifies keeping idle connections open + longer than pg's default. +- Testing the timeout with the real 5 s: every suite run would take 5 s longer; the wiring + is already checked by the exported-client test. + +**Consequences.** With the pool saturated, a request fails after 5 s with a timeout error +instead of hanging. A single pool per process. + +## 2026-09-24 — Per-page titles and a login page without mentioning the license + +**Context.** Every route had the same `<title>` "orgtable" (WCAG 2.4.2). Also, the login +screen ("Open source · AGPL-3.0", "open-source and self-hosted") and the meta description +said the app is open source under AGPL, but the license in force is the proprietary one +in `LICENSE` (owner's decision, commit 27c1ba9). + +**Decision.** - `app/layout.tsx`: `title: { template: "%s · orgtable", default: "orgtable" }`. - `/login` exporta «Iniciar sesión» y `/setup` «Configuración inicial»; el resto de - páginas las cubren otros lotes. -- Meta description: «Tableros Kanban self-hosted, una alternativa a Trello». -- Login: el párrafo dice «Tableros Kanban self-hosted…» y el pie pasa a «Kanban · - Self-hosted», sin mencionar licencia. -- `test/metadata.test.ts` comprueba los títulos resueltos, la description y que el HTML - del login no diga open source ni AGPL. - -**Alternativas descartadas.** -- Quitar el pie del panel de marca: cambiaba la composición (`justify-between` con tres - bloques). -- Poner un aviso de copyright en el pie: el texto legal lo aporta el propietario. - -**Consecuencias.** Cada página nueva debe exportar su `title`; sin él hereda «orgtable». - -## 2026-09-24 — Límite de 11 MB para el cuerpo de las Server Actions - -**Contexto.** Next 16 corta el cuerpo de una Server Action en 1 MB antes de ejecutarla. -`uploadAttachment` admite 10 MB (`MAX_ATTACHMENT_BYTES`) e `importBoard` 5 MB, así que -cualquier adjunto o importación de más de 1 MB fallaba con un error genérico. Además, -`proxy.ts` pasa por las rutas de página y Next solo guarda 10 MB de su cuerpo: lo que -exceda llega truncado a la acción, sin error. - -**Decisión.** `experimental.serverActions.bodySizeLimit` y -`experimental.proxyClientMaxBodySize` a 11 MB (en bytes) en `next.config.ts`. El -megabyte extra cubre la sobrecarga de multipart y deja que un archivo algo mayor de -10 MB llegue a la acción y reciba su mensaje propio en vez de un 413. -`test/next-config.test.ts` comprueba que el límite supera `MAX_ATTACHMENT_BYTES` más la -sobrecarga y que el del proxy coincide. - -**Alternativas descartadas.** -- Subir los adjuntos por un route handler en `/api` que autentique antes de leer el - cuerpo: es la solución de fondo, pero cambia la arquitectura de la subida; la decide - el propietario (anotada en el backlog de `docs/security.md`). -- Importar `MAX_ATTACHMENT_BYTES` en `next.config.ts`: `storage.ts` es `server-only` y - arrastra `node:fs`; se deja el número con comentario y lo vigila el test. - -**Consecuencias.** El límite es global y se aplica antes de autenticar: cualquiera puede -mandar 11 MB a una acción. Si cambia el máximo de adjuntos hay que subir este número (el -test falla si se queda corto). -## 2026-09-24 — Devolver el foco al ✕ al cancelar el borrado de un tablero - -**Contexto.** Al cancelar «¿Borrar? Sí / No» (con «No» o con Escape) el formulario se -desmontaba con el foco dentro y el ✕ se volvía a pintar sin él: el foco caía al -`<body>` y quien usa teclado o lector de pantalla perdía su sitio en la rejilla de -`/boards`. Además el ✕ quedaba invisible (`opacity-0`), porque ya no había foco dentro -de la tarjeta. - -**Decisión.** `DeleteBoardButton` guarda una ref al ✕ y un indicador `restoreFocus` -que solo pone a `true` la cancelación; un `useEffect` sobre `confirming` lo consume y -llama a `focus()`. El segundo clic («Sí» envía) no cambia. - -**Alternativas descartadas.** -- `autoFocus` en el ✕: todos los tableros de la rejilla lo llevarían y el último - robaría el foco al cargar la página. -- Dejar el ✕ montado y ocultar la confirmación con CSS: cambia la maquetación de la - esquina y seguiría haciendo falta mover el foco a mano. - -**Consecuencias.** Tras cancelar, el foco vuelve al ✕ y `group-focus-within` lo -mantiene visible. `test/delete-board-button-focus.test.ts` lo cubre con un arnés local -de hooks (sin jsdom): falla con la versión anterior y comprueba que el montaje inicial -no roba el foco. No se ha probado en navegador ni con lector de pantalla. - -## 2026-09-24 — Título por página en /boards, /board/[id] y /search - -**Contexto.** Todas las rutas compartían el `<title>` «orgtable», así que las pestañas -y el historial no se distinguían. La plantilla raíz (`%s · …`) es del lote de la -maquetación raíz; aquí solo van los títulos de estas páginas. - -**Decisión.** -- `export const metadata` con «Tus tableros» en `/boards` y «Búsqueda» en `/search`. - La consulta de búsqueda no va en el título: acabaría en el historial y en las - pestañas. -- `/board/[id]` usa `generateMetadata`, que lee el nombre con `getBoardTitleForUser` - (`app/lib/queries.ts`, envuelta en `React.cache`). Comparte con `getBoardWithLists` - el filtro `boardAccessWhere` (dueño o miembro), la misma guarda que la página. Sin - acceso o si el tablero no existe, título genérico «Tablero». - -**Alternativas descartadas.** -- Leer el título con `prisma.board.findUnique` sin filtro: el `<title>` se resuelve - aparte de la página y filtraría el nombre de un tablero ajeno aunque la página dé 404. -- Reutilizar `getBoardWithLists` en `generateMetadata`: carga el tablero completo solo - para leer el título. - -**Consecuencias.** Una consulta ligera más por visita al tablero. -`test/integration/board-metadata.int.test.ts` fija que el dueño y los miembros ven el -nombre, y que un no miembro o un id inexistente reciben «Tablero». - -## 2026-09-24 — Contraste AA de --danger/--success y campos etiquetados en /boards - -**Contexto.** `--success` (oklch 0.57 0.1 145) daba 3,86:1 sobre `--bg` y 3,72:1 en -los avisos `bg-success/10 text-success`. `--danger` rozaba el límite en sus avisos -(4,48:1) y en la insignia de vencida `bg-danger/15` (4,15:1 claro, 4,45:1 oscuro). El -placeholder de `Input`/`Textarea` (`text-ink-muted/70`) quedaba en ~3,2:1. En `/boards`, -el campo de nuevo tablero y el de importar JSON no tenían etiqueta accesible, y el -error de importación no se anunciaba. - -**Decisión.** -- Tokens, lo mínimo para 4,5:1 con margen: claro `--danger` 0.55→0.52 y `--success` - 0.57→0.48 (la propuesta del hallazgo); oscuro `--danger` 0.70→0.71. `--success` - oscuro no cambia. `DESIGN.md` actualizado. -- `test/contrast.test.ts` lee los tokens de `app/globals.css`, convierte OKLCH a sRGB y - exige 4,5:1 para ink, ink-muted, accent, danger y success sobre bg, surface, - surface-2 y column, para los avisos al 10 % y la insignia al 15 %, y para - accent-ink sobre accent, en los dos temas. -- Placeholder de `app/components/ui/input.tsx` a `text-ink-muted` (6,2:1). -- `aria-label` en los dos campos de `/boards` y `role="alert"` en el error de - importar. -- `title` con el nombre completo de la instancia en la barra superior, que se trunca - en móvil. El resto del ajuste responsive de la barra ya estaba en main. - -**Alternativas descartadas.** -- Cambiar solo `--success` y dejar `--danger` justo por debajo de 4,5:1 en sus avisos. -- Etiquetas `<label>` visibles: cambian la maquetación de la cabecera de `/boards`; el - placeholder ya describe el campo a la vista. - -**Consecuencias.** Verde y rojo algo más oscuros en tema claro, también en los rellenos -`bg-success`/`bg-danger` de otros componentes (barra de checklist, casillas). El test -avisa si un cambio de tono rompe el contraste. Otros placeholders con -`text-ink-muted/70` (search-box, card-modal) quedan fuera de este lote. - -## 2026-09-24 — Borrado de tableros con segundo clic y solo para el dueño - -**Contexto.** En `/boards`, el ✕ de cada tablero era un `<form action={deleteBoard}>` -de un solo clic que borraba en cascada listas, tarjetas y adjuntos. Tenía `opacity-0` -y solo aparecía al pasar el ratón, así que en táctil era invisible pero un toque en la -esquina lo disparaba. Además se pintaba en tableros donde el usuario era solo miembro, -y ahí `deleteBoard` no hacía nada ni avisaba. - -**Decisión.** -- Componente cliente `app/(app)/boards/delete-board-button.tsx`: el ✕ es un - `type="button"` que cambia a «¿Borrar? Sí / No» en línea; solo «Sí» envía el - formulario. El foco va a «No» y Escape cancela. -- Se pinta solo si `board.ownerId === user.id`, la misma regla que aplica - `deleteBoard` en el servidor. -- Exportar y borrar se ven siempre en pantallas sin hover - (`[@media(hover:none)]:opacity-100`) y al llegar con el teclado + `/login` exports "Sign in" and `/setup` "Initial setup"; the remaining pages are covered + by other batches. +- Meta description: "Self-hosted Kanban boards, a Trello alternative". +- Login: the paragraph says "Self-hosted Kanban boards..." and the footer becomes "Kanban · + Self-hosted", with no mention of a license. +- `test/metadata.test.ts` checks the resolved titles, the description, and that the login + HTML does not say open source or AGPL. + +**Alternatives considered.** +- Removing the footer from the brand panel: it changed the composition (`justify-between` + with three blocks). +- Putting a copyright notice in the footer: the legal text is for the owner to provide. + +**Consequences.** Every new page must export its `title`; without it, it inherits +"orgtable". + +## 2026-09-24 — 11 MB body limit for Server Actions + +**Context.** Next 16 cuts a Server Action's body at 1 MB before running it. +`uploadAttachment` accepts 10 MB (`MAX_ATTACHMENT_BYTES`) and `importBoard` 5 MB, so any +attachment or import over 1 MB failed with a generic error. Also, `proxy.ts` runs on page +routes and Next only buffers 10 MB of their body: anything beyond that reaches the action +truncated, with no error. + +**Decision.** `experimental.serverActions.bodySizeLimit` and +`experimental.proxyClientMaxBodySize` set to 11 MB (in bytes) in `next.config.ts`. The +extra megabyte covers multipart overhead and lets a file slightly over 10 MB reach the +action and get its own message instead of a 413. `test/next-config.test.ts` checks that +the limit exceeds `MAX_ATTACHMENT_BYTES` plus the overhead and that the proxy's matches. + +**Alternatives considered.** +- Uploading attachments through a route handler under `/api` that authenticates before + reading the body: this is the real fix, but it changes the upload architecture; the + owner decides (noted in the backlog in `docs/security.md`). +- Importing `MAX_ATTACHMENT_BYTES` in `next.config.ts`: `storage.ts` is `server-only` and + pulls in `node:fs`; the number is left with a comment and guarded by the test. + +**Consequences.** The limit is global and applies before authentication: anyone can send +11 MB to an action. If the attachment maximum changes, this number must be raised (the +test fails if it falls short). +## 2026-09-24 — Return focus to the ✕ when canceling a board deletion + +**Context.** When canceling "Delete? Yes / No" ("¿Borrar? Sí / No") (with "No" or with +Escape) the form unmounted with focus inside it and the ✕ was re-rendered without it: +focus fell to `<body>` and keyboard or screen reader users lost their place in the +`/boards` grid. The ✕ also stayed invisible (`opacity-0`), because there was no longer +focus inside the card. + +**Decision.** `DeleteBoardButton` keeps a ref to the ✕ and a `restoreFocus` flag that +only cancellation sets to `true`; a `useEffect` on `confirming` consumes it and calls +`focus()`. The second click ("Yes" submits) is unchanged. + +**Alternatives considered.** +- `autoFocus` on the ✕: every board in the grid would have it and the last one would + steal focus on page load. +- Keeping the ✕ mounted and hiding the confirmation with CSS: changes the corner's layout + and focus would still have to be moved by hand. + +**Consequences.** After canceling, focus returns to the ✕ and `group-focus-within` keeps +it visible. `test/delete-board-button-focus.test.ts` covers it with a local hooks harness +(no jsdom): it fails with the previous version and checks that the initial mount does not +steal focus. Not tested in a browser or with a screen reader. + +## 2026-09-24 — Per-page title on /boards, /board/[id] and /search + +**Context.** All routes shared the `<title>` "orgtable", so tabs and history entries were +indistinguishable. The root template (`%s · ...`) belongs to the root layout batch; only +these pages' titles go here. + +**Decision.** +- `export const metadata` with "Your boards" on `/boards` and "Search" on `/search`. The + search query does not go in the title: it would end up in history and in tabs. +- `/board/[id]` uses `generateMetadata`, which reads the name with `getBoardTitleForUser` + (`app/lib/queries.ts`, wrapped in `React.cache`). It shares the `boardAccessWhere` + filter (owner or member) with `getBoardWithLists`, the same guard as the page. Without + access or if the board does not exist, the generic title "Board". + +**Alternatives considered.** +- Reading the title with `prisma.board.findUnique` without a filter: the `<title>` is + resolved separately from the page and would leak another user's board name even though + the page returns 404. +- Reusing `getBoardWithLists` in `generateMetadata`: loads the full board just to read + the title. + +**Consequences.** One more lightweight query per board visit. +`test/integration/board-metadata.int.test.ts` pins that the owner and members see the +name, and that a non-member or a nonexistent id get "Board". + +## 2026-09-24 — AA contrast for --danger/--success and labeled fields on /boards + +**Context.** `--success` (oklch 0.57 0.1 145) gave 3.86:1 on `--bg` and 3.72:1 in the +`bg-success/10 text-success` notices. `--danger` was borderline in its notices (4.48:1) +and in the overdue badge `bg-danger/15` (4.15:1 light, 4.45:1 dark). The +`Input`/`Textarea` placeholder (`text-ink-muted/70`) was at ~3.2:1. On `/boards`, the +new-board field and the JSON import field had no accessible label, and the import error +was not announced. + +**Decision.** +- Tokens, the minimum for 4.5:1 with margin: light `--danger` 0.55→0.52 and `--success` + 0.57→0.48 (the finding's proposal); dark `--danger` 0.70→0.71. Dark `--success` is + unchanged. `DESIGN.md` updated. +- `test/contrast.test.ts` reads the tokens from `app/globals.css`, converts OKLCH to sRGB + and requires 4.5:1 for ink, ink-muted, accent, danger and success on bg, surface, + surface-2 and column, for the notices at 10 % and the badge at 15 %, and for accent-ink + on accent, in both themes. +- Placeholder in `app/components/ui/input.tsx` set to `text-ink-muted` (6.2:1). +- `aria-label` on the two fields on `/boards` and `role="alert"` on the import error. +- `title` with the full instance name in the top bar, which is truncated on mobile. The + rest of the bar's responsive adjustment was already on main. + +**Alternatives considered.** +- Changing only `--success` and leaving `--danger` just under 4.5:1 in its notices. +- Visible `<label>` elements: they change the layout of the `/boards` header; the + placeholder already describes the field visually. + +**Consequences.** Green and red slightly darker in the light theme, also in the +`bg-success`/`bg-danger` fills of other components (checklist bar, checkboxes). The test +warns if a hue change breaks contrast. Other placeholders with `text-ink-muted/70` +(search-box, card-modal) are out of scope for this batch. + +## 2026-09-24 — Board deletion needs a second click and is owner-only + +**Context.** On `/boards`, each board's ✕ was a single-click `<form action={deleteBoard}>` +that cascade-deleted lists, cards and attachments. It had `opacity-0` and only appeared +on hover, so on touch screens it was invisible but a tap on the corner triggered it. It +was also rendered on boards where the user was only a member, and there `deleteBoard` did +nothing and gave no feedback. + +**Decision.** +- Client component `app/(app)/boards/delete-board-button.tsx`: the ✕ is a + `type="button"` that turns into an inline "Delete? Yes / No"; only "Yes" submits the + form. Focus moves to "No" and Escape cancels. +- It is rendered only if `board.ownerId === user.id`, the same rule `deleteBoard` applies + on the server. +- Export and delete are always visible on screens without hover + (`[@media(hover:none)]:opacity-100`) and when reached with the keyboard (`group-focus-within`). -**Alternativas descartadas.** -- `window.confirm` (el patrón de `user-row-actions.tsx`): no se puede estilar ni - traducir el botón, y la norma del proyecto pide confirmación en línea. -- Mostrar el ✕ a los miembros con un aviso de error: ofrecería una acción imposible. - -**Consecuencias.** Un tablero ya no se puede perder con un toque. Las acciones de -esquina ocupan sitio fijo en móvil. El comportamiento del segundo clic no tiene test de -interacción (no hay jsdom ni e2e); `test/delete-board-button.test.ts` comprueba que el -estado inicial no puede enviar nada. -## 2026-09-24 — Modal de tarjeta: `<dialog>` nativo, fechas y confirmación de borrado - -**Contexto.** El modal de tarjeta (`app/components/card/card-modal.tsx`) no gestionaba -el foco (Tab recorría el tablero de fondo, sin nombre accesible), la hora de los -comentarios provocaba un error de hidratación al abrir por `?card=` (servidor en UTC, -navegador en su zona), el vencimiento de solo lectura salía como ISO crudo, los toggles -de etiqueta e ítem ignoraban fallos y «Borrar tarjeta» borraba al primer clic. - -**Decisión.** -- `<dialog>` nativo con `showModal()` en un layout effect y `close()` en su limpieza - (antes de que React retire el nodo, para que el navegador devuelva el foco a la - tarjeta). Escape por `onCancel`; el clic fuera se detecta con - `e.target === dialog`, porque el `<dialog>` ocupa la ventana y hace de scroll. -- Hora de comentarios: solo tras hidratar (`useSyncExternalStore`), en la zona del - navegador y `es-ES`; antes, `<time dateTime>` sin texto. -- Vencimiento: `Intl` `es-ES` con `timeZone: "UTC"` (es medianoche UTC). -- Toggles: `runToggle` devuelve un mensaje para un `role="alert"` que sale junto a lo - que falló (bajo las etiquetas, o bajo los ítems de cada checklist), no arriba del - modal, que tiene scroll; relanza los errores internos de Next con `unstable_rethrow` - (API pública, no `isRedirectError` interno). -- Borrado: confirmación en línea con un segundo clic («¿Borrar la tarjeta?» Sí, - borrar / No), con el foco movido al control nuevo en cada paso. -- Etiquetas no seleccionadas: solo se quita `opacity-55` (texto a ~2,2:1) y se mantiene - el relleno de color; el estado se distingue por el ✓, el anillo y `aria-pressed`. - `aria-pressed` también en los colores; colores nombrados en español. -- El fundido del fondo va en `::backdrop` con `motion-safe:`: la regla global de - movimiento reducido de `globals.css` no cubre `::backdrop`. -- Lógica pura en `app/components/card/card-modal-helpers.ts` para probarla en node. - -**Alternativas descartadas.** -- Atrapar el foco a mano o con una librería: `showModal()` ya vuelve inerte el fondo. -- `suppressHydrationWarning` en la hora: calla el aviso pero deja el texto incorrecto. -- Fijar `Atlantic/Canary` en el cliente: es self-hosted y cada usuario tiene su zona. -- `window.confirm`: bloquea y no sigue el estilo de la app. -- Etiqueta no seleccionada en contorno con un punto de color: es un cambio de diseño - que no se pidió; basta con quitar la opacidad. -- Añadir `*::backdrop` a la regla de `globals.css`: `motion-safe:` lo resuelve sin - tocar un archivo compartido. - -**Consecuencias.** Con `?card=` el `<dialog>` llega cerrado en el HTML del servidor y -se abre al hidratar (un instante sin modal). El comportamiento del diálogo (foco, Tab, -Escape, clic fuera) no tiene test automático: no hay jsdom ni e2e en el proyecto; -queda pendiente de verificación manual. El selector de etiquetas de `filter-bar.tsx` -debe usar el mismo estilo que este (relleno sin opacidad). -## 2026-09-24 — Panel de miembros con `<dialog>` nativo - -**Contexto.** El panel de miembros era un `div role="dialog"` sin foco inicial, sin foco -atrapado, sin devolver el foco al cerrar y sin nombre accesible. Los errores y -confirmaciones de la invitación no se anunciaban y el email se vaciaba al fallar. - -**Decisión.** -- `<dialog>` abierto con `showModal()`: el navegador pone el foco dentro y deja inerte el - resto. Escape (`cancel`) y el clic en el fondo llaman a `onClose`; el cierre real lo hace - el padre al desmontarlo. Al desmontar se cierra y se devuelve el foco a quien lo tenía, - a mano, porque el navegador no lo restaura si el `<dialog>` sale del DOM. -- `aria-labelledby`/`aria-describedby` con `useId`. -- Error con `role="alert"`, retirado mientras se envía (ver «Panel de miembros: rol al - invitar tras un error…», más arriba); éxito en una región `role="status"` siempre montada. -- El email usa `defaultValue` con los `values` que devuelve la acción al fallar. El rol, - además, remonta el `<select>` con una key (ver la misma entrada). - -**Alternativas descartadas.** -- Trampa de foco en JavaScript o una librería de diálogos: el `<dialog>` modal ya lo da - sin dependencias. -- Escuchar el evento `close` del `<dialog>`: en desarrollo, el doble montaje de Strict - Mode lo dispara al limpiar el efecto y cerraría el panel recién abierto. - -**Consecuencias.** Sin textos visibles nuevos. Falta verificarlo a mano con teclado y -lector de pantalla (no hay infraestructura e2e). El modal de tarjeta sigue con el patrón -antiguo (otro lote). +**Alternatives considered.** +- `window.confirm` (the pattern in `user-row-actions.tsx`): the button cannot be styled + or translated, and the project rule asks for inline confirmation. +- Showing the ✕ to members with an error message: it would offer an impossible action. + +**Consequences.** A board can no longer be lost with a single tap. The corner actions take +fixed space on mobile. The second-click behavior has no interaction test (no jsdom or +e2e); `test/delete-board-button.test.ts` checks that the initial state cannot submit +anything. +## 2026-09-24 — Card modal: native `<dialog>`, dates and delete confirmation + +**Context.** The card modal (`app/components/card/card-modal.tsx`) did not manage focus +(Tab walked through the board behind it, no accessible name), the comment time caused a +hydration error when opened via `?card=` (server in UTC, browser in its own zone), the +read-only due date was shown as raw ISO, the label and item toggles ignored failures, and +"Delete card" deleted on the first click. + +**Decision.** +- Native `<dialog>` with `showModal()` in a layout effect and `close()` in its cleanup + (before React removes the node, so the browser returns focus to the card). Escape via + `onCancel`; a click outside is detected with `e.target === dialog`, because the + `<dialog>` fills the viewport and acts as the scroll container. +- Comment time: only after hydration (`useSyncExternalStore`), in the browser's zone and + `es-ES`; before that, a `<time dateTime>` with no text. +- Due date: `Intl` `es-ES` with `timeZone: "UTC"` (it is midnight UTC). +- Toggles: `runToggle` returns a message for a `role="alert"` shown next to what failed + (under the labels, or under the items of each checklist), not at the top of the modal, + which scrolls; it rethrows Next's internal errors with `unstable_rethrow` (public API, + not the internal `isRedirectError`). +- Deletion: inline confirmation with a second click ("Delete the card?" Yes, delete / No), + with focus moved to the new control at each step. +- Unselected labels: only `opacity-55` is removed (text at ~2.2:1) and the color fill is + kept; the state is told apart by the ✓, the ring and `aria-pressed`. `aria-pressed` on + the colors too; color names in Spanish. +- The backdrop fade lives in `::backdrop` with `motion-safe:`: the global reduced-motion + rule in `globals.css` does not cover `::backdrop`. +- Pure logic in `app/components/card/card-modal-helpers.ts` so it can be tested in node. + +**Alternatives considered.** +- Trapping focus by hand or with a library: `showModal()` already makes the background + inert. +- `suppressHydrationWarning` on the time: it silences the warning but leaves the wrong + text. +- Hard-coding `Atlantic/Canary` on the client: it is self-hosted and each user has their + own zone. +- `window.confirm`: it blocks and does not follow the app's style. +- Unselected label as an outline with a color dot: a design change nobody asked for; + removing the opacity is enough. +- Adding `*::backdrop` to the rule in `globals.css`: `motion-safe:` solves it without + touching a shared file. + +**Consequences.** With `?card=` the `<dialog>` arrives closed in the server HTML and opens +on hydration (a moment without the modal). The dialog behavior (focus, Tab, Escape, +click outside) has no automated test: the project has no jsdom or e2e; it is pending +manual verification. The label picker in `filter-bar.tsx` should use the same style as +this one (fill without opacity). +## 2026-09-24 — Members panel with native `<dialog>` + +**Context.** The members panel was a `div role="dialog"` with no initial focus, no focus +trap, no focus return on close and no accessible name. Invitation errors and confirmations +were not announced, and the email field was cleared on failure. + +**Decision.** +- `<dialog>` opened with `showModal()`: the browser puts focus inside and makes the rest + inert. Escape (`cancel`) and a click on the backdrop call `onClose`; the actual close is + done by the parent when it unmounts it. On unmount it is closed and focus is returned to + whoever had it, by hand, because the browser does not restore it if the `<dialog>` + leaves the DOM. +- `aria-labeledby`/`aria-describedby` with `useId`. +- Error with `role="alert"`, removed while submitting (see "Members panel: invite role + after an error...", above); success in an always-mounted `role="status"` region. +- The email uses `defaultValue` with the `values` the action returns on failure. The role, + in addition, remounts the `<select>` with a key (see the same entry). + +**Alternatives considered.** +- A JavaScript focus trap or a dialog library: the modal `<dialog>` already provides it + with no dependencies. +- Listening to the `<dialog>`'s `close` event: in development, Strict Mode's double mount + fires it when the effect is cleaned up and would close the freshly opened panel. + +**Consequences.** No new visible text. Still needs manual verification with keyboard and +screen reader (there is no e2e infrastructure). The card modal still uses the old pattern +(another batch). --- -## 2026-09-24 — Recordatorios: día UTC, ventana de 7 días y un único aviso de vencida +## 2026-09-24 — Reminders: UTC day, 7-day window and a single overdue notice -**Contexto.** Las tarjetas vencidas se avisaban cada día para siempre y la pasada -crecía sin tope. El inicio del día se calculaba con la zona del servidor aunque `dueDate` -es medianoche UTC: con una zona negativa, una tarjeta que vence hoy salía como «Tarea -vencida» y el texto mostraba el día anterior. El formato usaba el locale `es`. +**Context.** Overdue cards were notified every day forever and the run grew without +bound. The start of the day was computed in the server's zone even though `dueDate` is +midnight UTC: with a negative zone, a card due today showed up as "Task overdue" and the +text showed the previous day. The formatting used the `es` locale. -**Decisión.** -- El día se cuenta en UTC y la fecha se formatea con `es-ES` y `timeZone: 'UTC'` (es una - fecha de calendario). -- «Vence pronto»: como mucho un aviso al día. «Tarea vencida»: un solo aviso, a partir del - día UTC siguiente al vencimiento, y solo para tarjetas vencidas en los últimos 7 días. -- Solo se avisa a asignados que siguen siendo miembros del tablero. -- `created` solo cuenta las notificaciones que `notify()` guardó de verdad. -- Se añaden tests del endpoint cron (503/401/200) sin modificar `route.ts`. +**Decision.** +- The day is counted in UTC and the date is formatted with `es-ES` and `timeZone: 'UTC'` + (it is a calendar date). +- "Due soon": at most one notice per day. "Task overdue": a single notice, from the UTC + day after the due date, and only for cards that became overdue in the last 7 days. +- Only assignees who are still board members are notified. +- `created` only counts the notifications that `notify()` actually saved. +- Tests for the cron endpoint (503/401/200) are added without modifying `route.ts`. -**Alternativas descartadas.** -- Usar `ORGTABLE_TIMEZONE` para el día de vencimiento: `dueDate` no tiene hora ni zona; - cualquier otra zona desplazaría la fecha elegida. -- Avisar de vencidas sin límite de antigüedad: avisos diarios infinitos. -- Fake timers en los tests: no afectan al `createdAt` que pone la BD. +**Alternatives considered.** +- Using `ORGTABLE_TIMEZONE` for the due day: `dueDate` has no time or zone; any other zone + would shift the chosen date. +- Notifying overdue cards with no age limit: endless daily notices. +- Fake timers in the tests: they do not affect the `createdAt` set by the database. -**Consecuencias.** Las tarjetas vencidas hace más de 7 días dejan de generar avisos. Una -tarjeta vencida produce un único aviso de vencida. Tests en -`test/integration/reminders.int.test.ts` y `cron-reminders.int.test.ts`. +**Consequences.** Cards overdue for more than 7 days stop generating notices. An overdue +card produces a single overdue notice. Tests in `test/integration/reminders.int.test.ts` +and `cron-reminders.int.test.ts`. --- -## 2026-09-24 — Expulsar a un miembro borra sus asignaciones; sin avisos a no miembros - -**Contexto.** `removeBoardMember` solo borraba la fila `BoardMember`. Las asignaciones -(`CardAssignee`) se quedaban, y `addComment` y los recordatorios avisaban a todos los -asignados: el expulsado seguía recibiendo el título de las tarjetas y hasta 140 -caracteres de cada comentario. Además, `updateBoardMemberRole` y `removeBoardMember` no -miraban cuántas filas cambiaban: con un `userId` ajeno se auditaba y notificaba un cambio -inexistente (revelando el título del tablero). Tampoco publicaban en el bus de tiempo -real. - -**Decisión.** -- `removeMemberFromBoard` (`app/lib/services/members.ts`) borra membresía y asignaciones - del tablero en una transacción y devuelve cuántas membresías borró. -- Las acciones de rol y expulsión no auditan ni notifican si no cambió nada (0 filas; en - el rol, tampoco si ya tenía ese rol). -- Defensa en profundidad: `addComment` solo avisa a asignados que siguen siendo miembros, - lo que cubre las asignaciones huérfanas ya existentes (los recordatorios, en su propia - entrada). -- Las tres acciones de miembros usan `revalidateBoard` (revalida y publica en el bus). -- `addBoardMember` devuelve `values` con lo enviado cuando hay error, para que el - formulario no se vacíe. -- Los tests de acciones sustituyen `requireBoardAccess` con `vi.mock` solo en su archivo, - sin tocar el stub global de `next/headers`. - -**Alternativas descartadas.** -- Limpiar ya las asignaciones huérfanas en producción: es una operación sobre datos - reales que decide el propietario (con copia y prueba en seco). El filtro por membresía - las neutraliza mientras tanto. -- Borrarlas con un trigger o un `onDelete` en BD: no hay relación directa entre - `CardAssignee` y `BoardMember`; exigiría una migración para algo que resuelve una - transacción. - -**Consecuencias.** Las filas huérfanas previas siguen en la BD (sin efecto en avisos, -pero aún visibles como avatares en la tarjeta) hasta que se limpien. Tests en -`test/integration/members.int.test.ts` y `collaboration.int.test.ts`. +## 2026-09-24 — Removing a member deletes their assignments; no notices to non-members + +**Context.** `removeBoardMember` only deleted the `BoardMember` row. The assignments +(`CardAssignee`) stayed, and `addComment` and the reminders notified every assignee: the +removed person kept receiving card titles and up to 140 characters of each comment. In +addition, `updateBoardMemberRole` and `removeBoardMember` did not check how many rows +changed: with someone else's `userId`, a non-existent change was audited and notified +(revealing the board title). They did not publish on the realtime bus either. + +**Decision.** +- `removeMemberFromBoard` (`app/lib/services/members.ts`) deletes the membership and the + board's assignments in one transaction and returns how many memberships it deleted. +- The role and removal actions do not audit or notify if nothing changed (0 rows; for the + role, also not if the user already had that role). +- Defense in depth: `addComment` only notifies assignees who are still members, which + covers the orphaned assignments that already exist (the reminders, in their own entry). +- The three member actions use `revalidateBoard` (revalidates and publishes on the bus). +- `addBoardMember` returns `values` with what was submitted when there is an error, so the + form is not cleared. +- The action tests replace `requireBoardAccess` with `vi.mock` only in their own file, + without touching the global `next/headers` stub. + +**Alternatives considered.** +- Cleaning up the orphaned assignments in production right away: it is an operation on + real data that the owner decides (with a backup and a dry run). The membership filter + neutralizes them in the meantime. +- Deleting them with a trigger or an `onDelete` in the database: there is no direct + relation between `CardAssignee` and `BoardMember`; it would require a migration for + something a transaction solves. + +**Consequences.** The pre-existing orphaned rows remain in the database (no effect on +notices, but still visible as avatars on the card) until they are cleaned up. Tests in +`test/integration/members.int.test.ts` and `collaboration.int.test.ts`. --- -## 2026-09-24 — `notify` informa de si guardó la notificación +## 2026-09-24 — `notify` reports whether it saved the notification -**Contexto.** `notify()` tenía un `catch` vacío: si la inserción fallaba no quedaba -rastro, y el cron de recordatorios contaba el aviso como creado igualmente, así que -`POST /api/cron/reminders` devolvía un `created` falso. +**Context.** `notify()` had an empty `catch`: if the insert failed there was no trace, and +the reminders cron counted the notice as created anyway, so `POST /api/cron/reminders` +returned a false `created`. -**Decisión.** `notify()` sigue siendo best-effort (nunca lanza), pero devuelve -`Promise<boolean>`: `true` en cuanto la fila se guarda (aunque después falle la -publicación en el bus, que va en su propio `try`) y `false` si no. El fallo se registra -con `console.error` con tipo, ids y código de error, sin título ni cuerpo (llevan texto -del usuario). +**Decision.** `notify()` is still best-effort (it never throws), but it returns +`Promise<boolean>`: `true` as soon as the row is saved (even if publishing on the bus, +which has its own `try`, fails afterwards) and `false` otherwise. The failure is logged +with `console.error` with the type, ids and error code, without title or body (they carry +user text). -**Alternativas descartadas.** -- Relanzar el error: rompería la mutación que origina el aviso (comentar, invitar…). -- Registrar el mensaje completo del error: puede incluir valores de la consulta. +**Alternatives considered.** +- Rethrowing the error: it would break the mutation that triggers the notice (commenting, + inviting...). +- Logging the full error message: it may include query values. -**Consecuencias.** Los llamadores que no miran el resultado no cambian. El cron solo -suma los avisos guardados. Test en `test/integration/notifications.int.test.ts`. +**Consequences.** Callers that ignore the result do not change. The cron only counts saved +notices. Test in `test/integration/notifications.int.test.ts`. --- -## 2026-09-24 — Recrear el EventSource cuando el navegador deja de reintentar - -**Contexto.** Detrás de un proxy TLS (nginx, Caddy), un reinicio con -`scripts/update.sh` hace que el proxy responda 502 al stream SSE. Ante una respuesta -que no es 200, el navegador deja el `EventSource` en `CLOSED` y no reintenta. El -cliente solo hacía `router.refresh()`: como la sesión seguía siendo válida la página no -redirigía y el tablero y la campana se quedaban sin tiempo real hasta recargar. - -**Decisión.** `keepEventSourceOpen` (`app/lib/realtime/reconnect.ts`) gestiona el -ciclo de vida del `EventSource` en `BoardRealtime` y `NotificationsBell`: registra los -manejadores, refresca en cada apertura salvo la primera (también en fuentes recreadas) -y, si la fuente queda `CLOSED`, hace `router.refresh()`, la cierra y crea otra tras un -backoff exponencial de 3 s a 30 s que se reinicia al abrir. La limpieza del efecto -cierra la fuente y cancela el reintento pendiente. Si el acceso se revocó, el refresh -redirige y el desmontaje corta los reintentos. Se prueba con un doble de -`EventSource` en `test/realtime-reconnect.test.ts` (no hay jsdom). - -**Alternativas descartadas.** -- Recrear la fuente dentro de cada componente: duplica la lógica del temporizador en - dos sitios y no se puede probar sin navegador. -- Reintentar sin backoff o con intervalo fijo corto: martillea al servidor mientras - arranca. -- Solo documentar el límite: el reinicio con `update.sh` es la operación habitual. - -**Consecuencias.** Si el servidor rechaza con 401/403 y por lo que sea la página no -redirige, se reintenta cada 30 s como máximo (petición barata que el servidor vuelve a -rechazar). Cada reapertura tras `CLOSED` provoca dos refresh (el de `CLOSED` y el de -la reapertura); se acepta por simplicidad. - -## 2026-09-24 — Hora de las notificaciones solo tras montar - -**Contexto.** El tiempo relativo de la campana depende de la hora y la zona del -navegador. Hoy la lista solo se pinta al abrir el desplegable (ya en cliente), pero -nada impedía que un cambio futuro la renderizara en el SSR con otro texto. - -**Decisión.** Mismo patrón que los comentarios: `useSyncExternalStore` (servidor -`false`, cliente `true`) y el texto relativo solo con el componente montado, dentro de -un `<time dateTime>`. La fecha absoluta usa `Intl.DateTimeFormat("es-ES")` en la zona -del navegador. - -**Alternativas descartadas.** `suppressHydrationWarning`: oculta el aviso pero deja el -texto del servidor. - -**Consecuencias.** Durante la hidratación la hora aparece vacía un instante. - -## 2026-09-24 — Refrescar el contador de notificaciones desde la Server Action - -**Contexto.** Al abrir una notificación con tablero no se llamaba a `router.refresh()` -y `markNotificationRead` no revalidaba nada: el layout compartido no se volvía a -renderizar y el contador de no leídas se quedaba igual. - -**Decisión.** `markNotificationRead` y `markAllNotificationsRead` llaman a -`refresh()` de `next/cache` (Next 16, solo en Server Actions) tras el `updateMany`, y -se quitan los `router.refresh()` del cliente. `test/stubs/next-cache.ts` exporta -`refresh` como no-op. - -**Alternativas descartadas.** -- `router.refresh()` también cuando hay tablero: compite con el `router.push` y - depende de que el cliente se acuerde en cada llamada. -- `revalidatePath("/", "layout")`: invalida más de lo necesario. - -**Consecuencias.** El refresco lo decide el servidor. El badge solo se puede comprobar -en el navegador; el test de integración (`notifications-read.int.test.ts`) comprueba -el dato y que se pide `refresh()`. - -## 2026-09-24 — Resincronizar el tablero y la campana al reconectar el EventSource - -**Contexto.** El servidor no reenvía los eventos perdidos (no hay `Last-Event-ID`), y -el cliente no hacía nada al reconectar: tras suspender el portátil o reiniciar el -servidor, el tablero y el contador de notificaciones mostraban datos viejos. - -**Decisión.** `createReconnectRefresher` (`app/lib/realtime/reconnect.ts`): en cada -`open` del EventSource salvo el primero se lanza el mismo refresh con debounce que -un evento. Si el EventSource queda en `CLOSED` (el servidor respondió 401/403), se -hace `router.refresh()` para que la página redirija. - -**Alternativas descartadas.** -- `Last-Event-ID` con historial de eventos en el servidor: exige guardar eventos por - canal (memoria o tabla) para un beneficio que un refresh ya cubre. -- Refrescar en cada `open`, incluido el primero: un render extra al cargar la página. - -**Consecuencias.** Un `router.refresh()` por reconexión (también cada 15 min por la -vida máxima del stream). El caso `CLOSED` por un 5xx del proxy se resuelve en la -entrada «Recrear el EventSource cuando el navegador deja de reintentar». - -## 2026-09-24 — Puente LISTEN/NOTIFY: cierre, reconexión y validación del payload - -**Contexto.** Si `LISTEN` fallaba o la conexión daba `error`, el cliente `pg` no se -cerraba y cada suscripción SSE abría otra conexión, sin registrar nada. Mientras el -puente estaba arrancando, los eventos publicados se perdían. El payload de `NOTIFY` se -parseaba con un cast: uno sin `id` se reemitía en `board:undefined`. Los `.catch` de -publicación estaban vacíos. - -**Decisión.** -- La conexión fallida o caída se cierra siempre (`end()`) y se registra con - `console.error` (sin `DATABASE_URL`). El estado global solo se limpia si el cliente - que falla es el activo. -- La promesa de arranque se guarda en `globalThis` y `publishCrossInstance` la espera. -- Reconexión con backoff exponencial de 1 s a 30 s, un único temporizador en - `globalThis` y solo mientras quedan suscriptores. -- `parseBridgePayload` valida con zod (`kind` board/user, `id` y `origin` no vacíos); - lo inválido se descarta con `console.warn` indicando solo la longitud. - -**Alternativas descartadas.** -- Reintentar en cada suscripción (lo de antes): sin backoff, una caída de la BD - multiplicaba las conexiones. -- Volcar el payload inválido al log: puede venir de cualquiera con acceso a la BD. - -**Consecuencias.** Solo afecta con `ORGTABLE_REALTIME_PG_NOTIFY=1`. Tests unitarios en -`test/realtime-bus.test.ts` con un `pg` simulado. No se ha probado con una caída real -de Postgres (`pg_terminate_backend`). - -## 2026-09-24 — Ciclo de vida y revalidación del acceso en los streams SSE - -**Contexto.** Los dos streams SSE (`/api/boards/[id]/stream` y -`/api/notifications/stream`) dejaban la suscripción al bus y el latido colgados si la -petición se abortaba durante la autorización o si `enqueue` fallaba (el flag `closed` -hacía que `close()` saliera sin limpiar), y no tenían `cancel()`. Además la -autorización solo se comprobaba al conectar: una sesión revocada o una persona -expulsada seguían recibiendo eventos, en contra del ADR 0008. - -**Decisión.** -- Helper común `app/lib/realtime/sse.ts` (`createSseResponse`) con una limpieza - idempotente llamada desde el abort (`{ once: true }`), `cancel()`, un `enqueue` - fallido, la revalidación y la vida máxima. Si la petición llega ya abortada se - responde 204 sin suscribirse. -- En cada latido (25 s) se revalida con la BD: sesión no revocada ni caducada - (`isSessionActive`) y, en el tablero, que siga siendo miembro. Un flag evita - consultas solapadas; si la consulta falla, se registra y se cierra. -- Vida máxima de 15 min: al cerrar, EventSource reconecta y la ruta reautoriza desde - la cookie. - -**Alternativas descartadas.** -- `verifySession` en el latido: depende de `cookies()`, que solo existe dentro de la - petición inicial. -- Evento de control para cortar al instante al revocar: más invasivo (publicar desde - cada acción de revocación o expulsión); 25 s de margen es aceptable. -- Mantener una copia de la lógica en cada ruta: las dos copias tenían el mismo fallo. - -**Consecuencias.** Una consulta ligera por conexión cada 25 s. Cada 15 min el cliente -reconecta. Tests en `test/integration/realtime.int.test.ts` (401/403, abort, cancel, -revocación, expulsión, vida máxima; sin listeners ni temporizadores tras cerrar). -## 2026-09-24 — El token de API queda fuera de la región viva - -**Contexto.** En `/settings`, el `role="status"` siempre montado envolvía todo el panel -del token recién creado: el texto, el `<code>` con el token en claro y el botón Copiar, -y dentro había además el `role="alert"` del fallo de copia. El lector de pantalla podía -leer el token entero cada vez que la región cambiaba (también al pasar a «Copiado») y -anunciar dos veces el aviso de fallo. - -**Decisión.** El `role="status"` solo contiene «Token «nombre» creado» y «Cópialo ahora: -por seguridad no se volverá a mostrar.». El `<code>`, el botón y el `role="alert"` son -hermanos de la región, no hijos. El recuadro verde pasa a ser el contenedor siempre -montado (sin estilo hasta que hay token), así el aspecto no cambia. - -**Alternativas descartadas.** `aria-live="off"` en el `<code>` dentro de la región: el -cambio de texto del botón seguiría disparando el anuncio de la región completa en -algunos lectores. - -**Consecuencias.** Test en `test/admin-forms-a11y.test.ts`: con un token creado, la -región no contiene el token ni el botón (se sustituye `useActionState` en el propio test -porque el render de servidor siempre da el estado inicial). La lectura real con un -lector de pantalla queda pendiente de verificación manual. - -## 2026-09-24 — Restablecer contraseña valida el userId; alcance del test del borrado - -**Contexto.** `resetUserPassword` validaba `userId` dentro de `resetPasswordSchema`: sin -`userId` redirigía a `?error=password` («La contraseña debe tener al menos 8 -caracteres.»), que no era el problema. Además, el test del propietario de tableros -pasaba también con el código anterior (comprobación previa + DELETE), así que no -demostraba la condición atómica del servicio. - -**Decisión.** -- `resetUserPassword` llama a `readUserId` antes de validar la contraseña, como el resto - de acciones por fila: sin `userId` responde `?error=invalid`. -- Test directo de `deleteUserAccount` (sin pasar por la acción): un propietario de - tableros devuelve `owns_boards` y su tablero y su adjunto siguen en BD; un id - inexistente devuelve `not_found`. - -**Alternativas descartadas.** Distinguir por `issues[0].path` del esquema: reparte la -validación del id entre dos sitios. Reproducir la carrera intercalando dos transacciones -(bloqueo de la fila `User` por el INSERT del tablero): depende del orden de bloqueos de -Postgres y sería un test frágil. - -**Consecuencias.** La carrera en sí (un tablero creado entre una comprobación y el -DELETE) NO está probada; los tests fijan el contrato del servicio (la salvaguarda está -en el propio DELETE, la acción no comprueba nada antes). Tests en -`test/integration/admin-users.int.test.ts`. +## 2026-09-24 — Recreate the EventSource when the browser stops retrying + +**Context.** Behind a TLS proxy (nginx, Caddy), a restart with `scripts/update.sh` makes +the proxy answer 502 to the SSE stream. On a non-200 response, the browser leaves the +`EventSource` in `CLOSED` and does not retry. The client only did `router.refresh()`: +since the session was still valid the page did not redirect, and the board and the +notification bell were left without realtime until a reload. + +**Decision.** `keepEventSourceOpen` (`app/lib/realtime/reconnect.ts`) manages the +`EventSource` lifecycle in `BoardRealtime` and `NotificationsBell`: it registers the +handlers, refreshes on every open except the first (also on recreated sources) and, if +the source ends up `CLOSED`, does `router.refresh()`, closes it and creates another after +an exponential backoff from 3 s to 30 s that resets on open. The effect cleanup closes the +source and cancels the pending retry. If access was revoked, the refresh redirects and the +unmount stops the retries. It is tested with an `EventSource` test double in +`test/realtime-reconnect.test.ts` (there is no jsdom). + +**Alternatives considered.** +- Recreating the source inside each component: duplicates the timer logic in two places + and cannot be tested without a browser. +- Retrying without backoff or with a short fixed interval: hammers the server while it + starts up. +- Only documenting the limitation: restarting with `update.sh` is the usual operation. + +**Consequences.** If the server rejects with 401/403 and for some reason the page does not +redirect, it retries every 30 s at most (a cheap request the server rejects again). Each +reopen after `CLOSED` causes two refreshes (the one on `CLOSED` and the one on reopen); +accepted for simplicity. + +## 2026-09-24 — Notification times only after mount + +**Context.** The bell's relative time depends on the browser's time and zone. Today the +list is only rendered when the dropdown opens (already on the client), but nothing +prevented a future change from rendering it in SSR with different text. + +**Decision.** Same pattern as the comments: `useSyncExternalStore` (server `false`, client +`true`) and the relative text only once the component is mounted, inside a +`<time dateTime>`. The absolute date uses `Intl.DateTimeFormat("es-ES")` in the browser's +zone. + +**Alternatives considered.** `suppressHydrationWarning`: hides the warning but leaves the +server text. + +**Consequences.** During hydration the time is empty for a moment. + +## 2026-09-24 — Refresh the notification counter from the Server Action + +**Context.** Opening a notification that has a board did not call `router.refresh()`, and +`markNotificationRead` revalidated nothing: the shared layout was not re-rendered and the +unread counter stayed the same. + +**Decision.** `markNotificationRead` and `markAllNotificationsRead` call `refresh()` from +`next/cache` (Next 16, Server Actions only) after the `updateMany`, and the client-side +`router.refresh()` calls are removed. `test/stubs/next-cache.ts` exports `refresh` as a +no-op. + +**Alternatives considered.** +- `router.refresh()` also when there is a board: it competes with the `router.push` and + depends on the client remembering it on every call. +- `revalidatePath("/", "layout")`: invalidates more than needed. + +**Consequences.** The server decides the refresh. The badge can only be checked in the +browser; the integration test (`notifications-read.int.test.ts`) checks the data and that +`refresh()` is requested. + +## 2026-09-24 — Resync the board and the bell when the EventSource reconnects + +**Context.** The server does not resend missed events (there is no `Last-Event-ID`), and +the client did nothing on reconnect: after the laptop was suspended or the server +restarted, the board and the notification counter showed stale data. + +**Decision.** `createReconnectRefresher` (`app/lib/realtime/reconnect.ts`): on every +EventSource `open` except the first, the same debounced refresh as an event is triggered. +If the EventSource ends up `CLOSED` (the server answered 401/403), `router.refresh()` is +called so the page redirects. + +**Alternatives considered.** +- `Last-Event-ID` with an event history on the server: requires storing events per + channel (memory or table) for a benefit a refresh already covers. +- Refreshing on every `open`, including the first: an extra render on page load. + +**Consequences.** One `router.refresh()` per reconnection (also every 15 min because of +the stream's maximum lifetime). The `CLOSED` case caused by a proxy 5xx is handled in the +entry "Recreate the EventSource when the browser stops retrying". + +## 2026-09-24 — LISTEN/NOTIFY bridge: closing, reconnection and payload validation + +**Context.** If `LISTEN` failed or the connection emitted `error`, the `pg` client was not +closed and every SSE subscription opened another connection, without logging anything. +While the bridge was starting, published events were lost. The `NOTIFY` payload was +parsed with a cast: one without `id` was re-emitted on `board:undefined`. The publish +`.catch` handlers were empty. + +**Decision.** +- A failed or dropped connection is always closed (`end()`) and logged with + `console.error` (without `DATABASE_URL`). The global state is only cleared if the + failing client is the active one. +- The startup promise is stored in `globalThis` and `publishCrossInstance` waits for it. +- Reconnection with exponential backoff from 1 s to 30 s, a single timer in `globalThis`, + and only while there are subscribers left. +- `parseBridgePayload` validates with zod (`kind` board/user, non-empty `id` and + `origin`); invalid payloads are discarded with a `console.warn` giving only the length. + +**Alternatives considered.** +- Retrying on every subscription (the previous behavior): with no backoff, a database + outage multiplied the connections. +- Dumping the invalid payload to the log: it can come from anyone with database access. + +**Consequences.** Only applies with `ORGTABLE_REALTIME_PG_NOTIFY=1`. Unit tests in +`test/realtime-bus.test.ts` with a mocked `pg`. Not tested against a real Postgres outage +(`pg_terminate_backend`). + +## 2026-09-24 — Lifecycle and access revalidation in the SSE streams + +**Context.** The two SSE streams (`/api/boards/[id]/stream` and +`/api/notifications/stream`) left the bus subscription and the heartbeat dangling if the +request was aborted during authorization or if `enqueue` failed (the `closed` flag made +`close()` return without cleaning up), and they had no `cancel()`. In addition, +authorization was only checked on connect: a revoked session or a removed person kept +receiving events, contrary to ADR 0008. + +**Decision.** +- Shared helper `app/lib/realtime/sse.ts` (`createSseResponse`) with an idempotent cleanup + called from the abort (`{ once: true }`), `cancel()`, a failed `enqueue`, the + revalidation and the maximum lifetime. If the request arrives already aborted, it + answers 204 without subscribing. +- On every heartbeat (25 s) access is revalidated against the database: session not + revoked or expired (`isSessionActive`) and, for the board, still a member. A flag + prevents overlapping queries; if the query fails, it is logged and the stream closes. +- Maximum lifetime of 15 min: on close, EventSource reconnects and the route + re-authorizes from the cookie. + +**Alternatives considered.** +- `verifySession` in the heartbeat: it depends on `cookies()`, which only exists inside + the initial request. +- A control event to cut off instantly on revocation: more invasive (publishing from + every revocation or removal action); a 25 s margin is acceptable. +- Keeping a copy of the logic in each route: both copies had the same bug. + +**Consequences.** One lightweight query per connection every 25 s. The client reconnects +every 15 min. Tests in `test/integration/realtime.int.test.ts` (401/403, abort, cancel, +revocation, removal, maximum lifetime; no listeners or timers left after closing). +## 2026-09-24 — The API token stays out of the live region + +**Context.** On `/settings`, the always-mounted `role="status"` wrapped the whole panel of +the newly created token: the text, the `<code>` with the plaintext token and the Copy +button, and it also contained the `role="alert"` for a copy failure. The screen reader +could read the whole token every time the region changed (also when switching to +"Copied") and announce the failure message twice. + +**Decision.** The `role="status"` only contains "Token "name" created" and "Copy it now: +for security it will not be shown again.". The `<code>`, the button and the +`role="alert"` are siblings of the region, not children. The green box becomes the +always-mounted container (unstyled until there is a token), so the look does not change. + +**Alternatives considered.** `aria-live="off"` on the `<code>` inside the region: the +button's text change would still trigger an announcement of the whole region in some +screen readers. + +**Consequences.** Test in `test/admin-forms-a11y.test.ts`: with a created token, the +region contains neither the token nor the button (`useActionState` is replaced in the +test itself because server rendering always yields the initial state). Actual reading +with a screen reader is pending manual verification. + +## 2026-09-24 — Password reset validates the userId; scope of the deletion test + +**Context.** `resetUserPassword` validated `userId` inside `resetPasswordSchema`: without +a `userId` it redirected to `?error=password` ("Password must be at least 8 +characters."), which was not the problem. In addition, the board-owner test also passed +with the previous code (prior check + DELETE), so it did not prove the service's atomic +condition. + +**Decision.** +- `resetUserPassword` calls `readUserId` before validating the password, like the other + per-row actions: without a `userId` it answers `?error=invalid`. +- Direct test of `deleteUserAccount` (without going through the action): a board owner + returns `owns_boards` and their board and attachment remain in the database; a + non-existent id returns `not_found`. + +**Alternatives considered.** Distinguishing by the schema's `issues[0].path`: splits the +id validation across two places. Reproducing the race by interleaving two transactions +(locking the `User` row via the board INSERT): depends on Postgres lock ordering and would +be a fragile test. + +**Consequences.** The race itself (a board created between a check and the DELETE) is NOT +tested; the tests pin down the service contract (the safeguard is in the DELETE itself, +the action checks nothing beforehand). Tests in `test/integration/admin-users.int.test.ts`. --- -## 2026-09-24 — Administración y ajustes: avisos anunciados, móvil y copia del token +## 2026-09-24 — Admin and settings: announced messages, mobile and token copy -**Contexto.** Los errores y avisos de `/admin/users` y `/settings` no se anunciaban al -lector de pantalla; el campo de nueva contraseña no tenía etiqueta; las tablas de -usuarios y auditoría se recortaban a 375 px (`overflow-hidden`) y dejaban la columna -Acciones fuera de alcance; si fallaba copiar el token (por `http://IP`, contexto no -seguro) no se avisaba, y el token solo se muestra una vez. +**Context.** Errors and messages in `/admin/users` and `/settings` were not announced to +the screen reader; the new-password field had no label; the users and audit tables were +clipped at 375 px (`overflow-hidden`) and left the Actions column out of reach; if copying +the token failed (because of `http://IP`, a non-secure context) there was no warning, and +the token is only shown once. -**Decisión.** -- Errores con `role="alert"`; éxitos dentro de un `role="status"` siempre montado. El - email duplicado marca el campo con `aria-invalid` + `aria-describedby`. -- `aria-label="Nueva contraseña para <nombre>"` en el campo de restablecer. -- `overflow-x-auto` en las tablas (mantiene el recorte de las esquinas). -- Copiar el token guarda el resultado ligado al token (`copied`/`error`); si falla, - muestra «No se pudo copiar automáticamente: selecciona el token y cópialo a mano.» y - deja el token seleccionado (`select-all` + `selectAllChildren`). +**Decision.** +- Errors with `role="alert"`; successes inside an always-mounted `role="status"`. A + duplicate email marks the field with `aria-invalid` + `aria-describedby`. +- `aria-label="Nueva contraseña para <nombre>"` on the reset field. +- `overflow-x-auto` on the tables (keeps the corner clipping). +- Copying the token stores the result tied to the token (`copied`/`error`); if it fails, + it shows "Could not copy automatically: select the token and copy it by hand." and + leaves the token selected (`select-all` + `selectAllChildren`). -**Alternativas descartadas.** Pasar las tablas a tarjetas en móvil: cambio de diseño que -decide el propietario. `document.execCommand("copy")` como respaldo: API obsoleta. +**Alternatives considered.** Turning the tables into cards on mobile: a design change for +the owner to decide. `document.execCommand("copy")` as a fallback: obsolete API. -**Consecuencias.** Test unitario `test/admin-forms-a11y.test.ts` (render en servidor: -las regiones `role="status"` existen desde el primer render). El resto (lector de -pantalla, 375 px, copia por http) queda pendiente de verificación manual. +**Consequences.** Unit test `test/admin-forms-a11y.test.ts` (server render: the +`role="status"` regions exist from the first render). The rest (screen reader, 375 px, +copy over http) is pending manual verification. --- -## 2026-09-24 — Crear usuario y crear token conservan lo escrito tras un error +## 2026-09-24 — Create user and create token keep the input after an error -**Contexto.** Con `action={fn}` React restablece el formulario al terminar la acción, -así que un email duplicado o un dato no válido borraban todo lo tecleado. +**Context.** With `action={fn}` React resets the form when the action finishes, so a +duplicate email or invalid data wiped everything typed. -**Decisión.** `createUser` y `createApiToken` devuelven `values` en cada error (nunca la -contraseña) y los campos usan `defaultValue`. Los `<select>` llevan además `key` con el -valor devuelto: React no actualiza el `defaultValue` de un `<select>` ya montado y el -reset lo devolvería al valor inicial (p. ej. «Administrador» → «Miembro»). -`createUser` indica `field: "email"` para marcar el campo del email duplicado. +**Decision.** `createUser` and `createApiToken` return `values` on every error (never the +password) and the fields use `defaultValue`. The `<select>` elements also get a `key` +with the returned value: React does not update the `defaultValue` of an already mounted +`<select>` and the reset would send it back to the initial value (e.g. "Administrator" → +"Member"). `createUser` returns `field: "email"` to mark the field of the duplicate email. -**Alternativas descartadas.** Campos controlados con `useState`: más código por campo -para el mismo resultado. Enviar con `onSubmit` + `startTransition` para evitar el reset: -cambia el flujo de envío de los dos formularios. +**Alternatives considered.** Controlled fields with `useState`: more code per field for the +same result. Submitting with `onSubmit` + `startTransition` to avoid the reset: changes the +submit flow of both forms. -**Consecuencias.** Tests en `admin-users.int.test.ts` y `tokens.int.test.ts` (los -valores vuelven y la contraseña no). +**Consequences.** Tests in `admin-users.int.test.ts` and `tokens.int.test.ts` (the values +come back and the password does not). --- -## 2026-09-24 — Fechas del servidor en la zona de la instancia (ORGTABLE_TIMEZONE) +## 2026-09-24 — Server dates in the instance time zone (ORGTABLE_TIMEZONE) -**Contexto.** `/admin/audit`, `/admin/users` y `/settings` son Server Components que -formateaban con `Intl.DateTimeFormat("es")` sin `timeZone`: salían en la zona del -proceso (UTC en el contenedor). Un login fallido a las 10:15 en Canarias aparecía a las -9:15, y las fechas cercanas a medianoche cambiaban de día. +**Context.** `/admin/audit`, `/admin/users` and `/settings` are Server Components that +formatted with `Intl.DateTimeFormat("es")` without `timeZone`: dates came out in the +process time zone (UTC in the container). A failed login at 10:15 in the Canary Islands +showed up as 9:15, and dates close to midnight jumped to another day. -**Decisión.** -- Helper único `app/lib/format-date.ts` (`formatDateTime`, `formatDate`) con locale - `es-ES` y la zona de la variable `ORGTABLE_TIMEZONE` (IANA; por defecto - `Atlantic/Canary`). Se valida con `Intl`: una zona desconocida lanza un error claro - en vez de caer a un valor por defecto. -- La auditoría muestra la abreviatura de la zona (`timeZoneName: "short"`). -- Los vencimientos (`dueDate`, fechas de calendario a medianoche UTC) NO usan este - helper: se formatean con `timeZone: "UTC"`. +**Decision.** +- A single helper `app/lib/format-date.ts` (`formatDateTime`, `formatDate`) with locale + `es-ES` and the time zone from the `ORGTABLE_TIMEZONE` variable (IANA; default + `Atlantic/Canary`). It is validated with `Intl`: an unknown zone throws a clear error + instead of falling back to a default value. +- The audit log shows the zone abbreviation (`timeZoneName: "short"`). +- Due dates (`dueDate`, calendar dates at UTC midnight) do NOT use this helper: they are + formatted with `timeZone: "UTC"`. -**Alternativas descartadas.** -- Formatear en el cliente con la zona del navegador: obliga a convertir tres páginas de - servidor en componentes cliente y da horas distintas a cada administrador. -- Fijar `Atlantic/Canary` en el código: es una app self-hosted, cada instancia tiene la - suya. +**Alternatives considered.** +- Formatting on the client with the browser time zone: forces three server pages to + become client components and gives each admin different times. +- Hard-coding `Atlantic/Canary`: this is a self-hosted app, and each instance has its + own zone. -**Consecuencias.** Variable de entorno nueva `ORGTABLE_TIMEZONE` (su documentación en -`.env.example` y README la hace el lote de despliegue). Test unitario -`test/format-date.test.ts` con `TZ=UTC`. +**Consequences.** New environment variable `ORGTABLE_TIMEZONE` (its documentation in +`.env.example` and the README is done by the deployment batch). Unit test +`test/format-date.test.ts` with `TZ=UTC`. --- -## 2026-09-24 — Acciones de administración: usuario inexistente y borrado atómico +## 2026-09-24 — Admin actions: non-existent user and atomic deletion -**Contexto.** `updateUserRole`, `resetUserPassword`, `revokeUserSessions` y `deleteUser` -no miraban si el usuario existía: auditaban y mostraban éxito con un id inexistente. -`deleteUser` comprobaba los tableros propios en una consulta previa; si el usuario -creaba un tablero entre medias, la cascada por `ownerId` borraba sus filas `Attachment` -y dejaba los ficheros huérfanos en el volumen de adjuntos. +**Context.** `updateUserRole`, `resetUserPassword`, `revokeUserSessions` and `deleteUser` +did not check whether the user existed: they wrote an audit entry and reported success +with a non-existent id. `deleteUser` checked the user's own boards in a prior query; if +the user created a board in between, the cascade via `ownerId` deleted its `Attachment` +rows and left the files orphaned in the attachments volume. -**Decisión.** -- El id se valida como cadena no vacía (`String(null)` daba `"null"`). -- Se usa el `count` de `updateMany`/`deleteMany` (y un `findUnique` en «Cerrar - sesiones», donde 0 sesiones es válido) y se redirige a `?error=not-found` antes de - auditar. Mensaje: «Ese usuario ya no existe.». -- `deleteUserAccount` (servicio) borra con `where: { id, boards: { none: {} } }`: la - condición va en el mismo DELETE, así que la cascada nunca alcanza adjuntos. +**Decision.** +- The id is validated as a non-empty string (`String(null)` gave `"null"`). +- The `count` from `updateMany`/`deleteMany` is used (plus a `findUnique` in "End + sessions", where 0 sessions is valid), and the action redirects to `?error=not-found` + before auditing. Message: "That user no longer exists.". +- `deleteUserAccount` (service) deletes with `where: { id, boards: { none: {} } }`: the + condition lives in the same DELETE, so the cascade never reaches attachments. -**Alternativas descartadas.** Leer los `storedName` y borrar los ficheros tras el -DELETE: con la condición atómica no hay adjuntos que borrar, y no tendría forma de -probarse sin saltarse la salvaguarda de tableros propios. +**Alternatives considered.** Reading the `storedName` values and deleting the files after +the DELETE: with the atomic condition there are no attachments to delete, and it could +not be tested without bypassing the owned-boards safeguard. -**Consecuencias.** Tests en `test/integration/admin-users.int.test.ts` (sin auditoría -con id inexistente; un propietario de tableros no se borra y su adjunto sigue en BD). -Esos tests no reproducen la carrera: ver la entrada «Restablecer contraseña valida el -userId; alcance del test del borrado». +**Consequences.** Tests in `test/integration/admin-users.int.test.ts` (no audit entry +with a non-existent id; a board owner is not deleted and their attachment stays in the +DB). Those tests do not reproduce the race: see the entry "Password reset validates the +userId; scope of the deletion test". --- -## 2026-09-24 — Crear un token de API valida permisos y caducidad +## 2026-09-24 — Creating an API token validates permissions and expiry -**Contexto.** `createApiToken` convertía un `scope` o `expiry` no reconocidos en -«escritura» y «no caduca», el valor más permisivo, con un comentario que hablaba de -«valores por defecto seguros». +**Context.** `createApiToken` turned an unrecognized `scope` or `expiry` into "write" +and "never expires", the most permissive value, with a comment that talked about "safe +default values". -**Decisión.** Nombre, permisos y caducidad se validan con un único `z.object` que -reutiliza los esquemas de `validators.ts`; si algo falla, se devuelve `{ error }` sin -crear nada («Permisos no válidos.» / «Caducidad no válida.»). Los valores por defecto -del formulario (escritura, nunca caduca) no cambian: los decide el propietario. +**Decision.** Name, permissions and expiry are validated with a single `z.object` that +reuses the schemas in `validators.ts`; if anything fails, it returns `{ error }` without +creating anything ("Invalid permissions." / "Invalid expiry."). The form defaults +(write, never expires) do not change: the owner decides them. -**Alternativas descartadas.** Caer a «solo lectura» y caducidad finita: seguiría -siendo un valor por defecto silencioso que tapa un formulario manipulado o roto. +**Alternatives considered.** Falling back to "read only" and a finite expiry: it would +still be a silent default that hides a tampered or broken form. -**Consecuencias.** Test en `test/integration/tokens.int.test.ts`. +**Consequences.** Test in `test/integration/tokens.int.test.ts`. --- -## 2026-09-24 — Restablecer la contraseña también revoca los tokens de API - -**Contexto.** `resetUserPassword` solo revocaba sesiones. `authenticateApiToken` no mira -la tabla `Session`, así que quien hubiera creado un token `full` sin caducidad con una -cuenta comprometida seguía entrando por `/api/v1` después del reset. El comentario de la -acción prometía lo contrario. - -**Decisión.** -- Nuevo servicio `resetPasswordAndRevokeAccess` en `app/lib/services/users.ts`: en una - sola `$transaction` cambia el hash, revoca las sesiones y **borra** los tokens del - usuario (borrar es la revocación del ADR 0013). Comprueba que el usuario existe antes - de hashear. -- La auditoría `user.password_reset` guarda cuántos tokens se revocaron. -- «Cerrar sesiones» no cambia de semántica (solo sesiones) y no se añade un botón nuevo - de revocar tokens; queda documentado que no toca los tokens. - -**Alternativas descartadas.** -- Invalidar tokens por fecha (`tokensValidAfter` en `User`): exige migración y deja - filas muertas en `/settings`. -- Revocar tokens también en «Cerrar sesiones»: cambia una acción que se usa para cerrar - el navegador de alguien sin romper sus scripts. - -**Consecuencias.** Tras un reset el usuario tiene que crear tokens nuevos. Test en -`test/integration/admin-revocation.int.test.ts` (el mismo Bearer pasa de 200 a 401; los -tokens de otros usuarios no se tocan). -## 2026-09-24 — Stub de next/headers: vaciar cookies y cabeceras por separado - -**Contexto.** `loginAs` llamaba a `__clearCookies`, que vaciaba también las cabeceras -inyectadas. Un test que hiciera `__setHeaders({ "x-forwarded-proto": "https" })` y luego -`loginAs` perdía la cabecera sin aviso: la cookie salía sin `Secure` y el rate limit no -veía la IP. Además, el test de `resetDb` también pasaba con la lista fija de tablas -anterior (CardAssignee y Comment caen por el CASCADE), así que no demostraba que las -tablas se leyeran del catálogo. - -**Decisión.** -- `__clearCookies` vacía solo las cookies y `__clearHeaders` solo las cabeceras. - `loginAs` llama a la primera; `resetDb`, a las dos. Orden documentado en - `docs/testing.md`: las cabeceras se ponen después de `resetDb`, antes o después de - `loginAs`. -- `helpers.int.test.ts` crea una tabla temporal sin FK, le mete una fila y exige que - `resetDb` la vacíe (con la lista fija falla); y comprueba que `loginAs` conserva - `X-Forwarded-Proto`/`X-Forwarded-For`. -- `audit.int.test.ts` exige que el log del fallo por FK no lleve el `userId`. Pasa con el - `describeError` actual (el mensaje de Prisma P2003 nombra la restricción, no el - valor), así que no se toca `app/lib/audit.ts`. - -**Alternativas descartadas.** -- Mantener un único `__clearCookies` con un parámetro para conservar cabeceras: - parámetro booleano que cambia el comportamiento. -- Comparar `pg_tables` con los modelos de Prisma: depende de `@@map` y no prueba que - `resetDb` use el catálogo, solo que el esquema coincide. -- Registrar solo nombre y código de todo `PrismaClientKnownRequestError`: el test no - muestra fuga y se perdería la restricción que falla, útil para diagnosticar. - -**Consecuencias.** Los tests de cookie `Secure` y de IP pueden usar `loginAs` sin -reinyectar cabeceras. La tabla temporal `zz_reset_probe` se borra en un `finally` y, -por si quedara de una ejecución interrumpida, también antes de crearla. - -## 2026-09-24 — Login y setup: conservar lo tecleado y anunciar los errores - -**Contexto.** React 19 resetea los formularios no controlados después de una acción: -tras «Credenciales incorrectas» se borraba el email, y tras un error de validación del -setup, los cuatro campos. Además el mensaje de error era un `<p>` sin `role="alert"` -que el lector de pantalla no anunciaba. - -**Decisión.** -- `AuthState.values` y `SetupState.values` devuelven lo enviado en cada error (email; - nombre de instancia, nombre y email), nunca la contraseña. El cliente lo pinta con - `defaultValue`. En el éxito no se devuelven. -- El `<p>` de error lleva `role="alert"` y se retira mientras `pending`, para que un - segundo error idéntico se vuelva a insertar y se anuncie otra vez. - -**Alternativas descartadas.** -- Campos controlados con `useState`: más código en dos formularios para el mismo efecto. -- `key` con un contador de intentos: obliga a añadir estado solo para eso. - -**Consecuencias.** La verificación con VoiceOver es manual: no hay infraestructura de -tests de componentes. Los tests de integración comprueban que `values` llega sin la -contraseña. +## 2026-09-24 — Resetting the password also revokes API tokens + +**Context.** `resetUserPassword` only revoked sessions. `authenticateApiToken` does not +look at the `Session` table, so anyone who had created a `full` token with no expiry +from a compromised account could still get in through `/api/v1` after the reset. The +action's comment promised the opposite. + +**Decision.** +- New service `resetPasswordAndRevokeAccess` in `app/lib/services/users.ts`: in a single + `$transaction` it changes the hash, revokes the sessions and **deletes** the user's + tokens (deleting is the revocation defined in ADR 0013). It checks that the user exists + before hashing. +- The `user.password_reset` audit entry records how many tokens were revoked. +- "End sessions" keeps its semantics (sessions only) and no new revoke-tokens button is + added; it is documented that it does not touch tokens. + +**Alternatives considered.** +- Invalidating tokens by date (`tokensValidAfter` on `User`): requires a migration and + leaves dead rows in `/settings`. +- Also revoking tokens in "End sessions": changes an action used to sign someone out of + their browser without breaking their scripts. + +**Consequences.** After a reset the user has to create new tokens. Test in +`test/integration/admin-revocation.int.test.ts` (the same Bearer goes from 200 to 401; +other users' tokens are not touched). +## 2026-09-24 — next/headers stub: clear cookies and headers separately + +**Context.** `loginAs` called `__clearCookies`, which also cleared the injected headers. +A test that did `__setHeaders({ "x-forwarded-proto": "https" })` and then `loginAs` +silently lost the header: the cookie went out without `Secure` and the rate limit did +not see the IP. Also, the `resetDb` test passed with the previous fixed table list too +(CardAssignee and Comment go via the CASCADE), so it did not prove that the tables were +read from the catalog. + +**Decision.** +- `__clearCookies` clears only cookies and `__clearHeaders` only headers. `loginAs` + calls the first; `resetDb` calls both. Order documented in `docs/testing.md`: headers + are set after `resetDb`, before or after `loginAs`. +- `helpers.int.test.ts` creates a temporary table with no FK, inserts a row and requires + `resetDb` to empty it (with the fixed list it fails); and it checks that `loginAs` + keeps `X-Forwarded-Proto`/`X-Forwarded-For`. +- `audit.int.test.ts` requires the log of the FK failure not to include the `userId`. It + passes with the current `describeError` (the Prisma P2003 message names the + constraint, not the value), so `app/lib/audit.ts` is not touched. + +**Alternatives considered.** +- Keeping a single `__clearCookies` with a parameter to keep headers: a boolean + parameter that changes behavior. +- Comparing `pg_tables` with the Prisma models: depends on `@@map` and does not prove + that `resetDb` uses the catalog, only that the schema matches. +- Logging only the name and code of every `PrismaClientKnownRequestError`: the test + shows no leak, and the failing constraint, useful for diagnosis, would be lost. + +**Consequences.** The `Secure` cookie and IP tests can use `loginAs` without re-injecting +headers. The temporary table `zz_reset_probe` is dropped in a `finally` and, in case it +is left over from an interrupted run, also before creating it. + +## 2026-09-24 — Login and setup: keep what was typed and announce errors + +**Context.** React 19 resets uncontrolled forms after an action: after "Incorrect +credentials" the email was cleared, and after a setup validation error, all four fields. +Also, the error message was a `<p>` without `role="alert"` that the screen reader did +not announce. + +**Decision.** +- `AuthState.values` and `SetupState.values` return what was submitted on every error + (email; instance name, name and email), never the password. The client renders it with + `defaultValue`. They are not returned on success. +- The error `<p>` has `role="alert"` and is removed while `pending`, so that a second + identical error is inserted again and announced again. + +**Alternatives considered.** +- Controlled fields with `useState`: more code in two forms for the same effect. +- A `key` with an attempt counter: requires adding state just for that. + +**Consequences.** Verification with VoiceOver is manual: there is no component testing +infrastructure. The integration tests check that `values` arrives without the password. --- -## 2026-09-24 — Setup: advisory lock, corte temprano y causa en el log - -**Contexto.** `completeSetup` comprobaba `setupCompletedAt` dentro de la transacción y -el comentario lo daba por atómico, pero con READ COMMITTED dos envíos simultáneos leían -«sin configurar» a la vez y creaban dos administradores (reproducido con un test: sin -bloqueo salen dos ADMIN). Además, con el setup ya hecho la acción seguía validando y -gastando bcrypt antes de descubrirlo, y cualquier fallo acababa en «Inténtalo de nuevo» -sin dejar la causa. - -**Decisión.** -- Lógica en `app/lib/services/setup.ts` (`completeInstanceSetup`), cuya primera - sentencia es `pg_advisory_xact_lock(hashtext('orgtable:setup'))`. -- La acción corta con `redirect("/login")` si `isSetupComplete()` antes de validar y de - hacer el hash; la comprobación dentro de la transacción se mantiene. -- En error, `console.error` con nombre, código y campos de Prisma y mensaje, sin - FormData ni email. El texto visible no cambia. - -**Alternativas descartadas.** -- `SELECT … FOR UPDATE` o `updateMany` condicional: la fila de `InstanceConfig` no existe - antes del primer setup y no hay nada que bloquear. -- Crear primero `InstanceConfig` y tratar el P2002 de su PK como «ya configurado»: - obliga a distinguirlo del P2002 del email duplicado. -- Nivel SERIALIZABLE: exigiría reintentos ante fallos de serialización. -- Mensaje específico para email duplicado: era opcional y añadía un texto visible nuevo. - -**Consecuencias.** El segundo envío espera al primero y recibe `ALREADY_SETUP` -(redirección a `/login`). Test de 20 iteraciones con dos llamadas simultáneas en +## 2026-09-24 — Setup: advisory lock, early exit and cause in the log + +**Context.** `completeSetup` checked `setupCompletedAt` inside the transaction and the +comment claimed it was atomic, but under READ COMMITTED two simultaneous submissions both +read "not set up" at the same time and created two admins (reproduced with a test: +without a lock, two ADMINs come out). Also, with setup already done the action still +validated and spent bcrypt time before finding out, and any failure ended in "Try again" +without recording the cause. + +**Decision.** +- Logic in `app/lib/services/setup.ts` (`completeInstanceSetup`), whose first statement + is `pg_advisory_xact_lock(hashtext('orgtable:setup'))`. +- The action exits with `redirect("/login")` if `isSetupComplete()` before validating and + hashing; the check inside the transaction stays. +- On error, `console.error` with the Prisma name, code and fields plus the message, + without FormData or email. The visible text does not change. + +**Alternatives considered.** +- `SELECT … FOR UPDATE` or a conditional `updateMany`: the `InstanceConfig` row does not + exist before the first setup and there is nothing to lock. +- Creating `InstanceConfig` first and treating the P2002 on its PK as "already set up": + requires telling it apart from the duplicate-email P2002. +- SERIALIZABLE level: would require retries on serialization failures. +- A specific message for a duplicate email: it was optional and added a new visible + text. + +**Consequences.** The second submission waits for the first and gets `ALREADY_SETUP` +(redirect to `/login`). A 20-iteration test with two simultaneous calls in `test/integration/setup.int.test.ts`. --- -## 2026-09-24 — La auditoría registra sus propios fallos +## 2026-09-24 — The audit log records its own failures -**Contexto.** `recordAudit` tenía un `catch {}` vacío: si fallaba la escritura en -`AuditLog`, los eventos de seguridad (`auth.login_failed`, `user.deleted`…) se perdían -sin rastro. +**Context.** `recordAudit` had an empty `catch {}`: if the write to `AuditLog` failed, +security events (`auth.login_failed`, `user.deleted`…) were lost without a trace. -**Decisión.** Se sigue sin relanzar (best-effort), pero se escribe `console.error` con -`action`, `targetType`, `targetId` y un resumen del error (tipo, código de Prisma y -mensaje). Nunca `detail`, `ip` ni `userId`. Para `PrismaClientValidationError` solo se -registra el tipo, porque su mensaje reproduce los argumentos de la consulta. +**Decision.** It still does not rethrow (best-effort), but it writes `console.error` with +`action`, `targetType`, `targetId` and a summary of the error (type, Prisma code and +message). Never `detail`, `ip` or `userId`. For `PrismaClientValidationError` only the +type is logged, because its message reproduces the query arguments. -**Alternativas descartadas.** -- Relanzar el error: haría fallar el login o el borrado por un problema de la tabla de - auditoría. -- Registrar el error completo: el mensaje de validación de Prisma incluye `detail` e - `ip`. +**Alternatives considered.** +- Rethrowing the error: login or deletion would fail because of a problem in the audit + table. +- Logging the full error: the Prisma validation message includes `detail` and `ip`. -**Consecuencias.** Un fallo de auditoría aparece en los logs del contenedor -(`docker compose logs app`) con la acción afectada. +**Consequences.** An audit failure shows up in the container logs +(`docker compose logs app`) with the affected action. --- -## 2026-09-24 — Login: IP de confianza, límite por cuenta y hash ficticio válido - -**Contexto.** Cuatro fallos del login: (1) el límite por IP tomaba el primer valor de -`X-Forwarded-For`, que escribe el cliente, y cambiándolo en cada intento la fuerza bruta -no tenía límite; tampoco había límite por cuenta. (2) El hash ficticio para emails -inexistentes medía 59 caracteres, `bcrypt.compare` devolvía `false` al instante y el -tiempo de respuesta revelaba qué emails tienen cuenta. (3) Cada fallo guardaba en -`AuditLog.detail` el email tecleado, exista o no la cuenta. (4) Cada intento rechazado -por el límite escribía una fila de auditoría. - -**Decisión.** -- `TRUSTED_PROXY_HOPS` (entero >= 0, por defecto 0, validado): con 0 se ignoran - `X-Forwarded-For` y `X-Real-IP`; con N se toma el N-ésimo valor desde la derecha - (`app/lib/client-ip.ts`). Sin IP no se aplica límite por IP ni un contador común. -- Límite por cuenta: 20 intentos fallidos cada 15 minutos por email normalizado, también - para emails inexistentes; se reinicia tras un login correcto. Se cuenta antes de - verificar y se borra con el éxito, para no tener carreras entre peticiones simultáneas. -- `DUMMY_PASSWORD_HASH`: hash bcrypt real de 60 caracteres y coste 12, como literal, y - `verifyPasswordOrDummy` en `app/lib/auth.ts`. -- `auth.login_failed` guarda solo el `userId` si la cuenta existe; nunca el email. -- `RateLimitResult.firstBlock`: se audita solo el primer rechazo de cada ventana, con - `detail` «límite por IP» o «límite por cuenta». -- ADR 0009 actualizado. - -**Alternativas descartadas.** -- Seguir con el primer valor de `X-Forwarded-For` o con `X-Real-IP`: los controla el - cliente cuando la app está expuesta sin proxy. -- Contador global `login:unknown` cuando no hay IP: un atacante bloquearía el login de - todo el mundo. -- Guardar un HMAC del email inexistente para correlacionar ataques: sigue siendo un dato - seudónimo de un tercero y no hacía falta. -- `bcrypt.hashSync` al importar el módulo: añade ~200 ms a cada arranque en frío. - -**Consecuencias.** Con el valor por defecto `AuditLog.ip` queda vacío y solo rige el -límite por cuenta. Quien conozca un email puede bloquear esa cuenta hasta 15 minutos -(riesgo aceptado, documentado en el ADR 0009). Las filas antiguas de `auth.login_failed` -con email siguen en producción: borrarlas es una operación masiva que decide el -propietario. `.env.example`, README y `docs/operations.md` los actualiza el lote de -despliegue. +## 2026-09-24 — Login: trusted IP, per-account limit and a valid dummy hash + +**Context.** Four login flaws: (1) the per-IP limit took the first value of +`X-Forwarded-For`, which the client writes, so by changing it on every attempt brute +force had no limit; there was no per-account limit either. (2) The dummy hash for +non-existent emails was 59 characters long, `bcrypt.compare` returned `false` instantly +and the response time revealed which emails have an account. (3) Every failure stored +the typed email in `AuditLog.detail`, whether or not the account existed. (4) Every +attempt rejected by the limit wrote an audit row. + +**Decision.** +- `TRUSTED_PROXY_HOPS` (integer >= 0, default 0, validated): with 0, `X-Forwarded-For` + and `X-Real-IP` are ignored; with N, the Nth value from the right is taken + (`app/lib/client-ip.ts`). Without an IP, neither a per-IP limit nor a shared counter + applies. +- Per-account limit: 20 failed attempts every 15 minutes per normalized email, also for + non-existent emails; it resets after a successful login. The attempt is counted before + verifying and cleared on success, to avoid races between simultaneous requests. +- `DUMMY_PASSWORD_HASH`: a real 60-character bcrypt hash with cost 12, as a literal, and + `verifyPasswordOrDummy` in `app/lib/auth.ts`. +- `auth.login_failed` stores only the `userId` if the account exists; never the email. +- `RateLimitResult.firstBlock`: only the first rejection in each window is audited, with + `detail` "IP limit" or "account limit". +- ADR 0009 updated. + +**Alternatives considered.** +- Keeping the first value of `X-Forwarded-For`, or `X-Real-IP`: the client controls them + when the app is exposed without a proxy. +- A global `login:unknown` counter when there is no IP: an attacker could block login + for everyone. +- Storing an HMAC of the non-existent email to correlate attacks: it is still + pseudonymous data about a third party and was not needed. +- `bcrypt.hashSync` at module import: adds ~200 ms to every cold start. + +**Consequences.** With the default value `AuditLog.ip` stays empty and only the +per-account limit applies. Anyone who knows an email can lock that account for up to 15 +minutes (accepted risk, documented in ADR 0009). Old `auth.login_failed` rows with an +email remain in production: deleting them is a bulk operation for the owner to decide. +`.env.example`, the README and `docs/operations.md` are updated by the deployment batch. --- -## 2026-09-24 — Cookie de sesión Secure según el esquema real y SESSION_SECRET ruidoso - -**Contexto.** La cookie `session` llevaba `Secure` cuando `NODE_ENV=production`. El -primer arranque documentado es por `http://IP:3000`, y el navegador descarta una cookie -`Secure` recibida por HTTP en un origen que no es localhost: tras el asistente la -instancia no dejaba entrar. Por otro lado, `decrypt` llamaba a `getKey()` dentro del -`try`, así que un `SESSION_SECRET` ausente se convertía en «sin sesión» y redirigía a -`/login` sin dejar rastro. - -**Decisión.** -- `secure` = la petición llegó por HTTPS: primer valor de `X-Forwarded-Proto` igual a - `https`. Next rellena esa cabecera con el esquema real del socket cuando no la trae - un proxy (`??=` en `base-server`), y un proxy TLS la fija a `https`. -- `decrypt`: sin token devuelve `null` antes de pedir la clave; con token, `getKey()` va - fuera del `try` y solo `jwtVerify` queda dentro. -- `createSession` valida la clave antes de crear la fila `Session`. - -**Alternativas descartadas.** -- Variable `SESSION_COOKIE_SECURE` para desactivarlo en LAN: otra opción de - configuración que rebaja la seguridad si se deja puesta tras poner TLS. -- Seguir con `NODE_ENV`: deja inservible la instalación por HTTP. - -**Consecuencias.** Por HTTP la cookie viaja sin `Secure` (lo que ya pasaba en la -práctica al no poder entrar); detrás de un proxy TLS que envíe `X-Forwarded-Proto` sí -lo lleva. Falsear esa cabecera solo afecta al propio cliente. La documentación del -despliegue (TLS delante) la actualiza el lote de despliegue. +## 2026-09-24 — Secure session cookie based on the real scheme, and a loud SESSION_SECRET + +**Context.** The `session` cookie had `Secure` when `NODE_ENV=production`. The documented +first start is over `http://IP:3000`, and the browser discards a `Secure` cookie +received over HTTP on a non-localhost origin: after the setup wizard the instance would +not let anyone in. Separately, `decrypt` called `getKey()` inside the `try`, so a missing +`SESSION_SECRET` turned into "no session" and redirected to `/login` without leaving a +trace. + +**Decision.** +- `secure` = the request arrived over HTTPS: the first value of `X-Forwarded-Proto` + equals `https`. Next fills that header with the real socket scheme when no proxy sends + it (`??=` in `base-server`), and a TLS proxy sets it to `https`. +- `decrypt`: with no token it returns `null` before asking for the key; with a token, + `getKey()` goes outside the `try` and only `jwtVerify` stays inside. +- `createSession` validates the key before creating the `Session` row. + +**Alternatives considered.** +- A `SESSION_COOKIE_SECURE` variable to disable it on a LAN: another configuration + option that weakens security if left on after adding TLS. +- Keeping `NODE_ENV`: leaves an HTTP install unusable. + +**Consequences.** Over HTTP the cookie travels without `Secure` (which in practice +already happened, since nobody could log in); behind a TLS proxy that sends +`X-Forwarded-Proto` it does carry it. Spoofing that header only affects the client +itself. The deployment documentation (TLS in front) is updated by the deployment batch. --- -## 2026-09-24 — Tests de integración con sesión: stub mutable de cookies - -**Contexto.** El stub de `next/headers` devolvía siempre `undefined` en `cookies().get`, -así que `verifySession`, `requireUser` y `requireBoardAccess` nunca autenticaban en los -tests: no había forma de probar las Server Actions ni la revocación de sesiones (ADR -0008). Además `resetDb` usaba una lista fija de tablas a la que le faltaban -`CardAssignee` y `Comment`. - -**Decisión.** -- `test/stubs/next-headers.ts` guarda cookies y cabeceras en un almacén mutable de - módulo, con `__setHeaders`, `__clearCookies` y `__getCookieOptions` para los tests. -- `loginAs(userId)` en `test/integration/helpers.ts` llama a la `createSession` real; - `resetDb` limpia además el stub y los contadores del rate limit. -- `SESSION_SECRET` fijo de test en `vitest.integration.config.ts`. -- `resetDb` lee las tablas de `pg_tables` (esquema actual, sin `_prisma_migrations`) y - valida cada nombre antes de interpolarlo en el `TRUNCATE`. -- Se confirma con un test que `cache()` de React no memoiza fuera de RSC (cambiar de - usuario con `loginAs` se ve al instante). - -**Alternativas descartadas.** -- `vi.mock("next/headers")` en cada archivo: repetiría el mismo almacén en todos los - tests. -- Mantener la lista de tablas a mano añadiendo las dos que faltaban: se volvería a - desfasar con la siguiente tabla. -- Estimar filas con `reltuples` para decidir qué vaciar: no es exacto. - -**Consecuencias.** Los tests pueden ejecutar Server Actions con sesión real (fila -`Session` + JWT firmado). Quitar una comprobación de `verifySession` hace fallar -`session.int.test.ts` (comprobado quitando `revokedAt` y el control de `userId`). -## 2026-09-24 — Adjuntos: bloquear las filas padre antes de leer qué ficheros borrar - -**Contexto.** El comentario de `services/attachments.ts` decía que «lo leído es -exactamente lo que se borra», pero con READ COMMITTED no era así: si `uploadAttachment` -insertaba su fila entre el `findMany` y el `delete`, el borrado esperaba a esa -inserción y la eliminaba en cascada; su fichero no estaba en la lista leída y quedaba -huérfano, con la subida respondida como correcta. - -**Decisión.** Dentro de la transacción, `SELECT … FOR UPDATE` de arriba abajo antes del -`findMany`: la tarjeta (`deleteCardWithAttachments`); la lista y sus tarjetas -(`deleteListWithAttachments`); el tablero, sus listas y sus tarjetas -(`deleteBoardWithAttachments`). Insertar un `Attachment` toma `FOR KEY SHARE` sobre su -`Card`, que choca con `FOR UPDATE`; bloquear también los padres frena las altas de -tarjetas y listas mientras se leen las hijas. Test de integración que reproduce la -carrera con una transacción sin confirmar y falla sin los bloqueos. - -**Alternativas descartadas.** -- Nivel SERIALIZABLE: obliga a reintentar ante fallos de serialización en cada llamada. -- Solo corregir el comentario: el fichero seguiría quedando huérfano. -- Barrido periódico de huérfanos: infraestructura nueva para un caso que el bloqueo - evita. - -**Consecuencias.** Un borrado grande de tablero bloquea sus listas y tarjetas durante la -transacción; las ediciones concurrentes de esas filas esperan unos milisegundos. Una -subida que llegue durante el borrado falla y `uploadAttachment` borra su fichero. +## 2026-09-24 — Integration tests with a session: mutable cookie stub + +**Context.** The `next/headers` stub always returned `undefined` from `cookies().get`, +so `verifySession`, `requireUser` and `requireBoardAccess` never authenticated in tests: +there was no way to test Server Actions or session revocation (ADR 0008). Also, +`resetDb` used a fixed table list that was missing `CardAssignee` and `Comment`. + +**Decision.** +- `test/stubs/next-headers.ts` keeps cookies and headers in a mutable module-level + store, with `__setHeaders`, `__clearCookies` and `__getCookieOptions` for tests. +- `loginAs(userId)` in `test/integration/helpers.ts` calls the real `createSession`; + `resetDb` also clears the stub and the rate-limit counters. +- A fixed test `SESSION_SECRET` in `vitest.integration.config.ts`. +- `resetDb` reads the tables from `pg_tables` (current schema, without + `_prisma_migrations`) and validates each name before interpolating it into the + `TRUNCATE`. +- A test confirms that React's `cache()` does not memoise outside RSC (switching user + with `loginAs` takes effect immediately). + +**Alternatives considered.** +- `vi.mock("next/headers")` in each file: would repeat the same store in every test. +- Keeping the table list by hand and adding the two missing ones: it would drift again + with the next table. +- Estimating rows with `reltuples` to decide what to empty: it is not exact. + +**Consequences.** Tests can run Server Actions with a real session (`Session` row + +signed JWT). Removing a check from `verifySession` makes `session.int.test.ts` fail +(verified by removing `revokedAt` and the `userId` check). +## 2026-09-24 — Attachments: lock the parent rows before reading which files to delete + +**Context.** The comment in `services/attachments.ts` said that "what is read is exactly +what gets deleted", but under READ COMMITTED that was not true: if `uploadAttachment` +inserted its row between the `findMany` and the `delete`, the deletion waited for that +insert and removed it by cascade; its file was not in the list that had been read and +was left orphaned, while the upload was reported as successful. + +**Decision.** Inside the transaction, `SELECT … FOR UPDATE` top-down before the +`findMany`: the card (`deleteCardWithAttachments`); the list and its cards +(`deleteListWithAttachments`); the board, its lists and its cards +(`deleteBoardWithAttachments`). Inserting an `Attachment` takes `FOR KEY SHARE` on its +`Card`, which conflicts with `FOR UPDATE`; locking the parents as well stops new cards +and lists from being created while the children are read. An integration test reproduces +the race with an uncommitted transaction and fails without the locks. + +**Alternatives considered.** +- SERIALIZABLE level: requires retrying on serialization failures in every call. +- Only fixing the comment: the file would still be left orphaned. +- A periodic orphan sweep: new infrastructure for a case the lock prevents. + +**Consequences.** A large board deletion locks its lists and cards for the duration of +the transaction; concurrent edits to those rows wait a few milliseconds. An upload that +arrives during the deletion fails and `uploadAttachment` deletes its file. --- -## 2026-09-24 — Orden: tope de 1024 caracteres para las claves de posición +## 2026-09-24 — Ordering: 1024-character cap on position keys -**Contexto.** `isValidPositionKey` limitaba las claves a 64 caracteres. Medido con un -script sobre `fractional-indexing`: soltar tarjetas siempre en el mismo hueco (p. ej. -siempre en la segunda posición) supera los 64 caracteres a las 373 inserciones. Desde -ahí `moveCard`/`reorderList` devolvían `{ ok: false }` para ese hueco y, como -`BoardView` ignora el resultado, el arrastre parecía funcionar y se deshacía al -refrescar. +**Context.** `isValidPositionKey` limited keys to 64 characters. Measured with a script +on `fractional-indexing`: always dropping cards into the same gap (e.g. always in second +position) exceeds 64 characters after 373 insertions. From then on +`moveCard`/`reorderList` returned `{ ok: false }` for that gap and, since `BoardView` +ignores the result, the drag looked like it worked and was undone on refresh. -**Decisión.** `POSITION_KEY_MAX = 1024` (unas 6000 inserciones seguidas en el mismo -hueco). Sigue impidiendo cadenas de megabytes y queda muy por debajo del máximo de una -entrada del índice btree `(listId, position)` de `Card` (≈2700 bytes). Documentado en -`docs/drag-and-drop.md` junto con su consecuencia. +**Decision.** `POSITION_KEY_MAX = 1024` (about 6000 consecutive insertions into the same +gap). It still prevents megabyte-long strings and stays well below the maximum size of an +entry in the `Card` btree index `(listId, position)` (≈2700 bytes). Documented in +`docs/drag-and-drop.md` together with its consequence. -**Alternativas descartadas.** -- Mantener 64 y solo documentarlo: el límite se alcanza con un uso normal. -- Quitar el tope: una clave de megabytes llegaría a la BD y rompería el índice. -- Rebalancear las posiciones de la lista al pasar de cierta longitud: más código y - escrituras en varias filas por un caso que 1024 ya cubre de sobra. +**Alternatives considered.** +- Keeping 64 and only documenting it: the limit is reached with normal use. +- Removing the cap: a megabyte-long key would reach the DB and break the index. +- Rebalancing the list's positions past a certain length: more code and writes across + several rows for a case that 1024 already covers with plenty of margin. -**Consecuencias.** El fallo silencioso de `BoardView` ante `{ ok: false }` sigue ahí -(fuera de este lote); con 1024 solo aparece en un uso extremo. +**Consequences.** The silent failure of `BoardView` on `{ ok: false }` is still there +(outside this batch); with 1024 it only shows up under extreme use. --- -## 2026-09-24 — Contraseñas de 72 bytes como máximo +## 2026-09-24 — Passwords of at most 72 bytes -**Contexto.** `setupSchema` y `passwordSchema` admitían 200 caracteres, pero bcryptjs -solo usa los primeros 72 bytes UTF-8: dos contraseñas con el mismo prefijo de 72 bytes -eran equivalentes sin que nadie lo supiera. +**Context.** `setupSchema` and `passwordSchema` allowed 200 characters, but bcryptjs only +uses the first 72 UTF-8 bytes: two passwords with the same 72-byte prefix were +equivalent without anyone knowing. -**Decisión.** `passwordSchema` rechaza más de 72 bytes UTF-8 (mensaje «Máximo 72 bytes -(unos 72 caracteres sin tildes ni emojis)») y `setupSchema.password` lo reutiliza, así -que el criterio vive en un solo sitio (setup, alta y reset por el administrador). -`loginSchema` no cambia. +**Decision.** `passwordSchema` rejects more than 72 UTF-8 bytes (message "Maximum 72 +bytes (about 72 characters without accents or emoji)") and `setupSchema.password` reuses +it, so the rule lives in a single place (setup, account creation and admin reset). +`loginSchema` does not change. -**Alternativas descartadas.** -- Prehash (SHA-256 antes de bcrypt): invalidaría los hashes existentes o exigiría - migrarlos. -- Aplicar el límite también al login: bloquearía a quien ya tenga guardada una - contraseña más larga (que sigue funcionando con sus primeros 72 bytes). +**Alternatives considered.** +- Prehashing (SHA-256 before bcrypt): would invalidate existing hashes or require + migrating them. +- Applying the limit to login as well: would lock out anyone who already has a longer + password stored (which still works with its first 72 bytes). -**Consecuencias.** Una contraseña nueva de más de 72 bytes se rechaza con un error -visible. El `maxLength` de los formularios no se toca aquí. +**Consequences.** A new password longer than 72 bytes is rejected with a visible error. +The forms' `maxLength` is not touched here. --- -## 2026-09-24 — Tiempo real: borrar un tablero avisa por el bus en vivo +## 2026-09-24 — Realtime: deleting a board notifies over the live bus -**Contexto.** `deleteBoard` (acción y `DELETE` de la API) no publicaba nada: las -pestañas que tenían el tablero abierto seguían mostrándolo hasta recargar. +**Context.** `deleteBoard` (the action and the API `DELETE`) published nothing: tabs that +had the board open kept showing it until reloaded. -**Decisión.** Tras borrar, se llama a `revalidateBoard(boardId)` como en el resto de -mutaciones; la Server Action solo lo hace si de verdad se borró (dueño). +**Decision.** After deleting, `revalidateBoard(boardId)` is called as in the other +mutations; the Server Action only does so if the board was actually deleted (owner). -**Alternativas descartadas.** Un evento específico `board.deleted` en el bus: el cliente -ya refresca con cualquier aviso del tablero y la página, sin membresía, redirige a -`/boards`. +**Alternatives considered.** A specific `board.deleted` event on the bus: the client +already refreshes on any board notification, and the page, with no membership, redirects +to `/boards`. -**Consecuencias.** Ninguna para instalaciones existentes. +**Consequences.** None for existing installations. --- -## 2026-09-24 — Adjuntos: borrar los ficheros al borrar tarjetas, listas y tableros +## 2026-09-24 — Attachments: delete the files when cards, lists and boards are deleted -**Contexto.** El `onDelete: Cascade` borraba las filas `Attachment` al borrar una -tarjeta, lista o tablero, pero los binarios se quedaban en el volumen para siempre. Una -subida cuya fila fallaba al crearse también dejaba el fichero. +**Context.** `onDelete: Cascade` deleted the `Attachment` rows when a card, list or board +was deleted, but the binaries stayed on the volume forever. An upload whose row failed to be +created also left the file behind. -**Decisión.** `services/attachments.ts` lee los nombres en disco y borra en BD en una -transacción y, tras el commit, borra los ficheros con `Promise.allSettled`. Lo usan -`deleteCard`, `deleteList`, `deleteBoard` y los `DELETE` de la API. `uploadAttachment` -borra el fichero si falla el alta de la fila y relanza el error. +**Decision.** `services/attachments.ts` reads the on-disk names and deletes from the +database in one transaction and, after the commit, deletes the files with +`Promise.allSettled`. It is used by `deleteCard`, `deleteList`, `deleteBoard` and the API +`DELETE` endpoints. `uploadAttachment` deletes the file if creating the row fails and +rethrows the error. -**Alternativas descartadas.** -- Borrar ficheros antes del commit: si la transacción fallara, la fila quedaría sin su - binario. +**Alternatives considered.** +- Deleting the files before the commit: if the transaction failed, the row would be left + without its binary. -**Consecuencias.** Los ficheros huérfanos que ya existan en una instalación no se -limpian (decisión del propietario; un barrido necesitaría script con dry-run). +**Consequences.** Orphaned files that already exist in an installation are not cleaned up +(owner's decision; a sweep would need a script with a dry run). --- -## 2026-09-24 — Tarjetas: vencimiento y descripción validados en acciones y API +## 2026-09-24 — Cards: due date and description validated in actions and API -**Contexto.** Una fecha no parseable se guardaba como `null` y borraba el vencimiento -sin avisar (en la API, con 200/201), y la descripción no tenía límite en la UI. +**Context.** An unparseable date was saved as `null` and silently cleared the due date (in +the API, with 200/201), and the description had no limit in the UI. -**Decisión.** -- En la UI, `dueDateSchema` (`""`, `null` o `YYYY-MM-DD` real con `z.iso.date()`); en - la API, `apiDueDateSchema` (`YYYY-MM-DD` o ISO 8601 con zona). En la API **la cadena - vacía da 422**, no es sinónimo de `null`: `null` ya existe para quitar la fecha y un - `""` suele ser un error del cliente. Si algo no valida en `updateCard`, no se guarda - nada (tampoco la descripción); `setCardDueDate` devuelve `ok:false`. -- `CARD_DESCRIPTION_MAX = 5000` compartido por UI, API e import. +**Decision.** +- In the UI, `dueDateSchema` (`""`, `null` or a real `YYYY-MM-DD` with `z.iso.date()`); in + the API, `apiDueDateSchema` (`YYYY-MM-DD` or ISO 8601 with a time zone). In the API **an + empty string returns 422**, it is not a synonym for `null`: `null` already exists to + clear the date and a `""` is usually a client error. If anything fails validation in + `updateCard`, nothing is saved (not even the description); `setCardDueDate` returns + `ok:false`. +- `CARD_DESCRIPTION_MAX = 5000` shared by the UI, the API and import. -**Alternativas descartadas.** -- `Date.parse` o solo `z.iso.datetime()` para la API: el primero acepta formatos - ambiguos y el segundo rompería el `YYYY-MM-DD` documentado. +**Alternatives considered.** +- `Date.parse` or just `z.iso.datetime()` for the API: the former accepts ambiguous + formats and the latter would break the documented `YYYY-MM-DD`. -**Consecuencias.** Un cliente de la API que enviara `dueDate: ""` para borrar la fecha -ahora recibe 422 y debe enviar `null`. +**Consequences.** An API client that sent `dueDate: ""` to clear the date now gets a 422 +and must send `null`. --- -## 2026-09-24 — Orden: claves de posición validadas y altas al final serializadas +## 2026-09-24 — Ordering: validated position keys and serialized appends at the end -**Contexto.** `moveCard`/`reorderList` guardaban cualquier cadena como posición y una -clave inválida dejaba la lista o el tablero sin poder crear nada (`positionAtEnd` -lanzaba). Además, leer la última posición y crear eran dos pasos, así que dos altas -simultáneas guardaban la misma clave y el arrastre se rompía en esa lista. +**Context.** `moveCard`/`reorderList` saved any string as a position, and an invalid key +left the list or board unable to create anything (`positionAtEnd` threw). Also, reading the +last position and creating were two separate steps, so two simultaneous creations saved the +same key and drag and drop broke in that list. -**Decisión.** -- `isValidPositionKey` (`ordering.ts`): regex base62 con tope de longitud + la propia - validación de `fractional-indexing` (la librería sola acepta `'a0 '`). Se valida antes - de cualquier consulta. -- Altas al final (`createCardAtEnd`, `createListAtEnd`, `createChecklistAtEnd`, - `createChecklistItemAtEnd`) en una transacción interactiva con `SELECT … FOR UPDATE` - sobre la fila padre (lista, tablero, tarjeta, checklist). Funciona con el adapter - `@prisma/adapter-pg`; probado con 10 POST en paralelo. +**Decision.** +- `isValidPositionKey` (`ordering.ts`): a base62 regex with a length cap + the validation + of `fractional-indexing` itself (the library alone accepts `'a0 '`). It is validated + before any query. +- Appends at the end (`createCardAtEnd`, `createListAtEnd`, `createChecklistAtEnd`, + `createChecklistItemAtEnd`) run in an interactive transaction with `SELECT … FOR UPDATE` + on the parent row (list, board, card, checklist). It works with the + `@prisma/adapter-pg` adapter; tested with 10 parallel POSTs. -**Alternativas descartadas.** -- Índice único `(listId, position)`: los movimientos concurrentes también colisionan y - fallarían en vez de ordenarse. -- Cambiar el contrato de arrastre a `beforeId/afterId`: cambio de API que decide el - propietario. +**Alternatives considered.** +- A unique index on `(listId, position)`: concurrent moves also collide and would fail + instead of being ordered. +- Changing the drag contract to `beforeId/afterId`: an API change that is the owner's call. -**Consecuencias.** Las posiciones inválidas o repetidas que ya existan no se limpian. +**Consequences.** Invalid or duplicate positions that already exist are not cleaned up. --- -## 2026-09-24 — Etiquetas: IDOR de toggleCardLabel y colores validados contra la paleta +## 2026-09-24 — Labels: toggleCardLabel IDOR and colors validated against the palette -**Contexto.** `toggleCardLabel` autorizaba por el tablero de la tarjeta, pero aceptaba -una etiqueta de otro tablero (IDOR): un editor de A podía vincular una etiqueta de B -conociendo su id. Además, el operador `in` sobre las paletas aceptaba claves del -prototipo como `'constructor'`, que acababan pintadas como color. +**Context.** `toggleCardLabel` authorized against the card's board, but accepted a label +from another board (IDOR): an editor of A could attach a label from B by knowing its id. +Also, the `in` operator on the palettes accepted prototype keys such as `'constructor'`, +which ended up rendered as a color. -**Decisión.** -- `labelBelongsToBoard` (`services/labels.ts`) tras autorizar; entrada validada con zod. -- Colores validados con `z.enum` de la paleta y `.catch` (etiqueta → `"blue"`, fondo → - `null`) para seguir siendo tolerantes con el import y los formularios; en lectura, - `Object.hasOwn` en vez de `in`. +**Decision.** +- `labelBelongsToBoard` (`services/labels.ts`) after authorizing; input validated with zod. +- Colors validated with a `z.enum` of the palette and `.catch` (label → `"blue"`, + background → `null`) to stay tolerant of import and forms; on read, `Object.hasOwn` + instead of `in`. -**Alternativas descartadas.** -- Rechazar el import con un color desconocido: un export de otra versión con un color - que ya no existe dejaría de importarse por un detalle cosmético. +**Alternatives considered.** +- Rejecting the import on an unknown color: an export from another version with a color + that no longer exists would fail to import over a cosmetic detail. -**Consecuencias.** Los `CardLabel` cruzados que ya existan en una instalación no se -limpian (decisión del propietario; un barrido necesitaría script con dry-run). +**Consequences.** Cross-board `CardLabel` rows that already exist in an installation are +not cleaned up (owner's decision; a sweep would need a script with a dry run). --- -## 2026-09-24 — Adjuntos: errores de disco visibles y 404 uniforme en la descarga - -**Contexto.** `deleteAttachmentFile` tenía un `catch` vacío que se tragaba también -`EACCES`/`EROFS` (la fila se borraba y el binario quedaba en disco sin rastro). La -descarga respondía 404 a un id inexistente y 403 a uno de un tablero ajeno (revelaba qué -ids existen) y convertía cualquier error de lectura en 404. Además, -`path.join(process.cwd(), "uploads")` hacía que Turbopack trazara el proyecto entero -hacia `.next/standalone`. - -**Decisión.** -- `deleteAttachmentFile` ignora solo `ENOENT`; el resto se registra con `console.error` - (nombre en disco y código, sin el nombre original) y **no se relanza**: la fila ya está - borrada y un throw rompería la acción antes de revalidar. -- La descarga busca el adjunto con una sola consulta acotada por membresía; sin - resultado, `404 Not found` con el mismo cuerpo en ambos casos. `ENOENT` al leer → 404 + - `console.warn`; cualquier otro código → 500 + `console.error`. -- `/* turbopackIgnore: true */` en el `path.join` del directorio de subida. -- Los tests de integración que invocan Server Actions crean una sesión real (fila - `Session` + JWT firmado, `test/integration/action-session.ts`) y solo sustituyen la - lectura de la cookie con un `vi.mock("next/headers")` local, para ejercer de verdad el - DAL y las guardas de rol sin tocar el stub compartido. - -**Alternativas descartadas.** -- Relanzar el error de `unlink`: dejaría la acción a medias tras borrar la fila. -- Mantener el 403 en la descarga: la API v1 ya evita distinguirlos a propósito. -- Mockear el DAL en los tests: no probaría la autorización real. - -**Consecuencias.** Un volumen sin permisos de escritura se ve en los logs. La reducción -del trazado NFT no se ha podido comprobar aquí (el build lo ejecuta el integrador): tras -`pnpm build`, `api/attachments/[id]/route.js.nft.json` debería bajar a unas 200 entradas -y `.next/standalone` no debería contener `docs/` ni `test/`. -## 2026-09-24 — Log del cron de copias fuera de `backups/` y URL del instalador según `APP_PORT` - -**Contexto.** La revisión de la primera pasada de este lote encontró que el ejemplo de -cron de `docs/operations.md` redirigía el log a `backups/backup.log`. En una instalación -nueva `backups/` no existe (lo crea `backup.sh`), cron abre la redirección antes de -lanzar el script y la copia no se ejecutaba nunca, sin dejar rastro en ningún log. -Además, `install.sh` descartaba la IP de `APP_PORT` y mandaba a `http://<IP>:3000` -aunque la app escuchara solo en `127.0.0.1`, y con `set -o pipefail` un `.env` sin -`APP_PORT` hacía fallar el instalador en el último paso. La documentación decía que la -app «no arranca» sin `SESSION_SECRET`, pero el secreto se comprueba al crear la sesión. -Y el consejo para proteger el asistente reseteado (`APP_PORT=127.0.0.1:3000`) no -protege nada detrás de un proxy, porque `/setup` sigue abierto por el dominio. - -**Decisión.** -- El ejemplo de cron escribe en `/home/usuario/orgtable-backup.log`, fuera del repo. -- `install.sh`: con `APP_PORT` de loopback explica cómo llegar por el proxy o por un - túnel SSH; con otra IP concreta muestra esa IP; con el puerto solo o `0.0.0.0`, la - IP del servidor. Sin `APP_PORT` usa el 3000 en vez de abortar. Tests en - `test/ops-scripts.test.ts` con `docker`, `git` y `hostname` falsos. -- `.env.example` e `installation.md` describen lo que pasa de verdad sin - `SESSION_SECRET`: la app arranca, pero el login y el asistente fallan. -- Reseteo del asistente: cortar el acceso público a `/setup` (parar el proxy o - bloquear la ruta) y completarlo por túnel SSH, o completarlo justo después. - -**Alternativas descartadas.** -- Log dentro de `backups/` pidiendo un `mkdir` previo: es un paso más que se olvida y - el fallo vuelve a ser silencioso. -- Log en la raíz del repo: aparece como fichero sin seguimiento en `git status` y - entra en el contexto de build de Docker. -- Hacer que la app se niegue a arrancar sin `SESSION_SECRET`: cambia el - comportamiento y queda fuera de este lote, que es de documentación y operación. - -**Consecuencias.** Quien ya copió el cron anterior debe cambiar la ruta del log (o -crear `backups/` una vez con `mkdir -m 700 backups`). El mensaje final del instalador -depende de `APP_PORT`. - -## 2026-09-24 — La documentación se alinea con la licencia propietaria - -**Contexto.** `LICENSE` es propietaria (MARA-KASSA SL) desde el commit `27c1ba9`, pero -el ADR 0005, `docs/index.md`, `docs/roadmap.md`, `docs/design-system.md`, -`docs/contributing.md`, `PRODUCT.md`, `CLAUDE.md` y `package.json` seguían diciendo -AGPL u open source. - -**Decisión.** Detalle en [ADR 0016](./decisions/0016-licencia-propietaria.md): la -vigente es `LICENSE`, que no se toca. ADR 0005 marcado «sustituida por ADR 0016»; -documentación sin «open source»; `contributing.md` remite a la cláusula de cesión de -`LICENSE` sin copiarla; `package.json` con `"license": "UNLICENSED"`; el CI falla si -reaparece AGPL u «open source» fuera del ADR 0005, el 0016 y este registro. - -**Alternativas descartadas.** Restaurar la AGPL (decisión del propietario) y licencia -dual (no se planteó). - -**Consecuencias.** Los textos visibles de la app (`app/layout.tsx`, pantalla de login) -los cambia otro lote de esta revisión; hasta que se integre, el paso de licencia del CI -falla por ellos. La cesión de derechos sin firma es dudosa según la ley española: si -entran colaboradores externos, conviene revisarla con un abogado o usar un CLA. +## 2026-09-24 — Attachments: visible disk errors and a uniform 404 on download + +**Context.** `deleteAttachmentFile` had an empty `catch` that also swallowed +`EACCES`/`EROFS` (the row was deleted and the binary stayed on disk without a trace). The +download returned 404 for a nonexistent id and 403 for one from someone else's board +(revealing which ids exist), and turned any read error into a 404. Also, +`path.join(process.cwd(), "uploads")` made Turbopack trace the whole project into +`.next/standalone`. + +**Decision.** +- `deleteAttachmentFile` ignores only `ENOENT`; everything else is logged with + `console.error` (on-disk name and code, without the original name) and **is not + rethrown**: the row is already deleted and a throw would break the action before + revalidating. +- The download looks up the attachment with a single query scoped by membership; with no + result, `404 Not found` with the same body in both cases. `ENOENT` on read → 404 + + `console.warn`; any other code → 500 + `console.error`. +- `/* turbopackIgnore: true */` on the `path.join` of the upload directory. +- The integration tests that invoke Server Actions create a real session (a `Session` row + + signed JWT, `test/integration/action-session.ts`) and only replace the cookie read with + a local `vi.mock("next/headers")`, so they really exercise the DAL and the role guards + without touching the shared stub. + +**Alternatives considered.** +- Rethrowing the `unlink` error: it would leave the action half done after deleting the + row. +- Keeping the 403 on download: the v1 API already avoids telling them apart on purpose. +- Mocking the DAL in the tests: it would not test the real authorization. + +**Consequences.** A volume without write permissions shows up in the logs. The reduction of +the NFT trace could not be checked here (the build is run by the integrator): after +`pnpm build`, `api/attachments/[id]/route.js.nft.json` should drop to about 200 entries and +`.next/standalone` should not contain `docs/` or `test/`. +## 2026-09-24 — Backup cron log outside `backups/` and installer URL based on `APP_PORT` + +**Context.** The review of the first pass of this batch found that the cron example in +`docs/operations.md` redirected the log to `backups/backup.log`. In a fresh installation +`backups/` does not exist (`backup.sh` creates it), cron opens the redirection before +launching the script, and the backup never ran, without leaving a trace in any log. Also, +`install.sh` discarded the IP from `APP_PORT` and pointed to `http://<IP>:3000` even when +the app listened only on `127.0.0.1`, and with `set -o pipefail` an `.env` without +`APP_PORT` made the installer fail at the last step. The documentation said the app "does +not start" without `SESSION_SECRET`, but the secret is checked when the session is created. +And the advice for protecting the reset wizard (`APP_PORT=127.0.0.1:3000`) protects nothing +behind a proxy, because `/setup` is still open through the domain. + +**Decision.** +- The cron example writes to `/home/usuario/orgtable-backup.log`, outside the repo. +- `install.sh`: with a loopback `APP_PORT` it explains how to get in through the proxy or + an SSH tunnel; with another specific IP it shows that IP; with only the port or + `0.0.0.0`, the server's IP. Without `APP_PORT` it uses 3000 instead of aborting. Tests in + `test/ops-scripts.test.ts` with fake `docker`, `git` and `hostname`. +- `.env.example` and `installation.md` describe what really happens without + `SESSION_SECRET`: the app starts, but login and the wizard fail. +- Wizard reset: cut public access to `/setup` (stop the proxy or block the route) and + complete it through an SSH tunnel, or complete it right afterwards. + +**Alternatives considered.** +- Log inside `backups/` with a prior `mkdir`: it is one more step that gets forgotten and + the failure becomes silent again. +- Log in the repo root: it shows up as an untracked file in `git status` and goes into the + Docker build context. +- Making the app refuse to start without `SESSION_SECRET`: it changes behavior and is out + of scope for this batch, which is about documentation and operations. + +**Consequences.** Anyone who already copied the previous cron must change the log path (or +create `backups/` once with `mkdir -m 700 backups`). The installer's final message depends +on `APP_PORT`. + +## 2026-09-24 — The documentation is aligned with the proprietary license + +**Context.** `LICENSE` has been proprietary (MARA-KASSA SL) since commit `27c1ba9`, but +ADR 0005, `docs/index.md`, `docs/roadmap.md`, `docs/design-system.md`, +`docs/contributing.md`, `PRODUCT.md`, `CLAUDE.md` and `package.json` still said AGPL or +open source. + +**Decision.** Details in [ADR 0016](./decisions/0016-licencia-propietaria.md): the license +in force is `LICENSE`, which is not touched. ADR 0005 marked "superseded by ADR 0016"; +documentation without "open source"; `contributing.md` points to the assignment clause in +`LICENSE` without copying it; `package.json` with `"license": "UNLICENSED"`; CI fails if +AGPL or "open source" reappears outside ADR 0005, ADR 0016 and this log. + +**Alternatives considered.** Restoring the AGPL (owner's decision) and dual licensing (not +considered). + +**Consequences.** The app's visible texts (`app/layout.tsx`, the login screen) are changed +by another batch of this review; until it is merged, the CI license step fails because of +them. An unsigned assignment of rights is questionable under Spanish law: if external +contributors join, it should be reviewed with a lawyer or replaced by a CLA. --- -## 2026-09-24 — README con estructura fija y documentación de recuperación - -**Contexto.** El README no tenía requisitos, comandos, variables ni despliegue, y -seguía diciendo que la interfaz estaba «en construcción» con los hitos 0–13 hechos. -`SECURITY.md` estaba en inglés y remitía al reporte privado de GitHub, que no existe -en repositorios privados. El reseteo del asistente abría `/setup` a cualquiera sin -avisar y se presentaba como forma de «rehacer el administrador», lo que falla con un -email ya existente. `docs/setup-wizard.md` decía «tres cosas» y listaba dos, y -`docs/database.md` enseñaba un `createPrismaClient` sin la guarda de `DATABASE_URL`. - -**Decisión.** -- README en seis secciones (qué es, requisitos, instalación, comandos, variables, - despliegue) más documentación y licencia, remitiendo a `docs/` sin duplicar. La - instalación en local escribe un `.env` mínimo con `SESSION_SECRET` generado por - `openssl`, lista para copiar y pegar. -- `SECURITY.md` en español; el canal de reporte queda como `TODO` (email y plazo) que - desbloquea el propietario. «Última release» pasa a «seguir `main` con - `scripts/update.sh`». -- Reseteo del asistente: aviso de exposición, email nuevo y comando con - `$POSTGRES_USER`/`$POSTGRES_DB` del contenedor. Recuperación de un administrador: - otro ADMIN desde `/admin/users` o `UPDATE` del hash generado con `bcryptjs` coste 12 - dentro del contenedor. - -**Alternativas descartadas.** -- Script de recuperación de contraseña: es un caso raro y el procedimiento de dos - comandos basta; un script más es más superficie que mantener. -- Mantener el badge y la URL del repositorio corregidos: la URL oficial la decide el - propietario, así que no se cambian (ver consecuencias). - -**Consecuencias.** El procedimiento de recuperación y el reseteo se probaron contra un -stack Docker real (el hash generado valida con `bcrypt.compare`). Las URLs de -`install.sh`, README (`aimarmndzz`, badge `Aimarmndz`) y `docs/installation.md` -(`<owner>`) siguen sin apuntar al remoto real; si el repositorio es privado, el -instalador con `curl` anónimo no funciona. Pendiente de decisión del propietario. +## 2026-09-24 — README with a fixed structure and recovery documentation + +**Context.** The README had no requirements, commands, variables or deployment, and still +said the interface was "under construction" when every feature through API token scopes +was done. `SECURITY.md` was in English and pointed to GitHub private reporting, which +does not exist in private repositories. The wizard reset opened `/setup` to anyone +without warning and was presented as a way to "redo the administrator", which fails with +an email that already exists. `docs/setup-wizard.md` said "three things" and listed two, +and `docs/database.md` showed a `createPrismaClient` without the `DATABASE_URL` guard. + +**Decision.** +- README in six sections (what it is, requirements, installation, commands, variables, + deployment) plus documentation and license, pointing to `docs/` without duplicating. The + local installation writes a minimal `.env` with a `SESSION_SECRET` generated by + `openssl`, ready to copy and paste. +- `SECURITY.md` in Spanish; the reporting channel stays as a `TODO` (email and response + time) for the owner to unblock. "Latest release" becomes "follow `main` with + `scripts/update.sh`". +- Wizard reset: exposure warning, new email and a command using the container's + `$POSTGRES_USER`/`$POSTGRES_DB`. Recovering an administrator: another ADMIN from + `/admin/users`, or an `UPDATE` with a hash generated with `bcryptjs` cost 12 inside the + container. + +**Alternatives considered.** +- A password recovery script: it is a rare case and the two-command procedure is enough; + one more script is more surface to maintain. +- Keeping the badge and the repository URL corrected: the official URL is the owner's + call, so they are not changed (see consequences). + +**Consequences.** The recovery procedure and the reset were tested against a real Docker +stack (the generated hash validates with `bcrypt.compare`). The URLs in `install.sh`, the +README (`aimarmndzz`, badge `Aimarmndz`) and `docs/installation.md` (`<owner>`) still do not +point to the real remote; if the repository is private, the installer with anonymous `curl` +does not work. Pending the owner's decision. --- -## 2026-09-24 — Imagen Docker: código de solo lectura y contexto de build sin datos +## 2026-09-24 — Docker image: read-only code and a build context without data -**Contexto.** El `Dockerfile` terminaba con `chown -R nextjs:nodejs /app`: una capa -nueva que duplicaba `node_modules` (cientos de MB) y dejaba `server.js`, `.next` y -`node_modules` escribibles por el proceso de la app. `.dockerignore` no excluía -`backups/` ni `uploads/`, así que `COPY . .` metía volcados con datos personales en el -builder y en su caché. +**Context.** The `Dockerfile` ended with `chown -R nextjs:nodejs /app`: a new layer that +duplicated `node_modules` (hundreds of MB) and left `server.js`, `.next` and +`node_modules` writable by the app process. `.dockerignore` did not exclude `backups/` or +`uploads/`, so `COPY . .` put dumps with personal data into the builder and its cache. -**Decisión.** -- Sin `chown -R`: `/app` queda de root. El entrypoint entra con `COPY --chmod=755` y - solo `.next/cache` se cede a `nextjs` (además de `/data/uploads`, que ya lo estaba). -- `.dockerignore` excluye `backups`, `**/*.sql.gz`, `**/*.tar.gz`, `uploads`, `data`, - `**/*.pem`, `coverage`, `test`, `scripts` e `install.sh`. +**Decision.** +- No `chown -R`: `/app` stays owned by root. The entrypoint comes in with + `COPY --chmod=755` and only `.next/cache` is handed to `nextjs` (besides + `/data/uploads`, which already was). +- `.dockerignore` excludes `backups`, `**/*.sql.gz`, `**/*.tar.gz`, `uploads`, `data`, + `**/*.pem`, `coverage`, `test`, `scripts` and `install.sh`. -**Alternativas descartadas.** -- `COPY --chown` en cada `COPY`: deja igualmente el código escribible. -- Stage `prod-deps` para reducir `node_modules`: mejora aparte, ya anotada en +**Alternatives considered.** +- `COPY --chown` on each `COPY`: it still leaves the code writable. +- A `prod-deps` stage to shrink `node_modules`: a separate improvement, already noted in `docs/docker.md`. -**Consecuencias.** Verificado construyendo la imagen: `docker history` ya no tiene la -capa del `chown`; dentro del contenedor `touch server.js` y `node_modules/x` fallan con -`Permission denied` y `.next/cache` y `/data/uploads` son escribibles; el stack arranca -`healthy`, aplica migraciones y sin `EACCES`. El builder no contiene `backups`, -`uploads`, `test`, `scripts` ni `.env`. +**Consequences.** Verified by building the image: `docker history` no longer has the +`chown` layer; inside the container `touch server.js` and `node_modules/x` fail with +`Permission denied`, and `.next/cache` and `/data/uploads` are writable; the stack starts +`healthy`, applies migrations and has no `EACCES`. The builder does not contain `backups`, +`uploads`, `test`, `scripts` or `.env`. --- -## 2026-09-24 — Variables del contenedor, CRON_SECRET, proxies de confianza y HTTPS - -**Contexto.** El servicio `app` de `docker-compose.yml` solo recibe las variables de -`environment:`, y `CRON_SECRET` no estaba: en Docker los recordatorios respondían -siempre 503. Las variables nuevas de otros cambios (`TRUSTED_PROXY_HOPS`, -`ORGTABLE_TIMEZONE`) tampoco llegarían. La documentación daba por buena -`X-Forwarded-For` para el límite de login, mandaba completar el asistente por -`http://IP:3000` sin advertir de nada y dejaba `SESSION_SECRET=changeme` (8 -caracteres, por debajo del mínimo de 16). - -**Decisión.** -- `docker-compose.yml` pasa `CRON_SECRET` (vacío por defecto = 503), - `TRUSTED_PROXY_HOPS` (0 por defecto) y `ORGTABLE_TIMEZONE` (`Atlantic/Canary`). El - CI comprueba con `docker compose config` que llegan. -- `install.sh` genera `CRON_SECRET` en instalaciones nuevas y lo añade al `.env` de - las existentes si falta; al terminar avisa de que por HTTP todo viaja sin cifrar. -- El puerto sigue publicado en todas las interfaces por defecto. Para publicarlo solo - en local se documenta `APP_PORT=127.0.0.1:3000`, que la interpolación del compose ya - admite, sin variable nueva. -- Se documenta HTTPS como recomendación (no requisito): la cookie de sesión es `Secure` - cuando la petición llega por HTTPS o con `X-Forwarded-Proto: https`. Ejemplos de - Caddy y nginx (`X-Forwarded-For $remote_addr`), `TRUSTED_PROXY_HOPS=1` y aviso de - que Docker se salta `ufw`. -- `docs/installation.md` es la referencia única de variables; `.env.example`, - `docs/docker.md` y el README remiten a ella. - -**Alternativas descartadas.** -- `env_file: .env` en el servicio: metería en el contenedor todas las variables, - incluidas `POSTGRES_PASSWORD` y las que no necesita. -- Publicar la app en `127.0.0.1` por defecto (o una variable `APP_BIND`): rompe a - quien entra hoy por `IP:3000`; lo decidió el propietario. -- `SESSION_COOKIE_SECURE`: descartada por el propietario a favor de detectar HTTPS. - -**Consecuencias.** Tras actualizar hay que recrear el contenedor -(`docker compose up -d`) para que vea las variables nuevas. Quien tenga `CRON_SECRET` -en el `.env` empieza a recibir recordatorios en cuanto programe el cron. Los -comportamientos de `TRUSTED_PROXY_HOPS`, `ORGTABLE_TIMEZONE` y la cookie `Secure` -dependen del código de otros cambios de esta misma revisión. +## 2026-09-24 — Container variables, CRON_SECRET, trusted proxies and HTTPS + +**Context.** The `app` service in `docker-compose.yml` only receives the variables in +`environment:`, and `CRON_SECRET` was not there: in Docker the reminders always returned +503. The new variables from other changes (`TRUSTED_PROXY_HOPS`, `ORGTABLE_TIMEZONE`) would +not get through either. The documentation trusted `X-Forwarded-For` for the login limit, +told users to complete the wizard over `http://IP:3000` without any warning, and left +`SESSION_SECRET=changeme` (8 characters, below the minimum of 16). + +**Decision.** +- `docker-compose.yml` passes `CRON_SECRET` (empty by default = 503), + `TRUSTED_PROXY_HOPS` (0 by default) and `ORGTABLE_TIMEZONE` (`Atlantic/Canary`). CI + checks with `docker compose config` that they get through. +- `install.sh` generates `CRON_SECRET` on new installations and adds it to the `.env` of + existing ones if missing; at the end it warns that over HTTP everything travels + unencrypted. +- The port is still published on all interfaces by default. To publish it only locally, + `APP_PORT=127.0.0.1:3000` is documented, which the compose interpolation already + supports, with no new variable. +- HTTPS is documented as a recommendation (not a requirement): the session cookie is + `Secure` when the request arrives over HTTPS or with `X-Forwarded-Proto: https`. Caddy + and nginx examples (`X-Forwarded-For $remote_addr`), `TRUSTED_PROXY_HOPS=1` and a warning + that Docker bypasses `ufw`. +- `docs/installation.md` is the single reference for variables; `.env.example`, + `docs/docker.md` and the README point to it. + +**Alternatives considered.** +- `env_file: .env` on the service: it would put every variable into the container, + including `POSTGRES_PASSWORD` and the ones it does not need. +- Publishing the app on `127.0.0.1` by default (or an `APP_BIND` variable): it breaks + whoever currently connects through `IP:3000`; the owner decided. +- `SESSION_COOKIE_SECURE`: rejected by the owner in favor of detecting HTTPS. + +**Consequences.** After updating, the container must be recreated (`docker compose up -d`) +so it sees the new variables. Anyone with `CRON_SECRET` in their `.env` starts receiving +reminders as soon as they schedule the cron. The behavior of `TRUSTED_PROXY_HOPS`, +`ORGTABLE_TIMEZONE` and the `Secure` cookie depends on code from other changes in this same +review. --- -## 2026-09-24 — Copias de seguridad, restauración y actualización fiables - -**Contexto.** `scripts/backup.sh` usaba `sh` sin `pipefail`: si `pg_dump` fallaba -quedaba un `.sql.gz` vacío y el script anunciaba «✓ Backup completado». Desde cron -(directorio `$HOME`) fallaba cada noche sin que nadie lo notara, omitía los adjuntos en -silencio y dejaba las copias legibles por todos (644). `restore.sh` cargaba el volcado -sin `--clean` ni `ON_ERROR_STOP` sobre un esquema ya migrado: mezclaba datos y decía -«✓». `update.sh` migraba producción sin copia previa. - -**Decisión.** -- Los tres scripts pasan a `bash` con `set -euo pipefail` y se sitúan en la raíz del - repositorio, así que funcionan desde cualquier directorio. -- `backup.sh`: `umask 077` (ficheros 600, directorio por defecto 700); escribe en - `.partial` y solo renombra si el gzip es válido, no está vacío y contiene la marca - final de `pg_dump`. `pg_dump --clean --if-exists`. Los adjuntos se leen con `exec` si - la app está en marcha o con un contenedor efímero (`run --rm --no-deps`) si no; solo - se omiten si no existe `/data/uploads` (código 3), cualquier otro fallo aborta. -- `restore.sh`: valida el fichero antes de preguntar, hace copia previa con - `backup.sh` en `backups/pre-restore-<fecha>`, para la app y carga - `DROP SCHEMA public CASCADE; CREATE SCHEMA public;` + volcado en una sola - transacción con `ON_ERROR_STOP`. Sirve también para volcados antiguos sin `--clean`. -- `update.sh`: `docker compose up -d --wait postgres` y `backup.sh` antes del - `git pull`; si la copia falla, no actualiza. -- Tests en `test/ops-scripts.test.ts` con un `docker` falso en el `PATH`, y - `shellcheck` en el CI. - -**Alternativas descartadas.** -- Dejar `sh` POSIX: no tiene `pipefail`, que es justo lo que faltaba. -- Restaurar solo con `--clean`: no arregla los volcados ya hechos sin él. -- `chmod 700` sobre cualquier destino que pase el usuario: con `/tmp` (o cron como - root) rompería el sistema. Solo se fuerza en el directorio por defecto; uno nuevo ya - nace en 700 por el umask. -- Omitir los adjuntos si la app está parada: es el fallo silencioso que se quería - quitar. - -**Consecuencias.** Verificado con Docker real (proyecto compose desechable): copia -desde `$HOME` con umask 022 da 700/600; con Postgres parado sale con código 1 y sin -ficheros; restaurar devuelve los datos y quita tablas ajenas; fichero inexistente o -corrupto sale con 1 sin tocar nada; el procedimiento de restaurar adjuntos de -`docs/operations.md` funciona. Las copias antiguas siguen con 644: la documentación da -el `chmod` para cerrarlas. `update.sh` no se probó contra un remoto real (solo con los -falsos del test). -## 2026-09-24 — Interfaz del tablero: arrastre, fechas, teclado y móvil - -**Contexto.** La tarjeta que vence hoy salía en rojo (se comparaba el instante de -medianoche UTC con `Date.now()`) mientras el filtro «Vencidas» la excluía, y la fecha se -formateaba en la zona local: al oeste de UTC mostraba el día anterior y descuadraba la -hidratación. Las tarjetas no se podían abrir con el teclado y, sin permisos de edición o -con filtros, ni siquiera recibían foco. Los chips del filtro no exponían su estado, las -etiquetas sin seleccionar tenían ~2,2:1 de contraste y los campos de añadir -lista/tarjeta solo tenían placeholder. El ✕ de una lista la borraba con todas sus -tarjetas al primer clic, junto al asa de arrastre. Reordenar listas arrastrando casi -nunca funcionaba (`over` resultaba ser una tarjeta), Escape dejaba la tarjeta en otra -lista sin guardarse, el resultado de `moveCard`/`reorderList`/`setCardDueDate` se -ignoraba y el calendario no se podía usar con teclado ni mostraba la pastilla bajo el -puntero. El deep-link `?card=` no abría la tarjeta si ya estabas en ese tablero: el -modal se inicializaba una sola vez con `useState(initialCardId)` y Next no remonta el -componente si solo cambian los searchParams. - -**Decisión.** -- `isOverdue(dueDate, now)` en `app/lib/board-filters.ts`: compara el día guardado - (`slice(0,10)`) con el día local; la usan el filtro y la tarjeta. -- `formatDueDay` con `Intl.DateTimeFormat('es-ES', { timeZone: 'UTC' })`: el vencimiento - es una fecha de calendario. -- «Vencida» depende del día del navegador, así que la tarjeta solo la calcula tras - hidratar (`useSyncExternalStore`); en el HTML del servidor nunca sale en rojo. -- La tarjeta es siempre `role="button"` y `tabIndex=0` (la copia flotante, - `tabIndex=-1`). Sin `aria-label`: el nombre accesible sale del contenido (etiquetas, - título, vencimiento, subtareas, contadores) y «vencida» se añade en texto `sr-only`, - porque el rojo solo se ve. Intro la abre; Espacio también si no se puede arrastrar. -- `aria-roledescription` en español («tarjeta», «lista») en lugar del «sortable» de - dnd-kit. -- Filtros: `type="button"`, `aria-pressed` y grupos etiquetados por su título. Etiqueta - sin seleccionar: texto de tinta con borde del color; seleccionada: relleno con ✓ y - anillo. -- `aria-label` en los campos: «Nombre de la nueva lista» y «Nueva tarjeta en <lista>». -- Popover de filtros anclado a la izquierda a cualquier ancho (`left-0`): «Filtrar» es - el primer botón de su grupo y a su derecha siempre caben los 288 px. Anclado a la - derecha se salía por la izquierda a 375 px y también hacia 768 px con un título largo, - cuando la cabecera se parte en dos filas. -- Borrar lista pide un segundo clic en línea («¿Borrar? Sí / No», el foco va a «No»), - sin `window.confirm`. -- `boardCollisionDetection` (`app/lib/board-collision.ts`): `closestCorners` filtrado a - listas cuando se arrastra una lista; `handleDragEnd` traduce además una tarjeta a su - lista. -- `KeyboardSensor` con Espacio para coger y Espacio/Intro para soltar (Intro no coge, - queda para abrir). Las listas usan `boardKeyboardCoordinates` (salta a la columna - vecina) y el calendario `calendarKeyboardCoordinates` (día o semana vecinos). - Instrucciones y anuncios en español. -- Instantánea de las listas al empezar el arrastre: se restaura con Escape, al soltar - fuera o si la acción falla. `persistOptimistic` (`app/lib/optimistic.ts`) deshace ante - `{ ok: false }` o excepción, hace `router.refresh()` y relanza las redirecciones de - Next (`unstable_rethrow`). En el tablero se avisa con `role="alert"` en la cabecera - («No se pudo mover la tarjeta. Inténtalo de nuevo.» / «…la lista…»); el aviso se - borra al empezar el siguiente arrastre. -- Título del tablero truncado (`min-w-0 truncate`) para que un nombre largo no desborde - en móvil. -- Calendario: `DragOverlay` con la pastilla, `id` estable y aviso «No se pudo cambiar la - fecha. Inténtalo de nuevo.» con `role="alert"` si el servidor no guarda. -- La tarjeta abierta se deriva de `useSearchParams()`. Abrir y cerrar cambian `?card` - con `window.history.replaceState` (Next lo sincroniza sin pedir la página); cerrar lo - quita siempre. La página del tablero deja de leer `searchParams`. - -**Alternativas descartadas.** -- Formatear en la zona del servidor (`ORGTABLE_TIMEZONE`): el vencimiento no es un - instante, es un día, y en UTC se muestra igual en todas partes. -- Un asa de arrastre separada dentro de la tarjeta: añade un control más; con Espacio - para coger e Intro para abrir basta. -- `window.confirm`: bloquea la página y no sigue el estilo de la app. -- `useOptimistic`: exigiría reestructurar el estado del arrastre; la instantánea - resuelve lo mismo con menos cambios. Deshacer en silencio: la tarjeta volvía a su - sitio sin explicación y el calendario ya avisaba. -- `aria-label` con solo el título en la tarjeta: tapaba el vencimiento, el estado - «vencida» y los contadores. Anclar el popover a la derecha solo en `lg`: depende de - que la cabecera no se parta, que varía con la longitud del título. -- `router.replace` con `?card`: cada apertura pediría la página al servidor. Sincronizar - `initialCardId` durante el render: seguiría sin abrir la misma notificación dos veces. - -**Consecuencias.** Tests en `test/board-filters.test.ts` (vence hoy/ayer, formato con -`TZ=America/New_York`). Test de render en servidor (`test/card-item.test.ts`) para rol, -foco, nombre accesible desde el contenido (sin `aria-label`) y ausencia de «vencida» en -el HTML inicial. El aviso de movimiento fallido no tiene test automático (necesita un -fallo de la Server Action en un DOM real); `persistOptimistic` llama a `onFailure` y eso -sí está probado. Sin test automático (no hay entorno -DOM); verificación manual pendiente. Tests con rectángulos sintéticos en -`test/board-collision.test.ts` (incluido el fallo de `closestCorners` a secas) y de -`persistOptimistic` en `test/optimistic.test.ts`. El arrastre real con ratón y teclado -queda por verificar a mano en el navegador. Toca `app/(app)/board/[id]/page.tsx` (fuera -del lote) para quitar la prop `initialCardId`. Verificación manual pendiente. +## 2026-09-24 — Reliable backups, restore and update + +**Context.** `scripts/backup.sh` used `sh` without `pipefail`: if `pg_dump` failed, an +empty `.sql.gz` was left and the script announced "✓ Backup completed". From cron +(directory `$HOME`) it failed every night without anyone noticing, silently skipped the +attachments and left the backups world-readable (644). `restore.sh` loaded the dump without +`--clean` or `ON_ERROR_STOP` onto an already migrated schema: it mixed data and still said +"✓". `update.sh` migrated production without a prior backup. + +**Decision.** +- The three scripts move to `bash` with `set -euo pipefail` and change to the repository + root, so they work from any directory. +- `backup.sh`: `umask 077` (files 600, default directory 700); it writes to `.partial` and + only renames if the gzip is valid, not empty and contains the final marker of `pg_dump`. + `pg_dump --clean --if-exists`. Attachments are read with `exec` if the app is running or + with an ephemeral container (`run --rm --no-deps`) if not; they are only skipped if + `/data/uploads` does not exist (exit code 3), any other failure aborts. +- `restore.sh`: validates the file before asking, makes a prior backup with `backup.sh` in + `backups/pre-restore-<fecha>`, stops the app and loads + `DROP SCHEMA public CASCADE; CREATE SCHEMA public;` + the dump in a single transaction + with `ON_ERROR_STOP`. It also works for older dumps without `--clean`. +- `update.sh`: `docker compose up -d --wait postgres` and `backup.sh` before the + `git pull`; if the backup fails, it does not update. +- Tests in `test/ops-scripts.test.ts` with a fake `docker` on the `PATH`, and `shellcheck` + in CI. + +**Alternatives considered.** +- Keeping POSIX `sh`: it has no `pipefail`, which is exactly what was missing. +- Restoring only with `--clean`: it does not fix the dumps already made without it. +- `chmod 700` on any destination the user passes: with `/tmp` (or cron as root) it would + break the system. It is only forced on the default directory; a new one is already + created as 700 because of the umask. +- Skipping attachments if the app is stopped: that is the silent failure this set out to + remove. + +**Consequences.** Verified with real Docker (a throwaway compose project): a backup from +`$HOME` with umask 022 gives 700/600; with Postgres stopped it exits with code 1 and no +files; restoring brings the data back and removes foreign tables; a nonexistent or corrupt +file exits with 1 without touching anything; the attachment restore procedure in +`docs/operations.md` works. Old backups stay at 644: the documentation gives the `chmod` to +lock them down. `update.sh` was not tested against a real remote (only with the test +fakes). +## 2026-09-24 — Board interface: dragging, dates, keyboard and mobile + +**Context.** A card due today showed in red (the UTC midnight instant was compared with +`Date.now()`) while the "Overdue" filter excluded it, and the date was formatted in the +local time zone: west of UTC it showed the previous day and broke hydration. Cards could +not be opened with the keyboard and, without edit permissions or with filters active, +they did not even receive focus. The filter chips did not expose their state, unselected +labels had ~2.2:1 contrast and the add list/card fields only had a placeholder. A list's +✕ deleted it with all its cards on the first click, right next to the drag handle. +Reordering lists by dragging almost never worked (`over` turned out to be a card), Escape +left the card in another list without saving it, the result of +`moveCard`/`reorderList`/`setCardDueDate` was ignored, and the calendar could not be used +with the keyboard nor did it show the chip under the pointer. The `?card=` deep-link did +not open the card if you were already on that board: the modal was initialized only once +with `useState(initialCardId)` and Next does not remount the component when only the +searchParams change. + +**Decision.** +- `isOverdue(dueDate, now)` in `app/lib/board-filters.ts`: compares the stored day + (`slice(0,10)`) with the local day; both the filter and the card use it. +- `formatDueDay` with `Intl.DateTimeFormat('es-ES', { timeZone: 'UTC' })`: the due date + is a calendar date. +- "Overdue" depends on the browser's day, so the card only computes it after hydration + (`useSyncExternalStore`); in the server HTML it never shows in red. +- The card is always `role="button"` and `tabIndex=0` (the floating copy, + `tabIndex=-1`). No `aria-label`: the accessible name comes from the content (labels, + title, due date, subtasks, counters) and "overdue" is added as `sr-only` text, because + the red is only visual. Enter opens it; Space does too when it cannot be dragged. +- `aria-roledescription` in Spanish ("tarjeta", "lista") instead of dnd-kit's + "sortable". +- Filters: `type="button"`, `aria-pressed` and groups labeled by their title. Unselected + label: ink text with a border in the label color; selected: filled with a ✓ and a ring. +- `aria-label` on the fields: "Name of the new list" and "New card in <list>". +- Filter popover anchored to the left at any width (`left-0`): "Filter" is the first + button of its group and the 288 px always fit to its right. Anchored to the right it + overflowed on the left at 375 px, and also around 768 px with a long title, when the + header wraps onto two rows. +- Deleting a list asks for a second inline click ("Delete? Yes / No", focus goes to + "No"), without `window.confirm`. +- `boardCollisionDetection` (`app/lib/board-collision.ts`): `closestCorners` filtered to + lists when a list is being dragged; `handleDragEnd` also maps a card to its list. +- `KeyboardSensor` with Space to pick up and Space/Enter to drop (Enter does not pick up, + it is kept for opening). Lists use `boardKeyboardCoordinates` (jumps to the neighboring + column) and the calendar uses `calendarKeyboardCoordinates` (neighboring day or week). + Instructions and announcements in Spanish. +- Snapshot of the lists when dragging starts: it is restored on Escape, when dropping + outside, or if the action fails. `persistOptimistic` (`app/lib/optimistic.ts`) rolls + back on `{ ok: false }` or an exception, calls `router.refresh()` and rethrows Next's + redirects (`unstable_rethrow`). On the board a `role="alert"` message appears in the + header ("Could not move the card. Try again." / "...the list..."); the message is + cleared when the next drag starts. +- Board title truncated (`min-w-0 truncate`) so a long name does not overflow on mobile. +- Calendar: `DragOverlay` with the chip, a stable `id` and a "Could not change the date. + Try again." message with `role="alert"` if the server does not save. +- The open card is derived from `useSearchParams()`. Opening and closing change `?card` + with `window.history.replaceState` (Next syncs it without requesting the page); closing + always removes it. The board page no longer reads `searchParams`. + +**Alternatives considered.** +- Formatting in the server's time zone (`ORGTABLE_TIMEZONE`): the due date is not an + instant, it is a day, and in UTC it shows the same everywhere. +- A separate drag handle inside the card: adds one more control; Space to pick up and + Enter to open is enough. +- `window.confirm`: blocks the page and does not match the app's style. +- `useOptimistic`: would require restructuring the drag state; the snapshot solves the + same problem with fewer changes. Rolling back silently: the card went back to its place + with no explanation, and the calendar already showed a message. +- An `aria-label` with only the title on the card: it hid the due date, the "overdue" + state and the counters. Anchoring the popover to the right only at `lg`: depends on the + header not wrapping, which varies with the title length. +- `router.replace` with `?card`: every opening would request the page from the server. + Syncing `initialCardId` during render: it would still not open the same notification + twice. + +**Consequences.** Tests in `test/board-filters.test.ts` (due today/yesterday, formatting +with `TZ=America/New_York`). Server render test (`test/card-item.test.ts`) for role, +focus, accessible name from the content (no `aria-label`) and absence of "overdue" in the +initial HTML. The failed-move message has no automated test (it needs a Server Action +failure in a real DOM); `persistOptimistic` calls `onFailure`, and that is tested. No +automated test (there is no DOM environment); manual verification pending. Tests with +synthetic rectangles in `test/board-collision.test.ts` (including the failure of plain +`closestCorners`) and of `persistOptimistic` in `test/optimistic.test.ts`. Real dragging +with mouse and keyboard remains to be verified by hand in the browser. Touches +`app/(app)/board/[id]/page.tsx` (outside the batch) to remove the `initialCardId` prop. +Manual verification pending. --- -## 2026-09-24 — Content-Security-Policy con nonce y HSTS - -**Contexto.** La norma de seguridad exige CSP, HSTS, X-Content-Type-Options y -Referrer-Policy. Solo había las dos últimas (más `X-Frame-Options` y -`Permissions-Policy`, que además incluía `interest-cohort`, una directiva que los -navegadores ya no reconocen y que ensuciaba la consola). - -**Decisión.** -- CSP generada en `proxy.ts` con un nonce aleatorio por petición (`app/lib/csp.ts`, - patrón de la guía de CSP de Next 16). Todas las páginas ya eran dinámicas (sesión), - así que el nonce no cambia el renderizado. -- `style-src-attr 'unsafe-inline'`: React pinta atributos `style` desde el servidor - (fondo del tablero, dnd-kit) y el nonce no cubre atributos. Los `<style>` sí exigen - nonce en producción. -- En desarrollo, `'unsafe-eval'` y estilos inline sin nonce (Turbopack y el overlay - inyectan `<style>` sin él). -- `/api/**` recibe `default-src 'none'; frame-ancestors 'none'` desde `next.config.ts`. -- HSTS `max-age=31536000` sin `includeSubDomains` ni `preload`. -- Se quita `interest-cohort` de `Permissions-Policy`. - -**Alternativas descartadas.** -- CSP estática con `'unsafe-inline'` en scripts: no protege contra XSS. -- Hashes en lugar de nonce: los scripts de hidratación de Next cambian en cada - respuesta. -- `upgrade-insecure-requests`: rompe las instalaciones por `http://IP:3000`, que es - como se documenta el primer arranque. -- HSTS con `includeSubDomains`/`preload`: impondría HTTPS a dominios hermanos del - instalador. - -**Consecuencias.** Verificado con Playwright contra `pnpm build && pnpm start`: cero -violaciones de CSP en tablero, calendario, ajustes, administración y búsqueda; el script -del tema se ejecuta (clase `dark` aplicada) y el tiempo real por SSE sigue funcionando. -Cualquier script de terceros futuro necesitará el nonce (`x-nonce`). +## 2026-09-24 — Content-Security-Policy with nonce, and HSTS + +**Context.** The security rule requires CSP, HSTS, X-Content-Type-Options and +Referrer-Policy. Only the last two were present (plus `X-Frame-Options` and +`Permissions-Policy`, which also included `interest-cohort`, a directive browsers no +longer recognize and which cluttered the console). + +**Decision.** +- CSP generated in `proxy.ts` with a random nonce per request (`app/lib/csp.ts`, the + pattern from the Next 16 CSP guide). Every page was already dynamic (session), so the + nonce does not change rendering. +- `style-src-attr 'unsafe-inline'`: React renders `style` attributes from the server + (board background, dnd-kit) and the nonce does not cover attributes. `<style>` elements + do require the nonce in production. +- In development, `'unsafe-eval'` and inline styles without a nonce (Turbopack and the + overlay inject `<style>` without it). +- `/api/**` gets `default-src 'none'; frame-ancestors 'none'` from `next.config.ts`. +- HSTS `max-age=31536000` without `includeSubDomains` or `preload`. +- `interest-cohort` is removed from `Permissions-Policy`. + +**Alternatives considered.** +- Static CSP with `'unsafe-inline'` for scripts: does not protect against XSS. +- Hashes instead of a nonce: Next's hydration scripts change on every response. +- `upgrade-insecure-requests`: breaks installs reached through `http://IP:3000`, which is + how the first start is documented. +- HSTS with `includeSubDomains`/`preload`: would force HTTPS on sibling domains of the + person installing it. + +**Consequences.** Verified with Playwright against `pnpm build && pnpm start`: zero CSP +violations on the board, calendar, settings, administration and search; the theme script +runs (`dark` class applied) and SSE realtime keeps working. Any future third-party script +will need the nonce (`x-nonce`). --- -## 2026-09-24 — Tablero: doble envío, hidratación y arrastre con teclado +## 2026-09-24 — Board: double submit, hydration and keyboard dragging -**Contexto.** Probando en el navegador se crearon dos listas iguales con 48 ms de -diferencia (Enter repetido), la consola mostraba un error de hidratación de dnd-kit -(`aria-describedby` distinto entre servidor y cliente) y las tarjetas solo se podían -mover con el ratón. +**Context.** While testing in the browser, two identical lists were created 48 ms apart +(repeated Enter), the console showed a dnd-kit hydration error (`aria-describedby` +differing between server and client) and cards could only be moved with the mouse. -**Decisión.** -- `AddListForm`/`AddCardForm`: una ref `pending` descarta los envíos mientras la acción - anterior no ha terminado (`preventDefault` en `onSubmit`; React no lanza la acción - de un formulario cuyo evento está cancelado). -- `DndContext` recibe `id={useId()}`, estable entre servidor y cliente. -- Se añade `KeyboardSensor` con `sortableKeyboardCoordinates`. +**Decision.** +- `AddListForm`/`AddCardForm`: a `pending` ref discards submissions while the previous + action has not finished (`preventDefault` in `onSubmit`; React does not run the action + of a form whose event was cancelled). +- `DndContext` gets `id={useId()}`, stable between server and client. +- `KeyboardSensor` is added with `sortableKeyboardCoordinates`. -**Alternativas descartadas.** -- `useActionState`/`useFormStatus` para deshabilitar el campo: el estado pendiente - llega en el siguiente render y un segundo Enter en el mismo tick se cuela; además, - deshabilitar el input le quita el foco. -- Deduplicar en el servidor por título: dos listas con el mismo nombre son legítimas. +**Alternatives considered.** +- `useActionState`/`useFormStatus` to disable the field: the pending state arrives on the + next render and a second Enter in the same tick slips through; disabling the input also + takes its focus away. +- Deduplicating on the server by title: two lists with the same name are legitimate. -**Consecuencias.** Verificado con Playwright: tres Enter seguidos crean una sola lista y -la consola queda sin errores. Con teclado: Tab hasta una tarjeta, Espacio para cogerla, -flechas para moverla y Espacio para soltarla (Escape cancela). +**Consequences.** Verified with Playwright: three Enters in a row create a single list and +the console stays free of errors. With the keyboard: Tab to a card, Space to pick it up, +arrows to move it and Space to drop it (Escape cancels). --- -## 2026-09-24 — Interfaz: móvil a 375 px y contraste del conmutador +## 2026-09-24 — Interface: mobile at 375 px and switcher contrast -**Contexto.** A 375 px todas las páginas del área autenticada desbordaban 76 px en -horizontal (la barra superior no cabía) y la cabecera del tablero dejaba «Miembros» -fuera de pantalla. En modo oscuro la opción activa del conmutador Tablero/Calendario -era texto claro sobre fondo blanco. El fondo del tablero no llegaba al final de la -pantalla y el calendario mostraba «Septiembre De 2026». +**Context.** At 375 px every page of the signed-in area overflowed 76 px horizontally (the +top bar did not fit) and the board header pushed "Members" off screen. In dark mode the +active option of the Board/Calendar switcher was light text on a white background. The +board background did not reach the bottom of the screen and the calendar showed +"Septiembre De 2026". -**Decisión.** -- Barra superior: en móvil «Administración» pasa a icono con `aria-label`, el nombre de - la instancia se trunca y los huecos se reducen. -- Cabecera del tablero con `flex-wrap`. -- La opción activa usa `bg-surface text-ink` (tokens del tema) en lugar de blanco fijo. -- El contenedor del área autenticada usa `flex-1` (el `min-h-full` no se resolvía - dentro del `body` flex). -- El mes del calendario usa `first-letter:uppercase` en vez de `capitalize`, que ponía - en mayúscula cada palabra. +**Decision.** +- Top bar: on mobile "Administration" becomes an icon with `aria-label`, the instance name + is truncated and gaps are reduced. +- Board header with `flex-wrap`. +- The active option uses `bg-surface text-ink` (theme tokens) instead of a fixed white. +- The signed-in area container uses `flex-1` (`min-h-full` did not resolve inside the flex + `body`). +- The calendar month uses `first-letter:uppercase` instead of `capitalize`, which + capitalised every word. -**Alternativas descartadas.** Un menú hamburguesa en móvil: con pasar a iconos basta -y no añade un componente nuevo. +**Alternatives considered.** A hamburger menu on mobile: switching to icons is enough and +does not add a new component. -**Consecuencias.** Verificado con Playwright a 375 px (desbordamiento 0 en `/boards`, -tablero, `/settings`, `/admin/users`, `/admin/audit` y `/search`) y a 1440 px en modo -oscuro. +**Consequences.** Verified with Playwright at 375 px (0 overflow on `/boards`, board, +`/settings`, `/admin/users`, `/admin/audit` and `/search`) and at 1440 px in dark mode. --- -## 2026-09-24 — Actualización de dependencias por seguridad - -**Contexto.** Tras pasar a pnpm, `pnpm audit` daba 63 avisos (2 críticos y 30 altos), -la mayoría en `next` 16.2.7 y en dependencias transitivas de Prisma, ESLint y Vitest. - -**Decisión.** -- Subir todo dentro de su versión mayor: `next`/`eslint-config-next` 16.3.6, Prisma - 7.10.0, React 19.3.0, zod 4.6.5, pg 8.23.0, jose 6.2.12, Tailwind 4.3.3, Vitest - 4.1.11 y tipos. Los rangos de `package.json` pasan a exigir esas versiones mínimas. -- `overrides` en `pnpm-workspace.yaml` para transitivas con parche en la misma mayor: - `brace-expansion` 5.0.12, `browserslist` 4.28.7 y `mysql2` 3.23.1. -- Se ignora `GHSA-ggr8-5vv4-36mx` (`deepmerge-ts` <8 dentro de `@prisma/config`): solo - fusiona la configuración local de `prisma.config.ts`, no entrada de usuario, y el - parche exige una versión mayor que Prisma 7.10 no admite. - -**Alternativas descartadas.** -- Subidas mayores (TypeScript 7, ESLint 10, Vitest 5, Prisma 8, `@types/node` 26, - `fractional-indexing` 4): van en commits aparte y tras leer sus changelogs; no - corrigen ninguno de los avisos. -- Forzar `deepmerge-ts` 8 con un override: cambiaría una mayor dentro del CLI que - aplica las migraciones en cada arranque del contenedor, para un riesgo sin vector - de ataque real. - -**Consecuencias.** `pnpm audit` queda sin avisos (1 ignorado y documentado). Al subir -`next`, `prisma` o `eslint-config-next` hay que revisar si los overrides siguen -haciendo falta. +## 2026-09-24 — Dependency upgrades for security + +**Context.** After moving to pnpm, `pnpm audit` reported 63 advisories (2 critical and 30 +high), most of them in `next` 16.2.7 and in transitive dependencies of Prisma, ESLint and +Vitest. + +**Decision.** +- Upgrade everything within its major version: `next`/`eslint-config-next` 16.3.6, Prisma + 7.10.0, React 19.3.0, zod 4.6.5, pg 8.23.0, jose 6.2.12, Tailwind 4.3.3, Vitest 4.1.11 + and types. The ranges in `package.json` now require those minimum versions. +- `overrides` in `pnpm-workspace.yaml` for transitive dependencies with a patch in the same + major: `brace-expansion` 5.0.12, `browserslist` 4.28.7 and `mysql2` 3.23.1. +- `GHSA-ggr8-5vv4-36mx` is ignored (`deepmerge-ts` <8 inside `@prisma/config`): it only + merges the local configuration from `prisma.config.ts`, not user input, and the patch + requires a major version that Prisma 7.10 does not support. + +**Alternatives considered.** +- Major upgrades (TypeScript 7, ESLint 10, Vitest 5, Prisma 8, `@types/node` 26, + `fractional-indexing` 4): they go in separate commits after reading their changelogs; + they fix none of the advisories. +- Forcing `deepmerge-ts` 8 with an override: it would change a major version inside the + CLI that applies the migrations on every container start, for a risk with no real attack + vector. + +**Consequences.** `pnpm audit` is clean (1 ignored and documented). When upgrading `next`, +`prisma` or `eslint-config-next`, check whether the overrides are still needed. --- -## 2026-09-24 — Migración de npm a pnpm - -**Contexto.** El estándar de los proyectos pasa a ser pnpm (lockfile siempre versionado, -nunca `package-lock.json`). El repo usaba npm en local, en CI y en el `Dockerfile`. - -**Decisión.** -- `pnpm import` generó `pnpm-lock.yaml` a partir de `package-lock.json`, así que las - versiones resueltas no cambian en este paso. `package-lock.json` se borra. -- `packageManager: pnpm@11.24.0` en `package.json`: Corepack (Docker) y - `pnpm/action-setup` (CI) instalan exactamente esa versión. -- pnpm 11 no ejecuta scripts de instalación salvo los permitidos. `pnpm-workspace.yaml` - lleva `allowBuilds` con `@prisma/engines` y `prisma` en `true` (descargan el - schema-engine que usa `migrate deploy`) y `sharp` y `unrs-resolver` en `false` (traen - binarios precompilados por dependencias opcionales; su script solo es un respaldo). -- CI pasa a Node 22 (igual que la imagen Docker) y añade `pnpm audit --prod +## 2026-09-24 — Migration from npm to pnpm + +**Context.** pnpm becomes the standard for the projects (lockfile always committed, never +`package-lock.json`). The repo used npm locally, in CI and in the `Dockerfile`. + +**Decision.** +- `pnpm import` generated `pnpm-lock.yaml` from `package-lock.json`, so the resolved + versions do not change in this step. `package-lock.json` is deleted. +- `packageManager: pnpm@11.24.0` in `package.json`: Corepack (Docker) and + `pnpm/action-setup` (CI) install exactly that version. +- pnpm 11 does not run install scripts except the allowed ones. `pnpm-workspace.yaml` has + `allowBuilds` with `@prisma/engines` and `prisma` set to `true` (they download the + schema-engine used by `migrate deploy`) and `sharp` and `unrs-resolver` set to `false` + (they ship prebuilt binaries through optional dependencies; their script is only a + fallback). +- CI moves to Node 22 (same as the Docker image) and adds `pnpm audit --prod --audit-level high`. -- El `Dockerfile` añade un stage `base` con `corepack enable`; el resto de stages no - cambia de estructura. - -**Alternativas descartadas.** -- `node-linker=hoisted` para imitar el árbol plano de npm: no hizo falta; el build - standalone y el CLI de Prisma funcionan con el layout aislado de pnpm. -- Instalar pnpm con `npm i -g` en la imagen: Corepack ya viene en `node:22` y respeta - la versión fijada. -- Aprobar todos los scripts de build (`--all`): amplía la superficie de ataque de la - cadena de suministro sin necesidad. - -**Consecuencias.** Hay que usar `pnpm` en lugar de `npm`/`npx` (documentación -actualizada). Una dependencia nueva con script de instalación necesita entrar en -`allowBuilds` o pnpm avisará con `ERR_PNPM_IGNORED_BUILDS`. +- The `Dockerfile` adds a `base` stage with `corepack enable`; the other stages keep their + structure. + +**Alternatives considered.** +- `node-linker=hoisted` to mimic npm's flat tree: not needed; the standalone build and the + Prisma CLI work with pnpm's isolated layout. +- Installing pnpm with `npm i -g` in the image: Corepack already ships with `node:22` and + honors the pinned version. +- Approving every build script (`--all`): needlessly widens the supply-chain attack + surface. + +**Consequences.** Use `pnpm` instead of `npm`/`npx` (documentation updated). A new +dependency with an install script must be added to `allowBuilds`, or pnpm will warn with +`ERR_PNPM_IGNORED_BUILDS`. diff --git a/docs/admin.md b/docs/admin.md index 617cc28..440d579 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -1,44 +1,46 @@ -# Administración de usuarios - -orgtable no tiene registro abierto: las cuentas las crea el **administrador** de la -instancia desde `/admin/users`. El primer administrador es quien completa el -asistente de configuración inicial (ver [`docs/setup-wizard.md`](./setup-wizard.md)). - -Solo los usuarios con rol global `ADMIN` ven el enlace **Administración** en la -cabecera y pueden entrar a `/admin/users`. La ruta está protegida por -`requireAdmin()` en el servidor (ver [`docs/authorization.md`](./authorization.md)). - -## Qué puede hacer el administrador - -- **Crear cuentas**: nombre, email, contraseña inicial y rol global - (`MEMBER` o `ADMIN`). El usuario podrá iniciar sesión con esa contraseña. -- **Cambiar el rol global** de cualquier otra cuenta (promover a admin o degradar). -- **Restablecer la contraseña** de cualquier cuenta (teclea una nueva). Además cierra - todas las sesiones del usuario y **borra todos sus tokens de API**: es la acción a - usar si una cuenta puede estar comprometida. El usuario tendrá que crear tokens - nuevos en `/settings`. -- **Cerrar sesiones** de una cuenta: revoca sus sesiones del navegador, pero **no** sus - tokens de API. -- **Borrar cuentas** (sus sesiones y tokens se borran en cascada). - -## Salvaguardas - -Para evitar dejar la instancia sin administración o perder datos por accidente: - -- **No puedes cambiar tu propio rol** ni **borrar tu propia cuenta**. Esto evita que - el único administrador se quede sin acceso. Para traspasar la administración, - promueve antes a otra persona a `ADMIN`. -- **No se puede borrar un usuario que es propietario de tableros**: borrarlo - arrastraría sus tableros (y todo su contenido) en cascada. El administrador debe - transferir o borrar esos tableros primero. La acción se bloquea con un aviso. - -## Notas de implementación - -- Acciones de servidor: [`app/lib/actions/admin.ts`](../app/lib/actions/admin.ts). -- El alta de usuario usa `useActionState` para dar feedback inline y limpiar el - formulario al crear. -- Las acciones por fila (cambiar rol, restablecer contraseña, borrar) devuelven su - resultado mediante una redirección con query (`?ok=…` / `?error=…`) que la página - traduce a un aviso. Así no hace falta un componente cliente con estado por cada - fila de la tabla. -- Las contraseñas se almacenan con bcrypt (ver [`docs/authentication.md`](./authentication.md)). +# User administration + +By default, accounts are created by the instance **administrator** in `/admin/users`. If +the administrator opens **sign-up** in `/admin/instance`, anyone can create a `MEMBER` +account at `/register` (see [privacy.md](./privacy.md)). The first administrator is +whoever completes the initial setup wizard (see +[`docs/setup-wizard.md`](./setup-wizard.md)). + +Administration has three tabs: **Users** (`/admin/users`), **Instance** +(`/admin/instance`: name, open or closed sign-up and the operator's legal details) and +**Audit** (`/admin/audit`). Only users with the global `ADMIN` role see the +**Administration** link in the account menu and can access it. The route is protected by +`requireAdmin()` on the server (see [`docs/authorization.md`](./authorization.md)). + +## What the administrator can do + +- **Create accounts**: name, email, initial password and global role (`MEMBER` or + `ADMIN`). The user can sign in with that password. +- **Change the global role** of any other account (promote to admin or demote). +- **Reset the password** of any account (by typing a new one). It also closes all the + user's sessions and **deletes all their API tokens**: use it when an account may be + compromised. The user will have to create new tokens in `/settings`. +- **Sign out** an account: revokes its browser sessions, but **not** its API tokens. +- **Delete accounts** (their sessions and tokens are deleted in cascade). + +## Safeguards + +To avoid leaving the instance without an administrator or losing data by accident: + +- **You cannot change your own role** or **delete your own account from this page**. + This stops the only administrator from locking themselves out. To hand over + administration, promote someone else to `ADMIN` first. Each person can still delete + their own account from *Settings › Privacy and data*, except the last `ADMIN` account. +- **A user who owns boards cannot be deleted**: deleting them would cascade to their + boards and all their content. The administrator must transfer or delete those boards + first. The action is blocked with a warning. + +## Implementation notes + +- Server Actions: [`app/lib/actions/admin.ts`](../app/lib/actions/admin.ts). +- Creating a user uses `useActionState` to give inline feedback and clear the form on + success. +- Per-row actions (change role, reset password, delete) return their result through a + redirect with a query string (`?ok=…` / `?error=…`) that the page turns into a notice. + This avoids a stateful Client Component for every table row. +- Passwords are stored with bcrypt (see [`docs/authentication.md`](./authentication.md)). diff --git a/docs/api/public-api.md b/docs/api/public-api.md index fa8a769..0856b90 100644 --- a/docs/api/public-api.md +++ b/docs/api/public-api.md @@ -1,130 +1,131 @@ -# API pública (REST v1) +# Public API (REST v1) -orgtable expone una API REST bajo `/api/v1` para automatizar tableros desde scripts u -otras aplicaciones (Hito 9). La decisión de diseño está en +orgtable exposes a REST API under `/api/v1` so you can automate boards from scripts or +other applications. The design decision is recorded in [ADR 0013](../decisions/0013-api-tokens.md). -## Autenticación +## Authentication -Las peticiones se autentican con un **token de acceso personal** en la cabecera -`Authorization`: +Requests are authenticated with a **personal access token** in the `Authorization` +header: ``` Authorization: Bearer orgt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` -- Crea y revoca tus tokens en **Ajustes** (`/settings`). El token completo se muestra - **una sola vez**; guárdalo bien. En la base de datos solo vive su hash SHA-256. -- Un token actúa **como tú**: tiene tu mismo acceso a los tableros (mismo rol). El - servidor vuelve a comprobar el rol del tablero en cada petición. -- **Alcance (scope)**: al crear el token eliges **Lectura y escritura** o **Solo lectura**. - Un token de solo lectura puede hacer `GET` pero recibe `403` en cualquier `POST`/`PATCH`/ - `DELETE`. -- **Caducidad**: opcionalmente el token caduca (30/90/365 días). Un token caducado se - rechaza con `401` (no se borra; lo verás marcado «Caducado» en Ajustes). -- Revocar un token (borrarlo en Ajustes) lo invalida al instante. - -## Convenciones - -- Cuerpos y respuestas en **JSON** (`Content-Type: application/json`). -- Las fechas se devuelven en **ISO 8601** (UTC). -- Errores con la forma `{ "error": "mensaje" }` y un código HTTP adecuado: - -| Código | Significado | -|--------|-------------| -| `401` | Token ausente o no válido | -| `403` | Autenticado, pero sin permiso (p. ej. editar siendo lector) | -| `404` | No existe **o** no tienes acceso (no se revela cuál) | -| `422` | Cuerpo no válido (validación) | - -> Que un recurso ajeno devuelva `404` (y no `403`) es intencionado: la API no revela la -> existencia de tableros a los que no perteneces. +- Create and revoke your tokens in **Settings** (`/settings`). The full token is shown + **only once**, so store it safely. The database keeps only its SHA-256 hash. +- A token acts **as you**: it has the same access to boards as you (same role). The + server checks the board role again on every request. +- **Scope**: when you create the token you choose **Read and write** or **Read only**. A + read-only token can make `GET` requests but gets `403` on any `POST`/`PATCH`/`DELETE`. +- **Expiry**: optionally, the token expires (30/90/365 days). An expired token is rejected + with `401` (it is not deleted; Settings shows it as expired). +- Revoking a token (deleting it in Settings) invalidates it immediately. + +## Conventions + +- Request and response bodies are **JSON** (`Content-Type: application/json`). +- Dates are returned in **ISO 8601** (UTC). +- The API answers in **English only**, whatever the language of the account: it is meant + for programs. +- Errors have the shape `{ "error": "message" }` and a suitable HTTP status: + +| Status | Meaning | +|--------|---------| +| `401` | Missing or invalid token | +| `403` | Authenticated, but not allowed (for example, editing as a viewer) | +| `404` | Does not exist **or** you have no access (the API does not say which) | +| `422` | Invalid body (validation) | + +> Returning `404` (not `403`) for someone else's resource is intentional: the API does not +> reveal that boards you do not belong to exist. ## Endpoints -### Tableros +### Boards -| Método | Ruta | Permiso | Descripción | -|--------|------|---------|-------------| -| `GET` | `/api/v1/boards` | miembro | Tus tableros (resumen) | -| `POST` | `/api/v1/boards` | — | Crea un tablero (quedas como propietario) | -| `GET` | `/api/v1/boards/:id` | miembro | El tablero con sus listas y tarjetas | -| `PATCH` | `/api/v1/boards/:id` | propietario | Renombra el tablero | -| `DELETE` | `/api/v1/boards/:id` | propietario | Borra el tablero | +| Method | Path | Permission | Description | +|--------|------|------------|-------------| +| `GET` | `/api/v1/boards` | member | Your boards (summary) | +| `POST` | `/api/v1/boards` | — | Creates a board (you become its owner) | +| `GET` | `/api/v1/boards/:id` | member | The board with its lists and cards | +| `PATCH` | `/api/v1/boards/:id` | owner | Renames the board | +| `DELETE` | `/api/v1/boards/:id` | owner | Deletes the board | -### Listas +### Lists -| Método | Ruta | Permiso | Descripción | -|--------|------|---------|-------------| -| `GET` | `/api/v1/boards/:id/lists` | miembro | Listas del tablero, en orden | -| `POST` | `/api/v1/boards/:id/lists` | editor | Crea una lista al final | +| Method | Path | Permission | Description | +|--------|------|------------|-------------| +| `GET` | `/api/v1/boards/:id/lists` | member | The board's lists, in order | +| `POST` | `/api/v1/boards/:id/lists` | editor | Creates a list at the end | -### Tarjetas +### Cards -| Método | Ruta | Permiso | Descripción | -|--------|------|---------|-------------| -| `GET` | `/api/v1/lists/:id/cards` | miembro | Tarjetas de la lista, en orden | -| `POST` | `/api/v1/lists/:id/cards` | editor | Crea una tarjeta al final | -| `GET` | `/api/v1/cards/:id` | miembro | Detalle de una tarjeta | -| `PATCH` | `/api/v1/cards/:id` | editor | Actualiza título, descripción o vencimiento | -| `DELETE` | `/api/v1/cards/:id` | editor | Borra la tarjeta | +| Method | Path | Permission | Description | +|--------|------|------------|-------------| +| `GET` | `/api/v1/lists/:id/cards` | member | The list's cards, in order | +| `POST` | `/api/v1/lists/:id/cards` | editor | Creates a card at the end | +| `GET` | `/api/v1/cards/:id` | member | A card's details | +| `PATCH` | `/api/v1/cards/:id` | editor | Updates the title, description or due date | +| `DELETE` | `/api/v1/cards/:id` | editor | Deletes the card | -**Cuerpos:** +**Bodies:** -- Crear tablero / lista: `{ "title": "…" }`. -- Crear tarjeta: `{ "title": "…", "description"?: string|null, "dueDate"?: "YYYY-MM-DD"|null }`. -- Actualizar tarjeta (`PATCH`): cualquiera de `title`, `description`, `dueDate` (al menos - uno). -- `description`: como máximo 5000 caracteres. -- `dueDate`: `"YYYY-MM-DD"` (se guarda a medianoche UTC) o una fecha-hora ISO 8601 con - zona (`"2026-07-01T09:00:00Z"`, `"2026-07-01T09:00:00+02:00"`). `null` quita el - vencimiento. Cualquier otro valor, incluida la cadena vacía `""` o una fecha imposible - como `"2026-02-31"`, da `422` y no cambia nada. +- Create a board / list: `{ "title": "…" }`. +- Create a card: `{ "title": "…", "description"?: string|null, "dueDate"?: "YYYY-MM-DD"|null }`. +- Update a card (`PATCH`): any of `title`, `description`, `dueDate` (at least one). +- `description`: at most 5000 characters. +- `dueDate`: `"YYYY-MM-DD"` (stored at midnight UTC) or an ISO 8601 date-time with a time + zone (`"2026-07-01T09:00:00Z"`, `"2026-07-01T09:00:00+02:00"`). `null` removes the due + date. Any other value, including the empty string `""` or an impossible date such as + `"2026-02-31"`, returns `422` and changes nothing. -## Ejemplos +## Examples ```bash TOKEN="orgt_…" -BASE="https://tu-instancia/api/v1" +BASE="https://your-instance/api/v1" -# Listar tableros +# List boards curl -H "Authorization: Bearer $TOKEN" "$BASE/boards" -# Crear un tablero +# Create a board curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"Sprint 12"}' "$BASE/boards" -# Crear una lista en un tablero +# Create a list on a board curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ - -d '{"title":"En progreso"}' "$BASE/boards/<boardId>/lists" + -d '{"title":"In progress"}' "$BASE/boards/<boardId>/lists" -# Crear una tarjeta con vencimiento +# Create a card with a due date curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ - -d '{"title":"Revisar PR","dueDate":"2026-07-01"}' "$BASE/lists/<listId>/cards" + -d '{"title":"Review PR","dueDate":"2026-07-01"}' "$BASE/lists/<listId>/cards" -# Editar una tarjeta +# Edit a card curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ - -d '{"title":"Revisado","dueDate":null}' "$BASE/cards/<cardId>" + -d '{"title":"Reviewed","dueDate":null}' "$BASE/cards/<cardId>" ``` -## Tiempo real +## Real time -Las mutaciones por API publican los mismos eventos que las de la interfaz, así que los -navegadores con el tablero abierto ven los cambios en vivo (ver -[docs/realtime.md](../realtime.md)). +API mutations publish the same events as the interface, so browsers with the board open +see the changes live (see [docs/realtime.md](../realtime.md)). -## Implementación +## Implementation -- Autenticación y autorización: `app/lib/api/auth.ts` (Bearer → usuario, rol por tablero). -- Generación/hash de tokens: `app/lib/api/tokens.ts`. Respuestas: `app/lib/api/respond.ts`. -- Serialización JSON: `app/lib/api/serialize.ts`. -- Rutas: `app/api/v1/**`. Gestión de tokens: `/settings` (`app/(app)/settings`). +- Authentication and authorization: `app/lib/api/auth.ts` (Bearer → user, role per board). +- Token generation and hashing: `app/lib/api/tokens.ts`. Responses: `app/lib/api/respond.ts` + (validation errors use the shared zod schemas, whose message keys are resolved to + English there). +- JSON serialization: `app/lib/api/serialize.ts`. +- Routes: `app/api/v1/**`. Token management: `/settings` (`app/(app)/settings`). -## Límites y evolución +## Limits and future work -- Los tokens soportan **scope** (solo lectura) y **caducidad** (Hito 13); aún no hay scope - por tablero. -- La superficie cubre tableros, listas y tarjetas; checklists, etiquetas, adjuntos y - miembros quedan como ampliación (backlog). Añadir un recurso es un route handler más que - reutiliza `authenticateApiToken` + `apiBoardRole` + `isReadOnly`. -- **Rate limiting** de la API y una referencia **OpenAPI** quedan en el backlog. +- Tokens support a **scope** (read only) and an **expiry date**; there is no per-board + scope yet. +- The API covers boards, lists and cards. Checklists, labels, attachments and members are + future work. Adding a resource means one more route handler that reuses + `authenticateApiToken` + `apiBoardRole` + `isReadOnly`. +- API **rate limiting** and an **OpenAPI** reference are also future work. diff --git a/docs/api/server-actions.md b/docs/api/server-actions.md index 9bf6624..8495f8b 100644 --- a/docs/api/server-actions.md +++ b/docs/api/server-actions.md @@ -1,68 +1,74 @@ -# Referencia de Server Actions +# Server Actions reference -Este documento cataloga **todas** las Server Actions del proyecto orgtable -(Next.js 16 + Prisma 7). Está pensado como referencia para contribuidores: para -cada acción se documenta su firma, qué valida, qué autorización aplica, qué hace -en la base de datos y qué efectos secundarios produce (`revalidatePath`, -`redirect`). +This document lists the project's Server Actions (Next.js 16 + Prisma 7). It is a +reference for contributors: for each action it describes the signature, what it +validates, which authorization it applies, what it does in the database and which side +effects it produces (`revalidatePath`, `redirect`). -Todas las acciones viven en `app/lib/actions/` y comienzan con la directiva -`"use server"`. +All actions live in `app/lib/actions/` and start with the `"use server"` directive. -## Patrón común +## Common pattern -La mayoría de las acciones siguen siempre el mismo flujo de cuatro pasos: +Most actions follow the same four-step flow: -1. **Validar** la entrada con un esquema [zod](https://zod.dev) de +1. **Validate** the input with a [zod](https://zod.dev) schema from `app/lib/validators.ts`. -2. **Autorizar** mediante el Data Access Layer (`app/lib/dal.ts`): `requireUser` - (exige sesión) o `requireBoardAccess` (exige ser miembro del tablero). Algunas - acciones aplican una comprobación de **solo dueño** directamente en la query. -3. **Mutar** la base de datos con Prisma. -4. **Revalidar** la caché de la ruta afectada con `revalidatePath` y, cuando - procede, **redirigir** con `redirect`. - -> Nota sobre el orden validar/autorizar: en varias acciones la autorización -> ocurre **antes** de validar el cuerpo (p. ej. `createBoard`, `createList`), -> porque primero hay que resolver a qué tablero pertenece la mutación. El -> principio es el mismo: nunca se toca la base de datos sin haber autorizado. - -### Dos estilos de invocación - -| Estilo | Acciones | Entrada | Retorno | Efectos | +2. **Authorize** through the Data Access Layer (`app/lib/dal.ts`): `requireUser` + (requires a session) or `requireBoardAccess` (requires board membership). Some actions + apply an **owner-only** check directly in the query. +3. **Mutate** the database with Prisma. +4. **Revalidate** the cache of the affected route with `revalidatePath` and, where + relevant, **redirect** with `redirect`. + +> About the validate/authorize order: in several actions authorization happens **before** +> the body is validated (for example `createBoard`, `createList`), because the action +> first has to work out which board the mutation belongs to. The principle is the same: +> the database is never touched before authorization. + +### Error messages and language + +Error messages returned to forms are shown to people, so they are in the language of the +request (the `locale` cookie, then `Accept-Language`, then English). The zod schemas carry +message **keys** (for example `"errors.validation.titleRequired"`) instead of text, and +the action resolves them with `issueMessage(m, issue)` from `app/i18n/server.ts`. The +messages live in `locales/<code>/errors.json`. See +[`docs/DECISIONES.md`](../DECISIONES.md) ("Bilingual interface"). + +### Two invocation styles + +| Style | Actions | Input | Return | Effects | | --- | --- | --- | --- | --- | -| **Formulario** (`<form action={...}>`) | `completeSetup`, `login`, `logout`, `createBoard`, `renameBoard`, `deleteBoard`, `createList`, `renameList`, `deleteList`, `createCard`, `renameCard`, `deleteCard` | `FormData` (a veces precedido de `_prev` para `useActionState`) | `void` o `{ error?: string }` | `revalidatePath` y/o `redirect` | -| **Drag & drop** (llamada imperativa desde el cliente) | `reorderList`, `moveCard` | Un **objeto** tipado | `{ ok: boolean }` | `revalidatePath`, sin `redirect` | - -Las acciones de formulario reciben `FormData` y normalmente terminan -redirigiendo o revalidando una ruta para que el servidor vuelva a renderizar. Las -acciones de drag & drop reciben un objeto plano (el cliente ya calculó la nueva -posición), actualizan **una única fila** y devuelven `{ ok: boolean }` para que -la UI pueda revertir el movimiento optimista si algo falla. Ver +| **Form** (`<form action={...}>`) | `completeSetup`, `login`, `logout`, `createBoard`, `renameBoard`, `deleteBoard`, `createList`, `renameList`, `deleteList`, `createCard`, `renameCard`, `deleteCard` | `FormData` (sometimes preceded by `_prev` for `useActionState`) | `void` or `{ error?: string }` | `revalidatePath` and/or `redirect` | +| **Drag and drop** (imperative call from the client) | `reorderList`, `moveCard` | A typed **object** | `{ ok: boolean }` | `revalidatePath`, no `redirect` | + +Form actions receive `FormData` and usually end by redirecting or revalidating a route so +the server renders again. Drag-and-drop actions receive a plain object (the client has +already computed the new position), update **a single row** and return `{ ok: boolean }` +so the UI can roll back the optimistic move if something fails. See [docs/drag-and-drop.md](../drag-and-drop.md). -### Posicionamiento (fractional indexing) +### Positioning (fractional indexing) -Las acciones que **crean** elementos (`createList`, `createCard`) calculan la -posición del nuevo elemento con `positionAtEnd` de `app/lib/ordering.ts`, que -genera una clave lexicográfica al final de la lista mediante *fractional -indexing*, dentro de una transacción que bloquea la fila padre para que dos altas -simultáneas no repitan posición. Las acciones de drag & drop no calculan la -posición: la reciben ya calculada del cliente y la validan con `isValidPositionKey` -antes de guardarla. Ver [docs/drag-and-drop.md](../drag-and-drop.md#el-servidor-solo-ordena-al-final-y-valida-lo-que-llega). +Actions that **create** items (`createList`, `createCard`) compute the new item's +position with `positionAtEnd` from `app/lib/ordering.ts`, which generates a lexicographic +key at the end of the list using *fractional indexing*. This happens inside a transaction +that locks the parent row, so two simultaneous inserts never get the same position. +Drag-and-drop actions do not compute the position: they receive it from the client and +check it with `isValidPositionKey` before saving it. See +[docs/drag-and-drop.md](../drag-and-drop.md#the-server-only-appends-and-validates-what-it-receives). --- -## Tabla resumen +## Summary table -| Acción | Archivo | Entrada | Retorno | Valida (zod) | Autorización | Efectos | +| Action | File | Input | Return | Validates (zod) | Authorization | Effects | | --- | --- | --- | --- | --- | --- | --- | -| `completeSetup` | `setup.ts` | `(_prev, FormData)` | `Promise<SetupState>` | `setupSchema` | Ninguna (solo si el setup no está hecho) | `createSession`, `redirect` | -| `login` | `auth.ts` | `(_prev, FormData)` | `Promise<AuthState>` | `loginSchema` | Ninguna (es el login) | `createSession`, `redirect` | +| `completeSetup` | `setup.ts` | `(_prev, FormData)` | `Promise<SetupState>` | `setupSchema` | None (only while setup is not done) | `createSession`, `redirect` | +| `login` | `auth.ts` | `(_prev, FormData)` | `Promise<AuthState>` | `loginSchema` | None (it is the login) | `createSession`, `redirect` | | `logout` | `auth.ts` | `()` | `Promise<void>` | — | — | `deleteSession`, `redirect` | | `createBoard` | `boards.ts` | `(FormData)` | `Promise<void>` | `boardTitleSchema` | `requireUser` | `revalidatePath`, `redirect` | | `renameBoard` | `boards.ts` | `(FormData)` | `Promise<void>` | `boardTitleSchema` | `requireBoardAccess` | `revalidatePath` | -| `deleteBoard` | `boards.ts` | `(FormData)` | `Promise<void>` | — | `requireUser` + solo dueño | `revalidatePath`, `redirect` | +| `deleteBoard` | `boards.ts` | `(FormData)` | `Promise<void>` | — | `requireUser` + owner only | `revalidatePath`, `redirect` | | `createList` | `lists.ts` | `(FormData)` | `Promise<void>` | `listTitleSchema` | `requireBoardAccess` | `revalidatePath` | | `renameList` | `lists.ts` | `(FormData)` | `Promise<void>` | `listTitleSchema` | `requireBoardAccess` | `revalidatePath` | | `deleteList` | `lists.ts` | `(FormData)` | `Promise<void>` | — | `requireBoardAccess` | `revalidatePath` | @@ -78,8 +84,8 @@ antes de guardarla. Ver [docs/drag-and-drop.md](../drag-and-drop.md#el-servidor- ### `completeSetup` -**Archivo:** `app/lib/actions/setup.ts` (la transacción vive en -`completeInstanceSetup`, `app/lib/services/setup.ts`) +**File:** `app/lib/actions/setup.ts` (the transaction lives in `completeInstanceSetup`, +`app/lib/services/setup.ts`) ```ts export type SetupState = { @@ -93,44 +99,41 @@ export async function completeSetup( ): Promise<SetupState> ``` -Acción del asistente de configuración inicial (*setup wizard*). Pensada para -`useActionState`, por eso recibe el estado previo `_prev` y devuelve un -`SetupState` con un posible mensaje de error. Los errores devuelven también `values` -(lo tecleado, **nunca** la contraseña) para que el formulario no se vacíe. - -- **Corte temprano:** si `isSetupComplete()` ya es cierto, `redirect("/login")` antes - de validar y de gastar bcrypt (el ID de la acción sigue en el bundle de `/setup` y se - puede invocar a mano). -- **Valida:** `setupSchema` sobre los campos `instanceName`, `name`, `email` y - `password`. Si falla, devuelve el primer mensaje de error de zod (o - `"Datos no válidos."`). -- **Autorización:** no hay sesión todavía. La protección es que el setup solo - puede completarse **una vez**. La recomprobación de `setupCompletedAt` dentro de la - transacción **no basta por sí sola** (con READ COMMITTED, dos envíos simultáneos la - pasarían a la vez): la transacción empieza con un advisory lock que serializa los - envíos. El porqué está en [`DECISIONES.md`](../DECISIONES.md) («Setup: advisory lock, - corte temprano y causa en el log»). -- **Base de datos** (`completeInstanceSetup`, todo dentro de `prisma.$transaction`): +Action of the setup wizard. It is built for `useActionState`, so it receives the previous +state `_prev` and returns a `SetupState` with an optional error message. Errors also +return `values` (what was typed, **never** the password) so the form is not cleared. + +- **Early exit:** if `isSetupComplete()` is already true, it calls `redirect("/login")` + before validating and before spending time on bcrypt (the action ID is still in the + `/setup` bundle and can be called by hand). +- **Validates:** `setupSchema` on the `instanceName`, `name`, `email` and `password` + fields. On failure it returns the first zod error message. +- **Authorization:** there is no session yet. The protection is that setup can only be + completed **once**. Checking `setupCompletedAt` again inside the transaction is **not + enough on its own** (under READ COMMITTED, two simultaneous submissions would both pass + it), so the transaction starts with an advisory lock that serializes submissions. The + reasoning is in [`DECISIONES.md`](../DECISIONES.md) (setup advisory lock entry). +- **Database** (`completeInstanceSetup`, all inside `prisma.$transaction`): - `pg_advisory_xact_lock(hashtext('orgtable:setup'))`; - - relee `InstanceConfig` (`id: "singleton"`); si ya hay `setupCompletedAt`, devuelve - `{ ok: false, reason: "ALREADY_SETUP" }` (no lanza); - - crea el `User` con `role: "ADMIN"` y la contraseña hasheada - (`hashPassword`, email normalizado a minúsculas); - - hace `upsert` de `InstanceConfig` fijando `instanceName` y - `setupCompletedAt`, y devuelve `{ ok: true, userId }`. -- **Efectos:** + - reads `InstanceConfig` (`id: "singleton"`) again; if `setupCompletedAt` is already + set, it returns `{ ok: false, reason: "ALREADY_SETUP" }` (it does not throw); + - creates the `User` with `role: "ADMIN"` and the hashed password (`hashPassword`, + email lower-cased); + - upserts `InstanceConfig` with `instanceName` and `setupCompletedAt`, and returns + `{ ok: true, userId }`. +- **Effects:** - `ALREADY_SETUP` → `redirect("/login")`; - - ante una excepción (BD caída, email duplicado…) registra la causa en el log sin - FormData ni email (`describeSetupError`) y devuelve - `{ error: "No se pudo completar la configuración. Inténtalo de nuevo.", values }`; - - en éxito crea la sesión (`createSession(userId)`), audita `setup.completed` y - `redirect("/boards")`. + - on an exception (database down, duplicate email…) it logs the cause without the + FormData or the email (`describeSetupError`) and returns a generic "could not + complete the setup" error together with `values`; + - on success it creates the session (`createSession(userId)`), audits + `setup.completed` and calls `redirect("/boards")`. -> Detalle de implementación: `redirect()` se llama **fuera** del `try/catch`, -> porque internamente lanza la excepción `NEXT_REDIRECT` de Next que no debe ser -> capturada por el `catch` de la transacción. +> Implementation detail: `redirect()` is called **outside** the `try/catch`, because it +> throws Next's internal `NEXT_REDIRECT` exception, which the transaction's `catch` must +> not swallow. -Ver [docs/authentication.md](../authentication.md) y +See [docs/authentication.md](../authentication.md) and [docs/database.md](../database.md). --- @@ -139,7 +142,7 @@ Ver [docs/authentication.md](../authentication.md) y ### `login` -**Archivo:** `app/lib/actions/auth.ts` +**File:** `app/lib/actions/auth.ts` ```ts export type AuthState = { error?: string; values?: { email: string } }; @@ -150,158 +153,157 @@ export async function login( ): Promise<AuthState> ``` -Inicio de sesión. Diseñada para `useActionState` (recibe `_prev`, devuelve -`AuthState`). Los errores devuelven también `values` (el email tecleado, **nunca** la -contraseña) para repintar el formulario. - -- **Límites de intentos:** por IP (solo si hay IP fiable, `TRUSTED_PROXY_HOPS > 0`) - antes de validar, y por cuenta (email normalizado) antes de verificar; el de cuenta se - reinicia con un login correcto. Si salta uno, devuelve - `{ error: "Demasiados intentos. Inténtalo de nuevo en N s.", values }`. Valores y - porqué en el [ADR 0009](../decisions/0009-in-memory-rate-limit.md). -- **Valida:** `loginSchema` sobre `email` y `password`. Si falla, devuelve - `{ error: "Revisa el email y la contraseña.", values }`. -- **Autorización:** ninguna previa (es la propia puerta de entrada). -- **Base de datos:** busca el `User` por email (normalizado a minúsculas) y verifica - la contraseña con `verifyPasswordOrDummy` (`app/lib/auth.ts`): si el usuario no - existe, compara contra un hash bcrypt ficticio válido del mismo coste, para **no - filtrar por tiempo** si un email está registrado. Si no hay usuario o la contraseña no - es válida, devuelve `{ error: "Credenciales incorrectas.", values }`. -- **Efectos:** audita `auth.login_failed` (sin el email tecleado), - `auth.login_rate_limited` (una vez por ventana bloqueada) o `auth.login`; en éxito, - `createSession(user.id)` y `redirect("/boards")`. - -Paso a paso en [docs/authentication.md](../authentication.md#login). +Signs a person in. Built for `useActionState` (receives `_prev`, returns `AuthState`). +Errors also return `values` (the typed email, **never** the password) to refill the form. + +- **Attempt limits:** per IP (only when the IP is trustworthy, `TRUSTED_PROXY_HOPS > 0`) + before validating, and per account (normalized email) before verifying; the account + limit resets after a successful login. If either limit is hit, it returns a "too many + attempts, try again in N s" error with `values`. Values and reasoning are in + [ADR 0009](../decisions/0009-in-memory-rate-limit.md). +- **Validates:** `loginSchema` on `email` and `password`. On failure it returns a + "check your email and password" error with `values`. +- **Authorization:** none beforehand (this is the front door). +- **Database:** looks up the `User` by email (lower-cased) and checks the password with + `verifyPasswordOrDummy` (`app/lib/auth.ts`): if the user does not exist, it compares + against a valid dummy bcrypt hash of the same cost, so **timing does not reveal** + whether an email is registered. If there is no user or the password is wrong, it + returns an "incorrect credentials" error with `values`. +- **Effects:** audits `auth.login_failed` (without the typed email), + `auth.login_rate_limited` (once per blocked window) or `auth.login`. On success it calls + `createSession(user.id)`, restores the `locale` cookie from `User.locale` (the + account's language wins over the browser's) and calls `redirect("/boards")`. + +Step by step in [docs/authentication.md](../authentication.md#login). ### `logout` -**Archivo:** `app/lib/actions/auth.ts` +**File:** `app/lib/actions/auth.ts` ```ts export async function logout(): Promise<void> ``` -Cierra la sesión. No recibe parámetros. +Ends the session. Takes no parameters. -- **Valida:** nada. -- **Autorización:** ninguna (cerrar sesión es seguro siempre). -- **Base de datos:** ninguna mutación de datos de dominio; `deleteSession` - elimina la cookie de sesión. -- **Efectos:** `deleteSession()` y `redirect("/login")`. +- **Validates:** nothing. +- **Authorization:** none (signing out is always safe). +- **Database:** no change to domain data; `deleteSession` removes the session and its + cookie. +- **Effects:** `deleteSession()`, audits `auth.logout` and calls `redirect("/login")`. -Ver [docs/authentication.md](../authentication.md). +See [docs/authentication.md](../authentication.md). --- -## Tableros +## Boards ### `createBoard` -**Archivo:** `app/lib/actions/boards.ts` +**File:** `app/lib/actions/boards.ts` ```ts export async function createBoard(formData: FormData): Promise<void> ``` -- **Autorización:** `requireUser()` (exige sesión válida; si no, redirige a +- **Authorization:** `requireUser()` (requires a valid session; otherwise redirects to `/login`). -- **Valida:** `boardTitleSchema` sobre el campo `title`. Si falla, **retorna sin - hacer nada** (no muestra error). -- **Base de datos:** crea el `Board` con `ownerId = user.id` y, en la misma - operación anidada, un `BoardMember` con `role: "OWNER"`. El creador queda como - dueño y, a la vez, como miembro del tablero — la autorización siempre se - comprueba contra `board_members`. -- **Efectos:** `revalidatePath("/boards")` y `redirect(\`/board/${board.id}\`)`. +- **Validates:** `boardTitleSchema` on the `title` field. On failure it **returns without + doing anything** (no error is shown). +- **Database:** creates the `Board` with `ownerId = user.id` and, in the same nested + write, a `BoardMember` with `role: "OWNER"`. The creator is both owner and member of the + board; authorization is always checked against `board_members`. +- **Effects:** `revalidatePath("/boards")` and `redirect(\`/board/${board.id}\`)`. ### `renameBoard` -**Archivo:** `app/lib/actions/boards.ts` +**File:** `app/lib/actions/boards.ts` ```ts export async function renameBoard(formData: FormData): Promise<void> ``` -- **Autorización:** `requireBoardAccess(boardId)` (exige ser miembro del tablero; - si no, redirige a `/boards`). -- **Valida:** `boardTitleSchema` sobre `title`. Si falla, retorna sin hacer nada. -- **Base de datos:** `board.update` cambiando el `title`. -- **Efectos:** `revalidatePath(\`/board/${boardId}\`)`. Sin redirect. +- **Authorization:** `requireBoardAccess(boardId)` (requires board membership; otherwise + redirects to `/boards`). +- **Validates:** `boardTitleSchema` on `title`. On failure it returns without doing + anything. +- **Database:** `board.update` changing the `title`. +- **Effects:** `revalidatePath(\`/board/${boardId}\`)`. No redirect. ### `deleteBoard` -**Archivo:** `app/lib/actions/boards.ts` +**File:** `app/lib/actions/boards.ts` ```ts export async function deleteBoard(formData: FormData): Promise<void> ``` -- **Autorización:** `requireUser()` **+ solo dueño**. A diferencia de las demás, - no usa `requireBoardAccess`: usa `board.deleteMany({ where: { id, ownerId: - user.id } })`, que solo borra si el usuario es el dueño y **no falla** si no - coincide (no hay error visible para no-dueños). -- **Valida:** nada. -- **Base de datos:** `deleteBoardWithAttachments` (`app/lib/services/attachments.ts`), - que hace el `board.deleteMany` y, tras el commit, borra los ficheros de los - adjuntos. Listas y tarjetas se borran en cascada (ver el esquema en - [docs/database.md](../database.md) y [docs/attachments.md](../attachments.md)). -- **Efectos:** si se borró, `revalidateBoard(boardId)` (avisa a las pestañas - abiertas); siempre `revalidatePath("/boards")` y `redirect("/boards")`. +- **Authorization:** `requireUser()` **+ owner only**. Unlike the others, it does not use + `requireBoardAccess`: it uses `board.deleteMany({ where: { id, ownerId: user.id } })`, + which deletes only if the user is the owner and **does not fail** otherwise (non-owners + see no error). +- **Validates:** nothing. +- **Database:** `deleteBoardWithAttachments` (`app/lib/services/attachments.ts`), which + runs the `board.deleteMany` and, after the commit, deletes the attachment files. Lists + and cards are deleted by cascade (see the schema in [docs/database.md](../database.md) + and [docs/attachments.md](../attachments.md)). +- **Effects:** if the board was deleted, `revalidateBoard(boardId)` (notifies open tabs); + always `revalidatePath("/boards")` and `redirect("/boards")`. --- -## Listas +## Lists ### `createList` -**Archivo:** `app/lib/actions/lists.ts` +**File:** `app/lib/actions/lists.ts` ```ts export async function createList(formData: FormData): Promise<void> ``` -- **Autorización:** `requireBoardAccess(boardId)`. -- **Valida:** `listTitleSchema` sobre `title`. Si falla, retorna sin hacer nada. -- **Base de datos:** `createListAtEnd` (`app/lib/services/lists.ts`): en una - transacción bloquea el tablero (`FOR UPDATE`), busca la última lista y crea la - nueva con `positionAtEnd(last?.position ?? null)` (*fractional indexing*, se añade - al final). -- **Efectos:** `revalidatePath(\`/board/${boardId}\`)`. +- **Authorization:** `requireBoardAccess(boardId)`. +- **Validates:** `listTitleSchema` on `title`. On failure it returns without doing + anything. +- **Database:** `createListAtEnd` (`app/lib/services/lists.ts`): inside a transaction it + locks the board (`FOR UPDATE`), finds the last list and creates the new one with + `positionAtEnd(last?.position ?? null)` (*fractional indexing*, appended at the end). +- **Effects:** `revalidatePath(\`/board/${boardId}\`)`. ### `renameList` -**Archivo:** `app/lib/actions/lists.ts` +**File:** `app/lib/actions/lists.ts` ```ts export async function renameList(formData: FormData): Promise<void> ``` -- **Autorización:** primero resuelve el `boardId` de la lista - (`list.findUnique`); si la lista no existe, retorna. Luego - `requireBoardAccess(list.boardId)`. -- **Valida:** `listTitleSchema` sobre `title`. Si falla, retorna sin hacer nada. -- **Base de datos:** `list.update` cambiando el `title`. -- **Efectos:** `revalidatePath(\`/board/${list.boardId}\`)`. +- **Authorization:** first resolves the list's `boardId` (`list.findUnique`); if the list + does not exist, it returns. Then `requireBoardAccess(list.boardId)`. +- **Validates:** `listTitleSchema` on `title`. On failure it returns without doing + anything. +- **Database:** `list.update` changing the `title`. +- **Effects:** `revalidatePath(\`/board/${list.boardId}\`)`. ### `deleteList` -**Archivo:** `app/lib/actions/lists.ts` +**File:** `app/lib/actions/lists.ts` ```ts export async function deleteList(formData: FormData): Promise<void> ``` -- **Autorización:** resuelve `boardId` de la lista; si no existe, retorna. Luego - `requireBoardAccess(list.boardId)`. -- **Valida:** nada. -- **Base de datos:** `deleteListWithAttachments` (`app/lib/services/attachments.ts`): - `list.delete` y, tras el commit, los ficheros de los adjuntos. Las tarjetas de la - lista se borran en cascada (`onDelete: Cascade` en el esquema, ver - [docs/database.md](../database.md)). -- **Efectos:** `revalidatePath(\`/board/${list.boardId}\`)`. +- **Authorization:** resolves the list's `boardId`; if the list does not exist, it + returns. Then `requireBoardAccess(list.boardId)`. +- **Validates:** nothing. +- **Database:** `deleteListWithAttachments` (`app/lib/services/attachments.ts`): + `list.delete` and, after the commit, the attachment files. The list's cards are deleted + by cascade (`onDelete: Cascade` in the schema, see [docs/database.md](../database.md)). +- **Effects:** `revalidatePath(\`/board/${list.boardId}\`)`. ### `reorderList` -**Archivo:** `app/lib/actions/lists.ts` +**File:** `app/lib/actions/lists.ts` ```ts export async function reorderList(input: { @@ -310,80 +312,79 @@ export async function reorderList(input: { }): Promise<{ ok: boolean }> ``` -Acción de **drag & drop**: persiste el reordenamiento de una lista. Recibe un -objeto (no `FormData`) y devuelve `{ ok: boolean }`. +**Drag-and-drop** action: saves a list's new order. It receives an object (not +`FormData`) and returns `{ ok: boolean }`. -- **Autorización:** resuelve `boardId` de la lista; si no existe, devuelve - `{ ok: false }`. Luego `requireBoardAccess(list.boardId)`. -- **Valida:** `reorderListSchema` (zod) antes de cualquier consulta. La nueva clave - de posición la **calcula el cliente** (*fractional indexing*) y el servidor la - valida con `isValidPositionKey`; una clave inválida devuelve `{ ok: false }` y no - se guarda. La regla está en - [docs/drag-and-drop.md](../drag-and-drop.md#validación-de-la-clave-isvalidpositionkey). -- **Base de datos:** `list.update` actualizando únicamente `position` de **una - sola fila**. -- **Efectos:** `revalidatePath(\`/board/${list.boardId}\`)`; devuelve - `{ ok: true }`. Sin redirect. +- **Authorization:** resolves the list's `boardId`; if the list does not exist, it returns + `{ ok: false }`. Then `requireBoardAccess(list.boardId)`. +- **Validates:** `reorderListSchema` (zod) before any query. The **client computes** the + new position key (*fractional indexing*) and the server checks it with + `isValidPositionKey`; an invalid key returns `{ ok: false }` and is not saved. The rule + is described in + [docs/drag-and-drop.md](../drag-and-drop.md#key-validation-isvalidpositionkey). +- **Database:** `list.update` changing only the `position` of **one row**. +- **Effects:** `revalidatePath(\`/board/${list.boardId}\`)`; returns `{ ok: true }`. No + redirect. -Ver [docs/drag-and-drop.md](../drag-and-drop.md). +See [docs/drag-and-drop.md](../drag-and-drop.md). --- -## Tarjetas +## Cards -> Helper interno `boardIdForList(listId)`: dado un `listId`, devuelve el -> `boardId` al que pertenece (para autorizar y revalidar), o `null` si la lista -> no existe. +> Internal helper `boardIdForList(listId)`: given a `listId`, returns the `boardId` it +> belongs to (to authorize and revalidate), or `null` if the list does not exist. ### `createCard` -**Archivo:** `app/lib/actions/cards.ts` +**File:** `app/lib/actions/cards.ts` ```ts export async function createCard(formData: FormData): Promise<void> ``` -- **Autorización:** obtiene `boardId` vía `boardIdForList(listId)`; si es `null`, - retorna. Luego `requireBoardAccess(boardId)`. -- **Valida:** `cardTitleSchema` sobre `title`. Si falla, retorna sin hacer nada. -- **Base de datos:** `createCardAtEnd` (`app/lib/services/cards.ts`): en una - transacción bloquea la lista (`FOR UPDATE`), busca la última tarjeta y crea la - nueva con `positionAtEnd(last?.position ?? null)` (*fractional indexing*, al final - de la lista). -- **Efectos:** `revalidatePath(\`/board/${boardId}\`)`. +- **Authorization:** gets `boardId` through `boardIdForList(listId)`; if it is `null`, it + returns. Then `requireBoardAccess(boardId)`. +- **Validates:** `cardTitleSchema` on `title`. On failure it returns without doing + anything. +- **Database:** `createCardAtEnd` (`app/lib/services/cards.ts`): inside a transaction it + locks the list (`FOR UPDATE`), finds the last card and creates the new one with + `positionAtEnd(last?.position ?? null)` (*fractional indexing*, at the end of the list). +- **Effects:** `revalidatePath(\`/board/${boardId}\`)`. ### `renameCard` -**Archivo:** `app/lib/actions/cards.ts` +**File:** `app/lib/actions/cards.ts` ```ts export async function renameCard(formData: FormData): Promise<void> ``` -- **Autorización:** busca la tarjeta seleccionando `list.boardId`; si no existe, - retorna. Luego `requireBoardAccess(card.list.boardId)`. -- **Valida:** `cardTitleSchema` sobre `title`. Si falla, retorna sin hacer nada. -- **Base de datos:** `card.update` cambiando el `title`. -- **Efectos:** `revalidatePath(\`/board/${card.list.boardId}\`)`. +- **Authorization:** looks up the card selecting `list.boardId`; if it does not exist, it + returns. Then `requireBoardAccess(card.list.boardId)`. +- **Validates:** `cardTitleSchema` on `title`. On failure it returns without doing + anything. +- **Database:** `card.update` changing the `title`. +- **Effects:** `revalidatePath(\`/board/${card.list.boardId}\`)`. ### `deleteCard` -**Archivo:** `app/lib/actions/cards.ts` +**File:** `app/lib/actions/cards.ts` ```ts export async function deleteCard(formData: FormData): Promise<void> ``` -- **Autorización:** busca la tarjeta seleccionando `list.boardId`; si no existe, - retorna. Luego `requireBoardAccess(card.list.boardId)`. -- **Valida:** nada. -- **Base de datos:** `deleteCardWithAttachments` (`app/lib/services/attachments.ts`): - `card.delete` y, tras el commit, los ficheros de los adjuntos. -- **Efectos:** `revalidatePath(\`/board/${card.list.boardId}\`)`. +- **Authorization:** looks up the card selecting `list.boardId`; if it does not exist, it + returns. Then `requireBoardAccess(card.list.boardId)`. +- **Validates:** nothing. +- **Database:** `deleteCardWithAttachments` (`app/lib/services/attachments.ts`): + `card.delete` and, after the commit, the attachment files. +- **Effects:** `revalidatePath(\`/board/${card.list.boardId}\`)`. ### `moveCard` -**Archivo:** `app/lib/actions/cards.ts` +**File:** `app/lib/actions/cards.ts` ```ts export async function moveCard(input: { @@ -393,89 +394,101 @@ export async function moveCard(input: { }): Promise<{ ok: boolean }> ``` -Acción de **drag & drop**: mueve una tarjeta, que puede cambiar de lista y/o de -posición. Recibe un objeto (no `FormData`) y devuelve `{ ok: boolean }`. +**Drag-and-drop** action: moves a card, which may change list and/or position. It +receives an object (not `FormData`) and returns `{ ok: boolean }`. -- **Autorización:** obtiene `boardId` de la lista destino vía - `boardIdForList(input.toListId)`; si es `null`, devuelve `{ ok: false }`. Luego - `requireBoardAccess(boardId)`. -- **Valida:** `moveCardSchema` (zod) antes de cualquier consulta. La nueva clave de - posición la **calcula el cliente** (*fractional indexing*) y el servidor la valida - con `isValidPositionKey`; una clave inválida devuelve `{ ok: false }` y no se - guarda. La regla está en - [docs/drag-and-drop.md](../drag-and-drop.md#validación-de-la-clave-isvalidpositionkey). -- **Base de datos:** un único `card.update` que fija `listId: toListId` y - `position`. -- **Efectos:** `revalidatePath(\`/board/${boardId}\`)`; devuelve `{ ok: true }`. - Sin redirect. +- **Authorization:** gets the target list's `boardId` through + `boardIdForList(input.toListId)`; if it is `null`, it returns `{ ok: false }`. Then + `requireBoardAccess(boardId)`, and `cardBelongsToBoard` checks that the card belongs to + that same board (prevents moving a card from a board you cannot edit). +- **Validates:** `moveCardSchema` (zod) before any query. The **client computes** the new + position key (*fractional indexing*) and the server checks it with + `isValidPositionKey`; an invalid key returns `{ ok: false }` and is not saved. The rule + is described in + [docs/drag-and-drop.md](../drag-and-drop.md#key-validation-isvalidpositionkey). +- **Database:** a single `card.update` that sets `listId: toListId` and `position`. +- **Effects:** `revalidatePath(\`/board/${boardId}\`)`; returns `{ ok: true }`. No + redirect. -Ver [docs/drag-and-drop.md](../drag-and-drop.md). +See [docs/drag-and-drop.md](../drag-and-drop.md). --- -## Multiusuario (Hito 3) - -Toda mutación que ya pasaba por `requireBoardAccess` ahora **comprueba además el -rol** con los ayudantes de `app/lib/roles.ts` (`canEditBoard`, `canManageBoard`). -Si el rol no alcanza, la acción no hace nada (formularios) o devuelve `{ ok: false }` -(drag & drop). Ver [docs/authorization.md](../authorization.md). - -### Miembros de tablero — `app/lib/actions/members.ts` - -Solo el **propietario** (`OWNER`) del tablero. - -- **`addBoardMember(_prev, formData)`** — `{ boardId, email, role }`. Valida con - `addMemberSchema`. Busca un usuario existente por email; si no existe, devuelve un - error. Crea la fila en `board_members`. Retorno: `{ error?, message?, values? }` para - `useActionState`; `values` (`{ email, role }` enviados) solo viene con `error`, para - que el formulario no se vacíe al fallar. -- **`updateBoardMemberRole(formData)`** — `{ boardId, userId, role }`. Cambia el rol - de un miembro. No permite tocar el rol del dueño del tablero. Si el usuario no es - miembro o ya tenía ese rol, no hace nada (ni auditoría ni notificación). -- **`removeBoardMember(formData)`** — `{ boardId, userId }`. Quita a un miembro y sus - asignaciones en las tarjetas del tablero (`removeMemberFromBoard`, una transacción). No - permite quitar al dueño. Si el usuario no era miembro, no audita ni notifica. - -Las tres llaman a `revalidateBoard(boardId)`, así que los demás miembros conectados ven -el cambio en vivo. - -### Administración de usuarios — `app/lib/actions/admin.ts` - -Solo el **administrador global** (`requireAdmin`). - -- **`createUser(_prev, formData)`** — `{ name, email, password, role }`. Valida con - `createUserSchema`, comprueba unicidad del email, hashea la contraseña y crea la - cuenta. Retorno: `{ error?, field?, message?, values? }` para `useActionState`; - `field` (`"email"`) marca el campo del error y `values` (`{ name, email, role }`, - **nunca** la contraseña) solo viene con `error`, para que el formulario no se vacíe. -- **`updateUserRole(formData)`** — `{ userId, role }`. Cambia el rol global. No puedes - cambiar tu propio rol. Devuelve el resultado vía redirección con query. -- **`resetUserPassword(formData)`** — `{ userId, password }`. Restablece la - contraseña y, en la misma transacción (`resetPasswordAndRevokeAccess`), revoca todas - las sesiones del usuario y **borra sus tokens de API** (ver - [docs/admin.md](../admin.md)). Vía redirección con query. -- **`revokeUserSessions(formData)`** — `{ userId }`. Cierra todas las sesiones activas - del usuario al instante; los tokens de API siguen vigentes. Vía redirección con - query. -- **`deleteUser(formData)`** — `{ userId }`. Borra la cuenta. No puedes borrarte a ti - mismo ni borrar a un usuario que es propietario de tableros (cascada). Vía - redirección con query. +## Board roles and administration + +Every mutation that goes through `requireBoardAccess` **also checks the role** with the +helpers in `app/lib/roles.ts` (`canEditBoard`, `canManageBoard`). If the role is not +enough, the action does nothing (forms) or returns `{ ok: false }` (drag and drop). See +[docs/authorization.md](../authorization.md). + +### Board members — `app/lib/actions/members.ts` + +Board **owner** (`OWNER`) only. + +- **`addBoardMember(_prev, formData)`** — `{ boardId, email, role }`. Validates with + `addMemberSchema`. Looks up an existing user by email; if none exists, it returns an + error. Creates the row in `board_members`. Returns `{ error?, message?, values? }` for + `useActionState`; `values` (the submitted `{ email, role }`) only comes with `error`, so + the form is not cleared on failure. +- **`updateBoardMemberRole(formData)`** — `{ boardId, userId, role }`. Changes a member's + role. The board owner's role cannot be changed. If the user is not a member or already + has that role, it does nothing (no audit, no notification). +- **`removeBoardMember(formData)`** — `{ boardId, userId }`. Removes a member and their + card assignments on the board (`removeMemberFromBoard`, one transaction). The owner + cannot be removed. If the user was not a member, nothing is audited or notified. + +All three call `revalidateBoard(boardId)`, so other connected members see the change +live. + +### User administration — `app/lib/actions/admin.ts` + +Global **administrator** only (`requireAdmin`). + +- **`createUser(_prev, formData)`** — `{ name, email, password, role }`. Validates with + `createUserSchema`, checks the email is unique, hashes the password and creates the + account. Returns `{ error?, field?, message?, values? }` for `useActionState`; `field` + (`"email"`) marks the field with the error, and `values` (`{ name, email, role }`, + **never** the password) only comes with `error`, so the form is not cleared. +- **`updateUserRole(formData)`** — `{ userId, role }`. Changes the global role. You cannot + change your own role. The result is reported by redirecting with a query string. +- **`resetUserPassword(formData)`** — `{ userId, password }`. Resets the password and, in + the same transaction (`resetPasswordAndRevokeAccess`), revokes all the user's sessions + and **deletes their API tokens** (see [docs/admin.md](../admin.md)). Reported by + redirecting with a query string. +- **`revokeUserSessions(formData)`** — `{ userId }`. Ends all the user's active sessions + immediately; API tokens remain valid. Reported by redirecting with a query string. +- **`deleteUser(formData)`** — `{ userId }`. Deletes the account. You cannot delete + yourself, nor a user who owns boards (cascade). Reported by redirecting with a query + string. --- -## Véase también +## Sign-up, own account and instance + +| Action | File | Who | What it does | +|---|---|---|---| +| `register` | `actions/register.ts` | Anonymous, when sign-up is open | Creates a `MEMBER` account with `termsAcceptedAt`, signs in and redirects to `/boards`. | +| `updateProfile` | `actions/account.ts` | The person themselves | Name and email; changing the email requires `currentPassword`. | +| `changePassword` | `actions/account.ts` | The person themselves | Checks the current password and ends the other sessions. | +| `revokeSession` / `revokeOtherSessions` | `actions/account.ts` | The person themselves | Ends one of their own sessions, or all except the current one. | +| `deleteAccount` | `actions/account.ts` | The person themselves | With the password: deletes the account and its boards (and files) and redirects to `/login?account=deleted`. Not allowed for the last `ADMIN` account. | +| `saveInstanceSettings` | `actions/instance.ts` | `ADMIN` | Instance name, open or closed sign-up, and the operator's legal details. | + +All of them validate with zod (`registerSchema`, `profileSchema`, `changePasswordSchema`, +`deleteAccountSchema` and `instanceSettingsSchema` in `validators.ts`) and audit the event. + +## See also -- [docs/authentication.md](../authentication.md) — sesiones, `requireUser`, - `requireBoardAccess` y el Data Access Layer. -- [docs/authorization.md](../authorization.md) — modelo de roles y dónde se aplica. -- [docs/drag-and-drop.md](../drag-and-drop.md) — reordenamiento optimista y - *fractional indexing* en el cliente. -- [docs/database.md](../database.md) — esquema Prisma, relaciones y borrado en - cascada. +- [docs/authentication.md](../authentication.md) — sessions, `requireUser`, + `requireBoardAccess` and the Data Access Layer. +- [docs/authorization.md](../authorization.md) — the role model and where it applies. +- [docs/drag-and-drop.md](../drag-and-drop.md) — optimistic reordering and client-side + *fractional indexing*. +- [docs/database.md](../database.md) — Prisma schema, relations and cascade deletes. --- -En resumen: cada Server Action sigue el patrón validar → autorizar → mutar → -revalidar, con dos variantes — las de formulario que reciben `FormData` y suelen -redirigir, y las de drag & drop (`reorderList`, `moveCard`) que reciben un objeto -y devuelven `{ ok: boolean }`. +In short: every Server Action follows the validate → authorize → mutate → revalidate +pattern, in two variants: form actions that receive `FormData` and usually redirect, and +drag-and-drop actions (`reorderList`, `moveCard`) that receive an object and return +`{ ok: boolean }`. diff --git a/docs/architecture.md b/docs/architecture.md index 5b811f6..723c69e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,228 +1,256 @@ -# Arquitectura de orgtable +# orgtable architecture -Esta guía ofrece una visión general del sistema para contribuidores. Describe la -estructura real del repositorio, la frontera entre servidor y cliente, el flujo de -una petición y las convenciones propias de Next.js 16 que conviene tener presentes. +This guide gives contributors an overview of the system. It describes the actual +repository layout, the server/client boundary, the path a request takes, and the +Next.js 16 conventions worth keeping in mind. -> Antes de escribir código, lee también [`AGENTS.md`](../AGENTS.md): esta versión de -> Next.js tiene cambios de ruptura respecto a versiones anteriores, y la documentación -> local en `node_modules/next/dist/docs/` es la fuente de verdad. +> Before writing code, also read [`AGENTS.md`](../AGENTS.md): this version of Next.js has +> breaking changes compared with earlier versions, and the local documentation in +> `node_modules/next/dist/docs/` is the source of truth. -## Visión general +## Overview -orgtable es un clon self-hosted de Trello construido sobre: +orgtable is a self-hosted Trello alternative built on: -| Capa | Tecnología | +| Layer | Technology | | --- | --- | | Framework | Next.js 16 (App Router) | | UI | React 19 | -| ORM | Prisma 7 (con driver adapter `@prisma/adapter-pg`) | -| Base de datos | PostgreSQL | -| Sesiones | JWT stateless (`jose`) en cookie `httpOnly` | -| Drag & drop | `@dnd-kit` + `fractional-indexing` | +| ORM | Prisma 7 (with the `@prisma/adapter-pg` driver adapter) | +| Database | PostgreSQL | +| Sessions | Signed JWT (`jose`) in an `httpOnly` cookie, backed by a `Session` table | +| Drag and drop | `@dnd-kit` + `fractional-indexing` | +| Internationalization | Own helpers on native `Intl`, messages in `locales/` (English and Spanish) | -El modelo de ejecución es **servidor por defecto**: páginas, layouts, lecturas de datos -y mutaciones (Server Actions) se ejecutan en el servidor. La interactividad -(arrastrar tarjetas, formularios con feedback en vivo) se concentra en **islas de -cliente** marcadas con `'use client'`. La persistencia es PostgreSQL a través de -Prisma 7. +The execution model is **server by default**: pages, layouts, data reads and mutations +(Server Actions) run on the server. Interactivity (dragging cards, forms with live +feedback) lives in **client islands** marked with `'use client'`. Persistence is +PostgreSQL through Prisma 7. -## Mapa de carpetas +## Folder map -El código de la aplicación vive bajo `app/`. Árbol real: +Application code lives under `app/`. Abridged tree: ``` app/ -├── layout.tsx # Layout raíz -├── page.tsx # Entrada: decide setup / login / boards +├── layout.tsx # Root layout (theme, CSP nonce, I18nProvider) +├── page.tsx # Entry point: decides setup / login / boards ├── globals.css -├── favicon.ico -├── setup/ # Wizard de configuración inicial (público) -│ ├── page.tsx -│ └── setup-form.tsx # 'use client' -├── (auth)/ -│ └── login/ # Grupo de rutas de autenticación (público) -│ ├── page.tsx -│ └── login-form.tsx # 'use client' -├── (app)/ # Grupo de rutas autenticadas -│ ├── layout.tsx # Frontera de seguridad SEGURA (DAL) -│ ├── boards/page.tsx # Lista de tableros del usuario -│ └── board/[id]/page.tsx # Un tablero concreto (params asíncronos) +├── icon.svg +├── robots.ts # robots.txt depending on the instance +├── setup/ # Initial setup wizard (public) +├── (auth)/ # Public auth routes: login/, register/ +├── (public)/legal/ # Legal pages generated from the operator's data +├── (app)/ # Authenticated route group +│ ├── layout.tsx # SECURE security boundary (DAL) +│ ├── boards/ # The user's boards (+ import/delete) +│ ├── board/[id]/page.tsx # A single board (async params) +│ ├── search/ # Global search +│ ├── settings/ # Profile, sessions, API tokens, appearance, data +│ └── admin/ # users/, audit/, instance/ (admins only) ├── api/ -│ └── health/route.ts # Health check (no toca la DB) +│ ├── health/route.ts # Health check (does not touch the DB) +│ ├── boards/[id]/stream # Real-time SSE stream of a board +│ ├── boards/[id]/export # Board export (JSON) +│ ├── notifications/stream # Per-user SSE stream of notifications +│ ├── attachments/[id] # Authorized attachment download +│ ├── account/export # Download of the user's own data +│ ├── cron/ # reminders, maintenance (Bearer secret) +│ └── v1/ # Public REST API (personal tokens) +├── i18n/ # config, server/client helpers, rich(), formatting ├── components/ -│ └── board/ # Islas de cliente del tablero -│ ├── board-view.tsx # 'use client' — DndContext, estado optimista -│ ├── list-column.tsx -│ ├── card-item.tsx -│ ├── add-list-form.tsx -│ └── add-card-form.tsx +│ ├── board/ # Board client islands (board-view, list-column, +│ │ # card-item, calendar-view, filter-bar, members-panel…) +│ ├── card/ # Card modal +│ └── … # Navbar pieces, language picker, theme toggle, ui/ └── lib/ - ├── prisma.ts # Singleton de PrismaClient (server-only) - ├── session.ts # JWT: encrypt/decrypt, crear/borrar cookie - ├── auth.ts # Hash/verificación de contraseña - ├── dal.ts # Data Access Layer (autorización segura) - ├── ordering.ts # Helpers de fractional indexing - ├── queries.ts # Lecturas (autorización en el WHERE) - ├── validators.ts # Esquemas zod compartidos - ├── types.ts # DTOs servidor → cliente - └── actions/ # Server Actions ('use server') - ├── auth.ts # login / logout - ├── setup.ts # Completar el wizard - ├── boards.ts # Crear / renombrar / borrar tablero - ├── lists.ts # Listas (columnas) - └── cards.ts # Tarjetas (crear, renombrar, borrar, mover) + ├── prisma.ts # PrismaClient singleton (server-only) + ├── session.ts # JWT + Session row, cookie handling + ├── auth.ts # Password hashing and verification + ├── dal.ts # Data Access Layer (secure authorization) + ├── roles.ts # Board role helpers (canEditBoard, canManageBoard) + ├── ordering.ts # Fractional indexing helpers + ├── queries.ts # Reads (authorization in the WHERE clause) + ├── validators.ts # Shared zod schemas + ├── types.ts # Server → client DTOs + ├── realtime/ # Pub/sub bus, SSE, revalidateBoard() + ├── api/ # Public API helpers (Bearer auth, serialization) + ├── services/ # Business logic without request context (testable) + └── actions/ # Server Actions ('use server'): auth, boards, lists, + # cards, labels, checklists, comments, members, … +locales/ # Messages per language (en/, es/); see locales/README.md +proxy.ts # Optimistic auth check + per-request CSP nonce ``` -### Rutas +### Main routes -| Ruta | Acceso | Descripción | +| Route | Access | Description | | --- | --- | --- | -| `/` | mixto | Punto de entrada; redirige según estado de instancia y sesión | -| `/setup` | público | Wizard de configuración inicial (se bloquea tras completarse) | -| `/login` | público | Inicio de sesión (grupo `(auth)`) | -| `/boards` | autenticado | Tableros del usuario (grupo `(app)`) | -| `/board/[id]` | autenticado | Vista de un tablero con listas y tarjetas | -| `/api/health` | público | Health check para docker-compose | - -Los grupos de rutas entre paréntesis — `(auth)` y `(app)` — agrupan páginas sin -añadir segmentos a la URL: sirven para compartir un layout. El layout de `(app)` es la -puerta de entrada al área autenticada. - -### Convenciones de Next.js 16 - -- **`proxy.ts`** (en la raíz del repo) sustituye al antiguo *middleware*. En Next.js 16 - el middleware se llama `proxy` y se ejecuta sobre runtime Node.js. Aquí solo hace un - chequeo de autenticación **optimista** (ver más abajo). -- **`prisma.config.ts`** (raíz) configura el CLI de Prisma 7: ruta del schema, de las - migraciones y la `DATABASE_URL` para Migrate. Prisma 7 no carga `.env` - automáticamente, así que el archivo lo hace a mano con `process.loadEnvFile`. -- **`next.config.ts`** activa `output: "standalone"` para el empaquetado en Docker. - -## Frontera servidor/cliente - -| Es de servidor | Es isla de cliente (`'use client'`) | +| `/` | mixed | Entry point; redirects depending on instance and session state | +| `/setup` | public | Initial setup wizard (locked once completed) | +| `/login` | public | Sign in (`(auth)` group) | +| `/register` | public | Open sign-up, only if the instance allows it | +| `/legal/*` | public | Privacy policy, terms, cookies and legal notice | +| `/boards` | authenticated | The user's boards (`(app)` group) | +| `/board/[id]` | authenticated | A board with its lists and cards | +| `/search` | authenticated | Search across boards and cards | +| `/settings` | authenticated | Account settings | +| `/admin/*` | admin | User management, audit log, instance settings | +| `/api/health` | public | Health check for docker-compose | +| `/api/v1/*` | API token | Public REST API | + +Route groups in parentheses, such as `(auth)` and `(app)`, group pages without adding +segments to the URL: they let pages share a layout. The `(app)` layout is the gateway to +the authenticated area. + +### Next.js 16 files + +- **`proxy.ts`** (repository root) replaces the old *middleware*. In Next.js 16 the + middleware is called `proxy` and runs on the Node.js runtime. Here it only does an + **optimistic** authentication check (see below) and sets the per-request CSP nonce. +- **`prisma.config.ts`** (root) configures the Prisma 7 CLI: path of the schema, of the + migrations, and the `DATABASE_URL` for Migrate. Prisma 7 does not load `.env` + automatically, so the file does it by hand with `process.loadEnvFile`. +- **`next.config.ts`** enables `output: "standalone"` for the Docker build and sets the + security headers. + +## Server/client boundary + +| Server | Client island (`'use client'`) | | --- | --- | -| `page.tsx`, `layout.tsx` | `board-view.tsx`, `list-column.tsx`, `card-item.tsx` | -| Lecturas en `lib/queries.ts` | Formularios con `useActionState` (`login-form`, `setup-form`, `add-*-form`) | -| Server Actions en `lib/actions/*` (`'use server'`) | | -| DAL, prisma, session, auth | | +| `page.tsx`, `layout.tsx` | `board-view.tsx`, `list-column.tsx`, `card-item.tsx`, `card-modal.tsx` | +| Reads in `lib/queries.ts` | Forms with `useActionState` (`login-form`, `setup-form`, `add-*-form`) | +| Server Actions in `lib/actions/*` (`'use server'`) | | +| DAL, prisma, session, auth, services | | -El código sensible (acceso a base de datos, sesiones, secretos) lleva -`import "server-only"` en la primera línea para que el bundler **falle el build** si -alguien lo importa por error desde un componente cliente. Se aplica en -`lib/prisma.ts`, `lib/session.ts`, `lib/dal.ts`, `lib/queries.ts` y `lib/ordering.ts`. +Sensitive code (database access, sessions, secrets) starts with `import "server-only"` so +the bundler **fails the build** if a client component imports it by mistake. This applies +to `lib/prisma.ts`, `lib/session.ts`, `lib/dal.ts`, `lib/queries.ts`, `lib/ordering.ts`, +the services, the real-time bus and the server-side i18n helpers, among others. -El puente entre ambos mundos son **DTOs serializables mínimos** (`lib/types.ts`): -`board/[id]/page.tsx` lee el tablero completo con Prisma y lo proyecta a `BoardDTO` -(solo `id`, `title`, `position` de listas y tarjetas) antes de pasárselo al componente -cliente `BoardView`. El componente nunca recibe entidades de Prisma completas. +The bridge between both worlds is a set of **minimal serializable DTOs** (`lib/types.ts`): +`board/[id]/page.tsx` reads the board with Prisma and projects it to `BoardDTO` before +passing it to the `BoardView` client component. Client components never receive full +Prisma entities. -## Flujo de una petición y autorización en capas +## Request flow and layered authorization -La autorización está deliberadamente **en capas**: el `proxy` es rápido pero optimista, -y el `DAL` es la verificación segura real. Una petición a una ruta privada recorre: +Authorization is deliberately **layered**: the `proxy` is fast but optimistic, and the +`DAL` is the real, secure check. A request to a private route goes through: ``` - Petición HTTP a /board/123 + HTTP request to /board/123 │ ▼ ┌───────────────────────────────┐ - │ proxy.ts (chequeo OPTIMISTA) │ ¿existe la cookie "session"? - │ - solo mira si HAY cookie │ no ─▶ redirect /login - │ - NO valida criptográficamente │ + │ proxy.ts (OPTIMISTIC check) │ does the "session" cookie exist? + │ - only checks THAT a cookie │ no ─▶ redirect /login + │ exists │ + │ - NO cryptographic validation │ └───────────────┬───────────────┘ - │ sí + │ yes ▼ ┌───────────────────────────────┐ - │ app/(app)/layout.tsx (SEGURO) │ isSetupComplete() → si no, /setup - │ - vía DAL (server-only) │ requireUser() → si no, /login - │ - valida el JWT de verdad │ + │ app/(app)/layout.tsx (SECURE) │ isSetupComplete() → otherwise /setup + │ - through the DAL (server-only)│ requireUser() → otherwise /login + │ - really validates the JWT │ + │ and the Session row │ └───────────────┬───────────────┘ - │ usuario válido + │ valid user ▼ ┌───────────────────────────────┐ - │ page.tsx (lectura) │ requireUser() + queries.ts - │ - getBoardWithLists(id, userId)│ (autorización en el WHERE) - │ - proyecta a BoardDTO │ si no existe/sin acceso → notFound() + │ page.tsx (read) │ requireUser() + queries.ts + │ - getBoardWithLists(id, userId)│ (authorization in the WHERE) + │ - projects to BoardDTO │ missing / no access → notFound() └───────────────┬───────────────┘ │ render ▼ ┌───────────────────────────────┐ - │ Isla cliente: BoardView │ drag & drop, estado optimista + │ Client island: BoardView │ drag and drop, optimistic state └───────────────┬───────────────┘ - │ el usuario muta algo + │ the user changes something ▼ ┌───────────────────────────────┐ - │ Server Action ('use server') │ 1. valida con zod - │ p. ej. cards.ts:moveCard │ 2. requireBoardAccess(boardId) - │ │ 3. escribe en Prisma - │ │ 4. revalidatePath(`/board/${id}`) + │ Server Action ('use server') │ 1. validates with zod + │ e.g. cards.ts:moveCard │ 2. requireBoardAccess(boardId) + role + │ │ 3. writes with Prisma + │ │ 4. revalidateBoard(id) └───────────────────────────────┘ ``` -### Detalle de cada capa - -1. **`proxy.ts` — chequeo optimista.** Comprueba únicamente si la cookie `session` - existe (`request.cookies.has("session")`). Si no, y la ruta no es pública - (`/login`, `/setup`), redirige a `/login`. No descifra ni valida el token: solo - evita renderizar rutas privadas a quien claramente no ha iniciado sesión. - -2. **`app/(app)/layout.tsx` — verificación segura.** En cada render llama al DAL: - `isSetupComplete()` (redirige a `/setup` si la instancia no está configurada) y - `requireUser()` (redirige a `/login` si el JWT no es válido). Es la frontera de - seguridad real. - -3. **Página — lectura.** `requireUser()` obtiene el usuario y `lib/queries.ts` lee los - datos. La autorización de lectura está incrustada en el `WHERE` de cada consulta - (`ownerId` o `members.some`), de modo que un usuario nunca puede leer un tablero del - que no es dueño ni miembro. - -4. **Mutaciones — Server Actions.** Toda escritura es una Server Action (`'use server'`) - que sigue el mismo patrón: validar la entrada con un esquema **zod** - (`lib/validators.ts`), autorizar con **`requireBoardAccess(boardId)`** del DAL - (comprueba la fila en `board_members`), escribir con Prisma y por último - **`revalidatePath`** para que la UI se refresque. El drag & drop además mantiene - estado optimista en el cliente y calcula la nueva clave de posición con - *fractional indexing* (`lib/ordering.ts`), de forma que cada movimiento solo - actualiza una fila. - -Para el detalle del modelo de sesión, hashing y por qué el chequeo está dividido en dos -capas, ver [`docs/authentication.md`](./authentication.md). - -## Convenciones de Next.js 16 a tener presentes - -Esta versión del framework introduce cambios de ruptura. Puntos clave al contribuir: - -- **APIs asíncronas.** `params`, `cookies()` y `headers()` son **asíncronos** y deben - esperarse con `await`. Por ejemplo, `board/[id]/page.tsx` hace - `const { id } = await params;`. -- **Mutaciones con Server Actions.** Las escrituras se hacen con Server Actions - (`'use server'`), no con rutas de API. Los formularios las invocan vía la prop - `action`; los formularios cliente usan `useActionState` para mostrar errores. -- **`export const dynamic = "force-dynamic"`.** Las páginas y layouts que dependen de la - base de datos o de la sesión se marcan así para que **no se prerendericen en build**. - Esto es crítico para el build de Docker, que se ejecuta **sin** base de datos: sin - esta marca el build intentaría consultar Postgres y fallaría. Lo aplican `page.tsx` - (raíz), `app/(app)/layout.tsx` y `app/setup/page.tsx`. -- **`output: "standalone"`.** `next.config.ts` genera un servidor Node mínimo en - `.next/standalone` que se ejecuta con `node server.js`, lo que mantiene pequeña la - imagen de producción. -- **`proxy.ts` y `prisma.config.ts`** (descritos arriba) son convenciones nuevas de - esta versión. - -Recuerda: la fuente de verdad es la documentación local en -`node_modules/next/dist/docs/`. Consúltala antes de escribir código nuevo, tal como -indica [`AGENTS.md`](../AGENTS.md). - -## Datos y despliegue - -Para el modelo de datos y las migraciones ver [`docs/database.md`](./database.md); para -el empaquetado en contenedores ver [`docs/docker.md`](./docker.md); y para poner en -marcha una instancia ver [`docs/installation.md`](./installation.md). +### Each layer in detail + +1. **`proxy.ts`: optimistic check.** It only checks whether the `session` cookie exists + (`request.cookies.has("session")`). If it does not and the route is not public + (`/login`, `/setup`, `/register`, `/legal`, `/robots.txt`), it redirects to `/login`. + It neither decrypts nor validates the token: it just avoids rendering private routes + for someone who has clearly not signed in. + +2. **`app/(app)/layout.tsx`: secure check.** On every render it calls the DAL: + `isSetupComplete()` (redirects to `/setup` if the instance is not configured) and + `requireUser()` (redirects to `/login` if the JWT or its `Session` row is not valid). + This is the real security boundary. + +3. **Page: read.** `requireUser()` returns the user and `lib/queries.ts` reads the data. + Read authorization is built into the `WHERE` of every query (`ownerId` or + `members.some`), so a user can never read a board they neither own nor belong to. + +4. **Mutations: Server Actions.** Every write is a Server Action (`'use server'`) that + follows the same pattern: validate the input with a **zod** schema + (`lib/validators.ts`), authorize with **`requireBoardAccess(boardId)`** from the DAL + (which checks the `board_members` row and returns the role) plus the role helpers in + `lib/roles.ts`, write with Prisma and finally call **`revalidateBoard()`**, which + revalidates the route and publishes a real-time event. Drag and drop also keeps + optimistic state on the client and computes the new position key with *fractional + indexing* (`lib/ordering.ts`), so each move updates a single row. + +For the session model, hashing, and why the check is split into two layers, see +[`docs/authentication.md`](./authentication.md). For roles, see +[`docs/authorization.md`](./authorization.md). + +## Internationalization + +The interface is available in English (default and fallback) and Spanish. Messages live +in `locales/<code>/<area>.json`; the helpers are in `app/i18n/`. The language of a request +comes from the `locale` cookie set by the language picker, otherwise from +`Accept-Language`, otherwise English. `User.locale` stores the account's choice: it +restores the cookie on sign-in and decides the language of the notifications written for +that person. The picker sits in the footer of the public pages and in +Settings › Appearance. The public REST API answers in English only. To add a language, +see [`locales/README.md`](../locales/README.md). + +## Next.js 16 conventions to keep in mind + +This version of the framework introduces breaking changes. Key points when contributing: + +- **Async APIs.** `params`, `searchParams`, `cookies()` and `headers()` are **async** and + must be awaited. For example, `board/[id]/page.tsx` does `const { id } = await params;`. +- **Mutations with Server Actions.** Writes from the UI go through Server Actions + (`'use server'`), not API routes. Forms call them through the `action` prop; client + forms use `useActionState` to show errors. Route handlers are used for streams, + downloads, cron endpoints and the public API. +- **`export const dynamic = "force-dynamic"`.** Pages and layouts that depend on the + database or on the session are marked this way so they are **not prerendered at build + time**. This is critical for the Docker build, which runs **without** a database: + without this flag the build would try to query Postgres and fail. It is used by the + root `page.tsx`, `app/(app)/layout.tsx` and `app/setup/page.tsx`. +- **`output: "standalone"`.** `next.config.ts` produces a minimal Node server in + `.next/standalone` that runs with `node server.js`, which keeps the production image + small. +- **`proxy.ts` and `prisma.config.ts`** (described above) are new conventions in this + version. + +Remember: the source of truth is the local documentation in `node_modules/next/dist/docs/`. +Read it before writing new code, as [`AGENTS.md`](../AGENTS.md) says. + +## Data and deployment + +For the data model and migrations see [`docs/database.md`](./database.md); for container +packaging see [`docs/docker.md`](./docker.md); and to get an instance running see +[`docs/installation.md`](./installation.md). --- -**Resumen:** orgtable es una app Next.js 16 servidor-por-defecto con islas de cliente, -autorización en capas (proxy optimista → DAL seguro → Server Actions validadas) y -persistencia en PostgreSQL vía Prisma 7. +**Summary:** orgtable is a server-first Next.js 16 app with client islands, layered +authorization (optimistic proxy → secure DAL → validated Server Actions) and persistence +in PostgreSQL through Prisma 7. diff --git a/docs/attachments.md b/docs/attachments.md index 0b30226..4d1512c 100644 --- a/docs/attachments.md +++ b/docs/attachments.md @@ -1,89 +1,88 @@ -# Adjuntos en tarjetas +# Card attachments -Cada tarjeta puede tener archivos adjuntos (hasta **10 MB** por archivo). El binario se -guarda en **disco**; en la base de datos solo viven los metadatos (nombre original, -nombre en disco, tipo MIME, tamaño). +Each card can have file attachments (up to **10 MB** per file). The binary is stored on +**disk**; only the metadata (original name, stored name, MIME type, size) lives in the +database. -El límite de 10 MB es `MAX_ATTACHMENT_BYTES` en `app/lib/storage.ts`. La subida viaja -en el cuerpo de una Server Action, cuyo tamaño máximo lo fija `serverActions.bodySizeLimit` -en `next.config.ts`: ese valor tiene que quedar algo por encima de los 10 MB (el -`FormData` añade cabeceras), o Next rechazará el archivo antes de llegar a la validación. +The 10 MB limit is `MAX_ATTACHMENT_BYTES` in `app/lib/storage.ts`. The upload travels in +the body of a Server Action, whose maximum size is set by `serverActions.bodySizeLimit` in +`next.config.ts`: that value must stay somewhat above 10 MB (the `FormData` adds headers), +or Next rejects the file before it reaches validation. -## Almacenamiento +## Storage -Los ficheros se guardan en el directorio que indique `ORGTABLE_UPLOAD_DIR`: +Files are stored in the directory given by `ORGTABLE_UPLOAD_DIR`: -- **Producción (Docker):** `/data/uploads`, un **volumen persistente** (`uploads`) - declarado en `docker-compose.yml`. Sobrevive a recreaciones del contenedor y se - incluye en la estrategia de copias de seguridad (ver - [`docs/operations.md`](./operations.md)). -- **Desarrollo local:** si no se define la variable, se usa `./uploads` (ignorado por - git). +- **Production (Docker):** `/data/uploads`, a **persistent volume** (`uploads`) declared + in `docker-compose.yml`. It survives container re-creation and is part of the backup + strategy (see [`docs/operations.md`](./operations.md)). +- **Local development:** if the variable is not set, `./uploads` is used (ignored by git). -Cada archivo se guarda con un **nombre aleatorio** (`crypto.randomUUID()` + extensión -saneada). El nombre original que ve el usuario se conserva aparte en la base de datos. -Las rutas se construyen con `path.basename`, lo que neutraliza cualquier intento de -*path traversal*. La decisión de usar disco local (y no S3) está en +Each file is stored under a **random name** (`crypto.randomUUID()` + a sanitized +extension). The original name the user sees is kept separately in the database. Paths +are built with `path.basename`, which neutralizes any *path traversal* attempt. The +decision to use local disk (rather than S3) is in [ADR 0010](./decisions/0010-local-file-storage.md). -## Subida, descarga y borrado +## Upload, download and deletion -| Operación | Quién | Cómo | +| Operation | Who | How | | --- | --- | --- | -| Subir | EDITOR / OWNER | Server Action `uploadAttachment` (recibe el `File` por `FormData`). Valida tamaño y guarda en disco. | -| Descargar | Cualquier miembro | Route Handler `GET /api/attachments/[id]`. | -| Borrar | EDITOR / OWNER | Server Action `deleteAttachment` (borra fila y fichero). | - -### Borrado en cascada y ficheros - -Borrar una tarjeta, una lista o un tablero (desde la interfaz o con `DELETE` en la API -v1) borra en cascada sus filas `Attachment`, pero la base de datos no toca el disco. Por -eso esos borrados pasan por [`app/lib/services/attachments.ts`](../app/lib/services/attachments.ts): -lee los nombres en disco y borra en la base de datos dentro de una transacción y, **solo -después del commit**, borra los ficheros. Si la transacción falla, no se toca el disco. - -Antes de leer, la transacción bloquea con `FOR UPDATE` las filas padre (tablero, sus -listas y sus tarjetas, según lo que se borre). Sin ese bloqueo, una subida que se -confirmara entre la lectura y el borrado desaparecía en cascada con su fichero fuera de -la lista: huérfano en disco y subida respondida como correcta. Con él, una subida en -curso termina antes de la lectura (su fichero se borra con los demás) y una posterior -falla por la clave ajena, así que `uploadAttachment` borra su fichero y relanza el +| Upload | EDITOR / OWNER | Server Action `uploadAttachment` (receives the `File` through `FormData`). Validates the size and writes to disk. | +| Download | Any member | Route Handler `GET /api/attachments/[id]`. | +| Delete | EDITOR / OWNER | Server Action `deleteAttachment` (deletes the row and the file). | + +### Cascading deletes and files + +Deleting a card, a list or a board (from the interface or with `DELETE` in the v1 API) +cascades to its `Attachment` rows, but the database does not touch the disk. That is why +these deletions go through +[`app/lib/services/attachments.ts`](../app/lib/services/attachments.ts): it reads the +stored names and deletes in the database inside a transaction and, **only after the +commit**, deletes the files. If the transaction fails, the disk is left untouched. + +Before reading, the transaction locks the parent rows with `FOR UPDATE` (the board, its +lists and its cards, depending on what is deleted). Without that lock, an upload committed +between the read and the delete would disappear in the cascade while its file was missing +from the list: an orphan on disk and an upload reported as successful. With the lock, an +in-flight upload finishes before the read (its file is deleted with the rest) and a later +one fails on the foreign key, so `uploadAttachment` deletes its file and rethrows the error. -`deleteAttachmentFile` nunca lanza: que el fichero ya no exista (`ENOENT`) es normal y -se ignora; cualquier otro error (permisos, volumen de solo lectura…) se registra con -`console.error` (`[storage] no se pudo borrar el adjunto`) porque deja un fichero -huérfano que hay que borrar a mano. Si falla el alta de la fila tras guardar el fichero -en una subida, el fichero se borra y el error se relanza. +`deleteAttachmentFile` never throws: a file that no longer exists (`ENOENT`) is normal and +ignored; any other error (permissions, read-only volume…) is logged with `console.error` +(prefix `[storage]`) because it leaves an orphan file that must be deleted by hand. If +creating the row fails after an upload has written the file, the file is deleted and the +error is rethrown. -Los ficheros que quedaron huérfanos antes de este cambio siguen en el volumen; no hay -barrido automático. +Files orphaned before this behavior was introduced remain on the volume; there is no +automatic sweep. -Piezas: +Components: -- [`app/lib/storage.ts`](../app/lib/storage.ts) — guardar/leer/borrar en disco y el - límite de tamaño. -- [`app/lib/services/attachments.ts`](../app/lib/services/attachments.ts) — borrado de - tarjetas, listas y tableros junto con sus ficheros. +- [`app/lib/storage.ts`](../app/lib/storage.ts) — save/read/delete on disk and the size + limit. +- [`app/lib/services/attachments.ts`](../app/lib/services/attachments.ts) — deleting + cards, lists and boards together with their files. - [`app/lib/actions/attachments.ts`](../app/lib/actions/attachments.ts) — Server Actions - con autorización por rol. -- [`app/api/attachments/[id]/route.ts`](../app/api/attachments/[id]/route.ts) — descarga - con autorización. -- El modal de tarjeta (`card-modal.tsx`) muestra la lista, el botón de subir (editores) - y el de borrar; la tarjeta muestra un contador de adjuntos. + with role-based authorization. +- [`app/api/attachments/[id]/route.ts`](../app/api/attachments/[id]/route.ts) — authorized + download. +- The card modal (`card-modal.tsx`) shows the list, the upload button (editors) and the + delete button; the card face shows an attachment count. -## Por qué la descarga pasa por un route handler +## Why downloads go through a route handler -Los adjuntos son **privados**: solo los miembros del tablero deben poder verlos. Por eso -no se sirven desde `/public` (que sería accesible por URL sin autenticar), sino desde un -route handler que comprueba **sesión válida + membresía** antes de devolver el binario, -con `Content-Disposition: attachment` y `Cache-Control: private, no-store`. +Attachments are **private**: only board members should see them. So they are not served +from `/public` (reachable by URL without authentication) but from a route handler that +checks **a valid session + membership** before returning the binary, with +`Content-Disposition: attachment` and `Cache-Control: private, no-store`. -Respuestas: +Responses: -| Código | Cuándo | +| Code | When | | --- | --- | -| `200` | Miembro del tablero y el fichero está en disco. | -| `401` | Sin sesión válida. | -| `404` | El adjunto no existe **o** no eres miembro del tablero (mismo cuerpo en ambos casos, para no revelar qué ids existen, como la API v1). También si la fila existe pero el fichero no está en disco; esto se registra con `console.warn` porque indica una inconsistencia. | -| `500` | Cualquier otro error al leer el fichero (permisos, E/S del volumen); se registra con `console.error`. | +| `200` | Board member and the file is on disk. | +| `401` | No valid session. | +| `404` | The attachment does not exist **or** you are not a board member (same body in both cases, so it does not reveal which ids exist, like the v1 API). Also when the row exists but the file is missing from disk; this is logged with `console.warn` because it indicates an inconsistency. | +| `500` | Any other error reading the file (permissions, volume I/O); logged with `console.error`. | diff --git a/docs/authentication.md b/docs/authentication.md index 2809d69..695c055 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,111 +1,136 @@ -# Autenticación +# Authentication -Esta guía explica cómo funciona la autenticación en **orgtable** para quienes contribuyen al proyecto. Cubre las sesiones JWT, el hashing de contraseñas, la doble capa de protección (proxy + DAL), las acciones de `login`/`logout` y los límites conocidos del MVP. +This guide explains how authentication works in **orgtable**, for contributors. It covers +JWT sessions, the session store, password hashing, the two protection layers (proxy + +DAL), the `login`/`logout` actions, open sign-up and the account settings. -Para el contexto general del sistema, consulta [`docs/architecture.md`](./architecture.md). +For the general system context, see [`docs/architecture.md`](./architecture.md). -## Visión general +## Overview -La autenticación se reparte en cinco piezas, cada una con una responsabilidad clara: +Authentication is split into five pieces, each with a clear responsibility: -| Archivo | Responsabilidad | +| File | Responsibility | | --- | --- | -| `app/lib/session.ts` | Firmar/verificar el JWT y gestionar la cookie de sesión. | -| `app/lib/auth.ts` | Hashear y verificar contraseñas con bcryptjs. | -| `proxy.ts` | Chequeo **optimista** de auth a nivel de ruta (existe la cookie). | -| `app/lib/dal.ts` | Verificación **segura** (descifra el JWT y consulta el usuario). | -| `app/lib/actions/auth.ts` | Server Actions de `login` y `logout`. | +| `app/lib/session.ts` | Sign/verify the JWT, manage the `Session` row and the session cookie. | +| `app/lib/auth.ts` | Hash and verify passwords with bcryptjs. | +| `proxy.ts` | **Optimistic** route-level auth check (does the cookie exist?). | +| `app/lib/dal.ts` | **Secure** check (decrypts the JWT, checks the session and loads the user). | +| `app/lib/actions/auth.ts` | `login` and `logout` Server Actions. | -Idea central: el token se firma con un secreto y se guarda en una cookie `httpOnly`. -Se valida criptográficamente en el Data Access Layer (DAL) y, desde el Hito 4, además -contra la **tabla de sesiones** (`Session`), lo que permite revocar sesiones al -instante. Ver [Almacén de sesiones y revocación inmediata](#almacén-de-sesiones-y-revocación-inmediata-hito-4). +Core idea: the token is signed with a secret and stored in an `httpOnly` cookie. It is +validated cryptographically in the Data Access Layer (DAL) and also against the **sessions +table** (`Session`), which makes it possible to revoke sessions immediately. See +[Session store and immediate revocation](#session-store-and-immediate-revocation). -## Sesiones JWT con `jose` +## JWT sessions with `jose` -El archivo `app/lib/session.ts` usa la librería [`jose`](https://github.com/panva/jose) para firmar y verificar tokens JWT. El módulo empieza con `import "server-only"`, de modo que nunca puede acabar en el bundle del cliente. +`app/lib/session.ts` uses the [`jose`](https://github.com/panva/jose) library to sign and +verify JWTs. The module starts with `import "server-only"`, so it can never end up in the +client bundle. -### Configuración +### Configuration -| Constante | Valor | +| Constant | Value | | --- | --- | -| Secreto | `process.env.SESSION_SECRET` (codificado con `TextEncoder`) | -| Algoritmo de firma | `HS256` | -| Nombre de la cookie | `session` | -| Duración (`MAX_AGE_MS`) | 7 días | +| Secret | `process.env.SESSION_SECRET` (encoded with `TextEncoder`), at least 16 characters | +| Signing algorithm | `HS256` | +| Cookie name | `session` | +| Lifetime (`SESSION_MAX_AGE_DAYS`) | 7 days | -El payload del token es mínimo y tipado: +The key is derived when it is used (not when the module is imported, so the build does +not break) and it is **required**: without `SESSION_SECRET`, `TextEncoder().encode(undefined)` +would produce an empty, predictable key that could be used to forge sessions. Failing +loudly is the safe choice, so `getKey()` throws a clear error. + +The token payload is minimal and typed: ```ts -type SessionPayload = { userId: string }; +export type SessionPayload = { userId: string; sid: string }; ``` +`sid` is the id of the session's row in the `Session` table. + ### `encrypt` / `decrypt` -`encrypt` firma el payload con `SignJWT`, estableciendo la cabecera `HS256`, la fecha de emisión (`setIssuedAt`) y una expiración de 7 días (`setExpirationTime("7d")`): +`encrypt` signs the payload with `SignJWT`, setting the `HS256` header, the issue date +(`setIssuedAt`) and a 7-day expiry (`setExpirationTime("7d")`): ```ts -export async function encrypt(payload: SessionPayload): Promise<string> { +async function encrypt(payload: SessionPayload): Promise<string> { return new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime("7d") - .sign(encodedKey); + .sign(getKey()); } ``` -`decrypt` verifica la firma con `jwtVerify`, restringiendo el algoritmo permitido a `HS256`. Devuelve `null` en cualquier caso de error: token ausente, `userId` no es un string, o el token está expirado/manipulado (el `catch` cubre firma inválida y caducidad). +`decrypt` verifies the signature with `jwtVerify`, restricting the allowed algorithm to +`HS256`. It returns `null` on any error: missing token, `userId` or `sid` not strings, or +an expired or tampered token (the `catch` covers an invalid signature and expiry). +`getKey()` is called **outside** the `try`: a missing `SESSION_SECRET` is a configuration +error and must break the request with its message, not turn into "no session". ```ts export async function decrypt( token: string | undefined, ): Promise<SessionPayload | null> { if (!token) return null; + const key = getKey(); try { - const { payload } = await jwtVerify(token, encodedKey, { + const { payload } = await jwtVerify(token, key, { algorithms: ["HS256"], }); - if (typeof payload.userId !== "string") return null; - return { userId: payload.userId }; + if (typeof payload.userId !== "string" || typeof payload.sid !== "string") { + return null; + } + return { userId: payload.userId, sid: payload.sid }; } catch { - // Token inválido, expirado o manipulado. + // Invalid, expired or tampered token. return null; } } ``` -> Restringir `algorithms` a `["HS256"]` en la verificación es importante: evita ataques de confusión de algoritmo (por ejemplo, un token forjado con `alg: none`). +> Restricting `algorithms` to `["HS256"]` during verification matters: it prevents +> algorithm confusion attacks (for example, a token forged with `alg: none`). ### `createSession` / `deleteSession` -`createSession` cifra el `userId` y guarda el token en la cookie `session` con los siguientes flags: +`createSession` creates a row in the `Session` table (with its expiry and the +`user-agent`), signs the JWT with that row's id (`sid`) and stores the token in the +`session` cookie with these flags: -| Flag | Valor | Motivo | +| Flag | Value | Reason | | --- | --- | --- | -| `httpOnly` | `true` | La cookie no es accesible desde JavaScript del cliente. | -| `secure` | `isHttpsRequest(requestHeaders)` | Solo si la petición llegó por HTTPS (primer valor de `X-Forwarded-Proto` igual a `https`, que pone Next o el proxy TLS). No depende de `NODE_ENV`: con él, la instalación por `http://IP:3000` no dejaba entrar porque el navegador descarta una cookie `Secure` recibida por HTTP. Ver [self-hosting.md](./self-hosting.md#https-y-dominio-reverse-proxy). | -| `sameSite` | `"lax"` | Mitiga CSRF permitiendo navegación top-level. | -| `expires` | `Date.now() + 7 días` | Coincide con la expiración del JWT. | -| `path` | `"/"` | Disponible en toda la aplicación. | +| `httpOnly` | `true` | The cookie is not accessible from client-side JavaScript. | +| `secure` | `isHttpsRequest(requestHeaders)` | Only if the request arrived over HTTPS (first value of `X-Forwarded-Proto` equal to `https`, set by Next or by the TLS proxy). It does not depend on `NODE_ENV`: with `NODE_ENV`, an install reached at `http://IP:3000` could not sign in, because the browser drops a `Secure` cookie received over HTTP. See [self-hosting.md](./self-hosting.md#https-and-domain-reverse-proxy). | +| `sameSite` | `"lax"` | Mitigates CSRF while allowing top-level navigation. | +| `expires` | `Date.now() + 7 days` | Matches the JWT expiry. | +| `path` | `"/"` | Available across the whole application. | -Antes de poner la cookie, `createSession` crea una fila en la tabla `Session` (con la -caducidad y el `user-agent`) y firma el JWT con su id (`sid`); ver -[Almacén de sesiones y revocación inmediata](#almacén-de-sesiones-y-revocación-inmediata-hito-4). -El código está en [`app/lib/session.ts`](../app/lib/session.ts); no se copia aquí para -que no se desfase. El porqué de `secure` está en [`DECISIONES.md`](./DECISIONES.md) -(«Cookie de sesión Secure según el esquema real y SESSION_SECRET ruidoso»). +The key is validated before the row is created, so a missing `SESSION_SECRET` never leaves +an orphan session in the database. The code is in +[`app/lib/session.ts`](../app/lib/session.ts); it is not copied here so it cannot drift. +The reasoning behind `secure` is recorded in [`DECISIONES.md`](./DECISIONES.md) (the entry +about the `Secure` session cookie and a noisy `SESSION_SECRET`). -`deleteSession` revoca la fila `Session` de la cookie actual (`revokedAt`) y borra la -cookie. +`deleteSession` revokes the `Session` row of the current cookie (`revokedAt`) and deletes +the cookie. -> **Nota sobre Next.js 16:** `cookies()` de `next/headers` es **asíncrono**. Siempre hay que hacer `const cookieStore = await cookies();` antes de leer o escribir. Olvidar el `await` es un error frecuente al migrar desde versiones anteriores. +> **Next.js 16 note:** `cookies()` and `headers()` from `next/headers` are **async**. +> Always `await` them (`const cookieStore = await cookies();`) before reading or writing. +> Forgetting the `await` is a common mistake when migrating from earlier versions. -## Hashing de contraseñas con bcryptjs +## Password hashing with bcryptjs -El archivo `app/lib/auth.ts` (también `server-only`) gestiona las contraseñas con [`bcryptjs`](https://github.com/dcodeIO/bcrypt.js) y un coste de **12 rounds** (`SALT_ROUNDS = 12`). +`app/lib/auth.ts` (also `server-only`) handles passwords with +[`bcryptjs`](https://github.com/dcodeIO/bcrypt.js) at a cost of **12 rounds** +(`SALT_ROUNDS = 12`). ```ts -const SALT_ROUNDS = 12; +export const SALT_ROUNDS = 12; export async function hashPassword(password: string): Promise<string> { return bcrypt.hash(password, SALT_ROUNDS); @@ -119,45 +144,52 @@ export async function verifyPassword( } ``` -| Función | Qué hace | +| Function | What it does | | --- | --- | -| `hashPassword` | Genera un hash bcrypt (con salt incorporado) a 12 rounds. | -| `verifyPassword` | Compara una contraseña en claro contra un hash existente. | -| `verifyPasswordOrDummy` | Igual, pero si no hay hash (la cuenta no existe) compara contra `DUMMY_PASSWORD_HASH` y devuelve `false`: el login tarda lo mismo exista o no el email. | +| `hashPassword` | Produces a bcrypt hash (with a built-in salt) at 12 rounds. | +| `verifyPassword` | Compares a plain-text password with an existing hash. | +| `verifyPasswordOrDummy` | Same, but if there is no hash (the account does not exist) it compares against `DUMMY_PASSWORD_HASH` and returns `false`: sign-in takes the same time whether the email exists or not. | -### ¿Por qué `bcryptjs` y no `bcrypt`? +### Why `bcryptjs` and not `bcrypt`? -`bcryptjs` es una implementación **en JavaScript puro**, sin dependencias nativas ni compilación. Esto elimina fricción al construir la imagen (no requiere toolchain de compilación, lo que es especialmente cómodo en imágenes Alpine). A cambio es algo más lento que el binding nativo, lo cual es aceptable para el MVP. +`bcryptjs` is a **pure JavaScript** implementation, with no native dependencies and no +compilation. This removes friction when building the image (no compiler toolchain needed, +which is especially convenient on Alpine images). In exchange it is somewhat slower than +the native binding, which is acceptable for this workload. -## Doble capa de protección +## Two layers of protection -orgtable protege las rutas privadas en **dos capas** con propósitos distintos. +orgtable protects private routes in **two layers** with different purposes. ``` -Petición +Request │ ▼ -┌──────────────────────────┐ chequeo OPTIMISTA -│ proxy.ts │ ¿existe la cookie "session"? -│ (a nivel de ruta) │── no ──► redirect /login +┌──────────────────────────┐ OPTIMISTIC check +│ proxy.ts │ does the "session" cookie exist? +│ (route level) │── no ──► redirect /login └────────────┬─────────────┘ - │ sí (o ruta pública) + │ yes (or public route) ▼ -┌──────────────────────────┐ verificación SEGURA -│ DAL — app/lib/dal.ts │ descifra el JWT + consulta el usuario -│ (en layout/página) │── inválido ──► redirect /login +┌──────────────────────────┐ SECURE check +│ DAL: app/lib/dal.ts │ decrypts the JWT + checks the Session +│ (in layout/page) │ + loads the user +│ │── invalid ──► redirect /login └────────────┬─────────────┘ - │ válido + │ valid ▼ - Renderiza la página + Renders the page ``` -### Capa 1: chequeo optimista en `proxy.ts` +### Layer 1: optimistic check in `proxy.ts` -En Next.js 16 el antiguo *middleware* se llama **`proxy`** (runtime Node.js). Aquí el chequeo es **optimista**: solo comprueba si **existe** la cookie `session`, sin validarla criptográficamente. +In Next.js 16 the old *middleware* is called **`proxy`** (Node.js runtime). The check +here is **optimistic**: it only checks whether the `session` cookie **exists**, without +validating it cryptographically. The proxy also sets a per-request Content Security Policy +nonce. ```ts -const PUBLIC_PATHS = ["/login", "/setup"]; +const PUBLIC_PATHS = ["/login", "/setup", "/register", "/legal", "/robots.txt"]; export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; @@ -169,13 +201,16 @@ export function proxy(request: NextRequest) { if (!isPublic && !hasSession) { return NextResponse.redirect(new URL("/login", request.nextUrl)); } - return NextResponse.next(); + // … per-request CSP nonce (app/lib/csp.ts) } ``` -- **Rutas públicas:** `/login` y `/setup` (y sus subrutas). -- Cualquier otra ruta sin cookie `session` se redirige a `/login`. -- El `matcher` excluye `api`, `_next/static`, `_next/image`, `favicon.ico` y archivos `.svg`: +- **Public routes:** `/login`, `/setup`, `/register` (which checks by itself whether + sign-up is open), `/legal` (the privacy policy must be readable **before** creating an + account) and `/robots.txt`, plus their subroutes. +- Any other route without a `session` cookie is redirected to `/login`. +- The `matcher` excludes `api`, `_next/static`, `_next/image`, `favicon.ico` and `.svg` + files: ```ts export const config = { @@ -183,22 +218,27 @@ export const config = { }; ``` -### Capa 2: verificación segura en el DAL +### Layer 2: secure check in the DAL -El Data Access Layer (`app/lib/dal.ts`) es la **única fuente de verdad segura**. Aquí sí se descifra y valida el JWT, y se consulta el usuario en la base de datos. +The Data Access Layer (`app/lib/dal.ts`) is the **only secure source of truth**. This is +where the JWT is actually decrypted and validated, the `Session` row is checked and the +user is loaded from the database. -### ¿Por qué dos capas? +### Why two layers? -| Capa | Comprueba | Coste | Para qué sirve | +| Layer | Checks | Cost | Purpose | | --- | --- | --- | --- | -| `proxy.ts` (optimista) | Solo **existencia** de la cookie | Muy barato, sin BBDD ni criptografía | Evitar empezar a renderizar rutas privadas a quien claramente no ha iniciado sesión. | -| DAL (seguro) | Firma del JWT + usuario en BBDD | Más caro | Fuente de verdad: decide realmente quién es el usuario. | +| `proxy.ts` (optimistic) | Only that the cookie **exists** | Very cheap, no DB and no crypto | Avoid starting to render private routes for someone who has clearly not signed in. | +| DAL (secure) | JWT signature + session + user in the DB | More expensive | Source of truth: decides who the user really is. | -El proxy es un filtro rápido y barato. **No es una garantía de seguridad** (la cookie podría existir pero estar manipulada o expirada). La decisión real de autorización siempre la toma el DAL dentro del layout o página. +The proxy is a fast, cheap filter. **It is not a security guarantee** (the cookie could +exist but be tampered with, expired or revoked). The real authorization decision is always +made by the DAL inside the layout or page. -## El Data Access Layer (`dal.ts`) +## The Data Access Layer (`dal.ts`) -Todas las funciones del DAL están memoizadas con `cache()` de React, de modo que llamarlas varias veces durante el mismo render **no repite la consulta**. El módulo es `server-only`. +All DAL functions are memoized with React's `cache()`, so calling them several times +during the same render **does not repeat the query**. The module is `server-only`. ```ts const INSTANCE_ID = "singleton"; @@ -206,20 +246,32 @@ const INSTANCE_ID = "singleton"; ### `verifySession` -Lee la cookie `session`, la descifra con `decrypt` y devuelve `{ userId }` o `null`. No toca la base de datos. +Reads the `session` cookie, decrypts it with `decrypt` and checks the `Session` row it +points to. It returns `{ userId, sessionId }` or `null`. ```ts export const verifySession = cache(async () => { const token = (await cookies()).get("session")?.value; - const session = await decrypt(token); - if (!session?.userId) return null; - return { userId: session.userId }; + const payload = await decrypt(token); + if (!payload?.userId || !payload.sid) return null; + + const session = await prisma.session.findUnique({ + where: { id: payload.sid }, + select: { userId: true, revokedAt: true, expiresAt: true }, + }); + if (!session) return null; + if (session.userId !== payload.userId) return null; + if (session.revokedAt) return null; + if (session.expiresAt.getTime() < Date.now()) return null; + + return { userId: payload.userId, sessionId: payload.sid }; }); ``` ### `getCurrentUser` -Resuelve la sesión y, si existe, busca el usuario en la base de datos. Devuelve `null` si no hay sesión. +Resolves the session and, if there is one, loads the user from the database. Returns +`null` if there is no session. ```ts export const getCurrentUser = cache(async () => { @@ -231,7 +283,8 @@ export const getCurrentUser = cache(async () => { ### `requireUser` -Igual que `getCurrentUser`, pero **redirige a `/login`** si no hay usuario. Es la función a usar en layouts y páginas protegidas. +Like `getCurrentUser`, but **redirects to `/login`** if there is no user. This is the +function to use in protected layouts and pages. ```ts export const requireUser = cache(async () => { @@ -241,9 +294,11 @@ export const requireUser = cache(async () => { }); ``` -### `getInstanceConfig` e `isSetupComplete` +### `getInstanceConfig` and `isSetupComplete` -`getInstanceConfig` lee la configuración singleton de la instancia (`id: "singleton"`). `isSetupComplete` devuelve `true` si el asistente de configuración inicial ya se completó (existe `setupCompletedAt`). +`getInstanceConfig` reads the singleton instance configuration (`id: "singleton"`). +`isSetupComplete` returns `true` if the initial setup wizard has been completed +(`setupCompletedAt` is set). ```ts export const getInstanceConfig = cache(async () => { @@ -258,7 +313,11 @@ export const isSetupComplete = cache(async () => { ### `requireBoardAccess` -Autorización **por tablero**. Exige primero un usuario válido (`requireUser`) y luego comprueba que sea miembro del tablero consultando `board_members`. Si no hay membresía, redirige a `/boards`. Devuelve el usuario y su `role`. Toda mutación sobre un tablero (listas, tarjetas, etc.) debe pasar por aquí. +**Per-board** authorization. It first requires a valid user (`requireUser`) and then +checks that they are a member of the board by querying `board_members`. Without a +membership it redirects to `/boards`. It returns the user and their `role`. Every mutation +on a board (lists, cards, etc.) must go through it; what the role allows is checked with +the helpers in `app/lib/roles.ts` (see [authorization.md](./authorization.md)). ```ts export const requireBoardAccess = cache(async (boardId: string) => { @@ -271,110 +330,146 @@ export const requireBoardAccess = cache(async (boardId: string) => { }); ``` -| Función | Devuelve | Si falla | +### `requireAdmin` + +Requires the current user to be a **global** instance administrator (`User.role = ADMIN`), +for the admin area (`/admin`). Redirects to `/boards` if not, and to `/login` if there is +no session (through `requireUser`). + +| Function | Returns | On failure | | --- | --- | --- | -| `verifySession` | `{ userId }` o `null` | Devuelve `null` | -| `getCurrentUser` | `User` o `null` | Devuelve `null` | -| `requireUser` | `User` | Redirige a `/login` | -| `getInstanceConfig` | `InstanceConfig` o `null` | Devuelve `null` | +| `verifySession` | `{ userId, sessionId }` or `null` | Returns `null` | +| `getCurrentUser` | `User` or `null` | Returns `null` | +| `requireUser` | `User` | Redirects to `/login` | +| `getInstanceConfig` | `InstanceConfig` or `null` | Returns `null` | | `isSetupComplete` | `boolean` | — | -| `requireBoardAccess` | `{ user, role }` | Redirige a `/boards` | +| `requireBoardAccess` | `{ user, role }` | Redirects to `/boards` | +| `requireAdmin` | `User` | Redirects to `/boards` (or `/login`) | -## Login y logout (`actions/auth.ts`) +## Login and logout (`actions/auth.ts`) -Las Server Actions (`"use server"`) viven en `app/lib/actions/auth.ts`. +The Server Actions (`"use server"`) live in `app/lib/actions/auth.ts`. ### `login` -El código está en [`app/lib/actions/auth.ts`](../app/lib/actions/auth.ts); aquí va el -orden de los pasos y el porqué, sin copiarlo. - -1. **Límite por IP** (`login:<ip>`, 10 intentos cada 10 minutos). Solo se aplica si hay - una IP fiable, es decir, con `TRUSTED_PROXY_HOPS > 0`; con el valor por defecto (0) - no hay límite por IP. -2. **Valida** el `email` y la `password` del `FormData` con `loginSchema` (zod, en - `app/lib/validators`). Si falla, devuelve un error genérico. -3. **Límite por cuenta** (`login-email:<email normalizado>`, 20 intentos cada 15 - minutos). Se cuenta antes de verificar, para que no haya carreras entre peticiones - simultáneas, y se reinicia con un login correcto, así que en la práctica solo suman - los fallos. Cuenta también los emails que no existen, para no revelar cuáles existen. -4. **Busca** al usuario por email (normalizado a minúsculas). -5. **Verifica la contraseña siempre** con `verifyPasswordOrDummy(password, - user?.passwordHash ?? null)` ([`app/lib/auth.ts`](../app/lib/auth.ts)): si la cuenta - no existe, compara contra `DUMMY_PASSWORD_HASH`. -6. Si el usuario no existe o la contraseña no es válida, registra `auth.login_failed` - (con el `userId` si la cuenta existe; **nunca** el email tecleado, que podría ser de - un tercero) y devuelve `"Credenciales incorrectas."`. -7. Si todo es correcto, reinicia el límite por cuenta, crea la sesión - (`createSession`), registra `auth.login` y **redirige a `/boards`**. - -Cuando salta cualquiera de los dos límites, el formulario responde con el tiempo de -espera y se registra `auth.login_rate_limited` **una sola vez por ventana**, no en cada -intento rechazado. Los valores y el riesgo aceptado de cada límite están en -[operations.md](./operations.md#límite-de-intentos-de-login-rate-limiting) y en el +The code is in [`app/lib/actions/auth.ts`](../app/lib/actions/auth.ts); this section gives +the order of the steps and the reasoning, without copying it. + +1. **Per-IP limit** (`login:<ip>`, 10 attempts every 10 minutes). It only applies when + there is a trustworthy IP, that is, with `TRUSTED_PROXY_HOPS > 0`; with the default + (0) there is no per-IP limit. +2. **Validates** `email` and `password` from the `FormData` with `loginSchema` (zod, in + `app/lib/validators`). On failure it returns a generic error. +3. **Per-account limit** (`login-email:<normalized email>`, 20 attempts every 15 + minutes). It is counted before verifying, so concurrent requests cannot race past it, + and it resets on a successful login, so in practice only failures add up. It also + counts emails that do not exist, so it does not reveal which ones do. +4. **Looks up** the user by email (normalized to lower case). +5. **Always verifies the password** with `verifyPasswordOrDummy(password, + user?.passwordHash ?? null)` ([`app/lib/auth.ts`](../app/lib/auth.ts)): if the account + does not exist, it compares against `DUMMY_PASSWORD_HASH`. +6. If the user does not exist or the password is wrong, it records `auth.login_failed` + (with the `userId` if the account exists; **never** the typed email, which could + belong to someone else) and returns a generic "invalid credentials" error. +7. If everything is correct, it resets the per-account limit, creates the session + (`createSession`), restores the language cookie from `User.locale` if the account has + one (the account's language wins over the browser's on any device), records + `auth.login` and **redirects to `/boards`**. + +When either limit is hit, the form answers with the waiting time and +`auth.login_rate_limited` is recorded **once per window**, not on every rejected attempt. +The values and the accepted risk of each limit are in +[operations.md](./operations.md#login-rate-limiting) and in [ADR 0009](./decisions/0009-in-memory-rate-limit.md). -Los errores devuelven también `values: { email }` (nunca la contraseña) para que el -formulario conserve lo tecleado. +Errors also return `values: { email }` (never the password) so the form keeps what was +typed. -> **Protección contra timing attacks:** cuando el usuario no existe, se verifica la -> contraseña contra un **hash ficticio** en lugar de retornar antes, para que el tiempo -> de respuesta sea similar exista o no la cuenta. Ese hash tiene que ser un **hash bcrypt -> válido del mismo coste** (12): con un literal mal formado, `bcrypt.compare` devuelve -> `false` al instante y el tiempo de respuesta revela qué emails tienen cuenta. Pasó con -> el literal anterior, de 59 caracteres; ver [`DECISIONES.md`](./DECISIONES.md) («Login: -> IP de confianza, límite por cuenta y hash ficticio válido»). No compares nunca contra -> un literal inventado: usa `verifyPasswordOrDummy`. El mensaje de error también es -> genérico por el mismo motivo. +> **Timing attack protection:** when the user does not exist, the password is verified +> against a **dummy hash** instead of returning early, so the response time is similar +> whether or not the account exists. That hash must be a **valid bcrypt hash with the +> same cost** (12): with a malformed literal, `bcrypt.compare` returns `false` +> immediately and the response time reveals which emails have an account. This happened +> with the previous 59-character literal; see [`DECISIONES.md`](./DECISIONES.md) (the +> entry about trusted IPs, the per-account limit and a valid dummy hash). Never compare +> against a made-up literal: use `verifyPasswordOrDummy`. The error message is generic +> for the same reason. ### `logout` -Borra la sesión y redirige a `/login`. +Revokes the session, records `auth.logout` and redirects to `/login`. ```ts export async function logout(): Promise<void> { + const session = await verifySession(); await deleteSession(); + if (session) { + await recordAudit({ action: "auth.logout", userId: session.userId }); + } redirect("/login"); } ``` -## Almacén de sesiones y revocación inmediata (Hito 4) +## Open sign-up (`actions/register.ts`) + +If the instance allows it (`isRegistrationOpen()`: sign-up open **and** the operator and +privacy contact filled in), `/register` creates a `MEMBER` account with the date the +policy was accepted (`User.termsAcceptedAt`) and signs the person in. Before creating it, +it applies: + +- a honeypot field; +- 5 sign-ups per hour per IP, if there is a trustworthy IP; +- 30 sign-ups per hour across the whole instance. + +Details and reasons in [privacy.md](./privacy.md#open-sign-up). + +## Your own account (`actions/account.ts`) + +From `/settings`, each person can: + +- change their name and email (changing the email asks for the current password); +- change their password, which **closes their other sessions** and keeps the current one; +- close specific sessions, or all except the current one; +- choose the theme and the interface language in Settings › Appearance (the language is + saved by `actions/locale.ts`); +- download their data (`GET /api/account/export`) or delete their account. + +The logic lives in `app/lib/services/account.ts`, without request context. The id of the +current session comes from `verifySession().sessionId`. -> **Actualización (Hito 4).** El MVP usaba JWT *stateless* sin tabla de sesiones, lo -> que impedía revocar un token al instante. Eso ha cambiado. +## Session store and immediate revocation -Ahora la cookie sigue siendo un JWT firmado, pero **lleva el id de una fila de la -tabla `Session`** (`sid`). El DAL valida el JWT **y además** comprueba esa fila: +The cookie is a signed JWT that **carries the id of a row in the `Session` table** (`sid`). +The DAL validates the JWT **and** checks that row: ``` -verifySession() → descifra el JWT → busca la Session por sid - → null si: no existe · revokedAt ≠ null · expiresAt pasada +verifySession() → decrypts the JWT → looks up the Session by sid + → null if: missing · revokedAt ≠ null · expiresAt in the past ``` -Esto da **revocación inmediata** (toma efecto en la siguiente petición, no a los 7 -días): - -- **`logout`** revoca la fila de la sesión actual (`revokedAt`) y borra la cookie. -- **Restablecer la contraseña** (por el administrador) revoca **todas** las sesiones - del usuario y **borra sus tokens de API**, en una sola transacción: cualquier sesión - abierta y cualquier token dejan de valer al instante (ver - [ADR 0013](./decisions/0013-api-tokens.md)). -- El administrador puede **forzar el cierre de sesión** de un usuario desde - `/admin/users` (acción `revokeUserSessions`). Solo afecta a las sesiones del - navegador: los tokens de API siguen vigentes. -- **Borrar un usuario** elimina sus sesiones en cascada. - -El coste es **una consulta más por petición** (la búsqueda de la sesión), memoizada -por request con `cache()` de React, así que se ejecuta una sola vez por render. La -decisión y el porqué están en +An earlier design used stateless JWTs with no sessions table, which made it impossible to +revoke a token before it expired. The session store gives **immediate revocation** (it +takes effect on the next request, not after 7 days): + +- **`logout`** revokes the current session's row (`revokedAt`) and deletes the cookie. +- **Resetting the password** (by the administrator) revokes **all** the user's sessions + and **deletes their API tokens**, in a single transaction: any open session and any + token stop working immediately (see [ADR 0013](./decisions/0013-api-tokens.md)). +- The administrator can **force a user to sign out** from `/admin/users` (the + `revokeUserSessions` action). It only affects browser sessions: API tokens remain + valid. +- **Deleting a user** deletes their sessions in cascade. + +The cost is **one more query per request** (the session lookup), memoized per request with +React's `cache()`, so it runs once per render. The decision and its reasoning are in [`docs/decisions/0008-session-store.md`](./decisions/0008-session-store.md). -Los cambios de **rol** no necesitan revocar sesiones: el rol se lee fresco de la base -de datos en cada petición, así que un cambio de rol surte efecto de inmediato. +**Role** changes do not need to revoke sessions: the role is read fresh from the database +on every request, so a role change takes effect immediately. --- -**Resumen:** orgtable autentica con un JWT firmado (`jose` + cookie `httpOnly`) cuyo -`sid` apunta a una fila de la tabla `Session`, validada en el DAL; junto con bcryptjs, -el chequeo optimista de `proxy.ts` y la verificación autoritativa del DAL, permite -revocar sesiones al instante (logout, reset de contraseña, cierre forzado). +**Summary:** orgtable authenticates with a signed JWT (`jose` + `httpOnly` cookie) whose +`sid` points to a row in the `Session` table, validated in the DAL. Together with +bcryptjs, the optimistic check in `proxy.ts` and the authoritative check in the DAL, this +allows sessions to be revoked immediately (logout, password reset, forced sign-out). diff --git a/docs/authorization.md b/docs/authorization.md index ff50d3d..a44fd50 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -1,102 +1,99 @@ -# Autorización y roles (multiusuario) +# Authorization and roles -orgtable tiene **dos niveles de roles**, independientes entre sí: +orgtable has **two independent levels of roles**: -- **Rol global** (`User.role`): `ADMIN` o `MEMBER`. Decide quién administra la - instancia (crear cuentas, cambiar roles, restablecer contraseñas, borrar usuarios). -- **Rol de tablero** (`BoardMember.role`): `OWNER`, `EDITOR` o `VIEWER`. Decide qué - puede hacer cada persona **dentro de un tablero concreto**. +- **Global role** (`User.role`): `ADMIN` or `MEMBER`. Decides who administers the + instance (creates accounts, changes roles, resets passwords, deletes users). +- **Board role** (`BoardMember.role`): `OWNER`, `EDITOR` or `VIEWER`. Decides what each + person can do **inside a specific board**. -Ambos modelos existían en el esquema desde el Hito 0 (ver `docs/database.md`); el -Hito 3 los pone en uso. +Both models are part of the schema (see `docs/database.md`) and every mutation enforces +them. -## Jerarquía de roles de tablero +## Board role hierarchy ``` VIEWER < EDITOR < OWNER ``` -| Acción | VIEWER | EDITOR | OWNER | +| Action | VIEWER | EDITOR | OWNER | | --- | :---: | :---: | :---: | -| Ver el tablero, listas y tarjetas | ✅ | ✅ | ✅ | -| Crear/editar/mover listas, tarjetas y checklists | — | ✅ | ✅ | -| Asignar etiquetas existentes a tarjetas | — | ✅ | ✅ | -| Crear/borrar etiquetas del tablero | — | — | ✅ | -| Renombrar el tablero | — | — | ✅ | -| Invitar/quitar miembros y cambiar sus roles | — | — | ✅ | -| Borrar el tablero | — | — | ✅ (solo el dueño) | - -El **propietario** de un tablero es su creador (`Board.ownerId`), que además es -miembro con rol `OWNER`. Su rol no se puede cambiar ni se le puede quitar del -tablero: es el ancla de la propiedad. - -Los ayudantes que codifican esta jerarquía viven en +| View the board, lists and cards | ✅ | ✅ | ✅ | +| Create/edit/move lists, cards and checklists | — | ✅ | ✅ | +| Apply existing labels to cards | — | ✅ | ✅ | +| Create/delete board labels | — | — | ✅ | +| Rename the board | — | — | ✅ | +| Invite/remove members and change their roles | — | — | ✅ | +| Delete the board | — | — | ✅ (owner only) | + +A board's **owner** is its creator (`Board.ownerId`), who is also a member with the +`OWNER` role. The owner's role cannot be changed and the owner cannot be removed from the +board: it anchors ownership. + +The helpers that encode this hierarchy live in [`app/lib/roles.ts`](../app/lib/roles.ts): ```ts -canEditBoard(role) // EDITOR u OWNER → puede editar contenido -canManageBoard(role) // solo OWNER → puede administrar el tablero +canEditBoard(role) // EDITOR or OWNER → can edit content +canManageBoard(role) // OWNER only → can administer the board ``` -Este módulo **no** lleva `server-only` a propósito: también lo importan los -componentes cliente para ocultar los controles que el usuario no puede usar. +This module deliberately does **not** import `server-only`: Client Components also import +it to hide the controls the user cannot use. -## Dónde se aplica la autorización +## Where authorization is enforced -La regla de oro: **el servidor es la única autoridad**. La interfaz oculta botones -por comodidad, pero nunca es la barrera de seguridad. +The golden rule: **the server is the only authority**. The interface hides buttons for +convenience, but it is never the security barrier. ### 1. Data Access Layer (`app/lib/dal.ts`) -- `requireUser()` — exige sesión válida (si no, redirige a `/login`). -- `requireAdmin()` — exige `User.role === "ADMIN"` (si no, redirige a `/boards`). -- `requireBoardAccess(boardId)` — exige ser miembro del tablero (si no, redirige a - `/boards`) y **devuelve el rol** del usuario en ese tablero. +- `requireUser()` — requires a valid session (otherwise redirects to `/login`). +- `requireAdmin()` — requires `User.role === "ADMIN"` (otherwise redirects to `/boards`). +- `requireBoardAccess(boardId)` — requires board membership (otherwise redirects to + `/boards`) and **returns the user's role** on that board. -### 2. Cada Server Action +### 2. Every Server Action -Tras `requireBoardAccess`, la acción comprueba el rol con los ayudantes de -`roles.ts` y, si no alcanza, **no hace nada** (acciones de formulario) o devuelve -`{ ok: false }` (acciones de drag & drop / imperativas): +After `requireBoardAccess`, the action checks the role with the `roles.ts` helpers and, +if it is not enough, **does nothing** (form actions) or returns `{ ok: false }` +(drag-and-drop and other imperative actions): ```ts export async function createList(formData: FormData) { const boardId = String(formData.get("boardId")); const { role } = await requireBoardAccess(boardId); - if (!canEditBoard(role)) return; // backstop de seguridad + if (!canEditBoard(role)) return; // security backstop // … } ``` -### 3. La interfaz (defensa en profundidad) +### 3. The interface (defense in depth) -El componente de tablero recibe el rol del visor y lo expone por contexto -([`board-perms.tsx`](../app/components/board/board-perms.tsx)). Con `canEdit` y -`canManage` los componentes ocultan: arrastrar y soltar, los formularios de añadir, -los botones de borrar, la edición en el modal de tarjeta y el panel de gestión de -miembros. Un `VIEWER` ve un tablero **de solo lectura**. +The board component receives the viewer's role and exposes it through context +([`board-perms.tsx`](../app/components/board/board-perms.tsx)). With `canEdit` and +`canManage`, components hide drag and drop, the "add" forms, the delete buttons, editing +in the card modal and the member management panel. A `VIEWER` sees a **read-only** board. -## Por qué guardas explícitas y no `forbidden()` +## Why explicit guards instead of `forbidden()` -Next.js 16 ofrece `forbidden()`/`unauthorized()` para cortar la ejecución con una -página 403/401, pero son **experimentales** (requieren `experimental.authInterrupts`). -orgtable prefiere **guardas explícitas** (`if (!canEditBoard(role)) return;`) por -tres motivos: no dependen de una API experimental, encajan con el estilo existente -de "fallar en silencio" de las acciones, y permiten que las acciones de drag & drop -devuelvan `{ ok: false }` para que el cliente revierta su UI optimista sin lanzar una -excepción. La decisión está registrada en +Next.js 16 provides `forbidden()`/`unauthorized()` to stop execution with a 403/401 page, +but they are **experimental** (they require `experimental.authInterrupts`). orgtable +prefers **explicit guards** (`if (!canEditBoard(role)) return;`) for three reasons: they +do not depend on an experimental API, they match the existing "fail silently" style of the +actions, and they let drag-and-drop actions return `{ ok: false }` so the client can roll +back its optimistic UI without throwing. The decision is recorded in [ADR 0006](./decisions/0006-rbac-explicit-guards.md). -## Invitar miembros +## Inviting members -No hay registro abierto: solo se puede invitar a **usuarios que ya existen**. El -flujo es: +Only **existing accounts** can be invited; there are no invitation emails. The flow is: -1. El administrador da de alta las cuentas en `/admin/users` (ver - [`docs/admin.md`](./admin.md)). -2. El propietario de un tablero abre **Miembros**, escribe el email de una persona - existente y elige su rol (`EDITOR` o `VIEWER`). +1. The account exists: the administrator created it in `/admin/users` (see + [`docs/admin.md`](./admin.md)), or the person signed up, if the instance has open + sign-up enabled. +2. The board owner opens **Members**, types the email of an existing person and picks a + role (`EDITOR` or `VIEWER`). -Si el email no corresponde a ninguna cuenta, la invitación se rechaza con un mensaje -que pide al administrador crear la cuenta primero. Así evitamos correos de -invitación, tokens y verificación en una app self-hosted de equipo pequeño. +If the email does not match any account, the invitation is rejected. This avoids +invitation emails, tokens and verification in a self-hosted app for small teams. diff --git a/docs/calendar.md b/docs/calendar.md index 676bed8..fb9f5ac 100644 --- a/docs/calendar.md +++ b/docs/calendar.md @@ -1,61 +1,59 @@ -# Vista de calendario - -La vista de calendario (Hito 7) muestra las tarjetas de un tablero con **fecha de -vencimiento** sobre una rejilla mensual. Es una forma alternativa de mirar el mismo -tablero: no añade datos nuevos ni cambia el modelo. - -## Cómo se usa - -- En la cabecera de un tablero hay un conmutador **Tablero / Calendario**. -- En **Calendario** se ve el mes actual; las flechas cambian de mes y «Hoy» vuelve al - mes en curso. El día de hoy se resalta con un anillo de acento. -- Cada tarjeta con vencimiento aparece como una pastilla en su día, con el punto de - color de su primera etiqueta. **Al pulsarla se abre su modal** —el mismo de la vista - de tablero—, así que se puede editar, completar checklist, etc., sin cambiar de vista. -- Las tarjetas **sin** fecha de vencimiento no aparecen; un aviso al pie indica cuántas - hay. - -## Diseño técnico - -- Componente cliente `app/components/board/calendar-view.tsx`, renderizado por - `BoardView` cuando la vista activa es `calendar`. La vista activa es estado local de - cliente (`useState`), no va en la URL: cambiar de vista no recarga ni navega. -- Reutiliza el `selectedCardId` de `BoardView` para abrir el modal, de modo que el - detalle de tarjeta y el tiempo real (`router.refresh()`) siguen funcionando igual. -- La rejilla es de 6 semanas (42 celdas) empezando en el **lunes** de la semana del día - 1 (locale español). -- **Sin desfases de zona horaria**: las tarjetas se indexan por la parte de fecha del - `dueDate` (`YYYY-MM-DD`, vía `slice(0,10)`) y cada celda se compara con la misma clave - local `YYYY-MM-DD`. No se usa la hora del `dueDate` (las fechas se eligen con un - `<input type="date">`, así que representan un día, no un instante). - -## Archivos - -- `app/components/board/calendar-view.tsx` — la rejilla mensual. -- `app/components/board/board-view.tsx` — el conmutador y el render condicional. - -## Reprogramar arrastrando (Hito 11) - -Si tienes permiso de edición, puedes **arrastrar una tarjeta a otro día** para cambiar su -fecha de vencimiento. Usa `@dnd-kit` (chips arrastrables, celdas como zonas de soltar) y -una acción enfocada `setCardDueDate` que **solo** toca la fecha (no pisa la descripción). -El tablero se resincroniza por el tiempo real tras revalidar. - -- Mientras se arrastra, una copia de la pastilla (`DragOverlay`) sigue al puntero; la - original se queda atenuada en su día (moverla con `transform` la recortaba el - `overflow-y-auto` del calendario). La copia se pinta en un portal a `document.body` - porque el panel lleva `backdrop-blur`, y un `backdrop-filter` crea un bloque contenedor - que desplazaría la copia `position: fixed` respecto al puntero. -- **Teclado**: con el foco en una pastilla, Espacio la coge, las flechas la llevan al día - vecino (izquierda/derecha) o a la misma columna de la semana anterior/siguiente - (arriba/abajo), y Espacio o Intro la sueltan; Escape cancela. Intro sin arrastrar abre - la tarjeta. Las coordenadas las calcula `calendarKeyboardCoordinates` - (`app/lib/board-collision.ts`); anuncios para lectores de pantalla en español. -- Si el servidor no guarda la nueva fecha (rechazo o fallo de red), la tarjeta sigue en - su día y aparece el aviso «No se pudo cambiar la fecha. Inténtalo de nuevo.» en su - propia fila, bajo el mes y la navegación (`role="alert"`). Se borra al empezar otro - arrastre. - -## Evolución posible - -- Un calendario **global** que cruce todos los tableros del usuario (hoy es por tablero). +# Calendar view + +The calendar view shows a board's cards that have a **due date** on a monthly grid. It is +another way of looking at the same board: it adds no data and does not change the model. + +## How to use it + +- The board header has a **Board / Calendar** switch. +- **Calendar** shows the current month; the arrows change the month and "Today" returns + to the current one. Today is highlighted with an accent ring. +- Each card with a due date appears as a chip on its day, with the color dot of its first + label. **Clicking it opens its modal** (the same one as in the board view), so you can + edit it, tick off checklist items and so on without switching views. +- Cards **without** a due date do not appear; a note at the bottom says how many there + are. + +## Technical design + +- Client Component `app/components/board/calendar-view.tsx`, rendered by `BoardView` when + the active view is `calendar`. The active view is local client state (`useState`), not + part of the URL: switching views neither reloads nor navigates. +- It reuses `BoardView`'s `selectedCardId` to open the modal, so card details and real + time (`router.refresh()`) keep working the same way. +- The grid has 6 weeks (42 cells), starting on the **Monday** of the week containing day 1. +- **No time-zone drift**: cards are indexed by the date part of `dueDate` (`YYYY-MM-DD`, + via `slice(0,10)`) and each cell is compared with the same local `YYYY-MM-DD` key. The + time part of `dueDate` is not used (dates are picked with an `<input type="date">`, so + they represent a day, not an instant). + +## Files + +- `app/components/board/calendar-view.tsx` — the monthly grid. +- `app/components/board/board-view.tsx` — the switch and the conditional render. + +## Rescheduling by dragging + +With edit permission, you can **drag a card to another day** to change its due date. It +uses `@dnd-kit` (draggable chips, cells as drop zones) and a focused `setCardDueDate` +action that **only** touches the date (it does not overwrite the description). The board +resyncs through real time after revalidation. + +- While dragging, a copy of the chip (`DragOverlay`) follows the pointer; the original + stays dimmed on its day (moving it with `transform` got it clipped by the calendar's + `overflow-y-auto`). The copy is rendered in a portal to `document.body` because the + panel uses `backdrop-blur`, and a `backdrop-filter` creates a containing block that + would offset the `position: fixed` copy from the pointer. +- **Keyboard**: with focus on a chip, Space picks it up, the arrow keys move it to the + neighbouring day (left/right) or to the same weekday in the previous/next week + (up/down), and Space or Enter drops it; Escape cancels. Enter without dragging opens + the card. Coordinates are computed by `calendarKeyboardCoordinates` + (`app/lib/board-collision.ts`); screen reader announcements are in the interface + language. +- If the server does not save the new date (rejection or network failure), the card + stays on its day and an error notice appears on its own row, below the month and the + navigation (`role="alert"`). It clears when another drag starts. + +## Possible improvements + +- A **global** calendar across all of the user's boards (today it is per board). diff --git a/docs/card-collaboration.md b/docs/card-collaboration.md index 0eee540..5883650 100644 --- a/docs/card-collaboration.md +++ b/docs/card-collaboration.md @@ -1,54 +1,53 @@ -# Colaboración en la tarjeta: asignados y comentarios - -El Hito 10 permite **asignar miembros** a una tarjeta y **comentar** en ella. La decisión -de diseño está en [ADR 0014](./decisions/0014-card-collaboration.md). - -## Asignados - -- En el modal de tarjeta, la sección **Asignados** muestra quién la tiene asignada - (avatares con la inicial) y, para quien puede editar, una lista de miembros del tablero - para asignar con un clic. Se puede quitar a un asignado con la ✕. -- En la cara de la tarjeta (en el tablero) aparecen los avatares de los asignados, así que - se ve de un vistazo quién la lleva. -- **Solo se puede asignar a miembros del tablero**: el servidor lo verifica - (`assignMemberToCard` en `app/lib/services/assignees.ts`). Asignar/quitar requiere - permiso de edición. -- Al asignar a otra persona se le envía una notificación `card.assigned` +# Card collaboration: assignees and comments + +Board members can be **assigned** to a card and can **comment** on it. The design +decision is in [ADR 0014](./decisions/0014-card-collaboration.md). + +## Assignees + +- In the card modal, the **Assignees** section shows who is assigned (avatars with their + initial) and, for users who can edit, a list of board members to assign with one click. + An assignee can be removed with the ✕. +- The card face on the board shows the assignees' avatars, so you can see at a glance who + is working on it. +- **Only board members can be assigned**: the server checks it (`assignMemberToCard` in + `app/lib/services/assignees.ts`). Assigning and unassigning require edit permission. +- Assigning someone else sends them a `card.assigned` notification ([docs/notifications.md](./notifications.md)). -- **Expulsar a un miembro del tablero borra sus asignaciones** en las tarjetas de ese - tablero (`removeMemberFromBoard` en `app/lib/services/members.ts`, en la misma - transacción que la membresía). Si quedaran, seguiría recibiendo el título de la tarjeta - y el texto de los comentarios de un tablero al que ya no tiene acceso. +- **Removing a member from a board deletes their assignments** on that board's cards + (`removeMemberFromBoard` in `app/lib/services/members.ts`, in the same transaction as + the membership). Otherwise they would keep receiving the card title and comment text + from a board they no longer have access to. -## Comentarios +## Comments -- La sección **Comentarios** del modal es un hilo plano: cada comentario muestra autor, - fecha y texto. Comentar requiere permiso de edición (los lectores no comentan). -- **Borrar** un comentario lo puede hacer su **autor** o el **propietario** del tablero. -- Al comentar, los **asignados** de la tarjeta (menos el autor) que sigan siendo - miembros del tablero reciben una notificación `comment.added`. El filtro por membresía - cubre también las asignaciones huérfanas que dejaban las expulsiones anteriores a este - cambio (no se han borrado de la BD). +- The **Comments** section of the modal is a flat thread: each comment shows its author, + date and text. Commenting requires edit permission (viewers cannot comment). +- A comment can be **deleted** by its **author** or by the board **owner**. +- When someone comments, the card's **assignees** (except the author) who are still board + members receive a `comment.added` notification. The membership filter also covers + orphaned assignments left by member removals made before assignments were cleaned up + (they were not deleted from the database). -## Archivos +## Files -- Modelos `CardAssignee` y `Comment` en `prisma/schema.prisma` (migración +- `CardAssignee` and `Comment` models in `prisma/schema.prisma` (migration `…_card_collaboration`). -- Servicios testeables: `app/lib/services/assignees.ts` (`assignMemberToCard`), +- Testable services: `app/lib/services/assignees.ts` (`assignMemberToCard`), `app/lib/services/cards.ts` (`cardContext`), `app/lib/services/members.ts` (`removeMemberFromBoard`). - Server Actions: `app/lib/actions/assignees.ts`, `app/lib/actions/comments.ts`. -- UI: secciones en `app/components/card/card-modal.tsx`; avatares en +- UI: sections in `app/components/card/card-modal.tsx`; avatars in `app/components/board/card-item.tsx`. -- DTO y lectura: `app/lib/types.ts` (`AssigneeDTO`, `CommentDTO`), `getBoardWithLists` en - `app/lib/queries.ts`. La API los expone en las respuestas de tarjeta +- DTOs and reads: `app/lib/types.ts` (`AssigneeDTO`, `CommentDTO`), `getBoardWithLists` in + `app/lib/queries.ts`. The API exposes them in card responses (`app/lib/api/serialize.ts`). -- Tests: `test/integration/collaboration.int.test.ts` y +- Tests: `test/integration/collaboration.int.test.ts` and `test/integration/members.int.test.ts`. -## Límites y evolución +## Limits and possible improvements -- Hilo plano, sin respuestas anidadas ni reacciones (reacciones están en el backlog). -- Crear/borrar comentarios y asignaciones **por la API** queda como ampliación (hoy la API - los expone en lectura; la interfaz cubre la escritura). -- @menciones que notifiquen a un usuario concreto: ampliación natural del parseo del texto. +- Flat thread, with no nested replies or reactions (reactions are in the backlog). +- Creating/deleting comments and assignments **through the API** is a possible extension + (today the API exposes them read-only; the interface covers writes). +- @mentions that notify a specific user: a natural extension of parsing the text. diff --git a/docs/card-detail.md b/docs/card-detail.md index 56e0633..f037eb2 100644 --- a/docs/card-detail.md +++ b/docs/card-detail.md @@ -1,30 +1,29 @@ -# Detalle de tarjeta (Hito 2) +# Card details -El **detalle de tarjeta** es el modal que se abre al pulsar sobre una tarjeta del -tablero. Permite enriquecer una tarjeta con todo el contexto que no cabe en su título: -**descripción**, **fecha de vencimiento**, **etiquetas de color** y **checklists** con -ítems marcables. +The **card details** view is the modal that opens when you click a card on the board. It +lets you add all the context that does not fit in a card's title: a **description**, a +**due date**, **color labels** and **checklists** with tickable items. -La interfaz vive en -[`app/components/card/card-modal.tsx`](../app/components/card/card-modal.tsx) y se apoya -en tres ficheros de Server Actions: +The interface lives in +[`app/components/card/card-modal.tsx`](../app/components/card/card-modal.tsx) and relies +on three Server Action files: - [`app/lib/actions/cards.ts`](../app/lib/actions/cards.ts) — `updateCard` -- [`app/lib/actions/labels.ts`](../app/lib/actions/labels.ts) — etiquetas +- [`app/lib/actions/labels.ts`](../app/lib/actions/labels.ts) — labels - [`app/lib/actions/checklists.ts`](../app/lib/actions/checklists.ts) — checklists -> Para la firma completa de cada acción y sus garantías de autorización, ver -> [`docs/api/server-actions.md`](./api/server-actions.md). Para los modelos de datos, -> ver [`docs/database.md`](./database.md). +> For the full signature of each action and its authorization guarantees, see +> [`docs/api/server-actions.md`](./api/server-actions.md). For the data models, see +> [`docs/database.md`](./database.md). --- -## El modal +## The modal -El componente `CardModal` recibe la tarjeta (`CardDTO`), el `boardId`, las etiquetas del -tablero (`boardLabels: LabelDTO[]`), los miembros del tablero (`members`, para la sección -de asignados), el id del usuario actual (`currentUserId`, para saber qué comentarios puede -borrar) y un callback `onClose`. +The `CardModal` component receives the card (`CardDTO`), the `boardId`, the board's +labels (`boardLabels: LabelDTO[]`), the board members (`members`, for the assignees +section), the current user's id (`currentUserId`, to know which comments they can delete) +and an `onClose` callback. ```tsx export function CardModal({ @@ -44,56 +43,58 @@ export function CardModal({ }) { /* … */ } ``` -### Estructura y cierre +### Structure and closing -El modal es un **`<dialog>` nativo** que se abre con `showModal()` dentro de un -`useLayoutEffect`, con `close()` en el cleanup. Así el navegador: +The modal is a **native `<dialog>`** opened with `showModal()` inside a +`useLayoutEffect`, with `close()` in the cleanup. This way the browser: -- vuelve **inerte** el resto de la página (el Tab no sale al tablero) y enfoca el primer - control del modal; -- al cerrar, **devuelve el foco** a la tarjeta que lo abrió. Se usa un layout effect para - que `close()` ocurra antes de que React quite el nodo: si se retira abierto, el foco no - se restaura. +- makes the rest of the page **inert** (Tab does not escape to the board) and focuses the + first control in the modal; +- **returns focus** to the card that opened it when it closes. A layout effect is used so + that `close()` runs before React removes the node: if the dialog is removed while open, + focus is not restored. -El `<dialog>` lleva `aria-labelledby` apuntando al título de la tarjeta. Ocupa toda la -ventana y hace de contenedor con scroll; el panel va dentro. Se cierra de tres maneras: +The `<dialog>` has `aria-labelledby` pointing to the card title. It fills the whole +viewport and acts as the scroll container; the panel sits inside it. It closes in three +ways: -| Gesto | Mecanismo | +| Gesture | Mechanism | |-------|-----------| -| **Escape** | `onCancel` del `<dialog>`: `e.preventDefault()` + `onClose()`, para que cierre React (desmontando el modal) y no el navegador por su cuenta. | -| **Clic fuera** | `onClick` del `<dialog>` con `e.target === dialogRef.current`: como el panel va dentro, un clic cuyo destino es el propio `<dialog>` cae fuera del panel. | -| **Botón ✕** | El botón "Cerrar" de la cabecera llama a `onClose` directamente. | - -La entrada se anima con dos keyframes definidos en `globals.css`: `overlay-in` sobre el -`::backdrop` (fade, 150 ms, solo con `motion-safe`) y `dialog-in` sobre el panel (fade + -leve `translateY`/`scale`, 180 ms). Ambas respetan `prefers-reduced-motion` (ver -[`docs/design-system.md`](./design-system.md)). El porqué del cambio a `<dialog>` está en -[`DECISIONES.md`](./DECISIONES.md) («Modal de tarjeta: `<dialog>` nativo, fechas y -confirmación de borrado»). - -### Borrar la tarjeta - -Solo quien puede editar ve **«Borrar tarjeta»** al pie del modal. Borrar es irreversible, -así que el primer clic no borra: muestra en línea «¿Borrar la tarjeta?» con **«Sí, -borrar»** y **«No»**, y mueve el foco a «No» (el botón pulsado desaparece y el teclado no -debe quedarse en `<body>`). Al cancelar, el foco vuelve a «Borrar tarjeta». No se usa -`window.confirm`. «Sí, borrar» envía `deleteCard` y cierra el modal. - -### Origen de los datos (resincronización) - -El modal **no mantiene su propia copia mutable** del estado de la tarjeta: lee los datos -directamente del estado del board (la `card` que recibe por props). Las acciones de -servidor llaman a `revalidateBoard(boardId)`, que revalida `/board/${boardId}` y publica -el cambio por el bus de tiempo real; eso provoca que el board se vuelva a renderizar con los datos frescos y el modal reciba la nueva `card`. Es decir, la -fuente de verdad es siempre el servidor: tras cada mutación el modal se **resincroniza** -en lugar de gestionar estado optimista local para estos campos. +| **Escape** | The `<dialog>`'s `onCancel`: `e.preventDefault()` + `onClose()`, so React closes it (unmounting the modal) rather than the browser on its own. | +| **Click outside** | The `<dialog>`'s `onClick` with `e.target === dialogRef.current`: since the panel is inside, a click whose target is the `<dialog>` itself landed outside the panel. | +| **✕ button** | The "Close" button in the header calls `onClose` directly. | + +The entrance is animated with two keyframes defined in `globals.css`: `overlay-in` on the +`::backdrop` (fade, 150 ms, `motion-safe` only) and `dialog-in` on the panel (fade plus a +slight `translateY`/`scale`, 180 ms). Both respect `prefers-reduced-motion` (see +[`docs/design-system.md`](./design-system.md)). The reasoning behind the switch to +`<dialog>` is recorded in [`DECISIONES.md`](./DECISIONES.md) (the entry on the native +`<dialog>` card modal, dates and delete confirmation). + +### Deleting the card + +Only people who can edit see **Delete card** at the bottom of the modal. Deleting cannot +be undone, so the first click does not delete: it shows an inline confirmation with +**Yes, delete** and **No**, and moves focus to **No** (the clicked button disappears and +keyboard focus must not fall back to `<body>`). On cancel, focus returns to **Delete +card**. `window.confirm` is not used. **Yes, delete** submits `deleteCard` and closes the +modal. + +### Data source (resynchronization) + +The modal **does not keep its own mutable copy** of the card's state: it reads the data +straight from the board state (the `card` it receives as a prop). Server actions call +`revalidateBoard(boardId)`, which revalidates `/board/${boardId}` and publishes the change +on the real-time bus; the board then re-renders with fresh data and the modal receives the +new `card`. In other words, the server is always the source of truth: after each mutation +the modal **resynchronizes** instead of managing local optimistic state for these fields. --- -## Edición de descripción y fecha de vencimiento +## Editing the description and due date -Descripción y vencimiento comparten un único formulario conectado a `updateCard` con -`useActionState`, para poder mostrar el error y desactivar el botón mientras guarda: +The description and the due date share a single form wired to `updateCard` through +`useActionState`, so it can show errors and disable the button while saving: ```tsx const [details, detailsAction, detailsPending] = useActionState< @@ -117,56 +118,57 @@ const [details, detailsAction, detailsPending] = useActionState< </form> ``` -- La descripción usa `<Textarea>` (`name="description"`) con - `maxLength={CARD_DESCRIPTION_MAX}` (5000). La constante está en - [`app/lib/limits.ts`](../app/lib/limits.ts), un módulo sin dependencias, para no meter - zod en el bundle del cliente; `validators.ts` la reexporta para los esquemas. -- El vencimiento usa `<Input type="date" name="dueDate">`. El valor por defecto se obtiene - recortando el ISO a `YYYY-MM-DD`: `card.dueDate ? card.dueDate.slice(0, 10) : ""`. -- Si no se guarda, el formulario se rehidrata con lo enviado (`details.description`, - `details.dueDate`): React resetea el formulario no controlado al acabar la acción y, sin - esto, lo escrito se perdería. El error se enlaza al textarea con `aria-describedby`. - -La acción **`updateCard(prev, formData): Promise<CardDetailsState>`** -([`cards.ts`](../app/lib/actions/cards.ts)) devuelve `{}` si guarda, o -`{ error, description, dueDate }` con lo enviado si no: - -1. Normaliza los saltos de línea de la descripción (`\r\n` → `\n`): el navegador los - envía como `\r\n`, pero `maxLength` los cuenta como un carácter, así que sin - normalizar una descripción que en la UI mide 5000 se rechazaría. -2. Resuelve el `boardId` de la tarjeta y autoriza con `requireBoardAccess` + `canEditBoard`. -3. Valida `dueDate` con `dueDateSchema` (`"YYYY-MM-DD"` o vacío para quitarla). Una fecha - inválida **no** se guarda como `null`: devuelve un error y no se guarda nada. -4. Si la descripción es igual a la guardada (tras normalizar y recortar), no se valida su - longitud ni se reescribe; solo se actualiza la fecha. Así las tarjetas con - descripciones anteriores al límite (más de 5000) pueden seguir cambiando el - vencimiento desde el modal. Si ha cambiado, se valida con - `cardDetailsSchema.shape.description` (recorte + máximo); vacía se guarda como `null`. -5. Si algo no valida no se guarda **nada**: guardar solo una parte perdería en silencio - lo que el usuario escribió en la otra. -6. Persiste con `prisma.card.update` y revalida el tablero (`revalidateBoard`). - -Mensajes de error que puede mostrar el modal: - -| Caso | Mensaje | +- The description uses `<Textarea>` (`name="description"`) with + `maxLength={CARD_DESCRIPTION_MAX}` (5000). The constant lives in + [`app/lib/limits.ts`](../app/lib/limits.ts), a dependency-free module, to keep zod out + of the client bundle; `validators.ts` re-exports it for the schemas. +- The due date uses `<Input type="date" name="dueDate">`. The default value is obtained by + trimming the ISO string to `YYYY-MM-DD`: `card.dueDate ? card.dueDate.slice(0, 10) : ""`. +- If saving fails, the form is rehydrated with what was submitted (`details.description`, + `details.dueDate`): React resets the uncontrolled form when the action finishes and, + without this, the user's input would be lost. The error is linked to the textarea with + `aria-describedby`. + +The **`updateCard(prev, formData): Promise<CardDetailsState>`** action +([`cards.ts`](../app/lib/actions/cards.ts)) returns `{}` when it saves, or +`{ error, description, dueDate }` with the submitted values when it does not: + +1. Normalises line breaks in the description (`\r\n` → `\n`): the browser submits them as + `\r\n`, but `maxLength` counts them as one character, so without normalizing a + description that measures 5000 in the UI would be rejected. +2. Resolves the card's `boardId` and authorizes with `requireBoardAccess` + `canEditBoard`. +3. Validates `dueDate` with `dueDateSchema` (`"YYYY-MM-DD"`, or empty to clear it). An + invalid date is **not** stored as `null`: it returns an error and nothing is saved. +4. If the description equals the stored one (after normalizing and trimming), its length + is not validated and it is not rewritten; only the date is updated. This lets cards + whose descriptions predate the limit (over 5000) still change their due date from the + modal. If it changed, it is validated with `cardDetailsSchema.shape.description` (trim + plus maximum); an empty description is stored as `null`. +5. If anything fails validation, **nothing** is saved: saving only part of it would + silently lose what the user wrote in the other field. +6. Persists with `prisma.card.update` and revalidates the board (`revalidateBoard`). + +Errors the modal can show (the text comes from `locales/` in the user's language): + +| Case | Message | | --- | --- | -| La tarjeta se borró mientras tanto | «La tarjeta ya no existe.» | -| Sin rol de edición | «No tienes permiso para editar esta tarjeta.» | -| Fecha no válida | «La fecha de vencimiento no es válida.» | -| Descripción nueva demasiado larga | «La descripción no puede superar 5000 caracteres (tiene N).» | +| The card was deleted in the meantime | The card no longer exists. | +| No edit role | You do not have permission to edit this card. | +| Invalid date | The due date is not valid. | +| New description too long | The description cannot exceed 5000 characters (it has N). | --- -## Etiquetas de color (a nivel de tablero) +## Color labels (board level) -Las etiquetas pertenecen al **tablero**, no a la tarjeta: una etiqueta se define una vez -y luego se puede asignar a cualquier tarjeta del tablero. En la tarjeta se muestran como -**badges** (pastillas de color). +Labels belong to the **board**, not the card: a label is defined once and can then be +assigned to any card on the board. On the card they are shown as **badges** (colored +pills). -### Modelos de datos +### Data models -Los modelos `Label` y `CardLabel` **ya existían** antes del Hito 2 (forman parte del -esquema base). `CardLabel` es la tabla de unión muchos-a-muchos entre `Card` y `Label`: +The `Label` and `CardLabel` models are part of the base schema. `CardLabel` is the +many-to-many join table between `Card` and `Label`: ```prisma model Label { @@ -185,11 +187,11 @@ model CardLabel { } ``` -### Crear etiquetas — `createLabel` +### Creating labels — `createLabel` -El subformulario `NewLabelForm` permite elegir un color de la paleta de etiquetas -(`LABEL_COLOR_KEYS`, ver [`colors.ts`](../app/lib/colors.ts)) y un nombre. Al enviar llama -a **`createLabel(formData)`** y resetea el formulario: +The `NewLabelForm` sub-form lets you pick a color from the label palette +(`LABEL_COLOR_KEYS`, see [`colors.ts`](../app/lib/colors.ts)) and a name. On submit it +calls **`createLabel(formData)`** and resets the form: ```tsx action={async (formData) => { @@ -198,20 +200,20 @@ action={async (formData) => { }} ``` -`createLabel` valida acceso al tablero, recorta el nombre a 40 caracteres (ignora vacíos), -valida que el color exista en `LABEL_COLORS` (cae a `"blue"` si no), crea el `Label` y -revalida. +`createLabel` checks board access, trims the name to 40 characters (ignoring empty +names), checks that the color exists in `LABEL_COLORS` (falling back to `"blue"` if not), +creates the `Label` and revalidates. -### Asignar / quitar — `toggleCardLabel` +### Assigning / removing — `toggleCardLabel` -Cada etiqueta del tablero se pinta como un botón. Su estado (`on`) se calcula con un -`Set` de los ids de etiqueta ya asignados a la tarjeta: +Each board label is rendered as a button. Its state (`on`) is computed from a `Set` of the +label ids already assigned to the card: ```tsx const assigned = new Set(card.labels.map((l) => l.id)); ``` -Al pulsar, se invoca **`toggleCardLabel`** dentro de `startTransition`, envuelta en +On click, **`toggleCardLabel`** is invoked inside `startTransition`, wrapped in `runToggle` ([`card-modal-helpers.ts`](../app/components/card/card-modal-helpers.ts)): ```tsx @@ -220,7 +222,7 @@ onClick={() => setLabelError( await runToggle( () => toggleCardLabel({ cardId: card.id, labelId: l.id }), - "No se pudo cambiar la etiqueta.", + /* localized "could not change the label" message */, "toggleCardLabel", ), ); @@ -228,31 +230,31 @@ onClick={() => } ``` -`runToggle` devuelve el mensaje de error para la interfaz si la acción responde -`{ ok: false }` o lanza (y lo registra en consola con el contexto), o `null` si fue bien; -los errores internos de Next (redirect a `/login` al caducar la sesión, `notFound`) los -relanza. El aviso sale con `role="alert"` **bajo la sección Etiquetas**, junto a lo que -falló (el modal tiene scroll y uno arriba del todo no se vería), y se borra en cuanto -otro cambio de etiqueta sale bien. +`runToggle` returns the error message for the interface if the action responds +`{ ok: false }` or throws (and logs it to the console with its context), or `null` if it +succeeded; Next.js internal errors (redirect to `/login` when the session expires, +`notFound`) are rethrown. The message appears with `role="alert"` **below the Labels +section**, next to what failed (the modal scrolls, and a message at the very top would not +be visible), and it is cleared as soon as another label change succeeds. -`toggleCardLabel` comprueba que la etiqueta sea del mismo tablero que la tarjeta, busca -la fila `CardLabel` por su clave compuesta `cardId_labelId` y, si existe, la borra; si -no, la crea: un **toggle** puro. Cada pastilla lleva `aria-pressed`; la asignada se -resalta con un anillo (`ring-2`) y un "✓". Las no asignadas **no** se atenúan: la -antigua `opacity-55` bajaba el contraste del texto blanco a ~2,2:1, por debajo de AA. El -fondo de cada pastilla usa `labelBackground(l.color)`. +`toggleCardLabel` checks that the label belongs to the same board as the card, looks up +the `CardLabel` row by its composite key `cardId_labelId` and deletes it if it exists, or +creates it otherwise: a pure **toggle**. Each pill has `aria-pressed`; the assigned one is +highlighted with a ring (`ring-2`) and a "✓". Unassigned pills are **not** dimmed: the old +`opacity-55` lowered the contrast of the white text to about 2.2:1, below AA. Each pill's +background uses `labelBackground(l.color)`. --- ## Checklists -Una tarjeta puede tener varias **checklists**, y cada checklist varios **ítems** -marcables. El encabezado de la sección muestra un contador global `done/total` y cada -checklist tiene su propia **barra de progreso**. +A card can have several **checklists**, and each checklist several tickable **items**. +The section header shows an overall `done/total` counter and each checklist has its own +**progress bar**. -### Modelos nuevos +### Models -`Checklist` y `ChecklistItem` son **modelos nuevos** introducidos en el Hito 2: +`Checklist` and `ChecklistItem`: ```prisma model Checklist { @@ -272,12 +274,12 @@ model ChecklistItem { } ``` -Tanto checklists como ítems usan una clave `position` (fractional indexing) para -ordenarse; las acciones calculan la siguiente posición con `positionAtEnd`. +Both checklists and items use a `position` key (fractional indexing) for ordering; the +actions compute the next position with `positionAtEnd`. -### Barra de progreso +### Progress bar -Para cada checklist se calcula el porcentaje de ítems hechos: +For each checklist, the percentage of completed items is computed: ```tsx const total = checklist.items.length; @@ -285,39 +287,37 @@ const done = checklist.items.filter((i) => i.done).length; const pct = total ? Math.round((done / total) * 100) : 0; ``` -Ese `pct` alimenta el ancho de una barra (`width: ${pct}%`) con color `bg-success`. El -contador global del encabezado de la sección suma los ítems de todas las checklists. +That `pct` drives the width of a bar (`width: ${pct}%`) colored `bg-success`. The overall +counter in the section header adds up the items of every checklist. -### Acciones de checklist +### Checklist actions -Todas las acciones resuelven el `boardId` recorriendo la relación -`item → checklist → card → list → board`, autorizan con `requireBoardAccess` y revalidan -`/board/${boardId}`. +Every action resolves the `boardId` by walking the relation +`item → checklist → card → list → board`, authorizes with `requireBoardAccess` and +revalidates `/board/${boardId}`. -| Acción | Entrada | Qué hace | +| Action | Input | What it does | |--------|---------|----------| -| `createChecklist(formData)` | `cardId`, `title` | Crea una checklist al final de la tarjeta (título por defecto `"Checklist"`, máx. 80 caracteres). | -| `deleteChecklist(formData)` | `checklistId` | Borra la checklist (cascada sobre sus ítems). | -| `addChecklistItem(formData)` | `checklistId`, `text` | Añade un ítem (máx. 200 caracteres, ignora vacíos) al final. | -| `toggleChecklistItem({ itemId, done })` | objeto | Marca/desmarca un ítem (`update` de `done`). | -| `deleteChecklistItem(formData)` | `itemId` | Borra un ítem. | +| `createChecklist(formData)` | `cardId`, `title` | Creates a checklist at the end of the card (default title `"Checklist"`, max. 80 characters). | +| `deleteChecklist(formData)` | `checklistId` | Deletes the checklist (cascading to its items). | +| `addChecklistItem(formData)` | `checklistId`, `text` | Appends an item (max. 200 characters, ignores empty text). | +| `toggleChecklistItem({ itemId, done })` | object | Ticks/unticks an item (`update` of `done`). | +| `deleteChecklistItem(formData)` | `itemId` | Deletes an item. | -Los formularios de creación (`NewChecklistForm`, el formulario de "+ Añadir ítem") usan el -mismo patrón de `action` asíncrono + `ref.current?.reset()` para limpiar el campo tras -enviar. +The creation forms (`NewChecklistForm`, the "+ Add item" form) use the same pattern of an +async `action` + `ref.current?.reset()` to clear the field after submitting. -### `useTransition` en los toggles +### `useTransition` in toggles -Las acciones de **toggle** (que se disparan con un click, no con un submit de formulario) -se ejecutan dentro de una transición de React. El componente obtiene -`startTransition` de `useTransition`: +**Toggle** actions (triggered by a click, not a form submit) run inside a React +transition. The component gets `startTransition` from `useTransition`: ```tsx const [, startTransition] = useTransition(); ``` -y lo pasa a cada `ChecklistBlock`. Marcar un ítem invoca `toggleChecklistItem` con el -mismo patrón `startTransition` + `runToggle` que las etiquetas: +and passes it to each `ChecklistBlock`. Ticking an item calls `toggleChecklistItem` with +the same `startTransition` + `runToggle` pattern as labels: ```tsx onClick={() => @@ -326,7 +326,7 @@ onClick={() => setItemError( await runToggle( () => toggleChecklistItem({ itemId: it.id, done: !it.done }), - "No se pudo actualizar el ítem.", + /* localized "could not update the item" message */, "toggleChecklistItem", ), ); @@ -334,24 +334,24 @@ onClick={() => } ``` -Esto mantiene la UI responsiva mientras la Server Action y la subsiguiente revalidación -están en curso: el board se resincroniza tras `revalidateBoard`, y el modal refleja el -nuevo estado del ítem (tachado + casilla en verde) sin recargar la página. Si falla, el -aviso (`role="alert"`) sale **bajo esa checklist**: cada `ChecklistBlock` guarda su propio -error, para que aparezca junto al ítem que falló. +This keeps the UI responsive while the Server Action and the following revalidation are +in flight: the board resyncs after `revalidateBoard`, and the modal shows the item's new +state (struck through, green checkbox) without reloading the page. If it fails, the +message (`role="alert"`) appears **below that checklist**: each `ChecklistBlock` keeps its +own error so it shows next to the item that failed. --- -## Resumen del flujo +## Flow summary -1. El usuario abre la tarjeta → `CardModal` con los datos actuales del board. -2. Edita descripción/fecha (form → `updateCard`), gestiona etiquetas - (`createLabel` / `toggleCardLabel`) o checklists +1. The user opens the card → `CardModal` with the board's current data. +2. They edit the description/date (form → `updateCard`), manage labels + (`createLabel` / `toggleCardLabel`) or checklists (`createChecklist` / `addChecklistItem` / `toggleChecklistItem` / `deleteChecklistItem` / `deleteChecklist`). -3. Cada acción persiste en la base de datos y llama a `revalidateBoard`. -4. El board se vuelve a renderizar; el modal recibe la `card` actualizada y se - **resincroniza**. No hay estado local duplicado para estos datos. +3. Each action persists to the database and calls `revalidateBoard`. +4. The board re-renders; the modal receives the updated `card` and **resynchronizes**. + There is no duplicated local state for this data. -Ver también: [`docs/api/server-actions.md`](./api/server-actions.md) · +See also: [`docs/api/server-actions.md`](./api/server-actions.md) · [`docs/database.md`](./database.md). diff --git a/docs/contributing.md b/docs/contributing.md index e9f02ad..ffbc1ee 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,42 +1,42 @@ -# Guía de contribución +# Contributing guide -¡Gracias por tu interés en contribuir a **orgtable**! Esta guía te ayuda a -levantar el proyecto en local y a aportar código sin sorpresas. +Thanks for your interest in contributing to **orgtable**! This guide helps you run the +project locally and contribute code without surprises. -orgtable es un clon de Trello self-hosted construido con **Next.js 16**, -**Prisma 7**, **PostgreSQL**, **Tailwind 4** y **TypeScript**. +orgtable is a self-hosted Trello alternative built with **Next.js 16**, **Prisma 7**, +**PostgreSQL**, **Tailwind 4** and **TypeScript**. --- -## Requisitos +## Requirements -- **Node.js 22+** (recomendado). Algunas dependencias del proyecto requieren una - versión reciente de Node, así que no uses versiones por debajo de la 22. -- **Docker**, para levantar una base de datos PostgreSQL local sin tener que - instalar Postgres en tu máquina. -- **pnpm** 11 (se activa con `corepack enable`; la versión exacta está fijada en - `packageManager` de `package.json`). No uses npm ni yarn: el lockfile es +- **Node.js 22+**. Some dependencies need a recent Node version, so do not use anything + older than 22. +- **Docker**, to run a local PostgreSQL database without installing Postgres on your + machine. +- **pnpm** 11 (enable it with `corepack enable`; the exact version is pinned in the + `packageManager` field of `package.json`). Do not use npm or yarn: the lockfile is `pnpm-lock.yaml`. --- -## Levantar el entorno de desarrollo +## Setting up the development environment -Sigue estos pasos en orden. +Follow these steps in order. -### 1. Clonar e instalar dependencias +### 1. Clone and install dependencies ```bash -git clone <url-del-repositorio> orgtable +git clone https://github.com/Aimarmndz/orgtable.git cd orgtable pnpm install ``` -### 2. Levantar PostgreSQL con Docker +### 2. Start PostgreSQL with Docker -Arranca un contenedor de Postgres para desarrollo. Usamos el puerto **5433** en -el host (mapeado al 5432 del contenedor) para no chocar con un Postgres local -que ya estés ejecutando en el 5432: +Start a Postgres container for development. We use port **5433** on the host (mapped to +5432 in the container) so it does not clash with a local Postgres you may already be +running on 5432: ```bash docker run -d --name orgtable-dev-pg \ @@ -47,129 +47,133 @@ docker run -d --name orgtable-dev-pg \ postgres:17-alpine ``` -Para parar y arrancar el contenedor más adelante: +To stop and start the container later: ```bash docker stop orgtable-dev-pg docker start orgtable-dev-pg ``` -### 3. Crear el archivo `.env` +### 3. Create the `.env` file -Copia la plantilla y ajústala para apuntar al Postgres que acabas de levantar: +Copy the template and point it at the Postgres you just started: ```bash cp .env.example .env ``` -Edita `.env` para que `DATABASE_URL` use el puerto **5433** y el usuario, -contraseña y base de datos del contenedor, y define un `SESSION_SECRET`: +Edit `.env` so that `DATABASE_URL` uses port **5433** and the container's user, password +and database, and set a `SESSION_SECRET`: ```dotenv DATABASE_URL="postgresql://orgtable:devpass@localhost:5433/orgtable" -SESSION_SECRET="un-secreto-cualquiera-para-desarrollo" +SESSION_SECRET="any-secret-for-development" ``` -> `SESSION_SECRET` es obligatorio y debe tener **al menos 16 caracteres**: con uno más -> corto la app no puede crear sesiones y el login (y el asistente inicial) fallan. -> Genera uno con `openssl rand -hex 32`. +> `SESSION_SECRET` is required and must be **at least 16 characters** long: with a shorter +> one the app cannot create sessions, and sign-in (and the setup wizard) fail. Generate +> one with `openssl rand -hex 32`. -### 4. Aplicar migraciones y generar el cliente Prisma +The other variables are optional; they are all described in +[`installation.md`](./installation.md#environment-variables). + +### 4. Apply migrations and generate the Prisma client ```bash pnpm exec prisma migrate dev pnpm exec prisma generate ``` -- `prisma migrate dev` aplica las migraciones existentes a tu base de datos. -- `prisma generate` genera el cliente de Prisma que usa la aplicación. +- `prisma migrate dev` applies the existing migrations to your database. +- `prisma generate` generates the Prisma client the app uses. -### 5. Arrancar el servidor de desarrollo +### 5. Start the development server ```bash pnpm dev ``` -Abre **http://localhost:3000** en tu navegador. La primera vez te recibirá el -asistente de configuración inicial (`/setup`). +Open **http://localhost:3000** in your browser. The first time, the setup wizard +(`/setup`) greets you. --- -## Scripts disponibles +## Available scripts -Definidos en `package.json`: +Defined in `package.json`: -- **`pnpm dev`** — Arranca el servidor de desarrollo de Next.js (`next dev`) - con recarga en caliente. -- **`pnpm build`** — Compila la aplicación para producción (`next build`). -- **`pnpm start`** — Sirve la build de producción (`next start`). Requiere - haber ejecutado antes `pnpm build`. -- **`pnpm lint`** — Ejecuta ESLint sobre el proyecto (`eslint`). +- **`pnpm dev`** — starts the Next.js development server (`next dev`) with hot reload. +- **`pnpm build`** — builds the app for production (`next build`). +- **`pnpm start`** — serves the production build (`next start`). Run `pnpm build` first. +- **`pnpm lint`** — runs ESLint on the project (`eslint`). +- **`pnpm typecheck`** — type check (`tsc --noEmit`). +- **`pnpm test`** — unit tests with Vitest (no database). `pnpm test:watch` runs them in + watch mode. +- **`pnpm test:integration`** — integration tests against a real PostgreSQL database + whose name ends in `_test`. See [`testing.md`](./testing.md). --- -## Trabajar con la base de datos +## Working with the database -El esquema vive en **`prisma/schema.prisma`**. Cualquier cambio en los modelos -pasa por una migración. +The schema lives in **`prisma/schema.prisma`**. Every change to the models goes through +a migration. -### Crear una migración +### Creating a migration -Tras editar `prisma/schema.prisma`, genera y aplica una nueva migración con un -nombre descriptivo: +After editing `prisma/schema.prisma`, generate and apply a new migration with a +descriptive name: ```bash -pnpm exec prisma migrate dev --name <nombre-descriptivo> +pnpm exec prisma migrate dev --name <descriptive-name> ``` -Esto crea los ficheros de migración, los aplica a tu base de datos y regenera el -cliente automáticamente. +This creates the migration files, applies them to your database and regenerates the +client automatically. Restart `pnpm dev` afterwards: a running dev server keeps the old +client. -### Regenerar el cliente manualmente +### Regenerating the client manually -Si necesitas regenerar el cliente de Prisma sin crear una migración: +If you need to regenerate the Prisma client without creating a migration: ```bash pnpm exec prisma generate ``` -Para más detalle sobre el modelo de datos, las decisiones de diseño y el uso de -*fractional indexing* para el orden de listas y tarjetas, consulta -[`docs/database.md`](./database.md). +For more on the data model, the design decisions and the use of *fractional indexing* +to order lists and cards, see [`database.md`](./database.md). -> Nota: en Prisma 7 la `url` de la base de datos **no** vive en -> `schema.prisma`. El CLI de Migrate la toma de `prisma.config.ts` y, en -> runtime, el `PrismaClient` se conecta mediante el driver adapter -> `@prisma/adapter-pg` (ver `app/lib/prisma.ts`). +> Note: in Prisma 7 the database `url` does **not** live in `schema.prisma`. The Migrate +> CLI reads it from `prisma.config.ts` and, at runtime, `PrismaClient` connects through +> the `@prisma/adapter-pg` driver adapter (see `app/lib/prisma.ts`). --- -## Avisos de Next.js 16 (MUY IMPORTANTE) +## Next.js 16 caveats (VERY IMPORTANT) -> **Esta no es la versión de Next.js que conoces.** Como advierte -> [`AGENTS.md`](../AGENTS.md), esta versión introduce *breaking changes*: APIs, -> convenciones y estructura de ficheros pueden diferir de lo que tienes -> memorizado. **Antes de escribir código, lee la guía correspondiente en -> `node_modules/next/dist/docs/`** y atiende a los avisos de deprecación. +> **This is not the Next.js you know.** As [`AGENTS.md`](../AGENTS.md) warns, this version +> introduces *breaking changes*: APIs, conventions and file structure may differ from what +> you remember. **Before writing code, read the relevant guide in +> `node_modules/next/dist/docs/`** and pay attention to deprecation notices. -Los cambios más relevantes que debes tener en cuenta: +The most relevant changes to keep in mind: -- **APIs asíncronas.** `params`, `searchParams`, `cookies()` y `headers()` son - **asíncronos**: hay que usar `await`. +- **Asynchronous APIs.** `params`, `searchParams`, `cookies()` and `headers()` are + **asynchronous**: you must `await` them. ```ts - // Página con params asíncronos + // Page with asynchronous params export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; // ... } - // cookies() / headers() también + // cookies() / headers() too const cookieStore = await cookies(); ``` -- **El middleware ahora es `proxy.ts`.** El antiguo `middleware.ts` ya no se usa; - el fichero se llama **`proxy.ts`** y exporta una función `proxy`: +- **The middleware is now `proxy.ts`.** The old `middleware.ts` is no longer used; the + file is called **`proxy.ts`** and exports a `proxy` function: ```ts import type { NextRequest } from "next/server"; @@ -181,49 +185,76 @@ Los cambios más relevantes que debes tener en cuenta: export const config = { matcher: ["/((?!_next/static).*)"] }; ``` - Se ejecuta en runtime **Node.js**. + It runs on the **Node.js** runtime. -- **Mutaciones con Server Actions.** Las mutaciones (crear/editar/mover - tarjetas, etc.) se implementan con **Server Actions** marcadas con - `'use server'`. +- **Mutations with Server Actions.** Mutations (creating, editing or moving cards, etc.) + are implemented as **Server Actions** marked with `'use server'`. -- **`revalidateTag` lleva 2 argumentos.** Ahora se invoca como - `revalidateTag(tag, 'max')`. Para escenarios de *read-your-writes* existe la - nueva función `updateTag()`. +- **`revalidateTag` takes 2 arguments.** It is now called as `revalidateTag(tag, 'max')`. + For *read-your-writes* scenarios there is the new `updateTag()` function. -Cuando dudes sobre cualquiera de estas APIs, la fuente de verdad es -`node_modules/next/dist/docs/`, no tu memoria ni ejemplos de internet. +When in doubt about any of these APIs, the source of truth is +`node_modules/next/dist/docs/`, not your memory or examples from the internet. --- -## Convenciones de código - -- **TypeScript estricto.** El proyecto usa tipado estricto; evita `any` y - resuelve los errores del compilador en lugar de silenciarlos. -- **`import "server-only"`.** Todo el código de servidor que toca secretos o la - base de datos debe importar `server-only` para garantizar que nunca acabe en - el bundle del cliente. -- **Client vs Server Components.** Los componentes interactivos (drag & drop, - modales, formularios con estado) son **Client Components** (`'use client'`). - Todo lo demás son **Server Components** por defecto. -- **Antes de abrir un PR**, ejecuta: +## Code conventions + +- **Language.** Code, comments, documentation and commit messages are in English. + Technical decisions are recorded in [`DECISIONES.md`](./DECISIONES.md) (newest first, + in English; the file name is kept). +- **No hard-coded interface text.** Every visible string goes through the message + catalogs in `locales/` (see [Translations](#translations)). +- **Strict TypeScript.** The project uses strict typing; avoid `any` and fix compiler + errors instead of silencing them. +- **`import "server-only"`.** All server code that touches secrets or the database must + import `server-only` so it never ends up in the client bundle. +- **Client vs Server Components.** Interactive components (drag and drop, dialogs, + stateful forms) are **Client Components** (`'use client'`). Everything else is a + **Server Component** by default. +- **Before opening a PR**, run the same checks as CI: ```bash pnpm lint + pnpm typecheck + pnpm test pnpm build ``` - Ambos deben pasar sin errores. + All of them must pass. If you changed database code, also run + `pnpm test:integration` (see [`testing.md`](./testing.md)). --- -## Licencia +## Translations + +The interface is available in English (the default and reference language) and Spanish. +Messages live in JSON files, one folder per language (`locales/<code>/<area>.json`), so +you can translate without knowing the code. + +- To fix a translation, edit the JSON file of that language. +- To add a language, copy `locales/en/`, translate it and register it with one line in + `locales/index.ts`. +- `pnpm test` checks that every language has the same keys, placeholders and plural + forms as English. + +The full guide is in [`locales/README.md`](../locales/README.md). + +--- + +## License + +orgtable is free software under the **GNU AGPL-3.0-or-later** with an additional +attribution term (section 7(b)): see [`LICENSE`](../LICENSE). Your contributions are +published under the same license (*inbound = outbound*): you keep your copyright and do +not need to sign a separate agreement. -orgtable es software propietario de MARA-KASSA SL. Antes de contribuir, lee -[`LICENSE`](../LICENSE): hacer una aportación implica aceptar el apartado «Cesión de -derechos por aportaciones». El contexto de la decisión está en -[ADR 0016](./decisions/0016-licencia-propietaria.md). +Every commit carries a `Signed-off-by` line ([DCO](https://developercertificate.org/)): +with it you certify that you have the right to contribute that code under the project's +license. `git commit -s` adds it for you. --- -Resumen: clona el repo, levanta Postgres con Docker en el puerto 5433, configura `.env`, aplica las migraciones de Prisma y arranca con `pnpm dev`, recordando que Next.js 16 trae *breaking changes* documentados en `node_modules/next/dist/docs/`. +In short: clone the repo, start Postgres with Docker on port 5433, set up `.env`, apply +the Prisma migrations and run `pnpm dev`, keeping in mind that Next.js 16 brings +*breaking changes* documented in `node_modules/next/dist/docs/`. diff --git a/docs/database.md b/docs/database.md index 7708ecc..16e93d1 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,43 +1,44 @@ -# Capa de datos +# Data layer -Esta guía documenta cómo orgtable habla con PostgreSQL: el ORM (Prisma 7), la -configuración específica de esta versión, los modelos del esquema y la estrategia -de ordenación que hace posible el drag & drop. +This guide documents how orgtable talks to PostgreSQL: the ORM (Prisma 7), the +configuration specific to this version, the schema models and the ordering strategy that +makes drag and drop possible. -El stack es **Next.js 16 + Prisma 7 + PostgreSQL**. Si vienes de proyectos con -Prisma 5/6, presta atención: **Prisma 7 introduce cambios importantes** en cómo se -configura la conexión y el motor de consultas. Las secciones siguientes explican -qué cambió y por qué el código tiene la forma que tiene. +The stack is **Next.js 16 + Prisma 7 + PostgreSQL**. If you come from projects on Prisma +5/6, pay attention: **Prisma 7 introduces significant changes** in how the connection and +the query engine are configured. The sections below explain what changed and why the code +looks the way it does. -Decisiones de arquitectura relacionadas: +Related architecture decisions: -- [`docs/decisions/0001-prisma.md`](decisions/0001-prisma.md) — por qué Prisma y - cómo lo configuramos en la versión 7. -- [`docs/decisions/0002-fractional-indexing.md`](decisions/0002-fractional-indexing.md) - — por qué usamos fractional indexing para ordenar listas y tarjetas. +- [`docs/decisions/0001-prisma.md`](decisions/0001-prisma.md): why Prisma, and how it is + configured in version 7. +- [`docs/decisions/0002-fractional-indexing.md`](decisions/0002-fractional-indexing.md): + why lists and cards are ordered with fractional indexing. --- -## Archivos clave +## Key files -| Archivo | Rol | +| File | Role | | --- | --- | -| `prisma/schema.prisma` | Definición de modelos, enums, índices y relaciones. | -| `prisma.config.ts` | Configuración del CLI de Prisma 7 (Migrate). | -| `app/lib/prisma.ts` | Cliente de runtime (`PrismaClient` con driver adapter). | +| `prisma/schema.prisma` | Models, enums, indexes and relations. | +| `prisma/migrations/` | Versioned SQL migrations. | +| `prisma.config.ts` | Prisma 7 CLI configuration (Migrate). | +| `app/lib/prisma.ts` | Runtime client (`PrismaClient` with a driver adapter). | --- -## Prisma 7: lo que cambia respecto a versiones anteriores +## Prisma 7: what changed from earlier versions -Prisma 7.8 reorganiza dónde vive la URL de conexión y cómo se conecta el cliente. -Estos cuatro hechos son la base de toda la capa de datos. **No los copies de -tutoriales antiguos**, porque la mayoría describe Prisma 5/6. +Prisma 7 reorganizes where the connection URL lives and how the client connects. These +four facts are the basis of the whole data layer. **Do not copy them from old tutorials**: +most of them describe Prisma 5/6. -### 1. La `url` ya no va en `schema.prisma` +### 1. The `url` no longer goes in `schema.prisma` -En versiones anteriores el bloque `datasource` llevaba `url = env("DATABASE_URL")`. -En Prisma 7 eso ya **no está permitido**. El esquema solo declara el proveedor: +In earlier versions the `datasource` block had `url = env("DATABASE_URL")`. Prisma 7 **no +longer allows** this. The schema only declares the provider: ```prisma datasource db { @@ -45,9 +46,9 @@ datasource db { } ``` -La URL que necesita el **CLI de Migrate** (para `migrate dev`, `migrate deploy`, -etc.) se define ahora en `prisma.config.ts`, en el campo `datasource.url`, usando el -helper `env()` que exporta `prisma/config`: +The URL that the **Migrate CLI** needs (for `migrate dev`, `migrate deploy`, etc.) is now +defined in `prisma.config.ts`, in the `datasource.url` field, using the `env()` helper +exported by `prisma/config`: ```ts // prisma.config.ts @@ -62,66 +63,70 @@ export default defineConfig({ }); ``` -### 2. Prisma 7 no carga `.env` automáticamente +### 2. Prisma 7 does not load `.env` automatically -Las versiones antiguas leían `.env` por arte de magia. Prisma 7 ya no lo hace, así -que `prisma.config.ts` lo carga a mano usando **`process.loadEnvFile()`**, una API -nativa de Node (disponible desde 20.12+). **No usamos la dependencia `dotenv`**: +Older versions read `.env` implicitly. Prisma 7 no longer does, so `prisma.config.ts` +loads it by hand with **`process.loadEnvFile()`**, a native Node API (available since +20.12). **The project does not use the `dotenv` dependency**: ```ts // prisma.config.ts try { process.loadEnvFile(path.join(process.cwd(), ".env")); } catch { - // .env ausente: las variables ya vienen del entorno (p. ej. docker-compose). + // No .env: the variables already come from the environment (e.g. docker-compose). } ``` -El `try/catch` es deliberado: en CI y en producción no existe un `.env` (las -variables se inyectan por el entorno), y el bloque simplemente lo ignora. +The `try/catch` is deliberate: in CI and in production there is no `.env` (variables are +injected by the environment), and the block simply skips it. -### 3. El query engine es WASM, no un binario de Rust +### 3. The query engine is WASM, not a Rust binary -Históricamente Prisma embebía un motor de consultas en **binario Rust**, lo que -obligaba a declarar `binaryTargets` en el generator y a copiar engines a la imagen -Docker según la plataforma. En Prisma 7 **el query engine es WASM** y la conexión -directa a la base de datos en runtime se hace mediante un **driver adapter**. +Historically Prisma embedded a query engine as a **Rust binary**, which meant declaring +`binaryTargets` in the generator and copying engines into the Docker image for each +platform. In Prisma 7 **the query engine is WASM**, and the runtime connection to the +database goes through a **driver adapter**. -Aquí usamos `@prisma/adapter-pg` (clase `PrismaPg`) sobre el driver `pg` -(node-postgres). El `PrismaClient` se instancia pasándole el adapter: +orgtable uses `@prisma/adapter-pg` (the `PrismaPg` class) on top of the `pg` driver +(node-postgres). The `PrismaClient` is created with the adapter: ```ts -// app/lib/prisma.ts +// app/lib/prisma.ts (abridged) import { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; function createPrismaClient() { const url = process.env.DATABASE_URL; - // Sin DATABASE_URL, el adapter caería en silencio a los valores por defecto de libpq - // (PGHOST/localhost…). Durante `next build` no se exige: el build no usa la BD. + // Without DATABASE_URL the adapter would silently fall back to the libpq defaults + // (PGHOST/localhost…). It is not required during `next build`, which does not use the DB. if (!url && process.env.NEXT_PHASE !== "phase-production-build") { - throw new Error( - "DATABASE_URL no está configurado. Define la cadena de conexión a PostgreSQL.", - ); + throw new Error(/* DATABASE_URL is not configured */); } - const adapter = new PrismaPg({ connectionString: url }); + const adapter = new PrismaPg(buildPoolConfig(url), { + onPoolError: (err) => console.error(/* … */ err.message), + }); return new PrismaClient({ adapter }); } ``` -La guarda de `DATABASE_URL` es un control de seguridad (ver -[security.md](./security.md)); si cambias esta función, mantenla. El código real está -en [`app/lib/prisma.ts`](../app/lib/prisma.ts). +- The `DATABASE_URL` guard is a security control (see [security.md](./security.md)); if + you change this function, keep it. +- `buildPoolConfig` sets a 5-second `connectionTimeoutMillis` (with the `pg` default of 0, + a request that finds no free connection waits forever; Prisma 7 no longer applies the + 5-second `connect_timeout` that Prisma 6 had) and an explicit 10-second + `idleTimeoutMillis`. `test/integration/prisma-pool.int.test.ts` checks both. +- `onPoolError` logs failures of idle connections (Postgres restarted, network cut). It + logs only the message, never the connection string, which contains the password. + +The real code is in [`app/lib/prisma.ts`](../app/lib/prisma.ts). -Consecuencia práctica: **no hace falta `binaryTargets`** en el generator ni copiar -engines binarios al contenedor. La imagen Docker queda más simple y portable. +Practical consequence: **no `binaryTargets`** in the generator and no engine binaries to +copy into the container. The Docker image is simpler and more portable. -### 4. El cliente es un singleton en `globalThis` +### 4. The client is a singleton on `globalThis` -En desarrollo, Next.js recarga los módulos en caliente (HMR). Si cada recarga -creara un `PrismaClient` nuevo, se abrirían conexiones sin cerrar hasta **agotar el -pool de PostgreSQL**. Para evitarlo, guardamos la instancia en `globalThis` y la -reutilizamos: +The client is stored on `globalThis` and reused, **in development and in production**: ```ts // app/lib/prisma.ts @@ -131,42 +136,52 @@ const globalForPrisma = globalThis as unknown as { export const prisma = globalForPrisma.prisma ?? createPrismaClient(); -if (process.env.NODE_ENV !== "production") { - globalForPrisma.prisma = prisma; -} +globalForPrisma.prisma = prisma; ``` -En producción el cliente se crea una sola vez (no se cachea en `globalThis`, no hace -falta). Nota: `app/lib/prisma.ts` empieza con `import "server-only"`, de modo que -este módulo nunca pueda colarse en un bundle de cliente. +- In development, Next.js hot-reloads modules (HMR). If every reload created a new + `PrismaClient`, connections would pile up until the **PostgreSQL pool is exhausted**. +- In production, Turbopack evaluates the module once per layer (route handlers on one + side, RSC/SSR on the other). Without `globalThis` there would be two pools and twice the + connections to Postgres. `realtime/bus.ts` and `rate-limit.ts` use the same pattern. + +`app/lib/prisma.ts` starts with `import "server-only"`, so this module can never end up in +a client bundle. --- -## Modelos +## Models -El esquema vive en `prisma/schema.prisma`. Tabla resumen: +The schema lives in `prisma/schema.prisma`. Summary: -| Modelo | Representa | Por qué existe | +| Model | Represents | Why it exists | | --- | --- | --- | -| `InstanceConfig` | Configuración global de la instancia (fila única). | Guarda el nombre de la instancia y, sobre todo, `setupCompletedAt`: la fuente de verdad de "ya configurada". | -| `User` | Una cuenta de usuario. | Autenticación y propiedad de tableros. | -| `Board` | Un tablero (un proyecto/espacio). | Contenedor de listas, etiquetas y miembros. | -| `List` | Una columna dentro de un tablero. | Agrupa tarjetas; ordenable por drag & drop. | -| `Card` | Una tarjeta dentro de una lista. | Unidad de trabajo; ordenable y movible entre listas. | -| `Label` | Una etiqueta de color de un tablero. | Clasificación visual de tarjetas. | -| `CardLabel` | Unión muchos-a-muchos entre `Card` y `Label`. | Permite que una tarjeta tenga varias etiquetas y viceversa. | -| `BoardMember` | Membresía de un usuario en un tablero con un rol. | Punto único de autorización por tablero. | +| `InstanceConfig` | Global instance configuration (single row). | Instance name, `setupCompletedAt` (the source of truth for "already configured"), open sign-up and the operator's legal details. | +| `User` | A user account. | Authentication, board ownership, interface language (`locale`). | +| `Session` | A browser session. | Lets sessions be revoked immediately (see [authentication.md](./authentication.md)). | +| `Board` | A board (a project or space). | Container for lists, labels and members. | +| `List` | A column within a board. | Groups cards; ordered by drag and drop. | +| `Card` | A card within a list. | Unit of work; ordered and movable between lists. | +| `Label` | A colored label of a board. | Visual classification of cards. | +| `CardLabel` | Many-to-many join between `Card` and `Label`. | A card can have several labels and vice versa. | +| `Checklist` / `ChecklistItem` | Checklists of a card and their items. | Sub-tasks with progress. | +| `Attachment` | A file attached to a card. | Metadata; the file itself is on disk (see [attachments.md](./attachments.md)). | +| `CardAssignee` | Many-to-many join between `Card` and `User`. | Card assignees (board members only). | +| `Comment` | A comment on a card. | Flat discussion thread. | +| `BoardMember` | A user's membership in a board, with a role. | Single point of per-board authorization. | +| `Notification` | An in-app notification for a user. | Notification bell, deep-links to boards and cards. | +| `ApiToken` | A personal API token (hashed). | Public REST API access, with scope and optional expiry. | +| `AuditLog` | A security/operations event. | Audit log in `/admin/audit`, with limited retention. | ### `InstanceConfig` -Una **fila única** con `id` fijo (`"singleton"`). Modela la configuración de toda la -instancia self-hosted. +A **single row** with a fixed `id` (`"singleton"`). It models the configuration of the +whole self-hosted instance. -El campo clave es **`setupCompletedAt` (DateTime opcional)**: es la **fuente de -verdad de "la instancia ya está configurada"**. Mientras sea `null`, el **setup -wizard** (`/setup`) está activo y guía la configuración inicial; en cuanto se rellena -con una fecha, el wizard queda cerrado. Toda la lógica de arranque debe consultar -este campo, no inferir el estado de otra parte. +The key field is **`setupCompletedAt` (optional DateTime)**: it is the **source of truth +for "the instance is configured"**. While it is `null`, the **setup wizard** (`/setup`) is +active and guides the initial configuration; once it holds a date, the wizard is closed. +All startup logic must check this field, not infer the state from somewhere else. ```prisma model InstanceConfig { @@ -174,126 +189,143 @@ model InstanceConfig { instanceName String setupCompletedAt DateTime? createdAt DateTime @default(now()) + registrationOpen Boolean @default(false) + // … operator's legal details (operatorName, operatorEmail, …), legalUpdatedAt } ``` +`registrationOpen` turns on open sign-up at `/register` (closed by default). The +`operator*` fields hold the legal details of **whoever runs the instance**, shown on the +`/legal/*` pages; see [privacy.md](./privacy.md). + ### `User` -Cuenta de usuario. `email` es único, `passwordHash` guarda la contraseña ya -hasheada (nunca en claro) y `role` usa el enum `Role` (`ADMIN` | `MEMBER`, -por defecto `MEMBER`). Relaciona con los `Board` que posee (`boards`) y con sus -`memberships` (las filas `BoardMember`). +A user account. `email` is unique, `passwordHash` stores the already-hashed password +(never in plain text) and `role` uses the `Role` enum (`ADMIN` | `MEMBER`, default +`MEMBER`). `termsAcceptedAt` records when someone accepted the policy at sign-up (null for +accounts created by the admin or the wizard). `locale` is the interface language the +person chose (null until they pick one, in which case the browser decides). It relates to +the `Board`s the user owns (`boards`), their `memberships` (`BoardMember` rows), sessions, +notifications, API tokens, assigned cards and comments. ### `Board` -Un tablero. Tiene un `owner` (relación a `User`, con `onDelete: Cascade`: borrar el -usuario borra sus tableros) y campos de presentación opcionales (`description`, -`background`). Agrupa `lists`, `labels` y `members`. Lleva `@@index([ownerId])` para -listar rápido los tableros de un usuario. +A board. It has an `owner` (relation to `User`, with `onDelete: Cascade`: deleting the +user deletes their boards) and optional presentation fields (`description`, `background`). +It groups `lists`, `labels` and `members`. It has `@@index([ownerId])` to list a user's +boards quickly. ### `List` -Columna de un tablero. Pertenece a un `board` (cascade) y se ordena por la columna -**`position` (string)** — fractional indexing, ver más abajo. Indexada por +A column of a board. It belongs to a `board` (cascade) and is ordered by the +**`position` (string)** column, using fractional indexing (see below). Indexed by `boardId`. ### `Card` -Tarjeta dentro de una lista. Pertenece a una `list` (cascade), tiene `description` y -`dueDate` opcionales, y se ordena por **`position` (string)**. El índice -`@@index([listId, position])` permite leer las tarjetas de una lista **ya ordenadas** -de forma eficiente. Las etiquetas se acceden vía la relación `labels` (a través de -`CardLabel`). +A card within a list. It belongs to a `list` (cascade), has optional `description` and +`dueDate`, and is ordered by **`position` (string)**. The +`@@index([listId, position])` index reads the cards of a list **already sorted**. Labels +are reached through the `labels` relation (via `CardLabel`); it also has checklists, +attachments, assignees and comments. ### `Label` -Etiqueta de color, definida **a nivel de tablero** (pertenece a un `board`, cascade). -Tiene `name` y `color`. Indexada por `boardId`. +A colored label, defined **at board level** (it belongs to a `board`, cascade). It has a +`name` and a `color`. Indexed by `boardId`. ### `CardLabel` -Tabla de unión **muchos-a-muchos** entre `Card` y `Label`. Su clave primaria es -compuesta (`@@id([cardId, labelId])`), lo que impide duplicar la misma etiqueta en -la misma tarjeta. Ambas relaciones son cascade. Índice adicional por `labelId`. +**Many-to-many** join table between `Card` and `Label`. Its primary key is composite +(`@@id([cardId, labelId])`), which prevents adding the same label twice to the same card. +Both relations cascade. Extra index on `labelId`. ### `BoardMember` -Membresía de un usuario en un tablero, con un `role` (enum `BoardRole`: -`OWNER` | `EDITOR` | `VIEWER`, por defecto `EDITOR`). Clave primaria compuesta -(`@@id([boardId, userId])`). +A user's membership in a board, with a `role` (enum `BoardRole`: `OWNER` | `EDITOR` | +`VIEWER`, default `EDITOR`). Composite primary key (`@@id([boardId, userId])`). -Aunque el **MVP es de un solo usuario**, `BoardMember` (igual que `Label` y -`CardLabel`) **se crea desde ya**. La razón es deliberada: la **autorización de cada -Server Action pasa por esta tabla desde el principio**, de modo que al llegar el hito -multiusuario no haya que re-migrar el esquema ni reescribir la capa de permisos. +`BoardMember` existed in the schema from the first migration, before multi-user support +shipped. This was deliberate: **every Server Action has been authorized through this +table from the start**, so adding multi-user support did not require re-migrating the +schema or rewriting the permission layer. --- -## Ordenación para drag & drop: fractional indexing +## Ordering for drag and drop: fractional indexing -Las columnas `position` de `List` y `Card` **no son enteros ni floats**: son -**claves string lexicográficas** generadas con la librería -[`fractional-indexing`](https://github.com/rocicorp/fractional-indexing) (la técnica -que popularizó Figma). +The `position` columns of `List`, `Card`, `Checklist` and `ChecklistItem` are **neither +integers nor floats**: they are **lexicographic string keys** generated with the +[`fractional-indexing`](https://github.com/rocicorp/fractional-indexing) library (the +technique popularized by Figma). -La idea: la posición de cada fila es una cadena que se ordena alfabéticamente. Para -insertar o mover una fila entre dos vecinas, se genera una clave **intermedia** entre -la anterior y la siguiente con `generateKeyBetween(prev, next)`: +The idea: each row's position is a string that sorts alphabetically. To insert or move a +row between two neighbours, an **intermediate** key between the previous and the next one +is generated with `generateKeyBetween(prev, next)`: ```ts import { generateKeyBetween } from "fractional-indexing"; -// Insertar entre dos tarjetas existentes: +// Insert between two existing cards: const position = generateKeyBetween(prevCard.position, nextCard.position); -// Al principio de la lista (sin vecino previo): +// At the start of the list (no previous neighbour): const first = generateKeyBetween(null, firstCard.position); -// Al final (sin vecino siguiente): +// At the end (no next neighbour): const last = generateKeyBetween(lastCard.position, null); ``` -**Por qué es mejor que un entero/float de posición:** +**Why this beats an integer or float position:** + +- **Moving a row updates ONLY that row.** With an integer position (0, 1, 2…), inserting + in the middle means renumbering all the neighbours: many writes and contention. With + fractional indexing a single `UPDATE` is enough. +- **No reindexing and no collisions.** There is always a key between two different keys, + so there is no need to reserve gaps or "compact" periodically, as happens when a float + runs out of precision between two very close values. +- **Concurrency-friendly.** Since each move touches a single row, two users moving + different cards almost never collide. -- **Mover una fila actualiza SOLO esa fila.** Con un entero de posición (0, 1, 2…), - insertar en medio obliga a re-numerar todos los vecinos: muchas escrituras y - contención. Con fractional indexing basta un único `UPDATE`. -- **Sin reindexados ni colisiones.** Siempre existe una clave entre dos claves - distintas, así que no hay que reservar huecos ni "compactar" periódicamente como - ocurre cuando un float se queda sin precisión entre dos valores muy juntos. -- **Amigable con la concurrencia.** Como cada movimiento toca una sola fila, dos - usuarios moviendo tarjetas distintas casi nunca chocan. +In the database these columns are `TEXT COLLATE "C"` (byte-by-byte order). With the +default glibc collation, `'Zz'` would sort after `'a0'`. Prisma does not model collations; +the `20260924000000_position_collate_c` migration sets it. -El índice `@@index([listId, position])` de `Card` aprovecha esto para devolver las -tarjetas de una lista ya ordenadas sin un sort adicional en memoria. +The `@@index([listId, position])` index on `Card` uses this to return the cards of a list +already sorted, without an extra in-memory sort. -Ver [`docs/decisions/0002-fractional-indexing.md`](decisions/0002-fractional-indexing.md) -para el razonamiento completo. +See [`docs/decisions/0002-fractional-indexing.md`](decisions/0002-fractional-indexing.md) +for the full reasoning, and [`docs/drag-and-drop.md`](./drag-and-drop.md) for the client +side. --- -## Comandos +## Commands -Generar el cliente de Prisma (tras cambiar el esquema): +Generate the Prisma client (after changing the schema): ```bash pnpm exec prisma generate ``` -Crear y aplicar una migración en **desarrollo** (genera el SQL y lo aplica a tu base -local): +Create and apply a migration in **development** (generates the SQL and applies it to your +local database): ```bash -pnpm exec prisma migrate dev --name <nombre> +pnpm exec prisma migrate dev --name <name> ``` -Aplicar migraciones pendientes en **producción** / al arrancar el contenedor (no -genera nada nuevo, solo aplica lo ya versionado): +Apply pending migrations in **production** or when the container starts (generates +nothing new, it only applies what is already versioned): ```bash pnpm exec prisma migrate deploy ``` -En **producción las migraciones las aplica el entrypoint del contenedor**: el -arranque ejecuta `prisma migrate deploy` antes de levantar la app, de modo que el -esquema esté siempre al día sin pasos manuales. +In **production the container entrypoint applies the migrations**: startup runs +`prisma migrate deploy` before starting the app, so the schema is always up to date with +no manual steps. + +> After a migration, restart `pnpm dev`: regenerating the Prisma client while the dev +> server is running leaves it with a stale client (errors such as +> `findMany of undefined`). diff --git a/docs/decisions/0001-prisma.md b/docs/decisions/0001-prisma.md index 5a3a812..135eba9 100644 --- a/docs/decisions/0001-prisma.md +++ b/docs/decisions/0001-prisma.md @@ -1,39 +1,38 @@ -# ADR 0001 — Prisma como ORM +# ADR 0001 — Prisma as the ORM -**Estado:** aceptada · **Fecha:** 2026-06-04 +**Status:** accepted · **Date:** 2026-06-04 -## Contexto +## Context -orgtable necesita una capa de acceso a datos sobre PostgreSQL que sea cómoda para -contribuidores, con migraciones versionadas y buena integración con TypeScript y -Next.js 16 (empaquetado en Docker con `output: standalone`). +orgtable needs a data access layer on top of PostgreSQL that is comfortable for +contributors, with versioned migrations and good integration with TypeScript and +Next.js 16 (packaged in Docker with `output: standalone`). -Se evaluaron **Prisma** y **Drizzle ORM**. +**Prisma** and **Drizzle ORM** were evaluated. -## Decisión +## Decision -Usar **Prisma 7** con el driver adapter `@prisma/adapter-pg` sobre `pg` (node-postgres). +Use **Prisma 7** with the `@prisma/adapter-pg` driver adapter on top of `pg` (node-postgres). -## Justificación +## Rationale -- Prisma es el ORM más popular del ecosistema, con la mejor experiencia "out of the - box" para quien no domina SQL: esquema declarativo, tipos generados y migraciones - guiadas (`migrate dev`). Esto baja la barrera de entrada para contribuidores. -- Prisma 7 cambió su arquitectura de forma favorable para self-hosting: - - El **query engine es WASM** (no un binario de Rust), así que el cliente se - empaqueta limpio en la imagen Docker sin copiar engines por plataforma. - - La conexión directa se hace con un **driver adapter** (`PrismaPg`), lo que da - control explícito sobre el pool de `pg`. -- Las migraciones (`prisma migrate deploy`) son idempotentes y encajan con el - arranque del contenedor. +- Prisma is the most popular ORM in the ecosystem, with the best out-of-the-box + experience for people who are not fluent in SQL: declarative schema, generated types and + guided migrations (`migrate dev`). This lowers the entry barrier for contributors. +- Prisma 7 changed its architecture in a way that favors self-hosting: + - The **query engine is WASM** (not a Rust binary), so the client is packaged cleanly + into the Docker image without copying per-platform engines. + - The direct connection goes through a **driver adapter** (`PrismaPg`), which gives + explicit control over the `pg` pool. +- Migrations (`prisma migrate deploy`) are idempotent and fit the container start-up. -## Consecuencias +## Consequences -- La URL de conexión ya **no** vive en `schema.prisma` (Prisma 7 lo prohíbe): va en - `prisma.config.ts` para el CLI, y en runtime se pasa vía el adapter. -- `prisma.config.ts` debe cargar `.env` manualmente (`process.loadEnvFile()`), porque - Prisma 7 ya no lo hace automáticamente. -- Trade-off aceptado: Drizzle generaría imágenes algo más pequeñas y SQL más - transparente, pero Prisma gana en DX para el público objetivo del proyecto. +- The connection URL **no longer** lives in `schema.prisma` (Prisma 7 forbids it): it goes + in `prisma.config.ts` for the CLI, and at runtime it is passed through the adapter. +- `prisma.config.ts` must load `.env` by hand (`process.loadEnvFile()`), because Prisma 7 + no longer does it automatically. +- Accepted trade-off: Drizzle would produce somewhat smaller images and more transparent + SQL, but Prisma wins on DX for the project's target audience. -Ver `docs/database.md` para el detalle de la implementación. +See `docs/database.md` for the implementation details. diff --git a/docs/decisions/0002-fractional-indexing.md b/docs/decisions/0002-fractional-indexing.md index 84c213e..ab652bc 100644 --- a/docs/decisions/0002-fractional-indexing.md +++ b/docs/decisions/0002-fractional-indexing.md @@ -1,58 +1,57 @@ -# ADR 0002 — Fractional indexing para el orden de listas y tarjetas - -**Estado:** aceptada · **Fecha:** 2026-06-04 - -## Contexto - -El núcleo de un tablero Kanban es reordenar listas y tarjetas con drag & drop. Cada -movimiento debe persistirse de forma eficiente y soportar reordenamientos frecuentes -sin degradarse. - -Opciones evaluadas para la columna de orden: - -1. **Entero/float `position`** (p. ej. `0.5` entre `0` y `1`): se queda sin precisión - de coma flotante tras pocas inserciones repetidas en el mismo hueco, rompiendo el - orden de forma silenciosa. O bien obliga a reindexar muchas filas por movimiento. -2. **LexoRank** (estilo Jira): robusto pero con lógica de rebalanceo compleja. -3. **Fractional indexing** (librería `fractional-indexing` de Figma, ~1 KB). - -## Decisión - -Usar **fractional indexing**. Las columnas `List.position` y `Card.position` son -`String` (clave lexicográfica). Para insertar/mover entre dos vecinas A y B se calcula -`generateKeyBetween(A.position, B.position)`; al final, `generateKeyBetween(last, null)`. - -## Justificación - -- **Un movimiento = un solo `UPDATE`** de la fila que se mueve. Nunca se tocan las - vecinas, así que no hay tormentas de escritura ni reindexado. -- El orden es estable y se mantiene con una simple ordenación lexicográfica en SQL. -- Mover una tarjeta entre listas es el mismo `UPDATE` cambiando `listId` y `position`. - -## Consecuencias - -- Dos clientes concurrentes podrían generar la misma clave al mover al mismo punto - exacto. En el MVP (un solo usuario) no aplica; cuando llegue el tiempo real - (ver `docs/roadmap.md`) se regenerará la clave del "perdedor". -- **Collation "C" obligatoria** (añadido 2026-09-24). Las claves (base62 `0-9A-Za-z`) - solo están bien ordenadas byte a byte. Con la collation por defecto de una BD glibc - (`en_US.utf8` en la imagen Debian de postgres, Ubuntu o servicios gestionados) - `ORDER BY position` deja `Zz` (lo que genera arrastrar al principio) después de `a0`: - la tarjeta reaparece al final al refrescar y `createCard`/`createList`/`addChecklist`/ - `addChecklistItem`, que buscan el último con `ORDER BY position DESC`, generan claves - repetidas. También pasa sin arrastrar, solo con altas al final: tras `aZ` viene `aa`, - que esa collation coloca antes de `aB`…`aZ`, así que a partir del elemento 38 de una - lista, tablero, tarjeta o checklist se toma `aZ` como último y se repite `aa`. La migración - `20260924000000_position_collate_c` fija `TEXT COLLATE "C"` en `List`, `Card`, - `Checklist` y `ChecklistItem.position`. Prisma no modela la collation, así que el - esquema sigue diciendo `String` y no aparece deriva; cualquier columna de posición - nueva debe crearse con `COLLATE "C"` a mano en su migración. No se ordena en JS ni con - `localeCompare`: el orden lo da la BD. -- La migración no reescribe valores. Las instalaciones que ya corrían sobre glibc pueden - tener claves repetidas; se detectan con +# ADR 0002 — Fractional indexing for the order of lists and cards + +**Status:** accepted · **Date:** 2026-06-04 + +## Context + +The core of a Kanban board is reordering lists and cards with drag & drop. Every move +must be persisted efficiently and support frequent reordering without degrading. + +Options evaluated for the ordering column: + +1. **Integer/float `position`** (e.g. `0.5` between `0` and `1`): runs out of + floating-point precision after a few repeated insertions into the same gap, silently + breaking the order. Or else it forces reindexing many rows per move. +2. **LexoRank** (Jira style): robust but with complex rebalancing logic. +3. **Fractional indexing** (Figma's `fractional-indexing` library, ~1 KB). + +## Decision + +Use **fractional indexing**. The `List.position` and `Card.position` columns are +`String` (lexicographic key). To insert/move between two neighbors A and B, compute +`generateKeyBetween(A.position, B.position)`; at the end, `generateKeyBetween(last, null)`. + +## Rationale + +- **One move = a single `UPDATE`** of the row being moved. Neighbours are never touched, + so there are no write storms and no reindexing. +- The order is stable and is kept with a simple lexicographic sort in SQL. +- Moving a card between lists is the same `UPDATE`, changing `listId` and `position`. + +## Consequences + +- Two concurrent clients could generate the same key when moving to exactly the same spot. + In the MVP (a single user) this does not apply; when realtime arrives + (see `docs/roadmap.md`) the "loser's" key will be regenerated. +- **Collation "C" is mandatory** (added 2026-09-24). The keys (base62 `0-9A-Za-z`) are + only correctly ordered byte by byte. With the default collation of a glibc database + (`en_US.utf8` in the Debian postgres image, Ubuntu or managed services) + `ORDER BY position` puts `Zz` (what dragging to the top generates) after `a0`: the card + reappears at the bottom on refresh, and `createCard`/`createList`/`addChecklist`/ + `addChecklistItem`, which look for the last one with `ORDER BY position DESC`, generate + duplicate keys. It also happens without dragging, just by appending: after `aZ` comes + `aa`, which that collation places before `aB`…`aZ`, so from the 38th element of a list, + board, card or checklist onwards `aZ` is taken as the last one and `aa` is repeated. The + migration `20260924000000_position_collate_c` sets `TEXT COLLATE "C"` on `List`, `Card`, + `Checklist` and `ChecklistItem.position`. Prisma does not model the collation, so the + schema still says `String` and no drift shows up; any new position column must be + created with `COLLATE "C"` by hand in its migration. Sorting is not done in JS or with + `localeCompare`: the database provides the order. +- The migration does not rewrite values. Installations that already ran on glibc may have + duplicate keys; they are detected with `SELECT "listId", position, count(*) FROM "Card" GROUP BY 1, 2 HAVING count(*) > 1` - y lo mismo con `"boardId"` en `"List"`, `"cardId"` en `"Checklist"` y `"checklistId"` - en `"ChecklistItem"`. Reasignarlas es decisión del propietario de - la instalación, con copia de seguridad previa (`scripts/backup.sh`). + and the same with `"boardId"` on `"List"`, `"cardId"` on `"Checklist"` and + `"checklistId"` on `"ChecklistItem"`. Reassigning them is up to the owner of the + installation, with a prior backup (`scripts/backup.sh`). -Ver `docs/drag-and-drop.md` para el uso desde la UI. +See `docs/drag-and-drop.md` for how the UI uses it. diff --git a/docs/decisions/0003-db-creds-in-env.md b/docs/decisions/0003-db-creds-in-env.md index 6f6c056..0f1ab14 100644 --- a/docs/decisions/0003-db-creds-in-env.md +++ b/docs/decisions/0003-db-creds-in-env.md @@ -1,35 +1,35 @@ -# ADR 0003 — Credenciales de base de datos en `.env` (no en el setup wizard) +# ADR 0003 — Database credentials in `.env` (not in the setup wizard) -**Estado:** aceptada · **Fecha:** 2026-06-04 +**Status:** accepted · **Date:** 2026-06-04 -## Contexto +## Context -orgtable se instala con un comando (estilo Coolify/Dokploy) y se configura por web la -primera vez. Surge la pregunta: ¿dónde se definen las credenciales de PostgreSQL? +orgtable is installed with one command (Coolify/Dokploy style) and configured over the web +the first time. The question arises: where are the PostgreSQL credentials defined? -- **Modelo Nextcloud/WordPress**: la app arranca sin base de datos y un asistente web - pide host/usuario/contraseña de la BD, los escribe a disco y se reinicia. -- **Modelo Coolify/Dokploy**: el instalador genera las credenciales antes de levantar; - el asistente web solo recoge datos de la aplicación (usuario admin, nombre). +- **Nextcloud/WordPress model**: the app starts without a database and a web wizard asks + for the database host/user/password, writes them to disk and restarts. +- **Coolify/Dokploy model**: the installer generates the credentials before starting; the + web wizard only collects application data (admin user, name). -## Decisión +## Decision -Adoptar el **modelo Coolify**: `install.sh` genera `POSTGRES_PASSWORD` y -`SESSION_SECRET` aleatorios (`openssl rand`) en `.env` antes de `docker compose up`. La -app **siempre** arranca con la base de datos conectada. El setup wizard solo pide el -**usuario administrador** y el **nombre de la instancia**. +Adopt the **Coolify model**: `install.sh` generates random `POSTGRES_PASSWORD` and +`SESSION_SECRET` (`openssl rand`) into `.env` before `docker compose up`. The app +**always** starts with the database connected. The setup wizard only asks for the +**administrator user** and the **instance name**. -## Justificación +## Rationale -- La app Next necesita conexión a PostgreSQL para arrancar el propio wizard (que - guarda el admin en la tabla `users`). El modelo Nextcloud implicaría arrancar sin - DB, escribir config en el sistema de archivos del contenedor y reiniciar —frágil y - con condiciones de carrera en un contenedor inmutable—. -- El usuario nunca teclea ni ve credenciales de infraestructura: menos errores, más - seguridad. +- The Next app needs a PostgreSQL connection to run the wizard itself (which stores the + admin in the `users` table). The Nextcloud model would mean starting without a database, + writing config to the container's file system and restarting — fragile, and prone to + race conditions in an immutable container. +- The user never types or sees infrastructure credentials: fewer mistakes, more security. -## Consecuencias +## Consequences -- El estado "ya configurado" vive en la base de datos (`InstanceConfig.setupCompletedAt`), - no en un archivo. Ver `docs/setup-wizard.md`. -- Si alguien borra esa fila manualmente, el wizard reaparece (aceptable: es su servidor). +- The "already configured" state lives in the database (`InstanceConfig.setupCompletedAt`), + not in a file. See `docs/setup-wizard.md`. +- If someone deletes that row by hand, the wizard reappears (acceptable: it is their + server). diff --git a/docs/decisions/0004-no-redis-mvp.md b/docs/decisions/0004-no-redis-mvp.md index 354e85f..3af955e 100644 --- a/docs/decisions/0004-no-redis-mvp.md +++ b/docs/decisions/0004-no-redis-mvp.md @@ -1,39 +1,38 @@ -# ADR 0004 — Sin Redis en el MVP +# ADR 0004 — No Redis in the MVP -**Estado:** aceptada · **Fecha:** 2026-06-04 +**Status:** accepted · **Date:** 2026-06-04 -## Contexto +## Context -Muchas apps self-hosted incluyen Redis por defecto (sesiones, caché, colas). ¿Lo -necesita orgtable en su primer hito? +Many self-hosted apps include Redis by default (sessions, cache, queues). Does orgtable +need it in its first release? -## Decisión +## Decision -**No** incluir Redis en el MVP. El `docker-compose.yml` solo tiene `app` + `postgres`. +**Do not** include Redis in the MVP. `docker-compose.yml` only has `app` + `postgres`. -## Justificación +## Rationale -- **Sesiones**: son JWT stateless firmados con `jose` y guardados en una cookie - httpOnly. No hay almacén de sesiones que requiera Redis. -- **Tiempo real**: el MVP no sincroniza en vivo entre usuarios, así que no hay - pub/sub. -- **Caché de Next**: en una sola instancia, el standalone usa su caché en disco/memoria - sin necesidad de un store externo. +- **Sessions**: they are stateless JWTs signed with `jose` and stored in an httpOnly + cookie. There is no session store that requires Redis. +- **Realtime**: the MVP does not sync live between users, so there is no pub/sub. +- **Next cache**: on a single instance, the standalone build uses its disk/memory cache + without needing an external store. -Añadir Redis ahora sería complejidad muerta. +Adding Redis now would be dead complexity. -## Consecuencias +## Consequences -- La revocación inmediata de sesiones no es posible en el MVP (límite documentado en +- Immediate session revocation is not possible in the MVP (limitation documented in `docs/authentication.md`). -## Actualización (Hito 4) +## Update (realtime feature) -Al implementar el tiempo real y la revocación de sesiones, **Redis no entró**. -Reconsideramos la suposición de esta ADR y elegimos reutilizar PostgreSQL: +When realtime and session revocation were implemented, **Redis did not come in**. We +reconsidered this ADR's assumption and chose to reuse PostgreSQL: -- Pub/sub: bus en proceso + PostgreSQL `LISTEN/NOTIFY` opcional. Ver +- Pub/sub: in-process bus + optional PostgreSQL `LISTEN/NOTIFY`. See [ADR 0007](./0007-pg-notify-over-redis.md). -- Sesiones: tabla `Session` en PostgreSQL. Ver [ADR 0008](./0008-session-store.md). +- Sessions: `Session` table in PostgreSQL. See [ADR 0008](./0008-session-store.md). -`docker-compose.yml` sigue siendo solo `app` + `postgres`. +`docker-compose.yml` is still only `app` + `postgres`. diff --git a/docs/decisions/0005-agpl.md b/docs/decisions/0005-agpl.md index afbc69f..98e8b92 100644 --- a/docs/decisions/0005-agpl.md +++ b/docs/decisions/0005-agpl.md @@ -1,30 +1,31 @@ -# ADR 0005 — Licencia AGPL-3.0 +# ADR 0005 — AGPL-3.0 license -**Estado:** sustituida por [ADR 0016](./0016-licencia-propietaria.md) · **Fecha:** 2026-06-04 +**Status:** superseded by [ADR 0016](./0016-licencia-propietaria.md), which in turn was +superseded on 2026-10-14 · **Date:** 2026-06-04 -> Esta decisión ya no está vigente. La licencia actual es la de [`LICENSE`](../../LICENSE) -> (propietaria, MARA-KASSA SL). Se conserva como registro histórico. +> Historical record. The current license is the one in [`LICENSE`](../../LICENSE): +> AGPL-3.0-or-later with an additional attribution term (section 7(b)). The reasoning is +> in [`docs/DECISIONES.md`](../DECISIONES.md). -## Contexto +## Context -orgtable es un clon open-source de un producto SaaS (Trello). Hay que elegir licencia. +orgtable is an open-source clone of a SaaS product (Trello). A license has to be chosen. -## Decisión +## Decision -Publicar bajo **GNU Affero General Public License v3.0 (AGPL-3.0)**. El texto íntegro -está en `LICENSE`. +Publish under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. The full text +is in `LICENSE`. -## Justificación +## Rationale -- La AGPL extiende el copyleft de la GPL a los servicios accesibles por red: quien - ofrezca orgtable como servicio (aunque sea modificado) debe poner su código a - disposición de los usuarios. -- Esto evita que un tercero tome el proyecto y lo ofrezca como SaaS cerrado sin - contribuir de vuelta, lo cual es coherente con un clon self-hosted de un SaaS. +- The AGPL extends the GPL's copyleft to network-accessible services: whoever offers + orgtable as a service (even modified) must make its code available to the users. +- This prevents a third party from taking the project and offering it as a closed SaaS + without contributing back, which fits a self-hosted clone of a SaaS. -## Consecuencias +## Consequences -- Las contribuciones se licencian bajo AGPL-3.0. -- Las cabeceras/avisos de licencia deben mantenerse. Ver `docs/contributing.md`. -- Trade-off aceptado: la AGPL puede frenar adopción empresarial frente a una licencia - permisiva (MIT), a cambio de proteger el carácter abierto del proyecto. +- Contributions are licensed under AGPL-3.0. +- License headers/notices must be kept. See `docs/contributing.md`. +- Accepted trade-off: the AGPL may slow down enterprise adoption compared with a + permissive license (MIT), in exchange for protecting the project's open nature. diff --git a/docs/decisions/0006-rbac-explicit-guards.md b/docs/decisions/0006-rbac-explicit-guards.md index 60a21b9..5b0b3cc 100644 --- a/docs/decisions/0006-rbac-explicit-guards.md +++ b/docs/decisions/0006-rbac-explicit-guards.md @@ -1,48 +1,47 @@ -# ADR 0006 — Autorización por rol con guardas explícitas +# ADR 0006 — Role-based authorization with explicit guards -**Estado:** aceptada · **Fecha:** 2026-06-05 +**Status:** accepted · **Date:** 2026-06-05 -## Contexto +## Context -El Hito 3 (multiusuario) introduce roles por tablero (`OWNER`/`EDITOR`/`VIEWER`) y un -rol global (`ADMIN`/`MEMBER`). Hay que decidir **cómo se aplica** la autorización en -las Server Actions y las páginas. +The multi-user feature introduces per-board roles (`OWNER`/`EDITOR`/`VIEWER`) and a +global role (`ADMIN`/`MEMBER`). We need to decide **how** authorization is enforced in +Server Actions and pages. -Next.js 16 ofrece las funciones `forbidden()` y `unauthorized()`, que lanzan una -interrupción y renderizan una página 403/401. Son cómodas, pero **experimentales**: -requieren activar `experimental.authInterrupts` en `next.config.ts`. +Next.js 16 offers the `forbidden()` and `unauthorized()` functions, which throw an +interrupt and render a 403/401 page. They are convenient, but **experimental**: they +require enabling `experimental.authInterrupts` in `next.config.ts`. -## Decisión +## Decision -Usar **guardas explícitas** con ayudantes puros, no las APIs experimentales: +Use **explicit guards** with pure helpers, not the experimental APIs: ```ts const { role } = await requireBoardAccess(boardId); -if (!canEditBoard(role)) return; // acción de formulario → no-op -if (!canEditBoard(role)) return { ok: false }; // acción de drag & drop → revertir +if (!canEditBoard(role)) return; // form action → no-op +if (!canEditBoard(role)) return { ok: false }; // drag & drop action → roll back ``` -La jerarquía de roles vive en `app/lib/roles.ts` (`canEditBoard`, `canManageBoard`), -compartida entre servidor y cliente. - -## Justificación - -- **Sin dependencias experimentales**: `authInterrupts` puede cambiar o requerir - ficheros `forbidden.tsx`. Las guardas explícitas son estándar y estables. -- **Coherencia con el código existente**: las acciones ya "fallan en silencio" - cuando la entrada no valida (`if (!parsed.success) return;`). El control de rol - sigue el mismo patrón. -- **UI optimista**: las acciones de drag & drop devuelven `{ ok: false }` en vez de - lanzar; el cliente puede revertir su estado sin un overlay de error. -- **Defensa en profundidad**: la UI oculta los controles según el rol - (`board-perms.tsx`), pero la barrera real es la guarda del servidor, que se ejecuta - siempre. - -## Consecuencias - -- Cada Server Action mutadora añade una línea de guarda. Es repetitivo pero explícito - y fácil de auditar. -- Las páginas de administración protegen con `requireAdmin()` (redirige a `/boards`), - no con `forbidden()`. -- Si en el futuro `authInterrupts` se estabiliza, se podría migrar a `forbidden()` - para mostrar páginas 403 propias sin cambiar el modelo de permisos. +The role hierarchy lives in `app/lib/roles.ts` (`canEditBoard`, `canManageBoard`), +shared between server and client. + +## Rationale + +- **No experimental dependencies**: `authInterrupts` may change or require + `forbidden.tsx` files. Explicit guards are standard and stable. +- **Consistency with the existing code**: actions already "fail silently" when the input + does not validate (`if (!parsed.success) return;`). The role check follows the same + pattern. +- **Optimistic UI**: drag & drop actions return `{ ok: false }` instead of throwing; the + client can roll back its state without an error overlay. +- **Defense in depth**: the UI hides controls according to the role (`board-perms.tsx`), + but the real barrier is the server guard, which always runs. + +## Consequences + +- Every mutating Server Action adds one guard line. It is repetitive but explicit and + easy to audit. +- Administration pages are protected with `requireAdmin()` (redirects to `/boards`), not + with `forbidden()`. +- If `authInterrupts` becomes stable in the future, we could migrate to `forbidden()` to + show custom 403 pages without changing the permission model. diff --git a/docs/decisions/0007-pg-notify-over-redis.md b/docs/decisions/0007-pg-notify-over-redis.md index 3c2334a..b2c8625 100644 --- a/docs/decisions/0007-pg-notify-over-redis.md +++ b/docs/decisions/0007-pg-notify-over-redis.md @@ -1,43 +1,43 @@ -# ADR 0007 — PostgreSQL LISTEN/NOTIFY en lugar de Redis para pub/sub +# ADR 0007 — PostgreSQL LISTEN/NOTIFY instead of Redis for pub/sub -**Estado:** aceptada · **Fecha:** 2026-06-05 · **Relacionada:** [ADR 0004](./0004-no-redis-mvp.md) +**Status:** accepted · **Date:** 2026-06-05 · **Related:** [ADR 0004](./0004-no-redis-mvp.md) -## Contexto +## Context -El Hito 4 introduce la sincronización en vivo: cuando alguien cambia un tablero, el -resto de personas con ese tablero abierto deben verlo sin recargar. Eso necesita un -mecanismo de **publicación/suscripción** entre el código que muta (Server Actions) y -las conexiones abiertas (SSE). +The realtime feature introduces live sync: when someone changes a board, everyone else +with that board open must see it without reloading. That needs a +**publish/subscribe** mechanism between the code that mutates (Server Actions) and the +open connections (SSE). -La [ADR 0004](./0004-no-redis-mvp.md) anticipaba que Redis entraría "cuando llegue el -tiempo real con pub/sub". Al implementarlo, reconsideramos esa suposición. +[ADR 0004](./0004-no-redis-mvp.md) anticipated that Redis would come in "when realtime +with pub/sub arrives". When implementing it, we reconsidered that assumption. -## Decisión +## Decision -**No añadir Redis.** El bus es **en proceso** (un `EventEmitter`) por defecto, y para -despliegues multi-instancia se usa **PostgreSQL `LISTEN/NOTIFY`** como puente opcional +**Do not add Redis.** The bus is **in-process** (an `EventEmitter`) by default, and for +multi-instance deployments **PostgreSQL `LISTEN/NOTIFY`** is used as an optional bridge (`ORGTABLE_REALTIME_PG_NOTIFY=1`). -## Justificación - -- **Una pieza menos de infraestructura.** Ya dependemos de PostgreSQL. Añadir Redis - significaría otro servicio que instalar, operar, respaldar y vigilar — justo lo que - una app self-hosted intenta evitar. -- **El caso por defecto no necesita nada.** El despliegue estándar es un solo - contenedor de app: Server Actions y SSE comparten proceso, así que un `EventEmitter` - en memoria basta. Redis sería complejidad muerta para el 95 % de las instalaciones. -- **`LISTEN/NOTIFY` cubre el multi-instancia.** Para varias réplicas, Postgres ya - ofrece pub/sub nativo. El payload de orgtable es diminuto ("el tablero X cambió"), - muy por debajo del límite de 8 KB de `NOTIFY`. -- **Degradación segura.** Si el puente falla, el modo en proceso sigue funcionando; una - mutación nunca se rompe por un problema de pub/sub. - -## Consecuencias - -- El bus vive en `app/lib/realtime/bus.ts` con una interfaz (`publishBoardEvent` / - `subscribeBoard`) que aísla el transporte. Si algún día hiciera falta Redis (p. ej. - por escala muy alta de fan-out), se añadiría como otro adaptador sin tocar las - Server Actions ni el endpoint SSE. -- `docker-compose.yml` sigue con solo `app` + `postgres`. -- ADR 0004 queda **actualizada**: Redis no entra con el tiempo real; se elige - `LISTEN/NOTIFY`. +## Rationale + +- **One less piece of infrastructure.** We already depend on PostgreSQL. Adding Redis + would mean another service to install, operate, back up and monitor — exactly what a + self-hosted app tries to avoid. +- **The default case needs nothing.** The standard deployment is a single app container: + Server Actions and SSE share a process, so an in-memory `EventEmitter` is enough. Redis + would be dead complexity for 95% of installations. +- **`LISTEN/NOTIFY` covers multi-instance.** For several replicas, Postgres already + offers native pub/sub. orgtable's payload is tiny ("board X changed"), far below the + 8 KB limit of `NOTIFY`. +- **Safe degradation.** If the bridge fails, the in-process mode keeps working; a + mutation never breaks because of a pub/sub problem. + +## Consequences + +- The bus lives in `app/lib/realtime/bus.ts` behind an interface (`publishBoardEvent` / + `subscribeBoard`) that isolates the transport. If Redis were ever needed (e.g. for a + very high fan-out scale), it would be added as another adapter without touching the + Server Actions or the SSE endpoint. +- `docker-compose.yml` still has only `app` + `postgres`. +- ADR 0004 is **updated**: Redis does not come in with realtime; `LISTEN/NOTIFY` is + chosen. diff --git a/docs/decisions/0008-session-store.md b/docs/decisions/0008-session-store.md index c8397e8..26bbaa0 100644 --- a/docs/decisions/0008-session-store.md +++ b/docs/decisions/0008-session-store.md @@ -1,46 +1,46 @@ -# ADR 0008 — Almacén de sesiones en servidor para revocación inmediata +# ADR 0008 — Server-side session store for immediate revocation -**Estado:** aceptada · **Fecha:** 2026-06-05 · **Relacionada:** [ADR 0004](./0004-no-redis-mvp.md) +**Status:** accepted · **Date:** 2026-06-05 · **Related:** [ADR 0004](./0004-no-redis-mvp.md) -## Contexto +## Context -El MVP usaba sesiones **JWT stateless**: el token se firmaba y se validaba solo por su -firma, sin guardar nada en la base de datos. Ventaja: cero consultas para autenticar. -Inconveniente, documentado como límite conocido: **no se podía revocar un token al -instante**. `logout` borraba la cookie local, pero un token robado o una sesión en otro -dispositivo seguían válidos hasta expirar (7 días). +The MVP used **stateless JWT** sessions: the token was signed and validated only by its +signature, without storing anything in the database. Advantage: zero queries to +authenticate. Drawback, documented as a known limitation: **a token could not be revoked +instantly**. `logout` deleted the local cookie, but a stolen token or a session on another +device stayed valid until it expired (7 days). -El Hito 4 pide "revocación inmediata de sesiones (tabla de sesiones)". +The realtime feature called for "immediate session revocation (sessions table)". -## Decisión +## Decision -Introducir una tabla **`Session`** y validar cada petición contra ella. La cookie sigue -siendo un JWT firmado, pero su payload lleva el **id de la sesión** (`sid`). El DAL: +Introduce a **`Session`** table and validate every request against it. The cookie is still +a signed JWT, but its payload carries the **session id** (`sid`). The DAL: -1. Verifica la firma y la expiración del JWT (como antes). -2. Busca la fila `Session` por `sid` y la rechaza si no existe, está revocada - (`revokedAt`) o ha expirado (`expiresAt`). +1. Verifies the JWT signature and expiry (as before). +2. Looks up the `Session` row by `sid` and rejects it if it does not exist, is revoked + (`revokedAt`) or has expired (`expiresAt`). -## Justificación +## Rationale -- **Revocación inmediata y real.** Marcar `revokedAt` invalida la sesión en la - siguiente petición. Habilita: logout que revoca de verdad, "cerrar en todos los - dispositivos" al cambiar la contraseña, y cierre forzado por el administrador. -- **Sin Redis.** Se reутiliza PostgreSQL (coherente con [ADR 0007](./0007-pg-notify-over-redis.md)). - No hace falta un almacén de sesiones aparte. -- **Coste acotado.** Es **una** consulta por petición, memoizada con `cache()` de React - (una sola vez por render). Para esta clase de app self-hosted, el coste es - despreciable frente a la ganancia de seguridad. -- **Los cambios de rol no necesitan revocación.** El rol se lee fresco de la BD en cada - petición, así que surten efecto solos. +- **Immediate, real revocation.** Setting `revokedAt` invalidates the session on the next + request. It enables: a logout that really revokes, "sign out on all devices" when the + password changes, and forced sign-out by the administrator. +- **No Redis.** PostgreSQL is reused (consistent with [ADR 0007](./0007-pg-notify-over-redis.md)). + No separate session store is needed. +- **Bounded cost.** It is **one** query per request, memoized with React's `cache()` + (only once per render). For this kind of self-hosted app, the cost is negligible + compared with the security gain. +- **Role changes do not need revocation.** The role is read fresh from the database on + every request, so changes take effect on their own. -## Consecuencias +## Consequences -- Nueva migración `Session` (`userId`, `userAgent`, `createdAt`, `expiresAt`, - `revokedAt`; FK con borrado en cascada al usuario). -- `login` y el setup crean una fila de sesión; `logout` la revoca; el reset de - contraseña y el cierre forzado revocan todas las del usuario. -- Esto supera el límite "no hay revocación inmediata" que describía - [`docs/authentication.md`](../authentication.md) para el MVP. -- Conviene una limpieza periódica de sesiones expiradas/revocadas (tarea de - mantenimiento futura); no es crítica porque el DAL ya las rechaza. +- New `Session` migration (`userId`, `userAgent`, `createdAt`, `expiresAt`, `revokedAt`; + FK to the user with cascade delete). +- `login` and the setup create a session row; `logout` revokes it; a password reset and a + forced sign-out revoke all of the user's sessions. +- This removes the "no immediate revocation" limitation that + [`docs/authentication.md`](../authentication.md) described for the MVP. +- A periodic cleanup of expired/revoked sessions would be useful (future maintenance + task); it is not critical because the DAL already rejects them. diff --git a/docs/decisions/0009-in-memory-rate-limit.md b/docs/decisions/0009-in-memory-rate-limit.md index d8aaaf0..73b8b21 100644 --- a/docs/decisions/0009-in-memory-rate-limit.md +++ b/docs/decisions/0009-in-memory-rate-limit.md @@ -1,64 +1,64 @@ -# ADR 0009 — Rate limiting en memoria (sin dependencias) +# ADR 0009 — In-memory rate limiting (no dependencies) -**Estado:** aceptada · **Fecha:** 2026-06-05 · **Relacionada:** [ADR 0007](./0007-pg-notify-over-redis.md) +**Status:** accepted · **Date:** 2026-06-05 · **Related:** [ADR 0007](./0007-pg-notify-over-redis.md) -## Contexto +## Context -El login necesita protección contra fuerza bruta. Las soluciones típicas usan Redis o -un servicio dedicado para contar intentos de forma compartida entre instancias. +Login needs protection against brute force. The usual solutions use Redis or a dedicated +service to count attempts in a way that is shared across instances. -## Decisión +## Decision -Implementar un limitador **en memoria** (ventana fija, un `Map` en `globalThis`), sin -dependencias externas. Dos límites para el login (`app/lib/actions/auth.ts`): +Implement an **in-memory** limiter (fixed window, a `Map` on `globalThis`), with no +external dependencies. Two limits for login (`app/lib/actions/auth.ts`): -- **Por IP**: 10 intentos cada 10 minutos. Solo se aplica si hay una IP fiable. -- **Por cuenta** (email normalizado): 20 intentos fallidos cada 15 minutos, cuente o no - el email con una cuenta real. Se reinicia tras un login correcto. +- **Per IP**: 10 attempts every 10 minutes. Only applied when there is a trustworthy IP. +- **Per account** (normalized email): 20 failed attempts every 15 minutes, whether or not + the email belongs to a real account. Reset after a successful login. -### IP del cliente y `TRUSTED_PROXY_HOPS` (revisión 2026-09-24) +### Client IP and `TRUSTED_PROXY_HOPS` (review of 2026-09-24) -`X-Forwarded-For` la escribe el cliente: cada proxy **añade** al final la IP que ve, -pero lo anterior viene de la petición, y Next solo rellena la cabecera si no llega -(`??=`). La versión inicial tomaba el **primer** valor, así que bastaba con enviar una -cabecera distinta en cada intento para que cada uno cayera en un contador nuevo. +`X-Forwarded-For` is written by the client: each proxy **appends** the IP it sees, but +whatever came before comes from the request, and Next only fills the header in if it is +missing (`??=`). The initial version took the **first** value, so sending a different +header on each attempt was enough to land each one in a new counter. -Ahora la IP sale de `app/lib/client-ip.ts` con la variable `TRUSTED_PROXY_HOPS` (entero ->= 0, por defecto 0): +Now the IP comes from `app/lib/client-ip.ts` with the `TRUSTED_PROXY_HOPS` variable +(integer >= 0, default 0): -- `0`: se ignoran `X-Forwarded-For` y `X-Real-IP`. No hay IP (desde una Server Action no - se lee la del socket), así que solo rige el límite por cuenta. No se usa un contador - común `unknown`, que bloquearía el login de todo el mundo a la vez. -- `N > 0`: se toma el N-ésimo valor **contando desde la derecha**, el que añadió el - proxy de confianza más externo. Con un nginx delante (`$proxy_add_x_forwarded_for`), - `N = 1`. Solo es seguro si la app **no** es accesible saltándose ese proxy; si lo es, - el cliente vuelve a elegir su IP. +- `0`: `X-Forwarded-For` and `X-Real-IP` are ignored. There is no IP (a Server Action + cannot read the socket's), so only the per-account limit applies. A shared `unknown` + counter is not used, since it would block everyone's login at once. +- `N > 0`: the Nth value **counting from the right** is taken, the one added by the + outermost trusted proxy. With an nginx in front (`$proxy_add_x_forwarded_for`), + `N = 1`. It is only safe if the app is **not** reachable bypassing that proxy; if it + is, the client gets to choose its IP again. -La misma IP es la que se guarda en `AuditLog.ip`: con `0` queda vacía. +The same IP is the one stored in `AuditLog.ip`: with `0` it stays empty. -Un intento rechazado solo escribe `auth.login_rate_limited` la **primera** vez en cada -ventana (`firstBlock`), para que un ataque sostenido no llene la tabla de auditoría. +A rejected attempt only writes `auth.login_rate_limited` the **first** time in each +window (`firstBlock`), so a sustained attack does not flood the audit table. -## Justificación +## Rationale -- **Cero infraestructura.** Coherente con el resto del proyecto (ADR 0004/0007): no - añadir Redis para una app self-hosted que ya intenta minimizar piezas. -- **El caso por defecto es una sola instancia**, donde un contador en memoria es - exacto y suficiente. -- **Degradación aceptable en multi-instancia.** Con N réplicas el límite efectivo es - N×10 intentos (y N×20 por cuenta): sigue cortando un ataque de fuerza bruta masivo, solo que de forma - menos estricta. Es un compromiso razonable frente a añadir un store compartido. +- **Zero infrastructure.** Consistent with the rest of the project (ADR 0004/0007): do not + add Redis to a self-hosted app that already tries to minimize moving parts. +- **The default case is a single instance**, where an in-memory counter is exact and + sufficient. +- **Acceptable degradation with multiple instances.** With N replicas the effective limit + is N×10 attempts (and N×20 per account): it still stops a massive brute-force attack, + just less strictly. It is a reasonable compromise compared with adding a shared store. -## Consecuencias +## Consequences -- **Bloqueo de cuentas ajenas**: quien conozca un email puede agotar sus 20 intentos y - dejar a esa persona sin poder entrar hasta 15 minutos. Se acepta a cambio de frenar - los ataques repartidos entre muchas IPs; un umbral más bajo empeoraría este riesgo. -- Sin IP fiable (`TRUSTED_PROXY_HOPS=0`) no hay límite global: probar una contraseña - contra muchos emails distintos solo lo frena el coste de bcrypt. -- El estado se pierde al reiniciar el proceso (un atacante gana una ventana nueva tras - un reinicio). Aceptable para el modelo de amenazas de una instancia self-hosted. -- Si en el futuro hace falta un límite global estricto, se puede sustituir la - implementación de `app/lib/rate-limit.ts` por una respaldada en Postgres sin tocar - los sitios que la llaman. -- Documentado en [`docs/operations.md`](../operations.md). +- **Locking out other people's accounts**: anyone who knows an email can use up its 20 + attempts and keep that person from signing in for up to 15 minutes. This is accepted in + exchange for slowing down attacks spread across many IPs; a lower threshold would make + this risk worse. +- Without a trustworthy IP (`TRUSTED_PROXY_HOPS=0`) there is no global limit: trying one + password against many different emails is only slowed down by the cost of bcrypt. +- The state is lost when the process restarts (an attacker gets a fresh window after a + restart). Acceptable for the threat model of a self-hosted instance. +- If a strict global limit is ever needed, the implementation in `app/lib/rate-limit.ts` + can be replaced with one backed by Postgres without touching its callers. +- Documented in [`docs/operations.md`](../operations.md). diff --git a/docs/decisions/0010-local-file-storage.md b/docs/decisions/0010-local-file-storage.md index 270236e..d39b786 100644 --- a/docs/decisions/0010-local-file-storage.md +++ b/docs/decisions/0010-local-file-storage.md @@ -1,37 +1,35 @@ -# ADR 0010 — Adjuntos en disco local (no S3) +# ADR 0010 — Attachments on local disk (not S3) -**Estado:** aceptada · **Fecha:** 2026-06-05 +**Status:** accepted · **Date:** 2026-06-05 -## Contexto +## Context -El Hito 5 añade adjuntos en las tarjetas. ¿Dónde se guarda el binario? Las opciones -habituales son almacenamiento de objetos (S3 o compatible) o el disco local del -servidor. +The operations feature adds attachments to cards. Where is the binary stored? The usual +options are object storage (S3 or compatible) or the server's local disk. -## Decisión +## Decision -Guardar los adjuntos en el **disco local**, en un directorio configurable -(`ORGTABLE_UPLOAD_DIR`) que en Docker es un volumen persistente. En la base de datos -solo se guardan los metadatos. +Store attachments on the **local disk**, in a configurable directory +(`ORGTABLE_UPLOAD_DIR`) that is a persistent volume in Docker. Only the metadata is stored +in the database. -## Justificación +## Rationale -- **Self-hosted, sin dependencias de nube.** orgtable se instala con un comando y un - `docker-compose` de dos servicios. Exigir una cuenta de S3 (o desplegar MinIO) - rompería esa simplicidad y la promesa de "tus datos en tu servidor". -- **Un volumen ya es persistente y respaldable.** El volumen `uploads` se añade a la - misma estrategia de backups que la base de datos (`docs/operations.md`). -- **El acceso debe ser autorizado igualmente.** Sea disco o S3, los adjuntos son - privados y se sirven por un route handler que valida la membresía. El backend de - almacenamiento no cambia ese diseño. +- **Self-hosted, no cloud dependencies.** orgtable is installed with one command and a + two-service `docker-compose`. Requiring an S3 account (or deploying MinIO) would break + that simplicity and the promise of "your data on your server". +- **A volume is already persistent and can be backed up.** The `uploads` volume joins the + same backup strategy as the database (`docs/operations.md`). +- **Access must be authorized either way.** Whether disk or S3, attachments are private + and are served by a route handler that checks membership. The storage backend does not + change that design. -## Consecuencias +## Consequences -- El almacenamiento está acoplado al sistema de ficheros del contenedor de la app. En - un despliegue **multi-instancia**, el volumen de uploads debe ser **compartido** entre - réplicas (NFS, volumen de red...) o migrarse a almacenamiento de objetos. -- `app/lib/storage.ts` aísla las operaciones de fichero (`save`/`read`/`delete`), así - que añadir un backend S3 en el futuro sería sustituir esa capa sin tocar las acciones - ni el route handler. -- Límite de 10 MB por archivo para acotar el uso de disco; ajustable en - `app/lib/storage.ts`. +- Storage is coupled to the app container's file system. In a **multi-instance** + deployment, the uploads volume must be **shared** between replicas (NFS, network + volume...) or moved to object storage. +- `app/lib/storage.ts` isolates the file operations (`save`/`read`/`delete`), so adding an + S3 backend in the future would mean replacing that layer without touching the actions + or the route handler. +- 10 MB limit per file to bound disk usage; adjustable in `app/lib/storage.ts`. diff --git a/docs/decisions/0011-search-ilike-scoped.md b/docs/decisions/0011-search-ilike-scoped.md index b0ff13e..a76f1ba 100644 --- a/docs/decisions/0011-search-ilike-scoped.md +++ b/docs/decisions/0011-search-ilike-scoped.md @@ -1,42 +1,42 @@ -# ADR 0011 — Búsqueda con ILIKE acotada en el servidor (sin motor de búsqueda) - -**Estado:** aceptada · **Fecha:** 2026-06-07 · **Relacionada:** [ADR 0004](./0004-no-redis-mvp.md), [ADR 0006](./0006-rbac-explicit-guards.md) - -## Contexto - -El Hito 6 añade búsqueda global de tableros y tarjetas. Las opciones habituales van -desde un motor dedicado (Elasticsearch, Meilisearch, Typesense) hasta la búsqueda -full-text nativa de PostgreSQL (`tsvector`/`tsquery`, índices GIN) o una simple -coincidencia por subcadena (`ILIKE`). - -## Decisión - -Implementar la búsqueda como **coincidencia por subcadena insensible a mayúsculas -(`ILIKE`)** sobre `title`/`description`, expresada con Prisma (`contains` + -`mode: "insensitive"`), **acotada en el `WHERE`** a los tableros donde el usuario es -dueño o miembro. La consulta vive en `app/lib/queries.ts` (`searchForUser`). - -## Justificación - -- **Cero infraestructura.** Coherente con ADR 0004/0007/0009: una app self-hosted no - debería exigir un servicio de búsqueda aparte ni un proceso de indexación. -- **Suficiente a la escala objetivo.** Una instancia self-hosted típica tiene miles, - no millones, de tarjetas. `ILIKE '%term%'` resuelve en milisegundos a esa escala. -- **La autorización es parte de la consulta**, no un filtro posterior: el mismo patrón - `OR: [{ ownerId }, { members: { some } }]` que ya usa `getBoardsForUser`. El servidor - es la única autoridad (ADR 0006); es imposible que un resultado filtre datos de un - tablero ajeno. -- **Sin estado derivado que mantener.** No hay índice que pueda quedar desincronizado - con los datos: se busca directamente sobre la fuente de verdad. - -## Consecuencias - -- `ILIKE '%term%'` no usa un índice B-tree estándar (el comodín inicial lo impide), así - que es un escaneo. Aceptable a la escala objetivo; si llegara a notarse, la vía de - mejora natural es full-text de PostgreSQL (columna `tsvector` + índice GIN) **sin - cambiar la interfaz** de `searchForUser`, o una extensión `pg_trgm` con índice GIN - para acelerar el `ILIKE` tal cual. -- No hay ranking por relevancia ni tolerancia a erratas (fuzzy). Los resultados se - ordenan por `updatedAt` (lo más reciente primero), que es un proxy razonable. -- La consulta mínima es de 2 caracteres para evitar escaneos de muy bajo valor. -- Documentado en [`docs/search.md`](../search.md). +# ADR 0011 — Search with server-scoped ILIKE (no search engine) + +**Status:** accepted · **Date:** 2026-06-07 · **Related:** [ADR 0004](./0004-no-redis-mvp.md), [ADR 0006](./0006-rbac-explicit-guards.md) + +## Context + +The search feature adds global search over boards and cards. The usual options range +from a dedicated engine (Elasticsearch, Meilisearch, Typesense) to PostgreSQL's native +full-text search (`tsvector`/`tsquery`, GIN indexes) or a simple substring match +(`ILIKE`). + +## Decision + +Implement search as a **case-insensitive substring match (`ILIKE`)** on +`title`/`description`, expressed with Prisma (`contains` + `mode: "insensitive"`), +**scoped in the `WHERE`** to the boards the user owns or is a member of. The query lives +in `app/lib/queries.ts` (`searchForUser`). + +## Rationale + +- **Zero infrastructure.** Consistent with ADR 0004/0007/0009: a self-hosted app should + not require a separate search service or an indexing process. +- **Enough at the target scale.** A typical self-hosted instance has thousands, not + millions, of cards. `ILIKE '%term%'` resolves in milliseconds at that scale. +- **Authorization is part of the query**, not a post-filter: the same + `OR: [{ ownerId }, { members: { some } }]` pattern that `getBoardsForUser` already uses. + The server is the only authority (ADR 0006); a result cannot leak data from someone + else's board. +- **No derived state to maintain.** There is no index that can drift out of sync with the + data: the search runs directly on the source of truth. + +## Consequences + +- `ILIKE '%term%'` does not use a standard B-tree index (the leading wildcard prevents + it), so it is a scan. Acceptable at the target scale; if it ever became noticeable, the + natural improvement is PostgreSQL full-text search (`tsvector` column + GIN index) + **without changing the interface** of `searchForUser`, or the `pg_trgm` extension with a + GIN index to speed up the `ILIKE` as it is. +- There is no relevance ranking and no typo tolerance (fuzzy). Results are sorted by + `updatedAt` (most recent first), which is a reasonable proxy. +- The minimum query is 2 characters, to avoid very low-value scans. +- Documented in [`docs/search.md`](../search.md). diff --git a/docs/decisions/0012-notifications.md b/docs/decisions/0012-notifications.md index 6b4019b..abe2b6a 100644 --- a/docs/decisions/0012-notifications.md +++ b/docs/decisions/0012-notifications.md @@ -1,47 +1,48 @@ -# ADR 0012 — Notificaciones in-app sobre el bus pub/sub (sin email ni cola) - -**Estado:** aceptada · **Fecha:** 2026-06-07 · **Relacionada:** [ADR 0007](./0007-pg-notify-over-redis.md), [ADR 0004](./0004-no-redis-mvp.md) - -## Contexto - -El Hito 8 añade notificaciones de eventos relevantes para una persona (te han añadido a -un tablero, te han cambiado el rol, te han quitado). Hay que decidir **dónde se -entregan** (in-app, email, push del navegador), **cómo se entregan en vivo** y **cómo se -guardan**. - -## Decisión - -- **Solo in-app**, por ahora: una campana en la barra superior con contador de no - leídas y un desplegable. Sin email ni Web Push. -- **Persistencia** en una tabla `Notification` por usuario, con el **texto ya calculado - en español** (no un payload estructurado): la app es monolingüe y así el render es - trivial. `boardId` es una referencia **suave** (string sin FK). -- **Entrega en vivo reutilizando el bus de tiempo real** del Hito 4 - ([ADR 0007](./0007-pg-notify-over-redis.md)): se añade un canal **por usuario** - (`user:<id>`) junto al canal por tablero (`board:<id>`). `notify()` escribe la fila y - publica un "ping"; un stream SSE (`/api/notifications/stream`) lo reenvía y el cliente - hace `router.refresh()`. -- **Best-effort**, como la auditoría: si crear la notificación o publicar falla, la - mutación que la origina (añadir miembro, etc.) no se ve afectada. - -## Justificación - -- **Cero infraestructura nueva.** No se añade una cola de trabajos ni un servicio de - email: el bus pub/sub ya existe y el puente opcional de PostgreSQL `LISTEN/NOTIFY` - cubre el caso multi-instancia sin Redis (coherente con ADR 0004/0007). -- **El "ping" no lleva datos.** Igual que el evento de tablero, solo avisa de que hay - algo nuevo; el cliente recarga y el servidor vuelve a consultar. Así no hay que - serializar el contenido por el canal ni preocuparse de fugas entre usuarios: cada - pestaña solo lee SUS notificaciones (el `WHERE` lleva el `userId` de la sesión). -- **Texto precalculado** evita una capa de plantillas/i18n para una app de un idioma. - -## Consecuencias - -- Sin notificaciones fuera de la app (si no tienes una pestaña abierta, las ves la - próxima vez que entras). Email/Web Push quedan como evolución futura; encajarían como - un consumidor más del mismo `notify()`. -- El conjunto de eventos es pequeño y vive en `app/lib/notifications.ts` - (`NotificationType`). Añadir un evento nuevo es llamar a `notify()` desde la acción - correspondiente. -- Documentado en [`docs/notifications.md`](../notifications.md); el canal por usuario se - describe también en [`docs/realtime.md`](../realtime.md). +# ADR 0012 — In-app notifications over the pub/sub bus (no email or queue) + +**Status:** accepted · **Date:** 2026-06-07 · **Related:** [ADR 0007](./0007-pg-notify-over-redis.md), [ADR 0004](./0004-no-redis-mvp.md) + +## Context + +The notifications feature adds notifications for events that matter to a person (you +were added to a board, your role changed, you were removed). We need to decide **where +they are delivered** (in-app, email, browser push), **how they are delivered live** and +**how they are stored**. + +## Decision + +- **In-app only**, for now: a bell in the top bar with an unread counter and a dropdown. + No email or Web Push. +- **Persistence** in a per-user `Notification` table, with the **text already rendered in + Spanish** (not a structured payload): the app was single-language at the time, so + rendering is trivial. `boardId` is a **soft** reference (string without an FK). +- **Live delivery reusing the realtime bus** from the realtime feature + ([ADR 0007](./0007-pg-notify-over-redis.md)): a **per-user** channel (`user:<id>`) is + added next to the per-board channel (`board:<id>`). `notify()` writes the row and + publishes a "ping"; an SSE stream (`/api/notifications/stream`) forwards it and the + client calls `router.refresh()`. +- **Best-effort**, like the audit log: if creating the notification or publishing fails, + the mutation that triggered it (adding a member, etc.) is not affected. + +## Rationale + +- **Zero new infrastructure.** No job queue or email service is added: the pub/sub bus + already exists and the optional PostgreSQL `LISTEN/NOTIFY` bridge covers the + multi-instance case without Redis (consistent with ADR 0004/0007). +- **The "ping" carries no data.** Like the board event, it only signals that there is + something new; the client reloads and the server queries again. That way the content + does not have to be serialized through the channel and there is no risk of leaks + between users: each tab only reads ITS OWN notifications (the `WHERE` carries the + session's `userId`). +- **Pre-rendered text** avoids a templates/i18n layer for a single-language app. + +## Consequences + +- No notifications outside the app (if you have no tab open, you see them the next time + you come in). Email/Web Push remain a future evolution; they would fit as one more + consumer of the same `notify()`. +- The set of events is small and lives in `app/lib/notifications.ts` + (`NotificationType`). Adding a new event means calling `notify()` from the relevant + action. +- Documented in [`docs/notifications.md`](../notifications.md); the per-user channel is + also described in [`docs/realtime.md`](../realtime.md). diff --git a/docs/decisions/0013-api-tokens.md b/docs/decisions/0013-api-tokens.md index 686be55..5c88c9e 100644 --- a/docs/decisions/0013-api-tokens.md +++ b/docs/decisions/0013-api-tokens.md @@ -1,56 +1,55 @@ -# ADR 0013 — API pública con tokens personales hasheados (Bearer) - -**Estado:** aceptada · **Fecha:** 2026-06-07 · **Relacionada:** [ADR 0006](./0006-rbac-explicit-guards.md), [ADR 0008](./0008-session-store.md) - -## Contexto - -El Hito 9 abre una API REST (`/api/v1`) para usar orgtable desde scripts u otras -aplicaciones. Hay que decidir **cómo se autentica** esa API (sin cookies de navegador) y -**cómo se guardan** las credenciales. - -## Decisión - -- **Tokens de acceso personal** (PAT) que el usuario crea en `/settings`. La petición los - envía en `Authorization: Bearer <token>`. -- **Nunca se guarda el token en claro**: solo su **hash SHA-256** (`ApiToken.tokenHash`, - único) y un **prefijo** visible para identificarlo en la lista. El token completo se - muestra **una sola vez** al crearlo. -- La API **reutiliza la autorización por rol** existente: resuelto el usuario por el - token, cada endpoint vuelve a comprobar el rol del tablero contra `board_members` - (igual que las Server Actions, [ADR 0006](./0006-rbac-explicit-guards.md)). Un token - actúa **como su dueño**, con su mismo acceso, ni más ni menos. -- Las mutaciones de la API llaman a `revalidateBoard()`, así que los cambios hechos por - la API también llegan en vivo a los navegadores conectados (tiempo real del Hito 4). - -## Justificación - -- **SHA-256 sin sal es suficiente para un token de alta entropía.** El token son 24 bytes - aleatorios (no una contraseña humana), así que no hay ataque de diccionario que mitigar - con bcrypt/argon2; un hash rápido permite además resolverlo en un único `findUnique` por - el hash. (Las contraseñas de usuario sí usan bcrypt: ahí el riesgo es distinto.) -- **Coherencia con el modelo de revocación** ([ADR 0008](./0008-session-store.md)): - revocar es borrar la fila; el token deja de funcionar al instante. -- **Reutilizar la autorización** evita una segunda fuente de verdad de permisos: el - servidor sigue siendo la única autoridad y no hay forma de que la API conceda más de lo - que concede la UI. -- **Cero dependencias nuevas**: `node:crypto` ya está disponible. - -## Consecuencias - -- Sin OAuth ni scopes por token: un token tiene todo el acceso del usuario. Si en el - futuro hicieran falta permisos más finos (solo lectura, un tablero concreto), se - añadirían columnas a `ApiToken` y un chequeo extra, sin cambiar el modelo de - autenticación. -- `lastUsedAt` se actualiza en cada petición (best-effort); es una escritura barata y útil - para que el usuario detecte tokens en uso o en desuso. -- Los tokens **no dependen de la tabla `Session`**: cerrar sesión o «Cerrar sesiones» - en `/admin/users` no los invalida. Por eso, **restablecer la contraseña** de un - usuario (acción del administrador) **borra también todos sus tokens** en la misma - transacción que revoca sus sesiones (`resetPasswordAndRevokeAccess` en - `app/lib/services/users.ts`, añadido el 2026-09-24): si la cuenta estaba - comprometida, un token creado por el atacante dejaría abierta la API. El usuario - tendrá que crear tokens nuevos. Ver [`docs/api/public-api.md`](../api/public-api.md) - y [`docs/admin.md`](../admin.md). -- La superficie inicial cubre tableros, listas y tarjetas (CRUD esencial); checklists, - etiquetas y adjuntos quedan como ampliación natural. -- Documentado en [`docs/api/public-api.md`](../api/public-api.md). +# ADR 0013 — Public API with hashed personal tokens (Bearer) + +**Status:** accepted · **Date:** 2026-06-07 · **Related:** [ADR 0006](./0006-rbac-explicit-guards.md), [ADR 0008](./0008-session-store.md) + +## Context + +The public API feature opens a REST API (`/api/v1`) so orgtable can be used from scripts +or other applications. We need to decide **how that API authenticates** (without browser +cookies) and **how the credentials are stored**. + +## Decision + +- **Personal access tokens** (PAT) that the user creates in `/settings`. The request sends + them in `Authorization: Bearer <token>`. +- **The token is never stored in plain text**: only its **SHA-256 hash** + (`ApiToken.tokenHash`, unique) and a visible **prefix** to identify it in the list. The + full token is shown **only once**, when it is created. +- The API **reuses the existing role-based authorization**: once the token resolves to a + user, each endpoint checks the board role against `board_members` again (just like the + Server Actions, [ADR 0006](./0006-rbac-explicit-guards.md)). A token acts **as its + owner**, with exactly the same access, no more and no less. +- API mutations call `revalidateBoard()`, so changes made through the API also reach + connected browsers live (via the realtime feature). + +## Rationale + +- **Unsalted SHA-256 is enough for a high-entropy token.** The token is 24 random bytes + (not a human password), so there is no dictionary attack to mitigate with + bcrypt/argon2; a fast hash also lets it be resolved with a single `findUnique` on the + hash. (User passwords do use bcrypt: the risk there is different.) +- **Consistent with the revocation model** ([ADR 0008](./0008-session-store.md)): revoking + means deleting the row; the token stops working instantly. +- **Reusing authorization** avoids a second source of truth for permissions: the server is + still the only authority and there is no way for the API to grant more than the UI does. +- **Zero new dependencies**: `node:crypto` is already available. + +## Consequences + +- No OAuth and no per-token scopes: a token has all of the user's access. If finer-grained + permissions were needed in the future (read-only, a specific board), columns would be + added to `ApiToken` along with an extra check, without changing the authentication + model. +- `lastUsedAt` is updated on every request (best-effort); it is a cheap write and helps + the user spot tokens that are in use or unused. +- Tokens **do not depend on the `Session` table**: signing out or "End sessions" in + `/admin/users` does not invalidate them. That is why **resetting a user's password** + (an administrator action) **also deletes all their tokens** in the same transaction + that revokes their sessions (`resetPasswordAndRevokeAccess` in + `app/lib/services/users.ts`, added on 2026-09-24): if the account was compromised, a + token created by the attacker would leave the API open. The user will have to create + new tokens. See [`docs/api/public-api.md`](../api/public-api.md) and + [`docs/admin.md`](../admin.md). +- The initial surface covers boards, lists and cards (essential CRUD); checklists, labels + and attachments are the natural extension. +- Documented in [`docs/api/public-api.md`](../api/public-api.md). diff --git a/docs/decisions/0014-card-collaboration.md b/docs/decisions/0014-card-collaboration.md index e110369..f3ec7cc 100644 --- a/docs/decisions/0014-card-collaboration.md +++ b/docs/decisions/0014-card-collaboration.md @@ -1,45 +1,45 @@ -# ADR 0014 — Modelo de colaboración en la tarjeta (asignados y comentarios) - -**Estado:** aceptada · **Fecha:** 2026-06-07 · **Relacionada:** [ADR 0006](./0006-rbac-explicit-guards.md), [ADR 0012](./0012-notifications.md) - -## Contexto - -El Hito 10 añade **asignar miembros** a una tarjeta y **comentarios**. Hay que decidir el -modelo de datos, quién puede hacer qué, y qué pasa al borrar. - -## Decisión - -- **Asignados**: tabla de unión `CardAssignee` (cardId, userId). Solo se puede asignar a - quien **ya es miembro del tablero**; lo verifica el servidor (servicio - `assignMemberToCard`, reutilizado por la Server Action). Asignar/quitar requiere permiso - de **edición** (`canEditBoard`). -- **Comentarios**: modelo `Comment` (autor, texto, fecha), **hilo plano** sin anidación. - Comentar requiere edición; **borrar** lo puede hacer el **autor o el propietario** del - tablero. -- **Borrado en cascada**: tanto `CardAssignee` como `Comment` se borran con la tarjeta y - con el usuario (`onDelete: Cascade`). -- **Notificaciones** reutilizando `notify()` ([ADR 0012](./0012-notifications.md)): - `card.assigned` al asignar a otra persona; `comment.added` a los asignados de la tarjeta - (menos al autor). - -## Justificación - -- **Asignar solo a miembros** evita un estado inconsistente (asignar a quien no ve el - tablero) y mantiene al servidor como única autoridad - ([ADR 0006](./0006-rbac-explicit-guards.md)). La lógica vive en un servicio testeable. -- **Hilo plano** cubre el 95% del caso (discusión breve en una tarjeta) sin la complejidad - de árboles de respuestas; encaja con un clon de Trello. -- **Cascade en comentarios**: se prioriza la simplicidad y la coherencia del borrado de - cuentas/tarjetas frente a preservar el historial con autor nulo. Si en el futuro se - quisiera conservar la discusión al borrar una cuenta, se cambiaría `authorId` a - `onDelete: SetNull` (nullable) y la UI mostraría "usuario eliminado". - -## Consecuencias - -- Un comentario desaparece si se borra su autor (un miembro). Aceptable: borrar cuentas es - raro y está restringido (no se puede borrar a quien posee tableros). -- La API expone asignados y comentarios en las respuestas de tarjeta (lectura). Crear/ - borrar por API queda como ampliación; la interfaz ya lo cubre por completo. -- Los asignados y comentarios **no** se incluyen en el export/import de tableros (igual que - los adjuntos): el export es una copia del contenido estructural del tablero. -- Documentado en [`docs/card-collaboration.md`](../card-collaboration.md). +# ADR 0014 — Card collaboration model (assignees and comments) + +**Status:** accepted · **Date:** 2026-06-07 · **Related:** [ADR 0006](./0006-rbac-explicit-guards.md), [ADR 0012](./0012-notifications.md) + +## Context + +The card collaboration feature adds **assigning members** to a card and **comments**. We +need to decide the data model, who can do what, and what happens on deletion. + +## Decision + +- **Assignees**: `CardAssignee` join table (cardId, userId). Only people who are **already + members of the board** can be assigned; the server checks it (the + `assignMemberToCard` service, reused by the Server Action). Assigning/unassigning + requires **edit** permission (`canEditBoard`). +- **Comments**: `Comment` model (author, text, date), a **flat thread** with no nesting. + Commenting requires edit permission; **deleting** can be done by the **author or the + owner** of the board. +- **Cascade delete**: both `CardAssignee` and `Comment` are deleted with the card and with + the user (`onDelete: Cascade`). +- **Notifications** reusing `notify()` ([ADR 0012](./0012-notifications.md)): + `card.assigned` when assigning someone else; `comment.added` to the card's assignees + (except the author). + +## Rationale + +- **Assigning only members** avoids an inconsistent state (assigning someone who cannot + see the board) and keeps the server as the only authority + ([ADR 0006](./0006-rbac-explicit-guards.md)). The logic lives in a testable service. +- **A flat thread** covers 95% of the cases (a short discussion on a card) without the + complexity of reply trees; it fits a Trello clone. +- **Cascade on comments**: simplicity and consistent deletion of accounts/cards are + favored over preserving history with a null author. If the discussion should ever be + kept when an account is deleted, `authorId` would change to `onDelete: SetNull` + (nullable) and the UI would show "deleted user". + +## Consequences + +- A comment disappears if its author (a member) is deleted. Acceptable: deleting accounts + is rare and restricted (someone who owns boards cannot be deleted). +- The API exposes assignees and comments in card responses (read-only). Creating/deleting + through the API is left as an extension; the interface already covers it fully. +- Assignees and comments are **not** included in board export/import (just like + attachments): the export is a copy of the board's structural content. +- Documented in [`docs/card-collaboration.md`](../card-collaboration.md). diff --git a/docs/decisions/0015-reminders-cron.md b/docs/decisions/0015-reminders-cron.md index 384fa5e..54be4b0 100644 --- a/docs/decisions/0015-reminders-cron.md +++ b/docs/decisions/0015-reminders-cron.md @@ -1,47 +1,47 @@ -# ADR 0015 — Recordatorios por endpoint cron externo (sin scheduler en proceso) - -**Estado:** aceptada · **Fecha:** 2026-06-07 · **Relacionada:** [ADR 0004](./0004-no-redis-mvp.md), [ADR 0012](./0012-notifications.md) - -> **Modificado el 2026-09-24:** la política de avisos (día contado en UTC, un único aviso -> de vencida y ventana de 7 días) sustituye a la «idempotencia por día» descrita abajo. -> Ver [`docs/reminders.md`](../reminders.md) y [`docs/DECISIONES.md`](../DECISIONES.md). - -## Contexto - -El Hito 11 añade **recordatorios de vencimiento**: avisar a los asignados de una tarjeta -cuando vence pronto o ya venció. Eso requiere ejecutar trabajo **de forma periódica**. - -## Decisión - -- Un **endpoint** `POST /api/cron/reminders` que ejecuta la pasada de recordatorios, lo - **dispara un programador externo** (cron del sistema, un sidecar en `docker compose`, un - `schedule` de GitHub Actions, un cron de la plataforma…), no un timer dentro del proceso - de la app. -- Se protege con un **secreto compartido** en `Authorization: Bearer <CRON_SECRET>`, - comparado en **tiempo constante** (`crypto.timingSafeEqual`). Si `CRON_SECRET` no está - definido, el endpoint responde `503` (desactivado). -- La lógica vive en un **servicio testeable** (`generateDueReminders(now)`), con `now` - inyectado. Es **idempotente por día**: no repite el aviso de la misma tarjeta a la misma - persona el mismo día (dedup por `(userId, cardId, type, createdAt >= inicio-de-hoy)`, - usando la referencia suave `Notification.cardId`). - -## Justificación - -- **Cero infraestructura nueva** (coherente con ADR 0004): no añadimos una cola de - trabajos ni un proceso planificador. Una app self-hosted ya suele tener cron disponible. -- **Sin estado en proceso**: un timer `setInterval` no sobrevive a reinicios ni se coordina - entre réplicas; un endpoint disparado desde fuera es trivial de operar y de hacer - idempotente. -- **Reutiliza `notify()`** ([ADR 0012](./0012-notifications.md)): los recordatorios son un - emisor más de notificaciones in-app; si en el futuro se añade email/Web Push, llegarán - por el mismo camino. - -## Consecuencias - -- Hay que configurar un cron externo (documentado en `docs/reminders.md`); si no se - configura, simplemente no hay recordatorios (el resto de la app no se ve afectada). -- La frecuencia la decide quien opera la instancia (p. ej. una vez al día). La dedup diaria - evita el spam aunque se invoque varias veces. -- **Email y preferencias por usuario** quedan como ampliación (backlog): encajan como otro - consumidor de `notify()` y un modelo de preferencias, sin cambiar este diseño. -- Documentado en [`docs/reminders.md`](../reminders.md). +# ADR 0015 — Reminders through an external cron endpoint (no in-process scheduler) + +**Status:** accepted · **Date:** 2026-06-07 · **Related:** [ADR 0004](./0004-no-redis-mvp.md), [ADR 0012](./0012-notifications.md) + +> **Amended on 2026-09-24:** the reminder policy (day counted in UTC, a single overdue +> reminder and a 7-day window) replaces the "once per day idempotency" described below. +> See [`docs/reminders.md`](../reminders.md) and [`docs/DECISIONES.md`](../DECISIONES.md). + +## Context + +The reminders feature adds **due-date reminders**: notifying a card's assignees when it is +due soon or already overdue. That requires running work **periodically**. + +## Decision + +- An **endpoint** `POST /api/cron/reminders` that runs the reminder pass, **triggered by an + external scheduler** (system cron, a sidecar in `docker compose`, a GitHub Actions + `schedule`, the platform's cron...), not a timer inside the app process. +- It is protected with a **shared secret** in `Authorization: Bearer <CRON_SECRET>`, + compared in **constant time** (`crypto.timingSafeEqual`). If `CRON_SECRET` is not set, + the endpoint answers `503` (disabled). +- The logic lives in a **testable service** (`generateDueReminders(now)`), with `now` + injected. It is **idempotent per day**: it does not repeat the reminder for the same + card to the same person on the same day (dedup by + `(userId, cardId, type, createdAt >= start-of-today)`, using the soft reference + `Notification.cardId`). + +## Rationale + +- **Zero new infrastructure** (consistent with ADR 0004): we do not add a job queue or a + scheduler process. A self-hosted app usually has cron available already. +- **No in-process state**: a `setInterval` timer does not survive restarts nor coordinate + across replicas; an endpoint triggered from outside is trivial to operate and to make + idempotent. +- **Reuses `notify()`** ([ADR 0012](./0012-notifications.md)): reminders are one more + emitter of in-app notifications; if email/Web Push is added in the future, they will + travel the same path. + +## Consequences + +- An external cron has to be configured (documented in `docs/reminders.md`); if it is not + configured, there are simply no reminders (the rest of the app is not affected). +- The frequency is decided by whoever operates the instance (e.g. once a day). The daily + dedup avoids spam even if it is invoked several times. +- **Email and per-user preferences** are left as an extension (backlog): they fit as + another consumer of `notify()` plus a preferences model, without changing this design. +- Documented in [`docs/reminders.md`](../reminders.md). diff --git a/docs/decisions/0016-licencia-propietaria.md b/docs/decisions/0016-licencia-propietaria.md index f209864..d9f2708 100644 --- a/docs/decisions/0016-licencia-propietaria.md +++ b/docs/decisions/0016-licencia-propietaria.md @@ -1,42 +1,43 @@ -# ADR 0016 — Licencia propietaria (MARA-KASSA SL) - -**Estado:** aceptada · **Fecha:** 2026-08-05 · **Sustituye a:** [ADR 0005](./0005-agpl.md) - -## Contexto - -El proyecto se publicó al principio bajo AGPL-3.0 (ADR 0005). El 2026-08-05 el -propietario sustituyó el texto de `LICENSE` por una licencia propietaria a nombre de -MARA-KASSA SL (commit `27c1ba9`), pero la documentación, el `package.json` y algunos -textos de la app seguían diciendo AGPL u «open source». Esta ADR deja constancia de la -decisión y alinea el resto con ella. - -## Decisión - -- La licencia vigente es la de [`LICENSE`](../../LICENSE): todos los derechos reservados - por MARA-KASSA SL, uso restringido a personas autorizadas y cesión a la empresa de los - derechos sobre las aportaciones. El texto de `LICENSE` es la única fuente; el resto de - documentos remite a él sin copiarlo. -- `package.json` declara `"license": "UNLICENSED"` (el valor que npm reserva para - software sin licencia de uso pública). -- La documentación y los textos visibles dejan de describir el proyecto como open source. -- El CI falla si reaparecen referencias a la licencia anterior fuera de esta ADR, de la - ADR 0005 y del registro de decisiones. - -## Alternativas descartadas - -- **Mantener la AGPL-3.0 y restaurar `LICENSE`.** Descartada por el propietario al - hacer el cambio. -- **Licencia dual (AGPL + comercial).** No se planteó; exigiría volver a publicar el - código bajo AGPL. - -## Consecuencias - -- Las copias que se distribuyeron mientras regía la AGPL-3.0 siguen bajo esa licencia - para quien las recibió: el cambio no es retroactivo. -- La cesión de derechos por aportaciones que incluye `LICENSE` se acepta al contribuir, - sin firma. Su validez frente a la ley española de propiedad intelectual es dudosa; si - entran colaboradores externos conviene que un abogado la revise o firmar un acuerdo de - contribución (CLA). -- `install.sh` y la documentación de instalación siguen apuntando a un repositorio - público de GitHub; si el repositorio es privado, la instalación con `curl` anónimo no - funciona. Lo decide el propietario. +# ADR 0016 — Proprietary license (MARA-KASSA SL) + +**Status:** superseded on 2026-10-14 (see [`docs/DECISIONES.md`](../DECISIONES.md#2026-10-14--agpl-30-or-later-license-with-attribution)) · **Date:** 2026-08-05 · **Supersedes:** [ADR 0005](./0005-agpl.md) + +> This decision is no longer in force: on 2026-10-14 the project went back to the AGPL +> (AGPL-3.0-or-later with an attribution term). The current license is the one in +> [`LICENSE`](../../LICENSE). It is kept as a historical record. + +## Context + +The project was first published under AGPL-3.0 (ADR 0005). On 2026-08-05 the owner +replaced the text of `LICENSE` with a proprietary license in the name of MARA-KASSA SL +(commit `27c1ba9`), but the documentation, `package.json` and some app texts still said +AGPL or "open source". This ADR records the decision and aligns everything else with it. + +## Decision + +- The current license is the one in [`LICENSE`](../../LICENSE): all rights reserved by + MARA-KASSA SL, use restricted to authorized people, and assignment to the company of the + rights over contributions. The text of `LICENSE` is the only source; the other documents + point to it without copying it. +- `package.json` declares `"license": "UNLICENSED"` (the value npm reserves for software + without a public license to use it). +- The documentation and visible texts stop describing the project as open source. +- CI fails if references to the previous license reappear outside this ADR, ADR 0005 and + the decision log. + +## Alternatives considered + +- **Keep AGPL-3.0 and restore `LICENSE`.** Rejected by the owner when making the change. +- **Dual license (AGPL + commercial).** Not considered; it would require publishing the + code under the AGPL again. + +## Consequences + +- Copies distributed while AGPL-3.0 applied remain under that license for whoever received + them: the change is not retroactive. +- The assignment of rights over contributions included in `LICENSE` is accepted by + contributing, without a signature. Its validity under Spanish intellectual property law + is doubtful; if external contributors join, a lawyer should review it, or a contributor + license agreement (CLA) should be signed. +- `install.sh` and the installation docs still point to a public GitHub repository; if the + repository is private, installing with anonymous `curl` does not work. The owner decides. diff --git a/docs/design-system.md b/docs/design-system.md index 6439310..6687aa1 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -1,50 +1,50 @@ -# Sistema de diseño +# Design system -Este documento describe el sistema visual de **orgtable**: su estética, los tokens de -color, el tema oscuro, las paletas de contenido y los componentes de interfaz -reutilizables. Es un resumen operativo; las decisiones de diseño completas viven en -[`DESIGN.md`](../DESIGN.md) y [`PRODUCT.md`](../PRODUCT.md). +This document describes **orgtable**'s visual system: its aesthetic, the color tokens, +the dark theme, the content palettes and the reusable UI components. It is an operational +summary; the full design decisions live in [`DESIGN.md`](../DESIGN.md) and +[`PRODUCT.md`](../PRODUCT.md). --- -## Estética y principios +## Aesthetic and principles -orgtable es un **clon de Trello self-hosted** (tableros → listas → -tarjetas con arrastrar y soltar). El producto debe sentirse tan fluido y familiar como -Trello, pero que sea tuyo. La marca es **familiar, colorida y directa**; alegre sin ser -infantil. La voz es concreta y en español. +orgtable is a **self-hosted Kanban board app** (boards → lists → cards with drag and +drop). It should feel as fluid and familiar as Trello, while being yours. The aesthetic is +**warm editorial**: warm neutrals (sand, paper, espresso) with a single **terracotta** +accent. -El principio central es: +The core principle is: -> **El color vive en el contenido; el cromo es neutro.** +> **Color lives in the content; the chrome is neutral.** -Los fondos de tablero y las etiquetas llevan el color (estrategia *committed*); la -interfaz base —barras, paneles, tarjetas, formularios— se mantiene neutra (estrategia -*restrained*). El tablero es el héroe: cabeceras y navegación se apartan para que mande el -contenido. +Board backgrounds and labels carry the color, in a muted and cohesive palette; the base +interface (bars, panels, cards, forms) stays neutral and warm (a *restrained* strategy). +The board is the hero: headers and navigation step aside so the content leads. -**Anti-referencias** (lo que evitamos): UI corporativa gris sin vida, dashboards SaaS -genéricos con gradientes decorativos y eyebrows en mayúsculas, y el "look hecho por IA" -(grises lavados sobre casi-blancos, bordes laterales de color como acento, sombras difusas -pegadas a bordes de 1px). +**Anti-references** (what we avoid): lifeless gray corporate UI, generic SaaS dashboards +with decorative gradients and uppercase eyebrows, and the "AI-made look" (washed-out grays +on near-whites, colored side borders used as accents, diffuse shadows stuck to 1px +borders). -### Accesibilidad +### Accessibility -- **Contraste AA**: el texto de cuerpo cumple ≥4.5:1 (los tokens `--ink` e `--ink-muted` - están calibrados para ello en ambos temas). -- Las **etiquetas de color nunca son el único indicador**: siempre llevan nombre/texto. -- **Tema claro y oscuro** con selector persistente. -- **`prefers-reduced-motion`** se respeta globalmente en `globals.css` (ver +- **AA contrast**: body text meets ≥4.5:1 (the `--ink` and `--ink-muted` tokens are + calibrated for it in both themes; `test/contrast.test.ts` checks the tokens in + `app/globals.css`). +- **Color labels are never the only indicator**: they always carry a name or text. +- **Light and dark themes** with a persistent picker. +- **`prefers-reduced-motion`** is respected globally in `globals.css` (see [Motion](#motion)). --- -## Tokens semánticos (OKLCH) +## Semantic tokens (OKLCH) -Los tokens se definen en [`app/globals.css`](../app/globals.css) con **OKLCH** (mejor -control perceptual de luminosidad y croma que RGB/HSL). Se exponen a Tailwind 4 mediante -`@theme inline`, que mapea cada variable CSS a una `--color-*` utilizable como -`bg-surface`, `text-ink`, `border-border`, etc. +Tokens are defined in [`app/globals.css`](../app/globals.css) in **OKLCH** (better +perceptual control of lightness and chroma than RGB/HSL). They are exposed to Tailwind 4 +through `@theme inline`, which maps each CSS variable to a `--color-*` usable as +`bg-surface`, `text-ink`, `border-border`, and so on. ```css @theme inline { @@ -60,57 +60,72 @@ control perceptual de luminosidad y croma que RGB/HSL). Se exponen a Tailwind 4 --color-accent-ink: var(--accent-ink); --color-danger: var(--danger); --color-success: var(--success); - /* … fuentes … */ + --font-sans: var(--font-figtree); + --font-display: var(--font-bricolage); + --font-mono: var(--font-geist-mono); } ``` -### Neutros (cromo) +### Neutrals (chrome) -| Token | Claro | Oscuro | Uso | +| Token | Light | Dark | Use | |-------|-------|--------|-----| -| `--bg` | `0.975 0.004 255` | `0.17 0.012 260` | Fondo de la app | -| `--surface` | `1 0 0` | `0.255 0.014 260` | Tarjetas, paneles, modales | -| `--surface-2` | `0.96 0.005 255` | `0.3 0.015 260` | Inputs, hovers, fondos secundarios | -| `--column` | `0.955 0.006 255` | `0.205 0.013 260` | Columnas del tablero | -| `--ink` | `0.22 0.02 260` | `0.96 0.01 260` | Texto principal (≥4.5:1) | -| `--ink-muted` | `0.46 0.02 260` | `0.72 0.015 260` | Texto secundario (≥4.5:1) | -| `--border` | `0.9 0.006 255` | `0.34 0.015 260` | Bordes y divisores sutiles | +| `--bg` | `0.965 0.013 74` | `0.205 0.012 52` | App background (sand / warm charcoal) | +| `--surface` | `0.992 0.005 80` | `0.25 0.014 52` | Cards, panels, modals (paper) | +| `--surface-2` | `0.94 0.015 74` | `0.3 0.016 52` | Inputs, hovers, secondary backgrounds | +| `--column` | `0.95 0.014 72` | `0.23 0.013 52` | Board columns | +| `--ink` | `0.27 0.022 52` | `0.95 0.012 75` | Main text (≥4.5:1) | +| `--ink-muted` | `0.49 0.026 55` | `0.73 0.02 68` | Secondary text (≥4.5:1) | +| `--border` | `0.88 0.016 72` | `0.36 0.016 55` | Subtle borders and dividers | -### Acento e intención +### Accent and intent -| Token | Claro | Oscuro | Uso | +| Token | Light | Dark | Use | |-------|-------|--------|-----| -| `--accent` | `0.55 0.17 255` | `0.68 0.15 255` | Acción primaria, foco, selección | -| `--accent-hover` | `0.5 0.17 255` | `0.74 0.15 255` | Hover del acento | -| `--accent-ink` | `1 0 0` | `0.17 0.02 260` | Texto sobre acento | -| `--danger` | `0.55 0.2 25` | `0.7 0.17 25` | Borrar, error | -| `--success` | `0.6 0.15 150` | `0.72 0.14 150` | Confirmaciones, progreso | +| `--accent` | `0.49 0.13 40` | `0.68 0.115 45` | Primary action, focus, selection (terracotta) | +| `--accent-hover` | `0.44 0.13 40` | `0.74 0.115 45` | Accent hover | +| `--accent-ink` | `0.99 0.01 80` | `0.2 0.02 50` | Text on accent | +| `--danger` | `0.52 0.18 28` | `0.71 0.16 30` | Delete, error | +| `--success` | `0.48 0.1 145` | `0.72 0.1 150` | Confirmations, progress | -> Todos los valores son `oklch(L C H)`. Cada token tiene su par claro/oscuro: el tema lo -> selecciona reasignando las variables bajo `:root` o `.dark`. +> All values are `oklch(L C H)`. Every token has a light/dark pair: the theme picks one by +> reassigning the variables under `:root` or `.dark`. `--danger` and `--success` are dark +> enough to keep 4.5:1 on their own 10–15 % tinted backgrounds too (alerts, overdue badge). --- -## Tema oscuro +## Typography -El tema oscuro funciona **por clase**: una clase `.dark` en `<html>` que reasigna todas -las variables de color. La activación a nivel de Tailwind se declara con -`@custom-variant`: +All fonts are **SIL OFL 1.1** (Google Fonts), loaded with `next/font/google` in +[`app/layout.tsx`](../app/layout.tsx). Next downloads them at build time and serves them +from the app itself, so there are no requests to Google at runtime. + +- **Bricolage Grotesque** (`--font-display`, `font-display` utility): headings and brand. +- **Figtree** (`--font-sans`): interface and body text. +- **Geist Mono** (`--font-mono`): tabular numbers, metadata and counters. + +--- + +## Dark theme + +The dark theme is **class-based**: a `.dark` class on `<html>` reassigns every color +variable. Tailwind activation is declared with `@custom-variant`: ```css @custom-variant dark (&:where(.dark, .dark *)); ``` -Esto hace que utilidades como `dark:block` o `dark:hidden` se apliquen cuando el elemento -está dentro de un árbol con la clase `.dark`, en lugar de depender de -`prefers-color-scheme`. Así el usuario puede **forzar** un tema independientemente de la -preferencia del sistema. +This makes utilities such as `dark:block` or `dark:hidden` apply when the element is +inside a tree with the `.dark` class, instead of depending on `prefers-color-scheme`. That +way the user can **force** a theme regardless of the system preference. -### Toggle persistente +### Persistent choice -El componente [`ThemeToggle`](../app/components/theme-toggle.tsx) alterna la clase y -persiste la elección en `localStorage`. No necesita estado de React: los dos iconos -(sol/luna) se muestran por CSS según la clase `.dark` (`dark:block` / `dark:hidden`). +The [`ThemeToggle`](../app/components/theme-toggle.tsx) component toggles the class and +stores the choice in `localStorage`. It needs no React state: the two icons (sun/moon) are +shown through CSS based on the `.dark` class (`dark:block` / `dark:hidden`). Settings › +Appearance offers the same choice with a third option, "system", which removes the stored +value and follows the device. ```tsx function toggle() { @@ -120,13 +135,13 @@ function toggle() { } ``` -### Script anti-parpadeo (anti-FOUC) +### Anti-flash script (anti-FOUC) -Si el tema se aplicara desde React tras la hidratación, habría un **flash** del tema -incorrecto en la primera pintura. Para evitarlo, -[`app/layout.tsx`](../app/layout.tsx) inyecta un script **inline en `<head>`** que se -ejecuta antes del primer paint: lee `localStorage`, cae a `prefers-color-scheme` si no hay -preferencia guardada y añade la clase `.dark` si procede. +If the theme were applied from React after hydration, the wrong theme would **flash** on +first paint. To avoid it, [`app/layout.tsx`](../app/layout.tsx) injects an **inline script +in `<head>`** (with the CSP nonce) that runs before the first paint: it reads +`localStorage`, falls back to `prefers-color-scheme` when no preference is stored, and adds +the `.dark` class when needed. ```js (function(){ @@ -140,97 +155,94 @@ preferencia guardada y añade la clase `.dark` si procede. })(); ``` -El `<html>` lleva `suppressHydrationWarning` porque el script muta la clase antes de que -React hidrate, lo que de otro modo provocaría un aviso de discordancia. - -La fuente es **Geist Sans** (con Geist Mono para detalles técnicos), cargada vía -`next/font` y expuesta como `--font-geist-sans` / `--font-geist-mono`. +`<html>` has `suppressHydrationWarning` because the script changes the class before React +hydrates, which would otherwise trigger a mismatch warning. --- -## Paletas de contenido +## Content palettes -Definidas en [`app/lib/colors.ts`](../app/lib/colors.ts). Son colores del **contenido** -(no del cromo): vibrantes y con luminosidad lo bastante baja para que el **texto blanco** -encima sea legible (≥4.5:1). +Defined in [`app/lib/colors.ts`](../app/lib/colors.ts). These are **content** colors (not +chrome): muted editorial tones (clay, olive, plum, ochre, brick, slate) with lightness low +enough for **white text** on top to stay legible (≥4.5:1). -### Color de tablero — `BOARD_COLORS` +### Board color: `BOARD_COLORS` -Seis hues para los fondos de tablero: +Six hues for board backgrounds: ```ts export const BOARD_COLORS = { - blue: "oklch(0.55 0.13 255)", - green: "oklch(0.56 0.12 155)", - purple: "oklch(0.52 0.15 300)", - amber: "oklch(0.62 0.13 70)", - rose: "oklch(0.57 0.16 8)", - teal: "oklch(0.56 0.09 195)", + blue: "oklch(0.5 0.07 248)", + green: "oklch(0.5 0.07 150)", + purple: "oklch(0.48 0.09 330)", + amber: "oklch(0.54 0.09 62)", + rose: "oklch(0.52 0.12 33)", + teal: "oklch(0.5 0.06 200)", } as const; ``` -Helpers asociados: +Related helpers: -- `pickBoardColor(seed)` — color **determinista** a partir de un id (hash simple), para - asignar un color estable al crear un tablero sin color explícito. -- `boardBackgroundFor({ id, background })` — usa el color elegido o, si no hay, uno estable - según el `id`. +- `pickBoardColor(seed)`: a **deterministic** color from an id (simple hash), used to give + a stable color to a board created without an explicit color. +- `boardBackgroundFor({ id, background })`: uses the chosen color or, if there is none, a + stable one based on the `id`. -### Color de etiqueta — `LABEL_COLORS` +### Label color: `LABEL_COLORS` -Ocho colores para las etiquetas (todas con L ~0.55–0.64 → texto blanco legible): +Eight colors for labels (all with L ≈ 0.52–0.57 → legible white text): ```ts export const LABEL_COLORS = { - green: "oklch(0.6 0.14 150)", - lime: "oklch(0.62 0.14 130)", - amber: "oklch(0.64 0.13 70)", - red: "oklch(0.58 0.2 25)", - purple: "oklch(0.55 0.16 300)", - blue: "oklch(0.58 0.15 255)", - teal: "oklch(0.58 0.1 195)", - pink: "oklch(0.6 0.18 350)", + green: "oklch(0.53 0.09 150)", + lime: "oklch(0.56 0.1 128)", + amber: "oklch(0.56 0.1 65)", + red: "oklch(0.53 0.15 30)", + purple: "oklch(0.5 0.11 330)", + blue: "oklch(0.52 0.09 250)", + teal: "oklch(0.52 0.07 200)", + pink: "oklch(0.55 0.12 350)", } as const; ``` -`labelBackground(color)` devuelve el color de fondo de una pastilla (con *fallback* a -`blue`). Las claves se exponen como `LABEL_COLOR_KEYS` para pintar el selector de color al -crear una etiqueta (ver [`docs/card-detail.md`](./card-detail.md)). +`labelBackground(color)` returns the background color of a label pill (falling back to +`blue`). The keys are exposed as `LABEL_COLOR_KEYS` to draw the color picker when creating +a label (see [`docs/card-detail.md`](./card-detail.md)). --- -## Componentes de interfaz +## UI components -Un vocabulario reducido y consistente. Los radios siguen la escala de `DESIGN.md` -(inputs/botones/tarjetas 8px, columnas/paneles 12px, modal 16px, pastillas radio completo). +A small, consistent vocabulary. Radii follow the scale in `DESIGN.md` (inputs, buttons and +cards 8px; columns and panels 12px; modal 16px; pills fully rounded). ### Button -[`app/components/ui/button.tsx`](../app/components/ui/button.tsx) — botón presentacional -(sin `"use client"`, válido en componentes de servidor y cliente) con cuatro variantes: +[`app/components/ui/button.tsx`](../app/components/ui/button.tsx): a presentational button +(no `"use client"`, valid in both server and client components) with four variants: -| Variante | Estilo | +| Variant | Style | |----------|--------| | `primary` | `bg-accent text-accent-ink hover:bg-accent-hover` | | `ghost` | `text-ink-muted hover:bg-surface-2 hover:text-ink` | | `danger` | `text-danger hover:bg-danger/10` | | `subtle` | `bg-surface-2 text-ink hover:bg-border` | -Base común: `rounded-lg`, `px-4 py-2`, `text-sm font-medium`, `transition-colors`, anillo -de foco `focus-visible:outline-accent` y estado `disabled` (`opacity-60`, -`pointer-events-none`). +Shared base: `rounded-lg`, `px-4 py-2`, `text-sm font-medium`, `transition` with a slight +press (`active:translate-y-px`), focus ring `focus-visible:outline-accent` and a `disabled` +state (`opacity-60`, `pointer-events-none`). ```tsx -<Button>Guardar</Button> {/* primary por defecto */} -<Button variant="subtle">Añadir</Button> -<Button variant="danger">Borrar</Button> +<Button>Save</Button> {/* primary by default */} +<Button variant="subtle">Add</Button> +<Button variant="danger">Delete</Button> ``` -### Input y Textarea +### Input and Textarea -[`app/components/ui/input.tsx`](../app/components/ui/input.tsx) exporta `Input` y -`Textarea` con estilos idénticos: fondo `--surface`, borde `--border`, texto `--ink`, -placeholder atenuado y foco con borde de acento + anillo +[`app/components/ui/input.tsx`](../app/components/ui/input.tsx) exports `Input` and +`Textarea` with identical styles: `--surface` background, `--border` border, `--ink` text, +a muted placeholder, and focus shown with an accent border plus a ring (`focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent/25`). ```tsx @@ -242,12 +254,12 @@ placeholder atenuado y foco con borde de acento + anillo ## Motion -Transiciones de **150–250 ms** con `ease-out`. La motion comunica estado (hover, apertura -de modal, entrada de tarjeta), nunca decora. En `globals.css` viven los keyframes -`overlay-in` (fade del backdrop) y `dialog-in` (fade + leve `translateY`/`scale`), usados -por el modal de detalle. +Transitions of **150–250 ms** with `ease-out`. Motion communicates state (hover, modal +opening, a card entering), never decorates. `globals.css` holds the `overlay-in` (backdrop +fade) and `dialog-in` (fade plus a slight `translateY`/`scale`) keyframes used by the +detail modal. -La preferencia de movimiento reducido se respeta globalmente: +The reduced-motion preference is respected globally: ```css @media (prefers-reduced-motion: reduce) { @@ -259,10 +271,10 @@ La preferencia de movimiento reducido se respeta globalmente: } ``` -Además, las scrollbars se afinan en punteros finos (`scrollbar-width: thin`) porque las -columnas del tablero scrollean a menudo. +Scrollbars are also slimmed on fine pointers (`scrollbar-width: thin`) because board +columns scroll often. --- -Ver también: [`DESIGN.md`](../DESIGN.md) · [`PRODUCT.md`](../PRODUCT.md) · +See also: [`DESIGN.md`](../DESIGN.md) · [`PRODUCT.md`](../PRODUCT.md) · [`docs/card-detail.md`](./card-detail.md). diff --git a/docs/docker.md b/docs/docker.md index dd0d046..1f4ebc0 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,26 +1,26 @@ -# Infraestructura Docker +# Docker infrastructure -Esta guía explica cómo está construida la infraestructura de contenedores de **orgtable** y por qué cada decisión está donde está. Está pensada para contribuidores que quieran **entender o modificar** la imagen y la orquestación. La infraestructura ya está construida y verificada funcionando end-to-end. +This guide explains how the **orgtable** container infrastructure is built and why each decision is where it is. It is meant for contributors who want to **understand or modify** the image and the orchestration. The infrastructure is in place and verified working end to end. -El stack se compone de: +The stack is made of: -| Pieza | Archivo | Rol | +| Piece | File | Role | | --- | --- | --- | -| Imagen de la app | `Dockerfile` | Build multi-stage que produce la imagen de producción de Next.js. | -| Orquestación | `docker-compose.yml` | Levanta PostgreSQL + la app, con red interna y healthchecks. | -| Arranque del contenedor | `docker-entrypoint.sh` | Aplica migraciones de Prisma y arranca el servidor. | -| Contexto de build | `.dockerignore` | Excluye lo que no debe entrar en la imagen (incluidos los secretos). | -| Salida de Next | `next.config.ts` | `output: "standalone"` empaqueta un servidor Node mínimo. | +| App image | `Dockerfile` | Multi-stage build that produces the Next.js production image. | +| Orchestration | `docker-compose.yml` | Starts PostgreSQL + the app, with an internal network and healthchecks. | +| Container start-up | `docker-entrypoint.sh` | Applies the Prisma migrations and starts the server. | +| Build context | `.dockerignore` | Excludes what must not end up in the image (secrets included). | +| Next output | `next.config.ts` | `output: "standalone"` packages a minimal Node server. | -Para instalar y ejecutar en un servidor real consulta [`installation.md`](./installation.md). Para el esquema de datos y las migraciones consulta [`database.md`](./database.md). +To install and run it on a real server, see [`installation.md`](./installation.md). For the data schema and migrations, see [`database.md`](./database.md). --- -## El `Dockerfile` (multi-stage) +## The `Dockerfile` (multi-stage) -El `Dockerfile` tiene tres stages: `deps`, `builder` y `runner`. La idea es construir con todas las herramientas disponibles pero enviar a producción solo lo imprescindible. +The `Dockerfile` has three stages: `deps`, `builder` and `runner`. The idea is to build with every tool available but ship only what is essential to production. -### Stage `deps` — instalación de dependencias +### `deps` stage: installing dependencies ```dockerfile FROM base AS deps @@ -29,11 +29,11 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile ``` -Se hace `pnpm install --frozen-lockfile` (pnpm llega por Corepack, en un stage `base`) sobre `node:22-alpine`. **El uso de Alpine aquí es deliberado, no estético.** +It runs `pnpm install --frozen-lockfile` (pnpm comes from Corepack, in a `base` stage) on `node:22-alpine`. **Using Alpine here is deliberate, not cosmetic.** -Alpine Linux usa **musl libc** (no glibc). Prisma descarga *engines nativos* durante la instalación, y uno de ellos es el **schema-engine** que utiliza `migrate deploy`. Al instalar dentro de Alpine, ese binario se descarga para la plataforma **`linux-musl`**, que es exactamente la que tendrá el contenedor final (también Alpine). Si las dependencias se instalaran sobre una imagen basada en glibc (p. ej. `node:22` Debian), el engine quedaría compilado para glibc y fallaría en el `runner` de Alpine. Mantener Alpine de punta a punta garantiza que el engine coincida con el sistema donde se ejecuta. +Alpine Linux uses **musl libc** (not glibc). Prisma downloads *native engines* during installation, one of which is the **schema engine** used by `migrate deploy`. Installing inside Alpine downloads that binary for the **`linux-musl`** platform, which is exactly what the final container (also Alpine) runs. If the dependencies were installed on a glibc-based image (e.g. Debian `node:22`), the engine would be built for glibc and would fail in the Alpine `runner`. Keeping Alpine end to end guarantees that the engine matches the system it runs on. -### Stage `builder` — generación de Prisma Client y build de Next +### `builder` stage: generating Prisma Client and building Next ```dockerfile FROM base AS builder @@ -45,16 +45,16 @@ RUN pnpm exec prisma generate RUN pnpm build ``` -Reutiliza los `node_modules` ya instalados en `deps`, copia el código y hace dos cosas: genera el Prisma Client y construye la app. +It reuses the `node_modules` installed in `deps`, copies the code and does two things: generates the Prisma Client and builds the app. -El detalle clave es la **`DATABASE_URL` ficticia** definida *antes* de `prisma generate`: +The key detail is the **dummy `DATABASE_URL`** defined *before* `prisma generate`: -- **Prisma 7 exige resolver `DATABASE_URL` al cargar `prisma.config.ts`.** Si la variable falta, la carga de la configuración lanza un error y `prisma generate` / `pnpm build` ni siquiera arrancan. -- Sin embargo, **ni `generate` ni el build se conectan realmente a la base de datos**. `generate` solo produce el cliente (query engine WASM, sin binario), y las páginas de la app son **dinámicas** (dependen de la sesión/auth), por lo que no consultan la BD durante el build. Es decir, la URL solo necesita *existir y ser sintácticamente válida*; nunca se abre una conexión a `localhost:5432`. +- **Prisma 7 must resolve `DATABASE_URL` when loading `prisma.config.ts`.** If the variable is missing, loading the configuration throws and `prisma generate` / `pnpm build` do not even start. +- However, **neither `generate` nor the build actually connects to the database**. `generate` only produces the client (WASM query engine, no binary), and the app pages are **dynamic** (they depend on the session), so they do not query the database during the build. The URL only needs to *exist and be syntactically valid*; no connection to `localhost:5432` is ever opened. -Tras esto, `pnpm build` produce `.next/standalone` gracias a `output: "standalone"` (ver [`next.config.ts`](../next.config.ts)). +After this, `pnpm build` produces `.next/standalone` thanks to `output: "standalone"` (see [`next.config.ts`](../next.config.ts)). -### Stage `runner` — imagen final de producción +### `runner` stage: the final production image ```dockerfile FROM node:22-alpine AS runner @@ -81,65 +81,65 @@ ENTRYPOINT ["docker-entrypoint.sh"] CMD ["node", "server.js"] ``` -Puntos importantes del `runner`: +Key points of the `runner`: -- **`node:22-alpine`** otra vez, para que el schema-engine de Prisma (descargado en `deps` para `linux-musl`) sea compatible con el sistema en runtime. -- **`apk add --no-cache openssl`**: el schema-engine de Prisma necesita `openssl` para ejecutar `migrate deploy`. Alpine no lo trae por defecto, así que se instala explícitamente. -- **Usuario sin privilegios `nextjs` (non-root)**: se crea el grupo `nodejs` y el usuario `nextjs` y se cambia a ese usuario con `USER nextjs`. La app nunca corre como root. -- **El código es de solo lectura para la app**: `/app` sigue siendo de `root`, así que el proceso no puede reescribir `server.js`, `.next` ni `node_modules`. Solo son escribibles `.next/cache` (caché de Next) y `/data/uploads` (adjuntos). No se hace `chown -R /app`: además de dejar el código escribible, duplicaría `node_modules` en una capa nueva de cientos de MB. -- **Se copian los artefactos de producción**: `.next/standalone` (el servidor Node mínimo), `.next/static`, `public`, y además `prisma/`, `prisma.config.ts` y `package.json` (necesarios para las migraciones). +- **`node:22-alpine`** again, so the Prisma schema engine (downloaded in `deps` for `linux-musl`) is compatible with the runtime system. +- **`apk add --no-cache openssl`**: the Prisma schema engine needs `openssl` to run `migrate deploy`. Alpine does not ship it by default, so it is installed explicitly. +- **Unprivileged `nextjs` user (non-root)**: the `nodejs` group and `nextjs` user are created and the image switches to that user with `USER nextjs`. The app never runs as root. +- **The code is read-only for the app**: `/app` stays owned by `root`, so the process cannot rewrite `server.js`, `.next` or `node_modules`. Only `.next/cache` (Next cache) and `/data/uploads` (attachments) are writable. There is no `chown -R /app`: besides making the code writable, it would duplicate `node_modules` in a new layer of hundreds of MB. +- **The production artefacts are copied**: `.next/standalone` (the minimal Node server), `.next/static`, `public`, plus `prisma/`, `prisma.config.ts` and `package.json` (needed for the migrations). -#### Por qué se copia el `node_modules` **completo** +#### Why the **whole** `node_modules` is copied -Esta es la parte menos obvia. En el `runner` se copia **todo** `node_modules` desde el `builder`, no solo `prisma` y `@prisma`: +This is the least obvious part. The `runner` copies **all** of `node_modules` from the `builder`, not just `prisma` and `@prisma`: ```dockerfile COPY --from=builder /app/node_modules ./node_modules ``` -El motivo es el **CLI de Prisma**. En el arranque, el entrypoint ejecuta `prisma migrate deploy`, y ese CLI depende de **dependencias transitivas hoisteadas** —por ejemplo **`effect`**, que es usada por `@prisma/config`—. El trazado de dependencias de `output: "standalone"` **solo incluye lo que importa la propia aplicación**, no lo que importa el CLI de Prisma. Por tanto, si copiáramos solo `prisma`/`@prisma`, al CLI le faltarían piezas como `effect` y `migrate deploy` fallaría. Copiar el árbol completo garantiza que tanto la app como el CLI tengan todo lo que necesitan. +The reason is the **Prisma CLI**. At start-up the entrypoint runs `prisma migrate deploy`, and that CLI depends on **hoisted transitive dependencies**, for example **`effect`**, which `@prisma/config` uses. The dependency tracing of `output: "standalone"` **only includes what the app itself imports**, not what the Prisma CLI imports. If only `prisma`/`@prisma` were copied, the CLI would be missing pieces such as `effect` and `migrate deploy` would fail. Copying the whole tree guarantees that both the app and the CLI have everything they need. -> **Mejora pendiente.** Copiar `node_modules` completo infla la imagen. A futuro se puede añadir un stage `prod-deps` que haga `pnpm install --prod --frozen-lockfile` y copiar solo ese árbol al `runner`, reduciendo el tamaño final. Queda anotado como optimización. +> **Possible improvement.** Copying the whole `node_modules` inflates the image. A `prod-deps` stage could run `pnpm install --prod --frozen-lockfile` and copy only that tree into the `runner`, reducing the final size. -### Entrypoint y comando +### Entrypoint and command ```dockerfile ENTRYPOINT ["docker-entrypoint.sh"] CMD ["node", "server.js"] ``` -El `ENTRYPOINT` ejecuta el script de arranque (migraciones) y, al final, este hace `exec` del `CMD`, que arranca el servidor standalone (`node server.js`). +The `ENTRYPOINT` runs the start-up script (migrations), which finally `exec`s the `CMD` that starts the standalone server (`node server.js`). --- -## `docker-entrypoint.sh` — migraciones idempotentes +## `docker-entrypoint.sh`: idempotent migrations ```sh #!/bin/sh set -e -echo "[orgtable] Aplicando migraciones de la base de datos..." +echo "[orgtable] Applying database migrations..." node node_modules/prisma/build/index.js migrate deploy -echo "[orgtable] Arrancando el servidor..." +echo "[orgtable] Starting the server..." exec "$@" ``` -En cada arranque del contenedor `app`: +Every time the `app` container starts: -1. **Aplica las migraciones pendientes** con `prisma migrate deploy`. Esta operación es **idempotente**: solo ejecuta las migraciones que aún no se han aplicado, comparando con el historial registrado en la BD. Arrancar el contenedor muchas veces no reaplica migraciones ya presentes. -2. El CLI se invoca por **ruta directa** (`node node_modules/prisma/build/index.js`) en lugar de `npx`, para **no depender de los symlinks de `.bin`** (que pueden no estar presentes según cómo se haya copiado/hoisteado `node_modules`). -3. `exec "$@"` reemplaza el proceso del script por el del `CMD` (`node server.js`), de modo que el servidor de Next pasa a ser PID 1 y recibe correctamente las señales del contenedor. +1. **It applies pending migrations** with `prisma migrate deploy`. This is **idempotent**: it only runs the migrations that have not been applied yet, comparing against the history recorded in the database. Starting the container many times never reapplies existing migrations. +2. The CLI is invoked by its **direct path** (`node node_modules/prisma/build/index.js`) instead of `npx`, so it **does not depend on the `.bin` symlinks** (which may be missing depending on how `node_modules` was copied or hoisted). +3. `exec "$@"` replaces the script process with the `CMD` (`node server.js`), so the Next server becomes PID 1 and receives the container signals correctly. -Más contexto sobre el esquema y el flujo de migraciones en [`database.md`](./database.md). +More on the schema and the migration flow in [`database.md`](./database.md). --- -## `docker-compose.yml` — orquestación +## `docker-compose.yml`: orchestration -Define dos servicios (`postgres` y `app`), dos volúmenes (`pgdata` para la base de datos y `uploads` para los adjuntos) y una red bridge interna (`orgtable`). +It defines two services (`postgres` and `app`), two volumes (`pgdata` for the database and `uploads` for attachments) and an internal bridge network (`orgtable`). -### Servicio `postgres` +### `postgres` service ```yaml postgres: @@ -160,11 +160,11 @@ postgres: - orgtable ``` -- **`postgres:17-alpine`** con persistencia en el volumen **`pgdata`** (los datos sobreviven a recreaciones del contenedor). -- **Healthcheck con `pg_isready`**: marca el servicio como `healthy` solo cuando la BD acepta conexiones. Esto es lo que permite a `app` esperar correctamente (ver más abajo). -- **No expone ningún puerto al host.** PostgreSQL solo es accesible desde la red interna `orgtable`, no desde fuera del host. **Es una decisión de seguridad**: la base de datos no debe estar publicada en la máquina anfitriona; solo la app necesita hablar con ella, y lo hace por la red de Docker. +- **`postgres:17-alpine`** with persistence in the **`pgdata`** volume (data survives container re-creation). +- **Healthcheck with `pg_isready`**: marks the service `healthy` only when the database accepts connections. This is what lets `app` wait correctly (see below). +- **It exposes no port to the host.** PostgreSQL is only reachable from the internal `orgtable` network, never from outside the host. **This is a security decision**: the database must not be published on the host machine; only the app needs to talk to it, and it does so over the Docker network. -### Servicio `app` +### `app` service ```yaml app: @@ -184,6 +184,8 @@ app: CRON_SECRET: ${CRON_SECRET:-} TRUSTED_PROXY_HOPS: ${TRUSTED_PROXY_HOPS:-0} ORGTABLE_TIMEZONE: ${ORGTABLE_TIMEZONE:-Atlantic/Canary} + ORGTABLE_AUDIT_RETENTION_DAYS: ${ORGTABLE_AUDIT_RETENTION_DAYS:-} + ORGTABLE_SOURCE_URL: ${ORGTABLE_SOURCE_URL:-} volumes: - uploads:/data/uploads ports: @@ -198,91 +200,91 @@ app: - orgtable ``` -- **Build local** desde el `Dockerfile` del repo (`context: .`). La imagen se construye en el servidor del usuario, no se descarga de un registry. -- **`depends_on` con `condition: service_healthy`**: la app **no arranca hasta que PostgreSQL está `healthy`**. Así, cuando el entrypoint ejecute `migrate deploy`, la BD ya está aceptando conexiones. -- **Solo recibe las variables de `environment:`**, no todo el `.env`. Si una variable nueva no se añade aquí, la app no la ve dentro del contenedor (el CI lo comprueba para las opcionales). Qué hace cada una: [tabla de variables de `installation.md`](./installation.md#variables-de-entorno). -- **Volumen `uploads`** montado en `/data/uploads`: los adjuntos sobreviven a recreaciones del contenedor. -- **Publica el puerto** `${APP_PORT:-3000}:3000` (configurable vía `APP_PORT`, por defecto `3000`, en todas las interfaces). Con `APP_PORT=127.0.0.1:3000` solo se publica en local, que es lo que conviene detrás de un proxy inverso ([self-hosting.md](./self-hosting.md#https-y-dominio-reverse-proxy)). Es el único servicio expuesto al host. -- **Healthcheck con `wget`** contra `http://127.0.0.1:3000/api/health`. +- **Local build** from the repository `Dockerfile` (`context: .`). The image is built on the user's server, not pulled from a registry. +- **`depends_on` with `condition: service_healthy`**: the app **does not start until PostgreSQL is `healthy`**. So when the entrypoint runs `migrate deploy`, the database is already accepting connections. +- **It only receives the variables under `environment:`**, not the whole `.env`. If a new variable is not added here, the app does not see it inside the container (CI checks this for the optional ones). What each one does: [variables table in `installation.md`](./installation.md#environment-variables). +- **`uploads` volume** mounted at `/data/uploads`: attachments survive container re-creation. +- **Publishes the port** `${APP_PORT:-3000}:3000` (configurable with `APP_PORT`, `3000` by default, on every interface). With `APP_PORT=127.0.0.1:3000` it is only published on localhost, which is what you want behind a reverse proxy ([self-hosting.md](./self-hosting.md#https-and-domain-reverse-proxy)). It is the only service exposed to the host. +- **Healthcheck with `wget`** against `http://127.0.0.1:3000/api/health`. -#### Por qué `127.0.0.1` y no `localhost` en el healthcheck +#### Why `127.0.0.1` and not `localhost` in the healthcheck -Este detalle estaba roto y se corrigió: **se usa `127.0.0.1` explícitamente, no `localhost`.** +This detail used to be broken and was fixed: **`127.0.0.1` is used explicitly, not `localhost`.** -Dentro del contenedor, **`localhost` resuelve a la dirección IPv6 `::1`**, mientras que el servidor standalone de Next escucha en **IPv4 `0.0.0.0`** (fijado con `HOSTNAME=0.0.0.0` en el `Dockerfile`). Con `localhost`, el `wget` intentaba conectar por IPv6 a un servidor que no escuchaba ahí y el healthcheck devolvía **"Connection refused"**, marcando el contenedor como `unhealthy` aunque la app funcionara. Forzar `127.0.0.1` (IPv4) hace que el chequeo apunte exactamente a donde el servidor está escuchando. +Inside the container, **`localhost` resolves to the IPv6 address `::1`**, while the Next standalone server listens on **IPv4 `0.0.0.0`** (set with `HOSTNAME=0.0.0.0` in the `Dockerfile`). With `localhost`, `wget` tried to connect over IPv6 to a server that was not listening there, and the healthcheck returned **"Connection refused"**, marking the container `unhealthy` even though the app worked. Forcing `127.0.0.1` (IPv4) points the check exactly where the server listens. -- **`start_period: 20s`**: da un margen al arranque inicial. El contenedor primero aplica migraciones y luego levanta el servidor, así que durante los primeros segundos el endpoint todavía no responde; `start_period` evita que esos fallos iniciales cuenten como `unhealthy`. +- **`start_period: 20s`**: gives the initial start-up some slack. The container first applies migrations and then starts the server, so for the first few seconds the endpoint does not answer yet; `start_period` keeps those early failures from counting as `unhealthy`. -### Sin Redis +### No Redis -El stack **no incluye Redis**. Es una decisión consciente para el MVP, documentada en [`docs/decisions/0004-no-redis-mvp.md`](./decisions/0004-no-redis-mvp.md). +The stack **does not include Redis**. This is a conscious decision, documented in [`docs/decisions/0004-no-redis-mvp.md`](./decisions/0004-no-redis-mvp.md). --- -## `.dockerignore` — qué NO entra en la imagen +## `.dockerignore`: what does NOT go into the image -El `.dockerignore` reduce el contexto de build y, sobre todo, evita filtrar secretos: +The `.dockerignore` shrinks the build context and, above all, keeps secrets from leaking: -| Patrón | Motivo | +| Pattern | Reason | | --- | --- | -| `node_modules` | Se reinstala dentro de la imagen (`pnpm install --frozen-lockfile`); no se envía el del host. | -| `.next` | Artefacto de build, se regenera dentro del contenedor. | -| `.git`, `.gitignore` | Historial y metadatos innecesarios en la imagen. | -| `.env`, `.env.*` | **CRÍTICO: los secretos nunca deben acabar en la imagen.** | -| `!.env.example` | Excepción: el ejemplo sin secretos sí se conserva. | -| `docs`, `*.md`, `.claude`, `.agents`, `skills-lock.json` | Documentación y tooling de desarrollo, irrelevantes en runtime. | -| `backups`, `**/*.sql.gz`, `**/*.tar.gz`, `uploads`, `data`, `**/*.pem` | Copias de seguridad (con datos personales), adjuntos de desarrollo y claves: `COPY . .` los metería en el builder y en su caché. | -| `coverage`, `test`, `scripts`, `install.sh` | Tests y scripts que se ejecutan en el host; la imagen no los usa. | -| `npm-debug.log*`, `*.tsbuildinfo` | Logs y temporales. | - -El punto más importante es el de los **archivos `.env*`**: las credenciales (`DATABASE_URL`, `SESSION_SECRET`, etc.) **se inyectan por entorno en runtime** (vía `docker-compose.yml` / `.env` del host), nunca se hornean dentro de la imagen. La única excepción es `.env.example`, que es una plantilla sin valores reales. Esto está alineado con [`docs/decisions/0003-db-creds-in-env.md`](./decisions/0003-db-creds-in-env.md). +| `node_modules` | Reinstalled inside the image (`pnpm install --frozen-lockfile`); the host copy is not sent. | +| `.next` | Build artefact, regenerated inside the container. | +| `.git`, `.gitignore` | History and metadata not needed in the image. | +| `.env`, `.env.*` | **CRITICAL: secrets must never end up in the image.** | +| `!.env.example` | Exception: the example without secrets is kept. | +| `docs`, `*.md`, `.claude`, `.agents`, `skills-lock.json` | Documentation and development tooling, irrelevant at runtime. | +| `backups`, `**/*.sql.gz`, `**/*.tar.gz`, `uploads`, `data`, `**/*.pem` | Backups (with personal data), development attachments and keys: `COPY . .` would put them in the builder and its cache. | +| `coverage`, `test`, `scripts`, `install.sh` | Tests and scripts that run on the host; the image does not use them. | +| `npm-debug.log*`, `*.tsbuildinfo` | Logs and temporary files. | + +The most important point is the **`.env*` files**: credentials (`DATABASE_URL`, `SESSION_SECRET`, etc.) **are injected through the environment at runtime** (via `docker-compose.yml` / the host `.env`), never baked into the image. The only exception is `.env.example`, a template with no real values. This follows [`docs/decisions/0003-db-creds-in-env.md`](./decisions/0003-db-creds-in-env.md). --- -## Construir y ejecutar localmente +## Building and running locally -Necesitas Docker y Docker Compose, y un archivo `.env` con las variables requeridas (lo genera `install.sh`; ver [`installation.md`](./installation.md)). +You need Docker and Docker Compose, and a `.env` file with the required variables (`install.sh` generates it; see [`installation.md`](./installation.md)). -Construir las imágenes: +Build the images: ```bash docker compose build ``` -Levantar el stack en segundo plano: +Start the stack in the background: ```bash docker compose up -d ``` -Comprobar el estado y los logs: +Check the status and the logs: ```bash docker compose ps docker compose logs -f app ``` -Cuando ambos servicios aparezcan como `healthy`, la app estará disponible en `http://localhost:${APP_PORT:-3000}`. +When both services show as `healthy`, the app is available at `http://localhost:${APP_PORT:-3000}`. -Parar el stack: +Stop the stack: ```bash docker compose down ``` -(`docker compose down -v` eliminaría también los volúmenes `pgdata` y `uploads` y, con ellos, **todos los datos y todos los adjuntos**.) +(`docker compose down -v` would also delete the `pgdata` and `uploads` volumes and, with them, **all the data and all the attachments**.) --- -## Cómo fluye el arranque +## Start-up flow -El orden de arranque está coordinado por el healthcheck de Postgres y el entrypoint de la app: +The start-up order is coordinated by the Postgres healthcheck and the app entrypoint: -1. **`postgres` arranca** y, cuando `pg_isready` responde, queda marcado como **`healthy`**. -2. **`app` arranca** (estaba esperando por `depends_on: condition: service_healthy`). Su `ENTRYPOINT` ejecuta `docker-entrypoint.sh`. -3. **El entrypoint aplica las migraciones** con `prisma migrate deploy` (idempotente: solo las que falten). -4. **El servidor arranca** vía `exec node server.js`, escuchando en IPv4 `0.0.0.0:3000`. -5. **El healthcheck pasa**: `wget` a `http://127.0.0.1:3000/api/health` responde correctamente y `app` queda marcado como **`healthy`**. `start_period` cubre el tiempo de migraciones + arranque. +1. **`postgres` starts** and, once `pg_isready` answers, is marked **`healthy`**. +2. **`app` starts** (it was waiting on `depends_on: condition: service_healthy`). Its `ENTRYPOINT` runs `docker-entrypoint.sh`. +3. **The entrypoint applies the migrations** with `prisma migrate deploy` (idempotent: only the missing ones). +4. **The server starts** via `exec node server.js`, listening on IPv4 `0.0.0.0:3000`. +5. **The healthcheck passes**: `wget` to `http://127.0.0.1:3000/api/health` answers and `app` is marked **`healthy`**. `start_period` covers the time for migrations + start-up. ``` postgres (pg_isready) ──healthy──▶ app entrypoint (migrate deploy) ──▶ node server.js ──▶ /api/health OK ──▶ app healthy @@ -290,4 +292,4 @@ postgres (pg_isready) ──healthy──▶ app entrypoint (migrate deploy) ─ --- -**Resumen:** un build multi-stage sobre Alpine empaqueta el standalone de Next con el `node_modules` completo para que el CLI de Prisma migre la BD en el arranque, mientras Compose coordina Postgres (privado, con healthcheck) y la app (migra y arranca) inyectando los secretos solo en runtime. +**Summary:** a multi-stage Alpine build packages the Next standalone server with the whole `node_modules`, so the Prisma CLI can migrate the database at start-up, while Compose coordinates Postgres (private, with a healthcheck) and the app (migrates and starts), injecting secrets only at runtime. diff --git a/docs/drag-and-drop.md b/docs/drag-and-drop.md index 2a08ad1..280a78b 100644 --- a/docs/drag-and-drop.md +++ b/docs/drag-and-drop.md @@ -1,42 +1,41 @@ -# Drag & Drop en orgtable +# Drag and drop in orgtable -Esta guía explica cómo funciona el arrastrar y soltar (drag & drop) de listas y -tarjetas en orgtable, pensada para quienes contribuyen al proyecto. El sistema -combina la librería `@dnd-kit` en el cliente, un estado optimista local y Server -Actions que persisten **un único cambio de fila** usando *fractional indexing*. +This guide explains how drag and drop of lists and cards works in orgtable. It is meant +for contributors. The system combines the `@dnd-kit` library on the client, local +optimistic state, and Server Actions that persist **a single row change** using +*fractional indexing*. -Archivos implicados: +Files involved: -- `app/components/board/board-view.tsx` — orquesta todo el D&D y el estado optimista. -- `app/components/board/list-column.tsx` — columna/lista arrastrable + `SortableContext` de sus tarjetas. -- `app/components/board/card-item.tsx` — tarjeta arrastrable. -- `app/lib/board-collision.ts` — detección de colisiones y flechas del teclado (puro, - cubierto por `test/board-collision.test.ts`). -- `app/lib/optimistic.ts` — `persistOptimistic`: guarda el cambio y lo deshace si falla. -- `app/lib/actions/cards.ts` — Server Action `moveCard`. -- `app/lib/actions/lists.ts` — Server Action `reorderList`. -- `app/lib/ordering.ts` — ayudantes de ordenación en el servidor. +- `app/components/board/board-view.tsx` — orchestrates drag and drop and the optimistic state. +- `app/components/board/list-column.tsx` — draggable column/list plus the `SortableContext` of its cards. +- `app/components/board/card-item.tsx` — draggable card. +- `app/lib/board-collision.ts` — collision detection and arrow-key coordinates (pure, + covered by `test/board-collision.test.ts`). +- `app/lib/optimistic.ts` — `persistOptimistic`: saves the change and undoes it if it fails. +- `app/lib/actions/cards.ts` — the `moveCard` Server Action. +- `app/lib/actions/lists.ts` — the `reorderList` Server Action. +- `app/lib/ordering.ts` — server-side ordering helpers. --- -## Librería: `@dnd-kit` +## Library: `@dnd-kit` -Se usan tres paquetes de `@dnd-kit`: +Three `@dnd-kit` packages are used: -- `@dnd-kit/core` — el `DndContext`, los sensores y el `DragOverlay`. -- `@dnd-kit/sortable` — los `SortableContext` y el hook `useSortable`. -- `@dnd-kit/utilities` — el helper `CSS` para aplicar las transformaciones. +- `@dnd-kit/core` — the `DndContext`, the sensors and the `DragOverlay`. +- `@dnd-kit/sortable` — the `SortableContext`s and the `useSortable` hook. +- `@dnd-kit/utilities` — the `CSS` helper used to apply transforms. -### Estructura de contextos +### Context structure -`BoardView` envuelve todo el tablero en un único `DndContext`. La detección de -colisiones es `boardCollisionDetection` (`app/lib/board-collision.ts`): `closestCorners` -(que funciona mejor que `closestCenter` entre contenedores verticales y horizontales), -pero **al arrastrar una lista solo considera otras listas**. Sin ese filtro, la copia -flotante de la lista (baja, en la parte de arriba de la columna) queda más cerca de la -primera tarjeta de la columna de destino que de la propia columna, `over` es una tarjeta -y el reordenado se descartaba. `handleDragEnd` además traduce una tarjeta a su lista -como defensa. +`BoardView` wraps the whole board in a single `DndContext`. Collision detection is +`boardCollisionDetection` (`app/lib/board-collision.ts`): `closestCorners` (which works +better than `closestCenter` across vertical and horizontal containers), but **while a list +is being dragged it only considers other lists**. Without that filter, the floating copy of +the list (short, at the top of the column) ends up closer to the first card of the target +column than to the column itself, `over` is a card, and the reorder used to be discarded. +`handleDragEnd` also maps a card to its list as a safeguard. ```tsx <DndContext @@ -51,10 +50,10 @@ como defensa. > ``` -Dentro hay dos niveles de `SortableContext`: +Inside there are two levels of `SortableContext`: -- **Columnas (horizontal):** en `BoardView`, un `SortableContext` con - `horizontalListSortingStrategy` envuelve todas las listas. +- **Columns (horizontal):** in `BoardView`, a `SortableContext` with + `horizontalListSortingStrategy` wraps all the lists. ```tsx <SortableContext @@ -67,8 +66,8 @@ Dentro hay dos niveles de `SortableContext`: </SortableContext> ``` -- **Tarjetas (vertical):** dentro de cada `ListColumn`, otro `SortableContext` - con `verticalListSortingStrategy` envuelve las tarjetas de esa lista. +- **Cards (vertical):** inside each `ListColumn`, another `SortableContext` with + `verticalListSortingStrategy` wraps the cards of that list. ```tsx <SortableContext @@ -81,12 +80,12 @@ Dentro hay dos niveles de `SortableContext`: </SortableContext> ``` -Tanto `ListColumn` como `CardItem` usan `useSortable` (con `data: { type: "list" }` -y `data: { type: "card", listId }` respectivamente) y aplican -`CSS.Translate.toString(transform)` para moverse mientras se arrastran, bajando la -opacidad a `0.4` en su sitio original (`isDragging`). +Both `ListColumn` and `CardItem` use `useSortable` (with `data: { type: "list" }` and +`data: { type: "card", listId }` respectively) and apply +`CSS.Translate.toString(transform)` to move while dragged, lowering the opacity to `0.4` +in their original place (`isDragging`). -### Sensores +### Sensors ```tsx const sensors = useSensors( @@ -98,42 +97,40 @@ const sensors = useSensors( ); ``` -- **Puntero**: el `distance: 5` significa que el puntero debe desplazarse al menos 5 px - antes de que comience un arrastre. Así un simple clic (abrir la tarjeta, el formulario - de "añadir") no inicia un arrastre accidental. -- **Teclado**: con el foco en una tarjeta (o en el título de una lista), **Espacio** la - coge, las **flechas** la mueven, **Espacio** o **Intro** la sueltan y **Escape** - cancela. **Intro no coge**: con el foco en una tarjeta, Intro (y Espacio si no se - puede arrastrar, p. ej. como Lector o con filtros) la abre. Las coordenadas salen de - `boardKeyboardCoordinates`: las listas saltan directamente a la columna vecina (las de - sortable se iban primero a la primera tarjeta de la propia columna y la primera flecha - no hacía nada); las tarjetas, con izquierda/derecha, saltan a la tarjeta más cercana en - vertical de la columna vecina, o a la columna si está vacía (con las de sortable la - flecha izquierda las devolvía a su propia columna). Arriba/abajo de una tarjeta siguen - usando las de `@dnd-kit/sortable`. -- **Lectores de pantalla**: las instrucciones y los anuncios de dnd-kit (en inglés por - defecto) se pasan en español por `accessibility`, y `aria-roledescription` es - «tarjeta»/«lista» en vez de «sortable». +- **Pointer**: `distance: 5` means the pointer must move at least 5 px before a drag + starts. A plain click (opening the card, the "add" form) therefore never starts an + accidental drag. +- **Keyboard**: with focus on a card (or on a list title), **Space** picks it up, the + **arrow keys** move it, **Space** or **Enter** drops it and **Escape** cancels. + **Enter does not pick up**: with focus on a card, Enter (and Space when the card cannot + be dragged, for example as a Viewer or while filtering) opens it. Coordinates come from + `boardKeyboardCoordinates`: lists jump straight to the neighbouring column (the + sortable defaults first went to the first card of the list's own column, so the first + arrow press did nothing); cards, with left/right, jump to the vertically closest card + in the neighbouring column, or to the column itself if it is empty (with the sortable + defaults the left arrow sent them back to their own column). Up/down on a card still use + the `@dnd-kit/sortable` defaults. +- **Screen readers**: dnd-kit's instructions and announcements (English-only by default) + are passed in the interface language through `accessibility`, and + `aria-roledescription` names the item as a card or list instead of "sortable". --- -## Estado optimista +## Optimistic state -`BoardView` es un componente cliente que mantiene las listas (con sus tarjetas -anidadas) en `useState`, inicializado a partir de las props que llegan del -servidor: +`BoardView` is a client component that keeps the lists (with their nested cards) in +`useState`, initialized from the props sent by the server: ```tsx const [lists, setLists] = useState<ListDTO[]>(board.lists); ``` -Durante un arrastre, este estado local se actualiza **al instante** para que la -interfaz se sienta fluida, antes (y con independencia) de que la Server Action -confirme el cambio. +During a drag, this local state is updated **immediately** so the interface feels +smooth, before (and independently of) the Server Action confirming the change. -Cuando el servidor envía datos nuevos —por ejemplo al crear o borrar una lista o -tarjeta, o tras un `revalidatePath`— el estado local se resincroniza durante el render -(patrón «ajustar estado cuando cambia una prop», sin `useEffect`): +When the server sends new data (for example after creating or deleting a list or card, +or after a revalidation), the local state is resynchronized during render (the "adjust +state when a prop changes" pattern, without `useEffect`): ```tsx const [syncedBoard, setSyncedBoard] = useState(board); @@ -144,33 +141,33 @@ if (board !== syncedBoard) { } ``` -### Deshacer +### Undo -Al empezar un arrastre se guarda una **instantánea** de las listas (`dragSnapshot`). Se -vuelve a ella si: +When a drag starts, a **snapshot** of the lists is saved (`dragSnapshot`). The board goes +back to it if: -- se **cancela** el arrastre (Escape) o se suelta fuera de cualquier zona: `onDragOver` - puede haber movido la tarjeta a otra lista solo en el estado local; -- el servidor **rechaza** el cambio (`{ ok: false }`) o la llamada **falla**: la Server - Action se ejecuta con `persistOptimistic`, que en ese caso restaura la instantánea, - muestra un aviso con `role="alert"` en la cabecera del tablero («No se pudo mover la - tarjeta/lista. Inténtalo de nuevo.», se borra al empezar el siguiente arrastre) y - hace `router.refresh()` para traer los datos reales. Las redirecciones de Next (sesión - caducada) se relanzan sin tocar y las gestiona el router. +- the drag is **canceled** (Escape) or dropped outside any drop zone: `onDragOver` may + have moved the card to another list in the local state only; +- the server **rejects** the change (`{ ok: false }`) or the call **fails**: the Server + Action runs through `persistOptimistic`, which then restores the snapshot, shows a + `role="alert"` message in the board header ("could not move the card/list, try again"; + it is cleared when the next drag starts) and calls `router.refresh()` to fetch the real + data. Next.js redirects (expired session) are rethrown untouched and handled by the + router. -Si llegan datos del servidor en mitad del arrastre, la instantánea pasa a ser esos -datos, para no volver a un estado anterior a ellos. +If server data arrives in the middle of a drag, the snapshot becomes that data, so the +board never reverts to a state older than it. --- -## Los tres manejadores +## The three handlers -El `DndContext` registra tres manejadores que viven en `BoardView`. +The `DndContext` registers three handlers, all living in `BoardView`. ### `onDragStart` — `handleDragStart` -Guarda el `id` del elemento que se está arrastrando. Ese id sirve después para -saber qué pintar en el `DragOverlay` y para localizar el elemento activo. +Stores the `id` of the item being dragged. That id is later used to decide what to render +in the `DragOverlay` and to locate the active item. ```tsx function handleDragStart(event: DragStartEvent) { @@ -179,31 +176,30 @@ function handleDragStart(event: DragStartEvent) { } ``` -`onDragCancel` (`handleDragCancel`) limpia el `activeId` y restaura la instantánea. +`onDragCancel` (`handleDragCancel`) clears `activeId` and restores the snapshot. ### `onDragOver` — `handleDragOver` -Mueve **visualmente** una tarjeta de una lista a otra mientras el puntero pasa por -encima, actualizando solo el estado local (todavía sin persistir nada). +**Visually** moves a card from one list to another while the pointer passes over it, +updating only the local state (nothing is persisted yet). -- Si lo que se arrastra es una **lista**, se ignora aquí (las listas se reordenan - en `onDragEnd`). -- Para una tarjeta, calcula el contenedor de origen (`from`) y el de destino - (`to`). Si son distintos, quita la tarjeta de la lista origen y la inserta en la - lista destino en la posición sobre la que está el puntero (al final si se - suelta sobre el cuerpo de la lista, o en el índice de la tarjeta sobre la que se - pasa). +- If the dragged item is a **list**, it is ignored here (lists are reordered in + `onDragEnd`). +- For a card, it works out the source container (`from`) and the target container + (`to`). If they differ, it removes the card from the source list and inserts it into + the target list at the position under the pointer (at the end if dropped on the list + body, or at the index of the card being hovered). -Esto produce la sensación de que la tarjeta "salta" en vivo de columna en columna. +This gives the feeling that the card "jumps" live from column to column. ### `onDragEnd` — `handleDragEnd` -Limpia el `activeId`, calcula la posición final y **persiste** el cambio. Hay dos -ramas: +Clears `activeId`, computes the final position and **persists** the change. There are +two branches: -**1. Reordenar listas (columnas).** Si el elemento activo es una lista, se usa -`arrayMove` para reordenar el array local, se calcula la nueva clave de posición -entre los vecinos y se llama a `reorderList`: +**1. Reordering lists (columns).** If the active item is a list, `arrayMove` reorders the +local array, the new position key between the neighbours is computed and `reorderList` +is called: ```tsx const reordered = arrayMove(lists, oldIndex, newIndex); @@ -212,126 +208,121 @@ const position = generateKeyBetween( reordered[movedIdx - 1]?.position ?? null, reordered[movedIdx + 1]?.position ?? null, ); -setLists(/* aplica la nueva position a la lista movida */); +setLists(/* apply the new position to the moved list */); void persistOptimistic( () => reorderList({ listId: activeIdStr, position }), () => revertTo(snapshot), - { message: "[tablero] fallo al reordenar la lista", context: { listId: activeIdStr } }, + { message: "[board] failed to reorder the list", context: { listId: activeIdStr } }, ); ``` -**2. Mover/reordenar una tarjeta.** Una tarjeta puede: +**2. Moving/reordering a card.** A card can: -- **Reordenarse dentro de la misma lista:** si se soltó sobre otra tarjeta del - mismo contenedor, se reordena con `arrayMove`. -- **Cambiar de lista:** el cambio de contenedor ya lo realizó `onDragOver`; aquí - solo se calcula la posición final en el contenedor en el que ha quedado. +- **Be reordered within the same list:** if it was dropped on another card in the same + container, it is reordered with `arrayMove`. +- **Change list:** `onDragOver` already moved it to the new container; here only the + final position in the container where it ended up is computed. -En ambos casos se calcula la clave de posición entre los vecinos de la tarjeta y -se llama a `moveCard`: +In both cases the position key between the card's neighbours is computed and `moveCard` +is called: ```tsx const position = generateKeyBetween( finalList.cards[idx - 1]?.position ?? null, finalList.cards[idx + 1]?.position ?? null, ); -setLists(/* aplica la nueva position a la tarjeta movida */); +setLists(/* apply the new position to the moved card */); void persistOptimistic( () => moveCard({ cardId: activeIdStr, toListId: container, position }), () => revertTo(snapshot), - { message: "[tablero] fallo al mover la tarjeta", context: { cardId: activeIdStr, toListId: container } }, + { message: "[board] failed to move the card", context: { cardId: activeIdStr, toListId: container } }, ); ``` --- -## Fractional indexing en el cliente +## Fractional indexing on the client -El **cliente** es quien calcula la nueva clave de posición, no el servidor. Para -ello usa `generateKeyBetween(before, after)` de la librería -[`fractional-indexing`](https://www.npmjs.com/package/fractional-indexing). +The **client**, not the server, computes the new position key. It uses +`generateKeyBetween(before, after)` from the +[`fractional-indexing`](https://www.npmjs.com/package/fractional-indexing) library. -La idea: cada lista y cada tarjeta tiene una `position` que es una clave string -ordenable lexicográficamente. Para insertar un elemento entre dos vecinos basta con -generar una clave intermedia entre las posiciones de esos vecinos (que vienen en -los propios datos del estado local). Cualquiera de los extremos puede ser `null` -(principio o final de la lista): +The idea: every list and every card has a `position`, a lexicographically sortable +string key. To insert an item between two neighbours, it is enough to generate a key +between their positions (which are already in the local state). Either end can be `null` +(start or end of the list): ```tsx import { generateKeyBetween } from "fractional-indexing"; const position = generateKeyBetween( - vecinoAnterior?.position ?? null, // before - vecinoSiguiente?.position ?? null, // after + previousNeighbour?.position ?? null, // before + nextNeighbour?.position ?? null, // after ); ``` -Como la clave generada queda estrictamente entre las dos vecinas, **nunca hace -falta reescribir las posiciones de los demás elementos**: solo cambia la fila que -se ha movido. - -Para más contexto, consulta: - -- `docs/decisions/0002-fractional-indexing.md` — por qué se eligió este enfoque. -- `docs/database.md` — cómo se modela el campo `position` en la base de datos. - -### El servidor solo ordena "al final" y valida lo que llega - -`app/lib/ordering.ts` (módulo `server-only`) expone dos funciones basadas en la -misma librería: - -- `positionAtEnd(lastPosition)` — clave posterior a la del último elemento. La usan - las altas (`createList`, `createCard`, checklists e ítems, y los `POST` de la API - v1) a través de `createListAtEnd`/`createCardAtEnd`/`createChecklistAtEnd`/ - `createChecklistItemAtEnd` (`app/lib/services/`). Cada alta va en una transacción - que bloquea la fila padre (`SELECT … FOR UPDATE` sobre el tablero, la lista, la - tarjeta o el checklist): sin el bloqueo, dos altas simultáneas leían la misma - última posición y guardaban la **misma** clave, y a partir de ahí - `generateKeyBetween(a, a)` rompía el arrastre en esa lista. -- `isValidPositionKey(key)` — valida la clave que calcula el cliente antes de - guardarla. El reordenamiento por arrastre lo calcula siempre el cliente y lo envía - ya resuelto; si el servidor guardara una clave mal formada (`'x'`, `'a00'`…), - `positionAtEnd` lanzaría para siempre al crear en esa lista o tablero. - -#### Validación de la clave (`isValidPositionKey`) - -Este es el único sitio que explica la regla; las demás páginas enlazan aquí. - -1. **Forma**: alfabeto base62 de la librería, empieza por letra y tiene como - máximo `POSITION_KEY_MAX` caracteres. La librería por sí sola acepta claves como - `'a0 '` (con espacio), por eso hace falta la regex. -2. **Estructura**: se intenta `generateKeyBetween(key, null)`; si la librería lanza - (parte entera mal formada, fracción acabada en `0`…), la clave no es válida. - -`moveCard` y `reorderList` validan con zod (`z.string().refine(isValidPositionKey)`) -**antes de cualquier consulta**. Una clave inválida devuelve `{ ok: false }` y no se -guarda nada. - -**Límite de longitud.** Las claves crecen al insertar una y otra vez en el mismo -hueco (aproximadamente un carácter cada seis inserciones). `POSITION_KEY_MAX` es -1024 caracteres, unas 6000 inserciones seguidas en el mismo hueco (con el tope -anterior de 64 bastaban 373); evita guardar cadenas enormes y queda por debajo del -máximo de una entrada del índice `(listId, position)`. Si una clave legítima lo -superase, `moveCard`/`reorderList` -devolverían `{ ok: false }` para ese hueco. Hoy `BoardView` no mira ese resultado -(`void moveCard(...)`): el movimiento optimista se queda en pantalla y se deshace -al refrescar, sin aviso. +Because the generated key sits strictly between its two neighbours, **the positions of +the other items never need to be rewritten**: only the moved row changes. + +For more context, see: + +- `docs/decisions/0002-fractional-indexing.md` — why this approach was chosen. +- `docs/database.md` — how the `position` field is modeled in the database. + +### The server only appends and validates what it receives + +`app/lib/ordering.ts` (a `server-only` module) exposes two functions built on the same +library: + +- `positionAtEnd(lastPosition)` — a key after the last item's key. It is used by + creation (`createList`, `createCard`, checklists and items, and the v1 API `POST`s) + through `createListAtEnd`/`createCardAtEnd`/`createChecklistAtEnd`/ + `createChecklistItemAtEnd` (`app/lib/services/`). Each creation runs in a transaction + that locks the parent row (`SELECT … FOR UPDATE` on the board, list, card or + checklist): without the lock, two simultaneous creations read the same last position + and stored the **same** key, and from then on `generateKeyBetween(a, a)` broke dragging + in that list. +- `isValidPositionKey(key)` — validates the key computed by the client before storing + it. Drag reordering is always computed by the client and sent already resolved; if the + server stored a malformed key (`'x'`, `'a00'`…), `positionAtEnd` would throw forever + when creating in that list or board. + +#### Key validation (`isValidPositionKey`) + +This is the only place that explains the rule; other pages link here. + +1. **Shape**: the library's base62 alphabet, starting with a letter, at most + `POSITION_KEY_MAX` characters long. The library on its own accepts keys such as + `'a0 '` (with a space), which is why the regex is needed. +2. **Structure**: `generateKeyBetween(key, null)` is attempted; if the library throws + (malformed integer part, fraction ending in `0`…), the key is invalid. + +`moveCard` and `reorderList` validate with zod (`z.string().refine(isValidPositionKey)`) +**before any query**. An invalid key returns `{ ok: false }` and nothing is stored. + +**Length limit.** Keys grow when inserting again and again into the same gap (roughly one +character every six insertions). `POSITION_KEY_MAX` is 1024 characters, about 6,000 +consecutive insertions into the same gap (the previous cap of 64 was reached after 373); +it avoids storing huge strings and stays below the maximum size of an entry in the +`(listId, position)` index. If a legitimate key ever exceeded it, `moveCard`/`reorderList` +would return `{ ok: false }` for that gap, and `persistOptimistic` would undo the move and +show the error message described in [Undo](#undo). --- -## Persistencia: una sola fila por movimiento +## Persistence: one row per move -Las dos Server Actions de arrastre son deliberadamente mínimas. Ambas: +Both drag Server Actions are deliberately minimal. Both: -1. Validan la entrada con zod, incluida la clave de posición (ver - [Validación de la clave](#validación-de-la-clave-isvalidpositionkey)). -2. Resuelven el `boardId` afectado. -3. Validan el acceso con `requireBoardAccess(boardId)` y el rol con `canEditBoard`. -4. Hacen **un único `UPDATE`** de la fila movida. -5. Llaman a `revalidateBoard(boardId)` (revalida la ruta y avisa por el bus en vivo). +1. Validate the input with zod, including the position key (see + [Key validation](#key-validation-isvalidpositionkey)). +2. Resolve the affected `boardId`. +3. Check access with `requireBoardAccess(boardId)` and the role with `canEditBoard`. +4. Run **a single `UPDATE`** on the moved row. +5. Call `revalidateBoard(boardId)` (revalidates the route and notifies the live bus). -**`moveCard`** (`app/lib/actions/cards.ts`) — mover/reordenar una tarjeta; puede -cambiar de lista y/o de posición: +**`moveCard`** (`app/lib/actions/cards.ts`) — moves/reorders a card; it can change list +and/or position: ```ts export async function moveCard(input: { @@ -346,7 +337,7 @@ export async function moveCard(input: { if (!boardId) return { ok: false }; const { role } = await requireBoardAccess(boardId); if (!canEditBoard(role)) return { ok: false }; - // La tarjeta debe ser de ese mismo tablero (IDOR). + // The card must belong to that same board (IDOR). if (!(await cardBelongsToBoard(cardId, boardId))) return { ok: false }; await prisma.card.update({ @@ -358,7 +349,7 @@ export async function moveCard(input: { } ``` -**`reorderList`** (`app/lib/actions/lists.ts`) — reordenar una columna: +**`reorderList`** (`app/lib/actions/lists.ts`) — reorders a column: ```ts export async function reorderList(input: { @@ -382,16 +373,16 @@ export async function reorderList(input: { } ``` -Gracias al cálculo fraccional, **nunca se reescriben las filas vecinas**: cada -arrastre es exactamente un `UPDATE`. +Thanks to fractional keys, **neighbouring rows are never rewritten**: each drag is +exactly one `UPDATE`. --- ## DragOverlay -Mientras se arrastra, `@dnd-kit` puede renderizar un elemento "flotante" que sigue -al puntero a través del `DragOverlay`. `BoardView` decide qué pintar según si el -elemento activo es una tarjeta o una lista: +While dragging, `@dnd-kit` can render a "floating" element that follows the pointer +through the `DragOverlay`. `BoardView` decides what to render depending on whether the +active item is a card or a list: ```tsx <DragOverlay> @@ -405,41 +396,40 @@ elemento activo es una tarjeta o una lista: </DragOverlay> ``` -`CardItem` acepta una prop `overlay` que ajusta su estilo (sombra más marcada y -sin opacidad reducida) y oculta el botón de borrar, para que la copia flotante se -vea correctamente. +`CardItem` accepts an `overlay` prop that adjusts its style (stronger shadow, no reduced +opacity) and hides the delete button, so the floating copy looks right. --- -## Diagrama del flujo +## Flow diagram ``` - Usuario arrastra una lista o tarjeta + User drags a list or a card │ ▼ - onDragStart → guarda activeId → DragOverlay pinta el elemento flotante + onDragStart → stores activeId → DragOverlay renders the floating item │ ▼ - onDragOver → (solo tarjetas) mueve la tarjeta entre listas - │ en el estado LOCAL (optimista, sin red) + onDragOver → (cards only) moves the card between lists + │ in LOCAL state (optimistic, no network) ▼ - onDragEnd → calcula la nueva position con generateKeyBetween(before, after) - │ y actualiza el estado LOCAL al instante + onDragEnd → computes the new position with generateKeyBetween(before, after) + │ and updates LOCAL state immediately ▼ Server Action (moveCard / reorderList) - │ requireBoardAccess + UN ÚNICO UPDATE de la fila movida + │ requireBoardAccess + ONE SINGLE UPDATE of the moved row ▼ - revalidatePath('/board/<id>') + revalidateBoard(boardId) (revalidates /board/<id> and publishes the event) │ ▼ - Llegan datos nuevos → resincronización en el render → setLists(board.lists) - (si la acción devuelve { ok: false } o falla: se restaura la instantánea, - se avisa en la cabecera y router.refresh()) - (resincroniza el estado optimista con el servidor) + New data arrives → resync during render → setLists(board.lists) + (if the action returns { ok: false } or fails: the snapshot is restored, + an error shows in the header and router.refresh() runs) + (resyncs the optimistic state with the server) ``` --- -En resumen: el cliente mueve la UI de forma optimista y calcula la posición con -*fractional indexing*, y cada arrastre se persiste con un único `UPDATE` validado -en el servidor seguido de una revalidación. +In short: the client moves the UI optimistically and computes the position with +*fractional indexing*, and each drag is persisted with a single server-validated `UPDATE` +followed by a revalidation. diff --git a/docs/filters.md b/docs/filters.md index fd0098b..82ee077 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -1,45 +1,45 @@ -# Filtros de tablero - -El Hito 12 añade **filtros** en la vista de tablero para acotar las tarjetas visibles. Es -solo de presentación: oculta las que no encajan, no cambia datos. - -## Cómo se usa - -- En la cabecera del tablero, el botón **Filtrar** (con un contador de filtros activos) - abre un panel con tres dimensiones: - - **Etiquetas**: muestra solo tarjetas con al menos una de las etiquetas elegidas. - - **Asignados**: solo tarjetas asignadas a al menos una de las personas elegidas. - - **Vencimiento**: `Cualquiera` / `Vencidas` / `Con fecha` / `Sin fecha`. -- Dentro de una dimensión la lógica es **OR** (cualquiera vale); entre dimensiones es - **AND** (deben cumplirse todas). «Limpiar filtros» lo resetea. -- Mientras hay un filtro activo, **el arrastre se desactiva** (las posiciones se calcularían - sobre un subconjunto visible); al limpiar, vuelve a estar disponible. -- **Vencidas** son las tarjetas cuyo día de vencimiento es anterior a hoy (día local del - navegador): la que vence hoy no cuenta. La misma regla (`isOverdue`) pinta en rojo la - fecha de la tarjeta, así que el filtro y la tarjeta nunca discrepan. -- Cada opción es un botón con `aria-pressed` (el lector anuncia si está activa) dentro de - un grupo etiquetado por su título. Las etiquetas sin seleccionar llevan texto de tinta - con el borde de su color; las seleccionadas, relleno del color con ✓. - -## Diseño - -- La lógica vive en un módulo **puro y testeable**, `app/lib/board-filters.ts` - (`cardMatchesFilters`, `filtersActive`, `isOverdue`, `formatDueDay`), compartido y - cubierto por `test/board-filters.test.ts`. `formatDueDay` formatea el vencimiento en - UTC (es una fecha de calendario guardada a medianoche UTC), así que muestra el mismo - día en cualquier zona horaria. -- El estado de los filtros es local de cliente en `BoardView` (no va en la URL). La barra - es `app/components/board/filter-bar.tsx`. `BoardView` filtra las tarjetas que pasa a cada - `ListColumn` y propaga `sortingDisabled` a `ListColumn`/`CardItem`. - -## Búsqueda vs. filtros - -Los filtros acotan **un tablero** que ya tienes abierto. La **búsqueda global** -([docs/search.md](./search.md)) encuentra tableros y tarjetas en toda la instancia. - -## Evolución (backlog) - -- Filtros rápidos guardados por tablero. -- Filtro por estado de checklist. -- Búsqueda **full-text** de PostgreSQL (`tsvector` + GIN) detrás de `searchForUser` (hoy - `ILIKE`, adecuado a la escala self-hosted — ver [ADR 0011](./decisions/0011-search-ilike-scoped.md)). +# Board filters + +The board view has **filters** to narrow down the visible cards. They only affect +presentation: they hide the cards that do not match and change no data. + +## How to use them + +- In the board header, the **Filter** button (with a count of active filters) opens a + panel with three dimensions: + - **Labels**: only cards with at least one of the chosen labels. + - **Assignees**: only cards assigned to at least one of the chosen people. + - **Due date**: `Any` / `Overdue` / `With date` / `No date`. +- Within a dimension the logic is **OR** (any match counts); across dimensions it is + **AND** (all must match). "Clear filters" resets them. +- While a filter is active, **dragging is disabled** (positions would be computed over a + visible subset); after clearing, it is available again. +- **Overdue** cards are those whose due day is before today (the browser's local day): a + card due today does not count. The same rule (`isOverdue`) paints the card's date red, + so the filter and the card never disagree. +- Each option is a button with `aria-pressed` (screen readers announce whether it is + active) inside a group labeled by its title. Unselected labels use ink text with a + border in their color; selected ones are filled with their color and show a ✓. + +## Design + +- The logic lives in a **pure, testable** module, `app/lib/board-filters.ts` + (`cardMatchesFilters`, `filtersActive`, `isOverdue`, `formatDueDay`), shared and covered + by `test/board-filters.test.ts`. `formatDueDay` formats the due date in UTC (it is a + calendar date stored at midnight UTC), so it shows the same day in every time zone. +- Filter state is local client state in `BoardView` (not part of the URL). The bar is + `app/components/board/filter-bar.tsx`. `BoardView` filters the cards it passes to each + `ListColumn` and propagates `sortingDisabled` to `ListColumn`/`CardItem`. + +## Search vs. filters + +Filters narrow down **one board** you already have open. **Global search** +([docs/search.md](./search.md)) finds boards and cards across the whole instance. + +## Possible improvements (backlog) + +- Saved quick filters per board. +- Filter by checklist status. +- PostgreSQL **full-text** search (`tsvector` + GIN) behind `searchForUser` (today + `ILIKE`, which suits self-hosted scale — see + [ADR 0011](./decisions/0011-search-ilike-scoped.md)). diff --git a/docs/index.md b/docs/index.md index 4528720..4d6bede 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,69 +1,68 @@ -# Documentación de orgtable +# orgtable documentation -orgtable es una aplicación de tableros Kanban self-hosted (una -alternativa a Trello), construida con Next.js 16, Prisma 7 y PostgreSQL. +orgtable is an open-source (AGPL-3.0-or-later), self-hosted Kanban board app, a Trello +alternative, built with Next.js 16, Prisma 7 and PostgreSQL. -Esta documentación está orientada tanto a quien quiere **auto-hospedar** la app como a -quien quiere **contribuir** al código. +This documentation is for both people who want to **self-host** the app and people who +want to **contribute** to the code. -## Para auto-hospedar +## Self-hosting -- [**Instalación**](./installation.md) — el instalador de un comando y la instalación manual. -- [**Asistente de configuración inicial**](./setup-wizard.md) — qué ocurre en el primer arranque. -- [**Self-hosting**](./self-hosting.md) — operar en producción: backups, actualizaciones, reverse proxy/TLS. +- [**Installation**](./installation.md) — the one-command installer and the manual installation. +- [**Setup wizard**](./setup-wizard.md) — what happens on first start. +- [**Self-hosting**](./self-hosting.md) — running in production: backups, updates, reverse proxy and TLS. +- [**Privacy, open sign-up and GDPR**](./privacy.md) — your instance's legal details, retention periods, user rights and the checklist for opening a public instance. -## Para contribuir +## Contributing -- [**Arquitectura**](./architecture.md) — visión general del sistema y la estructura del código. -- [**Base de datos**](./database.md) — el esquema, Prisma 7 y la estrategia de ordenación. -- [**Autenticación**](./authentication.md) — sesiones JWT, el DAL y la protección de rutas. -- [**Autorización y roles**](./authorization.md) — roles de tablero, permisos y dónde se aplican. -- [**Administración de usuarios**](./admin.md) — altas, roles y contraseñas desde `/admin/users`. -- [**Operación y seguridad**](./operations.md) — rate limiting, auditoría, backups y actualizaciones. -- [**Seguridad (postura y auditoría)**](./security.md) — modelo de amenazas, hallazgos y controles. -- [**Arrastrar y soltar**](./drag-and-drop.md) — `@dnd-kit` y fractional indexing. -- [**Tiempo real**](./realtime.md) — sincronización en vivo con SSE y el bus pub/sub. -- [**Notificaciones**](./notifications.md) — avisos in-app por usuario sobre el mismo bus. -- [**Detalle de tarjeta**](./card-detail.md) — modal, etiquetas, fechas y checklists. -- [**Colaboración en la tarjeta**](./card-collaboration.md) — asignados y comentarios. -- [**Búsqueda**](./search.md) — búsqueda global de tableros y tarjetas, acotada por acceso. -- [**Filtros de tablero**](./filters.md) — acotar las tarjetas visibles por etiqueta, asignado y vencimiento. -- [**Vista de calendario**](./calendar.md) — las tarjetas con vencimiento en una rejilla mensual. -- [**Recordatorios de vencimiento**](./reminders.md) — el cron de avisos y el arrastre para reprogramar. -- [**Adjuntos**](./attachments.md) — archivos en tarjetas, almacenamiento y descarga autorizada. -- [**Sistema de diseño**](./design-system.md) — tokens OKLCH, tema claro/oscuro, componentes. -- [**Docker**](./docker.md) — la imagen multi-stage y la orquestación. -- [**API de Server Actions**](./api/server-actions.md) — catálogo de mutaciones del servidor. -- [**API pública (REST v1)**](./api/public-api.md) — automatizar tableros con tokens de acceso. -- [**Contribuir**](./contributing.md) — entorno de desarrollo y convenciones. -- [**Tests e integración continua**](./testing.md) — Vitest y el workflow de GitHub Actions. +- [**Architecture**](./architecture.md) — system overview and code structure. +- [**Database**](./database.md) — the schema, Prisma 7 and the ordering strategy. +- [**Authentication**](./authentication.md) — JWT sessions, the DAL and route protection. +- [**Authorization and roles**](./authorization.md) — board roles, permissions and where they are enforced. +- [**Administration**](./admin.md) — users, instance settings (sign-up and legal details) and the audit log. +- [**Operations**](./operations.md) — rate limiting, audit log, backups, updates and scheduled tasks. +- [**Security (posture and audit)**](./security.md) — threat model, findings and controls. +- [**Drag and drop**](./drag-and-drop.md) — `@dnd-kit` and fractional indexing. +- [**Real time**](./realtime.md) — live sync with SSE and the pub/sub bus. +- [**Notifications**](./notifications.md) — per-user in-app notifications on the same bus. +- [**Card details**](./card-detail.md) — the card modal, labels, due dates and checklists. +- [**Card collaboration**](./card-collaboration.md) — assignees and comments. +- [**Search**](./search.md) — global search across boards and cards, scoped by access. +- [**Board filters**](./filters.md) — narrow the visible cards by label, assignee and due date. +- [**Calendar view**](./calendar.md) — cards with a due date on a monthly grid. +- [**Due-date reminders**](./reminders.md) — the reminder cron job and drag to reschedule. +- [**Attachments**](./attachments.md) — files on cards, storage and authorized download. +- [**Design system**](./design-system.md) — OKLCH tokens, light and dark themes, components. +- [**Translations**](../locales/README.md) — how the interface languages work and how to add one. +- [**Docker**](./docker.md) — the multi-stage image and the orchestration. +- [**Server Actions API**](./api/server-actions.md) — catalog of server mutations. +- [**Public API (REST v1)**](./api/public-api.md) — automate boards with access tokens. +- [**Contributing**](./contributing.md) — development environment and conventions. +- [**Tests and continuous integration**](./testing.md) — Vitest and the GitHub Actions workflow. -## Decisiones de arquitectura (ADRs) +## Decisions -Las decisiones técnicas importantes y su porqué están registradas en -[`decisions/`](./decisions/): +Technical decisions and their rationale are recorded in the +[decision log](./DECISIONES.md), newest first. The earlier architecture decision records +(ADRs) are in [`decisions/`](./decisions/): -- [0001 — Prisma como ORM](./decisions/0001-prisma.md) -- [0002 — Fractional indexing para el orden](./decisions/0002-fractional-indexing.md) -- [0003 — Credenciales de BD en `.env`](./decisions/0003-db-creds-in-env.md) -- [0004 — Sin Redis en el MVP](./decisions/0004-no-redis-mvp.md) -- [0005 — Licencia inicial (sustituida por la 0016)](./decisions/0005-agpl.md) -- [0006 — Autorización por rol con guardas explícitas](./decisions/0006-rbac-explicit-guards.md) -- [0007 — PostgreSQL LISTEN/NOTIFY en lugar de Redis](./decisions/0007-pg-notify-over-redis.md) -- [0008 — Almacén de sesiones en servidor](./decisions/0008-session-store.md) -- [0009 — Rate limiting en memoria](./decisions/0009-in-memory-rate-limit.md) -- [0010 — Adjuntos en disco local](./decisions/0010-local-file-storage.md) -- [0011 — Búsqueda con ILIKE acotada en el servidor](./decisions/0011-search-ilike-scoped.md) -- [0012 — Notificaciones in-app sobre el bus pub/sub](./decisions/0012-notifications.md) -- [0013 — API pública con tokens personales hasheados](./decisions/0013-api-tokens.md) -- [0014 — Modelo de colaboración en la tarjeta (asignados y comentarios)](./decisions/0014-card-collaboration.md) -- [0015 — Recordatorios por endpoint cron externo](./decisions/0015-reminders-cron.md) -- [0016 — Licencia propietaria](./decisions/0016-licencia-propietaria.md) +- [0001 — Prisma as the ORM](./decisions/0001-prisma.md) +- [0002 — Fractional indexing for ordering](./decisions/0002-fractional-indexing.md) +- [0003 — Database credentials in `.env`](./decisions/0003-db-creds-in-env.md) +- [0004 — No Redis in the MVP](./decisions/0004-no-redis-mvp.md) +- [0005 — Initial AGPL-3.0 license (historical)](./decisions/0005-agpl.md) +- [0006 — Role-based authorization with explicit guards](./decisions/0006-rbac-explicit-guards.md) +- [0007 — PostgreSQL LISTEN/NOTIFY instead of Redis](./decisions/0007-pg-notify-over-redis.md) +- [0008 — Server-side session store](./decisions/0008-session-store.md) +- [0009 — In-memory rate limiting](./decisions/0009-in-memory-rate-limit.md) +- [0010 — Attachments on local disk](./decisions/0010-local-file-storage.md) +- [0011 — Search with ILIKE, scoped on the server](./decisions/0011-search-ilike-scoped.md) +- [0012 — In-app notifications over the pub/sub bus](./decisions/0012-notifications.md) +- [0013 — Public API with hashed personal tokens](./decisions/0013-api-tokens.md) +- [0014 — Card collaboration model (assignees and comments)](./decisions/0014-card-collaboration.md) +- [0015 — Reminders through an external cron endpoint](./decisions/0015-reminders-cron.md) +- [0016 — Proprietary license (historical; the current license is AGPL-3.0-or-later, see the decision log)](./decisions/0016-licencia-propietaria.md) -## Hoja de ruta +## Roadmap -El alcance de cada versión está en la [hoja de ruta](./roadmap.md). - -> Algunos documentos (arquitectura, autenticación, arrastrar y soltar, API) se irán -> completando conforme se construye la interfaz; esta documentación crece en paralelo -> al código. +What has shipped and what is planned is in the [roadmap](./roadmap.md). diff --git a/docs/installation.md b/docs/installation.md index a9464d9..8f295ad 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,93 +1,93 @@ -# Instalación +# Installation -Esta guía explica cómo auto-hospedar **orgtable** (un clon de Trello self-hosted basado en Next.js 16, Prisma 7 y PostgreSQL) en tu propio servidor mediante Docker. +This guide explains how to self-host **orgtable** (a self-hosted Kanban app built with Next.js 16, Prisma 7 and PostgreSQL) on your own server with Docker. -orgtable sigue un modelo de credenciales al estilo Coolify/Dokploy: **nunca tecleas credenciales de base de datos**. El instalador las genera automáticamente y la aplicación arranca siempre con la base de datos ya conectada. Lo único que configuras tú es a través del asistente web inicial: tu usuario administrador y el nombre de la instancia. +orgtable follows a Coolify/Dokploy-style credentials model: **you never type database credentials**. The installer generates them, and the app always starts with the database already connected. The only things you configure are entered in the initial web wizard: your admin account and the instance name. -## Requisitos +## Requirements -- Un servidor con **Linux**. -- **Docker** y **Docker Compose v2** (`docker compose`). Si Docker no está instalado, el instalador lo instala por ti usando [get.docker.com](https://get.docker.com). -- Un **puerto disponible** en el host para la aplicación (por defecto, el `3000`). -- Acceso a internet en el servidor (para descargar Docker, clonar el repositorio y construir la imagen). +- A **Linux** server. +- **Docker** and **Docker Compose v2** (`docker compose`). If Docker is not installed, the installer installs it for you with [get.docker.com](https://get.docker.com). +- A **free port** on the host for the app (`3000` by default). +- Internet access on the server (to download Docker, clone the repository and build the image). -La imagen de la aplicación se **construye localmente en tu servidor** con `docker compose build`; no se descarga de ningún registro de contenedores. +The app image is **built locally on your server** with `docker compose build`; it is not pulled from any container registry. -## Instalación rápida +## Quick install -Ejecuta este único comando en tu servidor (sustituye `<owner>` por el propietario del repositorio): +Run this single command on your server: ```bash -curl -sSL https://raw.githubusercontent.com/<owner>/orgtable/main/install.sh | bash +curl -sSL https://raw.githubusercontent.com/Aimarmndz/orgtable/main/install.sh | bash ``` -El instalador detecta e instala Docker si falta, clona el repositorio, genera un `.env` con secretos aleatorios y levanta los servicios. La **primera build tarda unos minutos** porque la imagen se compila en el propio servidor. +The installer detects Docker and installs it if missing, clones the repository, generates a `.env` with random secrets and starts the services. The **first build takes a few minutes** because the image is compiled on the server itself. -Cuando termine, abre en tu navegador: +When it finishes, open this in your browser: ``` -http://<IP-del-servidor>:<APP_PORT> +http://<server-ip>:<APP_PORT> ``` -Por defecto, `APP_PORT` es `3000`. Aparecerá el **asistente de configuración inicial**, donde creas tu usuario administrador y pones nombre a la instancia. Consulta [docs/setup-wizard.md](./setup-wizard.md) para ese paso. +`APP_PORT` defaults to `3000`. You will see the **setup wizard**, where you create your admin account and name the instance. See [docs/setup-wizard.md](./setup-wizard.md) for that step. -> **HTTPS.** Por `http://` la aplicación funciona, pero la contraseña y la sesión viajan sin cifrar. Para un uso real, pon delante un proxy inverso con TLS (Caddy o nginx) antes de dar acceso a nadie más: pasos en [self-hosting.md › HTTPS y dominio](./self-hosting.md#https-y-dominio-reverse-proxy). Completa el asistente en cuanto arranque la instalación: mientras esté abierto, quien llegue antes a la app crea el administrador. +> **HTTPS.** The app works over `http://`, but the password and the session travel unencrypted. For real use, put a reverse proxy with TLS (Caddy or nginx) in front of it before giving anyone else access: see [self-hosting.md › HTTPS and domain](./self-hosting.md#https-and-domain-reverse-proxy). Complete the wizard as soon as the installation starts: while it is open, whoever reaches the app first creates the admin account. -## Qué hace el instalador +## What the installer does -El script `install.sh` realiza, paso a paso, lo siguiente: +The `install.sh` script works through these steps: -1. **Comprueba Docker.** Si el comando `docker` no existe, lo instala con `curl -fsSL https://get.docker.com | sh`. A continuación verifica que esté disponible **Docker Compose v2** (`docker compose`); si no lo está, aborta con un mensaje de error. +1. **Checks Docker.** If the `docker` command does not exist, it installs it with `curl -fsSL https://get.docker.com | sh`. It then checks that **Docker Compose v2** (`docker compose`) is available and aborts with an error if it is not. -2. **Clona o actualiza el código fuente.** El repositorio se instala en `$HOME/orgtable`: - - Si el directorio ya contiene un repositorio git, lo actualiza con `git pull --ff-only`. - - Si no existe, lo clona con `git clone`. +2. **Clones or updates the source code.** The repository is installed in `$HOME/orgtable`: + - If the directory already contains a git repository, it updates it with `git pull --ff-only`. + - Otherwise, it clones it with `git clone`. - Esto es configurable mediante variables de entorno: - - `ORGTABLE_DIR`: directorio de instalación (por defecto `$HOME/orgtable`). - - `ORGTABLE_REPO`: URL del repositorio (por defecto `https://github.com/aimarmndzz/orgtable.git`). + Both can be changed with environment variables: + - `ORGTABLE_DIR`: installation directory (default `$HOME/orgtable`). + - `ORGTABLE_REPO`: repository URL (default `https://github.com/Aimarmndz/orgtable.git`). -3. **Genera el `.env` con secretos aleatorios.** Solo si **no existe** ya un `.env`. En ese caso: - - Genera `POSTGRES_PASSWORD` con `openssl rand -hex 24`. - - Genera `SESSION_SECRET` con `openssl rand -hex 32`. - - Genera `CRON_SECRET` con `openssl rand -hex 32` (el secreto de los [recordatorios de vencimiento](./reminders.md)). - - Construye automáticamente el `DATABASE_URL` a partir de esas credenciales. - - Escribe el archivo y le aplica permisos `600` (`chmod 600 .env`). +3. **Generates the `.env` with random secrets.** Only if a `.env` **does not exist** yet. In that case it: + - Generates `POSTGRES_PASSWORD` with `openssl rand -hex 24`. + - Generates `SESSION_SECRET` with `openssl rand -hex 32`. + - Generates `CRON_SECRET` with `openssl rand -hex 32` (the secret for [due-date reminders](./reminders.md)). + - Builds `DATABASE_URL` from those credentials. + - Writes the file and sets its permissions to `600` (`chmod 600 .env`). - Si el `.env` ya existe y no tiene `CRON_SECRET`, se lo añade sin tocar el resto. + If the `.env` already exists but has no `CRON_SECRET`, the installer adds it and leaves everything else untouched. -4. **Construye y levanta los servicios.** Ejecuta `docker compose build` (la primera vez tarda unos minutos) y luego `docker compose up -d`. +4. **Builds and starts the services.** It runs `docker compose build` (a few minutes the first time) and then `docker compose up -d`. -5. **Muestra la URL de acceso.** Detecta la IP del servidor y el puerto configurado, imprime la dirección donde completar el asistente de configuración inicial y recuerda que para un uso real hace falta HTTPS. Si `APP_PORT` publica la app solo en local (`127.0.0.1:3000`), en vez de esa URL indica cómo llegar por el proxy inverso o por un túnel SSH. +5. **Shows the access URL.** It detects the server IP and the configured port, prints the address where you complete the setup wizard, and reminds you that real use needs HTTPS. If `APP_PORT` publishes the app on localhost only (`127.0.0.1:3000`), it explains how to reach it through the reverse proxy or an SSH tunnel instead. -### Idempotencia +### Idempotency -Puedes volver a ejecutar el comando de instalación sin problema. Si lo haces: +You can run the install command again safely. When you do: -- El repositorio se **actualiza** con `git pull --ff-only`. -- El `.env` existente **no se sobrescribe**: se conservan los secretos ya generados (`POSTGRES_PASSWORD`, `SESSION_SECRET`, etc.). Solo se añade `CRON_SECRET` si falta. -- La imagen se reconstruye y los servicios se relevantan con los cambios. +- The repository is **updated** with `git pull --ff-only`. +- The existing `.env` is **not overwritten**: the secrets already generated (`POSTGRES_PASSWORD`, `SESSION_SECRET`, etc.) are kept. Only `CRON_SECRET` is added if missing. +- The image is rebuilt and the services are restarted with the changes. -Esto hace que el instalador sirva también para **actualizar** una instalación existente. +This means the installer also works to **update** an existing installation. -## Instalación manual +## Manual installation -Si prefieres no usar el script, puedes instalar orgtable a mano. +If you prefer not to use the script, you can install orgtable by hand. -1. **Clona el repositorio** (sustituye `<owner>` por el propietario real): +1. **Clone the repository:** ```bash - git clone https://github.com/<owner>/orgtable.git + git clone https://github.com/Aimarmndz/orgtable.git cd orgtable ``` -2. **Crea tu archivo `.env`** a partir de la plantilla y rellénalo: +2. **Create your `.env` file** from the template and fill it in: ```bash cp .env.example .env ``` - Edita `.env` y ajusta los valores. Lo imprescindible de la plantilla `.env.example` es: + Edit `.env` and adjust the values. The essential part of the `.env.example` template is: ```env # --- PostgreSQL --- @@ -97,91 +97,95 @@ Si prefieres no usar el script, puedes instalar orgtable a mano. DATABASE_URL=postgresql://orgtable:changeme@postgres:5432/orgtable - # --- Aplicación --- - # Obligatorio, mínimo 16 caracteres. + # --- Application --- + # Required, at least 16 characters. SESSION_SECRET= APP_PORT=3000 ``` - Cambia `changeme` por una contraseña real (también dentro de `DATABASE_URL`) y rellena `SESSION_SECRET` y, para los recordatorios, `CRON_SECRET`. Genera cada secreto con: + Replace `changeme` with a real password (inside `DATABASE_URL` too), and fill in `SESSION_SECRET` and, for reminders, `CRON_SECRET`. Generate each secret with: ```bash openssl rand -hex 32 ``` - El resto de variables son opcionales; están en la [tabla de abajo](#variables-de-entorno). + The other variables are optional; they are listed in the [table below](#environment-variables). - Importante: el `DATABASE_URL` debe coincidir con el usuario, la contraseña y la base de datos de PostgreSQL. En docker-compose el host es `postgres` (el nombre del servicio), no `localhost`. Por seguridad, restringe los permisos del archivo: + Important: `DATABASE_URL` must match the PostgreSQL user, password and database. In docker-compose the host is `postgres` (the service name), not `localhost`. For security, restrict the file permissions: ```bash chmod 600 .env ``` -3. **Construye y levanta los servicios:** +3. **Build and start the services:** ```bash docker compose up -d --build ``` - La primera build tarda unos minutos. + The first build takes a few minutes. -4. Abre `http://<IP-del-servidor>:<APP_PORT>` y completa el [asistente de configuración inicial](./setup-wizard.md). Para un uso real, pon HTTPS delante (ver el aviso de [Instalación rápida](#instalación-rápida)). +4. Open `http://<server-ip>:<APP_PORT>` and complete the [setup wizard](./setup-wizard.md). For real use, put HTTPS in front of it (see the note in [Quick install](#quick-install)). -## Variables de entorno +## Environment variables -Esta tabla es la referencia de todas las variables. Se definen en el `.env` de la raíz del proyecto; el instalador genera las obligatorias y en una instalación manual las rellenas tú. La plantilla comentada es [`.env.example`](../.env.example). +This table is the reference for every variable. They are defined in the `.env` at the project root; the installer generates the required ones, and in a manual installation you fill them in yourself. The commented template is [`.env.example`](../.env.example). -El contenedor de la app solo recibe las variables que lista `docker-compose.yml` en `environment:`. Tras cambiar el `.env`, aplica los cambios con `docker compose up -d` (recrea el contenedor; `restart` no basta). +The app container only receives the variables listed under `environment:` in `docker-compose.yml`. After changing `.env`, apply it with `docker compose up -d` (this recreates the container; `restart` is not enough). -| Variable | Obligatoria | Para qué sirve | +| Variable | Required | Purpose | |---|---|---| -| `POSTGRES_USER` | Sí | Usuario de la base de datos PostgreSQL. Por defecto `orgtable`. | -| `POSTGRES_PASSWORD` | Sí | Contraseña del usuario de PostgreSQL. El instalador la genera con `openssl rand -hex 24`. | -| `POSTGRES_DB` | Sí | Nombre de la base de datos. Por defecto `orgtable`. | -| `DATABASE_URL` | Sí | Cadena de conexión que usa la aplicación. Debe ser coherente con las tres variables anteriores. El host es `postgres` (nombre del servicio en docker-compose). Formato: `postgresql://USUARIO:CONTRASEÑA@postgres:5432/BASE`. | -| `SESSION_SECRET` | Sí | Secreto para firmar las sesiones JWT, mínimo 16 caracteres: sin él (o con uno más corto) la app no puede crear sesiones y el login y el asistente inicial fallan. El instalador lo genera con `openssl rand -hex 32`. | -| `APP_PORT` | No | Puerto del host por el que se expone la aplicación. Por defecto `3000`. Admite `127.0.0.1:3000` para publicarla solo en local, detrás de un proxy inverso. Internamente, el contenedor escucha siempre en el `3000`. | -| `CRON_SECRET` | No | Secreto del endpoint de [recordatorios de vencimiento](./reminders.md). Vacío = endpoint desactivado. El instalador lo genera con `openssl rand -hex 32`. | -| `TRUSTED_PROXY_HOPS` | No | Número de proxies de confianza delante de la app (por defecto `0`: se ignora `X-Forwarded-For`). Con un proxy inverso, `1`. Ver [operations.md](./operations.md#límite-de-intentos-de-login-rate-limiting). | -| `ORGTABLE_TIMEZONE` | No | Zona horaria IANA de las fechas que formatea el servidor. Por defecto `Atlantic/Canary`. | -| `ORGTABLE_UPLOAD_DIR` | No | Directorio de los adjuntos. En Docker, `/data/uploads` (volumen `uploads`). Ver [attachments.md](./attachments.md). | -| `ORGTABLE_REALTIME_PG_NOTIFY` | No | `1` para propagar el tiempo real entre varias réplicas de la app con PostgreSQL `LISTEN/NOTIFY`. Por defecto `0`. Ver [realtime.md](./realtime.md). | - -## Comandos útiles de operación - -Ejecuta estos comandos desde el directorio de instalación (`$HOME/orgtable` por defecto). - -Ver los logs de la aplicación en tiempo real: +| `POSTGRES_USER` | Yes | PostgreSQL database user. Default `orgtable`. | +| `POSTGRES_PASSWORD` | Yes | Password of the PostgreSQL user. The installer generates it with `openssl rand -hex 24`. | +| `POSTGRES_DB` | Yes | Database name. Default `orgtable`. | +| `DATABASE_URL` | Yes | Connection string used by the app. It must match the three variables above. The host is `postgres` (the docker-compose service name). Format: `postgresql://USER:PASSWORD@postgres:5432/DATABASE`. | +| `SESSION_SECRET` | Yes | Secret used to sign the JWT sessions, at least 16 characters. Without it (or with a shorter one) the app cannot create sessions, so sign-in and the setup wizard fail. The installer generates it with `openssl rand -hex 32`. | +| `APP_PORT` | No | Host port the app is exposed on. Default `3000`. Accepts `127.0.0.1:3000` to publish it on localhost only, behind a reverse proxy. Inside the container the app always listens on `3000`. | +| `CRON_SECRET` | No | Secret for the cron endpoints: [reminders](./reminders.md) and data cleanup ([operations.md](./operations.md#scheduled-tasks)). Empty = endpoints disabled. The installer generates it with `openssl rand -hex 32`. | +| `TRUSTED_PROXY_HOPS` | No | Number of trusted proxies in front of the app (default `0`: `X-Forwarded-For` is ignored). Behind one reverse proxy, `1`. See [operations.md](./operations.md#login-rate-limiting). | +| `ORGTABLE_TIMEZONE` | No | IANA time zone for dates formatted on the server. Default `Atlantic/Canary`. | +| `ORGTABLE_UPLOAD_DIR` | No | Attachments directory. In Docker, `/data/uploads` (the `uploads` volume). See [attachments.md](./attachments.md). | +| `ORGTABLE_AUDIT_RETENTION_DAYS` | No | Days the audit log (which includes IP addresses if `TRUSTED_PROXY_HOPS > 0`) is kept before `POST /api/cron/maintenance` deletes it. Integer ≥ 1; default `90`. See [privacy.md](./privacy.md). | +| `ORGTABLE_SOURCE_URL` | No | The app's "Source code" link. The AGPL requires offering the source code to everyone who uses the app over a network: if you deploy a **modified** version, point this to your fork. Defaults to the original repository. Must start with `https://` or `http://`. | +| `ORGTABLE_REALTIME_PG_NOTIFY` | No | `1` to propagate real-time events between several app replicas with PostgreSQL `LISTEN/NOTIFY`. Default `0`. See [realtime.md](./realtime.md). | +| `ORGTABLE_DIR` | No | Installer only (`install.sh`, read from the shell, not from `.env`): installation directory. Default `$HOME/orgtable`. | +| `ORGTABLE_REPO` | No | Installer only (`install.sh`, read from the shell, not from `.env`): repository URL to clone. Default `https://github.com/Aimarmndz/orgtable.git`. | + +## Useful operation commands + +Run these commands from the installation directory (`$HOME/orgtable` by default). + +Follow the app logs: ```bash docker compose logs -f app ``` -Parar los servicios: +Stop the services: ```bash docker compose down ``` -Volver a levantarlos: +Start them again: ```bash docker compose up -d ``` -Actualizar a la última versión (copia de seguridad, actualiza el código, reconstruye y relevanta): +Update to the latest version (backup, update the code, rebuild and restart): ```bash scripts/update.sh ``` -Volver a ejecutar el instalador (`install.sh`) también actualiza sin tocar tu `.env`, pero **no** hace la copia de seguridad previa. Detalles en [operations.md](./operations.md#actualizaciones). +Running the installer (`install.sh`) again also updates without touching your `.env`, but it does **not** take a backup first. Details in [operations.md](./operations.md#updates). -Para operaciones avanzadas como **copias de seguridad** o configurar un **reverse proxy con TLS**, consulta [docs/self-hosting.md](./self-hosting.md). +For advanced operations such as **backups** or setting up a **reverse proxy with TLS**, see [docs/self-hosting.md](./self-hosting.md). -## Seguridad +## Security -- **PostgreSQL no se expone al host.** El servicio de base de datos solo es accesible desde la red interna de Docker (`orgtable`); no publica ningún puerto al exterior. La aplicación se comunica con él a través del nombre de servicio `postgres`. -- **El `.env` tiene permisos `600`.** El instalador aplica `chmod 600 .env`, de modo que solo el usuario propietario puede leer los secretos (contraseña de la base de datos y secreto de sesión). Si lo creas manualmente, aplica tú mismo este permiso. -- Los secretos se generan con `openssl` (`rand -hex 24` para la contraseña de PostgreSQL y `rand -hex 32` para el secreto de sesión), por lo que son únicos para cada instalación. +- **PostgreSQL is not exposed to the host.** The database service is only reachable from the internal Docker network (`orgtable`); it publishes no port. The app reaches it through the `postgres` service name. +- **The `.env` file has `600` permissions.** The installer runs `chmod 600 .env`, so only the owning user can read the secrets (database password and session secret). If you create it by hand, set this permission yourself. +- Secrets are generated with `openssl` (`rand -hex 24` for the PostgreSQL password and `rand -hex 32` for the session secret), so they are unique to each installation. diff --git a/docs/notifications.md b/docs/notifications.md index 02f7d1f..adca777 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -1,60 +1,67 @@ -# Notificaciones - -Las notificaciones in-app (Hito 8) avisan a una persona de eventos que le afectan. Se -ven en la **campana de la barra superior**, con un contador de no leídas. La decisión -técnica está en [ADR 0012](./decisions/0012-notifications.md). - -## Qué se notifica - -Eventos de pertenencia a tableros (definidos en `NotificationType`, -`app/lib/notifications.ts`): - -| Tipo | Cuándo | Enlace | -|------|--------|--------| -| `member.added` | Te añaden a un tablero | Al tablero | -| `member.role_changed` | Cambian tu rol en un tablero | Al tablero | -| `member.removed` | Te quitan de un tablero | Sin enlace (ya no tienes acceso) | - -El texto se calcula en español al crear la notificación (la app es monolingüe), así que -no hay plantillas ni i18n en el render. - -## Cómo funciona - -1. La acción que provoca el evento (p. ej. `addBoardMember` en - `app/lib/actions/members.ts`) llama a **`notify({ userId, type, title, body?, boardId? })`**. -2. `notify()` escribe una fila en `Notification` y **publica un evento en el bus** por el - canal del usuario (`user:<id>`). Es best-effort: si algo falla, la mutación principal - no se rompe. Devuelve `true` si la fila se guardó (aunque falle el aviso en vivo) y - `false` si no; el fallo se registra con `console.error` (tipo, ids y código de error, - nunca el título ni el cuerpo). El cron de recordatorios usa ese resultado para no contar - avisos que no existen. -3. El navegador del destinatario tiene abierto un **EventSource** a - `/api/notifications/stream`. Al recibir el "ping" hace `router.refresh()`. -4. El layout (`app/(app)/layout.tsx`) vuelve a consultar las notificaciones y el - contador, y la **campana** (`app/components/notifications-bell.tsx`) se repinta. - -El "ping" no transporta el contenido: solo avisa de que hay algo nuevo. Cada pestaña -solo lee **sus** notificaciones (el `WHERE` lleva el `userId` de la sesión), así que no -hay forma de que una notificación se filtre a otra persona. - -## Interacción - -- **Abrir una notificación**: la marca como leída y, si tiene tablero, navega a él. -- **Marcar todas**: pone a leídas todas las del usuario. -- Acciones en `app/lib/actions/notifications.ts` (`markNotificationRead`, - `markAllNotificationsRead`); el `WHERE` siempre incluye el `userId` de la sesión. - -## Entrega en vivo y multi-instancia - -Reutiliza el bus de tiempo real del Hito 4 ([docs/realtime.md](./realtime.md)). En el -despliegue estándar (una sola instancia) el canal por usuario es un `EventEmitter` en -proceso. En multi-instancia, el puente opcional de PostgreSQL `LISTEN/NOTIFY` -(`ORGTABLE_REALTIME_PG_NOTIFY=1`) reemite también los eventos de usuario, no solo los de -tablero. - -## Límites y evolución - -- Solo in-app: si no tienes una pestaña abierta, verás las notificaciones la próxima vez - que entres. Email o Web Push encajarían como otro consumidor de `notify()`. -- El catálogo de eventos es pequeño a propósito; añadir uno nuevo es llamar a `notify()` - desde la acción que lo origina. +# Notifications + +In-app notifications tell a person about events that affect them. They appear in the +**bell in the top bar**, with an unread count. The technical decision is in +[ADR 0012](./decisions/0012-notifications.md). + +## What is notified + +Types defined in `NotificationType` (`app/lib/notifications.ts`): + +| Type | When | Link | +|------|------|------| +| `member.added` | You are added to a board | To the board | +| `member.role_changed` | Your role on a board changes | To the board | +| `member.removed` | You are removed from a board | No link (you no longer have access) | +| `card.assigned` | Someone assigns you to a card | To the card | +| `comment.added` | Someone comments on a card you are assigned to | To the card | +| `due.reminder` | A card you are assigned to is due soon or overdue | To the card | + +Card collaboration events are described in +[docs/card-collaboration.md](./card-collaboration.md) and due-date reminders in +[docs/reminders.md](./reminders.md). + +The text is rendered when the notification is created, in the **recipient's language** +(`User.locale`; English if the account has not chosen one), so rendering needs no +templates. Notifications already stored keep the language they were written in. + +## How it works + +1. The action that triggers the event (e.g. `addBoardMember` in + `app/lib/actions/members.ts`) calls **`notify({ userId, type, title, body?, boardId? })`**. +2. `notify()` writes a row in `Notification` and **publishes an event on the bus** on the + user's channel (`user:<id>`). It is best-effort: if something fails, the main mutation + is not broken. It returns `true` if the row was saved (even if the live notice fails) + and `false` otherwise; the failure is logged with `console.error` (type, ids and error + code, never the title or body). The reminders cron uses that result so it does not + count notices that do not exist. +3. The recipient's browser keeps an **EventSource** open to `/api/notifications/stream`. + When it receives the "ping", it calls `router.refresh()`. +4. The layout (`app/(app)/layout.tsx`) queries the notifications and the count again, and + the **bell** (`app/components/notifications-bell.tsx`) re-renders. + +The "ping" carries no content: it only signals that there is something new. Each tab +only reads **its own** notifications (the `WHERE` includes the session's `userId`), so a +notification cannot leak to another person. + +## Interaction + +- **Opening a notification** marks it as read and, if it has a board or card, navigates + to it. +- **Mark all as read** marks every notification of the user as read. +- Actions in `app/lib/actions/notifications.ts` (`markNotificationRead`, + `markAllNotificationsRead`); the `WHERE` always includes the session's `userId`. + +## Live delivery and multiple instances + +It reuses the real-time bus ([docs/realtime.md](./realtime.md)). In the standard +deployment (a single instance), the per-user channel is an in-process `EventEmitter`. +With several instances, the optional PostgreSQL `LISTEN/NOTIFY` bridge +(`ORGTABLE_REALTIME_PG_NOTIFY=1`) also relays user events, not only board events. + +## Limits and possible improvements + +- In-app only: if you have no tab open, you will see the notifications the next time you + sign in. Email or Web Push would fit as another consumer of `notify()`. +- The event catalog is deliberately small; adding a new one means calling `notify()` + from the action that triggers it. diff --git a/docs/operations.md b/docs/operations.md index 18c9b9d..6a9fe23 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,157 +1,152 @@ -# Operación y seguridad +# Operations and security -Esta guía cubre las funciones operativas de orgtable: límite de intentos de login, -registro de auditoría, copias de seguridad, exportar/importar tableros y el flujo de -actualización. Para la puesta en producción general (reverse proxy, TLS), ver -[`docs/self-hosting.md`](./self-hosting.md). +This guide covers the operational features of orgtable: login rate limiting, the audit +log, backups, board export/import and the update flow. For general production setup +(reverse proxy, TLS), see [`docs/self-hosting.md`](./self-hosting.md). -## Límite de intentos de login (rate limiting) +## Login rate limiting -El login está protegido contra fuerza bruta con dos límites: +Login is protected against brute force with two limits: -- **Por IP: hasta 10 intentos cada 10 minutos**, solo si la IP del cliente es fiable - (ver abajo). -- **Por email: 20 intentos fallidos cada 15 minutos**. Solo cuentan los fallos y el - contador se reinicia tras un login correcto. +- **Per IP: up to 10 attempts every 10 minutes**, only when the client IP can be trusted + (see below). +- **Per email: 20 failed attempts every 15 minutes**. Only failures count, and the counter + resets after a successful login. -Superado un límite, el formulario responde con un aviso y un tiempo de espera, y el -intento se registra en la auditoría (`auth.login_rate_limited`). +Once a limit is exceeded, the form shows a message with a waiting time, and the attempt is +recorded in the audit log (`auth.login_rate_limited`). -La implementación ([`app/lib/rate-limit.ts`](../app/lib/rate-limit.ts)) es **en -memoria** (un contador por ventana fija), sin Redis ni dependencias externas. Es la -opción adecuada para el despliegue estándar de una sola instancia. La decisión está en +The implementation ([`app/lib/rate-limit.ts`](../app/lib/rate-limit.ts)) is **in memory** +(a fixed-window counter), with no Redis or external dependencies. It is the right choice +for the standard single-instance deployment. The decision is in [ADR 0009](./decisions/0009-in-memory-rate-limit.md). -**De dónde sale la IP: `TRUSTED_PROXY_HOPS`.** La cabecera `X-Forwarded-For` la puede -escribir cualquiera, así que la app solo se fía de ella si se le dice cuántos proxies -propios hay delante: +**Where the IP comes from: `TRUSTED_PROXY_HOPS`.** Anyone can write the +`X-Forwarded-For` header, so the app only trusts it when told how many of your own proxies +sit in front of it: -| `TRUSTED_PROXY_HOPS` | Comportamiento | +| `TRUSTED_PROXY_HOPS` | Behaviour | | --- | --- | -| `0` (por defecto) | Se ignora `X-Forwarded-For`. No hay límite por IP; se aplica solo el límite por email. | -| `N > 0` | Se toma la IP N-ésima de `X-Forwarded-For` contando por la derecha. Con un único proxy inverso (Caddy o nginx), `1`. Se acepta la IP con puerto (`1.2.3.4:5678`, `[2001:db8::1]:443`) y se descarta el puerto. Si la cabecera no llega, trae menos de N valores o el valor que toca no es una IP, esa petición no tiene límite por IP (solo el de email) y `AuditLog.ip` queda vacío; el log muestra un aviso `[client-ip] TRUSTED_PROXY_HOPS=N pero …` con el motivo y el número de valores (sin IPs), una vez por motivo y proceso. | +| `0` (default) | `X-Forwarded-For` is ignored. There is no per-IP limit; only the per-email limit applies. | +| `N > 0` | The N-th IP of `X-Forwarded-For`, counting from the right, is used. With a single reverse proxy (Caddy or nginx), use `1`. An IP with a port (`1.2.3.4:5678`, `[2001:db8::1]:443`) is accepted and the port is dropped. If the header is missing, has fewer than N values or the selected value is not an IP, that request has no per-IP limit (only the per-email one) and `AuditLog.ip` stays empty; the log prints a `[client-ip] TRUSTED_PROXY_HOPS=N but …` warning with the reason and the number of values (no IPs), once per reason and process. | -Con `N > 0`, el puerto de la app no debe ser accesible sin pasar por el proxy: quien -llegue directamente puede inventarse la cabecera. Cómo configurarlo, en -[self-hosting.md](./self-hosting.md#https-y-dominio-reverse-proxy). +With `N > 0`, the app port must not be reachable without going through the proxy: anyone +reaching it directly can forge the header. How to set it up: +[self-hosting.md](./self-hosting.md#https-and-domain-reverse-proxy). -> **Multi-instancia:** el conteo es por réplica. Con N réplicas el límite efectivo es -> N×10. Para un límite global compartido haría falta un store común (p. ej. -> `LISTEN/NOTIFY` no sirve aquí; sería Postgres con una tabla o Redis). No es el caso -> por defecto y queda como mejora futura. +> **Multiple instances:** counting is per replica. With N replicas the effective limit is +> N×10. A global shared limit would need a common store (`LISTEN/NOTIFY` does not help +> here; it would be a Postgres table or Redis). This is not the default setup and is left +> as a future improvement. -## Registro de auditoría +## Audit log -orgtable guarda una fila por evento relevante de seguridad y operación en la tabla -`AuditLog` ([`app/lib/audit.ts`](../app/lib/audit.ts)). El administrador los consulta -en **`/admin/audit`** (los 200 más recientes). +orgtable stores one row per relevant security or operational event in the `AuditLog` +table ([`app/lib/audit.ts`](../app/lib/audit.ts)). The administrator reviews them at +**`/admin/audit`** (the 200 most recent). -Eventos registrados: +Recorded events: -| Área | Acciones | +| Area | Actions | | --- | --- | -| Autenticación | `auth.login`, `auth.login_failed`, `auth.login_rate_limited`, `auth.logout` | -| Configuración | `setup.completed` | -| Usuarios | `user.created`, `user.deleted`, `user.role_changed`, `user.password_reset`, `user.sessions_revoked` | -| Tableros | `board.created`, `board.deleted` | -| Miembros | `member.added`, `member.removed`, `member.role_changed` | +| Authentication | `auth.login`, `auth.login_failed`, `auth.login_rate_limited`, `auth.logout` | +| Setup | `setup.completed` | +| Users | `user.created`, `user.deleted`, `user.role_changed`, `user.password_reset`, `user.sessions_revoked` | +| Boards | `board.created`, `board.deleted` | +| Members | `member.added`, `member.removed`, `member.role_changed` | -Cada fila guarda quién (usuario, opcional), la IP, el objetivo (`targetType`/ -`targetId`), un detalle y la marca de tiempo. El `userId` es **opcional con -`onDelete: SetNull`**: borrar una cuenta no borra su rastro de auditoría. El registro -es *best-effort*: un fallo al escribir el log nunca hace fallar la operación que lo -origina. +Each row stores who (user, optional), the IP, the target (`targetType`/`targetId`), a +detail and the timestamp. `userId` is **optional with `onDelete: SetNull`**: deleting an +account does not delete its audit trail. Logging is *best-effort*: a failure to write the +log never makes the originating operation fail. -## Copias de seguridad +## Backups -Hay dos cosas que respaldar: la **base de datos** (PostgreSQL) y los **adjuntos** -(volumen de disco). El script [`scripts/backup.sh`](../scripts/backup.sh) hace ambas: +There are two things to back up: the **database** (PostgreSQL) and the **attachments** +(disk volume). The [`scripts/backup.sh`](../scripts/backup.sh) script does both: ```sh -scripts/backup.sh [directorio_destino] # por defecto: ./backups +scripts/backup.sh [destination_dir] # default: ./backups ``` -Genera dos ficheros con marca de tiempo: +It writes two timestamped files: -- `orgtable-db-<fecha>.sql.gz` — volcado de `pg_dump --clean --if-exists` comprimido. -- `orgtable-uploads-<fecha>.tar.gz` — los adjuntos del volumen `/data/uploads`. +- `orgtable-db-<date>.sql.gz`: a compressed `pg_dump --clean --if-exists` dump. +- `orgtable-uploads-<date>.tar.gz`: the attachments from the `/data/uploads` volume. -Cómo se comporta: +How it behaves: -- Se puede lanzar desde cualquier directorio: se sitúa solo en la raíz de la instalación. -- Si algo falla (Postgres parado, volcado vacío o truncado, adjuntos que no se pueden - leer), sale con un código distinto de 0, lo dice por la salida de error y no deja - ficheros a medias. Solo imprime `✓ Backup completado` si las copias están bien. -- Los adjuntos se omiten únicamente si no existe `/data/uploads`. Si la app está parada, - se leen con un contenedor efímero que monta el mismo volumen. -- **Las copias contienen datos personales** (emails, hashes de contraseñas y de tokens, - comentarios). El script crea el directorio `backups/` con permisos `700` y los ficheros - con `600`. Las copias hechas con versiones anteriores del script pueden ser legibles - por otros usuarios del servidor; ciérralas una vez: +- It can be run from any directory: it moves to the installation root by itself. +- If anything fails (Postgres stopped, empty or truncated dump, unreadable attachments), + it exits with a non-zero code, reports it on standard error and leaves no partial + files. It only prints `✓ Backup completed` when the backups are good. +- Attachments are skipped only if `/data/uploads` does not exist. If the app is stopped, + they are read through a short-lived container that mounts the same volume. +- **Backups contain personal data** (emails, password and token hashes, comments). The + script creates the `backups/` directory with `700` permissions and the files with + `600`. Backups made with older versions of the script may be readable by other users on + the server; lock them down once: ```sh chmod 700 backups && chmod 600 backups/*.gz ``` -Automatiza el backup con un `cron` del sistema y guarda copias fuera del servidor. -Usa rutas absolutas, porque cron arranca en `$HOME`. Por ejemplo, cada día a las 3:30 -(`crontab -e`): +Automate backups with a system `cron` and keep copies off the server. Use absolute paths, +because cron starts in `$HOME`. For example, every day at 3:30 (`crontab -e`): ```cron -30 3 * * * /home/usuario/orgtable/scripts/backup.sh >> /home/usuario/orgtable-backup.log 2>&1 +30 3 * * * /home/user/orgtable/scripts/backup.sh >> /home/user/orgtable-backup.log 2>&1 ``` -El log va fuera de `backups/` a propósito: cron abre la redirección antes de ejecutar el -script, y en una instalación nueva ese directorio aún no existe (lo crea el propio -script), así que cron fallaría sin llegar a lanzar la copia. Si el script falla, el -error queda en ese log; revísalo o configura `MAILTO` en el crontab para recibirlo por -correo. +The log lives outside `backups/` on purpose: cron opens the redirection before running the +script, and on a fresh installation that directory does not exist yet (the script creates +it), so cron would fail without ever starting the backup. If the script fails, the error +ends up in that log; check it, or set `MAILTO` in the crontab to receive it by email. -### Restaurar +### Restore -Para **restaurar** la base de datos (⚠️ sustituye todos los datos actuales): +To **restore** the database (⚠️ replaces all current data): ```sh -scripts/restore.sh backups/orgtable-db-<fecha>.sql.gz +scripts/restore.sh backups/orgtable-db-<date>.sql.gz ``` -El script: +The script: -1. Comprueba que el fichero existe, que es un gzip válido y que el volcado está - completo. Si no, sale sin tocar nada. -2. Pide confirmación. -3. Hace una **copia previa automática** en `backups/pre-restore-<fecha>/` con - `scripts/backup.sh`. Si la copia falla, no sigue. -4. Para la app mientras restaura y, si estaba en marcha, la vuelve a arrancar al - terminar. -5. Vacía el esquema y carga el volcado en **una sola transacción** con - `ON_ERROR_STOP`: si algo falla, no se aplica nada y la base de datos queda como - estaba. Funciona también con volcados antiguos hechos sin `--clean`. +1. Checks that the file exists, is a valid gzip and that the dump is complete. If not, it + exits without touching anything. +2. Asks for confirmation. +3. Makes an **automatic safety backup** in `backups/pre-restore-<date>/` with + `scripts/backup.sh`. If that backup fails, it stops. +4. Stops the app while restoring and, if it was running, starts it again at the end. +5. Empties the schema and loads the dump in **a single transaction** with `ON_ERROR_STOP`: + if anything fails, nothing is applied and the database stays as it was. It also works + with older dumps made without `--clean`. -Al arrancar, la app aplica las migraciones que falten si el volcado es de una versión -anterior. +On startup, the app applies any missing migrations if the dump comes from an older +version. -Los adjuntos no se restauran con el script. ⚠️ Restaurarlos **sustituye todos los -adjuntos actuales**. Desde la raíz de la instalación, en este orden: +The script does not restore attachments. ⚠️ Restoring them **replaces all current +attachments**. From the installation root, in this order: -1. **Comprueba el archivo** antes de tocar nada. Si alguno de los dos comandos da error - (copia truncada al traerla de fuera, ruta equivocada), para aquí: +1. **Check the archive** before touching anything. If either command fails (backup + truncated when copied back, wrong path), stop here: ```sh - gzip -t backups/orgtable-uploads-<fecha>.tar.gz - tar tzf backups/orgtable-uploads-<fecha>.tar.gz > /dev/null + gzip -t backups/orgtable-uploads-<date>.tar.gz + tar tzf backups/orgtable-uploads-<date>.tar.gz > /dev/null ``` -2. **Haz una copia de lo actual**, por si hay que volver atrás: +2. **Back up what is there now**, in case you need to roll back: ```sh - scripts/backup.sh backups/pre-restore-uploads-<fecha> + scripts/backup.sh backups/pre-restore-uploads-<date> ``` -3. **Restaura con la app parada.** El archivo se extrae primero a un directorio temporal - dentro del propio volumen y los adjuntos actuales solo se sustituyen si `tar` termina - bien. Tiene que ser dentro del volumen porque `/data/uploads` es su punto de montaje: - no se puede borrar ni renombrar, solo su contenido. +3. **Restore with the app stopped.** The archive is first extracted to a temporary + directory inside the volume itself, and the current attachments are only replaced if + `tar` succeeds. It has to be inside the volume because `/data/uploads` is its mount + point: it cannot be deleted or renamed, only its contents. ```sh docker compose stop app @@ -164,48 +159,65 @@ adjuntos actuales**. Desde la raíz de la instalación, en este orden: find /data/uploads -mindepth 1 -maxdepth 1 ! -name .restore -exec rm -rf {} + find /data/uploads/.restore/uploads -mindepth 1 -maxdepth 1 -exec mv {} /data/uploads/ \; rm -rf /data/uploads/.restore - ' < backups/orgtable-uploads-<fecha>.tar.gz + ' < backups/orgtable-uploads-<date>.tar.gz docker compose start app ``` -Si la restauración falla a mitad, los adjuntos actuales siguen intactos y solo queda el -directorio temporal `/data/uploads/.restore`, que el siguiente intento borra al empezar. -Arranca la app igualmente con `docker compose start app`. +If the restore fails halfway, the current attachments remain intact and only the +temporary directory `/data/uploads/.restore` is left behind; the next attempt deletes it +first. Start the app anyway with `docker compose start app`. -## Exportar e importar tableros +## Board export and import -Independiente de las copias de seguridad de toda la instancia, cada tablero se puede -**exportar a un JSON portable** y volver a **importar** (en la misma instancia o en -otra): +Separately from whole-instance backups, each board can be **exported to a portable JSON +file** and **imported** again (on the same instance or another one): -- **Exportar:** en la galería de tableros, el icono de descarga de cada tablero - (`GET /api/boards/[id]/export`). Incluye listas, tarjetas, descripciones, fechas, - etiquetas y checklists. **No** incluye los binarios de los adjuntos. -- **Importar:** el formulario "Importar tablero" de la galería. Crea un tablero - **nuevo** del que pasas a ser propietario, regenerando identificadores y posiciones. +- **Export:** the download icon of each board in the board gallery + (`GET /api/boards/[id]/export`). It includes lists, cards, descriptions, dates, labels + and checklists. It does **not** include attachment files. +- **Import:** the "Import a board from JSON" section of the gallery. It creates a **new** board owned by + you, with regenerated identifiers and positions. -Implementación: [`app/api/boards/[id]/export/route.ts`](../app/api/boards/[id]/export/route.ts) -y la Server Action `importBoard` en +Implementation: [`app/api/boards/[id]/export/route.ts`](../app/api/boards/[id]/export/route.ts) +and the `importBoard` Server Action in [`app/lib/actions/boards.ts`](../app/lib/actions/boards.ts). -## Actualizaciones +## Scheduled tasks -La imagen de la app se construye localmente, así que actualizar es traer los cambios y -reconstruir. El script [`scripts/update.sh`](../scripts/update.sh) lo hace: +Two endpoints must be called by an **external** cron (ADR 0015), with `CRON_SECRET` from +`.env` as a `Bearer` token. Without `CRON_SECRET` they answer 503; with a wrong secret, +401. + +| Endpoint | Frequency | What it does | +|---|---|---| +| `POST /api/cron/reminders` | Every hour | Reminders for cards that are due soon or overdue ([reminders.md](./reminders.md)). | +| `POST /api/cron/maintenance` | Once a day | Deletes audit entries older than `ORGTABLE_AUDIT_RETENTION_DAYS` (90 by default), sessions closed or expired more than 30 days ago, and notifications older than 180 days. These are the retention periods promised by the privacy policy ([privacy.md](./privacy.md)). | + +```cron +# host crontab; loads CRON_SECRET from the installation's .env +0 * * * * . /opt/orgtable/.env && curl -fsS -X POST -H "Authorization: Bearer $CRON_SECRET" http://127.0.0.1:3000/api/cron/reminders +15 4 * * * . /opt/orgtable/.env && curl -fsS -X POST -H "Authorization: Bearer $CRON_SECRET" http://127.0.0.1:3000/api/cron/maintenance +``` + +Adjust the `.env` path and the port (`APP_PORT`) to your installation. + +## Updates + +The app image is built locally, so updating means pulling the changes and rebuilding. The +[`scripts/update.sh`](../scripts/update.sh) script does that: ```sh scripts/update.sh ``` -El script, en este orden: +The script, in this order: -1. Levanta Postgres si no lo estaba y hace una **copia de seguridad** con - `scripts/backup.sh` (queda en `backups/`). Si la copia falla, no actualiza. -2. Trae los cambios (`git pull --ff-only`). -3. Reconstruye y relanza (`docker compose up -d --build`). +1. Starts Postgres if it was not running and makes a **backup** with `scripts/backup.sh` + (stored in `backups/`). If the backup fails, it does not update. +2. Pulls the changes (`git pull --ff-only`). +3. Rebuilds and restarts (`docker compose up -d --build`). -Las **migraciones de base de datos se aplican solas** al arrancar: el -`docker-entrypoint.sh` ejecuta `prisma migrate deploy` antes de levantar el servidor. -Por eso la copia va antes: si una migración sale mal, se puede volver con -`scripts/restore.sh`. Un `docker compose up -d --build` lanzado a mano **no** hace esa -copia. +**Database migrations are applied automatically** on startup: `docker-entrypoint.sh` runs +`prisma migrate deploy` before starting the server. That is why the backup comes first: if +a migration goes wrong, you can go back with `scripts/restore.sh`. A +`docker compose up -d --build` run by hand does **not** make that backup. diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..e3f36b2 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,132 @@ +# Privacy, open sign-up and GDPR + +How orgtable handles personal data and what the operator of an instance has to do, +whether it is the official instance or a self-hosted one. The legal pages users see +(`/legal/*`) are generated from this behavior and from the details the admin enters in +**Admin › Instance**. This document is their technical reference. + +> The app's legal texts describe what the code does, but they are not legal advice. If +> you open sign-up to the public, have a professional review them with your real details. + +## Who the data controller is + +The data controller is **whoever operates the instance**, not the author of the software. +Each instance states its own in `/admin/instance`: + +| Field | Required | Where it appears | +|---|---|---| +| Controller (name or company name) | To open sign-up | Privacy, terms, legal notice and the sign-up form | +| Contact email | To open sign-up | Privacy, terms, legal notice | +| Tax ID (NIF) | No | Legal notice (art. 10 of the Spanish LSSI, if it applies) | +| Postal address | No | Legal notice | +| Where the data is hosted | No, but recommended | Privacy: the provider acts as data processor | +| Backup retention in days | No (0 = you make no backups) | Privacy. It must match your actual rotation (`scripts/backup.sh`) | + +Anything left empty appears as "not provided by the operator": the pages never make up +data. Every time a legal detail changes, `legalUpdatedAt` becomes the "last updated" date +of the pages. + +## Open sign-up + +It is **closed by default**: on a private instance the admin creates the accounts. It is +opened in `/admin/instance`, and only when the controller and the contact email are set. +The server checks this again on every sign-up (`isRegistrationOpen()`): if someone empties +those fields, sign-up closes automatically. + +At `/register`: + +- **Informed consent.** A required checkbox, unchecked by default, with links to the + privacy policy and the terms. The date is stored in `User.termsAcceptedAt`. Next to the + button there is a short first-layer notice: controller, purpose and rights. +- **New accounts are MEMBER.** Nobody can sign up as an admin. +- **Anti-abuse, without third parties**: + - An invisible trap field (honeypot). + - 5 sign-ups per hour per IP, only with `TRUSTED_PROXY_HOPS > 0`. + - 30 sign-ups per hour across the whole instance. During a bot wave, real people cannot + sign up either until the hour has passed; it shows up in the audit log as + `auth.register_rate_limited`. +- **No email verification.** It needs SMTP, which is still on the + [roadmap](./roadmap.md). That is why the form says whether an email already has an + account. The sign-up limit slows down mass enumeration. + +## What is stored and for how long + +| Data | Legal basis | Retention | +|---|---|---| +| Account: name, email, bcrypt hash, role, `termsAcceptedAt`, interface language | Contract (6.1.b). For the acceptance date, legitimate interest in being able to prove it (6.1.f) | Until the account is deleted | +| Content: boards, lists, cards, comments, assignments, attachments | Contract (6.1.b) | Until deleted | +| Sessions: user agent, dates | Contract (6.1.b) | Valid for 7 days; once closed or expired, 30 more days | +| Notifications | Contract (6.1.b) | 180 days | +| API tokens: name, prefix, hash | Contract (6.1.b) | Until revoked or the account is deleted | +| Audit log: security events, with IP if `TRUSTED_PROXY_HOPS > 0` | Legitimate interest in security (6.1.f) | `ORGTABLE_AUDIT_RETENTION_DAYS`, 90 days by default | + +The retention periods come from constants in the code (`app/lib/services/maintenance.ts` +and `SESSION_MAX_AGE_DAYS` in `app/lib/session.ts`). The privacy policy reads them from +there, so it cannot drift out of sync. + +### Periodic cleanup + +Data is purged by `POST /api/cron/maintenance`, protected with the same `CRON_SECRET` as +the reminders. **Schedule it once a day**; without it, the periods above are not met: + +```bash +# host crontab, at 04:15 +15 4 * * * curl -fsS -X POST -H "Authorization: Bearer $CRON_SECRET" https://your-instance/api/cron/maintenance +``` + +It responds with what it deleted (`auditLogsDeleted`, `sessionsDeleted`, +`notificationsDeleted`). More operational details in [operations.md](./operations.md). + +## Users' rights + +Users exercise all of them on their own, from **Settings**: + +- **Access and portability** (arts. 15 and 20). *Privacy and data › Download .json* + (`GET /api/account/export`, only with a browser session, not with tokens). It includes: + - the account; + - the boards the person owns, complete, with attachment metadata; + - membership of other people's boards; + - the person's own comments and assignments; + - notifications, sessions and tokens (without hashes); + - the person's audit entries. + + It never includes hashes or other people's data. +- **Rectification** (art. 16). *Profile*. Changing the email requires the current + password. +- **Erasure** (art. 17). *Privacy and data › Delete my account*, with the password. It + deletes: + - the boards the person created, with their lists, cards and the attachment files on + disk; + - in cascade, their sessions, tokens, memberships, assignments, comments and + notifications; + - the `detail` of the audit rows that targeted them; those rows remain without a user. + + The last ADMIN account cannot be deleted. **Other** people's notifications may keep the + person's name until they expire (180 days). +- **Objection and restriction**: through the controller's contact email. + +## Cookies + +Only strictly necessary technical storage (art. 22.2 of the Spanish LSSI), so there is no +banner: + +- `session` cookie: HttpOnly, SameSite=Lax and Secure when it arrives over HTTPS. Lasts 7 + days. +- `locale` cookie: the interface language chosen with the language picker (footer of the + public pages and Settings › Appearance). SameSite=Lax and Secure over HTTPS. Lasts one + year. +- `localStorage.theme`: the theme preference; it never leaves the browser. + +If you add analytics or any other third-party script, you will need a consent banner +**before** loading it, and you will have to extend the policy. + +## Checklist for opening a public instance + +1. HTTPS in front of the app ([self-hosting.md](./self-hosting.md)) and + `TRUSTED_PROXY_HOPS` set, so the per-IP sign-up limit works. +2. `CRON_SECRET` and both cron jobs scheduled: reminders and maintenance. +3. Backups with a rotation that matches the "backup retention" field. +4. Complete legal details in `/admin/instance` and the texts reviewed by a professional. +5. If you modified the code, `ORGTABLE_SOURCE_URL` pointing to your fork: the AGPL + requires offering it to whoever uses the app. +6. Open sign-up in `/admin/instance`. diff --git a/docs/realtime.md b/docs/realtime.md index bea94a0..0100187 100644 --- a/docs/realtime.md +++ b/docs/realtime.md @@ -1,137 +1,135 @@ -# Sincronización en vivo (tiempo real) +# Live sync (real time) -Cuando varias personas tienen el mismo tablero abierto, los cambios de una aparecen -en la pantalla de las demás **sin recargar**. orgtable lo consigue con -**Server-Sent Events (SSE)** y un bus de publicación/suscripción en el servidor. +When several people have the same board open, changes made by one of them appear on the +others' screens **without reloading**. orgtable does this with **Server-Sent Events +(SSE)** and a publish/subscribe bus on the server. -> El mismo bus tiene dos tipos de canal: **por tablero** (`board:<id>`, esta página) y -> **por usuario** (`user:<id>`), que usan las notificaciones del Hito 8. El patrón es -> idéntico (publicar un "ping" → SSE → `router.refresh()`); ver -> [docs/notifications.md](./notifications.md). +> The same bus has two kinds of channel: **per board** (`board:<id>`, this page) and **per +> user** (`user:<id>`), used by notifications. The pattern is identical (publish a "ping" → +> SSE → `router.refresh()`); see [docs/notifications.md](./notifications.md). -## Cómo funciona, de punta a punta +## How it works, end to end ``` -Persona A mueve una tarjeta +Person A moves a card │ ▼ Server Action (moveCard) ──► revalidateBoard(boardId) │ │ - │ ├─► revalidatePath(`/board/${id}`) (para quien recargue) - │ └─► publishBoardEvent(boardId) (bus en proceso) + │ ├─► revalidatePath(`/board/${id}`) (for anyone who reloads) + │ └─► publishBoardEvent(boardId) (in-process bus) │ │ ▼ ▼ ┌──────────────────────────────┐ - │ Bus de eventos (EventEmitter) │ + │ Event bus (EventEmitter) │ └──────────────────────────────┘ - │ (un suscriptor por conexión SSE) + │ (one subscriber per SSE connection) ▼ - Persona B ◄── event: change ── GET /api/boards/[id]/stream (ReadableStream SSE) + Person B ◄── event: change ── GET /api/boards/[id]/stream (SSE ReadableStream) │ ▼ -EventSource recibe "change" ──► router.refresh() ──► el servidor vuelve a -renderizar /board/[id] ──► BoardView se resincroniza con los datos nuevos. +EventSource receives "change" ──► router.refresh() ──► the server re-renders +/board/[id] ──► BoardView resyncs with the new data. ``` -Piezas: +Parts: -| Archivo | Rol | +| File | Role | | --- | --- | -| [`app/lib/realtime/bus.ts`](../app/lib/realtime/bus.ts) | El bus pub/sub: `publishBoardEvent` y `subscribeBoard`. | -| [`app/lib/realtime/notify.ts`](../app/lib/realtime/notify.ts) | `revalidateBoard`: revalida la ruta **y** publica el evento. Lo llaman todas las mutaciones. | -| [`app/api/boards/[id]/stream/route.ts`](../app/api/boards/[id]/stream/route.ts) | El endpoint SSE: autoriza, se suscribe al bus y emite eventos al navegador. | -| [`app/lib/realtime/sse.ts`](../app/lib/realtime/sse.ts) | Ciclo de vida común de los streams SSE: latido, revalidación del acceso, vida máxima y limpieza. | -| [`app/components/board/board-realtime.tsx`](../app/components/board/board-realtime.tsx) | Cliente: abre el `EventSource` y hace `router.refresh()` al recibir un evento o al reconectar. | -| [`app/lib/realtime/reconnect.ts`](../app/lib/realtime/reconnect.ts) | `keepEventSourceOpen`: refresca en cada reapertura salvo la primera y, si queda `CLOSED`, comprueba el estado (`probeStatus`) y recrea el `EventSource` con backoff. `scheduleRefresh`: debounce compartido de los refrescos. | - -## Por qué SSE (y no WebSockets) - -El flujo de orgtable es **servidor → cliente** (avisar de "algo cambió"). No hace falta -un canal bidireccional: las mutaciones siguen yendo por Server Actions sobre HTTP. SSE -es más simple que WebSockets, viaja sobre HTTP normal (atraviesa proxies sin upgrade), -y el navegador **reconecta solo** con `EventSource`. El evento solo dice "este tablero -cambió"; el cliente recarga los datos con `router.refresh()`, así que el payload es -mínimo y lo que se pinta sale siempre de la base de datos. - -Límite conocido: el servidor **no guarda ni reenvía** eventos (no hay `Last-Event-ID`). -Lo que cambie mientras la conexión está caída (suspensión del portátil, reinicio del -servidor, corte de red) no llega como evento. Para no quedarse con datos viejos, el -cliente hace `router.refresh()` **cada vez que el `EventSource` se reabre** (todas menos -la primera, en la que la página acaba de renderizarse). - -`EventSource` solo reintenta por su cuenta si la conexión se corta; si el servidor -responde algo que no es un 200 (401/403 por sesión o acceso revocados, o el 502 de un -proxy TLS mientras `scripts/update.sh` reinicia la app), queda en `CLOSED` para -siempre. De eso se encarga `keepEventSourceOpen` (`app/lib/realtime/reconnect.ts`), que -usan tanto el tablero como la campana de notificaciones: - -1. Como `EventSource` no expone el estado HTTP, `probeStatus(url)` lo pregunta con un - `GET` al mismo endpoint que se aborta en cuanto llegan las cabeceras (con un 200 el - stream se abre y se cierra al momento). -2. Solo con **401/403** se llama a `onAccessDenied`, que pide un refresh con - `scheduleRefresh` para que la página redirija a `/login` o `/boards` (el desmontaje - cancela el reintento). -3. Ante un **5xx, un error de red o un fallo de la propia comprobación** no se refresca: - solo se crea un `EventSource` nuevo con backoff exponencial de 3 s a 30 s. Al - reabrirse, `onReconnect` resincroniza con el servidor ya sano y el backoff se - reinicia. -4. La comprobación tiene un plazo de 10 s (`PROBE_TIMEOUT_MS`): si el proxy retiene la - conexión o el servidor no llega a responder, se aborta y se reintenta como ante un - error de red. Desmontar el componente también la aborta. - -Por qué no se refresca a ciegas: en Next 16, si la petición RSC de un -`router.refresh()` recibe una respuesta no OK o falla por red, el router hace una -navegación completa. Con el 502 del proxy o un 500 pasajero de la BD, la pestaña -acabaría en la página de error y se perdería lo que no se hubiera guardado. - -Todos los refrescos del tiempo real (eventos, reconexiones y acceso denegado) pasan por -`scheduleRefresh`, un debounce de 200 ms **compartido** por el tablero y la campana: en -`/board/[id]` los dos streams caen y vuelven a la vez, y un solo `router.refresh()` -repinta ambos. - -## El bus: en proceso por defecto, Postgres opcional - -Por defecto el bus es un **EventEmitter en memoria**. En el despliegue estándar de -orgtable (un único contenedor de app, ver `docker-compose.yml`) las Server Actions y el -endpoint SSE viven en el **mismo proceso Node**, así que un evento publicado llega a los -suscriptores directamente. Cero infraestructura extra. - -Para despliegues **multi-instancia** (varias réplicas de la app tras un balanceador), -un evento publicado en una réplica no llegaría a los clientes conectados a otra. Para -eso hay un puente opcional sobre **PostgreSQL `LISTEN/NOTIFY`** —no Redis, porque ya -dependemos de Postgres (ver [ADR 0007](./decisions/0007-pg-notify-over-redis.md)): - -- Se activa con `ORGTABLE_REALTIME_PG_NOTIFY=1`. -- Cada instancia hace `LISTEN orgtable_realtime` con un cliente `pg` dedicado. -- `publishBoardEvent` emite localmente **y** hace `pg_notify` con el id de la instancia - de origen. Las demás instancias reciben la notificación y la reemiten en su bus local; - la de origen la ignora (ya la emitió en el acto), evitando duplicados. -- El payload de cada `NOTIFY` se valida con zod (`parseBridgePayload`: `kind`, `id`, - `origin`); cualquiera con acceso a la BD puede escribir en el canal, así que un - payload inválido se descarta con un aviso en el log (solo su longitud, no el contenido). -- Mientras el puente arranca, `publishBoardEvent` espera a que termine en vez de - descartar el evento. -- Si el puente falla, nunca rompe una mutación: el modo en proceso sigue funcionando. El - fallo se registra, la conexión fallida se cierra (para no ir abriendo conexiones hasta - agotar las de Postgres) y se reintenta con backoff exponencial de 1 s a 30 s, con un - único temporizador y solo mientras quede algún suscriptor. - -## Autorización y robustez - -- El endpoint SSE exige **sesión válida + ser miembro** del tablero antes de abrir el - stream (responde 401/403, no redirige, porque es una conexión de datos). -- Envía un latido (`: ping`) cada 25 s para mantener viva la conexión a través de - proxies, y `X-Accel-Buffering: no` para desactivar el buffering de nginx. -- **La autorización se revalida en cada latido**: si la sesión se revocó o caducó (ADR - 0008) o la persona dejó de ser miembro del tablero, el stream se cierra. Como mucho - recibe eventos durante 25 s más. Si la consulta a la BD falla, también se cierra (el - cliente reconecta y se autoriza de nuevo). -- **Vida máxima de 15 min**: pasado ese tiempo el servidor cierra el stream y el - `EventSource` reconecta, lo que vuelve a autorizar desde cero con la cookie. Como toda - reconexión, provoca un `router.refresh()` en el cliente. -- La limpieza (cancelar la suscripción y el latido, cerrar el stream) es idempotente y - se dispara desde el abort de la petición, desde `cancel()` del stream, al fallar un - `enqueue`, al perder el acceso o al llegar a la vida máxima. Si la petición llega ya - abortada (el cliente se fue mientras se autorizaba), se responde 204 sin suscribirse. - Los tests de `test/integration/realtime.int.test.ts` comprueban que no quedan - listeners en el bus ni temporizadores. +| [`app/lib/realtime/bus.ts`](../app/lib/realtime/bus.ts) | The pub/sub bus: `publishBoardEvent` and `subscribeBoard`. | +| [`app/lib/realtime/notify.ts`](../app/lib/realtime/notify.ts) | `revalidateBoard`: revalidates the route **and** publishes the event. Every mutation calls it. | +| [`app/api/boards/[id]/stream/route.ts`](../app/api/boards/[id]/stream/route.ts) | The SSE endpoint: authorizes, subscribes to the bus and sends events to the browser. | +| [`app/lib/realtime/sse.ts`](../app/lib/realtime/sse.ts) | Shared lifecycle of the SSE streams: heartbeat, access revalidation, maximum lifetime and cleanup. | +| [`app/components/board/board-realtime.tsx`](../app/components/board/board-realtime.tsx) | Client: opens the `EventSource` and calls `router.refresh()` when an event arrives or on reconnect. | +| [`app/lib/realtime/reconnect.ts`](../app/lib/realtime/reconnect.ts) | `keepEventSourceOpen`: refreshes on every reopen except the first and, if the stream ends up `CLOSED`, checks the status (`probeStatus`) and recreates the `EventSource` with backoff. `scheduleRefresh`: shared debounce for refreshes. | + +## Why SSE (and not WebSockets) + +orgtable's flow is **server → client** (announcing that "something changed"). There is no +need for a bidirectional channel: mutations still go through Server Actions over HTTP. SSE +is simpler than WebSockets, runs over plain HTTP (it goes through proxies without an +upgrade), and the browser **reconnects by itself** with `EventSource`. The event only says +"this board changed"; the client reloads the data with `router.refresh()`, so the payload +is minimal and what is drawn always comes from the database. + +Known limitation: the server **does not store or replay** events (there is no +`Last-Event-ID`). Whatever changes while the connection is down (laptop sleep, server +restart, network outage) does not arrive as an event. To avoid stale data, the client +calls `router.refresh()` **every time the `EventSource` reopens** (all times except the +first, when the page has just been rendered). + +`EventSource` only retries on its own if the connection drops; if the server answers with +anything other than 200 (401/403 because the session or access was revoked, or the 502 of +a TLS proxy while `scripts/update.sh` restarts the app), it stays `CLOSED` for good. +`keepEventSourceOpen` (`app/lib/realtime/reconnect.ts`) handles that, and both the board +and the notification bell use it: + +1. Since `EventSource` does not expose the HTTP status, `probeStatus(url)` asks for it with + a `GET` to the same endpoint that is aborted as soon as the headers arrive (with a 200 + the stream opens and closes right away). +2. Only on **401/403** is `onAccessDenied` called; it requests a refresh through + `scheduleRefresh` so the page redirects to `/login` or `/boards` (unmounting cancels + the retry). +3. On a **5xx, a network error or a failure of the check itself**, there is no refresh: + only a new `EventSource` is created with exponential backoff from 3 s to 30 s. Once it + reopens, `onReconnect` resyncs with the now-healthy server and the backoff resets. +4. The check has a 10 s deadline (`PROBE_TIMEOUT_MS`): if the proxy holds the connection + or the server never answers, it is aborted and retried as on a network error. + Unmounting the component also aborts it. + +Why not refresh blindly: in Next 16, if the RSC request of a `router.refresh()` gets a +non-OK response or fails at the network level, the router performs a full navigation. +With the proxy's 502 or a transient database 500, the tab would end up on the error page +and lose anything not yet saved. + +All real-time refreshes (events, reconnects and access denied) go through +`scheduleRefresh`, a 200 ms debounce **shared** by the board and the bell: on +`/board/[id]` both streams drop and come back at the same time, and a single +`router.refresh()` repaints both. + +## The bus: in process by default, Postgres optional + +By default the bus is an **in-memory EventEmitter**. In orgtable's standard deployment (a +single app container, see `docker-compose.yml`) the Server Actions and the SSE endpoint +live in the **same Node process**, so a published event reaches the subscribers directly. +No extra infrastructure. + +For **multi-instance** deployments (several app replicas behind a load balancer), an event +published on one replica would not reach clients connected to another. For that there is +an optional bridge over **PostgreSQL `LISTEN/NOTIFY`**, not Redis, because Postgres is +already a dependency (see [ADR 0007](./decisions/0007-pg-notify-over-redis.md)): + +- Enable it with `ORGTABLE_REALTIME_PG_NOTIFY=1`. +- Each instance runs `LISTEN orgtable_realtime` with a dedicated `pg` client. +- `publishBoardEvent` emits locally **and** calls `pg_notify` with the id of the origin + instance. The other instances receive the notification and re-emit it on their local + bus; the origin ignores it (it already emitted it), which avoids duplicates. +- The payload of each `NOTIFY` is validated with zod (`parseBridgePayload`: `kind`, `id`, + `origin`); anyone with database access can write to the channel, so an invalid payload + is discarded with a log warning (only its length, not its content). +- While the bridge is starting, `publishBoardEvent` waits for it to finish instead of + dropping the event. +- If the bridge fails, it never breaks a mutation: the in-process mode keeps working. The + failure is logged, the failed connection is closed (so connections do not pile up until + Postgres runs out of them) and it is retried with exponential backoff from 1 s to 30 s, + with a single timer and only while there is at least one subscriber. + +## Authorization and robustness + +- The SSE endpoint requires a **valid session and board membership** before opening the + stream (it answers 401/403 and does not redirect, because it is a data connection). +- It sends a heartbeat (`: ping`) every 25 s to keep the connection alive through proxies, + and `X-Accel-Buffering: no` to disable nginx buffering. +- **Authorization is revalidated on every heartbeat**: if the session was revoked or + expired (ADR 0008) or the person is no longer a board member, the stream closes. At most + they receive events for 25 more seconds. If the database query fails, it also closes + (the client reconnects and is authorized again). +- **Maximum lifetime of 15 min**: after that the server closes the stream and the + `EventSource` reconnects, which re-authorizes from scratch with the cookie. Like every + reconnect, it triggers a `router.refresh()` on the client. +- Cleanup (cancelling the subscription and the heartbeat, closing the stream) is + idempotent and is triggered by the request abort, by the stream's `cancel()`, by a failed + `enqueue`, by losing access or by reaching the maximum lifetime. If the request arrives + already aborted (the client left while it was being authorized), the endpoint answers + 204 without subscribing. The tests in `test/integration/realtime.int.test.ts` check that + no listeners or timers are left on the bus. diff --git a/docs/reminders.md b/docs/reminders.md index 9f9ddc9..dd06af0 100644 --- a/docs/reminders.md +++ b/docs/reminders.md @@ -1,75 +1,76 @@ -# Recordatorios de vencimiento +# Due-date reminders -El Hito 11 avisa a quien tiene una tarjeta asignada cuando **vence pronto** (en las -próximas 24 h) o **acaba de vencer**. La decisión técnica está en +orgtable notifies the people assigned to a card when it is **due soon** (within the next +24 h) or has **just become overdue**. The technical decision is in [ADR 0015](./decisions/0015-reminders-cron.md). -## Cómo funciona +## How it works -- Un endpoint `POST /api/cron/reminders` ejecuta la pasada. Genera notificaciones in-app - (`due.reminder`) para cada asignado de cada tarjeta en ventana, con **deep-link** a la - tarjeta. Se puede invocar varias veces sin duplicar avisos: - - **Vence pronto** (vence hoy o en las próximas 24 h): como mucho un aviso al día por - tarjeta y persona. - - **Tarea vencida**: un **único** aviso tras vencer, y solo si venció en los **últimos 7 - días**. Una tarjeta olvidada hace meses no avisa cada día ni engorda la pasada. -- Solo se avisa a los asignados que **siguen siendo miembros** del tablero (expulsar a - alguien borra sus asignaciones; ver [docs/card-collaboration.md](./card-collaboration.md)). -- **Los días se cuentan en UTC**, no en la zona del servidor: `dueDate` es una fecha de - calendario (`YYYY-MM-DD`) guardada a medianoche UTC. Una tarjeta pasa a «vencida» al - empezar el día UTC siguiente al de su vencimiento, y la fecha del aviso se formatea en - UTC (`es-ES`) para que coincida con la elegida. -- La lógica vive en `app/lib/services/reminders.ts` (`generateDueReminders`). +- An endpoint, `POST /api/cron/reminders`, runs the pass. It creates in-app notifications + (`due.reminder`) for each assignee of each card in the window, with a **deep-link** to + the card. It can be called several times without duplicating notices: + - **Due soon** (due today or within the next 24 h): at most one notice per day per card + and person. + - **Overdue**: a **single** notice after the due date, and only if it became overdue in + the **last 7 days**. A card forgotten months ago does not notify every day or bloat + the pass. +- Only assignees who **are still board members** are notified (removing someone deletes + their assignments; see [docs/card-collaboration.md](./card-collaboration.md)). +- **Days are counted in UTC**, not in the server's time zone: `dueDate` is a calendar date + (`YYYY-MM-DD`) stored at midnight UTC. A card becomes overdue when the UTC day after its + due date begins, and the date in the notice is formatted in UTC so it matches the date + that was picked. +- The logic lives in `app/lib/services/reminders.ts` (`generateDueReminders`). -## Activación +## Enabling it -1. Define un secreto en el entorno (desactivado si no existe): +1. Set a secret in the environment (disabled if it is missing): ```bash CRON_SECRET=$(openssl rand -hex 32) ``` - En Docker, añádelo al `.env` que lee `docker compose` y recrea el contenedor para que - lo reciba (un `restart` no relee el entorno): + With Docker, add it to the `.env` read by `docker compose` and re-create the container + so it picks it up (a `restart` does not re-read the environment): ```bash docker compose up -d ``` -2. Programa una llamada periódica (p. ej. diaria) desde un **cron externo**, enviando el - secreto como Bearer: +2. Schedule a periodic call (e.g. daily) from an **external cron**, sending the secret as + a Bearer token: ```bash curl -fsS -X POST -H "Authorization: Bearer $CRON_SECRET" \ - https://tu-instancia/api/cron/reminders + https://your-instance/api/cron/reminders ``` - Ejemplo de crontab (cada día a las 8:00): + Example crontab entry (every day at 8:00): ```cron - 0 8 * * * curl -fsS -X POST -H "Authorization: Bearer XXXX" https://tu-instancia/api/cron/reminders + 0 8 * * * curl -fsS -X POST -H "Authorization: Bearer XXXX" https://your-instance/api/cron/reminders ``` -Respuestas: `200 {"created":N,"scanned":M}` si todo va bien (`created` solo cuenta las -notificaciones que se guardaron de verdad; `scanned`, las tarjetas en ventana); `401` si -el secreto no casa; `503` si `CRON_SECRET` no está definido. Los tres casos están -cubiertos en `test/integration/cron-reminders.int.test.ts`. +Responses: `200 {"created":N,"scanned":M}` on success (`created` only counts the +notifications that were actually saved; `scanned`, the cards in the window); `401` if the +secret does not match; `503` if `CRON_SECRET` is not set. All three cases are covered in +`test/integration/cron-reminders.int.test.ts`. -## Seguridad +## Security -- El secreto se compara en **tiempo constante** (`crypto.timingSafeEqual`) para no filtrar - información por el tiempo de respuesta. -- El endpoint no expone datos: solo dispara la generación de notificaciones. +- The secret is compared in **constant time** (`crypto.timingSafeEqual`) so response + timing leaks nothing. +- The endpoint exposes no data: it only triggers notification generation. -## Reprogramar desde el calendario +## Rescheduling from the calendar -Como complemento (mismo hito), en la **vista de calendario** puedes **arrastrar** una -tarjeta a otro día para cambiar su vencimiento (si tienes permiso de edición). Usa una -acción enfocada `setCardDueDate` que solo toca la fecha. Ver -[`docs/calendar.md`](./calendar.md). +As a complement, in the **calendar view** you can **drag** a card to another day to +change its due date (with edit permission). It uses a focused `setCardDueDate` action +that only touches the date. See [`docs/calendar.md`](./calendar.md). -## Límites y evolución (backlog) +## Limits and possible improvements (backlog) -- **Email / Web Push**: hoy los recordatorios son in-app. Encajarían como otro consumidor - de `notify()` (SMTP opcional por variables de entorno), sin cambiar este diseño. -- **Preferencias por usuario** (qué avisar y por dónde): ampliación natural. +- **Email / Web Push**: reminders are in-app today. They would fit as another consumer of + `notify()` (optional SMTP through environment variables), without changing this design. +- **Per-user preferences** (what to notify and through which channel): a natural + extension. diff --git a/docs/roadmap.md b/docs/roadmap.md index 7fbf7c3..3ecb448 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,263 +1,172 @@ -# Hoja de ruta - -orgtable se construye por hitos incrementales. Esta página resume qué incluye cada -uno y su estado. El alcance puede ajustarse conforme avanza el proyecto. - -## Hito 0 — Cimientos ✅ (completado) - -La infraestructura sobre la que se monta todo lo demás. Sin interfaz todavía. - -- [x] Esquema de base de datos (Prisma 7 + PostgreSQL) y primera migración. -- [x] Cliente Prisma con driver adapter (`@prisma/adapter-pg`). -- [x] Next.js configurado para `output: standalone`. -- [x] Endpoint de health (`/api/health`). -- [x] Imagen Docker multi-stage + `docker-compose.yml` (app + postgres). -- [x] Migraciones automáticas en el arranque del contenedor. -- [x] Instalador de un comando (`install.sh`) con generación de secretos. -- [x] Licencia y documentación base. (La licencia inicial se sustituyó después por la - propietaria de `LICENSE`; ver [ADR 0016](./decisions/0016-licencia-propietaria.md).) - -## Hito 1 — MVP de extremo a extremo ✅ (completado) - -El producto mínimo usable: instalar con un comando → configurar → usar como Trello. - -- [x] **Asistente de configuración inicial** (`/setup`): crear el usuario administrador - y el nombre de la instancia; bloqueo una vez completado. -- [x] **Autenticación**: sesiones JWT con `jose` (cookie httpOnly), Data Access Layer - (`verifySession`), login/logout, protección de rutas con `proxy.ts`. -- [x] **Tableros**: crear, listar, abrir y borrar. -- [x] **Listas y tarjetas**: crear, renombrar y borrar. -- [x] **Arrastrar y soltar** de listas y tarjetas con `@dnd-kit` + fractional indexing - + UI optimista, persistido vía Server Actions. - -## Hito 2 — Detalle de tarjeta ✅ (completado) - -- [x] Modal de tarjeta con descripción. -- [x] Etiquetas (labels): crear y asignar, con badges en la tarjeta. -- [x] Fechas de vencimiento (due dates), con aviso de vencida. -- [x] Checklists con ítems marcables y barra de progreso. -- [x] **Sistema de diseño** (estética Trello colorida) con tema claro y oscuro - (toggle persistente). Ver `DESIGN.md` y `PRODUCT.md`. - -## Hito 3 — Multiusuario ✅ (completado) - -- [x] Invitar miembros a un tablero (`board_members`) por email, con rol. -- [x] Roles (owner / editor / viewer) y autorización por rol en cada Server Action, - con la UI en solo lectura para los lectores. Ver `docs/authorization.md`. -- [x] Gestión de usuarios por el administrador (`/admin/users`): altas, roles, - restablecer contraseña y borrado con salvaguardas. Ver `docs/admin.md`. - -## Hito 4 — Tiempo real ✅ (completado) - -- [x] Sincronización en vivo entre usuarios con **SSE** (`/api/boards/[id]/stream`) y - un bus pub/sub; el cliente recarga con `router.refresh()`. Ver `docs/realtime.md`. -- [x] Pub/sub **en proceso** por defecto, con puente opcional vía PostgreSQL - `LISTEN/NOTIFY` para multi-instancia (no Redis; ver - [ADR 0007](./decisions/0007-pg-notify-over-redis.md)). -- [x] Revocación inmediata de sesiones con tabla `Session` (logout, reset de - contraseña, cierre forzado por el admin). Ver - [ADR 0008](./decisions/0008-session-store.md). - -## Hito 5 — Operación y pulido ✅ (completado) - -- [x] Backups de PostgreSQL (`scripts/backup.sh` / `restore.sh`) y exportar/importar - tableros en JSON. Ver `docs/operations.md`. -- [x] Adjuntos en tarjetas, en disco (volumen) con descarga autorizada. Ver - `docs/attachments.md`. -- [x] Flujo de actualización (`scripts/update.sh`; migraciones automáticas al arrancar). -- [x] Rate-limiting del login y registro de auditoría (`/admin/audit`). Ver - `docs/operations.md`. - -## Hito 6 — Búsqueda global ✅ (completado) - -- [x] Búsqueda de **tableros** y **tarjetas** por `title`/`description`, acotada en el - servidor a lo que el usuario puede ver (mismo patrón de acceso que - `getBoardsForUser`). Coincidencia `ILIKE`, sin motor de búsqueda externo - ([ADR 0011](./decisions/0011-search-ilike-scoped.md)). -- [x] Caja de búsqueda en la barra superior + página `/search` (formulario GET, sin - depender de JavaScript) y **deep-link** `?card=` para abrir una tarjeta desde un - resultado. Ver [`docs/search.md`](./search.md). - -## Hito 7 — Vista de calendario ✅ (completado) - -- [x] Conmutador **Tablero / Calendario** en cada tablero. La vista de calendario - coloca las tarjetas con fecha de vencimiento en una rejilla mensual (lunes - primero), con navegación entre meses y resalte del día de hoy. -- [x] Al pulsar una tarjeta se abre su modal (se reutiliza el detalle de tarjeta y el - tiempo real). Indexación por fecha sin desfases de zona horaria. Ver - [`docs/calendar.md`](./calendar.md). - -## Hito 8 — Notificaciones ✅ (completado) - -- [x] Notificaciones in-app por usuario (te añaden / te cambian el rol / te quitan de un - tablero), con campana y contador de no leídas en la barra superior, marcar una o - todas como leídas, y enlace al tablero. -- [x] **Entrega en vivo reutilizando el bus pub/sub** del Hito 4 con un canal por - usuario (`user:<id>`) y un stream SSE `/api/notifications/stream`; sin email ni - cola ([ADR 0012](./decisions/0012-notifications.md)). Ver - [`docs/notifications.md`](./notifications.md). - -## Hito 9 — API pública ✅ (completado) - -- [x] API REST `/api/v1` para tableros, listas y tarjetas (CRUD esencial), autenticada - con **tokens de acceso personal** (`Authorization: Bearer`). El token se guarda - hasheado (SHA-256) y se muestra una sola vez ([ADR 0013](./decisions/0013-api-tokens.md)). -- [x] La API **reutiliza la autorización por rol**: un token actúa como su dueño, con su - mismo acceso; el rol del tablero se revalida en cada petición. Las mutaciones por - API también emiten eventos de tiempo real. -- [x] Gestión de tokens en `/settings` (crear, ver una vez, revocar), con auditoría. Ver - [`docs/api/public-api.md`](./api/public-api.md). - ---- - -> **Hitos 0–13 completados.** A partir de aquí van los **próximos hitos planificados** -> (14–20): pendientes, con el alcance pensado y dónde encajan en la arquitectura actual, -> para poder arrancar cada uno directamente. El orden es orientativo; se priorizará según -> el uso real. Mantener la filosofía del proyecto: self-hosted, sin servicios externos -> obligatorios, el servidor como única autoridad, y reutilizar lo que ya existe -> (Server Actions + DAL + Prisma, RBAC, bus de tiempo real). - -## Hito 10 — Colaboración en la tarjeta: asignados y comentarios ✅ (completado) - -- [x] **Asignados**: modelo de unión `CardAssignee`. Asignar/quitar miembros desde el - modal (avatares también en la cara de la tarjeta). Solo se puede asignar a miembros - del tablero (servicio testeable `assignMemberToCard`). -- [x] **Comentarios**: modelo `Comment`, hilo plano en el modal; comentar requiere - edición, borrar lo hace el autor o el OWNER. `revalidateBoard` para el tiempo real. -- [x] **Notificaciones** reutilizando `notify()`: `card.assigned` y `comment.added` (a los - asignados). Tipos añadidos a `NotificationType`. -- [x] La **API** expone asignados y comentarios en las respuestas de tarjeta (lectura); - crear/borrar por API queda como ampliación. Decisión en - [ADR 0014](./decisions/0014-card-collaboration.md). Ver - [`docs/card-collaboration.md`](./card-collaboration.md). - -## Hito 11 — Fechas y recordatorios de vencimiento ✅ (completado) - -- [x] **Recordatorios de vencimiento**: `generateDueReminders` (servicio testeable) genera - notificaciones `due.reminder` para los asignados de tarjetas que vencen en 24 h o - están vencidas (política de avisos en [`docs/reminders.md`](./reminders.md)). Lo - dispara un **cron externo** vía el endpoint protegido `POST /api/cron/reminders` - (secreto en `Authorization: Bearer`, comparado en tiempo constante), sin scheduler - en proceso - ([ADR 0015](./decisions/0015-reminders-cron.md)). -- [x] **Arrastrar en el calendario** a otro día cambia el vencimiento (`@dnd-kit` + - `setCardDueDate`). Notificaciones con **deep-link** a la tarjeta (`Notification.cardId`). - Ver [`docs/reminders.md`](./reminders.md). - -> Diferido al **backlog**: email/Web Push (otro consumidor de `notify()`, SMTP opcional) y -> preferencias de notificación por usuario. No bloquean el resto del roadmap. - -## Hito 12 — Filtros de tablero ✅ (completado) - -- [x] **Filtros en el tablero** por etiqueta, asignado (Hito 10) y estado de vencimiento - (cualquiera / vencidas / con fecha / sin fecha). Barra desplegable en la cabecera que - oculta las tarjetas que no encajan, sin recargar; el arrastre se desactiva mientras se - filtra. Lógica pura testeable en `app/lib/board-filters.ts`. Ver - [`docs/filters.md`](./filters.md). - -> Diferido al **backlog**: búsqueda **full-text** de PostgreSQL (`tsvector` + GIN) detrás de -> `searchForUser` y filtros rápidos guardados. El `ILIKE` actual es adecuado a la escala -> self-hosted ([ADR 0011](./decisions/0011-search-ilike-scoped.md)); el full-text es una -> optimización, no un bloqueo. - -## Hito 13 — Scopes y caducidad de tokens de API ✅ (completado) - -- [x] **Scope por token**: `full` (lectura+escritura) o `read` (solo lectura). Los tokens - de solo lectura reciben `403` en todo `POST`/`PATCH`/`DELETE` (`isReadOnly` en cada - handler de escritura). Columna `scope` en `ApiToken`. -- [x] **Caducidad opcional** (`expiresAt`): un token caducado se rechaza con `401`. Elección - de scope/caducidad en `/settings`; estado «Caducado» visible. Extiende - [ADR 0013](./decisions/0013-api-tokens.md). Ver [`docs/api/public-api.md`](./api/public-api.md). - -> Diferido al **backlog**: ampliar la superficie de la API (checklists, etiquetas, adjuntos, -> miembros, mover/reordenar), **rate limiting** de la API y una **referencia OpenAPI**. - -## Hito 14 — PWA y experiencia móvil 🔜 (planificado) - -- [ ] **PWA instalable**: `manifest`, iconos, service worker con shell offline básico. -- [ ] **Pulido móvil** del tablero (arrastre táctil, columnas a pantalla completa, - objetivos de toque ≥44px) y de los modales. -- [ ] Docs: `docs/pwa.md`. - -## Hito 15 — Automatizaciones (reglas tipo Butler) 🔜 (planificado) - -Reglas "cuando pase X, haz Y" dentro de un tablero, al estilo de Butler de Trello, sin -servicios externos. - -- [ ] Modelo `AutomationRule` (tablero, **disparador**, **condición** y **acción** en - JSON). Disparadores: tarjeta movida a una lista, etiqueta añadida/quitada, - vencimiento próximo/cumplido, checklist completada. Acciones: mover, asignar, poner - etiqueta/fecha, crear checklist, notificar. -- [ ] **Ejecución enganchada al bus de eventos existente** (no un proceso aparte): cuando - una mutación publica su evento, el motor evalúa las reglas del tablero. Ejecutar - como el sistema, respetando los permisos del tablero. -- [ ] UI por tablero para crear/activar reglas (solo OWNER); auditoría de ejecuciones y - **tope por evento** para evitar bucles (regla que se dispara a sí misma). -- [ ] Migración; ADR (formato de reglas y prevención de bucles). Docs: `docs/automations.md`. - -## Hito 16 — Vistas adicionales: tabla y swimlanes 🔜 (planificado) - -- [ ] **Vista de tabla**: todas las tarjetas del tablero en una tabla ordenable y - filtrable (título, lista, asignado, vencimiento, etiquetas). Conmutador junto a - Tablero / Calendario en `BoardView`. -- [ ] **Swimlanes**: agrupar el tablero en filas por etiqueta o por asignado (Hito 10). - Estado de cliente, sin tocar el modelo de datos. -- [ ] Reutiliza los filtros del Hito 12. Docs: `docs/views.md`. - -## Hito 17 — Plantillas, duplicar y archivar 🔜 (planificado) - -- [ ] **Plantillas de tablero** y **duplicar** tablero/lista/tarjeta, regenerando ids y - posiciones (reutilizando la lógica de `importBoard` en `actions/boards.ts`). -- [ ] **Archivar** en vez de borrar (tableros, listas y tarjetas): columna `archivedAt` - nullable; ocultas por defecto, con una vista de archivados y opción de **restaurar**. - Migración aditiva (columnas nullable, sin romper datos existentes). -- [ ] Docs: `docs/templates-archive.md`. - -## Hito 18 — Actividad e historial de tarjeta 🔜 (planificado) - -- [ ] Registro de **actividad por tarjeta** visible para los miembros (creada, movida, - renombrada, comentario, asignación, cambio de vencimiento…). Modelo `CardActivity` - propio (el `AuditLog` actual es de seguridad/operación y solo lo ve el admin). -- [ ] Sección de actividad en el modal de tarjeta y, opcional, un feed por tablero. - Tiempo real reutilizando el bus. Migración. Docs: `docs/activity.md`. - -## Hito 19 — Webhooks e integraciones 🔜 (planificado) - -- [ ] **Webhooks salientes** por tablero: modelo `Webhook` (URL, secreto, eventos) y - entrega como **otro consumidor del bus** de eventos, con **firma HMAC** del payload - y reintentos best-effort (sin añadir una cola; coherente con ADR 0004/0007). -- [ ] **Entrada**: crear tarjetas vía **webhook entrante** con secreto (y, opcional, - email-to-card si hay buzón configurado). -- [ ] Gestión en los ajustes del tablero (solo OWNER). ADR (firma HMAC, reintentos sin - cola). Docs: `docs/webhooks.md`. - -## Hito 20 — Internacionalización (i18n) y accesibilidad 🔜 (planificado) - -- [ ] **i18n**: extraer los textos (hoy en español embebido) a un catálogo, selector de - idioma por usuario y un **locale en inglés** como segundo idioma. Las notificaciones - dejarían de precalcular texto fijo y pasarían a clave + datos (revisar - [ADR 0012](./decisions/0012-notifications.md)). -- [ ] **Accesibilidad**: pasada de foco, roles ARIA y lectores de pantalla en el - arrastrar y soltar, los modales y la campana; verificar contraste. -- [ ] ADR (estrategia i18n e impacto en notificaciones). Docs: `docs/i18n.md`. - ---- - -### Más allá del Hito 20 (candidatos, sin alcance cerrado) - -- **SSO / OIDC** opcional (OpenID Connect) además del login propio. -- **Doble factor (TOTP)** para endurecer el acceso. -- **Observabilidad**: endpoint de métricas (Prometheus), logs estructurados, readiness. -- **Backups programados desde la UI** y restauración guiada. - -### Backlog continuo (mejoras menores, sin hito propio) - -- **Email / Web Push** para notificaciones y recordatorios (otro consumidor de `notify()`, - SMTP opcional por entorno) + **preferencias de notificación por usuario** (diferido del Hito 11). -- Portadas/colores por tarjeta; descripción con **Markdown**. -- Exportar el tablero también a **CSV** (hoy solo JSON). -- Reacciones/emoji en comentarios (tras el Hito 10). - -> Al cerrar cada hito: marcar sus casillas, mover el encabezado a "✅ (completado)", -> actualizar `docs/index.md`, `CLAUDE.md` (decisiones + notas de hito) y escribir el ADR -> correspondiente si hubo una decisión técnica con su porqué. +# Roadmap + +This page lists what orgtable already does and where it is heading. Plans can change as +the project evolves and as people use it; the order of planned work is indicative. + +Every feature follows the same principles: orgtable stays self-hosted, needs no mandatory +external services, keeps the server as the only authority on permissions, and builds on +what already exists (Server Actions + Data Access Layer + Prisma, role-based access +control and the real-time bus). + +If you want to work on something here, open an issue first so we can agree on the +approach. See [`contributing.md`](./contributing.md). + +## Shipped + +### Boards, lists and cards + +- Create, rename and delete boards, lists and cards. +- Drag and drop for lists and cards, by mouse, touch and keyboard, with an optimistic UI + and fractional indexing so a move writes a single row. See + [`drag-and-drop.md`](./drag-and-drop.md). +- Card details: description, color labels, due dates with an overdue warning, and + checklists with a progress bar. See [`card-detail.md`](./card-detail.md). +- Attachments stored on disk (a Docker volume) and downloaded through an authorized + route. See [`attachments.md`](./attachments.md). +- Board export and import as JSON. + +### Collaboration + +- Board members with roles (owner, editor, viewer), checked by the server on every + action; viewers get a read-only interface. See [`authorization.md`](./authorization.md). +- Real-time sync between everyone on a board over Server-Sent Events, with an optional + PostgreSQL `LISTEN/NOTIFY` bridge for several app instances. See + [`realtime.md`](./realtime.md). +- Card assignees (board members only) and comments. See + [`card-collaboration.md`](./card-collaboration.md). +- In-app notifications with a bell and unread counter, delivered live. See + [`notifications.md`](./notifications.md). +- Due-date reminders, triggered by an external cron job. See + [`reminders.md`](./reminders.md). + +### Finding and viewing work + +- Global search across boards and cards, limited to what each person can see, with links + that open a card directly. See [`search.md`](./search.md). +- Board filters by label, assignee and due-date status. See [`filters.md`](./filters.md). +- Calendar view with a monthly grid; dragging a card to another day changes its due date. + See [`calendar.md`](./calendar.md). + +### Public API + +- REST API (`/api/v1`) for boards, lists and cards, authenticated with personal access + tokens that are stored hashed and shown only once. +- Tokens act with their owner's permissions, can be read-only or read-write, and can + expire. See [`api/public-api.md`](./api/public-api.md). + +### Accounts, administration and security + +- Setup wizard on first start that creates the admin account. See + [`setup-wizard.md`](./setup-wizard.md). +- JWT sessions backed by a session table, so signing out, resetting a password or an + admin action revokes them immediately. See [`authentication.md`](./authentication.md). +- User management for admins, sign-in rate limiting and an audit log. See + [`admin.md`](./admin.md) and [`operations.md`](./operations.md). +- Optional open sign-up per instance, with consent and anti-abuse measures. +- Account settings: profile, password, active sessions, API tokens, appearance, data + export and account deletion. +- Security headers, hardened dependencies and authorization tests. See + [`security.md`](./security.md). + +### Privacy and legal + +- Operator details entered once by the admin feed the privacy policy, terms, cookie + policy and legal notice pages. +- Everyone can download all their data as JSON or delete their account. +- Scheduled cleanup of expired sessions, notifications and audit entries. See + [`privacy.md`](./privacy.md). + +### Interface + +- Light and dark themes, keyboard and screen reader support, and a layout that works on + phones. See [`design-system.md`](./design-system.md). +- Interface in English and Spanish, chosen from the browser language or a picker in the + footer and in Settings › Appearance. New languages can be added without touching code. + See [`../locales/README.md`](../locales/README.md). + +### Self-hosting and operations + +- One-command installer that sets up Docker and generates the secrets. See + [`installation.md`](./installation.md). +- Multi-stage Docker image with automatic migrations on startup and a health endpoint. + See [`docker.md`](./docker.md). +- Backup, restore and update scripts. See [`self-hosting.md`](./self-hosting.md) and + [`operations.md`](./operations.md). +- Released under the AGPL-3.0-or-later with an attribution term. See + [`../LICENSE`](../LICENSE). + +## Planned + +### PWA and mobile + +- Installable progressive web app: manifest, icons and a service worker with a basic + offline shell. +- Mobile polish for the board and the dialogs: touch dragging, full-width columns and + touch targets of at least 44 px. + +### Automations + +Rules of the form "when X happens, do Y" inside a board, in the style of Trello's Butler, +without external services. + +- Triggers: card moved to a list, label added or removed, due date approaching or + reached, checklist completed. +- Actions: move, assign, set a label or a due date, add a checklist, notify. +- Rules run on the existing event bus, with the board's permissions, a per-event limit + to prevent loops, and an audit trail. Only board owners can create them. + +### More board views + +- Table view: every card of a board in a sortable, filterable table (title, list, + assignee, due date, labels), next to the Board and Calendar views. +- Swimlanes: group the board into rows by label or by assignee. +- Both reuse the existing board filters. + +### Templates, duplication and archiving + +- Board templates, and duplicating boards, lists and cards. +- Archive instead of delete for boards, lists and cards, with an archive view and + restore. + +### Card activity + +- An activity history on each card, visible to board members: created, moved, renamed, + commented, assigned, due date changed. +- Optionally, an activity feed per board, updated in real time. + +### Webhooks and integrations + +- Outgoing webhooks per board, with HMAC-signed payloads and best-effort retries. +- Incoming webhooks to create cards, and optionally email-to-card. +- Managed by board owners from the board settings. + +### Accessibility + +- A full pass on focus handling, ARIA roles and screen reader announcements for drag and + drop, dialogs and notifications, plus a contrast review. + +### More languages + +- Community translations beyond English and Spanish. The guide is in + [`../locales/README.md`](../locales/README.md). + +## Ideas / later + +- Optional single sign-on with OpenID Connect, alongside the built-in sign-in. +- Two-factor authentication (TOTP). +- Observability: a metrics endpoint (Prometheus), structured logs and a readiness check. +- Scheduled backups and guided restore from the interface. + +## Smaller improvements + +- Email and Web Push for notifications and reminders, with per-user notification + preferences. With email in place: address verification for open sign-up and password + recovery without an admin. +- Full-text search with PostgreSQL (`tsvector` + GIN) and saved quick filters. +- A wider public API (checklists, labels, attachments, members, moving and reordering, + creating assignees and comments), API rate limiting and an OpenAPI reference. +- Transferring board ownership, so deleting an account does not take shared boards with + it. +- Card covers and colors, and Markdown in descriptions. +- CSV export of a board, in addition to JSON. +- Reactions on comments. diff --git a/docs/screenshots/board-dark.webp b/docs/screenshots/board-dark.webp new file mode 100644 index 0000000..e29edeb Binary files /dev/null and b/docs/screenshots/board-dark.webp differ diff --git a/docs/screenshots/board-light.webp b/docs/screenshots/board-light.webp new file mode 100644 index 0000000..f687499 Binary files /dev/null and b/docs/screenshots/board-light.webp differ diff --git a/docs/screenshots/board-mobile.webp b/docs/screenshots/board-mobile.webp new file mode 100644 index 0000000..229b367 Binary files /dev/null and b/docs/screenshots/board-mobile.webp differ diff --git a/docs/screenshots/boards-light.webp b/docs/screenshots/boards-light.webp new file mode 100644 index 0000000..5109243 Binary files /dev/null and b/docs/screenshots/boards-light.webp differ diff --git a/docs/screenshots/calendar-light.webp b/docs/screenshots/calendar-light.webp new file mode 100644 index 0000000..051234c Binary files /dev/null and b/docs/screenshots/calendar-light.webp differ diff --git a/docs/screenshots/card-dark.webp b/docs/screenshots/card-dark.webp new file mode 100644 index 0000000..2262ce8 Binary files /dev/null and b/docs/screenshots/card-dark.webp differ diff --git a/docs/screenshots/card-light.webp b/docs/screenshots/card-light.webp new file mode 100644 index 0000000..da89889 Binary files /dev/null and b/docs/screenshots/card-light.webp differ diff --git a/docs/screenshots/hero.gif b/docs/screenshots/hero.gif new file mode 100644 index 0000000..aa899f6 Binary files /dev/null and b/docs/screenshots/hero.gif differ diff --git a/docs/screenshots/realtime.gif b/docs/screenshots/realtime.gif new file mode 100644 index 0000000..c96bf3f Binary files /dev/null and b/docs/screenshots/realtime.gif differ diff --git a/docs/screenshots/settings-light.webp b/docs/screenshots/settings-light.webp new file mode 100644 index 0000000..41fe36c Binary files /dev/null and b/docs/screenshots/settings-light.webp differ diff --git a/docs/search.md b/docs/search.md index bd59266..5e019ca 100644 --- a/docs/search.md +++ b/docs/search.md @@ -1,59 +1,59 @@ -# Búsqueda +# Search -La búsqueda global (Hito 6) permite encontrar **tableros** y **tarjetas** desde -cualquier punto de la app. Es de solo lectura y está acotada a lo que el usuario puede -ver. La decisión técnica está en [ADR 0011](./decisions/0011-search-ilike-scoped.md). +Global search finds **boards** and **cards** from anywhere in the app. It is read-only and +scoped to what the user can see. The technical decision is in +[ADR 0011](./decisions/0011-search-ilike-scoped.md). -## Cómo se usa +## How to use it -- En pantallas medianas o más anchas hay una **caja de búsqueda en la barra superior**. - En móvil, un icono de lupa lleva a la página de búsqueda. -- Escribir y enviar (Enter) navega a `/search?q=…`. La página renderiza los resultados - en el servidor: no hace falta JavaScript (mejora progresiva). -- Los resultados se agrupan en **Tableros** y **Tarjetas**. Cada tarjeta muestra su - tablero y su lista, y al pulsarla se abre directamente en su tablero mediante un - **deep-link** `?card=<id>` (ver más abajo). +- On medium and wider screens there is a **search box in the top bar**. On mobile, a + magnifier icon leads to the search page. +- Typing and submitting (Enter) navigates to `/search?q=…`. The page renders the results + on the server: no JavaScript is needed (progressive enhancement). +- Results are grouped into **Boards** and **Cards**. Each card shows its board and list, + and clicking it opens it directly on its board through a `?card=<id>` **deep-link** + (see below). -## Qué se busca +## What is searched -- **Tableros**: por `title` y `description`. -- **Tarjetas**: por `title` y `description`. +- **Boards**: by `title` and `description`. +- **Cards**: by `title` and `description`. -La coincidencia es por **subcadena, insensible a mayúsculas** (`ILIKE`), con un mínimo -de 2 caracteres. Los resultados se ordenan por `updatedAt` (lo más reciente primero) y -se limitan (12 tableros, 30 tarjetas) para mantener la respuesta ligera. +Matching is **case-insensitive substring** (`ILIKE`), with a minimum of 2 characters. +Results are sorted by `updatedAt` (most recent first) and capped (12 boards, 30 cards) to +keep the response light. -## Autorización +## Authorization -La consulta (`searchForUser` en `app/lib/queries.ts`) **incluye el filtro de acceso en -el propio `WHERE`**: solo tableros donde el usuario es dueño o miembro, y tarjetas -dentro de esos tableros. Es el mismo patrón que `getBoardsForUser`. No hay filtrado en -cliente ni después de la consulta: el servidor es la única autoridad -([docs/authorization.md](./authorization.md)). Un usuario nunca verá en los resultados -una tarjeta de un tablero al que no pertenece. +The query (`searchForUser` in `app/lib/queries.ts`) **puts the access filter in the +`WHERE` itself**: only boards the user owns or belongs to, and cards inside those boards. +It is the same pattern as `getBoardsForUser`. There is no filtering on the client or after +the query: the server is the only authority +([docs/authorization.md](./authorization.md)). A user never sees a card from a board they +do not belong to in the results. -## Deep-link a una tarjeta +## Deep-link to a card -`/board/<id>?card=<cardId>` abre el tablero con esa tarjeta ya desplegada en su modal. -`BoardView` deriva la tarjeta abierta de la URL con `useSearchParams()`, así que el -enlace funciona también si ya estás en ese tablero (Next no remonta el componente cuando -solo cambian los parámetros). Abrir y cerrar una tarjeta actualiza `?card` con -`window.history.replaceState`, sin pedir la página al servidor; al cerrar se quita, de -modo que volver a pulsar la misma notificación la abre de nuevo. Si el id no existe en el -tablero (por ejemplo, la tarjeta se borró), no se abre nada: es un comportamiento seguro -por defecto. +`/board/<id>?card=<cardId>` opens the board with that card already open in its modal. +`BoardView` derives the open card from the URL with `useSearchParams()`, so the link also +works when you are already on that board (Next does not remount the component when only +the parameters change). Opening and closing a card updates `?card` with +`window.history.replaceState`, without requesting the page from the server; closing +removes it, so clicking the same notification again reopens it. If the id does not exist +on the board (for example, the card was deleted), nothing opens: a safe default. -## Componentes y archivos +## Components and files -- `app/lib/queries.ts` — `searchForUser(userId, query)` y el tipo `SearchResults`. -- `app/(app)/search/page.tsx` — la página de resultados. -- `app/components/search-box.tsx` — la caja (formulario GET reutilizable, navbar y página). -- `app/(app)/board/[id]/page.tsx` + `app/components/board/board-view.tsx` — el deep-link. +- `app/lib/queries.ts` — `searchForUser(userId, query)` and the `SearchResults` type. +- `app/(app)/search/page.tsx` — the results page. +- `app/components/search-box.tsx` — the box (reusable GET form, used in the navbar and on + the page). +- `app/(app)/board/[id]/page.tsx` + `app/components/board/board-view.tsx` — the deep-link. -## Límites conocidos y evolución +## Known limits and possible improvements -- Sin ranking por relevancia ni tolerancia a erratas. Orden por recencia. -- `ILIKE '%term%'` es un escaneo (el comodín inicial impide usar un índice B-tree). A la - escala de una instancia self-hosted es instantáneo; si creciera, la vía es full-text - de PostgreSQL (`tsvector` + GIN) o `pg_trgm`, sin cambiar la interfaz de - `searchForUser`. Ver el ADR para el detalle. +- No relevance ranking or typo tolerance. Results are sorted by recency. +- `ILIKE '%term%'` is a scan (the leading wildcard prevents using a B-tree index). At the + scale of a self-hosted instance it is instant; if it grew, the path is PostgreSQL + full-text (`tsvector` + GIN) or `pg_trgm`, without changing the `searchForUser` + interface. See the ADR for details. diff --git a/docs/security.md b/docs/security.md index 04f1a5c..3a49bcf 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,120 +1,125 @@ -# Seguridad +# Security -Este documento resume la **postura de seguridad** de orgtable y el resultado de la -auditoría realizada sobre los hitos 0–11. Es self-hosted: el modelo de amenazas asume una -instancia tras un reverse proxy con TLS, con cuentas creadas por el administrador (no hay -registro abierto). +This document summarizes orgtable's **security posture** and the results of the security +audit of the codebase. orgtable is self-hosted: the threat model assumes an instance +behind a reverse proxy with TLS, with accounts created by the administrator or, when the +operator enables it, through open sign-up (see [privacy.md](./privacy.md)). -## Metodología de la auditoría +## Audit methodology -- **`pnpm audit`** sobre las dependencias. -- **Auditoría adversarial multi-agente**: 7 revisores en paralelo, uno por superficie de - ataque (autenticación/sesiones, autorización/IDOR, inyección, XSS/salida, subida de - ficheros, secretos/SSRF, hardening web), leyendo el **código real**. Cada hallazgo pasó - por un **escéptico** que intentó refutarlo contra el código (84 agentes en total). De 78 - hallazgos en bruto: **13 confirmados, 64 refutados**. -- **Revisión manual** de los puntos de mayor valor (cookies, cabeceras, descarga de - adjuntos, SQL crudo). +- **`pnpm audit`** on the dependencies. +- **Adversarial multi-agent audit**: 7 reviewers in parallel, one per attack surface + (authentication/sessions, authorization/IDOR, injection, XSS/output, file uploads, + secrets/SSRF, web hardening), reading the **actual code**. Every finding went through a + **skeptic** that tried to refute it against the code (84 agents in total). Of 78 raw + findings: **13 confirmed, 64 refuted**. +- **Manual review** of the highest-value areas (cookies, headers, attachment downloads, + raw SQL). -## Dependencias (`pnpm audit`) +## Dependencies (`pnpm audit`) -- **2026-09-24**: con la migración a pnpm, `pnpm audit` daba 63 avisos (2 críticos en - `next` 16.2.7). Se subió todo dentro de su versión mayor (`next` 16.3.6, Prisma 7.10…) - y se forzaron transitivas parcheadas con `overrides` en `pnpm-workspace.yaml`. Queda - **0 avisos** y uno ignorado y justificado (`deepmerge-ts`, solo en el CLI de Prisma). - El detalle y el porqué están en [`DECISIONES.md`](./DECISIONES.md). -- CI ejecuta `pnpm audit --prod --audit-level high` en cada push y PR. +- **2026-09-24**: after the move to pnpm, `pnpm audit` reported 63 advisories (2 critical + in `next` 16.2.7). Everything was upgraded within its major version (`next` 16.3.6, + Prisma 7.10…) and patched transitive dependencies were forced with `overrides` in + `pnpm-workspace.yaml`. The result was **0 advisories** plus one ignored and justified + (`deepmerge-ts`, only in the Prisma CLI). Details and reasoning are in + [`DECISIONES.md`](./DECISIONES.md). +- CI runs `pnpm audit --prod --audit-level high` on every push and PR. -## Hallazgos confirmados y su estado +## Confirmed findings and their status -| Severidad | Hallazgo | Estado | +| Severity | Finding | Status | |-----------|----------|--------| -| **Alta** | **IDOR en `moveCard`**: solo validaba el tablero de DESTINO, no el de ORIGEN. Un editor podía mover a su tablero una tarjeta de otro al que no tiene acceso, conociendo su id. | **Corregido**: `moveCard` exige `cardBelongsToBoard(cardId, destino)` (la tarjeta debe estar ya en el tablero de destino; no se mueven tarjetas entre tableros). Test de regresión en `security.int.test.ts`. | -| Media (op.) | `SESSION_SECRET` sin definir degradaba a clave vacía/predecible (sesiones forjables). | **Corregido**: se exige `SESSION_SECRET` (≥16 chars) al firmar/verificar; falla ruidosamente. | -| Media (op.) | `DATABASE_URL` sin validar: el adapter caería a los defaults de libpq y conectaría a una BD no deseada. | **Corregido**: `createPrismaClient` lanza si falta (salvo en `next build`). | -| Baja | Faltaban cabeceras de seguridad (clickjacking, sniffing, CSP, HSTS). | **Corregido**: `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy` y HSTS en `next.config.ts`; CSP con nonce en `proxy.ts` y CSP fija para `/api` y los `.svg`. Tests en `test/csp.test.ts` y `test/next-config.test.ts`. | -| Baja | El 503 del cron revelaba "sin CRON_SECRET". | **Corregido**: mensaje genérico ("Servicio no disponible"). | -| Media (op.) | El wizard de setup queda **bloqueado para siempre**; sin reset por UI. | **Aceptado/documentado**: es un bloqueo intencionado; la recuperación es `UPDATE "InstanceConfig" SET "setupCompletedAt" = NULL` (ver `docs/setup-wizard.md`). | - -Los demás hallazgos "confirmados" por el panel eran en realidad **controles correctos** -(ver abajo), no vulnerabilidades. - -## Controles verificados (lo que ya está bien) - -- **La autorización no depende del middleware**: `proxy.ts` solo hace un chequeo - *optimista* (¿existe la cookie?); la autoridad real es el **DAL** (`verifySession` valida - el JWT **y** la fila de sesión: existe, no revocada, no expirada) en cada página/acción. - Esto **mitiga los bypass de middleware** de Next. -- **RBAC de dos capas**: cada mutación comprueba membresía (`requireBoardAccess`/ - `apiBoardRole`) **y** rol (`canEditBoard`/`canManageBoard`). Las lecturas se acotan por - dueño-o-miembro en el `WHERE`. -- **Adjuntos**: descarga **autorizada por membresía** + `Content-Disposition: attachment` - (fuerza la descarga, no el render → mitiga XSS almacenado por SVG/HTML); subir requiere - editor; el nombre en disco es aleatorio (sin path traversal). -- **Inyección**: Prisma **parametriza** todas las consultas; el único SQL crudo - (`pg_notify` del bus, `TRUNCATE` de los tests) usa **parámetros/constantes**, nunca input - de usuario. La búsqueda usa `ILIKE` vía Prisma (parametrizada). -- **XSS**: React **escapa** el contenido de usuario (títulos, comentarios, notificaciones). - El único `dangerouslySetInnerHTML` es el script de tema, una **constante** sin input. -- **Secretos**: contraseñas con **bcrypt** (coste 12); tokens de API guardados **hasheados** - (SHA-256), mostrados una sola vez; el secreto del cron se compara en **tiempo constante**. -- **Sesiones**: cookie `httpOnly`, `Secure` cuando la petición llega por HTTPS (directa o - con `X-Forwarded-Proto: https` del proxy TLS; no depende de `NODE_ENV`, así que por - `http://IP:3000` va sin `Secure`), `sameSite=lax`, expiración; **revocación inmediata** - (logout, reset de contraseña, cierre forzado). Detrás de un proxy, este tiene que enviar - `X-Forwarded-Proto` (ver [self-hosting.md](./self-hosting.md#https-y-dominio-reverse-proxy)). -- **Anti-mass-assignment**: todas las acciones validan con **zod** (whitelist de campos). -- **Login**: límite de intentos por cuenta (email) siempre; límite por IP **solo** con - `TRUSTED_PROXY_HOPS > 0` (por defecto 0: sin límite por IP). Más auditoría de intentos y - de bloqueos. Valores y configuración en - [operations.md](./operations.md#límite-de-intentos-de-login-rate-limiting) y +| **High** | **IDOR in `moveCard`**: it only checked the DESTINATION board, not the SOURCE one. An editor who knew a card's id could move into their own board a card from another board they had no access to. | **Fixed**: `moveCard` requires `cardBelongsToBoard(cardId, destination)` (the card must already be on the destination board; cards are not moved between boards). Regression test in `security.int.test.ts`. | +| Medium (ops) | An unset `SESSION_SECRET` fell back to an empty/predictable key (forgeable sessions). | **Fixed**: `SESSION_SECRET` (≥16 characters) is required to sign and verify; it fails loudly. | +| Medium (ops) | `DATABASE_URL` was not validated: the adapter would fall back to libpq defaults and connect to an unintended database. | **Fixed**: `createPrismaClient` throws if it is missing (except during `next build`). | +| Low | Security headers were missing (clickjacking, sniffing, CSP, HSTS). | **Fixed**: `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy` and HSTS in `next.config.ts`; a nonce-based CSP in `proxy.ts` and a fixed CSP for `/api` and `.svg` files. Tests in `test/csp.test.ts` and `test/next-config.test.ts`. | +| Low | The cron 503 revealed "no CRON_SECRET". | **Fixed**: generic message ("Service unavailable."). | +| Medium (ops) | The setup wizard is **locked forever** once completed; there is no reset in the UI. | **Accepted/documented**: the lock is intentional; recovery is `UPDATE "InstanceConfig" SET "setupCompletedAt" = NULL` (see `docs/setup-wizard.md`). | + +The other findings the panel "confirmed" were actually **correct controls** (see below), +not vulnerabilities. + +## Verified controls + +- **Authorization does not depend on the middleware**: `proxy.ts` only does an + *optimistic* check (does the cookie exist?); the real authority is the **DAL** + (`verifySession` validates the JWT **and** the session row: it exists, is not revoked + and has not expired) on every page and action. This **mitigates Next middleware + bypasses**. +- **Two-layer RBAC**: every mutation checks membership (`requireBoardAccess`/ + `apiBoardRole`) **and** role (`canEditBoard`/`canManageBoard`). Reads are scoped to + owner-or-member in the `WHERE` clause. +- **Attachments**: downloads are **authorized by membership** and sent with + `Content-Disposition: attachment` (forces a download instead of rendering → mitigates + stored XSS via SVG/HTML); uploading requires editor rights; the file name on disk is + random (no path traversal). +- **Injection**: Prisma **parameterizes** every query; the only raw SQL (`pg_notify` in + the bus, `TRUNCATE` in the tests) uses **parameters/constants**, never user input. + Search uses `ILIKE` through Prisma (parameterized). +- **XSS**: React **escapes** user content (titles, comments, notifications). The only + `dangerouslySetInnerHTML` is the theme script, a **constant** with no input. +- **Secrets**: passwords with **bcrypt** (cost 12); API tokens stored **hashed** (SHA-256) + and shown only once; the cron secret is compared in **constant time**. +- **Sessions**: `httpOnly` cookie, `Secure` when the request arrives over HTTPS (directly + or with `X-Forwarded-Proto: https` from the TLS proxy; it does not depend on `NODE_ENV`, + so over `http://IP:3000` it is sent without `Secure`), `sameSite=lax`, expiry; + **immediate revocation** (logout, password reset, forced sign-out). Behind a proxy, the + proxy must send `X-Forwarded-Proto` (see + [self-hosting.md](./self-hosting.md#https-and-domain-reverse-proxy)). +- **Mass-assignment protection**: every action validates with **zod** (field whitelist). +- **Login**: the per-account (email) limit always applies; the per-IP limit **only** with + `TRUSTED_PROXY_HOPS > 0` (default 0: no per-IP limit). Attempts and lockouts are also + audited. Values and configuration in + [operations.md](./operations.md#login-rate-limiting) and [ADR 0009](./decisions/0009-in-memory-rate-limit.md). -- **Cabeceras** (`next.config.ts` + `proxy.ts`): - - Páginas: **CSP con nonce por petición** (`app/lib/csp.ts`): `script-src` solo propios - con nonce y `'strict-dynamic'`, sin `eval` en producción, `frame-ancestors 'none'`, - `object-src 'none'`, `base-uri`/`form-action 'self'`. El layout raíz lee el nonce de - `x-nonce` para el script inline del tema. - - `/api/**`: `default-src 'none'` (solo JSON, SSE y descargas). - - `*.svg` (fuera del matcher de `proxy.ts`): `default-src 'none'; style-src - 'unsafe-inline'`, para que un SVG abierto directamente no ejecute scripts. - - `Strict-Transport-Security: max-age=31536000` (sin `includeSubDomains`), +- **Headers** (`next.config.ts` + `proxy.ts`): + - Pages: **per-request nonce CSP** (`app/lib/csp.ts`): `script-src` only own scripts + with a nonce and `'strict-dynamic'`, no `eval` in production, `frame-ancestors 'none'`, + `object-src 'none'`, `base-uri`/`form-action 'self'`. The root layout reads the nonce + from `x-nonce` for the inline theme script. + - `/api/**`: `default-src 'none'` (JSON, SSE and downloads only). + - `*.svg` (outside the `proxy.ts` matcher): `default-src 'none'; style-src + 'unsafe-inline'`, so an SVG opened directly cannot run scripts. + - `Strict-Transport-Security: max-age=31536000` (without `includeSubDomains`), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. - - Sin `upgrade-insecure-requests`: rompería las instalaciones por `http://IP:3000`. - El porqué de cada excepción está en [`DECISIONES.md`](./DECISIONES.md). -- **Tamaño de las peticiones a Server Actions**: `serverActions.bodySizeLimit` y - `proxyClientMaxBodySize` en 11 MB (`next.config.ts`), justo por encima del máximo de un - adjunto (10 MB). Límite conocido: es global y Next lo aplica **antes de autenticar**, así - que cualquiera puede enviar hasta 11 MB a una acción; las acciones rechazan después lo - que exceda sus límites propios. - -## Riesgos aceptados - -- **Password spraying con la configuración por defecto**: con `TRUSTED_PROXY_HOPS=0` no - hay límite por IP ni global, así que probar una misma contraseña contra muchos emails - distintos solo lo frena el coste de bcrypt (el límite por cuenta no lo detecta). Detrás - de un proxy de confianza, fija `TRUSTED_PROXY_HOPS` para activar el límite por IP. Ver - [ADR 0009](./decisions/0009-in-memory-rate-limit.md#consecuencias). - -## Recomendaciones futuras (backlog) - -- **Guard del último admin**: impedir borrar/degradar al último ADMIN de la instancia. -- **Endpoint de reset del setup** (admin) en vez de SQL manual. -- Email/Web Push y preferencias (ya en el backlog del roadmap). -- **Subida de adjuntos por route handler** (`/api/...`) con autenticación antes de leer el - cuerpo, para poder bajar el límite global de las Server Actions. Lo decide el - propietario. - -## Reproducir la auditoría + - No `upgrade-insecure-requests`: it would break installations served over + `http://IP:3000`. The reasoning behind each exception is in + [`DECISIONES.md`](./DECISIONES.md). +- **Server Action request size**: `serverActions.bodySizeLimit` and + `proxyClientMaxBodySize` are set to 11 MB (`next.config.ts`), just above the maximum + attachment size (10 MB). Known limitation: the limit is global and Next applies it + **before authentication**, so anyone can send up to 11 MB to an action; actions then + reject anything that exceeds their own limits. + +## Accepted risks + +- **Password spraying with the default configuration**: with `TRUSTED_PROXY_HOPS=0` there + is no per-IP or global limit, so trying the same password against many different emails + is only slowed down by the bcrypt cost (the per-account limit does not catch it). Behind + a trusted proxy, set `TRUSTED_PROXY_HOPS` to enable the per-IP limit. See + [ADR 0009](./decisions/0009-in-memory-rate-limit.md#consequences). + +## Future recommendations + +- **Last-admin guard**: an explicit check that prevents deleting or demoting the last + ADMIN of the instance. +- **Setup reset endpoint** (admin) instead of manual SQL. +- Email/Web Push and notification preferences (already on the [roadmap](./roadmap.md)). +- **Attachment uploads through a route handler** (`/api/...`) that authenticates before + reading the body, so the global Server Action limit can be lowered. + +## Reproducing the audit ```bash -pnpm audit # dependencias +pnpm audit # dependencies pnpm lint && pnpm typecheck -pnpm test && pnpm test:integration # incluye tests de autorización (IDOR, 403/404) +pnpm test && pnpm test:integration # includes authorization tests (IDOR, 403/404) ``` -La auditoría adversarial multi-agente se relanza con el workflow `orgtable-security-audit`. +The adversarial multi-agent audit is rerun with the `orgtable-security-audit` workflow. -## Reportar una vulnerabilidad +## Reporting a vulnerability -Ver [`SECURITY.md`](../SECURITY.md) en la raíz del repositorio. +See [`SECURITY.md`](../SECURITY.md) at the repository root. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 4287d49..9aed35e 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -1,165 +1,171 @@ -# Self-hosting: operar orgtable en producción +# Self-hosting: running orgtable in production -Esta guía cubre el día a día de una instancia de orgtable ya instalada (ver -[instalación](./installation.md)). Todo gira en torno al `docker-compose.yml` y al -`.env` generados durante la instalación. +This guide covers the day-to-day running of an orgtable instance that is already installed +(see [installation](./installation.md)). Everything revolves around the +`docker-compose.yml` and the `.env` generated during installation. -## Comandos básicos +## Basic commands -Desde el directorio de instalación (por defecto `~/orgtable`): +From the installation directory (`~/orgtable` by default): ```bash -docker compose ps # estado de los servicios -docker compose logs -f app # logs de la aplicación en vivo -docker compose logs -f postgres # logs de la base de datos -docker compose restart app # reiniciar solo la app -docker compose down # parar todo (los datos persisten en el volumen) -docker compose up -d # levantar de nuevo +docker compose ps # service status +docker compose logs -f app # live app logs +docker compose logs -f postgres # database logs +docker compose restart app # restart the app only +docker compose down # stop everything (data persists in the volume) +docker compose up -d # start again ``` -## Actualizar a una versión nueva +## Updating to a new version -La imagen se construye localmente, así que actualizar es traer el código nuevo y -reconstruir. Hay un script que lo hace: +The image is built locally, so updating means fetching the new code and rebuilding. A +script does it for you: ```bash cd ~/orgtable -scripts/update.sh # copia de seguridad, git pull --ff-only y docker compose up -d --build +scripts/update.sh # backup, git pull --ff-only and docker compose up -d --build ``` -Las migraciones de base de datos pendientes se aplican **solas** al arrancar el -contenedor (`prisma migrate deploy` en el entrypoint; es idempotente). El script hace -antes una copia de seguridad y no actualiza si la copia falla; un `docker compose up -d ---build` a mano no la hace. Ver [docker.md](./docker.md) y -[operations.md](./operations.md#actualizaciones). +Pending database migrations are applied **automatically** when the container starts +(`prisma migrate deploy` in the entrypoint; it is idempotent). The script takes a backup +first and does not update if the backup fails; a manual `docker compose up -d --build` +does not take one. See [docker.md](./docker.md) and +[operations.md](./operations.md#updates). -## Copias de seguridad +## Backups -Hay **dos** cosas que respaldar: la base de datos (volumen `pgdata`) y los **adjuntos** -(volumen `uploads`). El script [`scripts/backup.sh`](../scripts/backup.sh) hace ambas: +There are **two** things to back up: the database (`pgdata` volume) and the +**attachments** (`uploads` volume). The [`scripts/backup.sh`](../scripts/backup.sh) +script does both: ```bash -scripts/backup.sh # genera backups/orgtable-db-*.sql.gz y -uploads-*.tar.gz -scripts/restore.sh backups/orgtable-db-<fecha>.sql.gz # restaurar la BD +scripts/backup.sh # creates backups/orgtable-db-*.sql.gz and -uploads-*.tar.gz +scripts/restore.sh backups/orgtable-db-<date>.sql.gz # restore the database ``` -> Programa `scripts/backup.sh` con un `cron` del sistema y guarda copias fuera del -> servidor. Las copias contienen datos personales. Detalles completos (cron, permisos, -> restauración de adjuntos, exportar/importar tableros sueltos) en -> [operations.md](./operations.md#copias-de-seguridad). +> Schedule `scripts/backup.sh` with a system `cron` job and keep copies off the server. +> Backups contain personal data. Full details (cron, permissions, restoring attachments, +> exporting/importing single boards) in +> [operations.md](./operations.md#backups). -## El archivo `.env` +## The `.env` file -Contiene los secretos de la instancia (contraseña de PostgreSQL, `SESSION_SECRET`). -Generado con permisos `600` por el instalador. +It holds the instance secrets (PostgreSQL password, `SESSION_SECRET`). The installer +creates it with `600` permissions. -- **No lo borres ni cambies `POSTGRES_PASSWORD`** una vez creado el volumen: PostgreSQL - fija la contraseña en la primera inicialización del volumen, así que cambiarla - después provoca errores de autenticación (tendrías que recrear el volumen y perder - los datos). -- Si cambias `SESSION_SECRET`, todas las sesiones activas se invalidan (los usuarios - tendrán que volver a iniciar sesión). +- **Do not delete it or change `POSTGRES_PASSWORD`** once the volume exists: PostgreSQL + sets the password when the volume is first initialized, so changing it later causes + authentication errors (you would have to recreate the volume and lose the data). +- If you change `SESSION_SECRET`, every active session is invalidated (users have to + sign in again). -## HTTPS y dominio (reverse proxy) +## HTTPS and domain (reverse proxy) -orgtable sirve HTTP plano en el puerto `APP_PORT` (3000 por defecto). Se puede usar así, -por ejemplo en `http://IP:3000` para el primer arranque o en una red local, pero **la -contraseña y la cookie de sesión viajan sin cifrar**. Para un uso real, ponlo detrás de -un proxy inverso con TLS. +orgtable serves plain HTTP on `APP_PORT` (3000 by default). You can use it that way, for +example at `http://IP:3000` for the first start or on a local network, but **the password +and the session cookie travel unencrypted**. For real use, put it behind a reverse proxy +with TLS. -La cookie de sesión lleva el atributo `Secure` cuando la petición llega por HTTPS: -directamente o con la cabecera `X-Forwarded-Proto: https` que añade el proxy. Por HTTP -plano se envía sin `Secure` para que el login funcione. +The session cookie gets the `Secure` attribute when the request arrives over HTTPS: +directly, or with the `X-Forwarded-Proto: https` header added by the proxy. Over plain +HTTP it is sent without `Secure` so that sign-in works. -Tres pasos: +Three steps: -1. **Publica la app solo en local**, para que nadie llegue a ella sin pasar por el - proxy. En el `.env`: +1. **Publish the app on localhost only**, so nobody can reach it without going through + the proxy. In `.env`: ```env APP_PORT=127.0.0.1:3000 ``` - y aplica el cambio con `docker compose up -d`. Un firewall como `ufw` **no basta**: - Docker publica los puertos con sus propias reglas de iptables y se lo salta. + and apply it with `docker compose up -d`. A firewall such as `ufw` is **not enough**: + Docker publishes ports with its own iptables rules and bypasses it. -2. **Indica que hay un proxy delante**, para que el límite de intentos de login use la - IP real del cliente ([operations.md](./operations.md#límite-de-intentos-de-login-rate-limiting)). - En el `.env`: +2. **Declare that there is a proxy in front**, so the sign-in rate limit uses the real + client IP ([operations.md](./operations.md#login-rate-limiting)). + In `.env`: ```env TRUSTED_PROXY_HOPS=1 ``` - Con `TRUSTED_PROXY_HOPS` mayor que 0 y el puerto de la app abierto al exterior, - cualquiera podría falsear su IP con `X-Forwarded-For`: haz antes el paso 1. + With `TRUSTED_PROXY_HOPS` greater than 0 and the app port open to the outside, anyone + could spoof their IP with `X-Forwarded-For`: do step 1 first. -3. **Configura el proxy.** Con **Caddy** (certificados automáticos): +3. **Configure the proxy.** With **Caddy** (automatic certificates): ```caddyfile - tablero.midominio.com { + boards.example.com { reverse_proxy 127.0.0.1:3000 } ``` - Caddy ya envía `X-Forwarded-Proto` y, mientras no configures `trusted_proxies`, - sustituye el `X-Forwarded-For` que mande el cliente por su IP real. No añadas - `trusted_proxies` salvo que haya otro proxy delante de Caddy. + Caddy already sends `X-Forwarded-Proto` and, as long as you do not configure + `trusted_proxies`, replaces any `X-Forwarded-For` sent by the client with its real + IP. Do not add `trusted_proxies` unless there is another proxy in front of Caddy. - Con **nginx** (certificados aparte, p. ej. con certbot): + With **nginx** (certificates handled separately, e.g. with certbot): ```nginx server { listen 443 ssl; - server_name tablero.midominio.com; + server_name boards.example.com; # ssl_certificate / ssl_certificate_key … location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; - # La IP del cliente tal como la ve nginx, sin lo que mande el propio cliente. + # The client IP as nginx sees it, ignoring whatever the client sends. proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; - # Tiempo real (SSE): sin buffer y con conexiones largas. + # Real time (SSE): no buffering and long-lived connections. proxy_buffering off; proxy_read_timeout 1h; } } ``` -## Seguridad +## Security -- **PostgreSQL no se expone al host**: en el `docker-compose.yml` el servicio - `postgres` no publica puertos, solo es accesible desde la red interna de Compose. -- Mantén el servidor y Docker actualizados. -- Si no usas proxy inverso, recuerda que Docker se salta `ufw`: para cerrar el puerto - de la app usa `APP_PORT=127.0.0.1:3000` o reglas en la cadena `DOCKER-USER` de - iptables. +- **PostgreSQL is not exposed to the host**: in `docker-compose.yml` the `postgres` + service publishes no ports; it is only reachable from the internal Compose network. +- Keep the server and Docker up to date. +- If you do not use a reverse proxy, remember that Docker bypasses `ufw`: to close the + app port use `APP_PORT=127.0.0.1:3000` or rules in the iptables `DOCKER-USER` chain. -## Restablecer el asistente de configuración +## Opening the instance to the public -El estado "configurado" vive en la fila `InstanceConfig` de la base de datos. Si -necesitas volver a ejecutar el [asistente inicial](./setup-wizard.md) (por ejemplo en -una demo), borra esa fila: +If you let anyone sign up, you become the data controller for their data. Before enabling +sign-up in **Admin › Instance**, go through the checklist in +[privacy.md](./privacy.md#checklist-for-opening-a-public-instance): HTTPS, +`TRUSTED_PROXY_HOPS`, both cron jobs, backups and your legal details. If you have modified +the code, set `ORGTABLE_SOURCE_URL` to offer your version, as the AGPL requires. + +## Resetting the setup wizard + +The "configured" state lives in the `InstanceConfig` row of the database. If you need to +run the [setup wizard](./setup-wizard.md) again (for example on a demo), delete that row: ```bash docker compose exec -T postgres sh -c \ 'psql -U "$POSTGRES_USER" "$POSTGRES_DB" -c "DELETE FROM \"InstanceConfig\";"' ``` -La próxima visita mostrará de nuevo el asistente. Esto no borra usuarios ni tableros -existentes. Antes de hacerlo, ten en cuenta: - -- **Mientras el asistente está abierto, cualquiera que llegue a la app puede crear una - cuenta de administrador.** Publicar la app solo en local (`APP_PORT=127.0.0.1:3000`) - no basta: detrás de un proxy inverso, `/setup` sigue abierto por el dominio. Mientras - dure, corta ese acceso (para el proxy o bloquea `/setup` en él, p. ej. en Caddy con - `respond /setup* 403`) y completa el asistente por un túnel SSH: - `ssh -L 3000:127.0.0.1:3000 usuario@servidor` y abre `http://localhost:3000`. Si no - puedes cortar el acceso, o `APP_PORT` publica la app en todas las interfaces, - completa el asistente justo después del reseteo. -- El asistente crea un administrador **nuevo**: usa un email que no exista ya, o la - creación fallará. -- No sirve para recuperar el acceso de un administrador existente. Para eso, ver - [setup-wizard.md](./setup-wizard.md#recuperar-el-acceso-de-un-administrador). +The next visit shows the wizard again. This does not delete existing users or boards. +Before doing it, keep in mind: + +- **While the wizard is open, anyone who reaches the app can create an admin account.** + Publishing the app on localhost only (`APP_PORT=127.0.0.1:3000`) is not enough: behind + a reverse proxy, `/setup` is still reachable through the domain. For the duration, cut + that access (stop the proxy or block `/setup` in it, e.g. in Caddy with + `respond /setup* 403`) and complete the wizard through an SSH tunnel: + `ssh -L 3000:127.0.0.1:3000 user@server` and open `http://localhost:3000`. If you + cannot cut access, or `APP_PORT` publishes the app on every interface, complete the + wizard right after the reset. +- The wizard creates a **new** admin: use an email address that does not exist yet, or + the creation fails. +- It does not recover access for an existing admin. For that, see + [setup-wizard.md](./setup-wizard.md#recovering-access-for-an-admin). diff --git a/docs/setup-wizard.md b/docs/setup-wizard.md index 45369b5..0da3e34 100644 --- a/docs/setup-wizard.md +++ b/docs/setup-wizard.md @@ -1,51 +1,47 @@ -# Asistente de configuración inicial (Setup Wizard) +# Setup wizard -Esta guía está dirigida a **auto-hospedadores** y **contribuidores** de orgtable. -Explica qué es el asistente de configuración inicial, cómo decide si debe -mostrarse y por qué su lógica es segura frente a envíos duplicados. +This guide is for **self-hosters** and **contributors** of orgtable. It explains what the +setup wizard is, how it decides whether to show up, and why its logic is safe against +duplicate submissions. -Relacionado: [`docs/installation.md`](./installation.md) · +Related: [`docs/installation.md`](./installation.md) · [`docs/authentication.md`](./authentication.md) · [`docs/decisions/0003-db-creds-in-env.md`](./decisions/0003-db-creds-in-env.md) -## Qué es +## What it is -El **setup wizard** es el asistente que aparece **la primera vez** que se abre -una instancia recién instalada de orgtable. Vive en la ruta `/setup` y su único -propósito es dejar la instancia lista para usarse creando la primera cuenta de -administrador. +The **setup wizard** is the screen shown **the first time** a freshly installed orgtable +instance is opened. It lives at `/setup`, and its only purpose is to make the instance +ready to use by creating the first admin account. -Te pide cuatro datos: +It asks for four things: -1. **Nombre de la instancia** — una etiqueta para tu equipo o servidor - (p. ej. "Mi equipo"). Máximo 60 caracteres. -2. **Tu nombre** — máximo 60 caracteres. -3. **Tu email** — será el usuario del administrador. -4. **Una contraseña** — entre 8 y 200 caracteres. +1. **Instance name**: a label for your team or server (e.g. "My team"). At most 60 + characters. +2. **Your name**: at most 60 characters. +3. **Your email**: the admin's sign-in. +4. **A password**: between 8 and 200 characters. -El formulario es minimalista a propósito: +The form is deliberately minimal: -> Este asistente solo aparece una vez. Crea tu cuenta de administrador. +> This wizard only appears once. Create your admin account. -### Lo que el wizard NO pide +### What the wizard does NOT ask for -El asistente **no pide credenciales de base de datos**. La conexión a PostgreSQL -no se configura aquí: la genera el script `install.sh` y se inyecta por variables -de entorno en tiempo de arranque. El razonamiento detrás de esta decisión está -documentado en -[`docs/decisions/0003-db-creds-in-env.md`](./decisions/0003-db-creds-in-env.md). -Para el proceso de instalación completo, consulta -[`docs/installation.md`](./installation.md). +The wizard **does not ask for database credentials**. The PostgreSQL connection is not +configured here: the `install.sh` script generates it and it is injected through +environment variables at start-up. The reasoning behind this decision is documented in +[`docs/decisions/0003-db-creds-in-env.md`](./decisions/0003-db-creds-in-env.md). For the +full installation process, see [`docs/installation.md`](./installation.md). -Cuando el wizard se ejecuta, la base de datos **ya está conectada y migrada**; -de hecho, el wizard necesita la base de datos para poder consultar y escribir su -fuente de verdad. +When the wizard runs, the database is **already connected and migrated**; in fact, the +wizard needs the database to read and write its source of truth. -## Fuente de verdad: `InstanceConfig.setupCompletedAt` +## Source of truth: `InstanceConfig.setupCompletedAt` -Toda la lógica del asistente gira en torno a un único campo de la base de datos. -La configuración global de la instancia se guarda en una **fila única** del -modelo `InstanceConfig` (su `id` es siempre el valor fijo `"singleton"`): +All the wizard logic revolves around a single database field. The global instance +configuration is stored in a **single row** of the `InstanceConfig` model (its `id` is +always the fixed value `"singleton"`): ```prisma // prisma/schema.prisma @@ -57,15 +53,15 @@ model InstanceConfig { } ``` -El campo clave es `setupCompletedAt`: +The key field is `setupCompletedAt`: -- Mientras es **`null`**, la instancia se considera **sin configurar** y el - wizard (`/setup`) está **activo**. -- En cuanto tiene una **fecha**, la instancia está configurada y el wizard queda - **bloqueado para siempre**. +- While it is **`null`**, the instance is considered **not configured** and the wizard + (`/setup`) is **active**. +- As soon as it holds a **date**, the instance is configured and the wizard is + **locked for good**. -Esa pregunta — "¿está la instancia ya configurada?" — se centraliza en una sola -función del Data Access Layer, `isSetupComplete()`: +That question ("is the instance already configured?") is answered by a single Data +Access Layer function, `isSetupComplete()`: ```ts // app/lib/dal.ts @@ -73,34 +69,34 @@ export const getInstanceConfig = cache(async () => { return prisma.instanceConfig.findUnique({ where: { id: INSTANCE_ID } }); }); -/** true si el setup wizard ya se completó. */ +/** true if the setup wizard has been completed. */ export const isSetupComplete = cache(async () => { const config = await getInstanceConfig(); return Boolean(config?.setupCompletedAt); }); ``` -`isSetupComplete()` simplemente comprueba si `setupCompletedAt` tiene valor. -Está envuelta en `cache()` de React, así que dentro de una misma petición la -consulta solo se ejecuta una vez aunque se llame desde varios sitios. +`isSetupComplete()` simply checks whether `setupCompletedAt` has a value. It is wrapped +in React's `cache()`, so within a single request the query runs only once even if it is +called from several places. -## Flujo de gating (control de acceso) +## Gating flow -El estado de `setupCompletedAt` determina a dónde se redirige al visitante, -formando un control cruzado entre el wizard y el resto de la app: +The state of `setupCompletedAt` decides where the visitor is redirected, forming a +two-way check between the wizard and the rest of the app: -- **`/setup`** redirige a **`/login`** si el setup **ya está completo** (no tiene - sentido reconfigurar una instancia ya inicializada). -- Las páginas de **login** y todo el **área autenticada** redirigen a - **`/setup`** si el setup **NO está completo** (no tiene sentido iniciar sesión - en una instancia sin administrador). +- **`/setup`** redirects to **`/login`** if setup is **already complete** (there is no + point in reconfiguring an initialized instance). +- The **login** pages and the whole **authenticated area** redirect to **`/setup`** if + setup is **NOT complete** (there is no point in signing in to an instance with no + admin). -La parte del lado de `/setup` se ve en su propia página: +The `/setup` side lives in its own page: ```tsx // app/setup/page.tsx export default async function SetupPage() { - // Si ya se completó el setup, el wizard queda bloqueado para siempre. + // Once setup is complete, the wizard is locked for good. if (await isSetupComplete()) redirect("/login"); return ( @@ -111,26 +107,26 @@ export default async function SetupPage() { } ``` -El resultado es que solo existe exactamente **un** estado válido por instancia: o -bien estás en el wizard (sin configurar), o bien estás en login / la app -(configurada). Nunca ambos a la vez. +As a result there is exactly **one** valid state per instance: either you are in the +wizard (not configured) or you are on the login page / in the app (configured). Never +both. -## La acción `completeSetup` +## The `completeSetup` action -Cuando envías el formulario, se ejecuta la Server Action `completeSetup` -(`app/lib/actions/setup.ts`). Sus pasos son: +When you submit the form, the `completeSetup` Server Action +(`app/lib/actions/setup.ts`) runs. Its steps are: -### 1. Corte temprano +### 1. Early exit -Si `isSetupComplete()` ya es cierto, la acción hace `redirect("/login")` antes de -validar nada y de gastar el hash de bcrypt. Hace falta aunque `/setup` ya redirija: -el ID de la acción sigue en el bundle de la página y se puede invocar a mano. No -sustituye a la comprobación dentro de la transacción, que es la definitiva. +If `isSetupComplete()` is already true, the action calls `redirect("/login")` before +validating anything or spending the bcrypt hash. This is needed even though `/setup` +already redirects: the action ID is still in the page bundle and can be invoked by hand. +It does not replace the check inside the transaction, which is the authoritative one. -### 2. Validación con zod +### 2. Validation with zod -Los cuatro campos se validan con `setupSchema` antes de tocar la base de datos. -Si la validación falla, la acción devuelve un error legible y no hace nada más: +The four fields are validated with `setupSchema` before touching the database. If +validation fails, the action returns a readable (localized) error and does nothing else: ```ts const parsed = setupSchema.safeParse({ @@ -140,143 +136,136 @@ const parsed = setupSchema.safeParse({ password: formData.get("password"), }); if (!parsed.success) { - return { error: parsed.error.issues[0]?.message ?? "Datos no válidos." }; + return { error: /* message of the first zod issue */ }; } ``` -La contraseña se hashea (`hashPassword`) **antes** de abrir la transacción. +The password is hashed (`hashPassword`) **before** the transaction is opened. -### 3. Transacción serializada +### 3. Serialized transaction -El núcleo es `completeInstanceSetup` (`app/lib/services/setup.ts`), una transacción -de Prisma (`prisma.$transaction`) que hace, en este orden: +The core is `completeInstanceSetup` (`app/lib/services/setup.ts`), a Prisma transaction +(`prisma.$transaction`) that does, in this order: -1. **Toma un advisory lock de transacción**: - `pg_advisory_xact_lock(hashtext('orgtable:setup'))`. Un segundo envío simultáneo - espera aquí hasta que el primero confirme o aborte. -2. **Recomprueba `setupCompletedAt`.** Si ya tiene fecha, devuelve - `{ ok: false, reason: "ALREADY_SETUP" }` (no lanza) y no escribe nada. -3. **Crea el usuario administrador** con la contraseña ya hasheada, el email en - minúsculas y el rol `ADMIN`. -4. **Marca `setupCompletedAt`** con la fecha actual y guarda el `instanceName` - (mediante `upsert`, por si la fila `InstanceConfig` ya existía sin completar), y - devuelve `{ ok: true, userId }`. +1. **Takes a transaction-level advisory lock**: + `pg_advisory_xact_lock(hashtext('orgtable:setup'))`. A second simultaneous + submission waits here until the first one commits or aborts. +2. **Re-checks `setupCompletedAt`.** If it already has a date, it returns + `{ ok: false, reason: "ALREADY_SETUP" }` (it does not throw) and writes nothing. +3. **Creates the admin user** with the already-hashed password, the email in lowercase + and the `ADMIN` role. +4. **Sets `setupCompletedAt`** to the current date and stores the `instanceName` (with + `upsert`, in case the `InstanceConfig` row already existed without being completed), + and returns `{ ok: true, userId }`. -La recomprobación del paso 2 **solo protege porque el lock del paso 1 serializa los -envíos**. Con el aislamiento por defecto de PostgreSQL (READ COMMITTED), sin el lock -dos envíos casi simultáneos leerían los dos «sin configurar» y crearían dos -administradores. No quites el lock: el porqué completo y las alternativas descartadas -están en [`DECISIONES.md`](./DECISIONES.md) («Setup: advisory lock, corte temprano y -causa en el log»), y `test/integration/setup.int.test.ts` lo comprueba con envíos -simultáneos. +The re-check in step 2 **only protects because the lock in step 1 serializes the +submissions**. With PostgreSQL's default isolation (READ COMMITTED), without the lock two +nearly simultaneous submissions would both read "not configured" and create two admins. +Do not remove the lock: the full reasoning and the alternatives considered are in +[`DECISIONES.md`](./DECISIONES.md) (the entry about the setup advisory lock), and +`test/integration/setup.int.test.ts` checks it with simultaneous submissions. -Como todo ocurre en una transacción, si algo falla a mitad no se queda un estado -inconsistente: o se crean el administrador y la marca de configuración juntos, o -no se crea nada. +Because everything happens in one transaction, a failure halfway through never leaves +an inconsistent state: either the admin and the setup flag are created together, or +nothing is. -### 4. Auto-login y redirección +### 4. Auto sign-in and redirect -Tras la transacción, la acción crea la sesión (auto-login con `createSession`) y -redirige a `/boards`: +After the transaction, the action creates the session (auto sign-in with +`createSession`) and redirects to `/boards`: ```ts await createSession(outcome.userId); redirect("/boards"); ``` -### Por qué `redirect()` se llama FUERA del try/catch +### Why `redirect()` is called OUTSIDE the try/catch -Esto es deliberado y conviene entenderlo si vas a modificar la acción: +This is deliberate and worth understanding if you change the action: ```ts // app/lib/actions/setup.ts try { - // ... la transacción ... + // ... the transaction ... } catch (e) { - outcome = /* ... clasifica el error ... */; + outcome = /* ... classify the error ... */; } -// redirect() se llama FUERA del try/catch: lanza una excepción interna de Next -// (NEXT_REDIRECT) que no debe ser capturada por el catch de arriba. +// redirect() is called OUTSIDE the try/catch: it throws an internal Next exception +// (NEXT_REDIRECT) that must not be caught by the catch above. if (!outcome.ok) { if (outcome.reason === "ALREADY_SETUP") redirect("/login"); - return { error: "No se pudo completar la configuración. Inténtalo de nuevo." }; + return { error: /* localized "could not complete setup, try again" message */ }; } await createSession(outcome.userId); redirect("/boards"); ``` -En Next.js, `redirect()` **no devuelve** un valor: funciona **lanzando una -excepción interna** (`NEXT_REDIRECT`) que el framework intercepta para realizar -la navegación. Si llamáramos a `redirect()` dentro del `try`, el `catch` de la -transacción **capturaría esa excepción de control** y la confundiría con un error -real, rompiendo la redirección. +In Next.js, `redirect()` **does not return** a value: it works by **throwing an internal +exception** (`NEXT_REDIRECT`) that the framework intercepts to perform the navigation. If +`redirect()` were called inside the `try`, the transaction's `catch` **would catch that +control-flow exception** and mistake it for a real error, breaking the redirect. -Por eso la transacción solo produce un **`outcome`** (un dato plano: -`{ ok: true, userId }` o `{ ok: false, reason }`), y todas las llamadas a -`redirect()` quedan **después** del `try/catch`, donde nada las va a capturar. +That is why the transaction only produces an **`outcome`** (plain data: +`{ ok: true, userId }` or `{ ok: false, reason }`), and every `redirect()` call comes +**after** the `try/catch`, where nothing will catch it. -## Bloqueo post-setup: triple defensa +## Post-setup lock: three layers of defense -Una vez completado el setup, reabrirlo se evita por **tres** mecanismos -independientes: +Once setup is complete, reopening it is prevented by **three** independent mechanisms: -1. **El campo en base de datos** (`InstanceConfig.setupCompletedAt`): la fuente - de verdad persistente. Mientras tenga fecha, la instancia se considera - configurada. -2. **La recomprobación en la transacción, serializada por el advisory lock**: aunque - dos formularios se envíen a la vez, el lock hace que el segundo espere al - primero y, al recomprobar, vea el setup hecho; devuelve `ALREADY_SETUP`, que - redirige a `/login`. Sin el lock, la recomprobación sola no basta (ver - [paso 3](#3-transacción-serializada)). -3. **Las comprobaciones previas con `isSetupComplete()`**: el redirect de la página - (`/setup` → `/login`) evita mostrar el formulario, y el corte temprano de la - acción evita validar y hashear si alguien la invoca a mano. +1. **The database field** (`InstanceConfig.setupCompletedAt`): the persistent source of + truth. While it holds a date, the instance is considered configured. +2. **The re-check inside the transaction, serialized by the advisory lock**: even if two + forms are submitted at once, the lock makes the second wait for the first and, on + re-checking, see that setup is done; it returns `ALREADY_SETUP`, which redirects to + `/login`. Without the lock, the re-check alone is not enough (see + [step 3](#3-serialized-transaction)). +3. **The prior checks with `isSetupComplete()`**: the page redirect (`/setup` → + `/login`) avoids showing the form, and the action's early exit avoids validating and + hashing if someone invokes it by hand. -Estas tres capas se solapan a propósito: las comprobaciones previas son la primera -línea (UX y coste), y la transacción con el lock es la red de seguridad real contra -condiciones de carrera. +These three layers overlap on purpose: the prior checks are the first line (UX and +cost), and the locked transaction is the real safety net against race conditions. -### Caso límite: borrar la fila `InstanceConfig` +### Edge case: deleting the `InstanceConfig` row -Como el estado vive en la base de datos, si alguien **elimina manualmente** la -fila `InstanceConfig` (o pone su `setupCompletedAt` a `null`), `isSetupComplete()` -volverá a devolver `false` y **el wizard reaparecerá**. +Since the state lives in the database, if someone **manually deletes** the +`InstanceConfig` row (or sets its `setupCompletedAt` to `null`), `isSetupComplete()` +returns `false` again and **the wizard reappears**. -Esto es **aceptable**: es tu servidor y tu base de datos. orgtable no intenta -protegerse de quien ya tiene acceso directo a PostgreSQL. Lo que hay que tener en -cuenta al hacerlo (exposición mientras el asistente está abierto, email nuevo) está en -[self-hosting.md](./self-hosting.md#restablecer-el-asistente-de-configuración). +This is **acceptable**: it is your server and your database. orgtable does not try to +protect itself from someone who already has direct access to PostgreSQL. What to keep in +mind when doing it (exposure while the wizard is open, new email) is covered in +[self-hosting.md](./self-hosting.md#resetting-the-setup-wizard). -El asistente siempre crea un administrador **nuevo**; no sirve para recuperar uno -existente. +The wizard always creates a **new** admin; it cannot recover an existing one. -### Recuperar el acceso de un administrador +### Recovering access for an admin -Si un administrador olvida su contraseña: +If an admin forgets their password: -- **Si hay otro administrador**, que la restablezca desde `/admin/users` (ver +- **If there is another admin**, they can reset it from `/admin/users` (see [admin.md](./admin.md)). -- **Si no hay ninguno con acceso**, cambia el hash directamente en la base de datos. - Desde el directorio de instalación, genera el hash con la misma librería y coste que - usa la app y guárdalo: +- **If no admin has access**, change the hash directly in the database. From the + installation directory, generate the hash with the same library and cost the app uses + and store it: ```bash HASH=$(docker compose exec -T app node -e \ 'require("bcryptjs").hash(process.argv[1], 12).then((h) => process.stdout.write(h))' \ - 'la-contraseña-nueva') + 'the-new-password') docker compose exec -T postgres sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"' <<SQL - UPDATE "User" SET "passwordHash" = '$HASH' WHERE email = 'admin@ejemplo.com'; + UPDATE "User" SET "passwordHash" = '$HASH' WHERE email = 'admin@example.com'; SQL ``` - Sustituye la contraseña y el email (en minúsculas). La contraseña queda en el - historial de la shell: bórrala de él después (`history -d` o editando - `~/.bash_history`). + Replace the password and the email (in lowercase). The password stays in the shell + history: remove it afterwards (`history -d` or by editing `~/.bash_history`). --- -**Resumen:** el setup wizard de `/setup` solo se muestra mientras -`InstanceConfig.setupCompletedAt` es null, crea el administrador en una -transacción atómica protegida por triple defensa y hace auto-login a `/boards`. +**Summary:** the setup wizard at `/setup` is only shown while +`InstanceConfig.setupCompletedAt` is null, creates the admin in an atomic transaction +protected by three layers of defense, and signs you in straight to `/boards`. diff --git a/docs/testing.md b/docs/testing.md index 2a16cb9..ebe48a6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,145 +1,150 @@ -# Tests e integración continua +# Tests and continuous integration -orgtable tiene tests unitarios con **Vitest** y un workflow de **GitHub Actions** que se -ejecuta en cada push a `main` y en cada pull request. +orgtable has tests written with **Vitest** and a **GitHub Actions** workflow that runs on +every push to `main` and on every pull request. -Hay dos niveles: **tests unitarios** (sin BD ni red) y **tests de integración** (rutas de -la API, Server Actions y servicios contra una PostgreSQL real). +There are two levels: **unit tests** (no database or network) and **integration tests** +(API routes, Server Actions and services against a real PostgreSQL database). -## Ejecutar en local +## Running locally ```bash -pnpm test # unitarios (rápidos, sin BD) -pnpm test:watch # unitarios en modo watch +pnpm test # unit tests (fast, no database) +pnpm test:watch # unit tests in watch mode pnpm typecheck # tsc --noEmit pnpm lint # eslint -# Integración: requiere una BD de test (su nombre DEBE acabar en `_test`). -# Crea orgtable_test en tu Postgres de dev y aplícale las migraciones una vez: -createdb -h localhost -p 5433 -U orgtable orgtable_test # o vía docker exec psql +# Integration: needs a test database (its name MUST end in `_test`). +# Create orgtable_test in your dev Postgres and apply the migrations once: +createdb -h localhost -p 5433 -U orgtable orgtable_test # or through docker exec psql DATABASE_URL='postgresql://orgtable:PASS@localhost:5433/orgtable_test' pnpm exec prisma migrate deploy -# y luego: +# then: DATABASE_URL='postgresql://orgtable:PASS@localhost:5433/orgtable_test' pnpm test:integration ``` -## Qué cubre cada archivo +## What each file covers -El inventario completo es `ls test/*.test.ts test/integration/*.int.test.ts`. Las tablas -agrupan los archivos por área para saber dónde mirar o dónde añadir un test; el detalle -de cada caso está en los `describe`/`it` del propio archivo, así que aquí no se repite. -Un archivo nuevo se añade a la fila de su área. +The full inventory is `ls test/*.test.ts test/integration/*.int.test.ts`. The tables group +the files by area so you know where to look or where to add a test; the individual cases +are in each file's `describe`/`it` blocks, so they are not repeated here. Add a new file to +the row of its area. -### Tests unitarios +### Unit tests -Están en `test/` y no usan base de datos ni red. No todos son lógica pura: -`ops-scripts.test.ts` lanza los scripts de `scripts/` e `install.sh` con bash y un -`docker` falso en el `PATH`, `storage.test.ts` escribe en un directorio temporal y los de -interfaz renderizan componentes con `react-dom/server` (no hay jsdom). +They live in `test/` and use no database or network. Not all of them are pure logic: +`ops-scripts.test.ts` runs the `scripts/` scripts and `install.sh` with bash and a fake +`docker` on the `PATH`, `storage.test.ts` writes to a temporary directory, and the UI tests +render components with `react-dom/server` (there is no jsdom). -| Área | Archivos (`test/<nombre>.test.ts`) | +| Area | Files (`test/<name>.test.ts`) | |------|----------| -| Autorización, sesión y seguridad | `roles`, `tokens`, `auth`, `client-ip`, `rate-limit`, `csp`, `next-config` | -| Validación y datos | `validators`, `ordering`, `colors`, `search-params`, `format-date`, `client-imports` (ningún componente cliente importa zod ni `validators.ts`) | -| Tablero y tarjetas (lógica de cliente) | `board-filters`, `board-collision`, `optimistic`, `card-modal-helpers` | -| Tiempo real | `realtime-bus` (puente `LISTEN/NOTIFY` con un `pg` falso), `realtime-reconnect` | -| Interfaz y accesibilidad | `card-item`, `admin-forms-a11y`, `delete-board-button`, `delete-board-button-focus`, `metadata`, `contrast` (lee `app/globals.css`) | -| Operación | `ops-scripts`, `storage` | +| Authorization, session and security | `roles`, `tokens`, `auth`, `client-ip`, `rate-limit`, `csp`, `next-config`, `user-agent` | +| Validation and data | `validators`, `ordering`, `colors`, `search-params`, `format-date`, `initials`, `client-imports` (no client component imports zod or `validators.ts`) | +| Translations | `i18n` (every registered language has the same keys as English, with the same placeholders and tags and the plural forms its grammar needs; see [`locales/README.md`](../locales/README.md)) | +| Board and cards (client logic) | `board-filters`, `board-collision`, `optimistic`, `card-modal-helpers` | +| Real time | `realtime-bus` (`LISTEN/NOTIFY` bridge with a fake `pg`), `realtime-reconnect` | +| UI and accessibility | `card-item`, `calendar-view`, `list-column-delete`, `members-panel`, `admin-forms-a11y`, `form-error-reannounce`, `delete-board-button`, `delete-board-button-focus`, `metadata`, `contrast` (reads `app/globals.css`) | +| Operations | `ops-scripts`, `storage` | -### Tests de integración +### Integration tests -Ejercen el código **contra una PostgreSQL real** (efímera), en `test/integration/`: +They exercise the code **against a real (throwaway) PostgreSQL database**, in +`test/integration/`: -| Área | Archivos (`test/integration/<nombre>.int.test.ts`) | +| Area | Files (`test/integration/<name>.int.test.ts`) | |------|----------| -| API pública (`/api/v1`) | `boards-api`, `board-detail-api`, `cards-api`, `api-scopes`, `api-auth` | -| Autorización de Server Actions | `authz-actions`, `cards-actions`, `security`, `labels`, `members`, `board-metadata` | -| Sesión, login y administración | `session`, `auth`, `setup`, `users`, `admin-users`, `admin-revocation`, `tokens`, `audit` | -| Tarjetas y colaboración | `cards-due-date`, `collaboration` | -| Orden de listas y tarjetas | `positions`, `position-collation` | -| Adjuntos | `attachments-delete`, `attachments-download` | -| Notificaciones y recordatorios | `notifications`, `notifications-read`, `reminders`, `cron-reminders` | -| Tiempo real (SSE) | `realtime` | -| BD y utilidades de test | `helpers`, `prisma-pool` | - -Cómo funcionan: - -- Usan el **mismo `PrismaClient` que la app**, apuntado a la BD de test por `DATABASE_URL`. - Invocan **directamente los route handlers** de `app/api/v1/**` con un `Request` (Bearer - token), sin levantar un servidor. -- **Guardia de seguridad** (`test/integration/helpers.ts`): se niega a truncar si la BD no - acaba en `_test`, para no tocar datos de dev/producción jamás. -- Antes de cada test, `resetDb()` hace `TRUNCATE … CASCADE` de todas las tablas del - esquema (las lee de `pg_tables`, sin `_prisma_migrations`), vacía las cookies y - cabeceras del stub y los contadores del rate limit. -- `vitest.integration.config.ts` aliasa `next/cache` y `next/headers` a stubs (fuera de - Next no hay store de revalidación ni request), además de `server-only` y `@/`. Fija - además: - - `TZ=UTC`, para que la lógica de fechas (recordatorios) sea determinista. - - `SESSION_SECRET` de test (una clave fija que no protege nada real), necesaria para - firmar las sesiones. - - `fileParallelism: false`: todos los archivos comparten la misma BD y la vacían entre - tests, así que no pueden correr a la vez. -- **Server Actions con sesión**: el stub de `next/headers` - (`test/stubs/next-headers.ts`) guarda cookies y cabeceras en memoria. - - `loginAs(userId)` (en `helpers.ts`) crea una sesión real (fila `Session` + cookie - firmada) y a partir de ahí el DAL ve a ese usuario; llamarla otra vez cambia de - usuario. - - Para inyectar cabeceras (p. ej. `X-Forwarded-For`) se usa `__setHeaders`; - `__clearCookies` vacía las cookies y `__clearHeaders` las cabeceras. Se importan - desde `../stubs/next-headers`. - - Orden: `loginAs` solo vacía las cookies, así que las cabeceras puestas antes con - `__setHeaders` se mantienen (p. ej. `X-Forwarded-Proto: https` → cookie `Secure`). - `resetDb` vacía ambas, por lo que las cabeceras se ponen después de `resetDb`. - - Las redirecciones de Next se comprueban por su `digest`: +| Public API (`/api/v1`) | `boards-api`, `board-detail-api`, `cards-api`, `api-scopes`, `api-auth` | +| Server Action authorization | `authz-actions`, `cards-actions`, `security`, `labels`, `members`, `board-metadata` | +| Session, login and administration | `session`, `auth`, `setup`, `users`, `admin-users`, `admin-revocation`, `tokens`, `audit` | +| Accounts, sign-up and privacy | `account`, `registration`, `maintenance` | +| Cards and collaboration | `cards-due-date`, `collaboration` | +| List and card order | `positions`, `position-collation` | +| Attachments | `attachments-delete`, `attachments-download` | +| Notifications and reminders | `notifications`, `notifications-read`, `reminders`, `cron-reminders` | +| Real time (SSE) | `realtime` | +| Database and test utilities | `helpers`, `prisma-pool` | + +How they work: + +- They use **the same `PrismaClient` as the app**, pointed at the test database through + `DATABASE_URL`. They call the `app/api/v1/**` **route handlers directly** with a + `Request` (Bearer token), without starting a server. +- **Safety guard** (`test/integration/helpers.ts`): it refuses to truncate unless the + database name ends in `_test`, so dev or production data is never touched. +- Before each test, `resetDb()` runs `TRUNCATE … CASCADE` on every table in the schema + (read from `pg_tables`, excluding `_prisma_migrations`), and clears the stub's cookies + and headers and the rate-limit counters. +- `vitest.integration.config.ts` aliases `next/cache` and `next/headers` to stubs (outside + Next there is no revalidation store or request), as well as `server-only` and `@/`. It + also sets: + - `TZ=UTC`, so date logic (reminders) is deterministic. + - A test `SESSION_SECRET` (a fixed key that protects nothing real), needed to sign + sessions. + - `fileParallelism: false`: all files share the same database and empty it between + tests, so they cannot run at the same time. +- **Server Actions with a session**: the `next/headers` stub (`test/stubs/next-headers.ts`) + keeps cookies and headers in memory. + - `loginAs(userId)` (in `helpers.ts`) creates a real session (a `Session` row plus a + signed cookie); from then on the DAL sees that user. Calling it again switches user. + - To inject headers (for example `X-Forwarded-For`), use `__setHeaders`; + `__clearCookies` clears the cookies and `__clearHeaders` the headers. They are + imported from `../stubs/next-headers`. + - Order: `loginAs` only clears cookies, so headers set earlier with `__setHeaders` are + kept (for example `X-Forwarded-Proto: https` → `Secure` cookie). `resetDb` clears + both, so set headers after `resetDb`. + - Next redirects are checked through their `digest`: `rejects.toMatchObject({ digest: expect.stringContaining(";/boards;") })`. -- La lógica de creación de cuentas se extrajo a `app/lib/services/users.ts` - (`createUserAccount`) para poder probarla sin el contexto de request (cookies/admin); la - Server Action `createUser` la reutiliza. +- Account creation logic lives in `app/lib/services/users.ts` (`createUserAccount`) so it + can be tested without a request context (cookies/admin); the `createUser` Server Action + reuses it. -## Detalles de configuración +## Configuration details -- `vitest.config.ts` define dos alias para poder importar módulos de la app fuera de Next: - - `server-only` → un stub vacío (`test/stubs/server-only.ts`): en los tests no hay - frontera servidor/cliente. - - `@/…` → la raíz del proyecto, el mismo alias que usa la app. -- Los tests importan `describe/it/expect` de `vitest` (sin globals). +- `vitest.config.ts` defines two aliases so app modules can be imported outside Next: + - `server-only` → an empty stub (`test/stubs/server-only.ts`): there is no + server/client boundary in tests. + - `@/…` → the project root, the same alias the app uses. +- Tests import `describe/it/expect` from `vitest` (no globals). -## Integración continua +## Continuous integration -`.github/workflows/ci.yml` levanta un **servicio PostgreSQL** (`postgres:17`, BD -`orgtable_test`) y ejecuta, en orden: +`.github/workflows/ci.yml` starts a **PostgreSQL service** (`postgres:17`, database +`orgtable_test`) and runs, in order: -1. **Shellcheck** de `scripts/*.sh`, `install.sh` y `docker-entrypoint.sh`. `shellcheck` - viene instalado en el runner; en local se instala con `brew install shellcheck` y: +1. **Shellcheck** on `scripts/*.sh`, `install.sh` and `docker-entrypoint.sh`. `shellcheck` + is preinstalled on the runner; locally, install it with `brew install shellcheck` and + run: ```bash shellcheck scripts/*.sh install.sh docker-entrypoint.sh ``` -2. **Variables del compose**: comprueba que `docker compose config` pasa al contenedor - `app` `CRON_SECRET`, `TRUSTED_PROXY_HOPS` y `ORGTABLE_TIMEZONE`. Si una se cae del - `environment:`, la función que depende de ella deja de funcionar en Docker sin ningún - error. En local: +2. **Compose variables**: checks that `docker compose config` passes `CRON_SECRET`, + `TRUSTED_PROXY_HOPS`, `ORGTABLE_TIMEZONE`, `ORGTABLE_AUDIT_RETENTION_DAYS` and + `ORGTABLE_SOURCE_URL` to the `app` container. If one drops out of `environment:`, the + feature that depends on it stops working under Docker without any error. Locally: ```bash - CRON_SECRET=probe TRUSTED_PROXY_HOPS=7 ORGTABLE_TIMEZONE=Europe/Madrid docker compose config \ - | grep -E 'CRON_SECRET|TRUSTED_PROXY_HOPS|ORGTABLE_TIMEZONE' + CRON_SECRET=probe TRUSTED_PROXY_HOPS=7 ORGTABLE_TIMEZONE=Europe/Madrid \ + ORGTABLE_AUDIT_RETENTION_DAYS=45 ORGTABLE_SOURCE_URL=https://example.org/fork \ + docker compose config | grep -E 'CRON_SECRET|TRUSTED_PROXY_HOPS|ORGTABLE_' ``` -3. **Referencias de licencia**: un `git grep` falla si reaparece la licencia anterior - fuera de los archivos excluidos. La expresión y la lista de exclusiones están en el - paso «Check license references» de `ci.yml` (el porqué, en la ADR 0016); para - reproducirlo en local se copia de ahí. - -Estos tres pasos corren antes de instalar pnpm y Node. Después, sobre Node 22 (pnpm llega -con `pnpm/action-setup`, que lee `packageManager`): - -4. `pnpm install --frozen-lockfile` — instala con el lockfile (reproducible). -5. `pnpm audit --prod --audit-level high` — falla si hay vulnerabilidades altas o - críticas en dependencias de producción. -6. `pnpm exec prisma generate` — genera el cliente Prisma (lo necesitan el typecheck y el build). +3. **License**: checks that `package.json` declares `AGPL-3.0-or-later`, that `LICENSE` is + the AGPL with its attribution term (section 7(b)), and that no traces of the former + proprietary license remain outside the historical record. The commands are in the + "Check license" step of `ci.yml`; copy them from there to reproduce it locally. + +These three steps run before pnpm and Node are installed. Then, on Node 22 (pnpm comes from +`pnpm/action-setup`, which reads `packageManager`): + +4. `pnpm install --frozen-lockfile`: installs from the lockfile (reproducible). +5. `pnpm audit --prod --audit-level high`: fails on high or critical vulnerabilities in + production dependencies. +6. `pnpm exec prisma generate`: generates the Prisma client (needed by typecheck and + build). 7. `pnpm lint` 8. `pnpm typecheck` -9. `pnpm test` — tests unitarios. -10. `pnpm exec prisma migrate deploy` — crea el esquema en la BD de test del servicio. -11. `pnpm test:integration` — tests de integración contra esa BD. +9. `pnpm test`: unit tests, including the translation check (`test/i18n.test.ts`). +10. `pnpm exec prisma migrate deploy`: creates the schema in the service's test database. +11. `pnpm test:integration`: integration tests against that database. 12. `pnpm build` -`DATABASE_URL` apunta a la BD del servicio (`…/orgtable_test`). El build no se conecta -(rutas dinámicas); los tests de integración sí. +`DATABASE_URL` points to the service database (`…/orgtable_test`). The build does not +connect to it (dynamic routes); the integration tests do. diff --git a/install.sh b/install.sh index a646b7b..00394b6 100644 --- a/install.sh +++ b/install.sh @@ -1,41 +1,41 @@ #!/usr/bin/env bash -# orgtable — instalador self-hosted. +# orgtable — self-hosted installer. # # curl -sSL https://raw.githubusercontent.com/<owner>/orgtable/main/install.sh | bash # -# Instala Docker si falta, clona/actualiza el repositorio, genera un .env con -# secretos aleatorios y levanta la app (build local). Ver docs/installation.md. +# Installs Docker if missing, clones/updates the repository, generates a .env with +# random secrets and starts the app (local build). See docs/installation.md. set -euo pipefail INSTALL_DIR="${ORGTABLE_DIR:-$HOME/orgtable}" -REPO_URL="${ORGTABLE_REPO:-https://github.com/aimarmndzz/orgtable.git}" +REPO_URL="${ORGTABLE_REPO:-https://github.com/Aimarmndz/orgtable.git}" log() { printf '\033[1;36m[orgtable]\033[0m %s\n' "$1"; } # 1. Docker ------------------------------------------------------------------- if ! command -v docker >/dev/null 2>&1; then - log "Docker no está instalado. Instalándolo con get.docker.com..." + log "Docker is not installed. Installing it with get.docker.com..." curl -fsSL https://get.docker.com | sh fi if ! docker compose version >/dev/null 2>&1; then - echo "Error: se necesita Docker Compose v2 (docker compose). Instálalo y reintenta." >&2 + echo "Error: Docker Compose v2 (docker compose) is required. Install it and try again." >&2 exit 1 fi -# 2. Código fuente (build local necesita el repo completo) -------------------- +# 2. Source code (the local build needs the full repo) ------------------------ if [ -d "$INSTALL_DIR/.git" ]; then - log "Actualizando instalación existente en $INSTALL_DIR..." + log "Updating existing installation in $INSTALL_DIR..." git -C "$INSTALL_DIR" pull --ff-only else - log "Clonando orgtable en $INSTALL_DIR..." + log "Cloning orgtable into $INSTALL_DIR..." git clone "$REPO_URL" "$INSTALL_DIR" fi cd "$INSTALL_DIR" -# 3. .env con secretos aleatorios (idempotente: no sobrescribe si ya existe) --- +# 3. .env with random secrets (idempotent: never overwritten if it exists) ---- if [ ! -f .env ]; then - log "Generando .env con secretos aleatorios..." + log "Generating .env with random secrets..." POSTGRES_PASSWORD="$(openssl rand -hex 24)" SESSION_SECRET="$(openssl rand -hex 32)" CRON_SECRET="$(openssl rand -hex 32)" @@ -50,42 +50,43 @@ APP_PORT=3000 EOF chmod 600 .env else - log ".env ya existe: se conservan los secretos actuales." - # Las instalaciones anteriores no tenían CRON_SECRET: se añade sin tocar lo demás, para - # que los recordatorios de vencimiento se puedan programar (docs/reminders.md). + log ".env already exists: keeping the current secrets." + # Older installations had no CRON_SECRET: add it without touching anything else, so + # due-date reminders can be scheduled (docs/reminders.md). if ! grep -q '^CRON_SECRET=.' .env; then - log "Añadiendo CRON_SECRET al .env..." - # Si el .env no acaba en salto de línea, la variable se pegaría a la última línea. + log "Adding CRON_SECRET to .env..." + # If .env does not end with a newline, the variable would be glued to the last line. if [ -n "$(tail -c 1 .env)" ]; then echo >> .env; fi printf 'CRON_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env fi fi -# 4. Construir y levantar ----------------------------------------------------- -log "Construyendo la imagen (esto puede tardar unos minutos la primera vez)..." +# 4. Build and start ------------------------------------------------------------ +log "Building the image (this can take a few minutes the first time)..." docker compose build -log "Levantando los servicios..." +log "Starting the services..." docker compose up -d IP="$(hostname -I 2>/dev/null | awk '{print $1}')" APP_PORT="$(grep '^APP_PORT=' .env | cut -d= -f2 || true)" APP_PORT="${APP_PORT:-3000}" PORT="${APP_PORT##*:}" -# APP_PORT puede llevar una IP delante («127.0.0.1:3000», ver docs/self-hosting.md). Si es -# de loopback, la app no es accesible por la IP del servidor: se dice cómo llegar a ella. +# APP_PORT may be prefixed with an IP ("127.0.0.1:3000", see docs/self-hosting.md). If it +# is a loopback address, the app cannot be reached via the server's IP: explain how to +# reach it instead. case "$APP_PORT" in 127.*:* | localhost:* | \[::1\]:*) - log "¡Listo! La app solo escucha en ${APP_PORT} (local). Completa el asistente de configuración" - log "inicial a través de tu proxy inverso o con un túnel SSH desde tu equipo:" - log " ssh -L ${PORT}:${APP_PORT} <usuario>@<servidor> y abre http://localhost:${PORT}" + log "Done! The app only listens on ${APP_PORT} (local). Complete the initial setup wizard" + log "through your reverse proxy or with an SSH tunnel from your machine:" + log " ssh -L ${PORT}:${APP_PORT} <user>@<server> and open http://localhost:${PORT}" ;; *) - HOST="${IP:-<IP-del-servidor>}" - # Con una IP concreta (que no sea 0.0.0.0, todas las interfaces), la app solo escucha ahí. + HOST="${IP:-<server-IP>}" + # With a specific IP (other than 0.0.0.0, all interfaces), the app only listens there. case "$APP_PORT" in 0.0.0.0:*) ;; *:*) HOST="${APP_PORT%:*}" ;; esac - log "¡Listo! Abre http://${HOST}:${PORT} y completa el asistente de configuración inicial." + log "Done! Open http://${HOST}:${PORT} and complete the initial setup wizard." ;; esac -log "Aviso: por HTTP la contraseña y la sesión viajan sin cifrar. Para uso real, pon delante" -log "un proxy inverso con HTTPS: docs/self-hosting.md, apartado «HTTPS y dominio»." +log "Warning: over plain HTTP the password and session travel unencrypted. For real use, put" +log "a reverse proxy with HTTPS in front: docs/self-hosting.md, section \"HTTPS and domain\"." diff --git a/locales/README.md b/locales/README.md new file mode 100644 index 0000000..a82a5f3 --- /dev/null +++ b/locales/README.md @@ -0,0 +1,96 @@ +# Translating orgtable + +Every piece of text in the interface lives here, one folder per language and one JSON file +per area of the app. You do not need to know the code to translate it. + +``` +locales/ +├── index.ts # the list of languages (one line each) +├── en/ # English: the reference, always complete +│ ├── language.json # code, native name and Intl tag of the language +│ ├── index.ts # the same in every folder: copy it as is +│ ├── common.json # shared words, language picker +│ ├── auth.json # sign in, sign up, first-run setup +│ ├── nav.json # top bar, account menu, notifications, search box +│ ├── boards.json # boards, lists, cards, filters, calendar, members +│ ├── settings.json # the settings page +│ ├── admin.json # administration: users, instance, audit log +│ ├── search.json # search page +│ ├── legal.json # privacy policy, terms, cookies, legal notice, footer +│ ├── errors.json # validation and error messages +│ └── notifications.json # in-app notification texts +└── es/ # Spanish +``` + +## Improve an existing translation + +Edit the JSON file and open a pull request. That's it. + +## Add a language + +1. Copy `en/` to a folder named with the language code, e.g. `fr/` or `pt-BR/`. Use the + two-letter [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) code, + plus a region only when it matters, e.g. `pt-BR`. +2. In `language.json`, set `code` (the folder name), `name` (the language's name in that + language, e.g. `"Français"`) and `intl` (the tag used to format dates and plurals, e.g. + `"fr-FR"`). +3. Translate the values of every JSON file. **Never change the keys** (the part before + the colon). +4. Add the language to `index.ts`: + + ```ts + import fr from "./fr"; + export const LANGUAGES = { en, es, fr }; + ``` + + A code with a region goes in quotes: `import ptBR from "./pt-BR";` and + `{ en, es, "pt-BR": ptBR }`. + +5. Run the translation checks: `pnpm test test/i18n.test.ts`. They list anything missing. + +The language then appears in the language picker. It is picked automatically for visitors +whose browser asks for it. A message you have not translated yet shows in English, so a +work-in-progress translation never breaks the app. The checks fail until it is complete, +though, so finish it before the pull request is merged. + +## The format + +- **Placeholders** in braces are filled in by the app: `"Created {date}"`. Keep them + exactly as they are. You can move them around in the sentence. +- **Tags** mark text that becomes a link or bold: `"Read the <link>privacy policy</link>"`. + Translate the text between the tags and keep the tags, with the same names. +- **Plurals** are objects with one entry per grammatical form: + + ```json + "cardCount": { "one": "{count} card", "other": "{count} cards" } + ``` + + Use the forms your language needs (`zero`, `one`, `two`, `few`, `many`, `other`; + [CLDR plural rules](https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html)). + `other` is always required. The checks tell you if a form is missing. +- Use your language's typography: quotes, apostrophes, `…`, non-breaking spaces before `:` + in French, and so on. +- Keep the tone short, direct and friendly. Use the informal "you" where your language + allows it, as the English and Spanish versions do. + +## Legal pages + +`legal.json` holds the privacy policy, terms of use, cookie policy and legal notice. They +describe what the software does. Translate them faithfully, without adding or removing +obligations. Data about whoever runs an instance (name, contact…) is not translated: it +comes from the instance settings. If you are not sure about a legal term, say so in the +pull request. + +## For developers + +- Server Components and Server Actions: `const { m, intl } = await getI18n()` + (`app/i18n/server.ts`). For text written for someone else, such as a notification, + use `i18nFor(user.locale)`. +- Client Components: `const { m, intl } = useI18n()` (`app/i18n/client.tsx`). +- Helpers in `app/i18n/format.ts`: `fmt(m.x, { name })`, `plural(intl, m.y, count)`. + For tags, use `rich(m.z, { link: (t) => <Link href="…">{t}</Link> })` from + `app/i18n/rich.tsx`. +- zod schemas use message **keys** (`"errors.validation.titleRequired"`), turned into + text with `issueMessage(m, issue)`. Schemas stay language-independent that way. +- Add every new text to `en/` first, then to `es/`. The checks fail if they differ. +- The public REST API (`/api/v1`) answers in English only: it is for programs, not people. diff --git a/locales/en/admin.json b/locales/en/admin.json new file mode 100644 index 0000000..fc321f3 --- /dev/null +++ b/locales/en/admin.json @@ -0,0 +1,145 @@ +{ + "nav": { + "label": "Administration", + "users": "Users", + "instance": "Instance", + "audit": "Audit log" + }, + "roles": { + "MEMBER": "Member", + "ADMIN": "Administrator" + }, + "users": { + "meta": { + "title": "Users", + "description": "Accounts, roles and passwords of this instance." + }, + "title": "Users", + "intro": "Manage the accounts of your instance: new accounts, roles and passwords.", + "ok": { + "role": "Role updated.", + "password": "Password reset. The user's sessions have been signed out and their API tokens revoked.", + "delete": "User deleted.", + "revoked": "Sessions signed out." + }, + "errors": { + "self-role": "You can't change your own role.", + "self-delete": "You can't delete your own account.", + "owns-boards": "That user owns boards. Transfer or delete them before deleting the account.", + "password": "The password must be at least 8 characters long.", + "invalid": "Invalid data.", + "not-found": "That user no longer exists." + }, + "table": { + "user": "User", + "boards": "Boards", + "joined": "Joined", + "actions": "Actions" + }, + "adminBadge": "Admin", + "ownedBoards": "Boards they own", + "memberBoards": "Boards they are a member of", + "you": "You", + "roleLabel": "Global role", + "newPassword": "New password", + "newPasswordFor": "New password for {name}", + "resetPassword": "Reset password", + "revokeSessions": "Sign out sessions", + "revokeSessionsTitle": "Signs out every active session of this user", + "deleteConfirm": "Delete this account? This can't be undone.", + "deleteLabel": "Delete user", + "create": { + "title": "Create account", + "intro": "Sign-up is not open: this is where you add the people of your instance.", + "name": "Name", + "email": "Email", + "password": "Initial password", + "role": "Role", + "submit": "Create user", + "submitting": "Creating…" + } + }, + "instance": { + "meta": { + "title": "Instance", + "description": "Name, open sign-up and legal details of the instance." + }, + "title": "Instance", + "intro": "Who can sign up and the details shown on the legal pages.", + "notConfigured": "The instance is not set up yet.", + "general": "General", + "instanceName": "Instance name", + "registration": { + "title": "Sign-up", + "description": "Who can create an account.", + "closedTitle": "Closed", + "closedText": "Only an administrator creates accounts.", + "openTitle": "Open", + "openText": "Anyone can sign up for free at /register." + }, + "legal": { + "title": "Legal details", + "description": "Those of whoever runs this instance. They appear in the <link>privacy policy</link>, the terms and the legal notice. Anything left empty shows as “not provided”.", + "requiredToOpen": "Required to open sign-up.", + "optional": "Optional.", + "fields": { + "operatorName": "Operator (name or company name)", + "operatorEmail": "Contact email for privacy", + "operatorLegalId": "Tax ID", + "operatorAddress": "Postal address", + "hostingDescription": "Where the data is hosted", + "backupRetentionDays": "Days you keep backups" + }, + "hostingPlaceholder": "e.g. a VPS from [provider] in the European Union", + "hostingHint": "Provider and country or region.", + "backupHint": "0 if you don't make backups. It must match your actual rotation." + }, + "save": "Save settings", + "saving": "Saving…" + }, + "audit": { + "meta": { + "title": "Audit log", + "description": "Recent security and operations events of the instance." + }, + "title": "Audit log", + "intro": { + "one": "The latest security and operations event.", + "other": "The latest {count} security and operations events." + }, + "empty": "No events yet.", + "table": { + "when": "When", + "action": "Action", + "who": "Who", + "detail": "Detail" + }, + "actions": { + "setup.completed": "Initial setup completed", + "auth.login": "Sign-in", + "auth.login_failed": "Failed sign-in", + "auth.login_rate_limited": "Sign-in blocked by the attempt limit", + "auth.logout": "Sign-out", + "auth.registered": "Account created via sign-up", + "auth.register_rate_limited": "Sign-up blocked by the account limit", + "account.updated": "Profile changed", + "account.password_changed": "Password changed by its owner", + "account.sessions_revoked": "Sessions signed out by their owner", + "account.exported": "Personal data downloaded", + "account.deleted": "Account deleted by its owner", + "instance.updated": "Instance settings changed", + "user.created": "User created", + "user.deleted": "User deleted", + "user.role_changed": "User role changed", + "user.password_reset": "Password reset", + "user.sessions_revoked": "Sessions signed out", + "board.created": "Board created", + "board.deleted": "Board deleted", + "member.added": "Member added", + "member.removed": "Member removed", + "member.role_changed": "Member role changed", + "apitoken.created": "API token created", + "apitoken.revoked": "API token revoked" + } + } +} diff --git a/locales/en/auth.json b/locales/en/auth.json new file mode 100644 index 0000000..6e43d2e --- /dev/null +++ b/locales/en/auth.json @@ -0,0 +1,57 @@ +{ + "brand": { + "headline": "Your work, in columns that breathe.", + "intro": "Self-hosted, open-source Kanban boards. No ads and no third parties watching.", + "sample": { + "todo": "To do", + "todoCard1": "Prepare the demo", + "todoCard2": "Review the budget", + "done": "Done", + "doneCard1": "Create the board" + }, + "keywords": "Kanban · Self-hosted · Open source" + }, + "fields": { + "name": "Name", + "email": "Email", + "emailPlaceholder": "you@example.com", + "password": "Password" + }, + "login": { + "metaTitle": "Sign in", + "title": "Welcome back", + "subtitle": "Sign in to your instance to get back to your boards.", + "accountDeleted": "Your account and your data have been deleted.", + "submit": "Sign in", + "submitting": "Signing in…", + "noAccount": "Don't have an account? <link>Create one for free</link>" + }, + "register": { + "metaTitle": "Create account", + "metaDescription": "Create your free orgtable account.", + "title": "Create your account", + "subtitle": "It's free. Already have an account? <link>Sign in</link>", + "passwordHint": "At least 8 characters.", + "honeypot": "Do not fill in this field", + "acceptTerms": "I have read the <privacy>privacy policy</privacy> and accept the <terms>terms of use</terms>.", + "submit": "Create account", + "submitting": "Creating your account…", + "firstLayer": "Controller: {operator}. Purpose: providing your account and the service. Your data is not passed on to anyone. You can download or delete your data from the settings whenever you want.", + "closedTitle": "Sign-up is closed", + "closedBody": "On this instance, accounts are created by whoever administers it. If you need one, ask that person.", + "closedCta": "Go to sign in" + }, + "setup": { + "metaTitle": "Initial setup", + "title": "Set up your instance", + "subtitle": "This wizard only appears once. Create your administrator account.", + "instanceLegend": "Instance", + "instanceName": "Instance name", + "instanceNamePlaceholder": "My team", + "adminLegend": "Administrator", + "yourName": "Your name", + "passwordHint": "At least 8 characters.", + "submit": "Create and sign in", + "submitting": "Creating…" + } +} diff --git a/locales/en/boards.json b/locales/en/boards.json new file mode 100644 index 0000000..4525c33 --- /dev/null +++ b/locales/en/boards.json @@ -0,0 +1,169 @@ +{ + "roles": { + "OWNER": "Owner", + "EDITOR": "Editor", + "VIEWER": "Viewer" + }, + "confirm": { + "deletePrompt": "Delete?", + "yes": "Yes", + "no": "No" + }, + "index": { + "metaTitle": "Your boards", + "title": "Your boards", + "subtitle": "Organize your work in lists and cards.", + "newBoardName": "New board name", + "createBoard": "Create board", + "importToggle": "Import a board from JSON", + "emptyTitle": "You don't have any boards yet", + "emptyText": "Create your first one with the field above and start organizing your work in lists and cards.", + "cardCount": { "one": "{count} card", "other": "{count} cards" }, + "sharedWith": { + "one": ", shared with {count} person: {names}", + "other": ", shared with {count} people: {names}" + }, + "exportBoard": "Export board {title}", + "exportTitle": "Export to JSON", + "deleteBoard": "Delete board {title}", + "confirmDeleteBoard": "Yes, delete the board {title}" + }, + "import": { + "fileLabel": "JSON file of the board to import", + "submit": "Import board", + "pending": "Importing…" + }, + "board": { + "metaTitle": "Board", + "viewBoard": "Board", + "viewCalendar": "Calendar", + "members": "Members", + "viewMembers": "View members", + "moveListFailed": "Couldn't move the list. Please try again.", + "moveCardFailed": "Couldn't move the card. Please try again." + }, + "dnd": { + "instructions": "Press Space to pick up the item, the arrow keys to move it, Space to drop it and Escape to cancel. Enter opens the card.", + "listName": "the list “{title}”", + "cardName": "the card “{title}”", + "pickedUp": "Picked up {item}.", + "over": "Over {target}.", + "outside": "Outside any list.", + "dropped": "Dropped {item} on {target}.", + "droppedOutside": "Dropped {item} outside any list; it doesn't move.", + "cancelled": "Move cancelled; {item} goes back to its place." + }, + "list": { + "roleDescription": "list", + "delete": "Delete list {title}", + "confirmDelete": "Yes, delete the list {title} and all its cards", + "keep": "No, keep the list", + "addPlaceholder": "+ Add list", + "newName": "New list name" + }, + "card": { + "roleDescription": "card", + "addPlaceholder": "+ Add card", + "newIn": "New card in {list}", + "subtasks": "Subtasks: {done} of {total} completed", + "overdue": ", overdue" + }, + "filters": { + "button": "Filter", + "dialog": "Filters", + "labels": "Labels", + "noLabels": "No labels.", + "assignees": "Assignees", + "due": "Due date", + "dueAny": "Any", + "dueOverdue": "Overdue", + "dueSet": "With a date", + "dueNone": "No date", + "clear": "Clear filters" + }, + "calendar": { + "instructions": "Press Space to pick up the card, the arrow keys to change the day, Space to drop it and Escape to cancel. Enter opens the card.", + "pickedUp": "Picked up the card “{title}”.", + "over": "Over {day}.", + "outside": "Outside the calendar.", + "dropped": "Dropped the card “{title}” on {day}.", + "droppedOutside": "Dropped the card “{title}” outside the calendar; it doesn't change.", + "cancelled": "Move cancelled; the card “{title}” stays on its day.", + "previousMonth": "Previous month", + "nextMonth": "Next month", + "today": "Today", + "dueDateFailed": "Couldn't change the date. Please try again.", + "undated": { + "one": "{count} card without a due date (it doesn't appear in the calendar).", + "other": "{count} cards without a due date (they don't appear in the calendar)." + } + }, + "members": { + "title": "Members", + "descriptionManage": "Invite people and choose what they can do on this board.", + "descriptionView": "People with access to this board.", + "email": "Email", + "emailPlaceholder": "person@example.com", + "role": "Role", + "invite": "Invite", + "inviting": "Adding…", + "roleOf": "Role of {name}", + "remove": "Remove {name}" + }, + "cardModal": { + "labels": "Labels", + "labelToggleFailed": "Couldn't change the label.", + "noBoardLabels": "There are no labels on this board yet.", + "noLabels": "No labels.", + "labelColor": "{color} color", + "colors": { + "green": "Green", + "lime": "Lime", + "amber": "Amber", + "red": "Red", + "purple": "Purple", + "blue": "Blue", + "teal": "Teal", + "pink": "Pink" + }, + "newLabel": "New label", + "newLabelName": "New label name", + "add": "Add", + "assignees": "Assignees", + "unassign": "Remove {name}", + "nobodyAssigned": "Nobody assigned.", + "description": "Description", + "descriptionPlaceholder": "Add more detail…", + "noDescription": "No description.", + "dueDate": "Due date", + "dueDateInline": "Due date:", + "saveChanges": "Save changes", + "checklists": "Checklists", + "checklistProgress": "· {done} of {total} completed", + "deleteChecklist": "Delete", + "itemToggleFailed": "Couldn't update the item.", + "markNotDone": "Mark as not done", + "markDone": "Mark as done", + "deleteItem": "Delete item", + "addItemPlaceholder": "+ Add item", + "newItemIn": "New item in {checklist}", + "newChecklist": "New checklist", + "newChecklistTitle": "New checklist title", + "addChecklist": "Add checklist", + "attachments": "Attachments", + "noAttachments": "No attachments.", + "deleteAttachment": "Delete attachment {filename}", + "attachFile": "File to attach to the card", + "attach": "Attach", + "uploading": "Uploading…", + "comments": "Comments", + "noComments": "Be the first to comment.", + "deleteComment": "Delete comment", + "commentPlaceholder": "Write a comment…", + "newComment": "New comment", + "comment": "Comment", + "deleteCard": "Delete card", + "deleteCardPrompt": "Delete the card?", + "confirmDeleteCard": "Yes, delete" + } +} diff --git a/locales/en/common.json b/locales/en/common.json new file mode 100644 index 0000000..058fc98 --- /dev/null +++ b/locales/en/common.json @@ -0,0 +1,22 @@ +{ + "appName": "orgtable", + "tagline": "Self-hosted Kanban boards, a Trello alternative", + "actions": { + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel", + "close": "Close", + "delete": "Delete", + "confirm": "Confirm", + "create": "Create", + "edit": "Edit", + "back": "Back" + }, + "language": { + "label": "Language", + "description": "The language of the interface. It is saved in this browser and in your account.", + "switchTo": "Change language", + "saved": "Language changed." + }, + "genericError": "Something went wrong. Please try again." +} diff --git a/locales/en/errors.json b/locales/en/errors.json new file mode 100644 index 0000000..e7ed415 --- /dev/null +++ b/locales/en/errors.json @@ -0,0 +1,101 @@ +{ + "validation": { + "emailInvalid": "Enter a valid email address", + "contactEmailInvalid": "Enter a valid contact email address", + "passwordRequired": "Enter your password", + "passwordTooShort": "The password must be at least 8 characters long", + "passwordTooLong": "At most 72 bytes (about 72 characters without accents or emoji)", + "currentPasswordRequired": "Enter your current password", + "passwordsDontMatch": "The two new passwords don't match", + "passwordToConfirm": "Enter your password to confirm", + "instanceNameRequired": "The instance name is required", + "yourNameRequired": "Your name is required", + "nameRequired": "The name is required", + "titleRequired": "The title is required", + "commentRequired": "The comment can't be empty", + "roleInvalid": "Choose a valid role", + "acceptTermsRequired": "You have to accept the privacy policy and the terms", + "noFieldsToUpdate": "There are no fields to update.", + "maxChars40": "At most 40 characters", + "maxChars60": "At most 60 characters", + "maxChars120": "At most 120 characters", + "maxChars200": "At most 200 characters", + "maxChars254": "At most 254 characters", + "maxChars500": "At most 500 characters", + "maxChars2000": "At most 2000 characters", + "backupDaysNumber": "The backup days have to be a number", + "backupDaysInteger": "The backup days have to be a whole number", + "backupDaysNegative": "The backup days can't be negative", + "backupDaysMax": "At most 3650 days" + }, + "auth": { + "checkCredentials": "Check the email and the password.", + "invalidCredentials": "Wrong email or password.", + "tooManyAttempts": { + "one": "Too many attempts. Try again in {seconds} second.", + "other": "Too many attempts. Try again in {seconds} seconds." + } + }, + "register": { + "closed": "Registration is closed on this instance.", + "failed": "The account could not be created. Please try again.", + "rateLimited": { + "one": "Too many accounts were created in a row. Try again in {minutes} minute.", + "other": "Too many accounts were created in a row. Try again in {minutes} minutes." + }, + "emailTaken": "An account with that email already exists. Sign in." + }, + "setup": { + "failed": "The setup could not be completed. Please try again." + }, + "account": { + "emailChangeNeedsPassword": "To change the email, enter your current password.", + "emailTaken": "That email is already used by another account.", + "profileSaved": "Profile saved.", + "wrongCurrentPassword": "The current password is not correct.", + "passwordChanged": "Password changed.", + "passwordChangedSessionsClosed": { + "one": "Password changed. {count} other session was signed out.", + "other": "Password changed. {count} other sessions were signed out." + }, + "wrongPassword": "The password is not correct.", + "lastAdmin": "You are the only administrator. Make someone else an administrator before deleting your account." + }, + "admin": { + "emailTaken": "A user with that email already exists.", + "userCreated": "Account created for {name}." + }, + "instance": { + "legalInfoRequired": "To open registration, first fill in the operator and the contact email: the privacy policy needs them.", + "notConfigured": "The instance is not set up yet.", + "saved": "Settings saved." + }, + "tokens": { + "invalidScope": "Invalid permissions.", + "invalidExpiry": "Invalid expiry." + }, + "boards": { + "importNoFile": "Choose a JSON file.", + "importTooLarge": "The file is too large (max. {megabytes} MB).", + "importInvalidJson": "The file is not valid JSON.", + "importInvalidFormat": "The file is not an orgtable board." + }, + "cards": { + "notFound": "This card no longer exists.", + "noPermission": "You don't have permission to edit this card.", + "invalidDueDate": "The due date is not valid.", + "descriptionTooLong": "The description can't be longer than {max} characters (it has {length})." + }, + "attachments": { + "cardNotFound": "Card not found.", + "noPermission": "You don't have permission to attach files.", + "noFile": "Choose a file.", + "tooLarge": "The file is larger than the {megabytes} MB limit." + }, + "members": { + "ownerOnly": "Only the owner can manage members.", + "userNotFound": "There is no user with that email. Ask the administrator to create the account first.", + "alreadyMember": "{name} is already a member of this board.", + "added": "{name} added as {role}." + } +} diff --git a/locales/en/index.ts b/locales/en/index.ts new file mode 100644 index 0000000..eddf02d --- /dev/null +++ b/locales/en/index.ts @@ -0,0 +1,30 @@ +// One language: its metadata and its messages by area. This file is the same in every +// language folder; when adding a language, copy it as is. +import language from "./language.json"; +import common from "./common.json"; +import auth from "./auth.json"; +import legal from "./legal.json"; +import nav from "./nav.json"; +import settings from "./settings.json"; +import admin from "./admin.json"; +import search from "./search.json"; +import boards from "./boards.json"; +import errors from "./errors.json"; +import notifications from "./notifications.json"; + +const messages = { + common, + auth, + legal, + nav, + settings, + admin, + search, + boards, + errors, + notifications, +}; + +const languagePack = { language, messages }; + +export default languagePack; diff --git a/locales/en/language.json b/locales/en/language.json new file mode 100644 index 0000000..5732f57 --- /dev/null +++ b/locales/en/language.json @@ -0,0 +1,5 @@ +{ + "code": "en", + "name": "English", + "intl": "en" +} diff --git a/locales/en/legal.json b/locales/en/legal.json new file mode 100644 index 0000000..ff483e2 --- /dev/null +++ b/locales/en/legal.json @@ -0,0 +1,233 @@ +{ + "footer": { + "navLabel": "Legal information", + "privacy": "Privacy", + "terms": "Terms", + "cookies": "Cookies", + "legalNotice": "Legal notice", + "sourceCode": "Source code", + "createdBy": "Created by <link>{author}</link>" + }, + "layout": { + "goToBoards": "Go to your boards", + "signIn": "Sign in" + }, + "nav": { + "label": "Legal pages", + "heading": "Legal", + "privacy": "Privacy", + "terms": "Terms of use", + "cookies": "Cookies", + "legalNotice": "Legal notice" + }, + "shared": { + "notProvided": "not provided by the operator", + "appliesTo": "Applies to this orgtable instance.", + "appliesToNamed": "Applies to this orgtable instance ({instance}).", + "lastUpdated": "Last updated: {date}.", + "missingInfo": "The administrator of this instance has not yet stated who the controller is or how to contact them. Until those details are provided, open sign-up is disabled.", + "operator": { + "controller": "Controller", + "contact": "Contact", + "taxId": "Tax ID (NIF)", + "address": "Address", + "hosting": "Where the data is hosted" + } + }, + "privacy": { + "title": "Privacy policy", + "metaDescription": "What data this orgtable instance stores, what for and for how long.", + "summary": { + "heading": "In short", + "body": "This instance stores what it needs for you to have an account and for your boards to work. There are no ads, no analytics and no tracking cookies: only technical cookies, for your session and your language. You can download or delete your data yourself from <link>Settings › Privacy and data</link>." + }, + "controller": { + "heading": "Data controller", + "body": "The controller is whoever operates this instance, not the author of the software. orgtable is open source and anyone can host their own: each instance has its own controller." + }, + "data": { + "heading": "What data is stored and why", + "colData": "Data", + "colPurpose": "Purpose", + "colBasis": "Legal basis", + "account": { + "data": "Name, email, the password hashed with bcrypt (never in plain text) and the interface language", + "purpose": "Your account: signing in, being recognised on shared boards and seeing the app in your language", + "basis": "Performance of the service you request (Art. 6(1)(b) GDPR)" + }, + "acceptance": { + "data": "The date on which you accepted this policy and the terms", + "purpose": "Being able to prove that you accepted them when you signed up", + "basis": "Legitimate interest in being able to prove that you accepted them (Art. 6(1)(f))" + }, + "content": { + "data": "Your boards, lists, cards, labels, checklists, comments, assignments and attachments", + "purpose": "The service itself", + "basis": "Performance of the service (Art. 6(1)(b))" + }, + "notifications": { + "data": "In-app notifications", + "purpose": "Letting you know about changes to your boards and about due dates", + "basis": "Performance of the service (Art. 6(1)(b))" + }, + "sessions": { + "data": "Sessions: browser (user agent), start date and expiry date", + "purpose": "Keeping you signed in and letting you end sessions remotely", + "basis": "Performance of the service (Art. 6(1)(b))" + }, + "tokens": { + "data": "API tokens: name, visible prefix and their hash (never the token itself)", + "purpose": "Letting you use the API with your own scripts", + "basis": "Performance of the service (Art. 6(1)(b))" + }, + "audit": { + "dataWithIp": "Security log: sign-ins and sign-outs, failed attempts and changes to accounts and permissions, with the IP address", + "dataWithoutIp": "Security log: sign-ins and sign-outs, failed attempts and changes to accounts and permissions (this instance does not store the IP address)", + "purpose": "Detecting unauthorised access and abuse", + "basis": "Legitimate interest in keeping the service secure (Art. 6(1)(f))" + }, + "noProfiling": "Your data is not used for advertising, no profiles are built and no automated decisions are made about you, and it is not sold or passed on to anyone." + }, + "retention": { + "heading": "How long it is kept", + "account": "Your account and your content: until you delete them or delete your account.", + "sessionLength": { + "one": "A session lasts {count} day.", + "other": "A session lasts {count} days." + }, + "endedSessions": { + "one": "Ended or expired sessions are deleted after {count} day.", + "other": "Ended or expired sessions are deleted after {count} days." + }, + "notifications": { + "one": "Notifications are deleted after {count} day.", + "other": "Notifications are deleted after {count} days." + }, + "audit": { + "one": "The security log is deleted after {count} day.", + "other": "The security log is deleted after {count} days." + }, + "backupsDays": { + "one": "Backups: they are kept for {count} day. Whatever you delete disappears from the backups when they expire.", + "other": "Backups: they are kept for {count} days. Whatever you delete disappears from the backups when they expire." + }, + "backupsNone": "Backups: this instance does not make backups.", + "backupsUnknown": "Backups: {notProvided}" + }, + "recipients": { + "heading": "Who else can see your data", + "boardMembers": "<b>The people you share a board with</b> see your name and email, and what you write on that board.", + "admin": "<b>Whoever administers the instance</b> sees the list of accounts (name, email, role) and the security log. They cannot see your password.", + "hosting": "<b>The hosting provider</b> stores the data on behalf of the controller, as a processor: {hosting}.", + "outsideEea": "If the data is hosted outside the European Economic Area, the controller must state so above and apply the safeguards of Chapter V of the GDPR." + }, + "cookies": { + "heading": "Cookies and browser storage", + "body": "Two technical cookies: <code>session</code>, essential to keep you signed in, and <code>locale</code>, which remembers the language you choose. Your theme preference (light or dark) is stored in your browser's local storage and never leaves it. Details in the <link>cookie policy</link>." + }, + "rights": { + "heading": "Your rights", + "intro": "You can exercise them yourself, without asking anyone for permission:", + "access": "<b>Access and portability</b>: download all your data as JSON from <link>Settings › Privacy and data</link>.", + "rectification": "<b>Rectification</b>: change your name and your email in <link>Settings › Profile</link>.", + "erasure": "<b>Erasure</b>: delete your account from <link>Settings › Privacy and data</link>. This deletes your account, the boards you created (with their cards and attachments), your comments and your notifications. Other people's boards remain, without you.", + "objection": "<b>Objection and restriction</b>: write to the controller's contact.", + "complaint": "If you believe your rights have not been respected, you can lodge a complaint with the data protection authority. In Spain, that is the <link>Spanish Data Protection Agency (AEPD)</link>." + }, + "security": { + "heading": "Security", + "body": "Passwords are stored with bcrypt, sessions can be revoked instantly and API tokens are stored as a hash. Sign-in attempts are rate-limited to slow down brute-force attacks." + }, + "changes": { + "heading": "Changes to this policy", + "body": "If the controller's details change, the “last updated” date above changes with them. Changes to what the software stores are published in its <link>source code</link>." + } + }, + "terms": { + "title": "Terms of use", + "metaDescription": "Terms for using this orgtable instance.", + "provider": { + "heading": "Who provides the service", + "body": "This orgtable instance is operated by {operator}. orgtable is open-source software ({license}); its author does not provide this service and is not liable for it, unless they are also the one operating the instance." + }, + "service": { + "heading": "The service", + "body": "Kanban boards to organise work, on your own or as a team. Creating an account and using it is free. By signing up you accept these terms and declare that you have read the <link>privacy policy</link>." + }, + "account": { + "heading": "Your account", + "age": "You must be at least 14 years old to sign up.", + "responsibility": "You are responsible for what is done with your account. Use a password you do not use anywhere else, and end any sessions you do not recognise from the settings.", + "deletion": "You can delete your account whenever you want from the settings." + }, + "content": { + "heading": "Your content", + "ownership": "What you write and upload remains yours. We only store it and show it to the people you share each board with, so that the service works.", + "prohibitedIntro": "You may not use the service to:", + "illegal": "Publish illegal content or content that infringes the rights of others.", + "malware": "Upload malware, or files in order to distribute them outside the service.", + "spam": "Send spam or harass other people.", + "abuse": "Try to access other people's accounts or boards, or deliberately overload the service.", + "enforcement": "If these rules are broken, the controller may remove the content or suspend the account. Where possible, they will warn you first and explain why." + }, + "availability": { + "heading": "Availability and warranties", + "body": "The service is offered free of charge and “as is”, with no guarantee of continuous availability or of being error-free. Keep copies of what matters: you can export each board and all your data from the app. Nothing above limits the rights the law grants you as a user." + }, + "changes": { + "heading": "Changes and end of the service", + "body": "If the terms change significantly, or if the service is going to close, you will be notified reasonably in advance so that you can export your data." + }, + "software": { + "heading": "The software", + "body": "orgtable is distributed under the {license} licence. You can view, download and modify the code of this instance in its <link>repository</link>, and host your own instance." + }, + "contact": { + "heading": "Contact", + "body": "For any questions about these terms: {contact}." + } + }, + "cookies": { + "title": "Cookie policy", + "metaDescription": "What this orgtable instance stores in your browser.", + "intro": "orgtable does not use advertising, analytics or third-party cookies. It only stores in your browser what it strictly needs to work, which does not require your consent (Article 22.2 of the Spanish LSSI). That is why you will not see a cookie banner.", + "colName": "Name", + "colType": "Type", + "colPurpose": "Purpose", + "colDuration": "Duration", + "session": { + "type": "First-party cookie, HttpOnly", + "purpose": "Keeping you signed in", + "duration": { + "one": "{count} day, or until you sign out", + "other": "{count} days, or until you sign out" + } + }, + "locale": { + "type": "First-party cookie", + "purpose": "Remembering the language you choose", + "duration": "One year" + }, + "theme": { + "type": "Browser local storage", + "purpose": "Remembering whether you prefer the light or dark theme", + "duration": "Until you delete it from your browser" + }, + "outro": "You can delete them all from your browser settings. Without the session cookie you will not be able to use the app with your account." + }, + "notice": { + "title": "Legal notice", + "metaDescription": "Details of who operates this orgtable instance.", + "owner": { + "heading": "Site owner" + }, + "purpose": { + "heading": "Purpose", + "body": "This site offers an instance of orgtable, a Kanban board application. The terms for using it are in the <terms>terms of use</terms>, and the processing of data in the <privacy>privacy policy</privacy>." + }, + "ip": { + "heading": "Intellectual property", + "body": "The orgtable software is open source and is distributed under its own licence, linked in the footer of this page. The content of the boards belongs to whoever creates it." + } + } +} diff --git a/locales/en/nav.json b/locales/en/nav.json new file mode 100644 index 0000000..836128e --- /dev/null +++ b/locales/en/nav.json @@ -0,0 +1,30 @@ +{ + "search": { + "label": "Search", + "placeholder": "Search cards and boards…" + }, + "theme": { + "toggle": "Switch between light and dark theme" + }, + "account": { + "label": "Your account: {name}", + "profile": "Profile and settings", + "security": "Password and sessions", + "privacy": "Privacy and data", + "admin": "Administration", + "signOut": "Sign out" + }, + "notifications": { + "title": "Notifications", + "labelUnread": "Notifications ({count} unread)", + "markAll": "Mark all as read", + "empty": "You have no notifications.", + "time": { + "now": "just now", + "minutes": "{count} min ago", + "hours": "{count} h ago", + "yesterday": "yesterday", + "days": "{count} d ago" + } + } +} diff --git a/locales/en/notifications.json b/locales/en/notifications.json new file mode 100644 index 0000000..52e7675 --- /dev/null +++ b/locales/en/notifications.json @@ -0,0 +1,31 @@ +{ + "roles": { + "OWNER": "Owner", + "EDITOR": "Editor", + "VIEWER": "Viewer" + }, + "someBoard": "a board", + "memberAdded": { + "title": "You were added to “{board}”", + "body": "As {role}." + }, + "memberRoleChanged": { + "title": "Your role on “{board}” has changed", + "body": "Your role is now {role}." + }, + "memberRemoved": { + "title": "You were removed from “{board}”" + }, + "cardAssigned": { + "title": "You were assigned “{card}”", + "body": "On the board “{board}”." + }, + "commentAdded": { + "title": "New comment on “{card}”" + }, + "dueReminder": { + "soonTitle": "Due soon: “{card}”", + "overdueTitle": "Overdue: “{card}”", + "body": "Due date: {date}." + } +} diff --git a/locales/en/search.json b/locales/en/search.json new file mode 100644 index 0000000..32c4b6d --- /dev/null +++ b/locales/en/search.json @@ -0,0 +1,12 @@ +{ + "meta": { + "title": "Search" + }, + "title": "Search", + "tooShort": "Type at least {min} characters to search your boards and cards.", + "noResults": "No results for “{query}”", + "noResultsHint": "Try other words. Only boards and cards you have access to are searched.", + "boards": "Boards · {count}", + "cards": "Cards · {count}", + "boardBadge": "Board" +} diff --git a/locales/en/settings.json b/locales/en/settings.json new file mode 100644 index 0000000..7722586 --- /dev/null +++ b/locales/en/settings.json @@ -0,0 +1,121 @@ +{ + "meta": { + "title": "Settings", + "description": "Your profile, security, API tokens and data." + }, + "title": "Settings", + "navLabel": "Settings sections", + "groups": { + "account": "Account", + "data": "Your data" + }, + "sections": { + "profile": "Profile", + "security": "Password and sessions", + "api": "API tokens", + "appearance": "Appearance", + "data": "Privacy and data", + "about": "About" + }, + "profile": { + "description": "This is how the people you share boards with see you.", + "name": "Name", + "email": "Email", + "currentPassword": "Current password", + "currentPasswordHint": "Only needed if you change your email.", + "save": "Save profile" + }, + "security": { + "description": "Changing your password signs out your other sessions.", + "currentPassword": "Current password", + "newPassword": "New password", + "newPasswordHint": "At least 8 characters.", + "confirmPassword": "Repeat the new password", + "submit": "Change password", + "submitting": "Changing…", + "sessionsTitle": "Open sessions", + "thisSession": "This session", + "sessionDates": "started {started} · expires {expires}", + "signOut": "Sign out", + "signOutOthers": { + "one": "Sign out the other session", + "other": "Sign out the other {count} sessions" + }, + "browserOnOs": "{browser} on {os}", + "unknownBrowser": "Unknown browser" + }, + "tokens": { + "description": "To use the orgtable API from scripts or other apps. A token acts <strong>as you</strong>, with the same access to boards, and is only shown once. See the <link>API documentation</link>.", + "empty": "You haven't created any tokens yet.", + "scopeRead": "Read only", + "scopeFull": "Read and write", + "scopeWrite": "Write", + "expired": "Expired", + "created": "created {date}", + "expires": "expires {date}", + "neverExpires": "never expires", + "used": "used {date}", + "neverUsed": "never used", + "revoke": "Revoke", + "form": { + "created": "Token “{name}” created", + "copyNow": "Copy it now: for security reasons it won't be shown again.", + "copy": "Copy", + "copied": "Copied", + "copyFailed": "Couldn't copy automatically: select the token and copy it by hand.", + "name": "Token name", + "namePlaceholder": "e.g. Backup script", + "scope": "Permissions", + "expiry": "Expires", + "expiryNever": "Never", + "expiryDays": { + "one": "{count} day", + "other": "{count} days" + }, + "expiryYear": "1 year", + "submit": "Create token", + "submitting": "Creating…" + } + }, + "appearance": { + "description": "Theme and language of the interface.", + "theme": "Theme", + "themeHint": "Saved in this browser.", + "themes": { + "light": { + "label": "Light", + "hint": "Warm paper" + }, + "dark": { + "label": "Dark", + "hint": "Warm charcoal" + }, + "system": { + "label": "System", + "hint": "Follows your device" + } + } + }, + "data": { + "description": "Your GDPR rights, without having to write to anyone. The details are in the <link>privacy policy</link>.", + "export": { + "title": "Download my data", + "description": "A JSON file with your account, your boards, your comments, your notifications and your security log.", + "button": "Download .json" + }, + "delete": { + "title": "Delete my account", + "descriptionNoBoards": "Your account and your comments are deleted forever. Other people's boards remain, without you. This can't be undone.", + "descriptionWithBoards": { + "one": "Your account, the board you created (with its cards and attachments) and your comments are deleted forever. Other people's boards remain, without you. This can't be undone.", + "other": "Your account, the {count} boards you created (with their cards and attachments) and your comments are deleted forever. Other people's boards remain, without you. This can't be undone." + }, + "confirmPassword": "Type your password to confirm", + "submit": "Delete my account", + "submitting": "Deleting…" + } + }, + "about": { + "line": "orgtable <version>{version}</version> · <source>Source code</source> · License <license>{license}</license>" + } +} diff --git a/locales/es/admin.json b/locales/es/admin.json new file mode 100644 index 0000000..ec23fe0 --- /dev/null +++ b/locales/es/admin.json @@ -0,0 +1,145 @@ +{ + "nav": { + "label": "Administración", + "users": "Usuarios", + "instance": "Instancia", + "audit": "Auditoría" + }, + "roles": { + "MEMBER": "Miembro", + "ADMIN": "Administrador" + }, + "users": { + "meta": { + "title": "Usuarios", + "description": "Altas, roles y contraseñas de las cuentas de la instancia." + }, + "title": "Usuarios", + "intro": "Administra las cuentas de tu instancia: altas, roles y contraseñas.", + "ok": { + "role": "Rol actualizado.", + "password": "Contraseña restablecida. Se han cerrado las sesiones del usuario y revocado sus tokens de API.", + "delete": "Usuario borrado.", + "revoked": "Sesiones cerradas." + }, + "errors": { + "self-role": "No puedes cambiar tu propio rol.", + "self-delete": "No puedes borrar tu propia cuenta.", + "owns-boards": "Ese usuario es propietario de tableros. Transfiérelos o bórralos antes de eliminar la cuenta.", + "password": "La contraseña debe tener al menos 8 caracteres.", + "invalid": "Datos no válidos.", + "not-found": "Ese usuario ya no existe." + }, + "table": { + "user": "Usuario", + "boards": "Tableros", + "joined": "Alta", + "actions": "Acciones" + }, + "adminBadge": "Admin", + "ownedBoards": "Tableros propios", + "memberBoards": "Tableros donde es miembro", + "you": "Tú", + "roleLabel": "Rol global", + "newPassword": "Nueva contraseña", + "newPasswordFor": "Nueva contraseña para {name}", + "resetPassword": "Restablecer contraseña", + "revokeSessions": "Cerrar sesiones", + "revokeSessionsTitle": "Cierra todas las sesiones activas de este usuario", + "deleteConfirm": "¿Borrar esta cuenta? Esta acción no se puede deshacer.", + "deleteLabel": "Borrar usuario", + "create": { + "title": "Crear cuenta", + "intro": "No hay registro abierto: aquí das de alta a las personas de tu instancia.", + "name": "Nombre", + "email": "Email", + "password": "Contraseña inicial", + "role": "Rol", + "submit": "Crear usuario", + "submitting": "Creando…" + } + }, + "instance": { + "meta": { + "title": "Instancia", + "description": "Nombre, registro abierto y datos legales de la instancia." + }, + "title": "Instancia", + "intro": "Quién puede registrarse y los datos que salen en las páginas legales.", + "notConfigured": "La instancia aún no está configurada.", + "general": "General", + "instanceName": "Nombre de la instancia", + "registration": { + "title": "Registro", + "description": "Quién puede crear una cuenta.", + "closedTitle": "Cerrado", + "closedText": "Solo un administrador da de alta las cuentas.", + "openTitle": "Abierto", + "openText": "Cualquiera puede registrarse gratis en /register." + }, + "legal": { + "title": "Datos legales", + "description": "Los de quien opera esta instancia. Salen en la <link>política de privacidad</link>, las condiciones y el aviso legal. Lo que dejes vacío aparece como «sin indicar».", + "requiredToOpen": "Obligatorio para abrir el registro.", + "optional": "Opcional.", + "fields": { + "operatorName": "Responsable (nombre o razón social)", + "operatorEmail": "Email de contacto para privacidad", + "operatorLegalId": "NIF", + "operatorAddress": "Dirección postal", + "hostingDescription": "Dónde se alojan los datos", + "backupRetentionDays": "Días que guardas las copias de seguridad" + }, + "hostingPlaceholder": "p. ej. un VPS de [proveedor] en la Unión Europea", + "hostingHint": "Proveedor y país o región.", + "backupHint": "0 si no haces copias. Debe coincidir con tu rotación real." + }, + "save": "Guardar ajustes", + "saving": "Guardando…" + }, + "audit": { + "meta": { + "title": "Auditoría", + "description": "Eventos recientes de seguridad y operación de la instancia." + }, + "title": "Auditoría", + "intro": { + "one": "El evento de seguridad y operación más reciente.", + "other": "Los {count} eventos de seguridad y operación más recientes." + }, + "empty": "Todavía no hay eventos.", + "table": { + "when": "Cuándo", + "action": "Acción", + "who": "Quién", + "detail": "Detalle" + }, + "actions": { + "setup.completed": "Configuración inicial completada", + "auth.login": "Inicio de sesión", + "auth.login_failed": "Inicio de sesión fallido", + "auth.login_rate_limited": "Login bloqueado por límite de intentos", + "auth.logout": "Cierre de sesión", + "auth.registered": "Cuenta creada desde el registro", + "auth.register_rate_limited": "Registro bloqueado por límite de altas", + "account.updated": "Perfil cambiado", + "account.password_changed": "Contraseña cambiada por su titular", + "account.sessions_revoked": "Sesiones cerradas por su titular", + "account.exported": "Datos personales descargados", + "account.deleted": "Cuenta borrada por su titular", + "instance.updated": "Ajustes de la instancia cambiados", + "user.created": "Usuario creado", + "user.deleted": "Usuario borrado", + "user.role_changed": "Rol de usuario cambiado", + "user.password_reset": "Contraseña restablecida", + "user.sessions_revoked": "Sesiones cerradas", + "board.created": "Tablero creado", + "board.deleted": "Tablero borrado", + "member.added": "Miembro añadido", + "member.removed": "Miembro quitado", + "member.role_changed": "Rol de miembro cambiado", + "apitoken.created": "Token de API creado", + "apitoken.revoked": "Token de API revocado" + } + } +} diff --git a/locales/es/auth.json b/locales/es/auth.json new file mode 100644 index 0000000..36fa89f --- /dev/null +++ b/locales/es/auth.json @@ -0,0 +1,57 @@ +{ + "brand": { + "headline": "Tu trabajo, en columnas que respiran.", + "intro": "Tableros Kanban self-hosted y de código abierto. Sin anuncios y sin terceros mirando.", + "sample": { + "todo": "Por hacer", + "todoCard1": "Preparar la demo", + "todoCard2": "Revisar el presupuesto", + "done": "Hecho", + "doneCard1": "Crear el tablero" + }, + "keywords": "Kanban · Self-hosted · Código abierto" + }, + "fields": { + "name": "Nombre", + "email": "Email", + "emailPlaceholder": "tu@correo.com", + "password": "Contraseña" + }, + "login": { + "metaTitle": "Iniciar sesión", + "title": "Bienvenido de nuevo", + "subtitle": "Accede a tu instancia para seguir con tus tableros.", + "accountDeleted": "Tu cuenta y tus datos se han borrado.", + "submit": "Entrar", + "submitting": "Entrando…", + "noAccount": "¿No tienes cuenta? <link>Crea una gratis</link>" + }, + "register": { + "metaTitle": "Crear cuenta", + "metaDescription": "Crea gratis tu cuenta de orgtable.", + "title": "Crea tu cuenta", + "subtitle": "Es gratis. ¿Ya tienes cuenta? <link>Inicia sesión</link>", + "passwordHint": "Al menos 8 caracteres.", + "honeypot": "No rellenes este campo", + "acceptTerms": "He leído la <privacy>política de privacidad</privacy> y acepto las <terms>condiciones de uso</terms>.", + "submit": "Crear cuenta", + "submitting": "Creando la cuenta…", + "firstLayer": "Responsable: {operator}. Finalidad: darte la cuenta y el servicio. No se cede a nadie. Puedes descargar o borrar tus datos desde los ajustes cuando quieras.", + "closedTitle": "El registro está cerrado", + "closedBody": "En esta instancia las cuentas las crea quien la administra. Si necesitas una, pídesela a esa persona.", + "closedCta": "Ir a iniciar sesión" + }, + "setup": { + "metaTitle": "Configuración inicial", + "title": "Configura tu instancia", + "subtitle": "Este asistente solo aparece una vez. Crea tu cuenta de administrador.", + "instanceLegend": "Instancia", + "instanceName": "Nombre de la instancia", + "instanceNamePlaceholder": "Mi equipo", + "adminLegend": "Administrador", + "yourName": "Tu nombre", + "passwordHint": "Mínimo 8 caracteres.", + "submit": "Crear y entrar", + "submitting": "Creando…" + } +} diff --git a/locales/es/boards.json b/locales/es/boards.json new file mode 100644 index 0000000..c9ad5f4 --- /dev/null +++ b/locales/es/boards.json @@ -0,0 +1,169 @@ +{ + "roles": { + "OWNER": "Propietario", + "EDITOR": "Editor", + "VIEWER": "Lector" + }, + "confirm": { + "deletePrompt": "¿Borrar?", + "yes": "Sí", + "no": "No" + }, + "index": { + "metaTitle": "Tus tableros", + "title": "Tus tableros", + "subtitle": "Organiza tu trabajo en listas y tarjetas.", + "newBoardName": "Nombre del nuevo tablero", + "createBoard": "Crear tablero", + "importToggle": "Importar un tablero desde JSON", + "emptyTitle": "Todavía no tienes tableros", + "emptyText": "Crea el primero con el campo de arriba y empieza a organizar tu trabajo en listas y tarjetas.", + "cardCount": { "one": "{count} tarjeta", "other": "{count} tarjetas" }, + "sharedWith": { + "one": ", compartido con {count} persona: {names}", + "other": ", compartido con {count} personas: {names}" + }, + "exportBoard": "Exportar tablero {title}", + "exportTitle": "Exportar a JSON", + "deleteBoard": "Borrar tablero {title}", + "confirmDeleteBoard": "Sí, borrar el tablero {title}" + }, + "import": { + "fileLabel": "Fichero JSON del tablero a importar", + "submit": "Importar tablero", + "pending": "Importando…" + }, + "board": { + "metaTitle": "Tablero", + "viewBoard": "Tablero", + "viewCalendar": "Calendario", + "members": "Miembros", + "viewMembers": "Ver miembros", + "moveListFailed": "No se pudo mover la lista. Inténtalo de nuevo.", + "moveCardFailed": "No se pudo mover la tarjeta. Inténtalo de nuevo." + }, + "dnd": { + "instructions": "Pulsa Espacio para coger el elemento, las flechas para moverlo, Espacio para soltarlo y Escape para cancelar. Intro abre la tarjeta.", + "listName": "la lista «{title}»", + "cardName": "la tarjeta «{title}»", + "pickedUp": "Has cogido {item}.", + "over": "Sobre {target}.", + "outside": "Fuera de cualquier lista.", + "dropped": "Has soltado {item} sobre {target}.", + "droppedOutside": "Has soltado {item} fuera de cualquier lista; no se mueve.", + "cancelled": "Movimiento cancelado; {item} vuelve a su sitio." + }, + "list": { + "roleDescription": "lista", + "delete": "Borrar lista {title}", + "confirmDelete": "Sí, borrar la lista {title} y todas sus tarjetas", + "keep": "No, conservar la lista", + "addPlaceholder": "+ Añadir lista", + "newName": "Nombre de la nueva lista" + }, + "card": { + "roleDescription": "tarjeta", + "addPlaceholder": "+ Añadir tarjeta", + "newIn": "Nueva tarjeta en {list}", + "subtasks": "Subtareas: {done} de {total} completadas", + "overdue": ", vencida" + }, + "filters": { + "button": "Filtrar", + "dialog": "Filtros", + "labels": "Etiquetas", + "noLabels": "Sin etiquetas.", + "assignees": "Asignados", + "due": "Vencimiento", + "dueAny": "Cualquiera", + "dueOverdue": "Vencidas", + "dueSet": "Con fecha", + "dueNone": "Sin fecha", + "clear": "Limpiar filtros" + }, + "calendar": { + "instructions": "Pulsa Espacio para coger la tarjeta, las flechas para cambiar de día, Espacio para soltarla y Escape para cancelar. Intro abre la tarjeta.", + "pickedUp": "Has cogido la tarjeta «{title}».", + "over": "Sobre el {day}.", + "outside": "Fuera del calendario.", + "dropped": "Has soltado la tarjeta «{title}» en el {day}.", + "droppedOutside": "Has soltado la tarjeta «{title}» fuera del calendario; no cambia.", + "cancelled": "Movimiento cancelado; la tarjeta «{title}» sigue en su día.", + "previousMonth": "Mes anterior", + "nextMonth": "Mes siguiente", + "today": "Hoy", + "dueDateFailed": "No se pudo cambiar la fecha. Inténtalo de nuevo.", + "undated": { + "one": "{count} tarjeta sin fecha de vencimiento (no aparece en el calendario).", + "other": "{count} tarjetas sin fecha de vencimiento (no aparecen en el calendario)." + } + }, + "members": { + "title": "Miembros", + "descriptionManage": "Invita a personas y elige qué pueden hacer en este tablero.", + "descriptionView": "Personas con acceso a este tablero.", + "email": "Email", + "emailPlaceholder": "persona@ejemplo.com", + "role": "Rol", + "invite": "Invitar", + "inviting": "Añadiendo…", + "roleOf": "Rol de {name}", + "remove": "Quitar a {name}" + }, + "cardModal": { + "labels": "Etiquetas", + "labelToggleFailed": "No se pudo cambiar la etiqueta.", + "noBoardLabels": "Aún no hay etiquetas en este tablero.", + "noLabels": "Sin etiquetas.", + "labelColor": "Color {color}", + "colors": { + "green": "verde", + "lime": "lima", + "amber": "ámbar", + "red": "rojo", + "purple": "morado", + "blue": "azul", + "teal": "turquesa", + "pink": "rosa" + }, + "newLabel": "Nueva etiqueta", + "newLabelName": "Nombre de la nueva etiqueta", + "add": "Añadir", + "assignees": "Asignados", + "unassign": "Quitar a {name}", + "nobodyAssigned": "Nadie asignado.", + "description": "Descripción", + "descriptionPlaceholder": "Añade más detalle…", + "noDescription": "Sin descripción.", + "dueDate": "Vencimiento", + "dueDateInline": "Vencimiento:", + "saveChanges": "Guardar cambios", + "checklists": "Checklists", + "checklistProgress": "· {done} de {total} completadas", + "deleteChecklist": "Eliminar", + "itemToggleFailed": "No se pudo actualizar el ítem.", + "markNotDone": "Marcar como pendiente", + "markDone": "Marcar como hecho", + "deleteItem": "Borrar ítem", + "addItemPlaceholder": "+ Añadir ítem", + "newItemIn": "Nuevo ítem en {checklist}", + "newChecklist": "Nueva checklist", + "newChecklistTitle": "Título de la nueva checklist", + "addChecklist": "Añadir checklist", + "attachments": "Adjuntos", + "noAttachments": "No hay adjuntos.", + "deleteAttachment": "Borrar adjunto {filename}", + "attachFile": "Archivo para adjuntar a la tarjeta", + "attach": "Adjuntar", + "uploading": "Subiendo…", + "comments": "Comentarios", + "noComments": "Sé el primero en comentar.", + "deleteComment": "Borrar comentario", + "commentPlaceholder": "Escribe un comentario…", + "newComment": "Nuevo comentario", + "comment": "Comentar", + "deleteCard": "Borrar tarjeta", + "deleteCardPrompt": "¿Borrar la tarjeta?", + "confirmDeleteCard": "Sí, borrar" + } +} diff --git a/locales/es/common.json b/locales/es/common.json new file mode 100644 index 0000000..09d192f --- /dev/null +++ b/locales/es/common.json @@ -0,0 +1,22 @@ +{ + "appName": "orgtable", + "tagline": "Tableros Kanban self-hosted, una alternativa a Trello", + "actions": { + "save": "Guardar", + "saving": "Guardando…", + "cancel": "Cancelar", + "close": "Cerrar", + "delete": "Borrar", + "confirm": "Confirmar", + "create": "Crear", + "edit": "Editar", + "back": "Volver" + }, + "language": { + "label": "Idioma", + "description": "El idioma de la interfaz. Se guarda en este navegador y en tu cuenta.", + "switchTo": "Cambiar de idioma", + "saved": "Idioma cambiado." + }, + "genericError": "Algo ha fallado. Inténtalo de nuevo." +} diff --git a/locales/es/errors.json b/locales/es/errors.json new file mode 100644 index 0000000..11f2142 --- /dev/null +++ b/locales/es/errors.json @@ -0,0 +1,101 @@ +{ + "validation": { + "emailInvalid": "Email no válido", + "contactEmailInvalid": "Email de contacto no válido", + "passwordRequired": "La contraseña es obligatoria", + "passwordTooShort": "La contraseña debe tener al menos 8 caracteres", + "passwordTooLong": "Máximo 72 bytes (unos 72 caracteres sin tildes ni emojis)", + "currentPasswordRequired": "Escribe tu contraseña actual", + "passwordsDontMatch": "Las dos contraseñas nuevas no coinciden", + "passwordToConfirm": "Escribe tu contraseña para confirmar", + "instanceNameRequired": "El nombre de la instancia es obligatorio", + "yourNameRequired": "Tu nombre es obligatorio", + "nameRequired": "El nombre es obligatorio", + "titleRequired": "El título es obligatorio", + "commentRequired": "El comentario no puede estar vacío", + "roleInvalid": "Elige un rol válido", + "acceptTermsRequired": "Tienes que aceptar la política de privacidad y las condiciones", + "noFieldsToUpdate": "No hay campos que actualizar.", + "maxChars40": "Máximo 40 caracteres", + "maxChars60": "Máximo 60 caracteres", + "maxChars120": "Máximo 120 caracteres", + "maxChars200": "Máximo 200 caracteres", + "maxChars254": "Máximo 254 caracteres", + "maxChars500": "Máximo 500 caracteres", + "maxChars2000": "Máximo 2000 caracteres", + "backupDaysNumber": "Los días de las copias tienen que ser un número", + "backupDaysInteger": "Los días de las copias tienen que ser un número entero", + "backupDaysNegative": "Los días de las copias no pueden ser negativos", + "backupDaysMax": "Máximo 3650 días" + }, + "auth": { + "checkCredentials": "Revisa el email y la contraseña.", + "invalidCredentials": "Credenciales incorrectas.", + "tooManyAttempts": { + "one": "Demasiados intentos. Inténtalo de nuevo en {seconds} segundo.", + "other": "Demasiados intentos. Inténtalo de nuevo en {seconds} segundos." + } + }, + "register": { + "closed": "El registro está cerrado en esta instancia.", + "failed": "No se pudo crear la cuenta. Inténtalo de nuevo.", + "rateLimited": { + "one": "Se han creado demasiadas cuentas seguidas. Inténtalo dentro de {minutes} minuto.", + "other": "Se han creado demasiadas cuentas seguidas. Inténtalo dentro de {minutes} minutos." + }, + "emailTaken": "Ya existe una cuenta con ese email. Inicia sesión." + }, + "setup": { + "failed": "No se pudo completar la configuración. Inténtalo de nuevo." + }, + "account": { + "emailChangeNeedsPassword": "Para cambiar el email, escribe tu contraseña actual.", + "emailTaken": "Ese email ya lo usa otra cuenta.", + "profileSaved": "Perfil guardado.", + "wrongCurrentPassword": "La contraseña actual no es correcta.", + "passwordChanged": "Contraseña cambiada.", + "passwordChangedSessionsClosed": { + "one": "Contraseña cambiada. Se cerró {count} sesión más.", + "other": "Contraseña cambiada. Se cerraron {count} sesiones más." + }, + "wrongPassword": "La contraseña no es correcta.", + "lastAdmin": "Eres la única persona administradora. Nombra a otra antes de borrar tu cuenta." + }, + "admin": { + "emailTaken": "Ya existe un usuario con ese email.", + "userCreated": "Cuenta creada para {name}." + }, + "instance": { + "legalInfoRequired": "Para abrir el registro, rellena antes el responsable y el email de contacto: la política de privacidad los necesita.", + "notConfigured": "La instancia aún no está configurada.", + "saved": "Ajustes guardados." + }, + "tokens": { + "invalidScope": "Permisos no válidos.", + "invalidExpiry": "Caducidad no válida." + }, + "boards": { + "importNoFile": "Selecciona un archivo JSON.", + "importTooLarge": "El archivo es demasiado grande (máx. {megabytes} MB).", + "importInvalidJson": "El archivo no es JSON válido.", + "importInvalidFormat": "El archivo no tiene el formato de un tablero de orgtable." + }, + "cards": { + "notFound": "La tarjeta ya no existe.", + "noPermission": "No tienes permiso para editar esta tarjeta.", + "invalidDueDate": "La fecha de vencimiento no es válida.", + "descriptionTooLong": "La descripción no puede superar {max} caracteres (tiene {length})." + }, + "attachments": { + "cardNotFound": "Tarjeta no encontrada.", + "noPermission": "No tienes permiso para adjuntar.", + "noFile": "Selecciona un archivo.", + "tooLarge": "El archivo supera el límite de {megabytes} MB." + }, + "members": { + "ownerOnly": "Solo el propietario puede gestionar los miembros.", + "userNotFound": "No existe ningún usuario con ese email. Pídele al administrador que cree la cuenta primero.", + "alreadyMember": "{name} ya es miembro de este tablero.", + "added": "{name} añadido como {role}." + } +} diff --git a/locales/es/index.ts b/locales/es/index.ts new file mode 100644 index 0000000..eddf02d --- /dev/null +++ b/locales/es/index.ts @@ -0,0 +1,30 @@ +// One language: its metadata and its messages by area. This file is the same in every +// language folder; when adding a language, copy it as is. +import language from "./language.json"; +import common from "./common.json"; +import auth from "./auth.json"; +import legal from "./legal.json"; +import nav from "./nav.json"; +import settings from "./settings.json"; +import admin from "./admin.json"; +import search from "./search.json"; +import boards from "./boards.json"; +import errors from "./errors.json"; +import notifications from "./notifications.json"; + +const messages = { + common, + auth, + legal, + nav, + settings, + admin, + search, + boards, + errors, + notifications, +}; + +const languagePack = { language, messages }; + +export default languagePack; diff --git a/locales/es/language.json b/locales/es/language.json new file mode 100644 index 0000000..4836df9 --- /dev/null +++ b/locales/es/language.json @@ -0,0 +1,5 @@ +{ + "code": "es", + "name": "Español", + "intl": "es-ES" +} diff --git a/locales/es/legal.json b/locales/es/legal.json new file mode 100644 index 0000000..91ca3ae --- /dev/null +++ b/locales/es/legal.json @@ -0,0 +1,233 @@ +{ + "footer": { + "navLabel": "Información legal", + "privacy": "Privacidad", + "terms": "Condiciones", + "cookies": "Cookies", + "legalNotice": "Aviso legal", + "sourceCode": "Código fuente", + "createdBy": "Creado por <link>{author}</link>" + }, + "layout": { + "goToBoards": "Ir a tus tableros", + "signIn": "Iniciar sesión" + }, + "nav": { + "label": "Páginas legales", + "heading": "Legal", + "privacy": "Privacidad", + "terms": "Condiciones de uso", + "cookies": "Cookies", + "legalNotice": "Aviso legal" + }, + "shared": { + "notProvided": "sin indicar por el operador", + "appliesTo": "Se aplica a esta instancia de orgtable.", + "appliesToNamed": "Se aplica a esta instancia de orgtable ({instance}).", + "lastUpdated": "Última actualización: {date}.", + "missingInfo": "El administrador de esta instancia todavía no ha indicado quién es el responsable ni cómo contactarle. Mientras falten esos datos, el registro abierto está desactivado.", + "operator": { + "controller": "Responsable", + "contact": "Contacto", + "taxId": "NIF", + "address": "Dirección", + "hosting": "Dónde se alojan los datos" + } + }, + "privacy": { + "title": "Política de privacidad", + "metaDescription": "Qué datos guarda esta instancia de orgtable, para qué y cuánto tiempo.", + "summary": { + "heading": "En resumen", + "body": "Esta instancia guarda lo necesario para que tengas una cuenta y tus tableros funcionen. No hay anuncios, ni analíticas, ni cookies de seguimiento: solo cookies técnicas, la de la sesión y la del idioma. Puedes descargar o borrar tus datos tú mismo desde <link>Ajustes › Privacidad y datos</link>." + }, + "controller": { + "heading": "Responsable del tratamiento", + "body": "El responsable es quien opera esta instancia, no el autor del software. orgtable es código abierto y cualquiera puede alojar el suyo: cada instancia tiene su propio responsable." + }, + "data": { + "heading": "Qué datos se guardan y para qué", + "colData": "Dato", + "colPurpose": "Para qué", + "colBasis": "Base legal", + "account": { + "data": "Nombre, email, la contraseña cifrada con bcrypt (nunca en claro) y el idioma de la interfaz", + "purpose": "Tu cuenta: entrar, que te reconozcan en los tableros compartidos y ver la app en tu idioma", + "basis": "Ejecución del servicio que pides (art. 6.1.b RGPD)" + }, + "acceptance": { + "data": "Fecha en la que aceptaste esta política y las condiciones", + "purpose": "Poder demostrar que las aceptaste al registrarte", + "basis": "Interés legítimo en poder acreditar que las aceptaste (art. 6.1.f)" + }, + "content": { + "data": "Tus tableros, listas, tarjetas, etiquetas, checklists, comentarios, asignaciones y adjuntos", + "purpose": "El servicio en sí", + "basis": "Ejecución del servicio (art. 6.1.b)" + }, + "notifications": { + "data": "Notificaciones dentro de la app", + "purpose": "Avisarte de cambios en tus tableros y de vencimientos", + "basis": "Ejecución del servicio (art. 6.1.b)" + }, + "sessions": { + "data": "Sesiones: navegador (user-agent), fecha de inicio y de caducidad", + "purpose": "Mantenerte con la sesión iniciada y que puedas cerrarlas a distancia", + "basis": "Ejecución del servicio (art. 6.1.b)" + }, + "tokens": { + "data": "Tokens de API: nombre, prefijo visible y su hash (nunca el token)", + "purpose": "Que uses la API con tus propios scripts", + "basis": "Ejecución del servicio (art. 6.1.b)" + }, + "audit": { + "dataWithIp": "Registro de seguridad: inicios y cierres de sesión, intentos fallidos y cambios de cuentas y permisos, con la dirección IP", + "dataWithoutIp": "Registro de seguridad: inicios y cierres de sesión, intentos fallidos y cambios de cuentas y permisos (esta instancia no guarda la IP)", + "purpose": "Detectar accesos indebidos y abusos", + "basis": "Interés legítimo en mantener el servicio seguro (art. 6.1.f)" + }, + "noProfiling": "No se usan tus datos para publicidad, no se elaboran perfiles ni se toman decisiones automatizadas sobre ti, y no se venden ni se ceden a nadie." + }, + "retention": { + "heading": "Cuánto tiempo se guardan", + "account": "Tu cuenta y tu contenido: hasta que los borres o borres tu cuenta.", + "sessionLength": { + "one": "Una sesión dura {count} día.", + "other": "Una sesión dura {count} días." + }, + "endedSessions": { + "one": "Las sesiones cerradas o caducadas se borran al cabo de {count} día.", + "other": "Las sesiones cerradas o caducadas se borran a los {count} días." + }, + "notifications": { + "one": "Las notificaciones se borran al cabo de {count} día.", + "other": "Las notificaciones se borran a los {count} días." + }, + "audit": { + "one": "El registro de seguridad se borra al cabo de {count} día.", + "other": "El registro de seguridad se borra a los {count} días." + }, + "backupsDays": { + "one": "Copias de seguridad: se guardan {count} día. Lo que borres desaparece de las copias cuando caducan.", + "other": "Copias de seguridad: se guardan {count} días. Lo que borres desaparece de las copias cuando caducan." + }, + "backupsNone": "Copias de seguridad: esta instancia no hace copias de seguridad.", + "backupsUnknown": "Copias de seguridad: {notProvided}" + }, + "recipients": { + "heading": "Quién más puede ver tus datos", + "boardMembers": "<b>Las personas con las que compartes un tablero</b> ven tu nombre y tu email, y lo que escribes en ese tablero.", + "admin": "<b>Quien administra la instancia</b> ve la lista de cuentas (nombre, email, rol) y el registro de seguridad. No ve tu contraseña.", + "hosting": "<b>El proveedor de alojamiento</b> guarda los datos por cuenta del responsable, como encargado del tratamiento: {hosting}.", + "outsideEea": "Si los datos se alojan fuera del Espacio Económico Europeo, el responsable tiene que indicarlo arriba y aplicar las garantías del capítulo V del RGPD." + }, + "cookies": { + "heading": "Cookies y almacenamiento del navegador", + "body": "Dos cookies técnicas: <code>session</code>, imprescindible para mantener la sesión iniciada, y <code>locale</code>, que recuerda el idioma que eliges. Tu preferencia de tema (claro u oscuro) se guarda en el almacenamiento local de tu navegador y no sale de él. Detalle en la <link>política de cookies</link>." + }, + "rights": { + "heading": "Tus derechos", + "intro": "Puedes ejercerlos tú mismo, sin pedir permiso a nadie:", + "access": "<b>Acceso y portabilidad</b>: descarga todos tus datos en JSON desde <link>Ajustes › Privacidad y datos</link>.", + "rectification": "<b>Rectificación</b>: cambia tu nombre y tu email en <link>Ajustes › Perfil</link>.", + "erasure": "<b>Supresión</b>: borra tu cuenta desde <link>Ajustes › Privacidad y datos</link>. Se borran tu cuenta, los tableros que creaste (con sus tarjetas y adjuntos), tus comentarios y tus notificaciones. Los tableros de otras personas siguen, sin ti.", + "objection": "<b>Oposición y limitación</b>: escribe al contacto del responsable.", + "complaint": "Si crees que no se han respetado tus derechos, puedes reclamar ante la autoridad de protección de datos. En España es la <link>Agencia Española de Protección de Datos</link>." + }, + "security": { + "heading": "Seguridad", + "body": "Las contraseñas se guardan con bcrypt, las sesiones se pueden revocar al momento y los tokens de API se guardan como hash. Los intentos de inicio de sesión tienen un límite para frenar ataques de fuerza bruta." + }, + "changes": { + "heading": "Cambios en esta política", + "body": "Si cambian los datos del responsable, la fecha de «última actualización» de arriba cambia con ellos. Los cambios en lo que guarda el software se publican en su <link>código fuente</link>." + } + }, + "terms": { + "title": "Condiciones de uso", + "metaDescription": "Condiciones para usar esta instancia de orgtable.", + "provider": { + "heading": "Quién presta el servicio", + "body": "Esta instancia de orgtable la opera {operator}. orgtable es un software de código abierto ({license}); su autor no presta este servicio ni responde de él, salvo que sea también quien opera la instancia." + }, + "service": { + "heading": "El servicio", + "body": "Tableros Kanban para organizar trabajo, solo o en equipo. Crear una cuenta y usarla es gratis. Al registrarte aceptas estas condiciones y declaras haber leído la <link>política de privacidad</link>." + }, + "account": { + "heading": "Tu cuenta", + "age": "Para registrarte necesitas tener al menos 14 años.", + "responsibility": "Eres responsable de lo que se haga con tu cuenta. Usa una contraseña que no uses en otro sitio y cierra las sesiones que no reconozcas desde los ajustes.", + "deletion": "Puedes borrar tu cuenta cuando quieras desde los ajustes." + }, + "content": { + "heading": "Tu contenido", + "ownership": "Lo que escribes y subes sigue siendo tuyo. Solo lo guardamos y lo mostramos a las personas con las que compartes cada tablero, para que el servicio funcione.", + "prohibitedIntro": "No está permitido usar el servicio para:", + "illegal": "Publicar contenido ilegal o que vulnere derechos de terceros.", + "malware": "Subir malware o archivos para distribuirlos fuera del servicio.", + "spam": "Enviar spam o acosar a otras personas.", + "abuse": "Intentar acceder a cuentas o tableros ajenos, o sobrecargar el servicio a propósito.", + "enforcement": "Si se incumplen estas normas, el responsable puede retirar el contenido o suspender la cuenta. Cuando sea posible, te avisará antes y te explicará el motivo." + }, + "availability": { + "heading": "Disponibilidad y garantías", + "body": "El servicio se ofrece gratis y «tal cual», sin garantía de disponibilidad continua ni de que no haya errores. Haz copias de lo importante: puedes exportar cada tablero y todos tus datos desde la aplicación. Nada de lo anterior limita los derechos que te reconoce la ley como persona usuaria." + }, + "changes": { + "heading": "Cambios y fin del servicio", + "body": "Si las condiciones cambian de forma importante, o si el servicio va a cerrar, se avisará con antelación razonable para que puedas exportar tus datos." + }, + "software": { + "heading": "El software", + "body": "orgtable se distribuye bajo la licencia {license}. Puedes consultar, descargar y modificar el código de esta instancia en su <link>repositorio</link>, y alojar tu propia instancia." + }, + "contact": { + "heading": "Contacto", + "body": "Para cualquier duda sobre estas condiciones: {contact}." + } + }, + "cookies": { + "title": "Política de cookies", + "metaDescription": "Qué guarda esta instancia de orgtable en tu navegador.", + "intro": "orgtable no usa cookies de publicidad, de analítica ni de terceros. Solo guarda en tu navegador lo imprescindible para funcionar, que no necesita tu consentimiento (art. 22.2 de la LSSI). Por eso no verás un aviso de cookies.", + "colName": "Nombre", + "colType": "Tipo", + "colPurpose": "Para qué", + "colDuration": "Duración", + "session": { + "type": "Cookie propia, HttpOnly", + "purpose": "Mantener tu sesión iniciada", + "duration": { + "one": "{count} día, o hasta que cierres sesión", + "other": "{count} días, o hasta que cierres sesión" + } + }, + "locale": { + "type": "Cookie propia", + "purpose": "Recordar el idioma que eliges", + "duration": "Un año" + }, + "theme": { + "type": "Almacenamiento local del navegador", + "purpose": "Recordar si prefieres el tema claro u oscuro", + "duration": "Hasta que lo borres desde tu navegador" + }, + "outro": "Puedes borrarlas todas desde la configuración de tu navegador. Sin la cookie de sesión no podrás usar la aplicación con tu cuenta." + }, + "notice": { + "title": "Aviso legal", + "metaDescription": "Datos de quien opera esta instancia de orgtable.", + "owner": { + "heading": "Titular" + }, + "purpose": { + "heading": "Objeto", + "body": "Este sitio ofrece una instancia de orgtable, una aplicación de tableros Kanban. Las condiciones para usarla están en las <terms>condiciones de uso</terms> y el tratamiento de datos, en la <privacy>política de privacidad</privacy>." + }, + "ip": { + "heading": "Propiedad intelectual", + "body": "El software orgtable es de código abierto y se distribuye con su propia licencia, enlazada en el pie de esta página. El contenido de los tableros pertenece a quien lo crea." + } + } +} diff --git a/locales/es/nav.json b/locales/es/nav.json new file mode 100644 index 0000000..7dc6af8 --- /dev/null +++ b/locales/es/nav.json @@ -0,0 +1,30 @@ +{ + "search": { + "label": "Buscar", + "placeholder": "Buscar tarjetas y tableros…" + }, + "theme": { + "toggle": "Cambiar entre tema claro y oscuro" + }, + "account": { + "label": "Tu cuenta: {name}", + "profile": "Perfil y ajustes", + "security": "Contraseña y sesiones", + "privacy": "Privacidad y datos", + "admin": "Administración", + "signOut": "Cerrar sesión" + }, + "notifications": { + "title": "Notificaciones", + "labelUnread": "Notificaciones ({count} sin leer)", + "markAll": "Marcar todas", + "empty": "No tienes notificaciones.", + "time": { + "now": "ahora", + "minutes": "hace {count} min", + "hours": "hace {count} h", + "yesterday": "ayer", + "days": "hace {count} d" + } + } +} diff --git a/locales/es/notifications.json b/locales/es/notifications.json new file mode 100644 index 0000000..d6a220f --- /dev/null +++ b/locales/es/notifications.json @@ -0,0 +1,31 @@ +{ + "roles": { + "OWNER": "Propietario", + "EDITOR": "Editor", + "VIEWER": "Lector" + }, + "someBoard": "un tablero", + "memberAdded": { + "title": "Te han añadido a «{board}»", + "body": "Como {role}." + }, + "memberRoleChanged": { + "title": "Tu rol en «{board}» ha cambiado", + "body": "Ahora eres {role}." + }, + "memberRemoved": { + "title": "Te han quitado de «{board}»" + }, + "cardAssigned": { + "title": "Te han asignado «{card}»", + "body": "En el tablero «{board}»." + }, + "commentAdded": { + "title": "Nuevo comentario en «{card}»" + }, + "dueReminder": { + "soonTitle": "Vence pronto: «{card}»", + "overdueTitle": "Tarea vencida: «{card}»", + "body": "Vencimiento: {date}." + } +} diff --git a/locales/es/search.json b/locales/es/search.json new file mode 100644 index 0000000..af91fce --- /dev/null +++ b/locales/es/search.json @@ -0,0 +1,12 @@ +{ + "meta": { + "title": "Búsqueda" + }, + "title": "Búsqueda", + "tooShort": "Escribe al menos {min} caracteres para buscar en tus tableros y tarjetas.", + "noResults": "Sin resultados para «{query}»", + "noResultsHint": "Prueba con otras palabras. Solo se buscan tableros y tarjetas a los que tienes acceso.", + "boards": "Tableros · {count}", + "cards": "Tarjetas · {count}", + "boardBadge": "Tablero" +} diff --git a/locales/es/settings.json b/locales/es/settings.json new file mode 100644 index 0000000..58788dc --- /dev/null +++ b/locales/es/settings.json @@ -0,0 +1,121 @@ +{ + "meta": { + "title": "Ajustes", + "description": "Tu perfil, tu seguridad, tus tokens de API y tus datos." + }, + "title": "Ajustes", + "navLabel": "Secciones de ajustes", + "groups": { + "account": "Cuenta", + "data": "Tus datos" + }, + "sections": { + "profile": "Perfil", + "security": "Contraseña y sesiones", + "api": "Tokens de API", + "appearance": "Apariencia", + "data": "Privacidad y datos", + "about": "Acerca de" + }, + "profile": { + "description": "Así te ven las personas con las que compartes tableros.", + "name": "Nombre", + "email": "Email", + "currentPassword": "Contraseña actual", + "currentPasswordHint": "Solo hace falta si cambias el email.", + "save": "Guardar perfil" + }, + "security": { + "description": "Al cambiar la contraseña se cierran tus otras sesiones.", + "currentPassword": "Contraseña actual", + "newPassword": "Nueva contraseña", + "newPasswordHint": "Al menos 8 caracteres.", + "confirmPassword": "Repite la nueva", + "submit": "Cambiar contraseña", + "submitting": "Cambiando…", + "sessionsTitle": "Sesiones abiertas", + "thisSession": "Esta sesión", + "sessionDates": "iniciada {started} · caduca {expires}", + "signOut": "Cerrar", + "signOutOthers": { + "one": "Cerrar la otra sesión", + "other": "Cerrar las otras {count} sesiones" + }, + "browserOnOs": "{browser} en {os}", + "unknownBrowser": "Navegador desconocido" + }, + "tokens": { + "description": "Para usar la API de orgtable desde scripts u otras aplicaciones. Un token actúa <strong>como tú</strong>, con tu mismo acceso a los tableros, y solo se muestra una vez. Consulta la <link>documentación de la API</link>.", + "empty": "Aún no has creado ningún token.", + "scopeRead": "Solo lectura", + "scopeFull": "Lectura y escritura", + "scopeWrite": "Escritura", + "expired": "Caducado", + "created": "creado {date}", + "expires": "caduca {date}", + "neverExpires": "no caduca", + "used": "usado {date}", + "neverUsed": "sin usar", + "revoke": "Revocar", + "form": { + "created": "Token «{name}» creado", + "copyNow": "Cópialo ahora: por seguridad no se volverá a mostrar.", + "copy": "Copiar", + "copied": "Copiado", + "copyFailed": "No se pudo copiar automáticamente: selecciona el token y cópialo a mano.", + "name": "Nombre del token", + "namePlaceholder": "p. ej. Script de backup", + "scope": "Permisos", + "expiry": "Caduca", + "expiryNever": "Nunca", + "expiryDays": { + "one": "{count} día", + "other": "{count} días" + }, + "expiryYear": "1 año", + "submit": "Crear token", + "submitting": "Creando…" + } + }, + "appearance": { + "description": "El tema y el idioma de la interfaz.", + "theme": "Tema", + "themeHint": "Se guarda en este navegador.", + "themes": { + "light": { + "label": "Claro", + "hint": "Papel cálido" + }, + "dark": { + "label": "Oscuro", + "hint": "Carbón cálido" + }, + "system": { + "label": "Del sistema", + "hint": "Sigue a tu dispositivo" + } + } + }, + "data": { + "description": "Tus derechos del RGPD, sin tener que escribir a nadie. El detalle está en la <link>política de privacidad</link>.", + "export": { + "title": "Descargar mis datos", + "description": "Un archivo JSON con tu cuenta, tus tableros, tus comentarios, tus notificaciones y tu registro de seguridad.", + "button": "Descargar .json" + }, + "delete": { + "title": "Borrar mi cuenta", + "descriptionNoBoards": "Se borran para siempre tu cuenta y tus comentarios. Los tableros de otras personas siguen, sin ti. No se puede deshacer.", + "descriptionWithBoards": { + "one": "Se borran para siempre tu cuenta, el tablero que creaste (con sus tarjetas y adjuntos) y tus comentarios. Los tableros de otras personas siguen, sin ti. No se puede deshacer.", + "other": "Se borran para siempre tu cuenta, los {count} tableros que creaste (con sus tarjetas y adjuntos) y tus comentarios. Los tableros de otras personas siguen, sin ti. No se puede deshacer." + }, + "confirmPassword": "Escribe tu contraseña para confirmar", + "submit": "Borrar mi cuenta", + "submitting": "Borrando…" + } + }, + "about": { + "line": "orgtable <version>{version}</version> · <source>Código fuente</source> · Licencia <license>{license}</license>" + } +} diff --git a/locales/index.ts b/locales/index.ts new file mode 100644 index 0000000..a9a35ea --- /dev/null +++ b/locales/index.ts @@ -0,0 +1,12 @@ +import en from "./en"; +import es from "./es"; + +// Languages of the interface. English (`en`) is the reference: every other language is +// checked against it (test/i18n.test.ts) and falls back to it for any missing message. +// +// To add a language, copy `en/` to `<code>/`, translate the JSON files, set +// `language.json`, and add one line below. Step by step: locales/README.md. +export const LANGUAGES = { + en, + es, +}; diff --git a/next.config.ts b/next.config.ts index 032057f..f996c8c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,18 +1,18 @@ import type { NextConfig } from "next"; -// Cabeceras de seguridad aplicadas a todas las respuestas. La CSP de las páginas lleva un -// nonce por petición y la pone proxy.ts (app/lib/csp.ts); aquí van las cabeceras fijas, -// una CSP cerrada para /api, que solo sirve JSON, SSE y descargas, y otra para los .svg, -// que proxy.ts no cubre. Ver docs/security.md. +// Security headers applied to every response. The page CSP carries a per-request nonce +// and is set by proxy.ts (app/lib/csp.ts); this file holds the static headers, a locked +// down CSP for /api, which only serves JSON, SSE and downloads, and another one for .svg +// files, which proxy.ts does not cover. See docs/security.md. const securityHeaders = [ - // Anti-clickjacking: la app no se embebe en iframes. + // Anti-clickjacking: the app is never embedded in iframes. { key: "X-Frame-Options", value: "DENY" }, - // Evita el MIME-sniffing (refuerza la descarga forzada de adjuntos). + // Prevents MIME sniffing (reinforces the forced download of attachments). { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, - // HSTS: el navegador solo lo respeta si la respuesta llega por HTTPS, así que no - // afecta a instalaciones por http://IP:3000. Sin includeSubDomains: orgtable suele - // vivir en un subdominio de alguien y no debe imponer HTTPS a sus hermanos. + // HSTS: browsers only honour it over HTTPS, so it does not affect installations on + // http://IP:3000. No includeSubDomains: orgtable usually lives on someone's subdomain + // and must not force HTTPS on its siblings. { key: "Strict-Transport-Security", value: "max-age=31536000" }, { key: "Permissions-Policy", @@ -20,26 +20,26 @@ const securityHeaders = [ }, ]; -// Tamaño máximo del cuerpo de una petición a una Server Action. Next corta en 1 MB por -// defecto, antes de ejecutar la acción, así que un adjunto de 3 MB fallaba con un error -// genérico en vez de subir. Debe superar al mayor de los límites propios de las acciones: -// MAX_ATTACHMENT_BYTES (10 MB, app/lib/storage.ts) y el import de tableros (5 MB, -// app/lib/actions/boards.ts). El margen de 1 MB cubre la sobrecarga de multipart y deja -// que un archivo algo mayor que 10 MB llegue a la acción y reciba su mensaje propio. -// Es un límite global y Next lo aplica antes de autenticar: cualquiera puede enviar -// 11 MB a una acción. test/next-config.test.ts comprueba que sigue por encima de 10 MB. +// Maximum body size of a Server Action request. Next cuts off at 1 MB by default, before +// running the action, so a 3 MB attachment failed with a generic error instead of +// uploading. It must exceed the largest of the actions' own limits: MAX_ATTACHMENT_BYTES +// (10 MB, app/lib/storage.ts) and board import (5 MB, app/lib/actions/boards.ts). The +// 1 MB margin covers multipart overhead and lets a file slightly over 10 MB reach the +// action and get its own error message. +// It is a global limit and Next applies it before authentication: anyone can send 11 MB +// to an action. test/next-config.test.ts checks that it stays above 10 MB. const ACTION_BODY_LIMIT_BYTES = 11 * 1024 * 1024; const nextConfig: NextConfig = { - // Genera .next/standalone (servidor Node mínimo) para empaquetar la imagen Docker - // de producción. Se ejecuta con `node server.js`. Ver Dockerfile y docs/docker.md. + // Produces .next/standalone (minimal Node server) to package the production Docker + // image. Run with `node server.js`. See Dockerfile and docs/docker.md. output: "standalone", experimental: { serverActions: { bodySizeLimit: ACTION_BODY_LIMIT_BYTES }, - // proxy.ts también pasa por las rutas de página (donde llegan las Server Actions) y - // Next guarda su cuerpo en memoria hasta 10 MB; lo que pase de ahí llega TRUNCADO a - // la acción, sin error. Por eso va alineado con el límite de las acciones. + // proxy.ts also runs on page routes (where Server Actions arrive) and Next buffers + // their body in memory up to 10 MB; anything beyond that reaches the action + // TRUNCATED, with no error. That is why it matches the actions' limit. proxyClientMaxBodySize: ACTION_BODY_LIMIT_BYTES, }, @@ -55,9 +55,9 @@ const nextConfig: NextConfig = { }, ], }, - // Los .svg quedan fuera del matcher de proxy.ts (no reciben la CSP con nonce). Un SVG - // abierto directamente es un documento que puede ejecutar scripts en el origen de la - // app, así que se sirven con una CSP que solo permite sus estilos en línea. + // .svg files are outside proxy.ts's matcher (they do not get the nonce CSP). An SVG + // opened directly is a document that can run scripts on the app's origin, so they + // are served with a CSP that only allows their inline styles. { source: "/:file(.*\\.svg)", headers: [ diff --git a/package.json b/package.json index 9319549..641d944 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "orgtable", - "version": "0.1.0", + "version": "0.2.0", "private": true, - "license": "UNLICENSED", + "license": "AGPL-3.0-or-later", "packageManager": "pnpm@11.24.0", "scripts": { "dev": "next dev", @@ -23,7 +23,7 @@ "bcryptjs": "^3.0.3", "fractional-indexing": "^3.4.0", "jose": "^6.2.12", - "next": "^16.3.6", + "next": "^16.3.8", "pg": "^8.23.0", "prisma": "^7.10.0", "react": "19.3.0", @@ -38,7 +38,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "eslint": "^9.39.5", - "eslint-config-next": "^16.3.6", + "eslint-config-next": "^16.3.8", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", "vitest": "^4.1.11" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e203d8..1bd2ec8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,8 @@ overrides: brace-expansion@>=3.0.0 <5.0.9: ^5.0.12 browserslist@<4.28.7: ^4.28.7 mysql2@<3.23.1: ^3.23.1 + sharp@<0.35.5: ^0.35.5 + source-map-js@<1.2.2: ^1.2.2 importers: @@ -38,8 +40,8 @@ importers: specifier: ^6.2.12 version: 6.2.12 next: - specifier: ^16.3.6 - version: 16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: ^16.3.8 + version: 16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) pg: specifier: ^8.23.0 version: 8.23.0 @@ -78,8 +80,8 @@ importers: specifier: ^9.39.5 version: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) eslint-config-next: - specifier: ^16.3.6 - version: 16.3.6(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + specifier: ^16.3.8 + version: 16.3.8(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -279,160 +281,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -460,60 +462,60 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@next/env@16.3.6': - resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} + '@next/env@16.3.8': + resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==} - '@next/eslint-plugin-next@16.3.6': - resolution: {integrity: sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng==} + '@next/eslint-plugin-next@16.3.8': + resolution: {integrity: sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==} - '@next/swc-darwin-arm64@16.3.6': - resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==} + '@next/swc-darwin-arm64@16.3.8': + resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.6': - resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==} + '@next/swc-darwin-x64@16.3.8': + resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.6': - resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==} + '@next/swc-linux-arm64-gnu@16.3.8': + resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.6': - resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==} + '@next/swc-linux-arm64-musl@16.3.8': + resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.6': - resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==} + '@next/swc-linux-x64-gnu@16.3.8': + resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.6': - resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==} + '@next/swc-linux-x64-musl@16.3.8': + resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.6': - resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==} + '@next/swc-win32-arm64-msvc@16.3.8': + resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.6': - resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==} + '@next/swc-win32-x64-msvc@16.3.8': + resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1573,8 +1575,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@16.3.6: - resolution: {integrity: sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==} + eslint-config-next@16.3.8: + resolution: {integrity: sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -2246,8 +2248,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@16.3.6: - resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==} + next@16.3.8: + resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -2580,8 +2582,8 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -2623,8 +2625,8 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} split2@4.2.0: @@ -3167,108 +3169,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@jridgewell/gen-mapping@0.3.13': @@ -3297,37 +3299,37 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true - '@next/env@16.3.6': {} + '@next/env@16.3.8': {} - '@next/eslint-plugin-next@16.3.6(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))': + '@next/eslint-plugin-next@16.3.8(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) fast-glob: 3.3.1 transitivePeerDependencies: - eslint - '@next/swc-darwin-arm64@16.3.6': + '@next/swc-darwin-arm64@16.3.8': optional: true - '@next/swc-darwin-x64@16.3.6': + '@next/swc-darwin-x64@16.3.8': optional: true - '@next/swc-linux-arm64-gnu@16.3.6': + '@next/swc-linux-arm64-gnu@16.3.8': optional: true - '@next/swc-linux-arm64-musl@16.3.6': + '@next/swc-linux-arm64-musl@16.3.8': optional: true - '@next/swc-linux-x64-gnu@16.3.6': + '@next/swc-linux-x64-gnu@16.3.8': optional: true - '@next/swc-linux-x64-musl@16.3.6': + '@next/swc-linux-x64-musl@16.3.8': optional: true - '@next/swc-win32-arm64-msvc@16.3.6': + '@next/swc-win32-arm64-msvc@16.3.8': optional: true - '@next/swc-win32-x64-msvc@16.3.6': + '@next/swc-win32-x64-msvc@16.3.8': optional: true '@nodelib/fs.scandir@2.1.5': @@ -3574,7 +3576,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.3 '@tailwindcss/oxide-android-arm64@4.3.3': @@ -4455,9 +4457,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.3.6(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3): + eslint-config-next@16.3.8(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 16.3.6(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) + '@next/eslint-plugin-next': 16.3.8(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) eslint-import-resolver-node: 0.3.10(supports-color@7.2.0) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) @@ -5168,9 +5170,9 @@ snapshots: natural-compare@1.4.0: {} - next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@next/env': 16.3.6 + '@next/env': 16.3.8 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.25 caniuse-lite: 1.0.30001810 @@ -5179,15 +5181,15 @@ snapshots: react-dom: 19.3.0(react@19.3.0) styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.3.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.6 - '@next/swc-darwin-x64': 16.3.6 - '@next/swc-linux-arm64-gnu': 16.3.6 - '@next/swc-linux-arm64-musl': 16.3.6 - '@next/swc-linux-x64-gnu': 16.3.6 - '@next/swc-linux-x64-musl': 16.3.6 - '@next/swc-win32-arm64-msvc': 16.3.6 - '@next/swc-win32-x64-msvc': 16.3.6 - sharp: 0.35.4(@types/node@20.19.43) + '@next/swc-darwin-arm64': 16.3.8 + '@next/swc-darwin-x64': 16.3.8 + '@next/swc-linux-arm64-gnu': 16.3.8 + '@next/swc-linux-arm64-musl': 16.3.8 + '@next/swc-linux-x64-gnu': 16.3.8 + '@next/swc-linux-x64-musl': 16.3.8 + '@next/swc-win32-arm64-msvc': 16.3.8 + '@next/swc-win32-x64-msvc': 16.3.8 + sharp: 0.35.5(@types/node@20.19.43) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -5339,13 +5341,13 @@ snapshots: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postcss@8.5.28: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postgres-array@2.0.0: {} @@ -5538,37 +5540,37 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 - sharp@0.35.4(@types/node@20.19.43): + sharp@0.35.5(@types/node@20.19.43): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 '@types/node': 20.19.43 optional: true @@ -5612,7 +5614,7 @@ snapshots: signal-exit@4.1.0: {} - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} split2@4.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 43da85d..278ccad 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,23 +1,25 @@ -# Solo estos paquetes pueden ejecutar scripts de instalación (pnpm 11 bloquea el -# resto). Prisma necesita descargar el schema-engine que usa `migrate deploy`. +# Only these packages may run install scripts (pnpm 11 blocks the rest). Prisma needs +# to download the schema-engine used by `migrate deploy`. allowBuilds: '@prisma/engines': true prisma: true sharp: false unrs-resolver: false -# Fuerza versiones parcheadas de dependencias transitivas con avisos de seguridad, -# siempre dentro de la misma versión mayor. Revisar al subir next, prisma o -# eslint-config-next: si ya traen la versión corregida, se quita la línea. +# Force patched versions of transitive dependencies with security advisories, always +# within the same major version. Review when bumping next, prisma or +# eslint-config-next: if they already ship the fixed version, drop the line. overrides: brace-expansion@>=3.0.0 <5.0.9: ^5.0.12 browserslist@<4.28.7: ^4.28.7 mysql2@<3.23.1: ^3.23.1 + sharp@<0.35.5: ^0.35.5 + source-map-js@<1.2.2: ^1.2.2 auditConfig: ignoreGhsas: - # deepmerge-ts <8 (vía @prisma/config): desbordamiento de pila al fusionar grafos - # recursivos. Solo fusiona la configuración local de prisma.config.ts, que no es - # entrada de usuario. El parche exige deepmerge-ts 8 (mayor); se quita al subir a - # un prisma que lo incluya. Ver docs/DECISIONES.md (2026-09-24). + # deepmerge-ts <8 (via @prisma/config): stack overflow when merging recursive + # graphs. It only merges the local prisma.config.ts config, which is not user input. + # The fix requires deepmerge-ts 8 (major); remove this once on a prisma that ships + # it. See docs/DECISIONES.md (2026-09-24). - GHSA-ggr8-5vv4-36mx diff --git a/prisma.config.ts b/prisma.config.ts index 11449ec..3ca1924 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,13 +1,14 @@ import path from "node:path"; import { defineConfig, env } from "prisma/config"; -// Prisma 7 no carga `.env` automáticamente. Lo cargamos a mano con la API nativa de -// Node (disponible en 20.12+), sin depender de `dotenv`. En CI/producción donde no -// existe `.env` (las variables se inyectan por el entorno) el try/catch lo ignora. +// Prisma 7 does not load `.env` automatically. It is loaded by hand with Node's native +// API (available since 20.12), without depending on `dotenv`. In CI/production, where +// there is no `.env` (variables are injected through the environment), the try/catch +// skips it. try { process.loadEnvFile(path.join(process.cwd(), ".env")); } catch { - // .env ausente: las variables ya vienen del entorno (p. ej. docker-compose). + // No .env: the variables already come from the environment (e.g. docker-compose). } export default defineConfig({ @@ -15,8 +16,8 @@ export default defineConfig({ migrations: { path: path.join("prisma", "migrations"), }, - // URL usada por el CLI de Migrate (migrate dev/deploy). El runtime de la app usa - // su propio adapter en app/lib/prisma.ts. + // URL used by the Migrate CLI (migrate dev/deploy). The app runtime uses its own + // adapter in app/lib/prisma.ts. datasource: { url: env("DATABASE_URL"), }, diff --git a/prisma/migrations/20261014171630_registration_and_legal/migration.sql b/prisma/migrations/20261014171630_registration_and_legal/migration.sql new file mode 100644 index 0000000..a7312e0 --- /dev/null +++ b/prisma/migrations/20261014171630_registration_and_legal/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "InstanceConfig" ADD COLUMN "backupRetentionDays" INTEGER, +ADD COLUMN "hostingDescription" TEXT, +ADD COLUMN "legalUpdatedAt" TIMESTAMP(3), +ADD COLUMN "operatorAddress" TEXT, +ADD COLUMN "operatorEmail" TEXT, +ADD COLUMN "operatorLegalId" TEXT, +ADD COLUMN "operatorName" TEXT, +ADD COLUMN "registrationOpen" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "termsAcceptedAt" TIMESTAMP(3); diff --git a/prisma/migrations/20261014175506_user_locale/migration.sql b/prisma/migrations/20261014175506_user_locale/migration.sql new file mode 100644 index 0000000..1977671 --- /dev/null +++ b/prisma/migrations/20261014175506_user_locale/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "locale" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2078854..053a54f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,27 +1,43 @@ -// orgtable — esquema de base de datos (Prisma + PostgreSQL) -// Documentado en docs/database.md. Decisiones: docs/decisions/0001-prisma.md, +// orgtable — database schema (Prisma + PostgreSQL) +// Documented in docs/database.md. Decisions: docs/decisions/0001-prisma.md, // 0002-fractional-indexing.md. generator client { provider = "prisma-client-js" } -// En Prisma 7 la `url` ya NO va aquí: se define en prisma.config.ts (para el CLI de -// Migrate) y en runtime el PrismaClient se conecta mediante el driver adapter -// @prisma/adapter-pg (ver app/lib/prisma.ts). El query engine es WASM, no binario, -// por lo que no hace falta `binaryTargets` ni copiar engines a la imagen Docker. +// In Prisma 7 the `url` NO longer goes here: it is defined in prisma.config.ts (for the +// Migrate CLI) and at runtime PrismaClient connects through the @prisma/adapter-pg +// driver adapter (see app/lib/prisma.ts). The query engine is WASM, not a binary, so +// there is no need for `binaryTargets` or copying engines into the Docker image. datasource db { provider = "postgresql" } -// Configuración global de la instancia. Es una fila única (id fijo "singleton"). -// `setupCompletedAt` no-nulo es la fuente de verdad de "la instancia ya está -// configurada"; mientras sea null, el setup wizard (/setup) está activo. +// Instance-wide settings. A single row (fixed id "singleton"). +// A non-null `setupCompletedAt` is the source of truth for "the instance is already +// set up"; while it is null, the setup wizard (/setup) is active. model InstanceConfig { id String @id @default("singleton") instanceName String setupCompletedAt DateTime? createdAt DateTime @default(now()) + // Open sign-up: anyone can create an account at /register. Closed by default (private + // instance, the admin creates accounts). It can only be opened once the controller and + // the privacy contact are set, because the privacy policy needs them. + registrationOpen Boolean @default(false) + // Legal details of WHOEVER OPERATES this instance (not the software's author). Shown + // on /legal/*. All optional: without them the legal pages say so instead of making + // anything up. See docs/privacy.md. + operatorName String? + operatorEmail String? + operatorLegalId String? + operatorAddress String? + hostingDescription String? + backupRetentionDays Int? + // Date the legal details were last edited: it is the "last updated" date shown on the + // legal pages. + legalUpdatedAt DateTime? } enum Role { @@ -41,6 +57,13 @@ model User { passwordHash String name String role Role @default(MEMBER) + // When the person accepted the privacy policy and terms on sign-up (/register). + // Null for accounts created by the admin or by the setup wizard. + termsAcceptedAt DateTime? + // Interface language chosen by the person ("en", "es"…; see locales/index.ts). Null + // until they pick one: then the browser decides. Notifications for them are written in + // this language. + locale String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt boards Board[] @@ -53,11 +76,11 @@ model User { comments Comment[] } -// Sesión de servidor (Hito 4). La cookie sigue siendo un JWT firmado, pero ahora -// lleva el id de ESTA fila (sid). El DAL valida el JWT y además comprueba que la -// sesión existe, no está revocada y no ha expirado: así la revocación es INMEDIATA -// (cerrar sesión, restablecer contraseña por el admin, "cerrar en todos los -// dispositivos"). Ver docs/authentication.md y docs/decisions/0008-session-store.md. +// Server-side session. The cookie is still a signed JWT, but it carries the id of THIS +// row (sid). The DAL validates the JWT and also checks that the session exists, is not +// revoked and has not expired: this makes revocation IMMEDIATE (sign out, password reset +// by the admin, "sign out on all devices"). See docs/authentication.md and +// docs/decisions/0008-session-store.md. model Session { id String @id @default(cuid()) userId String @@ -91,12 +114,12 @@ model List { boardId String board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) title String - // Clave de ordenación por fractional indexing (string lexicográfico). - // Mover una lista actualiza SOLO su propia fila. Ver docs/drag-and-drop.md. - // En la BD la columna es TEXT COLLATE "C" (orden byte a byte), igual que - // Card/Checklist/ChecklistItem.position: con la collation por defecto (glibc) 'Zz' - // quedaría después de 'a0'. Prisma no modela la collation; la fija la migración - // 20260924000000_position_collate_c. Ver ADR 0002. + // Fractional indexing sort key (lexicographic string). + // Moving a list updates ONLY its own row. See docs/drag-and-drop.md. + // In the DB the column is TEXT COLLATE "C" (byte order), like + // Card/Checklist/ChecklistItem.position: with the default (glibc) collation 'Zz' would + // sort after 'a0'. Prisma does not model collations; the + // 20260924000000_position_collate_c migration sets it. See ADR 0002. position String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -111,7 +134,7 @@ model Card { list List @relation(fields: [listId], references: [id], onDelete: Cascade) title String description String? - // Clave de ordenación por fractional indexing (ver List.position). + // Fractional indexing sort key (see List.position). position String dueDate DateTime? createdAt DateTime @default(now()) @@ -125,10 +148,9 @@ model Card { @@index([listId, position]) } -// Adjuntos de una tarjeta (Hito 5). El binario se guarda en disco (volumen -// configurable, ver app/lib/storage.ts); aquí solo viven los metadatos. `storedName` -// es el nombre aleatorio del fichero en disco; `filename` es el nombre original que ve -// el usuario. Ver docs/attachments.md. +// Card attachments. The binary is stored on disk (configurable volume, see +// app/lib/storage.ts); only the metadata lives here. `storedName` is the random file +// name on disk; `filename` is the original name the user sees. See docs/attachments.md. model Attachment { id String @id @default(cuid()) cardId String @@ -153,7 +175,7 @@ model Label { @@index([boardId]) } -// Tabla de unión muchos-a-muchos entre Card y Label. +// Many-to-many join table between Card and Label. model CardLabel { cardId String card Card @relation(fields: [cardId], references: [id], onDelete: Cascade) @@ -164,8 +186,8 @@ model CardLabel { @@index([labelId]) } -// Listas de comprobación dentro de una tarjeta (Hito 2). Una tarjeta puede tener -// varias checklists; cada una con varios ítems marcables. +// Checklists inside a card. A card can have several checklists, each with several +// checkable items. model Checklist { id String @id @default(cuid()) cardId String @@ -190,13 +212,10 @@ model ChecklistItem { @@index([checklistId]) } -// Membresía de un usuario en un tablero. Se crea ya en el MVP (single-user) para -// que la autorización de cada Server Action pase por aquí desde el principio y no -// haya que re-migrar al llegar el hito multiusuario. -// Registro de auditoría (Hito 5). Una fila por evento relevante de seguridad u -// operación (login, alta/baja de usuario, cambios de rol, borrado de tablero...). El -// usuario es opcional y con onDelete: SetNull para conservar el historial aunque la -// cuenta se borre. Ver app/lib/audit.ts y docs/operations.md. +// Audit log. One row per relevant security or operations event (login, user created or +// removed, role changes, board deletion...). The user is optional, with +// onDelete: SetNull, so the history is kept even if the account is deleted. See +// app/lib/audit.ts and docs/operations.md. model AuditLog { id String @id @default(cuid()) userId String? @@ -212,6 +231,9 @@ model AuditLog { @@index([userId]) } +// A user's membership in a board. It already existed in the single-user MVP so that +// every Server Action's authorization went through it from the start, with no +// re-migration needed when multi-user support arrived. model BoardMember { boardId String board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) @@ -224,12 +246,13 @@ model BoardMember { @@index([userId]) } -// Notificaciones in-app por usuario (Hito 8). El texto se precalcula en español al -// crearlas (no se guarda un payload estructurado: la app es monolingüe y así el render -// es trivial). `boardId` es una referencia SUAVE (string, sin FK): si el tablero se -// borra, la notificación sobrevive y el enlace simplemente no resuelve. La entrega en -// vivo va por el bus pub/sub (canal por usuario). Ver app/lib/notifications.ts, -// docs/notifications.md y docs/decisions/0012-notifications.md. +// Per-user in-app notifications. The text is rendered when the notification is created, +// in the recipient's language (User.locale), instead of storing a structured payload, +// which keeps rendering trivial. `boardId` is a SOFT reference (string, no FK): if the +// board is deleted, the notification survives and the link simply does not resolve. +// Live delivery goes through the pub/sub bus (per-user channel). See +// app/lib/notifications.ts, docs/notifications.md and +// docs/decisions/0012-notifications.md. model Notification { id String @id @default(cuid()) userId String @@ -238,8 +261,8 @@ model Notification { title String body String? boardId String? - // Referencia suave a la tarjeta (sin FK): permite el deep-link `?card=` y deduplicar - // los recordatorios de vencimiento (Hito 11). + // Soft reference to the card (no FK): enables the `?card=` deep link and deduplicating + // due-date reminders. cardId String? read Boolean @default(false) createdAt DateTime @default(now()) @@ -248,11 +271,11 @@ model Notification { @@index([userId, read]) } -// Token de acceso personal para la API pública (Hito 9). NUNCA se guarda el token en -// claro: solo su hash SHA-256 (`tokenHash`, único) y un `prefix` visible para que el -// usuario lo reconozca en la lista. El token completo se muestra UNA sola vez al -// crearlo. La petición a /api/v1/* lo manda en `Authorization: Bearer <token>` y se -// resuelve por hash. Ver app/lib/api/tokens.ts, docs/api/public-api.md y +// Personal access token for the public API. The token is NEVER stored in plain text: +// only its SHA-256 hash (`tokenHash`, unique) and a visible `prefix` so the user can +// recognise it in the list. The full token is shown ONLY ONCE, when it is created. +// Requests to /api/v1/* send it as `Authorization: Bearer <token>` and it is looked up +// by hash. See app/lib/api/tokens.ts, docs/api/public-api.md and // docs/decisions/0013-api-tokens.md. model ApiToken { id String @id @default(cuid()) @@ -261,9 +284,9 @@ model ApiToken { name String tokenHash String @unique prefix String - // Alcance del token (Hito 13): "full" (lectura+escritura) o "read" (solo lectura). + // Token scope: "full" (read+write) or "read" (read-only). scope String @default("full") - // Caducidad opcional (Hito 13): si está y ya pasó, el token deja de valer. + // Optional expiry: if set and already past, the token stops working. expiresAt DateTime? lastUsedAt DateTime? createdAt DateTime @default(now()) @@ -271,10 +294,9 @@ model ApiToken { @@index([userId]) } -// Asignación de una tarjeta a un miembro del tablero (Hito 10). Tabla de unión: una -// persona puede tener varias tarjetas y una tarjeta varios asignados. La autorización -// (que el asignado sea miembro del tablero) la garantiza la Server Action. Ver -// docs/card-collaboration.md. +// Assignment of a card to a board member. Join table: a person can have several cards +// and a card several assignees. Authorization (the assignee must be a board member) is +// enforced by the Server Action. See docs/card-collaboration.md. model CardAssignee { cardId String card Card @relation(fields: [cardId], references: [id], onDelete: Cascade) @@ -285,8 +307,8 @@ model CardAssignee { @@index([userId]) } -// Comentario en una tarjeta (Hito 10). Hilo simple, sin anidación. Se borra en cascada -// con la tarjeta o con el autor. Ver docs/card-collaboration.md. +// Comment on a card. Flat thread, no nesting. Deleted in cascade with the card or the +// author. See docs/card-collaboration.md. model Comment { id String @id @default(cuid()) cardId String diff --git a/proxy.ts b/proxy.ts index 70694bf..448fca4 100644 --- a/proxy.ts +++ b/proxy.ts @@ -2,17 +2,20 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { buildContentSecurityPolicy, generateNonce } from "@/app/lib/csp"; -// En Next.js 16 el middleware se llama `proxy` (runtime Node.js). Hace dos cosas: +// In Next.js 16 middleware is called `proxy` (Node.js runtime). It does two things: // -// 1. Chequeo de auth OPTIMISTA: solo comprueba si EXISTE la cookie de sesión, sin -// validarla criptográficamente (eso lo hace el DAL en app/lib/dal.ts, en cada -// layout/página protegida). Evita renderizar rutas privadas a quien claramente no ha -// iniciado sesión. Ver docs/authentication.md. -// 2. Content-Security-Policy con un nonce por petición (app/lib/csp.ts). Va en la -// petición (Next extrae el nonce para sus scripts; el layout raíz lo lee de -// `x-nonce` para el script del tema) y en la respuesta (la aplica el navegador). +// 1. OPTIMISTIC auth check: it only checks whether the session cookie EXISTS, without +// validating it cryptographically (the DAL in app/lib/dal.ts does that on every +// protected layout/page). It avoids rendering private routes for someone who is +// clearly not signed in. See docs/authentication.md. +// 2. Content-Security-Policy with a per-request nonce (app/lib/csp.ts). It is set on the +// request (Next extracts the nonce for its scripts; the root layout reads it from +// `x-nonce` for the theme script) and on the response (enforced by the browser). -const PUBLIC_PATHS = ["/login", "/setup"]; +// /register checks by itself whether sign-up is open; /legal is readable without a +// session (the privacy policy must be readable BEFORE creating an account). Search +// engines read /robots.txt without a cookie. +const PUBLIC_PATHS = ["/login", "/setup", "/register", "/legal", "/robots.txt"]; export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; @@ -40,6 +43,6 @@ export function proxy(request: NextRequest) { } export const config = { - // Excluye rutas de API (su CSP va en next.config.ts), assets estáticos e imágenes. + // Excludes API routes (their CSP is in next.config.ts), static assets and images. matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\.svg$).*)"], }; diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg> \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg> \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg> \ No newline at end of file diff --git a/scripts/backup.sh b/scripts/backup.sh index 3173f25..c0a52f2 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -1,17 +1,17 @@ #!/usr/bin/env bash -# Copia de seguridad de orgtable: vuelca PostgreSQL y archiva los adjuntos. +# orgtable backup: dumps PostgreSQL and archives the attachments. # -# Uso: scripts/backup.sh [directorio_destino] (por defecto: <repo>/backups) -# Se puede lanzar desde cualquier directorio (p. ej. desde cron): se sitúa en la raíz del -# repositorio para que `docker compose` encuentre el docker-compose.yml y el .env. -# Requiere que el servicio postgres esté en marcha. Ver docs/operations.md. +# Usage: scripts/backup.sh [destination_dir] (default: <repo>/backups) +# Can be run from any directory (e.g. from cron): it moves to the repository root so +# that `docker compose` finds docker-compose.yml and .env. +# Requires the postgres service to be running. See docs/operations.md. # -# Sale con código distinto de 0 (y sin dejar ficheros a medias) si algo falla: una copia -# vacía o truncada que se anuncia como buena es peor que ninguna. +# Exits non-zero (and leaves no half-written files) if anything fails: an empty or +# truncated backup announced as good is worse than none. set -euo pipefail -# Los volcados contienen datos personales (emails, hashes de contraseñas y de tokens, -# comentarios): solo el propietario puede leerlos. +# Dumps contain personal data (emails, password and token hashes, comments): only the +# owner may read them. umask 077 fail() { @@ -19,9 +19,9 @@ fail() { exit 1 } -# El destino se resuelve a ruta absoluta ANTES de cambiar de directorio, para que una -# ruta relativa se interprete desde donde se lanzó el script. Si hay que crearlo, umask -# ya lo deja en 700; uno ajeno que ya existía (p. ej. /tmp) no se toca. +# The destination is made absolute BEFORE changing directory, so a relative path is +# resolved from where the script was launched. If it has to be created, umask already +# makes it 700; a pre-existing foreign one (e.g. /tmp) is left alone. if [ -n "${1:-}" ]; then mkdir -p "$1" DIR="$(cd "$1" && pwd)" @@ -30,12 +30,12 @@ cd "$(dirname "$0")/.." if [ -z "${DIR:-}" ]; then DIR="$PWD/backups" mkdir -p "$DIR" - # El directorio por defecto es solo de las copias: se cierra aunque ya existiera - # (umask no cambia los permisos de un directorio existente). + # The default directory only holds backups: lock it down even if it already existed + # (umask does not change the permissions of an existing directory). chmod 700 "$DIR" fi -# Carga las variables del .env si existe (para el usuario y la base de datos). +# Load .env if present (for the database user and name). if [ -f .env ]; then set -a # shellcheck disable=SC1091 @@ -48,35 +48,35 @@ PGDB="${POSTGRES_DB:-orgtable}" TS=$(date +%Y%m%d-%H%M%S) DUMP="$DIR/orgtable-db-$TS.sql.gz" UPLOADS="$DIR/orgtable-uploads-$TS.tar.gz" -# Se escribe en .partial y solo se renombra al final si pasa las comprobaciones: así -# nunca queda en el destino un fichero con aspecto de copia buena que no lo es. +# Write to .partial and only rename at the end if the checks pass: the destination never +# holds a file that looks like a good backup but is not. DUMP_PART="$DUMP.partial" UPLOADS_PART="$UPLOADS.partial" trap 'rm -f "$DUMP_PART" "$UPLOADS_PART"' EXIT -echo "→ Volcando base de datos a $DUMP" -# --clean --if-exists: el volcado borra los objetos antes de crearlos, así que también -# sirve para restaurar a mano sobre una base de datos ya migrada. +echo "→ Dumping database to $DUMP" +# --clean --if-exists: the dump drops objects before creating them, so it can also be +# restored by hand over an already migrated database. docker compose exec -T postgres pg_dump --clean --if-exists -U "$PGUSER" -d "$PGDB" \ | gzip > "$DUMP_PART" \ - || fail "pg_dump ha fallado: no se ha generado la copia de la base de datos." + || fail "pg_dump failed: no database backup was created." -[ -s "$DUMP_PART" ] || fail "El volcado de la base de datos está vacío." -gzip -t "$DUMP_PART" || fail "El volcado de la base de datos no es un gzip válido." -# pg_dump escribe esta marca al terminar; si falta, el volcado está truncado. Se leen las -# últimas líneas a una variable (y no con `| grep -q`) para no romper la tubería con -# SIGPIPE bajo pipefail. +[ -s "$DUMP_PART" ] || fail "The database dump is empty." +gzip -t "$DUMP_PART" || fail "The database dump is not a valid gzip file." +# pg_dump writes this marker when it finishes; if it is missing, the dump is truncated. +# The last lines are read into a variable (not `| grep -q`) to avoid breaking the pipe +# with SIGPIPE under pipefail. TAIL="$(gunzip -c "$DUMP_PART" | tail -n 10)" case "$TAIL" in *"PostgreSQL database dump complete"*) ;; - *) fail "El volcado de la base de datos está incompleto (falta la marca final de pg_dump)." ;; + *) fail "The database dump is incomplete (pg_dump's end marker is missing)." ;; esac mv "$DUMP_PART" "$DUMP" -# Adjuntos: viven en el volumen montado en /data/uploads del contenedor app. Si la app -# está en marcha se lee desde ella; si no, se arranca un contenedor efímero con el mismo -# volumen. El código 3 significa «no hay directorio de adjuntos», lo único que se omite. -echo "→ Archivando adjuntos a $UPLOADS" +# Attachments live in the volume mounted at /data/uploads in the app container. If the +# app is running they are read from it; otherwise a throwaway container is started with +# the same volume. Exit code 3 means "no attachments directory", the only case skipped. +echo "→ Archiving attachments to $UPLOADS" TAR_CMD='if [ -d /data/uploads ]; then tar czf - -C /data uploads; else exit 3; fi' if [ -n "$(docker compose ps --status running -q app)" ]; then APP_RUN=(docker compose exec -T app sh -c "$TAR_CMD") @@ -89,13 +89,13 @@ TAR_STATUS=$? set -e if [ "$TAR_STATUS" -eq 3 ]; then rm -f "$UPLOADS_PART" - echo " (no existe /data/uploads en el contenedor: no hay adjuntos que archivar)" + echo " (/data/uploads does not exist in the container: no attachments to archive)" elif [ "$TAR_STATUS" -ne 0 ]; then - fail "No se pudieron archivar los adjuntos (código $TAR_STATUS). La base de datos sí se copió en $DUMP." + fail "Could not archive the attachments (exit code $TAR_STATUS). The database was backed up to $DUMP." else - gzip -t "$UPLOADS_PART" || fail "El archivo de adjuntos no es un gzip válido. La base de datos sí se copió en $DUMP." + gzip -t "$UPLOADS_PART" || fail "The attachments archive is not a valid gzip file. The database was backed up to $DUMP." mv "$UPLOADS_PART" "$UPLOADS" - echo " adjuntos archivados" + echo " attachments archived" fi -echo "✓ Backup completado en $DIR/" +echo "✓ Backup completed in $DIR/" diff --git a/scripts/readme-media/README.md b/scripts/readme-media/README.md new file mode 100644 index 0000000..d984377 --- /dev/null +++ b/scripts/readme-media/README.md @@ -0,0 +1,35 @@ +# README screenshots and GIFs + +Scripts to regenerate `docs/screenshots/` from the real app with sample data. They are not +part of the app or the CI. The reasoning is in +[`docs/DECISIONES.md`](../../docs/DECISIONES.md) (the entry about the README screenshots +and GIFs). + +They need Docker with the development Postgres, Node 22, Python 3 with Pillow, and +`playwright-core`, which is installed separately so it is not added to the project's +dependencies. + +```bash +# 1. Separate demo database (never the development one: seed-demo.mjs empties it) +docker exec orgtable-dev-pg psql -U orgtable -d postgres -c "CREATE DATABASE orgtable_demo" +DEMO_URL="postgresql://orgtable:<password>@localhost:5433/orgtable_demo" +DATABASE_URL="$DEMO_URL" pnpm exec prisma migrate deploy +export DEMO_PASSWORD="$(openssl rand -hex 12)" +DATABASE_URL="$DEMO_URL" node scripts/readme-media/seed-demo.mjs + +# 2. Production build served with the demo database (any free port, e.g. 3051) +pnpm build +DATABASE_URL="$DEMO_URL" pnpm start -p 3051 & + +# 3. Screenshots and frames. playwright-core goes in a temporary folder and the script is +# copied there: ESM imports are resolved from the script's folder. +mkdir -p /tmp/orgtable-media && npm i --prefix /tmp/orgtable-media playwright-core +cp scripts/readme-media/capture.mjs /tmp/orgtable-media/ +OUT=/tmp/orgtable-media node /tmp/orgtable-media/capture.mjs +OUT=/tmp/orgtable-media python3 scripts/readme-media/make_gifs.py +``` + +`capture.mjs` uses the Chromium installed by Playwright, or the one set in `CHROME`. +`make_gifs.py` writes the two GIFs and the WebP screenshots to `$OUT/gifs/`: copy them to +`docs/screenshots/` with `cp /tmp/orgtable-media/gifs/*.{gif,webp} docs/screenshots/`. +Recording changes the demo (cards are moved): seed it again before repeating. diff --git a/scripts/readme-media/capture.mjs b/scripts/readme-media/capture.mjs new file mode 100644 index 0000000..84bbafb --- /dev/null +++ b/scripts/readme-media/capture.mjs @@ -0,0 +1,192 @@ +// Screenshots and frames for the README. Saves PNGs to $OUT/readme/ and GIF frames to +// $OUT/frames/<name>/NNNN.png with a frames.json of durations. See README.md in this +// folder. +// +// BASE=http://localhost:3051 DEMO_PASSWORD=… OUT=/tmp/orgtable-media \ +// CHROME=/path/to/browser node capture.mjs +import { chromium } from "playwright-core"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; + +const BASE = process.env.BASE ?? "http://localhost:3051"; +const pw = process.env.DEMO_PASSWORD; +if (!pw) throw new Error("DEMO_PASSWORD is missing (the same one used for seed-demo.mjs)"); +const HERE = (process.env.OUT ?? "/tmp/orgtable-media").replace(/\/?$/, "/"); +mkdirSync(HERE + "readme", { recursive: true }); + +// Any Chromium (Chrome, Brave, Edge…). Without CHROME, the one Playwright installs. +const browser = await chromium.launch( + process.env.CHROME ? { executablePath: process.env.CHROME } : {}, +); + +async function open({ width = 1280, height = 800, dark = false, user = "lucia@example.com" } = {}) { + const ctx = await browser.newContext({ + viewport: { width, height }, + deviceScaleFactor: 2, + colorScheme: dark ? "dark" : "light", + // English UI: the users have no saved language, so the browser's locale decides. + locale: "en-US", + timezoneId: "Atlantic/Canary", + }); + const page = await ctx.newPage(); + await page.goto(`${BASE}/login`); + await page.fill("#email", user); + await page.fill("#password", pw); + await Promise.all([page.waitForURL("**/boards"), page.click("button[type=submit]")]); + return { ctx, page }; +} + +async function gotoBoard(page, title) { + await page.goto(`${BASE}/boards`); + await Promise.all([page.waitForURL("**/board/**"), page.getByRole("link", { name: new RegExp(`^${title}`) }).click()]); + await page.waitForTimeout(800); +} + +const shot = (page, name) => page.screenshot({ path: `${HERE}readme/${name}.png` }); + +/** Records frames while `act` runs. */ +async function record(page, name, act, { every = 70 } = {}) { + const dir = `${HERE}frames/${name}/`; + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + const frames = []; + let n = 0; + const grab = async (hold = every) => { + const file = `${dir}${String(n++).padStart(4, "0")}.png`; + await page.screenshot({ path: file }); + frames.push({ file, ms: hold }); + }; + await act(grab); + writeFileSync(`${dir}frames.json`, JSON.stringify(frames)); +} + +async function smoothMove(page, grab, from, to, steps) { + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const e = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2; // ease-in-out + await page.mouse.move(from.x + (to.x - from.x) * e, from.y + (to.y - from.y) * e); + await grab(60); + } +} + +const center = async (loc) => { + const b = await loc.boundingBox(); + return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; +}; + +// --- 1. Light board and main GIF (drag + open a card) ----------------------------------- +{ + const { ctx, page } = await open(); + await gotoBoard(page, "Website launch"); + await shot(page, "board-light"); + + await record(page, "hero", async (grab) => { + await grab(1200); + const card = page.getByText("Team photos", { exact: true }); + const target = page.getByText("Sitemap and robots.txt", { exact: true }); + const from = await center(card); + const tgt = await center(target); + await page.mouse.move(from.x, from.y); + await grab(250); + await page.mouse.down(); + await page.mouse.move(from.x + 6, from.y + 6); + await grab(80); + await smoothMove(page, grab, { x: from.x + 6, y: from.y + 6 }, { x: tgt.x, y: tgt.y + 34 }, 16); + await grab(300); + await page.mouse.up(); + await page.waitForTimeout(350); + await grab(1100); + + // Open a card's detail. + const open = await center(page.getByText("Build the home page", { exact: true })); + await smoothMove(page, grab, { x: tgt.x, y: tgt.y + 34 }, open, 8); + await page.mouse.click(open.x, open.y); + for (let i = 0; i < 4; i++) { await page.waitForTimeout(70); await grab(70); } + await grab(2600); + await page.keyboard.press("Escape"); + await page.waitForTimeout(300); + await grab(900); + }); + await ctx.close(); +} + +// --- 2. Card detail, calendar and filters in light mode ---------------------------------- +{ + const { ctx, page } = await open(); + await gotoBoard(page, "Website launch"); + await page.getByText("Build the home page", { exact: true }).click(); + await page.waitForTimeout(600); + await shot(page, "card-light"); + await page.keyboard.press("Escape"); + await page.waitForTimeout(300); + const cal = page.getByRole("button", { name: /Calendar/ }).or(page.getByRole("tab", { name: /Calendar/ })); + await cal.first().click(); + await page.waitForTimeout(600); + await shot(page, "calendar-light"); + await page.goto(`${BASE}/boards`); + await page.waitForTimeout(400); + await shot(page, "boards-light"); + await page.goto(`${BASE}/settings`); + await page.waitForTimeout(400); + await shot(page, "settings-light"); + await ctx.close(); +} + +// --- 3. Board in dark mode and on mobile --------------------------------------------------- +{ + const { ctx, page } = await open({ dark: true }); + await gotoBoard(page, "Website launch"); + await shot(page, "board-dark"); + await page.getByText("Build the home page", { exact: true }).click(); + await page.waitForTimeout(600); + await shot(page, "card-dark"); + await ctx.close(); +} +{ + const { ctx, page } = await open({ width: 390, height: 844 }); + await gotoBoard(page, "Website launch"); + await shot(page, "board-mobile"); + await ctx.close(); +} + +// --- 4. Real time: two people on the same board ------------------------------------------- +{ + const a = await open({ width: 900, height: 640 }); + const b = await open({ width: 900, height: 640, user: "marcos@example.com" }); + await gotoBoard(a.page, "Website launch"); + await gotoBoard(b.page, "Website launch"); + for (const p of [a.page, b.page]) await p.mouse.wheel(0, 0); + const dirA = `${HERE}frames/realtime-a/`, dirB = `${HERE}frames/realtime-b/`; + for (const d of [dirA, dirB]) { rmSync(d, { recursive: true, force: true }); mkdirSync(d, { recursive: true }); } + const frames = []; + let n = 0; + const grab = async (ms) => { + const id = String(n++).padStart(4, "0"); + await Promise.all([ + a.page.screenshot({ path: `${dirA}${id}.png` }), + b.page.screenshot({ path: `${dirB}${id}.png` }), + ]); + frames.push({ id, ms }); + }; + await grab(1200); + const card = a.page.getByText("Monthly newsletter", { exact: true }); + const target = a.page.getByText("Pricing page copy", { exact: true }); + const from = await center(card); + const tgt = await center(target); + await a.page.mouse.move(from.x, from.y); + await a.page.mouse.down(); + await a.page.mouse.move(from.x + 6, from.y + 6); + for (let i = 1; i <= 10; i++) { + const t = i / 10; + await a.page.mouse.move(from.x + (tgt.x - from.x) * t, from.y + (tgt.y - 20 - from.y) * t); + await grab(70); + } + await a.page.mouse.up(); + for (let i = 0; i < 14; i++) { await a.page.waitForTimeout(120); await grab(120); } + await grab(1800); + writeFileSync(`${HERE}frames/realtime.json`, JSON.stringify(frames)); + await a.ctx.close(); + await b.ctx.close(); +} + +await browser.close(); +console.log("screenshots ready"); diff --git a/scripts/readme-media/make_gifs.py b/scripts/readme-media/make_gifs.py new file mode 100644 index 0000000..cd06346 --- /dev/null +++ b/scripts/readme-media/make_gifs.py @@ -0,0 +1,87 @@ +"""Builds the README GIFs (and converts the screenshots to WebP) from capture.mjs output. + +A single palette shared by every frame (taken from a mosaic of several) avoids colour +flicker between frames; no dithering, so the flat UI blocks do not fill up with noise +and the file stays smaller. +""" +import json +import os +import sys +from pathlib import Path +from PIL import Image + +# Same OUT folder as capture.mjs. GIFs are written to OUT/gifs. +HERE = Path(os.environ.get("OUT", "/tmp/orgtable-media")) +OUT = HERE / "gifs" +OUT.mkdir(exist_ok=True) + + +def palette_from(frames, colors=128): + sample = frames[:: max(1, len(frames) // 6)] + w, h = sample[0].size + mosaic = Image.new("RGB", (w, h * len(sample))) + for i, f in enumerate(sample): + mosaic.paste(f, (0, h * i)) + return mosaic.quantize(colors=colors, method=Image.Quantize.MEDIANCUT) + + +def save_gif(frames, durations, path, colors=128): + pal = palette_from(frames, colors) + quant = [f.quantize(palette=pal, dither=Image.Dither.NONE) for f in frames] + quant[0].save( + path, + save_all=True, + append_images=quant[1:], + duration=durations, + loop=0, + optimize=True, + disposal=1, + ) + print(f"{path.name}: {len(frames)} frames, {path.stat().st_size / 1e6:.2f} MB") + + +def hero(width=960): + meta = json.loads((HERE / "frames/hero/frames.json").read_text()) + frames, durations = [], [] + for m in meta: + im = Image.open(m["file"]).convert("RGB") + im = im.resize((width, round(im.height * width / im.width)), Image.Resampling.LANCZOS) + frames.append(im) + durations.append(m["ms"]) + save_gif(frames, durations, OUT / "hero.gif") + + +def realtime(width=960): + meta = json.loads((HERE / "frames/realtime.json").read_text()) + frames, durations = [], [] + gap = 16 + for m in meta: + a = Image.open(HERE / f"frames/realtime-a/{m['id']}.png").convert("RGB") + b = Image.open(HERE / f"frames/realtime-b/{m['id']}.png").convert("RGB") + half = (width - gap) // 2 + a = a.resize((half, round(a.height * half / a.width)), Image.Resampling.LANCZOS) + b = b.resize((half, round(b.height * half / b.width)), Image.Resampling.LANCZOS) + canvas = Image.new("RGB", (width, a.height), (240, 232, 222)) + canvas.paste(a, (0, 0)) + canvas.paste(b, (half + gap, 0)) + frames.append(canvas) + durations.append(m["ms"]) + save_gif(frames, durations, OUT / "realtime.gif") + + +def screens(): + """capture.mjs PNGs to WebP: 1600 px wide (780 for the mobile one).""" + for png in sorted((HERE / "readme").glob("*.png")): + im = Image.open(png).convert("RGB") + width = 780 if png.stem == "board-mobile" else 1600 + if im.width > width: + im = im.resize((width, round(im.height * width / im.width)), Image.Resampling.LANCZOS) + out = OUT / f"{png.stem}.webp" + im.save(out, "WEBP", quality=86, method=6) + print(f"{out.name}: {out.stat().st_size // 1024} KB") + + +if __name__ == "__main__": + which = sys.argv[1:] or ["hero", "realtime", "screens"] + for name in which: + globals()[name]() diff --git a/scripts/readme-media/seed-demo.mjs b/scripts/readme-media/seed-demo.mjs new file mode 100644 index 0000000..4a8ac89 --- /dev/null +++ b/scripts/readme-media/seed-demo.mjs @@ -0,0 +1,167 @@ +// Seeds the demo DB (orgtable_demo) with sample data for the README screenshots. +// Fictional people with @example.com emails (reserved domain). See README.md in this +// folder. Usage: DATABASE_URL=…/orgtable_demo DEMO_PASSWORD=… node seed-demo.mjs +import { createRequire } from "node:module"; +import { randomUUID } from "node:crypto"; + +// pg, bcryptjs and fractional-indexing come from the project's own dependencies. +const require = createRequire(new URL("../../package.json", import.meta.url)); +const pg = require("pg"); +const bcrypt = require("bcryptjs"); +const { generateNKeysBetween } = require("fractional-indexing"); + +const url = process.env.DATABASE_URL; +// TRUNCATEs every table: only allowed against the demo DB. +if (!url?.endsWith("/orgtable_demo")) throw new Error("Only against orgtable_demo"); +const password = process.env.DEMO_PASSWORD; +if (!password) throw new Error("DEMO_PASSWORD is missing"); + +const db = new pg.Client({ connectionString: url }); +await db.connect(); +const id = () => randomUUID().replace(/-/g, "").slice(0, 25); +const q = (sql, params) => db.query(sql, params); + +const tables = ["Comment", "CardAssignee", "ChecklistItem", "Checklist", "CardLabel", "Attachment", + "Card", "Label", "List", "BoardMember", "Board", "Notification", "ApiToken", "Session", + "AuditLog", "User", "InstanceConfig"]; +await q(`TRUNCATE ${tables.map((t) => `"${t}"`).join(", ")} CASCADE`); + +await q(`INSERT INTO "InstanceConfig" (id, "instanceName", "setupCompletedAt", "registrationOpen", + "operatorName", "operatorEmail", "hostingDescription", "backupRetentionDays", "legalUpdatedAt") + VALUES ('singleton', 'orgtable', now(), true, 'Example operator', 'privacy@example.com', + 'a server in the European Union', 14, now())`); + +const hash = await bcrypt.hash(password, 12); +const people = { + lucia: { name: "Lucía Martín", email: "lucia@example.com", role: "ADMIN" }, + marcos: { name: "Marcos Ruiz", email: "marcos@example.com", role: "MEMBER" }, + ana: { name: "Ana Torres", email: "ana@example.com", role: "MEMBER" }, + diego: { name: "Diego Paz", email: "diego@example.com", role: "MEMBER" }, +}; +for (const p of Object.values(people)) { + p.id = id(); + await q(`INSERT INTO "User" (id, email, "passwordHash", name, role, "updatedAt") VALUES ($1,$2,$3,$4,$5,now())`, + [p.id, p.email, hash, p.name, p.role]); +} + +const day = 86_400_000; +const inDays = (n) => new Date(Date.UTC(2026, 9, 14) + n * day); + +async function board({ title, background, owner, members, labels, lists }) { + const boardId = id(); + await q(`INSERT INTO "Board" (id, "ownerId", title, background, "updatedAt") VALUES ($1,$2,$3,$4,now())`, + [boardId, owner.id, title, background]); + await q(`INSERT INTO "BoardMember" ("boardId","userId",role) VALUES ($1,$2,'OWNER')`, [boardId, owner.id]); + for (const [m, role] of members) { + await q(`INSERT INTO "BoardMember" ("boardId","userId",role) VALUES ($1,$2,$3)`, [boardId, m.id, role]); + } + const labelIds = {}; + for (const [name, color] of labels) { + labelIds[name] = id(); + await q(`INSERT INTO "Label" (id,"boardId",name,color) VALUES ($1,$2,$3,$4)`, [labelIds[name], boardId, name, color]); + } + const listKeys = generateNKeysBetween(null, null, lists.length); + for (const [li, list] of lists.entries()) { + const listId = id(); + await q(`INSERT INTO "List" (id,"boardId",title,position,"updatedAt") VALUES ($1,$2,$3,$4,now())`, + [listId, boardId, list.title, listKeys[li]]); + const cardKeys = generateNKeysBetween(null, null, list.cards.length); + for (const [ci, c] of list.cards.entries()) { + const cardId = id(); + await q(`INSERT INTO "Card" (id,"listId",title,description,position,"dueDate","updatedAt") VALUES ($1,$2,$3,$4,$5,$6,now())`, + [cardId, listId, c.title, c.description ?? null, cardKeys[ci], c.due == null ? null : inDays(c.due)]); + for (const l of c.labels ?? []) { + await q(`INSERT INTO "CardLabel" ("cardId","labelId") VALUES ($1,$2)`, [cardId, labelIds[l]]); + } + for (const a of c.assignees ?? []) { + await q(`INSERT INTO "CardAssignee" ("cardId","userId") VALUES ($1,$2)`, [cardId, a.id]); + } + if (c.checklist) { + const chId = id(); + await q(`INSERT INTO "Checklist" (id,"cardId",title,position) VALUES ($1,$2,$3,'a0')`, [chId, cardId, c.checklist.title]); + const itemKeys = generateNKeysBetween(null, null, c.checklist.items.length); + for (const [ii, [text, done]] of c.checklist.items.entries()) { + await q(`INSERT INTO "ChecklistItem" (id,"checklistId",text,done,position) VALUES ($1,$2,$3,$4,$5)`, + [id(), chId, text, done, itemKeys[ii]]); + } + } + for (const [author, text, ago] of c.comments ?? []) { + await q(`INSERT INTO "Comment" (id,"cardId","authorId",text,"createdAt") VALUES ($1,$2,$3,$4,$5)`, + [id(), cardId, author.id, text, new Date(Date.now() - ago * 3_600_000)]); + } + } + } + return boardId; +} + +const { lucia, marcos, ana, diego } = people; + +await board({ + title: "Website launch", + background: "rose", + owner: lucia, + members: [[marcos, "EDITOR"], [ana, "EDITOR"], [diego, "VIEWER"]], + labels: [["Design", "purple"], ["Development", "blue"], ["Content", "amber"], ["Urgent", "red"], ["SEO", "teal"]], + lists: [ + { title: "Ideas", cards: [ + { title: "Blog with customer stories", labels: ["Content"] }, + { title: "Dark mode on the landing page", labels: ["Design"] }, + { title: "Monthly newsletter", labels: ["Content"], assignees: [ana] }, + ] }, + { title: "To do", cards: [ + { title: "Pricing page copy", labels: ["Content"], due: 3, assignees: [ana], + description: "Three plans, friendly tone. Review with Lucía before Friday." }, + { title: "Meta descriptions for every page", labels: ["SEO"], due: 6, assignees: [marcos] }, + { title: "Contact form with validation", labels: ["Development"], assignees: [marcos], + checklist: { title: "Steps", items: [["Validate on the server", false], ["Accessible error messages", false], ["Test on mobile", false]] } }, + ] }, + { title: "In progress", cards: [ + { title: "Build the home page", labels: ["Development", "Design"], due: 1, assignees: [marcos, lucia], + description: "Hero, three benefit blocks and testimonials. Mobile-first.", + checklist: { title: "Sections", items: [["Hero", true], ["Benefits", true], ["Testimonials", false], ["Footer", false]] }, + comments: [[lucia, "The hero is on staging now, can you take a look?", 5], [marcos, "Seen it, tweaking the spacing on mobile.", 2]] }, + { title: "Team photos", labels: ["Design"], due: 2, assignees: [ana] }, + ] }, + { title: "Review", cards: [ + { title: "Palette and typography", labels: ["Design"], assignees: [lucia], + checklist: { title: "Checklist", items: [["AA contrast", true], ["Dark theme", true]] } }, + { title: "Sitemap and robots.txt", labels: ["SEO", "Development"], due: -1, assignees: [marcos] }, + ] }, + { title: "Done", cards: [ + { title: "Buy the domain", labels: ["Urgent"] }, + { title: "Wireframes", labels: ["Design"], assignees: [lucia, ana] }, + ] }, + ], +}); + +await board({ + title: "Office move", background: "teal", owner: lucia, members: [[diego, "EDITOR"]], + labels: [["Suppliers", "amber"], ["IT", "blue"]], + lists: [ + { title: "To do", cards: [{ title: "Get a quote for the move", labels: ["Suppliers"], due: 9 }] }, + { title: "In progress", cards: [{ title: "Set up fibre at the new office", labels: ["IT"], assignees: [diego] }] }, + { title: "Done", cards: [{ title: "Sign the lease" }] }, + ], +}); + +await board({ + title: "Q4 content", background: "amber", owner: ana, members: [[lucia, "EDITOR"]], + labels: [["Video", "pink"], ["Article", "green"]], + lists: [ + { title: "Drafts", cards: [{ title: "Getting started guide", labels: ["Article"], assignees: [ana] }] }, + { title: "Published", cards: [{ title: "Product intro video", labels: ["Video"] }] }, + ], +}); + +await board({ + title: "Personal", background: "green", owner: lucia, members: [], labels: [], + lists: [{ title: "This week", cards: [{ title: "Renew passport", due: 4 }, { title: "Book the dentist" }] }], +}); + +await q(`INSERT INTO "Notification" (id,"userId",type,title,body,read,"createdAt") VALUES + ($1,$2,'card.assigned','You were assigned “Build the home page”','On the board “Website launch”.',false, now() - interval '2 hours'), + ($3,$2,'comment.added','New comment on “Build the home page”','Seen it, tweaking the spacing on mobile.',false, now() - interval '1 hour')`, + [id(), lucia.id, id()]); + +console.log("Demo seeded:", Object.values(people).map((p) => p.email).join(", ")); +await db.end(); diff --git a/scripts/restore.sh b/scripts/restore.sh index 87ed02f..7f62d6b 100755 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# Restaura un volcado de PostgreSQL de orgtable. +# Restores an orgtable PostgreSQL dump. # -# Uso: scripts/restore.sh <archivo.sql.gz> -# ¡ATENCIÓN! Sustituye TODOS los datos actuales de la base de datos por los del volcado. -# Antes de tocar nada hace una copia de seguridad automática en backups/pre-restore-<fecha> -# y para la app mientras restaura. Los adjuntos no se restauran aquí (ver +# Usage: scripts/restore.sh <file.sql.gz> +# WARNING: replaces ALL current data in the database with the dump's contents. +# Before touching anything it takes an automatic backup in backups/pre-restore-<date> +# and stops the app while restoring. Attachments are not restored here (see # docs/operations.md). set -euo pipefail umask 077 @@ -14,20 +14,20 @@ fail() { exit 1 } -ARG="${1:?Uso: scripts/restore.sh <archivo.sql.gz>}" -[ -f "$ARG" ] || fail "No existe el fichero: $ARG" -# Ruta absoluta antes de cambiar a la raíz del repositorio. +ARG="${1:?Usage: scripts/restore.sh <file.sql.gz>}" +[ -f "$ARG" ] || fail "File not found: $ARG" +# Absolute path before moving to the repository root. FILE="$(cd "$(dirname "$ARG")" && pwd)/$(basename "$ARG")" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$SCRIPT_DIR/.." -# Se valida el fichero ANTES de pedir confirmación: un gzip corrupto o un volcado -# truncado no debe llegar a borrar nada. -gzip -t "$FILE" || fail "El fichero no es un gzip válido: $FILE" +# The file is validated BEFORE asking for confirmation: a corrupt gzip or a truncated +# dump must never get as far as deleting anything. +gzip -t "$FILE" || fail "The file is not a valid gzip file: $FILE" TAIL="$(gunzip -c "$FILE" | tail -n 10)" case "$TAIL" in *"PostgreSQL database dump complete"*) ;; - *) fail "El volcado está incompleto (falta la marca final de pg_dump): $FILE" ;; + *) fail "The dump is incomplete (pg_dump's end marker is missing): $FILE" ;; esac if [ -f .env ]; then @@ -39,41 +39,41 @@ fi PGUSER="${POSTGRES_USER:-orgtable}" PGDB="${POSTGRES_DB:-orgtable}" -printf 'Esto SOBRESCRIBIRÁ la base de datos "%s". ¿Continuar? [y/N] ' "$PGDB" +printf 'This will OVERWRITE the database "%s". Continue? [y/N] ' "$PGDB" read -r ans || ans="" case "$ans" in y | Y) ;; - *) echo "Cancelado."; exit 1 ;; + *) echo "Cancelled."; exit 1 ;; esac PRE="backups/pre-restore-$(date +%Y%m%d-%H%M%S)" -echo "→ Copia previa de seguridad en $PRE" -"$SCRIPT_DIR/backup.sh" "$PRE" || fail "No se pudo hacer la copia previa: no se ha tocado la base de datos." +echo "→ Safety backup in $PRE" +"$SCRIPT_DIR/backup.sh" "$PRE" || fail "Could not take the safety backup: the database was not touched." -# La app se para para que nadie escriba durante la restauración. Solo se vuelve a -# arrancar si estaba en marcha; al arrancar, el entrypoint aplica las migraciones que -# falten si el volcado es de una versión anterior. +# The app is stopped so nothing writes during the restore. It is only restarted if it +# was running; on start, the entrypoint applies any missing migrations if the dump comes +# from an older version. APP_WAS_RUNNING=0 if [ -n "$(docker compose ps --status running -q app)" ]; then APP_WAS_RUNNING=1 - echo "→ Parando la app" + echo "→ Stopping the app" docker compose stop app - trap 'echo "→ Arrancando la app"; docker compose start app' EXIT + trap 'echo "→ Starting the app"; docker compose start app' EXIT fi -echo "→ Restaurando $FILE" -# Se vacía el esquema public en la MISMA transacción que la carga: así sirven también -# los volcados antiguos, hechos sin --clean, y si algo falla (ON_ERROR_STOP) no se -# confirma nada y la base de datos queda como estaba. client_min_messages calla los -# NOTICE del borrado en cascada (uno por tabla); los errores se siguen mostrando. +echo "→ Restoring $FILE" +# The public schema is emptied in the SAME transaction as the load: this also handles +# old dumps made without --clean, and if anything fails (ON_ERROR_STOP) nothing is +# committed and the database stays as it was. client_min_messages silences the NOTICEs +# from the cascading drop (one per table); errors are still shown. { echo 'SET client_min_messages = warning; DROP SCHEMA public CASCADE; CREATE SCHEMA public;' gunzip -c "$FILE" } | docker compose exec -T postgres \ psql -v ON_ERROR_STOP=1 --single-transaction -q -U "$PGUSER" -d "$PGDB" > /dev/null \ - || fail "La restauración ha fallado y se ha deshecho. La copia previa está en $PRE." + || fail "The restore failed and was rolled back. The safety backup is in $PRE." -echo "✓ Restauración completada (copia previa en $PRE)" +echo "✓ Restore completed (safety backup in $PRE)" if [ "$APP_WAS_RUNNING" = 0 ]; then - echo " La app no estaba en marcha: arráncala con «docker compose up -d»." + echo " The app was not running: start it with \"docker compose up -d\"." fi diff --git a/scripts/update.sh b/scripts/update.sh index 98e82dd..c0345cb 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -1,27 +1,27 @@ #!/usr/bin/env bash -# Actualiza orgtable a la última versión y la relanza. +# Updates orgtable to the latest version and restarts it. # -# La imagen se construye localmente (no se publica en un registro), así que el flujo es -# "copia de seguridad + traer cambios + reconstruir". Las migraciones de base de datos se -# aplican SOLAS al arrancar (docker-entrypoint.sh ejecuta `prisma migrate deploy`), por -# eso la copia se hace antes: si una migración sale mal, hay desde dónde volver. Un -# `docker compose up -d --build` lanzado a mano NO pasa por aquí ni hace esa copia. -# Ver docs/operations.md. +# The image is built locally (it is not published to a registry), so the flow is +# "backup + pull changes + rebuild". Database migrations are applied AUTOMATICALLY on +# start (docker-entrypoint.sh runs `prisma migrate deploy`), which is why the backup +# comes first: if a migration goes wrong, there is something to roll back to. A manual +# `docker compose up -d --build` does NOT go through here and takes no backup. +# See docs/operations.md. set -euo pipefail cd "$(dirname "$0")/.." -echo "→ Asegurando que PostgreSQL está en marcha para la copia previa" +echo "→ Making sure PostgreSQL is running for the pre-update backup" docker compose up -d --wait postgres -echo "→ Copia de seguridad previa a la actualización" -# Con set -e, si la copia falla la actualización se aborta sin tocar nada. +echo "→ Pre-update backup" +# With set -e, if the backup fails the update aborts without touching anything. scripts/backup.sh -echo "→ Trayendo cambios (git pull)" +echo "→ Pulling changes (git pull)" git pull --ff-only -echo "→ Reconstruyendo e iniciando los contenedores" +echo "→ Rebuilding and starting the containers" docker compose up -d --build -echo "✓ orgtable actualizado. Las migraciones pendientes se aplican en el arranque." +echo "✓ orgtable updated. Pending migrations are applied on start." diff --git a/test/admin-forms-a11y.test.ts b/test/admin-forms-a11y.test.ts index c5aac11..a374460 100644 --- a/test/admin-forms-a11y.test.ts +++ b/test/admin-forms-a11y.test.ts @@ -1,14 +1,15 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { withI18n } from "./helpers/i18n"; -// Las acciones de servidor importan Prisma; para renderizar los formularios basta con -// sustituirlas (este test solo mira el HTML inicial, no ejecuta ninguna acción). +// The server actions import Prisma; replacing them is enough to render the forms (this +// test only looks at the initial HTML, it does not run any action). vi.mock("@/app/lib/actions/admin", () => ({ createUser: vi.fn() })); vi.mock("@/app/lib/actions/tokens", () => ({ createApiToken: vi.fn() })); -// En el render de servidor useActionState siempre devuelve el estado inicial. Para -// mirar el HTML con un token recién creado se sustituye solo cuando el test lo pide. +// In a server render useActionState always returns the initial state. To look at the +// HTML with a freshly created token it is replaced, only when the test asks for it. const actionState = vi.hoisted(() => ({ override: null as unknown })); vi.mock("react", async (importOriginal) => { const react = await importOriginal<typeof import("react")>(); @@ -31,28 +32,28 @@ const { CreateTokenForm } = await import( "@/app/(app)/settings/create-token-form" ); -describe("formularios de administración y ajustes — accesibilidad", () => { - it("crear usuario monta la región role=status antes de que llegue el aviso", () => { - const html = renderToStaticMarkup(createElement(CreateUserForm)); +describe("admin and settings forms — accessibility", () => { + it("create user mounts the role=status region before the message arrives", () => { + const html = renderToStaticMarkup(withI18n(createElement(CreateUserForm))); expect(html).toContain('role="status"'); }); - it("crear token monta la región role=status y el select conserva su valor inicial", () => { - const html = renderToStaticMarkup(createElement(CreateTokenForm)); + it("create token mounts the role=status region and the select keeps its initial value", () => { + const html = renderToStaticMarkup(withI18n(createElement(CreateTokenForm))); expect(html).toContain('role="status"'); expect(html).toMatch(/<option value="full" selected="">/); }); - it("con un token creado, la región role=status no contiene el token ni el botón", () => { - const token = "ot_token-secreto-de-prueba"; + it("with a created token, the role=status region contains neither the token nor the button", () => { + const token = "ot_secret-test-token"; actionState.override = { token, name: "CI" }; - const html = renderToStaticMarkup(createElement(CreateTokenForm)); + const html = renderToStaticMarkup(withI18n(createElement(CreateTokenForm))); const status = html.match(/<div role="status">(.*?)<\/div>/); expect(status).not.toBeNull(); - expect(status![1]).toContain("Token «CI» creado"); + expect(status![1]).toContain("Token “CI” created"); expect(status![1]).not.toContain(token); expect(status![1]).not.toContain("<button"); - // El token sigue visible fuera de la región viva. + // The token is still visible outside the live region. expect(html).toContain(`>${token}</code>`); }); }); diff --git a/test/auth.test.ts b/test/auth.test.ts index 36492b2..95c53ef 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -8,36 +8,36 @@ import { verifyPasswordOrDummy, } from "@/app/lib/auth"; -// Hash ficticio del login: tiene que ser un hash bcrypt válido y del mismo coste que los -// reales. El literal anterior medía 59 caracteres, bcrypt.compare devolvía false al -// instante y un email inexistente respondía ~200 veces más rápido que uno existente. -describe("hash ficticio del login", () => { - it("mide 60 caracteres y usa el mismo coste que hashPassword", () => { +// Dummy sign-in hash: it has to be a valid bcrypt hash with the same cost as the real +// ones. The previous literal was 59 characters long, bcrypt.compare returned false +// instantly and a non-existent email answered ~200 times faster than an existing one. +describe("dummy sign-in hash", () => { + it("is 60 characters long and uses the same cost as hashPassword", () => { expect(DUMMY_PASSWORD_HASH).toHaveLength(60); expect(bcrypt.getRounds(DUMMY_PASSWORD_HASH)).toBe(SALT_ROUNDS); }); - it("no coincide con ninguna contraseña y no lanza", async () => { + it("matches no password and does not throw", async () => { await expect(verifyPassword("x", DUMMY_PASSWORD_HASH)).resolves.toBe(false); await expect(verifyPasswordOrDummy("x", null)).resolves.toBe(false); }); - it("verifyPasswordOrDummy acepta la contraseña correcta de una cuenta real", async () => { - const hash = await hashPassword("correcta"); - await expect(verifyPasswordOrDummy("correcta", hash)).resolves.toBe(true); - await expect(verifyPasswordOrDummy("otra", hash)).resolves.toBe(false); + it("verifyPasswordOrDummy accepts the correct password of a real account", async () => { + const hash = await hashPassword("correct"); + await expect(verifyPasswordOrDummy("correct", hash)).resolves.toBe(true); + await expect(verifyPasswordOrDummy("other", hash)).resolves.toBe(false); }); - // Antes se medían los tiempos de una sola llamada de cada tipo: una pausa del GC o un - // runner de CI cargado bastaba para que fallara sin cambios de código. Lo que importa es - // que el camino sin cuenta haga el mismo trabajo de bcrypt contra el hash ficticio; que - // ese hash cueste lo mismo que uno real ya lo comprueba el primer test. - it("sin cuenta compara contra el hash ficticio con bcrypt", async () => { + // It used to time a single call of each kind: a GC pause or a busy CI runner was enough + // to make it fail without code changes. What matters is that the no-account path does + // the same bcrypt work against the dummy hash; that this hash costs the same as a real + // one is already checked by the first test. + it("without an account it compares against the dummy hash with bcrypt", async () => { const spy = vi.spyOn(bcrypt, "compare"); try { - await verifyPasswordOrDummy("mala", null); + await verifyPasswordOrDummy("wrong", null); expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith("mala", DUMMY_PASSWORD_HASH); + expect(spy).toHaveBeenCalledWith("wrong", DUMMY_PASSWORD_HASH); } finally { spy.mockRestore(); } diff --git a/test/board-collision.test.ts b/test/board-collision.test.ts index a55db70..735bd0c 100644 --- a/test/board-collision.test.ts +++ b/test/board-collision.test.ts @@ -13,9 +13,9 @@ import { calendarKeyboardCoordinates, } from "@/app/lib/board-collision"; -// Rectángulos sintéticos con la geometría real del tablero: columnas w-72 (288 px) -// separadas por gap-3 (12 px), tarjetas con 8 px de margen lateral bajo la cabecera, y -// la copia flotante de una lista (DragOverlay) de 288×48. +// Synthetic rectangles with the board's real geometry: w-72 columns (288 px) separated +// by gap-3 (12 px), cards with an 8 px side margin under the header, and the floating +// copy of a list (DragOverlay) of 288×48. function rect(left: number, top: number, width: number, height: number): ClientRect { return { left, top, width, height, right: left + width, bottom: top + height }; } @@ -39,7 +39,7 @@ function active(id: string, type: "list" | "card"): Active { }; } -// Columna A en x=0, B en x=300, C en x=600; una tarjeta en cada una, en y=49. +// Column A at x=0, B at x=300, C at x=600; one card in each, at y=49. const rects = new Map<UniqueIdentifier, ClientRect>([ ["A", rect(0, 0, 288, 190)], ["a1", rect(8, 49, 272, 60)], @@ -58,10 +58,10 @@ const containers = [ ]; describe("boardCollisionDetection", () => { - // La copia de la lista A, soltada encima de la columna B. + // The copy of list A, dropped over column B. const overlayOnB = rect(300, 0, 288, 48); - it("closestCorners a secas resuelve la lista arrastrada a una TARJETA (el fallo)", () => { + it("plain closestCorners resolves the dragged list to a CARD (the bug)", () => { const [first] = closestCorners({ active: active("A", "list"), collisionRect: overlayOnB, @@ -72,7 +72,7 @@ describe("boardCollisionDetection", () => { expect(first.id).toBe("b1"); }); - it("con una lista activa resuelve a la columna", () => { + it("with an active list it resolves to the column", () => { const [first] = boardCollisionDetection({ active: active("A", "list"), collisionRect: overlayOnB, @@ -83,7 +83,7 @@ describe("boardCollisionDetection", () => { expect(first.id).toBe("B"); }); - it("con una tarjeta activa sigue resolviendo a la tarjeta", () => { + it("with an active card it still resolves to the card", () => { const [first] = boardCollisionDetection({ active: active("a1", "card"), collisionRect: rect(308, 49, 272, 60), @@ -109,14 +109,14 @@ function context(over: Partial<SensorContext>): SensorContext { } as unknown as SensorContext; } -describe("boardKeyboardCoordinates (listas)", () => { +describe("boardKeyboardCoordinates (lists)", () => { const args = (collisionRect: ClientRect) => ({ active: "A", currentCoordinates: { x: collisionRect.left, y: collisionRect.top }, context: context({ active: active("A", "list"), collisionRect }), }); - it("la flecha derecha salta a la columna vecina, no a la primera tarjeta propia", () => { + it("the right arrow jumps to the neighbouring column, not to its own first card", () => { const { event, preventDefault } = key("ArrowRight"); expect(boardKeyboardCoordinates(event, args(rect(0, 0, 288, 48)))).toEqual({ x: 300, @@ -128,7 +128,7 @@ describe("boardKeyboardCoordinates (listas)", () => { ).toEqual({ x: 600, y: 0 }); }); - it("la flecha izquierda vuelve a la columna anterior y no pasa de la primera", () => { + it("the left arrow goes back to the previous column and stops at the first one", () => { expect( boardKeyboardCoordinates(key("ArrowLeft").event, args(rect(300, 0, 288, 48))), ).toEqual({ x: 0, y: 0 }); @@ -137,16 +137,16 @@ describe("boardKeyboardCoordinates (listas)", () => { ).toBeUndefined(); }); - it("arriba/abajo no mueven una lista", () => { + it("up/down do not move a list", () => { const { event, preventDefault } = key("ArrowDown"); expect(boardKeyboardCoordinates(event, args(rect(0, 0, 288, 48)))).toBeUndefined(); expect(preventDefault).toHaveBeenCalled(); }); }); -describe("boardKeyboardCoordinates (tarjetas, izquierda/derecha)", () => { - // Regresión: con la flecha izquierda, la propia columna de la tarjeta (su borde está - // 8 px antes) contaba como «a la izquierda» y la tarjeta volvía a su sitio sin moverse. +describe("boardKeyboardCoordinates (cards, left/right)", () => { + // Regression: with the left arrow, the card's own column (its edge is 8 px before) + // counted as "to the left" and the card went back to its place without moving. const args = (id: string, collisionRect: ClientRect, extra?: [string, ClientRect][]) => { const r = new Map(rects); const cs = [...containers]; @@ -165,7 +165,7 @@ describe("boardKeyboardCoordinates (tarjetas, izquierda/derecha)", () => { }; }; - it("izquierda lleva la tarjeta a la tarjeta de la columna anterior", () => { + it("left takes the card to the card of the previous column", () => { const { event, preventDefault } = key("ArrowLeft"); expect(boardKeyboardCoordinates(event, args("b1", rect(308, 49, 272, 60)))).toEqual({ x: 8, @@ -174,13 +174,13 @@ describe("boardKeyboardCoordinates (tarjetas, izquierda/derecha)", () => { expect(preventDefault).toHaveBeenCalled(); }); - it("derecha lleva la tarjeta a la columna siguiente", () => { + it("right takes the card to the next column", () => { expect( boardKeyboardCoordinates(key("ArrowRight").event, args("b1", rect(308, 49, 272, 60))), ).toEqual({ x: 608, y: 49 }); }); - it("en una columna vacía apunta a la propia columna", () => { + it("in an empty column it points at the column itself", () => { expect( boardKeyboardCoordinates( key("ArrowRight").event, @@ -189,7 +189,7 @@ describe("boardKeyboardCoordinates (tarjetas, izquierda/derecha)", () => { ).toEqual({ x: 900, y: 0 }); }); - it("no pasa de la primera ni de la última columna", () => { + it("does not go past the first or the last column", () => { expect( boardKeyboardCoordinates(key("ArrowLeft").event, args("a1", rect(8, 49, 272, 60))), ).toBeUndefined(); @@ -200,8 +200,8 @@ describe("boardKeyboardCoordinates (tarjetas, izquierda/derecha)", () => { }); describe("calendarKeyboardCoordinates", () => { - // Rejilla de 3×2 celdas de 100×80 con gap-1 (4 px). La pastilla está en la celda - // (0,0), desplazada 6 px a la derecha y 20 px hacia abajo. + // Grid of 3×2 cells of 100×80 with gap-1 (4 px). The pill is in cell (0,0), shifted + // 6 px to the right and 20 px down. const cells = new Map<UniqueIdentifier, ClientRect>(); const days: DroppableContainer[] = []; for (let row = 0; row < 2; row++) { @@ -226,21 +226,21 @@ describe("calendarKeyboardCoordinates", () => { context: ctx(overId), }); - it("derecha va al día siguiente y abajo a la semana siguiente", () => { + it("right goes to the next day and down to the next week", () => { expect(run("ArrowRight")).toEqual({ x: 110, y: 20 }); expect(run("ArrowDown")).toEqual({ x: 6, y: 104 }); }); - it("sin celda vecina en esa dirección no se mueve", () => { + it("without a neighbouring cell in that direction it does not move", () => { expect(run("ArrowLeft")).toBeUndefined(); expect(run("ArrowUp")).toBeUndefined(); }); - it("sin `over` usa la celda que contiene la pastilla", () => { + it("without `over` it uses the cell that contains the pill", () => { expect(run("ArrowRight", null)).toEqual({ x: 110, y: 20 }); }); - it("ignora las teclas que no son flechas", () => { + it("ignores keys that are not arrows", () => { const { event, preventDefault } = key("KeyA"); expect( calendarKeyboardCoordinates(event, { diff --git a/test/board-filters.test.ts b/test/board-filters.test.ts index fe62fbb..95376fe 100644 --- a/test/board-filters.test.ts +++ b/test/board-filters.test.ts @@ -8,13 +8,13 @@ import { } from "@/app/lib/board-filters"; import type { CardDTO } from "@/app/lib/types"; -// Hoy fijo (UTC) para los tests de vencimiento. +// Fixed "today" (UTC) for the due date tests. const NOW = Date.UTC(2026, 5, 15, 12, 0, 0); // 2026-06-15 function card(over: Partial<CardDTO>): CardDTO { return { id: "c1", - title: "Tarjeta", + title: "Card", position: "a0", description: null, dueDate: null, @@ -33,25 +33,25 @@ const f = (over: Partial<BoardFilters>): BoardFilters => ({ }); describe("board-filters", () => { - it("sin filtros activos todo pasa", () => { + it("with no active filters everything passes", () => { expect(filtersActive(EMPTY_FILTERS)).toBe(false); expect(cardMatchesFilters(card({}), EMPTY_FILTERS, NOW)).toBe(true); }); - it("filtra por etiqueta (OR dentro de la dimensión)", () => { + it("filters by label (OR within the dimension)", () => { const c = card({ labels: [{ id: "L1", name: "a", color: "blue" }] }); expect(cardMatchesFilters(c, f({ labelIds: ["L1"] }), NOW)).toBe(true); expect(cardMatchesFilters(c, f({ labelIds: ["L2"] }), NOW)).toBe(false); expect(cardMatchesFilters(c, f({ labelIds: ["L2", "L1"] }), NOW)).toBe(true); }); - it("filtra por asignado", () => { + it("filters by assignee", () => { const c = card({ assignees: [{ id: "U1", name: "Ana" }] }); expect(cardMatchesFilters(c, f({ assigneeIds: ["U1"] }), NOW)).toBe(true); expect(cardMatchesFilters(c, f({ assigneeIds: ["U2"] }), NOW)).toBe(false); }); - it("combina dimensiones con AND", () => { + it("combines dimensions with AND", () => { const c = card({ labels: [{ id: "L1", name: "a", color: "blue" }], assignees: [{ id: "U1", name: "Ana" }], @@ -64,7 +64,7 @@ describe("board-filters", () => { ).toBe(false); }); - it("filtra por estado de vencimiento", () => { + it("filters by due status", () => { const overdue = card({ dueDate: "2026-06-10T00:00:00.000Z" }); const future = card({ dueDate: "2026-06-20T00:00:00.000Z" }); const undated = card({ dueDate: null }); @@ -82,18 +82,18 @@ describe("board-filters", () => { }); describe("isOverdue", () => { - // `now` construido con fecha LOCAL (la del navegador), como en la app. - const now = new Date(2026, 8, 24, 10, 0, 0).getTime(); // 24/09/2026 10:00 local + // `now` built from a LOCAL date (the browser's), as in the app. + const now = new Date(2026, 8, 24, 10, 0, 0).getTime(); // 2026-09-24 10:00 local - it("la tarjeta que vence hoy no está vencida", () => { + it("a card due today is not overdue", () => { expect(isOverdue("2026-09-24T00:00:00.000Z", now)).toBe(false); }); - it("la que venció ayer sí", () => { + it("one due yesterday is", () => { expect(isOverdue("2026-09-23T00:00:00.000Z", now)).toBe(true); }); - it("coincide con el filtro «Vencidas»", () => { + it("matches the \"Overdue\" filter", () => { const today = card({ dueDate: "2026-09-24T00:00:00.000Z" }); const yesterday = card({ dueDate: "2026-09-23T00:00:00.000Z" }); expect(cardMatchesFilters(today, f({ due: "overdue" }), now)).toBe(false); @@ -102,24 +102,25 @@ describe("isOverdue", () => { }); describe("formatDueDay", () => { - // vi.unstubAllEnvs deja TZ como estaba; si no existía, la borra. Reasignar el valor - // original a mano guardaba el texto "undefined", que Node trata como UTC para el - // resto del archivo. + // vi.unstubAllEnvs leaves TZ as it was; if it did not exist, it deletes it. Reassigning + // the original value by hand stored the text "undefined", which Node treats as UTC for + // the rest of the file. afterEach(() => { vi.unstubAllEnvs(); }); - it("muestra el día guardado aunque la zona local vaya por detrás de UTC", async () => { - // Antes se formateaba en la zona local: en Nueva York (UTC-4) salía «23 sept». + it("shows the stored day even if the local zone is behind UTC", async () => { + // It used to be formatted in the local zone: in New York (UTC-4) it showed "Sep 23". vi.stubEnv("TZ", "America/New_York"); - // El formateador es de módulo y fija la zona al importarse: hay que cargar el módulo - // DESPUÉS de cambiar TZ. Con el import estático de la cabecera se quedaba con la zona - // de la máquina (UTC en CI) y el test pasaba aunque se quitara `timeZone: "UTC"`. + // The formatters are cached at module level and may fix the zone when created: the + // module has to be loaded AFTER changing TZ. With the static import at the top it kept + // the machine's zone (UTC in CI) and the test passed even without `timeZone: "UTC"`. vi.resetModules(); const { formatDueDay } = await import("@/app/lib/board-filters"); - expect(new Date("2026-09-24T00:00:00.000Z").getDate()).toBe(23); // la trampa - const label = formatDueDay("2026-09-24T00:00:00.000Z"); - expect(label).toContain("24"); - expect(label).toContain("sept"); + expect(new Date("2026-09-24T00:00:00.000Z").getDate()).toBe(23); // the trap + expect(formatDueDay("2026-09-24T00:00:00.000Z", "en")).toBe("Sep 24"); + const es = formatDueDay("2026-09-24T00:00:00.000Z", "es-ES"); + expect(es).toContain("24"); + expect(es).toContain("sept"); }); }); diff --git a/test/calendar-view.test.ts b/test/calendar-view.test.ts index e09096d..d5dae1b 100644 --- a/test/calendar-view.test.ts +++ b/test/calendar-view.test.ts @@ -2,8 +2,9 @@ import { describe, it, expect, vi } from "vitest"; import { createElement } from "react"; import { renderToString } from "react-dom/server"; import type { ListDTO } from "@/app/lib/types"; +import { withI18n } from "./helpers/i18n"; -// La acción de servidor importa Prisma; aquí solo se mira el HTML inicial. +// The server action imports Prisma; here only the initial HTML is checked. vi.mock("@/app/lib/actions/cards", () => ({ setCardDueDate: vi.fn() })); const { CalendarView, preventSpaceActivation } = await import( @@ -16,30 +17,30 @@ function keyUp(code: string) { return preventDefault; } -describe("CalendarView — pastillas", () => { - it("cancela el keyup de Espacio para que coger o soltar no abra la tarjeta", () => { +describe("CalendarView — pills", () => { + it("cancels the Space keyup so that picking up or dropping does not open the card", () => { expect(keyUp("Space")).toHaveBeenCalledOnce(); }); - it("no toca Intro ni otras teclas (Intro sigue abriendo la tarjeta)", () => { + it("leaves Enter and other keys alone (Enter still opens the card)", () => { expect(keyUp("Enter")).not.toHaveBeenCalled(); expect(keyUp("ArrowRight")).not.toHaveBeenCalled(); }); }); -describe("CalendarView — render de servidor", () => { - // Una tarjeta con fecha en el mes actual, para que aparezca en la rejilla. +describe("CalendarView — server render", () => { + // A card dated in the current month, so that it shows up in the grid. const now = new Date(); const due = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-15T00:00:00.000Z`; const lists: ListDTO[] = [ { id: "l1", - title: "Pendiente", + title: "To do", position: "a0", cards: [ { id: "c1", - title: "Revisar contrato", + title: "Review contract", position: "a0", description: null, dueDate: due, @@ -53,11 +54,11 @@ describe("CalendarView — render de servidor", () => { }, ]; - it("renderiza sin tocar document (el portal del DragOverlay solo se monta en el navegador)", () => { + it("renders without touching document (the DragOverlay portal is only mounted in the browser)", () => { const html = renderToString( - createElement(CalendarView, { lists, onOpenCard: () => {}, canEdit: true }), + withI18n(createElement(CalendarView, { lists, onOpenCard: () => {}, canEdit: true })), ); - expect(html).toContain("Revisar contrato"); - expect(html).toContain('aria-roledescription="tarjeta"'); + expect(html).toContain("Review contract"); + expect(html).toContain('aria-roledescription="card"'); }); }); diff --git a/test/card-item.test.ts b/test/card-item.test.ts index d3bec6d..e784ccf 100644 --- a/test/card-item.test.ts +++ b/test/card-item.test.ts @@ -5,15 +5,16 @@ import { CardItem } from "@/app/components/board/card-item"; import { BoardPermsProvider } from "@/app/components/board/board-perms"; import type { BoardRole } from "@/app/lib/roles"; import type { CardDTO } from "@/app/lib/types"; +import { withI18n } from "./helpers/i18n"; -// Render de servidor (sin DOM): comprueba el HTML que llega al navegador antes de -// hidratar, que es donde estaban los fallos de accesibilidad y de fechas. +// Server render (no DOM): checks the HTML that reaches the browser before hydration, +// which is where the accessibility and date bugs were. const card: CardDTO = { id: "c1", - title: "Revisar contrato", + title: "Review contract", position: "a0", description: null, - dueDate: "2020-01-02T00:00:00.000Z", // vencida hace tiempo + dueDate: "2020-01-02T00:00:00.000Z", // overdue long ago labels: [], checklists: [], attachments: [], @@ -23,47 +24,49 @@ const card: CardDTO = { function render(role: BoardRole, props: { overlay?: boolean } = {}): string { return renderToString( - createElement( - BoardPermsProvider, - // `children` va como tercer argumento; el tipo de props lo exige igualmente. - { role } as ComponentProps<typeof BoardPermsProvider>, - createElement(CardItem, { card, listId: "l1", ...props }), + withI18n( + createElement( + BoardPermsProvider, + // `children` goes as the third argument; the props type requires it anyway. + { role } as ComponentProps<typeof BoardPermsProvider>, + createElement(CardItem, { card, listId: "l1", ...props }), + ), ), ); } describe("CardItem", () => { - it("es enfocable y se anuncia como botón aunque no se pueda arrastrar (Lector)", () => { + it("is focusable and announced as a button even if it cannot be dragged (Viewer)", () => { const html = render("VIEWER"); expect(html).toContain('role="button"'); expect(html).toContain('tabindex="0"'); }); - it("toma el nombre accesible del contenido, no de un aria-label con solo el título", () => { + it("takes its accessible name from the content, not from an aria-label with only the title", () => { const html = render("VIEWER"); - // El primer elemento es la propia tarjeta: su etiqueta de apertura no lleva - // aria-label, así que el lector lee título, vencimiento, subtareas... + // The first element is the card itself: its opening tag has no aria-label, so the + // screen reader reads title, due date, subtasks... const openingTag = html.slice(0, html.indexOf(">") + 1); expect(openingTag).toContain('role="button"'); expect(openingTag).not.toContain("aria-label"); - expect(html).toContain("Revisar contrato"); - expect(html).toContain("2 ene"); + expect(html).toContain("Review contract"); + expect(html).toContain("Jan 2"); }); - it("con permisos de edición mantiene el foco y describe el rol en español", () => { + it("with edit permissions it keeps focus and describes the role in the interface language", () => { const html = render("EDITOR"); expect(html).toContain('role="button"'); expect(html).toContain('tabindex="0"'); - expect(html).toContain('aria-roledescription="tarjeta"'); + expect(html).toContain('aria-roledescription="card"'); }); - it("la copia flotante del arrastre no recibe foco", () => { + it("the floating drag copy does not receive focus", () => { expect(render("EDITOR", { overlay: true })).toContain('tabindex="-1"'); }); - it("no marca «vencida» en el HTML del servidor (depende del día del navegador)", () => { + it("does not mark \"overdue\" in the server HTML (it depends on the browser's day)", () => { const html = render("VIEWER"); expect(html).not.toContain("text-danger"); - expect(html).toContain("2 ene"); + expect(html).toContain("Jan 2"); }); }); diff --git a/test/card-modal-helpers.test.ts b/test/card-modal-helpers.test.ts index c0cacb0..fd17809 100644 --- a/test/card-modal-helpers.test.ts +++ b/test/card-modal-helpers.test.ts @@ -7,33 +7,37 @@ import { } from "@/app/components/card/card-modal-helpers"; describe("formatCommentTime", () => { - // 09:15 UTC en septiembre = 10:15 en Canarias (WEST, UTC+1). Solo comprueba que la - // zona recibida llega a Intl; el desajuste de hidratación lo evita useHydrated en el - // componente, que no tiene test (no hay jsdom en el proyecto). + // 09:15 UTC in September = 10:15 in the Canary Islands (WEST, UTC+1). It only checks + // that the given zone reaches Intl; the hydration mismatch is avoided by useHydrated in + // the component, which has no test (the project has no jsdom). const iso = "2026-09-24T09:15:00.000Z"; - it("formatea en la zona indicada", () => { - expect(formatCommentTime(iso, "Atlantic/Canary")).toContain("10:15"); - expect(formatCommentTime(iso, "UTC")).toContain("9:15"); + it("formats in the given zone", () => { + expect(formatCommentTime(iso, "en", "Atlantic/Canary")).toContain("10:15"); + expect(formatCommentTime(iso, "en", "UTC")).toContain("9:15"); + }); + + it("formats in the given language", () => { + expect(formatCommentTime(iso, "en", "UTC")).toContain("Sep"); + expect(formatCommentTime(iso, "es-ES", "UTC")).toContain("sept"); }); }); describe("formatDueDate", () => { - it("muestra la fecha de calendario legible, no el ISO crudo", () => { - expect(formatDueDate("2026-09-24T00:00:00.000Z")).toBe( + it("shows the readable calendar date, not the raw ISO", () => { + expect(formatDueDate("2026-09-24T00:00:00.000Z", "en")).toBe("September 24, 2026"); + expect(formatDueDate("2026-09-24T00:00:00.000Z", "es-ES")).toBe( "24 de septiembre de 2026", ); }); - it("no retrocede un día aunque el proceso corra al oeste de UTC", () => { + it("does not go back a day even if the process runs west of UTC", () => { const tz = process.env.TZ; process.env.TZ = "America/New_York"; try { - expect(formatDueDate("2026-01-01T00:00:00.000Z")).toBe( - "1 de enero de 2026", - ); + expect(formatDueDate("2026-01-01T00:00:00.000Z", "en")).toBe("January 1, 2026"); } finally { - // Asignar undefined a process.env lo guarda como el texto "undefined". + // Assigning undefined to process.env stores it as the text "undefined". if (tz === undefined) delete process.env.TZ; else process.env.TZ = tz; } @@ -41,39 +45,39 @@ describe("formatDueDate", () => { }); describe("runToggle", () => { - it("devuelve null si la acción va bien", async () => { + it("returns null if the action succeeds", async () => { await expect( - runToggle(async () => ({ ok: true }), "fallo", "prueba"), + runToggle(async () => ({ ok: true }), "failed", "test"), ).resolves.toBeNull(); }); - it("devuelve el mensaje si la acción responde ok: false", async () => { + it("returns the message if the action answers ok: false", async () => { await expect( - runToggle(async () => ({ ok: false }), "fallo", "prueba"), - ).resolves.toBe("fallo"); + runToggle(async () => ({ ok: false }), "failed", "test"), + ).resolves.toBe("failed"); }); - it("captura un rechazo y devuelve el mensaje", async () => { + it("catches a rejection and returns the message", async () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); await expect( runToggle( - () => Promise.reject(new Error("red caída")), - "fallo", - "prueba", + () => Promise.reject(new Error("network down")), + "failed", + "test", ), - ).resolves.toBe("fallo"); + ).resolves.toBe("failed"); expect(spy).toHaveBeenCalledOnce(); spy.mockRestore(); }); - it("relanza los errores internos de Next (redirect)", async () => { + it("rethrows Next's internal errors (redirect)", async () => { await expect( runToggle( async () => { redirect("/login"); }, - "fallo", - "prueba", + "failed", + "test", ), ).rejects.toThrow("NEXT_REDIRECT"); }); diff --git a/test/client-imports.test.ts b/test/client-imports.test.ts index 9ae1304..b31594b 100644 --- a/test/client-imports.test.ts +++ b/test/client-imports.test.ts @@ -4,10 +4,10 @@ import path from "node:path"; import * as limits from "@/app/lib/limits"; import * as validators from "@/app/lib/validators"; -// Los componentes "use client" no deben importar zod ni validators.ts: entrarían en el -// bundle del navegador solo por una constante, y validators.ts usa Buffer, que allí no -// existe. Las constantes que necesita la UI viven en app/lib/limits.ts. Solo mira las -// importaciones directas: no hay bundler en los tests para seguir las transitivas. +// "use client" components must not import zod or validators.ts: they would end up in the +// browser bundle just for a constant, and validators.ts uses Buffer, which does not exist +// there. The constants the UI needs live in app/lib/limits.ts. It only looks at direct +// imports: there is no bundler in the tests to follow transitive ones. const APP_DIR = path.resolve(__dirname, "../app"); @@ -23,16 +23,16 @@ function isClientModule(source: string): boolean { return /^\s*["']use client["']/.test(source); } -describe("importaciones de los componentes de cliente", () => { +describe("imports of the client components", () => { const clientFiles = sourceFiles(APP_DIR).filter((file) => isClientModule(readFileSync(file, "utf8")), ); - it("encuentra componentes de cliente (el filtro no está roto)", () => { + it("finds client components (the filter is not broken)", () => { expect(clientFiles.some((file) => file.endsWith("card-modal.tsx"))).toBe(true); }); - it("ninguno importa zod ni validators.ts", () => { + it("none of them imports zod or validators.ts", () => { const offenders = clientFiles.filter((file) => /from\s+["'](zod|@\/app\/lib\/validators|[./]+\/(lib\/)?validators)["']/.test( readFileSync(file, "utf8"), @@ -43,12 +43,12 @@ describe("importaciones de los componentes de cliente", () => { }); describe("limits.ts", () => { - it("no tiene importaciones, para poder usarse en el cliente", () => { + it("has no imports, so it can be used on the client", () => { const source = readFileSync(path.join(APP_DIR, "lib/limits.ts"), "utf8"); expect(source).not.toMatch(/^\s*import\s/m); }); - it("validators.ts reexporta el mismo límite de descripción", () => { + it("validators.ts re-exports the same description limit", () => { expect(validators.CARD_DESCRIPTION_MAX).toBe(limits.CARD_DESCRIPTION_MAX); }); }); diff --git a/test/client-ip.test.ts b/test/client-ip.test.ts index e4ad613..7fac16f 100644 --- a/test/client-ip.test.ts +++ b/test/client-ip.test.ts @@ -5,47 +5,48 @@ import { resolveClientIp, } from "@/app/lib/client-ip"; -// IP del cliente para el rate limit del login: solo se confía en X-Forwarded-For si se -// declaran proxies de confianza, y se toma el valor que añadió el más externo (contando -// desde la derecha). El primer valor lo escribe el cliente y no vale. +// Client IP for the sign-in rate limit: X-Forwarded-For is only trusted if trusted proxies +// are declared, and the value taken is the one added by the outermost one (counting from +// the right). The first value is written by the client and is worthless. describe("pickClientIp", () => { - it("con hops = 0 ignora la cabecera", () => { + it("with hops = 0 it ignores the header", () => { expect(pickClientIp("1.2.3.4", 0)).toBeNull(); }); - it("sin cabecera o vacía devuelve null", () => { + it("without a header or with an empty one it returns null", () => { expect(pickClientIp(null, 1)).toBeNull(); expect(pickClientIp("", 1)).toBeNull(); }); - it("toma el N-ésimo valor desde la derecha", () => { + it("takes the Nth value from the right", () => { expect(pickClientIp("1.2.3.4, 10.0.0.1", 1)).toBe("10.0.0.1"); expect(pickClientIp("1.2.3.4, 10.0.0.1", 2)).toBe("1.2.3.4"); }); - it("con menos valores que proxies declarados devuelve null", () => { + it("with fewer values than declared proxies it returns null", () => { expect(pickClientIp("10.0.0.1", 2)).toBeNull(); }); - it("recorta espacios y acepta IPv6", () => { + it("trims spaces and accepts IPv6", () => { expect(pickClientIp(" 1.2.3.4 ", 1)).toBe("1.2.3.4"); - expect(pickClientIp("falsa, 2001:db8::1", 1)).toBe("2001:db8::1"); + expect(pickClientIp("fake, 2001:db8::1", 1)).toBe("2001:db8::1"); }); - it("un valor que no es una IP devuelve null", () => { + it("a value that is not an IP returns null", () => { expect(pickClientIp("1.2.3.4, unknown", 1)).toBeNull(); expect(pickClientIp("1.2.3.4, ", 1)).toBeNull(); }); - // Regresión: con proxies que escriben la IP con el puerto de origen (Azure Application - // Gateway, IIS ARR), el valor no pasaba isIP y el login se quedaba sin límite por IP. - it("quita el puerto de IPv4 y de IPv6 entre corchetes", () => { + // Regression: with proxies that write the IP with the source port (Azure Application + // Gateway, IIS ARR), the value did not pass isIP and sign-in was left without a per-IP + // limit. + it("strips the port from IPv4 and from bracketed IPv6", () => { expect(pickClientIp("1.2.3.4:5678", 1)).toBe("1.2.3.4"); - expect(pickClientIp("falsa, [2001:db8::1]:443", 1)).toBe("2001:db8::1"); + expect(pickClientIp("fake, [2001:db8::1]:443", 1)).toBe("2001:db8::1"); expect(pickClientIp("[::1]", 1)).toBe("::1"); }); - it("no parte una IPv6 sin corchetes ni acepta basura con puerto", () => { + it("does not split an unbracketed IPv6 nor accept garbage with a port", () => { expect(pickClientIp("2001:db8::1:443", 1)).toBe("2001:db8::1:443"); expect(pickClientIp("unknown:80", 1)).toBeNull(); expect(pickClientIp("999.1.1.1:80", 1)).toBeNull(); @@ -55,12 +56,12 @@ describe("pickClientIp", () => { }); describe("resolveClientIp", () => { - it("con hops = 0 no hay IP ni problema", () => { + it("with hops = 0 there is neither an IP nor a problem", () => { expect(resolveClientIp("1.2.3.4", 0)).toEqual({ ip: null, problem: null }); expect(resolveClientIp(null, 0)).toEqual({ ip: null, problem: null }); }); - it("indica por qué no hay IP cuando hay proxies declarados", () => { + it("says why there is no IP when proxies are declared", () => { expect(resolveClientIp(null, 1)).toEqual({ ip: null, problem: "missing-header" }); expect(resolveClientIp("10.0.0.1", 2)).toEqual({ ip: null, @@ -74,9 +75,9 @@ describe("resolveClientIp", () => { }); }); -// El aviso se guarda en un Set del módulo: cada test importa una copia nueva para empezar -// sin avisos previos. -describe("aviso de proxy mal configurado", () => { +// Warnings are remembered in a module-level Set: each test imports a fresh copy to start +// without previous warnings. +describe("misconfigured proxy warning", () => { let warn: ReturnType<typeof vi.spyOn>; beforeEach(() => { vi.resetModules(); @@ -90,24 +91,25 @@ describe("aviso de proxy mal configurado", () => { return (await import("@/app/lib/client-ip")).pickClientIp; } - it("avisa una vez por motivo, sin la cabecera ni la IP", async () => { + it("warns once per reason, without the header or the IP", async () => { const pick = await freshPick(); pick("203.0.113.7, unknown", 1); pick("203.0.113.8, unknown", 1); expect(warn).toHaveBeenCalledTimes(1); const message = String(warn.mock.calls[0][0]); expect(message).toMatch(/TRUSTED_PROXY_HOPS=1/); - expect(message).toMatch(/no es una IP/); + expect(message).toMatch(/is not an IP/); expect(message).not.toMatch(/203\.0\.113/); - // Puede haber otro aviso con otro motivo: el texto no debe prometer que es el último. - expect(message).toMatch(/una vez por motivo y proceso/); + // There may be another warning with another reason: the text must not promise it is + // the last one. + expect(message).toMatch(/once per reason and process/); pick("10.0.0.1", 2); expect(warn).toHaveBeenCalledTimes(2); - expect(String(warn.mock.calls[1][0])).toMatch(/menos valores/); + expect(String(warn.mock.calls[1][0])).toMatch(/fewer values/); }); - it("no avisa con hops = 0 ni cuando obtiene la IP", async () => { + it("does not warn with hops = 0 nor when it gets the IP", async () => { const pick = await freshPick(); pick(null, 0); pick("1.2.3.4", 0); @@ -117,20 +119,20 @@ describe("aviso de proxy mal configurado", () => { }); describe("parseTrustedProxyHops", () => { - it("sin definir o vacío es 0", () => { + it("undefined or empty is 0", () => { expect(parseTrustedProxyHops(undefined)).toBe(0); expect(parseTrustedProxyHops("")).toBe(0); expect(parseTrustedProxyHops(" ")).toBe(0); }); - it("acepta enteros >= 0", () => { + it("accepts integers >= 0", () => { expect(parseTrustedProxyHops("0")).toBe(0); expect(parseTrustedProxyHops("2")).toBe(2); expect(parseTrustedProxyHops(" 1 ")).toBe(1); }); - it("rechaza lo que no es un entero >= 0", () => { - for (const bad of ["-1", "1.5", "uno", "1e2"]) { + it("rejects anything that is not an integer >= 0", () => { + for (const bad of ["-1", "1.5", "one", "1e2"]) { expect(() => parseTrustedProxyHops(bad)).toThrow(/TRUSTED_PROXY_HOPS/); } }); diff --git a/test/colors.test.ts b/test/colors.test.ts index 83136ef..6e96358 100644 --- a/test/colors.test.ts +++ b/test/colors.test.ts @@ -9,36 +9,36 @@ import { } from "@/app/lib/colors"; describe("colors", () => { - it("pickBoardColor es determinista y siempre dentro de la paleta", () => { - expect(pickBoardColor("mismo-seed")).toBe(pickBoardColor("mismo-seed")); - for (const seed of ["a", "board-123", "Lanzamiento web", ""]) { + it("pickBoardColor is deterministic and always within the palette", () => { + expect(pickBoardColor("same-seed")).toBe(pickBoardColor("same-seed")); + for (const seed of ["a", "board-123", "Website launch", ""]) { expect(BOARD_COLOR_KEYS).toContain(pickBoardColor(seed)); } }); - it("boardBackgroundFor usa el color guardado o uno estable por id", () => { + it("boardBackgroundFor uses the stored colour or a stable one per id", () => { expect(boardBackgroundFor({ id: "x", background: "teal" })).toBe( BOARD_COLORS.teal, ); - // Sin color guardado: estable para el mismo id. + // Without a stored colour: stable for the same id. const r1 = boardBackgroundFor({ id: "stable-id", background: null }); const r2 = boardBackgroundFor({ id: "stable-id", background: null }); expect(r1).toBe(r2); - // Un color inválido guardado también cae al determinista por id. - expect(boardBackgroundFor({ id: "y", background: "no-existe" })).toBe( + // An invalid stored colour also falls back to the deterministic one per id. + expect(boardBackgroundFor({ id: "y", background: "missing" })).toBe( BOARD_COLORS[pickBoardColor("y")], ); }); - it("labelBackground resuelve y cae a azul por defecto", () => { + it("labelBackground resolves and falls back to blue by default", () => { expect(labelBackground("red")).toBe(LABEL_COLORS.red); - expect(labelBackground("no-existe")).toBe(LABEL_COLORS.blue); + expect(labelBackground("missing")).toBe(LABEL_COLORS.blue); expect(labelBackground(null)).toBe(LABEL_COLORS.blue); }); - // Regresión: con el operador `in`, las claves del prototipo pasaban la comprobación y se - // pintaba una función como color. - it("las claves del prototipo no se toman como colores", () => { + // Regression: with the `in` operator, prototype keys passed the check and a function was + // painted as a colour. + it("prototype keys are not taken as colours", () => { for (const key of ["constructor", "__proto__", "toString"]) { expect(labelBackground(key)).toBe(LABEL_COLORS.blue); expect(boardBackgroundFor({ id: "y", background: key })).toBe( diff --git a/test/contrast.test.ts b/test/contrast.test.ts index 1bbbe7f..4603c46 100644 --- a/test/contrast.test.ts +++ b/test/contrast.test.ts @@ -2,21 +2,21 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -// Contraste WCAG AA (4,5:1 para texto normal) de los tokens de color de -// app/globals.css, en los dos temas. Se leen del propio CSS para que un cambio de -// tono que rompa el contraste falle aquí en vez de descubrirse a ojo. +// WCAG AA contrast (4.5:1 for normal text) of the colour tokens in app/globals.css, in +// both themes. They are read from the CSS itself so that a change of tone that breaks +// the contrast fails here instead of being discovered by eye. const css = readFileSync( fileURLToPath(new URL("../app/globals.css", import.meta.url)), "utf8", ); -type Rgb = [number, number, number]; // sRGB lineal, 0..1 +type Rgb = [number, number, number]; // linear sRGB, 0..1 -/** Tokens `--nombre: oklch(L C H)` de un bloque (`:root` o `.dark`). */ +/** `--name: oklch(L C H)` tokens of a block (`:root` or `.dark`). */ function tokens(selector: string): Record<string, Rgb> { const start = css.indexOf(`${selector} {`); - if (start === -1) throw new Error(`No se encontró el bloque ${selector}`); + if (start === -1) throw new Error(`Block not found: ${selector}`); const block = css.slice(start, css.indexOf("}", start)); const out: Record<string, Rgb> = {}; for (const m of block.matchAll( @@ -27,7 +27,7 @@ function tokens(selector: string): Record<string, Rgb> { return out; } -// Conversión OKLCH → sRGB lineal (Björn Ottosson), recortada a gama. +// OKLCH → linear sRGB conversion (Björn Ottosson), clipped to gamut. function oklchToLinearSrgb(L: number, C: number, H: number): Rgb { const h = (H * Math.PI) / 180; const a = C * Math.cos(h); @@ -48,7 +48,7 @@ const encode = (v: number) => const decode = (v: number) => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; -/** Color `fg` con opacidad `alpha` sobre `bg` (mezcla en sRGB, como el navegador). */ +/** Colour `fg` with opacity `alpha` over `bg` (blended in sRGB, like the browser). */ function over(fg: Rgb, bg: Rgb, alpha: number): Rgb { return fg.map((c, i) => decode(encode(c) * alpha + encode(bg[i]) * (1 - alpha)), @@ -66,30 +66,30 @@ const TEXT = ["ink", "ink-muted", "accent", "danger", "success"]; const BACKGROUNDS = ["bg", "surface", "surface-2", "column"]; describe.each([ - ["claro", ":root"], - ["oscuro", ".dark"], -])("contraste del tema %s", (_name, selector) => { + ["light", ":root"], + ["dark", ".dark"], +])("contrast of the %s theme", (_name, selector) => { const t = tokens(selector); - it.each(TEXT)("--%s como texto sobre los fondos del cromo", (fg) => { + it.each(TEXT)("--%s as text over the chrome backgrounds", (fg) => { for (const bg of BACKGROUNDS) { - expect(contrast(t[fg], t[bg]), `${fg} sobre ${bg}`).toBeGreaterThanOrEqual(AA); + expect(contrast(t[fg], t[bg]), `${fg} over ${bg}`).toBeGreaterThanOrEqual(AA); } }); - // Avisos `bg-danger/10 text-danger` y `bg-success/10 text-success` sobre la página - // o sobre una tarjeta, y la insignia de vencida (`bg-danger/15`) sobre la tarjeta. + // `bg-danger/10 text-danger` and `bg-success/10 text-success` messages over the page or + // over a card, and the overdue badge (`bg-danger/15`) over the card. it.each([ ["danger", 0.1, "bg"], ["danger", 0.1, "surface"], ["danger", 0.15, "surface"], ["success", 0.1, "bg"], ["success", 0.1, "surface"], - ] as const)("--%s sobre su propio fondo al %s encima de --%s", (fg, alpha, bg) => { + ] as const)("--%s over its own background at %s on top of --%s", (fg, alpha, bg) => { expect(contrast(t[fg], over(t[fg], t[bg], alpha))).toBeGreaterThanOrEqual(AA); }); - it("--accent-ink sobre --accent (botón primario)", () => { + it("--accent-ink over --accent (primary button)", () => { expect(contrast(t["accent-ink"], t.accent)).toBeGreaterThanOrEqual(AA); }); }); diff --git a/test/csp.test.ts b/test/csp.test.ts index 5b7cf9c..98675ca 100644 --- a/test/csp.test.ts +++ b/test/csp.test.ts @@ -3,52 +3,52 @@ import { NextRequest } from "next/server"; import { buildContentSecurityPolicy, generateNonce } from "@/app/lib/csp"; import { proxy, config } from "@/proxy"; -// La CSP es la última barrera ante un XSS: estos tests fijan las directivas que no se -// pueden relajar sin decidirlo (ver docs/DECISIONES.md, 2026-09-24). +// The CSP is the last barrier against XSS: these tests pin the directives that cannot be +// relaxed without a deliberate decision (see docs/DECISIONES.md, 2026-09-24). describe("buildContentSecurityPolicy", () => { const prod = buildContentSecurityPolicy("abc123", false); - it("solo permite scripts propios con el nonce de la petición", () => { + it("only allows own scripts with the request's nonce", () => { expect(prod).toContain("script-src 'self' 'nonce-abc123' 'strict-dynamic'"); expect(prod).not.toContain("'unsafe-inline' 'strict-dynamic'"); }); - it("no permite eval en producción, pero sí en desarrollo", () => { + it("does not allow eval in production, but does in development", () => { expect(prod).not.toContain("unsafe-eval"); expect(buildContentSecurityPolicy("abc123", true)).toContain("'unsafe-eval'"); }); - it("en producción los estilos inline exigen nonce", () => { + it("in production inline styles require a nonce", () => { expect(prod).toContain("style-src-elem 'self' 'nonce-abc123'"); expect(prod).not.toContain("style-src-elem 'self' 'unsafe-inline'"); }); - it("impide embeber la app, plugins y cambiar la base", () => { + it("prevents embedding the app, plugins and changing the base", () => { expect(prod).toContain("frame-ancestors 'none'"); expect(prod).toContain("object-src 'none'"); expect(prod).toContain("base-uri 'self'"); expect(prod).toContain("form-action 'self'"); }); - it("no fuerza HTTPS en los recursos (instalaciones por http://IP)", () => { + it("does not force HTTPS on resources (installations over http://IP)", () => { expect(prod).not.toContain("upgrade-insecure-requests"); }); }); describe("generateNonce", () => { - it("genera 128 bits en base64 y distinto en cada llamada", () => { + it("generates 128 bits in base64, different on every call", () => { const a = generateNonce(); expect(atob(a)).toHaveLength(16); expect(generateNonce()).not.toBe(a); }); }); -describe("proxy (CSP por petición)", () => { - // El matcher de Next es una expresión regular sobre la ruta. +describe("proxy (per-request CSP)", () => { + // Next's matcher is a regular expression over the path. const matches = (path: string) => config.matcher.some((m) => new RegExp(`^${m}$`).test(path)); - it("pasa por las páginas y deja fuera /api, los estáticos y los .svg", () => { + it("runs on pages and leaves out /api, static files and .svg", () => { expect(matches("/")).toBe(true); expect(matches("/login")).toBe(true); expect(matches("/boards/abc")).toBe(true); @@ -57,17 +57,17 @@ describe("proxy (CSP por petición)", () => { expect(matches("/logo.svg")).toBe(false); }); - it("/login recibe la CSP con el mismo nonce que ve el layout", () => { + it("/login gets the CSP with the same nonce the layout sees", () => { const res = proxy(new NextRequest("http://localhost:3000/login")); const csp = res.headers.get("Content-Security-Policy") ?? ""; - // NextResponse.next({ request }) reenvía así las cabeceras de la petición. + // This is how NextResponse.next({ request }) forwards the request headers. const nonce = res.headers.get("x-middleware-request-x-nonce"); expect(nonce).toBeTruthy(); expect(csp).toContain(`'nonce-${nonce}'`); expect(csp).toContain("frame-ancestors 'none'"); }); - it("/ con sesión recibe la CSP; sin sesión redirige a /login", () => { + it("/ with a session gets the CSP; without a session it redirects to /login", () => { const withSession = proxy( new NextRequest("http://localhost:3000/", { headers: { cookie: "session=x" } }), ); diff --git a/test/delete-board-button-focus.test.ts b/test/delete-board-button-focus.test.ts index da5d61e..a31bd5f 100644 --- a/test/delete-board-button-focus.test.ts +++ b/test/delete-board-button-focus.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { ReactElement, ReactNode } from "react"; -// No hay jsdom en el proyecto (ni se monta): para probar la devolución del foco se -// sustituyen useState/useRef/useEffect por un arnés mínimo que re-ejecuta el -// componente como función, adjunta las refs a botones falsos y corre los efectos cuyas -// dependencias cambian, en el mismo orden que React (render → refs → efectos). +// The project has no jsdom (and does not set one up): to test focus restoration, +// useState/useRef/useEffect are replaced with a minimal harness that re-runs the +// component as a function, attaches the refs to fake buttons and runs the effects whose +// dependencies change, in the same order as React (render → refs → effects). type Slot = unknown; const slots: Slot[] = []; let cursor = 0; @@ -41,6 +41,12 @@ vi.mock("react", async (importOriginal) => { }; }); vi.mock("@/app/lib/actions/boards", () => ({ deleteBoard: vi.fn() })); +// Without a React tree there is no context for useI18n(): it returns English directly. +vi.mock("@/app/i18n/client", async () => { + const { messagesFor } = await import("@/app/i18n/config"); + const m = messagesFor("en"); + return { useI18n: () => ({ locale: "en", intl: "en", m }) }; +}); const { DeleteBoardButton } = await import( "@/app/(app)/boards/delete-board-button" @@ -60,7 +66,7 @@ function render(): Element { title: "Plan", className: "x", }) as Element; - // React desmonta el ✕ al confirmar (ref a null) y lo vuelve a montar al cancelar. + // React unmounts the ✕ when confirming (ref to null) and mounts it again on cancel. const ref = tree.props.ref as { current: unknown } | undefined; if (ref) triggerRef = ref; if (triggerRef) triggerRef.current = ref ? trigger : null; @@ -82,7 +88,7 @@ function findIn(tree: Element, text: string): Element | null { function findByText(tree: Element, text: string): Element { const found = findIn(tree, text); - if (!found) throw new Error(`No se encontró el elemento con el texto «${text}»`); + if (!found) throw new Error(`No element found with the text "${text}"`); return found; } @@ -92,13 +98,13 @@ beforeEach(() => { trigger.focus.mockClear(); }); -describe("DeleteBoardButton: foco al cancelar", () => { - it("al montarse no roba el foco (cada tablero de la rejilla tiene su ✕)", () => { +describe("DeleteBoardButton: focus on cancel", () => { + it("does not steal focus on mount (every board in the grid has its own ✕)", () => { render(); expect(trigger.focus).not.toHaveBeenCalled(); }); - it("cancelar con «No» devuelve el foco al ✕", () => { + it("cancelling with \"No\" returns focus to the ✕", () => { const initial = render(); (initial.props.onClick as () => void)(); const form = render(); @@ -110,7 +116,7 @@ describe("DeleteBoardButton: foco al cancelar", () => { expect(trigger.focus).toHaveBeenCalledTimes(1); }); - it("cancelar con Escape devuelve el foco al ✕", () => { + it("cancelling with Escape returns focus to the ✕", () => { (render().props.onClick as () => void)(); const form = render(); (form.props.onKeyDown as (e: { key: string }) => void)({ key: "Escape" }); @@ -118,7 +124,7 @@ describe("DeleteBoardButton: foco al cancelar", () => { expect(trigger.focus).toHaveBeenCalledTimes(1); }); - it("otra tecla dentro de la confirmación no la cierra ni mueve el foco", () => { + it("another key inside the confirmation neither closes it nor moves focus", () => { (render().props.onClick as () => void)(); const form = render(); (form.props.onKeyDown as (e: { key: string }) => void)({ key: "Tab" }); diff --git a/test/delete-board-button.test.ts b/test/delete-board-button.test.ts index 752a13a..e0af979 100644 --- a/test/delete-board-button.test.ts +++ b/test/delete-board-button.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi } from "vitest"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { withI18n } from "./helpers/i18n"; -// La acción real arrastra Prisma y la sesión; aquí solo importa qué se pinta. +// The real action drags in Prisma and the session; here only what is rendered matters. vi.mock("@/app/lib/actions/boards", () => ({ deleteBoard: vi.fn() })); const { DeleteBoardButton } = await import( @@ -10,19 +11,21 @@ const { DeleteBoardButton } = await import( ); describe("DeleteBoardButton", () => { - it("el primer clic no puede enviar nada: sin formulario ni botón submit", () => { + it("the first click cannot submit anything: no form and no submit button", () => { const html = renderToStaticMarkup( - createElement(DeleteBoardButton, { - boardId: "b1", - title: "Plan", - className: "x", - }), + withI18n( + createElement(DeleteBoardButton, { + boardId: "b1", + title: "Plan", + className: "x", + }), + ), ); - // Antes era <form action={deleteBoard}><button>✕</button></form>: un clic (o un - // toque en la esquina en táctil) borraba el tablero en cascada. + // It used to be <form action={deleteBoard}><button>✕</button></form>: one click (or a + // tap on the corner on touch screens) deleted the board in cascade. expect(html).not.toContain("<form"); expect(html).toContain('type="button"'); expect(html).not.toContain('type="submit"'); - expect(html).toContain('aria-label="Borrar tablero Plan"'); + expect(html).toContain('aria-label="Delete board Plan"'); }); }); diff --git a/test/form-error-reannounce.test.ts b/test/form-error-reannounce.test.ts index a8c8c57..36941c1 100644 --- a/test/form-error-reannounce.test.ts +++ b/test/form-error-reannounce.test.ts @@ -1,15 +1,16 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { createElement, type FunctionComponent } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { withI18n } from "./helpers/i18n"; -// Las acciones de servidor importan Prisma; para renderizar los formularios basta con -// sustituirlas (este test solo mira el HTML, no ejecuta ninguna acción). +// The server actions import Prisma; replacing them is enough to render the forms (this +// test only looks at the HTML, it does not run any action). vi.mock("@/app/lib/actions/admin", () => ({ createUser: vi.fn() })); vi.mock("@/app/lib/actions/tokens", () => ({ createApiToken: vi.fn() })); vi.mock("@/app/lib/actions/boards", () => ({ importBoard: vi.fn() })); -// En el render de servidor useActionState siempre devuelve el estado inicial. Aquí se -// sustituye por un estado con error, y se marca pending cuando el test lo pide. +// In a server render useActionState always returns the initial state. Here it is replaced +// with a state that has an error, and marked pending when the test asks for it. const actionState = vi.hoisted(() => ({ override: null as unknown, pending: false, @@ -39,50 +40,50 @@ const { ImportBoardForm } = await import("@/app/(app)/boards/import-board-form") const forms: [string, FunctionComponent, object][] = [ [ - "crear usuario", + "create user", CreateUserForm, { - error: "Ya existe una cuenta con ese email.", + error: "An account with that email already exists.", field: "email", - values: { name: "Ana", email: "ana@ejemplo.com", role: "MEMBER" }, + values: { name: "Ana", email: "ana@example.com", role: "MEMBER" }, }, ], [ - "crear token", + "create token", CreateTokenForm, { - error: "El nombre es obligatorio.", + error: "The name is required.", values: { name: "", scope: "read", expiry: "30" }, }, ], - ["importar tablero", ImportBoardForm, { error: "El archivo no es un JSON válido." }], + ["import board", ImportBoardForm, { error: "The file is not valid JSON." }], ]; -describe("formularios con aviso de error — reanuncio de un error repetido", () => { +describe("forms with an error message — re-announcing a repeated error", () => { it.each(forms)( - "%s retira el aviso role=alert mientras se envía", + "%s removes the role=alert message while submitting", (_name, Form, state) => { actionState.override = state; - expect(renderToStaticMarkup(createElement(Form))).toContain('role="alert"'); + expect(renderToStaticMarkup(withI18n(createElement(Form)))).toContain('role="alert"'); - // Si el nodo siguiera montado con el mismo texto, un segundo error idéntico no - // cambiaría el DOM y el lector de pantalla no lo anunciaría. + // If the node stayed mounted with the same text, a second identical error would + // not change the DOM and the screen reader would not announce it. actionState.pending = true; - expect(renderToStaticMarkup(createElement(Form))).not.toContain('role="alert"'); + expect(renderToStaticMarkup(withI18n(createElement(Form)))).not.toContain('role="alert"'); }, ); - it("crear usuario no apunta aria-describedby a un aviso retirado", () => { - // Etiqueta <input> del email, sea cual sea el orden de sus atributos. + it("create user does not point aria-describedby at a removed message", () => { + // The email <input> tag, whatever the order of its attributes. const emailInput = (html: string) => html.match(/<input[^>]*name="email"[^>]*>/)?.[0]; actionState.override = forms[0][2]; - expect(emailInput(renderToStaticMarkup(createElement(CreateUserForm)))).toMatch( + expect(emailInput(renderToStaticMarkup(withI18n(createElement(CreateUserForm))))).toMatch( /aria-describedby="[^"]+"/, ); actionState.pending = true; - expect(emailInput(renderToStaticMarkup(createElement(CreateUserForm)))).not.toMatch( + expect(emailInput(renderToStaticMarkup(withI18n(createElement(CreateUserForm))))).not.toMatch( /aria-describedby=/, ); }); diff --git a/test/format-date.test.ts b/test/format-date.test.ts index a4fff41..051dea7 100644 --- a/test/format-date.test.ts +++ b/test/format-date.test.ts @@ -5,8 +5,8 @@ import { instanceTimeZone, } from "@/app/lib/format-date"; -// El servidor corre en UTC (contenedor). Se fuerza TZ=UTC para demostrar que el formato -// no depende de la zona del proceso sino de ORGTABLE_TIMEZONE. +// The server runs in UTC (container). TZ=UTC is forced to show that the output depends on +// ORGTABLE_TIMEZONE, not on the process time zone. beforeEach(() => { vi.stubEnv("TZ", "UTC"); vi.stubEnv("ORGTABLE_TIMEZONE", ""); @@ -15,25 +15,29 @@ afterEach(() => { vi.unstubAllEnvs(); }); -describe("format-date (zona de la instancia)", () => { - it("por defecto usa Atlantic/Canary: 09:15 UTC en verano son las 10:15", () => { - const text = formatDateTime(new Date("2026-07-15T09:15:00Z")); +describe("format-date (instance time zone)", () => { + it("defaults to Atlantic/Canary: 09:15 UTC in summer is 10:15", () => { + const text = formatDateTime(new Date("2026-07-15T09:15:00Z"), "es-ES"); expect(text).toContain("10:15:00"); expect(text).toContain("15/07/2026"); }); - it("cerca de medianoche UTC la fecha es la del día siguiente en Canarias", () => { - expect(formatDate(new Date("2026-07-15T23:30:00Z"))).toBe("16 jul 2026"); + it("near midnight UTC the date is already the next day in the Canaries", () => { + expect(formatDate(new Date("2026-07-15T23:30:00Z"), "es-ES")).toBe("16 jul 2026"); }); - it("respeta ORGTABLE_TIMEZONE", () => { + it("formats in the reader's language", () => { + expect(formatDate(new Date("2026-07-15T12:00:00Z"), "en")).toBe("Jul 15, 2026"); + }); + + it("honours ORGTABLE_TIMEZONE", () => { vi.stubEnv("ORGTABLE_TIMEZONE", "Europe/Madrid"); expect(instanceTimeZone()).toBe("Europe/Madrid"); - expect(formatDateTime(new Date("2026-07-15T09:15:00Z"))).toContain("11:15:00"); + expect(formatDateTime(new Date("2026-07-15T09:15:00Z"), "es-ES")).toContain("11:15:00"); }); - it("una zona no válida falla con un mensaje claro en vez de mostrar horas falsas", () => { + it("an invalid zone fails with a clear message instead of showing wrong times", () => { vi.stubEnv("ORGTABLE_TIMEZONE", "Canarias/Las_Palmas"); - expect(() => formatDate(new Date())).toThrow(/ORGTABLE_TIMEZONE/); + expect(() => formatDate(new Date(), "en")).toThrow(/ORGTABLE_TIMEZONE/); }); }); diff --git a/test/helpers/i18n.ts b/test/helpers/i18n.ts new file mode 100644 index 0000000..0c39e41 --- /dev/null +++ b/test/helpers/i18n.ts @@ -0,0 +1,18 @@ +import { createElement, type ComponentProps, type ReactNode } from "react"; +import { I18nProvider } from "@/app/i18n/client"; +import { messagesFor } from "@/app/i18n/config"; + +// Shared by the unit tests that render Client Components using useI18n(). English is the +// default language, so assertions on visible text are written in English. + +const en = messagesFor("en"); + +/** Wraps a tree in the same provider the root layout uses, with English messages. */ +export function withI18n(children: ReactNode) { + return createElement( + I18nProvider, + // `children` goes as the third argument; the props type requires it anyway. + { locale: "en", intl: "en", messages: en } as ComponentProps<typeof I18nProvider>, + children, + ); +} diff --git a/test/i18n.test.ts b/test/i18n.test.ts new file mode 100644 index 0000000..a70e815 --- /dev/null +++ b/test/i18n.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { LANGUAGES } from "@/locales"; +import { messagesFor, negotiateLocale } from "@/app/i18n/config"; +import { fmt, plural, messageAt } from "@/app/i18n/format"; + +// Translation checks (CI). English is the reference: every registered language must have +// the same messages, with the same {placeholders} and <tags>, and plural forms for the +// categories its grammar uses. How to add a language: locales/README.md. + +type Tree = { [key: string]: unknown }; + +function leaves(tree: unknown, prefix = ""): Map<string, unknown> { + const out = new Map<string, unknown>(); + if (!tree || typeof tree !== "object") return out; + for (const [key, value] of Object.entries(tree as Tree)) { + const path = prefix ? `${prefix}.${key}` : key; + if (isPlural(value) || typeof value === "string") out.set(path, value); + else for (const [k, v] of leaves(value, path)) out.set(k, v); + } + return out; +} + +function isPlural(value: unknown): value is Record<string, string> { + return ( + !!value && + typeof value === "object" && + "other" in value && + Object.values(value).every((v) => typeof v === "string") + ); +} + +/** Placeholders and tags of a message (or of all forms of a plural), sorted. */ +function tokens(value: unknown): string[] { + const texts = typeof value === "string" ? [value] : Object.values(value as Tree); + const found = new Set<string>(); + for (const t of texts) { + for (const m of String(t).matchAll(/\{(\w+)\}|<\/?(\w+)>/g)) found.add(m[0]); + } + return [...found].filter((t) => t !== "{count}").sort(); +} + +const reference = leaves(LANGUAGES.en.messages); +const others = Object.entries(LANGUAGES).filter(([code]) => code !== "en"); + +describe.each(others)("language %s", (code, lang) => { + const translated = leaves(lang.messages); + + it("language.json matches its folder and has a valid Intl tag", () => { + expect(lang.language.code).toBe(code); + expect(lang.language.name).not.toBe(""); + expect(() => new Intl.PluralRules(lang.language.intl)).not.toThrow(); + }); + + it("has every English message", () => { + const missing = [...reference.keys()].filter((k) => !translated.has(k)); + expect(missing, `Missing in locales/${code}/`).toEqual([]); + }); + + it("has no messages that English does not have", () => { + const extra = [...translated.keys()].filter((k) => !reference.has(k)); + expect(extra, `Unknown keys in locales/${code}/ (renamed or removed in English?)`).toEqual([]); + }); + + it("keeps the same {placeholders} and <tags>", () => { + const mismatched = [...reference.entries()] + .filter(([k]) => translated.has(k)) + .filter(([k, v]) => tokens(v).join() !== tokens(translated.get(k)).join()) + .map(([k]) => k); + expect(mismatched).toEqual([]); + }); + + it("plurals have the forms this language needs", () => { + const rules = new Intl.PluralRules(lang.language.intl); + const needed = new Set(Array.from({ length: 201 }, (_, n) => rules.select(n))); + const incomplete = [...reference.entries()] + .filter(([, v]) => isPlural(v)) + .filter(([k]) => { + const forms = translated.get(k); + return !isPlural(forms) || [...needed].some((c) => !(c in forms)); + }) + .map(([k]) => k); + expect(incomplete).toEqual([]); + }); +}); + +describe("i18n helpers", () => { + it("negotiates the language from Accept-Language with q-values", () => { + expect(negotiateLocale("es-ES,es;q=0.9,en;q=0.8")).toBe("es"); + expect(negotiateLocale("fr-FR,fr;q=0.9,en;q=0.5")).toBe("en"); + expect(negotiateLocale("de;q=0.9,es;q=0.4")).toBe("es"); + expect(negotiateLocale("")).toBe("en"); + expect(negotiateLocale(null)).toBe("en"); + }); + + it("fills placeholders and picks plural forms", () => { + expect(fmt("Hello {name}", { name: "Ana" })).toBe("Hello Ana"); + expect(fmt("Hello {name}")).toBe("Hello {name}"); + const forms = { one: "{count} card", other: "{count} cards" }; + expect(plural("en", forms, 1)).toBe("1 card"); + expect(plural("en", forms, 3)).toBe("3 cards"); + }); + + it("resolves message keys and falls back to English for missing translations", () => { + const es = messagesFor("es"); + expect(messageAt(es, "common.actions.save")).toBe("Guardar"); + expect(messageAt(es, "common.nope")).toBeUndefined(); + expect(messageAt(es, "constructor")).toBeUndefined(); + }); +}); diff --git a/test/initials.test.ts b/test/initials.test.ts new file mode 100644 index 0000000..3eee8dd --- /dev/null +++ b/test/initials.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from "vitest"; +import { initials } from "@/app/lib/initials"; + +describe("initials", () => { + it.each([ + ["Lucía Martín", "LM"], + ["ana", "A"], + [" Óscar de la Fuente ", "ÓD"], + ["", "?"], + ])("%j → %s", (name, expected) => { + expect(initials(name)).toBe(expected); + }); +}); diff --git a/test/integration/account.int.test.ts b/test/integration/account.int.test.ts new file mode 100644 index 0000000..e606adf --- /dev/null +++ b/test/integration/account.int.test.ts @@ -0,0 +1,335 @@ +import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; +import { access, rm } from "node:fs/promises"; +import path from "node:path"; + +// Temporary upload directory: storage.ts reads it when imported (see +// attachments-delete.int.test.ts). +const uploadDir = await vi.hoisted(async () => { + const { mkdtempSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const dir = mkdtempSync(join(tmpdir(), "orgtable-account-")); + process.env.ORGTABLE_UPLOAD_DIR = dir; + return dir; +}); + +import { prisma } from "@/app/lib/prisma"; +import { saveAttachmentFile } from "@/app/lib/storage"; +import { verifyPassword } from "@/app/lib/auth"; +import { verifySession } from "@/app/lib/dal"; +import { + changePassword, + deleteAccount, + revokeOtherSessions, + revokeSession, + updateProfile, +} from "@/app/lib/actions/account"; +import { exportUserData } from "@/app/lib/services/account"; +import { GET as exportRoute } from "@/app/api/account/export/route"; +import { fmt } from "@/app/i18n/format"; +import { + resetDb, + disconnect, + seedUser, + seedBoard, + seedList, + seedCard, + seedApiToken, + addMember, + loginAs, + en, +} from "./helpers"; + +const PASSWORD = "contraseña-segura"; + +beforeEach(async () => { + await resetDb(); +}); +afterAll(async () => { + await rm(uploadDir, { recursive: true, force: true }); + delete process.env.ORGTABLE_UPLOAD_DIR; + await disconnect(); +}); + +function form(fields: Record<string, string>): FormData { + const fd = new FormData(); + for (const [k, v] of Object.entries(fields)) fd.set(k, v); + return fd; +} + +async function redirectOf(run: Promise<unknown>): Promise<string> { + try { + await run; + } catch (e) { + const digest = (e as { digest?: string }).digest; + if (digest?.startsWith("NEXT_REDIRECT")) return digest.split(";")[2]; + throw e; + } + throw new Error("The action did not redirect"); +} + +async function fileExists(filePath: string): Promise<boolean> { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +/** Opens another session of the same user (another browser) without touching the current cookie. */ +async function openOtherSession(userId: string): Promise<string> { + const s = await prisma.session.create({ + data: { userId, expiresAt: new Date(Date.now() + 86_400_000), userAgent: "other" }, + }); + return s.id; +} + +describe("profile (updateProfile)", () => { + it("changes the name without asking for the password", async () => { + const user = await seedUser({ email: "me@test.local" }); + await loginAs(user.id); + const state = await updateProfile( + {}, + form({ name: "New Name", email: "me@test.local", currentPassword: "" }), + ); + expect(state.message).toBe(en.errors.account.profileSaved); + expect((await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).name).toBe( + "New Name", + ); + }); + + it("changing the email requires the current password", async () => { + const user = await seedUser({ email: "me@test.local" }); + await loginAs(user.id); + const state = await updateProfile( + {}, + form({ name: "Me", email: "other@test.local", currentPassword: "wrong" }), + ); + expect(state.field).toBe("currentPassword"); + expect((await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).email).toBe( + "me@test.local", + ); + + const ok = await updateProfile( + {}, + form({ name: "Me", email: "Other@Test.local", currentPassword: PASSWORD }), + ); + expect(ok.message).toBe(en.errors.account.profileSaved); + expect((await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).email).toBe( + "other@test.local", + ); + }); + + it("does not allow taking another account's email", async () => { + await seedUser({ email: "taken@test.local" }); + const user = await seedUser({ email: "me@test.local" }); + await loginAs(user.id); + const state = await updateProfile( + {}, + form({ name: "Me", email: "taken@test.local", currentPassword: PASSWORD }), + ); + expect(state.field).toBe("email"); + }); +}); + +describe("password and sessions", () => { + it("changes the password, signs out the other sessions and keeps the current one", async () => { + const user = await seedUser(); + await loginAs(user.id); + const current = (await verifySession())!.sessionId; + const other = await openOtherSession(user.id); + + const state = await changePassword( + {}, + form({ + currentPassword: PASSWORD, + newPassword: "another-long-password", + confirmPassword: "another-long-password", + }), + ); + expect(state.message).toBe( + fmt(en.errors.account.passwordChangedSessionsClosed.one, { count: 1 }), + ); + + const u = await prisma.user.findUniqueOrThrow({ where: { id: user.id } }); + expect(await verifyPassword("another-long-password", u.passwordHash)).toBe(true); + expect((await prisma.session.findUniqueOrThrow({ where: { id: other } })).revokedAt).not.toBeNull(); + expect((await prisma.session.findUniqueOrThrow({ where: { id: current } })).revokedAt).toBeNull(); + }); + + it("rejects a wrong current password and a confirmation that does not match", async () => { + const user = await seedUser(); + await loginAs(user.id); + const wrong = await changePassword( + {}, + form({ currentPassword: "wrong", newPassword: "new-long-one", confirmPassword: "new-long-one" }), + ); + expect(wrong.field).toBe("currentPassword"); + const mismatch = await changePassword( + {}, + form({ currentPassword: PASSWORD, newPassword: "new-long-one", confirmPassword: "something-else" }), + ); + expect(mismatch.field).toBe("confirmPassword"); + }); + + it("revokes one of your own sessions but not someone else's", async () => { + const user = await seedUser(); + const stranger = await seedUser(); + const mine = await openOtherSession(user.id); + const theirs = await openOtherSession(stranger.id); + await loginAs(user.id); + + expect(await redirectOf(revokeSession(form({ sessionId: theirs })))).toBe( + "/settings#security", + ); + expect((await prisma.session.findUniqueOrThrow({ where: { id: theirs } })).revokedAt).toBeNull(); + + await redirectOf(revokeSession(form({ sessionId: mine }))); + expect((await prisma.session.findUniqueOrThrow({ where: { id: mine } })).revokedAt).not.toBeNull(); + }); + + it("“sign out the others” leaves only the current one open", async () => { + const user = await seedUser(); + await loginAs(user.id); + await openOtherSession(user.id); + await openOtherSession(user.id); + await redirectOf(revokeOtherSessions()); + expect(await prisma.session.count({ where: { userId: user.id, revokedAt: null } })).toBe(1); + expect(await verifySession()).not.toBeNull(); + }); +}); + +describe("export my data", () => { + it("includes your own data and what you wrote on other people's boards, without hashes", async () => { + const owner = await seedUser({ email: "owner@test.local" }); + const me = await seedUser({ email: "me@test.local", name: "Me" }); + const myBoard = await seedBoard(me.id, "My board"); + const myList = await seedList(myBoard.id); + await seedCard(myList.id, "My card"); + const theirBoard = await seedBoard(owner.id, "Their board"); + await addMember(theirBoard.id, me.id, "EDITOR"); + const theirCard = await seedCard((await seedList(theirBoard.id)).id, "Their card"); + await prisma.comment.create({ + data: { cardId: theirCard.id, authorId: me.id, text: "My comment" }, + }); + await seedApiToken(me.id, { name: "script" }); + + const data = await exportUserData(me.id); + expect(data?.account).toMatchObject({ email: "me@test.local", name: "Me" }); + expect(data?.ownedBoards.map((b) => b.title)).toEqual(["My board"]); + expect(data?.ownedBoards[0].lists[0].cards[0].title).toBe("My card"); + expect(data?.memberships.map((m) => m.boardTitle).sort()).toEqual([ + "My board", + "Their board", + ]); + expect(data?.comments).toEqual([ + expect.objectContaining({ text: "My comment", cardTitle: "Their card" }), + ]); + expect(data?.apiTokens).toEqual([expect.objectContaining({ name: "script" })]); + + const json = JSON.stringify(data); + expect(json).not.toContain("passwordHash"); + expect(json).not.toContain("tokenHash"); + // Nothing from the other board that she did not write herself. + expect(json).not.toContain("owner@test.local"); + }); + + it("the route requires a session, downloads the JSON and audits it", async () => { + const user = await seedUser(); + expect((await exportRoute()).status).toBe(401); + + await loginAs(user.id); + const res = await exportRoute(); + expect(res.status).toBe(200); + expect(res.headers.get("content-disposition")).toMatch(/attachment; filename="orgtable-my-data-/); + expect(res.headers.get("cache-control")).toBe("private, no-store"); + expect((await res.json()).account.id).toBe(user.id); + expect(await prisma.auditLog.count({ where: { action: "account.exported" } })).toBe(1); + }); +}); + +describe("delete my account (deleteAccount)", () => { + it("deletes the account, its own boards with their files, and its comments; other boards stay", async () => { + const other = await seedUser(); + const me = await seedUser({ email: "me@test.local" }); + const myBoard = await seedBoard(me.id); + const myCard = await seedCard((await seedList(myBoard.id)).id); + const { storedName, size } = await saveAttachmentFile( + new File(["content"], "note.txt", { type: "text/plain" }), + ); + await prisma.attachment.create({ + data: { cardId: myCard.id, filename: "note.txt", storedName, mimeType: "text/plain", size }, + }); + const filePath = path.join(uploadDir, storedName); + + const theirBoard = await seedBoard(other.id); + await addMember(theirBoard.id, me.id); + const theirCard = await seedCard((await seedList(theirBoard.id)).id); + await prisma.comment.create({ data: { cardId: theirCard.id, authorId: me.id, text: "hello" } }); + // Audit row about this account with its email in `detail` (like user.created). + await prisma.auditLog.create({ + data: { action: "user.created", targetType: "user", targetId: me.id, detail: "yo@test.local (MEMBER)" }, + }); + + await loginAs(me.id); + expect(await redirectOf(deleteAccount({}, form({ password: PASSWORD })))).toBe( + "/login?account=deleted", + ); + + expect(await prisma.user.findUnique({ where: { id: me.id } })).toBeNull(); + expect(await prisma.board.findUnique({ where: { id: myBoard.id } })).toBeNull(); + expect(await fileExists(filePath)).toBe(false); + expect(await prisma.board.findUnique({ where: { id: theirBoard.id } })).not.toBeNull(); + expect(await prisma.comment.count()).toBe(0); + expect(await prisma.session.count({ where: { userId: me.id } })).toBe(0); + const auditDetails = (await prisma.auditLog.findMany()).map((a) => a.detail ?? ""); + expect(auditDetails.join(" ")).not.toContain("me@test.local"); + expect(await prisma.auditLog.count({ where: { action: "account.deleted", userId: null } })).toBe(1); + }); + + it("deletes nothing with a wrong password", async () => { + const me = await seedUser(); + await seedBoard(me.id); + await loginAs(me.id); + const state = await deleteAccount({}, form({ password: "wrong" })); + expect(state.field).toBe("password"); + expect(await prisma.user.count()).toBe(1); + expect(await prisma.board.count()).toBe(1); + }); + + it("the only ADMIN account cannot be deleted; with another admin, it can", async () => { + const admin = await seedUser({ role: "ADMIN" }); + await loginAs(admin.id); + const state = await deleteAccount({}, form({ password: PASSWORD })); + expect(state.error).toBe(en.errors.account.lastAdmin); + expect(await prisma.user.count()).toBe(1); + + await seedUser({ role: "ADMIN" }); + expect(await redirectOf(deleteAccount({}, form({ password: PASSWORD })))).toBe( + "/login?account=deleted", + ); + expect(await prisma.user.count({ where: { role: "ADMIN" } })).toBe(1); + }); + + it("two admins deleting themselves at once do not leave the instance without an admin", async () => { + const a = await seedUser({ role: "ADMIN" }); + const b = await seedUser({ role: "ADMIN" }); + const { deleteOwnAccount } = await import("@/app/lib/services/account"); + const results = await Promise.all([ + deleteOwnAccount(a.id, PASSWORD), + deleteOwnAccount(b.id, PASSWORD), + ]); + expect(results.filter((r) => r.ok)).toHaveLength(1); + expect(results.filter((r) => !r.ok && r.error === "last_admin")).toHaveLength(1); + expect(await prisma.user.count({ where: { role: "ADMIN" } })).toBe(1); + }); + + it("after deleting it, the cookie is removed and the session no longer exists", async () => { + const me = await seedUser(); + await loginAs(me.id); + await redirectOf(deleteAccount({}, form({ password: PASSWORD }))); + expect(await verifySession()).toBeNull(); + expect(await prisma.session.count()).toBe(0); + }); +}); diff --git a/test/integration/admin-revocation.int.test.ts b/test/integration/admin-revocation.int.test.ts index 5c02cce..b5ce26c 100644 --- a/test/integration/admin-revocation.int.test.ts +++ b/test/integration/admin-revocation.int.test.ts @@ -18,8 +18,8 @@ afterAll(async () => { await disconnect(); }); -describe("restablecer contraseña retira todo el acceso (integración)", () => { - it("revoca los tokens de API: el mismo Bearer pasa de 200 a 401", async () => { +describe("resetting a password removes all access (integration)", () => { + it("revokes the API tokens: the same Bearer goes from 200 to 401", async () => { const victim = await seedUser(); const other = await seedUser(); const token = await seedApiToken(victim.id); @@ -32,7 +32,7 @@ describe("restablecer contraseña retira todo el acceso (integración)", () => { const result = await resetPasswordAndRevokeAccess( victim.id, - "nueva-contraseña-larga", + "new-long-password", ); expect(result).toEqual({ ok: true, revokedTokens: 1 }); @@ -42,34 +42,34 @@ describe("restablecer contraseña retira todo el acceso (integración)", () => { expect(after.status).toBe(401); expect(await prisma.apiToken.count({ where: { userId: victim.id } })).toBe(0); - // Los tokens de otros usuarios no se tocan. + // Other users' tokens are left alone. const otherRes = await listBoards( apiRequest("GET", "/api/v1/boards", { token: otherToken }), ); expect(otherRes.status).toBe(200); }); - it("cambia la contraseña y revoca las sesiones activas", async () => { - const user = await seedUser({ password: "contraseña-antigua" }); + it("changes the password and revokes the active sessions", async () => { + const user = await seedUser({ password: "old-password" }); await prisma.session.create({ data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, }); - await resetPasswordAndRevokeAccess(user.id, "contraseña-nueva"); + await resetPasswordAndRevokeAccess(user.id, "new-password"); const row = await prisma.user.findUniqueOrThrow({ where: { id: user.id } }); - expect(await verifyPassword("contraseña-nueva", row.passwordHash)).toBe(true); + expect(await verifyPassword("new-password", row.passwordHash)).toBe(true); expect( await prisma.session.count({ where: { userId: user.id, revokedAt: null } }), ).toBe(0); }); - it("con un usuario inexistente no cambia nada", async () => { + it("changes nothing for a user that does not exist", async () => { const other = await seedUser(); await seedApiToken(other.id); const result = await resetPasswordAndRevokeAccess( - "no-existe", - "contraseña-nueva", + "does-not-exist", + "new-password", ); expect(result).toEqual({ ok: false, error: "not_found" }); expect(await prisma.apiToken.count()).toBe(1); diff --git a/test/integration/admin-users.int.test.ts b/test/integration/admin-users.int.test.ts index 8268b5b..9f474cb 100644 --- a/test/integration/admin-users.int.test.ts +++ b/test/integration/admin-users.int.test.ts @@ -16,11 +16,12 @@ import { seedList, seedCard, loginAs, + en, } from "./helpers"; -// Las acciones responden con redirect(): se sustituye por uno que lanza un error con la -// URL, igual que el real interrumpe la acción, para poder leer a dónde redirige. La -// autorización (requireAdmin) es la real, con la sesión de un admin sembrado. +// The actions answer with redirect(): it is replaced by one that throws an error with the +// URL, just as the real one interrupts the action, so the target can be read. The +// authorization (requireAdmin) is the real one, with the session of a seeded admin. class RedirectError extends Error {} vi.mock("next/navigation", () => ({ redirect: (url: string) => { @@ -34,7 +35,7 @@ function form(fields: Record<string, string>): FormData { return fd; } -/** Ejecuta la acción y devuelve la URL a la que redirige. */ +/** Runs the action and returns the URL it redirects to. */ async function redirectOf(run: Promise<void>): Promise<string> { try { await run; @@ -42,7 +43,7 @@ async function redirectOf(run: Promise<void>): Promise<string> { if (err instanceof RedirectError) return err.message; throw err; } - throw new Error("La acción no redirigió"); + throw new Error("The action did not redirect"); } beforeEach(async () => { @@ -53,10 +54,10 @@ afterAll(async () => { await disconnect(); }); -describe("acciones de administración con un usuario inexistente (integración)", () => { - const ghost = "usuario-que-no-existe"; +describe("admin actions on a user that does not exist (integration)", () => { + const ghost = "user-that-does-not-exist"; - it("cambiar el rol avisa y no audita", async () => { + it("changing the role reports it and does not audit", async () => { const url = await redirectOf( updateUserRole(form({ userId: ghost, role: "ADMIN" })), ); @@ -64,74 +65,74 @@ describe("acciones de administración con un usuario inexistente (integración)" expect(await prisma.auditLog.count({ where: { targetId: ghost } })).toBe(0); }); - it("restablecer la contraseña avisa y no audita", async () => { + it("resetting the password reports it and does not audit", async () => { const url = await redirectOf( - resetUserPassword(form({ userId: ghost, password: "contraseña-nueva" })), + resetUserPassword(form({ userId: ghost, password: "new-password" })), ); expect(url).toBe("/admin/users?error=not-found"); expect(await prisma.auditLog.count({ where: { targetId: ghost } })).toBe(0); }); - it("cerrar sesiones avisa y no audita", async () => { + it("revoking sessions reports it and does not audit", async () => { const url = await redirectOf(revokeUserSessions(form({ userId: ghost }))); expect(url).toBe("/admin/users?error=not-found"); expect(await prisma.auditLog.count({ where: { targetId: ghost } })).toBe(0); }); - it("borrar avisa y no audita", async () => { + it("deleting reports it and does not audit", async () => { const url = await redirectOf(deleteUser(form({ userId: ghost }))); expect(url).toBe("/admin/users?error=not-found"); expect(await prisma.auditLog.count({ where: { targetId: ghost } })).toBe(0); }); - it("sin userId en el formulario responde «invalid»", async () => { + it("answers “invalid” without a userId in the form", async () => { const url = await redirectOf(revokeUserSessions(form({}))); expect(url).toBe("/admin/users?error=invalid"); }); - it("restablecer la contraseña sin userId responde «invalid», no el aviso de contraseña", async () => { + it("resetting the password without a userId answers “invalid”, not the password warning", async () => { const url = await redirectOf( - resetUserPassword(form({ password: "contraseña-nueva" })), + resetUserPassword(form({ password: "new-password" })), ); expect(url).toBe("/admin/users?error=invalid"); }); }); -describe("createUser conserva lo escrito tras un error (integración)", () => { - it("con un email duplicado devuelve los valores, marca el campo y nunca la contraseña", async () => { +describe("createUser keeps what was typed after an error (integration)", () => { + it("with a duplicate email returns the values, flags the field and never the password", async () => { await seedUser({ email: "dup@test.local" }); const state = await createUser( {}, form({ name: "Ada", email: "dup@test.local", - password: "contraseña-secreta", + password: "secret-password", role: "ADMIN", }), ); - expect(state.error).toBe("Ya existe un usuario con ese email."); + expect(state.error).toBe(en.errors.admin.emailTaken); expect(state.field).toBe("email"); expect(state.values).toEqual({ name: "Ada", email: "dup@test.local", role: "ADMIN", }); - expect(JSON.stringify(state)).not.toContain("contraseña-secreta"); + expect(JSON.stringify(state)).not.toContain("secret-password"); }); - it("con datos no válidos también devuelve los valores (sin contraseña)", async () => { + it("with invalid data it also returns the values (without the password)", async () => { const state = await createUser( {}, - form({ name: "Ada", email: "no-es-email", password: "corta", role: "MEMBER" }), + form({ name: "Ada", email: "not-an-email", password: "short", role: "MEMBER" }), ); expect(state.error).toBeDefined(); - expect(state.values?.email).toBe("no-es-email"); - expect(JSON.stringify(state)).not.toContain("corta"); + expect(state.values?.email).toBe("not-an-email"); + expect(JSON.stringify(state)).not.toContain("short"); }); }); -describe("acciones de administración con un usuario real (integración)", () => { - it("cerrar sesiones de un usuario sin sesiones es válido y se audita", async () => { +describe("admin actions on a real user (integration)", () => { + it("revoking the sessions of a user without sessions is valid and audited", async () => { const user = await seedUser(); const url = await redirectOf(revokeUserSessions(form({ userId: user.id }))); expect(url).toBe("/admin/users?ok=revoked"); @@ -142,7 +143,7 @@ describe("acciones de administración con un usuario real (integración)", () => ).toBe(1); }); - it("cambiar el rol lo guarda y lo audita", async () => { + it("changing the role saves and audits it", async () => { const user = await seedUser(); const url = await redirectOf( updateUserRole(form({ userId: user.id, role: "ADMIN" })), @@ -153,7 +154,7 @@ describe("acciones de administración con un usuario real (integración)", () => expect(await prisma.auditLog.count({ where: { targetId: user.id } })).toBe(1); }); - it("borra un usuario sin tableros propios y lo audita", async () => { + it("deletes a user without boards of their own and audits it", async () => { const user = await seedUser(); const url = await redirectOf(deleteUser(form({ userId: user.id }))); expect(url).toBe("/admin/users?ok=delete"); @@ -165,7 +166,7 @@ describe("acciones de administración con un usuario real (integración)", () => ).toBe(1); }); - it("no borra a un propietario de tableros: sus adjuntos siguen en BD (nada queda huérfano)", async () => { + it("does not delete a board owner: their attachments stay in the DB (nothing is orphaned)", async () => { const owner = await seedUser(); const board = await seedBoard(owner.id); const list = await seedList(board.id); @@ -173,7 +174,7 @@ describe("acciones de administración con un usuario real (integración)", () => await prisma.attachment.create({ data: { cardId: card.id, - filename: "informe.pdf", + filename: "report.pdf", storedName: "00000000-0000-0000-0000-000000000000.pdf", mimeType: "application/pdf", size: 10, @@ -188,13 +189,13 @@ describe("acciones de administración con un usuario real (integración)", () => }); }); -// La acción no comprueba los tableros por su cuenta: la única salvaguarda es la -// condición `boards: { none: {} }` del DELETE del servicio. Se llama al servicio -// directamente para fijar ese contrato. La carrera en sí (un tablero creado entre una -// comprobación previa y el DELETE) no se reproduce: no hay forma determinista de -// intercalar dos transacciones aquí. -describe("deleteUserAccount (servicio, integración)", () => { - it("rechaza a un propietario de tableros sin borrar nada en cascada", async () => { +// The action does not check the boards on its own: the only safeguard is the +// `boards: { none: {} }` condition of the service's DELETE. The service is called +// directly to pin that contract. The race itself (a board created between an earlier +// check and the DELETE) is not reproduced: there is no deterministic way to interleave +// two transactions here. +describe("deleteUserAccount (service, integration)", () => { + it("rejects a board owner without cascading any deletion", async () => { const owner = await seedUser(); const board = await seedBoard(owner.id); const list = await seedList(board.id); @@ -202,7 +203,7 @@ describe("deleteUserAccount (servicio, integración)", () => { await prisma.attachment.create({ data: { cardId: card.id, - filename: "informe.pdf", + filename: "report.pdf", storedName: "00000000-0000-0000-0000-000000000001.pdf", mimeType: "application/pdf", size: 10, @@ -218,8 +219,8 @@ describe("deleteUserAccount (servicio, integración)", () => { expect(await prisma.attachment.count()).toBe(1); }); - it("distingue un usuario inexistente y borra uno sin tableros", async () => { - expect(await deleteUserAccount("usuario-que-no-existe")).toEqual({ + it("tells apart a user that does not exist and deletes one without boards", async () => { + expect(await deleteUserAccount("user-that-does-not-exist")).toEqual({ ok: false, error: "not_found", }); diff --git a/test/integration/api-auth.int.test.ts b/test/integration/api-auth.int.test.ts index f167ae3..b8e2e6c 100644 --- a/test/integration/api-auth.int.test.ts +++ b/test/integration/api-auth.int.test.ts @@ -19,8 +19,8 @@ afterAll(async () => { await disconnect(); }); -describe("authenticateApiToken — marca de último uso (integración)", () => { - it("actualiza lastUsedAt en cada petición autenticada", async () => { +describe("authenticateApiToken — last-used mark (integration)", () => { + it("updates lastUsedAt on every authenticated request", async () => { const user = await seedUser(); const token = await seedApiToken(user.id); @@ -34,14 +34,14 @@ describe("authenticateApiToken — marca de último uso (integración)", () => { expect(row.lastUsedAt).not.toBeNull(); }); - it("si falla la escritura, autentica igual y lo registra sin el token", async () => { + it("if the write fails, it still authenticates and logs it without the token", async () => { const user = await seedUser(); const token = await seedApiToken(user.id); const row = await prisma.apiToken.findFirstOrThrow({ where: { userId: user.id }, }); vi.spyOn(prisma.apiToken, "update").mockRejectedValueOnce( - new Error("BD caída"), + new Error("DB down"), ); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -51,10 +51,10 @@ describe("authenticateApiToken — marca de último uso (integración)", () => { expect(auth).toEqual({ userId: user.id, scope: "full" }); expect(errorSpy).toHaveBeenCalledWith( - "[api-auth] no se pudo actualizar lastUsedAt", - { tokenId: row.id, error: "BD caída" }, + "[api-auth] could not update lastUsedAt", + { tokenId: row.id, error: "DB down" }, ); - // Ni el token en claro ni su hash aparecen en el registro. + // Neither the plain token nor its hash appear in the log. const logged = JSON.stringify(errorSpy.mock.calls); expect(logged).not.toContain(token); expect(logged).not.toContain(row.tokenHash); diff --git a/test/integration/api-scopes.int.test.ts b/test/integration/api-scopes.int.test.ts index 8e12126..1347bfe 100644 --- a/test/integration/api-scopes.int.test.ts +++ b/test/integration/api-scopes.int.test.ts @@ -27,8 +27,8 @@ afterAll(async () => { await disconnect(); }); -describe("API v1 — scope y caducidad de tokens (integración)", () => { - it("un token de solo lectura puede LEER pero no ESCRIBIR (403)", async () => { +describe("API v1 — token scope and expiry (integration)", () => { + it("a read-only token can READ but not WRITE (403)", async () => { const user = await seedUser(); const token = await seedApiToken(user.id, { scope: "read" }); @@ -43,7 +43,7 @@ describe("API v1 — scope y caducidad de tokens (integración)", () => { expect(write.status).toBe(403); }); - it("un token con scope completo sí puede escribir", async () => { + it("a token with full scope can write", async () => { const user = await seedUser(); const token = await seedApiToken(user.id, { scope: "full" }); const res = await createBoard( @@ -52,10 +52,10 @@ describe("API v1 — scope y caducidad de tokens (integración)", () => { expect(res.status).toBe(201); }); - it("un token caducado se rechaza (401)", async () => { + it("an expired token is rejected (401)", async () => { const user = await seedUser(); const token = await seedApiToken(user.id, { - expiresAt: new Date(Date.now() - 1000), // ya caducó + expiresAt: new Date(Date.now() - 1000), // already expired }); const res = await listBoards( apiRequest("GET", "/api/v1/boards", { token }), @@ -64,7 +64,7 @@ describe("API v1 — scope y caducidad de tokens (integración)", () => { }); }); -// Foto de todo lo que podría cambiar un handler de escritura. +// Snapshot of everything a write handler could change. async function snapshot() { const [boards, lists, cards] = await Promise.all([ prisma.board.findMany({ orderBy: { id: "asc" }, select: { id: true, title: true } }), @@ -79,14 +79,14 @@ async function snapshot() { type Ids = { boardId: string; listId: string; cardId: string }; -// Cada handler de escritura, con un cuerpo VÁLIDO: el 403 debe venir del scope, no de la -// validación. +// Every write handler, with a VALID body: the 403 must come from the scope, not from +// validation. const WRITE_HANDLERS: [string, (token: string, ids: Ids) => Promise<Response>][] = [ [ "PATCH /boards/:id", (token, { boardId }) => patchBoard( - apiRequest("PATCH", `/api/v1/boards/${boardId}`, { token, body: { title: "Nuevo" } }), + apiRequest("PATCH", `/api/v1/boards/${boardId}`, { token, body: { title: "New" } }), ctx({ id: boardId }), ), ], @@ -118,7 +118,7 @@ const WRITE_HANDLERS: [string, (token: string, ids: Ids) => Promise<Response>][] "PATCH /cards/:id", (token, { cardId }) => patchCard( - apiRequest("PATCH", `/api/v1/cards/${cardId}`, { token, body: { title: "Nueva" } }), + apiRequest("PATCH", `/api/v1/cards/${cardId}`, { token, body: { title: "New" } }), ctx({ id: cardId }), ), ], @@ -132,8 +132,8 @@ const WRITE_HANDLERS: [string, (token: string, ids: Ids) => Promise<Response>][] ], ]; -describe("API v1 — un token de solo lectura no escribe en ningún handler", () => { - it.each(WRITE_HANDLERS)("%s da 403 y la BD no cambia", async (_name, call) => { +describe("API v1 — a read-only token writes through no handler", () => { + it.each(WRITE_HANDLERS)("%s gives 403 and the DB does not change", async (_name, call) => { const owner = await seedUser(); const token = await seedApiToken(owner.id, { scope: "read" }); const board = await seedBoard(owner.id); diff --git a/test/integration/attachments-delete.int.test.ts b/test/integration/attachments-delete.int.test.ts index 9dfeafd..2063282 100644 --- a/test/integration/attachments-delete.int.test.ts +++ b/test/integration/attachments-delete.int.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; import { access, readdir, rm } from "node:fs/promises"; import path from "node:path"; -// Directorio de subida temporal: storage.ts lo fija al importarse, así que se define antes -// de cualquier import (vi.hoisted se ejecuta primero). +// Temporary upload directory: storage.ts reads it when imported, so it is set before any +// import (vi.hoisted runs first). const uploadDir = await vi.hoisted(async () => { const { mkdtempSync } = await import("node:fs"); const { tmpdir } = await import("node:os"); @@ -45,13 +45,13 @@ afterAll(async () => { await disconnect(); }); -/** Guarda un fichero real en el directorio temporal y su fila Attachment. */ +/** Saves a real file in the temporary directory, plus its Attachment row. */ async function seedAttachment(cardId: string): Promise<string> { const { storedName, size } = await saveAttachmentFile( - new File(["contenido"], "nota.txt", { type: "text/plain" }), + new File(["content"], "note.txt", { type: "text/plain" }), ); await prisma.attachment.create({ - data: { cardId, filename: "nota.txt", storedName, mimeType: "text/plain", size }, + data: { cardId, filename: "note.txt", storedName, mimeType: "text/plain", size }, }); return path.join(uploadDir, storedName); } @@ -66,7 +66,7 @@ async function fileExists(filePath: string): Promise<boolean> { } } -/** Propietario con sesión, tablero, lista, tarjeta y un adjunto en disco. */ +/** Signed-in owner, board, list, card and an attachment on disk. */ async function seedBoardWithAttachment() { const owner = await seedUser(); const board = await seedBoard(owner.id); @@ -83,9 +83,9 @@ function form(fields: Record<string, string>): FormData { return fd; } -// Regresión: el Cascade borraba las filas Attachment, pero los ficheros se quedaban en el -// volumen para siempre. -describe("borrar tarjeta, lista o tablero borra los ficheros de sus adjuntos", () => { +// Regression: the Cascade deleted the Attachment rows, but the files stayed on the volume +// forever. +describe("deleting a card, list or board deletes its attachment files", () => { it("deleteCard (Server Action)", async () => { const { card, filePath } = await seedBoardWithAttachment(); expect(await fileExists(filePath)).toBe(true); @@ -103,12 +103,12 @@ describe("borrar tarjeta, lista o tablero borra los ficheros de sus adjuntos", ( expect(await fileExists(filePath)).toBe(false); }); - it("deleteBoard (Server Action) borra ficheros y avisa por el bus en vivo", async () => { + it("deleteBoard (Server Action) deletes the files and announces it on the live bus", async () => { const { board, filePath } = await seedBoardWithAttachment(); const events: string[] = []; const unsubscribe = subscribeBoard(board.id, (e) => events.push(e.boardId)); - // deleteBoard termina con redirect("/boards"), que lanza NEXT_REDIRECT. + // deleteBoard ends with redirect("/boards"), which throws NEXT_REDIRECT. await expect(deleteBoard(form({ boardId: board.id }))).rejects.toThrow("NEXT_REDIRECT"); unsubscribe(); @@ -117,7 +117,7 @@ describe("borrar tarjeta, lista o tablero borra los ficheros de sus adjuntos", ( expect(events).toEqual([board.id]); }); - it("deleteBoard de alguien que no es el dueño no borra nada", async () => { + it("deleteBoard by someone who is not the owner deletes nothing", async () => { const { board, filePath } = await seedBoardWithAttachment(); const editor = await seedUser(); await addMember(board.id, editor.id, "EDITOR"); @@ -140,7 +140,7 @@ describe("borrar tarjeta, lista o tablero borra los ficheros de sus adjuntos", ( expect(await fileExists(filePath)).toBe(false); }); - it("DELETE /api/v1/boards/:id borra ficheros y avisa por el bus en vivo", async () => { + it("DELETE /api/v1/boards/:id deletes the files and announces it on the live bus", async () => { const { owner, board, filePath } = await seedBoardWithAttachment(); const token = await seedApiToken(owner.id); const events: string[] = []; @@ -156,7 +156,7 @@ describe("borrar tarjeta, lista o tablero borra los ficheros de sus adjuntos", ( expect(events).toEqual([board.id]); }); - it("un VIEWER recibe 403 y el fichero sigue ahí", async () => { + it("a VIEWER gets 403 and the file is still there", async () => { const { board, card, filePath } = await seedBoardWithAttachment(); const viewer = await seedUser(); await addMember(board.id, viewer.id, "VIEWER"); @@ -173,13 +173,13 @@ describe("borrar tarjeta, lista o tablero borra los ficheros de sus adjuntos", ( }); /** - * Abre una transacción que da de alta un adjunto (con su fichero en disco) y la deja sin - * confirmar, como una subida a medias. Devuelve la ruta del fichero y `commit()`, que la - * confirma y espera a que termine. + * Opens a transaction that inserts an attachment (with its file on disk) and leaves it + * uncommitted, like an upload halfway through. Returns the file path and `commit()`, which + * commits it and waits for it to finish. */ async function pendingUpload(cardId: string) { const { storedName, size } = await saveAttachmentFile( - new File(["tarde"], "tarde.txt", { type: "text/plain" }), + new File(["late"], "late.txt", { type: "text/plain" }), ); let release!: () => void; const gate = new Promise<void>((resolve) => (release = resolve)); @@ -188,7 +188,7 @@ async function pendingUpload(cardId: string) { const tx = prisma.$transaction( async (t) => { await t.attachment.create({ - data: { cardId, filename: "tarde.txt", storedName, mimeType: "text/plain", size }, + data: { cardId, filename: "late.txt", storedName, mimeType: "text/plain", size }, }); inserted(); await gate; @@ -205,7 +205,7 @@ async function pendingUpload(cardId: string) { }; } -/** Espera a que alguna sesión de esta BD esté bloqueada esperando un lock. */ +/** Waits until some session of this DB is blocked waiting for a lock. */ async function waitForLockWait(): Promise<void> { for (let i = 0; i < 200; i++) { const [row] = await prisma.$queryRaw<{ n: bigint }[]>` @@ -214,14 +214,14 @@ async function waitForLockWait(): Promise<void> { if (row.n > BigInt(0)) return; await new Promise((r) => setTimeout(r, 25)); } - throw new Error("ninguna sesión llegó a esperar un lock"); + throw new Error("no session ever waited for a lock"); } -// Regresión: el borrado leía los adjuntos sin bloquear la tarjeta. Una subida confirmada -// entre esa lectura y el delete se borraba en cascada, pero su fichero no estaba en la -// lista leída y quedaba huérfano. -describe("borrado concurrente con una subida en curso", () => { - it("deleteCardWithAttachments espera a la subida y borra también su fichero", async () => { +// Regression: the deletion read the attachments without locking the card. An upload +// committed between that read and the delete was removed by the cascade, but its file was +// not in the list that had been read and was orphaned. +describe("concurrent deletion with an upload in progress", () => { + it("deleteCardWithAttachments waits for the upload and deletes its file too", async () => { const { card, filePath } = await seedBoardWithAttachment(); const upload = await pendingUpload(card.id); @@ -235,7 +235,7 @@ describe("borrado concurrente con una subida en curso", () => { expect(await fileExists(upload.filePath)).toBe(false); }); - it("deleteListWithAttachments espera a la subida y borra también su fichero", async () => { + it("deleteListWithAttachments waits for the upload and deletes its file too", async () => { const { list, card } = await seedBoardWithAttachment(); const upload = await pendingUpload(card.id); @@ -248,7 +248,7 @@ describe("borrado concurrente con una subida en curso", () => { expect(await fileExists(upload.filePath)).toBe(false); }); - it("deleteBoardWithAttachments espera a la subida y borra también su fichero", async () => { + it("deleteBoardWithAttachments waits for the upload and deletes its file too", async () => { const { owner, board, card } = await seedBoardWithAttachment(); const upload = await pendingUpload(card.id); const token = await seedApiToken(owner.id); @@ -267,17 +267,17 @@ describe("borrado concurrente con una subida en curso", () => { }); describe("uploadAttachment", () => { - it("si falla el alta en BD no deja el fichero huérfano", async () => { + it("does not orphan the file if the DB insert fails", async () => { const { card } = await seedBoardWithAttachment(); const before = await readdir(uploadDir); const spy = vi .spyOn(prisma.attachment, "create") - .mockRejectedValueOnce(new Error("fallo simulado de la BD")); + .mockRejectedValueOnce(new Error("simulated DB failure")); const fd = new FormData(); fd.set("cardId", card.id); - fd.set("file", new File(["x"], "otro.txt", { type: "text/plain" })); - await expect(uploadAttachment({}, fd)).rejects.toThrow("fallo simulado de la BD"); + fd.set("file", new File(["x"], "other.txt", { type: "text/plain" })); + await expect(uploadAttachment({}, fd)).rejects.toThrow("simulated DB failure"); spy.mockRestore(); expect((await readdir(uploadDir)).sort()).toEqual(before.sort()); diff --git a/test/integration/attachments-download.int.test.ts b/test/integration/attachments-download.int.test.ts index 38ab97b..aec2810 100644 --- a/test/integration/attachments-download.int.test.ts +++ b/test/integration/attachments-download.int.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; import { chmod, rm } from "node:fs/promises"; import path from "node:path"; -// Directorio de subida temporal (storage.ts lo fija al importarse). +// Temporary upload directory (storage.ts reads it when imported). const uploadDir = await vi.hoisted(async () => { const { mkdtempSync } = await import("node:fs"); const { tmpdir } = await import("node:os"); @@ -40,10 +40,10 @@ async function seedAttachment() { const board = await seedBoard(owner.id); const card = await seedCard((await seedList(board.id)).id); const { storedName, size } = await saveAttachmentFile( - new File(["hola"], "nota.txt", { type: "text/plain" }), + new File(["hello"], "note.txt", { type: "text/plain" }), ); const attachment = await prisma.attachment.create({ - data: { cardId: card.id, filename: "nota.txt", storedName, mimeType: "text/plain", size }, + data: { cardId: card.id, filename: "note.txt", storedName, mimeType: "text/plain", size }, }); return { owner, attachment, filePath: path.join(uploadDir, storedName) }; } @@ -52,35 +52,35 @@ function get(id: string) { return downloadAttachment(new Request(`http://test/api/attachments/${id}`), ctx({ id })); } -describe("descarga de adjuntos", () => { - it("un miembro descarga el fichero (200)", async () => { +describe("attachment download", () => { + it("a member downloads the file (200)", async () => { const { owner, attachment } = await seedAttachment(); await loginAs(owner.id); const res = await get(attachment.id); expect(res.status).toBe(200); - expect(await res.text()).toBe("hola"); + expect(await res.text()).toBe("hello"); }); - // Regresión: un id ajeno daba 403 y uno inexistente 404, lo que revelaba qué ids existen. - it("un no miembro recibe el mismo 404 que un id inexistente", async () => { + // Regression: someone else's id gave 403 and a missing one 404, which revealed which ids exist. + it("a non-member gets the same 404 as a missing id", async () => { const { attachment } = await seedAttachment(); const stranger = await seedUser(); await loginAs(stranger.id); const foreign = await get(attachment.id); - const missing = await get("cminexistente000000000000"); + const missing = await get("cmmissing0000000000000000"); expect(foreign.status).toBe(404); expect(missing.status).toBe(404); expect(await foreign.text()).toBe(await missing.text()); }); - it("sin sesión da 401", async () => { + it("gives 401 without a session", async () => { const { attachment } = await seedAttachment(); expect((await get(attachment.id)).status).toBe(401); }); - it("una fila sin fichero en disco da 404 y se registra como aviso", async () => { + it("a row without a file on disk gives 404 and is logged as a warning", async () => { const { owner, attachment, filePath } = await seedAttachment(); await rm(filePath); await loginAs(owner.id); @@ -92,9 +92,9 @@ describe("descarga de adjuntos", () => { warn.mockRestore(); }); - // Como root, chmod 000 no impide leer: el caso no se puede reproducir y se salta. + // As root, chmod 000 does not prevent reading: the case cannot be reproduced and is skipped. it.skipIf(process.getuid?.() === 0)( - "un error de lectura que no es ENOENT da 500 y se registra", + "a read error other than ENOENT gives 500 and is logged", async () => { const { owner, attachment, filePath } = await seedAttachment(); await chmod(filePath, 0o000); diff --git a/test/integration/audit.int.test.ts b/test/integration/audit.int.test.ts index 78f9db2..0a38caa 100644 --- a/test/integration/audit.int.test.ts +++ b/test/integration/audit.int.test.ts @@ -3,8 +3,8 @@ import { prisma } from "@/app/lib/prisma"; import { recordAudit } from "@/app/lib/audit"; import { resetDb, disconnect } from "./helpers"; -// recordAudit es best-effort: si la escritura falla no debe romper la acción, pero -// tampoco perder el evento en silencio. El log no lleva detail, ip ni datos personales. +// recordAudit is best-effort: if the write fails it must not break the action, but it must +// not lose the event silently either. The log carries no detail, ip or personal data. beforeEach(async () => { await resetDb(); @@ -16,11 +16,11 @@ afterAll(async () => { await disconnect(); }); -describe("recordAudit: fallo de escritura", () => { +describe("recordAudit: write failure", () => { it.each([ - ["FK de userId inexistente", { userId: "no-existe", detail: "dato-privado-xyz" }], - ["byte nulo en detail", { detail: "dato-privado-xyz\u0000" }], - ])("%s: resuelve sin lanzar y lo registra sin el detail", async (_caso, extra) => { + ["FK to a userId that does not exist", { userId: "does-not-exist", detail: "private-data-xyz" }], + ["null byte in detail", { detail: "private-data-xyz\u0000" }], + ])("%s: resolves without throwing and logs it without the detail", async (_case, extra) => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); await expect( @@ -31,13 +31,13 @@ describe("recordAudit: fallo de escritura", () => { expect(spy).toHaveBeenCalledTimes(1); const logged = JSON.stringify(spy.mock.calls[0]); expect(logged).toContain("auth.login"); - expect(logged).not.toContain("dato-privado-xyz"); + expect(logged).not.toContain("private-data-xyz"); expect(logged).not.toContain("192.0.2.10"); - // Tampoco el userId: el error de FK de Postgres lo repite («Key (userId)=(…)»). - expect(logged).not.toContain("no-existe"); + // Nor the userId: the Postgres FK error repeats it ("Key (userId)=(…)"). + expect(logged).not.toContain("does-not-exist"); }); - it("si la escritura va bien no registra nada en el log", async () => { + it("logs nothing if the write succeeds", async () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); await recordAudit({ action: "auth.login", ip: null }); expect(await prisma.auditLog.count()).toBe(1); diff --git a/test/integration/auth.int.test.ts b/test/integration/auth.int.test.ts index ebad16c..4e81866 100644 --- a/test/integration/auth.int.test.ts +++ b/test/integration/auth.int.test.ts @@ -2,10 +2,11 @@ import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vites import { prisma } from "@/app/lib/prisma"; import { login } from "@/app/lib/actions/auth"; import { __setHeaders } from "../stubs/next-headers"; -import { resetDb, disconnect, seedUser } from "./helpers"; +import { cookies } from "next/headers"; +import { resetDb, disconnect, seedUser, en, es } from "./helpers"; -// Login (Server Action) contra la BD real: límites por IP y por cuenta, auditoría sin el -// email tecleado. TRUSTED_PROXY_HOPS se fija por test con vi.stubEnv. +// Login (Server Action) against the real DB: per-IP and per-account limits, auditing +// without the typed email. TRUSTED_PROXY_HOPS is set per test with vi.stubEnv. beforeEach(async () => { await resetDb(); @@ -24,74 +25,78 @@ function form(email: string, password: string): FormData { return fd; } -const TOO_MANY = /Demasiados intentos/; +// "Too many attempts. Try again in {seconds} seconds.": the number varies, so only the +// text before the placeholder is matched. +const TOO_MANY = new RegExp( + `^${en.errors.auth.tooManyAttempts.other.split("{")[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, +); describe("login: rate limit", () => { - it("sin proxies de confianza, falsear X-Forwarded-For no evita el límite por cuenta", async () => { - // hops = 0 (por defecto): la cabecera se ignora y solo cuenta el límite por email. + it("without trusted proxies, spoofing X-Forwarded-For does not avoid the per-account limit", async () => { + // hops = 0 (the default): the header is ignored and only the per-email limit counts. for (let i = 0; i < 20; i++) { __setHeaders({ "x-forwarded-for": `203.0.113.${i}` }); - const r = await login({}, form("victima@test.local", "mala")); - expect(r.error).toBe("Credenciales incorrectas."); + const r = await login({}, form("victim@test.local", "wrong")); + expect(r.error).toBe(en.errors.auth.invalidCredentials); } __setHeaders({ "x-forwarded-for": "203.0.113.200" }); - const blocked = await login({}, form("victima@test.local", "mala")); + const blocked = await login({}, form("victim@test.local", "wrong")); expect(blocked.error).toMatch(TOO_MANY); }); - it("con un proxy de confianza, la IP es la que añade el proxy y no la que envía el cliente", async () => { + it("with a trusted proxy, the IP is the one the proxy adds, not the one the client sends", async () => { vi.stubEnv("TRUSTED_PROXY_HOPS", "1"); - // El cliente pone una IP distinta en cada intento; el proxy añade siempre la real. + // The client sends a different IP on each attempt; the proxy always appends the real one. for (let i = 0; i < 10; i++) { __setHeaders({ "x-forwarded-for": `198.51.100.${i}, 10.0.0.1` }); - const r = await login({}, form(`u${i}@test.local`, "mala")); - expect(r.error).toBe("Credenciales incorrectas."); + const r = await login({}, form(`u${i}@test.local`, "wrong")); + expect(r.error).toBe(en.errors.auth.invalidCredentials); } __setHeaders({ "x-forwarded-for": "198.51.100.99, 10.0.0.1" }); - const blocked = await login({}, form("otro@test.local", "mala")); + const blocked = await login({}, form("other@test.local", "wrong")); expect(blocked.error).toMatch(TOO_MANY); }); - it("el límite por cuenta cuenta solo fallos: un login correcto lo reinicia", async () => { - await seedUser({ email: "ana@test.local", password: "correcta-123" }); + it("the per-account limit only counts failures: a successful login resets it", async () => { + await seedUser({ email: "ana@test.local", password: "correct-123" }); for (let i = 0; i < 19; i++) { - await login({}, form("ana@test.local", "mala")); + await login({}, form("ana@test.local", "wrong")); } - await expect(login({}, form("ana@test.local", "correcta-123"))).rejects.toMatchObject({ + await expect(login({}, form("ana@test.local", "correct-123"))).rejects.toMatchObject({ digest: expect.stringContaining("/boards"), }); - // Tras el éxito vuelven a caber 20 fallos antes del bloqueo. + // After the success, 20 failures fit again before the block. for (let i = 0; i < 20; i++) { - const r = await login({}, form("ANA@test.local", "mala")); - expect(r.error).toBe("Credenciales incorrectas."); + const r = await login({}, form("ANA@test.local", "wrong")); + expect(r.error).toBe(en.errors.auth.invalidCredentials); } - expect((await login({}, form("ana@test.local", "mala"))).error).toMatch(TOO_MANY); + expect((await login({}, form("ana@test.local", "wrong"))).error).toMatch(TOO_MANY); }); - it("un ataque sostenido deja una sola fila auth.login_rate_limited", async () => { + it("a sustained attack leaves a single auth.login_rate_limited row", async () => { vi.stubEnv("TRUSTED_PROXY_HOPS", "1"); __setHeaders({ "x-forwarded-for": "10.0.0.7" }); for (let i = 0; i < 15; i++) { - await login({}, form(`x${i}@test.local`, "mala")); + await login({}, form(`x${i}@test.local`, "wrong")); } expect( await prisma.auditLog.count({ where: { action: "auth.login_rate_limited" } }), ).toBe(1); }); - it("un TRUSTED_PROXY_HOPS no válido falla con un error claro", async () => { - vi.stubEnv("TRUSTED_PROXY_HOPS", "uno"); - await expect(login({}, form("a@test.local", "mala"))).rejects.toThrow( + it("an invalid TRUSTED_PROXY_HOPS fails with a clear error", async () => { + vi.stubEnv("TRUSTED_PROXY_HOPS", "one"); + await expect(login({}, form("a@test.local", "wrong"))).rejects.toThrow( /TRUSTED_PROXY_HOPS/, ); }); }); -describe("login: auditoría de fallos", () => { - it("no guarda el email tecleado; si la cuenta existe guarda su userId", async () => { - const u = await seedUser({ email: "real@test.local", password: "correcta-123" }); - await login({}, form("fantasma@test.local", "mala")); - await login({}, form("real@test.local", "mala")); +describe("login: failure auditing", () => { + it("does not store the typed email; if the account exists it stores its userId", async () => { + const u = await seedUser({ email: "real@test.local", password: "correct-123" }); + await login({}, form("ghost@test.local", "wrong")); + await login({}, form("real@test.local", "wrong")); const rows = await prisma.auditLog.findMany({ where: { action: "auth.login_failed" }, @@ -106,18 +111,78 @@ describe("login: auditoría de fallos", () => { }); }); -describe("login: valores devueltos tras un error", () => { - it("devuelve el email tecleado y nunca la contraseña", async () => { - const state = await login({}, form("alguien@test.local", "secreto-pegado")); - expect(state.error).toBe("Credenciales incorrectas."); - expect(state.values).toEqual({ email: "alguien@test.local" }); - expect(JSON.stringify(state)).not.toContain("secreto-pegado"); +describe("login: values returned after an error", () => { + it("returns the typed email and never the password", async () => { + const state = await login({}, form("someone@test.local", "pasted-secret")); + expect(state.error).toBe(en.errors.auth.invalidCredentials); + expect(state.values).toEqual({ email: "someone@test.local" }); + expect(JSON.stringify(state)).not.toContain("pasted-secret"); }); - it("también con un email mal formado", async () => { - const state = await login({}, form("no-es-email", "secreto-pegado")); - expect(state.error).toBe("Revisa el email y la contraseña."); - expect(state.values).toEqual({ email: "no-es-email" }); - expect(JSON.stringify(state)).not.toContain("secreto-pegado"); + it("also with a malformed email", async () => { + const state = await login({}, form("not-an-email", "pasted-secret")); + expect(state.error).toBe(en.errors.auth.checkCredentials); + expect(state.values).toEqual({ email: "not-an-email" }); + expect(JSON.stringify(state)).not.toContain("pasted-secret"); + }); +}); + +// The language of the answer comes from the `locale` cookie, then Accept-Language, then +// English (app/i18n/server.ts). The stub request has neither unless the test sets them. +describe("login: language of the answer", () => { + it("answers in English when the request has no language preference", async () => { + const state = await login({}, form("someone@test.local", "wrong")); + expect(state.error).toBe(en.errors.auth.invalidCredentials); + }); + + it("answers in Spanish with Accept-Language: es", async () => { + __setHeaders({ "accept-language": "es-ES,es;q=0.9,en;q=0.8" }); + const state = await login({}, form("someone@test.local", "wrong")); + expect(state.error).toBe(es.errors.auth.invalidCredentials); + expect(state.error).not.toBe(en.errors.auth.invalidCredentials); + }); + + it("answers in Spanish with the locale=es cookie", async () => { + (await cookies()).set("locale", "es"); + const state = await login({}, form("not-an-email", "wrong")); + expect(state.error).toBe(es.errors.auth.checkCredentials); + }); + + it("the cookie wins over Accept-Language", async () => { + __setHeaders({ "accept-language": "es" }); + (await cookies()).set("locale", "en"); + const state = await login({}, form("someone@test.local", "wrong")); + expect(state.error).toBe(en.errors.auth.invalidCredentials); + }); + + it("an unknown language in the cookie falls back to Accept-Language", async () => { + __setHeaders({ "accept-language": "es" }); + (await cookies()).set("locale", "xx"); + const state = await login({}, form("someone@test.local", "wrong")); + expect(state.error).toBe(es.errors.auth.invalidCredentials); + }); +}); + +describe("login: the account's language", () => { + it("a successful login sets the locale cookie from User.locale", async () => { + const user = await seedUser({ email: "ana@test.local", password: "correct-123" }); + await prisma.user.update({ where: { id: user.id }, data: { locale: "es" } }); + __setHeaders({ "accept-language": "en" }); + + await expect(login({}, form("ana@test.local", "correct-123"))).rejects.toMatchObject({ + digest: expect.stringContaining("/boards"), + }); + + expect((await cookies()).get("locale")?.value).toBe("es"); + }); + + it("an account without a language leaves the cookie alone", async () => { + await seedUser({ email: "ana@test.local", password: "correct-123" }); + + await expect(login({}, form("ana@test.local", "correct-123"))).rejects.toMatchObject({ + digest: expect.stringContaining("/boards"), + }); + + expect((await cookies()).get("locale")).toBeUndefined(); }); }); diff --git a/test/integration/authz-actions.int.test.ts b/test/integration/authz-actions.int.test.ts index c377f36..ac8be8a 100644 --- a/test/integration/authz-actions.int.test.ts +++ b/test/integration/authz-actions.int.test.ts @@ -27,13 +27,13 @@ import { ctx, } from "./helpers"; -// Autorización de las Server Actions de comentarios, checklists, adjuntos e -// import/export, ejecutadas con sesión real (loginAs). Se comprueba siempre el efecto en -// la BD, no solo el valor devuelto. La descarga de adjuntos (404/403) la cubre su propio -// test de route handler. +// Authorization of the Server Actions for comments, checklists, attachments and +// import/export, run with a real session (loginAs). The effect on the DB is always +// checked, not just the returned value. Attachment download (404/403) is covered by its +// own route handler test. -// storage.ts lee ORGTABLE_UPLOAD_DIR al importarse: se fija a un directorio temporal -// antes de cualquier import para no escribir en ./uploads del repo. +// storage.ts reads ORGTABLE_UPLOAD_DIR when imported: it is pointed at a temporary +// directory before any import so nothing is written to the repo's ./uploads. const uploadDir = await vi.hoisted(async () => { const { mkdtempSync } = await import("node:fs"); const { tmpdir } = await import("node:os"); @@ -70,18 +70,18 @@ async function boardWithRoles() { return { owner, editor, viewer, board, list, card }; } -describe("comentarios", () => { - it("un VIEWER no puede comentar", async () => { +describe("comments", () => { + it("a VIEWER cannot comment", async () => { const { viewer, card } = await boardWithRoles(); await loginAs(viewer.id); - await addComment(fd({ cardId: card.id, text: "hola" })); + await addComment(fd({ cardId: card.id, text: "hello" })); expect(await prisma.comment.count()).toBe(0); }); - it("un EDITOR que no es el autor no puede borrar; el OWNER sí", async () => { + it("an EDITOR who is not the author cannot delete; the OWNER can", async () => { const { owner, editor, viewer, card } = await boardWithRoles(); const comment = await prisma.comment.create({ - data: { cardId: card.id, authorId: viewer.id, text: "de otro" }, + data: { cardId: card.id, authorId: viewer.id, text: "someone else's" }, }); await loginAs(editor.id); @@ -93,7 +93,7 @@ describe("comentarios", () => { expect(await prisma.comment.count()).toBe(0); }); - it("notifica comment.added a los asignados, no al autor", async () => { + it("notifies comment.added to the assignees, not to the author", async () => { const { owner, editor, card } = await boardWithRoles(); await prisma.cardAssignee.createMany({ data: [ @@ -103,7 +103,7 @@ describe("comentarios", () => { }); await loginAs(editor.id); - await addComment(fd({ cardId: card.id, text: "listo" })); + await addComment(fd({ cardId: card.id, text: "done" })); expect(await prisma.comment.count({ where: { authorId: editor.id } })).toBe(1); const notified = await prisma.notification.findMany({ @@ -115,18 +115,18 @@ describe("comentarios", () => { }); describe("checklists", () => { - it("un VIEWER no cambia nada con ninguna de las cinco acciones", async () => { + it("a VIEWER changes nothing with any of the five actions", async () => { const { viewer, card } = await boardWithRoles(); const checklist = await prisma.checklist.create({ - data: { cardId: card.id, title: "Tareas", position: "a0" }, + data: { cardId: card.id, title: "Tasks", position: "a0" }, }); const item = await prisma.checklistItem.create({ - data: { checklistId: checklist.id, text: "uno", position: "a0" }, + data: { checklistId: checklist.id, text: "one", position: "a0" }, }); await loginAs(viewer.id); - await createChecklist(fd({ cardId: card.id, title: "Nueva" })); - await addChecklistItem(fd({ checklistId: checklist.id, text: "dos" })); + await createChecklist(fd({ cardId: card.id, title: "New" })); + await addChecklistItem(fd({ checklistId: checklist.id, text: "two" })); expect(await toggleChecklistItem({ itemId: item.id, done: true })).toEqual({ ok: false }); await deleteChecklistItem(fd({ itemId: item.id })); await deleteChecklist(fd({ checklistId: checklist.id })); @@ -137,12 +137,12 @@ describe("checklists", () => { expect(items[0]?.done).toBe(false); }); - it("un EDITOR sí puede usarlas", async () => { + it("an EDITOR can use them", async () => { const { editor, card } = await boardWithRoles(); await loginAs(editor.id); - await createChecklist(fd({ cardId: card.id, title: "Nueva" })); + await createChecklist(fd({ cardId: card.id, title: "New" })); const checklist = await prisma.checklist.findFirstOrThrow({ where: { cardId: card.id } }); - await addChecklistItem(fd({ checklistId: checklist.id, text: "uno" })); + await addChecklistItem(fd({ checklistId: checklist.id, text: "one" })); const item = await prisma.checklistItem.findFirstOrThrow(); expect(await toggleChecklistItem({ itemId: item.id, done: true })).toEqual({ ok: true }); expect((await prisma.checklistItem.findUniqueOrThrow({ where: { id: item.id } })).done).toBe( @@ -151,38 +151,38 @@ describe("checklists", () => { }); }); -describe("adjuntos", () => { - it("un VIEWER no puede subir", async () => { +describe("attachments", () => { + it("a VIEWER cannot upload", async () => { const { viewer, card } = await boardWithRoles(); await loginAs(viewer.id); const state = await uploadAttachment( {}, - fd({ cardId: card.id, file: new File(["hola"], "nota.txt", { type: "text/plain" }) }), + fd({ cardId: card.id, file: new File(["data"], "note.txt", { type: "text/plain" }) }), ); expect(state.error).toBeTruthy(); expect(await prisma.attachment.count()).toBe(0); }); - it("rechaza un archivo vacío y uno de más del máximo", async () => { + it("rejects an empty file and one over the maximum", async () => { const { editor, card } = await boardWithRoles(); await loginAs(editor.id); - const empty = await uploadAttachment({}, fd({ cardId: card.id, file: new File([], "vacio.txt") })); + const empty = await uploadAttachment({}, fd({ cardId: card.id, file: new File([], "empty.txt") })); expect(empty.error).toBeTruthy(); - const big = new File([new Uint8Array(MAX_ATTACHMENT_BYTES + 1)], "grande.bin"); + const big = new File([new Uint8Array(MAX_ATTACHMENT_BYTES + 1)], "large.bin"); const tooBig = await uploadAttachment({}, fd({ cardId: card.id, file: big })); expect(tooBig.error).toBeTruthy(); expect(await prisma.attachment.count()).toBe(0); }); - it("un EDITOR sube un archivo y queda en el directorio de adjuntos", async () => { + it("an EDITOR uploads a file and it lands in the attachments directory", async () => { const { editor, card } = await boardWithRoles(); await loginAs(editor.id); const state = await uploadAttachment( {}, - fd({ cardId: card.id, file: new File(["hola"], "nota.txt", { type: "text/plain" }) }), + fd({ cardId: card.id, file: new File(["data"], "note.txt", { type: "text/plain" }) }), ); expect(state.error).toBeUndefined(); const attachment = await prisma.attachment.findFirstOrThrow({ where: { cardId: card.id } }); @@ -191,8 +191,8 @@ describe("adjuntos", () => { }); }); -describe("export/import de tableros", () => { - it("un no miembro recibe 404 al exportar", async () => { +describe("board export/import", () => { + it("a non-member gets 404 when exporting", async () => { const { board } = await boardWithRoles(); const stranger = await seedUser(); await loginAs(stranger.id); @@ -200,9 +200,9 @@ describe("export/import de tableros", () => { expect(res.status).toBe(404); }); - it("ida y vuelta: lo exportado por un miembro se importa como tablero nuevo suyo", async () => { + it("round trip: what a member exports is imported as a new board of their own", async () => { const { viewer, board, card } = await boardWithRoles(); - await prisma.card.update({ where: { id: card.id }, data: { description: "detalle" } }); + await prisma.card.update({ where: { id: card.id }, data: { description: "details" } }); await loginAs(viewer.id); const res = await exportBoard(new Request("http://test/export"), ctx({ id: board.id })); @@ -210,7 +210,7 @@ describe("export/import de tableros", () => { const json = await res.text(); await expect( - importBoard({}, fd({ file: new File([json], "tablero.json", { type: "application/json" }) })), + importBoard({}, fd({ file: new File([json], "board.json", { type: "application/json" }) })), ).rejects.toMatchObject({ digest: expect.stringContaining(";/board/") }); const imported = await prisma.board.findFirstOrThrow({ @@ -222,6 +222,6 @@ describe("export/import de tableros", () => { expect.objectContaining({ userId: viewer.id, role: "OWNER" }), ]); expect(imported.lists).toHaveLength(1); - expect(imported.lists[0]?.cards.map((c) => c.description)).toEqual(["detalle"]); + expect(imported.lists[0]?.cards.map((c) => c.description)).toEqual(["details"]); }); }); diff --git a/test/integration/board-detail-api.int.test.ts b/test/integration/board-detail-api.int.test.ts index a07bf46..088d2e9 100644 --- a/test/integration/board-detail-api.int.test.ts +++ b/test/integration/board-detail-api.int.test.ts @@ -23,7 +23,7 @@ afterAll(async () => { await disconnect(); }); -/** Tablero con propietario, un EDITOR, un VIEWER y un extraño, cada uno con su token. */ +/** Board with an owner, an EDITOR, a VIEWER and a stranger, each with their own token. */ async function seedRoles() { const owner = await seedUser(); const editor = await seedUser(); @@ -60,7 +60,7 @@ async function boardTitle(id: string) { } describe("API v1 — GET /boards/:id", () => { - it("200 a un miembro y 404 a un extraño", async () => { + it("200 for a member and 404 for a stranger", async () => { const { board, viewer, stranger } = await seedRoles(); const ok = await getBoard( apiRequest("GET", `/api/v1/boards/${board.id}`, { token: viewer.token }), @@ -78,21 +78,21 @@ describe("API v1 — GET /boards/:id", () => { }); describe("API v1 — PATCH /boards/:id", () => { - it("200 al propietario", async () => { + it("200 for the owner", async () => { const { board, owner } = await seedRoles(); - const res = await patch(board.id, owner.token, { title: "Renombrado" }); + const res = await patch(board.id, owner.token, { title: "Renamed" }); expect(res.status).toBe(200); - expect(await boardTitle(board.id)).toBe("Renombrado"); + expect(await boardTitle(board.id)).toBe("Renamed"); }); - it("403 al EDITOR y 404 a un extraño, sin cambios", async () => { + it("403 for the EDITOR and 404 for a stranger, without changes", async () => { const { board, editor, stranger } = await seedRoles(); expect((await patch(board.id, editor.token, { title: "X" })).status).toBe(403); expect((await patch(board.id, stranger.token, { title: "X" })).status).toBe(404); expect(await boardTitle(board.id)).toBe("Original"); }); - it("422 con título vacío o sin título", async () => { + it("422 with an empty or missing title", async () => { const { board, owner } = await seedRoles(); expect((await patch(board.id, owner.token, { title: " " })).status).toBe(422); expect((await patch(board.id, owner.token, {})).status).toBe(422); @@ -101,14 +101,14 @@ describe("API v1 — PATCH /boards/:id", () => { }); describe("API v1 — DELETE /boards/:id", () => { - it("403 al EDITOR y 404 a un extraño, sin borrar", async () => { + it("403 for the EDITOR and 404 for a stranger, without deleting", async () => { const { board, editor, stranger } = await seedRoles(); expect((await del(board.id, editor.token)).status).toBe(403); expect((await del(board.id, stranger.token)).status).toBe(404); expect(await prisma.board.count()).toBe(1); }); - it("200 al propietario y queda en la auditoría como board.deleted", async () => { + it("200 for the owner and recorded in the audit log as board.deleted", async () => { const { board, owner } = await seedRoles(); const res = await del(board.id, owner.token); expect(res.status).toBe(200); diff --git a/test/integration/board-metadata.int.test.ts b/test/integration/board-metadata.int.test.ts index 336bc30..e2cd791 100644 --- a/test/integration/board-metadata.int.test.ts +++ b/test/integration/board-metadata.int.test.ts @@ -7,7 +7,10 @@ import { seedBoard, addMember, loginAs, + en, + es, } from "./helpers"; +import { cookies } from "next/headers"; function metadataFor(boardId: string) { return generateMetadata({ params: Promise.resolve({ id: boardId }) }); @@ -20,32 +23,39 @@ afterAll(async () => { await disconnect(); }); -describe("título de la página del tablero (integración)", () => { - it("el dueño y los miembros ven el nombre del tablero", async () => { +describe("board page title (integration)", () => { + it("the owner and the members see the board's name", async () => { const owner = await seedUser(); const viewer = await seedUser(); - const board = await seedBoard(owner.id, "Plan secreto"); + const board = await seedBoard(owner.id, "Secret plan"); await addMember(board.id, viewer.id, "VIEWER"); await loginAs(owner.id); - expect((await metadataFor(board.id)).title).toBe("Plan secreto"); + expect((await metadataFor(board.id)).title).toBe("Secret plan"); await loginAs(viewer.id); - expect((await metadataFor(board.id)).title).toBe("Plan secreto"); + expect((await metadataFor(board.id)).title).toBe("Secret plan"); }); - it("quien no es miembro recibe el título genérico, no el nombre", async () => { + it("a non-member gets the generic title, not the name", async () => { const owner = await seedUser(); const stranger = await seedUser(); - const board = await seedBoard(owner.id, "Plan secreto"); + const board = await seedBoard(owner.id, "Secret plan"); await loginAs(stranger.id); - expect((await metadataFor(board.id)).title).toBe("Tablero"); + expect((await metadataFor(board.id)).title).toBe(en.boards.board.metaTitle); }); - it("un tablero inexistente también da el título genérico", async () => { + it("a board that does not exist also gets the generic title", async () => { const user = await seedUser(); await loginAs(user.id); - expect((await metadataFor("no-existe")).title).toBe("Tablero"); + expect((await metadataFor("does-not-exist")).title).toBe(en.boards.board.metaTitle); + }); + + it("the generic title follows the language of the request", async () => { + const user = await seedUser(); + await loginAs(user.id); + (await cookies()).set("locale", "es"); + expect((await metadataFor("does-not-exist")).title).toBe(es.boards.board.metaTitle); }); }); diff --git a/test/integration/boards-api.int.test.ts b/test/integration/boards-api.int.test.ts index 561ccc7..3e58f3f 100644 --- a/test/integration/boards-api.int.test.ts +++ b/test/integration/boards-api.int.test.ts @@ -17,32 +17,32 @@ afterAll(async () => { await disconnect(); }); -describe("API v1 — tableros (integración)", () => { - it("401 sin token", async () => { +describe("API v1 — boards (integration)", () => { + it("401 without a token", async () => { const res = await listBoards(apiRequest("GET", "/api/v1/boards")); expect(res.status).toBe(401); }); - it("401 con un token inexistente", async () => { + it("401 with a token that does not exist", async () => { const res = await listBoards( - apiRequest("GET", "/api/v1/boards", { token: "orgt_inexistente" }), + apiRequest("GET", "/api/v1/boards", { token: "orgt_missing" }), ); expect(res.status).toBe(401); }); - it("POST crea un tablero y deja al usuario como OWNER", async () => { + it("POST creates a board and makes the user its OWNER", async () => { const user = await seedUser(); const token = await seedApiToken(user.id); const res = await createBoard( apiRequest("POST", "/api/v1/boards", { token, - body: { title: "Mi tablero" }, + body: { title: "My board" }, }), ); expect(res.status).toBe(201); const body = await res.json(); - expect(body.title).toBe("Mi tablero"); + expect(body.title).toBe("My board"); const member = await prisma.boardMember.findUnique({ where: { boardId_userId: { boardId: body.id, userId: user.id } }, @@ -50,7 +50,7 @@ describe("API v1 — tableros (integración)", () => { expect(member?.role).toBe("OWNER"); }); - it("POST con título vacío → 422 (validación)", async () => { + it("POST with an empty title → 422 (validation)", async () => { const user = await seedUser(); const token = await seedApiToken(user.id); const res = await createBoard( @@ -59,12 +59,12 @@ describe("API v1 — tableros (integración)", () => { expect(res.status).toBe(422); }); - it("GET solo devuelve los tableros accesibles por el usuario del token", async () => { + it("GET only returns the boards the token's user can access", async () => { const a = await seedUser(); const b = await seedUser(); const tokenA = await seedApiToken(a.id); - await seedBoard(a.id, "De A"); - await seedBoard(b.id, "De B (ajeno)"); + await seedBoard(a.id, "A's"); + await seedBoard(b.id, "B's (not theirs)"); const res = await listBoards( apiRequest("GET", "/api/v1/boards", { token: tokenA }), @@ -72,7 +72,7 @@ describe("API v1 — tableros (integración)", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.boards.map((x: { title: string }) => x.title)).toEqual([ - "De A", + "A's", ]); }); }); diff --git a/test/integration/cards-actions.int.test.ts b/test/integration/cards-actions.int.test.ts index 62c12ca..5d0ff75 100644 --- a/test/integration/cards-actions.int.test.ts +++ b/test/integration/cards-actions.int.test.ts @@ -12,9 +12,9 @@ import { loginAs, } from "./helpers"; -// moveCard ejecutado de verdad (Server Action con sesión): regresión del IDOR de la -// auditoría de seguridad. Si se quita la comprobación cardBelongsToBoard de -// app/lib/actions/cards.ts, el primer test falla. +// moveCard run for real (Server Action with a session): regression test for the IDOR +// found in the security audit. If the cardBelongsToBoard check is removed from +// app/lib/actions/cards.ts, the first test fails. beforeEach(async () => { await resetDb(); @@ -39,7 +39,7 @@ async function scenario() { } describe("moveCard (Server Action)", () => { - it("un EDITOR de B no puede traerse una tarjeta del tablero A, al que no tiene acceso", async () => { + it("an EDITOR of B cannot pull in a card from board A, which they cannot access", async () => { const { editor, listA, listB1, cardA } = await scenario(); await loginAs(editor.id); @@ -50,7 +50,7 @@ describe("moveCard (Server Action)", () => { expect(card.listId).toBe(listA.id); }); - it("un EDITOR mueve tarjetas entre listas de su tablero", async () => { + it("an EDITOR moves cards between lists of their board", async () => { const { editor, listB2, cardB } = await scenario(); await loginAs(editor.id); @@ -61,7 +61,7 @@ describe("moveCard (Server Action)", () => { expect(card.listId).toBe(listB2.id); }); - it("un VIEWER no puede mover", async () => { + it("a VIEWER cannot move", async () => { const { boardB, listB1, listB2, cardB } = await scenario(); const viewer = await seedUser(); await addMember(boardB.id, viewer.id, "VIEWER"); @@ -74,7 +74,7 @@ describe("moveCard (Server Action)", () => { expect(card.listId).toBe(listB1.id); }); - it("quien no es miembro es redirigido a /boards", async () => { + it("a non-member is redirected to /boards", async () => { const { listB2, cardB, listB1 } = await scenario(); const stranger = await seedUser(); await loginAs(stranger.id); @@ -86,7 +86,7 @@ describe("moveCard (Server Action)", () => { expect(card.listId).toBe(listB1.id); }); - it("sin sesión es redirigido a /login", async () => { + it("without a session they are redirected to /login", async () => { const { listB2, cardB } = await scenario(); await expect( moveCard({ cardId: cardB.id, toListId: listB2.id, position: "a5" }), diff --git a/test/integration/cards-api.int.test.ts b/test/integration/cards-api.int.test.ts index c3d4daa..c5e0e75 100644 --- a/test/integration/cards-api.int.test.ts +++ b/test/integration/cards-api.int.test.ts @@ -30,8 +30,8 @@ afterAll(async () => { await disconnect(); }); -describe("API v1 — listas y tarjetas (integración)", () => { - it("un editor crea una lista y una tarjeta con vencimiento", async () => { +describe("API v1 — lists and cards (integration)", () => { + it("an editor creates a list and a card with a due date", async () => { const owner = await seedUser(); const token = await seedApiToken(owner.id); const board = await seedBoard(owner.id); @@ -39,31 +39,31 @@ describe("API v1 — listas y tarjetas (integración)", () => { const listRes = await createList( apiRequest("POST", `/api/v1/boards/${board.id}/lists`, { token, - body: { title: "Pendiente" }, + body: { title: "To do" }, }), ctx({ id: board.id }), ); expect(listRes.status).toBe(201); const list = await listRes.json(); - expect(list.title).toBe("Pendiente"); + expect(list.title).toBe("To do"); const cardRes = await createCard( apiRequest("POST", `/api/v1/lists/${list.id}/cards`, { token, - body: { title: "Hacer X", dueDate: "2026-07-01" }, + body: { title: "Do X", dueDate: "2026-07-01" }, }), ctx({ id: list.id }), ); expect(cardRes.status).toBe(201); const card = await cardRes.json(); - expect(card.title).toBe("Hacer X"); + expect(card.title).toBe("Do X"); expect(card.dueDate).toBe("2026-07-01T00:00:00.000Z"); const inDb = await prisma.card.findUnique({ where: { id: card.id } }); expect(inDb?.listId).toBe(list.id); }); - it("un VIEWER no puede crear listas (403)", async () => { + it("a VIEWER cannot create lists (403)", async () => { const owner = await seedUser(); const viewer = await seedUser(); const board = await seedBoard(owner.id); @@ -81,7 +81,7 @@ describe("API v1 — listas y tarjetas (integración)", () => { expect(await prisma.list.count()).toBe(0); }); - it("un usuario sin acceso recibe 404 (no se revela el tablero)", async () => { + it("a user without access gets 404 (the board is not revealed)", async () => { const owner = await seedUser(); const stranger = await seedUser(); const board = await seedBoard(owner.id); @@ -97,7 +97,7 @@ describe("API v1 — listas y tarjetas (integración)", () => { expect(res.status).toBe(404); }); - it("PATCH actualiza el título y limpia el vencimiento; DELETE borra la tarjeta", async () => { + it("PATCH updates the title and clears the due date; DELETE removes the card", async () => { const owner = await seedUser(); const token = await seedApiToken(owner.id); const board = await seedBoard(owner.id); @@ -115,13 +115,13 @@ describe("API v1 — listas y tarjetas (integración)", () => { const patchRes = await patchCard( apiRequest("PATCH", `/api/v1/cards/${card.id}`, { token, - body: { title: "Editada", dueDate: null }, + body: { title: "Edited", dueDate: null }, }), ctx({ id: card.id }), ); expect(patchRes.status).toBe(200); const patched = await patchRes.json(); - expect(patched.title).toBe("Editada"); + expect(patched.title).toBe("Edited"); expect(patched.dueDate).toBeNull(); const delRes = await deleteCard( @@ -133,7 +133,7 @@ describe("API v1 — listas y tarjetas (integración)", () => { }); }); -/** Tarjeta con vencimiento, su propietario y los tokens de un VIEWER y de un extraño. */ +/** Card with a due date, its owner, and the tokens of a VIEWER and of a stranger. */ async function seedCardWithRoles() { const owner = await seedUser(); const viewer = await seedUser(); @@ -170,8 +170,8 @@ async function dueDateOf(cardId: string) { return card.dueDate?.toISOString() ?? null; } -describe("API v1 — tarjetas: roles y acceso", () => { - it("PATCH y DELETE de un VIEWER dan 403 y no cambian nada", async () => { +describe("API v1 — cards: roles and access", () => { + it("PATCH and DELETE by a VIEWER give 403 and change nothing", async () => { const { card, viewerToken } = await seedCardWithRoles(); expect((await patch(card.id, viewerToken, { title: "X" })).status).toBe(403); const del = await deleteCard( @@ -182,7 +182,7 @@ describe("API v1 — tarjetas: roles y acceso", () => { expect(await prisma.card.findUnique({ where: { id: card.id } })).not.toBeNull(); }); - it("un extraño recibe 404 en GET, PATCH y DELETE de la tarjeta y en GET de la lista", async () => { + it("a stranger gets 404 on GET, PATCH and DELETE of the card and on GET of the list", async () => { const { list, card, strangerToken } = await seedCardWithRoles(); const token = strangerToken; const get = await getCard( @@ -205,11 +205,11 @@ describe("API v1 — tarjetas: roles y acceso", () => { }); }); -// Regresión: una dueDate no reconocible se guardaba como null (borraba el vencimiento) y -// la respuesta era 200/201. -describe("API v1 — tarjetas: validación de dueDate", () => { - it.each(["31/12/2026", "2026-02-31", "", "mañana"])( - "PATCH con dueDate %j da 422 y conserva la fecha", +// Regression: an unrecognisable dueDate was stored as null (clearing the due date) and +// the response was 200/201. +describe("API v1 — cards: dueDate validation", () => { + it.each(["31/12/2026", "2026-02-31", "", "tomorrow"])( + "PATCH with dueDate %j gives 422 and keeps the date", async (bad) => { const { card, ownerToken } = await seedCardWithRoles(); const res = await patch(card.id, ownerToken, { dueDate: bad }); @@ -218,12 +218,12 @@ describe("API v1 — tarjetas: validación de dueDate", () => { }, ); - it("POST con dueDate inválida da 422 y no crea nada", async () => { + it("POST with an invalid dueDate gives 422 and creates nothing", async () => { const { list, ownerToken } = await seedCardWithRoles(); const res = await createCard( apiRequest("POST", `/api/v1/lists/${list.id}/cards`, { token: ownerToken, - body: { title: "Nueva", dueDate: "31/12/2026" }, + body: { title: "New", dueDate: "31/12/2026" }, }), ctx({ id: list.id }), ); @@ -231,7 +231,7 @@ describe("API v1 — tarjetas: validación de dueDate", () => { expect(await prisma.card.count()).toBe(1); }); - it("'YYYY-MM-DD', ISO con zona y null siguen funcionando", async () => { + it("'YYYY-MM-DD', ISO with a time zone and null still work", async () => { const { card, ownerToken } = await seedCardWithRoles(); expect((await patch(card.id, ownerToken, { dueDate: "2026-08-15" })).status).toBe(200); expect(await dueDateOf(card.id)).toBe("2026-08-15T00:00:00.000Z"); diff --git a/test/integration/cards-due-date.int.test.ts b/test/integration/cards-due-date.int.test.ts index 18e9f5a..8e7b3a4 100644 --- a/test/integration/cards-due-date.int.test.ts +++ b/test/integration/cards-due-date.int.test.ts @@ -9,7 +9,9 @@ import { seedList, seedCard, loginAs, + en, } from "./helpers"; +import { fmt } from "@/app/i18n/format"; beforeEach(async () => { await resetDb(); @@ -20,7 +22,7 @@ afterAll(async () => { const DUE = new Date("2026-07-01T00:00:00.000Z"); -/** Tarjeta con vencimiento y descripción, y el propietario ya con sesión. */ +/** Card with a due date and a description, and its owner already signed in. */ async function seedCardWithDue() { const owner = await seedUser(); const board = await seedBoard(owner.id); @@ -48,11 +50,11 @@ function detailsForm(cardId: string, description: string, dueDate: string): Form return fd; } -// Regresión: una fecha no parseable se convertía en null y BORRABA el vencimiento (y -// setCardDueDate respondía ok:true). -describe("setCardDueDate valida la fecha", () => { - it.each(["24/09/2026", "2026-02-31", "mañana"])( - "%s da ok:false y conserva la fecha", +// Regression: an unparseable date turned into null and CLEARED the due date (and +// setCardDueDate answered ok:true). +describe("setCardDueDate validates the date", () => { + it.each(["24/09/2026", "2026-02-31", "tomorrow"])( + "%s gives ok:false and keeps the date", async (bad) => { const card = await seedCardWithDue(); expect(await setCardDueDate({ cardId: card.id, dueDate: bad })).toEqual({ @@ -62,7 +64,7 @@ describe("setCardDueDate valida la fecha", () => { }, ); - it("una fecha válida se guarda a medianoche UTC", async () => { + it("a valid date is stored at midnight UTC", async () => { const card = await seedCardWithDue(); expect(await setCardDueDate({ cardId: card.id, dueDate: "2026-09-24" })).toEqual({ ok: true, @@ -72,40 +74,40 @@ describe("setCardDueDate valida la fecha", () => { ); }); - it("null quita la fecha", async () => { + it("null clears the date", async () => { const card = await seedCardWithDue(); expect(await setCardDueDate({ cardId: card.id, dueDate: null })).toEqual({ ok: true }); expect((await readCard(card.id)).dueDate).toBeNull(); }); }); -describe("updateCard valida fecha y descripción", () => { +describe("updateCard validates the date and the description", () => { it.each(["24/09/2026", "2026-02-31"])( - "con fecha %s no guarda nada y devuelve el error con lo enviado", + "with date %s saves nothing and returns the error with what was sent", async (bad) => { const card = await seedCardWithDue(); - expect(await updateCard({}, detailsForm(card.id, "Cambiada", bad))).toEqual({ - error: "La fecha de vencimiento no es válida.", - description: "Cambiada", + expect(await updateCard({}, detailsForm(card.id, "Changed", bad))).toEqual({ + error: en.errors.cards.invalidDueDate, + description: "Changed", dueDate: bad, }); expect(await readCard(card.id)).toEqual({ dueDate: DUE, description: "Original" }); }, ); - it("una fecha vacía quita el vencimiento", async () => { + it("an empty date clears the due date", async () => { const card = await seedCardWithDue(); expect(await updateCard({}, detailsForm(card.id, "Original", ""))).toEqual({}); expect((await readCard(card.id)).dueDate).toBeNull(); }); - // Regresión: con más de 5000 caracteres la acción volvía sin guardar ni avisar, y el - // reseteo del formulario borraba lo escrito. - it("una descripción de 5001 caracteres no se guarda y devuelve el error", async () => { + // Regression: with more than 5000 characters the action returned without saving or + // warning, and the form reset wiped what had been typed. + it("a 5001-character description is not saved and returns the error", async () => { const card = await seedCardWithDue(); const long = "x".repeat(5001); expect(await updateCard({}, detailsForm(card.id, long, "2026-07-01"))).toEqual({ - error: "La descripción no puede superar 5000 caracteres (tiene 5001).", + error: fmt(en.errors.cards.descriptionTooLong, { max: 5000, length: 5001 }), description: long, dueDate: "2026-07-01", }); @@ -117,19 +119,19 @@ describe("updateCard valida fecha y descripción", () => { expect((await readCard(card.id)).description).toBe("y".repeat(5000)); }); - // El navegador envía los saltos de línea como \r\n pero maxLength los cuenta como uno: - // 2500 líneas de "a\n" miden 5000 en la UI y llegan con 7500 caracteres. - it("cuenta los saltos de línea \\r\\n como un carácter", async () => { + // The browser sends line breaks as \r\n but maxLength counts them as one: 2500 lines + // of "a\n" measure 5000 in the UI and arrive with 7500 characters. + it("counts \\r\\n line breaks as one character", async () => { const card = await seedCardWithDue(); const sent = "a\r\n".repeat(2500); expect(await updateCard({}, detailsForm(card.id, sent, "2026-07-01"))).toEqual({}); - // El trim de la validación quita el último salto de línea. + // The validation's trim removes the last line break. expect((await readCard(card.id)).description).toBe("a\n".repeat(2500).trimEnd()); }); - // Regresión: una descripción anterior al límite (más de 5000) hacía fallar la - // validación al reenviarla sin cambios y no se podía cambiar el vencimiento. - it("con una descripción heredada larga y sin cambios guarda la fecha", async () => { + // Regression: a description from before the limit (over 5000) failed validation when + // sent back unchanged, so the due date could not be changed. + it("with a long legacy description sent unchanged, it saves the date", async () => { const card = await seedCardWithDue(); const legacy = "z\r\n".repeat(3000); await prisma.card.update({ where: { id: card.id }, data: { description: legacy } }); @@ -140,9 +142,11 @@ describe("updateCard valida fecha y descripción", () => { description: legacy, }); - const edited = legacy + "más"; + const edited = legacy + "more"; const result = await updateCard({}, detailsForm(card.id, edited, "2026-09-25")); - expect(result.error).toMatch(/no puede superar 5000 caracteres/); + expect(result.error).toBe( + fmt(en.errors.cards.descriptionTooLong, { max: 5000, length: 6004 }), + ); expect(await readCard(card.id)).toEqual({ dueDate: new Date("2026-09-24T00:00:00.000Z"), description: legacy, diff --git a/test/integration/collaboration.int.test.ts b/test/integration/collaboration.int.test.ts index 7cb3196..0370b9e 100644 --- a/test/integration/collaboration.int.test.ts +++ b/test/integration/collaboration.int.test.ts @@ -21,8 +21,8 @@ afterAll(async () => { await disconnect(); }); -describe("colaboración en la tarjeta (integración)", () => { - it("asigna a un miembro del tablero, es idempotente, y rechaza a un no-miembro", async () => { +describe("card collaboration (integration)", () => { + it("assigns a board member, is idempotent, and rejects a non-member", async () => { const owner = await seedUser(); const editor = await seedUser(); const stranger = await seedUser(); @@ -31,21 +31,21 @@ describe("colaboración en la tarjeta (integración)", () => { const list = await seedList(board.id); const card = await seedCard(list.id); - // Miembro: se asigna. + // Member: gets assigned. const r1 = await assignMemberToCard(board.id, card.id, editor.id); expect(r1.assigned).toBe(true); expect( await prisma.cardAssignee.count({ where: { cardId: card.id } }), ).toBe(1); - // Idempotente: segunda vez no duplica. + // Idempotent: a second time does not duplicate. const r2 = await assignMemberToCard(board.id, card.id, editor.id); expect(r2).toEqual({ assigned: false, reason: "already" }); expect( await prisma.cardAssignee.count({ where: { cardId: card.id } }), ).toBe(1); - // No-miembro: se rechaza y no crea fila. + // Non-member: rejected, and no row is created. const r3 = await assignMemberToCard(board.id, card.id, stranger.id); expect(r3).toEqual({ assigned: false, reason: "not_member" }); expect( @@ -53,33 +53,33 @@ describe("colaboración en la tarjeta (integración)", () => { ).toBe(1); }); - it("getBoardWithLists devuelve los asignados y los comentarios con su autor", async () => { - const owner = await seedUser({ name: "Owner Persona" }); + it("getBoardWithLists returns the assignees and the comments with their author", async () => { + const owner = await seedUser({ name: "Owner Person" }); const board = await seedBoard(owner.id); const list = await seedList(board.id); const card = await seedCard(list.id); await assignMemberToCard(board.id, card.id, owner.id); await prisma.comment.create({ - data: { cardId: card.id, authorId: owner.id, text: "Primer comentario" }, + data: { cardId: card.id, authorId: owner.id, text: "First comment" }, }); const full = await getBoardWithLists(board.id, owner.id); const got = full?.lists[0]?.cards[0]; - expect(got?.assignees.map((a) => a.user.name)).toEqual(["Owner Persona"]); + expect(got?.assignees.map((a) => a.user.name)).toEqual(["Owner Person"]); expect(got?.comments).toHaveLength(1); - expect(got?.comments[0]?.text).toBe("Primer comentario"); - expect(got?.comments[0]?.author.name).toBe("Owner Persona"); + expect(got?.comments[0]?.text).toBe("First comment"); + expect(got?.comments[0]?.author.name).toBe("Owner Person"); }); - it("addComment no avisa a un asignado que ya no es miembro del tablero", async () => { + it("addComment does not notify an assignee who is no longer a board member", async () => { const owner = await seedUser(); const editor = await seedUser(); const expelled = await seedUser(); const board = await seedBoard(owner.id); await addMember(board.id, editor.id, "EDITOR"); const card = await seedCard((await seedList(board.id)).id); - // Asignación huérfana: como las que dejaba la expulsión antes de borrarlas. + // Orphan assignment: like the ones removal from the board used to leave behind. await prisma.cardAssignee.createMany({ data: [ { cardId: card.id, userId: editor.id }, @@ -90,7 +90,7 @@ describe("colaboración en la tarjeta (integración)", () => { const fd = new FormData(); fd.set("cardId", card.id); - fd.set("text", "Hola"); + fd.set("text", "Hello"); await addComment(fd); const notes = await prisma.notification.findMany({ diff --git a/test/integration/cron-reminders.int.test.ts b/test/integration/cron-reminders.int.test.ts index 536165b..5bff2d1 100644 --- a/test/integration/cron-reminders.int.test.ts +++ b/test/integration/cron-reminders.int.test.ts @@ -10,11 +10,11 @@ import { seedCard, } from "./helpers"; -// Endpoint POST /api/cron/reminders: 503 sin CRON_SECRET, 401 con cualquier credencial que -// no sea exactamente el secreto (sin lanzar excepción aunque la longitud difiera), 200 con -// el resultado de la pasada. +// POST /api/cron/reminders endpoint: 503 without CRON_SECRET, 401 with any credential that +// is not exactly the secret (without throwing even if the length differs), 200 with the +// result of the run. -const SECRET = "s3cr3t-largo"; +const SECRET = "s3cr3t-longer"; const originalSecret = process.env.CRON_SECRET; function cronRequest(authorization?: string): Request { @@ -38,26 +38,26 @@ afterAll(async () => { await disconnect(); }); -describe("POST /api/cron/reminders (integración)", () => { - it("responde 503 si CRON_SECRET no está definido", async () => { +describe("POST /api/cron/reminders (integration)", () => { + it("answers 503 if CRON_SECRET is not set", async () => { delete process.env.CRON_SECRET; const res = await POST(cronRequest(`Bearer ${SECRET}`)); expect(res.status).toBe(503); }); it.each([ - ["secreto de la misma longitud", "Bearer s3cr3t-largX"], - ["secreto más corto", "Bearer s"], - ["sin cabecera", undefined], - ["esquema Basic", `Basic ${SECRET}`], - ["Bearer vacío", "Bearer "], - ])("responde 401 con %s y no crea notificaciones", async (_caso, auth) => { + ["a secret of the same length", "Bearer s3cr3t-longeX"], + ["a shorter secret", "Bearer s"], + ["no header", undefined], + ["Basic scheme", `Basic ${SECRET}`], + ["empty Bearer", "Bearer "], + ])("answers 401 with %s and creates no notifications", async (_case, auth) => { const res = await POST(cronRequest(auth)); expect(res.status).toBe(401); expect(await prisma.notification.count()).toBe(0); }); - it("con el secreto correcto genera los avisos y es idempotente", async () => { + it("with the right secret it creates the reminders and is idempotent", async () => { const owner = await seedUser(); const board = await seedBoard(owner.id); const card = await seedCard((await seedList(board.id)).id); diff --git a/test/integration/helpers.int.test.ts b/test/integration/helpers.int.test.ts index 5507f3a..bf5e05e 100644 --- a/test/integration/helpers.int.test.ts +++ b/test/integration/helpers.int.test.ts @@ -16,18 +16,18 @@ afterAll(async () => { await disconnect(); }); -// Guarda de la propia infraestructura de tests: resetDb debe dejar vacías TODAS las -// tablas (la lista fija se había quedado sin CardAssignee ni Comment) y la "petición" del -// stub sin sesión ni cabeceras; loginAs, en cambio, conserva las cabeceras inyectadas. +// Guard for the test infrastructure itself: resetDb must empty ALL tables (the fixed list +// had lost CardAssignee and Comment) and leave the stub's "request" without a session or +// headers; loginAs, on the other hand, keeps the injected headers. describe("resetDb", () => { - it("vacía todas las tablas, incluidas CardAssignee, Comment e InstanceConfig", async () => { + it("empties every table, including CardAssignee, Comment and InstanceConfig", async () => { await resetDb(); const u = await seedUser(); const board = await seedBoard(u.id); const list = await seedList(board.id); const card = await seedCard(list.id); await prisma.cardAssignee.create({ data: { cardId: card.id, userId: u.id } }); - await prisma.comment.create({ data: { cardId: card.id, authorId: u.id, text: "hola" } }); + await prisma.comment.create({ data: { cardId: card.id, authorId: u.id, text: "hello" } }); await prisma.instanceConfig.create({ data: { id: "singleton", instanceName: "x", setupCompletedAt: new Date() }, }); @@ -48,11 +48,11 @@ describe("resetDb", () => { expect((await cookies()).get("session")).toBeUndefined(); }); - // Lo anterior también pasaba con la lista fija (CardAssignee y Comment caen por el - // CASCADE de Card/User). Una tabla sin FK hacia nada solo se vacía si resetDb lee el - // catálogo, que es lo que se quiere garantizar para las tablas futuras. - it("vacía también una tabla sin FK que no conoce de antemano", async () => { - // Por si una ejecución anterior murió antes del finally. + // The test above also passed with the fixed list (CardAssignee and Comment go through the + // CASCADE of Card/User). A table with no FK to anything is only emptied if resetDb reads + // the catalog, which is what has to be guaranteed for future tables. + it("also empties a table without FKs that it does not know in advance", async () => { + // In case an earlier run died before the finally. await prisma.$executeRawUnsafe(`DROP TABLE IF EXISTS "zz_reset_probe"`); await prisma.$executeRawUnsafe( `CREATE TABLE "zz_reset_probe" ("id" INTEGER PRIMARY KEY)`, @@ -72,8 +72,8 @@ describe("resetDb", () => { }); }); -describe("loginAs y las cabeceras de la petición", () => { - it("conserva las cabeceras puestas antes: la cookie sale Secure tras X-Forwarded-Proto https", async () => { +describe("loginAs and the request headers", () => { + it("keeps the headers set earlier: the cookie is Secure after X-Forwarded-Proto https", async () => { await resetDb(); const u = await seedUser(); __setHeaders({ "x-forwarded-proto": "https", "x-forwarded-for": "203.0.113.5" }); @@ -84,7 +84,7 @@ describe("loginAs y las cabeceras de la petición", () => { expect((await headers()).get("x-forwarded-for")).toBe("203.0.113.5"); }); - it("resetDb sí vacía las cabeceras inyectadas", async () => { + it("resetDb does clear the injected headers", async () => { __setHeaders({ "x-forwarded-proto": "https" }); await resetDb(); expect((await headers()).get("x-forwarded-proto")).toBeNull(); diff --git a/test/integration/helpers.ts b/test/integration/helpers.ts index 728a353..8c0ed18 100644 --- a/test/integration/helpers.ts +++ b/test/integration/helpers.ts @@ -3,31 +3,39 @@ import { createUserAccount } from "@/app/lib/services/users"; import { generateApiToken } from "@/app/lib/api/tokens"; import { positionAtEnd } from "@/app/lib/ordering"; import { createSession } from "@/app/lib/session"; +import { messagesFor } from "@/app/i18n/config"; import { __clearCookies, __clearHeaders } from "../stubs/next-headers"; import type { GlobalRole } from "@/app/lib/roles"; import type { BoardRole } from "@/app/lib/roles"; -// Utilidades compartidas por los tests de integración. Usan el MISMO PrismaClient que la -// app (apuntado a la BD de test por DATABASE_URL), así que los route handlers y los tests -// ven la misma conexión. +// Utilities shared by the integration tests. They use the SAME PrismaClient as the app +// (pointed at the test DB by DATABASE_URL), so route handlers and tests share the same +// connection. -// --- Guardia de seguridad --------------------------------------------------------------- -// Truncar tablas es destructivo: nos negamos a correr si la base de datos no acaba en -// `_test`. Evita borrar datos de desarrollo o, peor, de producción por error. +// --- Messages --------------------------------------------------------------------------- +// Texts the app returns, taken from locales/ so that tests do not duplicate strings. The +// stub request has neither a `locale` cookie nor Accept-Language, so actions answer in +// English (`en`) unless a test sets one of them. +export const en = messagesFor("en"); +export const es = messagesFor("es"); + +// --- Safety guard ----------------------------------------------------------------------- +// Truncating tables is destructive: we refuse to run unless the database name ends in +// `_test`. Prevents wiping development or, worse, production data by mistake. function assertTestDatabase(): void { const url = process.env.DATABASE_URL ?? ""; const dbName = url.split("/").pop()?.split("?")[0] ?? ""; if (!dbName.endsWith("_test")) { throw new Error( - `Los tests de integración exigen una base de datos cuyo nombre acabe en "_test" ` + - `(DATABASE_URL apunta a "${dbName || "(vacío)"}"). Abortando para no tocar datos reales.`, + `The integration tests require a database whose name ends in "_test" ` + + `(DATABASE_URL points at "${dbName || "(empty)"}"). Aborting so no real data is touched.`, ); } } -// Tablas a vaciar: se leen del catálogo en cada reset en vez de mantener una lista a mano -// (a la lista fija le faltaban CardAssignee y Comment, que solo se vaciaban por el -// CASCADE; una tabla futura sin FK se habría quedado con datos entre tests). +// Tables to empty: read from the catalog on every reset instead of keeping a list by hand +// (the fixed list was missing CardAssignee and Comment, which were only emptied through the +// CASCADE; a future table without an FK would have kept data between tests). const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; async function schemaTables(): Promise<string[]> { @@ -35,9 +43,9 @@ async function schemaTables(): Promise<string[]> { SELECT tablename FROM pg_tables WHERE schemaname = current_schema() AND tablename <> '_prisma_migrations'`; return rows.map(({ tablename }) => { - // El nombre se interpola en el TRUNCATE: solo se aceptan identificadores simples. + // The name is interpolated into the TRUNCATE: only simple identifiers are accepted. if (!TABLE_NAME.test(tablename)) { - throw new Error(`resetDb: nombre de tabla inesperado "${tablename}"`); + throw new Error(`resetDb: unexpected table name "${tablename}"`); } return tablename; }); @@ -47,14 +55,14 @@ export async function resetDb(): Promise<void> { assertTestDatabase(); const tables = await schemaTables(); if (tables.length === 0) { - throw new Error("resetDb: el esquema no tiene tablas; ¿faltan las migraciones?"); + throw new Error("resetDb: the schema has no tables; are the migrations missing?"); } const list = tables.map((t) => `"${t}"`).join(", "); await prisma.$executeRawUnsafe( `TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE`, ); - // Estado en memoria que sobrevive entre tests del mismo archivo: la "petición" del stub - // de next/headers (cookies de sesión, cabeceras) y los contadores del rate limit. + // In-memory state that survives between tests of the same file: the next/headers stub's + // "request" (session cookies, headers) and the rate-limit counters. __clearCookies(); __clearHeaders(); ( @@ -63,10 +71,10 @@ export async function resetDb(): Promise<void> { } /** - * Inicia sesión como `userId` en el stub de next/headers: crea una fila Session real y - * deja la cookie firmada, igual que el login. A partir de ahí las Server Actions y el DAL - * ven a ese usuario. Llamarla otra vez cambia de usuario. Solo vacía las cookies: las - * cabeceras puestas antes con `__setHeaders` se mantienen (p. ej. X-Forwarded-Proto). + * Signs in as `userId` in the next/headers stub: creates a real Session row and sets the + * signed cookie, just like the login does. From then on Server Actions and the DAL see that + * user. Calling it again switches user. It only clears the cookies: headers set earlier + * with `__setHeaders` are kept (e.g. X-Forwarded-Proto). */ export async function loginAs(userId: string): Promise<void> { __clearCookies(); @@ -87,16 +95,16 @@ export async function seedUser(opts?: { }): Promise<{ id: string; email: string }> { const email = opts?.email ?? `user_${Math.random().toString(36).slice(2)}@test.local`; const result = await createUserAccount({ - name: opts?.name ?? "Usuario de prueba", + name: opts?.name ?? "Test user", email, password: opts?.password ?? "contraseña-segura", role: opts?.role ?? "MEMBER", }); - if (!result.ok) throw new Error(`seedUser falló: ${result.error}`); + if (!result.ok) throw new Error(`seedUser failed: ${result.error}`); return result.user; } -/** Crea un token de API para un usuario y devuelve el token EN CLARO (para el Bearer). */ +/** Creates an API token for a user and returns the PLAIN token (for the Bearer header). */ export async function seedApiToken( userId: string, opts?: { name?: string; scope?: "full" | "read"; expiresAt?: Date | null }, @@ -115,10 +123,10 @@ export async function seedApiToken( return token; } -/** Crea un tablero con su propietario (miembro OWNER), como hace createBoard. */ +/** Creates a board with its owner (OWNER member), as createBoard does. */ export async function seedBoard( ownerId: string, - title = "Tablero de prueba", + title = "Test board", ): Promise<{ id: string; title: string }> { const board = await prisma.board.create({ data: { @@ -141,7 +149,7 @@ export async function addMember( export async function seedList( boardId: string, - title = "Lista de prueba", + title = "Test list", ): Promise<{ id: string }> { const last = await prisma.list.findFirst({ where: { boardId }, @@ -156,7 +164,7 @@ export async function seedList( export async function seedCard( listId: string, - title = "Tarjeta de prueba", + title = "Test card", ): Promise<{ id: string }> { const last = await prisma.card.findFirst({ where: { listId }, @@ -169,9 +177,9 @@ export async function seedCard( }); } -// --- Petición a un route handler -------------------------------------------------------- +// --- Request to a route handler --------------------------------------------------------- -/** Construye un `Request` con (opcional) Bearer y cuerpo JSON, para invocar un handler. */ +/** Builds a `Request` with an (optional) Bearer token and JSON body, to call a handler. */ export function apiRequest( method: string, url: string, @@ -187,7 +195,7 @@ export function apiRequest( }); } -/** Azúcar para el segundo argumento `{ params }` de los route handlers de Next 16. */ +/** Shorthand for the second `{ params }` argument of Next 16 route handlers. */ export function ctx<T extends Record<string, string>>(params: T) { return { params: Promise.resolve(params) }; } diff --git a/test/integration/labels.int.test.ts b/test/integration/labels.int.test.ts index 5594448..6d3cf2b 100644 --- a/test/integration/labels.int.test.ts +++ b/test/integration/labels.int.test.ts @@ -25,23 +25,23 @@ async function seedTwoBoards() { const boardB = await seedBoard(owner.id, "B"); const cardA = await seedCard((await seedList(boardA.id)).id); const labelA = await prisma.label.create({ - data: { boardId: boardA.id, name: "Urgente", color: "red" }, + data: { boardId: boardA.id, name: "Urgent", color: "red" }, }); const labelB = await prisma.label.create({ - data: { boardId: boardB.id, name: "Secreto de B", color: "green" }, + data: { boardId: boardB.id, name: "B's secret", color: "green" }, }); return { owner, boardA, boardB, cardA, labelA, labelB }; } -describe("etiquetas: pertenencia al tablero (IDOR de toggleCardLabel)", () => { - it("labelBelongsToBoard solo es true para el tablero real de la etiqueta", async () => { +describe("labels: board membership (toggleCardLabel IDOR)", () => { + it("labelBelongsToBoard is only true for the label's actual board", async () => { const { boardA, boardB, labelB } = await seedTwoBoards(); expect(await labelBelongsToBoard(labelB.id, boardB.id)).toBe(true); expect(await labelBelongsToBoard(labelB.id, boardA.id)).toBe(false); - expect(await labelBelongsToBoard("cminexistente000000000000", boardA.id)).toBe(false); + expect(await labelBelongsToBoard("cmmissing0000000000000000", boardA.id)).toBe(false); }); - it("toggleCardLabel rechaza una etiqueta de otro tablero y no crea el vínculo", async () => { + it("toggleCardLabel rejects a label from another board and does not create the link", async () => { const { owner, cardA, labelB } = await seedTwoBoards(); await loginAs(owner.id); @@ -50,16 +50,16 @@ describe("etiquetas: pertenencia al tablero (IDOR de toggleCardLabel)", () => { expect(await prisma.cardLabel.count()).toBe(0); }); - it("toggleCardLabel rechaza un id inexistente sin lanzar", async () => { + it("toggleCardLabel rejects a missing id without throwing", async () => { const { owner, cardA } = await seedTwoBoards(); await loginAs(owner.id); - const res = await toggleCardLabel({ cardId: cardA.id, labelId: "no-existe" }); + const res = await toggleCardLabel({ cardId: cardA.id, labelId: "does-not-exist" }); expect(res).toEqual({ ok: false }); expect(await prisma.cardLabel.count()).toBe(0); }); - it("toggleCardLabel pone y quita una etiqueta del mismo tablero", async () => { + it("toggleCardLabel adds and removes a label from the same board", async () => { const { owner, cardA, labelA } = await seedTwoBoards(); await loginAs(owner.id); @@ -74,15 +74,15 @@ describe("etiquetas: pertenencia al tablero (IDOR de toggleCardLabel)", () => { }); }); -describe("etiquetas: color validado contra la paleta", () => { - it("createLabel guarda 'blue' si el color es una clave del prototipo", async () => { +describe("labels: color validated against the palette", () => { + it("createLabel stores 'blue' if the color is a prototype key", async () => { const owner = await seedUser(); const board = await seedBoard(owner.id); await loginAs(owner.id); const fd = new FormData(); fd.set("boardId", board.id); - fd.set("name", "Rara"); + fd.set("name", "Odd"); fd.set("color", "constructor"); await createLabel(fd); @@ -90,14 +90,14 @@ describe("etiquetas: color validado contra la paleta", () => { expect(label.color).toBe("blue"); }); - it("createLabel conserva un color válido de la paleta", async () => { + it("createLabel keeps a valid palette color", async () => { const owner = await seedUser(); const board = await seedBoard(owner.id); await loginAs(owner.id); const fd = new FormData(); fd.set("boardId", board.id); - fd.set("name", "Bien"); + fd.set("name", "Fine"); fd.set("color", "teal"); await createLabel(fd); diff --git a/test/integration/locale.int.test.ts b/test/integration/locale.int.test.ts new file mode 100644 index 0000000..0d45f8b --- /dev/null +++ b/test/integration/locale.int.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, afterAll } from "vitest"; +import { cookies } from "next/headers"; +import { prisma } from "@/app/lib/prisma"; +import { setLocale } from "@/app/lib/actions/locale"; +import { login } from "@/app/lib/actions/auth"; +import { __getCookieOptions, __setHeaders } from "../stubs/next-headers"; +import { resetDb, disconnect, seedUser, loginAs, en, es } from "./helpers"; + +// setLocale (language picker): stores the choice in the `locale` cookie and, when signed +// in, in User.locale, so that notifications for that person are written in it. + +beforeEach(async () => { + await resetDb(); +}); +afterAll(async () => { + await disconnect(); +}); + +function localeForm(locale: string): FormData { + const fd = new FormData(); + fd.set("locale", locale); + return fd; +} + +async function localeCookie(): Promise<string | undefined> { + return (await cookies()).get("locale")?.value; +} + +async function storedLocale(userId: string): Promise<string | null> { + return (await prisma.user.findUniqueOrThrow({ where: { id: userId } })).locale; +} + +describe("setLocale (integration)", () => { + it("stores the cookie and User.locale for a signed-in user", async () => { + const user = await seedUser(); + await loginAs(user.id); + + await setLocale(localeForm("es")); + + expect(await localeCookie()).toBe("es"); + expect(__getCookieOptions("locale")).toMatchObject({ + path: "/", + sameSite: "lax", + secure: false, + }); + expect(await storedLocale(user.id)).toBe("es"); + + await setLocale(localeForm("en")); + expect(await localeCookie()).toBe("en"); + expect(await storedLocale(user.id)).toBe("en"); + }); + + it("marks the cookie Secure behind HTTPS", async () => { + __setHeaders({ "x-forwarded-proto": "https" }); + await setLocale(localeForm("es")); + expect(__getCookieOptions("locale")).toMatchObject({ secure: true }); + }); + + it("without a session only stores the cookie", async () => { + const user = await seedUser(); + + await setLocale(localeForm("es")); + + expect(await localeCookie()).toBe("es"); + expect(await storedLocale(user.id)).toBeNull(); + }); + + it("rejects an unknown code: no cookie and User.locale unchanged", async () => { + const user = await seedUser(); + await loginAs(user.id); + + for (const bad of ["xx", "ES", "", "constructor", "es;q=1"]) { + await setLocale(localeForm(bad)); + } + await setLocale(new FormData()); + + expect(await localeCookie()).toBeUndefined(); + expect(await storedLocale(user.id)).toBeNull(); + }); + + it("an unknown code does not overwrite an earlier valid choice", async () => { + const user = await seedUser(); + await loginAs(user.id); + await setLocale(localeForm("es")); + + await setLocale(localeForm("xx")); + + expect(await localeCookie()).toBe("es"); + expect(await storedLocale(user.id)).toBe("es"); + }); + + it("after choosing Spanish, the actions answer in Spanish", async () => { + expect((await login({}, loginForm())).error).toBe(en.errors.auth.invalidCredentials); + + await setLocale(localeForm("es")); + + expect((await login({}, loginForm())).error).toBe(es.errors.auth.invalidCredentials); + }); +}); + +function loginForm(): FormData { + const fd = new FormData(); + fd.set("email", "someone@test.local"); + fd.set("password", "wrong-password"); + return fd; +} diff --git a/test/integration/maintenance.int.test.ts b/test/integration/maintenance.int.test.ts new file mode 100644 index 0000000..3b7b819 --- /dev/null +++ b/test/integration/maintenance.int.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, afterAll } from "vitest"; +import { prisma } from "@/app/lib/prisma"; +import { purgeExpiredData } from "@/app/lib/services/maintenance"; +import { POST } from "@/app/api/cron/maintenance/route"; +import { resetDb, disconnect, seedUser } from "./helpers"; + +const DAY = 24 * 60 * 60 * 1000; + +beforeEach(async () => { + await resetDb(); +}); +afterAll(async () => { + await disconnect(); +}); + +describe("purgeExpiredData", () => { + it("deletes what expired under the privacy policy's retention periods, and nothing else", async () => { + const now = new Date("2026-10-14T12:00:00Z"); + const ago = (days: number) => new Date(now.getTime() - days * DAY); + const user = await seedUser(); + + await prisma.auditLog.createMany({ + data: [ + { action: "auth.login", createdAt: ago(91), ip: "203.0.113.1" }, + { action: "auth.login", createdAt: ago(89), ip: "203.0.113.2" }, + ], + }); + await prisma.session.createMany({ + data: [ + { userId: user.id, createdAt: ago(40), expiresAt: ago(33) }, // expired >30 d ago + { userId: user.id, createdAt: ago(10), expiresAt: ago(3) }, // expired recently + { userId: user.id, createdAt: ago(40), expiresAt: ago(33 - 7), revokedAt: ago(35) }, // revoked >30 d ago + { userId: user.id, createdAt: ago(1), expiresAt: new Date(now.getTime() + 6 * DAY) }, // still valid + ], + }); + await prisma.notification.createMany({ + data: [ + { userId: user.id, type: "x", title: "old", createdAt: ago(181) }, + { userId: user.id, type: "x", title: "recent", createdAt: ago(179) }, + ], + }); + + const result = await purgeExpiredData(now); + expect(result).toEqual({ auditLogsDeleted: 1, sessionsDeleted: 2, notificationsDeleted: 1 }); + expect((await prisma.auditLog.findMany()).map((a) => a.ip)).toEqual(["203.0.113.2"]); + expect(await prisma.session.count()).toBe(2); + expect((await prisma.notification.findMany()).map((n) => n.title)).toEqual(["recent"]); + }); + + it("honours ORGTABLE_AUDIT_RETENTION_DAYS and fails with an invalid value", async () => { + const now = new Date(); + await prisma.auditLog.create({ + data: { action: "auth.login", createdAt: new Date(now.getTime() - 10 * DAY) }, + }); + process.env.ORGTABLE_AUDIT_RETENTION_DAYS = "7"; + try { + expect((await purgeExpiredData(now)).auditLogsDeleted).toBe(1); + process.env.ORGTABLE_AUDIT_RETENTION_DAYS = "0"; + await expect(purgeExpiredData(now)).rejects.toThrow(/ORGTABLE_AUDIT_RETENTION_DAYS/); + } finally { + delete process.env.ORGTABLE_AUDIT_RETENTION_DAYS; + } + }); +}); + +describe("POST /api/cron/maintenance", () => { + const req = (auth?: string) => + new Request("http://localhost/api/cron/maintenance", { + method: "POST", + headers: auth ? { authorization: auth } : {}, + }); + + it("503 without CRON_SECRET, 401 with a wrong secret and 200 with the right one", async () => { + delete process.env.CRON_SECRET; + expect((await POST(req("Bearer x"))).status).toBe(503); + + process.env.CRON_SECRET = "long-test-secret"; + try { + expect((await POST(req("Bearer other"))).status).toBe(401); + const ok = await POST(req("Bearer long-test-secret")); + expect(ok.status).toBe(200); + expect(await ok.json()).toMatchObject({ auditLogsDeleted: 0 }); + } finally { + delete process.env.CRON_SECRET; + } + }); +}); diff --git a/test/integration/members.int.test.ts b/test/integration/members.int.test.ts index 13b0ce8..aabe3be 100644 --- a/test/integration/members.int.test.ts +++ b/test/integration/members.int.test.ts @@ -16,6 +16,7 @@ import { seedCard, addMember, loginAs, + en, } from "./helpers"; function form(fields: Record<string, string>): FormData { @@ -31,12 +32,12 @@ afterAll(async () => { await disconnect(); }); -describe("expulsar miembros (integración)", () => { - it("removeMemberFromBoard borra sus asignaciones en ese tablero y no en otros", async () => { +describe("removing members (integration)", () => { + it("removeMemberFromBoard deletes their assignments on that board and not on others", async () => { const owner = await seedUser(); const editor = await seedUser(); const board = await seedBoard(owner.id); - const other = await seedBoard(owner.id, "Otro tablero"); + const other = await seedBoard(owner.id, "Another board"); await addMember(board.id, editor.id); await addMember(other.id, editor.id); const card = await seedCard((await seedList(board.id)).id); @@ -62,7 +63,7 @@ describe("expulsar miembros (integración)", () => { expect(left).toEqual([{ cardId: otherCard.id }]); }); - it("removeBoardMember notifica y audita una sola vez, aunque se repita", async () => { + it("removeBoardMember notifies and audits only once, even if repeated", async () => { const owner = await seedUser(); const editor = await seedUser(); const board = await seedBoard(owner.id); @@ -83,7 +84,7 @@ describe("expulsar miembros (integración)", () => { ).toBe(1); }); - it("removeBoardMember con un usuario que no es miembro no notifica ni audita", async () => { + it("removeBoardMember with a user who is not a member neither notifies nor audits", async () => { const owner = await seedUser(); const stranger = await seedUser(); const board = await seedBoard(owner.id); @@ -96,8 +97,8 @@ describe("expulsar miembros (integración)", () => { }); }); -describe("cambiar el rol (integración)", () => { - it("con un usuario que no es miembro no notifica ni audita", async () => { +describe("changing the role (integration)", () => { + it("with a user who is not a member neither notifies nor audits", async () => { const owner = await seedUser(); const stranger = await seedUser(); const board = await seedBoard(owner.id); @@ -111,7 +112,7 @@ describe("cambiar el rol (integración)", () => { expect(await prisma.auditLog.count()).toBe(0); }); - it("cambia el rol una vez y no repite el aviso si el rol no cambia", async () => { + it("changes the role once and does not repeat the notification if the role stays the same", async () => { const owner = await seedUser(); const editor = await seedUser(); const board = await seedBoard(owner.id); @@ -138,8 +139,8 @@ describe("cambiar el rol (integración)", () => { }); }); -describe("tiempo real en la gestión de miembros (integración)", () => { - it("cambiar el rol y expulsar publican board:changed en el bus", async () => { +describe("real time in member management (integration)", () => { + it("changing the role and removing publish board:changed on the bus", async () => { const owner = await seedUser(); const editor = await seedUser(); const board = await seedBoard(owner.id); @@ -161,7 +162,7 @@ describe("tiempo real en la gestión de miembros (integración)", () => { expect(events.every((e) => e.type === "board:changed")).toBe(true); }); - it("invitar publica board:changed en el bus", async () => { + it("inviting publishes board:changed on the bus", async () => { const owner = await seedUser(); const guest = await seedUser(); const board = await seedBoard(owner.id); @@ -182,22 +183,22 @@ describe("tiempo real en la gestión de miembros (integración)", () => { }); }); -describe("formulario de invitación (integración)", () => { - it("con un email que no existe devuelve lo enviado para no vaciar el campo", async () => { +describe("invitation form (integration)", () => { + it("with an email that does not exist it returns what was sent so the field is not cleared", async () => { const owner = await seedUser(); const board = await seedBoard(owner.id); await loginAs(owner.id); const state = await addBoardMember( {}, - form({ boardId: board.id, email: "nadie@test.local", role: "VIEWER" }), + form({ boardId: board.id, email: "nobody@test.local", role: "VIEWER" }), ); expect(state.error).toBeDefined(); - expect(state.values).toEqual({ email: "nadie@test.local", role: "VIEWER" }); + expect(state.values).toEqual({ email: "nobody@test.local", role: "VIEWER" }); }); - it("si la invitación sale bien no devuelve valores (el formulario se vacía)", async () => { + it("if the invitation succeeds it returns no values (the form is cleared)", async () => { const owner = await seedUser(); const guest = await seedUser(); const board = await seedBoard(owner.id); @@ -213,12 +214,12 @@ describe("formulario de invitación (integración)", () => { }); }); -// Con la sesión real, estas acciones pasan por requireBoardAccess + canManageBoard. Antes -// el DAL estaba sustituido y devolvía el rol que pedía el test, así que una guarda rota no -// se habría notado. -describe("autorización de la gestión de miembros (integración)", () => { +// With the real session, these actions go through requireBoardAccess + canManageBoard. +// The DAL used to be mocked and returned whatever role the test asked for, so a broken +// guard would have gone unnoticed. +describe("authorization of member management (integration)", () => { for (const role of ["EDITOR", "VIEWER"] as const) { - it(`un ${role} no puede invitar, cambiar roles ni expulsar`, async () => { + it(`a ${role} cannot invite, change roles or remove`, async () => { const owner = await seedUser(); const actor = await seedUser(); const target = await seedUser(); @@ -232,7 +233,7 @@ describe("autorización de la gestión de miembros (integración)", () => { {}, form({ boardId: board.id, email: guest.email, role: "EDITOR" }), ); - expect(state.error).toBe("Solo el propietario puede gestionar los miembros."); + expect(state.error).toBe(en.errors.members.ownerOnly); await updateBoardMemberRole( form({ boardId: board.id, userId: target.id, role: "VIEWER" }), ); diff --git a/test/integration/notifications-read.int.test.ts b/test/integration/notifications-read.int.test.ts index ec4b011..1a2ea54 100644 --- a/test/integration/notifications-read.int.test.ts +++ b/test/integration/notifications-read.int.test.ts @@ -13,10 +13,10 @@ import { } from "@/app/lib/actions/notifications"; import { resetDb, disconnect, seedUser, loginAs } from "./helpers"; -// Regresión: al abrir una notificación con tablero no se refrescaba el router y el -// contador de no leídas del layout se quedaba igual. Ahora las acciones marcan como -// leída y llaman a refresh() de next/cache. El badge en sí solo se ve en el navegador; -// aquí se comprueba el dato y que se pide el refresh. +// Regression: opening a notification with a board did not refresh the router, and the +// layout's unread counter stayed the same. Now the actions mark it as read and call +// refresh() from next/cache. The badge itself is only visible in the browser; here the +// data is checked, and that the refresh is requested. const refreshMock = vi.hoisted(() => vi.fn()); vi.mock("next/cache", () => ({ @@ -27,7 +27,7 @@ vi.mock("next/cache", () => ({ async function seedNotification(userId: string, boardId: string | null = null) { return prisma.notification.create({ - data: { userId, type: "board.added", title: "Te añadieron", boardId }, + data: { userId, type: "board.added", title: "You were added", boardId }, select: { id: true }, }); } @@ -41,10 +41,10 @@ afterAll(async () => { await disconnect(); }); -describe("acciones de notificaciones", () => { - it("markNotificationRead marca la propia como leída y pide refresh", async () => { +describe("notification actions", () => { + it("markNotificationRead marks your own as read and requests a refresh", async () => { const user = await seedUser(); - const n = await seedNotification(user.id, "tablero-cualquiera"); + const n = await seedNotification(user.id, "any-board"); await loginAs(user.id); await markNotificationRead(n.id); @@ -56,7 +56,7 @@ describe("acciones de notificaciones", () => { expect(refreshMock).toHaveBeenCalledTimes(1); }); - it("markNotificationRead no toca las notificaciones de otra persona", async () => { + it("markNotificationRead does not touch someone else's notifications", async () => { const user = await seedUser(); const other = await seedUser(); const foreign = await seedNotification(other.id); @@ -70,11 +70,11 @@ describe("acciones de notificaciones", () => { expect(after.read).toBe(false); }); - it("markAllNotificationsRead marca todas las propias y pide refresh", async () => { + it("markAllNotificationsRead marks all of your own and requests a refresh", async () => { const user = await seedUser(); const other = await seedUser(); await seedNotification(user.id); - await seedNotification(user.id, "tablero-cualquiera"); + await seedNotification(user.id, "any-board"); await seedNotification(other.id); await loginAs(user.id); diff --git a/test/integration/notifications.int.test.ts b/test/integration/notifications.int.test.ts index e507c16..ff4dff9 100644 --- a/test/integration/notifications.int.test.ts +++ b/test/integration/notifications.int.test.ts @@ -1,7 +1,26 @@ import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; +import { cookies } from "next/headers"; import { prisma } from "@/app/lib/prisma"; -import { notify } from "@/app/lib/notifications"; -import { resetDb, disconnect, seedUser } from "./helpers"; +import { notify, recipientI18n } from "@/app/lib/notifications"; +import { + addBoardMember, + removeBoardMember, + updateBoardMemberRole, +} from "@/app/lib/actions/members"; +import { addComment } from "@/app/lib/actions/comments"; +import { fmt } from "@/app/i18n/format"; +import { + resetDb, + disconnect, + seedUser, + seedBoard, + seedList, + seedCard, + addMember, + loginAs, + en, + es, +} from "./helpers"; beforeEach(async () => { await resetDb(); @@ -13,36 +32,193 @@ afterAll(async () => { await disconnect(); }); -describe("notify (integración)", () => { - it("devuelve true y guarda la fila cuando la notificación se crea", async () => { +function form(fields: Record<string, string>): FormData { + const fd = new FormData(); + for (const [k, v] of Object.entries(fields)) fd.set(k, v); + return fd; +} + +async function setUserLocale(userId: string, locale: string | null): Promise<void> { + await prisma.user.update({ where: { id: userId }, data: { locale } }); +} + +async function notificationsOf(userId: string) { + return prisma.notification.findMany({ + where: { userId }, + select: { type: true, title: true, body: true }, + }); +} + +describe("notify (integration)", () => { + it("returns true and stores the row when the notification is created", async () => { const user = await seedUser(); const ok = await notify({ userId: user.id, type: "member.added", - title: "Te han añadido", + title: "You were added", }); expect(ok).toBe(true); expect(await prisma.notification.count({ where: { userId: user.id } })).toBe(1); }); - it("devuelve false y registra el fallo sin el texto si la inserción falla", async () => { + it("returns false and logs the failure without the text if the insert fails", async () => { const logged = vi.spyOn(console, "error").mockImplementation(() => {}); - // userId inexistente: la clave foránea hace fallar el insert. + // A userId that does not exist: the foreign key makes the insert fail. const ok = await notify({ - userId: "no-existe", + userId: "does-not-exist", type: "comment.added", - title: "Título privado", - body: "Cuerpo privado", + title: "Private title", + body: "Private body", }); expect(ok).toBe(false); expect(await prisma.notification.count()).toBe(0); expect(logged).toHaveBeenCalledTimes(1); const serialized = JSON.stringify(logged.mock.calls[0]); - expect(serialized).toContain("no-existe"); - expect(serialized).not.toContain("privado"); + expect(serialized).toContain("does-not-exist"); + expect(serialized).not.toContain("Private"); + }); +}); + +// Notifications are rendered when created, in the RECIPIENT's language (User.locale, +// English when unset); the message returned to the form is in the ACTOR's language. +describe("notification language (integration)", () => { + it("recipientI18n uses User.locale and falls back to English", async () => { + const spanish = await seedUser(); + const unset = await seedUser(); + const unknown = await seedUser(); + await setUserLocale(spanish.id, "es"); + await setUserLocale(unknown.id, "xx"); + + expect((await recipientI18n(spanish.id)).locale).toBe("es"); + expect((await recipientI18n(unset.id)).locale).toBe("en"); + expect((await recipientI18n(unknown.id)).locale).toBe("en"); + expect((await recipientI18n("does-not-exist")).locale).toBe("en"); + }); + + it("an invitation for a user with locale “es” is written in Spanish", async () => { + const owner = await seedUser(); + const guest = await seedUser(); + await setUserLocale(guest.id, "es"); + const board = await seedBoard(owner.id, "Roadmap"); + await loginAs(owner.id); + + const state = await addBoardMember( + {}, + form({ boardId: board.id, email: guest.email, role: "EDITOR" }), + ); + + // The actor has no language preference: the form answer is in English. + expect(state.message).toBe( + fmt(en.errors.members.added, { name: "Test user", role: en.notifications.roles.EDITOR }), + ); + expect(await notificationsOf(guest.id)).toEqual([ + { + type: "member.added", + title: fmt(es.notifications.memberAdded.title, { board: "Roadmap" }), + body: fmt(es.notifications.memberAdded.body, { role: es.notifications.roles.EDITOR }), + }, + ]); + }); + + it("an invitation for a user with locale null is written in English", async () => { + const owner = await seedUser(); + const guest = await seedUser(); + const board = await seedBoard(owner.id, "Roadmap"); + await loginAs(owner.id); + + await addBoardMember({}, form({ boardId: board.id, email: guest.email, role: "VIEWER" })); + + expect(await notificationsOf(guest.id)).toEqual([ + { + type: "member.added", + title: fmt(en.notifications.memberAdded.title, { board: "Roadmap" }), + body: fmt(en.notifications.memberAdded.body, { role: en.notifications.roles.VIEWER }), + }, + ]); + }); + + it("an actor browsing in Spanish gets a Spanish answer, the recipient still gets English", async () => { + const owner = await seedUser(); + const guest = await seedUser(); + const board = await seedBoard(owner.id, "Roadmap"); + await loginAs(owner.id); + (await cookies()).set("locale", "es"); + + const state = await addBoardMember( + {}, + form({ boardId: board.id, email: guest.email, role: "EDITOR" }), + ); + + expect(state.message).toBe( + fmt(es.errors.members.added, { name: "Test user", role: es.notifications.roles.EDITOR }), + ); + const [note] = await notificationsOf(guest.id); + expect(note?.title).toBe(fmt(en.notifications.memberAdded.title, { board: "Roadmap" })); + }); + + it("role change and removal follow each recipient's language", async () => { + const owner = await seedUser(); + const spanish = await seedUser(); + const english = await seedUser(); + await setUserLocale(spanish.id, "es"); + const board = await seedBoard(owner.id, "Roadmap"); + await addMember(board.id, spanish.id, "EDITOR"); + await addMember(board.id, english.id, "EDITOR"); + await loginAs(owner.id); + + for (const userId of [spanish.id, english.id]) { + await updateBoardMemberRole(form({ boardId: board.id, userId, role: "VIEWER" })); + await removeBoardMember(form({ boardId: board.id, userId })); + } + + const byType = async (userId: string, type: string) => + (await notificationsOf(userId)).find((n) => n.type === type); + + expect(await byType(spanish.id, "member.role_changed")).toEqual({ + type: "member.role_changed", + title: fmt(es.notifications.memberRoleChanged.title, { board: "Roadmap" }), + body: fmt(es.notifications.memberRoleChanged.body, { role: es.notifications.roles.VIEWER }), + }); + expect((await byType(spanish.id, "member.removed"))?.title).toBe( + fmt(es.notifications.memberRemoved.title, { board: "Roadmap" }), + ); + expect(await byType(english.id, "member.role_changed")).toEqual({ + type: "member.role_changed", + title: fmt(en.notifications.memberRoleChanged.title, { board: "Roadmap" }), + body: fmt(en.notifications.memberRoleChanged.body, { role: en.notifications.roles.VIEWER }), + }); + expect((await byType(english.id, "member.removed"))?.title).toBe( + fmt(en.notifications.memberRemoved.title, { board: "Roadmap" }), + ); + }); + + it("comment.added is written in each assignee's language", async () => { + const owner = await seedUser(); + const spanish = await seedUser(); + const english = await seedUser(); + await setUserLocale(spanish.id, "es"); + const board = await seedBoard(owner.id); + await addMember(board.id, spanish.id, "EDITOR"); + await addMember(board.id, english.id, "EDITOR"); + const card = await seedCard((await seedList(board.id)).id, "Launch"); + await prisma.cardAssignee.createMany({ + data: [ + { cardId: card.id, userId: spanish.id }, + { cardId: card.id, userId: english.id }, + ], + }); + await loginAs(owner.id); + + await addComment(form({ cardId: card.id, text: "Ready" })); + + const [toSpanish] = await notificationsOf(spanish.id); + const [toEnglish] = await notificationsOf(english.id); + expect(toSpanish?.title).toBe(fmt(es.notifications.commentAdded.title, { card: "Launch" })); + expect(toEnglish?.title).toBe(fmt(en.notifications.commentAdded.title, { card: "Launch" })); + expect(toSpanish?.title).not.toBe(toEnglish?.title); }); }); diff --git a/test/integration/position-collation.int.test.ts b/test/integration/position-collation.int.test.ts index 4649023..d05edfb 100644 --- a/test/integration/position-collation.int.test.ts +++ b/test/integration/position-collation.int.test.ts @@ -18,22 +18,22 @@ import { loginAs, } from "./helpers"; -// Las claves de fractional indexing (base62: 0-9A-Za-z) solo están bien ordenadas byte a -// byte. 'Zz' es la clave inmediatamente anterior a 'a0' (se genera al arrastrar al -// principio), pero con una collation lingüística (en_US.utf8 sobre glibc, la imagen -// Debian de postgres que usa el CI) queda DESPUÉS de 'a0l'. La migración -// 20260924000000_position_collate_c fija COLLATE "C" en las cuatro columnas. +// Fractional indexing keys (base62: 0-9A-Za-z) are only correctly ordered byte by byte. +// 'Zz' is the key right before 'a0' (generated when dragging to the top), but with a +// linguistic collation (en_US.utf8 on glibc, the Debian postgres image the CI uses) it +// sorts AFTER 'a0l'. Migration 20260924000000_position_collate_c sets COLLATE "C" on the +// four columns. // -// En postgres alpine (musl, la BD de desarrollo) el orden ya era byte a byte, así que -// las pruebas de comportamiento solo fallarían sin el arreglo en glibc; la primera -// prueba comprueba la collation de las columnas y falla en cualquier servidor sin él. +// On postgres alpine (musl, the development DB) the order was already byte by byte, so +// the behaviour tests would only fail without the fix on glibc; the first test checks the +// columns' collation and fails on any server without it. const KEYS = ["a0", "a0V", "a0l", "Zz"]; const EXPECTED_ORDER = ["Zz", "a0", "a0V", "a0l"]; -// También falla sin arrastrar: las altas al final generan a0…a9, aA…aZ y luego 'aa', que -// una collation lingüística ordena ANTES de 'aB'…'aZ'. Con 37 elementos (a0…aa) el -// «último» según ORDER BY DESC sería 'aZ' y el alta 38 repetiría 'aa'. +// It also fails without dragging: appending generates a0…a9, aA…aZ and then 'aa', which a +// linguistic collation sorts BEFORE 'aB'…'aZ'. With 37 items (a0…aa) the "last" one by +// ORDER BY DESC would be 'aZ' and the 38th insert would repeat 'aa'. function endKeys(count: number): string[] { const keys: string[] = []; let last: string | null = null; @@ -52,8 +52,8 @@ afterAll(async () => { await disconnect(); }); -describe("collation de las claves de posición (integración)", () => { - it("las columnas position usan la collation C", async () => { +describe("collation of the position keys (integration)", () => { + it("the position columns use the C collation", async () => { const rows = await prisma.$queryRaw< { table_name: string; collation_name: string | null }[] >`SELECT table_name, collation_name FROM information_schema.columns @@ -67,7 +67,7 @@ describe("collation de las claves de posición (integración)", () => { ]); }); - it("las tarjetas se ordenan byte a byte y createCard no repite claves", async () => { + it("cards are sorted byte by byte and createCard does not repeat keys", async () => { const owner = await seedUser(); const token = await seedApiToken(owner.id); const board = await seedBoard(owner.id); @@ -82,7 +82,7 @@ describe("collation de las claves de posición (integración)", () => { const res = await createCard( apiRequest("POST", `/api/v1/lists/${list.id}/cards`, { token, - body: { title: "nueva" }, + body: { title: "new" }, }), ctx({ id: list.id }), ); @@ -92,20 +92,20 @@ describe("collation de las claves de posición (integración)", () => { where: { listId: list.id }, select: { title: true, position: true }, }); - const created = cards.find((c) => c.title === "nueva"); - // Comparación de strings de JS = orden por unidades UTF-16, igual que byte a byte en - // ASCII: la nueva clave debe ir detrás de la mayor real ('a0l'). + const created = cards.find((c) => c.title === "new"); + // JS string comparison = order by UTF-16 code units, the same as byte by byte in ASCII: + // the new key must come after the actual largest one ('a0l'). expect(created && created.position > "a0l").toBe(true); expect(new Set(cards.map((c) => c.position)).size).toBe(cards.length); const after = await getBoardWithLists(board.id, owner.id); expect(after?.lists[0]?.cards.map((c) => c.title)).toEqual([ ...EXPECTED_ORDER, - "nueva", + "new", ]); }); - it("las listas se ordenan byte a byte y createList no repite claves", async () => { + it("lists are sorted byte by byte and createList does not repeat keys", async () => { const owner = await seedUser(); const token = await seedApiToken(owner.id); const board = await seedBoard(owner.id); @@ -119,7 +119,7 @@ describe("collation de las claves de posición (integración)", () => { const res = await createList( apiRequest("POST", `/api/v1/boards/${board.id}/lists`, { token, - body: { title: "nueva" }, + body: { title: "new" }, }), ctx({ id: board.id }), ); @@ -129,22 +129,22 @@ describe("collation de las claves de posición (integración)", () => { where: { boardId: board.id }, select: { title: true, position: true }, }); - const created = lists.find((l) => l.title === "nueva"); + const created = lists.find((l) => l.title === "new"); expect(created && created.position > "a0l").toBe(true); expect(new Set(lists.map((l) => l.position)).size).toBe(lists.length); const after = await getBoardWithLists(board.id, owner.id); - expect(after?.lists.map((l) => l.title)).toEqual([...EXPECTED_ORDER, "nueva"]); + expect(after?.lists.map((l) => l.title)).toEqual([...EXPECTED_ORDER, "new"]); }); - it("createChecklist y addChecklistItem no repiten claves tras 'aZ' → 'aa'", async () => { + it("createChecklist and addChecklistItem do not repeat keys after 'aZ' → 'aa'", async () => { expect(END_KEYS.slice(-2)).toEqual(["aZ", "aa"]); const owner = await seedUser(); const board = await seedBoard(owner.id); const list = await seedList(board.id); const card = await seedCard(list.id); - // Las acciones de checklist no tienen ruta en la API pública: se llaman con la sesión - // del propietario del tablero. + // The checklist actions have no route in the public API: they are called with the board + // owner's session. await loginAs(owner.id); await prisma.checklist.createMany({ data: END_KEYS.map((position) => ({ cardId: card.id, title: position, position })), @@ -152,7 +152,7 @@ describe("collation de las claves de posición (integración)", () => { const fd = new FormData(); fd.set("cardId", card.id); - fd.set("title", "nueva"); + fd.set("title", "new"); await createChecklist(fd); const checklists = await prisma.checklist.findMany({ @@ -160,12 +160,12 @@ describe("collation de las claves de posición (integración)", () => { select: { id: true, title: true, position: true }, }); expect(checklists).toHaveLength(END_KEYS.length + 1); - const created = checklists.find((c) => c.title === "nueva"); + const created = checklists.find((c) => c.title === "new"); expect(created && created.position > "aa").toBe(true); expect(new Set(checklists.map((c) => c.position)).size).toBe(checklists.length); const target = checklists.find((c) => c.title === "a0"); - if (!target) throw new Error("falta la checklist sembrada 'a0'"); + if (!target) throw new Error("the seeded checklist 'a0' is missing"); await prisma.checklistItem.createMany({ data: END_KEYS.map((position) => ({ checklistId: target.id, @@ -175,7 +175,7 @@ describe("collation de las claves de posición (integración)", () => { }); const itemFd = new FormData(); itemFd.set("checklistId", target.id); - itemFd.set("text", "nuevo"); + itemFd.set("text", "new item"); await addChecklistItem(itemFd); const items = await prisma.checklistItem.findMany({ @@ -183,7 +183,7 @@ describe("collation de las claves de posición (integración)", () => { select: { text: true, position: true }, }); expect(items).toHaveLength(END_KEYS.length + 1); - const createdItem = items.find((i) => i.text === "nuevo"); + const createdItem = items.find((i) => i.text === "new item"); expect(createdItem && createdItem.position > "aa").toBe(true); expect(new Set(items.map((i) => i.position)).size).toBe(items.length); }); diff --git a/test/integration/positions.int.test.ts b/test/integration/positions.int.test.ts index e8cfff2..7d38f91 100644 --- a/test/integration/positions.int.test.ts +++ b/test/integration/positions.int.test.ts @@ -24,12 +24,12 @@ afterAll(async () => { await disconnect(); }); -// Claves que antes se guardaban tal cual y dejaban la lista o el tablero sin poder crear -// nada (positionAtEnd lanzaba con ellas). 123 simula un cliente que envía un número. +// Keys that used to be stored as is and left the list or board unable to create anything +// (positionAtEnd threw on them). 123 simulates a client sending a number. const BAD_POSITIONS: unknown[] = ["x", "", 123, "a".repeat(10_000), "a0 ", "zzzz~"]; -describe("posiciones: moveCard y reorderList validan la clave", () => { - it.each(BAD_POSITIONS)("moveCard con %j da ok:false y no toca la BD", async (bad) => { +describe("positions: moveCard and reorderList validate the key", () => { + it.each(BAD_POSITIONS)("moveCard with %j gives ok:false and does not touch the DB", async (bad) => { const owner = await seedUser(); const board = await seedBoard(owner.id); const list = await seedList(board.id); @@ -46,19 +46,19 @@ describe("posiciones: moveCard y reorderList validan la clave", () => { const after = await prisma.card.findUniqueOrThrow({ where: { id: card.id } }); expect(after.position).toBe(before.position); - // La lista sigue admitiendo altas al final. + // The list still accepts inserts at the end. const token = await seedApiToken(owner.id); const created = await createCardApi( apiRequest("POST", `/api/v1/lists/${list.id}/cards`, { token, - body: { title: "Otra" }, + body: { title: "Another" }, }), ctx({ id: list.id }), ); expect(created.status).toBe(201); }); - it.each(BAD_POSITIONS)("reorderList con %j da ok:false y no toca la BD", async (bad) => { + it.each(BAD_POSITIONS)("reorderList with %j gives ok:false and does not touch the DB", async (bad) => { const owner = await seedUser(); const board = await seedBoard(owner.id); const list = await seedList(board.id); @@ -74,14 +74,14 @@ describe("posiciones: moveCard y reorderList validan la clave", () => { const created = await createListApi( apiRequest("POST", `/api/v1/boards/${board.id}/lists`, { token, - body: { title: "Otra" }, + body: { title: "Another" }, }), ctx({ id: board.id }), ); expect(created.status).toBe(201); }); - it("una clave válida se guarda (moveCard y reorderList)", async () => { + it("a valid key is saved (moveCard and reorderList)", async () => { const owner = await seedUser(); const board = await seedBoard(owner.id); const listA = await seedList(board.id); @@ -102,10 +102,10 @@ describe("posiciones: moveCard y reorderList validan la clave", () => { }); }); -// Regresión: leer la última posición y crear eran dos pasos sin bloqueo, así que altas -// simultáneas guardaban la misma clave. -describe("posiciones: altas concurrentes al final", () => { - it("10 POST en paralelo a /lists/:id/cards dan 10 posiciones distintas", async () => { +// Regression: reading the last position and creating were two steps without a lock, so +// simultaneous inserts stored the same key. +describe("positions: concurrent inserts at the end", () => { + it("10 parallel POSTs to /lists/:id/cards give 10 distinct positions", async () => { const owner = await seedUser(); const token = await seedApiToken(owner.id); const board = await seedBoard(owner.id); @@ -134,7 +134,7 @@ describe("posiciones: altas concurrentes al final", () => { expect(positions).toEqual([...positions].sort()); }); - it("10 POST en paralelo a /boards/:id/lists dan 10 posiciones distintas", async () => { + it("10 parallel POSTs to /boards/:id/lists give 10 distinct positions", async () => { const owner = await seedUser(); const token = await seedApiToken(owner.id); const board = await seedBoard(owner.id); diff --git a/test/integration/prisma-pool.int.test.ts b/test/integration/prisma-pool.int.test.ts index c973989..c0fa155 100644 --- a/test/integration/prisma-pool.int.test.ts +++ b/test/integration/prisma-pool.int.test.ts @@ -3,18 +3,18 @@ import { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import { buildPoolConfig } from "@/app/lib/prisma"; -// Configuración del pool de pg y singleton de PrismaClient (app/lib/prisma.ts). +// pg pool configuration and the PrismaClient singleton (app/lib/prisma.ts). -describe("pool de conexiones de Prisma", () => { - it("fija un timeout de conexión (pg espera para siempre por defecto)", () => { +describe("Prisma connection pool", () => { + it("sets a connection timeout (pg waits forever by default)", () => { const config = buildPoolConfig("postgresql://u:p@localhost:5432/x_test"); expect(config.connectionTimeoutMillis).toBe(5_000); expect(config.idleTimeoutMillis).toBe(10_000); }); - // Comprueba el cableado: el cliente que exporta el módulo se crea con buildPoolConfig y - // con onPoolError. Si alguien vuelve a `new PrismaPg({ connectionString })`, falla. - it("el cliente exportado usa buildPoolConfig y registra solo el mensaje de los errores", async () => { + // Checks the wiring: the client the module exports is created with buildPoolConfig and + // with onPoolError. If someone goes back to `new PrismaPg({ connectionString })`, it fails. + it("the exported client uses buildPoolConfig and logs only the error message", async () => { const g = globalThis as unknown as { prisma: PrismaClient | undefined }; const previous = g.prisma; const calls: ConstructorParameters<typeof PrismaPg>[] = []; @@ -37,10 +37,10 @@ describe("pool de conexiones de Prisma", () => { const [config, options] = calls[0]; expect(config).toEqual(buildPoolConfig(process.env.DATABASE_URL)); - // Se registra el mensaje, no el objeto Error (que podría arrastrar más datos). - options?.onPoolError?.(new Error("conexión cerrada")); + // The message is logged, not the Error object (which could carry more data). + options?.onPoolError?.(new Error("connection closed")); expect(errorSpy).toHaveBeenCalledTimes(1); - expect(errorSpy.mock.calls[0]).toEqual([expect.any(String), "conexión cerrada"]); + expect(errorSpy.mock.calls[0]).toEqual([expect.any(String), "connection closed"]); await prisma.$disconnect(); } finally { errorSpy.mockRestore(); @@ -50,10 +50,10 @@ describe("pool de conexiones de Prisma", () => { } }); - // Reproduce el cuelgue con la configuración de pg: con el pool agotado, la consulta - // siguiente falla por timeout en vez de quedarse esperando. Se usa un pool de 1 y - // 200 ms para no tardar 5 s; que la app use ese timeout lo vigila el test anterior. - it("con el pool agotado, una consulta falla por timeout en vez de colgarse", async () => { + // Reproduces the hang with the pg configuration: with the pool exhausted, the next query + // fails with a timeout instead of waiting. A pool of 1 and 200 ms are used so it does not + // take 5 s; that the app uses that timeout is checked by the previous test. + it("with the pool exhausted, a query fails with a timeout instead of hanging", async () => { const adapter = new PrismaPg({ ...buildPoolConfig(process.env.DATABASE_URL), max: 1, @@ -62,7 +62,7 @@ describe("pool de conexiones de Prisma", () => { const client = new PrismaClient({ adapter }); try { await client.$transaction(async (tx) => { - // La transacción interactiva ocupa la única conexión del pool. + // The interactive transaction takes the pool's only connection. await tx.$queryRaw`SELECT 1`; await expect(client.$queryRaw`SELECT 1`).rejects.toThrow(/timeout/i); }); @@ -72,10 +72,10 @@ describe("pool de conexiones de Prisma", () => { }); }); -describe("singleton de PrismaClient", () => { - // Turbopack evalúa el módulo una vez por capa; en producción, sin globalThis, cada - // evaluación abría su propio pool. - it("en producción reutiliza el mismo cliente entre evaluaciones del módulo", async () => { +describe("PrismaClient singleton", () => { + // Turbopack evaluates the module once per layer; in production, without globalThis, each + // evaluation opened its own pool. + it("in production it reuses the same client across module evaluations", async () => { const g = globalThis as unknown as { prisma: PrismaClient | undefined }; const previous = g.prisma; vi.stubEnv("NODE_ENV", "production"); diff --git a/test/integration/realtime.int.test.ts b/test/integration/realtime.int.test.ts index 138b267..482cc52 100644 --- a/test/integration/realtime.int.test.ts +++ b/test/integration/realtime.int.test.ts @@ -23,10 +23,10 @@ import { loginAs, } from "./helpers"; -// Streams SSE de tiempo real: autorización (401/403), limpieza de la suscripción y del -// latido al cortar la conexión, y cierre del stream cuando se revoca la sesión o se -// retira el acceso. Antes nada lo comprobaba y una regresión acumulaba listeners en el -// bus sin que nadie se enterara. +// Real-time SSE streams: authorization (401/403), cleanup of the subscription and the +// heartbeat when the connection drops, and closing the stream when the session is revoked +// or access is withdrawn. Nothing checked this before, and a regression piled up listeners +// on the bus without anyone noticing. const bus = () => (globalThis as { __orgtableBus?: EventEmitter }).__orgtableBus; @@ -47,7 +47,7 @@ const decoder = new TextDecoder(); async function readChunk(reader: Reader): Promise<string> { const { value, done } = await reader.read(); - if (done) throw new Error("El stream se cerró antes de tiempo"); + if (done) throw new Error("The stream closed too early"); return decoder.decode(value); } @@ -57,7 +57,7 @@ async function readUntil(reader: Reader, needle: string): Promise<string> { return text; } -/** Lee hasta el final; si el stream no se cierra, el test acaba por timeout. */ +/** Reads to the end; if the stream does not close, the test ends with a timeout. */ async function readToEnd(reader: Reader): Promise<string> { let text = ""; for (;;) { @@ -80,12 +80,12 @@ function openController(): AbortController { beforeEach(async () => { await resetDb(); - // Solo el latido (setInterval) y el reloj: la BD sigue usando temporizadores reales. + // Only the heartbeat (setInterval) and the clock: the DB keeps using real timers. vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] }); }); afterEach(() => { - // Abortar siempre, aunque el test falle, para no dejar streams vivos en el bus. + // Always abort, even if the test fails, so no live streams are left on the bus. for (const ac of aborters.splice(0)) ac.abort(); vi.useRealTimers(); }); @@ -95,7 +95,7 @@ afterAll(async () => { }); describe("GET /api/boards/[id]/stream", () => { - it("401 sin sesión", async () => { + it("401 without a session", async () => { const owner = await seedUser(); const board = await seedBoard(owner.id); const res = await boardStream( @@ -106,7 +106,7 @@ describe("GET /api/boards/[id]/stream", () => { expect(listeners(`board:${board.id}`)).toBe(0); }); - it("403 si no es miembro del tablero", async () => { + it("403 if not a board member", async () => { const owner = await seedUser(); const outsider = await seedUser(); const board = await seedBoard(owner.id); @@ -119,7 +119,7 @@ describe("GET /api/boards/[id]/stream", () => { expect(listeners(`board:${board.id}`)).toBe(0); }); - it("un miembro recibe los cambios y al abortar se libera el listener y el latido", async () => { + it("a member receives the changes, and aborting frees the listener and the heartbeat", async () => { const owner = await seedUser(); const board = await seedBoard(owner.id); await loginAs(owner.id); @@ -146,7 +146,7 @@ describe("GET /api/boards/[id]/stream", () => { expect(vi.getTimerCount()).toBe(0); }); - it("si la petición ya llegó abortada no se suscribe", async () => { + it("does not subscribe if the request arrived already aborted", async () => { const owner = await seedUser(); const board = await seedBoard(owner.id); await loginAs(owner.id); @@ -159,7 +159,7 @@ describe("GET /api/boards/[id]/stream", () => { expect(vi.getTimerCount()).toBe(0); }); - it("cancelar el body libera el listener y el latido", async () => { + it("cancelling the body frees the listener and the heartbeat", async () => { const owner = await seedUser(); const board = await seedBoard(owner.id); await loginAs(owner.id); @@ -175,7 +175,7 @@ describe("GET /api/boards/[id]/stream", () => { expect(vi.getTimerCount()).toBe(0); }); - it("se cierra en el siguiente latido si se revoca la sesión", async () => { + it("closes on the next heartbeat if the session is revoked", async () => { const owner = await seedUser(); const board = await seedBoard(owner.id); const sessionId = await loginAndGetSessionId(owner.id); @@ -198,7 +198,7 @@ describe("GET /api/boards/[id]/stream", () => { expect(vi.getTimerCount()).toBe(0); }); - it("se cierra en el siguiente latido si expulsan a la persona del tablero", async () => { + it("closes on the next heartbeat if the person is removed from the board", async () => { const owner = await seedUser(); const editor = await seedUser(); const board = await seedBoard(owner.id); @@ -221,7 +221,7 @@ describe("GET /api/boards/[id]/stream", () => { expect(listeners(`board:${board.id}`)).toBe(0); }); - it("sigue abierto tras el latido si el acceso es válido y se cierra al llegar a la vida máxima", async () => { + it("stays open after the heartbeat if access is valid and closes when it reaches its maximum lifetime", async () => { const owner = await seedUser(); const board = await seedBoard(owner.id); await loginAs(owner.id); @@ -245,14 +245,14 @@ describe("GET /api/boards/[id]/stream", () => { }); describe("GET /api/notifications/stream", () => { - it("401 sin sesión", async () => { + it("401 without a session", async () => { const res = await notificationsStream( streamRequest("/api/notifications/stream"), ); expect(res.status).toBe(401); }); - it("recibe los avisos del usuario y al abortar se libera el listener", async () => { + it("receives the user's notifications, and aborting frees the listener", async () => { const user = await seedUser(); await loginAs(user.id); const ac = openController(); @@ -275,7 +275,7 @@ describe("GET /api/notifications/stream", () => { expect(vi.getTimerCount()).toBe(0); }); - it("si la petición ya llegó abortada no se suscribe", async () => { + it("does not subscribe if the request arrived already aborted", async () => { const user = await seedUser(); await loginAs(user.id); const res = await notificationsStream( @@ -285,7 +285,7 @@ describe("GET /api/notifications/stream", () => { expect(listeners(`user:${user.id}`)).toBe(0); }); - it("se cierra en el siguiente latido si se revoca la sesión", async () => { + it("closes on the next heartbeat if the session is revoked", async () => { const user = await seedUser(); const sessionId = await loginAndGetSessionId(user.id); const ac = openController(); diff --git a/test/integration/registration.int.test.ts b/test/integration/registration.int.test.ts new file mode 100644 index 0000000..f5820d3 --- /dev/null +++ b/test/integration/registration.int.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, beforeEach, afterAll } from "vitest"; +import { prisma } from "@/app/lib/prisma"; +import { register } from "@/app/lib/actions/register"; +import { saveInstanceSettings } from "@/app/lib/actions/instance"; +import { isRegistrationOpen } from "@/app/lib/services/instance"; +import { verifySession } from "@/app/lib/dal"; +import robots from "@/app/robots"; +import { __setHeaders } from "../stubs/next-headers"; +import { cookies } from "next/headers"; +import { resetDb, disconnect, seedUser, loginAs, en, es } from "./helpers"; + +beforeEach(async () => { + await resetDb(); +}); +afterAll(async () => { + await disconnect(); +}); + +const LEGAL = { operatorName: "Test operator", operatorEmail: "privacy@test.local" }; + +async function seedInstance(data: Partial<{ registrationOpen: boolean; operatorName: string; operatorEmail: string }> = {}) { + await prisma.instanceConfig.create({ + data: { id: "singleton", instanceName: "Test", setupCompletedAt: new Date(), ...data }, + }); +} + +function form(fields: Record<string, string | undefined>): FormData { + const fd = new FormData(); + for (const [k, v] of Object.entries(fields)) if (v !== undefined) fd.set(k, v); + return fd; +} + +function registerForm(fields: Record<string, string | undefined> = {}): FormData { + return form({ + name: "New Person", + email: "New@Test.local", + password: "contraseña-larga", + acceptTerms: "on", + ...fields, + }); +} + +/** Runs register and returns the redirect URL (or fails if it does not redirect). */ +async function registerRedirect(fd: FormData): Promise<string> { + try { + const state = await register({}, fd); + throw new Error(`register did not redirect: ${JSON.stringify(state)}`); + } catch (e) { + const digest = (e as { digest?: string }).digest; + if (!digest?.startsWith("NEXT_REDIRECT")) throw e; + return digest.split(";")[2]; + } +} + +describe("open registration (register)", () => { + it("creates a MEMBER account, stores the acceptance, signs in and audits", async () => { + await seedInstance({ registrationOpen: true, ...LEGAL }); + const before = Date.now(); + + expect(await registerRedirect(registerForm())).toBe("/boards"); + + const user = await prisma.user.findUniqueOrThrow({ where: { email: "new@test.local" } }); + expect(user.role).toBe("MEMBER"); + expect(user.termsAcceptedAt?.getTime()).toBeGreaterThanOrEqual(before); + expect(user.passwordHash).not.toContain("contraseña"); + expect((await verifySession())?.userId).toBe(user.id); + expect( + await prisma.auditLog.count({ where: { action: "auth.registered", userId: user.id } }), + ).toBe(1); + }); + + it("creates nothing with registration closed", async () => { + await seedInstance({ registrationOpen: false, ...LEGAL }); + const state = await register({}, registerForm()); + expect(state.error).toBe(en.errors.register.closed); + expect(await prisma.user.count()).toBe(0); + }); + + // Defence in depth: if someone empties the legal details directly in the DB, registration + // closes by itself instead of accepting sign-ups without a complete privacy policy. + it("open but without an operator or contact counts as closed", async () => { + await seedInstance({ registrationOpen: true }); + expect(await isRegistrationOpen()).toBe(false); + const state = await register({}, registerForm()); + expect(state.error).toBe(en.errors.register.closed); + expect(await prisma.user.count()).toBe(0); + }); + + it("does not create the account without accepting the policy and the terms", async () => { + await seedInstance({ registrationOpen: true, ...LEGAL }); + const state = await register({}, registerForm({ acceptTerms: undefined })); + expect(state.field).toBe("acceptTerms"); + expect(state.error).toBe(en.errors.validation.acceptTermsRequired); + expect(await prisma.user.count()).toBe(0); + }); + + it("rejects a filled-in honeypot field without giving hints", async () => { + await seedInstance({ registrationOpen: true, ...LEGAL }); + const state = await register({}, registerForm({ website: "https://spam.example" })); + expect(state.error).toBe(en.errors.register.failed); + expect(await prisma.user.count()).toBe(0); + }); + + it("an email already registered (with different capitalisation) does not create another account", async () => { + await seedInstance({ registrationOpen: true, ...LEGAL }); + await seedUser({ email: "new@test.local" }); + const state = await register({}, registerForm()); + expect(state).toMatchObject({ field: "email", values: { email: "New@Test.local" } }); + expect(await prisma.user.count()).toBe(1); + }); + + it("two simultaneous sign-ups with the same email: one account and no 500 error", async () => { + await seedInstance({ registrationOpen: true, ...LEGAL }); + const results = await Promise.allSettled([ + registerRedirect(registerForm()), + registerRedirect(registerForm()), + ]); + expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1); + const rejected = results.find((r) => r.status === "rejected") as PromiseRejectedResult; + expect(String(rejected.reason)).toContain(en.errors.register.emailTaken); + expect(await prisma.user.count()).toBe(1); + }); + + it("limits sign-ups per IP when there is a trusted proxy", async () => { + await seedInstance({ registrationOpen: true, ...LEGAL }); + process.env.TRUSTED_PROXY_HOPS = "1"; + __setHeaders({ "x-forwarded-for": "203.0.113.7" }); + try { + for (let i = 0; i < 5; i++) { + await registerRedirect(registerForm({ email: `p${i}@test.local` })); + } + const state = await register({}, registerForm({ email: "p6@test.local" })); + expect(state.error).toMatch( + new RegExp(`^${en.errors.register.rateLimited.other.split("{")[0]}`), + ); + expect(await prisma.user.count()).toBe(5); + expect( + await prisma.auditLog.count({ where: { action: "auth.register_rate_limited" } }), + ).toBe(1); + } finally { + delete process.env.TRUSTED_PROXY_HOPS; + } + }); +}); + +describe("instance settings (saveInstanceSettings)", () => { + function settingsForm(fields: Record<string, string> = {}): FormData { + return form({ + instanceName: "Test", + registrationOpen: "closed", + operatorName: "", + operatorEmail: "", + operatorLegalId: "", + operatorAddress: "", + hostingDescription: "", + backupRetentionDays: "", + ...fields, + }); + } + + it("does not allow opening registration without an operator and contact", async () => { + await seedInstance(); + const admin = await seedUser({ role: "ADMIN" }); + await loginAs(admin.id); + + const state = await saveInstanceSettings({}, settingsForm({ registrationOpen: "open" })); + expect(state.field).toBe("registrationOpen"); + expect(state.values?.registrationOpen).toBe("open"); + expect((await prisma.instanceConfig.findUniqueOrThrow({ where: { id: "singleton" } })).registrationOpen).toBe(false); + }); + + it("saves the legal details, opens registration and dates the update", async () => { + await seedInstance(); + const admin = await seedUser({ role: "ADMIN" }); + await loginAs(admin.id); + + const state = await saveInstanceSettings( + {}, + settingsForm({ + registrationOpen: "open", + operatorName: " Operator ", + operatorEmail: "privacy@test.local", + backupRetentionDays: "14", + }), + ); + expect(state.message).toBe(en.errors.instance.saved); + + const config = await prisma.instanceConfig.findUniqueOrThrow({ where: { id: "singleton" } }); + expect(config).toMatchObject({ + registrationOpen: true, + operatorName: "Operator", + operatorEmail: "privacy@test.local", + operatorLegalId: null, + backupRetentionDays: 14, + }); + expect(config.legalUpdatedAt).toBeInstanceOf(Date); + expect(await isRegistrationOpen()).toBe(true); + expect(await prisma.auditLog.count({ where: { action: "instance.updated" } })).toBe(1); + }); + + it("rejects an invalid contact email", async () => { + await seedInstance(); + const admin = await seedUser({ role: "ADMIN" }); + await loginAs(admin.id); + const state = await saveInstanceSettings({}, settingsForm({ operatorEmail: "not-an-email" })); + expect(state.field).toBe("operatorEmail"); + }); + + it("a MEMBER cannot change the settings", async () => { + await seedInstance(); + const member = await seedUser(); + await loginAs(member.id); + await expect( + saveInstanceSettings({}, settingsForm({ registrationOpen: "open", ...LEGAL })), + ).rejects.toMatchObject({ digest: expect.stringContaining(";/boards;") }); + expect((await prisma.instanceConfig.findUniqueOrThrow({ where: { id: "singleton" } })).operatorName).toBeNull(); + }); +}); + +describe("robots.txt", () => { + it("closed, it allows indexing nothing; open, only the public pages", async () => { + await seedInstance({ registrationOpen: false, ...LEGAL }); + expect((await robots()).rules).toEqual({ userAgent: "*", disallow: "/" }); + + await prisma.instanceConfig.update({ where: { id: "singleton" }, data: { registrationOpen: true } }); + expect((await robots()).rules).toEqual({ + userAgent: "*", + allow: ["/login", "/register", "/legal/"], + disallow: "/", + }); + }); +}); + +// The account keeps the language the person signed up in, so that notifications for them +// are written in it (User.locale). +describe("registration language", () => { + it("stores the request's language as the account's language", async () => { + await seedInstance({ registrationOpen: true, ...LEGAL }); + (await cookies()).set("locale", "es"); + + expect(await registerRedirect(registerForm())).toBe("/boards"); + + const user = await prisma.user.findUniqueOrThrow({ where: { email: "new@test.local" } }); + expect(user.locale).toBe("es"); + }); + + it("without a language preference the account is stored in English", async () => { + await seedInstance({ registrationOpen: true, ...LEGAL }); + + await registerRedirect(registerForm()); + + const user = await prisma.user.findUniqueOrThrow({ where: { email: "new@test.local" } }); + expect(user.locale).toBe("en"); + }); + + it("errors follow the request's language", async () => { + await seedInstance({ registrationOpen: false, ...LEGAL }); + __setHeaders({ "accept-language": "es-ES,es;q=0.9" }); + + const state = await register({}, registerForm()); + + expect(state.error).toBe(es.errors.register.closed); + }); +}); diff --git a/test/integration/reminders.int.test.ts b/test/integration/reminders.int.test.ts index 4a7f52f..a5a0189 100644 --- a/test/integration/reminders.int.test.ts +++ b/test/integration/reminders.int.test.ts @@ -1,12 +1,15 @@ import { describe, it, expect, beforeEach, afterEach, afterAll } from "vitest"; import { prisma } from "@/app/lib/prisma"; import { generateDueReminders } from "@/app/lib/services/reminders"; +import { fmt } from "@/app/i18n/format"; import { resetDb, disconnect, seedUser, seedBoard, seedList, + en, + es, } from "./helpers"; const originalTz = process.env.TZ; @@ -15,7 +18,7 @@ beforeEach(async () => { await resetDb(); }); afterEach(() => { - // Algún test cambia la zona del proceso; Node la aplica en cuanto se asigna TZ. + // Some tests change the process time zone; Node applies it as soon as TZ is assigned. if (originalTz === undefined) delete process.env.TZ; else process.env.TZ = originalTz; }); @@ -25,10 +28,11 @@ afterAll(async () => { const DAY_MS = 24 * 60 * 60 * 1000; -// La dedup compara el createdAt REAL de la notificación (lo pone la BD al insertar) con el -// día de `now`. Por eso cada test usa un único `now` real (o de su mismo día UTC) y saca de -// él las fechas: si se calculara al importar el módulo, un test que cruzara la medianoche -// UTC mezclaría dos días. No se usan fake timers porque no afectan a ese createdAt. +// The dedup compares the notification's REAL createdAt (set by the DB on insert) with the +// day of `now`. That is why each test uses a single real `now` (or one from the same UTC +// day) and derives the dates from it: if it were computed when importing the module, a test +// crossing UTC midnight would mix two days. Fake timers are not used because they do not +// affect that createdAt. function isoDay(now: Date, offset: number): string { const d = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), @@ -67,21 +71,21 @@ async function seedOwnerList() { return { owner, board, list }; } -describe("recordatorios de vencimiento (integración)", () => { - it("avisa de tarjetas asignadas que vencen pronto o están vencidas, y nada más", async () => { +describe("due-date reminders (integration)", () => { + it("notifies about assigned cards that are due soon or overdue, and nothing else", async () => { const now = new Date(); const { owner, list } = await seedOwnerList(); - const soon = await seedCardDue(list.id, "Vence hoy", isoDay(now, 0), owner.id); + const soon = await seedCardDue(list.id, "Due today", isoDay(now, 0), owner.id); const overdue = await seedCardDue( list.id, - "Vencida", + "Overdue", isoDay(now, -2), owner.id, ); - await seedCardDue(list.id, "Futura", isoDay(now, 10), owner.id); // fuera de ventana - await seedCardDue(list.id, "Sin asignar", isoDay(now, 0)); // sin asignado - await seedCardDue(list.id, "Sin fecha", null, owner.id); // sin vencimiento + await seedCardDue(list.id, "Future", isoDay(now, 10), owner.id); // outside the window + await seedCardDue(list.id, "Unassigned", isoDay(now, 0)); // no assignee + await seedCardDue(list.id, "No date", null, owner.id); // no due date const result = await generateDueReminders(now); expect(result.created).toBe(2); @@ -93,14 +97,18 @@ describe("recordatorios de vencimiento (integración)", () => { expect(notes.map((n) => n.cardId).sort()).toEqual( [soon.id, overdue.id].sort(), ); - expect(notes.some((n) => n.title.startsWith("Tarea vencida"))).toBe(true); - expect(notes.some((n) => n.title.startsWith("Vence pronto"))).toBe(true); + expect(notes.map((n) => n.title).sort()).toEqual( + [ + fmt(en.notifications.dueReminder.overdueTitle, { card: "Overdue" }), + fmt(en.notifications.dueReminder.soonTitle, { card: "Due today" }), + ].sort(), + ); }); - it("es idempotente el mismo día (no duplica el aviso)", async () => { + it("is idempotent within the same day (does not duplicate the reminder)", async () => { const now = new Date(); const { owner, list } = await seedOwnerList(); - await seedCardDue(list.id, "Vence hoy", isoDay(now, 0), owner.id); + await seedCardDue(list.id, "Due today", isoDay(now, 0), owner.id); const first = await generateDueReminders(now); expect(first.created).toBe(1); @@ -112,17 +120,17 @@ describe("recordatorios de vencimiento (integración)", () => { ).toBe(1); }); - it("cuenta el día en UTC aunque el servidor esté en otra zona", async () => { + it("counts the day in UTC even if the server is in another time zone", async () => { const real = new Date(); - // Las 15:00 UTC de hoy: en Nueva York sigue siendo el mismo día, pero su medianoche - // local (04:00/05:00 UTC) es posterior a la medianoche UTC en que se guarda dueDate. + // 15:00 UTC today: in New York it is still the same day, but its local midnight + // (04:00/05:00 UTC) is later than the UTC midnight dueDate is stored at. const now = new Date( Date.UTC(real.getUTCFullYear(), real.getUTCMonth(), real.getUTCDate(), 15), ); process.env.TZ = "America/New_York"; const { owner, list } = await seedOwnerList(); const today = isoDay(now, 0); - await seedCardDue(list.id, "Vence hoy", today, owner.id); + await seedCardDue(list.id, "Due today", today, owner.id); const result = await generateDueReminders(now); expect(result.created).toBe(1); @@ -130,36 +138,36 @@ describe("recordatorios de vencimiento (integración)", () => { const note = await prisma.notification.findFirstOrThrow({ where: { type: "due.reminder" }, }); - expect(note.title).toBe("Vence pronto: «Vence hoy»"); - const expectedDay = new Intl.DateTimeFormat("es-ES", { + expect(note.title).toBe(fmt(en.notifications.dueReminder.soonTitle, { card: "Due today" })); + const expectedDay = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC", }).format(new Date(today)); - expect(note.body).toBe(`Vencimiento: ${expectedDay}.`); + expect(note.body).toBe(fmt(en.notifications.dueReminder.body, { date: expectedDay })); }); - it("avisa de una tarea vencida una sola vez, no cada día", async () => { + it("notifies about an overdue card only once, not every day", async () => { const now = new Date(); const { owner, list } = await seedOwnerList(); - await seedCardDue(list.id, "Vencida", isoDay(now, -2), owner.id); + await seedCardDue(list.id, "Overdue", isoDay(now, -2), owner.id); expect((await generateDueReminders(now)).created).toBe(1); - // Al día siguiente sigue vencida, pero ya se avisó tras vencer. + // The next day it is still overdue, but the reminder was already sent after it fell due. const tomorrow = new Date(now.getTime() + DAY_MS); const next = await generateDueReminders(tomorrow); expect(next).toEqual({ created: 0, scanned: 1 }); }); - it("no repite el aviso de vencida si ya hubo uno después del vencimiento", async () => { + it("does not repeat the overdue reminder if one was already sent after the due date", async () => { const now = new Date(); const { owner, board, list } = await seedOwnerList(); - const card = await seedCardDue(list.id, "Vencida", isoDay(now, -3), owner.id); - // Aviso enviado ayer, con la tarjeta ya vencida. + const card = await seedCardDue(list.id, "Overdue", isoDay(now, -3), owner.id); + // Reminder sent yesterday, with the card already overdue. await prisma.notification.create({ data: { userId: owner.id, type: "due.reminder", - title: "Tarea vencida: «Vencida»", + title: fmt(en.notifications.dueReminder.overdueTitle, { card: "Overdue" }), boardId: board.id, cardId: card.id, createdAt: new Date(now.getTime() - DAY_MS), @@ -169,25 +177,55 @@ describe("recordatorios de vencimiento (integración)", () => { expect(await generateDueReminders(now)).toEqual({ created: 0, scanned: 1 }); }); - it("ignora las tarjetas que vencieron hace más de 7 días", async () => { + it("ignores cards that fell due more than 7 days ago", async () => { const now = new Date(); const { owner, list } = await seedOwnerList(); - await seedCardDue(list.id, "Olvidada", isoDay(now, -8), owner.id); - await seedCardDue(list.id, "Reciente", isoDay(now, -7), owner.id); + await seedCardDue(list.id, "Forgotten", isoDay(now, -8), owner.id); + await seedCardDue(list.id, "Recent", isoDay(now, -7), owner.id); expect(await generateDueReminders(now)).toEqual({ created: 1, scanned: 1 }); }); - it("no avisa a un asignado que ya no es miembro del tablero", async () => { + it("does not notify an assignee who is no longer a board member", async () => { const now = new Date(); const { list } = await seedOwnerList(); const expelled = await seedUser(); - // Asignación huérfana: como las que dejaba la expulsión antes de borrarlas. - await seedCardDue(list.id, "Vence hoy", isoDay(now, 0), expelled.id); + // Orphan assignment: like the ones removal from the board used to leave behind. + await seedCardDue(list.id, "Due today", isoDay(now, 0), expelled.id); expect(await generateDueReminders(now)).toEqual({ created: 0, scanned: 1 }); expect( await prisma.notification.count({ where: { userId: expelled.id } }), ).toBe(0); }); + + it("writes each reminder in the assignee's language, with that language's date format", async () => { + const now = new Date(); + const { owner, board, list } = await seedOwnerList(); + const spanish = await seedUser(); + await prisma.user.update({ where: { id: spanish.id }, data: { locale: "es" } }); + await prisma.boardMember.create({ + data: { boardId: board.id, userId: spanish.id, role: "EDITOR" }, + }); + const today = isoDay(now, 0); + const card = await seedCardDue(list.id, "Due today", today, owner.id); + await prisma.cardAssignee.create({ data: { cardId: card.id, userId: spanish.id } }); + + expect((await generateDueReminders(now)).created).toBe(2); + + const day = (intl: string) => + new Intl.DateTimeFormat(intl, { dateStyle: "medium", timeZone: "UTC" }).format( + new Date(today), + ); + const toSpanish = await prisma.notification.findFirstOrThrow({ + where: { userId: spanish.id }, + }); + const toOwner = await prisma.notification.findFirstOrThrow({ + where: { userId: owner.id }, + }); + expect(toSpanish.title).toBe(fmt(es.notifications.dueReminder.soonTitle, { card: "Due today" })); + expect(toSpanish.body).toBe(fmt(es.notifications.dueReminder.body, { date: day("es-ES") })); + expect(toOwner.title).toBe(fmt(en.notifications.dueReminder.soonTitle, { card: "Due today" })); + expect(toOwner.body).toBe(fmt(en.notifications.dueReminder.body, { date: day("en") })); + }); }); diff --git a/test/integration/security.int.test.ts b/test/integration/security.int.test.ts index f0751f2..6dc1b53 100644 --- a/test/integration/security.int.test.ts +++ b/test/integration/security.int.test.ts @@ -16,11 +16,11 @@ afterAll(async () => { await disconnect(); }); -// Helper del guard contra el IDOR de moveCard: una tarjeta solo "pertenece" a su propio -// tablero. Este test prueba SOLO el helper; que moveCard lo use se comprueba ejecutando la -// acción en cards-actions.int.test.ts. -describe("seguridad: helper cardBelongsToBoard", () => { - it("cardBelongsToBoard es true solo para el tablero real de la tarjeta", async () => { +// Helper of the guard against the moveCard IDOR: a card only "belongs" to its own board. +// This test checks ONLY the helper; that moveCard uses it is checked by running the action +// in cards-actions.int.test.ts. +describe("security: cardBelongsToBoard helper", () => { + it("cardBelongsToBoard is only true for the card's actual board", async () => { const u1 = await seedUser(); const u2 = await seedUser(); const boardA = await seedBoard(u1.id, "A"); @@ -29,10 +29,10 @@ describe("seguridad: helper cardBelongsToBoard", () => { const cardA = await seedCard(listA.id); expect(await cardBelongsToBoard(cardA.id, boardA.id)).toBe(true); - // El intento de IDOR: reclamar la tarjeta de A desde el tablero B. + // The IDOR attempt: claiming board A's card from board B. expect(await cardBelongsToBoard(cardA.id, boardB.id)).toBe(false); - // Tarjeta inexistente. - expect(await cardBelongsToBoard("cminexistente000000000000", boardA.id)).toBe( + // A card that does not exist. + expect(await cardBelongsToBoard("cmmissing0000000000000000", boardA.id)).toBe( false, ); }); diff --git a/test/integration/session.int.test.ts b/test/integration/session.int.test.ts index 1b817de..d34e0fb 100644 --- a/test/integration/session.int.test.ts +++ b/test/integration/session.int.test.ts @@ -12,9 +12,9 @@ import { cookies } from "next/headers"; import { __getCookieOptions, __setHeaders } from "../stubs/next-headers"; import { resetDb, disconnect, seedUser, loginAs } from "./helpers"; -// Sesiones respaldadas por la tabla Session (ADR 0008): la revocación inmediata depende de -// las comprobaciones de verifySession (app/lib/dal.ts). Si alguna se quita, estos tests -// fallan. Usan el stub mutable de next/headers y el SESSION_SECRET de test. +// Sessions backed by the Session table (ADR 0008): immediate revocation depends on the +// checks in verifySession (app/lib/dal.ts). If any of them is removed, these tests fail. +// They use the mutable next/headers stub and the test SESSION_SECRET. beforeEach(async () => { await resetDb(); @@ -43,10 +43,10 @@ async function signToken( .sign(opts.secret ?? key()); } -describe("verifySession (integración)", () => { - it("con una sesión recién creada devuelve el usuario y el sid", async () => { - // Caso obligatorio: decrypt oculta los fallos (devuelve null), así que sin este test - // una mala configuración haría pasar todos los "devuelve null" de abajo. +describe("verifySession (integration)", () => { + it("with a freshly created session it returns the user and the sid", async () => { + // Required case: decrypt hides failures (returns null), so without this test a broken + // configuration would make every "returns null" below pass. const u = await seedUser(); await loginAs(u.id); @@ -55,7 +55,7 @@ describe("verifySession (integración)", () => { expect(session).toEqual({ userId: u.id, sessionId: row.id }); }); - it("cache() de React no memoiza fuera de RSC: cambiar de usuario se ve al instante", async () => { + it("React's cache() does not memoize outside RSC: switching user is visible at once", async () => { const a = await seedUser(); const b = await seedUser(); await loginAs(a.id); @@ -64,14 +64,14 @@ describe("verifySession (integración)", () => { expect((await getCurrentUser())?.id).toBe(b.id); }); - it("tras revokeAllUserSessions devuelve null", async () => { + it("returns null after revokeAllUserSessions", async () => { const u = await seedUser(); await loginAs(u.id); await revokeAllUserSessions(u.id); expect(await verifySession()).toBeNull(); }); - it("con la sesión caducada en la BD devuelve null", async () => { + it("returns null with the session expired in the DB", async () => { const u = await seedUser(); await loginAs(u.id); await prisma.session.updateMany({ @@ -81,7 +81,7 @@ describe("verifySession (integración)", () => { expect(await verifySession()).toBeNull(); }); - it("un JWT válido con el sid de la sesión de OTRO usuario devuelve null", async () => { + it("a valid JWT with the sid of ANOTHER user's session returns null", async () => { const victim = await seedUser(); const attacker = await seedUser(); await loginAs(victim.id); @@ -93,12 +93,12 @@ describe("verifySession (integración)", () => { expect(await verifySession()).toBeNull(); }); - it("rechaza cookies firmadas con otra clave, con otro algoritmo o sin sid", async () => { + it("rejects cookies signed with another key, with another algorithm or without a sid", async () => { const u = await seedUser(); await loginAs(u.id); const row = await prisma.session.findFirstOrThrow({ where: { userId: u.id } }); - const otherKey = new TextEncoder().encode("otra-clave-distinta-de-la-de-test-000"); + const otherKey = new TextEncoder().encode("another-key-different-from-the-test-one"); await setSessionCookie(await signToken({ userId: u.id, sid: row.id }, { secret: otherKey })); expect(await verifySession()).toBeNull(); @@ -109,7 +109,7 @@ describe("verifySession (integración)", () => { expect(await verifySession()).toBeNull(); }); - it("deleteSession marca revokedAt y borra la cookie", async () => { + it("deleteSession sets revokedAt and deletes the cookie", async () => { const u = await seedUser(); await loginAs(u.id); await deleteSession(); @@ -121,19 +121,19 @@ describe("verifySession (integración)", () => { }); }); -describe("decrypt: SESSION_SECRET ausente", () => { - it("falla ruidosamente en vez de tratarlo como 'sin sesión'", async () => { +describe("decrypt: missing SESSION_SECRET", () => { + it("fails loudly instead of treating it as 'no session'", async () => { vi.stubEnv("SESSION_SECRET", ""); await expect(decrypt("x.y.z")).rejects.toThrow(/SESSION_SECRET/); - // Sin cookie no se necesita la clave: /login y /setup siguen funcionando. + // Without a cookie the key is not needed: /login and /setup keep working. await expect(decrypt(undefined)).resolves.toBeNull(); }); - it("con secreto válido, un token manipulado sigue devolviendo null", async () => { + it("with a valid secret, a tampered token still returns null", async () => { expect(await decrypt("x.y.z")).toBeNull(); }); - it("createSession no deja una fila huérfana si falta la clave", async () => { + it("createSession does not leave an orphan row if the key is missing", async () => { const u = await seedUser(); vi.stubEnv("SESSION_SECRET", ""); await expect(createSession(u.id)).rejects.toThrow(/SESSION_SECRET/); @@ -141,15 +141,15 @@ describe("decrypt: SESSION_SECRET ausente", () => { }); }); -describe("cookie de sesión: Secure según el esquema de la petición", () => { - it("por HTTP (primer arranque en http://IP:3000) la cookie no lleva Secure", async () => { +describe("session cookie: Secure depending on the request scheme", () => { + it("over HTTP (first start on http://IP:3000) the cookie is not Secure", async () => { const u = await seedUser(); __setHeaders({ "x-forwarded-proto": "http" }); await createSession(u.id); expect(__getCookieOptions("session")).toMatchObject({ secure: false, httpOnly: true }); }); - it("por HTTPS (proxy TLS) la cookie lleva Secure", async () => { + it("over HTTPS (TLS proxy) the cookie is Secure", async () => { const u = await seedUser(); __setHeaders({ "x-forwarded-proto": "https" }); await createSession(u.id); diff --git a/test/integration/setup.int.test.ts b/test/integration/setup.int.test.ts index 4ece79e..6f8564a 100644 --- a/test/integration/setup.int.test.ts +++ b/test/integration/setup.int.test.ts @@ -3,10 +3,10 @@ import { prisma } from "@/app/lib/prisma"; import { completeSetup } from "@/app/lib/actions/setup"; import { completeInstanceSetup } from "@/app/lib/services/setup"; import { hashPassword } from "@/app/lib/auth"; -import { resetDb, disconnect, seedUser } from "./helpers"; +import { resetDb, disconnect, seedUser, en } from "./helpers"; -// Espía sobre hashPassword sin cambiar su comportamiento: permite comprobar que la acción -// no gasta bcrypt cuando el setup ya está hecho. +// Spy on hashPassword without changing its behaviour: lets us check that the action does +// not spend bcrypt time when the setup is already done. vi.mock("@/app/lib/auth", async (importOriginal) => { const original = await importOriginal<typeof import("@/app/lib/auth")>(); return { ...original, hashPassword: vi.fn(original.hashPassword) }; @@ -26,7 +26,7 @@ afterAll(async () => { function setupForm(fields: Partial<Record<string, string>> = {}): FormData { const fd = new FormData(); const values = { - instanceName: "Mi equipo", + instanceName: "My team", name: "Admin", email: "admin@test.local", password: "contraseña-larga", @@ -36,9 +36,9 @@ function setupForm(fields: Partial<Record<string, string>> = {}): FormData { return fd; } -describe("completeInstanceSetup: dos envíos simultáneos", () => { - it("solo crea un administrador; el otro recibe ALREADY_SETUP", async () => { - const passwordHash = "hash-no-usado-en-login"; +describe("completeInstanceSetup: two simultaneous submissions", () => { + it("creates only one administrator; the other gets ALREADY_SETUP", async () => { + const passwordHash = "hash-not-used-for-login"; for (let i = 0; i < 20; i++) { await prisma.instanceConfig.deleteMany(); await prisma.user.deleteMany(); @@ -58,9 +58,9 @@ describe("completeInstanceSetup: dos envíos simultáneos", () => { }); describe("completeSetup (Server Action)", () => { - it("con el setup ya hecho redirige a /login sin validar ni gastar bcrypt", async () => { + it("with the setup already done it redirects to /login without validating or spending bcrypt", async () => { await prisma.instanceConfig.create({ - data: { id: "singleton", instanceName: "Hecha", setupCompletedAt: new Date() }, + data: { id: "singleton", instanceName: "Done", setupCompletedAt: new Date() }, }); await expect(completeSetup({}, setupForm())).rejects.toMatchObject({ @@ -70,36 +70,36 @@ describe("completeSetup (Server Action)", () => { expect(await prisma.user.count()).toBe(0); }); - it("si la BD rechaza el alta devuelve el error, lo registra sin el email y no marca el setup", async () => { - await seedUser({ email: "ya-existe@test.local" }); + it("if the DB rejects the insert it returns the error, logs it without the email and does not mark the setup", async () => { + await seedUser({ email: "already-exists@test.local" }); const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - const state = await completeSetup({}, setupForm({ email: "ya-existe@test.local" })); + const state = await completeSetup({}, setupForm({ email: "already-exists@test.local" })); - expect(state.error).toBe("No se pudo completar la configuración. Inténtalo de nuevo."); + expect(state.error).toBe(en.errors.setup.failed); expect(spy).toHaveBeenCalledTimes(1); const logged = JSON.stringify(spy.mock.calls[0]); expect(logged).toContain("P2002"); - expect(logged).not.toContain("ya-existe@test.local"); + expect(logged).not.toContain("already-exists@test.local"); const config = await prisma.instanceConfig.findUnique({ where: { id: "singleton" } }); expect(config?.setupCompletedAt ?? null).toBeNull(); }); - it("con datos no válidos devuelve lo tecleado salvo la contraseña", async () => { + it("with invalid data it returns what was typed except the password", async () => { const state = await completeSetup( {}, - setupForm({ email: "no-es-email", password: "secreto-pegado" }), + setupForm({ email: "not-an-email", password: "pasted-secret" }), ); expect(state.error).toBeTruthy(); expect(state.values).toEqual({ - instanceName: "Mi equipo", + instanceName: "My team", name: "Admin", - email: "no-es-email", + email: "not-an-email", }); - expect(JSON.stringify(state)).not.toContain("secreto-pegado"); + expect(JSON.stringify(state)).not.toContain("pasted-secret"); }); - it("con datos correctos crea el admin, abre sesión y redirige a /boards", async () => { + it("with valid data it creates the admin, signs in and redirects to /boards", async () => { await expect(completeSetup({}, setupForm())).rejects.toMatchObject({ digest: expect.stringContaining(";/boards;"), }); diff --git a/test/integration/tokens.int.test.ts b/test/integration/tokens.int.test.ts index efa21c1..405b010 100644 --- a/test/integration/tokens.int.test.ts +++ b/test/integration/tokens.int.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterAll } from "vitest"; import { prisma } from "@/app/lib/prisma"; import { createApiToken } from "@/app/lib/actions/tokens"; -import { resetDb, disconnect, seedUser, loginAs } from "./helpers"; +import { resetDb, disconnect, seedUser, loginAs, en } from "./helpers"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -19,27 +19,27 @@ afterAll(async () => { await disconnect(); }); -describe("createApiToken — validación de permisos y caducidad (integración)", () => { - it("rechaza un scope desconocido sin crear nada", async () => { +describe("createApiToken — permission and expiry validation (integration)", () => { + it("rejects an unknown scope without creating anything", async () => { const state = await createApiToken( {}, tokenForm({ name: "script", scope: "readonly", expiry: "30" }), ); - expect(state.error).toBe("Permisos no válidos."); + expect(state.error).toBe(en.errors.tokens.invalidScope); expect(state.token).toBeUndefined(); expect(await prisma.apiToken.count()).toBe(0); }); - it("rechaza una caducidad desconocida sin crear nada", async () => { + it("rejects an unknown expiry without creating anything", async () => { const state = await createApiToken( {}, tokenForm({ name: "script", scope: "read", expiry: "7" }), ); - expect(state.error).toBe("Caducidad no válida."); + expect(state.error).toBe(en.errors.tokens.invalidExpiry); expect(await prisma.apiToken.count()).toBe(0); }); - it("tras un error devuelve lo elegido para no perderlo en el formulario", async () => { + it("after an error it returns what was chosen so the form does not lose it", async () => { const state = await createApiToken( {}, tokenForm({ name: "script", scope: "read", expiry: "7" }), @@ -47,13 +47,13 @@ describe("createApiToken — validación de permisos y caducidad (integración)" expect(state.values).toEqual({ name: "script", scope: "read", expiry: "7" }); }); - it("rechaza que falten permisos o caducidad (antes caía a escritura sin caducidad)", async () => { + it("rejects missing permissions or expiry (it used to fall back to write access without expiry)", async () => { const state = await createApiToken({}, tokenForm({ name: "script" })); expect(state.error).toBeDefined(); expect(await prisma.apiToken.count()).toBe(0); }); - it("guarda scope read y caducidad a 30 días", async () => { + it("stores the read scope and a 30-day expiry", async () => { const before = Date.now(); const state = await createApiToken( {}, @@ -70,7 +70,7 @@ describe("createApiToken — validación de permisos y caducidad (integración)" expect(expires).toBeLessThanOrEqual(Date.now() + 30 * DAY_MS); }); - it("«Nunca» (0) crea un token sin caducidad", async () => { + it("“Never” (0) creates a token without expiry", async () => { await createApiToken( {}, tokenForm({ name: "script", scope: "full", expiry: "0" }), diff --git a/test/integration/users.int.test.ts b/test/integration/users.int.test.ts index 7e21295..70e6bb6 100644 --- a/test/integration/users.int.test.ts +++ b/test/integration/users.int.test.ts @@ -11,12 +11,12 @@ afterAll(async () => { await disconnect(); }); -describe("creación de usuarios (integración)", () => { - it("crea una cuenta con la contraseña hasheada (nunca en claro) y email normalizado", async () => { +describe("user creation (integration)", () => { + it("creates an account with the password hashed (never in plain text) and the email normalised", async () => { const r = await createUserAccount({ name: "Ada Lovelace", email: " Ada@Example.com ", - password: "secreto-muy-largo", + password: "very-long-secret", role: "MEMBER", }); expect(r.ok).toBe(true); @@ -25,16 +25,16 @@ describe("creación de usuarios (integración)", () => { where: { email: "ada@example.com" }, }); expect(user).not.toBeNull(); - expect(user!.email).toBe("ada@example.com"); // recortado + minúsculas - expect(user!.passwordHash).not.toBe("secreto-muy-largo"); // nunca en claro + expect(user!.email).toBe("ada@example.com"); // trimmed + lower case + expect(user!.passwordHash).not.toBe("very-long-secret"); // never in plain text expect(user!.passwordHash.startsWith("$2")).toBe(true); // bcrypt - expect(await verifyPassword("secreto-muy-largo", user!.passwordHash)).toBe( + expect(await verifyPassword("very-long-secret", user!.passwordHash)).toBe( true, ); - expect(await verifyPassword("incorrecta", user!.passwordHash)).toBe(false); + expect(await verifyPassword("wrong", user!.passwordHash)).toBe(false); }); - it("rechaza un email duplicado (insensible a mayúsculas)", async () => { + it("rejects a duplicate email (case-insensitive)", async () => { await createUserAccount({ name: "A", email: "dup@test.local", @@ -52,9 +52,9 @@ describe("creación de usuarios (integración)", () => { expect(await prisma.user.count()).toBe(1); }); - it("respeta el rol global asignado", async () => { + it("honours the assigned global role", async () => { await createUserAccount({ - name: "Jefa", + name: "Boss", email: "admin@test.local", password: "z".repeat(10), role: "ADMIN", diff --git a/test/list-column-delete.test.ts b/test/list-column-delete.test.ts index a89855a..ec13c26 100644 --- a/test/list-column-delete.test.ts +++ b/test/list-column-delete.test.ts @@ -2,10 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { ReactElement, ReactNode } from "react"; import type { ListDTO } from "@/app/lib/types"; -// No hay jsdom en el proyecto (ni se monta): igual que en -// delete-board-button-focus.test.ts, se sustituyen useState/useRef/useEffect por un -// arnés mínimo que re-ejecuta el componente como función, adjunta la ref del ✕ a un -// botón falso y corre los efectos cuyas dependencias cambian (render → refs → efectos). +// The project has no jsdom (and does not set one up): as in +// delete-board-button-focus.test.ts, useState/useRef/useEffect are replaced with a +// minimal harness that re-runs the component as a function, attaches the ✕'s ref to a +// fake button and runs the effects whose dependencies change (render → refs → effects). type Slot = unknown; const slots: Slot[] = []; let cursor = 0; @@ -41,8 +41,8 @@ vi.mock("react", async (importOriginal) => { }, }; }); -// useSortable y el contexto de permisos necesitan un árbol de React de verdad; aquí -// solo importa la confirmación de borrado, así que se sustituyen por valores fijos. +// useSortable, the permissions context and the messages context need a real React tree; +// here only the delete confirmation matters, so they are replaced with fixed values. vi.mock("@dnd-kit/sortable", () => ({ SortableContext: () => null, verticalListSortingStrategy: {}, @@ -58,6 +58,11 @@ vi.mock("@dnd-kit/sortable", () => ({ vi.mock("@/app/components/board/board-perms", () => ({ useBoardPerms: () => ({ canEdit: true }), })); +vi.mock("@/app/i18n/client", async () => { + const { messagesFor } = await import("@/app/i18n/config"); + const m = messagesFor("en"); + return { useI18n: () => ({ locale: "en", intl: "en", m }) }; +}); vi.mock("@/app/lib/actions/lists", () => ({ deleteList: vi.fn() })); vi.mock("@/app/components/board/card-item", () => ({ CardItem: () => null })); vi.mock("@/app/components/board/add-card-form", () => ({ @@ -72,10 +77,10 @@ type Element = ReactElement<Props>; const trigger = { focus: vi.fn() }; let triggerRef: { current: unknown } | null = null; -// Con un filtro activo BoardView pasa solo las tarjetas visibles: aquí una de varias. +// With an active filter BoardView passes only the visible cards: here one of several. const list = { id: "l1", - title: "Pendiente", + title: "To do", position: "a0", cards: [{ id: "c1" }], } as unknown as ListDTO; @@ -99,7 +104,7 @@ function hasText(text: string) { function find(tree: Element, match: (el: Element) => boolean, what: string): Element { const found = findIn(tree, match); - if (!found) throw new Error(`No se encontró ${what}`); + if (!found) throw new Error(`Not found: ${what}`); return found; } @@ -107,7 +112,7 @@ function render(): Element { cursor = 0; pendingEffects = []; const tree = ListColumn({ list, onOpenCard: () => {} }) as Element; - // React desmonta el ✕ al confirmar (ref a null) y lo vuelve a montar al cancelar. + // React unmounts the ✕ when confirming (ref to null) and mounts it again on cancel. const button = findIn(tree, hasText("✕")); const ref = button?.props.ref as { current: unknown } | undefined; if (ref) triggerRef = ref; @@ -118,7 +123,7 @@ function render(): Element { function openConfirmation(): Element { const initial = render(); - (find(initial, hasText("✕"), "el ✕").props.onClick as () => void)(); + (find(initial, hasText("✕"), "the ✕").props.onClick as () => void)(); return render(); } @@ -130,22 +135,22 @@ beforeEach(() => { trigger.focus.mockClear(); }); -describe("ListColumn: confirmación de borrado", () => { - it("al montarse no roba el foco (cada columna del tablero tiene su ✕)", () => { +describe("ListColumn: delete confirmation", () => { + it("does not steal focus on mount (every column of the board has its own ✕)", () => { render(); expect(trigger.focus).not.toHaveBeenCalled(); }); - it("cancelar con «No» devuelve el foco al ✕", () => { + it("cancelling with \"No\" returns focus to the ✕", () => { const confirming = openConfirmation(); - (find(confirming, hasText("No"), "«No»").props.onClick as () => void)(); + (find(confirming, hasText("No"), "\"No\"").props.onClick as () => void)(); const back = render(); expect(findIn(back, isForm)).toBeNull(); expect(trigger.focus).toHaveBeenCalledTimes(1); }); - it("cancelar con Escape cierra la confirmación, devuelve el foco y no propaga", () => { - const form = find(openConfirmation(), isForm, "el formulario"); + it("cancelling with Escape closes the confirmation, returns focus and does not propagate", () => { + const form = find(openConfirmation(), isForm, "the form"); const stopPropagation = vi.fn(); (form.props.onKeyDown as (e: { key: string; stopPropagation: () => void }) => void)( { key: "Escape", stopPropagation }, @@ -156,8 +161,8 @@ describe("ListColumn: confirmación de borrado", () => { expect(trigger.focus).toHaveBeenCalledTimes(1); }); - it("otra tecla dentro de la confirmación no la cierra ni mueve el foco", () => { - const form = find(openConfirmation(), isForm, "el formulario"); + it("another key inside the confirmation neither closes it nor moves focus", () => { + const form = find(openConfirmation(), isForm, "the form"); (form.props.onKeyDown as (e: { key: string; stopPropagation: () => void }) => void)( { key: "Tab", stopPropagation: () => {} }, ); @@ -165,12 +170,12 @@ describe("ListColumn: confirmación de borrado", () => { expect(trigger.focus).not.toHaveBeenCalled(); }); - it("el «Sí» no anuncia el número de tarjetas visibles (con filtros no son todas)", () => { - const yes = find(openConfirmation(), hasText("Sí"), "«Sí»"); - // Antes: «…y sus 1 tarjetas», aunque el borrado en cascada se lleve también las - // ocultas por el filtro. + it("\"Yes\" does not announce the number of visible cards (with filters they are not all)", () => { + const yes = find(openConfirmation(), hasText("Yes"), "\"Yes\""); + // Before: "…and its 1 cards", although the cascading delete also takes the cards + // hidden by the filter. expect(yes.props["aria-label"]).toBe( - "Sí, borrar la lista Pendiente y todas sus tarjetas", + "Yes, delete the list To do and all its cards", ); }); }); diff --git a/test/members-panel.test.ts b/test/members-panel.test.ts index 994ca05..c1ff795 100644 --- a/test/members-panel.test.ts +++ b/test/members-panel.test.ts @@ -1,17 +1,18 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { withI18n } from "./helpers/i18n"; -// Las acciones de servidor importan Prisma; para renderizar el panel basta con -// sustituirlas (este test solo mira el HTML, no ejecuta ninguna acción). +// The server actions import Prisma; replacing them is enough to render the panel (this +// test only looks at the HTML, it does not run any action). vi.mock("@/app/lib/actions/members", () => ({ addBoardMember: vi.fn(), updateBoardMemberRole: vi.fn(), removeBoardMember: vi.fn(), })); -// En el render de servidor useActionState siempre devuelve el estado inicial. Para -// mirar el HTML tras un error (o mientras se envía) se sustituye cuando el test lo pide. +// In a server render useActionState always returns the initial state. To look at the +// HTML after an error (or while submitting) it is replaced when the test asks for it. const actionState = vi.hoisted(() => ({ override: null as unknown, pending: false, @@ -35,46 +36,48 @@ const { MembersPanel } = await import("@/app/components/board/members-panel"); function render(): string { return renderToStaticMarkup( - createElement(MembersPanel, { - boardId: "b1", - members: [], - canManage: true, - onClose: () => {}, - }), + withI18n( + createElement(MembersPanel, { + boardId: "b1", + members: [], + canManage: true, + onClose: () => {}, + }), + ), ); } -// Opción marcada del <select name="role"> del formulario de invitar. +// Selected option of the <select name="role"> in the invite form. function selectedInviteRole(html: string): string | undefined { const select = html.match(/<select name="role"[^>]*>(.*?)<\/select>/); return select?.[1].match(/<option value="([A-Z]+)" selected="">/)?.[1]; } -describe("panel de miembros", () => { - // OJO: no es la regresión del rol perdido tras un error. El HTML inicial ya salía con - // VIEWER antes del arreglo; el fallo estaba en el cliente, donde React no aplica el - // nuevo defaultValue a un <select> ya montado. El remontaje por key queda sin test - // porque el proyecto no tiene jsdom. El caso siguiente (OWNER → Editor) sí falla con - // el código anterior. - it("con un error, el HTML inicial marca el rol devuelto en el selector de invitar", () => { +describe("members panel", () => { + // NOTE: this is not the regression of the role lost after an error. The initial HTML + // already came out with VIEWER before the fix; the bug was on the client, where React + // does not apply the new defaultValue to an already mounted <select>. The remount by + // key is left untested because the project has no jsdom. The next case + // (OWNER → Editor) does fail with the previous code. + it("with an error, the initial HTML selects the returned role in the invite selector", () => { actionState.override = { - error: "No existe ninguna cuenta con ese email.", - values: { email: "x@ejemplo.com", role: "VIEWER" }, + error: "There is no account with that email.", + values: { email: "x@example.com", role: "VIEWER" }, }; expect(selectedInviteRole(render())).toBe("VIEWER"); }); - it("si el rol devuelto no es asignable, el selector vuelve a Editor", () => { + it("if the returned role is not assignable, the selector goes back to Editor", () => { actionState.override = { - error: "Rol no válido.", - values: { email: "x@ejemplo.com", role: "OWNER" }, + error: "Invalid role.", + values: { email: "x@example.com", role: "OWNER" }, }; expect(selectedInviteRole(render())).toBe("EDITOR"); }); - it("retira el aviso de error mientras se envía para que uno idéntico se reanuncie", () => { - const error = "Ana ya es miembro de este tablero."; - actionState.override = { error, values: { email: "ana@ejemplo.com", role: "EDITOR" } }; + it("removes the error message while submitting so that an identical one is announced again", () => { + const error = "Ana is already a member of this board."; + actionState.override = { error, values: { email: "ana@example.com", role: "EDITOR" } }; expect(render()).toContain(`role="alert"`); actionState.pending = true; @@ -83,7 +86,7 @@ describe("panel de miembros", () => { expect(html).not.toContain(error); }); - it("el fondo del diálogo solo se anima si no se pide movimiento reducido", () => { + it("the dialog backdrop is only animated if reduced motion is not requested", () => { const html = render(); expect(html).toContain("backdrop:motion-safe:animate-"); expect(html).not.toContain("backdrop:animate-"); diff --git a/test/metadata.test.ts b/test/metadata.test.ts index 52dfb8e..c109919 100644 --- a/test/metadata.test.ts +++ b/test/metadata.test.ts @@ -1,56 +1,151 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Metadata } from "next"; -import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { prerender } from "react-dom/static"; +import type { Locale } from "@/app/i18n/config"; -// Los módulos de página arrastran el DAL (sesión, BD) y los formularios cliente; aquí -// solo interesa el objeto `metadata` que exportan, así que se sustituyen por dobles. -// next/font es una transformación del compilador de Next y no existe fuera de él. +// The page modules drag in the DAL (session, DB), Prisma and the client forms; here only +// their metadata and the public footer matter, so those are replaced with doubles. +// next/font is a transformation of Next's compiler and does not exist outside it. +vi.mock("@/app/lib/prisma", () => ({ prisma: {} })); vi.mock("@/app/lib/dal", () => ({ isSetupComplete: vi.fn(), verifySession: vi.fn(), })); +vi.mock("@/app/lib/services/instance", () => ({ + isRegistrationOpen: vi.fn(), + getLegalInfo: vi.fn(), +})); +// The language picker in the footer posts to this Server Action (cookies + Prisma). +vi.mock("@/app/lib/actions/locale", () => ({ setLocale: vi.fn() })); vi.mock("@/app/(auth)/login/login-form", () => ({ LoginForm: () => null })); vi.mock("@/app/setup/setup-form", () => ({ SetupForm: () => null })); -vi.mock("next/font/local", () => ({ default: () => ({ variable: "" }) })); -vi.mock("next/font/google", () => ({ Geist_Mono: () => ({ variable: "" }) })); +vi.mock("next/font/google", () => ({ + Bricolage_Grotesque: () => ({ variable: "" }), + Figtree: () => ({ variable: "" }), + Geist_Mono: () => ({ variable: "" }), +})); + +// getI18n() reads the request's cookies and headers, which do not exist outside Next. +// The language of the "request" is set per test (English unless a test changes it). +const request = vi.hoisted(() => ({ locale: "en" as Locale })); +vi.mock("@/app/i18n/server", async () => { + const { languageInfo, messagesFor } = await import("@/app/i18n/config"); + return { + getI18n: async () => ({ + locale: request.locale, + intl: languageInfo(request.locale).intl, + m: messagesFor(request.locale), + }), + }; +}); + +beforeEach(() => { + request.locale = "en"; +}); + +type MetadataModule = { generateMetadata: () => Promise<Metadata> }; -/** Aplica la plantilla de título del layout raíz al título de una página. */ -function resolveTitle(root: Metadata, page: Metadata): string { - const template = root.title; +/** Applies the root layout's title template to a page's title. */ +async function resolveTitle(page: MetadataModule): Promise<string> { + const { generateMetadata: rootMetadata } = await import("@/app/layout"); + const template = (await rootMetadata()).title; if (!template || typeof template !== "object" || !("template" in template)) { - throw new Error("El layout raíz debe exportar un title con template"); + throw new Error("The root layout must export a title with a template"); } - if (typeof page.title !== "string") throw new Error("La página debe exportar un title"); - return (template.template ?? "%s").replace("%s", page.title); + const { title } = await page.generateMetadata(); + if (typeof title !== "string") throw new Error("The page must export a title"); + return (template.template ?? "%s").replace("%s", title); } -describe("metadatos de las páginas públicas", () => { - // Antes todas las rutas compartían el título «orgtable» (WCAG 2.4.2). - it("/login y /setup tienen títulos propios con el nombre de la app", async () => { - const { metadata: root } = await import("@/app/layout"); - const { metadata: login } = await import("@/app/(auth)/login/page"); - const { metadata: setup } = await import("@/app/setup/page"); +/** HTML of a tree with async Server Components (renderToStaticMarkup does not await them). */ +async function renderHtml(node: ReactNode): Promise<string> { + const { prelude } = await prerender(node); + return new Response(prelude).text(); +} + +async function renderLogin({ registrationOpen }: { registrationOpen: boolean }) { + const dal = await import("@/app/lib/dal"); + const instance = await import("@/app/lib/services/instance"); + vi.mocked(dal.isSetupComplete).mockResolvedValue(true); + vi.mocked(dal.verifySession).mockResolvedValue(null); + vi.mocked(instance.isRegistrationOpen).mockResolvedValue(registrationOpen); + const { default: LoginPage } = await import("@/app/(auth)/login/page"); + return renderHtml(await LoginPage({ searchParams: Promise.resolve({}) })); +} - expect(resolveTitle(root, login)).toBe("Iniciar sesión · orgtable"); - expect(resolveTitle(root, setup)).toBe("Configuración inicial · orgtable"); +describe("metadata of the public pages", () => { + // Every route used to share the title "orgtable" (WCAG 2.4.2). + it("/login and /setup have their own titles with the app name", async () => { + expect(await resolveTitle(await import("@/app/(auth)/login/page"))).toBe( + "Sign in · orgtable", + ); + expect(await resolveTitle(await import("@/app/setup/page"))).toBe( + "Initial setup · orgtable", + ); }); - // La licencia vigente es la propietaria de LICENSE: la descripción no puede decir - // open source ni AGPL. - it("la descripción no menciona una licencia que no es la vigente", async () => { - const { metadata: root } = await import("@/app/layout"); - expect(root.description).toBe("Tableros Kanban self-hosted, una alternativa a Trello"); - expect(root.description).not.toMatch(/open.?source|AGPL/i); + it("each legal page has its own title with the app name", async () => { + expect(await resolveTitle(await import("@/app/(public)/legal/privacy/page"))).toBe( + "Privacy policy · orgtable", + ); + expect(await resolveTitle(await import("@/app/(public)/legal/terms/page"))).toBe( + "Terms of use · orgtable", + ); + expect(await resolveTitle(await import("@/app/(public)/legal/cookies/page"))).toBe( + "Cookie policy · orgtable", + ); + expect(await resolveTitle(await import("@/app/(public)/legal/notice/page"))).toBe( + "Legal notice · orgtable", + ); }); - it("la pantalla de login no menciona open source ni AGPL", async () => { - const dal = await import("@/app/lib/dal"); - vi.mocked(dal.isSetupComplete).mockResolvedValue(true); - vi.mocked(dal.verifySession).mockResolvedValue(null); - const { default: LoginPage } = await import("@/app/(auth)/login/page"); + it("titles follow the visitor's language", async () => { + request.locale = "es"; + expect(await resolveTitle(await import("@/app/(auth)/login/page"))).toBe( + "Iniciar sesión · orgtable", + ); + }); + + it("the root layout's description describes the product", async () => { + const { generateMetadata } = await import("@/app/layout"); + expect((await generateMetadata()).description).toBe( + "Self-hosted Kanban boards, a Trello alternative", + ); + request.locale = "es"; + expect((await generateMetadata()).description).toBe( + "Tableros Kanban self-hosted, una alternativa a Trello", + ); + }); + + it("the login screen presents the product", async () => { + const html = await renderLogin({ registrationOpen: false }); + expect(html).toContain("Self-hosted, open-source Kanban boards"); + }); + + // Additional term 7(b) of LICENSE: the attribution must be visible in the footer of the + // public pages, next to the source code link (AGPL section 13). + it("the login footer carries the attribution, the license and the source code", async () => { + const html = await renderLogin({ registrationOpen: false }); + expect(html).toMatch(/Created by <a href="https:\/\/github\.com\/Aimarmndz"[^>]*>Aimar<\/a>/); + expect(html).toMatch( + /<a href="https:\/\/www\.gnu\.org\/licenses\/agpl-3\.0\.html"[^>]*>AGPL-3\.0-or-later<\/a>/, + ); + expect(html).toMatch(/<a href="https:\/\/github\.com\/Aimarmndz\/orgtable"[^>]*>Source code<\/a>/); + for (const route of ["privacy", "terms", "cookies", "notice"]) { + expect(html).toContain(`href="/legal/${route}"`); + } + }); + + it("the attribution keeps the author's name in every language", async () => { + request.locale = "es"; + const html = await renderLogin({ registrationOpen: false }); + expect(html).toMatch(/<a href="https:\/\/github\.com\/Aimarmndz"[^>]*>Aimar<\/a>/); + expect(html).toContain("AGPL-3.0-or-later"); + }); - const html = renderToStaticMarkup(await LoginPage()); - expect(html).toContain("Tableros Kanban self-hosted"); - expect(html).not.toMatch(/open.?source|AGPL/i); + it("the login links to sign-up only when registration is open", async () => { + expect(await renderLogin({ registrationOpen: false })).not.toContain('href="/register"'); + expect(await renderLogin({ registrationOpen: true })).toContain('href="/register"'); }); }); diff --git a/test/next-config.test.ts b/test/next-config.test.ts index 9c789ba..33c0941 100644 --- a/test/next-config.test.ts +++ b/test/next-config.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect } from "vitest"; import nextConfig from "@/next.config"; import { MAX_ATTACHMENT_BYTES } from "@/app/lib/storage"; -// El mismo compilador de reglas que usa el router de Next en runtime: así el test entiende -// los parámetros con regex (`/:file(.*\\.svg)`) igual que el servidor. +// The same rule compiler Next's router uses at runtime: that way the test understands +// parameters with a regex (`/:file(.*\\.svg)`) the same way the server does. import { buildCustomRoute } from "next/dist/server/lib/router-utils/filesystem"; -// Holgura mínima para la sobrecarga de multipart (límites, cabeceras de cada parte y -// campos del formulario). La guía de Next recomienda 10–20 KB; se exige más. +// Minimum headroom for the multipart overhead (boundaries, headers of each part and form +// fields). Next's guide recommends 10–20 KB; more is required here. const MULTIPART_OVERHEAD_BYTES = 64 * 1024; -/** Cabeceras que next.config.ts aplica a una ruta concreta, fusionadas por orden. */ +/** Headers next.config.ts applies to a given path, merged in order. */ async function headersFor(path: string): Promise<Map<string, string>> { const rules = (await nextConfig.headers?.()) ?? []; const result = new Map<string, string>(); @@ -21,26 +21,26 @@ async function headersFor(path: string): Promise<Map<string, string>> { return result; } -describe("límite del cuerpo de las Server Actions", () => { - // Con el valor por defecto de Next (1 MB) cualquier adjunto de más de 1 MB fallaba - // antes de llegar a uploadAttachment. - it("deja pasar un adjunto del tamaño máximo con su sobrecarga multipart", () => { +describe("Server Actions body size limit", () => { + // With Next's default (1 MB) any attachment over 1 MB failed before reaching + // uploadAttachment. + it("lets through an attachment of the maximum size with its multipart overhead", () => { const limit = nextConfig.experimental?.serverActions?.bodySizeLimit; expect(typeof limit).toBe("number"); expect(limit).toBeGreaterThan(MAX_ATTACHMENT_BYTES + MULTIPART_OVERHEAD_BYTES); }); - // proxy.ts pasa por las rutas de página: si guarda menos que eso, el cuerpo llega - // truncado a la acción sin ningún error. - it("proxy guarda en memoria el cuerpo completo de esas peticiones", () => { + // proxy.ts runs on page routes: if it buffers less than that, the body reaches the + // action truncated without any error. + it("proxy buffers the full body of those requests", () => { expect(nextConfig.experimental?.proxyClientMaxBodySize).toBe( nextConfig.experimental?.serverActions?.bodySizeLimit, ); }); }); -describe("cabeceras de seguridad (next.config.ts)", () => { - it.each(["/", "/login", "/api/v1/boards"])("%s lleva las cabeceras fijas", async (path) => { +describe("security headers (next.config.ts)", () => { + it.each(["/", "/login", "/api/v1/boards"])("%s carries the fixed headers", async (path) => { const h = await headersFor(path); expect(h.get("Strict-Transport-Security")).toBe("max-age=31536000"); expect(h.get("X-Content-Type-Options")).toBe("nosniff"); @@ -48,20 +48,20 @@ describe("cabeceras de seguridad (next.config.ts)", () => { expect(h.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); }); - it("/api/v1 recibe una CSP cerrada (solo JSON, SSE y descargas)", async () => { + it("/api/v1 gets a locked-down CSP (only JSON, SSE and downloads)", async () => { const h = await headersFor("/api/v1/boards"); expect(h.get("Content-Security-Policy")).toBe( "default-src 'none'; frame-ancestors 'none'", ); }); - it("las páginas no reciben la CSP estática: la pone proxy.ts con nonce", async () => { + it("pages do not get the static CSP: proxy.ts sets it with a nonce", async () => { expect((await headersFor("/login")).has("Content-Security-Policy")).toBe(false); }); - // proxy.ts excluye los .svg de su matcher: sin esta regla se servían sin CSP y un SVG - // abierto directamente podía ejecutar scripts en el origen de la app. - it.each(["/file.svg", "/icons/logo.svg"])("%s recibe una CSP que bloquea scripts", async (path) => { + // proxy.ts excludes .svg from its matcher: without this rule they were served without a + // CSP and an SVG opened directly could run scripts on the app's origin. + it.each(["/file.svg", "/icons/logo.svg"])("%s gets a CSP that blocks scripts", async (path) => { const h = await headersFor(path); expect(h.get("Content-Security-Policy")).toBe( "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'", diff --git a/test/ops-scripts.test.ts b/test/ops-scripts.test.ts index 1dd10e1..76ea095 100644 --- a/test/ops-scripts.test.ts +++ b/test/ops-scripts.test.ts @@ -16,10 +16,10 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { gzipSync } from "node:zlib"; -// Tests de scripts/backup.sh y scripts/restore.sh con un `docker` falso en el PATH. -// No hace falta Docker: el falso simula pg_dump, psql y el contenedor app según variables -// de entorno FAKE_*, y apunta cada llamada en un log. Cada test copia los scripts a un -// «repo» temporal, porque se sitúan en la raíz del repositorio y leen su .env. +// Tests of scripts/backup.sh and scripts/restore.sh with a fake `docker` in the PATH. +// Docker is not needed: the fake simulates pg_dump, psql and the app container according +// to FAKE_* environment variables, and writes every call to a log. Each test copies the +// scripts to a temporary "repo", because they locate the repository root and read its .env. const SCRIPTS_DIR = path.resolve(__dirname, "../scripts"); const INSTALL_SH = path.resolve(__dirname, "../install.sh"); @@ -38,7 +38,7 @@ const DUMP_OK = [ "", ].join("\n"); -// Falso de `docker compose …`. Solo entiende las llamadas que hacen los scripts. +// Fake `docker compose …`. It only understands the calls the scripts make. const FAKE_DOCKER = `#!/usr/bin/env bash printf '%s\\n' "$*" >> "$FAKE_LOG" [ "$1" = compose ] || exit 90 @@ -62,7 +62,7 @@ case "$1" in exit 0 ;; *tar*) case "\${FAKE_UPLOADS:-ok}" in - ok) printf 'adjunto' | gzip; exit 0 ;; + ok) printf 'attachment' | gzip; exit 0 ;; missing) exit 3 ;; *) echo 'tar: write error' >&2; exit 2 ;; esac ;; @@ -88,7 +88,7 @@ beforeEach(() => { bin = path.join(root, "fakebin"); mkdirSync(bin); writeFileSync(path.join(bin, "docker"), FAKE_DOCKER, { mode: 0o755 }); - // update.sh hace `git pull`: el falso solo lo apunta en el mismo log. + // update.sh runs `git pull`: the fake only records it in the same log. writeFileSync( path.join(bin, "git"), `#!/usr/bin/env bash\nprintf 'git %s\\n' "$*" >> "$FAKE_LOG"\n`, @@ -108,7 +108,7 @@ function run( args: string[], opts: { cwd?: string; env?: Record<string, string>; input?: string } = {}, ) { - // umask 022 explícito: el script debe proteger sus ficheros aunque el entorno no lo haga. + // Explicit umask 022: the script must protect its files even if the environment does not. const res = spawnSync( "bash", ["-c", 'umask 022; exec "$@"', "_", path.join(root, "scripts", script), ...args], @@ -133,27 +133,27 @@ const mode = (p: string) => statSync(p).mode & 0o777; const dockerCalls = () => readFileSync(log, "utf8").trim().split("\n").filter(Boolean); describe("scripts/backup.sh", () => { - it("falla y no deja ficheros si pg_dump falla", () => { + it("fails and leaves no files if pg_dump fails", () => { const dest = path.join(root, "out"); const r = run("backup.sh", [dest], { env: { FAKE_PG_DUMP: "fail" } }); expect(r.status).not.toBe(0); expect(r.stdout).not.toContain("✓"); - expect(r.stderr).toContain("pg_dump ha fallado"); + expect(r.stderr).toContain("pg_dump failed"); expect(readdirSync(dest)).toEqual([]); }); - it("rechaza un volcado truncado (sin la marca final de pg_dump)", () => { + it("rejects a truncated dump (without pg_dump's end marker)", () => { const dest = path.join(root, "out"); const r = run("backup.sh", [dest], { env: { FAKE_PG_DUMP: "truncated" } }); expect(r.status).not.toBe(0); - expect(r.stderr).toContain("incompleto"); + expect(r.stderr).toContain("incomplete"); expect(readdirSync(dest)).toEqual([]); }); - it("funciona lanzado desde otro directorio y deja copias solo legibles por el dueño", () => { + it("works when launched from another directory and leaves backups readable only by the owner", () => { const r = run("backup.sh", [], { cwd: tmpdir() }); expect(r.status).toBe(0); - expect(r.stdout).toContain("✓ Backup completado"); + expect(r.stdout).toContain("✓ Backup completed"); const dir = path.join(root, "backups"); expect(mode(dir)).toBe(0o700); const files = readdirSync(dir).sort(); @@ -161,39 +161,39 @@ describe("scripts/backup.sh", () => { expect(files[0]).toMatch(/^orgtable-db-.*\.sql\.gz$/); expect(files[1]).toMatch(/^orgtable-uploads-.*\.tar\.gz$/); for (const f of files) expect(mode(path.join(dir, f))).toBe(0o600); - // El volcado se pide con --clean --if-exists y con el usuario/BD del .env. + // The dump is requested with --clean --if-exists and with the user/DB from .env. expect(dockerCalls()).toContain( "compose exec -T postgres pg_dump --clean --if-exists -U u -d d", ); }); - it("una ruta de destino relativa se resuelve desde el directorio de llamada", () => { + it("a relative destination path is resolved from the calling directory", () => { const cwd = mkdtempSync(path.join(root, "cwd-")); const r = run("backup.sh", ["rel"], { cwd }); expect(r.status).toBe(0); expect(readdirSync(path.join(cwd, "rel"))).toHaveLength(2); }); - it("falla si la app está en marcha y no se pueden archivar los adjuntos", () => { + it("fails if the app is running and the attachments cannot be archived", () => { const dest = path.join(root, "out"); const r = run("backup.sh", [dest], { env: { FAKE_UPLOADS: "fail" } }); expect(r.status).not.toBe(0); expect(r.stdout).not.toContain("✓"); expect(r.stderr).toContain("tar: write error"); const files = readdirSync(dest); - // La copia de la BD, ya verificada, se conserva; la de adjuntos no queda a medias. + // The DB backup, already verified, is kept; the attachments one is not left half-written. expect(files).toHaveLength(1); expect(files[0]).toMatch(/\.sql\.gz$/); }); - it("omite los adjuntos solo si no existe /data/uploads", () => { + it("skips the attachments only if /data/uploads does not exist", () => { const dest = path.join(root, "out"); const r = run("backup.sh", [dest], { env: { FAKE_UPLOADS: "missing" } }); expect(r.status).toBe(0); expect(readdirSync(dest)).toHaveLength(1); }); - it("con la app parada archiva los adjuntos con un contenedor efímero", () => { + it("with the app stopped it archives the attachments with a throwaway container", () => { const dest = path.join(root, "out"); const r = run("backup.sh", [dest], { env: { FAKE_APP_RUNNING: "0" } }); expect(r.status).toBe(0); @@ -209,44 +209,44 @@ describe("scripts/restore.sh", () => { return p; } - it("falla sin tocar Docker si el fichero no existe", () => { + it("fails without touching Docker if the file does not exist", () => { const r = run("restore.sh", [path.join(root, "nope.sql.gz")], { input: "y\n" }); expect(r.status).not.toBe(0); expect(r.stdout).not.toContain("✓"); expect(dockerCalls()).toEqual([]); }); - it("falla sin tocar Docker si el fichero no es un gzip válido", () => { - const file = writeDump("bad.sql.gz", "esto no es gzip"); + it("fails without touching Docker if the file is not a valid gzip", () => { + const file = writeDump("bad.sql.gz", "this is not gzip"); const r = run("restore.sh", [file], { input: "y\n" }); expect(r.status).not.toBe(0); expect(dockerCalls()).toEqual([]); }); - it("falla sin tocar Docker si el volcado está truncado", () => { + it("fails without touching Docker if the dump is truncated", () => { const file = writeDump("trunc.sql.gz", gzipSync("CREATE TABLE t (a int);\n")); const r = run("restore.sh", [file], { input: "y\n" }); expect(r.status).not.toBe(0); - expect(r.stderr).toContain("incompleto"); + expect(r.stderr).toContain("incomplete"); expect(dockerCalls()).toEqual([]); }); - it("no anuncia éxito si psql falla y vuelve a arrancar la app", () => { + it("does not announce success if psql fails, and starts the app again", () => { const file = writeDump("ok.sql.gz", gzipSync(DUMP_OK)); const r = run("restore.sh", [file], { input: "y\n", env: { FAKE_PSQL: "fail" } }); expect(r.status).not.toBe(0); - expect(r.stdout).not.toContain("✓ Restauración completada"); - expect(r.stderr).toContain("se ha deshecho"); + expect(r.stdout).not.toContain("✓ Restore completed"); + expect(r.stderr).toContain("rolled back"); const calls = dockerCalls(); expect(calls).toContain("compose stop app"); expect(calls[calls.length - 1]).toBe("compose start app"); }); - it("hace copia previa, vacía el esquema en la misma transacción y restaura", () => { + it("takes a safety backup, empties the schema in the same transaction and restores", () => { const file = writeDump("ok.sql.gz", gzipSync(DUMP_OK)); const r = run("restore.sh", [file], { input: "y\n" }); expect(r.status).toBe(0); - expect(r.stdout).toContain("✓ Restauración completada"); + expect(r.stdout).toContain("✓ Restore completed"); const pre = readdirSync(path.join(root, "backups")); expect(pre).toHaveLength(1); @@ -256,7 +256,7 @@ describe("scripts/restore.sh", () => { const calls = dockerCalls(); const psql = calls.find((c) => c.includes("psql")); expect(psql).toContain("-v ON_ERROR_STOP=1 --single-transaction"); - // La copia previa (pg_dump) va antes de parar la app y de restaurar. + // The safety backup (pg_dump) comes before stopping the app and restoring. const idx = (s: string) => calls.findIndex((c) => c.includes(s)); expect(idx("pg_dump")).toBeLessThan(idx("stop app")); expect(idx("stop app")).toBeLessThan(idx("psql")); @@ -269,26 +269,26 @@ describe("scripts/restore.sh", () => { expect(sql).toContain("CREATE TABLE t (a int);"); }); - it("no hace nada si no se confirma", () => { + it("does nothing if not confirmed", () => { const file = writeDump("ok.sql.gz", gzipSync(DUMP_OK)); const r = run("restore.sh", [file], { input: "n\n" }); expect(r.status).not.toBe(0); - expect(r.stdout).toContain("Cancelado."); + expect(r.stdout).toContain("Cancelled."); expect(dockerCalls()).toEqual([]); }); - it("aborta sin restaurar si la copia previa falla", () => { + it("aborts without restoring if the safety backup fails", () => { const file = writeDump("ok.sql.gz", gzipSync(DUMP_OK)); const r = run("restore.sh", [file], { input: "y\n", env: { FAKE_PG_DUMP: "fail" } }); expect(r.status).not.toBe(0); - expect(r.stderr).toContain("No se pudo hacer la copia previa"); + expect(r.stderr).toContain("Could not take the safety backup"); expect(dockerCalls().some((c) => c.includes("psql"))).toBe(false); expect(dockerCalls()).not.toContain("compose stop app"); }); }); describe("scripts/update.sh", () => { - it("hace la copia de seguridad antes de traer cambios y reconstruir", () => { + it("takes the backup before pulling changes and rebuilding", () => { const r = run("update.sh", []); expect(r.status).toBe(0); const calls = dockerCalls(); @@ -300,10 +300,10 @@ describe("scripts/update.sh", () => { expect(readdirSync(path.join(root, "backups"))).toHaveLength(2); }); - it("no actualiza si la copia de seguridad falla", () => { + it("does not update if the backup fails", () => { const r = run("update.sh", [], { env: { FAKE_PG_DUMP: "fail" } }); expect(r.status).not.toBe(0); - expect(r.stdout).not.toContain("✓ orgtable actualizado"); + expect(r.stdout).not.toContain("✓ orgtable updated"); const calls = dockerCalls(); expect(calls.some((c) => c.startsWith("git "))).toBe(false); expect(calls.some((c) => c.includes("--build"))).toBe(false); @@ -311,8 +311,9 @@ describe("scripts/update.sh", () => { }); describe("install.sh", () => { - // Instalación ya existente en `root` (hay .git y .env con CRON_SECRET): así no clona ni - // genera secretos, y el `git pull` y `docker compose` van a los falsos del PATH. + // Existing installation in `root` (there is a .git and a .env with CRON_SECRET): so it + // neither clones nor generates secrets, and `git pull` and `docker compose` go to the + // fakes in the PATH. function runInstall(envFile: string) { mkdirSync(path.join(root, ".git")); writeFileSync(path.join(root, ".env"), envFile); @@ -332,13 +333,13 @@ describe("install.sh", () => { return { status: res.status, stdout: res.stdout, stderr: res.stderr }; } - it("con el puerto publicado en todas las interfaces indica la IP del servidor", () => { + it("with the port published on every interface it points to the server's IP", () => { const r = runInstall("CRON_SECRET=x\nAPP_PORT=3000\n"); expect(r.status).toBe(0); expect(r.stdout).toContain("http://203.0.113.7:3000"); }); - it("con APP_PORT=127.0.0.1:… no manda a una URL inaccesible por la IP del servidor", () => { + it("with APP_PORT=127.0.0.1:… it does not point to a URL unreachable through the server's IP", () => { const r = runInstall("CRON_SECRET=x\nAPP_PORT=127.0.0.1:3000\n"); expect(r.status).toBe(0); expect(r.stdout).not.toContain("203.0.113.7"); @@ -346,13 +347,13 @@ describe("install.sh", () => { expect(r.stdout).toContain("http://localhost:3000"); }); - it("con una IP concreta en APP_PORT indica esa IP", () => { + it("with a specific IP in APP_PORT it points to that IP", () => { const r = runInstall("CRON_SECRET=x\nAPP_PORT=10.0.0.5:8080\n"); expect(r.status).toBe(0); expect(r.stdout).toContain("http://10.0.0.5:8080"); }); - it("sin APP_PORT en el .env termina bien y usa el 3000", () => { + it("without APP_PORT in .env it finishes fine and uses 3000", () => { const r = runInstall("CRON_SECRET=x\n"); expect(r.status).toBe(0); expect(r.stdout).toContain("http://203.0.113.7:3000"); diff --git a/test/optimistic.test.ts b/test/optimistic.test.ts index 0775481..9d00614 100644 --- a/test/optimistic.test.ts +++ b/test/optimistic.test.ts @@ -2,40 +2,40 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { redirect } from "next/navigation"; import { persistOptimistic } from "@/app/lib/optimistic"; -const log = { message: "[test] fallo", context: { cardId: "c1" } }; +const log = { message: "[test] failed", context: { cardId: "c1" } }; afterEach(() => { vi.restoreAllMocks(); }); describe("persistOptimistic", () => { - it("no deshace nada si el servidor confirma", async () => { + it("undoes nothing if the server confirms", async () => { const onFailure = vi.fn(); await persistOptimistic(async () => ({ ok: true }), onFailure, log); expect(onFailure).not.toHaveBeenCalled(); }); - it("deshace si el servidor rechaza el cambio ({ ok: false })", async () => { + it("undoes if the server rejects the change ({ ok: false })", async () => { const onFailure = vi.fn(); await persistOptimistic(async () => ({ ok: false }), onFailure, log); expect(onFailure).toHaveBeenCalledTimes(1); }); - it("deshace y registra el error si la llamada falla, sin propagar el rechazo", async () => { + it("undoes and logs the error if the call fails, without propagating the rejection", async () => { const onFailure = vi.fn(); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - const boom = new Error("red caída"); + const boom = new Error("network down"); await expect( persistOptimistic(() => Promise.reject(boom), onFailure, log), ).resolves.toBeUndefined(); expect(onFailure).toHaveBeenCalledTimes(1); - expect(consoleError).toHaveBeenCalledWith("[test] fallo", { + expect(consoleError).toHaveBeenCalledWith("[test] failed", { cardId: "c1", error: boom, }); }); - it("relanza las redirecciones de Next sin deshacer (las gestiona el router)", async () => { + it("rethrows Next's redirects without undoing (the router handles them)", async () => { const onFailure = vi.fn(); let redirectError: unknown; try { diff --git a/test/ordering.test.ts b/test/ordering.test.ts index e3161d8..980b596 100644 --- a/test/ordering.test.ts +++ b/test/ordering.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect } from "vitest"; import { generateKeyBetween } from "fractional-indexing"; import { positionAtEnd, isValidPositionKey } from "@/app/lib/ordering"; -// El orden de listas/tarjetas es una clave string lexicográfica (fractional indexing, -// ADR 0002). Lo que importa: añadir al final mantiene el orden y las claves que llegan del -// cliente se validan antes de guardarse. +// The order of lists/cards is a lexicographic string key (fractional indexing, ADR 0002). +// What matters: appending at the end keeps the order, and keys coming from the client are +// validated before being stored. describe("ordering (fractional indexing)", () => { - it("positionAtEnd produce claves en orden lexicográfico al añadir al final", () => { + it("positionAtEnd produces keys in lexicographic order when appending at the end", () => { let last: string | null = null; const keys: string[] = []; for (let i = 0; i < 6; i++) { @@ -15,19 +15,19 @@ describe("ordering (fractional indexing)", () => { last = k; } expect(keys).toEqual([...keys].sort()); - // Sin claves repetidas. + // No repeated keys. expect(new Set(keys).size).toBe(keys.length); }); - it("isValidPositionKey acepta claves generadas por la librería", () => { + it("isValidPositionKey accepts keys generated by the library", () => { expect(isValidPositionKey("a0")).toBe(true); expect(isValidPositionKey("a0V")).toBe(true); expect(isValidPositionKey(positionAtEnd(positionAtEnd(null)))).toBe(true); }); - // Regresión: con un tope de 64 caracteres, soltar tarjetas siempre en el mismo hueco - // superaba el tope a las 373 inserciones y moveCard/reorderList devolvían ok:false. - it("isValidPositionKey acepta las claves largas que salen de insertar en el mismo hueco", () => { + // Regression: with a 64-character cap, always dropping cards into the same gap exceeded + // the cap after 373 insertions and moveCard/reorderList returned ok:false. + it("isValidPositionKey accepts the long keys produced by inserting into the same gap", () => { const first = generateKeyBetween(null, null); let next = generateKeyBetween(first, null); for (let i = 0; i < 1000; i++) { @@ -37,7 +37,7 @@ describe("ordering (fractional indexing)", () => { expect(isValidPositionKey(next)).toBe(true); }); - it("isValidPositionKey rechaza claves que romperían positionAtEnd", () => { + it("isValidPositionKey rejects keys that would break positionAtEnd", () => { const bad = ["", "x", "a", "a00", "!!!", "a0 ", "zzzz~", "a" + "1".repeat(1024)]; for (const key of bad) { expect(isValidPositionKey(key)).toBe(false); diff --git a/test/rate-limit.test.ts b/test/rate-limit.test.ts index 29b9461..fd57b6e 100644 --- a/test/rate-limit.test.ts +++ b/test/rate-limit.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { rateLimit, resetRateLimit } from "@/app/lib/rate-limit"; -// Limitador en memoria de ventana fija (ADR 0009). Un `>` en lugar de `>=` o un fallo al -// reiniciar la ventana cambiaría el límite real del login sin que nada lo detectara. +// In-memory fixed-window limiter (ADR 0009). A `>` instead of `>=` or a bug when resetting +// the window would change the real sign-in limit without anything noticing. const MIN = 60 * 1000; beforeEach(() => { @@ -17,7 +17,7 @@ afterEach(() => { }); describe("rateLimit", () => { - it("permite `limit` intentos y bloquea el siguiente con el tiempo restante", () => { + it("allows `limit` attempts and blocks the next one with the remaining time", () => { for (let i = 0; i < 10; i++) { expect(rateLimit("k", 10, 10 * MIN).ok).toBe(true); } @@ -28,25 +28,25 @@ describe("rateLimit", () => { }); }); - it("retryAfterSec descuenta el tiempo ya pasado", () => { + it("retryAfterSec subtracts the time already elapsed", () => { for (let i = 0; i < 10; i++) rateLimit("k", 10, 10 * MIN); vi.advanceTimersByTime(300 * 1000); expect(rateLimit("k", 10, 10 * MIN).retryAfterSec).toBe(300); }); - it("al llegar a resetAt abre una ventana nueva", () => { + it("on reaching resetAt it opens a new window", () => { for (let i = 0; i < 11; i++) rateLimit("k", 10, 10 * MIN); vi.advanceTimersByTime(10 * MIN); expect(rateLimit("k", 10, 10 * MIN).ok).toBe(true); }); - it("claves distintas no se interfieren", () => { + it("different keys do not interfere", () => { for (let i = 0; i < 11; i++) rateLimit("a", 10, 10 * MIN); expect(rateLimit("a", 10, 10 * MIN).ok).toBe(false); expect(rateLimit("b", 10, 10 * MIN).ok).toBe(true); }); - it("firstBlock solo en el primer rechazo de cada ventana", () => { + it("firstBlock only on the first rejection of each window", () => { const results = Array.from({ length: 5 }, () => rateLimit("k", 2, MIN)); expect(results.map((r) => r.ok)).toEqual([true, true, false, false, false]); expect(results.map((r) => r.firstBlock)).toEqual([false, false, true, false, false]); @@ -56,7 +56,7 @@ describe("rateLimit", () => { expect(next.map((r) => r.firstBlock)).toEqual([false, false, true]); }); - it("resetRateLimit olvida los intentos de esa clave", () => { + it("resetRateLimit forgets the attempts of that key", () => { for (let i = 0; i < 3; i++) rateLimit("k", 2, MIN); resetRateLimit("k"); expect(rateLimit("k", 2, MIN).ok).toBe(true); diff --git a/test/realtime-bus.test.ts b/test/realtime-bus.test.ts index 18a7b28..4340817 100644 --- a/test/realtime-bus.test.ts +++ b/test/realtime-bus.test.ts @@ -1,10 +1,9 @@ import { EventEmitter } from "node:events"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -// Tests del puente LISTEN/NOTIFY del bus de tiempo real (app/lib/realtime/bus.ts). Se -// sustituye `pg` por un cliente falso: lo que se comprueba es el ciclo de vida de la -// conexión (cierre al fallar, reconexión con backoff) y la validación del payload, no -// Postgres en sí. +// Tests of the LISTEN/NOTIFY bridge of the realtime bus (app/lib/realtime/bus.ts). `pg` +// is replaced with a fake client: what is checked is the connection life cycle (closing +// on failure, reconnecting with backoff) and payload validation, not Postgres itself. type Behavior = { listenFails: boolean; connectDelayMs: number }; @@ -24,8 +23,8 @@ const pgState = vi.hoisted(() => ({ vi.mock("pg", async () => { const { EventEmitter: Emitter } = await import("node:events"); class Client extends Emitter { - // Sin retardo resuelve en el acto: así los temporizadores falsos solo cuentan los - // del bus (reintentos), no los del cliente simulado. + // Without a delay it resolves at once: that way the fake timers only count the bus's + // own (retries), not the simulated client's. connect = vi.fn(() => pgState.behavior.connectDelayMs > 0 ? new Promise<void>((resolve) => @@ -35,7 +34,7 @@ vi.mock("pg", async () => { ); query = vi.fn(async (sql: string) => { if (sql.startsWith("LISTEN") && pgState.behavior.listenFails) { - throw new Error("LISTEN rechazado"); + throw new Error("LISTEN rejected"); } return { rows: [] }; }); @@ -93,27 +92,27 @@ afterEach(() => { }); describe("parseBridgePayload", () => { - it("acepta un payload válido", async () => { + it("accepts a valid payload", async () => { const { parseBridgePayload } = await loadBus(); expect( parseBridgePayload(JSON.stringify({ kind: "board", id: "b1", origin: "x" })), ).toEqual({ kind: "board", id: "b1", origin: "x" }); }); - it("devuelve null si el payload no es válido", async () => { + it("returns null if the payload is not valid", async () => { const { parseBridgePayload } = await loadBus(); expect(parseBridgePayload(JSON.stringify({ kind: "board", origin: "x" }))).toBeNull(); expect( - parseBridgePayload(JSON.stringify({ kind: "otro", id: "b1", origin: "x" })), + parseBridgePayload(JSON.stringify({ kind: "other", id: "b1", origin: "x" })), ).toBeNull(); expect(parseBridgePayload("null")).toBeNull(); expect(parseBridgePayload("[]")).toBeNull(); - expect(parseBridgePayload("no es json")).toBeNull(); + expect(parseBridgePayload("not json")).toBeNull(); }); }); -describe("puente LISTEN/NOTIFY", () => { - it("cierra la conexión y lo registra si LISTEN falla, y reintenta con backoff", async () => { +describe("LISTEN/NOTIFY bridge", () => { + it("closes the connection and logs it if LISTEN fails, and retries with backoff", async () => { const errors = vi.spyOn(console, "error").mockImplementation(() => {}); const bus = await loadBus({ listenFails: true }); @@ -123,16 +122,16 @@ describe("puente LISTEN/NOTIFY", () => { expect(pgState.instances).toHaveLength(1); expect(pgState.instances[0].end).toHaveBeenCalled(); expect(errors).toHaveBeenCalledWith( - "[realtime] no se pudo iniciar el puente LISTEN/NOTIFY", + "[realtime] could not start the LISTEN/NOTIFY bridge", expect.any(Error), ); - // Otra suscripción durante el backoff no abre otra conexión. + // Another subscription during the backoff does not open another connection. const unsubscribe2 = bus.subscribeBoard("b2", () => {}); await vi.advanceTimersByTimeAsync(0); expect(pgState.instances).toHaveLength(1); - // Primer reintento a 1 s, segundo a 2 s: cada cliente fallido se cierra. + // First retry after 1 s, second after 2 s: each failed client is closed. await vi.advanceTimersByTimeAsync(1_000); expect(pgState.instances).toHaveLength(2); expect(pgState.instances[1].end).toHaveBeenCalled(); @@ -141,7 +140,7 @@ describe("puente LISTEN/NOTIFY", () => { await vi.advanceTimersByTimeAsync(1); expect(pgState.instances).toHaveLength(3); - // Sin suscriptores ya no se programa ningún reintento. + // Without subscribers no retry is scheduled any more. unsubscribe(); unsubscribe2(); await vi.advanceTimersByTimeAsync(60_000); @@ -149,7 +148,7 @@ describe("puente LISTEN/NOTIFY", () => { expect(vi.getTimerCount()).toBe(0); }); - it("tras un error de la conexión la cierra y reconecta", async () => { + it("after a connection error it closes it and reconnects", async () => { const errors = vi.spyOn(console, "error").mockImplementation(() => {}); const bus = await loadBus(); @@ -158,10 +157,10 @@ describe("puente LISTEN/NOTIFY", () => { expect(pgState.instances).toHaveLength(1); expect(pgState.instances[0].end).not.toHaveBeenCalled(); - pgState.instances[0].emit("error", new Error("conexión perdida")); + pgState.instances[0].emit("error", new Error("connection lost")); expect(pgState.instances[0].end).toHaveBeenCalledTimes(1); expect(errors).toHaveBeenCalledWith( - "[realtime] error en la conexión del puente LISTEN/NOTIFY", + "[realtime] error on the LISTEN/NOTIFY bridge connection", expect.any(Error), ); @@ -170,7 +169,7 @@ describe("puente LISTEN/NOTIFY", () => { expect(pgState.instances[1].end).not.toHaveBeenCalled(); }); - it("publicar durante el arranque espera al puente en vez de perder el evento", async () => { + it("publishing during start-up waits for the bridge instead of losing the event", async () => { const bus = await loadBus({ connectDelayMs: 500 }); bus.subscribeBoard("b1", () => {}); @@ -185,11 +184,11 @@ describe("puente LISTEN/NOTIFY", () => { expect(notifyCall).toBeDefined(); }); - it("reemite los NOTIFY válidos de otras instancias y descarta los inválidos", async () => { + it("re-emits valid NOTIFYs from other instances and discards invalid ones", async () => { const warns = vi.spyOn(console, "warn").mockImplementation(() => {}); const bus = await loadBus(); const received: string[] = []; - // Antes, un payload sin id se reemitía en "board:undefined". + // A payload without an id used to be re-emitted on "board:undefined". const stray = vi.fn(); busEmitter().on("board:undefined", stray); @@ -198,14 +197,14 @@ describe("puente LISTEN/NOTIFY", () => { const client = pgState.instances[0]; client.emit("notification", { - payload: JSON.stringify({ kind: "board", id: "b1", origin: "otra" }), + payload: JSON.stringify({ kind: "board", id: "b1", origin: "other" }), }); expect(received).toEqual(["b1"]); client.emit("notification", { - payload: JSON.stringify({ kind: "board", origin: "otra" }), + payload: JSON.stringify({ kind: "board", origin: "other" }), }); - client.emit("notification", { payload: "{roto" }); + client.emit("notification", { payload: "{broken" }); expect(received).toEqual(["b1"]); expect(warns).toHaveBeenCalledTimes(2); expect(stray).not.toHaveBeenCalled(); diff --git a/test/realtime-reconnect.test.ts b/test/realtime-reconnect.test.ts index 3cad1c0..41a35e5 100644 --- a/test/realtime-reconnect.test.ts +++ b/test/realtime-reconnect.test.ts @@ -13,17 +13,17 @@ import { RETRY_MAX_MS, } from "@/app/lib/realtime/reconnect"; -// Regresión: tras un corte del EventSource, los cambios hechos mientras tanto no -// llegaban nunca porque el servidor no reenvía eventos. Ahora cada reapertura refresca. +// Regression: after an EventSource drop, changes made in the meantime never arrived +// because the server does not resend events. Now every reopening refreshes. describe("createReconnectRefresher", () => { - it("no refresca en la primera apertura", () => { + it("does not refresh on the first opening", () => { const onReconnect = vi.fn(); const onOpen = createReconnectRefresher(onReconnect); onOpen(); expect(onReconnect).not.toHaveBeenCalled(); }); - it("refresca en cada reapertura posterior", () => { + it("refreshes on every later reopening", () => { const onReconnect = vi.fn(); const onOpen = createReconnectRefresher(onReconnect); onOpen(); @@ -32,7 +32,7 @@ describe("createReconnectRefresher", () => { expect(onReconnect).toHaveBeenCalledTimes(2); }); - it("cada EventSource lleva su propio estado", () => { + it("each EventSource keeps its own state", () => { const a = vi.fn(); const b = vi.fn(); const onOpenA = createReconnectRefresher(a); @@ -45,7 +45,7 @@ describe("createReconnectRefresher", () => { }); }); -// Doble de EventSource: registra los manejadores y deja simular open/error/CLOSED. +// EventSource double: records the handlers and lets the test simulate open/error/CLOSED. class FakeSource { readyState = 0; closed = false; @@ -68,12 +68,12 @@ class FakeSource { this.readyState = 1; this.emit("open"); } - // El navegador se rinde (respuesta no 200, p. ej. 502 del proxy): CLOSED + error. + // The browser gives up (non-200 response, e.g. a 502 from the proxy): CLOSED + error. fail() { this.readyState = 2; this.emit("error"); } - // Corte transitorio: el navegador reintentará solo (CONNECTING + error). + // Transient drop: the browser will retry on its own (CONNECTING + error). drop() { this.readyState = 0; this.emit("error"); @@ -85,7 +85,7 @@ class FakeSource { } } -// `status`: lo que respondería el endpoint al comprobar por qué se cerró la fuente. +// `status`: what the endpoint would answer when checking why the source closed. function setup(status: number | null = 502) { const sources: FakeSource[] = []; const onEvent = vi.fn(); @@ -106,19 +106,19 @@ function setup(status: number | null = 502) { return { sources, onEvent, onReconnect, onAccessDenied, probe, stop }; } -// Deja resolver la promesa de `probe` sin adelantar el reloj. +// Lets the `probe` promise resolve without advancing the clock. const flush = () => vi.advanceTimersByTimeAsync(0); describe("retryDelay", () => { - it("dobla desde 3 s hasta un máximo de 30 s", () => { + it("doubles from 3 s up to a maximum of 30 s", () => { expect([0, 1, 2, 3, 4, 10].map(retryDelay)).toEqual([ 3_000, 6_000, 12_000, 24_000, 30_000, 30_000, ]); }); }); -// Regresión: con un 502 del proxy durante un reinicio el EventSource quedaba CLOSED y -// nadie creaba otro; el tiempo real se perdía hasta recargar la pestaña. +// Regression: with a 502 from the proxy during a restart the EventSource stayed CLOSED and +// nobody created another one; realtime was lost until the tab was reloaded. describe("keepEventSourceOpen", () => { beforeEach(() => { vi.useFakeTimers(); @@ -127,7 +127,7 @@ describe("keepEventSourceOpen", () => { vi.useRealTimers(); }); - it("reenvía los eventos SSE al manejador", () => { + it("forwards SSE events to the handler", () => { const { sources, onEvent, stop } = setup(); sources[0].open(); sources[0].emit("change"); @@ -135,7 +135,7 @@ describe("keepEventSourceOpen", () => { stop(); }); - it("recrea la fuente tras CLOSED con backoff y refresca al reabrir", async () => { + it("recreates the source after CLOSED with backoff and refreshes on reopening", async () => { const { sources, onReconnect, onAccessDenied, probe, stop } = setup(502); sources[0].open(); expect(onReconnect).not.toHaveBeenCalled(); @@ -150,7 +150,7 @@ describe("keepEventSourceOpen", () => { vi.advanceTimersByTime(1); expect(sources).toHaveLength(2); - // El servidor sigue caído: el siguiente intento espera el doble. + // The server is still down: the next attempt waits twice as long. sources[1].fail(); await flush(); vi.advanceTimersByTime(2 * RETRY_BASE_MS - 1); @@ -158,11 +158,11 @@ describe("keepEventSourceOpen", () => { vi.advanceTimersByTime(1); expect(sources).toHaveLength(3); - // Vuelve: la primera apertura de la fuente recreada sí es una reconexión. + // It comes back: the first opening of the recreated source is a reconnection. sources[2].open(); expect(onReconnect).toHaveBeenCalledTimes(1); - // Tras abrir, el backoff vuelve a empezar en 3 s. + // After opening, the backoff starts again at 3 s. sources[2].fail(); await flush(); vi.advanceTimersByTime(RETRY_BASE_MS); @@ -171,11 +171,11 @@ describe("keepEventSourceOpen", () => { stop(); }); - // Regresión: con un 502 del proxy (o un 500 por un fallo pasajero de la BD) se hacía - // router.refresh(); en Next 16 un refresh con respuesta no OK navega a la página de - // error y la pestaña perdía la app. Ahora un 5xx o un error de red solo reintentan. + // Regression: with a 502 from the proxy (or a 500 from a transient DB failure) it called + // router.refresh(); in Next 16 a refresh with a non-OK response navigates to the error + // page and the tab lost the app. Now a 5xx or a network error only retries. it.each([500, 502, 503, null])( - "ante %s no avisa de acceso denegado y reintenta", + "on %s it does not report access denied and retries", async (status) => { const { sources, onAccessDenied, stop } = setup(status); sources[0].fail(); @@ -188,7 +188,7 @@ describe("keepEventSourceOpen", () => { ); it.each([401, 403])( - "ante %s avisa de acceso denegado (para redirigir) y reintenta", + "on %s it reports access denied (to redirect) and retries", async (status) => { const { sources, onAccessDenied, stop } = setup(status); sources[0].fail(); @@ -200,7 +200,7 @@ describe("keepEventSourceOpen", () => { }, ); - it("si la comprobación falla, reintenta sin avisar de acceso denegado", async () => { + it("if the check fails, it retries without reporting access denied", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const sources: FakeSource[] = []; const onAccessDenied = vi.fn(); @@ -212,7 +212,7 @@ describe("keepEventSourceOpen", () => { }, events: {}, onReconnect: () => {}, - probe: () => Promise.reject(new Error("fallo inesperado")), + probe: () => Promise.reject(new Error("unexpected failure")), onAccessDenied, }); sources[0].fail(); @@ -225,7 +225,7 @@ describe("keepEventSourceOpen", () => { errorSpy.mockRestore(); }); - it("no reintenta ni avisa si se desmonta mientras se comprueba el estado", async () => { + it("neither retries nor reports if it unmounts while the status is being checked", async () => { const { sources, onAccessDenied, stop } = setup(401); sources[0].fail(); stop(); @@ -235,10 +235,10 @@ describe("keepEventSourceOpen", () => { expect(sources).toHaveLength(1); }); - // Regresión: el reintento se programaba solo al terminar la comprobación; si el proxy - // retenía la conexión o el servidor no llegaba a responder, no se recreaba la fuente - // nunca y el tiempo real se perdía hasta recargar la pestaña. - it("si la comprobación no responde, reintenta tras el plazo sin avisar", async () => { + // Regression: the retry was only scheduled when the check finished; if the proxy held + // the connection or the server never answered, the source was never recreated and + // realtime was lost until the tab was reloaded. + it("if the check does not answer, it retries after the timeout without reporting", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const sources: FakeSource[] = []; const onAccessDenied = vi.fn(); @@ -251,7 +251,7 @@ describe("keepEventSourceOpen", () => { }, events: {}, onReconnect: () => {}, - // Ignora la señal a propósito: el plazo no puede depender de que la respete. + // Ignores the signal on purpose: the timeout cannot depend on it being honoured. probe: (signal) => { signals.push(signal); return new Promise<number | null>(() => {}); @@ -271,7 +271,7 @@ describe("keepEventSourceOpen", () => { errorSpy.mockRestore(); }); - it("el desmontaje aborta la comprobación en curso", async () => { + it("unmounting aborts the check in progress", async () => { const { sources, probe, stop } = setup(502); sources[0].fail(); const signal = probe.mock.calls[0][0]; @@ -282,7 +282,7 @@ describe("keepEventSourceOpen", () => { expect(sources).toHaveLength(1); }); - it("no recrea la fuente en un corte que el navegador reintenta solo", async () => { + it("does not recreate the source on a drop the browser retries on its own", async () => { const { sources, probe, onReconnect, stop } = setup(); sources[0].open(); sources[0].drop(); @@ -295,7 +295,7 @@ describe("keepEventSourceOpen", () => { stop(); }); - it("la limpieza cierra la fuente y cancela el reintento pendiente", async () => { + it("cleanup closes the source and cancels the pending retry", async () => { const { sources, stop } = setup(); sources[0].fail(); await flush(); @@ -311,8 +311,8 @@ describe("keepEventSourceOpen", () => { }); }); -// Regresión: en /board/[id] la campana y el tablero reconectan a la vez y cada uno -// lanzaba su router.refresh(); dos renders completos del tablero en vez de uno. +// Regression: on /board/[id] the bell and the board reconnect at the same time and each +// fired its own router.refresh(); two full renders of the board instead of one. describe("scheduleRefresh", () => { beforeEach(() => { vi.useFakeTimers(); @@ -321,7 +321,7 @@ describe("scheduleRefresh", () => { vi.useRealTimers(); }); - it("agrupa en un solo refresh las peticiones de varios componentes", () => { + it("groups the requests of several components into a single refresh", () => { const refreshBoard = vi.fn(); const refreshBell = vi.fn(); scheduleRefresh(refreshBoard); @@ -330,7 +330,7 @@ describe("scheduleRefresh", () => { expect(refreshBoard.mock.calls.length + refreshBell.mock.calls.length).toBe(1); }); - it("vuelve a refrescar si la petición llega fuera de la ventana", () => { + it("refreshes again if the request arrives outside the window", () => { const refresh = vi.fn(); scheduleRefresh(refresh); vi.advanceTimersByTime(REFRESH_DEBOUNCE_MS - 1); @@ -343,8 +343,8 @@ describe("scheduleRefresh", () => { }); }); -// Con un servidor HTTP real: el estado tiene que llegar sin esperar al cuerpo (un -// stream SSE no termina nunca) y la conexión debe cerrarse al momento. +// With a real HTTP server: the status has to arrive without waiting for the body (an SSE +// stream never ends) and the connection must be closed right away. describe("probeStatus", () => { let server: Server; let base: string; @@ -364,12 +364,12 @@ describe("probeStatus", () => { res.writeHead(502).end("Bad Gateway"); return; } - // Proxy que retiene la conexión sin enviar cabeceras. + // A proxy that holds the connection without sending headers. if (req.url === "/hang") { res.on("close", markClosed); return; } - // Stream SSE que no se cierra por su cuenta. + // An SSE stream that does not close on its own. res.writeHead(200, { "Content-Type": "text/event-stream" }); res.write(": connected\n\n"); res.on("close", markClosed); @@ -383,17 +383,17 @@ describe("probeStatus", () => { await new Promise<void>((resolve) => server.close(() => resolve())); }); - it("devuelve el estado de error del endpoint", async () => { + it("returns the endpoint's error status", async () => { expect(await probeStatus(`${base}/401`)).toBe(401); expect(await probeStatus(`${base}/502`)).toBe(502); }); - it("con un stream abierto devuelve 200 y corta la conexión", async () => { + it("with an open stream it returns 200 and cuts the connection", async () => { expect(await probeStatus(`${base}/stream`)).toBe(200); await streamClosed; }); - it("si se aborta la señal antes de la respuesta devuelve null y corta", async () => { + it("if the signal is aborted before the response it returns null and cuts", async () => { const controller = new AbortController(); const pending = probeStatus(`${base}/hang`, controller.signal); setTimeout(() => controller.abort(), 50); @@ -401,17 +401,17 @@ describe("probeStatus", () => { await streamClosed; }); - it("con la señal ya abortada devuelve null sin hacer la petición", async () => { + it("with an already aborted signal it returns null without making the request", async () => { const controller = new AbortController(); controller.abort(); expect(await probeStatus(`${base}/401`, controller.signal)).toBeNull(); }); - it("sin respuesta (servidor caído) devuelve null", async () => { + it("without a response (server down) it returns null", async () => { const port = (server.address() as AddressInfo).port; server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); - // Se vuelve a crear para que afterEach tenga algo que cerrar. + // It is created again so that afterEach has something to close. expect(await probeStatus(`http://127.0.0.1:${port}/401`)).toBeNull(); server = createServer(); await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); diff --git a/test/roles.test.ts b/test/roles.test.ts index 6cad502..7a49ea5 100644 --- a/test/roles.test.ts +++ b/test/roles.test.ts @@ -3,35 +3,44 @@ import { canEditBoard, canManageBoard, atLeastBoardRole, - BOARD_ROLE_LABELS, ASSIGNABLE_BOARD_ROLES, } from "@/app/lib/roles"; +import { messagesFor } from "@/app/i18n/config"; -// La jerarquía de roles es el corazón de la autorización (ver docs/authorization.md): -// si esto se rompe, la UI o la API podrían conceder de más. +// The role hierarchy is the heart of authorization (see docs/authorization.md): +// if this breaks, the UI or the API could grant too much. describe("roles", () => { - it("canEditBoard: EDITOR y OWNER pueden editar; VIEWER no", () => { + it("canEditBoard: EDITOR and OWNER can edit; VIEWER cannot", () => { expect(canEditBoard("OWNER")).toBe(true); expect(canEditBoard("EDITOR")).toBe(true); expect(canEditBoard("VIEWER")).toBe(false); }); - it("canManageBoard: solo el OWNER administra", () => { + it("canManageBoard: only the OWNER administers", () => { expect(canManageBoard("OWNER")).toBe(true); expect(canManageBoard("EDITOR")).toBe(false); expect(canManageBoard("VIEWER")).toBe(false); }); - it("atLeastBoardRole respeta la jerarquía VIEWER < EDITOR < OWNER", () => { + it("atLeastBoardRole follows the hierarchy VIEWER < EDITOR < OWNER", () => { expect(atLeastBoardRole("OWNER", "EDITOR")).toBe(true); expect(atLeastBoardRole("EDITOR", "EDITOR")).toBe(true); expect(atLeastBoardRole("EDITOR", "OWNER")).toBe(false); expect(atLeastBoardRole("VIEWER", "EDITOR")).toBe(false); }); - it("OWNER no es un rol asignable desde la UI", () => { + it("OWNER is not a role that can be assigned from the UI", () => { expect(ASSIGNABLE_BOARD_ROLES).toEqual(["EDITOR", "VIEWER"]); expect(ASSIGNABLE_BOARD_ROLES).not.toContain("OWNER"); - expect(BOARD_ROLE_LABELS.OWNER).toBe("Propietario"); + }); + + it("every board role has a visible name in each language (m.boards.roles)", () => { + // The labels moved from roles.ts to locales/*/boards.json. + expect(messagesFor("en").boards.roles).toEqual({ + OWNER: "Owner", + EDITOR: "Editor", + VIEWER: "Viewer", + }); + expect(messagesFor("es").boards.roles.OWNER).toBe("Propietario"); }); }); diff --git a/test/search-params.test.ts b/test/search-params.test.ts index ee37b28..72a2e26 100644 --- a/test/search-params.test.ts +++ b/test/search-params.test.ts @@ -2,15 +2,15 @@ import { describe, it, expect } from "vitest"; import { firstParam } from "@/app/lib/search-params"; describe("firstParam", () => { - it("devuelve el string tal cual", () => { - expect(firstParam("hola")).toBe("hola"); + it("returns the string as is", () => { + expect(firstParam("hello")).toBe("hello"); }); - it("con la clave repetida (?q=a&q=b) devuelve el primer valor", () => { + it("with a repeated key (?q=a&q=b) it returns the first value", () => { expect(firstParam(["a", "b"])).toBe("a"); }); - it("con un array vacío o sin parámetro devuelve undefined", () => { + it("with an empty array or no parameter it returns undefined", () => { expect(firstParam([])).toBeUndefined(); expect(firstParam(undefined)).toBeUndefined(); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 26d4f98..0b05536 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, vi, afterAll, afterEach } from "vitest"; import { mkdirSync, rmSync, existsSync } from "node:fs"; import path from "node:path"; -// El directorio de subida se fija al importar storage.ts, así que la variable se define -// antes de cualquier import (vi.hoisted se ejecuta primero). +// The upload directory is fixed when storage.ts is imported, so the variable is set +// before any import (vi.hoisted runs first). const uploadDir = await vi.hoisted(async () => { const { mkdtempSync } = await import("node:fs"); const { tmpdir } = await import("node:os"); @@ -27,30 +27,30 @@ afterAll(() => { }); describe("storage: deleteAttachmentFile", () => { - it("borra el fichero guardado", async () => { + it("deletes the stored file", async () => { const { storedName } = await saveAttachmentFile( - new File(["hola"], "nota.txt", { type: "text/plain" }), + new File(["hello"], "note.txt", { type: "text/plain" }), ); expect(existsSync(path.join(uploadDir, storedName))).toBe(true); await deleteAttachmentFile(storedName); expect(existsSync(path.join(uploadDir, storedName))).toBe(false); }); - it("un fichero que ya no existe (ENOENT) no registra nada", async () => { + it("a file that no longer exists (ENOENT) logs nothing", async () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - await deleteAttachmentFile("no-existe.txt"); + await deleteAttachmentFile("missing.txt"); expect(spy).not.toHaveBeenCalled(); }); - // Regresión: el catch vacío se tragaba cualquier error. Un directorio con el nombre del - // adjunto hace fallar unlink con EISDIR (Linux) o EPERM (macOS), no con ENOENT. - it("otros errores de unlink se registran sin lanzar", async () => { + // Regression: the empty catch swallowed any error. A directory with the attachment's + // name makes unlink fail with EISDIR (Linux) or EPERM (macOS), not with ENOENT. + it("other unlink errors are logged without throwing", async () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - mkdirSync(path.join(uploadDir, "es-un-directorio")); - await expect(deleteAttachmentFile("es-un-directorio")).resolves.toBeUndefined(); + mkdirSync(path.join(uploadDir, "is-a-directory")); + await expect(deleteAttachmentFile("is-a-directory")).resolves.toBeUndefined(); expect(spy).toHaveBeenCalledTimes(1); const [, detail] = spy.mock.calls[0] as [string, { storedName: string; code: string }]; - expect(detail.storedName).toBe("es-un-directorio"); + expect(detail.storedName).toBe("is-a-directory"); expect(["EISDIR", "EPERM"]).toContain(detail.code); }); }); diff --git a/test/stubs/next-cache.ts b/test/stubs/next-cache.ts index 1204d35..46d3ee9 100644 --- a/test/stubs/next-cache.ts +++ b/test/stubs/next-cache.ts @@ -1,5 +1,5 @@ -// Stub de `next/cache` para los tests de integración: fuera del runtime de Next no hay -// store de revalidación, así que estas funciones son no-ops. Ver vitest.integration.config.ts. +// `next/cache` stub for the integration tests: outside the Next runtime there is no +// revalidation store, so these functions are no-ops. See vitest.integration.config.ts. export function revalidatePath(): void {} export function revalidateTag(): void {} export function refresh(): void {} diff --git a/test/stubs/next-headers.ts b/test/stubs/next-headers.ts index ca928a4..cad2c0d 100644 --- a/test/stubs/next-headers.ts +++ b/test/stubs/next-headers.ts @@ -1,12 +1,12 @@ -// Stub de `next/headers` para los tests de integración. Fuera de Next no hay petición, -// así que cookies y cabeceras viven en un almacén MUTABLE a nivel de módulo: -// - `cookies()` se comporta como el store de Next (`get`/`set`/`delete`/`has`), de modo -// que createSession() escribe la cookie y verifySession()/requireUser() la leen. Así -// se pueden probar las Server Actions con sesión (helper `loginAs` en +// `next/headers` stub for the integration tests. Outside Next there is no request, so +// cookies and headers live in a MUTABLE module-level store: +// - `cookies()` behaves like Next's store (`get`/`set`/`delete`/`has`), so +// createSession() writes the cookie and verifySession()/requireUser() read it. This +// makes it possible to test Server Actions with a session (the `loginAs` helper in // test/integration/helpers.ts). -// - `headers()` devuelve las cabeceras inyectadas con `__setHeaders` (p. ej. -// X-Forwarded-For para el rate limit del login). -// Los helpers `__*` solo existen en el stub; `resetDb()` los limpia antes de cada test. +// - `headers()` returns the headers injected with `__setHeaders` (e.g. +// X-Forwarded-For for the login rate limit). +// The `__*` helpers only exist in the stub; `resetDb()` clears them before each test. type StoredCookie = { name: string; @@ -37,26 +37,26 @@ export async function cookies() { }; } -/** Sustituye las cabeceras de la "petición" en curso. */ +/** Replaces the headers of the current "request". */ export function __setHeaders(init: Record<string, string>): void { requestHeaders = new Headers(init); } /** - * Vacía solo las cookies. Va separado de `__clearHeaders` porque `loginAs` lo llama y - * debe conservar las cabeceras inyectadas antes (X-Forwarded-Proto decide el Secure de la - * cookie de sesión y X-Forwarded-For la IP del rate limit). + * Clears only the cookies. Kept separate from `__clearHeaders` because `loginAs` calls it + * and must keep the headers injected earlier (X-Forwarded-Proto decides the session + * cookie's Secure flag and X-Forwarded-For the rate-limit IP). */ export function __clearCookies(): void { cookieJar.clear(); } -/** Vacía las cabeceras inyectadas con `__setHeaders`. */ +/** Clears the headers injected with `__setHeaders`. */ export function __clearHeaders(): void { requestHeaders = new Headers(); } -/** Opciones con las que se guardó una cookie (secure, httpOnly…), para aserciones. */ +/** Options a cookie was stored with (secure, httpOnly…), for assertions. */ export function __getCookieOptions( name: string, ): Record<string, unknown> | undefined { diff --git a/test/stubs/server-only.ts b/test/stubs/server-only.ts index 1cb7ae7..cc660d3 100644 --- a/test/stubs/server-only.ts +++ b/test/stubs/server-only.ts @@ -1,3 +1,3 @@ -// Stub de `server-only` para los tests: en Vitest no hay frontera servidor/cliente, así -// que importar este módulo es un no-op. Ver vitest.config.ts. +// `server-only` stub for tests: Vitest has no server/client boundary, so importing this +// module is a no-op. See vitest.config.ts. export {}; diff --git a/test/tokens.test.ts b/test/tokens.test.ts index 071ed8f..602f34b 100644 --- a/test/tokens.test.ts +++ b/test/tokens.test.ts @@ -1,26 +1,26 @@ import { describe, it, expect } from "vitest"; import { hashToken, generateApiToken } from "@/app/lib/api/tokens"; -// Tokens de la API pública (Hito 9, ADR 0013). Invariantes: el hash es SHA-256 estable, -// el token lleva el prefijo `orgt_`, su hash coincide y nunca se repite. +// Public API tokens (milestone 9, ADR 0013). Invariants: the hash is a stable SHA-256, +// the token carries the `orgt_` prefix, its hash matches and it never repeats. describe("api tokens", () => { - it("hashToken es determinista y SHA-256 en hex (64 chars)", () => { - const h = hashToken("orgt_ejemplo"); - expect(h).toBe(hashToken("orgt_ejemplo")); + it("hashToken is deterministic and SHA-256 in hex (64 chars)", () => { + const h = hashToken("orgt_example"); + expect(h).toBe(hashToken("orgt_example")); expect(h).toMatch(/^[0-9a-f]{64}$/); - expect(hashToken("otro")).not.toBe(h); + expect(hashToken("other")).not.toBe(h); }); - it("generateApiToken: prefijo orgt_, hash coherente y prefijo visible de 12", () => { + it("generateApiToken: orgt_ prefix, consistent hash and a 12-character visible prefix", () => { const t = generateApiToken(); expect(t.token.startsWith("orgt_")).toBe(true); expect(t.hash).toBe(hashToken(t.token)); expect(t.hash).toMatch(/^[0-9a-f]{64}$/); expect(t.token.startsWith(t.prefix)).toBe(true); - expect(t.prefix.length).toBe(12); // "orgt_" (5) + 7 caracteres + expect(t.prefix.length).toBe(12); // "orgt_" (5) + 7 characters }); - it("dos tokens consecutivos son distintos", () => { + it("two consecutive tokens are different", () => { const a = generateApiToken(); const b = generateApiToken(); expect(a.token).not.toBe(b.token); diff --git a/test/user-agent.test.ts b/test/user-agent.test.ts new file mode 100644 index 0000000..9a83341 --- /dev/null +++ b/test/user-agent.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { describeUserAgent, parseUserAgent } from "@/app/lib/user-agent"; +import { messagesFor } from "@/app/i18n/config"; + +const FIREFOX_MAC = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.6; rv:131.0) Gecko/20100101 Firefox/131.0"; +const CHROME_ANDROID = + "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Mobile Safari/537.36"; +const EDGE_WINDOWS = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36 Edg/129.0"; +const SAFARI_IOS = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1"; + +describe("parseUserAgent", () => { + it.each([ + [FIREFOX_MAC, "Firefox", "macOS"], + [CHROME_ANDROID, "Chrome", "Android"], + [EDGE_WINDOWS, "Edge", "Windows"], + [SAFARI_IOS, "Safari", "iOS"], + ])("%s → %s / %s", (ua, browser, os) => { + expect(parseUserAgent(ua)).toEqual({ browser, os }); + }); + + it("returns null for what it does not recognise", () => { + expect(parseUserAgent("curl/8.7.1")).toEqual({ browser: null, os: null }); + expect(parseUserAgent(null)).toEqual({ browser: null, os: null }); + }); + + it("recognises the system without the browser and vice versa", () => { + expect(parseUserAgent("SomeBot (Linux)")).toEqual({ browser: null, os: "Linux" }); + expect(parseUserAgent("Firefox/131.0")).toEqual({ browser: "Firefox", os: null }); + }); +}); + +describe("describeUserAgent", () => { + const en = messagesFor("en").settings.security; + const es = messagesFor("es").settings.security; + + it.each([ + [FIREFOX_MAC, "Firefox on macOS"], + [CHROME_ANDROID, "Chrome on Android"], + [EDGE_WINDOWS, "Edge on Windows"], + [SAFARI_IOS, "Safari on iOS"], + ["curl/8.7.1", "Unknown browser"], + ])("%s → %s", (ua, expected) => { + expect(describeUserAgent(ua, en)).toBe(expected); + }); + + it("without a user-agent", () => { + expect(describeUserAgent(null, en)).toBe("Unknown browser"); + }); + + it("uses only the browser or the system when the other one is unknown", () => { + expect(describeUserAgent("Firefox/131.0", en)).toBe("Firefox"); + expect(describeUserAgent("SomeBot (Linux)", en)).toBe("Linux"); + }); + + it("uses the templates of the reader's language", () => { + expect(describeUserAgent(FIREFOX_MAC, es)).toBe("Firefox en macOS"); + expect(describeUserAgent(null, es)).toBe("Navegador desconocido"); + }); +}); diff --git a/test/validators.test.ts b/test/validators.test.ts index da95520..32af34d 100644 --- a/test/validators.test.ts +++ b/test/validators.test.ts @@ -18,19 +18,19 @@ import { import { ASSIGNABLE_BOARD_ROLES } from "@/app/lib/roles"; describe("validators (zod)", () => { - it("boardTitleSchema recorta y exige 1..120 caracteres", () => { - expect(boardTitleSchema.parse(" Hola ")).toBe("Hola"); + it("boardTitleSchema trims and requires 1..120 characters", () => { + expect(boardTitleSchema.parse(" Hello ")).toBe("Hello"); expect(boardTitleSchema.safeParse("").success).toBe(false); expect(boardTitleSchema.safeParse(" ").success).toBe(false); expect(boardTitleSchema.safeParse("x".repeat(121)).success).toBe(false); }); - it("apiCardCreateSchema exige título y admite campos opcionales", () => { - expect(apiCardCreateSchema.safeParse({ title: "Tarea" }).success).toBe(true); + it("apiCardCreateSchema requires a title and accepts optional fields", () => { + expect(apiCardCreateSchema.safeParse({ title: "Task" }).success).toBe(true); expect( apiCardCreateSchema.safeParse({ - title: "Tarea", - description: "detalle", + title: "Task", + description: "details", dueDate: "2026-06-20", }).success, ).toBe(true); @@ -38,22 +38,22 @@ describe("validators (zod)", () => { expect(apiCardCreateSchema.safeParse({}).success).toBe(false); }); - it("apiCardUpdateSchema rechaza un objeto vacío (refine) y acepta campos sueltos", () => { + it("apiCardUpdateSchema rejects an empty object (refine) and accepts single fields", () => { expect(apiCardUpdateSchema.safeParse({}).success).toBe(false); - expect(apiCardUpdateSchema.safeParse({ title: "Nuevo" }).success).toBe(true); + expect(apiCardUpdateSchema.safeParse({ title: "New" }).success).toBe(true); expect(apiCardUpdateSchema.safeParse({ dueDate: null }).success).toBe(true); expect( apiCardUpdateSchema.safeParse({ description: null }).success, ).toBe(true); }); - it("apiTokenNameSchema exige nombre no vacío", () => { - expect(apiTokenNameSchema.safeParse("Mi token").success).toBe(true); + it("apiTokenNameSchema requires a non-empty name", () => { + expect(apiTokenNameSchema.safeParse("My token").success).toBe(true); expect(apiTokenNameSchema.safeParse(" ").success).toBe(false); expect(apiTokenNameSchema.safeParse("x".repeat(61)).success).toBe(false); }); - it("apiCardCreateSchema/apiCardUpdateSchema validan el formato de dueDate", () => { + it("apiCardCreateSchema/apiCardUpdateSchema validate the dueDate format", () => { for (const ok of ["2026-07-01", "2026-07-01T10:00:00Z", "2026-07-01T10:00:00+02:00"]) { expect(apiCardUpdateSchema.safeParse({ dueDate: ok }).success).toBe(true); expect(apiCardCreateSchema.safeParse({ title: "T", dueDate: ok }).success).toBe(true); @@ -65,7 +65,7 @@ describe("validators (zod)", () => { expect(apiCardCreateSchema.safeParse({ title: "T", dueDate: null }).success).toBe(true); }); - it("dueDateSchema y cardDetailsSchema (modal de tarjeta)", () => { + it("dueDateSchema and cardDetailsSchema (card modal)", () => { expect(dueDateSchema.safeParse("").success).toBe(true); expect(dueDateSchema.safeParse(null).success).toBe(true); expect(dueDateSchema.safeParse("2026-09-24").success).toBe(true); @@ -79,48 +79,48 @@ describe("validators (zod)", () => { ).toBe(false); }); - it("labelColorSchema cae a 'blue' fuera de la paleta (también claves del prototipo)", () => { + it("labelColorSchema falls back to 'blue' outside the palette (prototype keys too)", () => { expect(labelColorSchema.parse("red")).toBe("red"); expect(labelColorSchema.parse("constructor")).toBe("blue"); expect(labelColorSchema.parse("__proto__")).toBe("blue"); expect(labelColorSchema.parse(undefined)).toBe("blue"); }); - it("addMemberSchema y assignableBoardRoleSchema no permiten asignar OWNER", () => { + it("addMemberSchema and assignableBoardRoleSchema do not allow assigning OWNER", () => { expect(assignableBoardRoleSchema.safeParse("OWNER").success).toBe(false); expect(assignableBoardRoleSchema.safeParse("EDITOR").success).toBe(true); expect(addMemberSchema.safeParse({ email: "a@b.es", role: "OWNER" }).success).toBe(false); expect(addMemberSchema.safeParse({ email: "a@b.es", role: "EDITOR" }).success).toBe(true); - // El esquema y la lista de la UI no deben divergir. + // The schema and the UI list must not diverge. expect([...assignableBoardRoleSchema.options].sort()).toEqual( [...ASSIGNABLE_BOARD_ROLES].sort(), ); }); - it("commentTextSchema recorta y exige 1..2000 caracteres", () => { + it("commentTextSchema trims and requires 1..2000 characters", () => { expect(commentTextSchema.safeParse(" ").success).toBe(false); expect(commentTextSchema.safeParse("x".repeat(2001)).success).toBe(false); - expect(commentTextSchema.parse(" hola ")).toBe("hola"); + expect(commentTextSchema.parse(" hello ")).toBe("hello"); }); - it("boardImportSchema acepta el formato del export y rechaza lo que se sale de él", () => { - expect(boardImportSchema.safeParse({ board: { title: "Mínimo" } }).success).toBe(true); + it("boardImportSchema accepts the export format and rejects whatever falls outside it", () => { + expect(boardImportSchema.safeParse({ board: { title: "Minimal" } }).success).toBe(true); const full = { orgtable: { type: "board", version: 1 }, board: { - title: "Completo", + title: "Complete", background: "teal", - labels: [{ name: "Urgente", color: "red" }], + labels: [{ name: "Urgent", color: "red" }], lists: [ { - title: "Pendiente", + title: "To do", cards: [ { - title: "Tarea", - description: "Detalle", + title: "Task", + description: "Details", dueDate: "2026-07-01T00:00:00.000Z", - labels: ["Urgente"], - checklists: [{ title: "Pasos", items: [{ text: "Uno", done: false }] }], + labels: ["Urgent"], + checklists: [{ title: "Steps", items: [{ text: "One", done: false }] }], }, ], }, @@ -154,21 +154,21 @@ describe("validators (zod)", () => { ).toBe(false); }); - it("boardImportSchema sanea colores desconocidos en vez de rechazar el fichero", () => { + it("boardImportSchema sanitises unknown colours instead of rejecting the file", () => { const parsed = boardImportSchema.parse({ board: { title: "X", background: "constructor", - labels: [{ name: "Rara", color: "toString" }], + labels: [{ name: "Odd", color: "toString" }], }, }); expect(parsed.board.background).toBeNull(); expect(parsed.board.labels?.[0].color).toBe("blue"); }); - it("setupSchema y createUserSchema: longitud de contraseña y rol", () => { + it("setupSchema and createUserSchema: password length and role", () => { const user = { name: "Ana", email: "ana@example.com", role: "MEMBER" }; - const setup = { instanceName: "Equipo", name: "Ana", email: "ana@example.com" }; + const setup = { instanceName: "Team", name: "Ana", email: "ana@example.com" }; for (const [password, ok] of [ ["x".repeat(7), false], ["x".repeat(8), true], @@ -183,16 +183,16 @@ describe("validators (zod)", () => { ).toBe(false); }); - // bcrypt solo usa los primeros 72 bytes: por encima, dos contraseñas con el mismo prefijo - // serían equivalentes. - it("la contraseña no puede pasar de 72 bytes UTF-8", () => { + // bcrypt only uses the first 72 bytes: beyond that, two passwords with the same prefix + // would be equivalent. + it("the password cannot exceed 72 UTF-8 bytes", () => { const check = (password: string) => resetPasswordSchema.safeParse({ userId: "u1", password }).success; expect(check("a".repeat(72))).toBe(true); expect(check("a".repeat(73))).toBe(false); expect(check("ñ".repeat(36))).toBe(true); expect(check("ñ".repeat(37))).toBe(false); - const setup = { instanceName: "Equipo", name: "Ana", email: "ana@example.com" }; + const setup = { instanceName: "Team", name: "Ana", email: "ana@example.com" }; expect(setupSchema.safeParse({ ...setup, password: "a".repeat(73) }).success).toBe(false); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index ab2dba2..ec7486c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,13 +2,13 @@ import { defineConfig } from "vitest/config"; import { fileURLToPath } from "node:url"; import path from "node:path"; -// Configuración de los tests unitarios (Vitest): sin base de datos ni red. Qué cubren -// y qué no es lógica pura está en docs/testing.md, para no mantenerlo en dos sitios. +// Unit test config (Vitest): no database and no network. What they cover and what is +// not pure logic is described in docs/testing.md, to keep it in a single place. // -// Dos alias necesarios para ejecutar módulos de la app fuera de Next: -// - `server-only`: algunos módulos lo importan como marca de "solo servidor"; en los -// tests no aplica, así que se resuelve a un stub vacío. -// - `@/…`: el mismo alias que usa la app (tsconfig `@/* -> ./*`). +// Two aliases needed to run app modules outside Next: +// - `server-only`: some modules import it as a "server only" marker; it does not apply +// in tests, so it resolves to an empty stub. +// - `@/…`: the same alias the app uses (tsconfig `@/* -> ./*`). const root = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ @@ -24,7 +24,7 @@ export default defineConfig({ test: { environment: "node", include: ["test/**/*.test.ts"], - // Los tests de integración (DB real) tienen su propia config: vitest.integration.config.ts. + // Integration tests (real DB) have their own config: vitest.integration.config.ts. exclude: ["test/integration/**", "node_modules/**"], }, }); diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts index 2f2f644..1d5cf83 100644 --- a/vitest.integration.config.ts +++ b/vitest.integration.config.ts @@ -2,14 +2,14 @@ import { defineConfig } from "vitest/config"; import { fileURLToPath } from "node:url"; import path from "node:path"; -// Tests de INTEGRACIÓN: ejercen las rutas de la API y los servicios contra una base de -// datos PostgreSQL real (efímera). Se ejecutan con `pnpm test:integration` y requieren -// DATABASE_URL apuntando a una base de datos de test (su nombre debe acabar en `_test`; -// los helpers lo verifican para no tocar nunca datos de dev/producción). Ver docs/testing.md. +// INTEGRATION tests: they exercise the API routes and services against a real +// (ephemeral) PostgreSQL database. Run with `pnpm test:integration`; they require +// DATABASE_URL pointing at a test database (its name must end in `_test`; the helpers +// check it so dev/production data is never touched). See docs/testing.md. // -// Alias para poder importar módulos de la app fuera de Next: -// - server-only / next-cache / next-headers: stubs (no hay runtime de Next en los tests). -// - @/…: el mismo alias que usa la app. +// Aliases to import app modules outside Next: +// - server-only / next-cache / next-headers: stubs (there is no Next runtime in tests). +// - @/…: the same alias the app uses. const root = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ @@ -32,14 +32,14 @@ export default defineConfig({ }, test: { environment: "node", - // Zona horaria fija: la lógica de fechas (recordatorios) usa la hora local; UTC la - // hace determinista en cualquier máquina y en CI. - // SESSION_SECRET: clave FIJA y solo de test para que createSession/verifySession - // funcionen con el stub de cookies (loginAs). No protege nada real. - env: { TZ: "UTC", SESSION_SECRET: "clave-de-sesion-solo-para-tests-0123456789" }, + // Fixed time zone: date logic (reminders) uses local time; UTC makes it + // deterministic on any machine and in CI. + // SESSION_SECRET: a FIXED, test-only key so createSession/verifySession work with + // the cookie stub (loginAs). It protects nothing real. + env: { TZ: "UTC", SESSION_SECRET: "test-only-session-secret-0123456789" }, include: ["test/integration/**/*.int.test.ts"], - // Una sola conexión a la BD: los tests comparten un PrismaClient y resetean entre - // cada uno, así que no deben correr en paralelo entre archivos. + // A single DB connection: tests share one PrismaClient and reset between each test, + // so files must not run in parallel. fileParallelism: false, sequence: { concurrent: false }, hookTimeout: 30000,