Complete reset password flow
This commit is contained in:
parent
d5c5185351
commit
60671d5865
40
Cargo.lock
generated
40
Cargo.lock
generated
@ -358,6 +358,17 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-client-ip"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ef117890a418b7832678d9ea1e1c08456dd7b2fd1dadb9676cd6f0fe7eb4b21"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"forwarded-header-value",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.3.4"
|
||||
@ -737,6 +748,7 @@ dependencies = [
|
||||
"argon2",
|
||||
"async-redis-session",
|
||||
"axum",
|
||||
"axum-client-ip",
|
||||
"axum-login",
|
||||
"bytes",
|
||||
"chrono",
|
||||
@ -746,6 +758,7 @@ dependencies = [
|
||||
"futures",
|
||||
"headers",
|
||||
"http",
|
||||
"ipnetwork",
|
||||
"lettre",
|
||||
"maud",
|
||||
"notify",
|
||||
@ -1110,6 +1123,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "forwarded-header-value"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
||||
dependencies = [
|
||||
"nonempty",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
@ -1684,6 +1707,15 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6"
|
||||
|
||||
[[package]]
|
||||
name = "ipnetwork"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.11.0"
|
||||
@ -2043,6 +2075,12 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonempty"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "6.1.1"
|
||||
@ -3163,6 +3201,7 @@ dependencies = [
|
||||
"hashlink",
|
||||
"hex",
|
||||
"indexmap 2.0.0",
|
||||
"ipnetwork",
|
||||
"log",
|
||||
"memchr",
|
||||
"native-tls",
|
||||
@ -3290,6 +3329,7 @@ dependencies = [
|
||||
"hkdf",
|
||||
"hmac 0.12.1",
|
||||
"home",
|
||||
"ipnetwork",
|
||||
"itoa 1.0.9",
|
||||
"log",
|
||||
"md-5",
|
||||
|
@ -18,6 +18,7 @@ anyhow = "1"
|
||||
argon2 = "0.5"
|
||||
async-redis-session = "0.2"
|
||||
axum = { version = "0.6", features = ["form", "headers", "multipart", "query"] }
|
||||
axum-client-ip = "0.4"
|
||||
# waiting for new axum-login release which will support sqlx v. 0.7+
|
||||
axum-login = { git = "https://github.com/maxcountryman/axum-login", branch = "main", features = ["postgres"] }
|
||||
bytes = "1.4"
|
||||
@ -28,6 +29,7 @@ feed-rs = "1.3"
|
||||
futures = "0.3"
|
||||
headers = "0.3"
|
||||
http = "0.2.9"
|
||||
ipnetwork = "0.20"
|
||||
lettre = { version = "0.10", features = ["builder"] }
|
||||
maud = { version = "0.25", features = ["axum"] }
|
||||
notify = "6"
|
||||
@ -45,6 +47,7 @@ sqlx = { version = "0.7", features = [
|
||||
"migrate",
|
||||
"chrono",
|
||||
"uuid",
|
||||
"ipnetwork",
|
||||
] }
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
@ -38,6 +38,7 @@ builds
|
||||
createdb crawlnicle
|
||||
sudo -u postgres -i psql
|
||||
postgres=# ALTER DATABASE crawlnicle OWNER TO crawlnicle;
|
||||
postgres=# ALTER USER crawlnicle CREATEDB;
|
||||
\password crawlnicle
|
||||
|
||||
# Or, on Windows in PowerShell:
|
||||
|
@ -273,6 +273,11 @@ header.feed-header button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.auth-form-grid .forgot-password {
|
||||
grid-column: 2;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.auth-form-grid span.error {
|
||||
font-size: 16px;
|
||||
grid-column: 2 / 3;
|
||||
|
3
justfile
3
justfile
@ -53,5 +53,8 @@ watch-backend:
|
||||
watch:
|
||||
./watch.sh
|
||||
|
||||
reset:
|
||||
sqlx database reset
|
||||
|
||||
migrate:
|
||||
sqlx migrate run
|
||||
|
@ -90,3 +90,14 @@ create table if not exists "user_email_verification_token" (
|
||||
updated_at timestamptz
|
||||
);
|
||||
select trigger_updated_at('"user_email_verification_token"');
|
||||
|
||||
create table if not exists "user_password_reset_token" (
|
||||
token_id uuid primary key default uuid_generate_v4(),
|
||||
user_id uuid not null references "users" (user_id) on delete cascade,
|
||||
request_user_agent text,
|
||||
request_ip inet not null,
|
||||
expires_at timestamptz not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz
|
||||
);
|
||||
select trigger_updated_at('"user_password_reset_token"');
|
||||
|
@ -1,7 +1,34 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use axum_client_ip::SecureClientIpSource;
|
||||
use clap::Parser;
|
||||
use lettre::message::Mailbox;
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct IpSource(pub SecureClientIpSource);
|
||||
|
||||
impl FromStr for IpSource {
|
||||
type Err = &'static str;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
// SourceClientIpSource doesn't implement FromStr itself, so I have to implement it on this
|
||||
// wrapping newtype. See https://github.com/imbolc/axum-client-ip/issues/11
|
||||
let inner = match s {
|
||||
"RightmostForwarded" => SecureClientIpSource::RightmostForwarded,
|
||||
"RightmostXForwardedFor" => SecureClientIpSource::RightmostXForwardedFor,
|
||||
"XRealIp" => SecureClientIpSource::XRealIp,
|
||||
"FlyClientIp" => SecureClientIpSource::FlyClientIp,
|
||||
"TrueClientIp" => SecureClientIpSource::TrueClientIp,
|
||||
"CfConnectingIp" => SecureClientIpSource::CfConnectingIp,
|
||||
"ConnectInfo" => SecureClientIpSource::ConnectInfo,
|
||||
_ => return Err("Unknown variant"),
|
||||
};
|
||||
Ok(Self(inner))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser, Clone, Debug)]
|
||||
pub struct Config {
|
||||
#[clap(long, env)]
|
||||
@ -32,4 +59,6 @@ pub struct Config {
|
||||
pub email_from: Mailbox,
|
||||
#[clap(long, env)]
|
||||
pub session_secret: String,
|
||||
#[clap(long, env)]
|
||||
pub ip_source: IpSource,
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::Response;
|
||||
use axum::{Form, TypedHeader};
|
||||
use axum_login::SqlxStore;
|
||||
use lettre::SmtpTransport;
|
||||
use maud::html;
|
||||
use serde::Deserialize;
|
||||
|
127
src/handlers/forgot_password.rs
Normal file
127
src/handlers/forgot_password.rs
Normal file
@ -0,0 +1,127 @@
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::TypedHeader;
|
||||
use axum::{extract::State, Form};
|
||||
use axum_client_ip::SecureClientIp;
|
||||
use headers::UserAgent;
|
||||
use lettre::SmtpTransport;
|
||||
use maud::html;
|
||||
use serde::Deserialize;
|
||||
use serde_with::serde_as;
|
||||
use sqlx::PgPool;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::htmx::HXTarget;
|
||||
use crate::mailers::forgot_password::send_forgot_password_email;
|
||||
use crate::models::user::AuthContext;
|
||||
use crate::partials::forgot_password_form::{forgot_password_form, ForgotPasswordFormProps};
|
||||
use crate::{models::user::User, partials::layout::Layout};
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize)]
|
||||
pub struct ForgotPassword {
|
||||
email: String,
|
||||
}
|
||||
|
||||
pub fn forgot_password_page(
|
||||
hx_target: Option<TypedHeader<HXTarget>>,
|
||||
layout: Layout,
|
||||
form_props: ForgotPasswordFormProps,
|
||||
) -> Response {
|
||||
layout
|
||||
.with_subtitle("forgot password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Forgot Password" }
|
||||
}
|
||||
p class="readable-width" {
|
||||
"A password reset email will be sent if the email submitted matches an account in the system and the email is verfied. If your email is not verified, " a href="/confirm-email" { "please verify your email first" } "."
|
||||
}
|
||||
(forgot_password_form(form_props))
|
||||
}
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
auth: AuthContext,
|
||||
hx_target: Option<TypedHeader<HXTarget>>,
|
||||
layout: Layout,
|
||||
) -> Result<Response> {
|
||||
Ok(forgot_password_page(
|
||||
hx_target,
|
||||
layout,
|
||||
ForgotPasswordFormProps {
|
||||
email: auth.current_user.map(|u| u.email),
|
||||
email_error: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn post(
|
||||
State(pool): State<PgPool>,
|
||||
State(mailer): State<SmtpTransport>,
|
||||
State(config): State<Config>,
|
||||
SecureClientIp(ip): SecureClientIp,
|
||||
hx_target: Option<TypedHeader<HXTarget>>,
|
||||
user_agent: Option<TypedHeader<UserAgent>>,
|
||||
layout: Layout,
|
||||
Form(forgot_password): Form<ForgotPassword>,
|
||||
) -> Result<Response> {
|
||||
dbg!(&ip);
|
||||
dbg!(&user_agent);
|
||||
dbg!(&forgot_password.email);
|
||||
let user: User = match User::get_by_email(&pool, forgot_password.email.clone()).await {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
dbg!(&err);
|
||||
if let Error::NotFoundString(_, _) = err {
|
||||
info!(email = forgot_password.email, "invalid email");
|
||||
return Ok(layout
|
||||
.with_subtitle("forgot password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Reset password email sent" }
|
||||
}
|
||||
p class="readable-width" {
|
||||
"If the email you entered matched an existing account with a verified email, then a password reset email was sent. Please follow the link sent in the email."
|
||||
}
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
if user.email_verified {
|
||||
info!(user_id = %user.user_id, "user exists with verified email, sending password reset email");
|
||||
send_forgot_password_email(
|
||||
pool,
|
||||
mailer,
|
||||
config,
|
||||
user,
|
||||
ip.into(),
|
||||
user_agent.map(|ua| ua.to_string()),
|
||||
);
|
||||
} else {
|
||||
warn!(user_id = %user.user_id, "user exists with unverified email, skip sending password reset email");
|
||||
}
|
||||
Ok(layout
|
||||
.with_subtitle("forgot password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Reset password email sent" }
|
||||
}
|
||||
p class="readable-width" {
|
||||
"If the email you entered matched an existing account with a verified email, then a password reset email was sent. Please follow the link sent in the email."
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
@ -57,7 +57,7 @@ pub async fn post(
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
if let Error::NotFoundString(_, _) = err {
|
||||
info!(email = login.email, "invalid enail");
|
||||
info!(email = login.email, "invalid email");
|
||||
return Ok(login_page(
|
||||
hx_target,
|
||||
layout,
|
||||
|
@ -1,8 +1,6 @@
|
||||
use axum::response::Redirect;
|
||||
use crate::{models::user::AuthContext, htmx::HXRedirect};
|
||||
|
||||
use crate::models::user::AuthContext;
|
||||
|
||||
pub async fn get(mut auth: AuthContext) -> Redirect {
|
||||
pub async fn get(mut auth: AuthContext) -> HXRedirect {
|
||||
auth.logout().await;
|
||||
Redirect::to("/")
|
||||
HXRedirect::to("/").reload(true)
|
||||
}
|
||||
|
@ -6,7 +6,9 @@ pub mod home;
|
||||
pub mod import;
|
||||
pub mod feed;
|
||||
pub mod feeds;
|
||||
pub mod forgot_password;
|
||||
pub mod log;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod register;
|
||||
pub mod reset_password;
|
||||
|
306
src/handlers/reset_password.rs
Normal file
306
src/handlers/reset_password.rs
Normal file
@ -0,0 +1,306 @@
|
||||
use axum::extract::Query;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::TypedHeader;
|
||||
use axum::{extract::State, Form};
|
||||
use axum_client_ip::SecureClientIp;
|
||||
use headers::UserAgent;
|
||||
use lettre::SmtpTransport;
|
||||
use maud::html;
|
||||
use serde::Deserialize;
|
||||
use serde_with::serde_as;
|
||||
use sqlx::PgPool;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::htmx::HXTarget;
|
||||
use crate::mailers::reset_password::send_password_reset_email;
|
||||
use crate::models::user::UpdateUserPassword;
|
||||
use crate::models::user_password_reset_token::UserPasswordResetToken;
|
||||
use crate::partials::reset_password_form::{reset_password_form, ResetPasswordFormProps};
|
||||
use crate::uuid::Base62Uuid;
|
||||
use crate::{models::user::User, partials::layout::Layout};
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetPassword {
|
||||
pub token: Uuid,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub password_confirmation: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetPasswordQuery {
|
||||
pub token_id: Option<Base62Uuid>,
|
||||
}
|
||||
|
||||
pub fn reset_password_page(
|
||||
hx_target: Option<TypedHeader<HXTarget>>,
|
||||
layout: Layout,
|
||||
form_props: ResetPasswordFormProps,
|
||||
) -> Response {
|
||||
layout
|
||||
.with_subtitle("forgot password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Reset Password" }
|
||||
}
|
||||
p {
|
||||
"A password reset email will be sent if the email submitted matches an account in the system and the email is verfied. If your email is not verified, " a href="/confirm-email" { "please verify your email first" } "."
|
||||
}
|
||||
(reset_password_form(form_props))
|
||||
}
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
State(pool): State<PgPool>,
|
||||
hx_target: Option<TypedHeader<HXTarget>>,
|
||||
layout: Layout,
|
||||
query: Query<ResetPasswordQuery>,
|
||||
) -> Result<Response> {
|
||||
if let Some(token_id) = query.token_id {
|
||||
info!(token_id = %token_id.as_uuid(), "get with token_id");
|
||||
let token = match UserPasswordResetToken::get(&pool, token_id.as_uuid()).await {
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
if let Error::NotFoundUuid(_, _) = err {
|
||||
warn!(token_id = %token_id.as_uuid(), "token not found in database");
|
||||
return Ok(layout
|
||||
.with_subtitle("reset password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Password reset token not found" }
|
||||
}
|
||||
p class="readable-width" { "The reset password link has already been used or is invalid." }
|
||||
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } "." }
|
||||
}
|
||||
}));
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
if token.expired() {
|
||||
warn!(token_id = %token.token_id, "token expired");
|
||||
Ok(layout
|
||||
.with_subtitle("reset password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Password reset token is expired" }
|
||||
}
|
||||
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } ". The link in the email will be valid for 24 hours." }
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
info!(token_id = %token.token_id, "token valid, showing reset password form");
|
||||
let user = User::get(&pool, token.user_id).await?;
|
||||
Ok(layout
|
||||
.with_subtitle("reset password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Reset Password" }
|
||||
}
|
||||
(reset_password_form(ResetPasswordFormProps {
|
||||
token: token.token_id,
|
||||
email: user.email,
|
||||
password_error: None,
|
||||
general_error: None,
|
||||
}))
|
||||
}
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
Ok(layout
|
||||
.with_subtitle("reset password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Missing password reset token" }
|
||||
}
|
||||
p class="readable-width" { "Passwords can only be reset by requesting a password reset email and following the unique link within the email."}
|
||||
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } "." }
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post(
|
||||
State(pool): State<PgPool>,
|
||||
State(mailer): State<SmtpTransport>,
|
||||
State(config): State<Config>,
|
||||
SecureClientIp(ip): SecureClientIp,
|
||||
hx_target: Option<TypedHeader<HXTarget>>,
|
||||
user_agent: Option<TypedHeader<UserAgent>>,
|
||||
layout: Layout,
|
||||
Form(reset_password): Form<ResetPassword>,
|
||||
) -> Result<Response> {
|
||||
if reset_password.password != reset_password.password_confirmation {
|
||||
return Ok(layout
|
||||
.with_subtitle("reset password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Reset Password" }
|
||||
}
|
||||
(reset_password_form(ResetPasswordFormProps {
|
||||
token: reset_password.token,
|
||||
email: reset_password.email,
|
||||
password_error: Some("passwords do not match".to_string()),
|
||||
general_error: None,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
}
|
||||
let token = match UserPasswordResetToken::get(&pool, reset_password.token).await {
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
if let Error::NotFoundUuid(_, _) = err {
|
||||
warn!(token_id = %reset_password.token, "token not found in database");
|
||||
return Ok(layout
|
||||
.with_subtitle("reset password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Reset Password" }
|
||||
}
|
||||
(reset_password_form(ResetPasswordFormProps {
|
||||
token: reset_password.token,
|
||||
email: reset_password.email,
|
||||
password_error: None,
|
||||
general_error: Some("token not found".to_string()),
|
||||
}))
|
||||
p class="error readable-width" { "The reset password link has already been used or is invalid." }
|
||||
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } "." }
|
||||
}
|
||||
}));
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
if token.expired() {
|
||||
warn!(token_id = %token.token_id, "token expired");
|
||||
return Ok(layout
|
||||
.with_subtitle("reset password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Reset Password" }
|
||||
}
|
||||
(reset_password_form(ResetPasswordFormProps {
|
||||
token: reset_password.token,
|
||||
email: reset_password.email,
|
||||
password_error: None,
|
||||
general_error: Some("token expired".to_string()),
|
||||
}))
|
||||
p class="error readable-width" { "The reset password link has expired." }
|
||||
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } ". The link in the email will be valid for 24 hours." }
|
||||
}
|
||||
}));
|
||||
}
|
||||
let user = match User::get(&pool, token.user_id).await {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
if let Error::NotFoundString(_, _) = err {
|
||||
info!(user_id = %token.user_id, email = reset_password.email, "invalid token user_id");
|
||||
return Ok(layout
|
||||
.with_subtitle("reset password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Reset Password" }
|
||||
}
|
||||
(reset_password_form(ResetPasswordFormProps {
|
||||
token: reset_password.token,
|
||||
email: reset_password.email,
|
||||
password_error: None,
|
||||
general_error: Some("user not found".to_string()),
|
||||
}))
|
||||
p class="error readable-width" { "The user associated with this password reset could not be found." }
|
||||
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } "." }
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
info!(user_id = %user.user_id, "user exists with verified email, resetting password");
|
||||
// TODO: do both in transaction
|
||||
UserPasswordResetToken::delete(&pool, reset_password.token).await?;
|
||||
let user = match user
|
||||
.update_password(
|
||||
&pool,
|
||||
UpdateUserPassword {
|
||||
password: reset_password.password,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
if let Error::InvalidEntity(validation_errors) = err {
|
||||
let field_errors = validation_errors.field_errors();
|
||||
return Ok(layout
|
||||
.with_subtitle("reset password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Reset Password" }
|
||||
}
|
||||
(reset_password_form(ResetPasswordFormProps {
|
||||
token: reset_password.token,
|
||||
email: reset_password.email,
|
||||
password_error: field_errors.get("password").map(|&errors| {
|
||||
errors
|
||||
.iter()
|
||||
.filter_map(|error| error.message.clone().map(|m| m.to_string()))
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
}),
|
||||
general_error: None,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
send_password_reset_email(
|
||||
mailer,
|
||||
config,
|
||||
user,
|
||||
ip.into(),
|
||||
user_agent.map(|ua| ua.to_string()),
|
||||
);
|
||||
Ok(layout
|
||||
.with_subtitle("reset password")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Password reset!" }
|
||||
}
|
||||
p class="readable-width" {
|
||||
"Your password has been reset. "
|
||||
a href="/" { "Return home" }
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
105
src/mailers/forgot_password.rs
Normal file
105
src/mailers/forgot_password.rs
Normal file
@ -0,0 +1,105 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use ipnetwork::IpNetwork;
|
||||
use lettre::message::{Mailbox, Message, MultiPart};
|
||||
use lettre::{SmtpTransport, Transport};
|
||||
use maud::html;
|
||||
use sqlx::PgPool;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::models::user::User;
|
||||
use crate::models::user_password_reset_token::{CreatePasswordResetToken, UserPasswordResetToken};
|
||||
use crate::uuid::Base62Uuid;
|
||||
|
||||
// TODO: put in config
|
||||
const PASSWORD_RESET_TOKEN_EXPIRATION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
pub fn send_forgot_password_email(
|
||||
pool: PgPool,
|
||||
mailer: SmtpTransport,
|
||||
config: Config,
|
||||
user: User,
|
||||
request_ip: IpNetwork,
|
||||
request_user_agent: Option<String>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let user_email_address = match user.email.parse() {
|
||||
Ok(address) => address,
|
||||
Err(err) => {
|
||||
error!("failed to parse email address: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mailbox = Mailbox::new(user.name.clone(), user_email_address);
|
||||
let token = match UserPasswordResetToken::create(
|
||||
&pool,
|
||||
CreatePasswordResetToken {
|
||||
token_id: Uuid::new_v4(), // cyptographically-secure random uuid
|
||||
user_id: user.user_id,
|
||||
request_ip,
|
||||
request_user_agent,
|
||||
expires_at: Utc::now() + PASSWORD_RESET_TOKEN_EXPIRATION,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
error!("failed to create user password reset token: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut password_reset_link = config.public_url.clone();
|
||||
password_reset_link.set_path("reset-password");
|
||||
password_reset_link
|
||||
.query_pairs_mut()
|
||||
.append_pair("token_id", &Base62Uuid::from(token.token_id).to_string());
|
||||
let password_reset_link = password_reset_link.as_str();
|
||||
|
||||
let email = match Message::builder()
|
||||
.from(config.email_from.clone())
|
||||
.to(mailbox)
|
||||
.subject("Reset your crawlnicle account password")
|
||||
.multipart(MultiPart::alternative_plain_html(
|
||||
format!(
|
||||
"Reset your crawlnicle account password\n\nA password reset has been requested for your crawlnicle account. If you did not request this, please ignore this email.\n\nRequest IP address: {}\nRequest user agent: {}\n\nClick here to reset your password: {}",
|
||||
token.request_ip,
|
||||
token.request_user_agent.clone().unwrap_or_default(),
|
||||
password_reset_link
|
||||
),
|
||||
html! {
|
||||
h1 { "Reset your crawlnicle account password" }
|
||||
p {
|
||||
"A password reset has been requested for your crawlnicle account. If you did not request this, please ignore this email."
|
||||
}
|
||||
div {
|
||||
"IP address: " (token.request_ip.to_string())
|
||||
}
|
||||
div {
|
||||
"user agent: " (token.request_user_agent.unwrap_or_default())
|
||||
}
|
||||
p {
|
||||
a href=(password_reset_link) { "Click here to reset your password" }
|
||||
}
|
||||
}.into_string(),
|
||||
))
|
||||
{
|
||||
Ok(email) => email,
|
||||
Err(err) => {
|
||||
error!("failed to create email: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: notify the user that email has been sent somehow
|
||||
match mailer.send(&email) {
|
||||
Ok(_) => (),
|
||||
Err(err) => {
|
||||
error!("failed to send email: {}", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
@ -1 +1,3 @@
|
||||
pub mod email_verification;
|
||||
pub mod forgot_password;
|
||||
pub mod reset_password;
|
||||
|
65
src/mailers/reset_password.rs
Normal file
65
src/mailers/reset_password.rs
Normal file
@ -0,0 +1,65 @@
|
||||
use ipnetwork::IpNetwork;
|
||||
use lettre::message::{Mailbox, Message, MultiPart};
|
||||
use lettre::{SmtpTransport, Transport};
|
||||
use maud::html;
|
||||
use tracing::error;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::models::user::User;
|
||||
|
||||
pub fn send_password_reset_email(
|
||||
mailer: SmtpTransport,
|
||||
config: Config,
|
||||
user: User,
|
||||
request_ip: IpNetwork,
|
||||
request_user_agent: Option<String>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let user_email_address = match user.email.parse() {
|
||||
Ok(address) => address,
|
||||
Err(err) => {
|
||||
error!("failed to parse email address: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mailbox = Mailbox::new(user.name.clone(), user_email_address);
|
||||
|
||||
let email = match Message::builder()
|
||||
.from(config.email_from.clone())
|
||||
.to(mailbox)
|
||||
.subject("Your crawlnicle account password was reset")
|
||||
.multipart(MultiPart::alternative_plain_html(
|
||||
format!(
|
||||
"Your crawlnicle account password was reset\n\nIf you did not perform this change, then this might indicate that your account has been compromised.\n\nRequest IP address: {}\nRequest user agent: {}",
|
||||
request_ip,
|
||||
request_user_agent.clone().unwrap_or_default()
|
||||
),
|
||||
html! {
|
||||
h1 { "Your crawlnicle account password was reset" }
|
||||
p {
|
||||
"If you did not perform this change, then this might indicate that your account has been compromised."
|
||||
}
|
||||
div {
|
||||
"IP address: " (request_ip.to_string())
|
||||
}
|
||||
div {
|
||||
"user agent: " (request_user_agent.unwrap_or_default())
|
||||
}
|
||||
}.into_string(),
|
||||
))
|
||||
{
|
||||
Ok(email) => email,
|
||||
Err(err) => {
|
||||
error!("failed to create email: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match mailer.send(&email) {
|
||||
Ok(_) => (),
|
||||
Err(err) => {
|
||||
error!("failed to send email: {}", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
17
src/main.rs
17
src/main.rs
@ -8,8 +8,7 @@ use axum::{
|
||||
Extension, Router,
|
||||
};
|
||||
use axum_login::{
|
||||
axum_sessions::SessionLayer,
|
||||
AuthLayer, PostgresStore, RequireAuthorizationLayer,
|
||||
axum_sessions::SessionLayer, AuthLayer, PostgresStore, RequireAuthorizationLayer,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use clap::Parser;
|
||||
@ -17,7 +16,6 @@ use dotenvy::dotenv;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::SmtpTransport;
|
||||
use notify::Watcher;
|
||||
use rand::Rng;
|
||||
use reqwest::Client;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use tokio::sync::watch::channel;
|
||||
@ -41,7 +39,7 @@ use uuid::Uuid;
|
||||
async fn serve(app: Router, addr: SocketAddr) -> Result<()> {
|
||||
debug!("listening on {}", addr);
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@ -75,7 +73,7 @@ async fn main() -> Result<()> {
|
||||
.await?;
|
||||
|
||||
let session_store = RedisSessionStore::new(config.redis_url.clone())?;
|
||||
let session_layer = SessionLayer::new(session_store, &secret).with_secure(false);
|
||||
let session_layer = SessionLayer::new(session_store, secret).with_secure(false);
|
||||
let user_store = PostgresStore::<User>::new(pool.clone())
|
||||
.with_query("select * from users where user_id = $1");
|
||||
let auth_layer = AuthLayer::new(user_store, &secret);
|
||||
@ -100,6 +98,8 @@ async fn main() -> Result<()> {
|
||||
let _ = crawl_scheduler.bootstrap().await;
|
||||
let importer = ImporterHandle::new(pool.clone(), crawl_scheduler.clone(), imports.clone());
|
||||
|
||||
let ip_source_extension = config.ip_source.0.clone().into_extension();
|
||||
|
||||
let addr = format!("{}:{}", &config.host, &config.port).parse()?;
|
||||
let mut app = Router::new()
|
||||
.route("/protected", get(protected_handler))
|
||||
@ -129,6 +129,10 @@ async fn main() -> Result<()> {
|
||||
.route("/register", post(handlers::register::post))
|
||||
.route("/confirm-email", get(handlers::confirm_email::get))
|
||||
.route("/confirm-email", post(handlers::confirm_email::post))
|
||||
.route("/forgot-password", get(handlers::forgot_password::get))
|
||||
.route("/forgot-password", post(handlers::forgot_password::post))
|
||||
.route("/reset-password", get(handlers::reset_password::get))
|
||||
.route("/reset-password", post(handlers::reset_password::post))
|
||||
.nest_service("/static", ServeDir::new("static"))
|
||||
.with_state(AppState {
|
||||
pool,
|
||||
@ -144,7 +148,8 @@ async fn main() -> Result<()> {
|
||||
})
|
||||
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
|
||||
.layer(auth_layer)
|
||||
.layer(session_layer);
|
||||
.layer(session_layer)
|
||||
.layer(ip_source_extension);
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
debug!("starting livereload");
|
||||
|
@ -108,51 +108,49 @@ impl Entry {
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
if let Some(published_before) = options.published_before {
|
||||
if let Some(id_before) = options.id_before {
|
||||
sqlx::query_as!(
|
||||
Entry,
|
||||
"select * from entry
|
||||
where deleted_at is null
|
||||
and (published_at, entry_id) < ($1, $2)
|
||||
order by published_at desc, entry_id desc
|
||||
limit $3
|
||||
",
|
||||
published_before,
|
||||
id_before,
|
||||
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
Entry,
|
||||
"select * from entry
|
||||
where deleted_at is null
|
||||
and published_at < $1
|
||||
order by published_at desc
|
||||
limit $2
|
||||
",
|
||||
published_before,
|
||||
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
} else if let Some(published_before) = options.published_before {
|
||||
if let Some(id_before) = options.id_before {
|
||||
sqlx::query_as!(
|
||||
Entry,
|
||||
"select * from entry
|
||||
where deleted_at is null
|
||||
and (published_at, entry_id) < ($1, $2)
|
||||
order by published_at desc, entry_id desc
|
||||
limit $3
|
||||
",
|
||||
published_before,
|
||||
id_before,
|
||||
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
Entry,
|
||||
"select * from entry
|
||||
where deleted_at is null
|
||||
and published_at < $1
|
||||
order by published_at desc
|
||||
limit $1
|
||||
limit $2
|
||||
",
|
||||
published_before,
|
||||
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
Entry,
|
||||
"select * from entry
|
||||
where deleted_at is null
|
||||
order by published_at desc
|
||||
limit $1
|
||||
",
|
||||
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,3 +2,4 @@ pub mod entry;
|
||||
pub mod feed;
|
||||
pub mod user;
|
||||
pub mod user_email_verification_token;
|
||||
pub mod user_password_reset_token;
|
||||
|
@ -34,6 +34,16 @@ pub struct CreateUser {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default, Validate)]
|
||||
pub struct UpdateUserPassword {
|
||||
#[validate(length(
|
||||
min = 8,
|
||||
max = 255,
|
||||
message = "password must be between 8 and 255 characters long"
|
||||
))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl AuthUser<Uuid> for User {
|
||||
fn get_id(&self) -> Uuid {
|
||||
self.user_id
|
||||
@ -130,6 +140,33 @@ impl User {
|
||||
Error::Sqlx(error)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_password(&self, pool: &PgPool, payload: UpdateUserPassword) -> Result<User> {
|
||||
payload.validate()?;
|
||||
let password_hash = hash_password(payload.password).await?;
|
||||
|
||||
Ok(sqlx::query_as!(
|
||||
User,
|
||||
r#"update users set
|
||||
password_hash = $2
|
||||
where
|
||||
user_id = $1
|
||||
returning
|
||||
user_id,
|
||||
email,
|
||||
email_verified,
|
||||
password_hash,
|
||||
name,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at
|
||||
"#,
|
||||
self.user_id,
|
||||
password_hash,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
pub type AuthContext = axum_login::extractors::AuthContext<Uuid, User, PostgresStore<User>>;
|
||||
|
87
src/models/user_password_reset_token.rs
Normal file
87
src/models/user_password_reset_token.rs
Normal file
@ -0,0 +1,87 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use ipnetwork::IpNetwork;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserPasswordResetToken {
|
||||
pub token_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub request_user_agent: Option<String>,
|
||||
pub request_ip: IpNetwork,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatePasswordResetToken {
|
||||
pub token_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub request_user_agent: Option<String>,
|
||||
pub request_ip: IpNetwork,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl UserPasswordResetToken {
|
||||
pub fn expired(&self) -> bool {
|
||||
Utc::now() > self.expires_at
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, token_id: Uuid) -> Result<UserPasswordResetToken> {
|
||||
sqlx::query_as!(
|
||||
UserPasswordResetToken,
|
||||
r#"select
|
||||
*
|
||||
from user_password_reset_token
|
||||
where token_id = $1"#,
|
||||
token_id
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if let sqlx::error::Error::RowNotFound = error {
|
||||
return Error::NotFoundUuid("user_password_reset_token", token_id);
|
||||
}
|
||||
Error::Sqlx(error)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
payload: CreatePasswordResetToken,
|
||||
) -> Result<UserPasswordResetToken> {
|
||||
Ok(sqlx::query_as!(
|
||||
UserPasswordResetToken,
|
||||
r#"insert into user_password_reset_token (
|
||||
token_id, user_id, request_user_agent, request_ip, expires_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5
|
||||
) returning *"#,
|
||||
payload.token_id,
|
||||
payload.user_id,
|
||||
payload.request_user_agent,
|
||||
payload.request_ip,
|
||||
payload.expires_at
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
pool: &PgPool,
|
||||
token_id: Uuid,
|
||||
) -> Result<()> {
|
||||
sqlx::query!(
|
||||
r#"delete from user_password_reset_token
|
||||
where token_id = $1"#,
|
||||
token_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
27
src/partials/forgot_password_form.rs
Normal file
27
src/partials/forgot_password_form.rs
Normal file
@ -0,0 +1,27 @@
|
||||
use maud::{html, Markup};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ForgotPasswordFormProps {
|
||||
pub email: Option<String>,
|
||||
pub email_error: Option<String>,
|
||||
}
|
||||
|
||||
pub fn forgot_password_form(props: ForgotPasswordFormProps) -> Markup {
|
||||
let ForgotPasswordFormProps { email, email_error } = props;
|
||||
html! {
|
||||
form action="forgot-password" method="post" class="auth-form-grid" {
|
||||
label for="email" { "Email" }
|
||||
input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
value=(email.unwrap_or_default())
|
||||
required;
|
||||
@if let Some(email_error) = email_error {
|
||||
span class="error" { (email_error) }
|
||||
}
|
||||
button type="submit" { "Send password reset email" }
|
||||
}
|
||||
}
|
||||
}
|
@ -31,6 +31,7 @@ pub fn login_form(props: LoginFormProps) -> Markup {
|
||||
@if let Some(general_error) = general_error {
|
||||
span class="error" { (general_error) }
|
||||
}
|
||||
a href="/forgot-password" class="forgot-password" { "Forgot password" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,9 +5,11 @@ pub mod entry_list;
|
||||
pub mod feed_link;
|
||||
pub mod feed_list;
|
||||
pub mod footer;
|
||||
pub mod forgot_password_form;
|
||||
pub mod header;
|
||||
pub mod layout;
|
||||
pub mod login_form;
|
||||
pub mod opml_import_form;
|
||||
pub mod register_form;
|
||||
pub mod reset_password_form;
|
||||
pub mod user_name;
|
||||
|
43
src/partials/reset_password_form.rs
Normal file
43
src/partials/reset_password_form.rs
Normal file
@ -0,0 +1,43 @@
|
||||
use maud::{html, Markup};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResetPasswordFormProps {
|
||||
pub token: Uuid,
|
||||
pub email: String,
|
||||
pub password_error: Option<String>,
|
||||
pub general_error: Option<String>,
|
||||
}
|
||||
|
||||
pub fn reset_password_form(props: ResetPasswordFormProps) -> Markup {
|
||||
let ResetPasswordFormProps { token, email, password_error, general_error } = props;
|
||||
html! {
|
||||
form action="reset-password" method="post" class="auth-form-grid" {
|
||||
input
|
||||
type="text"
|
||||
name="token"
|
||||
id="token"
|
||||
value=(token.to_string())
|
||||
style="display:none;";
|
||||
label for="email" { "Email" }
|
||||
input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
value=(email)
|
||||
required;
|
||||
label for="password" { "Password" }
|
||||
input type="password" name="password" id="password" placeholder="Password" minlength="8" maxlength="255" required;
|
||||
@if let Some(password_error) = password_error {
|
||||
span class="error" { (password_error) }
|
||||
}
|
||||
label for="password_confirmation" { "Confirm Password" }
|
||||
input type="password" name="password_confirmation" id="password_confirmation" placeholder="Confirm Password" required;
|
||||
button type="submit" { "Reset password" }
|
||||
@if let Some(general_error) = general_error {
|
||||
span class="error" { (general_error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user