diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 7c35ec03..9ed7a1c7 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -170,6 +170,8 @@ pub fn site_config_from_config( displayname_change_allowed: experimental_config.displayname_change_allowed, password_change_allowed: password_config.enabled() && experimental_config.password_change_allowed, + account_recovery_allowed: password_config.enabled() + && experimental_config.account_recovery_enabled, captcha, }) } diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index e53d5ba1..84ccf8c5 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -36,6 +36,15 @@ const fn is_default_true(value: &bool) -> bool { *value == default_true() } +const fn default_false() -> bool { + false +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +const fn is_default_false(value: &bool) -> bool { + *value == default_false() +} + /// Configuration sections for experimental options /// /// Do not change these options unless you know what you are doing. @@ -80,6 +89,10 @@ pub struct ExperimentalConfig { /// Whether users are allowed to change their passwords. Defaults to `true`. #[serde(default = "default_true", skip_serializing_if = "is_default_true")] pub password_change_allowed: bool, + + /// Whether email-based account recovery is enabled. Defaults to `false`. + #[serde(default = "default_false", skip_serializing_if = "is_default_false")] + pub account_recovery_enabled: bool, } impl Default for ExperimentalConfig { @@ -91,6 +104,7 @@ impl Default for ExperimentalConfig { email_change_allowed: default_true(), displayname_change_allowed: default_true(), password_change_allowed: default_true(), + account_recovery_enabled: default_false(), } } } @@ -103,6 +117,7 @@ impl ExperimentalConfig { && is_default_true(&self.email_change_allowed) && is_default_true(&self.displayname_change_allowed) && is_default_true(&self.password_change_allowed) + && is_default_false(&self.account_recovery_enabled) } } diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index 8e3e0ccb..17893646 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -73,6 +73,9 @@ pub struct SiteConfig { /// Whether users can change their password. pub password_change_allowed: bool, + /// Whether users can recover their account via email. + pub account_recovery_allowed: bool, + /// Captcha configuration pub captcha: Option, } diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index b75c930c..7ddc1f7e 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -133,6 +133,7 @@ pub fn test_site_config() -> SiteConfig { email_change_allowed: true, displayname_change_allowed: true, password_change_allowed: true, + account_recovery_allowed: true, captcha: None, } } diff --git a/crates/handlers/src/views/recovery/finish.rs b/crates/handlers/src/views/recovery/finish.rs index fef181df..df0b6c66 100644 --- a/crates/handlers/src/views/recovery/finish.rs +++ b/crates/handlers/src/views/recovery/finish.rs @@ -23,12 +23,13 @@ use mas_axum_utils::{ csrf::{CsrfExt, ProtectedForm}, FancyError, }; +use mas_data_model::SiteConfig; use mas_policy::Policy; use mas_router::UrlBuilder; use mas_storage::{BoxClock, BoxRepository, BoxRng}; use mas_templates::{ - ErrorContext, FieldError, FormState, RecoveryFinishContext, RecoveryFinishFormField, - TemplateContext, Templates, + EmptyContext, ErrorContext, FieldError, FormState, RecoveryFinishContext, + RecoveryFinishFormField, TemplateContext, Templates, }; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; @@ -50,11 +51,18 @@ pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, mut repo: BoxRepository, + State(site_config): State, State(templates): State, PreferredLanguage(locale): PreferredLanguage, cookie_jar: CookieJar, Query(query): Query, ) -> Result { + if !site_config.account_recovery_allowed { + let context = EmptyContext.with_language(locale); + let rendered = templates.render_recovery_disabled(&context)?; + return Ok((cookie_jar, Html(rendered)).into_response()); + } + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let ticket = repo @@ -117,6 +125,7 @@ pub(crate) async fn post( clock: BoxClock, mut repo: BoxRepository, mut policy: Policy, + State(site_config): State, State(password_manager): State, State(templates): State, State(url_builder): State, @@ -125,6 +134,12 @@ pub(crate) async fn post( Query(query): Query, Form(form): Form>, ) -> Result { + if !site_config.account_recovery_allowed { + let context = EmptyContext.with_language(locale); + let rendered = templates.render_recovery_disabled(&context)?; + return Ok((cookie_jar, Html(rendered)).into_response()); + } + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let ticket = repo diff --git a/crates/handlers/src/views/recovery/progress.rs b/crates/handlers/src/views/recovery/progress.rs index 99c51182..a6a4d556 100644 --- a/crates/handlers/src/views/recovery/progress.rs +++ b/crates/handlers/src/views/recovery/progress.rs @@ -22,12 +22,13 @@ use mas_axum_utils::{ csrf::{CsrfExt, ProtectedForm}, FancyError, SessionInfoExt, }; +use mas_data_model::SiteConfig; use mas_router::UrlBuilder; use mas_storage::{ job::{JobRepositoryExt, SendAccountRecoveryEmailsJob}, BoxClock, BoxRepository, BoxRng, }; -use mas_templates::{RecoveryProgressContext, TemplateContext, Templates}; +use mas_templates::{EmptyContext, RecoveryProgressContext, TemplateContext, Templates}; use ulid::Ulid; use crate::PreferredLanguage; @@ -36,12 +37,19 @@ pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, mut repo: BoxRepository, + State(site_config): State, State(templates): State, State(url_builder): State, PreferredLanguage(locale): PreferredLanguage, cookie_jar: CookieJar, Path(id): Path, ) -> Result { + if !site_config.account_recovery_allowed { + let context = EmptyContext.with_language(locale); + let rendered = templates.render_recovery_disabled(&context)?; + return Ok((cookie_jar, Html(rendered)).into_response()); + } + let (session_info, cookie_jar) = cookie_jar.session_info(); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); @@ -75,6 +83,7 @@ pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, mut repo: BoxRepository, + State(site_config): State, State(templates): State, State(url_builder): State, PreferredLanguage(locale): PreferredLanguage, @@ -82,6 +91,12 @@ pub(crate) async fn post( Path(id): Path, Form(form): Form>, ) -> Result { + if !site_config.account_recovery_allowed { + let context = EmptyContext.with_language(locale); + let rendered = templates.render_recovery_disabled(&context)?; + return Ok((cookie_jar, Html(rendered)).into_response()); + } + let (session_info, cookie_jar) = cookie_jar.session_info(); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); diff --git a/crates/handlers/src/views/recovery/start.rs b/crates/handlers/src/views/recovery/start.rs index 2b904f1c..c8324042 100644 --- a/crates/handlers/src/views/recovery/start.rs +++ b/crates/handlers/src/views/recovery/start.rs @@ -25,14 +25,15 @@ use mas_axum_utils::{ csrf::{CsrfExt, ProtectedForm}, FancyError, SessionInfoExt, }; -use mas_data_model::UserAgent; +use mas_data_model::{SiteConfig, UserAgent}; use mas_router::UrlBuilder; use mas_storage::{ job::{JobRepositoryExt, SendAccountRecoveryEmailsJob}, BoxClock, BoxRepository, BoxRng, }; use mas_templates::{ - FieldError, FormState, RecoveryStartContext, RecoveryStartFormField, TemplateContext, Templates, + EmptyContext, FieldError, FormState, RecoveryStartContext, RecoveryStartFormField, + TemplateContext, Templates, }; use serde::{Deserialize, Serialize}; @@ -47,11 +48,18 @@ pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, mut repo: BoxRepository, + State(site_config): State, State(templates): State, State(url_builder): State, PreferredLanguage(locale): PreferredLanguage, cookie_jar: CookieJar, ) -> Result { + if !site_config.account_recovery_allowed { + let context = EmptyContext.with_language(locale); + let rendered = templates.render_recovery_disabled(&context)?; + return Ok((cookie_jar, Html(rendered)).into_response()); + } + let (session_info, cookie_jar) = cookie_jar.session_info(); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); @@ -78,12 +86,19 @@ pub(crate) async fn post( mut repo: BoxRepository, user_agent: TypedHeader, activity_tracker: BoundActivityTracker, + State(site_config): State, State(templates): State, State(url_builder): State, PreferredLanguage(locale): PreferredLanguage, cookie_jar: CookieJar, Form(form): Form>, ) -> Result { + if !site_config.account_recovery_allowed { + let context = EmptyContext.with_language(locale); + let rendered = templates.render_recovery_disabled(&context)?; + return Ok((cookie_jar, Html(rendered)).into_response()); + } + let (session_info, cookie_jar) = cookie_jar.session_info(); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); diff --git a/crates/templates/src/context/ext.rs b/crates/templates/src/context/ext.rs index f92f226b..9b80e408 100644 --- a/crates/templates/src/context/ext.rs +++ b/crates/templates/src/context/ext.rs @@ -54,6 +54,7 @@ impl SiteConfigExt for SiteConfig { SiteFeatures { password_registration: self.password_registration_enabled, password_login: self.password_login_enabled, + account_recovery: self.account_recovery_allowed, } } } diff --git a/crates/templates/src/context/features.rs b/crates/templates/src/context/features.rs index 96df6734..edd94c8b 100644 --- a/crates/templates/src/context/features.rs +++ b/crates/templates/src/context/features.rs @@ -27,6 +27,9 @@ pub struct SiteFeatures { /// Whether local password-based login is enabled. pub password_login: bool, + + /// Whether email-based account recovery is enabled. + pub account_recovery: bool, } impl Object for SiteFeatures { @@ -34,11 +37,16 @@ impl Object for SiteFeatures { match field.as_str()? { "password_registration" => Some(Value::from(self.password_registration)), "password_login" => Some(Value::from(self.password_login)), + "account_recovery" => Some(Value::from(self.account_recovery)), _ => None, } } fn enumerate(self: &Arc) -> Enumerator { - Enumerator::Str(&["password_registration", "password_login"]) + Enumerator::Str(&[ + "password_registration", + "password_login", + "account_recovery", + ]) } } diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 0d5268cf..4163e5c5 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -357,6 +357,9 @@ register_templates! { /// Render the account recovery finish page pub fn render_recovery_finish(WithLanguage>) { "pages/recovery/finish.html" } + /// Render the account recovery disabled page + pub fn render_recovery_disabled(WithLanguage) { "pages/recovery/disabled.html" } + /// Render the re-authentication form pub fn render_reauth(WithLanguage>>) { "pages/reauth.html" } @@ -425,6 +428,7 @@ impl Templates { check::render_recovery_start(self, now, rng)?; check::render_recovery_progress(self, now, rng)?; check::render_recovery_finish(self, now, rng)?; + check::render_recovery_disabled(self, now, rng)?; check::render_reauth(self, now, rng)?; check::render_form_post::(self, now, rng)?; check::render_error(self, now, rng)?; @@ -455,6 +459,7 @@ mod tests { let features = SiteFeatures { password_login: true, password_registration: true, + account_recovery: true, }; let vite_manifest_path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json"); diff --git a/docs/config.schema.json b/docs/config.schema.json index 3bd7a6f6..85f73be3 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2038,6 +2038,10 @@ "password_change_allowed": { "description": "Whether users are allowed to change their passwords. Defaults to `true`.", "type": "boolean" + }, + "account_recovery_enabled": { + "description": "Whether email-based account recovery is enabled. Defaults to `false`.", + "type": "boolean" } } } diff --git a/templates/pages/login.html b/templates/pages/login.html index 1568bab6..17838916 100644 --- a/templates/pages/login.html +++ b/templates/pages/login.html @@ -19,7 +19,7 @@ limitations under the License. {% from "components/idp_brand.html" import logo %} {% block content %} -
+
{% if features.password_login %}
@@ -58,6 +58,10 @@ limitations under the License. {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} {% endcall %} + + {% if features.account_recovery %} + {{ button.link_text(text=_("mas.login.forgot_password"), href="/recover", class="self-center") }} + {% endif %} {{ button.button(text=_("action.continue")) }} diff --git a/templates/pages/recovery/disabled.html b/templates/pages/recovery/disabled.html new file mode 100644 index 00000000..f2635345 --- /dev/null +++ b/templates/pages/recovery/disabled.html @@ -0,0 +1,32 @@ +{# +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. +#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.lock_solid() }} +
+ +
+

{{ _("mas.recovery.disabled.heading") }}

+

{{ _("mas.recovery.disabled.description") }}

+
+ + {{ button.link_outline(text=_("action.back"), href="/login") }} +
+{% endblock content %} diff --git a/translations/en.json b/translations/en.json index 67d85385..18256271 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1,16 +1,20 @@ { "action": { + "back": "Back", + "@back": { + "context": "pages/recovery/disabled.html:30:32-48" + }, "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:89:13-31" + "context": "pages/consent.html:75:11-29, pages/device_consent.html:132:13-31, pages/login.html:104:13-31, pages/policy_violation.html:52:13-31, pages/register.html:89:13-31" }, "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/recovery/start.html:45:26-46, pages/register.html:84: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:66:30-50, pages/reauth.html:40:28-48, pages/recovery/start.html:46:26-46, pages/register.html:84:28-48, pages/sso.html:45:28-48" }, "create_account": "Create Account", "@create_account": { - "context": "pages/login.html:72:35-61, pages/upstream_oauth2/do_register.html:157:26-52" + "context": "pages/login.html:76:35-61, pages/upstream_oauth2/do_register.html:157:26-52" }, "sign_in": "Sign in", "@sign_in": { @@ -294,17 +298,22 @@ "login": { "call_to_register": "Don't have an account yet?", "@call_to_register": { - "context": "pages/login.html:68:15-46" + "context": "pages/login.html:72:15-46" }, "continue_with_provider": "Continue with %(provider)s", "@continue_with_provider": { - "context": "pages/login.html:87:13-65", + "context": "pages/login.html:91:13-65", "description": "Button to log in with an upstream provider" }, "description": "Please sign in to continue:", "@description": { "context": "pages/login.html:38:31-57" }, + "forgot_password": "Forgot password?", + "@forgot_password": { + "context": "pages/login.html:63:35-65", + "description": "On the login page, link to the account recovery process" + }, "headline": "Sign in", "@headline": { "context": "pages/login.html:37:33-56" @@ -321,7 +330,7 @@ }, "no_login_methods": "No login methods available.", "@no_login_methods": { - "context": "pages/login.html:94:11-42" + "context": "pages/login.html:98:11-42" } }, "navbar": { @@ -376,6 +385,16 @@ } }, "recovery": { + "disabled": { + "description": "If you have lost your credentials, please contact the administrator to recover your account.", + "@description": { + "context": "pages/recovery/disabled.html:27:25-63" + }, + "heading": "Account recovery is disabled", + "@heading": { + "context": "pages/recovery/disabled.html:26:27-61" + } + }, "finish": { "confirm": "Enter new password again", "@confirm": {