From 4b0d404116ece2b4a6166dd82c52e36209a64975 Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Wed, 16 Sep 2026 13:20:44 +0200 Subject: [PATCH] feat: included 2FA and lots of feature from django-htmx-template --- .gitea/workflows/test-build.yaml | 3 + .pre-commit-config.yaml | 20 + Dockerfile | 19 - Makefile | 53 ++ README.md | 58 +- config/settings/__init__.py | 2 +- config/settings/base.py | 21 +- config/settings/env.py | 37 ++ config/urls.py | 2 +- conftest.py | 14 + crochet/admin.py | 10 +- crochet/context_processors.py | 5 + crochet/forms.py | 51 +- crochet/locale/en/LC_MESSAGES/django.po | 535 +++++++++++++----- crochet/locale/es/LC_MESSAGES/django.po | 503 ++++++++++++---- .../commands/build_pattern_detail_css.py | 7 +- .../0008_twofactorremembereddevice.py | 25 + crochet/models.py | 24 + crochet/pattern_render.py | 4 +- crochet/static/css/main.css | 13 + crochet/static/css/theme.css | 42 ++ crochet/static/js/modal-reopen.js | 25 + crochet/static/js/theme-toggle.js | 33 ++ crochet/templates/403.html | 29 + crochet/templates/404.html | 24 + crochet/templates/500.html | 68 +++ .../crochet/_account_2fa_status.html | 102 ++++ .../templates/crochet/account_settings.html | 11 + crochet/templates/crochet/base.html | 11 + .../templates/crochet/email/verify_email.html | 25 + .../templates/crochet/email/verify_email.txt | 10 + .../crochet/email/verify_email_subject.txt | 1 + .../templates/crochet/two_factor_setup.html | 51 ++ .../templates/registration/register_done.html | 24 + .../registration/two_factor_verify.html | 38 ++ .../registration/verify_email_invalid.html | 24 + crochet/tests/test_auth.py | 13 +- crochet/tests/test_cover_image.py | 2 +- crochet/tests/test_email_verification.py | 92 +++ crochet/tests/test_error_pages.py | 30 + crochet/tests/test_pattern_render.py | 8 +- crochet/tests/test_rate_limiting.py | 94 +++ crochet/tests/test_two_factor.py | 395 +++++++++++++ crochet/tests/test_user_agent.py | 50 ++ crochet/tokens.py | 15 + crochet/urls.py | 21 +- crochet/user_agent.py | 40 ++ crochet/views.py | 370 +++++++++++- docker-compose.yml | 47 ++ garage.toml | 18 + pyproject.toml | 38 +- uv.lock | 289 +++++++++- 52 files changed, 3135 insertions(+), 311 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 Makefile create mode 100644 crochet/context_processors.py create mode 100644 crochet/migrations/0008_twofactorremembereddevice.py create mode 100644 crochet/static/js/modal-reopen.js create mode 100644 crochet/static/js/theme-toggle.js create mode 100644 crochet/templates/403.html create mode 100644 crochet/templates/404.html create mode 100644 crochet/templates/500.html create mode 100644 crochet/templates/crochet/_account_2fa_status.html create mode 100644 crochet/templates/crochet/email/verify_email.html create mode 100644 crochet/templates/crochet/email/verify_email.txt create mode 100644 crochet/templates/crochet/email/verify_email_subject.txt create mode 100644 crochet/templates/crochet/two_factor_setup.html create mode 100644 crochet/templates/registration/register_done.html create mode 100644 crochet/templates/registration/two_factor_verify.html create mode 100644 crochet/templates/registration/verify_email_invalid.html create mode 100644 crochet/tests/test_email_verification.py create mode 100644 crochet/tests/test_error_pages.py create mode 100644 crochet/tests/test_rate_limiting.py create mode 100644 crochet/tests/test_two_factor.py create mode 100644 crochet/tests/test_user_agent.py create mode 100644 crochet/tokens.py create mode 100644 crochet/user_agent.py create mode 100644 docker-compose.yml create mode 100644 garage.toml diff --git a/.gitea/workflows/test-build.yaml b/.gitea/workflows/test-build.yaml index cb81495..4fd78fd 100644 --- a/.gitea/workflows/test-build.yaml +++ b/.gitea/workflows/test-build.yaml @@ -30,6 +30,9 @@ jobs: - name: Install dependencies run: uv pip install --system -r pyproject.toml --group=dev + - name: Lint + run: uv run ruff check . + - name: Compile translations run: uv run python manage.py compilemessages diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6cee3e5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.7 + hooks: + # Solo lint (--fix incluido): el formateador de ruff no se activa + # aquí a propósito, ver [tool.ruff.format] en pyproject.toml — el + # proyecto usa un estilo de wrapping más compacto del que aplicaría + # `ruff format` por defecto, y forzarlo reescribiría archivos enteros + # sin que nadie lo haya pedido. `ruff format` sigue disponible a mano + # (`make format`) para quien quiera adoptarlo. + - id: ruff + args: [--fix] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-added-large-files diff --git a/Dockerfile b/Dockerfile index cf1aaf0..b0081f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,30 +16,11 @@ ARG gid=1001 RUN addgroup -S ${user} && adduser -S ${user} -G ${user} -u ${uid} -s /bin/sh -# pango + ttf-dejavu: librerías nativas que weasyprint necesita en tiempo de -# ejecución (no al instalar el paquete Python, que ya funciona sin esto) para -# poder generar el PDF del patrón (ver PatternPdfView); sin fuentes -# instaladas, Pango no tiene nada que medir/dibujar y WeasyPrint termina -# haciendo segfault en vez de fallar con un error legible. -# gettext: msgfmt, para compilemessages en build.sh. -# Sin gcc ni libpq-dev: psycopg[binary] (ver pyproject.toml) trae su propio -# libpq ya compilado dentro de la wheel (hay una para musllinux_x86_64, -# justo lo que necesita esta imagen), así que no hace falta ni compilar -# nada ni que psycopg encuentre una libpq del sistema en tiempo de -# ejecución. Probado a mano (build+run real): "import psycopg" funciona -# sin ninguno de los dos. RUN apk add --no-cache gettext pango ttf-dejavu RUN chown -R ${user}:${user} /code COPY --chown=${user}:${user} . /code -# libstdc++/libgcc: solo los necesita en tiempo de ejecución el binario de -# Tailwind (ver build_pattern_detail_css, dentro de build.sh) para generar -# pattern-detail.css, no la app en sí; "--virtual" + "apk del" en la misma -# capa para que no se queden en la imagen final (build.sh además borra el -# propio binario de Tailwind al terminar, ver scripts/build.sh). build.sh -# se ejecuta como ${user} (no root), aunque este RUN empiece como root -# para poder instalar/desinstalar el paquete virtual. RUN apk add --no-cache --virtual .tailwind-runtime libstdc++ libgcc \ && su ${user} -c "sh ./scripts/build.sh" \ && apk del .tailwind-runtime diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ceb4b26 --- /dev/null +++ b/Makefile @@ -0,0 +1,53 @@ +.DEFAULT_GOAL := help + +.PHONY: help install run migrate makemigrations collectstatic superuser shell \ + test coverage lint format messages compilemessages \ + precommit-install precommit + +help: ## Muestra esta ayuda + @grep -E '^[a-zA-Z0-9_-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + +install: ## Instala dependencias (incluidas las de desarrollo) + uv sync --group dev + +run: ## Arranca el servidor de desarrollo + uv run python manage.py runserver 0.0.0.0:8000 + +migrate: ## Aplica las migraciones pendientes + uv run python manage.py migrate + +makemigrations: ## Genera migraciones a partir de cambios en los modelos + uv run python manage.py makemigrations + +collectstatic: ## Recopila los archivos estáticos + uv run python manage.py collectstatic --noinput + +superuser: ## Crea un superusuario (para /admin/) + uv run python manage.py createsuperuser + +shell: ## Abre una shell de Django (django-extensions shell_plus si está instalado) + uv run python manage.py shell_plus + +test: ## Ejecuta la suite de tests + uv run pytest + +coverage: ## Ejecuta los tests con cobertura + uv run pytest --cov + +lint: ## Comprueba estilo/errores con ruff (no modifica nada) + uv run ruff check . + +format: ## Aplica el formateador de ruff (ver [tool.ruff.format] en pyproject.toml) + uv run ruff format . + +messages: ## Regenera las cadenas traducibles (es/en) + uv run python manage.py makemessages -l es -l en + +compilemessages: ## Compila las traducciones (.po -> .mo) + uv run python manage.py compilemessages + +precommit-install: ## Instala el hook de pre-commit en este repo + uv run pre-commit install + +precommit: ## Ejecuta todos los hooks de pre-commit sobre el repo entero + uv run pre-commit run --all-files diff --git a/README.md b/README.md index 0b69af8..a5ae018 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,12 @@ cuentas de usuario para guardar y gestionar los propios patrones. impresión del navegador. - **Vista de solo lectura compartible** (`pattern//`), sin necesidad de cuenta ni de cargar el editor. -- **Cuentas de usuario**: registro (con email, necesario para poder - recuperar la contraseña), login/logout, ajustes de cuenta (cambiar - email/contraseña, con HTMX) y recuperación de contraseña por email. - Cada patrón pertenece a quien lo creó; solo su propietario puede - editarlo o borrarlo. +- **Cuentas de usuario**: registro con verificación de email, login/logout + (con verificación en dos pasos opcional por TOTP + códigos de respaldo), + ajustes de cuenta (cambiar email/contraseña, con HTMX) y recuperación de + contraseña por email. Login, registro y recuperación de contraseña están + protegidos contra fuerza bruta (`django-ratelimit`). Cada patrón + pertenece a quien lo creó; solo su propietario puede editarlo o borrarlo. - **"Mis patrones"**: listado de los patrones propios con portada, fecha de actualización y accesos directos a editar/ver/eliminar. - **i18n completo** (español/inglés): interfaz, URLs traducidas @@ -123,6 +124,42 @@ WeasyPrint y ejecuta `scripts/build.sh` (CSS precompilado, `collectstatic`, `compilemessages`) al construir la imagen, y `scripts/run.sh` (Uvicorn) al arrancar el contenedor. +### Entorno local con Postgres + S3 (Garage) + +`docker-compose.yml` levanta un Postgres y un +[Garage](https://garagehq.deuxfleurs.fr/) (S3 self-hosted) para poder +probar `S3_ENABLED` sin depender de un bucket real: + +```bash +docker compose up -d +``` + +Con eso, en tu `.env`: + +```env +DATABASE_URL=postgres://django:django@localhost:6543/django +S3_ENABLED=True +S3_ACCESS_KEY_ID=GKa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 +S3_SECRET_ACCESS_KEY=f0e1d2c3b4a5968778695a4b3c2d1e0ff0e1d2c3b4a5968778695a4b3c2d1e0f +S3_STORAGE_BUCKET_NAME=default-bucket +S3_ENDPOINT_URL=http://localhost:3900 +S3_REGION_NAME=garage +S3_USE_SSL=False +S3_ADDRESSING_STYLE=path +``` + +La web de administración de Garage queda en `http://localhost:3909/` +(usuario `admin`, contraseña `garage-dev`; cámbiala en `docker-compose.yml` +si vas a exponerla más allá de tu propia máquina). + +### Herramientas de desarrollo + +`make help` lista los atajos disponibles (`make test`, `make lint`, +`make format`, `make messages`...). El estilo del código se comprueba con +[ruff](https://docs.astral.sh/ruff/) (`make lint`); `make precommit-install` +instala un hook de pre-commit que lo ejecuta automáticamente en cada +commit. + ## Variables de entorno Todas se leen en `config/settings/env.py`; sin `.env`, o si falta alguna, @@ -137,10 +174,15 @@ se usan los valores por defecto indicados (pensados para desarrollo local). | `DATABASE_URL` | `sqlite:///db.sqlite3` | Formato `django-environ` (p. ej. `postgres://usuario:password@host:5432/nombre_bd`). | | `S3_ENABLED` | `False` | Si es `True`, los archivos subidos (imágenes de portada/patrón) se guardan en S3 en vez de en el filesystem local. | | `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` / `S3_STORAGE_BUCKET_NAME` / `S3_ENDPOINT_URL` | vacío | Credenciales del bucket, solo necesarias si `S3_ENABLED=True`. | -| `EMAIL_BACKEND` | backend de consola | Usa `django.core.mail.backends.smtp.EmailBackend` para enviar emails de verdad (recuperación de contraseña). | +| `S3_REGION_NAME` / `S3_USE_SSL` / `S3_ADDRESSING_STYLE` | vacío / `True` / `auto` | Para S3 compatibles que lo exigen (p. ej. Garage, ver [entorno local con Garage](#entorno-local-con-postgres--s3-garage)): región obligatoria y sin soporte de direccionamiento "virtual" por defecto. | +| `EMAIL_BACKEND` | backend de consola | Usa `django.core.mail.backends.smtp.EmailBackend` para enviar emails de verdad (verificación de cuenta, recuperación de contraseña...). | | `EMAIL_HOST` / `EMAIL_HOST_USER` / `EMAIL_HOST_PASSWORD` / `EMAIL_PORT` | vacío / vacío / vacío / `587` | Credenciales SMTP, solo con el backend SMTP. | | `DEFAULT_FROM_EMAIL` | `webmaster@localhost` | Remitente de los emails salientes. La mayoría de proveedores SMTP rechazan enviar si no coincide con la cuenta autenticada (o un alias verificado). | | `SUPPORT_EMAIL` | `soporte@localhost` | Dirección de contacto que se muestra en el aviso que recibe el email anterior de una cuenta cuando alguien lo cambia (ver `EmailUpdateForm`). | +| `SITE_NAME` | `Crochet` | Nombre mostrado en los emails de aviso de la cuenta (registro, verificación en dos pasos...). | +| `RATELIMIT_ENABLE` | `True` | Desactiva la limitación de peticiones en login/registro/recuperación de contraseña/verificación en dos pasos (pensado para tests o depuración, no para producción). | +| `SECURE_SSL_REDIRECT` / `SESSION_COOKIE_SECURE` / `CSRF_COOKIE_SECURE` | `False` | Actívalas en producción una vez la app esté detrás de HTTPS de verdad; en desarrollo local (HTTP plano) dejarlas activas rompe la sesión/CSRF. | +| `SECURE_HSTS_SECONDS` / `SECURE_HSTS_INCLUDE_SUBDOMAINS` / `SECURE_HSTS_PRELOAD` | `0` / `False` / `False` | HSTS; empieza con un valor bajo (p. ej. `3600`) antes de subirlo a uno largo (p. ej. `31536000` = 1 año) — un valor largo mal configurado deja el dominio inaccesible por HTTPS durante ese tiempo. | | `LOG_LEVEL` | `INFO` | Nivel de log de Django y de la app (`crochet`). | Existen además `CORS_ORIGIN_WHITELIST`, `REDIS_HOST`, `REDIS_PORT`, @@ -168,6 +210,10 @@ Como mínimo hay que ajustar, respecto al `.env` de desarrollo: configura `S3_ENABLED=True` y las credenciales de `S3_*` — el filesystem local (por defecto) no es compartido ni persistente entre despliegues. +8. **HTTPS**: una vez la app esté detrás de HTTPS de verdad, activa + `SECURE_SSL_REDIRECT=True`, `SESSION_COOKIE_SECURE=True` y + `CSRF_COOKIE_SECURE=True` (y, si quieres, `SECURE_HSTS_SECONDS` — ver la + tabla de arriba antes de subirlo a un valor largo). La imagen Docker ya ejecuta `collectstatic`, `compilemessages` y el CSS precompilado al construirse, y sirve la app con Uvicorn (variables diff --git a/config/settings/__init__.py b/config/settings/__init__.py index a87b913..13751a8 100644 --- a/config/settings/__init__.py +++ b/config/settings/__init__.py @@ -1,2 +1,2 @@ -from config.settings.env import * from config.settings.base import * +from config.settings.env import * diff --git a/config/settings/base.py b/config/settings/base.py index a461d94..1554edf 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -10,10 +10,8 @@ For the full list of config and their values, see https://docs.djangoproject.com/en/6.0/ref/settings/ """ -from pathlib import Path from config.settings.env import * - # Application definition DJANGO_APPS = [ @@ -30,6 +28,9 @@ THIRD_PARTY_APPS = [ 'django_extensions', 'django_filters', 'django_htmx', + 'django_otp', + 'django_otp.plugins.otp_totp', + 'django_otp.plugins.otp_static', 'watchman', ] @@ -54,6 +55,7 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django_otp.middleware.OTPMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django_htmx.middleware.HtmxMiddleware', @@ -79,6 +81,7 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'crochet.context_processors.site_name', ], }, }, @@ -102,6 +105,11 @@ LOGIN_URL = 'crochet:login' LOGIN_REDIRECT_URL = 'crochet:home' LOGOUT_REDIRECT_URL = 'crochet:login' +# Nombre que muestran apps como Google Authenticator/Aegis junto al usuario +# al escanear el QR de alta de verificación en dos pasos (ver +# TOTPDevice.config_url, crochet/views.py). +OTP_TOTP_ISSUER = SITE_NAME + # Password validation # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators @@ -165,6 +173,15 @@ EMAIL_USE_TLS = True EMAIL_USE_SSL = False +# Security +# https://docs.djangoproject.com/en/6.0/topics/security/ +# SECURE_SSL_REDIRECT/SESSION_COOKIE_SECURE/CSRF_COOKIE_SECURE/SECURE_HSTS_* +# se leen de env.py (ver env.py): desactivados por defecto porque asumen +# HTTPS, que no hay en desarrollo local. SECURE_CONTENT_TYPE_NOSNIFF no +# depende de eso (no rompe nada servir por HTTP), así que va fijo aquí. +SECURE_CONTENT_TYPE_NOSNIFF = True + + # Logging # https://docs.djangoproject.com/en/6.0/topics/logging/ diff --git a/config/settings/env.py b/config/settings/env.py index 940aa09..e89f495 100644 --- a/config/settings/env.py +++ b/config/settings/env.py @@ -35,6 +35,13 @@ AWS_ACCESS_KEY_ID = env.str('S3_ACCESS_KEY_ID', '') AWS_SECRET_ACCESS_KEY = env.str('S3_SECRET_ACCESS_KEY', '') AWS_STORAGE_BUCKET_NAME = env.str('S3_STORAGE_BUCKET_NAME', '') AWS_S3_ENDPOINT_URL = env.str('S3_ENDPOINT_URL', '') +# Compatibilidad con Garage (ver docker-compose.yml/garage.toml, S3 +# self-hosted para desarrollo local): a diferencia de AWS S3, exige indicar +# la región a mano y no soporta el estilo de direccionamiento "virtual" +# (bucket como subdominio) por defecto. +AWS_S3_REGION_NAME = env.str('S3_REGION_NAME', '') +AWS_S3_USE_SSL = env.bool('S3_USE_SSL', True) +AWS_S3_ADDRESSING_STYLE = env.str('S3_ADDRESSING_STYLE', 'auto') ITEMS_PER_PAGE = env.int('ITEMS_PER_PAGE', 20) @@ -49,4 +56,34 @@ DEFAULT_FROM_EMAIL = env.str('DEFAULT_FROM_EMAIL', 'webmaster@localhost') # EmailUpdateForm) por si el cambio no lo ha hecho él. SUPPORT_EMAIL = env.str('SUPPORT_EMAIL', 'soporte@localhost') +# Nombre mostrado en el navbar, el y los emails de aviso de la +# cuenta (ver EmailUpdateForm/StyledPasswordChangeForm/AccountDeleteForm). +SITE_NAME = env.str('SITE_NAME', 'Crochet') + LOG_LEVEL = env.str('LOG_LEVEL', 'INFO') + +# Limitación de peticiones (ver crochet/views.py: login, registro, +# recuperación de contraseña y verificación en dos pasos) con +# django-ratelimit. Usa el cache framework de Django (settings.CACHES, +# "default" por defecto: LocMemCache si no se configura otra cosa) para +# contar peticiones; LocMemCache es propio de cada proceso, así que con +# más de un worker/instancia en producción el límite real es "por +# proceso", no global — configura ahí un backend compartido (Redis, +# Memcached) si eso te importa. +RATELIMIT_ENABLE = env.bool('RATELIMIT_ENABLE', True) + +# Cabeceras/cookies de seguridad para servir por HTTPS en producción. Todo +# esto viene desactivado por defecto porque en desarrollo local se sirve +# por HTTP plano (activarlo ahí dejaría la app inaccesible o el navegador +# descartando las cookies de sesión/CSRF). Actívalo en el .env de +# producción una vez la app esté detrás de HTTPS de verdad. +SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', False) +SESSION_COOKIE_SECURE = env.bool('SESSION_COOKIE_SECURE', False) +CSRF_COOKIE_SECURE = env.bool('CSRF_COOKIE_SECURE', False) +# En segundos; 0 desactiva HSTS. Empieza bajo (p. ej. 3600) para probar en +# producción antes de subirlo a un valor largo (p. ej. 31536000 = 1 año): +# un valor largo con la configuración equivocada deja el dominio +# inaccesible por HTTPS durante ese tiempo para quien ya lo haya recibido. +SECURE_HSTS_SECONDS = env.int('SECURE_HSTS_SECONDS', 0) +SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool('SECURE_HSTS_INCLUDE_SUBDOMAINS', False) +SECURE_HSTS_PRELOAD = env.bool('SECURE_HSTS_PRELOAD', False) diff --git a/config/urls.py b/config/urls.py index 2a447db..85781d5 100644 --- a/config/urls.py +++ b/config/urls.py @@ -17,7 +17,7 @@ Including another URLconf from django.conf import settings from django.conf.urls.i18n import i18n_patterns from django.contrib import admin -from django.urls import path, include +from django.urls import include, path from django.views.static import serve urlpatterns = [ diff --git a/conftest.py b/conftest.py index 771f510..9181ab0 100644 --- a/conftest.py +++ b/conftest.py @@ -1,10 +1,24 @@ import tempfile import pytest +from django.core.cache import cache from django.core.management import call_command from django.test import override_settings +@pytest.fixture(autouse=True) +def clear_ratelimit_cache(): + """django-ratelimit (ver LOGIN_RATE/REGISTER_RATE/PASSWORD_RESET_RATE en + crochet/views.py) cuenta peticiones en el cache framework de Django, que + -a diferencia de la base de datos- no se resetea solo entre tests; sin + esto, tests de distintos módulos que llaman a la misma vista (login, + registro...) irían sumando al mismo contador y algún test acabaría + recibiendo un 403 por límite alcanzado en vez del resultado que espera, + según en qué orden se ejecuten.""" + cache.clear() + yield + + @pytest.fixture(scope='session', autouse=True) def collected_static_files(): """{% static %} (usado en pattern.html/pattern_detail.html) necesita el diff --git a/crochet/admin.py b/crochet/admin.py index e9316ea..eaa131a 100644 --- a/crochet/admin.py +++ b/crochet/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from crochet.models import Pattern, PatternImage, StitchType +from crochet.models import Pattern, PatternImage, StitchType, TwoFactorRememberedDevice class PatternImageInline(admin.TabularInline): @@ -26,3 +26,11 @@ class PatternImageAdmin(admin.ModelAdmin): class StitchTypeAdmin(admin.ModelAdmin): list_display = ('__str__', 'order') list_editable = ('order',) + + +@admin.register(TwoFactorRememberedDevice) +class TwoFactorRememberedDeviceAdmin(admin.ModelAdmin): + list_display = ('user', 'device_name', 'created_at', 'expires_at') + list_filter = ('device_name',) + search_fields = ('user__username', 'device_name') + readonly_fields = ('token', 'created_at') diff --git a/crochet/context_processors.py b/crochet/context_processors.py new file mode 100644 index 0000000..0b9f17d --- /dev/null +++ b/crochet/context_processors.py @@ -0,0 +1,5 @@ +from django.conf import settings + + +def site_name(request): + return {'site_name': settings.SITE_NAME} diff --git a/crochet/forms.py b/crochet/forms.py index 8d6b098..23508de 100644 --- a/crochet/forms.py +++ b/crochet/forms.py @@ -68,7 +68,7 @@ class StyledPasswordChangeForm(PasswordChangeForm): # "anterior" distinta: el aviso va al propio email de la cuenta. # Sin email no hay a quién avisar (cuentas antiguas sin uno). if user.email: - context = {'user': user, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL} + context = {'user': user, 'site_name': settings.SITE_NAME, 'support_email': settings.SUPPORT_EMAIL} _send_account_notice( 'crochet/email/password_changed_subject.txt', 'crochet/email/password_changed_notice.txt', @@ -109,7 +109,10 @@ class EmailUpdateForm(forms.Form): # todavía puede leerlo. Si no había email antes, o si "cambiar" no # ha cambiado nada, no hay a quién avisar ni de qué. if old_email and old_email != self.user.email: - context = {'user': self.user, 'old_email': old_email, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL} + context = { + 'user': self.user, 'old_email': old_email, + 'site_name': settings.SITE_NAME, 'support_email': settings.SUPPORT_EMAIL, + } _send_account_notice( 'crochet/email/email_changed_subject.txt', 'crochet/email/email_changed_notice.txt', @@ -138,6 +141,48 @@ class DisplayNameForm(forms.Form): self.user.save(update_fields=['first_name']) +class TwoFactorTokenForm(forms.Form): + # Un único campo de texto tanto para el código de 6 dígitos de la app + # (TOTP) como para un código de respaldo (StaticDevice): cuál de los + # dos es se decide al verificarlo (ver TwoFactorVerifyView/ + # TwoFactorSetupView en crochet/views.py), no aquí. autocomplete + # "one-time-code" es el valor estándar para que el navegador/gestor de + # contraseñas ofrezca autorrellenar con el código recibido, si puede. + token = forms.CharField( + label=_('Código'), + widget=forms.TextInput(attrs={'class': INPUT_CLASSES, 'autocomplete': 'one-time-code'}), + ) + + +class TwoFactorVerifyForm(TwoFactorTokenForm): + # Solo en el login (ver TwoFactorVerifyView), no en el alta + # (TwoFactorSetupView reusa TwoFactorTokenForm sin este campo): ahí no + # tiene sentido "recordar" un dispositivo que todavía se está + # configurando. + remember_device = forms.BooleanField( + label=_('Recordar este dispositivo 30 días'), + required=False, + widget=forms.CheckboxInput(attrs={'class': 'checkbox checkbox-sm'}), + ) + + +class TwoFactorDisableForm(forms.Form): + password = forms.CharField( + label=_('Contraseña'), + widget=forms.PasswordInput(attrs={'class': INPUT_CLASSES}), + ) + + def __init__(self, *args, user, **kwargs): + self.user = user + super().__init__(*args, **kwargs) + + def clean_password(self): + password = self.cleaned_data['password'] + if not self.user.check_password(password): + raise forms.ValidationError(_('Contraseña incorrecta.')) + return password + + class AccountDeleteForm(forms.Form): password = forms.CharField( label=_('Contraseña'), widget=forms.PasswordInput(attrs={'class': INPUT_CLASSES}), @@ -157,7 +202,7 @@ class AccountDeleteForm(forms.Form): # Antes de borrar la cuenta (ver AccountDeleteView), mientras el # email todavía existe. Sin email no hay a quién avisar. if self.user.email: - context = {'user': self.user, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL} + context = {'user': self.user, 'site_name': settings.SITE_NAME, 'support_email': settings.SUPPORT_EMAIL} _send_account_notice( 'crochet/email/account_deleted_subject.txt', 'crochet/email/account_deleted_notice.txt', diff --git a/crochet/locale/en/LC_MESSAGES/django.po b/crochet/locale/en/LC_MESSAGES/django.po index 13999b3..1599d98 100644 --- a/crochet/locale/en/LC_MESSAGES/django.po +++ b/crochet/locale/en/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: crochet\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-22 08:53+0000\n" +"POT-Creation-Date: 2026-09-16 11:16+0000\n" "Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -13,18 +13,134 @@ msgstr "" msgid "Email" msgstr "Email" -#: crochet/forms.py:128 crochet/templates/crochet/account_settings.html:40 +#: crochet/forms.py:131 crochet/templates/crochet/account_settings.html:40 msgid "Nombre para mostrar" msgstr "Display name" -#: crochet/forms.py:143 +#: crochet/forms.py:152 +msgid "Código" +msgstr "Code" + +#: crochet/forms.py:163 +msgid "Recordar este dispositivo 30 días" +msgstr "Remember this device for 30 days" + +#: crochet/forms.py:171 crochet/forms.py:188 msgid "Contraseña" msgstr "Password" -#: crochet/forms.py:153 +#: crochet/forms.py:182 crochet/forms.py:198 msgid "Contraseña incorrecta." msgstr "Incorrect password." +#: crochet/templates/403.html:9 crochet/templates/403.html:19 +msgid "Acceso denegado" +msgstr "Access denied" + +#: crochet/templates/403.html:21 +msgid "" +"No tienes permiso para ver esta página, o se han hecho demasiadas peticiones " +"seguidas desde aquí." +msgstr "" +"You don't have permission to view this page, or too many requests have been " +"made in a row from here." + +#: crochet/templates/403.html:24 crochet/templates/404.html:19 +#: crochet/templates/500.html:64 +#: crochet/templates/crochet/cookie_policy.html:54 +msgid "Volver al inicio" +msgstr "Back to home" + +#: crochet/templates/404.html:4 crochet/templates/404.html:14 +msgid "Página no encontrada" +msgstr "Page not found" + +#: crochet/templates/404.html:16 +msgid "La página que buscas no existe o se ha movido." +msgstr "The page you're looking for doesn't exist or has moved." + +#: crochet/templates/500.html:15 +msgid "Error del servidor" +msgstr "Server error" + +#: crochet/templates/500.html:62 +msgid "Vaya, algo ha ido mal" +msgstr "Oops, something went wrong" + +#: crochet/templates/500.html:63 +msgid "Ha ocurrido un error inesperado. Prueba a recargar en un momento." +msgstr "An unexpected error occurred. Try reloading in a moment." + +#: crochet/templates/crochet/_account_2fa_status.html:11 +msgid "Activada" +msgstr "Enabled" + +#: crochet/templates/crochet/_account_2fa_status.html:17 +#: crochet/templates/crochet/_account_2fa_status.html:90 +msgid "Desactivar" +msgstr "Disable" + +#: crochet/templates/crochet/_account_2fa_status.html:23 +msgid "Dispositivos recordados" +msgstr "Remembered devices" + +#: crochet/templates/crochet/_account_2fa_status.html:29 +#, python-format +msgid "desde el %(date)s" +msgstr "since %(date)s" + +#: crochet/templates/crochet/_account_2fa_status.html:33 +msgid "Olvidar este dispositivo" +msgstr "Forget this device" + +#: crochet/templates/crochet/_account_2fa_status.html:42 +msgid "¿Olvidar este dispositivo?" +msgstr "Forget this device?" + +#: crochet/templates/crochet/_account_2fa_status.html:44 +#, python-format +msgid "" +"La próxima vez que inicies sesión desde \"%(name)s\" volverá a pedirse el " +"código de verificación en dos pasos." +msgstr "" +"Next time you log in from \"%(name)s\" you'll be asked for the two-factor " +"code again." + +#: crochet/templates/crochet/_account_2fa_status.html:54 +#: crochet/templates/crochet/_account_2fa_status.html:88 +#: crochet/templates/crochet/two_factor_setup.html:45 +msgid "Cancelar" +msgstr "Cancel" + +#: crochet/templates/crochet/_account_2fa_status.html:56 +msgid "Olvidar" +msgstr "Forget" + +#: crochet/templates/crochet/_account_2fa_status.html:59 +#: crochet/templates/crochet/_account_2fa_status.html:94 +msgid "Cerrar" +msgstr "Close" + +#: crochet/templates/crochet/_account_2fa_status.html:68 +msgid "Desactivar verificación en dos pasos" +msgstr "Disable two-factor authentication" + +#: crochet/templates/crochet/_account_2fa_status.html:70 +msgid "" +"Confirma tu contraseña para desactivarla. Se olvidarán también todos los " +"dispositivos recordados." +msgstr "" +"Confirm your password to disable it. All remembered devices will also be " +"forgotten." + +#: crochet/templates/crochet/_account_2fa_status.html:98 +msgid "Desactivada" +msgstr "Disabled" + +#: crochet/templates/crochet/_account_2fa_status.html:99 +msgid "Activar" +msgstr "Enable" + #: crochet/templates/crochet/_account_delete_form.html:9 msgid "" "¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus " @@ -34,7 +150,7 @@ msgstr "" "deleted. This action cannot be undone." #: crochet/templates/crochet/_account_delete_form.html:19 -#: crochet/templates/crochet/account_settings.html:68 +#: crochet/templates/crochet/account_settings.html:78 msgid "Eliminar cuenta" msgstr "Delete account" @@ -109,12 +225,12 @@ msgid "Actualizado el %(date)s" msgstr "Updated on %(date)s" #: crochet/templates/crochet/account_home.html:44 -#: crochet/templates/crochet/pattern.html:182 +#: crochet/templates/crochet/pattern.html:156 msgid "Vista previa" msgstr "Preview" #: crochet/templates/crochet/account_home.html:48 -#: crochet/templates/crochet/pattern.html:55 +#: crochet/templates/crochet/pattern.html:46 msgid "Eliminar" msgstr "Delete" @@ -124,7 +240,7 @@ msgstr "You don't have any patterns yet." #: crochet/templates/crochet/account_settings.html:4 #: crochet/templates/crochet/account_settings.html:18 -#: crochet/templates/crochet/base.html:52 +#: crochet/templates/crochet/base.html:62 msgid "Ajustes de la cuenta" msgstr "Account settings" @@ -140,19 +256,37 @@ msgstr "Security" msgid "Zona de peligro" msgstr "Danger zone" -#: crochet/templates/crochet/account_settings.html:70 +#: crochet/templates/crochet/account_settings.html:63 +#: crochet/templates/registration/two_factor_verify.html:4 +#: crochet/templates/registration/two_factor_verify.html:14 +msgid "Verificación en dos pasos" +msgstr "Two-factor authentication" + +#: crochet/templates/crochet/account_settings.html:65 +msgid "" +"Pide un código de tu app de autenticación (Google Authenticator, Aegis...) " +"además de la contraseña al iniciar sesión." +msgstr "" +"Ask for a code from your authenticator app (Google Authenticator, Aegis...) " +"in addition to your password when logging in." + +#: crochet/templates/crochet/account_settings.html:80 msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente." msgstr "This will permanently delete your account and all your patterns." -#: crochet/templates/crochet/base.html:39 +#: crochet/templates/crochet/base.html:37 +msgid "Cambiar tema" +msgstr "Change theme" + +#: crochet/templates/crochet/base.html:49 msgid "Cambiar idioma" msgstr "Change language" -#: crochet/templates/crochet/base.html:56 +#: crochet/templates/crochet/base.html:66 msgid "Cerrar sesión" msgstr "Log out" -#: crochet/templates/crochet/base.html:62 +#: crochet/templates/crochet/base.html:72 #: crochet/templates/crochet/home.html:25 #: crochet/templates/crochet/password_reset_complete.html:18 #: crochet/templates/registration/login.html:4 @@ -225,10 +359,6 @@ msgstr "" "Both are technical cookies necessary for the site to work (logging in and " "protecting forms), so they don't require your prior consent." -#: crochet/templates/crochet/cookie_policy.html:54 -msgid "Volver al inicio" -msgstr "Back to home" - #: crochet/templates/crochet/email/account_deleted_notice.html:4 #: crochet/templates/crochet/email/account_deleted_subject.txt:1 #, python-format @@ -251,6 +381,8 @@ msgstr "" #: crochet/templates/crochet/email/email_changed_notice.txt:4 #: crochet/templates/crochet/email/password_changed_notice.html:12 #: crochet/templates/crochet/email/password_changed_notice.txt:4 +#: crochet/templates/crochet/email/verify_email.html:19 +#: crochet/templates/crochet/email/verify_email.txt:7 msgid "Tu usuario:" msgstr "Your username:" @@ -333,6 +465,34 @@ msgstr "Your username, in case you've forgotten:" msgid "Si no has solicitado este cambio, puedes ignorar este email." msgstr "If you didn't request this change, you can ignore this email." +#: crochet/templates/crochet/email/verify_email.html:4 +msgid "Confirma tu cuenta" +msgstr "Confirm your account" + +#: crochet/templates/crochet/email/verify_email.html:8 +#: crochet/templates/crochet/email/verify_email.txt:2 +#, python-format +msgid "Has recibido este email porque te has registrado en %(site_name)s." +msgstr "You're receiving this email because you signed up for %(site_name)s." + +#: crochet/templates/crochet/email/verify_email.html:14 +msgid "Confirmar cuenta" +msgstr "Confirm account" + +#: crochet/templates/crochet/email/verify_email.html:23 +#: crochet/templates/crochet/email/verify_email.txt:9 +msgid "Si no has sido tú, puedes ignorar este email." +msgstr "If this wasn't you, you can ignore this email." + +#: crochet/templates/crochet/email/verify_email.txt:4 +msgid "Sigue este enlace para confirmar tu cuenta:" +msgstr "Follow this link to confirm your account:" + +#: crochet/templates/crochet/email/verify_email_subject.txt:1 +#, python-format +msgid "Confirma tu cuenta en %(site_name)s" +msgstr "Confirm your account on %(site_name)s" + #: crochet/templates/crochet/home.html:4 msgid "Crochet — Crea y comparte tus patrones" msgstr "Crochet — Create and share your patterns" @@ -403,6 +563,8 @@ msgid "Elige una contraseña nueva" msgstr "Choose a new password" #: crochet/templates/crochet/password_reset_confirm.html:33 +#: crochet/templates/registration/verify_email_invalid.html:4 +#: crochet/templates/registration/verify_email_invalid.html:14 msgid "Enlace no válido" msgstr "Invalid link" @@ -419,6 +581,7 @@ msgid "Solicitar un enlace nuevo" msgstr "Request a new link" #: crochet/templates/crochet/password_reset_done.html:14 +#: crochet/templates/registration/register_done.html:14 msgid "Revisa tu email" msgstr "Check your email" @@ -432,6 +595,8 @@ msgstr "" #: crochet/templates/crochet/password_reset_done.html:19 #: crochet/templates/crochet/password_reset_form.html:32 +#: crochet/templates/registration/register_done.html:19 +#: crochet/templates/registration/two_factor_verify.html:33 msgid "Volver a iniciar sesión" msgstr "Back to login" @@ -453,245 +618,245 @@ msgstr "Send link" msgid "Recupera tu contraseña en %(site_name)s" msgstr "Reset your password on %(site_name)s" -#: crochet/templates/crochet/pattern.html:26 -msgid "Idioma del patrón" -msgstr "Pattern language" - -# crochet/templates/crochet/pattern.html -#: crochet/templates/crochet/pattern.html:38 -msgid "No se ha podido guardar. Inténtalo de nuevo." -msgstr "Couldn't save. Please try again." - -#: crochet/templates/crochet/pattern.html:38 -msgid "Guardar" -msgstr "Save" - -#: crochet/templates/crochet/pattern.html:41 -msgid "Ver patrón" -msgstr "View pattern" - -#: crochet/templates/crochet/pattern.html:42 -msgid "Exportar a PDF" -msgstr "Export to PDF" - -#: crochet/templates/crochet/pattern.html:49 -msgid "Más opciones" -msgstr "More options" - -#: crochet/templates/crochet/pattern.html:65 -msgid "Guardado" -msgstr "Saved" - -#: crochet/templates/crochet/pattern.html:78 +#: crochet/templates/crochet/pattern.html:23 msgid "Título del patrón" msgstr "Pattern title" -#: crochet/templates/crochet/pattern.html:79 +#: crochet/templates/crochet/pattern.html:24 msgid "Título del patrón..." msgstr "Pattern title..." -#: crochet/templates/crochet/pattern.html:82 +# crochet/templates/crochet/pattern.html +#: crochet/templates/crochet/pattern.html:29 +msgid "No se ha podido guardar. Inténtalo de nuevo." +msgstr "Couldn't save. Please try again." + +#: crochet/templates/crochet/pattern.html:29 +msgid "Guardar" +msgstr "Save" + +#: crochet/templates/crochet/pattern.html:32 +msgid "Ver patrón" +msgstr "View pattern" + +#: crochet/templates/crochet/pattern.html:33 +msgid "Exportar a PDF" +msgstr "Export to PDF" + +#: crochet/templates/crochet/pattern.html:40 +msgid "Más opciones" +msgstr "More options" + +#: crochet/templates/crochet/pattern.html:56 +msgid "Guardado" +msgstr "Saved" + +#: crochet/templates/crochet/pattern.html:64 msgid "Imagen de portada" msgstr "Cover image" -#: crochet/templates/crochet/pattern.html:95 +#: crochet/templates/crochet/pattern.html:73 msgid "Añadir imagen" msgstr "Add image" -#: crochet/templates/crochet/pattern.html:98 +#: crochet/templates/crochet/pattern.html:76 msgid "Cambiar" msgstr "Change" -#: crochet/templates/crochet/pattern.html:103 +#: crochet/templates/crochet/pattern.html:81 msgid "No se ha podido subir la imagen de portada. Inténtalo de nuevo." msgstr "Couldn't upload the cover image. Please try again." -#: crochet/templates/crochet/pattern.html:114 +#: crochet/templates/crochet/pattern.html:88 msgid "Personalización de página" msgstr "Page customization" -#: crochet/templates/crochet/pattern.html:117 +#: crochet/templates/crochet/pattern.html:91 msgid "Autor" msgstr "Author" -#: crochet/templates/crochet/pattern.html:118 +#: crochet/templates/crochet/pattern.html:92 msgid "Autor..." msgstr "Author..." -#: crochet/templates/crochet/pattern.html:122 -#: crochet/templates/crochet/pattern.html:206 +#: crochet/templates/crochet/pattern.html:96 +#: crochet/templates/crochet/pattern.html:191 msgid "Color del texto" msgstr "Text color" -#: crochet/templates/crochet/pattern.html:126 +#: crochet/templates/crochet/pattern.html:100 msgid "Color de acento (títulos)" msgstr "Accent color (headings)" -#: crochet/templates/crochet/pattern.html:130 -#: crochet/templates/crochet/pattern.html:206 +#: crochet/templates/crochet/pattern.html:104 +#: crochet/templates/crochet/pattern.html:191 msgid "Color de fondo" msgstr "Background color" -#: crochet/templates/crochet/pattern.html:134 +#: crochet/templates/crochet/pattern.html:108 msgid "Tipografía" msgstr "Font" -#: crochet/templates/crochet/pattern.html:140 +#: crochet/templates/crochet/pattern.html:114 msgid "Monoespaciada" msgstr "Monospace" -#: crochet/templates/crochet/pattern.html:144 +#: crochet/templates/crochet/pattern.html:118 msgid "Tamaño de letra" msgstr "Font size" -#: crochet/templates/crochet/pattern.html:146 +#: crochet/templates/crochet/pattern.html:120 msgid "Pequeño" msgstr "Small" -#: crochet/templates/crochet/pattern.html:147 +#: crochet/templates/crochet/pattern.html:121 msgid "Normal" msgstr "Normal" -#: crochet/templates/crochet/pattern.html:148 +#: crochet/templates/crochet/pattern.html:122 msgid "Grande" msgstr "Large" -#: crochet/templates/crochet/pattern.html:152 +#: crochet/templates/crochet/pattern.html:126 msgid "Alineación del texto" msgstr "Text alignment" -#: crochet/templates/crochet/pattern.html:154 +#: crochet/templates/crochet/pattern.html:128 msgid "Izquierda" msgstr "Left" -#: crochet/templates/crochet/pattern.html:155 +#: crochet/templates/crochet/pattern.html:129 msgid "Centrado" msgstr "Center" -#: crochet/templates/crochet/pattern.html:156 +#: crochet/templates/crochet/pattern.html:130 msgid "Justificado" msgstr "Justify" -#: crochet/templates/crochet/pattern.html:160 +#: crochet/templates/crochet/pattern.html:134 msgid "Tamaño de página" msgstr "Page size" -#: crochet/templates/crochet/pattern.html:165 +#: crochet/templates/crochet/pattern.html:139 msgid "Carta (Letter)" msgstr "Letter" -#: crochet/templates/crochet/pattern.html:170 +#: crochet/templates/crochet/pattern.html:144 msgid "Orientación" msgstr "Orientation" -#: crochet/templates/crochet/pattern.html:172 +#: crochet/templates/crochet/pattern.html:146 msgid "Vertical" msgstr "Portrait" -#: crochet/templates/crochet/pattern.html:173 +#: crochet/templates/crochet/pattern.html:147 msgid "Horizontal" msgstr "Landscape" -#: crochet/templates/crochet/pattern.html:181 -#: crochet/templates/crochet/pattern.html:189 +#: crochet/templates/crochet/pattern.html:155 +#: crochet/templates/crochet/pattern.html:174 msgid "Secciones" msgstr "Sections" -#: crochet/templates/crochet/pattern.html:191 +#: crochet/templates/crochet/pattern.html:166 +msgid "Idioma del patrón" +msgstr "Pattern language" + +#: crochet/templates/crochet/pattern.html:176 msgid "Colapsar todo" msgstr "Collapse all" -#: crochet/templates/crochet/pattern.html:191 +#: crochet/templates/crochet/pattern.html:176 msgid "Expandir todo" msgstr "Expand all" -#: crochet/templates/crochet/pattern.html:196 +#: crochet/templates/crochet/pattern.html:181 msgid "Añadir sección" msgstr "Add section" -#: crochet/templates/crochet/pattern.html:199 +#: crochet/templates/crochet/pattern.html:184 msgid "Título" msgstr "Title" -#: crochet/templates/crochet/pattern.html:199 +#: crochet/templates/crochet/pattern.html:184 msgid "Título..." msgstr "Title..." -#: crochet/templates/crochet/pattern.html:201 +#: crochet/templates/crochet/pattern.html:186 msgid "Subtítulo" msgstr "Subtitle" -#: crochet/templates/crochet/pattern.html:201 +#: crochet/templates/crochet/pattern.html:186 msgid "Subtítulo..." msgstr "Subtitle..." -#: crochet/templates/crochet/pattern.html:203 +#: crochet/templates/crochet/pattern.html:188 msgid "Texto" msgstr "Text" -#: crochet/templates/crochet/pattern.html:203 +#: crochet/templates/crochet/pattern.html:188 msgid "Escribe aquí..." msgstr "Write here..." -#: crochet/templates/crochet/pattern.html:205 -#: crochet/templates/crochet/pattern.html:206 +#: crochet/templates/crochet/pattern.html:190 +#: crochet/templates/crochet/pattern.html:191 msgid "Nota" msgstr "Note" -#: crochet/templates/crochet/pattern.html:205 +#: crochet/templates/crochet/pattern.html:190 msgid "Escribe una nota o consejo..." msgstr "Write a note or tip..." -#: crochet/templates/crochet/pattern.html:208 +#: crochet/templates/crochet/pattern.html:193 msgid "Materiales" msgstr "Materials" -#: crochet/templates/crochet/pattern.html:208 +#: crochet/templates/crochet/pattern.html:193 msgid "Añadir material..." msgstr "Add material..." -#: crochet/templates/crochet/pattern.html:208 +#: crochet/templates/crochet/pattern.html:193 msgid "Añadir línea" msgstr "Add line" -#: crochet/templates/crochet/pattern.html:210 -#: crochet/templates/crochet/pattern.html:212 +#: crochet/templates/crochet/pattern.html:195 +#: crochet/templates/crochet/pattern.html:197 msgid "Imagen" msgstr "Image" -#: crochet/templates/crochet/pattern.html:212 +#: crochet/templates/crochet/pattern.html:197 msgid "No se ha podido subir la imagen. Inténtalo de nuevo." msgstr "Couldn't upload the image. Please try again." -#: crochet/templates/crochet/pattern.html:214 +#: crochet/templates/crochet/pattern.html:199 msgid "Patrón" msgstr "Pattern" -#: crochet/templates/crochet/pattern.html:214 +#: crochet/templates/crochet/pattern.html:199 msgid "Elementos" msgstr "Elements" -#: crochet/templates/crochet/pattern.html:216 +#: crochet/templates/crochet/pattern.html:201 msgid "Grupo" msgstr "Group" -#: crochet/templates/crochet/pattern.html:220 +#: crochet/templates/crochet/pattern.html:205 msgid "Arrastrar para reordenar" msgstr "Drag to reorder" -#: crochet/templates/crochet/pattern.html:221 +#: crochet/templates/crochet/pattern.html:206 msgid "Colapsar / expandir" msgstr "Collapse / expand" -#: crochet/templates/crochet/pattern.html:222 +#: crochet/templates/crochet/pattern.html:207 msgid "Duplicar sección" msgstr "Duplicate section" -#: crochet/templates/crochet/pattern.html:223 +#: crochet/templates/crochet/pattern.html:208 msgid "Eliminar sección" msgstr "Delete section" -#: crochet/templates/crochet/pattern.html:224 +#: crochet/templates/crochet/pattern.html:209 msgid "" "¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las " "secciones que contiene." @@ -699,7 +864,7 @@ msgstr "" "Are you sure you want to delete this group? All sections inside it will also " "be deleted." -#: crochet/templates/crochet/pattern.html:229 +#: crochet/templates/crochet/pattern.html:214 msgid "Añade una sección para ver aquí el resultado." msgstr "Add a section to see the result here." @@ -707,6 +872,40 @@ msgstr "Add a section to see the result here." msgid "Descargar PDF" msgstr "Download PDF" +#: crochet/templates/crochet/two_factor_setup.html:4 +#: crochet/templates/crochet/two_factor_setup.html:26 +msgid "Activar verificación en dos pasos" +msgstr "Enable two-factor authentication" + +#: crochet/templates/crochet/two_factor_setup.html:15 +msgid "Guarda tus códigos de respaldo" +msgstr "Save your backup codes" + +#: crochet/templates/crochet/two_factor_setup.html:17 +msgid "" +"Cada uno sirve para iniciar sesión una vez si pierdes acceso a tu app de " +"autenticación. Guárdalos en un sitio seguro: no se van a volver a mostrar." +msgstr "" +"Each one lets you log in once if you lose access to your authenticator app. " +"Keep them somewhere safe: they won't be shown again." + +#: crochet/templates/crochet/two_factor_setup.html:24 +msgid "Ya los he guardado" +msgstr "I've saved them" + +#: crochet/templates/crochet/two_factor_setup.html:28 +msgid "" +"Escanea este código con tu app de autenticación (Google Authenticator, " +"Aegis, 1Password...) e introduce el código de 6 dígitos que te muestre para " +"confirmar." +msgstr "" +"Scan this code with your authenticator app (Google Authenticator, Aegis, " +"1Password...) and enter the 6-digit code it shows you to confirm." + +#: crochet/templates/crochet/two_factor_setup.html:42 +msgid "Confirmar" +msgstr "Confirm" + #: crochet/templates/registration/login.html:17 msgid "Usuario o contraseña incorrectos." msgstr "Incorrect username or password." @@ -736,96 +935,168 @@ msgstr "Sign up" msgid "¿Ya tienes cuenta? Inicia sesión" msgstr "Already have an account? Log in" -#: crochet/urls.py:38 +#: crochet/templates/registration/register_done.html:4 +msgid "Confirma tu email" +msgstr "Confirm your email" + +#: crochet/templates/registration/register_done.html:16 +msgid "" +"Te hemos enviado un enlace para confirmar tu cuenta. Ábrelo desde tu bandeja " +"de entrada para poder iniciar sesión." +msgstr "" +"We've sent you a link to confirm your account. Open it from your inbox so " +"you can log in." + +#: crochet/templates/registration/two_factor_verify.html:16 +msgid "" +"Introduce el código de tu app de autenticación, o uno de tus códigos de " +"respaldo si no tienes acceso a ella." +msgstr "" +"Enter the code from your authenticator app, or one of your backup codes if " +"you don't have access to it." + +#: crochet/templates/registration/two_factor_verify.html:30 +msgid "Verificar" +msgstr "Verify" + +#: crochet/templates/registration/verify_email_invalid.html:16 +msgid "" +"El enlace de confirmación no es válido, puede que ya se haya usado o que la " +"cuenta ya esté verificada." +msgstr "" +"The confirmation link is invalid, it may have already been used, or the " +"account may already be verified." + +#: crochet/templates/registration/verify_email_invalid.html:19 +msgid "Ir a iniciar sesión" +msgstr "Go to login" + +#: crochet/urls.py:46 msgid "cookies/" msgstr "" -#: crochet/urls.py:39 +#: crochet/urls.py:47 msgid "pattern/<uuid:uuid>/" msgstr "" -#: crochet/urls.py:40 +#: crochet/urls.py:48 msgid "pattern/<uuid:uuid>/edit/" msgstr "" -#: crochet/urls.py:41 +#: crochet/urls.py:49 msgid "pattern/<uuid:uuid>/save/" msgstr "" -#: crochet/urls.py:42 +#: crochet/urls.py:50 msgid "pattern/<uuid:uuid>/images/" msgstr "" -#: crochet/urls.py:43 +#: crochet/urls.py:51 msgid "pattern/<uuid:uuid>/cover/" msgstr "" -#: crochet/urls.py:44 +#: crochet/urls.py:52 msgid "pattern/<uuid:uuid>/pdf/" msgstr "" -#: crochet/urls.py:45 +#: crochet/urls.py:53 msgid "pattern/<uuid:uuid>/delete/" msgstr "" -#: crochet/urls.py:46 +#: crochet/urls.py:54 msgid "pattern/new/" msgstr "" -#: crochet/urls.py:47 +#: crochet/urls.py:55 msgid "account/settings/" msgstr "" -#: crochet/urls.py:48 +#: crochet/urls.py:56 msgid "account/settings/email/" msgstr "" -#: crochet/urls.py:50 +#: crochet/urls.py:58 msgid "account/settings/password/" msgstr "" -#: crochet/urls.py:54 +#: crochet/urls.py:62 msgid "account/settings/display-name/" msgstr "" -#: crochet/urls.py:57 +#: crochet/urls.py:65 msgid "account/settings/delete/" msgstr "" -#: crochet/urls.py:58 -msgid "account/register/" -msgstr "" - -#: crochet/urls.py:60 -msgid "account/login/" -msgstr "" - -#: crochet/urls.py:65 -msgid "account/logout/" +#: crochet/urls.py:66 +msgid "account/settings/2fa/setup/" msgstr "" #: crochet/urls.py:67 +msgid "account/settings/2fa/disable/" +msgstr "" + +#: crochet/urls.py:69 +msgid "account/settings/2fa/devices/<int:device_id>/forget/" +msgstr "" + +#: crochet/urls.py:72 +msgid "account/register/" +msgstr "" + +#: crochet/urls.py:73 +msgid "account/register/done/" +msgstr "" + +#: crochet/urls.py:74 +msgid "account/verify/<uidb64>/<token>/" +msgstr "" + +#: crochet/urls.py:75 +msgid "account/2fa/verify/" +msgstr "" + +#: crochet/urls.py:77 +msgid "account/login/" +msgstr "" + +#: crochet/urls.py:82 +msgid "account/logout/" +msgstr "" + +#: crochet/urls.py:84 msgid "account/password-reset/" msgstr "" -#: crochet/urls.py:86 +#: crochet/urls.py:103 msgid "account/password-reset/done/" msgstr "" -#: crochet/urls.py:90 +#: crochet/urls.py:107 msgid "account/reset/<uidb64>/<token>/" msgstr "" -#: crochet/urls.py:98 +#: crochet/urls.py:115 msgid "account/reset/done/" msgstr "" +#: crochet/user_agent.py:33 crochet/user_agent.py:40 +msgid "Dispositivo desconocido" +msgstr "Unknown device" + # crochet/views.py (PatternDetailView.EMPTY_MESSAGE) -#: crochet/views.py:29 +#: crochet/views.py:51 msgid "Este patrón todavía no tiene contenido." msgstr "This pattern doesn't have any content yet." -#: crochet/views.py:350 +#: crochet/views.py:407 +msgid "Tu cuenta ha sido verificada. ¡Bienvenido/a!" +msgstr "Your account has been verified. Welcome!" + +#: crochet/views.py:473 crochet/views.py:541 +msgid "Código incorrecto." +msgstr "Incorrect code." + +#: crochet/views.py:708 msgid "Tu cuenta se ha eliminado correctamente." msgstr "Your account has been deleted successfully." diff --git a/crochet/locale/es/LC_MESSAGES/django.po b/crochet/locale/es/LC_MESSAGES/django.po index 1c41937..a396d52 100644 --- a/crochet/locale/es/LC_MESSAGES/django.po +++ b/crochet/locale/es/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: crochet\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-22 08:53+0000\n" +"POT-Creation-Date: 2026-09-16 11:16+0000\n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -13,18 +13,128 @@ msgstr "" msgid "Email" msgstr "" -#: crochet/forms.py:128 crochet/templates/crochet/account_settings.html:40 +#: crochet/forms.py:131 crochet/templates/crochet/account_settings.html:40 msgid "Nombre para mostrar" msgstr "" -#: crochet/forms.py:143 +#: crochet/forms.py:152 +msgid "Código" +msgstr "" + +#: crochet/forms.py:163 +msgid "Recordar este dispositivo 30 días" +msgstr "" + +#: crochet/forms.py:171 crochet/forms.py:188 msgid "Contraseña" msgstr "" -#: crochet/forms.py:153 +#: crochet/forms.py:182 crochet/forms.py:198 msgid "Contraseña incorrecta." msgstr "" +#: crochet/templates/403.html:9 crochet/templates/403.html:19 +msgid "Acceso denegado" +msgstr "" + +#: crochet/templates/403.html:21 +msgid "" +"No tienes permiso para ver esta página, o se han hecho demasiadas peticiones " +"seguidas desde aquí." +msgstr "" + +#: crochet/templates/403.html:24 crochet/templates/404.html:19 +#: crochet/templates/500.html:64 +#: crochet/templates/crochet/cookie_policy.html:54 +msgid "Volver al inicio" +msgstr "" + +#: crochet/templates/404.html:4 crochet/templates/404.html:14 +msgid "Página no encontrada" +msgstr "" + +#: crochet/templates/404.html:16 +msgid "La página que buscas no existe o se ha movido." +msgstr "" + +#: crochet/templates/500.html:15 +msgid "Error del servidor" +msgstr "" + +#: crochet/templates/500.html:62 +msgid "Vaya, algo ha ido mal" +msgstr "" + +#: crochet/templates/500.html:63 +msgid "Ha ocurrido un error inesperado. Prueba a recargar en un momento." +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:11 +msgid "Activada" +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:17 +#: crochet/templates/crochet/_account_2fa_status.html:90 +msgid "Desactivar" +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:23 +msgid "Dispositivos recordados" +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:29 +#, python-format +msgid "desde el %(date)s" +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:33 +msgid "Olvidar este dispositivo" +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:42 +msgid "¿Olvidar este dispositivo?" +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:44 +#, python-format +msgid "" +"La próxima vez que inicies sesión desde \"%(name)s\" volverá a pedirse el " +"código de verificación en dos pasos." +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:54 +#: crochet/templates/crochet/_account_2fa_status.html:88 +#: crochet/templates/crochet/two_factor_setup.html:45 +msgid "Cancelar" +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:56 +msgid "Olvidar" +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:59 +#: crochet/templates/crochet/_account_2fa_status.html:94 +msgid "Cerrar" +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:68 +msgid "Desactivar verificación en dos pasos" +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:70 +msgid "" +"Confirma tu contraseña para desactivarla. Se olvidarán también todos los " +"dispositivos recordados." +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:98 +msgid "Desactivada" +msgstr "" + +#: crochet/templates/crochet/_account_2fa_status.html:99 +msgid "Activar" +msgstr "" + #: crochet/templates/crochet/_account_delete_form.html:9 msgid "" "¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus " @@ -32,7 +142,7 @@ msgid "" msgstr "" #: crochet/templates/crochet/_account_delete_form.html:19 -#: crochet/templates/crochet/account_settings.html:68 +#: crochet/templates/crochet/account_settings.html:78 msgid "Eliminar cuenta" msgstr "" @@ -105,12 +215,12 @@ msgid "Actualizado el %(date)s" msgstr "" #: crochet/templates/crochet/account_home.html:44 -#: crochet/templates/crochet/pattern.html:182 +#: crochet/templates/crochet/pattern.html:156 msgid "Vista previa" msgstr "" #: crochet/templates/crochet/account_home.html:48 -#: crochet/templates/crochet/pattern.html:55 +#: crochet/templates/crochet/pattern.html:46 msgid "Eliminar" msgstr "" @@ -120,7 +230,7 @@ msgstr "" #: crochet/templates/crochet/account_settings.html:4 #: crochet/templates/crochet/account_settings.html:18 -#: crochet/templates/crochet/base.html:52 +#: crochet/templates/crochet/base.html:62 msgid "Ajustes de la cuenta" msgstr "" @@ -136,19 +246,35 @@ msgstr "" msgid "Zona de peligro" msgstr "" -#: crochet/templates/crochet/account_settings.html:70 +#: crochet/templates/crochet/account_settings.html:63 +#: crochet/templates/registration/two_factor_verify.html:4 +#: crochet/templates/registration/two_factor_verify.html:14 +msgid "Verificación en dos pasos" +msgstr "" + +#: crochet/templates/crochet/account_settings.html:65 +msgid "" +"Pide un código de tu app de autenticación (Google Authenticator, Aegis...) " +"además de la contraseña al iniciar sesión." +msgstr "" + +#: crochet/templates/crochet/account_settings.html:80 msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente." msgstr "" -#: crochet/templates/crochet/base.html:39 +#: crochet/templates/crochet/base.html:37 +msgid "Cambiar tema" +msgstr "" + +#: crochet/templates/crochet/base.html:49 msgid "Cambiar idioma" msgstr "" -#: crochet/templates/crochet/base.html:56 +#: crochet/templates/crochet/base.html:66 msgid "Cerrar sesión" msgstr "" -#: crochet/templates/crochet/base.html:62 +#: crochet/templates/crochet/base.html:72 #: crochet/templates/crochet/home.html:25 #: crochet/templates/crochet/password_reset_complete.html:18 #: crochet/templates/registration/login.html:4 @@ -214,10 +340,6 @@ msgid "" "consentimiento previo." msgstr "" -#: crochet/templates/crochet/cookie_policy.html:54 -msgid "Volver al inicio" -msgstr "" - #: crochet/templates/crochet/email/account_deleted_notice.html:4 #: crochet/templates/crochet/email/account_deleted_subject.txt:1 #, python-format @@ -238,6 +360,8 @@ msgstr "" #: crochet/templates/crochet/email/email_changed_notice.txt:4 #: crochet/templates/crochet/email/password_changed_notice.html:12 #: crochet/templates/crochet/email/password_changed_notice.txt:4 +#: crochet/templates/crochet/email/verify_email.html:19 +#: crochet/templates/crochet/email/verify_email.txt:7 msgid "Tu usuario:" msgstr "" @@ -314,6 +438,34 @@ msgstr "" msgid "Si no has solicitado este cambio, puedes ignorar este email." msgstr "" +#: crochet/templates/crochet/email/verify_email.html:4 +msgid "Confirma tu cuenta" +msgstr "" + +#: crochet/templates/crochet/email/verify_email.html:8 +#: crochet/templates/crochet/email/verify_email.txt:2 +#, python-format +msgid "Has recibido este email porque te has registrado en %(site_name)s." +msgstr "" + +#: crochet/templates/crochet/email/verify_email.html:14 +msgid "Confirmar cuenta" +msgstr "" + +#: crochet/templates/crochet/email/verify_email.html:23 +#: crochet/templates/crochet/email/verify_email.txt:9 +msgid "Si no has sido tú, puedes ignorar este email." +msgstr "" + +#: crochet/templates/crochet/email/verify_email.txt:4 +msgid "Sigue este enlace para confirmar tu cuenta:" +msgstr "" + +#: crochet/templates/crochet/email/verify_email_subject.txt:1 +#, python-format +msgid "Confirma tu cuenta en %(site_name)s" +msgstr "" + #: crochet/templates/crochet/home.html:4 msgid "Crochet — Crea y comparte tus patrones" msgstr "" @@ -376,6 +528,8 @@ msgid "Elige una contraseña nueva" msgstr "" #: crochet/templates/crochet/password_reset_confirm.html:33 +#: crochet/templates/registration/verify_email_invalid.html:4 +#: crochet/templates/registration/verify_email_invalid.html:14 msgid "Enlace no válido" msgstr "" @@ -390,6 +544,7 @@ msgid "Solicitar un enlace nuevo" msgstr "" #: crochet/templates/crochet/password_reset_done.html:14 +#: crochet/templates/registration/register_done.html:14 msgid "Revisa tu email" msgstr "" @@ -401,6 +556,8 @@ msgstr "" #: crochet/templates/crochet/password_reset_done.html:19 #: crochet/templates/crochet/password_reset_form.html:32 +#: crochet/templates/registration/register_done.html:19 +#: crochet/templates/registration/two_factor_verify.html:33 msgid "Volver a iniciar sesión" msgstr "" @@ -422,250 +579,250 @@ msgstr "" msgid "Recupera tu contraseña en %(site_name)s" msgstr "" -#: crochet/templates/crochet/pattern.html:26 -msgid "Idioma del patrón" -msgstr "" - -#: crochet/templates/crochet/pattern.html:38 -msgid "No se ha podido guardar. Inténtalo de nuevo." -msgstr "" - -#: crochet/templates/crochet/pattern.html:38 -msgid "Guardar" -msgstr "" - -#: crochet/templates/crochet/pattern.html:41 -msgid "Ver patrón" -msgstr "" - -#: crochet/templates/crochet/pattern.html:42 -msgid "Exportar a PDF" -msgstr "" - -#: crochet/templates/crochet/pattern.html:49 -msgid "Más opciones" -msgstr "" - -#: crochet/templates/crochet/pattern.html:65 -msgid "Guardado" -msgstr "" - -#: crochet/templates/crochet/pattern.html:78 +#: crochet/templates/crochet/pattern.html:23 msgid "Título del patrón" msgstr "" -#: crochet/templates/crochet/pattern.html:79 +#: crochet/templates/crochet/pattern.html:24 msgid "Título del patrón..." msgstr "" -#: crochet/templates/crochet/pattern.html:82 +#: crochet/templates/crochet/pattern.html:29 +msgid "No se ha podido guardar. Inténtalo de nuevo." +msgstr "" + +#: crochet/templates/crochet/pattern.html:29 +msgid "Guardar" +msgstr "" + +#: crochet/templates/crochet/pattern.html:32 +msgid "Ver patrón" +msgstr "" + +#: crochet/templates/crochet/pattern.html:33 +msgid "Exportar a PDF" +msgstr "" + +#: crochet/templates/crochet/pattern.html:40 +msgid "Más opciones" +msgstr "" + +#: crochet/templates/crochet/pattern.html:56 +msgid "Guardado" +msgstr "" + +#: crochet/templates/crochet/pattern.html:64 msgid "Imagen de portada" msgstr "" -#: crochet/templates/crochet/pattern.html:95 +#: crochet/templates/crochet/pattern.html:73 msgid "Añadir imagen" msgstr "" -#: crochet/templates/crochet/pattern.html:98 +#: crochet/templates/crochet/pattern.html:76 msgid "Cambiar" msgstr "" -#: crochet/templates/crochet/pattern.html:103 +#: crochet/templates/crochet/pattern.html:81 msgid "No se ha podido subir la imagen de portada. Inténtalo de nuevo." msgstr "" -#: crochet/templates/crochet/pattern.html:114 +#: crochet/templates/crochet/pattern.html:88 msgid "Personalización de página" msgstr "" -#: crochet/templates/crochet/pattern.html:117 +#: crochet/templates/crochet/pattern.html:91 msgid "Autor" msgstr "" -#: crochet/templates/crochet/pattern.html:118 +#: crochet/templates/crochet/pattern.html:92 msgid "Autor..." msgstr "" -#: crochet/templates/crochet/pattern.html:122 -#: crochet/templates/crochet/pattern.html:206 +#: crochet/templates/crochet/pattern.html:96 +#: crochet/templates/crochet/pattern.html:191 msgid "Color del texto" msgstr "" -#: crochet/templates/crochet/pattern.html:126 +#: crochet/templates/crochet/pattern.html:100 msgid "Color de acento (títulos)" msgstr "" -#: crochet/templates/crochet/pattern.html:130 -#: crochet/templates/crochet/pattern.html:206 +#: crochet/templates/crochet/pattern.html:104 +#: crochet/templates/crochet/pattern.html:191 msgid "Color de fondo" msgstr "" -#: crochet/templates/crochet/pattern.html:134 +#: crochet/templates/crochet/pattern.html:108 msgid "Tipografía" msgstr "" -#: crochet/templates/crochet/pattern.html:140 +#: crochet/templates/crochet/pattern.html:114 msgid "Monoespaciada" msgstr "" -#: crochet/templates/crochet/pattern.html:144 +#: crochet/templates/crochet/pattern.html:118 msgid "Tamaño de letra" msgstr "" -#: crochet/templates/crochet/pattern.html:146 +#: crochet/templates/crochet/pattern.html:120 msgid "Pequeño" msgstr "" -#: crochet/templates/crochet/pattern.html:147 +#: crochet/templates/crochet/pattern.html:121 msgid "Normal" msgstr "" -#: crochet/templates/crochet/pattern.html:148 +#: crochet/templates/crochet/pattern.html:122 msgid "Grande" msgstr "" -#: crochet/templates/crochet/pattern.html:152 +#: crochet/templates/crochet/pattern.html:126 msgid "Alineación del texto" msgstr "" -#: crochet/templates/crochet/pattern.html:154 +#: crochet/templates/crochet/pattern.html:128 msgid "Izquierda" msgstr "" -#: crochet/templates/crochet/pattern.html:155 +#: crochet/templates/crochet/pattern.html:129 msgid "Centrado" msgstr "" -#: crochet/templates/crochet/pattern.html:156 +#: crochet/templates/crochet/pattern.html:130 msgid "Justificado" msgstr "" -#: crochet/templates/crochet/pattern.html:160 +#: crochet/templates/crochet/pattern.html:134 msgid "Tamaño de página" msgstr "" -#: crochet/templates/crochet/pattern.html:165 +#: crochet/templates/crochet/pattern.html:139 msgid "Carta (Letter)" msgstr "" -#: crochet/templates/crochet/pattern.html:170 +#: crochet/templates/crochet/pattern.html:144 msgid "Orientación" msgstr "" -#: crochet/templates/crochet/pattern.html:172 +#: crochet/templates/crochet/pattern.html:146 msgid "Vertical" msgstr "" -#: crochet/templates/crochet/pattern.html:173 +#: crochet/templates/crochet/pattern.html:147 msgid "Horizontal" msgstr "" -#: crochet/templates/crochet/pattern.html:181 -#: crochet/templates/crochet/pattern.html:189 +#: crochet/templates/crochet/pattern.html:155 +#: crochet/templates/crochet/pattern.html:174 msgid "Secciones" msgstr "" -#: crochet/templates/crochet/pattern.html:191 +#: crochet/templates/crochet/pattern.html:166 +msgid "Idioma del patrón" +msgstr "" + +#: crochet/templates/crochet/pattern.html:176 msgid "Colapsar todo" msgstr "" -#: crochet/templates/crochet/pattern.html:191 +#: crochet/templates/crochet/pattern.html:176 msgid "Expandir todo" msgstr "" -#: crochet/templates/crochet/pattern.html:196 +#: crochet/templates/crochet/pattern.html:181 msgid "Añadir sección" msgstr "" -#: crochet/templates/crochet/pattern.html:199 +#: crochet/templates/crochet/pattern.html:184 msgid "Título" msgstr "" -#: crochet/templates/crochet/pattern.html:199 +#: crochet/templates/crochet/pattern.html:184 msgid "Título..." msgstr "" -#: crochet/templates/crochet/pattern.html:201 +#: crochet/templates/crochet/pattern.html:186 msgid "Subtítulo" msgstr "" -#: crochet/templates/crochet/pattern.html:201 +#: crochet/templates/crochet/pattern.html:186 msgid "Subtítulo..." msgstr "" -#: crochet/templates/crochet/pattern.html:203 +#: crochet/templates/crochet/pattern.html:188 msgid "Texto" msgstr "" -#: crochet/templates/crochet/pattern.html:203 +#: crochet/templates/crochet/pattern.html:188 msgid "Escribe aquí..." msgstr "" -#: crochet/templates/crochet/pattern.html:205 -#: crochet/templates/crochet/pattern.html:206 +#: crochet/templates/crochet/pattern.html:190 +#: crochet/templates/crochet/pattern.html:191 msgid "Nota" msgstr "" -#: crochet/templates/crochet/pattern.html:205 +#: crochet/templates/crochet/pattern.html:190 msgid "Escribe una nota o consejo..." msgstr "" -#: crochet/templates/crochet/pattern.html:208 +#: crochet/templates/crochet/pattern.html:193 msgid "Materiales" msgstr "" -#: crochet/templates/crochet/pattern.html:208 +#: crochet/templates/crochet/pattern.html:193 msgid "Añadir material..." msgstr "" -#: crochet/templates/crochet/pattern.html:208 +#: crochet/templates/crochet/pattern.html:193 msgid "Añadir línea" msgstr "" -#: crochet/templates/crochet/pattern.html:210 -#: crochet/templates/crochet/pattern.html:212 +#: crochet/templates/crochet/pattern.html:195 +#: crochet/templates/crochet/pattern.html:197 msgid "Imagen" msgstr "" -#: crochet/templates/crochet/pattern.html:212 +#: crochet/templates/crochet/pattern.html:197 msgid "No se ha podido subir la imagen. Inténtalo de nuevo." msgstr "" -#: crochet/templates/crochet/pattern.html:214 +#: crochet/templates/crochet/pattern.html:199 msgid "Patrón" msgstr "" -#: crochet/templates/crochet/pattern.html:214 +#: crochet/templates/crochet/pattern.html:199 msgid "Elementos" msgstr "" -#: crochet/templates/crochet/pattern.html:216 +#: crochet/templates/crochet/pattern.html:201 msgid "Grupo" msgstr "" -#: crochet/templates/crochet/pattern.html:220 +#: crochet/templates/crochet/pattern.html:205 msgid "Arrastrar para reordenar" msgstr "" -#: crochet/templates/crochet/pattern.html:221 +#: crochet/templates/crochet/pattern.html:206 msgid "Colapsar / expandir" msgstr "" -#: crochet/templates/crochet/pattern.html:222 +#: crochet/templates/crochet/pattern.html:207 msgid "Duplicar sección" msgstr "" -#: crochet/templates/crochet/pattern.html:223 +#: crochet/templates/crochet/pattern.html:208 msgid "Eliminar sección" msgstr "" -#: crochet/templates/crochet/pattern.html:224 +#: crochet/templates/crochet/pattern.html:209 msgid "" "¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las " "secciones que contiene." msgstr "" -#: crochet/templates/crochet/pattern.html:229 +#: crochet/templates/crochet/pattern.html:214 msgid "Añade una sección para ver aquí el resultado." msgstr "" @@ -673,6 +830,36 @@ msgstr "" msgid "Descargar PDF" msgstr "" +#: crochet/templates/crochet/two_factor_setup.html:4 +#: crochet/templates/crochet/two_factor_setup.html:26 +msgid "Activar verificación en dos pasos" +msgstr "" + +#: crochet/templates/crochet/two_factor_setup.html:15 +msgid "Guarda tus códigos de respaldo" +msgstr "" + +#: crochet/templates/crochet/two_factor_setup.html:17 +msgid "" +"Cada uno sirve para iniciar sesión una vez si pierdes acceso a tu app de " +"autenticación. Guárdalos en un sitio seguro: no se van a volver a mostrar." +msgstr "" + +#: crochet/templates/crochet/two_factor_setup.html:24 +msgid "Ya los he guardado" +msgstr "" + +#: crochet/templates/crochet/two_factor_setup.html:28 +msgid "" +"Escanea este código con tu app de autenticación (Google Authenticator, " +"Aegis, 1Password...) e introduce el código de 6 dígitos que te muestre para " +"confirmar." +msgstr "" + +#: crochet/templates/crochet/two_factor_setup.html:42 +msgid "Confirmar" +msgstr "" + #: crochet/templates/registration/login.html:17 msgid "Usuario o contraseña incorrectos." msgstr "" @@ -702,97 +889,163 @@ msgstr "" msgid "¿Ya tienes cuenta? Inicia sesión" msgstr "" -#: crochet/urls.py:38 +#: crochet/templates/registration/register_done.html:4 +msgid "Confirma tu email" +msgstr "" + +#: crochet/templates/registration/register_done.html:16 +msgid "" +"Te hemos enviado un enlace para confirmar tu cuenta. Ábrelo desde tu bandeja " +"de entrada para poder iniciar sesión." +msgstr "" + +#: crochet/templates/registration/two_factor_verify.html:16 +msgid "" +"Introduce el código de tu app de autenticación, o uno de tus códigos de " +"respaldo si no tienes acceso a ella." +msgstr "" + +#: crochet/templates/registration/two_factor_verify.html:30 +msgid "Verificar" +msgstr "" + +#: crochet/templates/registration/verify_email_invalid.html:16 +msgid "" +"El enlace de confirmación no es válido, puede que ya se haya usado o que la " +"cuenta ya esté verificada." +msgstr "" + +#: crochet/templates/registration/verify_email_invalid.html:19 +msgid "Ir a iniciar sesión" +msgstr "" + +#: crochet/urls.py:46 msgid "cookies/" msgstr "" # Rutas traducibles de crochet/urls.py: la base "pattern/" se traduce como # "patron/" (sin tilde, para no meter caracteres acentuados en la URL). -#: crochet/urls.py:39 +#: crochet/urls.py:47 msgid "pattern/<uuid:uuid>/" msgstr "patron/<uuid:uuid>/" -#: crochet/urls.py:40 +#: crochet/urls.py:48 msgid "pattern/<uuid:uuid>/edit/" msgstr "patron/<uuid:uuid>/editar/" -#: crochet/urls.py:41 +#: crochet/urls.py:49 msgid "pattern/<uuid:uuid>/save/" msgstr "patron/<uuid:uuid>/guardar/" -#: crochet/urls.py:42 +#: crochet/urls.py:50 msgid "pattern/<uuid:uuid>/images/" msgstr "patron/<uuid:uuid>/imagenes/" -#: crochet/urls.py:43 +#: crochet/urls.py:51 msgid "pattern/<uuid:uuid>/cover/" msgstr "patron/<uuid:uuid>/portada/" -#: crochet/urls.py:44 +#: crochet/urls.py:52 msgid "pattern/<uuid:uuid>/pdf/" msgstr "patron/<uuid:uuid>/pdf/" -#: crochet/urls.py:45 +#: crochet/urls.py:53 msgid "pattern/<uuid:uuid>/delete/" msgstr "patron/<uuid:uuid>/eliminar/" -#: crochet/urls.py:46 +#: crochet/urls.py:54 msgid "pattern/new/" msgstr "patron/nuevo/" -#: crochet/urls.py:47 +#: crochet/urls.py:55 msgid "account/settings/" msgstr "cuenta/ajustes/" -#: crochet/urls.py:48 +#: crochet/urls.py:56 msgid "account/settings/email/" msgstr "cuenta/ajustes/email/" -#: crochet/urls.py:50 +#: crochet/urls.py:58 msgid "account/settings/password/" msgstr "cuenta/ajustes/contrasena/" -#: crochet/urls.py:54 +#: crochet/urls.py:62 msgid "account/settings/display-name/" msgstr "cuenta/ajustes/nombre/" -#: crochet/urls.py:57 +#: crochet/urls.py:65 msgid "account/settings/delete/" msgstr "cuenta/ajustes/eliminar/" -#: crochet/urls.py:58 +#: crochet/urls.py:66 +msgid "account/settings/2fa/setup/" +msgstr "cuenta/ajustes/2fa/activar/" + +#: crochet/urls.py:67 +msgid "account/settings/2fa/disable/" +msgstr "cuenta/ajustes/2fa/desactivar/" + +#: crochet/urls.py:69 +msgid "account/settings/2fa/devices/<int:device_id>/forget/" +msgstr "cuenta/ajustes/2fa/dispositivos/<int:device_id>/olvidar/" + +#: crochet/urls.py:72 msgid "account/register/" msgstr "cuenta/registro/" -#: crochet/urls.py:60 +#: crochet/urls.py:73 +msgid "account/register/done/" +msgstr "cuenta/registro/hecho/" + +#: crochet/urls.py:74 +msgid "account/verify/<uidb64>/<token>/" +msgstr "cuenta/verificar/<uidb64>/<token>/" + +#: crochet/urls.py:75 +msgid "account/2fa/verify/" +msgstr "cuenta/2fa/verificar/" + +#: crochet/urls.py:77 msgid "account/login/" msgstr "cuenta/entrar/" -#: crochet/urls.py:65 +#: crochet/urls.py:82 msgid "account/logout/" msgstr "cuenta/salir/" -#: crochet/urls.py:67 +#: crochet/urls.py:84 msgid "account/password-reset/" msgstr "cuenta/recuperar-contrasena/" -#: crochet/urls.py:86 +#: crochet/urls.py:103 msgid "account/password-reset/done/" msgstr "cuenta/recuperar-contrasena/enviado/" -#: crochet/urls.py:90 +#: crochet/urls.py:107 msgid "account/reset/<uidb64>/<token>/" msgstr "cuenta/restablecer/<uidb64>/<token>/" -#: crochet/urls.py:98 +#: crochet/urls.py:115 msgid "account/reset/done/" msgstr "cuenta/restablecer/hecho/" -#: crochet/views.py:29 +#: crochet/user_agent.py:33 crochet/user_agent.py:40 +msgid "Dispositivo desconocido" +msgstr "" + +#: crochet/views.py:51 msgid "Este patrón todavía no tiene contenido." msgstr "" -#: crochet/views.py:350 +#: crochet/views.py:407 +msgid "Tu cuenta ha sido verificada. ¡Bienvenido/a!" +msgstr "" + +#: crochet/views.py:473 crochet/views.py:541 +msgid "Código incorrecto." +msgstr "" + +#: crochet/views.py:708 msgid "Tu cuenta se ha eliminado correctamente." msgstr "" diff --git a/crochet/management/commands/build_pattern_detail_css.py b/crochet/management/commands/build_pattern_detail_css.py index 2ebdd49..0e1cd1a 100644 --- a/crochet/management/commands/build_pattern_detail_css.py +++ b/crochet/management/commands/build_pattern_detail_css.py @@ -51,7 +51,10 @@ SOURCES = [ class Command(BaseCommand): - help = 'Recompila crochet/static/css/pattern-detail.css (Tailwind precompilado para la vista de solo lectura / PDF).' + help = ( + 'Recompila crochet/static/css/pattern-detail.css ' + '(Tailwind precompilado para la vista de solo lectura / PDF).' + ) def handle(self, *args, **options): if not TAILWIND_BIN.exists(): @@ -94,6 +97,6 @@ class Command(BaseCommand): try: urllib.request.urlretrieve(url, TAILWIND_BIN) except OSError as error: - raise CommandError(f'No se ha podido descargar {url}: {error}') + raise CommandError(f'No se ha podido descargar {url}: {error}') from error TAILWIND_BIN.chmod(TAILWIND_BIN.stat().st_mode | stat.S_IEXEC) diff --git a/crochet/migrations/0008_twofactorremembereddevice.py b/crochet/migrations/0008_twofactorremembereddevice.py new file mode 100644 index 0000000..5fd9b6d --- /dev/null +++ b/crochet/migrations/0008_twofactorremembereddevice.py @@ -0,0 +1,25 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crochet', '0007_pattern_cover_image'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TwoFactorRememberedDevice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('device_name', models.CharField(blank=True, default='', max_length=255)), + ('token', models.CharField(max_length=64, unique=True)), + ('expires_at', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='remembered_devices', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/crochet/models.py b/crochet/models.py index 7adb303..1f12f8e 100644 --- a/crochet/models.py +++ b/crochet/models.py @@ -51,6 +51,9 @@ class PatternImage(models.Model): image = models.ImageField(upload_to='patterns/%Y/%m/') uploaded_at = models.DateTimeField(auto_now_add=True) + def __str__(self): + return f'{self.pattern} - {self.image.name}' + class StitchType(models.Model): # Catálogo de tipos de punto arrastrables en una sección de "Patrón" @@ -67,3 +70,24 @@ class StitchType(models.Model): def __str__(self): return self.translations.get('es') or f'StitchType #{self.pk}' + + +class TwoFactorRememberedDevice(models.Model): + # "Recordar este dispositivo" (ver TwoFactorVerifyView/ + # RateLimitedLoginView en crochet/views.py): el token vive en una cookie + # del navegador, no aquí -esta fila solo sirve para poder comprobarlo + # server-side y para poder revocarlo (se borra entera al desactivar la + # verificación en dos pasos, ver TwoFactorDisableView)-. + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='remembered_devices') + # Descripción legible sacada del User-Agent al marcar "recordar este + # dispositivo" (ver crochet/user_agent.py), para que se reconozca en la + # lista de ajustes de cuenta ("Chrome (Windows)"...); no es un dato de + # seguridad -un User-Agent se falsifica sin esfuerzo-, solo una ayuda + # visual. + device_name = models.CharField(max_length=255, blank=True, default='') + token = models.CharField(max_length=64, unique=True) + expires_at = models.DateTimeField() + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f'{self.user} - {self.device_name} ({self.expires_at:%Y-%m-%d})' diff --git a/crochet/pattern_render.py b/crochet/pattern_render.py index cfc749e..b1df867 100644 --- a/crochet/pattern_render.py +++ b/crochet/pattern_render.py @@ -68,7 +68,9 @@ def _line(tag, text, css_class): def _render_group(section, lang, stitch_types_by_id): - inner = mark_safe(''.join(_render_section(child, lang, stitch_types_by_id) for child in section.get('children', []))) + inner = mark_safe( + ''.join(_render_section(child, lang, stitch_types_by_id) for child in section.get('children', [])), + ) if not inner: return '' return format_html('<div class="my-2 flex flex-col gap-1">{0}</div>', inner) diff --git a/crochet/static/css/main.css b/crochet/static/css/main.css index 5593f55..a82712d 100644 --- a/crochet/static/css/main.css +++ b/crochet/static/css/main.css @@ -17,6 +17,19 @@ font-family: 'Poppins', system-ui, sans-serif; } +/* Contrapartida oscura de la paleta de arriba, activada por el mismo + data-theme="crochet-dark" que usa theme.css para el tema daisyUI a juego + (ver theme-toggle.js y el script del <head> en base.html). Primary/accent + se quedan igual (ya funcionan sobre fondo oscuro); solo cambian + fondo/texto y se aclara un poco el secondary para mantener contraste. */ +[data-theme="crochet-dark"] { + --brand-bg: #21170f; + --brand-bg-soft: #2b2016; + --brand-border: #3c2e1f; + --brand-text: #f5ecdd; + --brand-secondary: #4da695; +} + body { background-color: var(--brand-bg); color: var(--brand-text); diff --git a/crochet/static/css/theme.css b/crochet/static/css/theme.css index 69e9f5b..d2186df 100644 --- a/crochet/static/css/theme.css +++ b/crochet/static/css/theme.css @@ -35,6 +35,48 @@ --color-error: oklch(71% 0.194 13.428); --color-error-content: oklch(27% 0.105 12.094); + --radius-selector: 0.5rem; + --radius-field: 0.75rem; + --radius-box: 1rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 1; + --noise: 0; +} + +/* Contrapartida oscura de "crochet", activada con el botón del navbar (ver + theme-toggle.js). Misma estructura que arriba, reusando las variables + --brand-* que main.css redefine para [data-theme="crochet-dark"]. */ +[data-theme="crochet-dark"] { + color-scheme: dark; + + --color-base-100: var(--brand-bg); + --color-base-200: var(--brand-bg-soft); + --color-base-300: var(--brand-border); + --color-base-content: var(--brand-text); + + --color-primary: var(--brand-primary); + --color-primary-content: var(--brand-primary-content); + + --color-secondary: var(--brand-secondary); + --color-secondary-content: #ffffff; + + --color-accent: var(--brand-accent); + --color-accent-content: #2e2118; + + --color-neutral: var(--brand-text); + --color-neutral-content: #21170f; + + --color-info: oklch(74% 0.16 232.661); + --color-info-content: oklch(29% 0.066 243.157); + --color-success: oklch(76% 0.177 163.223); + --color-success-content: oklch(37% 0.077 168.94); + --color-warning: oklch(82% 0.189 84.429); + --color-warning-content: oklch(41% 0.112 45.904); + --color-error: oklch(71% 0.194 13.428); + --color-error-content: oklch(27% 0.105 12.094); + --radius-selector: 0.5rem; --radius-field: 0.75rem; --radius-box: 1rem; diff --git a/crochet/static/js/modal-reopen.js b/crochet/static/js/modal-reopen.js new file mode 100644 index 0000000..4691d87 --- /dev/null +++ b/crochet/static/js/modal-reopen.js @@ -0,0 +1,25 @@ +// Reabre cualquier <dialog data-reopen> que llegue en un swap de HTMX. +// Hace falta porque el patrón habitual del proyecto (ver +// _account_2fa_status.html) intercambia el fragmento entero -incluido el +// propio <dialog>- al enviar un formulario dentro de un modal: el +// navegador crea un <dialog> nuevo, y uno nuevo no hereda el estado +// abierto del que reemplaza (showModal() no se "recuerda" entre +// elementos). El servidor añade data-reopen al renderizar el modal con +// errores de validación (el caso en el que realmente hace falta que siga +// abierto tras el swap). +// +// htmx:afterSettle, NO htmx:afterSwap. Cuando el elemento que target/swap +// reemplaza es un ANCESTRO del <form> que hizo la petición -este caso: +// hx-target="#account-2fa-status" en un <form> anidado dentro de ese mismo +// div-, "afterSwap" se dispara sobre el elemento que originó la petición, +// que en ese momento YA se ha quedado fuera del árbol del documento (el +// outerHTML swap lo reemplazó); un evento despachado sobre un nodo +// desconectado no burbujea a ningún sitio, así que un listener en +// document.body nunca lo ve. "afterSettle" en cambio se dispara sobre el +// nuevo contenido ya insertado en el árbol, así que sí burbujea con +// normalidad. +document.body.addEventListener('htmx:afterSettle', () => { + document.querySelectorAll('dialog[data-reopen]').forEach((dialog) => { + dialog.showModal(); + }); +}); diff --git a/crochet/static/js/theme-toggle.js b/crochet/static/js/theme-toggle.js new file mode 100644 index 0000000..9a1b8d9 --- /dev/null +++ b/crochet/static/js/theme-toggle.js @@ -0,0 +1,33 @@ +// Alterna entre el tema claro y oscuro (ver theme.css: [data-theme="crochet"] +// vs [data-theme="crochet-dark"]) y recuerda la preferencia en localStorage. +// El tema inicial ya lo aplica el script del <head> en base.html (antes de +// pintar la página, para evitar el parpadeo del tema por defecto); este +// script solo atiende el clic del botón y mantiene su icono al día. +const THEME_STORAGE_KEY = 'theme'; +const DARK_THEME = 'crochet-dark'; +const LIGHT_THEME = 'crochet'; + +const toggleButton = document.getElementById('theme-toggle'); +const icon = document.getElementById('theme-toggle-icon'); + +function currentTheme() { + return document.documentElement.dataset.theme === DARK_THEME ? DARK_THEME : LIGHT_THEME; +} + +function updateIcon() { + // Se muestra el icono del tema al que se pasaría al pulsar, no el actual. + icon.textContent = currentTheme() === DARK_THEME ? '☀️' : '🌙'; +} + +if (toggleButton && icon) { + updateIcon(); + + toggleButton.addEventListener('click', () => { + const next = currentTheme() === DARK_THEME ? LIGHT_THEME : DARK_THEME; + document.documentElement.dataset.theme = next; + updateIcon(); + try { + localStorage.setItem(THEME_STORAGE_KEY, next); + } catch {} + }); +} diff --git a/crochet/templates/403.html b/crochet/templates/403.html new file mode 100644 index 0000000..c0bd240 --- /dev/null +++ b/crochet/templates/403.html @@ -0,0 +1,29 @@ +{% extends 'crochet/base.html' %} +{% load i18n %} +{# Django la usa como plantilla de error 403 para cualquier PermissionDenied + sin capturar, incluida Ratelimited (ver crochet/views.py: LOGIN_RATE/ + REGISTER_RATE/PASSWORD_RESET_RATE) — de ahí el mensaje genérico en vez de + uno específico de "demasiadas peticiones", ya que también cubre otros + 403 (p. ej. _get_owned_pattern_or_403). #} + +{% block title %}{% trans 'Acceso denegado' %}{% endblock %} + +{% block extra_css %} + {% include 'crochet/_platform_assets.html' %} +{% endblock %} + +{% block content %} + <main class="flex justify-center px-4 py-12"> + <div class="card w-full max-w-sm bg-base-100 shadow-xl border border-base-300"> + <div class="card-body text-center"> + <h1 class="card-title justify-center">{% trans 'Acceso denegado' %}</h1> + <p class="text-sm text-base-content/70"> + {% trans 'No tienes permiso para ver esta página, o se han hecho demasiadas peticiones seguidas desde aquí.' %} + </p> + <p class="text-sm text-center mt-2"> + <a href="{% url 'crochet:home' %}" class="link link-primary">{% trans 'Volver al inicio' %}</a> + </p> + </div> + </div> + </main> +{% endblock %} diff --git a/crochet/templates/404.html b/crochet/templates/404.html new file mode 100644 index 0000000..0ba2714 --- /dev/null +++ b/crochet/templates/404.html @@ -0,0 +1,24 @@ +{% extends 'crochet/base.html' %} +{% load i18n %} + +{% block title %}{% trans 'Página no encontrada' %}{% endblock %} + +{% block extra_css %} + {% include 'crochet/_platform_assets.html' %} +{% endblock %} + +{% block content %} + <main class="flex justify-center px-4 py-12"> + <div class="card w-full max-w-sm bg-base-100 shadow-xl border border-base-300"> + <div class="card-body text-center"> + <h1 class="card-title justify-center">{% trans 'Página no encontrada' %}</h1> + <p class="text-sm text-base-content/70"> + {% trans 'La página que buscas no existe o se ha movido.' %} + </p> + <p class="text-sm text-center mt-2"> + <a href="{% url 'crochet:home' %}" class="link link-primary">{% trans 'Volver al inicio' %}</a> + </p> + </div> + </div> + </main> +{% endblock %} diff --git a/crochet/templates/500.html b/crochet/templates/500.html new file mode 100644 index 0000000..7f46908 --- /dev/null +++ b/crochet/templates/500.html @@ -0,0 +1,68 @@ +{% load i18n static %} +<!DOCTYPE html> +<html lang="es" data-theme="crochet"> + +<head> + <script> + try { + if (localStorage.getItem('theme') === 'crochet-dark') { + document.documentElement.dataset.theme = 'crochet-dark'; + } + } catch {} + </script> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{% trans 'Error del servidor' %} + + + + + +
+

{% trans 'Vaya, algo ha ido mal' %}

+

{% trans 'Ha ocurrido un error inesperado. Prueba a recargar en un momento.' %}

+ {% trans 'Volver al inicio' %} +
+ + + diff --git a/crochet/templates/crochet/_account_2fa_status.html b/crochet/templates/crochet/_account_2fa_status.html new file mode 100644 index 0000000..1aac0f5 --- /dev/null +++ b/crochet/templates/crochet/_account_2fa_status.html @@ -0,0 +1,102 @@ +{% load i18n %} + +
+ {% if two_factor_enabled %} +
+ {% trans 'Activada' %} + +
+ + {% if remembered_devices %} +
+

{% trans 'Dispositivos recordados' %}

+
    + {% for device in remembered_devices %} +
  • + {{ device.device_name }} + + {% blocktranslate with date=device.created_at|date:'d/m/Y' %}desde el {{ date }}{% endblocktranslate %} + + +
  • + + + + + {% endfor %} +
+
+ {% endif %} + + + + + + {% else %} +
+ {% trans 'Desactivada' %} + {% trans 'Activar' %} +
+ {% endif %} +
diff --git a/crochet/templates/crochet/account_settings.html b/crochet/templates/crochet/account_settings.html index e1018e3..4028f75 100644 --- a/crochet/templates/crochet/account_settings.html +++ b/crochet/templates/crochet/account_settings.html @@ -57,6 +57,16 @@ {% include 'crochet/_account_password_form.html' %} + +
+
+

{% trans 'Verificación en dos pasos' %}

+

+ {% trans 'Pide un código de tu app de autenticación (Google Authenticator, Aegis...) además de la contraseña al iniciar sesión.' %} +

+ {% include 'crochet/_account_2fa_status.html' %} +
+