Add a configuration option to make email optional for password registration

This commit is contained in:
Quentin Gliech
2025-10-07 17:13:05 +02:00
parent 138f1b1a42
commit 28e573b400
18 changed files with 482 additions and 97 deletions

View File

@@ -211,6 +211,7 @@ pub fn site_config_from_config(
password_login_enabled: password_config.enabled(),
password_registration_enabled: password_config.enabled()
&& account_config.password_registration_enabled,
password_registration_email_required: account_config.password_registration_email_required,
registration_token_required: account_config.registration_token_required,
email_change_allowed: account_config.email_change_allowed,
displayname_change_allowed: account_config.displayname_change_allowed,

View File

@@ -50,6 +50,13 @@ pub struct AccountConfig {
#[serde(default = "default_false", skip_serializing_if = "is_default_false")]
pub password_registration_enabled: bool,
/// Whether self-service password registrations require a valid email.
/// Defaults to `true`.
///
/// This has no effect if password registration is disabled.
#[serde(default = "default_true", skip_serializing_if = "is_default_true")]
pub password_registration_email_required: bool,
/// Whether users are allowed to change their passwords. Defaults to `true`.
///
/// This has no effect if password login is disabled.
@@ -89,6 +96,7 @@ impl Default for AccountConfig {
email_change_allowed: default_true(),
displayname_change_allowed: default_true(),
password_registration_enabled: default_false(),
password_registration_email_required: default_true(),
password_change_allowed: default_true(),
password_recovery_enabled: default_false(),
account_deactivation_allowed: default_true(),

View File

@@ -64,6 +64,9 @@ pub struct SiteConfig {
/// Whether password registration is enabled.
pub password_registration_enabled: bool,
/// Whether a valid email address is required for password registrations.
pub password_registration_email_required: bool,
/// Whether registration tokens are required for password registrations.
pub registration_token_required: bool,

View File

@@ -22,6 +22,9 @@ pub struct SiteConfig {
/// Whether password registration is enabled.
pub password_registration_enabled: bool,
/// Whether a valid email address is required for password registrations.
pub password_registration_email_required: bool,
/// Whether registration tokens are required for password registrations.
pub registration_token_required: bool,
@@ -59,6 +62,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation {
server_name: "example.com".to_owned(),
password_login_enabled: true,
password_registration_enabled: true,
password_registration_email_required: true,
registration_token_required: true,
email_change_allowed: true,
displayname_change_allowed: true,
@@ -80,6 +84,7 @@ pub async fn handler(
server_name: site_config.server_name,
password_login_enabled: site_config.password_login_enabled,
password_registration_enabled: site_config.password_registration_enabled,
password_registration_email_required: site_config.password_registration_email_required,
registration_token_required: site_config.registration_token_required,
email_change_allowed: site_config.email_change_allowed,
displayname_change_allowed: site_config.displayname_change_allowed,

View File

@@ -140,6 +140,7 @@ pub fn test_site_config() -> SiteConfig {
email_change_allowed: true,
displayname_change_allowed: true,
password_change_allowed: true,
password_registration_email_required: true,
account_recovery_allowed: true,
account_deactivation_allowed: true,
captcha: None,

View File

@@ -45,6 +45,7 @@ use crate::{
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct RegisterForm {
username: String,
#[serde(default)]
email: String,
password: String,
password_confirm: String,
@@ -165,9 +166,16 @@ pub(crate) async fn post(
.await
.is_ok();
let state = form.to_form_state();
// The email form is only shown if the server requires it
let email = site_config
.password_registration_email_required
.then_some(form.email);
// Validate the form
let state = {
let mut state = form.to_form_state();
let mut state = state;
if !passed_captcha {
state.add_error_on_form(FormError::Captcha);
@@ -195,14 +203,16 @@ pub(crate) async fn post(
homeserver_denied_username = true;
}
if let Some(email) = &email {
// Note that we don't check here if the email is already taken here, as
// we don't want to leak the information about other users. Instead, we will
// show an error message once the user confirmed their email address.
if form.email.is_empty() {
if email.is_empty() {
state.add_error_on_field(RegisterFormField::Email, FieldError::Required);
} else if Address::from_str(&form.email).is_err() {
} else if Address::from_str(email).is_err() {
state.add_error_on_field(RegisterFormField::Email, FieldError::Invalid);
}
}
if form.password.is_empty() {
state.add_error_on_field(RegisterFormField::Password, FieldError::Required);
@@ -240,7 +250,7 @@ pub(crate) async fn post(
.evaluate_register(mas_policy::RegisterInput {
registration_method: mas_policy::RegistrationMethod::Password,
username: &form.username,
email: Some(&form.email),
email: email.as_deref(),
requester: mas_policy::Requester {
ip_address: activity_tracker.ip(),
user_agent: user_agent.clone(),
@@ -295,7 +305,9 @@ pub(crate) async fn post(
state.add_error_on_form(FormError::RateLimitExceeded);
}
if let Err(e) = limiter.check_email_authentication_email(requester, &form.email) {
if let Some(email) = &email
&& let Err(e) = limiter.check_email_authentication_email(requester, email)
{
tracing::warn!(error = &e as &dyn std::error::Error);
state.add_error_on_form(FormError::RateLimitExceeded);
}
@@ -343,10 +355,11 @@ pub(crate) async fn post(
registration
};
let registration = if let Some(email) = email {
// Create a new user email authentication session
let user_email_authentication = repo
.user_email()
.add_authentication_for_registration(&mut rng, &clock, form.email, &registration)
.add_authentication_for_registration(&mut rng, &clock, email, &registration)
.await?;
// Schedule a job to verify the email
@@ -358,10 +371,12 @@ pub(crate) async fn post(
)
.await?;
let registration = repo
.user_registration()
repo.user_registration()
.set_email_authentication(registration, &user_email_authentication)
.await?;
.await?
} else {
registration
};
// Hash the password
let password = Zeroizing::new(form.password);
@@ -713,4 +728,319 @@ mod tests {
response.assert_status(StatusCode::OK);
assert!(response.body().contains("This username is already taken"));
}
/// Test registration without email when email is not required
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_register_without_email_when_not_required(pool: PgPool) {
setup();
let state = TestState::from_pool_with_site_config(
pool,
SiteConfig {
password_registration_email_required: false,
..test_site_config()
},
)
.await
.unwrap();
let cookies = CookieHelper::new();
// Render the registration page and get the CSRF token
let request =
Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty();
let request = cookies.with_cookies(request);
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
// Submit the registration form without email
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
.form(serde_json::json!({
"csrf": csrf_token,
"username": "alice",
"password": "correcthorsebatterystaple",
"password_confirm": "correcthorsebatterystaple",
"accept_terms": "on",
}));
let request = cookies.with_cookies(request);
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::SEE_OTHER);
let location = response.headers().get(LOCATION).unwrap();
// The handler redirects with the ID as the second to last portion of the path
let id = location
.to_str()
.unwrap()
.rsplit('/')
.nth(1)
.unwrap()
.parse()
.unwrap();
// There should be a new registration in the database
let mut repo = state.repository().await.unwrap();
let registration = repo.user_registration().lookup(id).await.unwrap().unwrap();
assert_eq!(registration.username, "alice".to_owned());
assert!(registration.password.is_some());
// Email authentication should be None when email is not required and not
// provided
assert!(registration.email_authentication_id.is_none());
}
/// Test registration with valid email when email is not required
/// (email input is ignored completely when not required)
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_register_with_email_when_not_required(pool: PgPool) {
setup();
let state = TestState::from_pool_with_site_config(
pool,
SiteConfig {
password_registration_email_required: false,
..test_site_config()
},
)
.await
.unwrap();
let cookies = CookieHelper::new();
// Render the registration page and get the CSRF token
let request =
Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty();
let request = cookies.with_cookies(request);
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
// Submit the registration form with valid email
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
.form(serde_json::json!({
"csrf": csrf_token,
"username": "charlie",
"email": "charlie@example.com",
"password": "correcthorsebatterystaple",
"password_confirm": "correcthorsebatterystaple",
"accept_terms": "on",
}));
let request = cookies.with_cookies(request);
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::SEE_OTHER);
let location = response.headers().get(LOCATION).unwrap();
// The handler redirects with the ID as the second to last portion of the path
let id = location
.to_str()
.unwrap()
.rsplit('/')
.nth(1)
.unwrap()
.parse()
.unwrap();
// There should be a new registration in the database
let mut repo = state.repository().await.unwrap();
let registration = repo.user_registration().lookup(id).await.unwrap().unwrap();
assert_eq!(registration.username, "charlie".to_owned());
assert!(registration.password.is_some());
// Email authentication should be None when email is not required
// (email input is completely ignored in this case)
assert!(registration.email_authentication_id.is_none());
}
/// Test registration fails when email is required but not provided
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_register_fails_without_email_when_required(pool: PgPool) {
setup();
let state = TestState::from_pool_with_site_config(
pool,
SiteConfig {
password_registration_email_required: true,
..test_site_config()
},
)
.await
.unwrap();
let cookies = CookieHelper::new();
// Render the registration page and get the CSRF token
let request =
Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty();
let request = cookies.with_cookies(request);
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
// Submit the registration form without email
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
.form(serde_json::json!({
"csrf": csrf_token,
"username": "david",
"password": "correcthorsebatterystaple",
"password_confirm": "correcthorsebatterystaple",
"accept_terms": "on",
}));
let request = cookies.with_cookies(request);
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Check that the response contains an error about the email field
let body = response.body();
assert!(body.contains("email") || body.contains("Email"));
// Ensure no registration was created
let mut repo = state.repository().await.unwrap();
let user_exists = repo.user().exists("david").await.unwrap();
assert!(!user_exists);
}
/// Test registration fails when email is required but empty
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_register_fails_with_empty_email_when_required(pool: PgPool) {
setup();
let state = TestState::from_pool_with_site_config(
pool,
SiteConfig {
password_registration_email_required: true,
..test_site_config()
},
)
.await
.unwrap();
let cookies = CookieHelper::new();
// Render the registration page and get the CSRF token
let request =
Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty();
let request = cookies.with_cookies(request);
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
// Submit the registration form with empty email
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
.form(serde_json::json!({
"csrf": csrf_token,
"username": "eve",
"email": "",
"password": "correcthorsebatterystaple",
"password_confirm": "correcthorsebatterystaple",
"accept_terms": "on",
}));
let request = cookies.with_cookies(request);
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Check that the response contains an error about the email field
let body = response.body();
assert!(body.contains("email") || body.contains("Email"));
// Ensure no registration was created
let mut repo = state.repository().await.unwrap();
let user_exists = repo.user().exists("eve").await.unwrap();
assert!(!user_exists);
}
/// Test registration fails with invalid email when email is required
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_register_fails_with_invalid_email_when_required(pool: PgPool) {
setup();
let state = TestState::from_pool_with_site_config(
pool,
SiteConfig {
password_registration_email_required: true,
..test_site_config()
},
)
.await
.unwrap();
let cookies = CookieHelper::new();
// Render the registration page and get the CSRF token
let request =
Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty();
let request = cookies.with_cookies(request);
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
// Submit the registration form with invalid email
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
.form(serde_json::json!({
"csrf": csrf_token,
"username": "grace",
"email": "not-an-email",
"password": "correcthorsebatterystaple",
"password_confirm": "correcthorsebatterystaple",
"accept_terms": "on",
}));
let request = cookies.with_cookies(request);
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Check that the response contains an error about the email field
let body = response.body();
assert!(body.contains("email") || body.contains("Email"));
// Ensure no registration was created
let mut repo = state.repository().await.unwrap();
let user_exists = repo.user().exists("grace").await.unwrap();
assert!(!user_exists);
}
}

View File

@@ -151,12 +151,12 @@ pub(crate) async fn get(
None
};
// For now, we require an email address on the registration, but this might
// change in the future
let email_authentication_id = registration
.email_authentication_id
.context("No email authentication started for this registration")
.map_err(InternalError::from_anyhow)?;
// If there is an email authentication, we need to check that the email
// address was verified. If there is no email authentication attached, we
// need to make sure the server doesn't require it
let email_authentication = if let Some(email_authentication_id) =
registration.email_authentication_id
{
let email_authentication = repo
.user_email()
.lookup_authentication(email_authentication_id)
@@ -198,6 +198,16 @@ pub(crate) async fn get(
.into_response());
}
Some(email_authentication)
} else if site_config.password_registration_email_required {
// This could only happen in theory during a configuration change
return Err(InternalError::from_anyhow(anyhow::anyhow!(
"Server requires an email address to complete the registration, but no email authentication was attached to the user registration"
)));
} else {
None
};
// Check that the display name is set
if registration.display_name.is_none() {
return Ok((
@@ -236,9 +246,11 @@ pub(crate) async fn get(
.add(&mut rng, &clock, &user, user_agent)
.await?;
if let Some(email_authentication) = email_authentication {
repo.user_email()
.add(&mut rng, &clock, &user, email_authentication.email)
.await?;
}
if let Some(password) = registration.password {
let user_password = repo

View File

@@ -45,6 +45,7 @@ impl SiteConfigExt for SiteConfig {
fn templates_features(&self) -> SiteFeatures {
SiteFeatures {
password_registration: self.password_registration_enabled,
password_registration_email_required: self.password_registration_email_required,
password_login: self.password_login_enabled,
account_recovery: self.account_recovery_allowed,
login_with_email_allowed: self.login_with_email_allowed,

View File

@@ -18,6 +18,9 @@ pub struct SiteFeatures {
/// Whether local password-based registration is enabled.
pub password_registration: bool,
/// Whether local password-based registration requires an email address.
pub password_registration_email_required: bool,
/// Whether local password-based login is enabled.
pub password_login: bool,
@@ -32,6 +35,9 @@ impl Object for SiteFeatures {
fn get_value(self: &Arc<Self>, field: &Value) -> Option<Value> {
match field.as_str()? {
"password_registration" => Some(Value::from(self.password_registration)),
"password_registration_email_required" => {
Some(Value::from(self.password_registration_email_required))
}
"password_login" => Some(Value::from(self.password_login)),
"account_recovery" => Some(Value::from(self.account_recovery)),
"login_with_email_allowed" => Some(Value::from(self.login_with_email_allowed)),
@@ -42,6 +48,7 @@ impl Object for SiteFeatures {
fn enumerate(self: &Arc<Self>) -> Enumerator {
Enumerator::Str(&[
"password_registration",
"password_registration_email_required",
"password_login",
"account_recovery",
"login_with_email_allowed",

View File

@@ -509,6 +509,7 @@ mod tests {
let features = SiteFeatures {
password_login: true,
password_registration: true,
password_registration_email_required: true,
account_recovery: true,
login_with_email_allowed: true,
};

View File

@@ -35,6 +35,7 @@
"server_name": "example.com",
"password_login_enabled": true,
"password_registration_enabled": true,
"password_registration_email_required": true,
"registration_token_required": true,
"email_change_allowed": true,
"displayname_change_allowed": true,
@@ -3680,6 +3681,7 @@
"minimum_password_complexity",
"password_change_allowed",
"password_login_enabled",
"password_registration_email_required",
"password_registration_enabled",
"registration_token_required",
"server_name"
@@ -3697,6 +3699,10 @@
"description": "Whether password registration is enabled.",
"type": "boolean"
},
"password_registration_email_required": {
"description": "Whether a valid email address is required for password registrations.",
"type": "boolean"
},
"registration_token_required": {
"description": "Whether registration tokens are required for password registrations.",
"type": "boolean"

View File

@@ -2604,6 +2604,10 @@
"description": "Whether to enable self-service password registration. Defaults to `false` if password authentication is enabled.\n\nThis has no effect if password login is disabled.",
"type": "boolean"
},
"password_registration_email_required": {
"description": "Whether self-service password registrations require a valid email. Defaults to `true`.\n\nThis has no effect if password registration is disabled.",
"type": "boolean"
},
"password_change_allowed": {
"description": "Whether users are allowed to change their passwords. Defaults to `true`.\n\nThis has no effect if password login is disabled.",
"type": "boolean"

View File

@@ -296,6 +296,12 @@ account:
# This has no effect if password login is disabled.
password_registration_enabled: false
# Whether self-service registrations require a valid email
#
# Defaults to `true`
# This has no effect if password registration is disabled.
password_registration_email_required: true
# Whether users are allowed to change their passwords
#
# Defaults to `true`.

View File

@@ -81,13 +81,6 @@ violation contains {"msg": sprintf(
common.requester_banned(input.requester, data.requester)
}
# Check that we supplied an email for password registration
violation contains {"field": "email", "msg": "email required for password-based registration"} if {
input.registration_method == "password"
not input.email
}
# Check if the email is valid using the email policy
# and add the email field to the violation object
violation contains object.union({"field": "email"}, v) if {

View File

@@ -39,11 +39,8 @@ test_banned_subdomain if {
with data.banned_domains as ["staging.element.io"]
}
test_email_required if {
not register.allow with input as {"username": "hello", "registration_method": "password"}
}
test_no_email if {
register.allow with input as {"username": "hello", "registration_method": "password"}
register.allow with input as {"username": "hello", "registration_method": "upstream-oauth2"}
}

View File

@@ -41,7 +41,11 @@ Please see LICENSE files in the repository root for full details.
{% endfor %}
{% if features.password_registration %}
{% if features.password_registration_email_required %}
{{ button.button(text=_("mas.register.continue_with_email")) }}
{% else %}
{{ button.button(text=_("mas.register.continue_with_password")) }}
{% endif %}
{% endif %}
{% if providers %}

View File

@@ -35,9 +35,11 @@ Please see LICENSE files in the repository root for full details.
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="none" required />
{% endcall %}
{% if features.password_registration_email_required %}
{% call(f) field.field(label=_("common.email_address"), name="email", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="email" autocomplete="email" required />
{% endcall %}
{% endif %}
{% call(f) field.field(label=_("common.password"), name="password", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="new-password" required />

View File

@@ -10,7 +10,7 @@
},
"continue": "Continue",
"@continue": {
"context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48"
"context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:76:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48"
},
"create_account": "Create Account",
"@create_account": {
@@ -79,7 +79,7 @@
},
"email_address": "Email address",
"@email_address": {
"context": "pages/recovery/start.html:34:33-58, pages/register/password.html:38:33-58, pages/upstream_oauth2/do_register.html:114:37-62"
"context": "pages/recovery/start.html:34:33-58, pages/register/password.html:39:35-60, pages/upstream_oauth2/do_register.html:114:37-62"
},
"loading": "Loading…",
"@loading": {
@@ -91,11 +91,11 @@
},
"password": "Password",
"@password": {
"context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53"
"context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55, pages/register/password.html:44:33-53"
},
"password_confirm": "Confirm password",
"@password_confirm": {
"context": "pages/register/password.html:46:33-61"
"context": "pages/register/password.html:48:33-61"
},
"username": "Username",
"@username": {
@@ -423,7 +423,7 @@
},
"continue_with_provider": "Continue with %(provider)s",
"@continue_with_provider": {
"context": "pages/login.html:81:15-67, pages/register/index.html:53:15-67",
"context": "pages/login.html:81:15-67, pages/register/index.html:57:15-67",
"description": "Button to log in with an upstream provider"
},
"description": "Please sign in to continue:",
@@ -613,12 +613,16 @@
"register": {
"call_to_login": "Already have an account?",
"@call_to_login": {
"context": "pages/register/index.html:59:35-66, pages/register/password.html:77:33-64",
"context": "pages/register/index.html:63:35-66, pages/register/password.html:79:33-64",
"description": "Displayed on the registration page to suggest to log in instead"
},
"continue_with_email": "Continue with email address",
"@continue_with_email": {
"context": "pages/register/index.html:44:30-67"
"context": "pages/register/index.html:45:32-69"
},
"continue_with_password": "Continue with password",
"@continue_with_password": {
"context": "pages/register/index.html:47:32-72"
},
"create_account": {
"description": "Choose a username to continue.",
@@ -632,7 +636,7 @@
},
"terms_of_service": "I agree to the <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Terms and Conditions</a>",
"@terms_of_service": {
"context": "pages/register/password.html:51:35-95, pages/upstream_oauth2/do_register.html:179:35-95"
"context": "pages/register/password.html:53:35-95, pages/upstream_oauth2/do_register.html:179:35-95"
}
},
"registration_token": {