From c401d6411b3f494d4202b6e06ba2b0682ab84444 Mon Sep 17 00:00:00 2001 From: Martin Brennan <mjrbrennan@gmail.com> Date: Fri, 3 Sep 2021 13:04:24 +1000 Subject: [PATCH] A11Y: Improve create account modal for screen readers (#14234) Improves the create account modal for screen readers by doing the following: * Making the `modal-alert` section into an `aria-role="alert"` region and making it show and hide using height instead of display:none so screen readers pick it up. Made a change so the field-related error messages are always shown beneath the field. * Add `aria-invalid` and `aria-describedby` attributes to each field in the modal, so the screen reader will read out the error hint on error. This necessitated an Ember component extension to allow both the `aria-*` attributes to be bound and to render on `{{input}}`. * Moved the social login buttons to the right in the HTML structure so they are not read out first. * Added `aria-label` attributes to the login buttons so they can have different content for screen readers. * In some cases for modals, the title that should be used for the `aria-labelledby` attribute is within the modal content and not the discourse-modal-title title. This introduces a new titleAriaElementId property to the d-modal component that is then used by the create-account modal to read out the title ------ This is the same as e0d2de73d89cdea13e9681b2daaa52074ee510a5 but fixes the Ember-input-component-extension to use the public Ember components TextField and TextArea instead of the private TextSupport so the extension works in both normal Ember and Ember CLI. --- .../discourse/app/components/d-modal-body.js | 25 ++++-- .../discourse/app/components/d-modal.js | 18 ++-- .../app/controllers/create-account.js | 30 ++++--- .../discourse/app/controllers/login.js | 5 +- .../ember-input-component-extension.js | 15 ++++ .../discourse/app/lib/show-modal.js | 4 + .../discourse/app/mixins/name-validation.js | 8 +- .../app/mixins/password-validation.js | 10 ++- .../app/mixins/username-validation.js | 8 +- .../discourse/app/models/login-method.js | 5 ++ .../discourse/app/routes/application.js | 13 ++- .../app/templates/components/d-modal.hbs | 2 +- .../templates/components/login-buttons.hbs | 2 +- .../discourse/app/templates/modal.hbs | 1 + .../app/templates/modal/create-account.hbs | 82 +++++++++++++------ .../create-account-user-fields-test.js | 8 +- app/assets/stylesheets/common/base/login.scss | 5 +- config/locales/client.en.yml | 6 ++ 18 files changed, 178 insertions(+), 69 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/initializers/ember-input-component-extension.js diff --git a/app/assets/javascripts/discourse/app/components/d-modal-body.js b/app/assets/javascripts/discourse/app/components/d-modal-body.js index 2ea7d11324d..1e46c4442a8 100644 --- a/app/assets/javascripts/discourse/app/components/d-modal-body.js +++ b/app/assets/javascripts/discourse/app/components/d-modal-body.js @@ -8,7 +8,10 @@ export default Component.extend({ didInsertElement() { this._super(...arguments); - $("#modal-alert").hide(); + this._modalAlertElement = document.getElementById("modal-alert"); + if (this._modalAlertElement) { + this._modalAlertElement.innerHTML = ""; + } let fixedParent = $(this.element).closest(".d-modal.fixed-modal"); if (fixedParent.length) { @@ -55,10 +58,10 @@ export default Component.extend({ }, _clearFlash() { - const modalAlert = document.getElementById("modal-alert"); - if (modalAlert) { - modalAlert.style.display = "none"; - modalAlert.classList.remove( + if (this._modalAlertElement) { + this._modalAlertElement.innerHTML = ""; + this._modalAlertElement.classList.remove( + "alert", "alert-error", "alert-info", "alert-success", @@ -69,10 +72,14 @@ export default Component.extend({ _flash(msg) { this._clearFlash(); + if (!this._modalAlertElement) { + return; + } - $("#modal-alert") - .addClass(`alert alert-${msg.messageClass || "success"}`) - .html(msg.text || "") - .fadeIn(); + this._modalAlertElement.classList.add( + "alert", + `alert-${msg.messageClass || "success"}` + ); + this._modalAlertElement.innerHTML = msg.text || ""; }, }); diff --git a/app/assets/javascripts/discourse/app/components/d-modal.js b/app/assets/javascripts/discourse/app/components/d-modal.js index a08ec543e95..941a0ca556e 100644 --- a/app/assets/javascripts/discourse/app/components/d-modal.js +++ b/app/assets/javascripts/discourse/app/components/d-modal.js @@ -1,8 +1,7 @@ -import { computed } from "@ember/object"; import Component from "@ember/component"; import I18n from "I18n"; import { next, schedule } from "@ember/runloop"; -import { bind, on } from "discourse-common/utils/decorators"; +import discourseComputed, { bind, on } from "discourse-common/utils/decorators"; export default Component.extend({ classNameBindings: [ @@ -21,6 +20,7 @@ export default Component.extend({ submitOnEnter: true, dismissable: true, title: null, + titleAriaElementId: null, subtitle: null, role: "dialog", headerClass: null, @@ -41,9 +41,17 @@ export default Component.extend({ // Inform screenreaders of the modal "aria-modal": "true", - ariaLabelledby: computed("title", function () { - return this.title ? "discourse-modal-title" : null; - }), + @discourseComputed("title", "titleAriaElementId") + ariaLabelledby(title, titleAriaElementId) { + if (titleAriaElementId) { + return titleAriaElementId; + } + if (title) { + return "discourse-modal-title"; + } + + return; + }, @on("didInsertElement") setUp() { diff --git a/app/assets/javascripts/discourse/app/controllers/create-account.js b/app/assets/javascripts/discourse/app/controllers/create-account.js index 52a3f7230a7..b58957450dc 100644 --- a/app/assets/javascripts/discourse/app/controllers/create-account.js +++ b/app/assets/javascripts/discourse/app/controllers/create-account.js @@ -140,16 +140,19 @@ export default Controller.extend( "serverAccountEmail", "serverEmailValidation", "accountEmail", - "rejectedEmails.[]" + "rejectedEmails.[]", + "forceValidationReason" ) emailValidation( serverAccountEmail, serverEmailValidation, email, - rejectedEmails + rejectedEmails, + forceValidationReason ) { const failedAttrs = { failed: true, + ok: false, element: document.querySelector("#new-account-email"), }; @@ -162,6 +165,9 @@ export default Controller.extend( return EmberObject.create( Object.assign(failedAttrs, { message: I18n.t("user.email.required"), + reason: forceValidationReason + ? I18n.t("user.email.required") + : null, }) ); } @@ -426,6 +432,7 @@ export default Controller.extend( createAccount() { this.clearFlash(); + this.set("forceValidationReason", true); const validation = [ this.emailValidation, this.usernameValidation, @@ -435,23 +442,22 @@ export default Controller.extend( ].find((v) => v.failed); if (validation) { - if (validation.message) { - this.flash(validation.message, "error"); - } - const element = validation.element; - if (element.tagName === "DIV") { - if (element.scrollIntoView) { - element.scrollIntoView(); + if (element) { + if (element.tagName === "DIV") { + if (element.scrollIntoView) { + element.scrollIntoView(); + } + element.click(); + } else { + element.focus(); } - element.click(); - } else { - element.focus(); } return; } + this.set("forceValidationReason", false); this.performAccountCreation(); }, }, diff --git a/app/assets/javascripts/discourse/app/controllers/login.js b/app/assets/javascripts/discourse/app/controllers/login.js index fe6b32b757a..a3a8934e522 100644 --- a/app/assets/javascripts/discourse/app/controllers/login.js +++ b/app/assets/javascripts/discourse/app/controllers/login.js @@ -428,7 +428,10 @@ export default Controller.extend(ModalFunctionality, { }); next(() => { - showModal("createAccount", { modalClass: "create-account" }); + showModal("createAccount", { + modalClass: "create-account", + titleAriaElementId: "create-account-title", + }); }); }, }); diff --git a/app/assets/javascripts/discourse/app/initializers/ember-input-component-extension.js b/app/assets/javascripts/discourse/app/initializers/ember-input-component-extension.js new file mode 100644 index 00000000000..902f7958c62 --- /dev/null +++ b/app/assets/javascripts/discourse/app/initializers/ember-input-component-extension.js @@ -0,0 +1,15 @@ +import TextField from "@ember/component/text-field"; +import TextArea from "@ember/component/text-area"; + +export default { + name: "ember-input-component-extensions", + + initialize() { + TextField.reopen({ + attributeBindings: ["aria-describedby", "aria-invalid"], + }); + TextArea.reopen({ + attributeBindings: ["aria-describedby", "aria-invalid"], + }); + }, +}; diff --git a/app/assets/javascripts/discourse/app/lib/show-modal.js b/app/assets/javascripts/discourse/app/lib/show-modal.js index db3067fbcf5..dde3094b315 100644 --- a/app/assets/javascripts/discourse/app/lib/show-modal.js +++ b/app/assets/javascripts/discourse/app/lib/show-modal.js @@ -47,6 +47,10 @@ export default function (name, opts) { modalController.set("title", null); } + if (opts.titleAriaElementId) { + modalController.set("titleAriaElementId", opts.titleAriaElementId); + } + if (opts.panels) { modalController.setProperties({ panels: opts.panels, diff --git a/app/assets/javascripts/discourse/app/mixins/name-validation.js b/app/assets/javascripts/discourse/app/mixins/name-validation.js index 52623f537e3..65287c97325 100644 --- a/app/assets/javascripts/discourse/app/mixins/name-validation.js +++ b/app/assets/javascripts/discourse/app/mixins/name-validation.js @@ -15,12 +15,14 @@ export default Mixin.create({ }, // Validate the name. - @discourseComputed("accountName") - nameValidation() { - if (this.siteSettings.full_name_required && isEmpty(this.accountName)) { + @discourseComputed("accountName", "forceValidationReason") + nameValidation(accountName, forceValidationReason) { + if (this.siteSettings.full_name_required && isEmpty(accountName)) { return EmberObject.create({ failed: true, + ok: false, message: I18n.t("user.name.required"), + reason: forceValidationReason ? I18n.t("user.name.required") : null, element: document.querySelector("#new-account-name"), }); } diff --git a/app/assets/javascripts/discourse/app/mixins/password-validation.js b/app/assets/javascripts/discourse/app/mixins/password-validation.js index feb020cbf7a..eea9b942de3 100644 --- a/app/assets/javascripts/discourse/app/mixins/password-validation.js +++ b/app/assets/javascripts/discourse/app/mixins/password-validation.js @@ -33,7 +33,8 @@ export default Mixin.create({ "rejectedPasswords.[]", "accountUsername", "accountEmail", - "passwordMinLength" + "passwordMinLength", + "forceValidationReason" ) passwordValidation( password, @@ -41,10 +42,12 @@ export default Mixin.create({ rejectedPasswords, accountUsername, accountEmail, - passwordMinLength + passwordMinLength, + forceValidationReason ) { const failedAttrs = { failed: true, + ok: false, element: document.querySelector("#new-account-password"), }; @@ -67,6 +70,9 @@ export default Mixin.create({ return EmberObject.create( Object.assign(failedAttrs, { message: I18n.t("user.password.required"), + reason: forceValidationReason + ? I18n.t("user.password.required") + : null, }) ); } diff --git a/app/assets/javascripts/discourse/app/mixins/username-validation.js b/app/assets/javascripts/discourse/app/mixins/username-validation.js index b8f14bc818d..522b40ad5f0 100644 --- a/app/assets/javascripts/discourse/app/mixins/username-validation.js +++ b/app/assets/javascripts/discourse/app/mixins/username-validation.js @@ -11,6 +11,7 @@ function failedResult(attrs) { let result = EmberObject.create({ shouldCheck: false, failed: true, + ok: false, element: document.querySelector("#new-account-username"), }); result.setProperties(attrs); @@ -60,7 +61,12 @@ export default Mixin.create({ } if (isEmpty(username)) { - return failedResult({ message: I18n.t("user.username.required") }); + return failedResult({ + message: I18n.t("user.username.required"), + reason: this.forceValidationReason + ? I18n.t("user.username.required") + : null, + }); } if (username.length < this.siteSettings.min_username_length) { diff --git a/app/assets/javascripts/discourse/app/models/login-method.js b/app/assets/javascripts/discourse/app/models/login-method.js index efe7c438bc8..bebb069b93d 100644 --- a/app/assets/javascripts/discourse/app/models/login-method.js +++ b/app/assets/javascripts/discourse/app/models/login-method.js @@ -13,6 +13,11 @@ const LoginMethod = EmberObject.extend({ return this.title_override || I18n.t(`login.${this.name}.title`); }, + @discourseComputed + screenReaderTitle() { + return this.title_override || I18n.t(`login.${this.name}.sr_title`); + }, + @discourseComputed prettyName() { return this.pretty_name_override || I18n.t(`login.${this.name}.name`); diff --git a/app/assets/javascripts/discourse/app/routes/application.js b/app/assets/javascripts/discourse/app/routes/application.js index 4f9558c292f..a283404cf58 100644 --- a/app/assets/javascripts/discourse/app/routes/application.js +++ b/app/assets/javascripts/discourse/app/routes/application.js @@ -267,11 +267,18 @@ const ApplicationRoute = DiscourseRoute.extend(OpenComposer, { const returnPath = encodeURIComponent(window.location.pathname); window.location = getURL("/session/sso?return_path=" + returnPath); } else { - this._autoLogin("createAccount", "create-account", { signup: true }); + this._autoLogin("createAccount", "create-account", { + signup: true, + titleAriaElementId: "create-account-title", + }); } }, - _autoLogin(modal, modalClass, { notAuto = null, signup = false } = {}) { + _autoLogin( + modal, + modalClass, + { notAuto = null, signup = false, titleAriaElementId = null } = {} + ) { const methods = findAll(); if (!this.siteSettings.enable_local_logins && methods.length === 1) { @@ -279,7 +286,7 @@ const ApplicationRoute = DiscourseRoute.extend(OpenComposer, { signup: signup, }); } else { - showModal(modal); + showModal(modal, { titleAriaElementId }); this.controllerFor("modal").set("modalClass", modalClass); if (notAuto) { notAuto(); diff --git a/app/assets/javascripts/discourse/app/templates/components/d-modal.hbs b/app/assets/javascripts/discourse/app/templates/components/d-modal.hbs index 7067f487ef8..a0da06d06a7 100644 --- a/app/assets/javascripts/discourse/app/templates/components/d-modal.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/d-modal.hbs @@ -30,7 +30,7 @@ {{/if}} </div> - <div id="modal-alert"></div> + <div id="modal-alert" role="alert"></div> {{yield}} diff --git a/app/assets/javascripts/discourse/app/templates/components/login-buttons.hbs b/app/assets/javascripts/discourse/app/templates/components/login-buttons.hbs index bc2bc0aba98..c4e553f5b1d 100644 --- a/app/assets/javascripts/discourse/app/templates/components/login-buttons.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/login-buttons.hbs @@ -1,5 +1,5 @@ {{#each buttons as |b|}} - <button type="button" class="btn btn-social {{b.name}}" {{action externalLogin b}}> + <button type="button" class="btn btn-social {{b.name}}" {{action externalLogin b}} aria-label={{b.screenReaderTitle}}> {{#if b.isGoogle}} <svg class="fa d-icon d-icon-custom-google-oauth2 svg-icon" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"/><path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"/><path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"/><path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"/></svg> {{else if b.icon}} diff --git a/app/assets/javascripts/discourse/app/templates/modal.hbs b/app/assets/javascripts/discourse/app/templates/modal.hbs index d266b5fc1f0..44b6f315125 100644 --- a/app/assets/javascripts/discourse/app/templates/modal.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal.hbs @@ -1,6 +1,7 @@ {{#d-modal modalClass=modalClass title=title + titleAriaElementId=titleAriaElementId subtitle=subtitle panels=panels selectedPanel=selectedPanel diff --git a/app/assets/javascripts/discourse/app/templates/modal/create-account.hbs b/app/assets/javascripts/discourse/app/templates/modal/create-account.hbs index c33f956d23f..f8688d623be 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/create-account.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/create-account.hbs @@ -1,17 +1,12 @@ {{#create-account email=accountEmail disabled=submitDisabled action=(action "createAccount")}} {{#unless complete}} {{plugin-outlet name="create-account-before-modal-body"}} - {{#d-modal-body class=modalBodyClasses}} + {{#d-modal-body class=modalBodyClasses preventModalAlertHiding=true}} <div class="create-account-form"> - <div class="login-welcome-header"> + <div class="login-welcome-header" id="create-account-title"> <h1 class="login-title">{{i18n "create_account.header_title"}}</h1> <img src={{wavingHandURL}} alt="" class="waving-hand"> <p class="login-subheader">{{i18n "create_account.subheader_title"}}</p> </div> - {{#unless hasAuthOptions}} - <div class="create-account-login-buttons"> - {{login-buttons externalLogin=(action "externalLogin")}} - </div> - {{/unless}} {{#if showCreateForm}} <div class="login-form"> @@ -22,7 +17,18 @@ </div> {{/if}} <div class="input-group create-account-email"> - {{input type="email" disabled=emailDisabled value=accountEmail id="new-account-email" name="email" class=(value-entered accountEmail) autofocus="autofocus" focusOut=(action "checkEmailAvailability")}} + {{input + type="email" + disabled=emailDisabled + value=accountEmail + id="new-account-email" + name="email" + class=(value-entered accountEmail) + autofocus="autofocus" + focusOut=(action "checkEmailAvailability") + aria-describedby="account-email-validation" + aria-invalid=emailValidation.failed + }} <label class="alt-placeholder" for="new-account-email"> {{i18n "user.email.title"}} {{~#if userFields~}} @@ -34,8 +40,17 @@ </div> <div class="input-group"> - {{input value=accountUsername disabled=usernameDisabled class=(value-entered accountUsername) id="new-account-username" name="username" maxlength=maxUsernameLength - autocomplete="discourse"}} + {{input + value=accountUsername + disabled=usernameDisabled + class=(value-entered accountUsername) + id="new-account-username" + name="username" + maxlength=maxUsernameLength + aria-describedby="username-validation" + aria-invalid=usernameValidation.failed + autocomplete="discourse" + }} <label class="alt-placeholder" for="new-account-username"> {{i18n "user.username.title"}} {{~#if userFields~}} @@ -49,7 +64,14 @@ <div class="input-group"> {{#if fullnameRequired}} - {{text-field disabled=nameDisabled value=accountName id="new-account-name" class=(value-entered accountName)}} + {{text-field + disabled=nameDisabled + value=accountName + id="new-account-name" + class=(value-entered accountName) + aria-describedby="fullname-validation" + aria-invalid=nameValidation.failed + }} <label class="alt-placeholder" for="new-account-name"> {{i18n "user.name.title"}} {{#if siteSettings.full_name_required}} @@ -59,26 +81,35 @@ {{/if}} </label> - {{input-tip validation=nameValidation}} + {{input-tip validation=nameValidation id="fullname-validation"}} <span class="more-info">{{nameInstructions}}</span> {{/if}} </div> {{plugin-outlet - name="create-account-before-password" - noTags=true - args=(hash - accountName=accountName - accountUsername=accountUsername - accountPassword=accountPassword - userFields=userFields - authOptions=authOptions - ) + name="create-account-before-password" + noTags=true + args=(hash + accountName=accountName + accountUsername=accountUsername + accountPassword=accountPassword + userFields=userFields + authOptions=authOptions + ) }} <div class="input-group"> {{#if passwordRequired}} - {{password-field value=accountPassword class=(value-entered accountPassword) type="password" id="new-account-password" autocomplete="current-password" capsLockOn=capsLockOn}} + {{password-field + value=accountPassword + class=(value-entered accountPassword) + type="password" + id="new-account-password" + autocomplete="current-password" + capsLockOn=capsLockOn + aria-describedby="password-validation" + aria-invalid=passwordValidation.failed + }} <label class="alt-placeholder" for="new-account-password"> {{i18n "user.password.title"}} {{~#if userFields~}} @@ -86,7 +117,7 @@ {{/if}} </label> - {{input-tip validation=passwordValidation}} + {{input-tip validation=passwordValidation id="password-validation"}} <span class="more-info">{{passwordInstructions}}</span> <div class="caps-lock-warning {{unless capsLockOn " hidden"}}"> {{d-icon "exclamation-triangle"}} {{i18n "login.caps_lock_warning"}} @@ -156,6 +187,11 @@ {{plugin-outlet name="create-account-after-modal-footer" tagName=""}} {{/if}} + {{#unless hasAuthOptions}} + <div class="create-account-login-buttons"> + {{login-buttons externalLogin=(action "externalLogin")}} + </div> + {{/unless}} {{#if skipConfirmation}} {{loading-spinner size="large"}} diff --git a/app/assets/javascripts/discourse/tests/acceptance/create-account-user-fields-test.js b/app/assets/javascripts/discourse/tests/acceptance/create-account-user-fields-test.js index 02adf36a8de..a5548ab424a 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/create-account-user-fields-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/create-account-user-fields-test.js @@ -2,7 +2,6 @@ import { acceptance, exists, query, - queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, visit } from "@ember/test-helpers"; import { test } from "qunit"; @@ -39,9 +38,8 @@ acceptance("Create Account - User Fields", function (needs) { assert.ok(exists(".user-field"), "it has at least one user field"); await click(".modal-footer .btn-primary"); - assert.ok(exists("#modal-alert"), "it shows the required field alert"); assert.equal( - queryAll("#modal-alert").text(), + query("#account-email-validation").innerText.trim(), "Please enter an email address" ); @@ -63,12 +61,8 @@ acceptance("Create Account - User Fields", function (needs) { ); await click(".modal-footer .btn-primary"); - assert.equal(query("#modal-alert").style.display, ""); - await fillIn(".user-field input[type=text]:nth-of-type(1)", "Barky"); await click(".user-field input[type=checkbox]"); - await click(".modal-footer .btn-primary"); - assert.equal(query("#modal-alert").style.display, "none"); }); }); diff --git a/app/assets/stylesheets/common/base/login.scss b/app/assets/stylesheets/common/base/login.scss index 16544563415..1fa25dedf8f 100644 --- a/app/assets/stylesheets/common/base/login.scss +++ b/app/assets/stylesheets/common/base/login.scss @@ -60,7 +60,10 @@ min-height: 35px; } #modal-alert:empty { - display: none; + min-height: 0px; + padding: 0px; + overflow: hidden; + display: inline; } .login-welcome-header { z-index: z("modal", "content"); diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 06ce6dc9f17..f531052cad7 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1897,21 +1897,27 @@ en: google_oauth2: name: "Google" title: "with Google" + sr_title: "Login with Google" twitter: name: "Twitter" title: "with Twitter" + sr_title: "Login with Twitter" instagram: name: "Instagram" title: "with Instagram" + sr_title: "Login with Instagram" facebook: name: "Facebook" title: "with Facebook" + sr_title: "Login with Facebook" github: name: "GitHub" title: "with GitHub" + sr_title: "Login with GitHub" discord: name: "Discord" title: "with Discord" + sr_title: "Login with Discord" second_factor_toggle: totp: "Use an authenticator app instead" backup_code: "Use a backup code instead"