feat: enable login via email magic link
This commit is contained in:
parent
8def2bfaf3
commit
b4fd4cea05
|
|
@ -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), ¶m.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(¶m.email, ¶m.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), ¶m.email).await?;
|
||||
return Ok((CookieJar::new(), HeaderMap::new(), res));
|
||||
}
|
||||
|
||||
let sign_up_res = state
|
||||
.gotrue_client
|
||||
.sign_up_with_referrer(¶m.email, ¶m.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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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  </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 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
Loading…
Reference in New Issue