Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/cli/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ pub fn site_config_from_config(
captcha,
minimum_password_complexity: password_config.minimum_complexity(),
session_expiration,
login_with_email_allowed: account_config.login_with_email_allowed,
})
}

Expand Down
8 changes: 8 additions & 0 deletions crates/config/src/sections/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ pub struct AccountConfig {
/// `true`.
#[serde(default = "default_true", skip_serializing_if = "is_default_true")]
pub account_deactivation_allowed: bool,

/// Whether users can log in with their email address. Defaults to `false`.
///
/// This has no effect if password login is disabled.
#[serde(default = "default_false", skip_serializing_if = "is_default_false")]
pub login_with_email_allowed: bool,
}

impl Default for AccountConfig {
Expand All @@ -77,6 +83,7 @@ impl Default for AccountConfig {
password_change_allowed: default_true(),
password_recovery_enabled: default_false(),
account_deactivation_allowed: default_true(),
login_with_email_allowed: default_false(),
}
}
}
Expand All @@ -90,6 +97,7 @@ impl AccountConfig {
&& is_default_true(&self.password_change_allowed)
&& is_default_false(&self.password_recovery_enabled)
&& is_default_true(&self.account_deactivation_allowed)
&& is_default_false(&self.login_with_email_allowed)
}
}

Expand Down
3 changes: 3 additions & 0 deletions crates/data-model/src/site_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,7 @@ pub struct SiteConfig {
pub minimum_password_complexity: u8,

pub session_expiration: Option<SessionExpirationConfig>,

/// Whether users can log in with their email address.
pub login_with_email_allowed: bool,
}
4 changes: 4 additions & 0 deletions crates/handlers/src/graphql/model/site_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ pub struct SiteConfig {
/// The exact scorer (including dictionaries and other data tables)
/// in use is <https://crates.io/crates/zxcvbn>.
minimum_password_complexity: u8,

/// Whether users can log in with their email address.
login_with_email_allowed: bool,
}

#[derive(SimpleObject)]
Expand Down Expand Up @@ -98,6 +101,7 @@ impl SiteConfig {
password_registration_enabled: data_model.password_registration_enabled,
account_deactivation_allowed: data_model.account_deactivation_allowed,
minimum_password_complexity: data_model.minimum_password_complexity,
login_with_email_allowed: data_model.login_with_email_allowed,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/handlers/src/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ pub fn test_site_config() -> SiteConfig {
captcha: None,
minimum_password_complexity: 1,
session_expiration: None,
login_with_email_allowed: true,
}
}

Expand Down
25 changes: 24 additions & 1 deletion crates/handlers/src/views/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ pub(crate) async fn post(
.unwrap_or(&form.username);

// First, lookup the user
let Some(user) = repo.user().find_by_username(username).await? else {
let Some(user) = get_user_by_email_or_by_username(site_config, &mut repo, username).await?
else {
let form_state = form_state.with_error_on_form(FormError::InvalidCredentials);
PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]);
return render(
Expand Down Expand Up @@ -337,6 +338,28 @@ pub(crate) async fn post(
Ok((cookie_jar, reply).into_response())
}

async fn get_user_by_email_or_by_username(
site_config: SiteConfig,
repo: &mut impl RepositoryAccess,
username_or_email: &str,
) -> Result<Option<mas_data_model::User>, Box<dyn std::error::Error>> {
if site_config.login_with_email_allowed && username_or_email.contains('@') {
let maybe_user_email = repo.user_email().find_by_email(username_or_email).await?;

if let Some(user_email) = maybe_user_email {
let user = repo.user().lookup(user_email.user_id).await?;

if user.is_some() {
return Ok(user);
}
}
}

let user = repo.user().find_by_username(username_or_email).await?;

Ok(user)
}

fn handle_login_hint(
mut ctx: LoginContext,
next: &PostAuthContext,
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions crates/storage-pg/src/user/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,43 @@ impl UserEmailRepository for PgUserEmailRepository<'_> {
Ok(Some(user_email.into()))
}

#[tracing::instrument(
name = "db.user_email.find_by_email",
skip_all,
fields(
db.query.text,
user_email.email = email,
),
err,
)]
async fn find_by_email(&mut self, email: &str) -> Result<Option<UserEmail>, Self::Error> {
let res = sqlx::query_as!(
UserEmailLookup,
r#"
SELECT user_email_id
, user_id
, email
, created_at
FROM user_emails
WHERE email = $1
"#,
email,
)
.traced()
.fetch_all(&mut *self.conn)
.await?;

if res.len() != 1 {
return Ok(None);
}

let Some(user_email) = res.into_iter().next() else {
return Ok(None);
};

Ok(Some(user_email.into()))
}

#[tracing::instrument(
name = "db.user_email.all",
skip_all,
Expand Down
14 changes: 14 additions & 0 deletions crates/storage/src/user/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ pub trait UserEmailRepository: Send + Sync {
/// Returns [`Self::Error`] if the underlying repository fails
async fn find(&mut self, user: &User, email: &str) -> Result<Option<UserEmail>, Self::Error>;

/// Lookup an [`UserEmail`] by its email address
///
/// Returns `None` if no matching [`UserEmail`] was found or if multiple
/// [`UserEmail`] are found
///
/// # Parameters
/// * `email`: The email address to lookup
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn find_by_email(&mut self, email: &str) -> Result<Option<UserEmail>, Self::Error>;

/// Get all [`UserEmail`] of a [`User`]
///
/// # Parameters
Expand Down Expand Up @@ -298,6 +311,7 @@ pub trait UserEmailRepository: Send + Sync {
repository_impl!(UserEmailRepository:
async fn lookup(&mut self, id: Ulid) -> Result<Option<UserEmail>, Self::Error>;
async fn find(&mut self, user: &User, email: &str) -> Result<Option<UserEmail>, Self::Error>;
async fn find_by_email(&mut self, email: &str) -> Result<Option<UserEmail>, Self::Error>;

async fn all(&mut self, user: &User) -> Result<Vec<UserEmail>, Self::Error>;
async fn list(
Expand Down
1 change: 1 addition & 0 deletions crates/templates/src/context/ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ impl SiteConfigExt for SiteConfig {
password_registration: self.password_registration_enabled,
password_login: self.password_login_enabled,
account_recovery: self.account_recovery_allowed,
login_with_email_allowed: self.login_with_email_allowed,
}
}
}
6 changes: 6 additions & 0 deletions crates/templates/src/context/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use minijinja::{
};

/// Site features information.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SiteFeatures {
/// Whether local password-based registration is enabled.
Expand All @@ -22,6 +23,9 @@ pub struct SiteFeatures {

/// Whether email-based account recovery is enabled.
pub account_recovery: bool,

/// Whether users can log in with their email address.
pub login_with_email_allowed: bool,
}

impl Object for SiteFeatures {
Expand All @@ -30,6 +34,7 @@ impl Object for SiteFeatures {
"password_registration" => Some(Value::from(self.password_registration)),
"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)),
_ => None,
}
}
Expand All @@ -39,6 +44,7 @@ impl Object for SiteFeatures {
"password_registration",
"password_login",
"account_recovery",
"login_with_email_allowed",
])
}
}
1 change: 1 addition & 0 deletions crates/templates/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ mod tests {
password_login: true,
password_registration: true,
account_recovery: true,
login_with_email_allowed: true,
};
let vite_manifest_path =
Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json");
Expand Down
4 changes: 4 additions & 0 deletions docs/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2504,6 +2504,10 @@
"account_deactivation_allowed": {
"description": "Whether users are allowed to delete their own account. Defaults to `true`.",
"type": "boolean"
},
"login_with_email_allowed": {
"description": "Whether users can log in with their email address. Defaults to `false`.\n\nThis has no effect if password login is disabled.",
"type": "boolean"
}
}
},
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,12 @@ account:
#
# Defaults to `true`.
account_deactivation_allowed: true

# Whether users can log in with their email address.
#
# Defaults to `false`.
# This has no effect if password login is disabled.
login_with_email_allowed: false
```

## `captcha`
Expand Down
4 changes: 4 additions & 0 deletions frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1662,6 +1662,10 @@ type SiteConfig implements Node {
"""
minimumPasswordComplexity: Int!
"""
Whether users can log in with their email address.
"""
loginWithEmailAllowed: Boolean!
"""
The ID of the site configuration.
"""
id: ID!
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/gql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,8 @@ export type SiteConfig = Node & {
id: Scalars['ID']['output'];
/** Imprint to show in the footer. */
imprint?: Maybe<Scalars['String']['output']>;
/** Whether users can log in with their email address. */
loginWithEmailAllowed: Scalars['Boolean']['output'];
/**
* Minimum password complexity, from 0 to 4, in terms of a zxcvbn score.
* The exact scorer (including dictionaries and other data tables)
Expand Down
12 changes: 9 additions & 3 deletions templates/pages/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,15 @@ <h1 class="title">{{ _("mas.login.headline") }}</h1>

<input type="hidden" name="csrf" value="{{ csrf_token }}" />

{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" required />
{% endcall %}
{% if features.login_with_email_allowed %}
{% call(f) field.field(label=_("mas.login.username_or_email"), name="username", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" required />
{% endcall %}
{% else %}
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" required />
{% endcall %}
{% endif %}

{% if features.password_login %}
{% call(f) field.field(label=_("common.password"), name="password", form_state=form) %}
Expand Down
Loading
Loading