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/.github/workflows/github-actions-demo.yml b/.github/workflows/github-actions-demo.yml index 7a13c11..bdf7e78 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 @@ -42,57 +42,14 @@ 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 - - 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' + run: perseus --wasm-opt-version version_118 deploy --verbose + - name: Run clippy for server + 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 --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 c49c59c..365ac8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,10 @@ 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.31", features = ["serde"] } +chrono = { version = "0.4.38", features = ["serde", "wasm-bindgen"] } +lazy_static = "1.5" [target.'cfg(engine)'.dev-dependencies] fantoccini = "0.19" @@ -28,7 +29,20 @@ 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 = "1.0", features = [ + "sqlx-postgres", + "runtime-tokio-native-tls", + "macros", + "with-chrono", +] } +jsonwebtoken = "9.3.0" +argon2 = "0.5" [target.'cfg(client)'.dependencies] -wasm-bindgen = "0.2" +wasm-bindgen = "0.2.93" reqwest = { version = "0.11", features = ["json"] } +sea-orm = { version = "1.0" } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(engine)', 'cfg(client)'] } diff --git a/README.md b/README.md index 4266666..d894b8a 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,9 +11,11 @@ Run `curl --proto '=https' --tlsv1.3 https://sh.rustup.rs -sSf | sh` ## 2. Install npm -### Windows: +### Windows: https://nodejs.org/en +(todo look into:) +https://pnpm.io/ ### Unix based systems: @@ -23,13 +25,22 @@ 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 tailwindcss, for styling +## 4. Install docker for Postgresql + +## 5. Install SeaORM for database + +`cargo install sea-orm-cli@1.0.0-rc.5` + +## 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,11 +50,19 @@ 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 src/entity --with-serde both` + To build CSS run: `npm run build` 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/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..d0edd7d --- /dev/null +++ b/migration/src/m20240813_000001_create_users.rs @@ -0,0 +1,52 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// https://github.com/SeaQL/sea-orm/blob/368b1126f73f47c7ec30fe523834f6a0962a193b/sea-orm-migration/src/schema.rs + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // User table + // @todo verify all data saved is length-checked + manager + .create_table( + Table::create() + .table(User::Table) + .col(pk_auto(User::Id)) + .col(string_uniq(User::Username)) + .col(string(User::PasswordHashAndSalt)) + .col(string_null(User::Nickname)) + .col(timestamp(User::CreationTime)) + .col(timestamp(User::LastActiveTime)) + .col(boolean(User::IsAdmin)) + .col(string_null(User::Email)) + .col(string_null(User::Avatar)) + .col(string_null(User::ForgotPasswordRequest)) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(User::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum User { + Table, + Id, + Username, + PasswordHashAndSalt, + Nickname, + CreationTime, + LastActiveTime, + IsAdmin, + Email, + Avatar, + ForgotPasswordRequest, +} diff --git a/migration/src/m20240813_000002_create_game.rs b/migration/src/m20240813_000002_create_game.rs new file mode 100644 index 0000000..f139e01 --- /dev/null +++ b/migration/src/m20240813_000002_create_game.rs @@ -0,0 +1,163 @@ +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); + + // Game type enum + manager + .create_type(schema.create_enum_from_active_enum::()) + .await?; + // Game table + manager + .create_table( + Table::create() + .table(Game::Table) + .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(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-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_table(Table::drop().table(TeamResultToUser::Table).to_owned()) + .await?; + manager + .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_type(Type::drop().name(GameType::name()).to_owned()) + .await + } +} + +// Enums + +#[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, + GameType, +} + +#[derive(DeriveIden)] +enum TeamResult { + Table, + Id, + Place, + Score, +} + +// Assoc +#[derive(DeriveIden)] +enum GameToTeamResult { + Table, + GameId, + TeamResultId, +} + +#[derive(DeriveIden)] +enum TeamResultToUser { + Table, + TeamResultId, + UserId, +} 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..6e9af84 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,6 @@ "author": "", "license": "ISC", "devDependencies": { - "tailwindcss": "^3.3.3" + "tailwindcss": "^3.4.10" } } diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..6f79b1a --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,19 @@ +use crate::user; +use axum_login::{AuthUser, AuthnBackend, UserId}; +use serde::{Deserialize, Serialize}; + +// References +// https://github.com/maxcountryman/axum-login/tree/main/examples/sqlite/src +// https://framesurge.sh/perseus/en-US/docs/0.4.x/state/intro + +impl AuthUser for user::Model { + type Id = i32; + + fn id(&self) -> Self::Id { + self.id + } + + fn session_auth_hash(&self) -> &[u8] { + self.password.as_bytes() + } +} diff --git a/src/capsules/forgot_password_form.rs b/src/capsules/forgot_password_form.rs new file mode 100644 index 0000000..5efb6c8 --- /dev/null +++ b/src/capsules/forgot_password_form.rs @@ -0,0 +1,119 @@ +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); + + // Close modal + state.reset(); + global_state + .modals_open + .forgot_password + .set(OpenState::Closed) + }); + } + }; + let handle_submit = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let 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"){"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"){"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"){"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 new file mode 100644 index 0000000..dd962a7 --- /dev/null +++ b/src/capsules/login_form.rs @@ -0,0 +1,198 @@ +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::{LoginInfo, LoginResponse}, + endpoints::LOGIN, + state_enums::{LoginState, OpenState}, + templates::{get_api_path}, + global_state::{self, AppStateRx}, + models::auth::WebAuthInfo, + }; + use reqwest::StatusCode; + } +} + +lazy_static! { + pub static ref LOGIN_FORM: Capsule = get_capsule(); +} + +#[derive(Serialize, Deserialize, Clone, ReactiveState)] +#[rx(alias = "LoginFormStateRx")] +struct LoginFormState { + username: String, + password: String, + remember_me: bool, +} + +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, +} + +#[auto_scope] +fn login_form_capsule( + cx: Scope, + state: &LoginFormStateRx, + props: LoginFormProps, +) -> 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); + global_state.modals_open.login.set(OpenState::Closed) + }); + } + }; + + let handle_forgot_password = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + global_state + .modals_open + .forgot_password + .set(OpenState::Open); + // Close modal + state.reset(); + global_state.modals_open.login.set(OpenState::Closed); + }); + } + }; + + 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)] + { + 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: username.clone(), + password: state.password.get().as_ref().clone(), + remember_me, + }; + + // // @todo clean up error handling + let client = reqwest::Client::new(); + let response = client + .post(get_api_path(LOGIN).as_str()) + .json(&login_info) + .send() + .await + .unwrap(); + + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + + if response.status() != StatusCode::OK { + // todo update to some type of alert + state.username.set(response.status().to_string()); + return; + } + + let response = response.json::().await.unwrap(); + + // Save token to session/local storage and update state + global_state.auth.handle_log_in(WebAuthInfo { + token: response.token, + expires: response.expires, + username, + remember_me, + }); + + // Close modal + state.reset(); + global_state.modals_open.login.set(OpenState::Closed); + }); + } + }; + + view! { cx, + 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"} + 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"){} + } + div (class="flex justify-between"){ + (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, }, + }) + 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"){ + button (on:click = handle_register, class="text-blue-700 hover:underline dark:text-blue-500"){"Create account"} + } + } + } + } + } + } +} + +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_owned(), + password: "".to_owned(), + remember_me: false, + } +} diff --git a/src/capsules/mod.rs b/src/capsules/mod.rs new file mode 100644 index 0000000..d85fc52 --- /dev/null +++ b/src/capsules/mod.rs @@ -0,0 +1,3 @@ +pub mod forgot_password_form; +pub mod login_form; +pub mod register_form; diff --git a/src/capsules/register_form.rs b/src/capsules/register_form.rs new file mode 100644 index 0000000..9190576 --- /dev/null +++ b/src/capsules/register_form.rs @@ -0,0 +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 new file mode 100644 index 0000000..9f0560c --- /dev/null +++ b/src/components/header.rs @@ -0,0 +1,106 @@ +use std::sync::Arc; + +use perseus::prelude::*; +use sycamore::prelude::*; +use web_sys::Event; + +use crate::{ + capsules::{ + forgot_password_form::{ForgotPasswordFormProps, FORGOT_PASSWORD_FORM}, + login_form::{LoginFormProps, LOGIN_FORM}, + }, + endpoints::LOGIN, + global_state::AppStateRx, + models::auth::LoginInfo, + state_enums::{GameState, LoginState, OpenState}, +}; + +#[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); + + let handle_log_in = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + global_state.modals_open.login.set(OpenState::Open); + }); + } + }; + + let handle_register = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + global_state.modals_open.register.set(OpenState::Open); + }); + } + }; + + let handle_log_out = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + global_state.auth.handle_log_out(); + }); + } + }; + + view! { cx, + header { + 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(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") { + "Log in" + } + } + } + LoginState::Authenticated => { + view! { cx, + div { + "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" + } + } + } + // Will only appear for a few seconds + LoginState::Unknown => { + view! { cx, + div (class = "px-5 py-2.5 me-2 mb-2"){ + "Loading..." + } + } + }, + }) + } + } + } + } +} diff --git a/src/components/layout.rs b/src/components/layout.rs index a561828..7734ca2 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -1,44 +1,113 @@ +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, + state_enums::{GameState, LoginState, OpenState}, +}; +use perseus::prelude::*; use sycamore::prelude::*; +use web_sys::Event; #[derive(Prop)] pub struct LayoutProps<'a, G: Html> { + pub game: GameState, pub title: &'a str, pub children: Children<'a, G>, } +// Using elements from here: https://flowbite.com/docs/components/buttons/ + #[component] pub fn Layout<'a, G: Html>( cx: Scope<'a>, - LayoutProps { title: _, children }: LayoutProps<'a, G>, + LayoutProps { + game, + title, + children, + }: LayoutProps<'a, G>, ) -> View { let children = children.call(cx); - view! { cx, - header { - div (class = "flex items-center justify-between") { - div (class = "w-full text-gray-700 md:text-center text-2xl font-semibold") { - "Pool Elo - Season 1" - } - } + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); - 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" } + // Check if the client is authenticated or not + #[cfg(client)] + global_state.auth.detect_state(); + + view! { cx, + // 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.register.get() { + OpenState::Open => { + view! { cx, + (REGISTER_FORM.widget(cx, "", + RegisterFormProps{ + registration_code: true, + nickname: true, + email: true, + } + )) + } + } + OpenState::Closed => { + view!{ cx, } + } + }) + (match *global_state.modals_open.forgot_password.get() { + OpenState::Open => { + view! { cx, + (FORGOT_PASSWORD_FORM.widget(cx, "", + ForgotPasswordFormProps{} + )) + } + } + OpenState::Closed => { + view!{ cx, } + } + }) } main(style = "my-8") { + // 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" } + } + } + } + } + // 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/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/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 3ee5b55..0000000 --- a/src/data/pool_match.rs +++ /dev/null @@ -1,55 +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, -} - -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); - } -} diff --git a/src/data/store.rs b/src/data/store.rs deleted file mode 100644 index 839a49b..0000000 --- a/src/data/store.rs +++ /dev/null @@ -1,34 +0,0 @@ -// (Server only) In-memory data storage and persistent storage - -use crate::data::pool_match::PoolMatchList; -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, -} - -impl Store { - fn new() -> Store { - fs::create_dir_all("data").unwrap(); - match Path::new("data/store.json").exists() { - false => Store { - matches: PoolMatchList::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 9815690..0000000 --- a/src/data/user.rs +++ /dev/null @@ -1,3 +0,0 @@ - -pub type PlayerId = u32; - diff --git a/src/endpoints.rs b/src/endpoints.rs index 5cda3da..0b90741 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -1 +1,4 @@ -pub const MATCH: &str = "/api/post-match"; +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 new file mode 100644 index 0000000..18c89a5 --- /dev/null +++ b/src/entity/game.rs @@ -0,0 +1,37 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +use super::sea_orm_active_enums::GameType; +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 time: DateTimeWithTimeZone, + pub game_type: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::game_to_team_result::Entity")] + GameToTeamResult, +} + +impl Related for Entity { + fn to() -> RelationDef { + 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()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/game_to_team_result.rs b/src/entity/game_to_team_result.rs new file mode 100644 index 0000000..52040a4 --- /dev/null +++ b/src/entity/game_to_team_result.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 = "game_to_team_result")] +pub struct Model { + #[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)] +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::team_result::Entity", + from = "Column::TeamResultId", + to = "super::team_result::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + TeamResult, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Game.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::TeamResult.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/mod.rs b/src/entity/mod.rs new file mode 100644 index 0000000..8a33116 --- /dev/null +++ b/src/entity/mod.rs @@ -0,0 +1,10 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +pub mod prelude; + +pub mod game; +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/src/entity/sea_orm_active_enums.rs b/src/entity/sea_orm_active_enums.rs new file mode 100644 index 0000000..25dba9d --- /dev/null +++ b/src/entity/sea_orm_active_enums.rs @@ -0,0 +1,15 @@ +//! `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, +} 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..de767cd --- /dev/null +++ b/src/entity/user.rs @@ -0,0 +1,44 @@ +//! `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, + #[sea_orm(unique)] + pub username: String, + pub password_hash_and_salt: String, + pub nickname: Option, + pub creation_time: DateTime, + pub last_active_time: DateTime, + pub is_admin: bool, + pub email: Option, + pub avatar: Option, + pub forgot_password_request: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[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/global_state.rs b/src/global_state.rs new file mode 100644 index 0000000..98c0472 --- /dev/null +++ b/src/global_state.rs @@ -0,0 +1,148 @@ +// 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 into session storage always + let storage: web_sys::Storage = web_sys::window() + .unwrap() + .session_storage() + .unwrap() + .unwrap(); + let value = serde_json::to_string(&auth_info).unwrap(); + storage.set_item("auth", &value).unwrap(); + + // Save token to session storage + self.username.set(Some(auth_info.username.clone())); + self.remember_me.set(Some(auth_info.remember_me.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(); + let storage: web_sys::Storage = web_sys::window() + .unwrap() + .session_storage() + .unwrap() + .unwrap(); + storage.remove_item("auth").unwrap(); + // Update state + self.auth_info.set(None); + self.username.set(None); + self.remember_me.set(None); + self.state.set(LoginState::NotAuthenticated); + } +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "ModalOpenDataRx")] +pub struct ModalOpenData { + pub login: OpenState, + pub register: OpenState, + pub forgot_password: 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, + register: OpenState::Closed, + forgot_password: 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 => { + // Try session storage + let storage: web_sys::Storage = web_sys::window() + .unwrap() + .session_storage() + .unwrap() + .unwrap(); + let saved_auth = storage.get("auth").unwrap(); + match saved_auth { + Some(auth_info) => { + let auth_info = serde_json::from_str(&auth_info).unwrap(); + self.handle_log_in(auth_info); + } + None => { + self.state.set(LoginState::NotAuthenticated); + } + } + } + } + } +} diff --git a/src/main.rs b/src/main.rs index a5c1e32..4477403 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,14 @@ +mod capsules; mod components; -mod data; mod endpoints; +#[allow(unused_imports)] +mod entity; mod error_views; +mod global_state; +mod models; #[cfg(engine)] mod server; +mod state_enums; mod templates; use perseus::prelude::*; @@ -18,7 +23,10 @@ cfg_if::cfg_if! { stores::MutableStore, turbine::Turbine, }; - use crate::server::routes::register_routes; + use crate::server::routes::get_api_router; + use crate::server::server_state::ServerState; + use futures::executor::block_on; + use sea_orm::Database; } } @@ -31,9 +39,24 @@ pub async fn dflt_server Update to use environment variable + // TODO -> error handling + // Includes making database connection + let db_conn = Database::connect("postgres://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()) @@ -46,11 +69,14 @@ 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()) .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 new file mode 100644 index 0000000..e1850f6 --- /dev/null +++ b/src/models/auth.rs @@ -0,0 +1,48 @@ +use chrono::serde::ts_seconds; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone)] +pub struct LoginInfo { + pub username: String, + pub password: String, + pub remember_me: bool, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct LoginResponse { + pub token: String, + #[serde(with = "ts_seconds")] + pub expires: DateTime, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: String, + pub exp: usize, +} + +// For client local storage and session storage +#[derive(Serialize, Deserialize, Clone)] +pub struct WebAuthInfo { + pub token: String, + #[serde(with = "ts_seconds")] + pub expires: DateTime, + pub username: String, + pub remember_me: bool, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct RegisterRequest { + pub username: String, + pub password: String, + pub email: String, + pub nickname: String, + pub registration_code: String, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct ForgotPasswordRequest { + pub username: String, + pub contact_info: String, +} diff --git a/src/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 new file mode 100644 index 0000000..bcb51d1 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub mod generic; diff --git a/src/server/auth/forgot_password.rs b/src/server/auth/forgot_password.rs new file mode 100644 index 0000000..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 new file mode 100644 index 0000000..fec2d93 --- /dev/null +++ b/src/server/auth/login.rs @@ -0,0 +1,106 @@ +use crate::entity::prelude::*; +use crate::models::auth::{Claims, LoginInfo, LoginResponse}; +use crate::{ + 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 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 = + credentials_are_correct(&login_info.username, &login_info.password, &state); + + match user_authenticated.await { + 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( + 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 ") { + 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..aa36f87 --- /dev/null +++ b/src/server/auth/mod.rs @@ -0,0 +1,3 @@ +pub mod forgot_password; +pub mod login; +pub mod register; diff --git a/src/server/auth/register.rs b/src/server/auth/register.rs new file mode 100644 index 0000000..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/mod.rs b/src/server/mod.rs index 9bebc39..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 934c557..6efbe3b 100644 --- a/src/server/routes.rs +++ b/src/server/routes.rs @@ -1,34 +1,23 @@ // (Server only) Routes +use crate::endpoints::{FORGOT_PASSWORD, LOGIN, LOGIN_TEST, REGISTER}; +use axum::routing::{post, Router}; +use futures::executor::block_on; +use sea_orm::Database; -use crate::{ - data::{ - pool_match::{PoolMatch, PoolMatchList}, - store::DATA, +use super::{ + auth::{ + forgot_password::post_forgot_password, + login::{post_login_user, post_test_login}, + register::post_register_user, }, - endpoints::MATCH, + server_state::ServerState, }; -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)); - app -} - -async fn post_match(Json(pool_match): Json) -> 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) +pub fn get_api_router(state: ServerState) -> Router { + Router::new() + .route(REGISTER, post(post_register_user)) + .route(LOGIN, post(post_login_user)) + .route(LOGIN_TEST, post(post_test_login)) + .route(FORGOT_PASSWORD, post(post_forgot_password)) + .with_state(state) } diff --git a/src/server/server_state.rs b/src/server/server_state.rs new file mode 100644 index 0000000..b4fbed2 --- /dev/null +++ b/src/server/server_state.rs @@ -0,0 +1,6 @@ +use sea_orm::DatabaseConnection; + +#[derive(Clone)] +pub struct ServerState { + pub db_conn: DatabaseConnection, +} diff --git a/src/state_enums.rs b/src/state_enums.rs new file mode 100644 index 0000000..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 0939d14..95d682e 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, state_enums::GameState}; use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; @@ -7,9 +6,7 @@ use web_sys::Event; cfg_if::cfg_if! { if #[cfg(client)] { - use crate::data::pool_match::{PoolMatch, PoolMatchList}; - use crate::templates::global_state::AppStateRx; - use crate::endpoints::MATCH; + use crate::global_state::AppStateRx; use crate::templates::get_api_path; use chrono::Utc; } @@ -20,43 +17,37 @@ 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() - 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); - }) + // state.winner.get().as_ref().clone() + spawn_local_scoped(cx, async move {}) + } + }; + + let handle_add_user = move |_event: Event| { + #[cfg(client)] + { + // state.winner.get().as_ref().clone() + spawn_local_scoped(cx, async move {}) } }; view! { cx, - Layout(title = "Add Game Results") { + Layout(title = "Add Game Results", game = GameState::Pool) { 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 +60,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 +84,8 @@ async fn get_request_state( _req: Request, ) -> Result> { Ok(PageState { - name: "Ferris".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 deleted file mode 100644 index 4ac7502..0000000 --- a/src/templates/global_state.rs +++ /dev/null @@ -1,44 +0,0 @@ -// Not a page, global state that is shared between all pages - -use crate::data::pool_match::PoolMatchList; -use perseus::{prelude::*, state::GlobalStateCreator}; -use serde::{Deserialize, Serialize}; - -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 fn get_global_state_creator() -> GlobalStateCreator { - GlobalStateCreator::new() - .build_state_fn(get_build_state) - .request_state_fn(get_request_state) -} - -#[engine_only_fn] -fn get_state() -> AppState { - let matches = thread::spawn(move || DATA.lock().unwrap().deref().matches.clone()) - .join() - .unwrap(); - - AppState { matches } -} - -#[engine_only_fn] -pub async fn get_build_state() -> AppState { - get_state() -} - -#[engine_only_fn] -pub async fn get_request_state(_req: Request) -> AppState { - get_state() -} diff --git a/src/templates/index.rs b/src/templates/index.rs index ff585f8..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/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/one_v_one_board.rs b/src/templates/one_v_one_board.rs index cccbe82..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 5bc0f26..0401a5a 100644 --- a/src/templates/overall_board.rs +++ b/src/templates/overall_board.rs @@ -1,47 +1,21 @@ -use crate::{components::layout::Layout, templates::global_state::AppStateRx, data::user::PlayerId}; +use crate::{components::layout::Layout, global_state::AppStateRx, state_enums::GameState}; 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); + 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( - 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![], )) } }