diff --git a/Cargo.lock b/Cargo.lock index 1071ffdd..f0db6b02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +# It is not intended for manual editing. version = 3 [[package]] @@ -477,18 +478,17 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "attohttpc" -version = "0.22.0" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fcf00bc6d5abb29b5f97e3c61a90b6d3caa12f3faf897d4a3e3607c050a35a7" +checksum = "0f77d243921b0979fbbd728dd2d5162e68ac8252976797c24eb5b3a6af9090dc" dependencies = [ "http 0.2.12", "log", - "rustls 0.20.9", + "rustls", "serde", "serde_json", "url", - "webpki", - "webpki-roots 0.22.6", + "webpki-roots", ] [[package]] @@ -529,12 +529,12 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "aws-creds" -version = "0.34.1" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3776743bb68d4ad02ba30ba8f64373f1be4e082fe47651767171ce75bb2f6cf5" +checksum = "390ad3b77f3e21e01a4a0355865853b681daf1988510b0b15e31c0c4ae7eb0f6" dependencies = [ "attohttpc", - "dirs", + "home", "log", "quick-xml", "rust-ini", @@ -611,6 +611,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.73" @@ -626,12 +649,6 @@ dependencies = [ "rustc-demangle", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.21.7" @@ -915,8 +932,10 @@ dependencies = [ name = "client" version = "0.1.0" dependencies = [ + "anyhow", "chrono", "graphql_client", + "http-auth-basic", "pxid", "reqwest", "serde", @@ -987,6 +1006,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", +] + [[package]] name = "const_format" version = "0.2.32" @@ -1263,31 +1302,14 @@ dependencies = [ ] [[package]] -name = "dirs" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.3.7" +name = "dlv-list" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" dependencies = [ - "libc", - "redox_users", - "winapi", + "const-random", ] -[[package]] -name = "dlv-list" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" - [[package]] name = "dotenv" version = "0.15.0" @@ -1440,7 +1462,7 @@ checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" dependencies = [ "futures-core", "futures-sink", - "spin 0.9.8", + "spin", ] [[package]] @@ -1811,6 +1833,12 @@ dependencies = [ "ahash 0.7.8", ] +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1921,6 +1949,15 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-auth-basic" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c088bddfd73005b09807131224ad12c302655436b1270c8346a3ae8aaa37a" +dependencies = [ + "base64 0.22.1", +] + [[package]] name = "http-body" version = "0.4.6" @@ -2010,20 +2047,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.30", - "rustls 0.21.12", - "tokio", - "tokio-rustls", -] - [[package]] name = "hyper-tls" version = "0.5.0" @@ -2190,7 +2213,7 @@ dependencies = [ "base64 0.21.7", "js-sys", "pem", - "ring 0.17.8", + "ring", "serde", "serde_json", "simple_asn1", @@ -2211,7 +2234,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin", ] [[package]] @@ -2436,16 +2459,6 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" -[[package]] -name = "libredox" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" -dependencies = [ - "bitflags 2.6.0", - "libc", -] - [[package]] name = "libsqlite3-sys" version = "0.27.0" @@ -2624,7 +2637,7 @@ dependencies = [ "httparse", "memchr", "mime", - "spin 0.9.8", + "spin", "version_check", ] @@ -2808,12 +2821,12 @@ dependencies = [ [[package]] name = "ordered-multimap" -version = "0.4.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +checksum = "4ed8acf08e98e744e5384c8bc63ceb0364e68a6854187221c18df61c4797690e" dependencies = [ "dlv-list", - "hashbrown 0.12.3", + "hashbrown 0.13.2", ] [[package]] @@ -3174,9 +3187,9 @@ dependencies = [ [[package]] name = "pxid" -version = "0.5.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92572e83a97f5f573815b60c6ffec7c8b99285313f3434ea17add3422f64bfdb" +checksum = "b449b61ac8394ea6bf2442c621a8a72bf1ef9c3fbc44cabf3019f52c58a69606" dependencies = [ "async-graphql", "crc32fast", @@ -3189,9 +3202,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.26.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f50b1c63b38611e7d4d7f68b82d3ad0cc71a2ad2e7f61fc10f1328d917c93cd" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" dependencies = [ "memchr", "serde", @@ -3283,17 +3296,6 @@ dependencies = [ "bitflags 2.6.0", ] -[[package]] -name = "redox_users" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" -dependencies = [ - "getrandom", - "libredox", - "thiserror", -] - [[package]] name = "regex" version = "1.10.5" @@ -3362,7 +3364,6 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.30", - "hyper-rustls", "hyper-tls", "ipnet", "js-sys", @@ -3372,7 +3373,6 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls 0.21.12", "rustls-pemfile", "serde", "serde_json", @@ -3381,33 +3381,14 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", - "tokio-rustls", - "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", - "webpki-roots 0.25.4", "winreg", ] -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin 0.5.2", - "untrusted 0.7.1", - "web-sys", - "winapi", -] - [[package]] name = "ring" version = "0.17.8" @@ -3418,8 +3399,8 @@ dependencies = [ "cfg-if", "getrandom", "libc", - "spin 0.9.8", - "untrusted 0.9.0", + "spin", + "untrusted", "windows-sys 0.52.0", ] @@ -3499,9 +3480,9 @@ dependencies = [ [[package]] name = "rust-ini" -version = "0.18.0" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +checksum = "7e2a3bcec1f113553ef1c88aae6c020a369d03d55b58de9869a0908930385091" dependencies = [ "cfg-if", "ordered-multimap", @@ -3509,32 +3490,36 @@ dependencies = [ [[package]] name = "rust-s3" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2ac5ff6acfbe74226fa701b5ef793aaa054055c13ebb7060ad36942956e027" +checksum = "c6679da8efaf4c6f0c161de0961dfe95fb6e9049c398d6fbdada2639f053aedb" dependencies = [ "async-trait", "aws-creds", "aws-region", - "base64 0.13.1", + "base64 0.21.7", "bytes", "cfg-if", "futures", "hex", "hmac", "http 0.2.12", + "hyper 0.14.30", + "hyper-tls", "log", "maybe-async", "md5", + "native-tls", "percent-encoding", "quick-xml", - "reqwest", "serde", "serde_derive", + "serde_json", "sha2", "thiserror", "time", "tokio", + "tokio-native-tls", "tokio-stream", "url", ] @@ -3594,18 +3579,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustls" -version = "0.20.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" -dependencies = [ - "log", - "ring 0.16.20", - "sct", - "webpki", -] - [[package]] name = "rustls" version = "0.21.12" @@ -3613,7 +3586,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", - "ring 0.17.8", + "ring", "rustls-webpki", "sct", ] @@ -3633,8 +3606,8 @@ version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", + "ring", + "untrusted", ] [[package]] @@ -3685,8 +3658,8 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", + "ring", + "untrusted", ] [[package]] @@ -4023,16 +3996,20 @@ dependencies = [ "async-graphql-axum", "async-trait", "axum", + "axum-extra", "base64 0.22.1", "chrono", + "cookie", "core", "dotenv", + "http-auth-basic", "jsonwebtoken", "pxid", "rand", "rust-argon2", "rust-s3", "serde", + "serde_json", "thiserror", "tokio", "tower-http", @@ -4198,12 +4175,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "spin" version = "0.9.8" @@ -4276,7 +4247,7 @@ dependencies = [ "paste", "percent-encoding", "rust_decimal", - "rustls 0.21.12", + "rustls", "rustls-pemfile", "serde", "serde_json", @@ -4290,7 +4261,7 @@ dependencies = [ "tracing", "url", "uuid", - "webpki-roots 0.25.4", + "webpki-roots", ] [[package]] @@ -4690,6 +4661,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinyvec" version = "1.8.0" @@ -4742,16 +4722,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-stream" version = "0.1.15" @@ -5077,12 +5047,6 @@ dependencies = [ "void", ] -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" @@ -5310,6 +5274,7 @@ dependencies = [ name = "web" version = "0.0.0" dependencies = [ + "anyhow", "client", "leptos", "leptos-use", @@ -5331,25 +5296,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" -dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", -] - -[[package]] -name = "webpki-roots" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" -dependencies = [ - "webpki", -] - [[package]] name = "webpki-roots" version = "0.25.4" diff --git a/Cargo.toml b/Cargo.toml index 417abb2b..e969872b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,12 +15,15 @@ resolver = "2" [workspace.dependencies] anyhow = "1.0.86" +http-auth-basic = "0.3.3" async-graphql = { version = "7.0.5", features = ["chrono", "decimal", "tracing"] } async-graphql-axum = "7.0.5" async-trait = "0.1.80" axum = "0.7.5" +axum-extra = "0.9.3" base64 = "0.22.1" chrono = { version = "0.4.38", default-features = false } +cookie = "0.18.1" dotenv = "0.15.0" fake = "2.9.2" graphql_client = "0.14.0" @@ -29,12 +32,13 @@ lazy_static = "1.4.0" leptos = "0.6" leptos_meta = "0.6" leptos_router = "0.6" -pxid = { version = "0.5", features = ["async-graphql"] } +leptos-use = "0.10" +pxid = { version = "1", features = ["async-graphql", "serde"] } rand = "0.8.5" regex = "1.9.3" reqwest = "0.11" rust-argon2 = "2.1.0" -rust-s3 = { version = "0.33.0", features = ["tokio-rustls-tls", "fail-on-err"], default-features = false } +rust-s3 = { version = "0.34.0", features = ["tokio-rustls-tls", "fail-on-err"], default-features = false } sea-orm = "0.12" sea-orm-cli = { version = "0.12", default-features = false } sea-orm-migration = "0.12" diff --git a/README.md b/README.md index 5b1947e7..30e76670 100644 --- a/README.md +++ b/README.md @@ -81,13 +81,14 @@ The client and server solution is available in this repository. | Directory | Description | | ------------------ | ------------------------------------------------------------------------------------------ | -| `crates/` | Contains GraphQL Server Logic, CLI and Domain libraries. Rust is the predominant language. | | `crates/cli` | CLI used to manage the Server instance. run database migrations and other developer tasks | +| `crates/client` | HTTP Client used as bridge library between client interface and application services | | `crates/core` | Domain Logic, includes Models, Value Objects, Repositories and Services | | `crates/entity` | Entities generated from database | | `crates/migration` | Database migrations | | `crates/server` | HTTP Server Logic, uses Axum and GraphQL | | `crates/test` | E2E Tests for the GraphQL Server | +| `crates/types` | Plain types shared between different application layers | | `crates/web` | Web UI, written in Leptos | ## Testing diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 9abdff49..25a3e8d6 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -9,8 +9,10 @@ name = "townhall_client" path = "src/lib.rs" [dependencies] +anyhow = { workspace = true } chrono = { workspace = true, features = ["js-sys", "wasmbind"] } graphql_client = { workspace = true, features = ["reqwest"] } +http-auth-basic = { workspace = true } pxid = { workspace = true, features = ["serde"] } reqwest = { workspace = true, features = ["blocking", "json"] } serde = { workspace = true } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 71a8a21f..826ac269 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -2,22 +2,28 @@ mod modules; pub use modules::*; +pub(crate) const GRAPHQL_PATH: &str = "/graphql"; + +use std::fmt::Display; + +use anyhow::{anyhow, Result}; +use reqwest::Url; + use auth::AuthClient; pub struct Client { pub auth: AuthClient, } -impl Default for Client { - fn default() -> Self { - Self::new() - } -} - impl Client { - pub fn new() -> Self { - Self { - auth: AuthClient::new(), - } + pub fn new>(domain: T) -> Result { + let domain = domain + .clone() + .try_into() + .map_err(|_| anyhow!("Provided domain \"{domain}\" is not a valid Url."))?; + + Ok(Self { + auth: AuthClient::new(domain.clone()), + }) } } diff --git a/crates/client/src/modules/auth/me/Me.gql b/crates/client/src/modules/auth/me/Me.gql new file mode 100644 index 00000000..8820f641 --- /dev/null +++ b/crates/client/src/modules/auth/me/Me.gql @@ -0,0 +1,21 @@ +query Me { + me { + user { + id + name + surname + username + email + createdAt + updatedAt + avatar { + id + url + } + } + error { + code + message + } + } +} diff --git a/crates/client/src/modules/auth/me/mod.rs b/crates/client/src/modules/auth/me/mod.rs new file mode 100644 index 00000000..e3c26b35 --- /dev/null +++ b/crates/client/src/modules/auth/me/mod.rs @@ -0,0 +1,37 @@ +use anyhow::{anyhow, bail, Result}; +use graphql_client::reqwest::post_graphql; +use graphql_client::GraphQLQuery; +use pxid::Pxid; + +use me::{MeMeError, MeMeUser, Variables}; + +use crate::{DateTime, GRAPHQL_PATH}; + +use super::AuthClient; + +#[derive(GraphQLQuery)] +#[graphql( + response_derives = "Clone,Debug,Deserialize", + schema_path = "schema.json", + query_path = "src/modules/auth/me/Me.gql" +)] +pub struct Me { + pub user: Option, + pub error: Option, +} + +pub async fn me(auth_client: &AuthClient) -> Result { + let url = auth_client.domain.join(GRAPHQL_PATH)?; + let res = post_graphql::(&auth_client.client, url, Variables {}) + .await + .map_err(|err| anyhow!("Failed to create token. {err}"))?; + + if let Some(ref data) = res.data { + return Ok(Me { + user: data.me.user.to_owned(), + error: data.me.error.to_owned(), + }); + } + + bail!("Failed to get user data. err = {:#?}", res.errors.unwrap()) +} diff --git a/crates/client/src/modules/auth/mod.rs b/crates/client/src/modules/auth/mod.rs index 36b97d9b..9b7ab3f0 100644 --- a/crates/client/src/modules/auth/mod.rs +++ b/crates/client/src/modules/auth/mod.rs @@ -1,33 +1,57 @@ +pub mod me; pub mod token_create; pub mod user_register; -use reqwest::Client; +use anyhow::{anyhow, Result}; +use http_auth_basic::Credentials; +use reqwest::header::{HeaderValue, AUTHORIZATION}; +use reqwest::{Client, Url}; pub struct AuthClient { client: Client, -} - -impl Default for AuthClient { - fn default() -> Self { - Self::new() - } + domain: Url, } impl AuthClient { - pub fn new() -> Self { + pub fn new(domain: Url) -> Self { Self { + domain, client: Client::new(), } } - pub async fn token_create(&self, email: String, password: String) -> token_create::TokenCreate { - token_create::token_create(&self.client, email, password).await + pub async fn login(&self, email: String, password: String) -> Result<()> { + let credentials = Credentials::new(email.as_str(), password.as_str()); + let authorization = credentials.as_http_header(); + let authorization = HeaderValue::from_str(authorization.as_str()).unwrap(); + let url = self.domain.join("/api/v1/auth/login")?; + + self.client + .get(url) + .header(AUTHORIZATION, authorization) + .send() + .await + .map_err(|err| anyhow!("Failed to authenticate. {err}"))?; + + Ok(()) + } + + pub async fn token_create( + &self, + email: String, + password: String, + ) -> Result { + token_create::token_create(self, email, password).await } pub async fn user_register( &self, input: user_register::UserRegisterInput, - ) -> user_register::UserRegister { - user_register::user_register(&self.client, input).await + ) -> Result { + user_register::user_register(self, input).await + } + + pub async fn me(&self) -> Result { + me::me(self).await } } diff --git a/crates/client/src/modules/auth/token_create/mod.rs b/crates/client/src/modules/auth/token_create/mod.rs index 769ae57e..7ce9b21a 100644 --- a/crates/client/src/modules/auth/token_create/mod.rs +++ b/crates/client/src/modules/auth/token_create/mod.rs @@ -1,12 +1,16 @@ +use anyhow::{anyhow, Result}; use graphql_client::reqwest::post_graphql; use graphql_client::GraphQLQuery; -use reqwest::Client; use token_create::{TokenCreateTokenCreateError, TokenCreateTokenCreateToken}; +use crate::GRAPHQL_PATH; + +use super::AuthClient; + #[derive(GraphQLQuery)] #[graphql( - response_derives = "Debug", + response_derives = "Clone,Debug", schema_path = "schema.json", query_path = "src/modules/auth/token_create/TokenCreate.gql" )] @@ -15,15 +19,20 @@ pub struct TokenCreate { pub error: Option, } -pub async fn token_create(client: &Client, email: String, password: String) -> TokenCreate { +pub async fn token_create( + auth_client: &AuthClient, + email: String, + password: String, +) -> Result { let variables = token_create::Variables { email, password }; - let res = post_graphql::(client, "http://127.0.0.1:7878/graphql", variables) + let url = auth_client.domain.join(GRAPHQL_PATH)?; + let res = post_graphql::(&auth_client.client, url, variables) .await - .unwrap(); + .map_err(|err| anyhow!("Failed to create token. {err}"))?; let data = res.data.unwrap().token_create; - TokenCreate { + Ok(TokenCreate { token: data.token, error: data.error, - } + }) } diff --git a/crates/client/src/modules/auth/user_register/mod.rs b/crates/client/src/modules/auth/user_register/mod.rs index 67b5c3ca..908f10a1 100644 --- a/crates/client/src/modules/auth/user_register/mod.rs +++ b/crates/client/src/modules/auth/user_register/mod.rs @@ -1,18 +1,20 @@ +use anyhow::{anyhow, Result}; use graphql_client::reqwest::post_graphql; use graphql_client::GraphQLQuery; use pxid::Pxid; -use reqwest::Client; use townhall_types::user::Email; use user_register::{UserRegisterUserRegisterError, UserRegisterUserRegisterUser}; pub use crate::auth::user_register::user_register::UserRegisterInput; -use crate::DateTime; +use crate::{DateTime, GRAPHQL_PATH}; + +use super::AuthClient; #[derive(GraphQLQuery)] #[graphql( - response_derives = "Debug", + response_derives = "Clone,Debug", schema_path = "schema.json", query_path = "src/modules/auth/user_register/UserRegister.gql" )] @@ -21,15 +23,19 @@ pub struct UserRegister { pub error: Option, } -pub async fn user_register(client: &Client, input: UserRegisterInput) -> UserRegister { +pub async fn user_register( + auth_client: &AuthClient, + input: UserRegisterInput, +) -> Result { let variables = user_register::Variables { input }; - let res = post_graphql::(client, "http://127.0.0.1:7878/graphql", variables) + let url = auth_client.domain.join(GRAPHQL_PATH)?; + let res = post_graphql::(&auth_client.client, url, variables) .await - .unwrap(); + .map_err(|err| anyhow!("Failed to register user. {err}"))?; let data = res.data.unwrap().user_register; - UserRegister { + Ok(UserRegister { user: data.user, error: data.error, - } + }) } diff --git a/crates/core/src/user/repository/user.rs b/crates/core/src/user/repository/user.rs index cc58bb01..ad159c99 100644 --- a/crates/core/src/user/repository/user.rs +++ b/crates/core/src/user/repository/user.rs @@ -140,7 +140,7 @@ impl UserRepository { } pub async fn update(&self, id: Pxid, dto: UpdateUserDto) -> Result { - let user = entity::prelude::User::find_by_id(&id.to_string()) + let user = entity::prelude::User::find_by_id(id.to_string()) .one(&*self.db) .await .map_err(|_| UserError::DatabaseError)?; diff --git a/crates/core/src/user/service.rs b/crates/core/src/user/service.rs index cee94537..4f7e8f10 100644 --- a/crates/core/src/user/service.rs +++ b/crates/core/src/user/service.rs @@ -139,9 +139,8 @@ impl UserService

{ pub async fn update_avatar(&self, id: Pxid, dto: UploadAvatarDto) -> Result { self.image_service .validate(&dto.bytes, UseCase::Avatar) - .map_err(|err| { + .inspect_err(|err| { tracing::warn!("Provided image is not valid for uploading as user's avatar"); - err })?; let Some(user) = self.find_by_id(id).await? else { diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 73b60110..f69cf0cd 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -18,15 +18,19 @@ async-graphql = { workspace = true } async-graphql-axum = { workspace = true } async-trait = { workspace = true } axum = { workspace = true } +axum-extra = { workspace = true, features = ["cookie"] } base64 = { workspace = true } chrono = "0.4.38" +cookie = { workspace = true } dotenv = { workspace = true } +http-auth-basic = { workspace = true } jsonwebtoken = { workspace = true } pxid = { workspace = true, features = ["async-graphql", "serde"] } rand = { workspace = true } rust-argon2 = { workspace = true } rust-s3 = { workspace = true, features = ["tokio-rustls-tls", "fail-on-err"], default-features = false } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros"] } tower-http = { workspace = true, features = ["cors"] } diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index d4300503..fedc77bd 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -1,5 +1,7 @@ use std::env; +pub const ACCESS_TOKEN_COOKIE_NAME: &str = "access-token"; + pub struct Config { pub database_url: String, pub jwt_secret: String, diff --git a/crates/server/src/graphql/context/token.rs b/crates/server/src/graphql/context/token.rs index eef75451..c1fc4286 100644 --- a/crates/server/src/graphql/context/token.rs +++ b/crates/server/src/graphql/context/token.rs @@ -1,12 +1,28 @@ use anyhow::Result; use axum::http::header::AUTHORIZATION; use axum::http::HeaderMap; +use axum_extra::extract::CookieJar; use townhall::auth::service::{AuthService, Token}; +use crate::config::ACCESS_TOKEN_COOKIE_NAME; + const HTTP_AUTHORIZATION_SCHEME: &str = "jwt"; pub fn try_extract_token(auth_service: &AuthService, headers: &HeaderMap) -> Result> { + if let Some(jwt) = get_jwt_from_access_token_cookie(headers) { + let token = auth_service.parse_jwt(&jwt).ok(); + + match token { + Some(token) => { + return Ok(Some(token)); + } + None => { + anyhow::bail!("Failed to parse JWT"); + } + } + } + if let Some(jwt) = get_jwt_from_auth_header(headers) { let token = auth_service.parse_jwt(&jwt).ok(); @@ -23,6 +39,17 @@ pub fn try_extract_token(auth_service: &AuthService, headers: &HeaderMap) -> Res Ok(None) } +/// Retrieves the Access Token Cookie and attempts to retrieve the JSON Web Token +fn get_jwt_from_access_token_cookie(headers: &HeaderMap) -> Option { + let cookies = CookieJar::from_headers(headers); + + if let Some(cookie) = cookies.get(ACCESS_TOKEN_COOKIE_NAME) { + return Some(cookie.value().to_string()); + } + + None +} + /// Retrieves the value from the Authorization HTTP Header and attempts to /// retrieve the JSON Web Token if the Authorization Scheme is `JWT` and a /// token is present. diff --git a/crates/server/src/handlers/api/mod.rs b/crates/server/src/handlers/api/mod.rs new file mode 100644 index 00000000..9122b30e --- /dev/null +++ b/crates/server/src/handlers/api/mod.rs @@ -0,0 +1,60 @@ +pub mod v1; + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Router; +use serde::Serialize; + +pub type Result = std::result::Result, ApiError>; + +#[derive(Debug, Serialize)] +pub struct ApiError { + message: String, + status_code: u16, +} + +impl ApiError { + pub fn new(message: impl Into, status_code: StatusCode) -> Self { + Self { + message: message.into(), + status_code: status_code.as_u16(), + } + } + + pub fn message(&self) -> &str { + &self.message + } + + pub fn status_code(&self) -> u16 { + self.status_code + } + + pub fn bad_request(message: impl Into) -> Self { + Self::new(message, StatusCode::BAD_REQUEST) + } + + pub fn unprocessable_entity(message: impl Into) -> Self { + Self::new(message, StatusCode::UNPROCESSABLE_ENTITY) + } + + pub fn internal_server_error(message: impl Into) -> Self { + Self::new(message, StatusCode::INTERNAL_SERVER_ERROR) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + if let Ok(response) = Response::builder() + .status(self.status_code) + .body(self.message.clone()) + { + return response.into_response(); + } + + ApiError::new("Server layer error", StatusCode::INTERNAL_SERVER_ERROR).into_response() + } +} + +pub fn api() -> Router { + Router::new().nest("/v1", v1::v1()) +} diff --git a/crates/server/src/handlers/api/v1/auth/login.rs b/crates/server/src/handlers/api/v1/auth/login.rs new file mode 100644 index 00000000..eec53e79 --- /dev/null +++ b/crates/server/src/handlers/api/v1/auth/login.rs @@ -0,0 +1,82 @@ +use std::str::FromStr; +use std::sync::Arc; + +use axum::extract::Request; +use axum::http::header::{AUTHORIZATION, SET_COOKIE}; +use axum::http::{HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use cookie::{CookieBuilder, SameSite}; +use http_auth_basic::Credentials; +use serde::Serialize; +use tracing::instrument; + +use townhall::user::model::Email; + +use crate::config::ACCESS_TOKEN_COOKIE_NAME; +use crate::context::Context; + +#[derive(Serialize)] +struct LoginResponse { + access_token: String, +} + +#[instrument(skip(ctx))] +pub async fn handler(Extension(ctx): Extension>, req: Request) -> Response { + let auth_header = req.headers().get(AUTHORIZATION); + + if let Some(auth_header) = auth_header { + let Ok(header_value) = auth_header.to_str() else { + return StatusCode::BAD_REQUEST.into_response(); + }; + + let Ok(credentials) = Credentials::from_str(header_value) else { + return StatusCode::BAD_REQUEST.into_response(); + }; + + let Ok(email) = Email::from_str(&credentials.user_id) else { + return StatusCode::BAD_REQUEST.into_response(); + }; + + match ctx + .services + .user + .verify_credentials(&email, &credentials.password) + .await + { + Ok(credentials_ok) => { + if credentials_ok { + if let Some(user) = ctx.services.user.find_by_email(&email).await.unwrap() { + let token = ctx.services.auth.sign_token(user.id).unwrap(); + let cookie = + CookieBuilder::new(ACCESS_TOKEN_COOKIE_NAME, token.to_string()) + .same_site(SameSite::None) + .secure(true) + .path("/") + .build(); + let mut response = Json(LoginResponse { + access_token: token.to_string(), + }) + .into_response(); + + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(cookie.to_string().as_str()).unwrap(), + ); + + return response; + } + } + + return StatusCode::BAD_REQUEST.into_response(); + } + Err(err) => { + tracing::error!(%err, "Failed to verify credentials"); + + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + } + } + + StatusCode::UNAUTHORIZED.into_response() +} diff --git a/crates/server/src/handlers/api/v1/auth/mod.rs b/crates/server/src/handlers/api/v1/auth/mod.rs new file mode 100644 index 00000000..71ff2b3b --- /dev/null +++ b/crates/server/src/handlers/api/v1/auth/mod.rs @@ -0,0 +1,8 @@ +pub mod login; + +use axum::routing::get; +use axum::Router; + +pub fn auth() -> Router { + Router::new().route("/login", get(login::handler)) +} diff --git a/crates/server/src/handlers/api/v1/mod.rs b/crates/server/src/handlers/api/v1/mod.rs new file mode 100644 index 00000000..92ae5a83 --- /dev/null +++ b/crates/server/src/handlers/api/v1/mod.rs @@ -0,0 +1,7 @@ +pub mod auth; + +use axum::Router; + +pub fn v1() -> Router { + Router::new().nest("/auth", auth::auth()) +} diff --git a/crates/server/src/handlers/mod.rs b/crates/server/src/handlers/mod.rs index 50b9335c..36e8ff33 100644 --- a/crates/server/src/handlers/mod.rs +++ b/crates/server/src/handlers/mod.rs @@ -1 +1,29 @@ +pub mod api; pub mod graphql; + +use std::sync::Arc; + +use axum::http::{header, HeaderValue, Method}; +use axum::routing::get; +use axum::{Extension, Router}; +use tower_http::cors::CorsLayer; + +use crate::context::Context; +use crate::graphql::schema::GraphQLSchema; + +pub fn router(context: Arc, schema: GraphQLSchema) -> Router { + let api = api::api(); + + Router::new() + .route("/graphql", get(graphql::playground).post(graphql::schema)) + .nest("/api", api) + .layer(Extension(context)) + .layer(Extension(schema)) + .layer( + CorsLayer::new() + .allow_credentials(true) + .allow_origin("http://localhost:8080".parse::().unwrap()) + .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::COOKIE]) + .allow_methods([Method::GET, Method::POST]), + ) +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 80b47a35..b7881926 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -9,11 +9,8 @@ use std::sync::Arc; use anyhow::Result; use async_graphql::{EmptySubscription, Schema}; -use axum::http::{header, HeaderValue, Method}; -use axum::routing::get; -use axum::{Extension, Router}; +use handlers::router; use tokio::net::TcpListener; -use tower_http::cors::CorsLayer; use crate::context::Context; use crate::graphql::schema::{MutationRoot, QueryRoot}; @@ -31,19 +28,7 @@ pub async fn start() -> Result<()> { ) .data(Arc::clone(&context)) .finish(); - let app = Router::new() - .route( - "/graphql", - get(handlers::graphql::playground).post(handlers::graphql::schema), - ) - .layer(Extension(context)) - .layer(Extension(schema)) - .layer( - CorsLayer::new() - .allow_origin("*".parse::().unwrap()) - .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]) - .allow_methods([Method::GET, Method::POST]), - ); + let app = router(context, schema); tracing::info!("GraphQL Playground available on http://{}/graphql", addr); diff --git a/crates/types/src/user/mod.rs b/crates/types/src/user/mod.rs index 37134325..9d5a1bef 100644 --- a/crates/types/src/user/mod.rs +++ b/crates/types/src/user/mod.rs @@ -1,7 +1,22 @@ mod email; -mod user; mod username; pub use email::*; -pub use user::*; pub use username::*; + +use chrono::{DateTime, Utc}; +use pxid::Pxid; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct User { + pub id: Pxid, + pub name: String, + pub surname: String, + pub username: Username, + pub email: Email, + pub avatar_id: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/crates/types/src/user/user.rs b/crates/types/src/user/user.rs deleted file mode 100644 index 08ef6090..00000000 --- a/crates/types/src/user/user.rs +++ /dev/null @@ -1,19 +0,0 @@ -use chrono::{DateTime, Utc}; -use pxid::Pxid; -use serde::{Deserialize, Serialize}; - -use super::email::Email; -use super::username::Username; - -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] -pub struct User { - pub id: Pxid, - pub name: String, - pub surname: String, - pub username: Username, - pub email: Email, - pub avatar_id: Option, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index eac6c6a6..a7ac08e5 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -16,14 +16,15 @@ name = "townhall-web" path = "src/bin/main.rs" [dependencies] -leptos = { workspace = true, features = ["csr"] } +anyhow = { workspace = true } +leptos = { workspace = true, features = ["csr"] } leptos_meta = { workspace = true, features = ["csr"] } leptos_router = { workspace = true, features = ["csr"] } +leptos-use = { workspace = true } # Local Dependencies client = { path = "../client" } types = { path = "../types" } -leptos-use = "0.10.10" [dev-dependencies] wasm-bindgen = "0.2" diff --git a/crates/web/Trunk.toml b/crates/web/Trunk.toml index 0654db1c..4dfb84fe 100644 --- a/crates/web/Trunk.toml +++ b/crates/web/Trunk.toml @@ -50,3 +50,11 @@ open = false # Protocol used for autoreload WebSockets connection. ws_protocol = "ws" + +[[proxy]] +backend = "http://127.0.0.1:7878/api/" +rewrite = "/api/" + +[[proxy]] +backend = "http://127.0.0.1:7878/graphql" +rewrite = "/graphql" diff --git a/crates/web/src/components/auth/login.rs b/crates/web/src/components/auth/login.rs index 4a08a97a..3d21c33f 100644 --- a/crates/web/src/components/auth/login.rs +++ b/crates/web/src/components/auth/login.rs @@ -18,11 +18,13 @@ pub fn LoginCard( let password_value = create_rw_signal(String::default()); let handle_submit = create_action(move |_| async move { - let client = Client::new(); + let client = Client::new("http://localhost:8080"); let res = client + .unwrap() .auth .token_create(email_value.get_untracked(), password_value.get_untracked()) - .await; + .await + .unwrap(); if let Some(ref error) = res.error { error_setter.set(Some(error.message.to_owned())); diff --git a/crates/web/src/components/auth/register.rs b/crates/web/src/components/auth/register.rs index d09d4a12..ac9c1e77 100644 --- a/crates/web/src/components/auth/register.rs +++ b/crates/web/src/components/auth/register.rs @@ -24,17 +24,19 @@ pub fn SignupCard( let password_value = create_rw_signal(String::default()); let submit = create_action(move |_| async move { - let client = Client::new(); + let client = Client::new("http://localhost:8080"); let res = client + .unwrap() .auth .user_register(townhall_client::auth::user_register::UserRegisterInput { - name: name_value.get_untracked().into(), - surname: surname_value.get_untracked().into(), - username: username_value.get_untracked().into(), + name: name_value.get_untracked(), + surname: surname_value.get_untracked(), + username: username_value.get_untracked(), email: Email::from_str(email_value.get_untracked().as_str()).unwrap(), - password: password_value.get_untracked().into(), + password: password_value.get_untracked(), }) - .await; + .await + .unwrap(); if let Some(ref error) = res.error { error_setter.set(Some(error.message.to_owned())); diff --git a/crates/web/src/components/layout/header.rs b/crates/web/src/components/layout/app/header.rs similarity index 100% rename from crates/web/src/components/layout/header.rs rename to crates/web/src/components/layout/app/header.rs diff --git a/crates/web/src/components/layout/app/mod.rs b/crates/web/src/components/layout/app/mod.rs new file mode 100644 index 00000000..4b279235 --- /dev/null +++ b/crates/web/src/components/layout/app/mod.rs @@ -0,0 +1,36 @@ +mod header; + +use leptos::{ + component, create_render_effect, expect_context, spawn_local, view, IntoView, + SignalGetUntracked, +}; +use leptos_router::{use_navigate, Outlet}; + +use crate::context::{session::UserSession, AppContext}; + +use self::header::Header; + +#[component] +pub fn AppLayout() -> impl IntoView { + let app_context = expect_context::(); + + create_render_effect(move |_| { + spawn_local(async move { + if let UserSession::Unauthenticated = + app_context.get_untracked().session.user.get_untracked() + { + let navigate = use_navigate(); + navigate("/auth/login", Default::default()); + } + }); + }); + + view! { +

+
+
+ +
+
+ } +} diff --git a/crates/web/src/components/layout/auth/mod.rs b/crates/web/src/components/layout/auth/mod.rs new file mode 100644 index 00000000..e8c77135 --- /dev/null +++ b/crates/web/src/components/layout/auth/mod.rs @@ -0,0 +1,34 @@ +use leptos::{ + component, create_render_effect, create_slice, expect_context, spawn_local, view, IntoView, + SignalGet, +}; +use leptos_router::{use_navigate, Outlet}; + +use crate::context::session::UserSession; +use crate::context::AppContext; + +#[component] +pub fn AuthLayout() -> impl IntoView { + let app_context = expect_context::(); + let (session_getter, _) = + create_slice(app_context, |ctx| ctx.session.user.get(), |_, _: ()| {}); + + create_render_effect(move |_| { + let session = move || session_getter.get(); + + spawn_local(async move { + if let UserSession::Authenticated(_) = session() { + let navigate = use_navigate(); + navigate("/", Default::default()); + } + }); + }); + + view! { +
+
+ +
+
+ } +} diff --git a/crates/web/src/components/layout/mod.rs b/crates/web/src/components/layout/mod.rs index c938245c..f7fefe5f 100644 --- a/crates/web/src/components/layout/mod.rs +++ b/crates/web/src/components/layout/mod.rs @@ -1,18 +1,2 @@ -mod header; - -use leptos::{component, view, IntoView}; -use leptos_router::Outlet; - -use self::header::Header; - -#[component] -pub fn Layout() -> impl IntoView { - view! { -
-
-
- -
-
- } -} +pub mod app; +pub mod auth; diff --git a/crates/web/src/context/mod.rs b/crates/web/src/context/mod.rs new file mode 100644 index 00000000..3fc1bfab --- /dev/null +++ b/crates/web/src/context/mod.rs @@ -0,0 +1,10 @@ +pub mod session; + +use leptos::RwSignal; + +pub type AppContext = RwSignal; + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct Context { + pub session: session::SessionContext, +} diff --git a/crates/web/src/context/session.rs b/crates/web/src/context/session.rs new file mode 100644 index 00000000..056e3116 --- /dev/null +++ b/crates/web/src/context/session.rs @@ -0,0 +1,60 @@ +use std::str::FromStr; + +use anyhow::Result; +use leptos::{RwSignal, SignalGetUntracked, SignalSet}; + +use townhall_client::Client; +use townhall_types::user::{Email, User, Username}; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum UserSession { + #[default] + Unknown, + Authenticated(User), + Unauthenticated, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SessionContext { + pub user: RwSignal, +} + +impl SessionContext { + pub async fn whoami(&self) -> Result { + if matches!(self.user.get_untracked(), UserSession::Unauthenticated) + || matches!(self.user.get_untracked(), UserSession::Unknown) + { + let client = Client::new("http://127.0.0.1:8080")?; + let res = client.auth.me().await?; + + if let Some(ref user) = res.user { + let user = user.to_owned(); + let email = Email::from_str(&user.email)?; + let username = Username::from_str(&user.username)?; + + self.user.set(UserSession::Authenticated(User { + email, + username, + id: user.id, + name: user.name.clone(), + surname: user.surname.clone(), + avatar_id: None, + created_at: user.created_at, + updated_at: user.updated_at, + deleted_at: None, + })); + + return Ok(true); + } + } + + self.user.set(UserSession::Unauthenticated); + Ok(false) + } + + pub async fn login(&self, email: String, password: String) -> Result { + let client = Client::new("http://127.0.0.1:8080")?; + client.auth.login(email, password).await?; + self.whoami().await + } +} diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index 526d305f..56a10d56 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -1,28 +1,19 @@ pub mod components; -pub mod stores; +pub mod context; pub mod views; use leptos::{component, create_rw_signal, provide_context, view, IntoView}; -use leptos_meta::{provide_meta_context, Title}; -use leptos_router::{Route, Router, Routes}; +use leptos_meta::provide_meta_context; -use crate::components::layout::Layout; -use crate::stores::AppStore; -use crate::views::home::Home; +use crate::context::Context; +use crate::views::View; #[component] pub fn App() -> impl IntoView { + provide_context(create_rw_signal(Context::default())); provide_meta_context(); - provide_context(create_rw_signal(AppStore::default())); view! { - - <Router> - <Routes> - <Route path="/" view={Layout}> - <Route path="/" view=Home /> - </Route> - </Routes> - </Router> + <View /> } } diff --git a/crates/web/src/stores/mod.rs b/crates/web/src/stores/mod.rs deleted file mode 100644 index 6e44bd3d..00000000 --- a/crates/web/src/stores/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod session; - -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct AppStore { - pub session: session::SessionStore, -} diff --git a/crates/web/src/stores/session.rs b/crates/web/src/stores/session.rs deleted file mode 100644 index 4c55333e..00000000 --- a/crates/web/src/stores/session.rs +++ /dev/null @@ -1,6 +0,0 @@ -use leptos::RwSignal; - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct SessionStore { - pub user: RwSignal<Option<()>>, -} diff --git a/crates/web/src/views/login.rs b/crates/web/src/views/login.rs new file mode 100644 index 00000000..8ff91f32 --- /dev/null +++ b/crates/web/src/views/login.rs @@ -0,0 +1,77 @@ +use leptos::{ + component, create_action, create_rw_signal, create_signal, expect_context, view, IntoView, + Show, SignalGet, SignalGetUntracked, SignalSet, +}; +use leptos_router::use_navigate; + +use crate::{ + components::text_field::{TextField, TextFieldType}, + context::AppContext, +}; + +#[component] +pub fn Login() -> impl IntoView { + let app_context = expect_context::<AppContext>(); + let (error_getter, error_setter) = create_signal::<Option<String>>(None); + let email_value = create_rw_signal(String::default()); + let password_value = create_rw_signal(String::default()); + let handle_submit = create_action(move |_| async move { + let navigate = use_navigate(); + let email = email_value.get_untracked(); + let password = password_value.get_untracked(); + + match app_context + .get_untracked() + .session + .login(email, password) + .await + { + Ok(_) => { + navigate("/", Default::default()); + } + Err(err) => { + error_setter.set(Some(err.to_string())); + } + } + }); + + view! { + <div class="min-h-screen relative flex justify-center items-center bg-no-repeat bg-cover bg-slate-800 bg-[url('https://images.unsplash.com/photo-1580192985016-7e15ef081dd8?q=80&w=1961&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D')]"> + <div class="absolute bg-black rounded-[50%] w-full h-full blur-[23rem]"></div> + <div class="flex justify-center items-center z-20"> + <div class="w-full"> + <h1 class="text-6xl text-center font-bold text-white mb-16">TownHall</h1> + <form class="w-96" on:submit=move |ev| { + ev.prevent_default(); + handle_submit.dispatch(()); + }> + <TextField + class="w-full" + name="email" + placeholder="Email" + value=email_value + /> + <TextField + class="w-full" + name="password" + r#type=TextFieldType::Password + placeholder="Password" + value=password_value + /> + <button type="submit">Login</button> + <Show when=move || error_getter.get().is_some()> + <div class="bg-rose-600 text-white p-2 rounded-md"> + {error_getter.get().unwrap()} + </div> + </Show> + </form> + <div class="text-center w-full text-white mt-3"> + {"Don't have an account? "} <a class="underline" href="/auth/signup"> + Sign up! + </a> + </div> + </div> + </div> + </div> + } +} diff --git a/crates/web/src/views/mod.rs b/crates/web/src/views/mod.rs index 9b86bcf6..c61b7f07 100644 --- a/crates/web/src/views/mod.rs +++ b/crates/web/src/views/mod.rs @@ -1 +1,51 @@ pub mod home; +pub mod login; +pub mod signup; + +use leptos::{ + component, create_render_effect, expect_context, spawn_local, view, IntoView, + SignalGetUntracked, +}; +use leptos_meta::Title; +use leptos_router::{Route, Router, Routes}; + +use crate::components::layout::app::AppLayout; +use crate::components::layout::auth::AuthLayout; +use crate::context::session::UserSession; +use crate::context::AppContext; + +use self::home::Home; +use self::login::Login; +use self::signup::SignUp; + +#[component] +pub fn View() -> impl IntoView { + let app_context = expect_context::<AppContext>(); + + create_render_effect(move |_| { + spawn_local(async move { + let session = app_context.get_untracked().session.user.get_untracked(); + + if let UserSession::Unknown = session { + if let Err(err) = app_context.get_untracked().session.whoami().await { + leptos::logging::error!("Failed to check user session. {err}"); + } + } + }); + }); + + view! { + <Title text="TownHall"/> + <Router> + <Routes> + <Route path="/" view={AppLayout}> + <Route path="/" view=Home /> + </Route> + <Route path="/auth" view={AuthLayout}> + <Route path="/login" view=Login /> + <Route path="/signup" view=SignUp /> + </Route> + </Routes> + </Router> + } +} diff --git a/crates/web/src/views/signup.rs b/crates/web/src/views/signup.rs new file mode 100644 index 00000000..5f4aafe5 --- /dev/null +++ b/crates/web/src/views/signup.rs @@ -0,0 +1,104 @@ +use std::str::FromStr; + +use leptos::wasm_bindgen::UnwrapThrowExt; +use leptos::{ + component, create_action, create_rw_signal, create_signal, view, IntoView, Show, SignalGet, + SignalGetUntracked, SignalSet, +}; + +use townhall_client::{auth::user_register::UserRegisterInput, Client}; +use townhall_types::user::Email; + +use crate::components::text_field::{TextField, TextFieldType}; + +#[component] +pub fn SignUp() -> impl IntoView { + let (error_getter, error_setter) = create_signal::<Option<String>>(None); + let name_value = create_rw_signal(String::default()); + let surname_value = create_rw_signal(String::default()); + let username_value = create_rw_signal(String::default()); + let email_value = create_rw_signal(String::default()); + let password_value = create_rw_signal(String::default()); + let handle_submit = create_action(move |_| async move { + let client = Client::new("http://127.0.0.1:8080").unwrap(); + let Ok(email) = Email::from_str(email_value.get_untracked().as_str()) else { + error_setter.set(Some(String::from("Email is not valid"))); + return; + }; + let user_register = client + .auth + .user_register(UserRegisterInput { + email, + name: name_value.get_untracked(), + surname: surname_value.get_untracked(), + username: username_value.get_untracked(), + password: password_value.get_untracked(), + }) + .await + .unwrap_throw(); + + if let Some(err) = user_register.error { + error_setter.set(Some(err.message)); + } else { + // Redirect to login page + } + }); + + view! { + <div class="min-h-screen relative flex justify-center items-center bg-no-repeat bg-cover bg-slate-800 bg-[url('https://images.unsplash.com/photo-1580192985016-7e15ef081dd8?q=80&w=1961&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D')]"> + <div class="absolute bg-black rounded-[50%] w-full h-full blur-[23rem]"></div> + <div class="flex justify-center items-center z-20"> + <div class="w-full"> + <h1 class="text-6xl text-center font-bold text-white mb-16">TownHall</h1> + <form class="w-96" on:submit=move |ev| { + ev.prevent_default(); + handle_submit.dispatch(()); + }> + <TextField + class="w-full" + name="name" + placeholder="Name" + value=name_value + /> + <TextField + class="w-full" + name="surname" + placeholder="Surname" + value=surname_value + /> + <TextField + class="w-full" + name="username" + placeholder="Username" + value=username_value + /> + <TextField + class="w-full" + name="email" + placeholder="Email" + value=email_value + /> + <TextField + class="w-full" + name="password" + r#type=TextFieldType::Password + placeholder="Password" + value=password_value + /> + <button type="submit">Login</button> + <Show when=move || error_getter.get().is_some()> + <div class="bg-rose-600 text-white p-2 rounded-md"> + {error_getter.get().unwrap()} + </div> + </Show> + </form> + <div class="text-center w-full text-white mt-3"> + {"Already have an account? "} <a class="underline" href="/auth/login"> + Login! + </a> + </div> + </div> + </div> + </div> + } +}