1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-07 17:03:01 +03:00

Render reCAPTCHA challenge on the registration form

This commit is contained in:
Quentin Gliech
2024-05-10 17:17:19 +02:00
parent c422c29a60
commit a3beeb2398
18 changed files with 342 additions and 19 deletions

View File

@@ -145,7 +145,8 @@ impl Options {
&config.matrix, &config.matrix,
&config.experimental, &config.experimental,
&config.passwords, &config.passwords,
); &config.captcha,
)?;
// Load and compile the templates // Load and compile the templates
let templates = let templates =

View File

@@ -15,8 +15,8 @@
use clap::Parser; use clap::Parser;
use figment::Figment; use figment::Figment;
use mas_config::{ use mas_config::{
BrandingConfig, ConfigurationSection, ExperimentalConfig, MatrixConfig, PasswordsConfig, BrandingConfig, CaptchaConfig, ConfigurationSection, ExperimentalConfig, MatrixConfig,
TemplatesConfig, PasswordsConfig, TemplatesConfig,
}; };
use mas_storage::{Clock, SystemClock}; use mas_storage::{Clock, SystemClock};
use rand::SeedableRng; use rand::SeedableRng;
@@ -48,6 +48,7 @@ impl Options {
let matrix_config = MatrixConfig::extract(figment)?; let matrix_config = MatrixConfig::extract(figment)?;
let experimental_config = ExperimentalConfig::extract(figment)?; let experimental_config = ExperimentalConfig::extract(figment)?;
let password_config = PasswordsConfig::extract(figment)?; let password_config = PasswordsConfig::extract(figment)?;
let captcha_config = CaptchaConfig::extract(figment)?;
let clock = SystemClock::default(); let clock = SystemClock::default();
// XXX: we should disallow SeedableRng::from_entropy // XXX: we should disallow SeedableRng::from_entropy
@@ -59,7 +60,8 @@ impl Options {
&matrix_config, &matrix_config,
&experimental_config, &experimental_config,
&password_config, &password_config,
); &captcha_config,
)?;
let templates = let templates =
templates_from_config(&template_config, &site_config, &url_builder).await?; templates_from_config(&template_config, &site_config, &url_builder).await?;
templates.check_render(clock.now(), &mut rng)?; templates.check_render(clock.now(), &mut rng)?;

View File

@@ -52,7 +52,8 @@ impl Options {
&config.matrix, &config.matrix,
&config.experimental, &config.experimental,
&config.passwords, &config.passwords,
); &config.captcha,
)?;
// Load and compile the templates // Load and compile the templates
let templates = let templates =

View File

@@ -16,7 +16,7 @@ use std::time::Duration;
use anyhow::Context; use anyhow::Context;
use mas_config::{ use mas_config::{
BrandingConfig, DatabaseConfig, EmailConfig, EmailSmtpMode, EmailTransportKind, BrandingConfig, CaptchaConfig, DatabaseConfig, EmailConfig, EmailSmtpMode, EmailTransportKind,
ExperimentalConfig, MatrixConfig, PasswordsConfig, PolicyConfig, TemplatesConfig, ExperimentalConfig, MatrixConfig, PasswordsConfig, PolicyConfig, TemplatesConfig,
}; };
use mas_data_model::SiteConfig; use mas_data_model::SiteConfig;
@@ -120,13 +120,39 @@ pub async fn policy_factory_from_config(
.context("failed to load the policy") .context("failed to load the policy")
} }
pub fn captcha_config_from_config(
captcha_config: &CaptchaConfig,
) -> Result<Option<mas_data_model::CaptchaConfig>, anyhow::Error> {
let Some(service) = captcha_config.service else {
return Ok(None);
};
let service = match service {
mas_config::CaptchaServiceKind::RecaptchaV2 => mas_data_model::CaptchaService::RecaptchaV2,
};
Ok(Some(mas_data_model::CaptchaConfig {
service,
site_key: captcha_config
.site_key
.clone()
.context("missing site key")?,
secret_key: captcha_config
.secret_key
.clone()
.context("missing secret key")?,
}))
}
pub fn site_config_from_config( pub fn site_config_from_config(
branding_config: &BrandingConfig, branding_config: &BrandingConfig,
matrix_config: &MatrixConfig, matrix_config: &MatrixConfig,
experimental_config: &ExperimentalConfig, experimental_config: &ExperimentalConfig,
password_config: &PasswordsConfig, password_config: &PasswordsConfig,
) -> SiteConfig { captcha_config: &CaptchaConfig,
SiteConfig { ) -> Result<SiteConfig, anyhow::Error> {
let captcha = captcha_config_from_config(captcha_config)?;
Ok(SiteConfig {
access_token_ttl: experimental_config.access_token_ttl, access_token_ttl: experimental_config.access_token_ttl,
compat_token_ttl: experimental_config.compat_token_ttl, compat_token_ttl: experimental_config.compat_token_ttl,
server_name: matrix_config.homeserver.clone(), server_name: matrix_config.homeserver.clone(),
@@ -140,7 +166,8 @@ pub fn site_config_from_config(
displayname_change_allowed: experimental_config.displayname_change_allowed, displayname_change_allowed: experimental_config.displayname_change_allowed,
password_change_allowed: password_config.enabled() password_change_allowed: password_config.enabled()
&& experimental_config.password_change_allowed, && experimental_config.password_change_allowed,
} captcha,
})
} }
pub async fn templates_from_config( pub async fn templates_from_config(

View File

@@ -0,0 +1,80 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use schemars::JsonSchema;
use serde::{de::Error, Deserialize, Serialize};
use crate::ConfigurationSection;
/// Which service should be used for CAPTCHA protection
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)]
pub enum CaptchaServiceKind {
/// Use Google's reCAPTCHA v2 API
#[serde(rename = "recaptcha_v2")]
RecaptchaV2,
}
/// Configuration section to setup CAPTCHA protection on a few operations
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, Default)]
pub struct CaptchaConfig {
/// Which service should be used for CAPTCHA protection
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<CaptchaServiceKind>,
/// The site key to use
#[serde(skip_serializing_if = "Option::is_none")]
pub site_key: Option<String>,
/// The secret key to use
#[serde(skip_serializing_if = "Option::is_none")]
pub secret_key: Option<String>,
}
impl CaptchaConfig {
/// Returns true if the configuration is the default one
pub(crate) fn is_default(&self) -> bool {
self.service.is_none() && self.site_key.is_none() && self.secret_key.is_none()
}
}
impl ConfigurationSection for CaptchaConfig {
const PATH: Option<&'static str> = Some("captcha");
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
let metadata = figment.find_metadata(Self::PATH.unwrap());
let error_on_field = |mut error: figment::error::Error, field: &'static str| {
error.metadata = metadata.cloned();
error.profile = Some(figment::Profile::Default);
error.path = vec![Self::PATH.unwrap().to_owned(), field.to_owned()];
error
};
let missing_field = |field: &'static str| {
error_on_field(figment::error::Error::missing_field(field), field)
};
if let Some(CaptchaServiceKind::RecaptchaV2) = self.service {
if self.site_key.is_none() {
return Err(missing_field("site_key"));
}
if self.secret_key.is_none() {
return Err(missing_field("secret_key"));
}
}
Ok(())
}
}

View File

@@ -17,6 +17,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
mod branding; mod branding;
mod captcha;
mod clients; mod clients;
mod database; mod database;
mod email; mod email;
@@ -32,6 +33,7 @@ mod upstream_oauth2;
pub use self::{ pub use self::{
branding::BrandingConfig, branding::BrandingConfig,
captcha::{CaptchaConfig, CaptchaServiceKind},
clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig}, clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig},
database::DatabaseConfig, database::DatabaseConfig,
email::{EmailConfig, EmailSmtpMode, EmailTransportKind}, email::{EmailConfig, EmailSmtpMode, EmailTransportKind},
@@ -107,6 +109,10 @@ pub struct RootConfig {
#[serde(default, skip_serializing_if = "BrandingConfig::is_default")] #[serde(default, skip_serializing_if = "BrandingConfig::is_default")]
pub branding: BrandingConfig, pub branding: BrandingConfig,
/// Configuration section to setup CAPTCHA protection on a few operations
#[serde(default, skip_serializing_if = "CaptchaConfig::is_default")]
pub captcha: CaptchaConfig,
/// Experimental configuration options /// Experimental configuration options
#[serde(default, skip_serializing_if = "ExperimentalConfig::is_default")] #[serde(default, skip_serializing_if = "ExperimentalConfig::is_default")]
pub experimental: ExperimentalConfig, pub experimental: ExperimentalConfig,
@@ -126,6 +132,7 @@ impl ConfigurationSection for RootConfig {
self.policy.validate(figment)?; self.policy.validate(figment)?;
self.upstream_oauth2.validate(figment)?; self.upstream_oauth2.validate(figment)?;
self.branding.validate(figment)?; self.branding.validate(figment)?;
self.captcha.validate(figment)?;
self.experimental.validate(figment)?; self.experimental.validate(figment)?;
Ok(()) Ok(())
@@ -155,6 +162,7 @@ impl RootConfig {
policy: PolicyConfig::default(), policy: PolicyConfig::default(),
upstream_oauth2: UpstreamOAuth2Config::default(), upstream_oauth2: UpstreamOAuth2Config::default(),
branding: BrandingConfig::default(), branding: BrandingConfig::default(),
captcha: CaptchaConfig::default(),
experimental: ExperimentalConfig::default(), experimental: ExperimentalConfig::default(),
}) })
} }
@@ -175,6 +183,7 @@ impl RootConfig {
policy: PolicyConfig::default(), policy: PolicyConfig::default(),
upstream_oauth2: UpstreamOAuth2Config::default(), upstream_oauth2: UpstreamOAuth2Config::default(),
branding: BrandingConfig::default(), branding: BrandingConfig::default(),
captcha: CaptchaConfig::default(),
experimental: ExperimentalConfig::default(), experimental: ExperimentalConfig::default(),
} }
} }
@@ -209,6 +218,9 @@ pub struct AppConfig {
#[serde(default)] #[serde(default)]
pub branding: BrandingConfig, pub branding: BrandingConfig,
#[serde(default)]
pub captcha: CaptchaConfig,
#[serde(default)] #[serde(default)]
pub experimental: ExperimentalConfig, pub experimental: ExperimentalConfig,
} }
@@ -224,6 +236,7 @@ impl ConfigurationSection for AppConfig {
self.matrix.validate(figment)?; self.matrix.validate(figment)?;
self.policy.validate(figment)?; self.policy.validate(figment)?;
self.branding.validate(figment)?; self.branding.validate(figment)?;
self.captcha.validate(figment)?;
self.experimental.validate(figment)?; self.experimental.validate(figment)?;
Ok(()) Ok(())

View File

@@ -40,7 +40,7 @@ pub use self::{
AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, DeviceCodeGrant, AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, DeviceCodeGrant,
DeviceCodeGrantState, InvalidRedirectUriError, JwksOrJwksUri, Pkce, Session, SessionState, DeviceCodeGrantState, InvalidRedirectUriError, JwksOrJwksUri, Pkce, Session, SessionState,
}, },
site_config::SiteConfig, site_config::{CaptchaConfig, CaptchaService, SiteConfig},
tokens::{ tokens::{
AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType, AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType,
}, },

View File

@@ -15,6 +15,25 @@
use chrono::Duration; use chrono::Duration;
use url::Url; use url::Url;
/// Which Captcha service is being used
#[derive(Debug, Clone)]
pub enum CaptchaService {
RecaptchaV2,
}
/// Captcha configuration
#[derive(Debug, Clone)]
pub struct CaptchaConfig {
/// Which Captcha service is being used
pub service: CaptchaService,
/// The site key used by the instance
pub site_key: String,
/// The secret key used by the instance
pub secret_key: String,
}
/// Random site configuration we want accessible in various places. /// Random site configuration we want accessible in various places.
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -51,4 +70,7 @@ pub struct SiteConfig {
/// Whether users can change their password. /// Whether users can change their password.
pub password_change_allowed: bool, pub password_change_allowed: bool,
/// Captcha configuration
pub captcha: Option<CaptchaConfig>,
} }

View File

@@ -132,6 +132,7 @@ pub fn test_site_config() -> SiteConfig {
email_change_allowed: true, email_change_allowed: true,
displayname_change_allowed: true, displayname_change_allowed: true,
password_change_allowed: true, password_change_allowed: true,
captcha: None,
} }
} }

View File

@@ -26,7 +26,7 @@ use mas_axum_utils::{
csrf::{CsrfExt, CsrfToken, ProtectedForm}, csrf::{CsrfExt, CsrfToken, ProtectedForm},
FancyError, SessionInfoExt, FancyError, SessionInfoExt,
}; };
use mas_data_model::UserAgent; use mas_data_model::{CaptchaConfig, UserAgent};
use mas_i18n::DataLocale; use mas_i18n::DataLocale;
use mas_matrix::BoxHomeserverConnection; use mas_matrix::BoxHomeserverConnection;
use mas_policy::Policy; use mas_policy::Policy;
@@ -96,6 +96,7 @@ pub(crate) async fn get(
csrf_token, csrf_token,
&mut repo, &mut repo,
&templates, &templates,
site_config.captcha.clone(),
) )
.await?; .await?;
@@ -216,6 +217,7 @@ pub(crate) async fn post(
csrf_token, csrf_token,
&mut repo, &mut repo,
&templates, &templates,
site_config.captcha.clone(),
) )
.await?; .await?;
@@ -278,6 +280,7 @@ async fn render(
csrf_token: CsrfToken, csrf_token: CsrfToken,
repo: &mut impl RepositoryAccess, repo: &mut impl RepositoryAccess,
templates: &Templates, templates: &Templates,
captcha_config: Option<CaptchaConfig>,
) -> Result<String, FancyError> { ) -> Result<String, FancyError> {
let next = action.load_context(repo).await?; let next = action.load_context(repo).await?;
let ctx = if let Some(next) = next { let ctx = if let Some(next) = next {
@@ -285,7 +288,10 @@ async fn render(
} else { } else {
ctx ctx
}; };
let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale); let ctx = ctx
.with_captcha(captcha_config)
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_register(&ctx)?; let content = templates.render_register(&ctx)?;
Ok(content) Ok(content)

View File

@@ -15,6 +15,7 @@
//! Contexts used in templates //! Contexts used in templates
mod branding; mod branding;
mod captcha;
mod ext; mod ext;
mod features; mod features;
@@ -41,7 +42,9 @@ use serde::{ser::SerializeStruct, Deserialize, Serialize};
use ulid::Ulid; use ulid::Ulid;
use url::Url; use url::Url;
pub use self::{branding::SiteBranding, ext::SiteConfigExt, features::SiteFeatures}; pub use self::{
branding::SiteBranding, captcha::WithCaptcha, ext::SiteConfigExt, features::SiteFeatures,
};
use crate::{FieldError, FormField, FormState}; use crate::{FieldError, FormField, FormState};
/// Helper trait to construct context wrappers /// Helper trait to construct context wrappers
@@ -95,6 +98,14 @@ pub trait TemplateContext: Serialize {
} }
} }
/// Attach a CAPTCHA configuration to the template context
fn with_captcha(self, captcha: Option<mas_data_model::CaptchaConfig>) -> WithCaptcha<Self>
where
Self: Sized,
{
WithCaptcha::new(captcha, self)
}
/// Generate sample values for this context type /// Generate sample values for this context type
/// ///
/// This is then used to check for template validity in unit tests and in /// This is then used to check for template validity in unit tests and in

View File

@@ -0,0 +1,77 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::Arc;
use minijinja::{
value::{Enumerator, Object},
Value,
};
use serde::Serialize;
use crate::TemplateContext;
#[derive(Debug)]
struct CaptchaConfig(mas_data_model::CaptchaConfig);
impl Object for CaptchaConfig {
fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
match key.as_str() {
Some("service") => Some(match &self.0.service {
mas_data_model::CaptchaService::RecaptchaV2 => "recaptcha_v2".into(),
}),
Some("site_key") => Some(self.0.site_key.clone().into()),
_ => None,
}
}
fn enumerate(self: &Arc<Self>) -> Enumerator {
Enumerator::Str(&["service", "site_key"])
}
}
/// Context with an optional CAPTCHA configuration in it
#[derive(Serialize)]
pub struct WithCaptcha<T> {
captcha: Option<Value>,
#[serde(flatten)]
inner: T,
}
impl<T> WithCaptcha<T> {
#[must_use]
pub fn new(captcha: Option<mas_data_model::CaptchaConfig>, inner: T) -> Self {
Self {
captcha: captcha.map(|captcha| Value::from_object(CaptchaConfig(captcha))),
inner,
}
}
}
impl<T: TemplateContext> TemplateContext for WithCaptcha<T> {
fn sample(
now: chrono::DateTime<chrono::prelude::Utc>,
rng: &mut impl rand::prelude::Rng,
) -> Vec<Self>
where
Self: Sized,
{
let inner = T::sample(now, rng);
inner
.into_iter()
.map(|inner| Self::new(None, inner))
.collect()
}
}

View File

@@ -22,6 +22,7 @@ use std::{collections::HashSet, sync::Arc};
use anyhow::Context as _; use anyhow::Context as _;
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use camino::{Utf8Path, Utf8PathBuf}; use camino::{Utf8Path, Utf8PathBuf};
use context::WithCaptcha;
use mas_i18n::Translator; use mas_i18n::Translator;
use mas_router::UrlBuilder; use mas_router::UrlBuilder;
use mas_spa::ViteManifest; use mas_spa::ViteManifest;
@@ -326,7 +327,7 @@ register_templates! {
pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login.html" } pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login.html" }
/// Render the registration page /// Render the registration page
pub fn render_register(WithLanguage<WithCsrf<RegisterContext>>) { "pages/register.html" } pub fn render_register(WithLanguage<WithCsrf<WithCaptcha<RegisterContext>>>) { "pages/register.html" }
/// Render the client consent page /// Render the client consent page
pub fn render_consent(WithLanguage<WithCsrf<WithSession<ConsentContext>>>) { "pages/consent.html" } pub fn render_consent(WithLanguage<WithCsrf<WithSession<ConsentContext>>>) { "pages/consent.html" }

View File

@@ -184,6 +184,14 @@
} }
] ]
}, },
"captcha": {
"description": "Configuration section to setup CAPTCHA protection on a few operations",
"allOf": [
{
"$ref": "#/definitions/CaptchaConfig"
}
]
},
"experimental": { "experimental": {
"description": "Experimental configuration options", "description": "Experimental configuration options",
"allOf": [ "allOf": [
@@ -1949,6 +1957,40 @@
} }
} }
}, },
"CaptchaConfig": {
"description": "Configuration section to setup CAPTCHA protection on a few operations",
"type": "object",
"properties": {
"service": {
"description": "Which service should be used for CAPTCHA protection",
"allOf": [
{
"$ref": "#/definitions/CaptchaServiceKind"
}
]
},
"site_key": {
"description": "The site key to use",
"type": "string"
},
"secret_key": {
"description": "The secret key to use",
"type": "string"
}
}
},
"CaptchaServiceKind": {
"description": "Which service should be used for CAPTCHA protection",
"oneOf": [
{
"description": "Use Google's reCAPTCHA v2 API",
"type": "string",
"enum": [
"recaptcha_v2"
]
}
]
},
"ExperimentalConfig": { "ExperimentalConfig": {
"description": "Configuration sections for experimental options\n\nDo not change these options unless you know what you are doing.", "description": "Configuration sections for experimental options\n\nDo not change these options unless you know what you are doing.",
"type": "object", "type": "object",

View File

@@ -23,6 +23,7 @@ limitations under the License.
{% import "components/errors.html" as errors %} {% import "components/errors.html" as errors %}
{% import "components/icon.html" as icon %} {% import "components/icon.html" as icon %}
{% import "components/scope.html" as scope %} {% import "components/scope.html" as scope %}
{% import "components/captcha.html" as captcha %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ lang }}"> <html lang="{{ lang }}">
@@ -31,6 +32,7 @@ limitations under the License.
<title>{% block title %}{{ _("app.name") }}{% endblock title %}</title> <title>{% block title %}{{ _("app.name") }}{% endblock title %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
{{ include_asset('src/templates.css', preload=true) | indent(4) | safe }} {{ include_asset('src/templates.css', preload=true) | indent(4) | safe }}
{{ captcha.head() }}
</head> </head>
<body> <body>
<div class="layout-container"> <div class="layout-container">

View File

@@ -0,0 +1,35 @@
{#
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
#}
{% macro form() -%}
{%- if captcha|default(False) -%}
{%- if captcha.service == "recaptcha_v2" -%}
<div class="g-recaptcha" data-sitekey="{{ captcha.site_key }}"></div>
{%- else -%}
{{ throw(message="Invalid captcha service setup") }}
{%- endif %}
{%- endif -%}
{% endmacro %}
{% macro head() -%}
{%- if captcha|default(False) -%}
{%- if captcha.service == "recaptcha_v2" -%}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
{%- else -%}
{{ throw(message="Invalid captcha service setup") }}
{%- endif %}
{%- endif -%}
{%- endmacro %}

View File

@@ -69,6 +69,8 @@ limitations under the License.
{% endcall %} {% endcall %}
{% endif %} {% endif %}
{{ captcha.form() }}
{{ button.button(text=_("action.continue")) }} {{ button.button(text=_("action.continue")) }}
</form> </form>

View File

@@ -2,11 +2,11 @@
"action": { "action": {
"cancel": "Cancel", "cancel": "Cancel",
"@cancel": { "@cancel": {
"context": "pages/consent.html:75:11-29, pages/device_consent.html:132:13-31, pages/login.html:100:13-31, pages/policy_violation.html:52:13-31, pages/register.html:77:13-31" "context": "pages/consent.html:75:11-29, pages/device_consent.html:132:13-31, pages/login.html:100:13-31, pages/policy_violation.html:52:13-31, pages/register.html:79:13-31"
}, },
"continue": "Continue", "continue": "Continue",
"@continue": { "@continue": {
"context": "pages/account/emails/add.html:45:26-46, pages/account/emails/verify.html:60:26-46, pages/consent.html:63:28-48, pages/device_consent.html:129:13-33, pages/device_link.html:48:26-46, pages/login.html:62:30-50, pages/reauth.html:40:28-48, pages/register.html:72:28-48, pages/sso.html:45:28-48" "context": "pages/account/emails/add.html:45:26-46, pages/account/emails/verify.html:60:26-46, pages/consent.html:63:28-48, pages/device_consent.html:129:13-33, pages/device_link.html:48:26-46, pages/login.html:62:30-50, pages/reauth.html:40:28-48, pages/register.html:74:28-48, pages/sso.html:45:28-48"
}, },
"create_account": "Create Account", "create_account": "Create Account",
"@create_account": { "@create_account": {
@@ -29,7 +29,7 @@
}, },
"name": "matrix-authentication-service", "name": "matrix-authentication-service",
"@name": { "@name": {
"context": "app.html:25:14-27, base.html:31:31-44", "context": "app.html:25:14-27, base.html:32:31-44",
"description": "Name of the application" "description": "Name of the application"
}, },
"technical_description": "OpenID Connect discovery document: <a class=\"cpd-link\" data-kind=\"primary\" href=\"%(discovery_url)s\">%(discovery_url)s</a>", "technical_description": "OpenID Connect discovery document: <a class=\"cpd-link\" data-kind=\"primary\" href=\"%(discovery_url)s\">%(discovery_url)s</a>",
@@ -349,7 +349,7 @@
"register": { "register": {
"call_to_login": "Already have an account?", "call_to_login": "Already have an account?",
"@call_to_login": { "@call_to_login": {
"context": "pages/register.html:87:11-42", "context": "pages/register.html:89:11-42",
"description": "Displayed on the registration page to suggest to log in instead" "description": "Displayed on the registration page to suggest to log in instead"
}, },
"create_account": { "create_account": {
@@ -364,7 +364,7 @@
}, },
"sign_in_instead": "Sign in instead", "sign_in_instead": "Sign in instead",
"@sign_in_instead": { "@sign_in_instead": {
"context": "pages/register.html:91:31-64" "context": "pages/register.html:93:31-64"
}, },
"terms_of_service": "I agree to the <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Terms and Conditions</a>", "terms_of_service": "I agree to the <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Terms and Conditions</a>",
"@terms_of_service": { "@terms_of_service": {