feat: enable login via email magic link

This commit is contained in:
Zack Fu Zi Xiang 2024-02-08 12:03:21 +08:00
parent 8def2bfaf3
commit b4fd4cea05
No known key found for this signature in database
GPG Key ID: 39DE600AFEEED522
10 changed files with 119 additions and 94 deletions

View File

@ -19,11 +19,12 @@ use gotrue::params::{
MagicLinkParams,
};
use gotrue_entity::dto::{GotrueTokenResponse, SignUpResponse, UpdateGotrueUserParams, User};
use gotrue_entity::error::GoTrueError;
use tracing::info;
pub fn router() -> Router<AppState> {
Router::new()
.route("/login", post(login_handler))
.route("/signin", post(sign_in_handler))
.route("/signup", post(sign_up_handler))
.route("/login_refresh/:refresh_token", post(login_refresh_handler))
.route("/logout", post(logout_handler))
@ -112,18 +113,14 @@ pub async fn open_app_handler(session: UserSession) -> Result<HeaderMap, WebApiE
// to the target user
pub async fn invite_handler(
State(state): State<AppState>,
session: UserSession,
Form(param): Form<WebApiInviteUserRequest>,
) -> Result<WebApiResponse<()>, WebApiError<'static>> {
state
.gotrue_client
.magic_link(
&session.token.access_token,
&MagicLinkParams {
email: param.email,
..Default::default()
},
)
.magic_link(&MagicLinkParams {
email: param.email,
..Default::default()
})
.await?;
Ok(WebApiResponse::<()>::from_str("Invitation sent".into()))
}
@ -275,13 +272,18 @@ pub async fn login_refresh_handler(
// login and set the cookie
// sign up if not exist
pub async fn login_handler(
pub async fn sign_in_handler(
State(state): State<AppState>,
jar: CookieJar,
Form(param): Form<WebApiLoginRequest>,
) -> Result<(CookieJar, HeaderMap, WebApiResponse<()>), WebApiError<'static>> {
if param.password.is_empty() {
let res = send_magic_link(State(state), &param.email).await?;
return Ok((CookieJar::new(), HeaderMap::new(), res));
}
// Attempt to sign in with email and password
let token_res = state
let token = state
.gotrue_client
.token(&gotrue::grant::Grant::Password(
gotrue::grant::PasswordGrant {
@ -289,48 +291,36 @@ pub async fn login_handler(
password: param.password.to_owned(),
},
))
.await;
.await?;
match token_res {
Ok(token) => session_login(State(state), token, jar).await, // login success
Err(err) => match &err {
GoTrueError::ClientError(client_err) => {
match (
client_err.error.as_str(),
client_err.error_description.as_deref(),
) {
// Email not exist or wrong password
("invalid_grant", Some("Invalid login credentials")) => {
let sign_up_res = state
.gotrue_client
.sign_up_with_referrer(&param.email, &param.password, Some("/"))
.await;
session_login(State(state), token, jar).await
}
match sign_up_res {
Ok(resp) => match resp {
// when GOTRUE_MAILER_AUTOCONFIRM=true, auto sign in
SignUpResponse::Authenticated(token) => {
session_login(State(state), token, jar).await
},
SignUpResponse::NotAuthenticated(user) => match user.identities {
Some(_identities) => {
// new user, awaiting email verification
Ok((
jar,
HeaderMap::new(),
WebApiResponse::<()>::from_str("Email Verification Sent".into()),
))
},
None => Err(err.into()), // user exists but sign in password not correct
},
},
Err(err) => Err(err.into()),
}
},
_ => Err(err.into()),
}
},
_ => Err(err.into()),
pub async fn sign_up_handler(
State(state): State<AppState>,
jar: CookieJar,
Form(param): Form<WebApiLoginRequest>,
) -> Result<(CookieJar, HeaderMap, WebApiResponse<()>), WebApiError<'static>> {
if param.password.is_empty() {
let res = send_magic_link(State(state), &param.email).await?;
return Ok((CookieJar::new(), HeaderMap::new(), res));
}
let sign_up_res = state
.gotrue_client
.sign_up_with_referrer(&param.email, &param.password, Some("/"))
.await?;
match sign_up_res {
// when GOTRUE_MAILER_AUTOCONFIRM=true, auto sign in
SignUpResponse::Authenticated(token) => session_login(State(state), token, jar).await,
SignUpResponse::NotAuthenticated(user) => {
info!("user signed up and not authenticated: {:?}", user);
Ok((
jar,
HeaderMap::new(),
WebApiResponse::<()>::from_str("Email Verification Sent".into()),
))
},
}
}
@ -382,6 +372,22 @@ async fn session_login(
))
}
async fn send_magic_link(
State(state): State<AppState>,
email: &str,
) -> Result<WebApiResponse<()>, WebApiError<'static>> {
Ok(
state
.gotrue_client
.magic_link(&MagicLinkParams {
email: email.to_owned(),
..Default::default()
})
.await?
.into(),
)
}
fn get_base_url(header_map: &HeaderMap) -> String {
let scheme = get_header_value_or_default(header_map, "x-scheme", "http");
let host = get_header_value_or_default(header_map, "host", "localhost");

View File

@ -1,6 +1,6 @@
<div>
<h4>Please enter the following information to create new SSO</h4>
<form hx-post="/web-api/admin/sso" hx-target="#none">
<form hx-post="/web-api/admin/sso">
<table>
<tr>
<td>Email</td>

View File

@ -2,7 +2,7 @@
{% include "user_details.html" %}
<div>
<form hx-put="/web-api/admin/user/{{ user.id|escape }}" hx-target="#none">
<form hx-put="/web-api/admin/user/{{ user.id|escape }}">
<table>
<tr>
<td>Set Password:</td>

View File

@ -1,6 +1,6 @@
<div>
<h3>Password Change</h3>
<form hx-post="/web-api/change_password" hx-target="#none">
<form hx-post="/web-api/change_password">
<table>
<tr>
<td>New Password:</td>

View File

@ -1,6 +1,6 @@
<div id="create-user">
<h4>Please enter the following information to create a new user</h4>
<form hx-post="/web-api/admin/user" hx-target="#none">
<form hx-post="/web-api/admin/user">
<table>
<tr>
<td>Email:</td>

View File

@ -1,6 +1,6 @@
<div id="invite-user">
<h4>Please enter the following email invite a new user</h4>
<form hx-post="/web-api/invite" hx-target="#none">
<form hx-post="/web-api/invite">
<table>
<tr>
<td>Email:</td>

View File

@ -24,18 +24,31 @@
</html>
<script>
function findButton(event) {
let button = event.target.querySelector(".button");
if (button) {
return button;
} else {
return event.target.closest(".button");
}
}
document.body.addEventListener("htmx:beforeRequest", function (event) {
const closeButton = event.target.querySelector(".button");
closeButton.classList.add("loading-button");
closeButton.disabled = true;
const closeButton = findButton(event);
if (closeButton) {
closeButton.classList.add("loading-button");
closeButton.disabled = true;
}
});
document.body.addEventListener("htmx:afterRequest", function (evt) {
const closeButton = event.target.querySelector(".button");
closeButton.classList.remove("loading-button");
closeButton.disabled = false;
document.body.addEventListener("htmx:afterRequest", function (event) {
const closeButton = findButton(event);
if (closeButton) {
closeButton.classList.remove("loading-button");
closeButton.disabled = false;
}
const detail = evt.detail;
const detail = event.detail;
if (detail.failed) {
const xhr = detail.xhr;
displayHttpFail(xhr.status, xhr.statusText, xhr.responseText);

View File

@ -20,10 +20,10 @@
</div>
<h3>Email Login</h3>
<form hx-post="/web-api/login" hx-target="#none">
<form>
<table style="width: 100%">
<tr>
<td>Email:</td>
<td>Email</td>
<td>
<input
class="input"
@ -36,7 +36,7 @@
</td>
</tr>
<tr>
<td>Password:</td>
<td>Password &nbsp</td>
<td>
<input
class="input"
@ -44,19 +44,35 @@
type="password"
id="password"
name="password"
placeholder="********"
placeholder="********(optional)"
/>
</td>
</tr>
</table>
<button
class="button cyan"
type="submit"
style="width: 100%; margin: 16px auto 0; padding: 8px 8px"
id="submitBtn"
<small style="color: #888"
><i>
(Magic link will be sent to email if password is not provided)
</i></small
>
Sign In / Sign Up
</button>
<div style="display: flex; margin: 8px 0px">
<button
hx-post="/web-api/signin"
class="button cyan"
type="submit"
style="width: 100%; padding: 8px 8px"
>
Sign In
</button>
<button
hx-post="/web-api/signup"
class="button purple"
type="submit"
style="width: 100%; padding: 8px 8px"
>
Sign Up
</button>
</div>
</form>
<!-- Load OAuth Providers if configured -->

View File

@ -374,23 +374,16 @@ impl Client {
pub async fn invite(&self, email: &str) -> Result<(), AppResponseError> {
self
.gotrue_client
.magic_link(
&self.access_token()?,
&MagicLinkParams {
email: email.to_owned(),
..Default::default()
},
)
.magic_link(&MagicLinkParams {
email: email.to_owned(),
..Default::default()
})
.await?;
Ok(())
}
#[instrument(level = "debug", skip_all, err)]
pub async fn create_magic_link(
&self,
email: &str,
password: &str,
) -> Result<User, AppResponseError> {
pub async fn create_user(&self, email: &str, password: &str) -> Result<User, AppResponseError> {
Ok(
self
.gotrue_client

View File

@ -224,14 +224,11 @@ impl Client {
to_gotrue_result(resp).await
}
pub async fn magic_link(
&self,
access_token: &str,
magic_link_params: &MagicLinkParams,
) -> Result<(), GoTrueError> {
pub async fn magic_link(&self, magic_link_params: &MagicLinkParams) -> Result<(), GoTrueError> {
let url = format!("{}/magiclink", self.base_url);
let resp = self
.http_client_with_auth(Method::POST, &url, access_token)
.client
.request(Method::POST, &url)
.json(&magic_link_params)
.send()
.await?;