diff --git a/.dev_docker/.env b/.dev_docker/.env new file mode 100644 index 0000000..1681cac --- /dev/null +++ b/.dev_docker/.env @@ -0,0 +1,4 @@ +POSTGRES_DB=yugi_app +POSTGRES_USER=yugi +POSTGRES_PASSWORD=yugi +DATABASE_URL=postgres://yugi:yugi@db:5432/yugi_app diff --git a/.dev_docker/docker-compose.yml b/.dev_docker/docker-compose.yml new file mode 100644 index 0000000..8c9c49e --- /dev/null +++ b/.dev_docker/docker-compose.yml @@ -0,0 +1,18 @@ +services: + db: + image: postgres:15.3-alpine + restart: unless-stopped + ports: + - 5433:5432 + networks: + - db + volumes: + - postgres-data:/var/lib/postgresql/data + env_file: + - .env + +volumes: + postgres-data: + +networks: + db: diff --git a/.gitignore b/.gitignore index 95b78e4..05d5cbe 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,9 @@ Cargo.lock dist target static -pkg +pkg ./Cargo.lock package-lock.json /card_downloader/cards /card_downloader/dl_cache.pkl +/data/ diff --git a/Cargo.toml b/Cargo.toml index e5b2e5e..90d77a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,12 +13,13 @@ sycamore = { version = "0.8.2", features = [ ] } serde = { version = "1", features = ["derive"] } serde_json = "1" -env_logger = "0.11.3" +env_logger = "0.10.0" log = "0.4.20" once_cell = "1.18.0" -web-sys = "0.3.64" +web-sys = { version = "0.3.64", features = ["Window", "Storage"] } cfg-if = "1.0.0" -chrono = { version = "0.4.38", features = ["serde"] } +chrono = { version = "0.4.38", features = ["serde", "wasm-bindgen"] } +lazy_static = "1.5" strum = "0.26.2" strum_macros = "0.26.2" polars = { version = "0.39.2", default-features = false, features = [ @@ -41,8 +42,21 @@ fantoccini = "0.19" tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } perseus-axum = { version = "0.4.2" } axum = "0.6" +futures = "0.3.28" +sea-orm = { version = "1.0", features = [ + "sqlx-postgres", + "runtime-tokio-native-tls", + "macros", + "with-chrono", +] } +jsonwebtoken = "9.3.0" +argon2 = "0.5" tower-http = { version = "0.3", features = ["fs"] } [target.'cfg(client)'.dependencies] -wasm-bindgen = "0.2" +wasm-bindgen = "0.2.93" reqwest = { version = "0.11", features = ["json"] } +sea-orm = { version = "1.0" } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(engine)', 'cfg(client)'] } diff --git a/README.md b/README.md index 27fff76..68aa91e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Installing requirements ## 1. Install rust: -### Windows: +### Windows: Download installer from https://www.rust-lang.org/tools/install @@ -11,25 +11,50 @@ Run `curl --proto '=https' --tlsv1.3 https://sh.rustup.rs -sSf | sh` ## 2. Install npm -### Windows: +### Windows: https://nodejs.org/en +(todo look into:) +https://pnpm.io/ + +`npm i -D daisyui@latest` ### Unix based systems: `sudo apt install nodejs` + `sudo apt install npm` +`npm i -D daisyui@latest` + +For easy UI see: + +https://daisyui.com/components/button/ + ## 3. Install Perseus, for real-time updates while developing `cargo install perseus-cli` + +(temporarily broken, if this doensn't work run `cargo install perseus-cli --locked` ) + `rustup target add wasm32-unknown-unknown` -## 4. Install tailwindcss, for styling +`rustup target add wasm32-unknown-unknown` + + +## 4. Install docker for Postgresql + + +## 5. Install SeaORM for database + +`cargo install sea-orm-cli@1.0.0-rc.5` + + +## 6. Install tailwindcss, for styling `npm install -D tailwindcss` -Also take a look at +Also take a look at Website: https://framesurge.sh/perseus/en-US/ @@ -39,6 +64,13 @@ https://blog.logrocket.com/building-rust-app-perseus/ # Building the project + +To set up the database, run: +`$env:DATABASE_URL = "postgres://yugi:yugi@localhost:5432/yugi_app"; sea-orm-cli migrate up` + +Updating entities after updating database: +`$env:DATABASE_URL = "postgres://yugi:yugi@localhost:5432/yugi_app"; sea-orm-cli generate entity -o src/entity --with-serde both` + To build CSS run: `npm run build` @@ -57,6 +89,9 @@ For printing polars tables: See https://docs.rs/polars/latest/polars/#config-with-env-vars +Default Windows: + +`Env:RUST_LOG = "info"; $Env:POLARS_FMT_MAX_COLS = "100"; $Env:POLARS_TABLE_WIDTH = "200";` # Deploying the project diff --git a/migration/Cargo.toml b/migration/Cargo.toml new file mode 100644 index 0000000..780de08 --- /dev/null +++ b/migration/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "migration" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "migration" +path = "src/lib.rs" + +[dependencies] +async-std = { version = "1", features = ["attributes", "tokio1"] } + +[dependencies.sea-orm-migration] +version = "1.0.0" +features = ["sqlx-postgres", "runtime-tokio-native-tls"] diff --git a/migration/README.md b/migration/README.md new file mode 100644 index 0000000..7a7bc0a --- /dev/null +++ b/migration/README.md @@ -0,0 +1,46 @@ +# References + +https://www.sea-ql.org/sea-orm-tutorial/ch01-02-migration-cli.html +https://www.sea-ql.org/SeaORM/docs/migration/writing-migration/ + +# Running Migrator CLI + +- Generate a new migration file + ```sh + cargo run -- generate MIGRATION_NAME + ``` +- Apply all pending migrations + ```sh + cargo run + ``` + ```sh + cargo run -- up + ``` +- Apply first 10 pending migrations + ```sh + cargo run -- up -n 10 + ``` +- Rollback last applied migrations + ```sh + cargo run -- down + ``` +- Rollback last 10 applied migrations + ```sh + cargo run -- down -n 10 + ``` +- Drop all tables from the database, then reapply all migrations + ```sh + cargo run -- fresh + ``` +- Rollback all applied migrations, then reapply all migrations + ```sh + cargo run -- refresh + ``` +- Rollback all applied migrations + ```sh + cargo run -- reset + ``` +- Check the status of all migrations + ```sh + cargo run -- status + ``` diff --git a/migration/src/lib.rs b/migration/src/lib.rs new file mode 100644 index 0000000..06702f0 --- /dev/null +++ b/migration/src/lib.rs @@ -0,0 +1,12 @@ +pub use sea_orm_migration::prelude::*; + +mod m20240813_000001_create_users; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![Box::new(m20240813_000001_create_users::Migration)] + } +} diff --git a/migration/src/m20240813_000001_create_users.rs b/migration/src/m20240813_000001_create_users.rs new file mode 100644 index 0000000..d0edd7d --- /dev/null +++ b/migration/src/m20240813_000001_create_users.rs @@ -0,0 +1,52 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// https://github.com/SeaQL/sea-orm/blob/368b1126f73f47c7ec30fe523834f6a0962a193b/sea-orm-migration/src/schema.rs + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // User table + // @todo verify all data saved is length-checked + manager + .create_table( + Table::create() + .table(User::Table) + .col(pk_auto(User::Id)) + .col(string_uniq(User::Username)) + .col(string(User::PasswordHashAndSalt)) + .col(string_null(User::Nickname)) + .col(timestamp(User::CreationTime)) + .col(timestamp(User::LastActiveTime)) + .col(boolean(User::IsAdmin)) + .col(string_null(User::Email)) + .col(string_null(User::Avatar)) + .col(string_null(User::ForgotPasswordRequest)) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(User::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum User { + Table, + Id, + Username, + PasswordHashAndSalt, + Nickname, + CreationTime, + LastActiveTime, + IsAdmin, + Email, + Avatar, + ForgotPasswordRequest, +} diff --git a/migration/src/main.rs b/migration/src/main.rs new file mode 100644 index 0000000..c6b6e48 --- /dev/null +++ b/migration/src/main.rs @@ -0,0 +1,6 @@ +use sea_orm_migration::prelude::*; + +#[async_std::main] +async fn main() { + cli::run_cli(migration::Migrator).await; +} diff --git a/package.json b/package.json index ea79e79..efe2ad0 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "author": "", "license": "ISC", "devDependencies": { + "daisyui": "^4.12.10", "tailwindcss": "^3.4.1" } } diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..6f79b1a --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,19 @@ +use crate::user; +use axum_login::{AuthUser, AuthnBackend, UserId}; +use serde::{Deserialize, Serialize}; + +// References +// https://github.com/maxcountryman/axum-login/tree/main/examples/sqlite/src +// https://framesurge.sh/perseus/en-US/docs/0.4.x/state/intro + +impl AuthUser for user::Model { + type Id = i32; + + fn id(&self) -> Self::Id { + self.id + } + + fn session_auth_hash(&self) -> &[u8] { + self.password.as_bytes() + } +} diff --git a/src/capsules/forgot_password_form.rs b/src/capsules/forgot_password_form.rs new file mode 100644 index 0000000..1e96f4c --- /dev/null +++ b/src/capsules/forgot_password_form.rs @@ -0,0 +1,159 @@ +use lazy_static::lazy_static; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; +use web_sys::Event; + +use crate::{ + components::{ + static_components::close_button::CloseButtonSvg, sub_components::error_block::ErrorBlock, + }, + global_state::AppStateRx, +}; + +cfg_if::cfg_if! { + if #[cfg(client)] { + use crate::{ + state_enums::{ OpenState}, + templates::get_api_path, + endpoints::FORGOT_PASSWORD, + models::{ + auth::ForgotPasswordRequest, + generic::GenericResponse, + }, + }; + use reqwest::StatusCode; + + } +} + +lazy_static! { + pub static ref FORGOT_PASSWORD_FORM: Capsule = + get_capsule(); +} + +#[derive(Serialize, Deserialize, Clone, ReactiveState)] +#[rx(alias = "ForgotPasswordFormStateRx")] +struct ForgotPasswordFormState { + username: String, + how_to_reach: String, + error: String, +} + +impl ForgotPasswordFormStateRx { + #[cfg(client)] + fn reset(&self) { + self.username.set(String::new()); + self.how_to_reach.set(String::new()); + self.error.set(String::new()); + } +} + +#[derive(Clone)] +pub struct ForgotPasswordFormProps {} + +#[auto_scope] +fn forgot_password_form_capsule( + cx: Scope, + state: &ForgotPasswordFormStateRx, + _props: ForgotPasswordFormProps, +) -> View { + // If there's a tentative username, set it + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + state + .username + .set((*global_state.auth.pending_username.get()).clone()); + global_state.auth.pending_username.set(String::new()); + + let close_modal = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + // Close modal + state.reset(); + global_state + .modals_open + .forgot_password + .set(OpenState::Closed) + }); + } + }; + let handle_submit = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let request = ForgotPasswordRequest { + username: state.username.get().as_ref().clone(), + contact_info: state.how_to_reach.get().as_ref().clone(), + }; + + // // @todo clean up error handling + let client = reqwest::Client::new(); + let response = client + .post(get_api_path(FORGOT_PASSWORD).as_str()) + .json(&request) + .send() + .await + .unwrap(); + let status = response.status(); + let response_data = response.json::().await.unwrap(); + if status != StatusCode::OK { + state.error.set(response_data.status); + return; + } + + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + + // Close modal + state.reset(); + global_state + .modals_open + .forgot_password + .set(OpenState::Closed); + }); + } + }; + + view! { cx, + dialog (class="modal-open modal modal-bottom sm:modal-middle animate-none") { + div (class="modal-box"){ + // Header row - title and close button + h3 (class="text-lg font-bold mb-4 text-center"){"Forgot Password"} + button (on:click = close_modal, class = "btn btn-circle right-2 top-2 absolute") { CloseButtonSvg {} } + + // Add component for handling error messages + ErrorBlock(error = state.error.clone()) + + // Username field + div (class = "label") { span (class = "label-text") { "Username" } } + input (bind:value = state.username, class = "input input-bordered w-full") + + // Password field + div (class = "label") { span (class = "label-text") { "Contact Info" } } + input (bind:value = state.how_to_reach, class = "input input-bordered w-full") + + // Submit button + div (class = "flex justify-center mt-6") { + button (on:click = handle_submit, class="btn"){"Submit"} + } + } + } + } +} + +pub fn get_capsule() -> Capsule { + Capsule::build(Template::build("forgot_password_form").build_state_fn(get_build_state)) + .empty_fallback() + .view_with_state(forgot_password_form_capsule) + .build() +} + +#[engine_only_fn] +async fn get_build_state(_info: StateGeneratorInfo<()>) -> ForgotPasswordFormState { + ForgotPasswordFormState { + username: String::new(), + how_to_reach: String::new(), + error: String::new(), + } +} diff --git a/src/capsules/login_form.rs b/src/capsules/login_form.rs new file mode 100644 index 0000000..e61e106 --- /dev/null +++ b/src/capsules/login_form.rs @@ -0,0 +1,210 @@ +use lazy_static::lazy_static; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; +use web_sys::Event; + +use crate::{ + components::{ + static_components::close_button::CloseButtonSvg, sub_components::error_block::ErrorBlock, + }, + global_state::AppStateRx, +}; + +cfg_if::cfg_if! { + if #[cfg(client)] { + use crate::{ + endpoints::LOGIN, + models::auth::{LoginInfo, LoginResponse, WebAuthInfo}, + models::generic::GenericResponse, + state_enums::{OpenState}, + templates::get_api_path, + }; + use reqwest::StatusCode; + } +} + +lazy_static! { + pub static ref LOGIN_FORM: Capsule = get_capsule(); +} + +#[derive(Serialize, Deserialize, Clone, ReactiveState)] +#[rx(alias = "LoginFormStateRx")] +struct LoginFormState { + username: String, + password: String, + remember_me: bool, + error: String, +} + +impl LoginFormStateRx { + #[cfg(client)] + fn reset(&self) { + self.username.set(String::new()); + self.password.set(String::new()); + self.remember_me.set(false); + self.error.set(String::new()); + } +} + +#[derive(Clone)] +pub struct LoginFormProps { + pub remember_me: bool, +} + +#[auto_scope] +fn login_form_capsule( + cx: Scope, + state: &LoginFormStateRx, + props: LoginFormProps, +) -> View { + // If there's a tentative username, set it + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + state + .username + .set((*global_state.auth.pending_username.get()).clone()); + global_state.auth.pending_username.set(String::new()); + + let close_modal = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + state.reset(); + global_state.modals_open.login.set(OpenState::Closed) + }); + } + }; + + let handle_forgot_password = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + + // Update tentative username + global_state + .auth + .pending_username + .set((*state.username.get()).clone()); + + // Open new modal + global_state + .modals_open + .forgot_password + .set(OpenState::Open); + + // Close modal + state.reset(); + global_state.modals_open.login.set(OpenState::Closed); + }); + } + }; + + let handle_log_in = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let remember_me = *state.remember_me.get().as_ref(); + let username = state.username.get().as_ref().clone(); + let login_info = LoginInfo { + username: username.clone(), + password: state.password.get().as_ref().clone(), + remember_me, + }; + + // // @todo clean up error handling + let client = reqwest::Client::new(); + let response = client + .post(get_api_path(LOGIN).as_str()) + .json(&login_info) + .send() + .await + .unwrap(); + + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + + if response.status() != StatusCode::OK { + let response = response.json::().await.unwrap(); + state.error.set(response.status.to_string()); + return; + } + + let response = response.json::().await.unwrap(); + + // Save token to session/local storage and update state + global_state.auth.handle_log_in(WebAuthInfo { + token: response.token, + expires: response.expires, + username, + remember_me, + }); + + // Close modal + state.reset(); + global_state.modals_open.login.set(OpenState::Closed); + }); + } + }; + + view! { cx, + dialog (class="modal-open modal modal-bottom sm:modal-middle") { + div (class="modal-box"){ + // Header row - title and close button + h3 (class="text-lg font-bold mb-4 text-center"){"Sign in"} + button (on:click = close_modal, class = "btn btn-circle right-2 top-2 absolute") { CloseButtonSvg {} } + + // Add component for handling error messages + ErrorBlock(error = state.error.clone()) + + // Username field + div (class = "label") { span (class = "label-text") { "Username" } } + input (bind:value = state.username, class = "input input-bordered w-full") + + // Password field + div (class = "label") { span (class = "label-text") { "Password" } } + input (bind:value = state.password, type = "password", class = "input input-bordered w-full") + + // Remember me button and forget password button + div (class="flex justify-between items-center mt-1"){ + // Remember me button + (match props.remember_me { + true => { view!{ cx, + div (class = "flex items-start form-control") { + label (class = "label cursor-pointer") { + span (class = "label-text mr-4") { "Remember me" } + input (bind:checked = state.remember_me, type = "checkbox", class = "checkbox") + } + } + }}, + false => view!{cx, }, + }) + // Forget password button + button (on:click = handle_forgot_password, class="flex link link-primary"){"Lost Password?"} + } + + // Log in button + div (class = "flex justify-center") { + button (on:click = handle_log_in, class="btn"){"Log in"} + } + } + } + } +} + +pub fn get_capsule() -> Capsule { + Capsule::build(Template::build("login_form").build_state_fn(get_build_state)) + .empty_fallback() + .view_with_state(login_form_capsule) + .build() +} + +#[engine_only_fn] +async fn get_build_state(_info: StateGeneratorInfo<()>) -> LoginFormState { + LoginFormState { + username: String::new(), + password: String::new(), + remember_me: false, + error: String::new(), + } +} diff --git a/src/capsules/mod.rs b/src/capsules/mod.rs new file mode 100644 index 0000000..d85fc52 --- /dev/null +++ b/src/capsules/mod.rs @@ -0,0 +1,3 @@ +pub mod forgot_password_form; +pub mod login_form; +pub mod register_form; diff --git a/src/capsules/register_form.rs b/src/capsules/register_form.rs new file mode 100644 index 0000000..ca26fa7 --- /dev/null +++ b/src/capsules/register_form.rs @@ -0,0 +1,191 @@ +use lazy_static::lazy_static; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; +use web_sys::Event; + +use crate::components::{ + static_components::close_button::CloseButtonSvg, sub_components::error_block::ErrorBlock, +}; + +cfg_if::cfg_if! { + if #[cfg(client)] { + use crate::{ + models::auth::{RegisterRequest}, + endpoints::REGISTER, + state_enums::OpenState, + templates::get_api_path, + global_state::AppStateRx, + models::{ + generic::GenericResponse + }, + }; + use reqwest::StatusCode; + } +} + +lazy_static! { + pub static ref REGISTER_FORM: Capsule = get_capsule(); +} + +#[derive(Serialize, Deserialize, Clone, ReactiveState)] +#[rx(alias = "RegisterFormStateRx")] +struct RegisterFormState { + username: String, + password: String, + nickname: String, + registration_code: String, + email: String, + error: String, +} + +impl RegisterFormStateRx { + #[cfg(client)] + fn reset(&self) { + self.username.set(String::new()); + self.password.set(String::new()); + self.nickname.set(String::new()); + self.registration_code.set(String::new()); + self.email.set(String::new()); + self.error.set(String::new()); + } +} + +#[derive(Clone)] +pub struct RegisterFormProps { + pub nickname: bool, + pub registration_code: bool, + pub email: bool, +} + +#[auto_scope] +fn register_form_capsule( + cx: Scope, + state: &RegisterFormStateRx, + props: RegisterFormProps, +) -> View { + let close_modal = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + state.reset(); + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + global_state.modals_open.register.set(OpenState::Closed) + }); + } + }; + + let handle_register = move |_event: Event| { + #[cfg(client)] + { + let registration_code = state.registration_code.get().as_ref().clone(); + spawn_local_scoped(cx, async move { + let register_info = RegisterRequest { + username: state.username.get().as_ref().clone(), + password: state.password.get().as_ref().clone(), + nickname: state.nickname.get().as_ref().clone(), + email: state.email.get().as_ref().clone(), + registration_code, + }; + + // // @todo clean up error handling + let client = reqwest::Client::new(); + let response = client + .post(get_api_path(REGISTER).as_str()) + .json(®ister_info) + .send() + .await + .unwrap(); + + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + let status = response.status(); + let response_data = response.json::().await.unwrap(); + if status != StatusCode::OK { + // todo update to some type of alert + state.error.set(response_data.status); + return; + } + + // Update tentative username + global_state + .auth + .pending_username + .set((*state.username.get()).clone()); + + // Open login modal + global_state.modals_open.login.set(OpenState::Open); + + // Close modal + state.reset(); + global_state.modals_open.register.set(OpenState::Closed); + }); + } + }; + + view! { cx, + dialog (class="modal-open modal modal-bottom sm:modal-middle"){ + div (class="modal-box") { + // Header row - title and close button + h3 (class="text-lg font-bold mb-4 text-center"){"Register"} + button (on:click = close_modal, class = "btn btn-circle right-2 top-2 absolute") { CloseButtonSvg {} } + + // Add component for handling error messages + ErrorBlock(error = state.error.clone()) + + // Username field + div (class = "label") { span (class = "label-text") { "Username" } } + input (bind:value = state.username, class = "input input-bordered w-full") + + // Password field + div (class = "label") { span (class = "label-text") { "Password" } } + input (bind:value = state.password, type = "password", class = "input input-bordered w-full") + + (match props.registration_code { + true => { view! {cx, + div (class = "label") { span (class = "label-text") { "Registration Code" } } + input (bind:value = state.registration_code, class = "input input-bordered w-full") + }}, + false => {view!{cx,}}, + }) + (match props.nickname { + true => { view! {cx, + div (class = "label") { span (class = "label-text") { "Nickname (Optional)" } } + input (bind:value = state.nickname, class = "input input-bordered w-full") + }}, + false => {view!{cx,}}, + }) + (match props.email { + true => { view! {cx, + div (class = "label") { span (class = "label-text") { "Email (Optional)" } } + input (bind:value = state.email, class = "input input-bordered w-full") + }}, + false => {view!{cx,}}, + }) + + // Register button + div (class = "flex justify-center mt-6") { + button (on:click = handle_register, class="btn"){"Register"} + } + } + } + } +} + +pub fn get_capsule() -> Capsule { + Capsule::build(Template::build("register_form").build_state_fn(get_build_state)) + .empty_fallback() + .view_with_state(register_form_capsule) + .build() +} + +#[engine_only_fn] +async fn get_build_state(_info: StateGeneratorInfo<()>) -> RegisterFormState { + RegisterFormState { + username: String::new(), + password: String::new(), + error: String::new(), + nickname: String::new(), + registration_code: String::new(), + email: String::new(), + } +} diff --git a/src/components/header.rs b/src/components/header.rs new file mode 100644 index 0000000..f621031 --- /dev/null +++ b/src/components/header.rs @@ -0,0 +1,132 @@ +use perseus::prelude::*; +use sycamore::prelude::*; +use web_sys::Event; + +use crate::{ + components::static_components::menu_button::MenuButtonSvg, + global_state::AppStateRx, + state_enums::{ContentState, LoginState}, +}; + +cfg_if::cfg_if! { + if #[cfg(client)] { + use crate::{ + state_enums::OpenState, + }; + } +} + +#[derive(Prop)] +pub struct HeaderProps { + pub content_state: ContentState, +} + +// TODO update to have user preferences +#[component] +fn LinkList(cx: Scope) -> View { + // Get global state to get style info + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + + // TODO -> update to use inventory theme + view! { cx, + li { + a (href = "inventory") { + label (class = "swap") { + input ( + type="radio", + name = "theme-dropdown", + class = "theme-controller", + value = (*global_state.style.theme.default.get()), + ) {} + p (class = ( + if *global_state.style.theme.current.get() == *global_state.style.theme.default.get(){ + "font-bold" + } + else { + "" + } + )){ "Inventory" } + } + } + } + } +} + +#[component] +pub fn Header(cx: Scope, props: HeaderProps) -> View { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + + let handle_log_in = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + global_state.modals_open.login.set(OpenState::Open); + }); + } + }; + + let handle_register = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + global_state.modals_open.register.set(OpenState::Open); + }); + } + }; + + let handle_log_out = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + global_state.auth.handle_log_out(); + }); + } + }; + + view! { cx, + header (class="navbar bg-base-100") { + // Navigation + div (class="navbar-start") { + div (class="dropdown") { + div (tabindex="0", role="button", class="btn btn-ghost lg:hidden") { MenuButtonSvg {} } + ul (tabindex = "0", class = "menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow" ) { + LinkList {} + } + } + ul (class="menu menu-horizontal px-1 hidden lg:flex") { + LinkList {} + } + } + // Title + div (class="navbar-center lg:flex") { + (props.content_state.to_string()) + } + // User buttons + div (class="navbar-end") { + (match *global_state.auth.state.get() { + LoginState::Authenticated => { view! { cx, + button(on:click = handle_log_out, class = "btn btn-primary mr-2") { + "Log out" + } + } }, + LoginState::NotAuthenticated => { view! { cx, + button(on:click = handle_register, class = "btn btn-primary mr-2") { + "Register" + } + button(on:click = handle_log_in, class = "btn btn-secondary mr-2") { + "Log in" + } + } }, + LoginState::Unknown => { view! { cx, + div (class = "px-5 py-2.5 me-2 mb-2") { + "Loading..." + } + } }, + }) + } + } + } +} diff --git a/src/components/layout.rs b/src/components/layout.rs index d3413f3..2fcda47 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -1,48 +1,131 @@ +use crate::{ + capsules::{ + forgot_password_form::{ForgotPasswordFormProps, FORGOT_PASSWORD_FORM}, + login_form::{LoginFormProps, LOGIN_FORM}, + register_form::{RegisterFormProps, REGISTER_FORM}, + }, + components::header::Header, + global_state::AppStateRx, + state_enums::{ContentState, OpenState}, +}; +use perseus::prelude::*; use sycamore::prelude::*; #[derive(Prop)] pub struct LayoutProps<'a, G: Html> { - pub title: &'a str, + pub content_state: ContentState, pub children: Children<'a, G>, } +// Using elements from here: https://flowbite.com/docs/components/buttons/ + #[component] pub fn Layout<'a, G: Html>( cx: Scope<'a>, - LayoutProps { title: _, children }: LayoutProps<'a, G>, + LayoutProps { + content_state, + children, + }: LayoutProps<'a, G>, ) -> View { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + // Set the theme + // TODO use theme for pages + global_state.style.theme.current.set(match content_state { + ContentState::None => (*global_state.style.theme.default.get()).clone(), + _ => (*global_state.style.theme.default.get()).clone(), + }); + #[cfg(client)] + let _ = web_sys::window() + .unwrap() + .document() + .unwrap() + .document_element() + .unwrap() + .set_attribute("data-theme", &global_state.style.theme.current.get()); + let children = children.call(cx); - view! { cx, - header { - div (class = "flex items-center justify-between") { - div (class = "w-full text-gray-700 md:text-center text-2xl font-semibold") { - "Yugioh Inventory" - } - } + // Check if the client is authenticated or not + #[cfg(client)] + global_state.auth.detect_state(); - div (class = "container mx-auto px-6 py-3") { - nav (class = "sm:flex sm:justify-center sm:items-center mt-4 hidden") { - div (class = "flex flex-col sm:flex-row"){ - a(href = "inventory", - class = "mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0" - ) { "Inventory" } - a(href = "tournaments", - class = "mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0" - ) { "Tournaments" } + let content_state_header = content_state.clone(); + + view! { cx, + // Main page header, including login functionality + Header(content_state = content_state_header) + + // Modals + section(class = "flex-2") { + (match *global_state.modals_open.login.get() { + OpenState::Open => { + view! { cx, + (LOGIN_FORM.widget(cx, "", + LoginFormProps{ + remember_me: true, + } + )) } } - } + OpenState::Closed => { + view!{ cx, } + } + }) + (match *global_state.modals_open.register.get() { + OpenState::Open => { + view! { cx, + (REGISTER_FORM.widget(cx, "", + RegisterFormProps{ + registration_code: true, + nickname: true, + email: true, + } + )) + } + } + OpenState::Closed => { + view!{ cx, } + } + }) + (match *global_state.modals_open.forgot_password.get() { + OpenState::Open => { + view! { cx, + (FORGOT_PASSWORD_FORM.widget(cx, "", + ForgotPasswordFormProps{} + )) + } + } + OpenState::Closed => { + view!{ cx, } + } + }) } main(style = "my-8") { - div(class = "container mx-auto px-6") { - div(class = "md:flex mt-8 md:-mx-4") { - div(class = "rounded-md overflow-hidden bg-cover bg-center") { - (children) + (match content_state { + ContentState::None => view!{ cx, }, + ContentState::Tournaments => view!{ cx, }, + ContentState::Inventory => view!{ cx, + // Body header + div (class = "container mx-auto px-6 py-3") { + nav (class = "sm:flex sm:justify-center sm:items-center mt-4 hidden") { + div (class = "flex flex-col sm:flex-row"){ + a(href = "inventory", + class = "mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0" + ) { "Inventory" } + } + } } - } - } + // Content body + div(class = "container mx-auto px-6") { + div(class = "md:flex mt-8 md:-mx-4") { + div(class = "rounded-md overflow-hidden bg-cover bg-center") { + (children) + } + } + } + }, + }) } } } diff --git a/src/components/mod.rs b/src/components/mod.rs index dd64619..9e82fad 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1 +1,4 @@ +mod header; pub mod layout; +pub mod static_components; +pub mod sub_components; diff --git a/src/components/static_components/close_button.rs b/src/components/static_components/close_button.rs new file mode 100644 index 0000000..15ebb88 --- /dev/null +++ b/src/components/static_components/close_button.rs @@ -0,0 +1,21 @@ +use perseus::prelude::*; +use sycamore::prelude::*; + +#[component] +pub fn CloseButtonSvg(cx: Scope) -> View { + view! { cx, + svg ( + xmlns = "http://www.w3.org/2000/svg", + class = "h-6 w-6 stroke-primary", + fill = "none", + viewBox = "0 0 24 24", + ) { + path ( + stroke-linecap = "round", + stroke-linejoin = "round", + stroke-width = "2", + d = "M6 18L18 6M6 6l12 12" + ){} + } + } +} diff --git a/src/components/static_components/indicator.rs b/src/components/static_components/indicator.rs new file mode 100644 index 0000000..8325eb1 --- /dev/null +++ b/src/components/static_components/indicator.rs @@ -0,0 +1,22 @@ +use perseus::prelude::*; +use sycamore::prelude::*; + +#[component] +pub fn ErrorSvg(cx: Scope) -> View { + view! { cx, + svg ( + xmlns="http://www.w3.org/2000/svg", + class="h-6 w-6 shrink-0 stroke-current", + fill="none", + viewBox="0 0 24 24", + ) + { + path ( + stroke-linecap="round", + stroke-linejoin="round", + stroke-width="2", + d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z", + ){} + } + } +} diff --git a/src/components/static_components/menu_button.rs b/src/components/static_components/menu_button.rs new file mode 100644 index 0000000..3798289 --- /dev/null +++ b/src/components/static_components/menu_button.rs @@ -0,0 +1,20 @@ +use perseus::prelude::*; +use sycamore::prelude::*; + +#[component] +pub fn MenuButtonSvg(cx: Scope) -> View { + view! { cx, + svg ( + xmlns="http://www.w3.org/2000/svg", + class="h-5 w-5", + fill="none", + viewBox="0 0 24 24", + stroke="currentColor"){ + path ( + stroke-linecap="round", + stroke-linejoin="round", + stroke-width="2", + d="M4 6h16M4 12h8m-8 6h16") {} + } + } +} diff --git a/src/components/static_components/mod.rs b/src/components/static_components/mod.rs new file mode 100644 index 0000000..d52e3d0 --- /dev/null +++ b/src/components/static_components/mod.rs @@ -0,0 +1,3 @@ +pub mod close_button; +pub mod indicator; +pub mod menu_button; diff --git a/src/components/sub_components/error_block.rs b/src/components/sub_components/error_block.rs new file mode 100644 index 0000000..2f5135d --- /dev/null +++ b/src/components/sub_components/error_block.rs @@ -0,0 +1,25 @@ +use perseus::prelude::*; +use sycamore::prelude::*; + +use crate::components::static_components::indicator::ErrorSvg; + +#[component(inline_props)] +pub fn ErrorBlock<'a, G: Html>(cx: Scope<'a>, error: RcSignal) -> View { + let error = create_ref(cx, error); + let is_empty = create_selector(cx, || error.get().is_empty()); + + view! { cx, + (match !(*is_empty.get()) { + true => { view!{cx, + div (role="alert", class="alert alert-error") { + // Error icon + ErrorSvg {} + + // Error text + span {(*error.get())} + } + }}, + false => {view!{cx,}}, + }) + } +} diff --git a/src/components/sub_components/mod.rs b/src/components/sub_components/mod.rs new file mode 100644 index 0000000..6d2012d --- /dev/null +++ b/src/components/sub_components/mod.rs @@ -0,0 +1 @@ +pub mod error_block; diff --git a/src/config/color.rs b/src/config/color.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/config/mod.rs b/src/config/mod.rs deleted file mode 100644 index 2c53cf0..0000000 --- a/src/config/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod color; diff --git a/src/data/mod.rs b/src/data/mod.rs deleted file mode 100644 index 078999f..0000000 --- a/src/data/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod user; -pub mod card; - -#[cfg(engine)] -pub mod store; diff --git a/src/data/store.rs b/src/data/store.rs deleted file mode 100644 index 6af3b40..0000000 --- a/src/data/store.rs +++ /dev/null @@ -1,23 +0,0 @@ -// (Server only) In-memory data storage and persistent storage - -use once_cell::sync::Lazy; -use serde::{Deserialize, Serialize}; -use std::{fs, path::Path, sync::Mutex}; - -use super::card::CardTable; - -#[derive(Serialize, Deserialize, Clone)] -pub struct Store { - pub card_table: CardTable, -} - -impl Store { - fn new() -> Store { - // fs::create_dir_all("data").unwrap(); - let card_table = CardTable::new_from_server_json(Path::new("./data/cardinfo.json")); - Store { card_table} - } - // TODO -> Store data -} - -pub static DATA: Lazy> = Lazy::new(|| Mutex::new(Store::new())); diff --git a/src/data/user.rs b/src/data/user.rs deleted file mode 100644 index c6a6ce3..0000000 --- a/src/data/user.rs +++ /dev/null @@ -1 +0,0 @@ -pub type PlayerId = u32; diff --git a/src/endpoints.rs b/src/endpoints.rs index 5cda3da..a166646 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -1 +1,6 @@ -pub const MATCH: &str = "/api/post-match"; +pub const REGISTER: &str = "/api/register"; +pub const LOGIN: &str = "/api/login"; +// TODO -> remove once it's used +#[cfg(engine)] +pub const LOGIN_TEST: &str = "/api/login-test"; +pub const FORGOT_PASSWORD: &str = "/api/forgot-password"; diff --git a/src/entity/mod.rs b/src/entity/mod.rs new file mode 100644 index 0000000..5e9350e --- /dev/null +++ b/src/entity/mod.rs @@ -0,0 +1,5 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 + +pub mod prelude; + +pub mod user; diff --git a/src/entity/prelude.rs b/src/entity/prelude.rs new file mode 100644 index 0000000..6437ecc --- /dev/null +++ b/src/entity/prelude.rs @@ -0,0 +1,3 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 + +pub use super::user::Entity as User; diff --git a/src/entity/user.rs b/src/entity/user.rs new file mode 100644 index 0000000..378a2e4 --- /dev/null +++ b/src/entity/user.rs @@ -0,0 +1,26 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "user")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub username: String, + pub password_hash_and_salt: String, + pub nickname: Option, + pub creation_time: DateTime, + pub last_active_time: DateTime, + pub is_admin: bool, + pub email: Option, + pub avatar: Option, + pub forgot_password_request: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/global_state.rs b/src/global_state.rs new file mode 100644 index 0000000..12f6f95 --- /dev/null +++ b/src/global_state.rs @@ -0,0 +1,167 @@ +// Not a page, global state that is shared between all pages + +use perseus::{prelude::*, state::GlobalStateCreator}; +use serde::{Deserialize, Serialize}; + +use crate::{ + models::auth::WebAuthInfo, + state_enums::{LoginState, OpenState}, +}; + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "AppStateRx")] +pub struct AppState { + #[rx(nested)] + pub auth: AuthData, + #[rx(nested)] + pub modals_open: ModalOpenData, + #[rx(nested)] + pub style: StyleData, +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "AuthDataRx")] +pub struct AuthData { + pub state: LoginState, + pub pending_username: String, + pub username: Option, + pub remember_me: Option, + pub auth_info: Option, +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "ModalOpenDataRx")] +pub struct ModalOpenData { + pub login: OpenState, + pub register: OpenState, + pub forgot_password: OpenState, +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "StyleDataRx")] +pub struct StyleData { + #[rx(nested)] + pub theme: ThemeData, +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "ThemeDataRx")] +pub struct ThemeData { + pub current: String, + pub default: String, +} + +pub fn get_global_state_creator() -> GlobalStateCreator { + GlobalStateCreator::new().build_state_fn(get_build_state) +} + +#[engine_only_fn] +pub async fn get_build_state() -> AppState { + AppState { + auth: AuthData { + state: LoginState::Unknown, + pending_username: String::new(), + username: None, + remember_me: None, + auth_info: None, + }, + modals_open: ModalOpenData { + login: OpenState::Closed, + register: OpenState::Closed, + forgot_password: OpenState::Closed, + }, + style: StyleData { + theme: ThemeData { + current: "luxury".to_owned(), + default: "luxury".to_owned(), + }, + }, + } +} + +impl AuthDataRx { + #[cfg(client)] + pub fn handle_log_in(&self, auth_info: WebAuthInfo) { + // Save new token to persistent storage + if auth_info.remember_me { + let storage: web_sys::Storage = + web_sys::window().unwrap().local_storage().unwrap().unwrap(); + let value = serde_json::to_string(&auth_info).unwrap(); + storage.set_item("auth", &value).unwrap(); + } + // Save into session storage always + let storage: web_sys::Storage = web_sys::window() + .unwrap() + .session_storage() + .unwrap() + .unwrap(); + let value = serde_json::to_string(&auth_info).unwrap(); + storage.set_item("auth", &value).unwrap(); + + // Save token to session storage + self.username.set(Some(auth_info.username.clone())); + self.remember_me.set(Some(auth_info.remember_me)); + self.auth_info.set(Some(auth_info)); + self.state.set(LoginState::Authenticated); + } + #[cfg(client)] + pub fn handle_log_out(&self) { + // Delete persistent storage + // TODO -> handle error if local storage is not readable in browser + let storage: web_sys::Storage = + web_sys::window().unwrap().local_storage().unwrap().unwrap(); + storage.remove_item("auth").unwrap(); + let storage: web_sys::Storage = web_sys::window() + .unwrap() + .session_storage() + .unwrap() + .unwrap(); + storage.remove_item("auth").unwrap(); + // Update state + self.auth_info.set(None); + self.username.set(None); + self.remember_me.set(None); + self.state.set(LoginState::NotAuthenticated); + } +} + +// Client only code to check if they're authenticated +#[cfg(client)] +impl AuthDataRx { + pub fn detect_state(&self) { + // If the user is in a known state, return + if let LoginState::Authenticated | LoginState::NotAuthenticated = *self.state.get() { + return; + } + // TODO handle error case better + // Save new token to persistent storage + let storage: web_sys::Storage = + web_sys::window().unwrap().local_storage().unwrap().unwrap(); + let saved_auth = storage.get("auth").unwrap(); + match saved_auth { + Some(auth_info) => { + // TODO check if session is expiring + let auth_info = serde_json::from_str(&auth_info).unwrap(); + self.handle_log_in(auth_info); + } + None => { + // Try session storage + let storage: web_sys::Storage = web_sys::window() + .unwrap() + .session_storage() + .unwrap() + .unwrap(); + let saved_auth = storage.get("auth").unwrap(); + match saved_auth { + Some(auth_info) => { + let auth_info = serde_json::from_str(&auth_info).unwrap(); + self.handle_log_in(auth_info); + } + None => { + self.state.set(LoginState::NotAuthenticated); + } + } + } + } + } +} diff --git a/src/main.rs b/src/main.rs index b862d4f..3c10854 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,21 +1,19 @@ +mod capsules; mod components; -mod data; mod endpoints; +#[allow(unused_imports)] +mod entity; mod error_views; +mod global_state; +mod models; #[cfg(engine)] mod server; +mod state_enums; mod templates; -use std::path::Path; - -use data::card::CardTable; use perseus::prelude::*; use sycamore::prelude::view; -extern crate strum; -#[macro_use] -extern crate strum_macros; - cfg_if::cfg_if! { if #[cfg(engine)] { use std::net::SocketAddr; @@ -25,7 +23,10 @@ cfg_if::cfg_if! { stores::MutableStore, turbine::Turbine, }; - use crate::server::routes::register_routes; + use crate::server::routes::get_api_router; + use crate::server::server_state::ServerState; + use futures::executor::block_on; + use sea_orm::Database; } } @@ -38,9 +39,24 @@ pub async fn dflt_server Update to use environment variable + // TODO -> error handling + // Includes making database connection + let db_conn = Database::connect("postgres://yugi:yugi@localhost:5433/yugi_app"); + let db_conn = block_on(db_conn); + let db_conn = match db_conn { + Ok(db_conn) => db_conn, + Err(err) => { + panic!("{}", err); + } + }; + let state = ServerState { db_conn }; + + // Get server routes + let api_router = get_api_router(state); + let app = app.merge(api_router); axum::Server::bind(&addr) .serve(app.into_make_service()) @@ -53,14 +69,16 @@ pub fn main() -> PerseusApp { env_logger::init(); PerseusApp::new() - .global_state_creator(crate::templates::global_state::get_global_state_creator()) + .global_state_creator(crate::global_state::get_global_state_creator()) .template(crate::templates::index::get_template()) - .template(crate::templates::inventory::get_template()) - .template(crate::templates::preferences::get_template()) + .template(crate::templates::inventory::index::get_template()) + .capsule_ref(&*crate::capsules::login_form::LOGIN_FORM) + .capsule_ref(&*crate::capsules::forgot_password_form::FORGOT_PASSWORD_FORM) + .capsule_ref(&*crate::capsules::register_form::REGISTER_FORM) .error_views(crate::error_views::get_error_views()) .index_view(|cx| { view! { cx, - html (class = "flex w-full h-full"){ + html (class = "flex w-full h-full", data-theme = "luxury"){ head { meta(charset = "UTF-8") meta(name = "viewport", content = "width=device-width, initial-scale=1.0") diff --git a/src/models/auth.rs b/src/models/auth.rs new file mode 100644 index 0000000..e1850f6 --- /dev/null +++ b/src/models/auth.rs @@ -0,0 +1,48 @@ +use chrono::serde::ts_seconds; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone)] +pub struct LoginInfo { + pub username: String, + pub password: String, + pub remember_me: bool, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct LoginResponse { + pub token: String, + #[serde(with = "ts_seconds")] + pub expires: DateTime, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: String, + pub exp: usize, +} + +// For client local storage and session storage +#[derive(Serialize, Deserialize, Clone)] +pub struct WebAuthInfo { + pub token: String, + #[serde(with = "ts_seconds")] + pub expires: DateTime, + pub username: String, + pub remember_me: bool, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct RegisterRequest { + pub username: String, + pub password: String, + pub email: String, + pub nickname: String, + pub registration_code: String, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct ForgotPasswordRequest { + pub username: String, + pub contact_info: String, +} diff --git a/src/data/card.rs b/src/models/card.rs similarity index 100% rename from src/data/card.rs rename to src/models/card.rs diff --git a/src/models/generic.rs b/src/models/generic.rs new file mode 100644 index 0000000..7aa4029 --- /dev/null +++ b/src/models/generic.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone)] +pub struct GenericResponse { + pub status: String, +} + +impl GenericResponse { + #[cfg(engine)] + pub fn ok() -> Self { + GenericResponse { + status: String::new(), + } + } + #[cfg(engine)] + pub fn err(msg: &str) -> Self { + GenericResponse { + status: msg.to_string(), + } + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..d9e36fb --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,3 @@ +pub mod auth; +pub mod card; +pub mod generic; diff --git a/src/server/auth/forgot_password.rs b/src/server/auth/forgot_password.rs new file mode 100644 index 0000000..10effbd --- /dev/null +++ b/src/server/auth/forgot_password.rs @@ -0,0 +1,40 @@ +use crate::{ + entity::{prelude::*, user}, + models::{auth::ForgotPasswordRequest, generic::GenericResponse}, + server::server_state::ServerState, +}; +use axum::{ + extract::{Json, State}, + http::StatusCode, +}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set}; + +pub async fn post_forgot_password( + State(state): State, + Json(password_request): Json, +) -> (StatusCode, Json) { + // Get user + let existing_user: Option = User::find() + .filter(user::Column::Username.eq(password_request.username)) + .one(&state.db_conn) + .await + .unwrap(); + match existing_user { + Some(user) => { + let mut user = user.into_active_model(); + user.forgot_password_request = Set(Some(password_request.contact_info)); + let user = user.update(&state.db_conn).await; + match user { + Ok(_) => (StatusCode::OK, Json(GenericResponse::ok())), + Err(_) => ( + StatusCode::BAD_REQUEST, + Json(GenericResponse::err("Database error")), + ), + } + } + None => ( + StatusCode::BAD_REQUEST, + Json(GenericResponse::err("Username doesn't exist")), + ), + } +} diff --git a/src/server/auth/login.rs b/src/server/auth/login.rs new file mode 100644 index 0000000..908c7d7 --- /dev/null +++ b/src/server/auth/login.rs @@ -0,0 +1,115 @@ +use crate::{ + entity::{ + prelude::*, + user::{self}, + }, + models::{ + auth::{Claims, LoginInfo, LoginResponse}, + generic::GenericResponse, + }, + server::server_state::ServerState, +}; +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use axum::{ + extract::{Json, State}, + http::{HeaderMap, StatusCode}, +}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + +pub async fn credentials_are_correct( + username: &str, + password: &str, + state: &ServerState, +) -> Result<(), String> { + // Get user + let existing_user: Option = User::find() + .filter(user::Column::Username.eq(username)) + .one(&state.db_conn) + .await + .unwrap(); + let hash_to_check: String = match existing_user { + Some(user) => user.password_hash_and_salt, + None => { + // @todo make dummy password hash + return Err("Username doesn't exist".to_owned()); + } + }; + + match Argon2::default().verify_password( + password.as_bytes(), + &PasswordHash::new(hash_to_check.as_str()).unwrap(), + ) { + Ok(_) => Ok(()), + Err(_) => Err("Invalid credentials".to_owned()), + } +} + +pub async fn post_login_user( + State(state): State, + Json(login_info): Json, +) -> ( + StatusCode, + Result, Json>, +) { + let user_authenticated = + credentials_are_correct(&login_info.username, &login_info.password, &state); + + match user_authenticated.await { + Err(why) => ( + StatusCode::UNAUTHORIZED, + Err(Json(GenericResponse::err(why.as_str()))), + ), + Ok(_) => { + let expires = match login_info.remember_me { + true => chrono::Utc::now() + chrono::Duration::days(365), + false => chrono::Utc::now() + chrono::Duration::days(1), + }; + + let claims = Claims { + sub: login_info.username.clone(), + exp: expires.timestamp() as usize, + }; + // @todo change secret + let token = match encode( + &Header::default(), + &claims, + &EncodingKey::from_secret("secret".as_ref()), + ) { + Ok(token) => token, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Err(Json(GenericResponse::err("Failed to get token"))), + ) + } + }; + + (StatusCode::OK, Ok(Json(LoginResponse { token, expires }))) + } + } +} + +pub async fn post_test_login( + State(_): State, + header_map: HeaderMap, +) -> Result, StatusCode> { + if let Some(auth_header) = header_map.get("Authorization") { + if let Ok(auth_header_str) = auth_header.to_str() { + if auth_header_str.starts_with("Bearer ") { + let token = auth_header_str.trim_start_matches("Bearer ").to_string(); + // @todo change secret + if decode::( + &token, + &DecodingKey::from_secret("secret".as_ref()), + &Validation::default(), + ) + .is_ok() + { + return Ok(Json("Logged in".to_owned())); + } + } + } + } + Err(StatusCode::UNAUTHORIZED) +} diff --git a/src/server/auth/mod.rs b/src/server/auth/mod.rs new file mode 100644 index 0000000..aa36f87 --- /dev/null +++ b/src/server/auth/mod.rs @@ -0,0 +1,3 @@ +pub mod forgot_password; +pub mod login; +pub mod register; diff --git a/src/server/auth/register.rs b/src/server/auth/register.rs new file mode 100644 index 0000000..d75f6de --- /dev/null +++ b/src/server/auth/register.rs @@ -0,0 +1,86 @@ +use crate::{ + entity::{prelude::*, user}, + models::{auth::RegisterRequest, generic::GenericResponse}, + server::server_state::ServerState, +}; +use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHash, PasswordHasher, +}; +use axum::{extract::State, http::StatusCode, Json}; +use chrono::Utc; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set}; + +pub async fn post_register_user( + State(state): State, + Json(register_info): Json, +) -> (StatusCode, Json) { + // TODO -> update to use env, maybe prevent brute force too + if register_info.registration_code != "ferris" { + return ( + StatusCode::UNAUTHORIZED, + Json(GenericResponse::err("Incorrect registration code")), + ); + } + + // See if username already exists + let username = register_info.username; + let existing_user: Option = User::find() + .filter(user::Column::Username.eq(username.clone())) + .one(&state.db_conn) + .await + .unwrap(); + if existing_user.is_some() { + return ( + StatusCode::BAD_REQUEST, + Json(GenericResponse::err("Username already exists")), + ); + } + + // Generate password + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(register_info.password.as_bytes(), &salt) + .unwrap() + .to_string(); + let phc_string = PasswordHash::new(&password_hash).unwrap().to_string(); + + // If the username doen't exist, create the user + let new_user = user::ActiveModel { + username: Set(username), + password_hash_and_salt: Set(phc_string), + nickname: Set({ + if register_info.nickname.is_empty() { + None + } else { + Some(register_info.nickname) + } + }), + creation_time: Set(Utc::now().naive_utc()), + last_active_time: Set(Utc::now().naive_utc()), + is_admin: Set(false), + email: Set({ + if register_info.email.is_empty() { + None + } else { + Some(register_info.email) + } + }), + avatar: Set(None), + forgot_password_request: Set(None), + ..Default::default() + }; + let db_resp = user::Entity::insert(new_user).exec(&state.db_conn).await; + match db_resp { + Ok(_) => {} + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(GenericResponse::err("Database error")), + ); + } + }; + + (StatusCode::OK, Json(GenericResponse::ok())) +} diff --git a/src/server/mod.rs b/src/server/mod.rs index 6a664ab..a3505e9 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1 +1,3 @@ +pub mod auth; pub mod routes; +pub mod server_state; diff --git a/src/server/routes.rs b/src/server/routes.rs index 4ede635..16c52ea 100644 --- a/src/server/routes.rs +++ b/src/server/routes.rs @@ -1,13 +1,21 @@ // (Server only) Routes +use crate::endpoints::{FORGOT_PASSWORD, LOGIN, LOGIN_TEST, REGISTER}; +use axum::routing::{post, Router}; -use crate::{data::store::DATA, endpoints::MATCH}; -use axum::{ - extract::Json, - routing::{post, Router}, +use super::{ + auth::{ + forgot_password::post_forgot_password, + login::{post_login_user, post_test_login}, + register::post_register_user, + }, + server_state::ServerState, }; -use std::thread; -pub fn register_routes(app: Router) -> Router { - // let app = app.route(MATCH, post(post_match)); - app +pub fn get_api_router(state: ServerState) -> Router { + Router::new() + .route(REGISTER, post(post_register_user)) + .route(LOGIN, post(post_login_user)) + .route(LOGIN_TEST, post(post_test_login)) + .route(FORGOT_PASSWORD, post(post_forgot_password)) + .with_state(state) } diff --git a/src/server/server_state.rs b/src/server/server_state.rs new file mode 100644 index 0000000..b4fbed2 --- /dev/null +++ b/src/server/server_state.rs @@ -0,0 +1,6 @@ +use sea_orm::DatabaseConnection; + +#[derive(Clone)] +pub struct ServerState { + pub db_conn: DatabaseConnection, +} diff --git a/src/state_enums.rs b/src/state_enums.rs new file mode 100644 index 0000000..878d6c4 --- /dev/null +++ b/src/state_enums.rs @@ -0,0 +1,37 @@ +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone)] +pub enum LoginState { + Authenticated, + NotAuthenticated, + Unknown, +} + +#[derive(Serialize, Deserialize, Clone)] +pub enum ContentState { + None, + Inventory, + Tournaments, +} + +impl Display for ContentState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + ContentState::Inventory => "Inventory", + ContentState::Tournaments => "Tournament", + ContentState::None => "", + } + ) + } +} + +#[derive(Serialize, Deserialize, Clone)] +pub enum OpenState { + Open, + Closed, +} diff --git a/src/templates/index.rs b/src/templates/index.rs index ff585f8..bca5971 100644 --- a/src/templates/index.rs +++ b/src/templates/index.rs @@ -1,10 +1,10 @@ -use crate::components::layout::Layout; +use crate::{components::layout::Layout, state_enums::ContentState}; use perseus::prelude::*; use sycamore::prelude::*; fn index_page(cx: Scope) -> View { view! { cx, - Layout(title = "Index") { + Layout(content_state = ContentState::None) { // Anything we put in here will be rendered inside the `
` block of the layout p { "Hello World!" } br {} diff --git a/src/templates/inventory.rs b/src/templates/inventory/index.rs similarity index 52% rename from src/templates/inventory.rs rename to src/templates/inventory/index.rs index 45a2e37..1f27a7f 100644 --- a/src/templates/inventory.rs +++ b/src/templates/inventory/index.rs @@ -1,10 +1,10 @@ -use crate::components::layout::Layout; +use crate::{components::layout::Layout, state_enums::ContentState}; use perseus::prelude::*; use sycamore::prelude::*; -fn inventory_page(cx: Scope) -> View { +fn inventory_index_page(cx: Scope) -> View { view! { cx, - Layout(title = "Index") { + Layout(content_state = ContentState::Inventory) { // Anything we put in here will be rendered inside the `
` block of the layout p { "Hello World!" } br {} @@ -15,10 +15,13 @@ fn inventory_page(cx: Scope) -> View { #[engine_only_fn] fn head(cx: Scope) -> View { view! { cx, - title { "Inventory" } + title { "Inventory Page" } } } pub fn get_template() -> Template { - Template::build("inventory").view(inventory_page).head(head).build() + Template::build("inventory") + .view(inventory_index_page) + .head(head) + .build() } diff --git a/src/templates/inventory/mod.rs b/src/templates/inventory/mod.rs new file mode 100644 index 0000000..33edc95 --- /dev/null +++ b/src/templates/inventory/mod.rs @@ -0,0 +1 @@ +pub mod index; diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 8fbaba8..5c32ae4 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -1,12 +1,10 @@ -pub mod global_state; pub mod index; pub mod inventory; -pub mod preferences; #[cfg(client)] use perseus::utils::get_path_prefix_client; -#[allow(dead_code)] +#[cfg(client)] pub fn get_api_path(path: &str) -> String { #[cfg(engine)] { diff --git a/src/templates/preferences.rs b/src/templates/preferences.rs deleted file mode 100644 index 83f3075..0000000 --- a/src/templates/preferences.rs +++ /dev/null @@ -1,59 +0,0 @@ -use perseus::prelude::*; -use serde::{Deserialize, Serialize}; -use crate::components::layout::Layout; -use sycamore::prelude::*; -use web_sys::Event; - -cfg_if::cfg_if! { - if #[cfg(client)] { - use crate::templates::get_api_path; - } -} - -// Reactive page - -#[derive(Serialize, Deserialize, Clone, ReactiveState)] -#[rx(alias = "PageStateRx")] -struct PageState { - name: String, -} - -fn add_preferences_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View { - view! { cx, - Layout(title = "Add Game Results") { - div (class = "flex flex-wrap") { - input (bind:value = state.name, - class = "appearance-none block w-full bg-gray-200 text-gray-700 border \ - border-red-500 rounded py-3 px-4 mb-3 leading-tight focus:outline-none \ - focus:bg-white",) - } - } - } -} - -#[engine_only_fn] -async fn get_request_state( - _info: StateGeneratorInfo<()>, - _req: Request, -) -> Result> { - Ok(PageState { - name: "Ferris".to_string(), - }) -} - -#[engine_only_fn] -fn head(cx: Scope) -> View { - view! { cx, - title { "Preferences" } - } -} - -// Template - -pub fn get_template() -> Template { - Template::build("preferences") - .request_state_fn(get_request_state) - .view_with_state(add_preferences_page) - .head(head) - .build() -} diff --git a/tailwind.config.js b/tailwind.config.js index 5bbe0a7..ec76bc7 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,11 +1,19 @@ +const { default: daisyui } = require("daisyui"); + module.exports = { - content: [ - "./src/**/*.rs", - "./index.html", - "./src/**/*.html", - "./src/**/*.css", - ], + purge: { + mode: "all", + content: [ + "./src/**/*.rs", + "./index.html", + "./src/**/*.html", + "./src/**/*.css", + ], + }, theme: {}, variants: {}, - plugins: [], + plugins: [require("daisyui")], + daisyui: { + themes: ["light", "dark", "luxury"], + }, };