mirror of
https://github.com/leptos-rs/leptos
synced 2024-09-20 14:32:00 +00:00
example: Login with API token (CSR only) (#523)
This commit is contained in:
parent
46e6e7629c
commit
0301c7f1cf
21 changed files with 1021 additions and 0 deletions
7
examples/login_with_token_csr_only/Cargo.toml
Normal file
7
examples/login_with_token_csr_only/Cargo.toml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
[workspace]
|
||||||
|
members = ["client", "api-boundary", "server"]
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
leptos = { path = "../../leptos" }
|
||||||
|
leptos_router = { path = "../../router" }
|
||||||
|
api-boundary = { path = "api-boundary" }
|
23
examples/login_with_token_csr_only/README.md
Normal file
23
examples/login_with_token_csr_only/README.md
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Leptos Login Example
|
||||||
|
|
||||||
|
This example demonstrates a scenario of a client-side rendered application
|
||||||
|
that uses uses an existing API that you cannot or do not want to change.
|
||||||
|
The authentications of this example are done using an API token.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
First start the example server:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd server/ && cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
then use [`trunk`](https://trunkrs.dev) to serve the SPA:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd client/ && trunk serve
|
||||||
|
```
|
||||||
|
|
||||||
|
finally you can visit the web application at `http://localhost:8080`
|
||||||
|
|
||||||
|
The `api-boundary` crate contains data structures that are used by the server and the client.
|
|
@ -0,0 +1,8 @@
|
||||||
|
[package]
|
||||||
|
name = "api-boundary"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
22
examples/login_with_token_csr_only/api-boundary/src/lib.rs
Normal file
22
examples/login_with_token_csr_only/api-boundary/src/lib.rs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Credentials {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UserInfo {
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiToken {
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Error {
|
||||||
|
pub message: String,
|
||||||
|
}
|
19
examples/login_with_token_csr_only/client/Cargo.toml
Normal file
19
examples/login_with_token_csr_only/client/Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
[package]
|
||||||
|
name = "client"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
api-boundary = "*"
|
||||||
|
|
||||||
|
leptos = { version = "0.2.0-alpha2", features = ["stable"] }
|
||||||
|
leptos_router = { version = "0.2.0-alpha2", features = ["stable", "csr"] }
|
||||||
|
|
||||||
|
log = "0.4"
|
||||||
|
console_error_panic_hook = "0.1"
|
||||||
|
console_log = "0.2"
|
||||||
|
gloo-net = "0.2"
|
||||||
|
gloo-storage = "0.2"
|
||||||
|
serde = "1.0"
|
||||||
|
thiserror = "1.0"
|
3
examples/login_with_token_csr_only/client/Trunk.toml
Normal file
3
examples/login_with_token_csr_only/client/Trunk.toml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[[proxy]]
|
||||||
|
rewrite = "/api/"
|
||||||
|
backend = "http://0.0.0.0:3000/"
|
7
examples/login_with_token_csr_only/client/index.html
Normal file
7
examples/login_with_token_csr_only/client/index.html
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<link data-trunk rel="rust" data-wasm-opt="z" data-weak-refs/>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
94
examples/login_with_token_csr_only/client/src/api.rs
Normal file
94
examples/login_with_token_csr_only/client/src/api.rs
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
use gloo_net::http::{Request, Response};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use api_boundary::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct UnauthorizedApi {
|
||||||
|
url: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthorizedApi {
|
||||||
|
url: &'static str,
|
||||||
|
token: ApiToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnauthorizedApi {
|
||||||
|
pub const fn new(url: &'static str) -> Self {
|
||||||
|
Self { url }
|
||||||
|
}
|
||||||
|
pub async fn register(&self, credentials: &Credentials) -> Result<()> {
|
||||||
|
let url = format!("{}/users", self.url);
|
||||||
|
let response = Request::post(&url).json(credentials)?.send().await?;
|
||||||
|
into_json(response).await
|
||||||
|
}
|
||||||
|
pub async fn login(
|
||||||
|
&self,
|
||||||
|
credentials: &Credentials,
|
||||||
|
) -> Result<AuthorizedApi> {
|
||||||
|
let url = format!("{}/login", self.url);
|
||||||
|
let response = Request::post(&url).json(credentials)?.send().await?;
|
||||||
|
let token = into_json(response).await?;
|
||||||
|
Ok(AuthorizedApi::new(self.url, token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthorizedApi {
|
||||||
|
pub const fn new(url: &'static str, token: ApiToken) -> Self {
|
||||||
|
Self { url, token }
|
||||||
|
}
|
||||||
|
fn auth_header_value(&self) -> String {
|
||||||
|
format!("Bearer {}", self.token.token)
|
||||||
|
}
|
||||||
|
async fn send<T>(&self, req: Request) -> Result<T>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let response = req
|
||||||
|
.header("Authorization", &self.auth_header_value())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
into_json(response).await
|
||||||
|
}
|
||||||
|
pub async fn logout(&self) -> Result<()> {
|
||||||
|
let url = format!("{}/logout", self.url);
|
||||||
|
self.send(Request::post(&url)).await
|
||||||
|
}
|
||||||
|
pub async fn user_info(&self) -> Result<UserInfo> {
|
||||||
|
let url = format!("{}/users", self.url);
|
||||||
|
self.send(Request::get(&url)).await
|
||||||
|
}
|
||||||
|
pub fn token(&self) -> &ApiToken {
|
||||||
|
&self.token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error(transparent)]
|
||||||
|
Fetch(#[from] gloo_net::Error),
|
||||||
|
#[error("{0:?}")]
|
||||||
|
Api(api_boundary::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<api_boundary::Error> for Error {
|
||||||
|
fn from(e: api_boundary::Error) -> Self {
|
||||||
|
Self::Api(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn into_json<T>(response: Response) -> Result<T>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
// ensure we've got 2xx status
|
||||||
|
if response.ok() {
|
||||||
|
Ok(response.json().await?)
|
||||||
|
} else {
|
||||||
|
Err(response.json::<api_boundary::Error>().await?.into())
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
use leptos::{ev, *};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn CredentialsForm(
|
||||||
|
cx: Scope,
|
||||||
|
title: &'static str,
|
||||||
|
action_label: &'static str,
|
||||||
|
action: Action<(String, String), ()>,
|
||||||
|
error: Signal<Option<String>>,
|
||||||
|
disabled: Signal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let (password, set_password) = create_signal(cx, String::new());
|
||||||
|
let (email, set_email) = create_signal(cx, String::new());
|
||||||
|
|
||||||
|
let dispatch_action =
|
||||||
|
move || action.dispatch((email.get(), password.get()));
|
||||||
|
|
||||||
|
let button_is_disabled = Signal::derive(cx, move || {
|
||||||
|
disabled.get() || password.get().is_empty() || email.get().is_empty()
|
||||||
|
});
|
||||||
|
|
||||||
|
view! { cx,
|
||||||
|
<form on:submit=|ev|ev.prevent_default()>
|
||||||
|
<p>{ title }</p>
|
||||||
|
{move || error.get().map(|err| view!{ cx,
|
||||||
|
<p style ="color:red;" >{ err }</p>
|
||||||
|
})}
|
||||||
|
<input
|
||||||
|
type = "email"
|
||||||
|
required
|
||||||
|
placeholder = "Email address"
|
||||||
|
prop:disabled = move || disabled.get()
|
||||||
|
on:keyup = move |ev: ev::KeyboardEvent| {
|
||||||
|
let val = event_target_value(&ev);
|
||||||
|
set_email.update(|v|*v = val);
|
||||||
|
}
|
||||||
|
// The `change` event fires when the browser fills the form automatically,
|
||||||
|
on:change = move |ev| {
|
||||||
|
let val = event_target_value(&ev);
|
||||||
|
set_email.update(|v|*v = val);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type = "password"
|
||||||
|
required
|
||||||
|
placeholder = "Password"
|
||||||
|
prop:disabled = move || disabled.get()
|
||||||
|
on:keyup = move |ev: ev::KeyboardEvent| {
|
||||||
|
match &*ev.key() {
|
||||||
|
"Enter" => {
|
||||||
|
dispatch_action();
|
||||||
|
}
|
||||||
|
_=> {
|
||||||
|
let val = event_target_value(&ev);
|
||||||
|
set_password.update(|p|*p = val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The `change` event fires when the browser fills the form automatically,
|
||||||
|
on:change = move |ev| {
|
||||||
|
let val = event_target_value(&ev);
|
||||||
|
set_password.update(|p|*p = val);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
prop:disabled = move || button_is_disabled.get()
|
||||||
|
on:click = move |_| dispatch_action()
|
||||||
|
>
|
||||||
|
{ action_label }
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod credentials;
|
||||||
|
pub mod navbar;
|
||||||
|
|
||||||
|
pub use self::{credentials::*, navbar::*};
|
|
@ -0,0 +1,32 @@
|
||||||
|
use leptos::*;
|
||||||
|
use leptos_router::*;
|
||||||
|
|
||||||
|
use crate::Page;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn NavBar<F>(
|
||||||
|
cx: Scope,
|
||||||
|
logged_in: Signal<bool>,
|
||||||
|
on_logout: F,
|
||||||
|
) -> impl IntoView
|
||||||
|
where
|
||||||
|
F: Fn() + 'static + Clone,
|
||||||
|
{
|
||||||
|
view! { cx,
|
||||||
|
<nav>
|
||||||
|
<Show
|
||||||
|
when = move || logged_in.get()
|
||||||
|
fallback = |cx| view! { cx,
|
||||||
|
<A href=Page::Login.path() >"Login"</A>
|
||||||
|
" | "
|
||||||
|
<A href=Page::Register.path() >"Register"</A>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<a href="#" on:click={
|
||||||
|
let on_logout = on_logout.clone();
|
||||||
|
move |_| on_logout()
|
||||||
|
}>"Logout"</a>
|
||||||
|
</Show>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
130
examples/login_with_token_csr_only/client/src/lib.rs
Normal file
130
examples/login_with_token_csr_only/client/src/lib.rs
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use leptos::*;
|
||||||
|
use leptos_router::*;
|
||||||
|
|
||||||
|
use api_boundary::*;
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod components;
|
||||||
|
mod pages;
|
||||||
|
|
||||||
|
use self::{components::*, pages::*};
|
||||||
|
|
||||||
|
const DEFAULT_API_URL: &str = "/api";
|
||||||
|
const API_TOKEN_STORAGE_KEY: &str = "api-token";
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn App(cx: Scope) -> impl IntoView {
|
||||||
|
// -- signals -- //
|
||||||
|
|
||||||
|
let authorized_api = create_rw_signal(cx, None::<api::AuthorizedApi>);
|
||||||
|
let user_info = create_rw_signal(cx, None::<UserInfo>);
|
||||||
|
let logged_in = Signal::derive(cx, move || authorized_api.get().is_some());
|
||||||
|
|
||||||
|
// -- actions -- //
|
||||||
|
|
||||||
|
let fetch_user_info = create_action(cx, move |_| async move {
|
||||||
|
match authorized_api.get() {
|
||||||
|
Some(api) => match api.user_info().await {
|
||||||
|
Ok(info) => {
|
||||||
|
user_info.update(|i| *i = Some(info));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Unable to fetch user info: {err}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
log::error!("Unable to fetch user info: not logged in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let logout = create_action(cx, move |_| async move {
|
||||||
|
match authorized_api.get() {
|
||||||
|
Some(api) => match api.logout().await {
|
||||||
|
Ok(_) => {
|
||||||
|
authorized_api.update(|a| *a = None);
|
||||||
|
user_info.update(|i| *i = None);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Unable to logout: {err}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
log::error!("Unable to logout user: not logged in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- callbacks -- //
|
||||||
|
|
||||||
|
let on_logout = move || {
|
||||||
|
logout.dispatch(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- init API -- //
|
||||||
|
|
||||||
|
let unauthorized_api = api::UnauthorizedApi::new(DEFAULT_API_URL);
|
||||||
|
if let Ok(token) = LocalStorage::get(API_TOKEN_STORAGE_KEY) {
|
||||||
|
let api = api::AuthorizedApi::new(DEFAULT_API_URL, token);
|
||||||
|
authorized_api.update(|a| *a = Some(api));
|
||||||
|
fetch_user_info.dispatch(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::debug!("User is logged in: {}", logged_in.get());
|
||||||
|
|
||||||
|
// -- effects -- //
|
||||||
|
|
||||||
|
create_effect(cx, move |_| {
|
||||||
|
log::debug!("API authorization state changed");
|
||||||
|
match authorized_api.get() {
|
||||||
|
Some(api) => {
|
||||||
|
log::debug!(
|
||||||
|
"API is now authorized: save token in LocalStorage"
|
||||||
|
);
|
||||||
|
LocalStorage::set(API_TOKEN_STORAGE_KEY, api.token())
|
||||||
|
.expect("LocalStorage::set");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log::debug!("API is no longer authorized: delete token from LocalStorage");
|
||||||
|
LocalStorage::delete(API_TOKEN_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! { cx,
|
||||||
|
<Router>
|
||||||
|
<NavBar logged_in on_logout />
|
||||||
|
<main>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path=Page::Home.path()
|
||||||
|
view=move |cx| view! { cx,
|
||||||
|
<Home user_info = user_info.into() />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=Page::Login.path()
|
||||||
|
view=move |cx| view! { cx,
|
||||||
|
<Login
|
||||||
|
api = unauthorized_api
|
||||||
|
on_success = move |api| {
|
||||||
|
log::info!("Successfully logged in");
|
||||||
|
authorized_api.update(|v| *v = Some(api));
|
||||||
|
let navigate = use_navigate(cx);
|
||||||
|
navigate(Page::Home.path(), Default::default()).expect("Home route");
|
||||||
|
fetch_user_info.dispatch(());
|
||||||
|
} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=Page::Register.path()
|
||||||
|
view=move |cx| view! { cx,
|
||||||
|
<Register api = unauthorized_api />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</Router>
|
||||||
|
}
|
||||||
|
}
|
9
examples/login_with_token_csr_only/client/src/main.rs
Normal file
9
examples/login_with_token_csr_only/client/src/main.rs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
use client::*;
|
||||||
|
|
||||||
|
pub fn main() {
|
||||||
|
_ = console_log::init_with_level(log::Level::Debug);
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
mount_to_body(|cx| view! { cx, <App /> })
|
||||||
|
}
|
20
examples/login_with_token_csr_only/client/src/pages/home.rs
Normal file
20
examples/login_with_token_csr_only/client/src/pages/home.rs
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
use crate::Page;
|
||||||
|
use api_boundary::UserInfo;
|
||||||
|
use leptos::*;
|
||||||
|
use leptos_router::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Home(cx: Scope, user_info: Signal<Option<UserInfo>>) -> impl IntoView {
|
||||||
|
view! { cx,
|
||||||
|
<h2>"Leptos Login example"</h2>
|
||||||
|
{move || match user_info.get() {
|
||||||
|
Some(info) => view!{ cx,
|
||||||
|
<p>"You are logged in with "{ info.email }"."</p>
|
||||||
|
}.into_view(cx),
|
||||||
|
None => view!{ cx,
|
||||||
|
<p>"You are not logged in."</p>
|
||||||
|
<A href=Page::Login.path() >"Login now."</A>
|
||||||
|
}.into_view(cx)
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
66
examples/login_with_token_csr_only/client/src/pages/login.rs
Normal file
66
examples/login_with_token_csr_only/client/src/pages/login.rs
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
use leptos::*;
|
||||||
|
use leptos_router::*;
|
||||||
|
|
||||||
|
use api_boundary::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::{self, AuthorizedApi, UnauthorizedApi},
|
||||||
|
components::credentials::*,
|
||||||
|
Page,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Login<F>(cx: Scope, api: UnauthorizedApi, on_success: F) -> impl IntoView
|
||||||
|
where
|
||||||
|
F: Fn(AuthorizedApi) + 'static + Clone,
|
||||||
|
{
|
||||||
|
let (login_error, set_login_error) = create_signal(cx, None::<String>);
|
||||||
|
let (wait_for_response, set_wait_for_response) = create_signal(cx, false);
|
||||||
|
|
||||||
|
let login_action =
|
||||||
|
create_action(cx, move |(email, password): &(String, String)| {
|
||||||
|
log::debug!("Try to login with {email}");
|
||||||
|
let email = email.to_string();
|
||||||
|
let password = password.to_string();
|
||||||
|
let credentials = Credentials { email, password };
|
||||||
|
let on_success = on_success.clone();
|
||||||
|
async move {
|
||||||
|
set_wait_for_response.update(|w| *w = true);
|
||||||
|
let result = api.login(&credentials).await;
|
||||||
|
set_wait_for_response.update(|w| *w = false);
|
||||||
|
match result {
|
||||||
|
Ok(res) => {
|
||||||
|
set_login_error.update(|e| *e = None);
|
||||||
|
on_success(res);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let msg = match err {
|
||||||
|
api::Error::Fetch(js_err) => {
|
||||||
|
format!("{js_err:?}")
|
||||||
|
}
|
||||||
|
api::Error::Api(err) => err.message,
|
||||||
|
};
|
||||||
|
error!(
|
||||||
|
"Unable to login with {}: {msg}",
|
||||||
|
credentials.email
|
||||||
|
);
|
||||||
|
set_login_error.update(|e| *e = Some(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let disabled = Signal::derive(cx, move || wait_for_response.get());
|
||||||
|
|
||||||
|
view! { cx,
|
||||||
|
<CredentialsForm
|
||||||
|
title = "Please login to your account"
|
||||||
|
action_label = "Login"
|
||||||
|
action = login_action
|
||||||
|
error = login_error.into()
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<p>"Don't have an account?"</p>
|
||||||
|
<A href=Page::Register.path()>"Register"</A>
|
||||||
|
}
|
||||||
|
}
|
23
examples/login_with_token_csr_only/client/src/pages/mod.rs
Normal file
23
examples/login_with_token_csr_only/client/src/pages/mod.rs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
pub mod home;
|
||||||
|
pub mod login;
|
||||||
|
pub mod register;
|
||||||
|
|
||||||
|
pub use self::{home::*, login::*, register::*};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub enum Page {
|
||||||
|
#[default]
|
||||||
|
Home,
|
||||||
|
Login,
|
||||||
|
Register,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Page {
|
||||||
|
pub fn path(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Home => "/",
|
||||||
|
Self::Login => "/login",
|
||||||
|
Self::Register => "/register",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,77 @@
|
||||||
|
use leptos::*;
|
||||||
|
use leptos_router::*;
|
||||||
|
|
||||||
|
use api_boundary::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::{self, UnauthorizedApi},
|
||||||
|
components::credentials::*,
|
||||||
|
Page,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Register(cx: Scope, api: UnauthorizedApi) -> impl IntoView {
|
||||||
|
let (register_response, set_register_response) =
|
||||||
|
create_signal(cx, None::<()>);
|
||||||
|
let (register_error, set_register_error) =
|
||||||
|
create_signal(cx, None::<String>);
|
||||||
|
let (wait_for_response, set_wait_for_response) = create_signal(cx, false);
|
||||||
|
|
||||||
|
let register_action =
|
||||||
|
create_action(cx, move |(email, password): &(String, String)| {
|
||||||
|
let email = email.to_string();
|
||||||
|
let password = password.to_string();
|
||||||
|
let credentials = Credentials { email, password };
|
||||||
|
log!("Try to register new account for {}", credentials.email);
|
||||||
|
async move {
|
||||||
|
set_wait_for_response.update(|w| *w = true);
|
||||||
|
let result = api.register(&credentials).await;
|
||||||
|
set_wait_for_response.update(|w| *w = false);
|
||||||
|
match result {
|
||||||
|
Ok(res) => {
|
||||||
|
set_register_response.update(|v| *v = Some(res));
|
||||||
|
set_register_error.update(|e| *e = None);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let msg = match err {
|
||||||
|
api::Error::Fetch(js_err) => {
|
||||||
|
format!("{js_err:?}")
|
||||||
|
}
|
||||||
|
api::Error::Api(err) => err.message,
|
||||||
|
};
|
||||||
|
log::warn!(
|
||||||
|
"Unable to register new account for {}: {msg}",
|
||||||
|
credentials.email
|
||||||
|
);
|
||||||
|
set_register_error.update(|e| *e = Some(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let disabled = Signal::derive(cx, move || wait_for_response.get());
|
||||||
|
|
||||||
|
view! { cx,
|
||||||
|
<Show
|
||||||
|
when = move || register_response.get().is_some()
|
||||||
|
fallback = move |_| view!{ cx,
|
||||||
|
<CredentialsForm
|
||||||
|
title = "Please enter the desired credentials"
|
||||||
|
action_label = "Register"
|
||||||
|
action = register_action
|
||||||
|
error = register_error.into()
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<p>"Your already have an account?"</p>
|
||||||
|
<A href=Page::Login.path()>"Login"</A>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>"You have successfully registered."</p>
|
||||||
|
<p>
|
||||||
|
"You can now "
|
||||||
|
<A href=Page::Login.path()>"login"</A>
|
||||||
|
" with your new account."
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
18
examples/login_with_token_csr_only/server/Cargo.toml
Normal file
18
examples/login_with_token_csr_only/server/Cargo.toml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "server"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
api-boundary = "*"
|
||||||
|
axum = { version = "0.6", features = ["headers"] }
|
||||||
|
env_logger = "0.10"
|
||||||
|
log = "0.4"
|
||||||
|
mailparse = "0.14"
|
||||||
|
pwhash = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
tokio = { version = "1.25", features = ["macros", "rt-multi-thread"] }
|
||||||
|
tower-http = { version = "0.3", features = ["cors"] }
|
||||||
|
uuid = { version = "1.3", features = ["v4"] }
|
114
examples/login_with_token_csr_only/server/src/adapters.rs
Normal file
114
examples/login_with_token_csr_only/server/src/adapters.rs
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
use crate::{application::*, Error};
|
||||||
|
use api_boundary as json;
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::Json,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
impl From<InvalidEmailAddress> for json::Error {
|
||||||
|
fn from(_: InvalidEmailAddress) -> Self {
|
||||||
|
Self {
|
||||||
|
message: "Invalid email address".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<InvalidPassword> for json::Error {
|
||||||
|
fn from(err: InvalidPassword) -> Self {
|
||||||
|
let InvalidPassword::TooShort(min_len) = err;
|
||||||
|
Self {
|
||||||
|
message: format!("Invalid password (min. length = {min_len})"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CreateUserError> for json::Error {
|
||||||
|
fn from(err: CreateUserError) -> Self {
|
||||||
|
let message = match err {
|
||||||
|
CreateUserError::UserExists => "User already exits".to_string(),
|
||||||
|
};
|
||||||
|
Self { message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<LoginError> for json::Error {
|
||||||
|
fn from(err: LoginError) -> Self {
|
||||||
|
let message = match err {
|
||||||
|
LoginError::InvalidEmailOrPassword => {
|
||||||
|
"Invalid email or password".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Self { message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<LogoutError> for json::Error {
|
||||||
|
fn from(err: LogoutError) -> Self {
|
||||||
|
let message = match err {
|
||||||
|
LogoutError::NotLoggedIn => "No user is logged in".to_string(),
|
||||||
|
};
|
||||||
|
Self { message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthError> for json::Error {
|
||||||
|
fn from(err: AuthError) -> Self {
|
||||||
|
let message = match err {
|
||||||
|
AuthError::NotAuthorized => "Not authorized".to_string(),
|
||||||
|
};
|
||||||
|
Self { message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CredentialParsingError> for json::Error {
|
||||||
|
fn from(err: CredentialParsingError) -> Self {
|
||||||
|
match err {
|
||||||
|
CredentialParsingError::EmailAddress(err) => err.into(),
|
||||||
|
CredentialParsingError::Password(err) => err.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CredentialParsingError {
|
||||||
|
#[error(transparent)]
|
||||||
|
EmailAddress(#[from] InvalidEmailAddress),
|
||||||
|
#[error(transparent)]
|
||||||
|
Password(#[from] InvalidPassword),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<json::Credentials> for Credentials {
|
||||||
|
type Error = CredentialParsingError;
|
||||||
|
fn try_from(
|
||||||
|
json::Credentials { email, password }: json::Credentials,
|
||||||
|
) -> Result<Self, Self::Error> {
|
||||||
|
let email: EmailAddress = email.parse()?;
|
||||||
|
let password = Password::try_from(password)?;
|
||||||
|
Ok(Self { email, password })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for Error {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (code, value) = match self {
|
||||||
|
Self::Logout(err) => {
|
||||||
|
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||||
|
}
|
||||||
|
Self::Login(err) => {
|
||||||
|
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||||
|
}
|
||||||
|
Self::Credentials(err) => {
|
||||||
|
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||||
|
}
|
||||||
|
Self::CreateUser(err) => {
|
||||||
|
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||||
|
}
|
||||||
|
Self::Auth(err) => {
|
||||||
|
(StatusCode::UNAUTHORIZED, json::Error::from(err))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(code, Json(value)).into_response()
|
||||||
|
}
|
||||||
|
}
|
159
examples/login_with_token_csr_only/server/src/application.rs
Normal file
159
examples/login_with_token_csr_only/server/src/application.rs
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
use mailparse::addrparse;
|
||||||
|
use pwhash::bcrypt;
|
||||||
|
use std::{collections::HashMap, str::FromStr, sync::RwLock};
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AppState {
|
||||||
|
users: RwLock<HashMap<EmailAddress, Password>>,
|
||||||
|
tokens: RwLock<HashMap<Uuid, EmailAddress>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn create_user(
|
||||||
|
&self,
|
||||||
|
credentials: Credentials,
|
||||||
|
) -> Result<(), CreateUserError> {
|
||||||
|
let Credentials { email, password } = credentials;
|
||||||
|
let user_exists = self.users.read().unwrap().get(&email).is_some();
|
||||||
|
if user_exists {
|
||||||
|
return Err(CreateUserError::UserExists);
|
||||||
|
}
|
||||||
|
self.users.write().unwrap().insert(email, password);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login(
|
||||||
|
&self,
|
||||||
|
email: EmailAddress,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<Uuid, LoginError> {
|
||||||
|
let valid_credentials = self
|
||||||
|
.users
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.get(&email)
|
||||||
|
.map(|hashed_password| hashed_password.verify(password))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !valid_credentials {
|
||||||
|
Err(LoginError::InvalidEmailOrPassword)
|
||||||
|
} else {
|
||||||
|
let token = Uuid::new_v4();
|
||||||
|
self.tokens.write().unwrap().insert(token, email);
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn logout(&self, token: &str) -> Result<(), LogoutError> {
|
||||||
|
let token = token
|
||||||
|
.parse::<Uuid>()
|
||||||
|
.map_err(|_| LogoutError::NotLoggedIn)?;
|
||||||
|
self.tokens.write().unwrap().remove(&token);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authorize_user(
|
||||||
|
&self,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<CurrentUser, AuthError> {
|
||||||
|
token
|
||||||
|
.parse::<Uuid>()
|
||||||
|
.map_err(|_| AuthError::NotAuthorized)
|
||||||
|
.and_then(|token| {
|
||||||
|
self.tokens
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.get(&token)
|
||||||
|
.cloned()
|
||||||
|
.map(|email| CurrentUser { email, token })
|
||||||
|
.ok_or(AuthError::NotAuthorized)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CreateUserError {
|
||||||
|
#[error("The user already exists")]
|
||||||
|
UserExists,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum LoginError {
|
||||||
|
#[error("Invalid email or password")]
|
||||||
|
InvalidEmailOrPassword,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum LogoutError {
|
||||||
|
#[error("You are not logged in")]
|
||||||
|
NotLoggedIn,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AuthError {
|
||||||
|
#[error("You are not authorized")]
|
||||||
|
NotAuthorized,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Credentials {
|
||||||
|
pub email: EmailAddress,
|
||||||
|
pub password: Password,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Eq, PartialEq, Hash)]
|
||||||
|
pub struct EmailAddress(String);
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[error("The given email address is invalid")]
|
||||||
|
pub struct InvalidEmailAddress;
|
||||||
|
|
||||||
|
impl FromStr for EmailAddress {
|
||||||
|
type Err = InvalidEmailAddress;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
addrparse(s)
|
||||||
|
.ok()
|
||||||
|
.and_then(|parsed| parsed.extract_single_info())
|
||||||
|
.map(|single_info| Self(single_info.addr))
|
||||||
|
.ok_or(InvalidEmailAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailAddress {
|
||||||
|
pub fn into_string(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CurrentUser {
|
||||||
|
pub email: EmailAddress,
|
||||||
|
pub token: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LEN: usize = 3;
|
||||||
|
|
||||||
|
pub struct Password(String);
|
||||||
|
|
||||||
|
impl Password {
|
||||||
|
pub fn verify(&self, password: &str) -> bool {
|
||||||
|
bcrypt::verify(password, &self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum InvalidPassword {
|
||||||
|
#[error("Password is too short (min. length is {0})")]
|
||||||
|
TooShort(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for Password {
|
||||||
|
type Error = InvalidPassword;
|
||||||
|
fn try_from(p: String) -> Result<Self, Self::Error> {
|
||||||
|
if p.len() < MIN_PASSWORD_LEN {
|
||||||
|
return Err(InvalidPassword::TooShort(MIN_PASSWORD_LEN));
|
||||||
|
}
|
||||||
|
let hashed = bcrypt::hash(&p).unwrap();
|
||||||
|
Ok(Self(hashed))
|
||||||
|
}
|
||||||
|
}
|
113
examples/login_with_token_csr_only/server/src/main.rs
Normal file
113
examples/login_with_token_csr_only/server/src/main.rs
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
use std::{env, sync::Arc};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{State, TypedHeader},
|
||||||
|
headers::{authorization::Bearer, Authorization},
|
||||||
|
http::Method,
|
||||||
|
response::Json,
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
|
use api_boundary as json;
|
||||||
|
|
||||||
|
mod adapters;
|
||||||
|
mod application;
|
||||||
|
|
||||||
|
use self::application::*;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
if let Err(err) = env::var("RUST_LOG") {
|
||||||
|
match err {
|
||||||
|
env::VarError::NotPresent => {
|
||||||
|
env::set_var("RUST_LOG", "debug");
|
||||||
|
}
|
||||||
|
env::VarError::NotUnicode(_) => {
|
||||||
|
return Err(anyhow::anyhow!("The value of 'RUST_LOG' does not contain valid unicode data."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
let shared_state = Arc::new(AppState::default());
|
||||||
|
|
||||||
|
let cors_layer = CorsLayer::new()
|
||||||
|
.allow_methods([Method::GET, Method::POST])
|
||||||
|
.allow_origin(Any);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/login", post(login))
|
||||||
|
.route("/logout", post(logout))
|
||||||
|
.route("/users", post(create_user))
|
||||||
|
.route("/users", get(get_user_info))
|
||||||
|
.route_layer(cors_layer)
|
||||||
|
.with_state(shared_state);
|
||||||
|
|
||||||
|
let addr = "0.0.0.0:3000".parse().unwrap();
|
||||||
|
log::info!("Listen on {addr}");
|
||||||
|
axum::Server::bind(&addr)
|
||||||
|
.serve(app.into_make_service())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<Json<T>, Error>;
|
||||||
|
|
||||||
|
/// API error
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
enum Error {
|
||||||
|
#[error(transparent)]
|
||||||
|
CreateUser(#[from] CreateUserError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Login(#[from] LoginError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Logout(#[from] LogoutError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Auth(#[from] AuthError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Credentials(#[from] adapters::CredentialParsingError),
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_user(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(credentials): Json<json::Credentials>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let credentials = Credentials::try_from(credentials)?;
|
||||||
|
state.create_user(credentials)?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(credentials): Json<json::Credentials>,
|
||||||
|
) -> Result<json::ApiToken> {
|
||||||
|
let json::Credentials { email, password } = credentials;
|
||||||
|
log::debug!("{email} tries to login");
|
||||||
|
let email = email.parse().map_err(|_|
|
||||||
|
// Here we don't want to leak detailed info.
|
||||||
|
LoginError::InvalidEmailOrPassword)?;
|
||||||
|
let token = state.login(email, &password).map(|s| s.to_string())?;
|
||||||
|
Ok(Json(json::ApiToken { token }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
state.logout(auth.token())?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user_info(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
|
||||||
|
) -> Result<json::UserInfo> {
|
||||||
|
let user = state.authorize_user(auth.token())?;
|
||||||
|
let CurrentUser { email, .. } = user;
|
||||||
|
Ok(Json(json::UserInfo {
|
||||||
|
email: email.into_string(),
|
||||||
|
}))
|
||||||
|
}
|
Loading…
Reference in a new issue