Skip to content

Commit

Permalink
feat(users): add 2FA with email (#238)
Browse files Browse the repository at this point in the history
  • Loading branch information
javierEd authored Feb 23, 2025
1 parent 7aed07e commit 284b664
Show file tree
Hide file tree
Showing 28 changed files with 459 additions and 106 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions locales/fluent/en/mailer.ftl
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
by-default-all-user-accounts-are-disabled-but-we-will-let-you-know-when-your-account-is-enabled = By default, all user accounts are disabled, but we will let you know when your account is enabled
confirm-your-email = Confirm your email
confirm-your-login = Confirm your login
if-not-please-contact-us-at-the-following-email-address = If not, please contact us at the following email address
if-you-have-any-questions-please-contact-us-at-the-following-email-address = If you have any questions, please contact us at the following email address
if-you-recognize-this-action-you-can-ignore-this-message = If you recognize this action, you can ignore this message
Expand Down
1 change: 1 addition & 0 deletions locales/fluent/es/mailer.ftl
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
by-default-all-user-accounts-are-disabled-but-we-will-let-you-know-when-your-account-is-enabled = Por defecto, todas las cuentas de usuario están deshabilitadas, pero te informaremos cuando tu cuenta esté habilitada
confirm-your-email = Confirmar tu correo electrónico
confirm-your-login = Confirmar tu inicio de sesión
if-not-please-contact-us-at-the-following-email-address = Si no, por favor contáctenos a la siguiente dirección de correo electrónico
if-you-have-any-questions-please-contact-us-at-the-following-email-address = Si tienes alguna pregunta, por favor contáctenos a la siguiente dirección de correo electrónico
if-you-recognize-this-action-you-can-ignore-this-message = Si reconoces esta acción, puedes ignorar este mensaje
Expand Down
1 change: 1 addition & 0 deletions locales/fluent/pt/mailer.ftl
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
by-default-all-user-accounts-are-disabled-but-we-will-let-you-know-when-your-account-is-enabled = Por padrão, todas as contas de usuário estão desativadas, mas avisaremos quando sua conta for ativada
confirm-your-email = Confirme seu e-mail
confirm-your-login = Confirme seu login
if-not-please-contact-us-at-the-following-email-address = Caso contrário, entre em contato conosco através do seguinte endereço de e-mail
if-you-have-any-questions-please-contact-us-at-the-following-email-address = Se tiver alguma dúvida, entre em contato conosco através do seguinte endereço de e-mail
if-you-recognize-this-action-you-can-ignore-this-message = Se você reconhece esta ação, pode ignorar esta mensagem
Expand Down
3 changes: 3 additions & 0 deletions locales/leptos/en/accounts.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
a_confirmation_code_has_been_sent_to_your_email_address: A confirmation code has been sent to your email address
accounts: Accounts
back_to_login: Back to login
by_submitting_this_form_you_agree_to_the_following: By submitting this form, you agree to the following
confirm_login: Confirm login
failed_to_authenticate_user: Failed to authenticate user
failed_to_confirm_login: Failed to confirm login
failed_to_create_user: Failed to create user
failed_to_get_invitation: Failed to get invitation
failed_to_send_password_reset_code: Failed to send password reset code
Expand Down
3 changes: 3 additions & 0 deletions locales/leptos/es/accounts.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
a_confirmation_code_has_been_sent_to_your_email_address: Un código de confirmación ha sido enviado a su dirección de correo electrónico
accounts: Cuentas
back_to_login: Volver a inicio de sesión
by_submitting_this_form_you_agree_to_the_following: Al enviar este formulario, usted acepta lo siguiente
confirm_login: Confirmar inicio de sesión
failed_to_authenticate_user: Error al autenticar usuario
failed_to_confirm_login: Error al confirmar inicio de sesión
failed_to_create_user: Error al crear usuario
failed_to_send_password_reset_code: Error al enviar código de reinicio de contraseña
failed_to_get_invitation: Error al obtener invitación
Expand Down
3 changes: 3 additions & 0 deletions locales/leptos/pt/accounts.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
a_confirmation_code_has_been_sent_to_your_email_address: Um código de confirmação foi enviado para seu endereço de e-mail
accounts: Contas
back_to_login: Voltar ao login
by_submitting_this_form_you_agree_to_the_following: Ao enviar este formulário, você concorda com o seguinte
confirm_login: Confirmar login
failed_to_authenticate_user: Falha ao autenticar usuário
failed_to_confirm_login: Falha ao confirmar login
failed_to_create_user: Falha ao criar usuário
failed_to_get_invitation: Falha ao obter convite
failed_to_send_password_reset_code: Falha ao enviar código de redefinição de senha
Expand Down
1 change: 1 addition & 0 deletions mango3-accounts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ leptos = { workspace = true }
leptos_i18n = { workspace = true }
leptos_meta = { workspace = true }
leptos_router = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true, optional = true }
uuid = { workspace = true, optional = true }
wasm-bindgen = { workspace = true }
Expand Down
53 changes: 53 additions & 0 deletions mango3-accounts/src/components/login_confirmation_modal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use leptos::prelude::*;

use mango3_leptos_utils::components::forms::ActionFormErrorAlert;
use mango3_leptos_utils::components::{Modal, SubmitButton, TextField};
use mango3_leptos_utils::i18n::{t, use_i18n};
use mango3_leptos_utils::icons::InformationCircleOutlined;
use mango3_leptos_utils::models::ActionFormResp;

use crate::server_functions::AttemptToConfirmLogin;

#[component]
pub fn LoginConfirmationModal(is_open: RwSignal<bool>, #[prop(into)] on_success: Callback<()>) -> impl IntoView {
let i18n = use_i18n();
let server_action = ServerAction::<AttemptToConfirmLogin>::new();
let action_value = server_action.value();
let error_alert_is_active = RwSignal::new(false);
let error_code = RwSignal::new(None);

Effect::new(move || {
let response = ActionFormResp::from(action_value);

if response.is_success() {
is_open.set(false);
on_success.run(());
}

error_alert_is_active.set(response.is_invalid());
error_code.set(response.error("code"));
});

view! {
<Modal is_open=is_open>
<h4 class="text-lg font-bold">{t!(i18n, accounts.confirm_login)}</h4>

<div role="alert" class="alert mt-4">
<InformationCircleOutlined class="self-start my-2" />

<div>{t!(i18n, accounts.a_confirmation_code_has_been_sent_to_your_email_address)}"."</div>
</div>

<ActionForm action=server_action attr:autocomplete="off" attr:novalidate="true" attr:class="form">
<ActionFormErrorAlert
is_active=error_alert_is_active
message=move || t!(i18n, accounts.failed_to_confirm_login)
/>

<TextField label=move || t!(i18n, shared.code) name="code" error=error_code />

<SubmitButton is_loading=server_action.pending() />
</ActionForm>
</Modal>
}
}
2 changes: 2 additions & 0 deletions mango3-accounts/src/components/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mod invitation_code_modal;
mod login_confirmation_modal;
mod reset_password_modal;

pub use invitation_code_modal::InvitationCodeModal;
pub use login_confirmation_modal::LoginConfirmationModal;
pub use reset_password_modal::ResetPasswordModal;
1 change: 1 addition & 0 deletions mango3-accounts/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod app;
pub mod components;
pub mod models;
pub mod pages;
pub mod server_functions;

Expand Down
18 changes: 18 additions & 0 deletions mango3-accounts/src/models.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};

#[cfg(feature = "ssr")]
use mango3_core::models::UserSession;

#[derive(Clone, Default, Deserialize, Serialize)]
pub struct UserSessionResp {
pub is_confirmed: bool,
}

#[cfg(feature = "ssr")]
impl From<&UserSession> for UserSessionResp {
fn from(value: &UserSession) -> Self {
UserSessionResp {
is_confirmed: value.is_confirmed(),
}
}
}
63 changes: 35 additions & 28 deletions mango3-accounts/src/pages/login_page.rs
Original file line number Diff line number Diff line change
@@ -1,48 +1,44 @@
use leptos::prelude::*;

use leptos_router::hooks::use_navigate;
use mango3_leptos_utils::async_t_string;
use mango3_leptos_utils::components::{ActionFormAlert, PasswordField, SubmitButton, TextField};
use mango3_leptos_utils::components::forms::{ActionFormErrorAlert, ActionFormSuccessModal};
use mango3_leptos_utils::components::{PasswordField, SubmitButton, TextField};
use mango3_leptos_utils::context::use_basic_config;
use mango3_leptos_utils::i18n::{t, use_i18n};
use mango3_leptos_utils::models::ActionFormResp;
use mango3_leptos_utils::pages::GuestPage;
use mango3_leptos_utils::utils::ToSignalTrait;

#[server]
pub async fn attempt_to_login(username_or_email: String, password: String) -> Result<ActionFormResp, ServerFnError> {
use mango3_core::models::User;
use mango3_leptos_utils::ssr::{expect_core_context, extract_i18n, require_no_authentication, start_user_session};

let i18n = extract_i18n().await?;

if !require_no_authentication().await? {
return ActionFormResp::new_with_error(&i18n);
}

let core_context = expect_core_context();

let result = User::authenticate(&core_context, &username_or_email, &password).await;

if let Ok(ref user) = result {
start_user_session(&core_context, &user).await?;
}

ActionFormResp::new(&i18n, result)
}
use crate::components::LoginConfirmationModal;
use crate::server_functions::AttemptToLogin;

#[component]
pub fn LoginPage() -> impl IntoView {
let basic_config = use_basic_config();
let i18n = use_i18n();
let basic_config = use_basic_config();
let navigate = use_navigate();
let server_action = ServerAction::<AttemptToLogin>::new();
let action_value = server_action.value();
let error_alert_is_active = RwSignal::new(false);
let error_username_or_email = RwSignal::new(None);
let error_password = RwSignal::new(None);
let login_confirmation_modal_is_open = RwSignal::new(false);
let success_modal_is_open = RwSignal::new(false);
let text_title = async_t_string!(i18n, shared.login).to_signal();

Effect::new(move || {
let response = ActionFormResp::from(action_value);

if response.is_success() {
if response.data.as_ref().map(|user_session| user_session.is_confirmed) == Some(true) {
success_modal_is_open.set(true);
} else {
login_confirmation_modal_is_open.set(true);
}
}

error_alert_is_active.set(response.is_invalid());
error_username_or_email.set(response.error("username-or-email"));
error_password.set(response.error("password"));
});
Expand All @@ -52,11 +48,9 @@ pub fn LoginPage() -> impl IntoView {
<h2 class="text-xl font-bold mb-4">{move || text_title.get()}</h2>

<ActionForm action=server_action attr:autocomplete="off" attr:novalidate="true" attr:class="form">
<ActionFormAlert
action_value=action_value
error_message=move || t!(i18n, accounts.failed_to_authenticate_user)
redirect_to=basic_config.home_url.clone()
success_message=move || t!(i18n, accounts.user_authenticated_successfully)
<ActionFormErrorAlert
is_active=error_alert_is_active
message=move || t!(i18n, accounts.failed_to_authenticate_user)
/>

<TextField
Expand All @@ -70,6 +64,19 @@ pub fn LoginPage() -> impl IntoView {
<SubmitButton is_loading=server_action.pending() />
</ActionForm>

<LoginConfirmationModal
is_open=login_confirmation_modal_is_open
on_success=move || success_modal_is_open.set(true)
/>

<ActionFormSuccessModal
is_open=success_modal_is_open
message=move || t!(i18n, accounts.user_authenticated_successfully)
on_close=move || {
navigate(&basic_config.home_url, Default::default());
}
/>

<div class="max-w-[640px] ml-auto mr-auto mt-4 flex flex-col gap-4">
<a class="btn btn-block btn-outline" href="/register">
{t!(i18n, accounts.i_dont_have_an_account)}
Expand Down
86 changes: 78 additions & 8 deletions mango3-accounts/src/server_functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,78 @@ use mango3_leptos_utils::models::ActionFormResp;
#[cfg(feature = "ssr")]
use mango3_core::config::BASIC_CONFIG;
#[cfg(feature = "ssr")]
use mango3_core::models::{InvitationCode, User, UserPasswordReset};
use mango3_core::models::{InvitationCode, User, UserPasswordReset, UserSession};
#[cfg(feature = "ssr")]
use mango3_leptos_utils::ssr::{expect_core_context, extract_i18n, require_no_authentication};
use mango3_leptos_utils::ssr::{
expect_core_context, extract_confirmation_code, extract_i18n, finish_confirmation_code, require_no_authentication,
start_confirmation_code, start_user_session,
};

use crate::models::UserSessionResp;

#[server]
pub async fn attempt_to_confirm_login(code: String) -> Result<ActionFormResp, ServerFnError> {
let i18n = extract_i18n().await?;

if !require_no_authentication().await? {
return ActionFormResp::new_with_error(&i18n);
};

let Some(confirmation_code) = extract_confirmation_code().await? else {
return ActionFormResp::new_with_error(&i18n);
};

let core_context = expect_core_context();

let user_session = UserSession::get_by_confirmation_code(&core_context, &confirmation_code).await?;

let result = user_session.confirm(&core_context, &code).await;

match result {
Ok(ref user_session) => {
let _ = start_user_session(&core_context, &user_session).await;
let _ = finish_confirmation_code().await;

ActionFormResp::new(&i18n, result)
}
_ => ActionFormResp::new_with_error(&i18n),
}
}

#[server]
pub async fn attempt_to_login(
username_or_email: String,
password: String,
) -> Result<ActionFormResp<UserSessionResp>, ServerFnError> {
let i18n = extract_i18n().await?;

if !require_no_authentication().await? {
return ActionFormResp::new_with_error(&i18n);
}

let core_context = expect_core_context();

let Ok(user) = User::authenticate(&core_context, &username_or_email, &password).await else {
return ActionFormResp::new_with_error(&i18n);
};

let result = UserSession::insert(&core_context, &user, false).await;

match result {
Ok(ref user_session) => {
let user_session_resp = UserSessionResp::from(user_session);

if let Some(Ok(confirmation_code)) = user_session.confirmation_code(&core_context).await {
let _ = start_confirmation_code(&confirmation_code).await;
} else {
let _ = start_user_session(&core_context, &user_session).await;
}

ActionFormResp::new_with_data(&i18n, result, user_session_resp)
}
_ => ActionFormResp::new_with_error(&i18n),
}
}

#[server]
pub async fn attempt_to_register(
Expand All @@ -22,9 +91,6 @@ pub async fn attempt_to_register(
birthdate: String,
country_alpha2: String,
) -> Result<ActionFormResp, ServerFnError> {
use mango3_core::models::User;
use mango3_leptos_utils::ssr::{expect_core_context, extract_i18n, require_no_authentication, start_user_session};

let i18n = extract_i18n().await?;

if !require_no_authentication().await? {
Expand Down Expand Up @@ -56,7 +122,9 @@ pub async fn attempt_to_register(
.await;

if let Ok(ref user) = result {
start_user_session(&core_context, &user).await?;
if let Ok(user_session) = UserSession::insert(&core_context, &user, true).await {
let _ = start_user_session(&core_context, &user_session).await?;
}

if let Some(invitation_code) = invitation_code {
let _ = invitation_code.delete(&core_context).await;
Expand All @@ -67,7 +135,9 @@ pub async fn attempt_to_register(
}

#[server]
pub async fn attempt_to_send_password_reset_code(username_or_email: String) -> Result<ActionFormResp, ServerFnError> {
pub async fn attempt_to_send_password_reset_code(
username_or_email: String,
) -> Result<ActionFormResp<()>, ServerFnError> {
let i18n = extract_i18n().await?;

if !require_no_authentication().await? {
Expand All @@ -91,7 +161,7 @@ pub async fn attempt_to_update_password_with_code(
username_or_email: String,
code: String,
new_password: String,
) -> Result<ActionFormResp, ServerFnError> {
) -> Result<ActionFormResp<()>, ServerFnError> {
let i18n = extract_i18n().await?;

if !require_no_authentication().await? {
Expand Down
1 change: 1 addition & 0 deletions mango3-core/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ pub(crate) static REGEX_USERNAME: LazyLock<Regex> =
pub(crate) const HASHTAG_LOOKAROUND: [Option<&str>; 3] = [Some(" "), Some("\n"), None];

pub(crate) const KEY_TEXT_CONFIRM_YOUR_EMAIL: &str = "confirm-your-email";
pub(crate) const KEY_TEXT_CONFIRM_YOUR_LOGIN: &str = "confirm-your-login";
pub(crate) const KEY_TEXT_RESET_YOUR_PASSWORD: &str = "reset-your-password";

pub(crate) const PREFIX_GET_BLOB_BY_ID: &str = "get_blob_by_id";
Expand Down
Loading

0 comments on commit 284b664

Please sign in to comment.