From 01eaf059ddc62a260b0e66861f69f030e6610c7a Mon Sep 17 00:00:00 2001 From: Jacob Kassman Date: Sat, 9 Dec 2023 21:49:14 -0500 Subject: [PATCH 01/22] Add the ability to add users --- src/data/pool_match.rs | 19 ++++++++++++ src/data/store.rs | 3 ++ src/endpoints.rs | 1 + src/server/routes.rs | 20 ++++++++++++- src/templates/add_game_form.rs | 55 ++++++++++++++++++++++++++++++---- src/templates/global_state.rs | 9 +++++- 6 files changed, 99 insertions(+), 8 deletions(-) diff --git a/src/data/pool_match.rs b/src/data/pool_match.rs index 3ee5b55..b2ba79c 100644 --- a/src/data/pool_match.rs +++ b/src/data/pool_match.rs @@ -33,6 +33,11 @@ pub struct PoolMatchList { pub max_id: MatchId, } +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct UserList{ + pub users: Vec, +} + impl PoolMatch { pub fn new(data: MatchData, time: DateTime) -> PoolMatch { PoolMatch { id: 0, data, time } @@ -53,3 +58,17 @@ impl PoolMatchList { self.pool_matches.push(pool_match); } } + +impl UserList { + pub fn new() -> UserList { + UserList { + users: vec![], + } + } + + pub fn add_user(&mut self, user: String) -> usize { + let user_id = self.users.len(); + self.users.push(user); + user_id + } +} diff --git a/src/data/store.rs b/src/data/store.rs index 839a49b..4069df9 100644 --- a/src/data/store.rs +++ b/src/data/store.rs @@ -1,6 +1,7 @@ // (Server only) In-memory data storage and persistent storage use crate::data::pool_match::PoolMatchList; +use crate::data::pool_match::UserList; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use std::{fs, path::Path, sync::Mutex}; @@ -8,6 +9,7 @@ use std::{fs, path::Path, sync::Mutex}; #[derive(Serialize, Deserialize, Clone)] pub struct Store { pub matches: PoolMatchList, + pub users: UserList, } impl Store { @@ -16,6 +18,7 @@ impl Store { match Path::new("data/store.json").exists() { false => Store { matches: PoolMatchList::new(), + users: UserList::new(), }, true => { let contents = fs::read_to_string("data/store.json").unwrap(); diff --git a/src/endpoints.rs b/src/endpoints.rs index 5cda3da..139ed38 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -1 +1,2 @@ pub const MATCH: &str = "/api/post-match"; +pub const USER: &str = "/api/post-user"; diff --git a/src/server/routes.rs b/src/server/routes.rs index 934c557..df2cc88 100644 --- a/src/server/routes.rs +++ b/src/server/routes.rs @@ -2,10 +2,11 @@ use crate::{ data::{ - pool_match::{PoolMatch, PoolMatchList}, + pool_match::{PoolMatch, PoolMatchList, UserList}, store::DATA, }, endpoints::MATCH, + endpoints::USER, }; use axum::{ extract::Json, @@ -15,6 +16,7 @@ use std::thread; pub fn register_routes(app: Router) -> Router { let app = app.route(MATCH, post(post_match)); + let app = app.route(USER, post(post_user)); app } @@ -32,3 +34,19 @@ async fn post_match(Json(pool_match): Json) -> Json { Json(matches) } + +async fn post_user(user: String) -> Json { + // Update the store with the new match + let users = thread::spawn(move || { + // Get the store + let mut data = DATA.lock().unwrap(); + let user_id = (*data).users.add_user(user); + println!("Added new user id: {}\nAll users: {:?}", user_id, (*data).users); + (*data).users.clone() + }) + .join() + .unwrap(); + + Json(users) +} + diff --git a/src/templates/add_game_form.rs b/src/templates/add_game_form.rs index 0939d14..d89de31 100644 --- a/src/templates/add_game_form.rs +++ b/src/templates/add_game_form.rs @@ -7,9 +7,9 @@ use web_sys::Event; cfg_if::cfg_if! { if #[cfg(client)] { - use crate::data::pool_match::{PoolMatch, PoolMatchList}; + use crate::data::pool_match::{PoolMatch, PoolMatchList, UserList}; use crate::templates::global_state::AppStateRx; - use crate::endpoints::MATCH; + use crate::endpoints::{MATCH, USER}; use crate::templates::get_api_path; use chrono::Utc; } @@ -20,14 +20,15 @@ cfg_if::cfg_if! { #[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] struct PageState { - name: String, + winner: String, + new_user: String, } fn add_game_form_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View { let handle_add_match = move |_event: Event| { #[cfg(client)] { - // state.name.get().as_ref().clone() + // state.winner.get().as_ref().clone() spawn_local_scoped(cx, async move { let new_match = PoolMatch::new( MatchData { @@ -53,10 +54,37 @@ fn add_game_form_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStat } }; + let handle_add_user = move |_event: Event| { + #[cfg(client)] + { + // state.winner.get().as_ref().clone() + spawn_local_scoped(cx, async move { + let client = reqwest::Client::new(); + let new_users = client + .post(get_api_path(USER).as_str()) + .body(state.new_user.get().as_ref().clone()) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + global_state.users.set(new_users); + }) + } + }; + view! { cx, Layout(title = "Add Game Results") { div (class = "flex flex-wrap") { - input (bind:value = state.name, + select { + option (value="red") + option (value="blue") + } + } + div (class = "flex flex-wrap") { + input (bind:value = state.winner, 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",) @@ -69,6 +97,20 @@ fn add_game_form_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStat "Add result" } } + div (class = "flex flex-wrap") { + input (bind:value = state.new_user, + 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",) + } + div (class = "flex flex-wrap") { + button(on:click = handle_add_user, + class = "flex-shrink-0 bg-teal-500 hover:bg-teal-700 border-teal-500 \ + hover:border-teal-700 text-sm border-4 text-white py-1 px-2 rounded", + ) { + "Add new user" + } + } } } } @@ -79,7 +121,8 @@ async fn get_request_state( _req: Request, ) -> Result> { Ok(PageState { - name: "Ferris".to_string(), + winner: "Ferris".to_string(), + new_user: "newguy".to_string(), }) } diff --git a/src/templates/global_state.rs b/src/templates/global_state.rs index 4ac7502..8658355 100644 --- a/src/templates/global_state.rs +++ b/src/templates/global_state.rs @@ -1,6 +1,8 @@ // Not a page, global state that is shared between all pages use crate::data::pool_match::PoolMatchList; +use crate::data::pool_match::UserList; + use perseus::{prelude::*, state::GlobalStateCreator}; use serde::{Deserialize, Serialize}; @@ -16,6 +18,7 @@ cfg_if::cfg_if! { #[rx(alias = "AppStateRx")] pub struct AppState { pub matches: PoolMatchList, + pub users: UserList, } pub fn get_global_state_creator() -> GlobalStateCreator { @@ -30,7 +33,11 @@ fn get_state() -> AppState { .join() .unwrap(); - AppState { matches } + let users = thread::spawn(move || DATA.lock().unwrap().deref().users.clone()) + .join() + .unwrap(); + + AppState { matches, users } } #[engine_only_fn] From 5cb67786bfbbf69192e11dab728b6b5a44f7e8ba Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Wed, 14 Aug 2024 01:20:48 -0400 Subject: [PATCH 02/22] Add initial database schema and references Also look into users a bit --- .dev/.env | 4 + .dev/docker-compose.yml | 18 +++ Cargo.toml | 11 +- README.md | 20 ++- entity/src/game.rs | 30 ++++ entity/src/mod.rs | 8 + entity/src/one_vs_one.rs | 50 ++++++ entity/src/prelude.rs | 5 + entity/src/sea_orm_active_enums.rs | 23 +++ entity/src/user.rs | 18 +++ migration/Cargo.toml | 16 ++ migration/README.md | 46 ++++++ migration/src/lib.rs | 16 ++ .../src/m20240813_000001_create_users.rs | 42 +++++ migration/src/m20240813_000002_create_game.rs | 152 ++++++++++++++++++ migration/src/main.rs | 6 + package.json | 2 +- src/data/user.rs | 35 ++++ src/main.rs | 9 ++ 19 files changed, 505 insertions(+), 6 deletions(-) create mode 100644 .dev/.env create mode 100644 .dev/docker-compose.yml create mode 100644 entity/src/game.rs create mode 100644 entity/src/mod.rs create mode 100644 entity/src/one_vs_one.rs create mode 100644 entity/src/prelude.rs create mode 100644 entity/src/sea_orm_active_enums.rs create mode 100644 entity/src/user.rs create mode 100644 migration/Cargo.toml create mode 100644 migration/README.md create mode 100644 migration/src/lib.rs create mode 100644 migration/src/m20240813_000001_create_users.rs create mode 100644 migration/src/m20240813_000002_create_game.rs create mode 100644 migration/src/main.rs diff --git a/.dev/.env b/.dev/.env new file mode 100644 index 0000000..a9ee94e --- /dev/null +++ b/.dev/.env @@ -0,0 +1,4 @@ +POSTGRES_DB=elo_app +POSTGRES_USER=elo +POSTGRES_PASSWORD=elo +DATABASE_URL=postgres://elo:elo@db:5432/elo_app diff --git a/.dev/docker-compose.yml b/.dev/docker-compose.yml new file mode 100644 index 0000000..0d98468 --- /dev/null +++ b/.dev/docker-compose.yml @@ -0,0 +1,18 @@ +services: + db: + image: postgres:15.3-alpine + restart: unless-stopped + ports: + - 5432:5432 + networks: + - db + volumes: + - postgres-data:/var/lib/postgresql/data + env_file: + - .env + +volumes: + postgres-data: + +networks: + db: diff --git a/Cargo.toml b/Cargo.toml index c49c59c..8845d5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,9 @@ log = "0.4.20" once_cell = "1.18.0" web-sys = "0.3.64" cfg-if = "1.0.0" -chrono = { version = "0.4.31", features = ["serde"] } +chrono = { version = "0.4.38", features = ["serde", "wasm-bindgen"] } +axum-login = "0.15.3" +password-auth = "1.0.0" [target.'cfg(engine)'.dev-dependencies] fantoccini = "0.19" @@ -28,6 +30,13 @@ tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } perseus-axum = { version = "0.4.2" } axum = "0.6" tower-http = { version = "0.3", features = ["fs"] } +futures = "0.3.28" +sea-orm = { version = "0.12.0", features = [ + "sqlx-postgres", + "runtime-tokio-native-tls", + "macros", + "with-chrono", +] } [target.'cfg(client)'.dependencies] wasm-bindgen = "0.2" diff --git a/README.md b/README.md index 4266666..957433f 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,7 +11,7 @@ Run `curl --proto '=https' --tlsv1.3 https://sh.rustup.rs -sSf | sh` ## 2. Install npm -### Windows: +### Windows: https://nodejs.org/en @@ -25,11 +25,17 @@ https://nodejs.org/en `cargo install perseus-cli` `rustup target add wasm32-unknown-unknown` -## 4. Install tailwindcss, for styling +## 4. Install docker for Postgresql + +## 5. Install SeaORM for database + +`cargo install sea-orm-cli` + +## 5. 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 +45,12 @@ https://blog.logrocket.com/building-rust-app-perseus/ # Building the project +To set up the database, run: +`$env:DATABASE_URL = "postgres://elo:elo@localhost:5432/elo_app"; sea-orm-cli migrate up` + +Updating entities after updating database: +`$env:DATABASE_URL = "postgres://elo:elo@localhost:5432/elo_app"; sea-orm-cli generate entity -o entity/src --with-serde both` + To build CSS run: `npm run build` diff --git a/entity/src/game.rs b/entity/src/game.rs new file mode 100644 index 0000000..a739bc7 --- /dev/null +++ b/entity/src/game.rs @@ -0,0 +1,30 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +use super::sea_orm_active_enums::GameType; +use super::sea_orm_active_enums::PlayerSetupType; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "game")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub player_setup_type: Option, + pub game_type: Option, + pub time: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::one_vs_one::Entity")] + OneVsOne, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::OneVsOne.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/mod.rs b/entity/src/mod.rs new file mode 100644 index 0000000..f22ac25 --- /dev/null +++ b/entity/src/mod.rs @@ -0,0 +1,8 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +pub mod prelude; + +pub mod game; +pub mod one_vs_one; +pub mod sea_orm_active_enums; +pub mod user; diff --git a/entity/src/one_vs_one.rs b/entity/src/one_vs_one.rs new file mode 100644 index 0000000..550cda4 --- /dev/null +++ b/entity/src/one_vs_one.rs @@ -0,0 +1,50 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "one_vs_one")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub player_one: i32, + pub player_two: i32, + pub game_id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::game::Entity", + from = "Column::GameId", + to = "super::game::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + Game, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::PlayerOne", + to = "super::user::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + User2, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::PlayerTwo", + to = "super::user::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + User1, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Game.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/prelude.rs b/entity/src/prelude.rs new file mode 100644 index 0000000..f412b60 --- /dev/null +++ b/entity/src/prelude.rs @@ -0,0 +1,5 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +pub use super::game::Entity as Game; +pub use super::one_vs_one::Entity as OneVsOne; +pub use super::user::Entity as User; diff --git a/entity/src/sea_orm_active_enums.rs b/entity/src/sea_orm_active_enums.rs new file mode 100644 index 0000000..631fc29 --- /dev/null +++ b/entity/src/sea_orm_active_enums.rs @@ -0,0 +1,23 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "game_type")] +pub enum GameType { + #[sea_orm(string_value = "PickleBall")] + PickleBall, + #[sea_orm(string_value = "Pool")] + Pool, + #[sea_orm(string_value = "TableTennis")] + TableTennis, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "player_setup_type")] +pub enum PlayerSetupType { + #[sea_orm(string_value = "OneVsOne")] + OneVsOne, + #[sea_orm(string_value = "TwoVsTwo")] + TwoVsTwo, +} diff --git a/entity/src/user.rs b/entity/src/user.rs new file mode 100644 index 0000000..aa72e13 --- /dev/null +++ b/entity/src/user.rs @@ -0,0 +1,18 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +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, + pub username: String, + pub password: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} 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..03c62c5 --- /dev/null +++ b/migration/src/lib.rs @@ -0,0 +1,16 @@ +pub use sea_orm_migration::prelude::*; + +mod m20240813_000001_create_users; +mod m20240813_000002_create_game; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m20240813_000001_create_users::Migration), + Box::new(m20240813_000002_create_game::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..60a3819 --- /dev/null +++ b/migration/src/m20240813_000001_create_users.rs @@ -0,0 +1,42 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(User::Table) + .if_not_exists() + .col( + ColumnDef::new(User::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(string(User::Username)) + .col(string(User::Password)) + .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, + // Hash + Password, +} diff --git a/migration/src/m20240813_000002_create_game.rs b/migration/src/m20240813_000002_create_game.rs new file mode 100644 index 0000000..0dc83ee --- /dev/null +++ b/migration/src/m20240813_000002_create_game.rs @@ -0,0 +1,152 @@ +use sea_orm::{DbBackend, DeriveActiveEnum, EnumIter, Schema}; +use sea_orm_migration::sea_orm::ActiveEnum; +use sea_orm_migration::{prelude::*, schema::*, sea_query::extension::postgres::Type}; + +use super::m20240813_000001_create_users::User; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let schema = Schema::new(DbBackend::Postgres); + + manager + .create_type(schema.create_enum_from_active_enum::()) + .await?; + manager + .create_type(schema.create_enum_from_active_enum::()) + .await?; + manager + .create_table( + Table::create() + .table(Game::Table) + .if_not_exists() + .col( + ColumnDef::new(Game::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Game::PlayerSetupType).custom(PlayerSetupType::name())) + .col(ColumnDef::new(Game::GameType).custom(GameType::name())) + .col( + ColumnDef::new(Game::Time) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + manager + .create_table( + Table::create() + .table(Game::Table) + .if_not_exists() + .col( + ColumnDef::new(Game::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Game::PlayerSetupType).custom(PlayerSetupType::name())) + .col(ColumnDef::new(Game::GameType).custom(GameType::name())) + .to_owned(), + ) + .await?; + manager + .create_table( + Table::create() + .table(OneVsOne::Table) + .if_not_exists() + .col( + ColumnDef::new(OneVsOne::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(OneVsOne::PlayerOne).integer().not_null()) + .col(ColumnDef::new(OneVsOne::PlayerTwo).integer().not_null()) + .col(ColumnDef::new(OneVsOne::GameId).integer().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-user-one_vs_one-player_one_id") + .from(OneVsOne::Table, OneVsOne::PlayerOne) + .to(User::Table, User::Id), + ) + .foreign_key( + ForeignKey::create() + .name("fk-user-one_vs_one-player_two_id") + .from(OneVsOne::Table, OneVsOne::PlayerTwo) + .to(User::Table, User::Id), + ) + .foreign_key( + ForeignKey::create() + .name("fk-game-one_vs_one-game_id") + .from(OneVsOne::Table, OneVsOne::GameId) + .to(Game::Table, Game::Id), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_type(Type::drop().name(PlayerSetupType::name()).to_owned()) + .await?; + manager + .drop_type(Type::drop().name(GameType::name()).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Game::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(OneVsOne::Table).to_owned()) + .await + } +} + +// Enums +#[derive(EnumIter, DeriveActiveEnum)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "player_setup_type")] +enum PlayerSetupType { + #[sea_orm(string_value = "OneVsOne")] + OneVsOne, + #[sea_orm(string_value = "TwoVsTwo")] + TwoVsTwo, +} + +#[derive(EnumIter, DeriveActiveEnum)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "game_type")] +enum GameType { + #[sea_orm(string_value = "TableTennis")] + TableTennis, + #[sea_orm(string_value = "Pool")] + Pool, + #[sea_orm(string_value = "PickleBall")] + PickleBall, +} + +// Tables +#[derive(DeriveIden)] +enum Game { + Table, + Id, + Time, + PlayerSetupType, + GameType, +} + +#[derive(DeriveIden)] +enum OneVsOne { + Table, + Id, + GameId, + PlayerOne, + PlayerTwo, +} 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 66afa07..adef649 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,6 @@ "author": "", "license": "ISC", "devDependencies": { - "tailwindcss": "^3.3.3" + "tailwindcss": "^3.4.9" } } diff --git a/src/data/user.rs b/src/data/user.rs index 9815690..0124a63 100644 --- a/src/data/user.rs +++ b/src/data/user.rs @@ -1,3 +1,38 @@ +use axum_login::{AuthUser, AuthnBackend, UserId}; +use serde::{Deserialize, Serialize}; pub type PlayerId = u32; +// References +// https://github.com/maxcountryman/axum-login/tree/main/examples/sqlite/src +// https://framesurge.sh/perseus/en-US/docs/0.4.x/state/intro + +#[derive(Clone, Serialize, Deserialize)] +pub struct User { + id: u64, + pub username: String, + password: String, +} + +// Override debug to prevent logging password hash +impl std::fmt::Debug for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("User") + .field("id", &self.id) + .field("username", &self.username) + .field("password", &"[hidden]") + .finish() + } +} + +impl AuthUser for User { + type Id = u64; + + fn id(&self) -> Self::Id { + self.id + } + + fn session_auth_hash(&self) -> &[u8] { + self.password.as_bytes() + } +} diff --git a/src/main.rs b/src/main.rs index a5c1e32..b7289a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,8 @@ cfg_if::cfg_if! { stores::MutableStore, turbine::Turbine, }; + use futures::executor::block_on; + use sea_orm::{Database}; use crate::server::routes::register_routes; } } @@ -35,6 +37,13 @@ pub async fn dflt_server Update to use environment variable + if let Err(err) = block_on(Database::connect( + "postgres://elo:elo@localhost:5432/elo_app", + )) { + panic!("{}", err); + } + axum::Server::bind(&addr) .serve(app.into_make_service()) .await From 5d28013f3044116d04e443bc15a3425823e06c89 Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Wed, 14 Aug 2024 21:31:48 -0400 Subject: [PATCH 03/22] Update readme with fixes also update tailwind --- README.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 957433f..c57eb6d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Run `curl --proto '=https' --tlsv1.3 https://sh.rustup.rs -sSf | sh` ### Windows: https://nodejs.org/en +(todo look into:) +https://pnpm.io/ ### Unix based systems: @@ -23,6 +25,9 @@ https://nodejs.org/en ## 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 docker for Postgresql @@ -56,6 +61,8 @@ To build CSS run: To build the project for testing, run `perseus serve --verbose` +(if broken: todo remove once fixed) +`perseus --wasm-opt-version version_118 serve --verbose` # Deploying the project diff --git a/package.json b/package.json index adef649..6e9af84 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,6 @@ "author": "", "license": "ISC", "devDependencies": { - "tailwindcss": "^3.4.9" + "tailwindcss": "^3.4.10" } } From 528682d9b1e1d4ebf5405bc8eea77c71dd7a2ab6 Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Sun, 18 Aug 2024 03:26:12 -0400 Subject: [PATCH 04/22] Update to more generic db models Also integrated entities into codebase --- Cargo.toml | 1 + README.md | 2 +- entity/src/prelude.rs | 5 - entity/src/user.rs | 18 -- .../src/m20240813_000001_create_users.rs | 25 ++- migration/src/m20240813_000002_create_game.rs | 161 ++++++++++-------- src/auth.rs | 19 +++ src/data/mod.rs | 5 - src/data/pool_match.rs | 74 -------- src/data/store.rs | 37 ---- src/data/user.rs | 38 ----- {entity/src => src/entity}/game.rs | 21 ++- .../entity/game_to_team_result.rs | 31 ++-- {entity/src => src/entity}/mod.rs | 4 +- src/entity/prelude.rs | 7 + .../entity}/sea_orm_active_enums.rs | 8 - src/entity/team_result.rs | 53 ++++++ src/entity/team_result_to_user.rs | 47 +++++ src/entity/user.rs | 42 +++++ src/main.rs | 2 +- src/server/routes.rs | 42 +---- src/templates/add_game_form.rs | 42 +---- src/templates/global_state.rs | 19 +-- src/templates/overall_board.rs | 30 +--- 24 files changed, 314 insertions(+), 419 deletions(-) delete mode 100644 entity/src/prelude.rs delete mode 100644 entity/src/user.rs create mode 100644 src/auth.rs delete mode 100644 src/data/mod.rs delete mode 100644 src/data/pool_match.rs delete mode 100644 src/data/store.rs delete mode 100644 src/data/user.rs rename {entity/src => src/entity}/game.rs (56%) rename entity/src/one_vs_one.rs => src/entity/game_to_team_result.rs (62%) rename {entity/src => src/entity}/mod.rs (62%) create mode 100644 src/entity/prelude.rs rename {entity/src => src/entity}/sea_orm_active_enums.rs (60%) create mode 100644 src/entity/team_result.rs create mode 100644 src/entity/team_result_to_user.rs create mode 100644 src/entity/user.rs diff --git a/Cargo.toml b/Cargo.toml index 8845d5a..72cc281 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,3 +41,4 @@ sea-orm = { version = "0.12.0", features = [ [target.'cfg(client)'.dependencies] wasm-bindgen = "0.2" reqwest = { version = "0.11", features = ["json"] } +sea-orm = { version = "0.12.0" } diff --git a/README.md b/README.md index c57eb6d..1eaca27 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ To set up the database, run: `$env:DATABASE_URL = "postgres://elo:elo@localhost:5432/elo_app"; sea-orm-cli migrate up` Updating entities after updating database: -`$env:DATABASE_URL = "postgres://elo:elo@localhost:5432/elo_app"; sea-orm-cli generate entity -o entity/src --with-serde both` +`$env:DATABASE_URL = "postgres://elo:elo@localhost:5432/elo_app"; sea-orm-cli generate entity -o src/entity --with-serde both` To build CSS run: `npm run build` diff --git a/entity/src/prelude.rs b/entity/src/prelude.rs deleted file mode 100644 index f412b60..0000000 --- a/entity/src/prelude.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 - -pub use super::game::Entity as Game; -pub use super::one_vs_one::Entity as OneVsOne; -pub use super::user::Entity as User; diff --git a/entity/src/user.rs b/entity/src/user.rs deleted file mode 100644 index aa72e13..0000000 --- a/entity/src/user.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 - -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, - pub username: String, - pub password: String, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/migration/src/m20240813_000001_create_users.rs b/migration/src/m20240813_000001_create_users.rs index 60a3819..dbfe201 100644 --- a/migration/src/m20240813_000001_create_users.rs +++ b/migration/src/m20240813_000001_create_users.rs @@ -3,23 +3,25 @@ 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 manager .create_table( Table::create() .table(User::Table) - .if_not_exists() - .col( - ColumnDef::new(User::Id) - .integer() - .not_null() - .auto_increment() - .primary_key(), - ) + .col(pk_auto(User::Id)) .col(string(User::Username)) .col(string(User::Password)) + .col(string(User::Salt)) + .col(timestamp_with_time_zone(User::CreationTime)) + .col(timestamp_with_time_zone(User::LastActiveTime)) + .col(boolean(User::IsAdmin)) + .col(string_null(User::Email)) + .col(string_null(User::Avatar)) .to_owned(), ) .await @@ -37,6 +39,11 @@ pub enum User { Table, Id, Username, - // Hash Password, + Salt, + CreationTime, + LastActiveTime, + IsAdmin, + Email, + Avatar, } diff --git a/migration/src/m20240813_000002_create_game.rs b/migration/src/m20240813_000002_create_game.rs index 0dc83ee..f139e01 100644 --- a/migration/src/m20240813_000002_create_game.rs +++ b/migration/src/m20240813_000002_create_game.rs @@ -12,114 +12,112 @@ impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { let schema = Schema::new(DbBackend::Postgres); - manager - .create_type(schema.create_enum_from_active_enum::()) - .await?; + // Game type enum manager .create_type(schema.create_enum_from_active_enum::()) .await?; + // Game table manager .create_table( Table::create() .table(Game::Table) - .if_not_exists() - .col( - ColumnDef::new(Game::Id) - .integer() - .not_null() - .auto_increment() - .primary_key(), - ) - .col(ColumnDef::new(Game::PlayerSetupType).custom(PlayerSetupType::name())) - .col(ColumnDef::new(Game::GameType).custom(GameType::name())) - .col( - ColumnDef::new(Game::Time) - .timestamp_with_time_zone() - .not_null(), - ) - .to_owned(), - ) - .await?; - manager - .create_table( - Table::create() - .table(Game::Table) - .if_not_exists() - .col( - ColumnDef::new(Game::Id) - .integer() - .not_null() - .auto_increment() - .primary_key(), - ) - .col(ColumnDef::new(Game::PlayerSetupType).custom(PlayerSetupType::name())) + .col(pk_auto(Game::Id)) + .col(timestamp_with_time_zone(Game::Time)) .col(ColumnDef::new(Game::GameType).custom(GameType::name())) .to_owned(), ) .await?; + // TeamResult table manager .create_table( Table::create() - .table(OneVsOne::Table) - .if_not_exists() - .col( - ColumnDef::new(OneVsOne::Id) - .integer() - .not_null() - .auto_increment() - .primary_key(), - ) - .col(ColumnDef::new(OneVsOne::PlayerOne).integer().not_null()) - .col(ColumnDef::new(OneVsOne::PlayerTwo).integer().not_null()) - .col(ColumnDef::new(OneVsOne::GameId).integer().not_null()) - .foreign_key( - ForeignKey::create() - .name("fk-user-one_vs_one-player_one_id") - .from(OneVsOne::Table, OneVsOne::PlayerOne) - .to(User::Table, User::Id), + .table(TeamResult::Table) + .col(pk_auto(TeamResult::Id)) + .col(integer(TeamResult::Place)) + .col(integer_null(TeamResult::Score)) + .to_owned(), + ) + .await?; + // Game to TeamResult assoc + manager + .create_table( + Table::create() + .table(GameToTeamResult::Table) + .col(integer(GameToTeamResult::GameId)) + .col(integer(GameToTeamResult::TeamResultId)) + .primary_key( + Index::create() + .name("pk-game_to_team_result") + .col(GameToTeamResult::GameId) + .col(GameToTeamResult::TeamResultId), ) .foreign_key( ForeignKey::create() - .name("fk-user-one_vs_one-player_two_id") - .from(OneVsOne::Table, OneVsOne::PlayerTwo) - .to(User::Table, User::Id), - ) - .foreign_key( - ForeignKey::create() - .name("fk-game-one_vs_one-game_id") - .from(OneVsOne::Table, OneVsOne::GameId) + .name("fk-game_to_team_result-game_id") + .from(GameToTeamResult::Table, GameToTeamResult::GameId) .to(Game::Table, Game::Id), ) + .foreign_key( + ForeignKey::create() + .name("fk-game_to_team_result-team_result_id") + .from(GameToTeamResult::Table, GameToTeamResult::TeamResultId) + .to(TeamResult::Table, TeamResult::Id), + ) + .to_owned(), + ) + .await?; + // TeamResult to User assoc + manager + .create_table( + Table::create() + .table(TeamResultToUser::Table) + .col(integer(TeamResultToUser::TeamResultId)) + .col(integer(TeamResultToUser::UserId)) + .primary_key( + Index::create() + .name("pk-team_result_to_user") + .col(TeamResultToUser::TeamResultId) + .col(TeamResultToUser::UserId), + ) + .foreign_key( + ForeignKey::create() + .name("fk-team_result_to_user-team_result_id") + .from(TeamResultToUser::Table, TeamResultToUser::TeamResultId) + .to(TeamResult::Table, TeamResult::Id), + ) + .foreign_key( + ForeignKey::create() + .name("fk-team_result_to_user-user_id") + .from(TeamResultToUser::Table, TeamResultToUser::UserId) + .to(User::Table, User::Id), + ) .to_owned(), ) .await } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Drop tables in reverse manager - .drop_type(Type::drop().name(PlayerSetupType::name()).to_owned()) + .drop_table(Table::drop().table(TeamResultToUser::Table).to_owned()) .await?; manager - .drop_type(Type::drop().name(GameType::name()).to_owned()) + .drop_table(Table::drop().table(GameToTeamResult::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(TeamResult::Table).to_owned()) .await?; manager .drop_table(Table::drop().table(Game::Table).to_owned()) .await?; + // Drop types manager - .drop_table(Table::drop().table(OneVsOne::Table).to_owned()) + .drop_type(Type::drop().name(GameType::name()).to_owned()) .await } } // Enums -#[derive(EnumIter, DeriveActiveEnum)] -#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "player_setup_type")] -enum PlayerSetupType { - #[sea_orm(string_value = "OneVsOne")] - OneVsOne, - #[sea_orm(string_value = "TwoVsTwo")] - TwoVsTwo, -} #[derive(EnumIter, DeriveActiveEnum)] #[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "game_type")] @@ -138,15 +136,28 @@ enum Game { Table, Id, Time, - PlayerSetupType, GameType, } #[derive(DeriveIden)] -enum OneVsOne { +enum TeamResult { Table, Id, - GameId, - PlayerOne, - PlayerTwo, + Place, + Score, +} + +// Assoc +#[derive(DeriveIden)] +enum GameToTeamResult { + Table, + GameId, + TeamResultId, +} + +#[derive(DeriveIden)] +enum TeamResultToUser { + Table, + TeamResultId, + UserId, } 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/data/mod.rs b/src/data/mod.rs deleted file mode 100644 index 68c233a..0000000 --- a/src/data/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod pool_match; -pub mod user; - -#[cfg(engine)] -pub mod store; diff --git a/src/data/pool_match.rs b/src/data/pool_match.rs deleted file mode 100644 index b2ba79c..0000000 --- a/src/data/pool_match.rs +++ /dev/null @@ -1,74 +0,0 @@ -use crate::data::user::PlayerId; -use chrono::serde::ts_seconds; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -pub type MatchId = u32; - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub enum MatchType { - Standard8Ball, - Standard9Ball, - CutThroat, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct MatchData { - pub type_: MatchType, - pub winners: Vec, - pub losers: Vec, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct PoolMatch { - pub id: MatchId, - pub data: MatchData, - #[serde(with = "ts_seconds")] - pub time: DateTime, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct PoolMatchList { - pub pool_matches: Vec, - pub max_id: MatchId, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct UserList{ - pub users: Vec, -} - -impl PoolMatch { - pub fn new(data: MatchData, time: DateTime) -> PoolMatch { - PoolMatch { id: 0, data, time } - } -} - -impl PoolMatchList { - pub fn new() -> PoolMatchList { - PoolMatchList { - pool_matches: vec![], - max_id: 0, - } - } - - pub fn add_pool_match(&mut self, mut pool_match: PoolMatch) { - pool_match.id = self.max_id + 1; - self.max_id += 1; - self.pool_matches.push(pool_match); - } -} - -impl UserList { - pub fn new() -> UserList { - UserList { - users: vec![], - } - } - - pub fn add_user(&mut self, user: String) -> usize { - let user_id = self.users.len(); - self.users.push(user); - user_id - } -} diff --git a/src/data/store.rs b/src/data/store.rs deleted file mode 100644 index 4069df9..0000000 --- a/src/data/store.rs +++ /dev/null @@ -1,37 +0,0 @@ -// (Server only) In-memory data storage and persistent storage - -use crate::data::pool_match::PoolMatchList; -use crate::data::pool_match::UserList; -use once_cell::sync::Lazy; -use serde::{Deserialize, Serialize}; -use std::{fs, path::Path, sync::Mutex}; - -#[derive(Serialize, Deserialize, Clone)] -pub struct Store { - pub matches: PoolMatchList, - pub users: UserList, -} - -impl Store { - fn new() -> Store { - fs::create_dir_all("data").unwrap(); - match Path::new("data/store.json").exists() { - false => Store { - matches: PoolMatchList::new(), - users: UserList::new(), - }, - true => { - let contents = fs::read_to_string("data/store.json").unwrap(); - serde_json::from_str(&contents).unwrap() - } - } - } - // TODO -> Store data - #[allow(dead_code)] - pub fn write(&self) { - let contents = serde_json::to_string(&self).unwrap(); - fs::write("data/store.json", contents).unwrap(); - } -} - -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 0124a63..0000000 --- a/src/data/user.rs +++ /dev/null @@ -1,38 +0,0 @@ -use axum_login::{AuthUser, AuthnBackend, UserId}; -use serde::{Deserialize, Serialize}; - -pub type PlayerId = u32; - -// References -// https://github.com/maxcountryman/axum-login/tree/main/examples/sqlite/src -// https://framesurge.sh/perseus/en-US/docs/0.4.x/state/intro - -#[derive(Clone, Serialize, Deserialize)] -pub struct User { - id: u64, - pub username: String, - password: String, -} - -// Override debug to prevent logging password hash -impl std::fmt::Debug for User { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("User") - .field("id", &self.id) - .field("username", &self.username) - .field("password", &"[hidden]") - .finish() - } -} - -impl AuthUser for User { - type Id = u64; - - fn id(&self) -> Self::Id { - self.id - } - - fn session_auth_hash(&self) -> &[u8] { - self.password.as_bytes() - } -} diff --git a/entity/src/game.rs b/src/entity/game.rs similarity index 56% rename from entity/src/game.rs rename to src/entity/game.rs index a739bc7..18c89a5 100644 --- a/entity/src/game.rs +++ b/src/entity/game.rs @@ -1,7 +1,6 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 use super::sea_orm_active_enums::GameType; -use super::sea_orm_active_enums::PlayerSetupType; use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -10,20 +9,28 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key)] pub id: i32, - pub player_setup_type: Option, - pub game_type: Option, pub time: DateTimeWithTimeZone, + pub game_type: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { - #[sea_orm(has_many = "super::one_vs_one::Entity")] - OneVsOne, + #[sea_orm(has_many = "super::game_to_team_result::Entity")] + GameToTeamResult, } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::OneVsOne.def() + Relation::GameToTeamResult.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::game_to_team_result::Relation::TeamResult.def() + } + fn via() -> Option { + Some(super::game_to_team_result::Relation::Game.def().rev()) } } diff --git a/entity/src/one_vs_one.rs b/src/entity/game_to_team_result.rs similarity index 62% rename from entity/src/one_vs_one.rs rename to src/entity/game_to_team_result.rs index 550cda4..52040a4 100644 --- a/entity/src/one_vs_one.rs +++ b/src/entity/game_to_team_result.rs @@ -4,13 +4,12 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] -#[sea_orm(table_name = "one_vs_one")] +#[sea_orm(table_name = "game_to_team_result")] pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - pub player_one: i32, - pub player_two: i32, + #[sea_orm(primary_key, auto_increment = false)] pub game_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub team_result_id: i32, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -24,21 +23,13 @@ pub enum Relation { )] Game, #[sea_orm( - belongs_to = "super::user::Entity", - from = "Column::PlayerOne", - to = "super::user::Column::Id", + belongs_to = "super::team_result::Entity", + from = "Column::TeamResultId", + to = "super::team_result::Column::Id", on_update = "NoAction", on_delete = "NoAction" )] - User2, - #[sea_orm( - belongs_to = "super::user::Entity", - from = "Column::PlayerTwo", - to = "super::user::Column::Id", - on_update = "NoAction", - on_delete = "NoAction" - )] - User1, + TeamResult, } impl Related for Entity { @@ -47,4 +38,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::TeamResult.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/mod.rs b/src/entity/mod.rs similarity index 62% rename from entity/src/mod.rs rename to src/entity/mod.rs index f22ac25..8a33116 100644 --- a/entity/src/mod.rs +++ b/src/entity/mod.rs @@ -3,6 +3,8 @@ pub mod prelude; pub mod game; -pub mod one_vs_one; +pub mod game_to_team_result; pub mod sea_orm_active_enums; +pub mod team_result; +pub mod team_result_to_user; pub mod user; diff --git a/src/entity/prelude.rs b/src/entity/prelude.rs new file mode 100644 index 0000000..6f116a3 --- /dev/null +++ b/src/entity/prelude.rs @@ -0,0 +1,7 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +pub use super::game::Entity as Game; +pub use super::game_to_team_result::Entity as GameToTeamResult; +pub use super::team_result::Entity as TeamResult; +pub use super::team_result_to_user::Entity as TeamResultToUser; +pub use super::user::Entity as User; diff --git a/entity/src/sea_orm_active_enums.rs b/src/entity/sea_orm_active_enums.rs similarity index 60% rename from entity/src/sea_orm_active_enums.rs rename to src/entity/sea_orm_active_enums.rs index 631fc29..25dba9d 100644 --- a/entity/src/sea_orm_active_enums.rs +++ b/src/entity/sea_orm_active_enums.rs @@ -13,11 +13,3 @@ pub enum GameType { #[sea_orm(string_value = "TableTennis")] TableTennis, } -#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] -#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "player_setup_type")] -pub enum PlayerSetupType { - #[sea_orm(string_value = "OneVsOne")] - OneVsOne, - #[sea_orm(string_value = "TwoVsTwo")] - TwoVsTwo, -} diff --git a/src/entity/team_result.rs b/src/entity/team_result.rs new file mode 100644 index 0000000..d3bfadc --- /dev/null +++ b/src/entity/team_result.rs @@ -0,0 +1,53 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "team_result")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub place: i32, + pub score: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::game_to_team_result::Entity")] + GameToTeamResult, + #[sea_orm(has_many = "super::team_result_to_user::Entity")] + TeamResultToUser, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::GameToTeamResult.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::TeamResultToUser.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::game_to_team_result::Relation::Game.def() + } + fn via() -> Option { + Some(super::game_to_team_result::Relation::TeamResult.def().rev()) + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::team_result_to_user::Relation::User.def() + } + fn via() -> Option { + Some(super::team_result_to_user::Relation::TeamResult.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/team_result_to_user.rs b/src/entity/team_result_to_user.rs new file mode 100644 index 0000000..7e73bed --- /dev/null +++ b/src/entity/team_result_to_user.rs @@ -0,0 +1,47 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "team_result_to_user")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub team_result_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::team_result::Entity", + from = "Column::TeamResultId", + to = "super::team_result::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + TeamResult, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::TeamResult.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/user.rs b/src/entity/user.rs new file mode 100644 index 0000000..36b32b0 --- /dev/null +++ b/src/entity/user.rs @@ -0,0 +1,42 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +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, + pub username: String, + pub password: String, + pub salt: String, + pub creation_time: DateTimeWithTimeZone, + pub last_active_time: DateTimeWithTimeZone, + pub is_admin: bool, + pub email: Option, + pub avatar: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::team_result_to_user::Entity")] + TeamResultToUser, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::TeamResultToUser.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::team_result_to_user::Relation::TeamResult.def() + } + fn via() -> Option { + Some(super::team_result_to_user::Relation::User.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/main.rs b/src/main.rs index b7289a1..25f1752 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ mod components; -mod data; mod endpoints; +mod entity; mod error_views; #[cfg(engine)] mod server; diff --git a/src/server/routes.rs b/src/server/routes.rs index df2cc88..f343cbf 100644 --- a/src/server/routes.rs +++ b/src/server/routes.rs @@ -1,52 +1,16 @@ // (Server only) Routes - -use crate::{ - data::{ - pool_match::{PoolMatch, PoolMatchList, UserList}, - store::DATA, - }, - endpoints::MATCH, - endpoints::USER, -}; +use crate::{endpoints::USER, entity::user}; use axum::{ extract::Json, routing::{post, Router}, }; -use std::thread; pub fn register_routes(app: Router) -> Router { - let app = app.route(MATCH, post(post_match)); let app = app.route(USER, post(post_user)); app } -async fn post_match(Json(pool_match): Json) -> Json { +async fn post_user(user: String) -> Json { // Update the store with the new match - let matches = thread::spawn(move || { - // Get the store - let mut data = DATA.lock().unwrap(); - (*data).matches.add_pool_match(pool_match); - println!("{:?}", (*data).matches.pool_matches); - (*data).matches.clone() - }) - .join() - .unwrap(); - - Json(matches) + todo!() } - -async fn post_user(user: String) -> Json { - // Update the store with the new match - let users = thread::spawn(move || { - // Get the store - let mut data = DATA.lock().unwrap(); - let user_id = (*data).users.add_user(user); - println!("Added new user id: {}\nAll users: {:?}", user_id, (*data).users); - (*data).users.clone() - }) - .join() - .unwrap(); - - Json(users) -} - diff --git a/src/templates/add_game_form.rs b/src/templates/add_game_form.rs index d89de31..d7f20b6 100644 --- a/src/templates/add_game_form.rs +++ b/src/templates/add_game_form.rs @@ -1,5 +1,4 @@ -use crate::data::pool_match::MatchType; -use crate::{components::layout::Layout, data::pool_match::MatchData}; +use crate::components::layout::Layout; use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; @@ -7,7 +6,6 @@ use web_sys::Event; cfg_if::cfg_if! { if #[cfg(client)] { - use crate::data::pool_match::{PoolMatch, PoolMatchList, UserList}; use crate::templates::global_state::AppStateRx; use crate::endpoints::{MATCH, USER}; use crate::templates::get_api_path; @@ -29,28 +27,7 @@ fn add_game_form_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStat #[cfg(client)] { // state.winner.get().as_ref().clone() - spawn_local_scoped(cx, async move { - let new_match = PoolMatch::new( - MatchData { - type_: MatchType::Standard8Ball, - winners: vec![1], - losers: vec![2, 3, 4], - }, - Utc::now(), - ); - let client = reqwest::Client::new(); - let new_matches = client - .post(get_api_path(MATCH).as_str()) - .json(&new_match) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let global_state = Reactor::::from_cx(cx).get_global_state::(cx); - global_state.matches.set(new_matches); - }) + spawn_local_scoped(cx, async move {}) } }; @@ -58,20 +35,7 @@ fn add_game_form_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStat #[cfg(client)] { // state.winner.get().as_ref().clone() - spawn_local_scoped(cx, async move { - let client = reqwest::Client::new(); - let new_users = client - .post(get_api_path(USER).as_str()) - .body(state.new_user.get().as_ref().clone()) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let global_state = Reactor::::from_cx(cx).get_global_state::(cx); - global_state.users.set(new_users); - }) + spawn_local_scoped(cx, async move {}) } }; diff --git a/src/templates/global_state.rs b/src/templates/global_state.rs index 8658355..5fd4e1f 100644 --- a/src/templates/global_state.rs +++ b/src/templates/global_state.rs @@ -1,8 +1,5 @@ // Not a page, global state that is shared between all pages -use crate::data::pool_match::PoolMatchList; -use crate::data::pool_match::UserList; - use perseus::{prelude::*, state::GlobalStateCreator}; use serde::{Deserialize, Serialize}; @@ -10,16 +7,12 @@ cfg_if::cfg_if! { if #[cfg(engine)] { use std::thread; use std::ops::Deref; - use crate::data::store::DATA; } } #[derive(Serialize, Deserialize, ReactiveState, Clone)] #[rx(alias = "AppStateRx")] -pub struct AppState { - pub matches: PoolMatchList, - pub users: UserList, -} +pub struct AppState {} pub fn get_global_state_creator() -> GlobalStateCreator { GlobalStateCreator::new() @@ -29,15 +22,7 @@ pub fn get_global_state_creator() -> GlobalStateCreator { #[engine_only_fn] fn get_state() -> AppState { - let matches = thread::spawn(move || DATA.lock().unwrap().deref().matches.clone()) - .join() - .unwrap(); - - let users = thread::spawn(move || DATA.lock().unwrap().deref().users.clone()) - .join() - .unwrap(); - - AppState { matches, users } + AppState {} } #[engine_only_fn] diff --git a/src/templates/overall_board.rs b/src/templates/overall_board.rs index 5bc0f26..db45c2d 100644 --- a/src/templates/overall_board.rs +++ b/src/templates/overall_board.rs @@ -1,22 +1,13 @@ -use crate::{components::layout::Layout, templates::global_state::AppStateRx, data::user::PlayerId}; +use crate::{components::layout::Layout, templates::global_state::AppStateRx}; use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -use crate::data::pool_match::PoolMatch; - #[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] struct PageState {} -fn format_list_or_single(to_format: &Vec) -> String{ - match to_format.len() { - 1 => to_format[0].to_string(), - _ => format!("{:?}", to_format), - } -} - fn overall_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, _state: &'a PageStateRx) -> View { let global_state = Reactor::::from_cx(cx).get_global_state::(cx); @@ -24,24 +15,7 @@ fn overall_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, _state: &'a PageSta Layout(title = "Overall Leaderboard") { ul { (View::new_fragment( - global_state.matches.get() - .pool_matches - .iter() - .rev() - .map(|item: &PoolMatch| { - let game = item.clone(); - - view! { cx, - li (class = "text-blue-700", id = "ha",) { - (game.id) - (" ") - (format_list_or_single(&game.data.winners)) - (" ") - (format_list_or_single(&game.data.losers)) - } - } - }) - .collect(), + vec![], )) } } From 8ad6967b7afb215602fd8177749c7761beb8a630 Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Sun, 18 Aug 2024 15:34:42 -0400 Subject: [PATCH 05/22] Remove automatic deploy Too annoying to host now --- .github/workflows/github-actions-demo.yml | 56 +---------------------- 1 file changed, 2 insertions(+), 54 deletions(-) diff --git a/.github/workflows/github-actions-demo.yml b/.github/workflows/github-actions-demo.yml index 7a13c11..f6cf1cb 100644 --- a/.github/workflows/github-actions-demo.yml +++ b/.github/workflows/github-actions-demo.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository code - # TODO -> Change to actions/checkout@v3 once this is resolved https://github.com/actions/checkout/issues/1370 + # TODO -> Change to actions/checkout@v3 once this is resolved https://github.com/actions/checkout/issues/1370 uses: https://gitea.com/ScMi1/checkout@v1 - name: Get rust uses: actions-rs/toolchain@v1 @@ -25,7 +25,7 @@ jobs: - name: Add wasm32 to cargo run: rustup target add wasm32-unknown-unknown - name: Cache rust - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@v2 with: cache-on-failure: "true" - name: Install perseus @@ -44,55 +44,3 @@ jobs: run: npm run build - name: Build the project run: perseus deploy - - name: Download private docker registry cert - uses: nicklasfrahm/scp-action@v1.0.1 - with: - direction: download - host: ${{ secrets.REGISTRY_HOST }} - username: ${{ secrets.REGISTRY_HOST_USERNAME }} - insecure_password: ${{ secrets.REGISTRY_HOST_PASSWORD }} - source: ${{ secrets.REGISTRY_CRT_PATH }} - target: ca.crt - insecure_ignore_fingerprint: true - - - name: Add directory for docker registry cert - run: 'mkdir -p /etc/docker/certs.d/${{ secrets.REGISTRY_HOST }}' - - - name: Move private docker registry cert - run: 'mv ca.crt /etc/docker/certs.d/${{ secrets.REGISTRY_HOST }}' - - - name: Install docker - uses: papodaca/install-docker-action@main - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - with: - config-inline: | - [registry."${{ secrets.REGISTRY_HOST }}"] - insecure = true - ca=["/etc/docker/certs.d/${{ secrets.REGISTRY_HOST }}/ca.crt"] - - - name: Login to private docker registry - uses: docker/login-action@v2 - with: - registry: ${{ secrets.REGISTRY_HOST }} - username: ${{ secrets.REGISTRY_USERNAME }} - password: ${{ secrets.REGISTRY_PASSWORD }} - - - name: Build and push - uses: docker/build-push-action@v4 - with: - context: . - push: true - tags: ${{ secrets.REGISTRY_HOST }}/vsquad/pool-elo:${{ gitea.ref_name }} - - - name: Trigger deployment - uses: fjogeleit/http-request-action@v1 - with: - url: ${{ secrets.TRIGGER_DEPLOY_URL }} - method: POST - customHeaders: '{"Content-Type": "application/json"}' - data: '{"actionName": "deploy", "arguments": []}' - preventFailureOnNoResponse: 'true' From 0fd6ef8817baae36dea68728bde4333a4b9eeaa7 Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Sun, 18 Aug 2024 15:40:22 -0400 Subject: [PATCH 06/22] Fix build, test out clippy --- .github/workflows/github-actions-demo.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/github-actions-demo.yml b/.github/workflows/github-actions-demo.yml index f6cf1cb..4000ab9 100644 --- a/.github/workflows/github-actions-demo.yml +++ b/.github/workflows/github-actions-demo.yml @@ -42,5 +42,10 @@ jobs: run: npm install -D tailwindcss - name: Compile css run: npm run build + # TODO -> Remove wasm-opt-version once perseus is updated - name: Build the project - run: perseus deploy + run: perseus --wasm-opt-version version_118 deploy + - name: Run clippy for server + run: RUSTFLAGS="--cfg=engine" cargo clippy -all + - name: Run clippy for frontend + run: RUSTFLAGS="--cfg=client" cargo clippy --all From f71d2ae70ae0e51f7d913ff5cbfc691f88280992 Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Sun, 18 Aug 2024 15:57:11 -0400 Subject: [PATCH 07/22] Add verbose building --- .github/workflows/github-actions-demo.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-actions-demo.yml b/.github/workflows/github-actions-demo.yml index 4000ab9..08155a3 100644 --- a/.github/workflows/github-actions-demo.yml +++ b/.github/workflows/github-actions-demo.yml @@ -44,8 +44,8 @@ jobs: run: npm run build # TODO -> Remove wasm-opt-version once perseus is updated - name: Build the project - run: perseus --wasm-opt-version version_118 deploy + run: perseus --wasm-opt-version version_118 deploy --verbose - name: Run clippy for server - run: RUSTFLAGS="--cfg=engine" cargo clippy -all + run: RUSTFLAGS="--cfg=engine" cargo clippy --all - name: Run clippy for frontend run: RUSTFLAGS="--cfg=client" cargo clippy --all From d3208303ba8c8e688f08980ee9ec7de3ade6fe0e Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Sun, 18 Aug 2024 16:06:46 -0400 Subject: [PATCH 08/22] Add formatting check and fail on clippy warnings --- .github/workflows/github-actions-demo.yml | 8 ++++++-- src/server/mod.rs | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/github-actions-demo.yml b/.github/workflows/github-actions-demo.yml index 08155a3..f28d535 100644 --- a/.github/workflows/github-actions-demo.yml +++ b/.github/workflows/github-actions-demo.yml @@ -46,6 +46,10 @@ jobs: - name: Build the project run: perseus --wasm-opt-version version_118 deploy --verbose - name: Run clippy for server - run: RUSTFLAGS="--cfg=engine" cargo clippy --all + run: RUSTFLAGS="--cfg=engine" cargo clippy --all -- -D warnings + continue-on-error: true - name: Run clippy for frontend - run: RUSTFLAGS="--cfg=client" cargo clippy --all + run: RUSTFLAGS="--cfg=client" cargo clippy --all -- -D warnings + continue-on-error: true + - name: Check for formatting issues + run: cargo fmt --check diff --git a/src/server/mod.rs b/src/server/mod.rs index 9bebc39..6a664ab 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,2 +1 @@ pub mod routes; - From ffe6a771d291f482671524845ff6f179369dffaf Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Sun, 18 Aug 2024 16:30:37 -0400 Subject: [PATCH 09/22] Add engine and client cfg Should fix some linking errors --- Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 72cc281..200f3fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,3 +42,6 @@ sea-orm = { version = "0.12.0", features = [ wasm-bindgen = "0.2" reqwest = { version = "0.11", features = ["json"] } sea-orm = { version = "0.12.0" } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(engine)', 'cfg(client)'] } From d29da66536056f93a0e61d838763ab257e94dcde Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Sun, 18 Aug 2024 19:56:00 -0400 Subject: [PATCH 10/22] Fix clippy errors, update deps --- .github/workflows/github-actions-demo.yml | 4 ++-- Cargo.toml | 4 ++-- src/components/layout.rs | 7 +++++-- src/main.rs | 1 + src/server/routes.rs | 14 +++++++++++--- src/templates/add_game_form.rs | 2 +- src/templates/global_state.rs | 3 +-- src/templates/index.rs | 2 +- src/templates/one_v_one_board.rs | 2 +- src/templates/overall_board.rs | 4 ++-- 10 files changed, 27 insertions(+), 16 deletions(-) diff --git a/.github/workflows/github-actions-demo.yml b/.github/workflows/github-actions-demo.yml index f28d535..bdf7e78 100644 --- a/.github/workflows/github-actions-demo.yml +++ b/.github/workflows/github-actions-demo.yml @@ -46,10 +46,10 @@ jobs: - name: Build the project run: perseus --wasm-opt-version version_118 deploy --verbose - name: Run clippy for server - run: RUSTFLAGS="--cfg=engine" cargo clippy --all -- -D warnings + run: RUSTFLAGS="--cfg=engine" cargo clippy --all --future-incompat-report -- -D warnings continue-on-error: true - name: Run clippy for frontend - run: RUSTFLAGS="--cfg=client" cargo clippy --all -- -D warnings + run: RUSTFLAGS="--cfg=client" cargo clippy --all --future-incompat-report -- -D warnings continue-on-error: true - name: Check for formatting issues run: cargo fmt --check diff --git a/Cargo.toml b/Cargo.toml index 200f3fd..fb1b76d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,9 +39,9 @@ sea-orm = { version = "0.12.0", features = [ ] } [target.'cfg(client)'.dependencies] -wasm-bindgen = "0.2" +wasm-bindgen = "0.2.93" reqwest = { version = "0.11", features = ["json"] } -sea-orm = { version = "0.12.0" } +sea-orm = { version = "0.1" } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(engine)', 'cfg(client)'] } diff --git a/src/components/layout.rs b/src/components/layout.rs index a561828..7c37254 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -2,14 +2,17 @@ use sycamore::prelude::*; #[derive(Prop)] pub struct LayoutProps<'a, G: Html> { - pub title: &'a str, + pub _title: &'a str, pub children: Children<'a, G>, } #[component] pub fn Layout<'a, G: Html>( cx: Scope<'a>, - LayoutProps { title: _, children }: LayoutProps<'a, G>, + LayoutProps { + _title: _, + children, + }: LayoutProps<'a, G>, ) -> View { let children = children.call(cx); diff --git a/src/main.rs b/src/main.rs index 25f1752..8d738bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod components; mod endpoints; +#[allow(unused_imports)] mod entity; mod error_views; #[cfg(engine)] diff --git a/src/server/routes.rs b/src/server/routes.rs index f343cbf..b65cb22 100644 --- a/src/server/routes.rs +++ b/src/server/routes.rs @@ -1,5 +1,8 @@ // (Server only) Routes -use crate::{endpoints::USER, entity::user}; +use crate::{ + endpoints::{MATCH, USER}, + entity::{game, user}, +}; use axum::{ extract::Json, routing::{post, Router}, @@ -7,10 +10,15 @@ use axum::{ pub fn register_routes(app: Router) -> Router { let app = app.route(USER, post(post_user)); - app + app.route(MATCH, post(post_match)) } -async fn post_user(user: String) -> Json { +async fn post_user(_user: String) -> Json { + // Update the store with the new match + todo!() +} + +async fn post_match(_user: String) -> Json { // Update the store with the new match todo!() } diff --git a/src/templates/add_game_form.rs b/src/templates/add_game_form.rs index d7f20b6..673bfed 100644 --- a/src/templates/add_game_form.rs +++ b/src/templates/add_game_form.rs @@ -40,7 +40,7 @@ fn add_game_form_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStat }; view! { cx, - Layout(title = "Add Game Results") { + Layout(_title = "Add Game Results") { div (class = "flex flex-wrap") { select { option (value="red") diff --git a/src/templates/global_state.rs b/src/templates/global_state.rs index 5fd4e1f..c6a7b2d 100644 --- a/src/templates/global_state.rs +++ b/src/templates/global_state.rs @@ -5,8 +5,7 @@ use serde::{Deserialize, Serialize}; cfg_if::cfg_if! { if #[cfg(engine)] { - use std::thread; - use std::ops::Deref; + } } diff --git a/src/templates/index.rs b/src/templates/index.rs index ff585f8..0228503 100644 --- a/src/templates/index.rs +++ b/src/templates/index.rs @@ -4,7 +4,7 @@ use sycamore::prelude::*; fn index_page(cx: Scope) -> View { view! { cx, - Layout(title = "Index") { + Layout(_title = "Index") { // Anything we put in here will be rendered inside the `
` block of the layout p { "Hello World!" } br {} diff --git a/src/templates/one_v_one_board.rs b/src/templates/one_v_one_board.rs index cccbe82..4a31bec 100644 --- a/src/templates/one_v_one_board.rs +++ b/src/templates/one_v_one_board.rs @@ -9,7 +9,7 @@ struct PageState {} fn one_v_one_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, _state: &'a PageStateRx) -> View { view! { cx, - Layout(title = "1v1 Leaderboard") { + Layout(_title = "1v1 Leaderboard") { p { "leaderboard" } } } diff --git a/src/templates/overall_board.rs b/src/templates/overall_board.rs index db45c2d..8c4f887 100644 --- a/src/templates/overall_board.rs +++ b/src/templates/overall_board.rs @@ -9,10 +9,10 @@ use sycamore::prelude::*; struct PageState {} fn overall_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, _state: &'a PageStateRx) -> View { - let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + let _global_state = Reactor::::from_cx(cx).get_global_state::(cx); view! { cx, - Layout(title = "Overall Leaderboard") { + Layout(_title = "Overall Leaderboard") { ul { (View::new_fragment( vec![], From 993a0834b462b4b714364e2b01dcef03c9194305 Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Sun, 18 Aug 2024 20:11:32 -0400 Subject: [PATCH 11/22] Fix sea-orm version --- Cargo.toml | 4 ++-- README.md | 2 +- src/entity/game.rs | 2 +- src/entity/game_to_team_result.rs | 2 +- src/entity/mod.rs | 2 +- src/entity/prelude.rs | 2 +- src/entity/sea_orm_active_enums.rs | 2 +- src/entity/team_result.rs | 2 +- src/entity/team_result_to_user.rs | 2 +- src/entity/user.rs | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fb1b76d..a69a1e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ perseus-axum = { version = "0.4.2" } axum = "0.6" tower-http = { version = "0.3", features = ["fs"] } futures = "0.3.28" -sea-orm = { version = "0.12.0", features = [ +sea-orm = { version = "1.0", features = [ "sqlx-postgres", "runtime-tokio-native-tls", "macros", @@ -41,7 +41,7 @@ sea-orm = { version = "0.12.0", features = [ [target.'cfg(client)'.dependencies] wasm-bindgen = "0.2.93" reqwest = { version = "0.11", features = ["json"] } -sea-orm = { version = "0.1" } +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 1eaca27..d894b8a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ https://pnpm.io/ ## 5. Install SeaORM for database -`cargo install sea-orm-cli` +`cargo install sea-orm-cli@1.0.0-rc.5` ## 5. Install tailwindcss, for styling diff --git a/src/entity/game.rs b/src/entity/game.rs index 18c89a5..bd93b2a 100644 --- a/src/entity/game.rs +++ b/src/entity/game.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 use super::sea_orm_active_enums::GameType; use sea_orm::entity::prelude::*; diff --git a/src/entity/game_to_team_result.rs b/src/entity/game_to_team_result.rs index 52040a4..2aab72e 100644 --- a/src/entity/game_to_team_result.rs +++ b/src/entity/game_to_team_result.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; diff --git a/src/entity/mod.rs b/src/entity/mod.rs index 8a33116..51b1436 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 pub mod prelude; diff --git a/src/entity/prelude.rs b/src/entity/prelude.rs index 6f116a3..d4c75d9 100644 --- a/src/entity/prelude.rs +++ b/src/entity/prelude.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 pub use super::game::Entity as Game; pub use super::game_to_team_result::Entity as GameToTeamResult; diff --git a/src/entity/sea_orm_active_enums.rs b/src/entity/sea_orm_active_enums.rs index 25dba9d..f3a45b7 100644 --- a/src/entity/sea_orm_active_enums.rs +++ b/src/entity/sea_orm_active_enums.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; diff --git a/src/entity/team_result.rs b/src/entity/team_result.rs index d3bfadc..602061a 100644 --- a/src/entity/team_result.rs +++ b/src/entity/team_result.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; diff --git a/src/entity/team_result_to_user.rs b/src/entity/team_result_to_user.rs index 7e73bed..1d73820 100644 --- a/src/entity/team_result_to_user.rs +++ b/src/entity/team_result_to_user.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; diff --git a/src/entity/user.rs b/src/entity/user.rs index 36b32b0..82cc0ed 100644 --- a/src/entity/user.rs +++ b/src/entity/user.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; From 528ce0335c5b6b5aea3f3fb0ba2e4c46c2dd8b15 Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Mon, 19 Aug 2024 02:38:29 -0400 Subject: [PATCH 12/22] Add auth global state and login button --- src/components/layout.rs | 90 ++++++++++++++++++++++++++++------- src/templates/global_state.rs | 55 ++++++++++++++++----- 2 files changed, 115 insertions(+), 30 deletions(-) diff --git a/src/components/layout.rs b/src/components/layout.rs index 7c37254..6ffc68a 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -1,4 +1,7 @@ +use crate::templates::global_state::{AppStateRx, LoginState}; +use perseus::prelude::*; use sycamore::prelude::*; +use web_sys::Event; #[derive(Prop)] pub struct LayoutProps<'a, G: Html> { @@ -6,6 +9,8 @@ pub struct LayoutProps<'a, G: Html> { 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>, @@ -15,33 +20,84 @@ pub fn Layout<'a, G: Html>( }: LayoutProps<'a, G>, ) -> View { let children = children.call(cx); + // Get global state to get authentication info + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + + // Check if the client is authenticated or not + #[cfg(client)] + global_state.auth.detect_state(); + + // TODO -> move into function + 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.auth.state.set(LoginState::Authenticated); + }); + } + }; view! { cx, + // Main page header header { - div (class = "flex items-center justify-between") { - div (class = "w-full text-gray-700 md:text-center text-2xl font-semibold") { + div (class = "flex items-center justify-between w-full md:text-center") { + div(class = "flex-1") {} + div(class = "text-gray-700 text-2xl font-semibold py-2") { "Pool Elo - Season 1" } - } - - 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 = "add-game-form", - class = "mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0" - ) { "Add game result" } - a(href = "one-v-one-board", - class = "mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0" - ) { "1v1 Leaderboard" } - a(href = "overall-board", - class = "mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0" - ) { "Overall Leaderboard" } - } + div(class = "flex-1 py-2") {( + match *global_state.auth.state.get() { + LoginState::NotAuthenticated => { + view! { cx, + button(class = "text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700") { + "Register" + } + button(on:click = handle_log_in,class = "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800") { + "Login" + } + } + } + LoginState::Authenticated => { + view! { cx, + div { + "Hello {username}!" + } + } + } + // Will only appear for a few seconds + LoginState::Unknown => { + view! { cx, + div (class = "px-5 py-2.5 me-2 mb-2"){ + "Loading..." + } + } + }, + }) } } } main(style = "my-8") { + // Body header + div { + 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 = "add-game-form", + class = "mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0" + ) { "Add game result" } + a(href = "one-v-one-board", + class = "mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0" + ) { "1v1 Leaderboard" } + a(href = "overall-board", + class = "mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0" + ) { "Overall Leaderboard" } + } + } + } + } + // Actual 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") { diff --git a/src/templates/global_state.rs b/src/templates/global_state.rs index c6a7b2d..7c71814 100644 --- a/src/templates/global_state.rs +++ b/src/templates/global_state.rs @@ -11,25 +11,54 @@ cfg_if::cfg_if! { #[derive(Serialize, Deserialize, ReactiveState, Clone)] #[rx(alias = "AppStateRx")] -pub struct AppState {} - -pub fn get_global_state_creator() -> GlobalStateCreator { - GlobalStateCreator::new() - .build_state_fn(get_build_state) - .request_state_fn(get_request_state) +pub struct AppState { + #[rx(nested)] + pub auth: AuthData, } -#[engine_only_fn] -fn get_state() -> AppState { - AppState {} +#[derive(Serialize, Deserialize, Clone)] +pub enum LoginState { + Authenticated, + NotAuthenticated, + Unknown, +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "AuthDataRx")] +pub struct AuthData { + pub state: LoginState, + pub username: Option, + pub claims: Claims, +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "ClaimsRx")] +pub struct Claims {} + +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 { - get_state() + AppState { + auth: AuthData { + state: LoginState::Unknown, + username: None, + claims: Claims {}, + }, + } } -#[engine_only_fn] -pub async fn get_request_state(_req: Request) -> AppState { - get_state() +// 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 -> Get state from storage + self.state.set(LoginState::NotAuthenticated); + } } From 8d2460c60e3a05ec1b9e43a1d6efb9c385d88730 Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Wed, 21 Aug 2024 19:32:22 -0400 Subject: [PATCH 13/22] Add login modal --- src/components/layout.rs | 52 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/components/layout.rs b/src/components/layout.rs index 6ffc68a..2558932 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -41,7 +41,7 @@ pub fn Layout<'a, G: Html>( view! { cx, // Main page header header { - div (class = "flex items-center justify-between w-full md:text-center") { + div (class = "flex items-center justify-between w-full md:text-center h-20") { div(class = "flex-1") {} div(class = "text-gray-700 text-2xl font-semibold py-2") { "Pool Elo - Season 1" @@ -79,6 +79,56 @@ pub fn Layout<'a, G: Html>( } main(style = "my-8") { + + ( + match *global_state.auth.state.get() { + LoginState::Authenticated => { view! { cx, + + + + div (class="overflow-x-hidden overflow-y-auto fixed h-modal md:h-full top-4 left-0 right-0 md:inset-0 z-50 justify-center items-center"){ + div (class="relative w-full max-w-md px-4 h-full md:h-auto") { + + div (class="bg-white rounded-lg shadow relative dark:bg-gray-700"){ + + div (class="flex justify-end p-2"){ + button (class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-800 dark:hover:text-white"){ + "Back" + } + } + + form (class="space-y-6 px-6 lg:px-8 pb-4 sm:pb-6 xl:pb-8") { + h3 (class="text-xl font-medium text-gray-900 dark:text-white"){"Sign in to our platform"} + div { + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300") {"Your email"} + input (class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white") {} + } + div { + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Your password"} + input (class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} + } + div (class="flex justify-between"){ + div (class="flex items-start"){ + div (class="flex items-center h-5"){ + input (class="bg-gray-50 border border-gray-300 focus:ring-3 focus:ring-blue-300 h-4 w-4 rounded dark:bg-gray-600 dark:border-gray-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800") {} + } + div (class="text-sm ml-3"){ + label (class="font-medium text-gray-900 dark:text-gray-300"){"Remember me"} + } + } + a (class="text-sm text-blue-700 hover:underline dark:text-blue-500"){"Lost Password?"} + } + button (class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"){"Login to your account"} + div (class="text-sm font-medium text-gray-500 dark:text-gray-300"){ + a (class="text-blue-700 hover:underline dark:text-blue-500"){"Create account"} + } + } + } + } + } + }}, + _ => { view! { cx, div {} } }}) + // Body header div { div (class = "container mx-auto px-6 py-3") { From c2ecce0324773f38ebe854cd9d4167b0818335ca Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Fri, 23 Aug 2024 15:45:40 -0400 Subject: [PATCH 14/22] Add login form capsule --- Cargo.toml | 1 + src/capsules/login_form.rs | 86 ++++++++++++++++++++++++++++++++++++++ src/capsules/mod.rs | 1 + src/components/layout.rs | 57 ++++++------------------- src/main.rs | 2 + 5 files changed, 103 insertions(+), 44 deletions(-) create mode 100644 src/capsules/login_form.rs create mode 100644 src/capsules/mod.rs diff --git a/Cargo.toml b/Cargo.toml index a69a1e8..416cea4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ cfg-if = "1.0.0" chrono = { version = "0.4.38", features = ["serde", "wasm-bindgen"] } axum-login = "0.15.3" password-auth = "1.0.0" +lazy_static = "1.5" [target.'cfg(engine)'.dev-dependencies] fantoccini = "0.19" diff --git a/src/capsules/login_form.rs b/src/capsules/login_form.rs new file mode 100644 index 0000000..7849a39 --- /dev/null +++ b/src/capsules/login_form.rs @@ -0,0 +1,86 @@ +use lazy_static::lazy_static; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; + +lazy_static! { + pub static ref LOGIN_FORM: Capsule = get_capsule(); +} + +#[auto_scope] +fn login_form_capsule( + cx: Scope, + state: &LoginFormStateRx, + props: LoginFormProps, +) -> View { + view! { + cx, + div (class="overflow-x-hidden overflow-y-auto fixed h-modal md:h-full top-4 left-0 right-0 md:inset-0 z-50 justify-center items-center"){ + div (class="relative w-full max-w-md px-4 h-full md:h-auto") { + div (class="bg-white rounded-lg shadow relative dark:bg-gray-700"){ + div (class="flex justify-end p-2"){ + button (class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-800 dark:hover:text-white"){ + "Back" + } + } + form (class="space-y-6 px-6 lg:px-8 pb-4 sm:pb-6 xl:pb-8") { + h3 (class="text-xl font-medium text-gray-900 dark:text-white"){"Sign in to our platform"} + div { + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300") {"Your email"} + input (class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white") {} + } + div { + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Your password"} + input (class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} + } + div (class="flex justify-between"){ + div (class="flex items-start"){ + div (class="flex items-center h-5"){ + input (class="bg-gray-50 border border-gray-300 focus:ring-3 focus:ring-blue-300 h-4 w-4 rounded dark:bg-gray-600 dark:border-gray-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800") {} + } + div (class="text-sm ml-3"){ + label (class="font-medium text-gray-900 dark:text-gray-300"){"Remember me"} + } + } + a (class="text-sm text-blue-700 hover:underline dark:text-blue-500"){"Lost Password?"} + } + button (class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"){"Login to your account"} + div (class="text-sm font-medium text-gray-500 dark:text-gray-300"){ + a (class="text-blue-700 hover:underline dark:text-blue-500"){"Create account"} + } + } + } + } + } + } +} + +#[derive(Serialize, Deserialize, Clone, ReactiveState)] +#[rx(alias = "LoginFormStateRx")] +struct LoginFormState { + username: String, + password: String, +} + +#[derive(Clone)] +pub struct LoginFormProps { + pub remember_me: bool, + pub endpoint: String, + pub lost_password_url: Option, + pub forgot_password_url: Option, +} + +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: "".to_string(), + password: "".to_string(), + } +} diff --git a/src/capsules/mod.rs b/src/capsules/mod.rs new file mode 100644 index 0000000..1f54584 --- /dev/null +++ b/src/capsules/mod.rs @@ -0,0 +1 @@ +pub mod login_form; diff --git a/src/components/layout.rs b/src/components/layout.rs index 2558932..e7a814e 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -1,4 +1,7 @@ -use crate::templates::global_state::{AppStateRx, LoginState}; +use crate::{ + capsules::login_form::{LoginFormProps, LOGIN_FORM}, + templates::global_state::{AppStateRx, LoginState}, +}; use perseus::prelude::*; use sycamore::prelude::*; use web_sys::Event; @@ -46,6 +49,7 @@ pub fn Layout<'a, G: Html>( div(class = "text-gray-700 text-2xl font-semibold py-2") { "Pool Elo - Season 1" } + div(class = "flex-1 py-2") {( match *global_state.auth.state.get() { LoginState::NotAuthenticated => { @@ -83,49 +87,14 @@ pub fn Layout<'a, G: Html>( ( match *global_state.auth.state.get() { LoginState::Authenticated => { view! { cx, - - - - div (class="overflow-x-hidden overflow-y-auto fixed h-modal md:h-full top-4 left-0 right-0 md:inset-0 z-50 justify-center items-center"){ - div (class="relative w-full max-w-md px-4 h-full md:h-auto") { - - div (class="bg-white rounded-lg shadow relative dark:bg-gray-700"){ - - div (class="flex justify-end p-2"){ - button (class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-800 dark:hover:text-white"){ - "Back" - } - } - - form (class="space-y-6 px-6 lg:px-8 pb-4 sm:pb-6 xl:pb-8") { - h3 (class="text-xl font-medium text-gray-900 dark:text-white"){"Sign in to our platform"} - div { - label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300") {"Your email"} - input (class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white") {} - } - div { - label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Your password"} - input (class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} - } - div (class="flex justify-between"){ - div (class="flex items-start"){ - div (class="flex items-center h-5"){ - input (class="bg-gray-50 border border-gray-300 focus:ring-3 focus:ring-blue-300 h-4 w-4 rounded dark:bg-gray-600 dark:border-gray-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800") {} - } - div (class="text-sm ml-3"){ - label (class="font-medium text-gray-900 dark:text-gray-300"){"Remember me"} - } - } - a (class="text-sm text-blue-700 hover:underline dark:text-blue-500"){"Lost Password?"} - } - button (class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"){"Login to your account"} - div (class="text-sm font-medium text-gray-500 dark:text-gray-300"){ - a (class="text-blue-700 hover:underline dark:text-blue-500"){"Create account"} - } - } - } - } - } + (LOGIN_FORM.widget(cx, "", + LoginFormProps{ + remember_me: true, + endpoint: "".to_string(), + lost_password_url: Some("".to_string()), + forgot_password_url: Some("".to_string()) + }) + ) }}, _ => { view! { cx, div {} } }}) diff --git a/src/main.rs b/src/main.rs index 8d738bf..f09b598 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod capsules; mod components; mod endpoints; #[allow(unused_imports)] @@ -61,6 +62,7 @@ pub fn main() -> PerseusApp { .template(crate::templates::add_game_form::get_template()) .template(crate::templates::one_v_one_board::get_template()) .template(crate::templates::overall_board::get_template()) + .capsule_ref(&*crate::capsules::login_form::LOGIN_FORM) .error_views(crate::error_views::get_error_views()) .index_view(|cx| { view! { cx, From 462ca81a1572de0c30a132b265def5b4142a6e7d Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Sun, 25 Aug 2024 14:07:27 -0400 Subject: [PATCH 15/22] notwork - login form capsule with rcsignal --- Cargo.toml | 1 - src/capsules/login_form.rs | 46 +++++++++----- src/components/header.rs | 103 +++++++++++++++++++++++++++++++ src/components/layout.rs | 79 ++++-------------------- src/components/mod.rs | 1 + src/main.rs | 1 + src/state_enums.rs | 22 +++++++ src/templates/add_game_form.rs | 4 +- src/templates/global_state.rs | 9 +-- src/templates/index.rs | 4 +- src/templates/one_v_one_board.rs | 4 +- src/templates/overall_board.rs | 6 +- 12 files changed, 180 insertions(+), 100 deletions(-) create mode 100644 src/components/header.rs create mode 100644 src/state_enums.rs diff --git a/Cargo.toml b/Cargo.toml index 416cea4..ed3d193 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,6 @@ once_cell = "1.18.0" web-sys = "0.3.64" cfg-if = "1.0.0" chrono = { version = "0.4.38", features = ["serde", "wasm-bindgen"] } -axum-login = "0.15.3" password-auth = "1.0.0" lazy_static = "1.5" diff --git a/src/capsules/login_form.rs b/src/capsules/login_form.rs index 7849a39..d669b7d 100644 --- a/src/capsules/login_form.rs +++ b/src/capsules/login_form.rs @@ -2,24 +2,53 @@ use lazy_static::lazy_static; use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; +use web_sys::Event; + +use crate::state_enums::OpenState; lazy_static! { pub static ref LOGIN_FORM: Capsule = get_capsule(); } +#[derive(Serialize, Deserialize, Clone, ReactiveState)] +#[rx(alias = "LoginFormStateRx")] +struct LoginFormState { + username: String, + password: String, +} + +#[derive(Clone)] +pub struct LoginFormProps { + pub open_state: RcSignal, + pub remember_me: bool, + pub endpoint: String, + pub lost_password_url: Option, + pub forgot_password_url: Option, +} + #[auto_scope] fn login_form_capsule( cx: Scope, state: &LoginFormStateRx, props: LoginFormProps, ) -> View { + let close_modal = move |_event: Event| { + #[cfg(client)] + { + let open_state = props.open_state.clone(); + spawn_local_scoped(cx, async move { + open_state.set(OpenState::Closed); + }); + } + }; + view! { cx, div (class="overflow-x-hidden overflow-y-auto fixed h-modal md:h-full top-4 left-0 right-0 md:inset-0 z-50 justify-center items-center"){ div (class="relative w-full max-w-md px-4 h-full md:h-auto") { div (class="bg-white rounded-lg shadow relative dark:bg-gray-700"){ div (class="flex justify-end p-2"){ - button (class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-800 dark:hover:text-white"){ + button (on:click = close_modal, class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-800 dark:hover:text-white"){ "Back" } } @@ -55,21 +84,6 @@ fn login_form_capsule( } } -#[derive(Serialize, Deserialize, Clone, ReactiveState)] -#[rx(alias = "LoginFormStateRx")] -struct LoginFormState { - username: String, - password: String, -} - -#[derive(Clone)] -pub struct LoginFormProps { - pub remember_me: bool, - pub endpoint: String, - pub lost_password_url: Option, - pub forgot_password_url: Option, -} - pub fn get_capsule() -> Capsule { Capsule::build(Template::build("login_form").build_state_fn(get_build_state)) .empty_fallback() diff --git a/src/components/header.rs b/src/components/header.rs new file mode 100644 index 0000000..940d689 --- /dev/null +++ b/src/components/header.rs @@ -0,0 +1,103 @@ +use perseus::prelude::*; +use sycamore::prelude::*; +use web_sys::Event; + +use crate::{ + capsules::login_form::{LoginFormProps, LOGIN_FORM}, + state_enums::{GameState, LoginState, OpenState}, + templates::global_state::AppStateRx, +}; + +#[derive(Prop)] +pub struct HeaderProps<'a> { + pub game: GameState, + pub title: &'a str, +} + +#[component] +pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderProps<'a>) -> View { + // Get global state to get authentication info + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + // Create signal for opening/closing the login modal + let login_modal_state = create_rc_signal(OpenState::Closed); + + let handle_log_in = { + let login_modal_state = login_modal_state.clone(); + move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + login_modal_state.set(OpenState::Open); + }); + } + } + }; + + view! { cx, + header { + div (class = "flex items-center justify-between w-full md:text-center h-20") { + div(class = "flex-1") {} + + // Title + div(class = "text-gray-700 text-2xl font-semibold py-2") { + "Pool Elo - Season 1" + } + + // Login / register or user buttons + div(class = "flex-1 py-2") {( + match *global_state.auth.state.get() { + LoginState::NotAuthenticated => { + view! { cx, + button(class = "text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700") { + "Register" + } + button(on:click = handle_log_in,class = "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800") { + "Login" + } + } + } + LoginState::Authenticated => { + view! { cx, + div { + "Hello {username}!" + } + } + } + // Will only appear for a few seconds + LoginState::Unknown => { + view! { cx, + div (class = "px-5 py-2.5 me-2 mb-2"){ + "Loading..." + } + } + }, + }) + } + } + } + + section { + div(class = "flex-1 py-2") {( + match *login_modal_state.get() { + OpenState::Open => { + let login_modal_state = login_modal_state.clone(); + view! { cx, + (LOGIN_FORM.widget(cx, "", + LoginFormProps{ + open_state: login_modal_state.clone(), + remember_me: true, + endpoint: "".to_string(), + lost_password_url: Some("".to_string()), + forgot_password_url: Some("".to_string()), + } + )) + } + } + OpenState::Closed => { + view!{ cx, } + } + }) + } + } + } +} diff --git a/src/components/layout.rs b/src/components/layout.rs index e7a814e..6c5604e 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -1,6 +1,8 @@ use crate::{ capsules::login_form::{LoginFormProps, LOGIN_FORM}, - templates::global_state::{AppStateRx, LoginState}, + components::header::{Header, HeaderProps}, + state_enums::{GameState, LoginState}, + templates::global_state::AppStateRx, }; use perseus::prelude::*; use sycamore::prelude::*; @@ -8,7 +10,8 @@ use web_sys::Event; #[derive(Prop)] pub struct LayoutProps<'a, G: Html> { - pub _title: &'a str, + pub game: GameState, + pub title: &'a str, pub children: Children<'a, G>, } @@ -18,86 +21,26 @@ pub struct LayoutProps<'a, G: Html> { pub fn Layout<'a, G: Html>( cx: Scope<'a>, LayoutProps { - _title: _, + game, + title, children, }: LayoutProps<'a, G>, ) -> View { let children = children.call(cx); + // Get global state to get authentication info + #[cfg(client)] let global_state = Reactor::::from_cx(cx).get_global_state::(cx); // Check if the client is authenticated or not #[cfg(client)] global_state.auth.detect_state(); - // TODO -> move into function - 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.auth.state.set(LoginState::Authenticated); - }); - } - }; - view! { cx, - // Main page header - header { - div (class = "flex items-center justify-between w-full md:text-center h-20") { - div(class = "flex-1") {} - div(class = "text-gray-700 text-2xl font-semibold py-2") { - "Pool Elo - Season 1" - } - - div(class = "flex-1 py-2") {( - match *global_state.auth.state.get() { - LoginState::NotAuthenticated => { - view! { cx, - button(class = "text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700") { - "Register" - } - button(on:click = handle_log_in,class = "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800") { - "Login" - } - } - } - LoginState::Authenticated => { - view! { cx, - div { - "Hello {username}!" - } - } - } - // Will only appear for a few seconds - LoginState::Unknown => { - view! { cx, - div (class = "px-5 py-2.5 me-2 mb-2"){ - "Loading..." - } - } - }, - }) - } - } - } + // Main page header, including login functionality + Header(game = game, title = title) main(style = "my-8") { - - ( - match *global_state.auth.state.get() { - LoginState::Authenticated => { view! { cx, - (LOGIN_FORM.widget(cx, "", - LoginFormProps{ - remember_me: true, - endpoint: "".to_string(), - lost_password_url: Some("".to_string()), - forgot_password_url: Some("".to_string()) - }) - ) - }}, - _ => { view! { cx, div {} } }}) - // Body header div { div (class = "container mx-auto px-6 py-3") { diff --git a/src/components/mod.rs b/src/components/mod.rs index dd64619..f70771d 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1 +1,2 @@ +mod header; pub mod layout; diff --git a/src/main.rs b/src/main.rs index f09b598..e37f41d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod entity; mod error_views; #[cfg(engine)] mod server; +mod state_enums; mod templates; use perseus::prelude::*; diff --git a/src/state_enums.rs b/src/state_enums.rs new file mode 100644 index 0000000..028a43b --- /dev/null +++ b/src/state_enums.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone)] +pub enum LoginState { + Authenticated, + NotAuthenticated, + Unknown, +} + +#[derive(Serialize, Deserialize, Clone)] +pub enum GameState { + None, + Pool, + Pickleball, + TableTennis, +} + +#[derive(Serialize, Deserialize, Clone)] +pub enum OpenState { + Open, + Closed, +} diff --git a/src/templates/add_game_form.rs b/src/templates/add_game_form.rs index 673bfed..a02d1a1 100644 --- a/src/templates/add_game_form.rs +++ b/src/templates/add_game_form.rs @@ -1,4 +1,4 @@ -use crate::components::layout::Layout; +use crate::{components::layout::Layout, state_enums::GameState}; use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; @@ -40,7 +40,7 @@ fn add_game_form_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStat }; view! { cx, - Layout(_title = "Add Game Results") { + Layout(title = "Add Game Results", game = GameState::Pool) { div (class = "flex flex-wrap") { select { option (value="red") diff --git a/src/templates/global_state.rs b/src/templates/global_state.rs index 7c71814..bff16af 100644 --- a/src/templates/global_state.rs +++ b/src/templates/global_state.rs @@ -3,6 +3,8 @@ use perseus::{prelude::*, state::GlobalStateCreator}; use serde::{Deserialize, Serialize}; +use crate::state_enums::LoginState; + cfg_if::cfg_if! { if #[cfg(engine)] { @@ -16,13 +18,6 @@ pub struct AppState { pub auth: AuthData, } -#[derive(Serialize, Deserialize, Clone)] -pub enum LoginState { - Authenticated, - NotAuthenticated, - Unknown, -} - #[derive(Serialize, Deserialize, ReactiveState, Clone)] #[rx(alias = "AuthDataRx")] pub struct AuthData { diff --git a/src/templates/index.rs b/src/templates/index.rs index 0228503..dd25099 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::GameState}; use perseus::prelude::*; use sycamore::prelude::*; fn index_page(cx: Scope) -> View { view! { cx, - Layout(_title = "Index") { + Layout(title = "Index", game = GameState::Pool) { // Anything we put in here will be rendered inside the `
` block of the layout p { "Hello World!" } br {} diff --git a/src/templates/one_v_one_board.rs b/src/templates/one_v_one_board.rs index 4a31bec..f4b5954 100644 --- a/src/templates/one_v_one_board.rs +++ b/src/templates/one_v_one_board.rs @@ -1,4 +1,4 @@ -use crate::components::layout::Layout; +use crate::{components::layout::Layout, state_enums::GameState}; use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; @@ -9,7 +9,7 @@ struct PageState {} fn one_v_one_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, _state: &'a PageStateRx) -> View { view! { cx, - Layout(_title = "1v1 Leaderboard") { + Layout(title = "1v1 Leaderboard", game = GameState::Pool) { p { "leaderboard" } } } diff --git a/src/templates/overall_board.rs b/src/templates/overall_board.rs index 8c4f887..cf60a2b 100644 --- a/src/templates/overall_board.rs +++ b/src/templates/overall_board.rs @@ -1,4 +1,6 @@ -use crate::{components::layout::Layout, templates::global_state::AppStateRx}; +use crate::{ + components::layout::Layout, state_enums::GameState, templates::global_state::AppStateRx, +}; use perseus::prelude::*; use serde::{Deserialize, Serialize}; @@ -12,7 +14,7 @@ fn overall_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, _state: &'a PageSta let _global_state = Reactor::::from_cx(cx).get_global_state::(cx); view! { cx, - Layout(_title = "Overall Leaderboard") { + Layout(title = "Overall Leaderboard", game = GameState::Pool) { ul { (View::new_fragment( vec![], From 99b4d9af1a85846e66e73427c3fc44646735f371 Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Sun, 25 Aug 2024 16:36:13 -0400 Subject: [PATCH 16/22] Moved modal state into global state unfortunate but easiest way --- src/capsules/login_form.rs | 12 +++----- src/components/header.rs | 58 ++++++++++++++++------------------- src/templates/global_state.rs | 13 +++++++- 3 files changed, 43 insertions(+), 40 deletions(-) diff --git a/src/capsules/login_form.rs b/src/capsules/login_form.rs index d669b7d..5f765fe 100644 --- a/src/capsules/login_form.rs +++ b/src/capsules/login_form.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use sycamore::prelude::*; use web_sys::Event; -use crate::state_enums::OpenState; +use crate::{state_enums::OpenState, templates::global_state::AppStateRx}; lazy_static! { pub static ref LOGIN_FORM: Capsule = get_capsule(); @@ -19,7 +19,6 @@ struct LoginFormState { #[derive(Clone)] pub struct LoginFormProps { - pub open_state: RcSignal, pub remember_me: bool, pub endpoint: String, pub lost_password_url: Option, @@ -35,17 +34,16 @@ fn login_form_capsule( let close_modal = move |_event: Event| { #[cfg(client)] { - let open_state = props.open_state.clone(); spawn_local_scoped(cx, async move { - open_state.set(OpenState::Closed); + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + global_state.modals_open.login.set(OpenState::Closed) }); } }; - view! { - cx, + view! { cx, div (class="overflow-x-hidden overflow-y-auto fixed h-modal md:h-full top-4 left-0 right-0 md:inset-0 z-50 justify-center items-center"){ - div (class="relative w-full max-w-md px-4 h-full md:h-auto") { + div (class="relative md:mx-auto w-full md:w-1/2 lg:w-1/3 z-0 my-10") { div (class="bg-white rounded-lg shadow relative dark:bg-gray-700"){ div (class="flex justify-end p-2"){ button (on:click = close_modal, class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-800 dark:hover:text-white"){ diff --git a/src/components/header.rs b/src/components/header.rs index 940d689..3b495ae 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use perseus::prelude::*; use sycamore::prelude::*; use web_sys::Event; @@ -18,18 +20,14 @@ pub struct HeaderProps<'a> { pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderProps<'a>) -> View { // Get global state to get authentication info let global_state = Reactor::::from_cx(cx).get_global_state::(cx); - // Create signal for opening/closing the login modal - let login_modal_state = create_rc_signal(OpenState::Closed); - let handle_log_in = { - let login_modal_state = login_modal_state.clone(); - move |_event: Event| { - #[cfg(client)] - { - spawn_local_scoped(cx, async move { - login_modal_state.set(OpenState::Open); - }); - } + 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); + }); } }; @@ -76,28 +74,24 @@ pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderPro } } - section { - div(class = "flex-1 py-2") {( - match *login_modal_state.get() { - OpenState::Open => { - let login_modal_state = login_modal_state.clone(); - view! { cx, - (LOGIN_FORM.widget(cx, "", - LoginFormProps{ - open_state: login_modal_state.clone(), - remember_me: true, - endpoint: "".to_string(), - lost_password_url: Some("".to_string()), - forgot_password_url: Some("".to_string()), - } - )) - } + section(class = "flex-2") { + (match *global_state.modals_open.login.get() { + OpenState::Open => { + view! { cx, + (LOGIN_FORM.widget(cx, "", + LoginFormProps{ + remember_me: true, + endpoint: "".to_string(), + lost_password_url: Some("".to_string()), + forgot_password_url: Some("".to_string()), + } + )) } - OpenState::Closed => { - view!{ cx, } - } - }) - } + } + OpenState::Closed => { + view!{ cx, } + } + }) } } } diff --git a/src/templates/global_state.rs b/src/templates/global_state.rs index bff16af..b96d6ec 100644 --- a/src/templates/global_state.rs +++ b/src/templates/global_state.rs @@ -3,7 +3,7 @@ use perseus::{prelude::*, state::GlobalStateCreator}; use serde::{Deserialize, Serialize}; -use crate::state_enums::LoginState; +use crate::state_enums::{LoginState, OpenState}; cfg_if::cfg_if! { if #[cfg(engine)] { @@ -16,6 +16,8 @@ cfg_if::cfg_if! { pub struct AppState { #[rx(nested)] pub auth: AuthData, + #[rx(nested)] + pub modals_open: ModalOpenData, } #[derive(Serialize, Deserialize, ReactiveState, Clone)] @@ -26,6 +28,12 @@ pub struct AuthData { pub claims: Claims, } +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "ModalOpenDataRx")] +pub struct ModalOpenData { + pub login: OpenState, +} + #[derive(Serialize, Deserialize, ReactiveState, Clone)] #[rx(alias = "ClaimsRx")] pub struct Claims {} @@ -42,6 +50,9 @@ pub async fn get_build_state() -> AppState { username: None, claims: Claims {}, }, + modals_open: ModalOpenData { + login: OpenState::Closed, + }, } } From 0f20ba3b8607278a56d56252252fa1a68a53aec1 Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Mon, 26 Aug 2024 00:09:08 -0400 Subject: [PATCH 17/22] Added basic logging in Needs a lot of work --- Cargo.toml | 2 +- src/capsules/login_form.rs | 69 +++++++++++++++++++++++++++++----- src/components/header.rs | 8 ++-- src/endpoints.rs | 4 +- src/main.rs | 1 + src/models/auth.rs | 23 ++++++++++++ src/models/mod.rs | 1 + src/server/auth/login.rs | 63 +++++++++++++++++++++++++++++++ src/server/auth/mod.rs | 1 + src/server/mod.rs | 1 + src/server/routes.rs | 27 ++++--------- src/templates/add_game_form.rs | 5 +-- src/templates/global_state.rs | 14 ++++--- 13 files changed, 174 insertions(+), 45 deletions(-) create mode 100644 src/models/auth.rs create mode 100644 src/models/mod.rs create mode 100644 src/server/auth/login.rs create mode 100644 src/server/auth/mod.rs diff --git a/Cargo.toml b/Cargo.toml index ed3d193..796ec1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ cfg-if = "1.0.0" chrono = { version = "0.4.38", features = ["serde", "wasm-bindgen"] } password-auth = "1.0.0" lazy_static = "1.5" - +jsonwebtoken = "9" [target.'cfg(engine)'.dev-dependencies] fantoccini = "0.19" diff --git a/src/capsules/login_form.rs b/src/capsules/login_form.rs index 5f765fe..fd4d121 100644 --- a/src/capsules/login_form.rs +++ b/src/capsules/login_form.rs @@ -1,10 +1,25 @@ +use std::num::NonZeroU16; + use lazy_static::lazy_static; use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; use web_sys::Event; -use crate::{state_enums::OpenState, templates::global_state::AppStateRx}; +use crate::{ + endpoints::LOGIN, + state_enums::OpenState, + templates::{get_api_path, global_state::AppStateRx}, +}; + +cfg_if::cfg_if! { + if #[cfg(client)] { + use crate::{ + models::auth::{LoginInfo, LoginResponse}, + }; + use reqwest::StatusCode; + } +} lazy_static! { pub static ref LOGIN_FORM: Capsule = get_capsule(); @@ -15,6 +30,7 @@ lazy_static! { struct LoginFormState { username: String, password: String, + remember_me: bool, } #[derive(Clone)] @@ -41,6 +57,38 @@ fn login_form_capsule( } }; + let handle_log_in = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let login_info = LoginInfo { + username: state.username.get().as_ref().clone(), + password: state.password.get().as_ref().clone(), + remember_me: state.remember_me.get().as_ref().clone(), + }; + + // // @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 { + state.username.set(response.status().to_string()); + return; + } + + let response = response.json::().await.unwrap(); + state.username.set(response.token.clone()); + }); + } + }; + view! { cx, div (class="overflow-x-hidden overflow-y-auto fixed h-modal md:h-full top-4 left-0 right-0 md:inset-0 z-50 justify-center items-center"){ div (class="relative md:mx-auto w-full md:w-1/2 lg:w-1/3 z-0 my-10") { @@ -50,20 +98,20 @@ fn login_form_capsule( "Back" } } - form (class="space-y-6 px-6 lg:px-8 pb-4 sm:pb-6 xl:pb-8") { + div (class="space-y-6 px-6 lg:px-8 pb-4 sm:pb-6 xl:pb-8") { h3 (class="text-xl font-medium text-gray-900 dark:text-white"){"Sign in to our platform"} div { - label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300") {"Your email"} - input (class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white") {} + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300") {"Username"} + input (bind:value = state.username, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white") {} } div { - label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Your password"} - input (class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Password"} + input (bind:value = state.password, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} } div (class="flex justify-between"){ div (class="flex items-start"){ div (class="flex items-center h-5"){ - input (class="bg-gray-50 border border-gray-300 focus:ring-3 focus:ring-blue-300 h-4 w-4 rounded dark:bg-gray-600 dark:border-gray-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800") {} + input (bind:checked = state.remember_me, type = "checkbox", class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600") {} } div (class="text-sm ml-3"){ label (class="font-medium text-gray-900 dark:text-gray-300"){"Remember me"} @@ -71,7 +119,7 @@ fn login_form_capsule( } a (class="text-sm text-blue-700 hover:underline dark:text-blue-500"){"Lost Password?"} } - button (class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"){"Login to your account"} + button (on:click = handle_log_in, class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"){"Log in"} div (class="text-sm font-medium text-gray-500 dark:text-gray-300"){ a (class="text-blue-700 hover:underline dark:text-blue-500"){"Create account"} } @@ -92,7 +140,8 @@ pub fn get_capsule() -> Capsule { #[engine_only_fn] async fn get_build_state(_info: StateGeneratorInfo<()>) -> LoginFormState { LoginFormState { - username: "".to_string(), - password: "".to_string(), + username: "".to_owned(), + password: "".to_owned(), + remember_me: false, } } diff --git a/src/components/header.rs b/src/components/header.rs index 3b495ae..7fcf713 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -6,6 +6,8 @@ use web_sys::Event; use crate::{ capsules::login_form::{LoginFormProps, LOGIN_FORM}, + endpoints::LOGIN, + models::auth::LoginInfo, state_enums::{GameState, LoginState, OpenState}, templates::global_state::AppStateRx, }; @@ -81,9 +83,9 @@ pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderPro (LOGIN_FORM.widget(cx, "", LoginFormProps{ remember_me: true, - endpoint: "".to_string(), - lost_password_url: Some("".to_string()), - forgot_password_url: Some("".to_string()), + endpoint: "".to_owned(), + lost_password_url: Some("".to_owned()), + forgot_password_url: Some("".to_owned()), } )) } diff --git a/src/endpoints.rs b/src/endpoints.rs index 139ed38..33ed856 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -1,2 +1,2 @@ -pub const MATCH: &str = "/api/post-match"; -pub const USER: &str = "/api/post-user"; +pub const LOGIN: &str = "/api/login"; +pub const LOGIN_TEST: &str = "/api/login-test"; diff --git a/src/main.rs b/src/main.rs index e37f41d..e762c56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod endpoints; #[allow(unused_imports)] mod entity; mod error_views; +mod models; #[cfg(engine)] mod server; mod state_enums; diff --git a/src/models/auth.rs b/src/models/auth.rs new file mode 100644 index 0000000..6ca5771 --- /dev/null +++ b/src/models/auth.rs @@ -0,0 +1,23 @@ +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, +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/src/server/auth/login.rs b/src/server/auth/login.rs new file mode 100644 index 0000000..aec843e --- /dev/null +++ b/src/server/auth/login.rs @@ -0,0 +1,63 @@ +use crate::models::auth::{Claims, LoginInfo, LoginResponse}; +use axum::{ + extract::Json, + http::{HeaderMap, StatusCode}, +}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; + +pub fn is_valid_user(username: &str, password: &str) -> bool { + return true; +} + +pub async fn post_login_user( + Json(login_info): Json, +) -> Result, StatusCode> { + let user_authenticated = is_valid_user(&login_info.username, &login_info.password); + + match user_authenticated { + false => Err(StatusCode::UNAUTHORIZED), + true => { + 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 Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + + let resp = LoginResponse { token, expires }; + Ok(Json(resp)) + } + } +} + +pub async fn post_test_login(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 + match decode::( + &token, + &DecodingKey::from_secret("secret".as_ref()), + &Validation::default(), + ) { + Ok(_) => return Ok(Json("Logged in".to_owned())), + Err(_) => {} + } + } + } + } + Err(StatusCode::UNAUTHORIZED) +} diff --git a/src/server/auth/mod.rs b/src/server/auth/mod.rs new file mode 100644 index 0000000..320cbbb --- /dev/null +++ b/src/server/auth/mod.rs @@ -0,0 +1 @@ +pub mod login; diff --git a/src/server/mod.rs b/src/server/mod.rs index 6a664ab..474f258 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1 +1,2 @@ +pub mod auth; pub mod routes; diff --git a/src/server/routes.rs b/src/server/routes.rs index b65cb22..2fbccdc 100644 --- a/src/server/routes.rs +++ b/src/server/routes.rs @@ -1,24 +1,11 @@ // (Server only) Routes -use crate::{ - endpoints::{MATCH, USER}, - entity::{game, user}, -}; -use axum::{ - extract::Json, - routing::{post, Router}, -}; +use crate::endpoints::{LOGIN, LOGIN_TEST}; +use axum::routing::{post, Router}; + +use super::auth::login::{post_login_user, post_test_login}; pub fn register_routes(app: Router) -> Router { - let app = app.route(USER, post(post_user)); - app.route(MATCH, post(post_match)) -} - -async fn post_user(_user: String) -> Json { - // Update the store with the new match - todo!() -} - -async fn post_match(_user: String) -> Json { - // Update the store with the new match - todo!() + let app = app.route(LOGIN, post(post_login_user)); + let app = app.route(LOGIN_TEST, post(post_test_login)); + app } diff --git a/src/templates/add_game_form.rs b/src/templates/add_game_form.rs index a02d1a1..da566c3 100644 --- a/src/templates/add_game_form.rs +++ b/src/templates/add_game_form.rs @@ -7,7 +7,6 @@ use web_sys::Event; cfg_if::cfg_if! { if #[cfg(client)] { use crate::templates::global_state::AppStateRx; - use crate::endpoints::{MATCH, USER}; use crate::templates::get_api_path; use chrono::Utc; } @@ -85,8 +84,8 @@ async fn get_request_state( _req: Request, ) -> Result> { Ok(PageState { - winner: "Ferris".to_string(), - new_user: "newguy".to_string(), + winner: "Ferris".to_owned(), + new_user: "newguy".to_owned(), }) } diff --git a/src/templates/global_state.rs b/src/templates/global_state.rs index b96d6ec..fb2190c 100644 --- a/src/templates/global_state.rs +++ b/src/templates/global_state.rs @@ -3,7 +3,10 @@ use perseus::{prelude::*, state::GlobalStateCreator}; use serde::{Deserialize, Serialize}; -use crate::state_enums::{LoginState, OpenState}; +use crate::{ + models::auth::Claims, + state_enums::{LoginState, OpenState}, +}; cfg_if::cfg_if! { if #[cfg(engine)] { @@ -34,10 +37,6 @@ pub struct ModalOpenData { pub login: OpenState, } -#[derive(Serialize, Deserialize, ReactiveState, Clone)] -#[rx(alias = "ClaimsRx")] -pub struct Claims {} - pub fn get_global_state_creator() -> GlobalStateCreator { GlobalStateCreator::new().build_state_fn(get_build_state) } @@ -48,7 +47,10 @@ pub async fn get_build_state() -> AppState { auth: AuthData { state: LoginState::Unknown, username: None, - claims: Claims {}, + claims: Claims { + sub: "".to_owned(), + exp: 0, + }, }, modals_open: ModalOpenData { login: OpenState::Closed, From 65d47615daf7b17dd8fdcb0c970cff770fda7d02 Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Mon, 26 Aug 2024 17:43:08 -0400 Subject: [PATCH 18/22] Got log in and out working, moved global state close to done! --- Cargo.toml | 5 +- src/capsules/login_form.rs | 32 ++++++--- src/components/header.rs | 20 +++++- src/components/layout.rs | 2 +- src/global_state.rs | 115 +++++++++++++++++++++++++++++++++ src/main.rs | 3 +- src/models/auth.rs | 10 +++ src/templates/add_game_form.rs | 2 +- src/templates/global_state.rs | 72 --------------------- src/templates/mod.rs | 2 - src/templates/overall_board.rs | 4 +- 11 files changed, 172 insertions(+), 95 deletions(-) create mode 100644 src/global_state.rs delete mode 100644 src/templates/global_state.rs diff --git a/Cargo.toml b/Cargo.toml index 796ec1c..3ce9d44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,12 +16,12 @@ serde_json = "1" 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", "wasm-bindgen"] } password-auth = "1.0.0" lazy_static = "1.5" -jsonwebtoken = "9" + [target.'cfg(engine)'.dev-dependencies] fantoccini = "0.19" @@ -37,6 +37,7 @@ sea-orm = { version = "1.0", features = [ "macros", "with-chrono", ] } +jsonwebtoken = "9.3.0" [target.'cfg(client)'.dependencies] wasm-bindgen = "0.2.93" diff --git a/src/capsules/login_form.rs b/src/capsules/login_form.rs index fd4d121..58c6d72 100644 --- a/src/capsules/login_form.rs +++ b/src/capsules/login_form.rs @@ -1,4 +1,4 @@ -use std::num::NonZeroU16; +use std::arch::global_asm; use lazy_static::lazy_static; use perseus::prelude::*; @@ -6,16 +6,15 @@ use serde::{Deserialize, Serialize}; use sycamore::prelude::*; use web_sys::Event; -use crate::{ - endpoints::LOGIN, - state_enums::OpenState, - templates::{get_api_path, global_state::AppStateRx}, -}; - cfg_if::cfg_if! { if #[cfg(client)] { use crate::{ models::auth::{LoginInfo, LoginResponse}, + endpoints::LOGIN, + state_enums::{LoginState, OpenState}, + templates::{get_api_path}, + global_state::{self, AppStateRx}, + models::auth::WebAuthInfo, }; use reqwest::StatusCode; } @@ -61,10 +60,12 @@ fn login_form_capsule( #[cfg(client)] { spawn_local_scoped(cx, async move { + let remember_me = state.remember_me.get().as_ref().clone(); + let username = state.username.get().as_ref().clone(); let login_info = LoginInfo { - username: state.username.get().as_ref().clone(), + username: username.clone(), password: state.password.get().as_ref().clone(), - remember_me: state.remember_me.get().as_ref().clone(), + remember_me, }; // // @todo clean up error handling @@ -79,12 +80,23 @@ fn login_form_capsule( let global_state = Reactor::::from_cx(cx).get_global_state::(cx); if response.status() != StatusCode::OK { + // todo update to some type of alert state.username.set(response.status().to_string()); return; } let response = response.json::().await.unwrap(); - state.username.set(response.token.clone()); + + // 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 + global_state.modals_open.login.set(OpenState::Closed); }); } }; diff --git a/src/components/header.rs b/src/components/header.rs index 7fcf713..149840b 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -7,9 +7,9 @@ use web_sys::Event; use crate::{ capsules::login_form::{LoginFormProps, LOGIN_FORM}, endpoints::LOGIN, + global_state::AppStateRx, models::auth::LoginInfo, state_enums::{GameState, LoginState, OpenState}, - templates::global_state::AppStateRx, }; #[derive(Prop)] @@ -33,6 +33,16 @@ pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderPro } }; + 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 { div (class = "flex items-center justify-between w-full md:text-center h-20") { @@ -52,14 +62,18 @@ pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderPro "Register" } button(on:click = handle_log_in,class = "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800") { - "Login" + "Log in" } } } LoginState::Authenticated => { view! { cx, div { - "Hello {username}!" + "Hello " + (global_state.auth.username.get().as_ref().clone().unwrap_or("".to_owned())) + } + button(on:click = handle_log_out, class = "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800") { + "Log out" } } } diff --git a/src/components/layout.rs b/src/components/layout.rs index 6c5604e..e9bcc36 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -1,8 +1,8 @@ use crate::{ capsules::login_form::{LoginFormProps, LOGIN_FORM}, components::header::{Header, HeaderProps}, + global_state::AppStateRx, state_enums::{GameState, LoginState}, - templates::global_state::AppStateRx, }; use perseus::prelude::*; use sycamore::prelude::*; diff --git a/src/global_state.rs b/src/global_state.rs new file mode 100644 index 0000000..2fe2a33 --- /dev/null +++ b/src/global_state.rs @@ -0,0 +1,115 @@ +// Not a page, global state that is shared between all pages + +use perseus::{prelude::*, state::GlobalStateCreator}; +use serde::{Deserialize, Serialize}; + +use crate::{ + models::auth::{Claims, WebAuthInfo}, + state_enums::{LoginState, OpenState}, +}; + +cfg_if::cfg_if! { + if #[cfg(engine)] { + + } +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "AppStateRx")] +pub struct AppState { + #[rx(nested)] + pub auth: AuthData, + #[rx(nested)] + pub modals_open: ModalOpenData, +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "AuthDataRx")] +pub struct AuthData { + pub state: LoginState, + pub username: Option, + pub remember_me: Option, + pub auth_info: Option, +} + +impl AuthDataRx { + 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 token to session storage + self.username.set(Some(auth_info.username.clone())); + self.remember_me.set(Some(auth_info.remember_me.clone())); + self.auth_info.set(Some(auth_info)); + self.state.set(LoginState::Authenticated); + } + + 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(); + // Update state + self.auth_info.set(None); + self.username.set(None); + self.remember_me.set(None); + self.state.set(LoginState::NotAuthenticated); + } +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "ModalOpenDataRx")] +pub struct ModalOpenData { + pub login: OpenState, +} + +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, + username: None, + remember_me: None, + auth_info: None, + }, + modals_open: ModalOpenData { + login: OpenState::Closed, + }, + } +} + +// 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 => { + self.state.set(LoginState::NotAuthenticated); + } + } + } +} diff --git a/src/main.rs b/src/main.rs index e762c56..6b96339 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod endpoints; #[allow(unused_imports)] mod entity; mod error_views; +mod global_state; mod models; #[cfg(engine)] mod server; @@ -59,7 +60,7 @@ 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::add_game_form::get_template()) .template(crate::templates::one_v_one_board::get_template()) diff --git a/src/models/auth.rs b/src/models/auth.rs index 6ca5771..66ca6b0 100644 --- a/src/models/auth.rs +++ b/src/models/auth.rs @@ -21,3 +21,13 @@ 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, +} diff --git a/src/templates/add_game_form.rs b/src/templates/add_game_form.rs index da566c3..95d682e 100644 --- a/src/templates/add_game_form.rs +++ b/src/templates/add_game_form.rs @@ -6,7 +6,7 @@ use web_sys::Event; cfg_if::cfg_if! { if #[cfg(client)] { - use crate::templates::global_state::AppStateRx; + use crate::global_state::AppStateRx; use crate::templates::get_api_path; use chrono::Utc; } diff --git a/src/templates/global_state.rs b/src/templates/global_state.rs deleted file mode 100644 index fb2190c..0000000 --- a/src/templates/global_state.rs +++ /dev/null @@ -1,72 +0,0 @@ -// Not a page, global state that is shared between all pages - -use perseus::{prelude::*, state::GlobalStateCreator}; -use serde::{Deserialize, Serialize}; - -use crate::{ - models::auth::Claims, - state_enums::{LoginState, OpenState}, -}; - -cfg_if::cfg_if! { - if #[cfg(engine)] { - - } -} - -#[derive(Serialize, Deserialize, ReactiveState, Clone)] -#[rx(alias = "AppStateRx")] -pub struct AppState { - #[rx(nested)] - pub auth: AuthData, - #[rx(nested)] - pub modals_open: ModalOpenData, -} - -#[derive(Serialize, Deserialize, ReactiveState, Clone)] -#[rx(alias = "AuthDataRx")] -pub struct AuthData { - pub state: LoginState, - pub username: Option, - pub claims: Claims, -} - -#[derive(Serialize, Deserialize, ReactiveState, Clone)] -#[rx(alias = "ModalOpenDataRx")] -pub struct ModalOpenData { - pub login: OpenState, -} - -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, - username: None, - claims: Claims { - sub: "".to_owned(), - exp: 0, - }, - }, - modals_open: ModalOpenData { - login: OpenState::Closed, - }, - } -} - -// 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 -> Get state from storage - self.state.set(LoginState::NotAuthenticated); - } -} diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 8e589c5..a7a618f 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -1,5 +1,4 @@ pub mod add_game_form; -pub mod global_state; pub mod index; pub mod one_v_one_board; pub mod overall_board; @@ -7,7 +6,6 @@ pub mod overall_board; #[cfg(client)] use perseus::utils::get_path_prefix_client; -#[allow(dead_code)] pub fn get_api_path(path: &str) -> String { #[cfg(engine)] { diff --git a/src/templates/overall_board.rs b/src/templates/overall_board.rs index cf60a2b..0401a5a 100644 --- a/src/templates/overall_board.rs +++ b/src/templates/overall_board.rs @@ -1,6 +1,4 @@ -use crate::{ - components::layout::Layout, state_enums::GameState, templates::global_state::AppStateRx, -}; +use crate::{components::layout::Layout, global_state::AppStateRx, state_enums::GameState}; use perseus::prelude::*; use serde::{Deserialize, Serialize}; From e376874afad28cd5a91831c661d376f69ab043a5 Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Mon, 26 Aug 2024 19:44:52 -0400 Subject: [PATCH 19/22] Clean up login form, add files for others --- src/capsules/forgot_password_form.rs | 0 src/capsules/login_form.rs | 24 +++++++++++---------- src/capsules/mod.rs | 2 ++ src/capsules/register_form.rs | 0 src/components/header.rs | 3 --- src/global_state.rs | 31 +++++++++++++++++++++++++++- 6 files changed, 45 insertions(+), 15 deletions(-) create mode 100644 src/capsules/forgot_password_form.rs create mode 100644 src/capsules/register_form.rs diff --git a/src/capsules/forgot_password_form.rs b/src/capsules/forgot_password_form.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/capsules/login_form.rs b/src/capsules/login_form.rs index 58c6d72..ba91059 100644 --- a/src/capsules/login_form.rs +++ b/src/capsules/login_form.rs @@ -35,9 +35,6 @@ struct LoginFormState { #[derive(Clone)] pub struct LoginFormProps { pub remember_me: bool, - pub endpoint: String, - pub lost_password_url: Option, - pub forgot_password_url: Option, } #[auto_scope] @@ -121,14 +118,19 @@ fn login_form_capsule( input (bind:value = state.password, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} } div (class="flex justify-between"){ - div (class="flex items-start"){ - div (class="flex items-center h-5"){ - input (bind:checked = state.remember_me, type = "checkbox", class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600") {} - } - div (class="text-sm ml-3"){ - label (class="font-medium text-gray-900 dark:text-gray-300"){"Remember me"} - } - } + (match props.remember_me { + true => { view!{ cx, + div (class="flex items-start"){ + div (class="flex items-center h-5"){ + input (bind:checked = state.remember_me, type = "checkbox", class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600") {} + } + } + div (class="text-sm ml-3"){ + label (class="font-medium text-gray-900 dark:text-gray-300"){"Remember me"} + } + }}, + false => view!{cx, }, + }) a (class="text-sm text-blue-700 hover:underline dark:text-blue-500"){"Lost Password?"} } button (on:click = handle_log_in, class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"){"Log in"} diff --git a/src/capsules/mod.rs b/src/capsules/mod.rs index 1f54584..d85fc52 100644 --- a/src/capsules/mod.rs +++ b/src/capsules/mod.rs @@ -1 +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..e69de29 diff --git a/src/components/header.rs b/src/components/header.rs index 149840b..eef573b 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -97,9 +97,6 @@ pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderPro (LOGIN_FORM.widget(cx, "", LoginFormProps{ remember_me: true, - endpoint: "".to_owned(), - lost_password_url: Some("".to_owned()), - forgot_password_url: Some("".to_owned()), } )) } diff --git a/src/global_state.rs b/src/global_state.rs index 2fe2a33..c466e36 100644 --- a/src/global_state.rs +++ b/src/global_state.rs @@ -41,6 +41,14 @@ impl AuthDataRx { 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())); @@ -55,6 +63,12 @@ impl AuthDataRx { 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); @@ -108,7 +122,22 @@ impl AuthDataRx { self.handle_log_in(auth_info); } None => { - self.state.set(LoginState::NotAuthenticated); + // 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); + } + } } } } From 242f9b1218b29f65ee75e7feaa14589d20fcf1bb Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Mon, 26 Aug 2024 20:15:48 -0400 Subject: [PATCH 20/22] Add initial forgot password form --- src/capsules/forgot_password_form.rs | 120 +++++++++++++++++++++++++++ src/capsules/login_form.rs | 38 +++++++-- src/components/header.rs | 17 +++- src/global_state.rs | 2 + src/main.rs | 1 + 5 files changed, 170 insertions(+), 8 deletions(-) diff --git a/src/capsules/forgot_password_form.rs b/src/capsules/forgot_password_form.rs index e69de29..fa7c7cf 100644 --- a/src/capsules/forgot_password_form.rs +++ b/src/capsules/forgot_password_form.rs @@ -0,0 +1,120 @@ +use lazy_static::lazy_static; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; +use web_sys::Event; + +cfg_if::cfg_if! { + if #[cfg(client)] { + use crate::{ + state_enums::{ OpenState}, + templates::{get_api_path}, + global_state::{self, AppStateRx}, + }; + 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, +} + +impl ForgotPasswordFormStateRx { + #[cfg(client)] + fn reset(&self) { + self.username.set(String::new()); + self.how_to_reach.set(String::new()); + } +} + +#[derive(Clone)] +pub struct ForgotPasswordFormProps {} + +#[auto_scope] +fn forgot_password_form_capsule( + cx: Scope, + state: &ForgotPasswordFormStateRx, + _props: ForgotPasswordFormProps, +) -> View { + 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 + .forgot_password + .set(OpenState::Closed) + }); + } + }; + let handle_submit = 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); + }); + } + }; + + view! { cx, + div (class="overflow-x-hidden overflow-y-auto fixed h-modal md:h-full top-4 left-0 right-0 md:inset-0 z-50 justify-center items-center"){ + div (class="relative md:mx-auto w-full md:w-1/2 lg:w-1/3 z-0 my-10") { + div (class="bg-white rounded-lg shadow relative dark:bg-gray-700"){ + div (class="flex justify-end p-2"){ + button (on:click = close_modal, class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-800 dark:hover:text-white"){ + "Back" + } + } + div (class="space-y-6 px-6 lg:px-8 pb-4 sm:pb-6 xl:pb-8") { + h3 (class="text-xl font-medium text-gray-900 dark:text-white"){"Sign in to our platform"} + div { + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300") {"Username"} + input (bind:value = state.username, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white") {} + } + div { + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"How to contact you with new password"} + input (bind:value = state.how_to_reach, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} + } + + button (on:click = handle_submit, class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"){"Log in"} + div (class="text-sm font-medium text-gray-500 dark:text-gray-300"){ + a (class="text-blue-700 hover:underline dark:text-blue-500"){"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: "".to_owned(), + how_to_reach: "".to_owned(), + } +} diff --git a/src/capsules/login_form.rs b/src/capsules/login_form.rs index ba91059..414b4de 100644 --- a/src/capsules/login_form.rs +++ b/src/capsules/login_form.rs @@ -1,5 +1,3 @@ -use std::arch::global_asm; - use lazy_static::lazy_static; use perseus::prelude::*; use serde::{Deserialize, Serialize}; @@ -14,7 +12,7 @@ cfg_if::cfg_if! { state_enums::{LoginState, OpenState}, templates::{get_api_path}, global_state::{self, AppStateRx}, - models::auth::WebAuthInfo, + models::auth::WebAuthInfo, }; use reqwest::StatusCode; } @@ -32,6 +30,15 @@ struct LoginFormState { remember_me: bool, } +impl LoginFormStateRx { + #[cfg(client)] + fn reset(&self) { + self.username.set(String::new()); + self.password.set(String::new()); + self.remember_me.set(false); + } +} + #[derive(Clone)] pub struct LoginFormProps { pub remember_me: bool, @@ -53,6 +60,22 @@ fn login_form_capsule( } }; + 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); + 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)] { @@ -93,6 +116,7 @@ fn login_form_capsule( }); // Close modal + state.reset(); global_state.modals_open.login.set(OpenState::Closed); }); } @@ -124,14 +148,14 @@ fn login_form_capsule( div (class="flex items-center h-5"){ input (bind:checked = state.remember_me, type = "checkbox", class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600") {} } - } - div (class="text-sm ml-3"){ - label (class="font-medium text-gray-900 dark:text-gray-300"){"Remember me"} + div (class="text-sm ml-3"){ + label (class="font-medium text-gray-900 dark:text-gray-300"){"Remember me"} + } } }}, false => view!{cx, }, }) - a (class="text-sm text-blue-700 hover:underline dark:text-blue-500"){"Lost Password?"} + a (on:click = handle_forgot_password, class="text-sm text-blue-700 hover:underline dark:text-blue-500"){"Lost Password?"} } button (on:click = handle_log_in, class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"){"Log in"} div (class="text-sm font-medium text-gray-500 dark:text-gray-300"){ diff --git a/src/components/header.rs b/src/components/header.rs index eef573b..cb862bc 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -5,7 +5,10 @@ use sycamore::prelude::*; use web_sys::Event; use crate::{ - capsules::login_form::{LoginFormProps, LOGIN_FORM}, + capsules::{ + forgot_password_form::{ForgotPasswordFormProps, FORGOT_PASSWORD_FORM}, + login_form::{LoginFormProps, LOGIN_FORM}, + }, endpoints::LOGIN, global_state::AppStateRx, models::auth::LoginInfo, @@ -105,6 +108,18 @@ pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderPro view!{ cx, } } }) + (match *global_state.modals_open.forgot_password.get() { + OpenState::Open => { + view! { cx, + (FORGOT_PASSWORD_FORM.widget(cx, "", + ForgotPasswordFormProps{} + )) + } + } + OpenState::Closed => { + view!{ cx, } + } + }) } } } diff --git a/src/global_state.rs b/src/global_state.rs index c466e36..f25fb3e 100644 --- a/src/global_state.rs +++ b/src/global_state.rs @@ -81,6 +81,7 @@ impl AuthDataRx { #[rx(alias = "ModalOpenDataRx")] pub struct ModalOpenData { pub login: OpenState, + pub forgot_password: OpenState, } pub fn get_global_state_creator() -> GlobalStateCreator { @@ -98,6 +99,7 @@ pub async fn get_build_state() -> AppState { }, modals_open: ModalOpenData { login: OpenState::Closed, + forgot_password: OpenState::Closed, }, } } diff --git a/src/main.rs b/src/main.rs index 6b96339..39df0d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,7 @@ pub fn main() -> PerseusApp { .template(crate::templates::one_v_one_board::get_template()) .template(crate::templates::overall_board::get_template()) .capsule_ref(&*crate::capsules::login_form::LOGIN_FORM) + .capsule_ref(&*crate::capsules::forgot_password_form::FORGOT_PASSWORD_FORM) .error_views(crate::error_views::get_error_views()) .index_view(|cx| { view! { cx, From f4f491085da38f646beff7f3547d54612c7dfa41 Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Tue, 27 Aug 2024 02:12:57 -0400 Subject: [PATCH 21/22] Add database to server endpoints, move modals, add forget pw to db --- .../src/m20240813_000001_create_users.rs | 3 ++ src/capsules/forgot_password_form.rs | 11 +++-- src/capsules/login_form.rs | 17 +++++++- src/capsules/register_form.rs | 1 + src/components/header.rs | 29 ------------- src/components/layout.rs | 41 ++++++++++++++++--- src/endpoints.rs | 2 + src/entity/user.rs | 1 + src/global_state.rs | 2 + src/main.rs | 29 ++++++++----- src/models/auth.rs | 6 +++ src/server/auth/forgot_password.rs | 13 ++++++ src/server/auth/login.rs | 13 ++++-- src/server/auth/mod.rs | 1 + src/server/mod.rs | 1 + src/server/routes.rs | 22 +++++++--- src/server/server_state.rs | 6 +++ 17 files changed, 137 insertions(+), 61 deletions(-) create mode 100644 src/server/auth/forgot_password.rs create mode 100644 src/server/server_state.rs diff --git a/migration/src/m20240813_000001_create_users.rs b/migration/src/m20240813_000001_create_users.rs index dbfe201..1c6dcd2 100644 --- a/migration/src/m20240813_000001_create_users.rs +++ b/migration/src/m20240813_000001_create_users.rs @@ -9,6 +9,7 @@ pub struct Migration; 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() @@ -22,6 +23,7 @@ impl MigrationTrait for Migration { .col(boolean(User::IsAdmin)) .col(string_null(User::Email)) .col(string_null(User::Avatar)) + .col(string_null(User::ForgotPasswordRequest)) .to_owned(), ) .await @@ -46,4 +48,5 @@ pub enum User { IsAdmin, Email, Avatar, + ForgotPasswordRequest, } diff --git a/src/capsules/forgot_password_form.rs b/src/capsules/forgot_password_form.rs index fa7c7cf..5efb6c8 100644 --- a/src/capsules/forgot_password_form.rs +++ b/src/capsules/forgot_password_form.rs @@ -49,6 +49,8 @@ fn forgot_password_form_capsule( { 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 @@ -83,20 +85,17 @@ fn forgot_password_form_capsule( } } div (class="space-y-6 px-6 lg:px-8 pb-4 sm:pb-6 xl:pb-8") { - h3 (class="text-xl font-medium text-gray-900 dark:text-white"){"Sign in to our platform"} + h3 (class="text-xl font-medium text-gray-900 dark:text-white"){"Forgot Password"} div { label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300") {"Username"} input (bind:value = state.username, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white") {} } div { - label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"How to contact you with new password"} + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Contact Info"} input (bind:value = state.how_to_reach, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} } - button (on:click = handle_submit, class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"){"Log in"} - div (class="text-sm font-medium text-gray-500 dark:text-gray-300"){ - a (class="text-blue-700 hover:underline dark:text-blue-500"){"Submit"} - } + button (on:click = handle_submit, class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"){"Submit"} } } } diff --git a/src/capsules/login_form.rs b/src/capsules/login_form.rs index 414b4de..b0ab949 100644 --- a/src/capsules/login_form.rs +++ b/src/capsules/login_form.rs @@ -76,6 +76,19 @@ fn login_form_capsule( } }; + 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); + // Close modal + state.reset(); + global_state.modals_open.login.set(OpenState::Closed); + }); + } + }; + let handle_log_in = move |_event: Event| { #[cfg(client)] { @@ -155,11 +168,11 @@ fn login_form_capsule( }}, false => view!{cx, }, }) - a (on:click = handle_forgot_password, class="text-sm text-blue-700 hover:underline dark:text-blue-500"){"Lost Password?"} + button (on:click = handle_forgot_password, class="text-sm text-blue-700 hover:underline dark:text-blue-500"){"Lost Password?"} } button (on:click = handle_log_in, class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"){"Log in"} div (class="text-sm font-medium text-gray-500 dark:text-gray-300"){ - a (class="text-blue-700 hover:underline dark:text-blue-500"){"Create account"} + button (on:click = handle_register, class="text-blue-700 hover:underline dark:text-blue-500"){"Create account"} } } } diff --git a/src/capsules/register_form.rs b/src/capsules/register_form.rs index e69de29..8b13789 100644 --- a/src/capsules/register_form.rs +++ b/src/capsules/register_form.rs @@ -0,0 +1 @@ + diff --git a/src/components/header.rs b/src/components/header.rs index cb862bc..4d12dc4 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -92,34 +92,5 @@ pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderPro } } } - - 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.forgot_password.get() { - OpenState::Open => { - view! { cx, - (FORGOT_PASSWORD_FORM.widget(cx, "", - ForgotPasswordFormProps{} - )) - } - } - OpenState::Closed => { - view!{ cx, } - } - }) - } } } diff --git a/src/components/layout.rs b/src/components/layout.rs index e9bcc36..4870289 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -1,8 +1,11 @@ use crate::{ - capsules::login_form::{LoginFormProps, LOGIN_FORM}, + capsules::{ + forgot_password_form::{ForgotPasswordFormProps, FORGOT_PASSWORD_FORM}, + login_form::{LoginFormProps, LOGIN_FORM}, + }, components::header::{Header, HeaderProps}, global_state::AppStateRx, - state_enums::{GameState, LoginState}, + state_enums::{GameState, LoginState, OpenState}, }; use perseus::prelude::*; use sycamore::prelude::*; @@ -28,8 +31,6 @@ pub fn Layout<'a, G: Html>( ) -> View { let children = children.call(cx); - // Get global state to get authentication info - #[cfg(client)] let global_state = Reactor::::from_cx(cx).get_global_state::(cx); // Check if the client is authenticated or not @@ -40,6 +41,36 @@ pub fn Layout<'a, G: Html>( // Main page header, including login functionality Header(game = game, title = title) + // 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.forgot_password.get() { + OpenState::Open => { + view! { cx, + (FORGOT_PASSWORD_FORM.widget(cx, "", + ForgotPasswordFormProps{} + )) + } + } + OpenState::Closed => { + view!{ cx, } + } + }) + } + main(style = "my-8") { // Body header div { @@ -59,7 +90,7 @@ pub fn Layout<'a, G: Html>( } } } - // Actual body + // 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") { diff --git a/src/endpoints.rs b/src/endpoints.rs index 33ed856..87f1288 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -1,2 +1,4 @@ +pub const REGISTER: &str = "/api/login"; pub const LOGIN: &str = "/api/login"; pub const LOGIN_TEST: &str = "/api/login-test"; +pub const FORGOT_PASSWORD: &str = "/api/forgot-password"; diff --git a/src/entity/user.rs b/src/entity/user.rs index 82cc0ed..6bb30a3 100644 --- a/src/entity/user.rs +++ b/src/entity/user.rs @@ -16,6 +16,7 @@ pub struct Model { pub is_admin: bool, pub email: Option, pub avatar: Option, + pub forgot_password_request: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/global_state.rs b/src/global_state.rs index f25fb3e..98c0472 100644 --- a/src/global_state.rs +++ b/src/global_state.rs @@ -81,6 +81,7 @@ impl AuthDataRx { #[rx(alias = "ModalOpenDataRx")] pub struct ModalOpenData { pub login: OpenState, + pub register: OpenState, pub forgot_password: OpenState, } @@ -99,6 +100,7 @@ pub async fn get_build_state() -> AppState { }, modals_open: ModalOpenData { login: OpenState::Closed, + register: OpenState::Closed, forgot_password: OpenState::Closed, }, } diff --git a/src/main.rs b/src/main.rs index 39df0d3..6c81df4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,9 +23,10 @@ cfg_if::cfg_if! { stores::MutableStore, turbine::Turbine, }; + use crate::server::routes::get_api_router; + use crate::server::server_state::ServerState; use futures::executor::block_on; - use sea_orm::{Database}; - use crate::server::routes::register_routes; + use sea_orm::Database; } } @@ -38,16 +39,24 @@ pub async fn dflt_server Update to use environment variable - if let Err(err) = block_on(Database::connect( - "postgres://elo:elo@localhost:5432/elo_app", - )) { - panic!("{}", err); - } + // TODO -> error handling + // Includes making database connection + let db_conn = Database::connect("postgres://elo:elo@localhost:5432/elo_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()) diff --git a/src/models/auth.rs b/src/models/auth.rs index 66ca6b0..cc7349f 100644 --- a/src/models/auth.rs +++ b/src/models/auth.rs @@ -31,3 +31,9 @@ pub struct WebAuthInfo { pub username: String, pub remember_me: bool, } + +#[derive(Serialize, Deserialize, Clone)] +pub struct ForgotPasswordRequest { + pub username: String, + pub contact_info: String, +} diff --git a/src/server/auth/forgot_password.rs b/src/server/auth/forgot_password.rs new file mode 100644 index 0000000..0df2656 --- /dev/null +++ b/src/server/auth/forgot_password.rs @@ -0,0 +1,13 @@ +use crate::{models::auth::ForgotPasswordRequest, server::server_state::ServerState}; +use axum::{ + extract::{Json, State}, + http::{HeaderMap, StatusCode}, +}; +use sea_orm::DatabaseConnection; + +pub async fn post_forgot_password( + State(state): State, + Json(password_request): Json, +) -> StatusCode { + StatusCode::OK +} diff --git a/src/server/auth/login.rs b/src/server/auth/login.rs index aec843e..c687a47 100644 --- a/src/server/auth/login.rs +++ b/src/server/auth/login.rs @@ -1,6 +1,9 @@ -use crate::models::auth::{Claims, LoginInfo, LoginResponse}; +use crate::{ + models::auth::{Claims, LoginInfo, LoginResponse}, + server::server_state::ServerState, +}; use axum::{ - extract::Json, + extract::{Json, State}, http::{HeaderMap, StatusCode}, }; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; @@ -10,6 +13,7 @@ pub fn is_valid_user(username: &str, password: &str) -> bool { } pub async fn post_login_user( + State(state): State, Json(login_info): Json, ) -> Result, StatusCode> { let user_authenticated = is_valid_user(&login_info.username, &login_info.password); @@ -42,7 +46,10 @@ pub async fn post_login_user( } } -pub async fn post_test_login(header_map: HeaderMap) -> Result, StatusCode> { +pub async fn post_test_login( + State(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 ") { diff --git a/src/server/auth/mod.rs b/src/server/auth/mod.rs index 320cbbb..85b2611 100644 --- a/src/server/auth/mod.rs +++ b/src/server/auth/mod.rs @@ -1 +1,2 @@ +pub mod forgot_password; pub mod login; diff --git a/src/server/mod.rs b/src/server/mod.rs index 474f258..a3505e9 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,2 +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 2fbccdc..baf4a77 100644 --- a/src/server/routes.rs +++ b/src/server/routes.rs @@ -1,11 +1,21 @@ // (Server only) Routes -use crate::endpoints::{LOGIN, LOGIN_TEST}; +use crate::endpoints::{FORGOT_PASSWORD, LOGIN, LOGIN_TEST}; use axum::routing::{post, Router}; +use futures::executor::block_on; +use sea_orm::Database; -use super::auth::login::{post_login_user, post_test_login}; +use super::{ + auth::{ + forgot_password::post_forgot_password, + login::{post_login_user, post_test_login}, + }, + server_state::ServerState, +}; -pub fn register_routes(app: Router) -> Router { - let app = app.route(LOGIN, post(post_login_user)); - let app = app.route(LOGIN_TEST, post(post_test_login)); - app +pub fn get_api_router(state: ServerState) -> Router { + Router::new() + .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, +} From 5af626b746697d943ec89ddbd794a77c1f65fffa Mon Sep 17 00:00:00 2001 From: Matthew Kaminski Date: Wed, 28 Aug 2024 16:53:08 -0400 Subject: [PATCH 22/22] Add basic register and login --- Cargo.toml | 2 +- .../src/m20240813_000001_create_users.rs | 14 +- src/capsules/login_form.rs | 4 +- src/capsules/register_form.rs | 180 ++++++++++++++++++ src/components/header.rs | 14 +- src/components/layout.rs | 17 ++ src/endpoints.rs | 2 +- src/entity/game.rs | 2 +- src/entity/game_to_team_result.rs | 2 +- src/entity/mod.rs | 2 +- src/entity/prelude.rs | 2 +- src/entity/sea_orm_active_enums.rs | 2 +- src/entity/team_result.rs | 2 +- src/entity/team_result_to_user.rs | 2 +- src/entity/user.rs | 11 +- src/main.rs | 1 + src/models/auth.rs | 9 + src/models/generic.rs | 19 ++ src/models/mod.rs | 1 + src/server/auth/login.rs | 46 ++++- src/server/auth/mod.rs | 1 + src/server/auth/register.rs | 89 +++++++++ src/server/routes.rs | 4 +- 23 files changed, 397 insertions(+), 31 deletions(-) create mode 100644 src/models/generic.rs create mode 100644 src/server/auth/register.rs diff --git a/Cargo.toml b/Cargo.toml index 3ce9d44..365ac8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,6 @@ once_cell = "1.18.0" web-sys = { version = "0.3.64", features = ["Window", "Storage"] } cfg-if = "1.0.0" chrono = { version = "0.4.38", features = ["serde", "wasm-bindgen"] } -password-auth = "1.0.0" lazy_static = "1.5" [target.'cfg(engine)'.dev-dependencies] @@ -38,6 +37,7 @@ sea-orm = { version = "1.0", features = [ "with-chrono", ] } jsonwebtoken = "9.3.0" +argon2 = "0.5" [target.'cfg(client)'.dependencies] wasm-bindgen = "0.2.93" diff --git a/migration/src/m20240813_000001_create_users.rs b/migration/src/m20240813_000001_create_users.rs index 1c6dcd2..d0edd7d 100644 --- a/migration/src/m20240813_000001_create_users.rs +++ b/migration/src/m20240813_000001_create_users.rs @@ -15,11 +15,11 @@ impl MigrationTrait for Migration { Table::create() .table(User::Table) .col(pk_auto(User::Id)) - .col(string(User::Username)) - .col(string(User::Password)) - .col(string(User::Salt)) - .col(timestamp_with_time_zone(User::CreationTime)) - .col(timestamp_with_time_zone(User::LastActiveTime)) + .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)) @@ -41,8 +41,8 @@ pub enum User { Table, Id, Username, - Password, - Salt, + PasswordHashAndSalt, + Nickname, CreationTime, LastActiveTime, IsAdmin, diff --git a/src/capsules/login_form.rs b/src/capsules/login_form.rs index b0ab949..dd962a7 100644 --- a/src/capsules/login_form.rs +++ b/src/capsules/login_form.rs @@ -145,14 +145,14 @@ fn login_form_capsule( } } div (class="space-y-6 px-6 lg:px-8 pb-4 sm:pb-6 xl:pb-8") { - h3 (class="text-xl font-medium text-gray-900 dark:text-white"){"Sign in to our platform"} + h3 (class="text-xl font-medium text-gray-900 dark:text-white"){"Sign in"} div { label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300") {"Username"} input (bind:value = state.username, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white") {} } div { label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Password"} - input (bind:value = state.password, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} + input (bind:value = state.password, type = "password", class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} } div (class="flex justify-between"){ (match props.remember_me { diff --git a/src/capsules/register_form.rs b/src/capsules/register_form.rs index 8b13789..9190576 100644 --- a/src/capsules/register_form.rs +++ b/src/capsules/register_form.rs @@ -1 +1,181 @@ +use lazy_static::lazy_static; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; +use web_sys::Event; +cfg_if::cfg_if! { + if #[cfg(client)] { + use crate::{ + models::auth::{RegisterRequest}, + endpoints::REGISTER, + state_enums::{LoginState, OpenState}, + templates::{get_api_path}, + global_state::{self, AppStateRx}, + models::auth::WebAuthInfo, + }; + 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, +} + +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()); + } +} + +#[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); + + if response.status() != StatusCode::OK { + // todo update to some type of alert + state.username.set(response.status().to_string()); + return; + } + + // Open login modal + global_state.modals_open.login.set(OpenState::Open); + state.reset(); + + // Close modal + state.reset(); + global_state.modals_open.register.set(OpenState::Closed); + }); + } + }; + + view! { cx, + div (class="overflow-x-hidden overflow-y-auto fixed h-modal md:h-full top-4 left-0 right-0 md:inset-0 z-50 justify-center items-center"){ + div (class="relative md:mx-auto w-full md:w-1/2 lg:w-1/3 z-0 my-10") { + div (class="bg-white rounded-lg shadow relative dark:bg-gray-700"){ + div (class="flex justify-end p-2"){ + button (on:click = close_modal, class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-800 dark:hover:text-white"){ + "Back" + } + } + div (class="space-y-6 px-6 lg:px-8 pb-4 sm:pb-6 xl:pb-8") { + h3 (class="text-xl font-medium text-gray-900 dark:text-white"){"Register"} + div { + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300") {"Username"} + input (bind:value = state.username, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white") {} + } + div { + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Password"} + input (bind:value = state.password, type = "password", class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} + } + (match props.registration_code { + true => { view!{cx, + div { + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Registration code"} + input (bind:value = state.registration_code, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} + } + }}, + false => {view!{cx,}}, + }) + (match props.nickname { + true => { view!{cx, + div { + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Nickname (optional)"} + input (bind:value = state.nickname, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} + } + }}, + false => {view!{cx,}}, + }) + (match props.email { + true => { view!{cx, + div { + label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Email (optional)"} + input (bind:value = state.email, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){} + } + }}, + false => {view!{cx,}}, + }) + button (on:click = handle_register, class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"){"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(), + nickname: String::new(), + registration_code: String::new(), + email: String::new(), + } +} diff --git a/src/components/header.rs b/src/components/header.rs index 4d12dc4..9f0560c 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -36,6 +36,16 @@ pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderPro } }; + 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)] { @@ -61,10 +71,10 @@ pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderPro match *global_state.auth.state.get() { LoginState::NotAuthenticated => { view! { cx, - button(class = "text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700") { + button(on:click = handle_register, class = "text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700") { "Register" } - button(on:click = handle_log_in,class = "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800") { + button(on:click = handle_log_in, class = "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800") { "Log in" } } diff --git a/src/components/layout.rs b/src/components/layout.rs index 4870289..7734ca2 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -2,6 +2,7 @@ use crate::{ capsules::{ forgot_password_form::{ForgotPasswordFormProps, FORGOT_PASSWORD_FORM}, login_form::{LoginFormProps, LOGIN_FORM}, + register_form::{RegisterFormProps, REGISTER_FORM}, }, components::header::{Header, HeaderProps}, global_state::AppStateRx, @@ -57,6 +58,22 @@ pub fn Layout<'a, G: Html>( 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, diff --git a/src/endpoints.rs b/src/endpoints.rs index 87f1288..0b90741 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -1,4 +1,4 @@ -pub const REGISTER: &str = "/api/login"; +pub const REGISTER: &str = "/api/register"; pub const LOGIN: &str = "/api/login"; pub const LOGIN_TEST: &str = "/api/login-test"; pub const FORGOT_PASSWORD: &str = "/api/forgot-password"; diff --git a/src/entity/game.rs b/src/entity/game.rs index bd93b2a..18c89a5 100644 --- a/src/entity/game.rs +++ b/src/entity/game.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 use super::sea_orm_active_enums::GameType; use sea_orm::entity::prelude::*; diff --git a/src/entity/game_to_team_result.rs b/src/entity/game_to_team_result.rs index 2aab72e..52040a4 100644 --- a/src/entity/game_to_team_result.rs +++ b/src/entity/game_to_team_result.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; diff --git a/src/entity/mod.rs b/src/entity/mod.rs index 51b1436..8a33116 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 pub mod prelude; diff --git a/src/entity/prelude.rs b/src/entity/prelude.rs index d4c75d9..6f116a3 100644 --- a/src/entity/prelude.rs +++ b/src/entity/prelude.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 pub use super::game::Entity as Game; pub use super::game_to_team_result::Entity as GameToTeamResult; diff --git a/src/entity/sea_orm_active_enums.rs b/src/entity/sea_orm_active_enums.rs index f3a45b7..25dba9d 100644 --- a/src/entity/sea_orm_active_enums.rs +++ b/src/entity/sea_orm_active_enums.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; diff --git a/src/entity/team_result.rs b/src/entity/team_result.rs index 602061a..d3bfadc 100644 --- a/src/entity/team_result.rs +++ b/src/entity/team_result.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; diff --git a/src/entity/team_result_to_user.rs b/src/entity/team_result_to_user.rs index 1d73820..7e73bed 100644 --- a/src/entity/team_result_to_user.rs +++ b/src/entity/team_result_to_user.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; diff --git a/src/entity/user.rs b/src/entity/user.rs index 6bb30a3..de767cd 100644 --- a/src/entity/user.rs +++ b/src/entity/user.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -8,11 +8,12 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key)] pub id: i32, + #[sea_orm(unique)] pub username: String, - pub password: String, - pub salt: String, - pub creation_time: DateTimeWithTimeZone, - pub last_active_time: DateTimeWithTimeZone, + 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, diff --git a/src/main.rs b/src/main.rs index 6c81df4..4477403 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,6 +76,7 @@ pub fn main() -> PerseusApp { .template(crate::templates::overall_board::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, diff --git a/src/models/auth.rs b/src/models/auth.rs index cc7349f..e1850f6 100644 --- a/src/models/auth.rs +++ b/src/models/auth.rs @@ -32,6 +32,15 @@ pub struct WebAuthInfo { 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, diff --git a/src/models/generic.rs b/src/models/generic.rs new file mode 100644 index 0000000..61938b8 --- /dev/null +++ b/src/models/generic.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone)] +pub struct GenericResponse { + pub status: String, +} + +impl GenericResponse { + pub fn ok() -> Self { + GenericResponse { + status: String::new(), + } + } + pub fn err(msg: &str) -> Self { + GenericResponse { + status: msg.to_string(), + } + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 0e4a05d..bcb51d1 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1 +1,2 @@ pub mod auth; +pub mod generic; diff --git a/src/server/auth/login.rs b/src/server/auth/login.rs index c687a47..fec2d93 100644 --- a/src/server/auth/login.rs +++ b/src/server/auth/login.rs @@ -1,24 +1,60 @@ +use crate::entity::prelude::*; +use crate::models::auth::{Claims, LoginInfo, LoginResponse}; use crate::{ - models::auth::{Claims, LoginInfo, LoginResponse}, + entity::user::{self, Entity}, + models::auth::RegisterRequest, server::server_state::ServerState, }; +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::SaltString; +use argon2::Argon2; +use argon2::PasswordHash; +use argon2::PasswordHasher; +use argon2::PasswordVerifier; use axum::{ extract::{Json, State}, http::{HeaderMap, StatusCode}, }; +use futures::sink::Fanout; +use sea_orm::ColumnTrait; +use sea_orm::EntityTrait; +use sea_orm::InsertResult; +use sea_orm::QueryFilter; +use sea_orm::Set; + use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; -pub fn is_valid_user(username: &str, password: &str) -> bool { - return true; +pub async fn credentials_are_correct(username: &str, password: &str, state: &ServerState) -> bool { + // 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 false; + } + }; + + return Argon2::default() + .verify_password( + password.as_bytes(), + &PasswordHash::new(hash_to_check.as_str()).unwrap(), + ) + .is_ok(); } pub async fn post_login_user( State(state): State, Json(login_info): Json, ) -> Result, StatusCode> { - let user_authenticated = is_valid_user(&login_info.username, &login_info.password); + let user_authenticated = + credentials_are_correct(&login_info.username, &login_info.password, &state); - match user_authenticated { + match user_authenticated.await { false => Err(StatusCode::UNAUTHORIZED), true => { let expires = match login_info.remember_me { diff --git a/src/server/auth/mod.rs b/src/server/auth/mod.rs index 85b2611..aa36f87 100644 --- a/src/server/auth/mod.rs +++ b/src/server/auth/mod.rs @@ -1,2 +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..e273a8a --- /dev/null +++ b/src/server/auth/register.rs @@ -0,0 +1,89 @@ +use crate::entity::prelude::*; +use crate::models::generic::GenericResponse; +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::SaltString; +use argon2::Argon2; +use argon2::PasswordHash; +use argon2::PasswordHasher; +use axum::{extract::State, http::StatusCode, Json}; +use chrono::Utc; +use sea_orm::ColumnTrait; +use sea_orm::EntityTrait; +use sea_orm::InsertResult; +use sea_orm::QueryFilter; +use sea_orm::Set; + +use crate::{ + entity::user::{self, Entity}, + models::auth::RegisterRequest, + server::server_state::ServerState, +}; + +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 == "" { + 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 == "" { + None + } else { + Some(register_info.email) + } + }), + avatar: Set(None), + forgot_password_request: Set(None), + ..Default::default() + }; + // TODO -> error handling + let db_resp = user::Entity::insert(new_user) + .exec(&state.db_conn) + .await + .unwrap(); + + return (StatusCode::OK, Json(GenericResponse::ok())); +} diff --git a/src/server/routes.rs b/src/server/routes.rs index baf4a77..6efbe3b 100644 --- a/src/server/routes.rs +++ b/src/server/routes.rs @@ -1,5 +1,5 @@ // (Server only) Routes -use crate::endpoints::{FORGOT_PASSWORD, LOGIN, LOGIN_TEST}; +use crate::endpoints::{FORGOT_PASSWORD, LOGIN, LOGIN_TEST, REGISTER}; use axum::routing::{post, Router}; use futures::executor::block_on; use sea_orm::Database; @@ -8,12 +8,14 @@ use super::{ auth::{ forgot_password::post_forgot_password, login::{post_login_user, post_test_login}, + register::post_register_user, }, server_state::ServerState, }; 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))