diff --git a/README.md b/README.md index 09aecc88..6dde2ca3 100644 --- a/README.md +++ b/README.md @@ -454,6 +454,8 @@ An LDAP user is authenticated by LDAP and authorized through the WebVirtCloud pe Set `OTP_ENABLED = True` in `settings.py` to ask for a one-time code at login. It is per user: a user without a confirmed device signs in with the password alone. +The QR code is mailed as a link to a page that shows it, good for one hour: the mail does not hold the code's secret. Email OTP QR code on a user's page (Users) sends it; Lost OTP? on the login page sends it to an enrolled user who gives their address. + ### Serial console A VM without graphics (no `` device, a ``) opens a serial console (xterm.js) instead of VNC. It is off by default. To turn it on, set `WEBVIRTCLOUD_SERIAL_CONSOLE=1` (Docker: in `docker-compose.override.yml`, then `docker compose up -d`) or `SERIAL_CONSOLE_ENABLED = True` in `settings.py` and `systemctl restart webvirt-cloud webvirt-socketiod`. diff --git a/accounts/templates/accounts/email/otp.html b/accounts/templates/accounts/email/otp.html deleted file mode 100644 index c41a01b4..00000000 --- a/accounts/templates/accounts/email/otp.html +++ /dev/null @@ -1,8 +0,0 @@ -{% load i18n %} -{% blocktrans %} -Scan this QR code to get OTP for account '{{ user }}' -{% endblocktrans %} -
-{{ qr_svg }} -

{% trans 'Some e-mail clients does not render SVG, also generating PNG.' %}

-{{ totp_url }} diff --git a/accounts/templates/accounts/otp_qr.html b/accounts/templates/accounts/otp_qr.html new file mode 100644 index 00000000..82697f70 --- /dev/null +++ b/accounts/templates/accounts/otp_qr.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{% trans "OTP QR Code" %}{% endblock %} + +{% block page_heading %}{% trans "OTP QR Code" %}{% endblock page_heading %} + +{% block content %} +
+
+

{% blocktrans %}Scan this QR code to get OTP for account '{{ user }}'{% endblocktrans %}

+ {{ qr_svg }} +
+
+{% endblock content %} diff --git a/accounts/tests/test_utils.py b/accounts/tests/test_utils.py index ec85580d..1c436aa5 100644 --- a/accounts/tests/test_utils.py +++ b/accounts/tests/test_utils.py @@ -2,17 +2,19 @@ import base64 import hashlib import re import struct -import zlib +import time from types import SimpleNamespace from unittest.mock import patch from django.contrib.auth import get_user_model from django.core import mail -from django.test import TestCase +from django.test import RequestFactory, TestCase, override_settings +from django.urls import reverse from django_otp.plugins.otp_totp.models import TOTPDevice from lxml import html -from accounts.utils import (get_existing_totp_device, get_user_totp_device, send_email_with_otp, validate_ssh_key) +from accounts.utils import (OTP_LINK, OTP_LINK_MAX_AGE, get_existing_totp_device, get_user_totp_device, otp_qr_svg, + send_email_with_otp, validate_ssh_key) class AccountsUtilsTestCase(TestCase): @@ -80,32 +82,30 @@ class AccountsUtilsTestCase(TestCase): device = TOTPDevice.objects.create(user=self.user, confirmed=True) self.assertEqual(get_existing_totp_device(self.user).id, device.id) - def test_send_email_with_otp(self): + @override_settings(OTP_ENABLED=True) + def test_the_mail_links_to_the_qr_code_and_holds_no_secret(self): device = get_user_totp_device(self.user) - send_email_with_otp(self.user, device) + send_email_with_otp(RequestFactory().get("/"), self.user) self.assertEqual(len(mail.outbox), 1) - self.assertIn("OTP QR Code", mail.outbox[0].subject) - self.assertEqual(mail.outbox[0].to, ["user@example.com"]) - # the QR code as inline SVG and, for clients without SVG, as a PNG data URI - doc = html.fromstring(mail.outbox[0].alternatives[0][0]) - self.assertEqual(len(doc.xpath("//svg")), 1) - (img,) = doc.xpath("//img") - self.assertEqual(img.get("alt"), device.config_url) - self.assertTrue(base64.b64decode(img.get("src").removeprefix("data:image/png;base64,")).startswith(b"\x89PNG")) + message = mail.outbox[0] + self.assertEqual((message.subject, message.to, message.alternatives), ("OTP QR Code", ["user@example.com"], [])) + self.assertNotIn(device.bin_key.hex(), message.body) + self.assertNotIn("otpauth", message.body) + link = re.search(r"http://testserver(/\S+)", message.body).group(1) + page = self.client.get(link) + self.assertEqual(page["Cache-Control"], "no-store") + self.assertEqual(len(html.fromstring(page.content).xpath("//svg")), 1) - def test_otp_email_qr_codes_are_what_django_qr_code_drew(self): - # sha256 of the SVG and the PNG django-qr-code 4.2.0 made for this URL + @override_settings(OTP_ENABLED=True) + def test_a_link_that_is_old_or_not_signed_shows_nothing(self): + get_user_totp_device(self.user) + token = OTP_LINK.sign(str(self.user.pk)) + with patch("django.core.signing.time.time", return_value=time.time() + OTP_LINK_MAX_AGE + 1): + self.assertEqual(self.client.get(reverse("accounts:otp_qr", args=[token])).status_code, 404) + self.assertEqual(self.client.get(reverse("accounts:otp_qr", args=[f"{self.user.pk}:x:y"])).status_code, 404) + + def test_the_qr_code_is_what_django_qr_code_drew(self): + # sha256 of the SVG django-qr-code 4.2.0 made for this URL url = "otpauth://totp/WebVirtCloud:alice?secret=JBSWY3DPEHPK3PXP&issuer=WebVirtCloud" - with patch("accounts.utils.send_mail") as send: - send_email_with_otp(self.user, SimpleNamespace(config_url=url)) - page = send.call_args.kwargs["html_message"] - svg = re.search(r"", page, re.S).group(0) - png = base64.b64decode(re.search(r'I4s", png[pos:pos + 8]) - chunks[kind] = chunks.get(kind, b"") + png[pos + 8:pos + 8 + size] - pos += 12 + size - self.assertEqual(hashlib.sha256(chunks[b"IHDR"] + zlib.decompress(chunks[b"IDAT"])).hexdigest(), "cca16de611c73468827f70001f69dc2e48633e6aae2b570a9391eb40a1f8ae88") diff --git a/accounts/urls.py b/accounts/urls.py index 2056dd9a..1179d066 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -38,6 +38,7 @@ urlpatterns = [ path("api_token/created/", views.api_token_created, name="api_token_created"), path("api_token//delete/", views.api_token_delete, name="api_token_delete"), path("email_otp/", views.email_otp, name="email_otp"), + path("otp_qr//", views.otp_qr, name="otp_qr"), path( "admin_email_otp//", views.admin_email_otp, diff --git a/accounts/utils.py b/accounts/utils.py index a2c1605c..80bdd9e2 100644 --- a/accounts/utils.py +++ b/accounts/utils.py @@ -5,7 +5,8 @@ from decimal import Decimal import segno from django.core.mail import send_mail -from django.template.loader import render_to_string +from django.core.signing import TimestampSigner +from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ from django_otp import devices_for_user @@ -56,24 +57,26 @@ def validate_ssh_key(key): return True -def send_email_with_otp(user, device): - # An inline SVG (1.8 mm modules) and, for mail clients that show no SVG, - # a PNG (12 px modules); error correction M, 4-module border. - qr = segno.make(device.config_url, error="m", mode="byte", encoding="utf-8", micro=False) - style = {"border": 4, "dark": "black", "light": "white"} +# the link a mail carries to its user's OTP QR code: the mail holds no secret +OTP_LINK = TimestampSigner(salt="accounts.otp_qr") +OTP_LINK_MAX_AGE = 3600 + + +def send_email_with_otp(request, user): + """Mail the user a link to the page of their OTP QR code, good for + OTP_LINK_MAX_AGE: the QR code's secret is never in a mailbox.""" + url = request.build_absolute_uri(reverse("accounts:otp_qr", args=[OTP_LINK.sign(str(user.pk))])) send_mail( _("OTP QR Code"), - _("Please view HTML version of this message."), + _("Open this link within an hour to see the OTP QR code of account '%(user)s':\n\n%(url)s") + % {"user": user, "url": url}, None, [user.email], - html_message=render_to_string( - "accounts/email/otp.html", - { - "totp_url": device.config_url, - "user": user, - "qr_svg": mark_safe(qr.svg_inline(scale=Decimal("1.8"), unit="mm", **style)), - "qr_png": qr.png_data_uri(scale=12, **style), - }, - ), fail_silently=False, ) + + +def otp_qr_svg(device): + """The device's QR code as inline SVG: 1.8 mm modules, error correction M, a 4-module border.""" + qr = segno.make(device.config_url, error="m", mode="byte", encoding="utf-8", micro=False) + return mark_safe(qr.svg_inline(scale=Decimal("1.8"), unit="mm", border=4, dark="black", light="white")) diff --git a/accounts/views.py b/accounts/views.py index f85b1b52..02be62b7 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -9,6 +9,7 @@ from django.contrib.auth import get_user_model, update_session_auth_hash from django.contrib.auth.decorators import login_not_required, login_required from django.contrib.auth.forms import PasswordChangeForm from django.core.exceptions import PermissionDenied +from django.core.signing import BadSignature from django.db import transaction from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -28,7 +29,7 @@ from accounts.forms import ApiTokenForm, EmailOTPForm, ProfileForm, UserSSHKeyFo from accounts.models import ApiToken, User, UserAttributes, UserInstance, UserSSHKey from . import forms -from .utils import get_existing_totp_device, get_user_totp_device, send_email_with_otp +from .utils import OTP_LINK, OTP_LINK_MAX_AGE, get_existing_totp_device, get_user_totp_device, otp_qr_svg, send_email_with_otp def profile(request): @@ -301,7 +302,7 @@ EMAIL_OTP_INTERVAL = 300 @login_not_required def email_otp(request): """ - "Lost OTP?": mail the OTP QR code to the user with this address. Open to + "Lost OTP?": mail a link to the OTP QR code to the user with this address. Open to anyone, so it mails only one exact, active match, at most once per EMAIL_OTP_INTERVAL, and answers the same in every case. """ @@ -318,7 +319,7 @@ def email_otp(request): device = get_existing_totp_device(user) if device is not None: try: - send_email_with_otp(user, device) + send_email_with_otp(request, user) except Exception: logging.getLogger(__name__).exception("Could not mail the OTP QR code to user %s", user.pk) @@ -337,6 +338,24 @@ def email_otp(request): ) +@login_not_required +def otp_qr(request, token): + """The OTP QR code of the user a mailed link (send_email_with_otp) names.""" + if not settings.OTP_ENABLED: + raise Http404 + try: + user_id = OTP_LINK.unsign(token, max_age=OTP_LINK_MAX_AGE) + except BadSignature: + raise Http404 + user = get_object_or_404(get_user_model(), pk=user_id, is_active=True) + device = get_existing_totp_device(user) + if device is None: + raise Http404 + response = render(request, "accounts/otp_qr.html", {"user": user, "qr_svg": otp_qr_svg(device)}) + response["Cache-Control"] = "no-store" + return response + + @require_POST @superuser_only def admin_email_otp(request, user_id): @@ -345,7 +364,8 @@ def admin_email_otp(request, user_id): if user.email != "": try: with audit(request, None, subject, "mail OTP QR code"): - send_email_with_otp(user, get_user_totp_device(user)) + get_user_totp_device(user) + send_email_with_otp(request, user) except OSError as err: # the SMTP server refused it, or did not answer within EMAIL_TIMEOUT messages.error(request, _("The OTP QR code could not be mailed to %(user)s: %(error)s") % { "user": user, "error": err}) diff --git a/locale/tr/LC_MESSAGES/django.mo b/locale/tr/LC_MESSAGES/django.mo index 729d6c96..1a43720d 100644 Binary files a/locale/tr/LC_MESSAGES/django.mo and b/locale/tr/LC_MESSAGES/django.mo differ diff --git a/locale/tr/LC_MESSAGES/django.po b/locale/tr/LC_MESSAGES/django.po index 74b8d35e..a23e7b7d 100644 --- a/locale/tr/LC_MESSAGES/django.po +++ b/locale/tr/LC_MESSAGES/django.po @@ -11,7 +11,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-05 13:37+0000\n" +"POT-Creation-Date: 2026-11-05 14:27+0000\n" "PO-Revision-Date: 2020-11-04 12:00+0000\n" "Last-Translator: catborise , 2020\n" "Language-Team: Turkish (https://www.transifex.com/catborise/teams/110663/tr/)\n" @@ -314,19 +314,6 @@ msgstr "İptal" msgid "Change password" msgstr "Parolayı değiştir" -#: accounts/templates/accounts/email/otp.html:2 -#, python-format -msgid "" -"\n" -"Scan this QR code to get OTP for account '%(user)s'\n" -msgstr "" -"\n" -"'%(user)s' hesabı için bu OTP QR kodunu tarayın\n" - -#: accounts/templates/accounts/email/otp.html:7 -msgid "Some e-mail clients does not render SVG, also generating PNG." -msgstr "Bazı e-posta istemcileri SVG göstermez, PNG de oluşturuluyor." - #: accounts/templates/accounts/email_otp_form.html:12 msgid "" "\n" @@ -408,6 +395,16 @@ msgstr "Doğrula ve oturum aç" msgid "2FA Protection Active" msgstr "2FA Koruması Etkin" +#: accounts/templates/accounts/otp_qr.html:4 +#: accounts/templates/accounts/otp_qr.html:6 accounts/utils.py:70 +msgid "OTP QR Code" +msgstr "OTP QR Kodu" + +#: accounts/templates/accounts/otp_qr.html:11 +#, python-format +msgid "Scan this QR code to get OTP for account '%(user)s'" +msgstr "'%(user)s' hesabının OTP kodlarını almak için bu QR kodunu tarayın" + #: accounts/templates/api_token_created.html:4 #: accounts/templates/api_token_created.html:5 msgid "API token created" @@ -526,7 +523,7 @@ msgid "Add" msgstr "Ekle" #: accounts/templates/profile.html:64 accounts/templates/profile.html:70 -#: accounts/views.py:69 accounts/views.py:73 +#: accounts/views.py:70 accounts/views.py:74 msgid "Create an API token" msgstr "API anahtarı oluştur" @@ -556,97 +553,100 @@ msgstr "%(name)s SSH anahtarı için işlemler" msgid "No SSH keys." msgstr "SSH anahtarı yok." -#: accounts/utils.py:65 -msgid "OTP QR Code" -msgstr "OTP QR Kodu" +#: accounts/utils.py:71 +#, python-format +msgid "" +"Open this link within an hour to see the OTP QR code of account '%(user)s':\n" +"\n" +"%(url)s" +msgstr "" +"'%(user)s' hesabının OTP QR kodunu görmek için bu bağlantıyı bir saat içinde açın:\n" +"\n" +"%(url)s" -#: accounts/utils.py:66 -msgid "Please view HTML version of this message." -msgstr "Lütfen bu mesajın HTML sürümünü görüntüleyin." - -#: accounts/views.py:46 +#: accounts/views.py:47 msgid "Profile updated" msgstr "Profil güncellendi" -#: accounts/views.py:72 +#: accounts/views.py:73 #, python-format msgid "A user has at most %(limit)s API tokens: delete one first." msgstr "" "Bir kullanıcının en fazla %(limit)s API anahtarı olabilir: önce birini silin." -#: accounts/views.py:107 +#: accounts/views.py:108 msgid "API token deleted" msgstr "API anahtarı silindi" -#: accounts/views.py:115 +#: accounts/views.py:116 msgid "Delete API token" msgstr "API anahtarını sil" -#: accounts/views.py:116 +#: accounts/views.py:117 msgid "Scripts and tools that use it can no longer reach the API." msgstr "Onu kullanan betikler ve araçlar artık API'ye erişemez." -#: accounts/views.py:126 +#: accounts/views.py:127 msgid "SSH key added" msgstr "SSH anahtarı eklendi" -#: accounts/views.py:134 +#: accounts/views.py:135 msgid "Add SSH key" msgstr "SSH Anahtarı Ekle" -#: accounts/views.py:155 +#: accounts/views.py:156 msgid "SSH key deleted" msgstr "SSH anahtarı silindi" -#: accounts/views.py:163 +#: accounts/views.py:164 msgid "Delete SSH key" msgstr "SSH anahtarı sil" -#: accounts/views.py:164 +#: accounts/views.py:165 msgid "" "VMs that already have it keep it; VMs created from now on do not get it." msgstr "" "Ona zaten sahip olan sanal makinelerde kalır; bundan sonra oluşturulan sanal " "makinelere eklenmez." -#: accounts/views.py:212 +#: accounts/views.py:213 msgid "Password Changed" msgstr "Parola Değişti" -#: accounts/views.py:241 +#: accounts/views.py:242 msgid "Create User Instance" msgstr "Yeni Kullanıcı Sanal Makinesi Oluştur" -#: accounts/views.py:275 +#: accounts/views.py:276 msgid "Update User Instance" msgstr "Kullanıcı Sanal Makinesini Güncelle" -#: accounts/views.py:293 +#: accounts/views.py:294 msgid "The user loses access to this VM; the VM itself is not changed." msgstr "" "Kullanıcı bu sanal makineye erişimini kaybeder; sanal makinenin kendisi " "değişmez." -#: accounts/views.py:326 +#: accounts/views.py:327 #, python-format msgid "OTP Sent to %(email)s" msgstr "OTP %(email)s ye gönderildi" -#: accounts/views.py:335 +#: accounts/views.py:336 msgid "Email OTP" msgstr "OTP E-Postası" -#: accounts/views.py:350 +#: accounts/views.py:370 #, python-format msgid "The OTP QR code could not be mailed to %(user)s: %(error)s" msgstr "OTP QR kodu %(user)s kullanıcısına e-postayla gönderilemedi: %(error)s" -#: accounts/views.py:354 +#: accounts/views.py:374 #, python-format msgid "OTP QR code was emailed to user %(user)s" msgstr "OTP QR kodu %(user)s kullanıcısına gönderildi" -#: accounts/views.py:358 +#: accounts/views.py:378 msgid "User email not set, failed to send QR code" msgstr "Kullanıcı e-postası girilmemiş, QR kod gönderimi başarısız" @@ -2699,57 +2699,57 @@ msgstr "" msgid "The name of the virtual machine must not contain any special characters" msgstr "Sanal makine adı özel karakterler içeremez" -#: instances/jobs.py:68 +#: instances/jobs.py:69 msgid "The VM moved to another compute after the job was queued" msgstr "İş kuyruğa alındıktan sonra sanal makine başka bir sunucuya taşındı" -#: instances/jobs.py:136 +#: instances/jobs.py:137 msgid "Enter a whole number of vCPUs for the clone" msgstr "Klon için vCPU sayısını tam sayı olarak girin" -#: instances/jobs.py:138 +#: instances/jobs.py:139 msgid "Enter a whole number of memory (MiB) for the clone" msgstr "Klon için belleği (MiB) tam sayı olarak girin" -#: instances/jobs.py:165 +#: instances/jobs.py:166 msgid "Choose the clone's name from the list of free names" msgstr "Klonun adını boş adlar listesinden seçin" -#: instances/jobs.py:193 +#: instances/jobs.py:194 #, python-format msgid "%(quota)s: cannot create '%(clone_name)s'." msgstr "%(quota)s: '%(clone_name)s' oluşturulamıyor." -#: instances/jobs.py:196 instances/jobs.py:545 +#: instances/jobs.py:197 instances/jobs.py:546 #, python-format msgid "Instance '%(clone_name)s' already exists!" msgstr "'%(clone_name)s' sanal makinesi zaten mevcut!" -#: instances/jobs.py:198 +#: instances/jobs.py:199 #, python-format msgid "Instance name '%(clone_name)s' contains invalid characters!" msgstr "Sanal makine adı '%(clone_name)s' geçersiz karakterler içermektedir!" -#: instances/jobs.py:201 +#: instances/jobs.py:202 #, python-format msgid "Instance MAC '%(clone_mac)s' invalid format!" msgstr "Sanal makine MAC adresi '%(clone_mac)s' geçersiz biçemde belirtilmiş!" -#: instances/jobs.py:203 +#: instances/jobs.py:204 #, python-format msgid "Disk name '%(disk_name)s' contains invalid characters!" msgstr "Disk adı '%(disk_name)s' geçersiz karakterler içeriyor!" -#: instances/jobs.py:223 +#: instances/jobs.py:224 #, python-format msgid "The clone is made, but not as asked: %(error)s" msgstr "Klon oluşturuldu, ama istendiği gibi değil: %(error)s" -#: instances/jobs.py:279 +#: instances/jobs.py:280 msgid "The VM was not defined and no volume of it was left." msgstr "Sanal makine tanımlanmadı ve ondan geriye birim kalmadı." -#: instances/jobs.py:281 +#: instances/jobs.py:282 #, python-format msgid "" "The VM was not defined; check these volumes, then resolve this job: " @@ -2758,28 +2758,28 @@ msgstr "" "Sanal makine tanımlanmadı; şu birimleri kontrol edin, sonra bu işi çözün: " "%(volumes)s" -#: instances/jobs.py:302 +#: instances/jobs.py:303 msgid "Delete snapshot" msgstr "Anlık görüntüyü sil" -#: instances/jobs.py:312 vrtManager/instance.py:2286 +#: instances/jobs.py:313 vrtManager/instance.py:2286 #, python-format msgid "The VM has no external snapshot named %(name)s" msgstr "Sanal makinede %(name)s adında harici anlık görüntü yok" -#: instances/jobs.py:325 +#: instances/jobs.py:326 msgid "Merging the snapshot" msgstr "Anlık görüntü birleştiriliyor" -#: instances/jobs.py:344 +#: instances/jobs.py:345 msgid "The VM is no longer on its compute" msgstr "Sanal makine artık kendi sunucusunda değil" -#: instances/jobs.py:347 +#: instances/jobs.py:348 msgid "libvirt is still merging the snapshot" msgstr "libvirt anlık görüntüyü hâlâ birleştiriyor" -#: instances/jobs.py:355 +#: instances/jobs.py:356 msgid "" "The snapshot is still listed and no merge runs. It cannot be told whether the" " disks changed; check the VM before deleting the snapshot again or reverting " @@ -2789,44 +2789,44 @@ msgstr "" "değişmediği bilinemiyor; anlık görüntüyü yeniden silmeden ya da ona dönmeden " "önce sanal makineyi kontrol edin." -#: instances/jobs.py:362 +#: instances/jobs.py:363 msgid "The VM is no longer on the compute the job ran on" msgstr "Sanal makine artık işin çalıştığı sunucuda değil" -#: instances/jobs.py:368 +#: instances/jobs.py:369 msgid "Take snapshot" msgstr "Anlık görüntü al" -#: instances/jobs.py:380 vrtManager/instance.py:1882 +#: instances/jobs.py:381 vrtManager/instance.py:1882 #, python-format msgid "A snapshot named %(name)s already exists" msgstr "%(name)s adında bir anlık görüntü zaten var" -#: instances/jobs.py:392 +#: instances/jobs.py:393 msgid "Taking the snapshot" msgstr "Anlık görüntü alınıyor" -#: instances/jobs.py:405 +#: instances/jobs.py:406 msgid "libvirt is still taking the snapshot" msgstr "libvirt anlık görüntüyü hâlâ alıyor" -#: instances/jobs.py:408 +#: instances/jobs.py:409 msgid "The snapshot was not taken" msgstr "Anlık görüntü alınmadı" -#: instances/jobs.py:431 +#: instances/jobs.py:432 msgid "Revert snapshot" msgstr "Snapshota dön" -#: instances/jobs.py:462 +#: instances/jobs.py:463 msgid "Reverting the VM" msgstr "Sanal makine geri döndürülüyor" -#: instances/jobs.py:473 +#: instances/jobs.py:474 msgid "libvirt is still reverting the VM" msgstr "libvirt sanal makineyi hâlâ geri döndürüyor" -#: instances/jobs.py:477 +#: instances/jobs.py:478 msgid "" "It cannot be told whether the VM was reverted; check it, then revert again or" " resolve this job." @@ -2834,12 +2834,12 @@ msgstr "" "Sanal makinenin geri döndürülüp döndürülmediği bilinemiyor; kontrol edin, " "sonra yeniden geri döndürün ya da bu işi çözün." -#: instances/jobs.py:494 +#: instances/jobs.py:495 msgid "The VM must be running, paused or shut off to be cloned" msgstr "" "Klonlanmak için sanal makine çalışıyor, duraklatılmış ya da kapalı olmalı" -#: instances/jobs.py:495 +#: instances/jobs.py:496 #, python-format msgid "" "The guest's file systems could not be thawed (%(error)s); thaw them with: " @@ -2848,19 +2848,19 @@ msgstr "" "Konuğun dosya sistemleri çözülemedi (%(error)s); şununla çözün: virsh " "domfsthaw %(vm)s" -#: instances/jobs.py:514 +#: instances/jobs.py:515 msgid "Clone VM" msgstr "Sanal makineyi klonla" -#: instances/jobs.py:555 instances/jobs.py:562 instances/jobs.py:830 +#: instances/jobs.py:556 instances/jobs.py:563 instances/jobs.py:831 msgid "Copying disks" msgstr "Diskler kopyalanıyor" -#: instances/jobs.py:614 +#: instances/jobs.py:615 msgid "libvirt has no result of the copy" msgstr "libvirt'te kopyanın sonucu yok" -#: instances/jobs.py:625 +#: instances/jobs.py:626 #, python-format msgid "" "The copy failed: %(error)s. The guest's file systems could not be thawed " @@ -2869,11 +2869,11 @@ msgstr "" "Kopyalama başarısız oldu: %(error)s. Konuğun dosya sistemleri çözülemedi " "(%(thaw)s); şununla çözün: virsh domfsthaw %(vm)s" -#: instances/jobs.py:628 instances/jobs.py:712 +#: instances/jobs.py:629 instances/jobs.py:713 msgid "The copy failed" msgstr "Kopyalama başarısız oldu" -#: instances/jobs.py:653 +#: instances/jobs.py:654 #, python-format msgid "" "The VM is no longer on the compute the job ran on; check these volumes, then " @@ -2882,7 +2882,7 @@ msgstr "" "Sanal makine artık işin çalıştığı sunucuda değil; şu birimleri kontrol edin, " "sonra bu işi çözün: %(volumes)s" -#: instances/jobs.py:688 +#: instances/jobs.py:689 #, python-format msgid "" "A volume of the clone was being created; check these volumes, then resolve " @@ -2891,15 +2891,15 @@ msgstr "" "Klonun bir birimi oluşturuluyordu; şu birimleri kontrol edin, sonra bu işi " "çözün: %(volumes)s" -#: instances/jobs.py:692 +#: instances/jobs.py:693 msgid "The job stopped before the copy started" msgstr "İş, kopyalama başlamadan durdu" -#: instances/jobs.py:699 +#: instances/jobs.py:700 msgid "libvirt is still copying the disks" msgstr "libvirt diskleri hâlâ kopyalıyor" -#: instances/jobs.py:702 +#: instances/jobs.py:703 #, python-format msgid "" "Another backup runs on the VM; check these volumes, then resolve this job: " @@ -2908,7 +2908,7 @@ msgstr "" "Sanal makinede başka bir yedekleme çalışıyor; şu birimleri kontrol edin, " "sonra bu işi çözün: %(volumes)s" -#: instances/jobs.py:707 +#: instances/jobs.py:708 #, python-format msgid "" "libvirt has no result of the copy; check these volumes, then resolve this " @@ -2917,140 +2917,140 @@ msgstr "" "libvirt'te kopyanın sonucu yok; şu birimleri kontrol edin, sonra bu işi " "çözün: %(volumes)s" -#: instances/jobs.py:735 instances/templates/create_instance_w1.html:60 +#: instances/jobs.py:736 instances/templates/create_instance_w1.html:60 #: instances/templates/create_instance_w2.html:356 msgid "Create VM" msgstr "Sanal makine oluştur" -#: instances/jobs.py:759 instances/jobs.py:804 instances/views.py:2491 +#: instances/jobs.py:760 instances/jobs.py:805 instances/views.py:2491 msgid "A virtual machine with this name already exists" msgstr "Bu isimde bir sanal makine mevcut" -#: instances/jobs.py:761 instances/jobs.py:801 +#: instances/jobs.py:762 instances/jobs.py:802 msgid "There is an instance with same name. Remove it and try again!" msgstr "Aynı isimde başka bir sanal makine var. Onu silip yeniden deneyin!" -#: instances/jobs.py:764 +#: instances/jobs.py:765 msgid "First you need to create or select an image" msgstr "Öncelikle bir imaj oluşturun ya da mevcut bir imajı seçin" -#: instances/jobs.py:766 +#: instances/jobs.py:767 msgid "Choose one disk source: a new disk, existing volumes or a template" msgstr "" "Tek bir disk kaynağı seçin: yeni bir disk, mevcut birimler ya da bir şablon" -#: instances/jobs.py:768 +#: instances/jobs.py:769 msgid "Image has already exist. Please check volumes or change instance name" msgstr "" "İmaj zaten mevcut. Lütfen ya makine adını değişin ya da disk alanlarını " "kontrol edin" -#: instances/jobs.py:770 +#: instances/jobs.py:771 msgid "Invalid cache mode" msgstr "Geçersiz önbellek modu" -#: instances/jobs.py:772 +#: instances/jobs.py:773 #, python-format msgid "This host offers no CPU mode or model %(mode)s" msgstr "Bu sunucu %(mode)s CPU modunu veya modelini sunmuyor" -#: instances/jobs.py:774 +#: instances/jobs.py:775 #, python-format msgid "A CD-ROM cannot use the %(bus)s bus" msgstr "CD-ROM %(bus)s veri yolunu kullanamaz" -#: instances/jobs.py:777 +#: instances/jobs.py:778 msgid "The cloud-init seed is read from a CD-ROM: choose its bus" msgstr "cloud-init tohumu bir CD-ROM'dan okunur: veri yolunu seçin" -#: instances/jobs.py:780 +#: instances/jobs.py:781 msgid "The cloud-init seed needs a directory storage pool for the disk" msgstr "cloud-init tohumu, disk için bir dizin depolama havuzu gerektirir" -#: instances/jobs.py:786 +#: instances/jobs.py:787 #, python-format msgid "This host offers no firmware %(firmware)s" msgstr "Bu sunucu %(firmware)s firmware'ini sunmuyor" -#: instances/jobs.py:788 vrtManager/instance.py:3010 +#: instances/jobs.py:789 vrtManager/instance.py:3010 msgid "This host offers no TPM 2.0: install swtpm on it" msgstr "Bu sunucu TPM 2.0 sunmuyor: üzerine swtpm kurun" -#: instances/jobs.py:849 +#: instances/jobs.py:850 msgid "Writing the cloud-init seed" msgstr "cloud-init tohumu yazılıyor" -#: instances/jobs.py:856 +#: instances/jobs.py:857 msgid "Defining the VM" msgstr "Sanal makine tanımlanıyor" -#: instances/jobs.py:889 +#: instances/jobs.py:890 #, python-format msgid "%(error)s. Remove these volumes by hand: %(volumes)s" msgstr "%(error)s. Şu birimleri elle silin: %(volumes)s" -#: instances/jobs.py:914 +#: instances/jobs.py:915 #, python-format msgid "The instance is already on %(compute)s" msgstr "Sanal makine zaten %(compute)s üzerinde" -#: instances/jobs.py:916 +#: instances/jobs.py:917 #, python-format msgid "%(compute)s is not reachable" msgstr "%(compute)s sunucusuna ulaşılamıyor" -#: instances/jobs.py:918 +#: instances/jobs.py:919 #, python-format msgid "%(compute)s is the VM's own host under another name" msgstr "%(compute)s, sanal makinenin kendi sunucusunun başka bir adı" -#: instances/jobs.py:936 +#: instances/jobs.py:937 msgid "Migrate VM" msgstr "Sanal makineyi taşı" -#: instances/jobs.py:980 +#: instances/jobs.py:981 msgid "Migrating the VM" msgstr "Sanal makine taşınıyor" -#: instances/jobs.py:1027 +#: instances/jobs.py:1028 msgid "libvirt is still migrating the VM" msgstr "libvirt sanal makineyi hâlâ taşıyor" -#: instances/jobs.py:1034 +#: instances/jobs.py:1035 msgid "The VM stayed on the source host" msgstr "Sanal makine kaynak sunucuda kaldı" -#: instances/jobs.py:1036 +#: instances/jobs.py:1037 msgid "" "The VM is on both computes or on neither; check them, then resolve this job." msgstr "" "Sanal makine ya iki sunucuda da var ya da hiçbirinde yok; kontrol edin, sonra" " bu işi çözün." -#: instances/jobs.py:1068 +#: instances/jobs.py:1085 msgid "Move disk" msgstr "Diski taşı" -#: instances/jobs.py:1075 +#: instances/jobs.py:1092 msgid "Choose a disk of the VM" msgstr "Sanal makinenin bir diskini seçin" -#: instances/jobs.py:1099 +#: instances/jobs.py:1116 msgid "Copying the disk" msgstr "Disk kopyalanıyor" -#: instances/jobs.py:1142 +#: instances/jobs.py:1159 #, python-format msgid "Another block job runs on %(dev)s; check the VM" msgstr "" "%(dev)s üzerinde başka bir blok işi çalışıyor; sanal makineyi kontrol edin" -#: instances/jobs.py:1148 +#: instances/jobs.py:1165 #, python-format msgid "The disk stayed in %(path)s; remove %(dest)s if it is there" msgstr "Disk %(path)s içinde kaldı; varsa %(dest)s dosyasını silin" -#: instances/jobs.py:1150 +#: instances/jobs.py:1167 #, python-format msgid "The definitions of %(dev)s name %(paths)s; check the VM" msgstr "%(dev)s tanımları %(paths)s gösteriyor; sanal makineyi kontrol edin" diff --git a/locale/zh/LC_MESSAGES/django.mo b/locale/zh/LC_MESSAGES/django.mo index d624f42e..4c3b25c2 100644 Binary files a/locale/zh/LC_MESSAGES/django.mo and b/locale/zh/LC_MESSAGES/django.mo differ diff --git a/locale/zh/LC_MESSAGES/django.po b/locale/zh/LC_MESSAGES/django.po index 52a908ee..6306120b 100644 --- a/locale/zh/LC_MESSAGES/django.po +++ b/locale/zh/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-05 13:37+0000\n" +"POT-Creation-Date: 2026-11-05 14:27+0000\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -311,19 +311,6 @@ msgstr "取消" msgid "Change password" msgstr "修改密码" -#: accounts/templates/accounts/email/otp.html:2 -#, python-format -msgid "" -"\n" -"Scan this QR code to get OTP for account '%(user)s'\n" -msgstr "" -"\n" -"扫描此二维码获取账户“%(user)s”的 OTP\n" - -#: accounts/templates/accounts/email/otp.html:7 -msgid "Some e-mail clients does not render SVG, also generating PNG." -msgstr "部分邮件客户端无法显示 SVG,因此同时生成了 PNG。" - #: accounts/templates/accounts/email_otp_form.html:12 msgid "" "\n" @@ -405,6 +392,16 @@ msgstr "验证并登录" msgid "2FA Protection Active" msgstr "双因素保护已启用" +#: accounts/templates/accounts/otp_qr.html:4 +#: accounts/templates/accounts/otp_qr.html:6 accounts/utils.py:70 +msgid "OTP QR Code" +msgstr "OTP 二维码" + +#: accounts/templates/accounts/otp_qr.html:11 +#, python-format +msgid "Scan this QR code to get OTP for account '%(user)s'" +msgstr "扫描此二维码以获取账户“%(user)s”的 OTP" + #: accounts/templates/api_token_created.html:4 #: accounts/templates/api_token_created.html:5 msgid "API token created" @@ -520,7 +517,7 @@ msgid "Add" msgstr "添加" #: accounts/templates/profile.html:64 accounts/templates/profile.html:70 -#: accounts/views.py:69 accounts/views.py:73 +#: accounts/views.py:70 accounts/views.py:74 msgid "Create an API token" msgstr "创建 API 令牌" @@ -548,92 +545,95 @@ msgstr "SSH 密钥 %(name)s 的操作" msgid "No SSH keys." msgstr "没有 SSH 密钥。" -#: accounts/utils.py:65 -msgid "OTP QR Code" -msgstr "OTP 二维码" +#: accounts/utils.py:71 +#, python-format +msgid "" +"Open this link within an hour to see the OTP QR code of account '%(user)s':\n" +"\n" +"%(url)s" +msgstr "" +"请在一小时内打开此链接,查看账户“%(user)s”的 OTP 二维码:\n" +"\n" +"%(url)s" -#: accounts/utils.py:66 -msgid "Please view HTML version of this message." -msgstr "请查看此邮件的 HTML 版本。" - -#: accounts/views.py:46 +#: accounts/views.py:47 msgid "Profile updated" msgstr "资料已更新" -#: accounts/views.py:72 +#: accounts/views.py:73 #, python-format msgid "A user has at most %(limit)s API tokens: delete one first." msgstr "每个用户最多有 %(limit)s 个 API 令牌:请先删除一个。" -#: accounts/views.py:107 +#: accounts/views.py:108 msgid "API token deleted" msgstr "API 令牌已删除" -#: accounts/views.py:115 +#: accounts/views.py:116 msgid "Delete API token" msgstr "删除 API 令牌" -#: accounts/views.py:116 +#: accounts/views.py:117 msgid "Scripts and tools that use it can no longer reach the API." msgstr "使用它的脚本和工具将无法再访问 API。" -#: accounts/views.py:126 +#: accounts/views.py:127 msgid "SSH key added" msgstr "SSH 密钥已添加" -#: accounts/views.py:134 +#: accounts/views.py:135 msgid "Add SSH key" msgstr "添加 SSH 密钥" -#: accounts/views.py:155 +#: accounts/views.py:156 msgid "SSH key deleted" msgstr "SSH 密钥已删除" -#: accounts/views.py:163 +#: accounts/views.py:164 msgid "Delete SSH key" msgstr "删除 SSH 密钥" -#: accounts/views.py:164 +#: accounts/views.py:165 msgid "" "VMs that already have it keep it; VMs created from now on do not get it." msgstr "已有此密钥的虚拟机会保留它;此后创建的虚拟机不会再获得它。" -#: accounts/views.py:212 +#: accounts/views.py:213 msgid "Password Changed" msgstr "密码已修改" -#: accounts/views.py:241 +#: accounts/views.py:242 msgid "Create User Instance" msgstr "创建用户虚拟机" -#: accounts/views.py:275 +#: accounts/views.py:276 msgid "Update User Instance" msgstr "更新用户虚拟机" -#: accounts/views.py:293 +#: accounts/views.py:294 msgid "The user loses access to this VM; the VM itself is not changed." msgstr "该用户将失去对此虚拟机的访问权限;虚拟机本身不受影响。" -#: accounts/views.py:326 +#: accounts/views.py:327 #, python-format msgid "OTP Sent to %(email)s" msgstr "OTP 已发送至 %(email)s" -#: accounts/views.py:335 +#: accounts/views.py:336 msgid "Email OTP" msgstr "邮件发送 OTP" -#: accounts/views.py:350 +#: accounts/views.py:370 #, python-format msgid "The OTP QR code could not be mailed to %(user)s: %(error)s" msgstr "无法将 OTP 二维码通过邮件发送给 %(user)s:%(error)s" -#: accounts/views.py:354 +#: accounts/views.py:374 #, python-format msgid "OTP QR code was emailed to user %(user)s" msgstr "OTP 二维码已通过邮件发送给用户 %(user)s" -#: accounts/views.py:358 +#: accounts/views.py:378 msgid "User email not set, failed to send QR code" msgstr "未设置用户邮箱,无法发送二维码" @@ -2604,332 +2604,332 @@ msgstr "控制台监听的不只是 localhost;只有管理员可以移除其 msgid "The name of the virtual machine must not contain any special characters" msgstr "虚拟机名称不能包含任何特殊字符" -#: instances/jobs.py:68 +#: instances/jobs.py:69 msgid "The VM moved to another compute after the job was queued" msgstr "任务排队后,虚拟机已移到另一个计算节点" -#: instances/jobs.py:136 +#: instances/jobs.py:137 msgid "Enter a whole number of vCPUs for the clone" msgstr "请为克隆输入整数个 vCPU" -#: instances/jobs.py:138 +#: instances/jobs.py:139 msgid "Enter a whole number of memory (MiB) for the clone" msgstr "请为克隆输入整数的内存(MiB)" -#: instances/jobs.py:165 +#: instances/jobs.py:166 msgid "Choose the clone's name from the list of free names" msgstr "请从可用名称列表中选择克隆的名称" -#: instances/jobs.py:193 +#: instances/jobs.py:194 #, python-format msgid "%(quota)s: cannot create '%(clone_name)s'." msgstr "%(quota)s:无法创建“%(clone_name)s”。" -#: instances/jobs.py:196 instances/jobs.py:545 +#: instances/jobs.py:197 instances/jobs.py:546 #, python-format msgid "Instance '%(clone_name)s' already exists!" msgstr "虚拟机 '%(clone_name)s' 已存在!" -#: instances/jobs.py:198 +#: instances/jobs.py:199 #, python-format msgid "Instance name '%(clone_name)s' contains invalid characters!" msgstr "虚拟机名称 '%(clone_name)s' 包含无效字符!" -#: instances/jobs.py:201 +#: instances/jobs.py:202 #, python-format msgid "Instance MAC '%(clone_mac)s' invalid format!" msgstr "虚拟机 MAC '%(clone_mac)s' 格式无效!" -#: instances/jobs.py:203 +#: instances/jobs.py:204 #, python-format msgid "Disk name '%(disk_name)s' contains invalid characters!" msgstr "磁盘名称 '%(disk_name)s' 包含无效字符!" -#: instances/jobs.py:223 +#: instances/jobs.py:224 #, python-format msgid "The clone is made, but not as asked: %(error)s" msgstr "克隆已创建,但与要求不符:%(error)s" -#: instances/jobs.py:279 +#: instances/jobs.py:280 msgid "The VM was not defined and no volume of it was left." msgstr "虚拟机未定义,也没有留下它的卷。" -#: instances/jobs.py:281 +#: instances/jobs.py:282 #, python-format msgid "" "The VM was not defined; check these volumes, then resolve this job: " "%(volumes)s" msgstr "虚拟机未定义;请检查这些卷,然后处理此任务:%(volumes)s" -#: instances/jobs.py:302 +#: instances/jobs.py:303 msgid "Delete snapshot" msgstr "删除快照" -#: instances/jobs.py:312 vrtManager/instance.py:2286 +#: instances/jobs.py:313 vrtManager/instance.py:2286 #, python-format msgid "The VM has no external snapshot named %(name)s" msgstr "虚拟机没有名为 %(name)s 的外部快照" -#: instances/jobs.py:325 +#: instances/jobs.py:326 msgid "Merging the snapshot" msgstr "正在合并快照" -#: instances/jobs.py:344 +#: instances/jobs.py:345 msgid "The VM is no longer on its compute" msgstr "虚拟机已不在其计算节点上" -#: instances/jobs.py:347 +#: instances/jobs.py:348 msgid "libvirt is still merging the snapshot" msgstr "libvirt 仍在合并快照" -#: instances/jobs.py:355 +#: instances/jobs.py:356 msgid "" "The snapshot is still listed and no merge runs. It cannot be told whether the" " disks changed; check the VM before deleting the snapshot again or reverting " "to it." msgstr "快照仍在列表中,且没有正在运行的合并。无法判断磁盘是否已更改;再次删除该快照或恢复到它之前,请检查虚拟机。" -#: instances/jobs.py:362 +#: instances/jobs.py:363 msgid "The VM is no longer on the compute the job ran on" msgstr "虚拟机已不在运行该任务的计算节点上" -#: instances/jobs.py:368 +#: instances/jobs.py:369 msgid "Take snapshot" msgstr "创建快照" -#: instances/jobs.py:380 vrtManager/instance.py:1882 +#: instances/jobs.py:381 vrtManager/instance.py:1882 #, python-format msgid "A snapshot named %(name)s already exists" msgstr "名为 %(name)s 的快照已存在" -#: instances/jobs.py:392 +#: instances/jobs.py:393 msgid "Taking the snapshot" msgstr "正在创建快照" -#: instances/jobs.py:405 +#: instances/jobs.py:406 msgid "libvirt is still taking the snapshot" msgstr "libvirt 仍在创建快照" -#: instances/jobs.py:408 +#: instances/jobs.py:409 msgid "The snapshot was not taken" msgstr "未创建快照" -#: instances/jobs.py:431 +#: instances/jobs.py:432 msgid "Revert snapshot" msgstr "恢复快照" -#: instances/jobs.py:462 +#: instances/jobs.py:463 msgid "Reverting the VM" msgstr "正在恢复虚拟机" -#: instances/jobs.py:473 +#: instances/jobs.py:474 msgid "libvirt is still reverting the VM" msgstr "libvirt 仍在恢复虚拟机" -#: instances/jobs.py:477 +#: instances/jobs.py:478 msgid "" "It cannot be told whether the VM was reverted; check it, then revert again or" " resolve this job." msgstr "无法判断虚拟机是否已恢复;请检查后再次恢复,或处理此任务。" -#: instances/jobs.py:494 +#: instances/jobs.py:495 msgid "The VM must be running, paused or shut off to be cloned" msgstr "虚拟机必须处于运行、暂停或关机状态才能克隆" -#: instances/jobs.py:495 +#: instances/jobs.py:496 #, python-format msgid "" "The guest's file systems could not be thawed (%(error)s); thaw them with: " "virsh domfsthaw %(vm)s" msgstr "无法解冻客户机的文件系统(%(error)s);请用以下命令解冻:virsh domfsthaw %(vm)s" -#: instances/jobs.py:514 +#: instances/jobs.py:515 msgid "Clone VM" msgstr "克隆虚拟机" -#: instances/jobs.py:555 instances/jobs.py:562 instances/jobs.py:830 +#: instances/jobs.py:556 instances/jobs.py:563 instances/jobs.py:831 msgid "Copying disks" msgstr "正在复制磁盘" -#: instances/jobs.py:614 +#: instances/jobs.py:615 msgid "libvirt has no result of the copy" msgstr "libvirt 没有复制的结果" -#: instances/jobs.py:625 +#: instances/jobs.py:626 #, python-format msgid "" "The copy failed: %(error)s. The guest's file systems could not be thawed " "(%(thaw)s); thaw them with: virsh domfsthaw %(vm)s" msgstr "复制失败:%(error)s。无法解冻客户机的文件系统(%(thaw)s);请用以下命令解冻:virsh domfsthaw %(vm)s" -#: instances/jobs.py:628 instances/jobs.py:712 +#: instances/jobs.py:629 instances/jobs.py:713 msgid "The copy failed" msgstr "复制失败" -#: instances/jobs.py:653 +#: instances/jobs.py:654 #, python-format msgid "" "The VM is no longer on the compute the job ran on; check these volumes, then " "resolve this job: %(volumes)s" msgstr "虚拟机已不在运行该任务的计算节点上;请检查这些卷,然后处理此任务:%(volumes)s" -#: instances/jobs.py:688 +#: instances/jobs.py:689 #, python-format msgid "" "A volume of the clone was being created; check these volumes, then resolve " "this job: %(volumes)s" msgstr "克隆的一个卷正在创建中;请检查这些卷,然后处理此任务:%(volumes)s" -#: instances/jobs.py:692 +#: instances/jobs.py:693 msgid "The job stopped before the copy started" msgstr "任务在复制开始前停止" -#: instances/jobs.py:699 +#: instances/jobs.py:700 msgid "libvirt is still copying the disks" msgstr "libvirt 仍在复制磁盘" -#: instances/jobs.py:702 +#: instances/jobs.py:703 #, python-format msgid "" "Another backup runs on the VM; check these volumes, then resolve this job: " "%(volumes)s" msgstr "虚拟机上正在运行另一个备份;请检查这些卷,然后处理此任务:%(volumes)s" -#: instances/jobs.py:707 +#: instances/jobs.py:708 #, python-format msgid "" "libvirt has no result of the copy; check these volumes, then resolve this " "job: %(volumes)s" msgstr "libvirt 没有复制的结果;请检查这些卷,然后处理此任务:%(volumes)s" -#: instances/jobs.py:735 instances/templates/create_instance_w1.html:60 +#: instances/jobs.py:736 instances/templates/create_instance_w1.html:60 #: instances/templates/create_instance_w2.html:356 msgid "Create VM" msgstr "创建虚拟机" -#: instances/jobs.py:759 instances/jobs.py:804 instances/views.py:2491 +#: instances/jobs.py:760 instances/jobs.py:805 instances/views.py:2491 msgid "A virtual machine with this name already exists" msgstr "同名虚拟机已存在" -#: instances/jobs.py:761 instances/jobs.py:801 +#: instances/jobs.py:762 instances/jobs.py:802 msgid "There is an instance with same name. Remove it and try again!" msgstr "已存在同名虚拟机。请删除后重试!" -#: instances/jobs.py:764 +#: instances/jobs.py:765 msgid "First you need to create or select an image" msgstr "请先创建或选择一个镜像" -#: instances/jobs.py:766 +#: instances/jobs.py:767 msgid "Choose one disk source: a new disk, existing volumes or a template" msgstr "请选择一种磁盘来源:新磁盘、现有卷或模板" -#: instances/jobs.py:768 +#: instances/jobs.py:769 msgid "Image has already exist. Please check volumes or change instance name" msgstr "镜像已存在。请检查卷或更改虚拟机名称" -#: instances/jobs.py:770 +#: instances/jobs.py:771 msgid "Invalid cache mode" msgstr "无效的缓存模式" -#: instances/jobs.py:772 +#: instances/jobs.py:773 #, python-format msgid "This host offers no CPU mode or model %(mode)s" msgstr "此主机不提供 CPU 模式或型号 %(mode)s" -#: instances/jobs.py:774 +#: instances/jobs.py:775 #, python-format msgid "A CD-ROM cannot use the %(bus)s bus" msgstr "CD-ROM 不能使用 %(bus)s 总线" -#: instances/jobs.py:777 +#: instances/jobs.py:778 msgid "The cloud-init seed is read from a CD-ROM: choose its bus" msgstr "cloud-init 种子从 CD-ROM 读取:请选择其总线" -#: instances/jobs.py:780 +#: instances/jobs.py:781 msgid "The cloud-init seed needs a directory storage pool for the disk" msgstr "cloud-init 种子需要为磁盘使用目录类型的存储池" -#: instances/jobs.py:786 +#: instances/jobs.py:787 #, python-format msgid "This host offers no firmware %(firmware)s" msgstr "此主机不提供固件 %(firmware)s" -#: instances/jobs.py:788 vrtManager/instance.py:3010 +#: instances/jobs.py:789 vrtManager/instance.py:3010 msgid "This host offers no TPM 2.0: install swtpm on it" msgstr "此主机不提供 TPM 2.0:请在主机上安装 swtpm" -#: instances/jobs.py:849 +#: instances/jobs.py:850 msgid "Writing the cloud-init seed" msgstr "正在写入 cloud-init 种子" -#: instances/jobs.py:856 +#: instances/jobs.py:857 msgid "Defining the VM" msgstr "正在定义虚拟机" -#: instances/jobs.py:889 +#: instances/jobs.py:890 #, python-format msgid "%(error)s. Remove these volumes by hand: %(volumes)s" msgstr "%(error)s。请手动删除这些卷:%(volumes)s" -#: instances/jobs.py:914 +#: instances/jobs.py:915 #, python-format msgid "The instance is already on %(compute)s" msgstr "虚拟机已在 %(compute)s 上" -#: instances/jobs.py:916 +#: instances/jobs.py:917 #, python-format msgid "%(compute)s is not reachable" msgstr "无法连接 %(compute)s" -#: instances/jobs.py:918 +#: instances/jobs.py:919 #, python-format msgid "%(compute)s is the VM's own host under another name" msgstr "%(compute)s 是该虚拟机所在主机的另一个名称" -#: instances/jobs.py:936 +#: instances/jobs.py:937 msgid "Migrate VM" msgstr "迁移虚拟机" -#: instances/jobs.py:980 +#: instances/jobs.py:981 msgid "Migrating the VM" msgstr "正在迁移虚拟机" -#: instances/jobs.py:1027 +#: instances/jobs.py:1028 msgid "libvirt is still migrating the VM" msgstr "libvirt 仍在迁移虚拟机" -#: instances/jobs.py:1034 +#: instances/jobs.py:1035 msgid "The VM stayed on the source host" msgstr "虚拟机留在了源主机上" -#: instances/jobs.py:1036 +#: instances/jobs.py:1037 msgid "" "The VM is on both computes or on neither; check them, then resolve this job." msgstr "虚拟机同时位于两个计算节点上,或都不在;请检查后处理此任务。" -#: instances/jobs.py:1068 +#: instances/jobs.py:1085 msgid "Move disk" msgstr "移动磁盘" -#: instances/jobs.py:1075 +#: instances/jobs.py:1092 msgid "Choose a disk of the VM" msgstr "请选择虚拟机的一个磁盘" -#: instances/jobs.py:1099 +#: instances/jobs.py:1116 msgid "Copying the disk" msgstr "正在复制磁盘" -#: instances/jobs.py:1142 +#: instances/jobs.py:1159 #, python-format msgid "Another block job runs on %(dev)s; check the VM" msgstr "%(dev)s 上正在运行另一个块任务;请检查虚拟机" -#: instances/jobs.py:1148 +#: instances/jobs.py:1165 #, python-format msgid "The disk stayed in %(path)s; remove %(dest)s if it is there" msgstr "磁盘仍在 %(path)s;如果 %(dest)s 存在,请将其删除" -#: instances/jobs.py:1150 +#: instances/jobs.py:1167 #, python-format msgid "The definitions of %(dev)s name %(paths)s; check the VM" msgstr "%(dev)s 的定义指向 %(paths)s;请检查虚拟机" diff --git a/webvirtcloud/tests/test_authorization_matrix.py b/webvirtcloud/tests/test_authorization_matrix.py index f9aa1f60..f93f892c 100644 --- a/webvirtcloud/tests/test_authorization_matrix.py +++ b/webvirtcloud/tests/test_authorization_matrix.py @@ -35,6 +35,7 @@ ALLOWED_FOR_ANY_USER = { "instances:states": "the VM list's poll: only the user's own instances", "accounts:login": "login page", "accounts:email_otp": "part of the login flow (its own issues are tracked separately)", + "accounts:otp_qr": "the OTP QR code a mailed link opens: only with that signed, hour-old-at-most link", "accounts:logout": "logout", "accounts:profile": "the user's own profile", "accounts:change_password": "the user's own password; new users get the permission by default", @@ -64,6 +65,7 @@ ALLOWED_FOR_ANY_USER = { ALLOWED_FOR_ANONYMOUS = { "accounts:login": "login page", "accounts:email_otp": "part of the login flow (its own issues are tracked separately)", + "accounts:otp_qr": "the OTP QR code a mailed link opens: only with that signed, hour-old-at-most link", "ds_openstack_index": "cloud-init datasource, fetched by VMs that do not log in", "ds_openstack_metadata": "cloud-init datasource, fetched by VMs that do not log in", "ds_openstack_userdata": "cloud-init datasource, fetched by VMs that do not log in",