27 Commits

Author SHA1 Message Date
79b7312896 Fixes some React warnings. 2022-06-05 16:52:30 +02:00
6efccd631d Adds register page. 2022-06-05 16:15:01 +02:00
65d2dece55 Adds an AdminPanel with currently active registration tokens. 2022-06-04 21:57:54 +02:00
34737bfb6b Updates all libraries and some cleanup in the Rust part. 2022-06-04 13:10:09 +02:00
5ace39d820 Merge pull request 'join_gamenight' (#8) from join_gamenight into main
Reviewed-on: Roflin/gamenight#8
2022-06-03 19:47:02 +02:00
b7f981e3a6 Adds the ability to join or leave a gamenight. 2022-05-31 21:27:35 +02:00
f7f9f7456b Adds a Apihelper to cleanup the react components. 2022-05-31 19:56:44 +02:00
cfaa6ebdb1 Merge pull request 'Adds some basic styling' (#7) from some-styling-work into main
Reviewed-on: Roflin/gamenight#7
2022-05-30 21:35:58 +02:00
8a318e877f Merge pull request 'gamenight-participants' (#6) from gamenight-participants into main
Reviewed-on: Roflin/gamenight#6
2022-05-30 21:32:47 +02:00
bcfcf66df5 Merge pull request 'Adds the ability to add games with suggestions from known games.' (#3) from game-adding-to-gamenight into main
Reviewed-on: Roflin/gamenight#3
2022-05-30 21:31:01 +02:00
83a0b5ad9d Adds some basic styling 2022-05-29 18:26:08 +02:00
9de8ffaa2d Some Cleaup. 2022-05-29 10:46:05 +02:00
102a3e6082 Formatting commit 2022-05-29 10:33:55 +02:00
639405bf9f Adds a details page for a single gamenight. 2022-05-29 10:33:19 +02:00
2ba2026e21 Gamenights also return their game list. 2022-05-29 10:33:19 +02:00
86cdbedd41 Adds the participants part of the API. 2022-05-29 10:33:19 +02:00
836a4ab59f Formatting commit. 2022-05-29 10:33:19 +02:00
5c27be0191 Schema rewrite to split up the schema.rs file. 2022-05-29 10:33:19 +02:00
1a6ead4760 Adds the ability to add games with suggestions from known games. 2022-05-29 10:32:20 +02:00
5ffeea6553 Merge pull request 'initial-frontend-work' (#2) from initial-frontend-work into main
Reviewed-on: Roflin/gamenight#2
2022-05-27 20:30:06 +02:00
cc26aed9a5 Reworked the database code to make use of the ? operator. 2022-05-14 23:44:40 +02:00
92e0257e74 Added gamenight owners and some ui and api functions to delete gamenights. 2022-05-14 23:36:35 +02:00
0a214ca388 Fixes the infinite loop and refactores some statechanges into useEffect hooks 2022-05-01 17:51:28 +02:00
2cfaf2b4cc Adds an add gamenight control and fixes the fetch gamenight Effect,
Introduces an infinite fetch gamenights loop
2022-04-29 22:40:10 +02:00
bf796201bf move to function based react components 2022-04-29 20:27:54 +02:00
56d0889963 A start on a frontend application in React. 2022-04-23 23:30:26 +02:00
d80f705b5d Merge pull request 'Added a user system with no proper user validation but working authorisation.' (#1) from user-system into main
Reviewed-on: Roflin/gamenight#1
2022-04-23 13:17:28 +02:00
37 changed files with 8209 additions and 4920 deletions

767
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,18 +7,20 @@ edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
rocket = { version = "0.5.0-rc.1", features = ["default", "json"] } rocket = { version = "0.5.0-rc.2", features = ["default", "json"] }
libsqlite3-sys = { version = ">=0.8.0, <0.19.0", features = ["bundled"] } rocket_sync_db_pools = { version = "0.1.0-rc.2", features = ["diesel_postgres_pool"] }
rocket_sync_db_pools = { version = "0.1.0-rc.1", features = ["diesel_sqlite_pool"] } rocket_dyn_templates = { version = "0.1.0-rc.2", features = ["handlebars"] }
diesel = { version = "1.4.8", features = ["sqlite"] } diesel = {version = "1.4.8", features = ["uuidv07", "r2d2", "postgres", "chrono"]}
diesel_migrations = "1.4.0" diesel_migrations = "1.4.0"
rocket_dyn_templates = { version = "0.1.0-rc.1", features = ["handlebars"] } diesel-derive-enum = { version = "1.1", features = ["postgres"] }
chrono = "0.4.19" chrono = {version = "0.4.19", features = ["serde"] }
serde = "1.0.136" serde = "1.0.136"
password-hash = "0.4" password-hash = "0.4"
argon2 = "0.4" argon2 = "0.4"
rand_core = { version = "0.6", features = ["std"] } rand_core = { version = "0.6", features = ["std"] }
diesel-derive-enum = { version = "1.1", features = ["sqlite"] }
jsonwebtoken = "8.1" jsonwebtoken = "8.1"
validator = { version = "0.14", features = ["derive"] } validator = { version = "0.15", features = ["derive"] }
uuid = { version = "0.8.2", features = ["v4", "serde"] }
futures = "0.3.21"
rand = "0.8.5"
base64 = "0.13.0"

View File

@@ -1,12 +1,12 @@
-- Your SQL goes here -- Your SQL goes here
CREATE TABLE gamenight ( CREATE TABLE gamenight (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id UUID NOT NULL PRIMARY KEY,
game text TEXT NOT NULL, name VARCHAR NOT NULL,
datetime TEXT NOT NULL datetime VARCHAR NOT NULL
); );
CREATE TABLE known_games ( CREATE TABLE known_games (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id UUID NOT NULL PRIMARY KEY,
game TEXT UNIQUE NOT NULL name VARCHAR UNIQUE NOT NULL
); );

View File

@@ -1,4 +1,4 @@
-- This file should undo anything in `up.sql` -- This file should undo anything in `up.sql`
drop table pwd; drop table pwd;
drop table user; drop table users;

View File

@@ -1,12 +1,26 @@
CREATE TABLE user ( CREATE TABLE users (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id UUID NOT NULL PRIMARY KEY,
username TEXT UNIQUE NOT NULL, username VARCHAR UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL, email VARCHAR UNIQUE NOT NULL,
role TEXT NOT NULL role VARCHAR NOT NULL
); );
CREATE TABLE pwd ( CREATE TABLE pwd (
user_id INTEGER NOT NULL PRIMARY KEY, user_id UUID NOT NULL PRIMARY KEY,
password TEXT NOT NULL, password VARCHAR NOT NULL,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE CONSTRAINT FK_UserId FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
--Initialize default admin user, with password "gamenight!"
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
DO $$
DECLARE
admin_uuid uuid = uuid_generate_v4();
BEGIN
INSERT INTO users (id, username, email, role)
values(admin_uuid, 'admin', '', 'admin');
insert INTO pwd (user_id, password)
values(admin_uuid, '$argon2id$v=19$m=4096,t=3,p=1$zEdUjCAnZqd8DziYWzlFHw$YBLQhKvYIZBY43B8zM6hyBvLKuqTeh0EM5pKOfbWQSI');
END $$;

View File

@@ -0,0 +1,4 @@
-- This file should undo anything in `up.sql`
ALTER TABLE gamenight
DROP COLUMN owner_id;

View File

@@ -0,0 +1,19 @@
ALTER TABLE gamenight RENAME TO _gamenight_old;
CREATE TABLE gamenight (
id UUID NOT NULL PRIMARY KEY,
name VARCHAR NOT NULL,
datetime VARCHAR NOT NULL,
owner_id UUID NOT NULL,
CONSTRAINT FK_OwnerId FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
);
SET session_replication_role = 'replica';
INSERT INTO gamenight (id, name, datetime, owner_id)
select id, name, datetime, '00000000-0000-0000-0000-000000000000'
FROM _gamenight_old;
drop table _gamenight_old;
SET session_replication_role = 'origin';

View File

@@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
drop table gamenight_gamelist;

View File

@@ -0,0 +1,9 @@
-- Your SQL goes here
create table gamenight_gamelist (
gamenight_id UUID NOT NULL,
game_id UUID NOT NULL,
CONSTRAINT FK_gamenight_id FOREIGN KEY (gamenight_id) REFERENCES gamenight(id) ON DELETE CASCADE,
CONSTRAINT FK_game_id FOREIGN KEY (game_id) REFERENCES known_games(id) ON DELETE CASCADE,
PRIMARY KEY(gamenight_id, game_id)
);

View File

@@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
drop table gamenight_participants;

View File

@@ -0,0 +1,9 @@
-- Your SQL goes here
create table gamenight_participants (
gamenight_id UUID NOT NULL,
user_id UUID NOT NULL,
CONSTRAINT FK_gamenight_id FOREIGN KEY (gamenight_id) REFERENCES gamenight(id) ON DELETE CASCADE,
CONSTRAINT FK_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY(gamenight_id, user_id)
)

View File

@@ -0,0 +1,6 @@
-- This file should undo anything in `up.sql`
drop table registration_tokens;
ALTER TABLE gamenight
ALTER datetime TYPE VARCHAR;

View File

@@ -0,0 +1,11 @@
-- Your SQL goes here
create table registration_tokens (
id UUID PRIMARY KEY,
token CHARACTER(32) NOT NULL,
single_use BOOLEAN NOT NULL,
expires TIMESTAMPTZ
);
ALTER TABLE gamenight
ALTER datetime TYPE TIMESTAMPTZ using datetime::timestamp;

View File

@@ -1,27 +1,41 @@
use crate::schema; use crate::schema;
use crate::schema::admin::RegistrationToken;
use crate::schema::gamenight::*;
use crate::schema::users::*;
use crate::schema::DatabaseError;
use crate::schema::DbConn; use crate::schema::DbConn;
use crate::AppConfig; use crate::AppConfig;
use chrono::DateTime;
use chrono::Utc; use chrono::Utc;
use jsonwebtoken::decode; use futures::future::join_all;
use jsonwebtoken::encode; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use jsonwebtoken::DecodingKey;
use jsonwebtoken::Validation;
use jsonwebtoken::{EncodingKey, Header};
use rocket::http::Status; use rocket::http::Status;
use rocket::request::Outcome; use rocket::request::{FromRequest, Outcome, Request};
use rocket::request::{FromRequest, Request};
use rocket::serde::json::{json, Json, Value}; use rocket::serde::json::{json, Json, Value};
use rocket::State; use rocket::State;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::borrow::Cow; use std::borrow::Cow;
use uuid::Uuid;
use validator::ValidateArgs; use validator::ValidateArgs;
#[derive(Debug, Responder)] #[derive(Debug, Responder)]
pub enum ApiResponseVariant { pub enum ApiResponseVariant {
Status(Status), Status(Status),
// Redirect(Redirect),
Value(Value), Value(Value),
// Flash(Flash<Redirect>) }
#[derive(Serialize, Deserialize, Debug)]
pub enum ApiData {
#[serde(rename = "user")]
User(UserWithToken),
#[serde(rename = "gamenights")]
Gamenights(Vec<GamenightOutput>),
#[serde(rename = "gamenight")]
Gamenight(GamenightOutput),
#[serde(rename = "games")]
Games(Vec<Game>),
#[serde(rename = "registration_tokens")]
RegistrationTokens(Vec<RegistrationToken>),
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
@@ -29,8 +43,8 @@ struct ApiResponse {
result: Cow<'static, str>, result: Cow<'static, str>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
message: Option<Cow<'static, str>>, message: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(flatten, skip_serializing_if = "Option::is_none")]
jwt: Option<Cow<'static, str>>, data: Option<ApiData>,
} }
impl ApiResponse { impl ApiResponse {
@@ -40,22 +54,57 @@ impl ApiResponse {
const SUCCES: Self = Self { const SUCCES: Self = Self {
result: Self::SUCCES_RESULT, result: Self::SUCCES_RESULT,
message: None, message: None,
jwt: None, data: None,
}; };
fn error(message: String) -> Self { fn error(message: String) -> Self {
Self { Self {
result: Self::FAILURE_RESULT, result: Self::FAILURE_RESULT,
message: Some(Cow::Owned(message)), message: Some(Cow::Owned(message)),
jwt: None, data: None,
} }
} }
fn login_response(jwt: String) -> Self { fn login_response(user: User, jwt: String) -> Self {
Self { Self {
result: Self::SUCCES_RESULT, result: Self::SUCCES_RESULT,
message: None, message: None,
jwt: Some(Cow::Owned(jwt)), data: Some(ApiData::User(UserWithToken {
user: user,
jwt: jwt,
})),
}
}
fn gamenights_response(gamenights: Vec<GamenightOutput>) -> Self {
Self {
result: Self::SUCCES_RESULT,
message: None,
data: Some(ApiData::Gamenights(gamenights)),
}
}
fn gamenight_response(gamenight: GamenightOutput) -> Self {
Self {
result: Self::SUCCES_RESULT,
message: None,
data: Some(ApiData::Gamenight(gamenight)),
}
}
fn games_response(games: Vec<Game>) -> Self {
Self {
result: Self::SUCCES_RESULT,
message: None,
data: Some(ApiData::Games(games)),
}
}
fn registration_tokens_response(tokens: Vec<RegistrationToken>) -> Self {
Self {
result: Self::SUCCES_RESULT,
message: None,
data: Some(ApiData::RegistrationTokens(tokens)),
} }
} }
} }
@@ -69,15 +118,13 @@ const AUTH_HEADER: &str = "Authorization";
const BEARER: &str = "Bearer "; const BEARER: &str = "Bearer ";
#[rocket::async_trait] #[rocket::async_trait]
impl<'r> FromRequest<'r> for schema::User { impl<'r> FromRequest<'r> for User {
type Error = ApiError; type Error = ApiError;
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let header = match req.headers().get_one(AUTH_HEADER) { let header = match req.headers().get_one(AUTH_HEADER) {
Some(header) => header, Some(header) => header,
None => { None => return Outcome::Forward(()),
return Outcome::Forward(())
}
}; };
if !header.starts_with(BEARER) { if !header.starts_with(BEARER) {
@@ -92,21 +139,119 @@ impl<'r> FromRequest<'r> for schema::User {
&Validation::default(), &Validation::default(),
) { ) {
Ok(token) => token, Ok(token) => token,
Err(_) => { Err(_) => return Outcome::Forward(()),
return Outcome::Forward(())
}
}; };
let id = token.claims.uid; let id = token.claims.uid;
let conn = req.guard::<DbConn>().await.unwrap(); let conn = req.guard::<DbConn>().await.unwrap();
return Outcome::Success(schema::get_user(conn, id).await); return match get_user(&conn, id).await {
Ok(o) => Outcome::Success(o),
Err(_) => Outcome::Forward(()),
};
} }
} }
#[derive(Debug, Serialize, Deserialize)]
pub struct GamenightOutput {
#[serde(flatten)]
gamenight: Gamenight,
game_list: Vec<Game>,
participants: Vec<User>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GamenightUpdate {
action: String,
}
#[patch(
"/gamenights/<gamenight_id>",
format = "application/json",
data = "<patch_json>"
)]
pub async fn patch_gamenight(
conn: DbConn,
user: User,
gamenight_id: String,
patch_json: Json<GamenightUpdate>,
) -> ApiResponseVariant {
let uuid = Uuid::parse_str(&gamenight_id).unwrap();
let patch = patch_json.into_inner();
match patch.action.as_str() {
"RemoveParticipant" => {
let entry = GamenightParticipantsEntry {
gamenight_id: uuid,
user_id: user.id,
};
match remove_participant(&conn, entry).await {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
}
}
"AddParticipant" => {
let entry = GamenightParticipantsEntry {
gamenight_id: uuid,
user_id: user.id,
};
match add_participant(&conn, entry).await {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
}
}
_ => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
}
}
#[get("/gamenights/<gamenight_id>")]
pub async fn gamenight(conn: DbConn, _user: User, gamenight_id: String) -> ApiResponseVariant {
let uuid = Uuid::parse_str(&gamenight_id).unwrap();
let gamenight = match get_gamenight(&conn, uuid).await {
Ok(result) => result,
Err(err) => return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
};
let games = match get_games_of_gamenight(&conn, uuid).await {
Ok(result) => result,
Err(err) => return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
};
let participants = match load_participants(&conn, uuid).await {
Ok(result) => result,
Err(err) => return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
};
let gamenight_output = GamenightOutput {
gamenight: gamenight,
game_list: games,
participants: participants,
};
return ApiResponseVariant::Value(json!(ApiResponse::gamenight_response(gamenight_output)));
}
#[get("/gamenights")] #[get("/gamenights")]
pub async fn gamenights(conn: DbConn, _user: schema::User) -> ApiResponseVariant { pub async fn gamenights(conn: DbConn, _user: User) -> ApiResponseVariant {
let gamenights = schema::get_all_gamenights(conn).await; let gamenights = match get_all_gamenights(&conn).await {
ApiResponseVariant::Value(json!(gamenights)) Ok(result) => result,
Err(err) => return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
};
let conn_ref = &conn;
let game_results: Result<Vec<GamenightOutput>, DatabaseError> =
join_all(gamenights.iter().map(|gn| async move {
let games = get_games_of_gamenight(conn_ref, gn.id).await?;
let participants = load_participants(conn_ref, gn.id).await?;
Ok(GamenightOutput {
gamenight: gn.clone(),
game_list: games,
participants: participants,
})
}))
.await
.into_iter()
.collect();
match game_results {
Ok(result) => ApiResponseVariant::Value(json!(ApiResponse::gamenights_response(result))),
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
}
} }
#[get("/gamenights", rank = 2)] #[get("/gamenights", rank = 2)]
@@ -114,25 +259,127 @@ pub async fn gamenights_unauthorized() -> ApiResponseVariant {
ApiResponseVariant::Status(Status::Unauthorized) ApiResponseVariant::Status(Status::Unauthorized)
} }
#[post("/gamenight", format = "application/json", data = "<gamenight_json>")] #[derive(Debug, Serialize, Deserialize, Clone)]
pub async fn gamenight_post_json( pub struct GamenightInput {
conn: DbConn, pub name: String,
user: Option<schema::User>, pub datetime: DateTime<Utc>,
gamenight_json: Json<schema::GameNightNoId>, pub owner_id: Option<Uuid>,
) -> ApiResponseVariant { pub game_list: Vec<Game>,
if user.is_some() { }
schema::insert_gamenight(conn, gamenight_json.into_inner()).await;
ApiResponseVariant::Value(json!(ApiResponse::SUCCES)) impl Into<Gamenight> for GamenightInput {
} else { fn into(self) -> Gamenight {
ApiResponseVariant::Status(Status::Unauthorized) Gamenight {
id: Uuid::new_v4(),
name: self.name,
datetime: self.datetime,
owner_id: self.owner_id.unwrap(),
}
} }
} }
#[post("/register", format = "application/json", data = "<register_json>")] #[post("/gamenights", format = "application/json", data = "<gamenight_json>")]
pub async fn gamenights_post_json(
conn: DbConn,
user: User,
gamenight_json: Json<GamenightInput>,
) -> ApiResponseVariant {
let mut gamenight = gamenight_json.into_inner();
gamenight.owner_id = Some(user.id);
let mut mutable_game_list = gamenight.game_list.clone();
match add_unknown_games(&conn, &mut mutable_game_list).await {
Ok(_) => (),
Err(err) => return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
};
let gamenight_id = match insert_gamenight(&conn, gamenight.clone().into(), mutable_game_list)
.await
{
Ok(id) => id,
Err(err) => return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
};
let participant = GamenightParticipantsEntry {
gamenight_id: gamenight_id,
user_id: user.id,
};
match add_participant(&conn, participant).await {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
}
}
#[post("/gamenights", rank = 2)]
pub async fn gamenights_post_json_unauthorized() -> ApiResponseVariant {
ApiResponseVariant::Status(Status::Unauthorized)
}
#[delete(
"/gamenights",
format = "application/json",
data = "<delete_gamenight_json>"
)]
pub async fn gamenights_delete_json(
conn: DbConn,
user: User,
delete_gamenight_json: Json<DeleteGamenight>,
) -> ApiResponseVariant {
if user.role == Role::Admin {
if let Err(error) = delete_gamenight(&conn, delete_gamenight_json.game_id).await {
return ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string())));
}
return ApiResponseVariant::Value(json!(ApiResponse::SUCCES));
}
match get_gamenight(&conn, delete_gamenight_json.game_id).await {
Ok(gamenight) => {
if user.id == gamenight.owner_id {
if let Err(error) = delete_gamenight(&conn, delete_gamenight_json.game_id).await {
return ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string())));
}
return ApiResponseVariant::Value(json!(ApiResponse::SUCCES));
}
}
Err(error) => {
return ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string())))
}
}
ApiResponseVariant::Status(Status::Unauthorized)
}
#[delete("/gamenights", rank = 2)]
pub async fn gamenights_delete_json_unauthorized() -> ApiResponseVariant {
ApiResponseVariant::Status(Status::Unauthorized)
}
#[post(
"/register/<registration_token>",
format = "application/json",
data = "<register_json>"
)]
pub async fn register_post_json( pub async fn register_post_json(
conn: DbConn, conn: DbConn,
register_json: Json<schema::Register>, config: &State<AppConfig>,
register_json: Json<Register>,
registration_token: String,
) -> ApiResponseVariant { ) -> ApiResponseVariant {
let token = match schema::admin::get_registration_token(&conn, registration_token).await {
Ok(res) => res,
Err(error) => {
return ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string())))
}
};
if let Some(expiry) = token.expires {
if expiry < Utc::now() {
return ApiResponseVariant::Value(json!(ApiResponse::error(
"Registration token has expired".to_string()
)));
}
}
let register = register_json.into_inner(); let register = register_json.into_inner();
let register_clone = register.clone(); let register_clone = register.clone();
match conn match conn
@@ -143,28 +390,60 @@ pub async fn register_post_json(
Err(error) => { Err(error) => {
return ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string()))) return ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string())))
} }
};
let user = match insert_user(&conn, register).await {
Ok(user) => user,
Err(err) => return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
};
if token.single_use {
match schema::admin::delete_registration_token(&conn, token.id).await {
Ok(_) => (),
Err(err) => {
return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string())))
}
}
} }
match schema::insert_user(conn, register).await { match create_jwt_token(&user, config) {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)), Ok(token) => ApiResponseVariant::Value(json!(ApiResponse::login_response(user, token))),
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))), Err(error) => ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string()))),
} }
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct Claims { struct Claims {
exp: i64, exp: i64,
uid: i32, uid: Uuid,
role: schema::Role, role: Role,
}
fn create_jwt_token(
user: &User,
config: &State<AppConfig>,
) -> Result<String, jsonwebtoken::errors::Error> {
let my_claims = Claims {
exp: Utc::now().timestamp() + chrono::Duration::days(7).num_seconds(),
uid: user.id,
role: user.role,
};
let secret = &config.inner().jwt_secret;
encode(
&Header::default(),
&my_claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
} }
#[post("/login", format = "application/json", data = "<login_json>")] #[post("/login", format = "application/json", data = "<login_json>")]
pub async fn login_post_json( pub async fn login_post_json(
conn: DbConn, conn: DbConn,
config: &State<AppConfig>, config: &State<AppConfig>,
login_json: Json<schema::Login>, login_json: Json<Login>,
) -> ApiResponseVariant { ) -> ApiResponseVariant {
match schema::login(conn, login_json.into_inner()).await { match login(conn, login_json.into_inner()).await {
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))), Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
Ok(login_result) => { Ok(login_result) => {
if !login_result.result { if !login_result.result {
@@ -173,19 +452,11 @@ pub async fn login_post_json(
)))); ))));
} }
let my_claims = Claims { let user = login_result.user.unwrap();
exp: Utc::now().timestamp() + chrono::Duration::days(7).num_seconds(), match create_jwt_token(&user, config) {
uid: login_result.id.unwrap(), Ok(token) => {
role: login_result.role.unwrap(), ApiResponseVariant::Value(json!(ApiResponse::login_response(user, token)))
}; }
let secret = &config.inner().jwt_secret;
match encode(
&Header::default(),
&my_claims,
&EncodingKey::from_secret(secret.as_bytes()),
) {
Ok(token) => ApiResponseVariant::Value(json!(ApiResponse::login_response(token))),
Err(error) => { Err(error) => {
ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string()))) ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string())))
} }
@@ -193,3 +464,155 @@ pub async fn login_post_json(
} }
} }
} }
#[get("/games")]
pub async fn games(conn: DbConn, _user: User) -> ApiResponseVariant {
match get_all_known_games(&conn).await {
Ok(games) => ApiResponseVariant::Value(json!(ApiResponse::games_response(games))),
Err(error) => ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string()))),
}
}
#[get("/games", rank = 2)]
pub async fn games_unauthorized() -> ApiResponseVariant {
ApiResponseVariant::Status(Status::Unauthorized)
}
#[get(
"/participants",
format = "application/json",
data = "<gamenight_id_json>"
)]
pub async fn get_participants(
conn: DbConn,
_user: User,
gamenight_id_json: Json<GamenightId>,
) -> ApiResponseVariant {
match load_participants(&conn, gamenight_id_json.into_inner().gamenight_id).await {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
Err(error) => ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string()))),
}
}
#[get("/participants", rank = 2)]
pub async fn get_participants_unauthorized() -> ApiResponseVariant {
ApiResponseVariant::Status(Status::Unauthorized)
}
#[post("/participants", format = "application/json", data = "<entry_json>")]
pub async fn post_participants(
conn: DbConn,
_user: User,
entry_json: Json<GamenightParticipantsEntry>,
) -> ApiResponseVariant {
match add_participant(&conn, entry_json.into_inner()).await {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
Err(error) => ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string()))),
}
}
#[post("/participants", rank = 2)]
pub async fn post_participants_unauthorized() -> ApiResponseVariant {
ApiResponseVariant::Status(Status::Unauthorized)
}
#[delete("/participants", format = "application/json", data = "<entry_json>")]
pub async fn delete_participants(
conn: DbConn,
_user: User,
entry_json: Json<GamenightParticipantsEntry>,
) -> ApiResponseVariant {
match remove_participant(&conn, entry_json.into_inner()).await {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
Err(error) => ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string()))),
}
}
#[delete("/participants", rank = 2)]
pub async fn delete_participants_unauthorized() -> ApiResponseVariant {
ApiResponseVariant::Status(Status::Unauthorized)
}
#[derive(Deserialize)]
pub struct RegistrationTokenData {
single_use: bool,
expires: Option<DateTime<Utc>>,
}
impl Into<RegistrationToken> for RegistrationTokenData {
fn into(self) -> RegistrationToken {
use rand::Rng;
let random_bytes = rand::thread_rng().gen::<[u8; 24]>();
RegistrationToken {
id: Uuid::new_v4(),
token: base64::encode_config(random_bytes, base64::URL_SAFE),
single_use: self.single_use,
expires: self.expires,
}
}
}
#[post(
"/admin/registration_tokens",
format = "application/json",
data = "<token_json>"
)]
pub async fn add_registration_token(
conn: DbConn,
user: User,
token_json: Json<RegistrationTokenData>,
) -> ApiResponseVariant {
if user.role != Role::Admin {
return ApiResponseVariant::Status(Status::Unauthorized);
}
match schema::admin::add_registration_token(&conn, token_json.into_inner().into()).await {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
}
}
#[post("/admin/registration_tokens", rank = 2)]
pub async fn add_registration_token_unauthorized() -> ApiResponseVariant {
ApiResponseVariant::Status(Status::Unauthorized)
}
#[get("/admin/registration_tokens")]
pub async fn get_registration_tokens(conn: DbConn, user: User) -> ApiResponseVariant {
if user.role != Role::Admin {
return ApiResponseVariant::Status(Status::Unauthorized);
}
match schema::admin::get_all_registration_tokens(&conn).await {
Ok(results) => {
ApiResponseVariant::Value(json!(ApiResponse::registration_tokens_response(results)))
}
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
}
}
#[get("/admin/registration_tokens", rank = 2)]
pub async fn get_registration_tokens_unauthorized() -> ApiResponseVariant {
ApiResponseVariant::Status(Status::Unauthorized)
}
#[delete("/admin/registration_tokens/<gamenight_id>")]
pub async fn delete_registration_tokens(
conn: DbConn,
user: User,
gamenight_id: String,
) -> ApiResponseVariant {
if user.role != Role::Admin {
return ApiResponseVariant::Status(Status::Unauthorized);
}
let uuid = Uuid::parse_str(&gamenight_id).unwrap();
match schema::admin::delete_registration_token(&conn, uuid).await {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
}
}
#[delete("/admin/registration_tokens", rank = 2)]
pub async fn delete_registration_tokens_unauthorized() -> ApiResponseVariant {
ApiResponseVariant::Status(Status::Unauthorized)
}

View File

@@ -33,7 +33,7 @@ impl Default for AppConfig {
} }
#[launch] #[launch]
fn rocket() -> _ { async fn rocket() -> _ {
let figment = Figment::from(rocket::Config::default()) let figment = Figment::from(rocket::Config::default())
.merge(Serialized::defaults(AppConfig::default())) .merge(Serialized::defaults(AppConfig::default()))
.merge(Toml::file("App.toml").nested()) .merge(Toml::file("App.toml").nested())
@@ -43,25 +43,37 @@ fn rocket() -> _ {
let rocket = rocket::custom(figment) let rocket = rocket::custom(figment)
.attach(schema::DbConn::fairing()) .attach(schema::DbConn::fairing())
.attach(Template::fairing()) .attach(Template::fairing())
.attach(site::CORS)
.attach(AdHoc::on_ignite("Run Migrations", schema::run_migrations)) .attach(AdHoc::on_ignite("Run Migrations", schema::run_migrations))
.attach(AdHoc::config::<AppConfig>()) .attach(AdHoc::config::<AppConfig>())
.mount( .mount("/", routes![site::index, site::files])
"/",
routes![
site::index,
site::gamenights,
site::add_game_night,
site::register
],
)
.mount( .mount(
"/api", "/api",
routes![ routes![
api::gamenight,
api::patch_gamenight,
api::gamenights, api::gamenights,
api::gamenights_unauthorized, api::gamenights_unauthorized,
api::gamenight_post_json, api::gamenights_post_json,
api::gamenights_post_json_unauthorized,
api::register_post_json, api::register_post_json,
api::login_post_json api::login_post_json,
api::gamenights_delete_json,
api::gamenights_delete_json_unauthorized,
api::games,
api::games_unauthorized,
api::get_participants,
api::get_participants_unauthorized,
api::post_participants,
api::post_participants_unauthorized,
api::delete_participants,
api::delete_participants_unauthorized,
api::add_registration_token,
api::add_registration_token_unauthorized,
api::get_registration_tokens,
api::get_registration_tokens_unauthorized,
api::delete_registration_tokens,
api::delete_registration_tokens_unauthorized,
], ],
); );

View File

@@ -1,319 +0,0 @@
use crate::diesel::BoolExpressionMethods;
use crate::diesel::Connection;
use crate::diesel::ExpressionMethods;
use crate::diesel::QueryDsl;
use argon2::password_hash::SaltString;
use argon2::PasswordHash;
use argon2::PasswordVerifier;
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher},
Argon2,
};
use diesel::dsl::count;
use diesel::RunQueryDsl;
use diesel_derive_enum::DbEnum;
use rocket::{Build, Rocket};
use rocket_sync_db_pools::database;
use serde::{Deserialize, Serialize};
use std::ops::Deref;
use validator::{Validate, ValidationError};
#[database("gamenight_database")]
pub struct DbConn(diesel::SqliteConnection);
impl Deref for DbConn {
type Target = rocket_sync_db_pools::Connection<DbConn, diesel::SqliteConnection>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
table! {
gamenight (id) {
id -> Integer,
game -> Text,
datetime -> Text,
}
}
table! {
known_games (game) {
id -> Integer,
game -> Text,
}
}
table! {
use diesel::sql_types::Integer;
use diesel::sql_types::Text;
use super::RoleMapping;
user(id) {
id -> Integer,
username -> Text,
email -> Text,
role -> RoleMapping,
}
}
table! {
pwd(user_id) {
user_id -> Integer,
password -> Text,
}
}
allow_tables_to_appear_in_same_query!(gamenight, known_games,);
pub enum DatabaseError {
Hash(password_hash::Error),
Query(String),
}
impl std::fmt::Display for DatabaseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
match self {
DatabaseError::Hash(err) => write!(f, "{}", err),
DatabaseError::Query(err) => write!(f, "{}", err),
}
}
}
pub async fn get_all_gamenights(conn: DbConn) -> Vec<GameNight> {
conn.run(|c| gamenight::table.load::<GameNight>(c).unwrap())
.await
}
pub async fn insert_gamenight(conn: DbConn, new_gamenight: GameNightNoId) -> () {
conn.run(|c| {
diesel::insert_into(gamenight::table)
.values(new_gamenight)
.execute(c)
.unwrap()
})
.await;
}
pub async fn insert_user(conn: DbConn, new_user: Register) -> Result<(), DatabaseError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = match argon2.hash_password(new_user.password.as_bytes(), &salt) {
Ok(hash) => hash.to_string(),
Err(error) => return Err(DatabaseError::Hash(error)),
};
let user_insert_result = conn
.run(move |c| {
c.transaction(|| {
diesel::insert_into(user::table)
.values((
user::username.eq(&new_user.username),
user::email.eq(&new_user.email),
user::role.eq(Role::User),
))
.execute(c)?;
let ids: Vec<i32> = match user::table
.filter(
user::username
.eq(&new_user.username)
.and(user::email.eq(&new_user.email)),
)
.select(user::id)
.get_results(c)
{
Ok(id) => id,
Err(e) => return Err(e),
};
diesel::insert_into(pwd::table)
.values((pwd::user_id.eq(ids[0]), pwd::password.eq(&password_hash)))
.execute(c)
})
})
.await;
match user_insert_result {
Err(e) => Err(DatabaseError::Query(e.to_string())),
_ => Ok(()),
}
}
pub async fn login(conn: DbConn, login: Login) -> Result<LoginResult, DatabaseError> {
conn.run(move |c| -> Result<LoginResult, DatabaseError> {
let id: i32 = match user::table
.filter(user::username.eq(&login.username))
.or_filter(user::email.eq(&login.username))
.select(user::id)
.first(c)
{
Ok(id) => id,
Err(error) => return Err(DatabaseError::Query(error.to_string())),
};
let pwd: String = match pwd::table
.filter(pwd::user_id.eq(id))
.select(pwd::password)
.first(c)
{
Ok(pwd) => pwd,
Err(error) => return Err(DatabaseError::Query(error.to_string())),
};
let parsed_hash = match PasswordHash::new(&pwd) {
Ok(hash) => hash,
Err(error) => return Err(DatabaseError::Hash(error)),
};
if Argon2::default()
.verify_password(&login.password.as_bytes(), &parsed_hash)
.is_ok()
{
let role: Role = match user::table
.filter(user::id.eq(id))
.select(user::role)
.first(c)
{
Ok(role) => role,
Err(error) => return Err(DatabaseError::Query(error.to_string())),
};
Ok(LoginResult {
result: true,
id: Some(id),
role: Some(role),
})
} else {
Ok(LoginResult {
result: false,
id: None,
role: None,
})
}
})
.await
}
pub async fn get_user(conn: DbConn, id: i32) -> User {
conn.run(move |c| user::table.filter(user::id.eq(id)).first(c).unwrap())
.await
}
pub fn unique_username(
username: &String,
conn: &diesel::SqliteConnection,
) -> Result<(), ValidationError> {
match user::table
.select(count(user::username))
.filter(user::username.eq(username))
.execute(conn)
{
Ok(0) => Ok(()),
Ok(_) => Err(ValidationError::new("User already exists")),
Err(_) => Err(ValidationError::new("Database error while validating user")),
}
}
pub fn unique_email(
email: &String,
conn: &diesel::SqliteConnection,
) -> Result<(), ValidationError> {
match user::table
.select(count(user::email))
.filter(user::email.eq(email))
.execute(conn)
{
Ok(0) => Ok(()),
Ok(_) => Err(ValidationError::new("email already exists")),
Err(_) => Err(ValidationError::new(
"Database error while validating email",
)),
}
}
pub async fn run_migrations(rocket: Rocket<Build>) -> Rocket<Build> {
// This macro from `diesel_migrations` defines an `embedded_migrations`
// module containing a function named `run`. This allows the example to be
// run and tested without any outside setup of the database.
embed_migrations!();
let conn = DbConn::get_one(&rocket).await.expect("database connection");
conn.run(|c| embedded_migrations::run(c))
.await
.expect("can run migrations");
rocket
}
#[derive(Debug, Serialize, Deserialize, DbEnum, Clone)]
pub enum Role {
Admin,
User,
}
#[derive(Serialize, Deserialize, Debug, Insertable, Queryable)]
#[table_name = "user"]
pub struct User {
pub id: i32,
pub username: String,
pub email: String,
pub role: Role,
}
#[derive(Serialize, Deserialize, Debug, FromForm, Insertable)]
#[table_name = "known_games"]
pub struct GameNoId {
pub game: String,
}
#[derive(Serialize, Deserialize, Debug, FromForm, Queryable)]
pub struct Game {
pub id: i32,
pub game: String,
}
#[derive(Serialize, Deserialize, Debug, FromForm, Insertable)]
#[table_name = "gamenight"]
pub struct GameNightNoId {
pub game: String,
pub datetime: String,
}
#[derive(Serialize, Deserialize, Debug, FromForm, Queryable)]
pub struct GameNight {
pub id: i32,
pub game: String,
pub datetime: String,
}
#[derive(Serialize, Deserialize, Debug, Validate, Clone)]
pub struct Register {
#[validate(
length(min = 1),
custom(function = "unique_username", arg = "&'v_a diesel::SqliteConnection")
)]
pub username: String,
#[validate(
email,
custom(function = "unique_email", arg = "&'v_a diesel::SqliteConnection")
)]
pub email: String,
#[validate(length(min = 10), must_match = "password_repeat")]
pub password: String,
pub password_repeat: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Login {
pub username: String,
pub password: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct LoginResult {
pub result: bool,
pub id: Option<i32>,
pub role: Option<Role>,
}

View File

@@ -0,0 +1,67 @@
use crate::schema::{DatabaseError, DbConn};
use chrono::DateTime;
use chrono::Utc;
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
table! {
registration_tokens (id) {
id -> diesel::sql_types::Uuid,
token -> Char,
single_use -> Bool,
expires -> Nullable<Timestamptz>,
}
}
#[derive(Serialize, Deserialize, Debug, Insertable, Queryable)]
#[table_name = "registration_tokens"]
pub struct RegistrationToken {
pub id: Uuid,
pub token: String,
pub single_use: bool,
pub expires: Option<DateTime<Utc>>,
}
pub async fn get_all_registration_tokens(
conn: &DbConn,
) -> Result<Vec<RegistrationToken>, DatabaseError> {
Ok(conn
.run(|c| registration_tokens::table.load::<RegistrationToken>(c))
.await?)
}
pub async fn add_registration_token(
conn: &DbConn,
token: RegistrationToken,
) -> Result<usize, DatabaseError> {
Ok(conn
.run(|c| {
diesel::insert_into(registration_tokens::table)
.values(token)
.execute(c)
})
.await?)
}
pub async fn delete_registration_token(conn: &DbConn, id: Uuid) -> Result<usize, DatabaseError> {
Ok(conn
.run(move |c| {
diesel::delete(registration_tokens::table.filter(registration_tokens::id.eq(id)))
.execute(c)
})
.await?)
}
pub async fn get_registration_token(
conn: &DbConn,
token: String,
) -> Result<RegistrationToken, DatabaseError> {
Ok(conn
.run(|c| {
registration_tokens::table
.filter(registration_tokens::token.eq(token))
.first(c)
})
.await?)
}

View File

@@ -0,0 +1,215 @@
use crate::schema::users::{users, User};
use crate::schema::{DatabaseError, DbConn};
use chrono::{DateTime, Utc};
use diesel::{Connection, ExpressionMethods, QueryDsl, RunQueryDsl};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
table! {
gamenight (id) {
id -> diesel::sql_types::Uuid,
name -> VarChar,
datetime -> Timestamptz,
owner_id -> Uuid,
}
}
table! {
known_games (id) {
id -> diesel::sql_types::Uuid,
name -> VarChar,
}
}
table! {
gamenight_gamelist(gamenight_id, game_id) {
gamenight_id -> diesel::sql_types::Uuid,
game_id -> diesel::sql_types::Uuid,
}
}
table! {
gamenight_participants(gamenight_id, user_id) {
gamenight_id -> diesel::sql_types::Uuid,
user_id -> diesel::sql_types::Uuid,
}
}
#[derive(Serialize, Deserialize, Debug, Queryable, Clone, Insertable)]
#[table_name = "known_games"]
pub struct Game {
pub id: Uuid,
pub name: String,
}
#[derive(Serialize, Deserialize, Debug, Queryable, Insertable, Clone)]
#[table_name = "gamenight"]
pub struct Gamenight {
pub id: Uuid,
pub name: String,
pub datetime: DateTime<Utc>,
pub owner_id: Uuid,
}
#[derive(Serialize, Deserialize, Debug, Queryable, Insertable)]
#[table_name = "gamenight_gamelist"]
pub struct GamenightGameListEntry {
pub gamenight_id: Uuid,
pub game_id: Uuid,
}
#[derive(Serialize, Deserialize, Debug, Queryable, Insertable, Identifiable)]
#[table_name = "gamenight_participants"]
#[primary_key(gamenight_id, user_id)]
pub struct GamenightParticipantsEntry {
pub gamenight_id: Uuid,
pub user_id: Uuid,
}
#[derive(Serialize, Deserialize, Debug, Queryable)]
pub struct DeleteGamenight {
pub game_id: Uuid,
}
#[derive(Serialize, Deserialize, Debug, Queryable)]
pub struct GamenightId {
pub gamenight_id: Uuid,
}
pub async fn get_all_gamenights(conn: &DbConn) -> Result<Vec<Gamenight>, DatabaseError> {
Ok(conn.run(|c| gamenight::table.load::<Gamenight>(c)).await?)
}
pub async fn insert_gamenight(
conn: &DbConn,
new_gamenight: Gamenight,
game_list: Vec<Game>,
) -> Result<Uuid, DatabaseError> {
Ok(conn
.run(move |c| {
c.transaction::<_, DatabaseError, _>(|| {
let id: Uuid = diesel::insert_into(gamenight::table)
.values(&new_gamenight)
.returning(gamenight::id)
.get_result(c)?;
let entries: Vec<GamenightGameListEntry> = game_list
.iter()
.map(|g| GamenightGameListEntry {
gamenight_id: new_gamenight.id.clone(),
game_id: g.id.clone(),
})
.collect();
diesel::insert_into(gamenight_gamelist::table)
.values(entries)
.execute(c)?;
Ok(id)
})
})
.await?)
}
pub async fn get_gamenight(conn: &DbConn, gamenight_id: Uuid) -> Result<Gamenight, DatabaseError> {
Ok(conn
.run(move |c| gamenight::table.find(gamenight_id).first(c))
.await?)
}
pub async fn delete_gamenight(conn: &DbConn, game_id: Uuid) -> Result<usize, DatabaseError> {
Ok(conn
.run(move |c| diesel::delete(gamenight::table.filter(gamenight::id.eq(game_id))).execute(c))
.await?)
}
pub async fn get_all_known_games(conn: &DbConn) -> Result<Vec<Game>, DatabaseError> {
Ok(conn.run(|c| known_games::table.load::<Game>(c)).await?)
}
pub async fn get_games_of_gamenight(
conn: &DbConn,
gamenight_id: Uuid,
) -> Result<Vec<Game>, DatabaseError> {
Ok(conn
.run::<_, Result<Vec<Game>, _>>(move |c| {
let linked_game_ids: Vec<GamenightGameListEntry> = gamenight_gamelist::table
.filter(gamenight_gamelist::gamenight_id.eq(gamenight_id))
.load::<GamenightGameListEntry>(c)?;
linked_game_ids
.iter()
.map(|l| {
known_games::table
.filter(known_games::id.eq(l.game_id))
.first::<Game>(c)
})
.collect()
})
.await?)
}
pub async fn add_game(conn: &DbConn, game: Game) -> Result<usize, DatabaseError> {
Ok(conn
.run(|c| {
diesel::insert_into(known_games::table)
.values(game)
.execute(c)
})
.await?)
}
pub async fn add_unknown_games(conn: &DbConn, games: &mut Vec<Game>) -> Result<(), DatabaseError> {
let all_games = get_all_known_games(conn).await?;
for game in games.iter_mut() {
if !all_games.iter().any(|g| g.name == game.name) {
game.id = Uuid::new_v4();
add_game(conn, game.clone()).await?;
}
}
Ok(())
}
pub async fn load_participants(
conn: &DbConn,
gamenight_id: Uuid,
) -> Result<Vec<User>, DatabaseError> {
Ok(conn
.run::<_, Result<Vec<User>, _>>(move |c| {
let linked_participants = gamenight_participants::table
.filter(gamenight_participants::gamenight_id.eq(gamenight_id))
.load::<GamenightParticipantsEntry>(c)?;
linked_participants
.iter()
.map(|l| {
users::table
.filter(users::id.eq(l.user_id))
.first::<User>(c)
})
.collect()
})
.await?)
}
pub async fn add_participant(
conn: &DbConn,
participant: GamenightParticipantsEntry,
) -> Result<usize, DatabaseError> {
Ok(conn
.run(move |c| {
diesel::insert_into(gamenight_participants::table)
.values(&participant)
.on_conflict_do_nothing()
.execute(c)
})
.await?)
}
pub async fn remove_participant(
conn: &DbConn,
participant: GamenightParticipantsEntry,
) -> Result<usize, DatabaseError> {
Ok(conn
.run(move |c| diesel::delete(&participant).execute(c))
.await?)
}

58
backend/src/schema/mod.rs Normal file
View File

@@ -0,0 +1,58 @@
pub mod admin;
pub mod gamenight;
pub mod users;
use rocket::{Build, Rocket};
use rocket_sync_db_pools::database;
use std::ops::Deref;
#[database("gamenight_database")]
pub struct DbConn(diesel::PgConnection);
impl Deref for DbConn {
type Target = rocket_sync_db_pools::Connection<DbConn, diesel::PgConnection>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub enum DatabaseError {
Hash(password_hash::Error),
Query(String),
}
pub async fn run_migrations(rocket: Rocket<Build>) -> Rocket<Build> {
// This macro from `diesel_migrations` defines an `embedded_migrations`
// module containing a function named `run`. This allows the example to be
// run and tested without any outside setup of the database.
embed_migrations!();
let conn = DbConn::get_one(&rocket).await.expect("database connection");
conn.run(|c| embedded_migrations::run(c))
.await
.expect("can run migrations");
rocket
}
impl From<diesel::result::Error> for DatabaseError {
fn from(error: diesel::result::Error) -> Self {
Self::Query(error.to_string())
}
}
impl From<password_hash::Error> for DatabaseError {
fn from(error: password_hash::Error) -> Self {
Self::Hash(error)
}
}
impl std::fmt::Display for DatabaseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
match self {
DatabaseError::Hash(err) => write!(f, "{}", err),
DatabaseError::Query(err) => write!(f, "{}", err),
}
}
}

192
backend/src/schema/users.rs Normal file
View File

@@ -0,0 +1,192 @@
use crate::schema::{DatabaseError, DbConn};
use argon2::password_hash::SaltString;
use argon2::PasswordHash;
use argon2::PasswordVerifier;
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher},
Argon2,
};
use diesel::{Connection, ExpressionMethods, QueryDsl, RunQueryDsl};
use diesel_derive_enum::DbEnum;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use validator::{Validate, ValidationError};
#[derive(Debug, Serialize, Deserialize, DbEnum, Clone, Copy, PartialEq)]
pub enum Role {
Admin,
User,
}
table! {
users(id) {
id -> diesel::sql_types::Uuid,
username -> VarChar,
email -> VarChar,
role -> crate::schema::users::RoleMapping,
}
}
table! {
pwd(user_id) {
user_id -> diesel::sql_types::Uuid,
password -> VarChar,
}
}
#[derive(Serialize, Deserialize, Debug, Insertable, Queryable)]
#[table_name = "pwd"]
struct Pwd {
user_id: Uuid,
password: String,
}
#[derive(Serialize, Deserialize, Debug, Insertable, Queryable)]
#[table_name = "users"]
pub struct User {
pub id: Uuid,
pub username: String,
pub email: String,
pub role: Role,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UserWithToken {
#[serde(flatten)]
pub user: User,
pub jwt: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Login {
pub username: String,
pub password: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct LoginResult {
pub result: bool,
pub user: Option<User>,
}
#[derive(Serialize, Deserialize, Debug, Validate, Clone)]
pub struct Register {
#[validate(
length(min = 1),
custom(function = "unique_username", arg = "&'v_a diesel::PgConnection")
)]
pub username: String,
#[validate(
email,
custom(function = "unique_email", arg = "&'v_a diesel::PgConnection")
)]
pub email: String,
#[validate(length(min = 10), must_match = "password_repeat")]
pub password: String,
pub password_repeat: String,
}
pub async fn insert_user(conn: &DbConn, new_user: Register) -> Result<User, DatabaseError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(new_user.password.as_bytes(), &salt)?
.to_string();
Ok(conn
.run(move |c| {
c.transaction(|| {
let id = Uuid::new_v4();
diesel::insert_into(users::table)
.values(User {
id: id.clone(),
username: new_user.username,
email: new_user.email,
role: Role::User,
})
.execute(c)?;
diesel::insert_into(pwd::table)
.values(Pwd {
user_id: id,
password: password_hash,
})
.execute(c)?;
users::table.filter(users::id.eq(id)).first::<User>(c)
})
})
.await?)
}
pub async fn login(conn: DbConn, login: Login) -> Result<LoginResult, DatabaseError> {
conn.run(move |c| -> Result<LoginResult, DatabaseError> {
let id: Uuid = users::table
.filter(users::username.eq(&login.username))
.or_filter(users::email.eq(&login.username))
.select(users::id)
.first(c)?;
let pwd: String = pwd::table
.filter(pwd::user_id.eq(id))
.select(pwd::password)
.first(c)?;
let parsed_hash = PasswordHash::new(&pwd)?;
if Argon2::default()
.verify_password(&login.password.as_bytes(), &parsed_hash)
.is_ok()
{
let user: User = users::table.find(id).first(c)?;
Ok(LoginResult {
result: true,
user: Some(user),
})
} else {
Ok(LoginResult {
result: false,
user: None,
})
}
})
.await
}
pub async fn get_user(conn: &DbConn, id: Uuid) -> Result<User, DatabaseError> {
Ok(conn
.run(move |c| users::table.filter(users::id.eq(id)).first(c))
.await?)
}
pub fn unique_username(
username: &String,
conn: &diesel::PgConnection,
) -> Result<(), ValidationError> {
match users::table
.count()
.filter(users::username.eq(username))
.get_result(conn)
{
Ok(0) => Ok(()),
Ok(_) => Err(ValidationError::new("User already exists")),
Err(_) => Err(ValidationError::new("Database error while validating user")),
}
}
pub fn unique_email(email: &String, conn: &diesel::PgConnection) -> Result<(), ValidationError> {
match users::table
.count()
.filter(users::email.eq(email))
.get_result(conn)
{
Ok(0) => Ok(()),
Ok(_) => Err(ValidationError::new("email already exists")),
Err(_) => Err(ValidationError::new(
"Database error while validating email",
)),
}
}

View File

@@ -1,90 +1,42 @@
use crate::schema; use rocket::fs::NamedFile;
use rocket::request::FlashMessage; use rocket::{
use rocket::response::Redirect; fairing::{Fairing, Info, Kind},
use rocket_dyn_templates::Template; http::Header,
use serde::{Deserialize, Serialize}; Request, Response,
use std::borrow::Cow;
#[derive(Serialize, Deserialize, Debug)]
struct FlashData {
has_data: bool,
kind: Cow<'static, str>,
message: Cow<'static, str>,
}
impl FlashData {
const EMPTY: Self = Self {
has_data: false,
message: Cow::Borrowed(""),
kind: Cow::Borrowed(""),
}; };
use std::io;
use std::path::{Path, PathBuf};
pub struct CORS;
#[rocket::async_trait]
impl Fairing for CORS {
fn info(&self) -> Info {
Info {
name: "Attaching CORS headers to responses",
kind: Kind::Response,
}
} }
#[derive(Serialize, Deserialize, Debug)] async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) {
struct GameNightsData { response.set_header(Header::new("Access-Control-Allow-Origin", "*"));
gamenights: Vec<schema::GameNight>, response.set_header(Header::new(
flash: FlashData, "Access-Control-Allow-Methods",
"POST, GET, PATCH, OPTIONS",
));
response.set_header(Header::new("Access-Control-Allow-Headers", "*"));
response.set_header(Header::new("Access-Control-Allow-Credentials", "true"));
}
} }
#[get("/gamenights")] #[get("/<file..>", rank = 10)]
pub async fn gamenights(conn: schema::DbConn) -> Template { pub async fn files(file: PathBuf) -> Option<NamedFile> {
let gamenights = schema::get_all_gamenights(conn).await; NamedFile::open(Path::new("../frontend/build/").join(file))
.await
let data = GameNightsData { .ok()
gamenights: gamenights,
flash: FlashData::EMPTY,
};
Template::render("gamenights", &data)
} }
#[get("/")] #[get("/")]
pub async fn index() -> Redirect { pub async fn index() -> io::Result<NamedFile> {
Redirect::to(uri!(gamenights)) NamedFile::open("../frontend/build/index.html").await
}
#[derive(Serialize, Deserialize, Debug)]
struct GameNightAddData {
post_url: String,
flash: FlashData,
}
#[get("/gamenight/add")]
pub async fn add_game_night(flash: Option<FlashMessage<'_>>) -> Template {
let flash_data = match flash {
None => FlashData::EMPTY,
Some(flash) => FlashData {
has_data: true,
message: Cow::Owned(flash.message().to_string()),
kind: Cow::Owned(flash.kind().to_string()),
},
};
let data = GameNightAddData {
post_url: "/api/gamenight".to_string(),
flash: flash_data,
};
Template::render("gamenight_add", &data)
}
#[derive(Serialize, Deserialize, Debug)]
struct RegisterData {
flash: FlashData,
}
#[get("/register")]
pub async fn register(flash: Option<FlashMessage<'_>>) -> Template {
let flash_data = match flash {
None => FlashData::EMPTY,
Some(flash) => FlashData {
has_data: true,
message: Cow::Owned(flash.message().to_string()),
kind: Cow::Owned(flash.kind().to_string()),
},
};
let data = RegisterData { flash: flash_data };
Template::render("register", &data)
} }

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
version: '3.8'
services:
db:
container_name: pg_container
image: postgres
restart: always
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: root
POSTGRES_DB: gamenight
ports:
- "5432:5432"
pgadmin:
container_name: pgadmin4_container
image: dpage/pgadmin4
restart: always
environment:
PGADMIN_DEFAULT_EMAIL: admin@admin.com
PGADMIN_DEFAULT_PASSWORD: root
ports:
- "5050:80"

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,20 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@material-ui/icons": "^4.11.3",
"@mui/icons-material": "^5.8.0",
"@mui/material": "^5.8.0",
"@mui/x-date-pickers": "^5.0.0-alpha.4",
"@testing-library/jest-dom": "^5.16.4", "@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.0.1", "@testing-library/react": "^13.0.1",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"react": "^18.0.0", "date-fns": "^2.28.0",
"react-dom": "^18.0.0", "moment": "^2.29.3",
"react": "^18.1.0",
"react-datetime": "^3.1.1",
"react-dom": "^18.1.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
@@ -15,7 +24,16 @@
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject",
"watch": "npm-watch"
},
"watch": {
"build": {
"patterns": [
"src/"
],
"extensions": "js,jsx"
}
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@@ -34,5 +52,9 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"dotenv-cli": "^5.1.0",
"npm-watch": "^0.11.0"
} }
} }

View File

@@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>React App</title> <title>It's gamenight</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -28,6 +28,19 @@
color: #61dafb; color: #61dafb;
} }
fieldset label {
display: block;
text-align: left;
}
input {
display: block;
}
input[type=submit] {
margin:5px;
}
@keyframes App-logo-spin { @keyframes App-logo-spin {
from { from {
transform: rotate(0deg); transform: rotate(0deg);

View File

@@ -1,25 +1,159 @@
import logo from './logo.svg';
import './App.css'; import './App.css';
import React, { useState, useEffect, useCallback } from 'react';
import MenuBar from './components/MenuBar';
import Login from './components/Login';
import Gamenights from './components/Gamenights';
import Gamenight from './components/Gamenight';
import AdminPanel from './components/AdminPanel';
import Register from './components/Register';
import { get_gamenights, get_games, unpack_api_result, login } from './api/Api';
const localStorageUserKey = 'user';
function App() { function App() {
return ( const [user, setUser] = useState(null);
const [gamenights, setGamenights] = useState([]);
const [flashData, setFlashData] = useState({});
const [games, setGames] = useState([]);
const [activeGamenightId, setActiveGamenightId] = useState(null);
const [appState, setAppState] = useState('LoggedOut')
const handleLogin = (input) => {
unpack_api_result(login(input), setFlashData)
.then(result => {
if(result !== undefined) {
setUser(result.user);
localStorage.setItem(localStorageUserKey, JSON.stringify(result.user));
setAppState('LoggedIn')
}
});
};
useEffect(() => {
if(activeGamenightId !== null) {
setAppState('GamenightDetails');
} else {
setAppState(user === null ? 'LoggedOut' : 'LoggedIn')
}
}, [activeGamenightId, user])
const onLogout = () => {
setUser(null);
localStorage.removeItem(localStorageUserKey);
setAppState('LoggedOut')
};
const onAdmin = () => {
setAppState('AdminPanel')
}
const onUser = () => {
setAppState('UserPage')
}
const onRegister = () => {
setAppState('RegisterPage')
}
const onReset = () => {
setAppState(user === null ? 'LoggedOut' : 'LoggedIn')
}
const onRegistered = (user) => {
setUser(user);
}
const setFlash = (data) => {
setFlashData(data);
};
const refetchGamenights = useCallback(() => {
unpack_api_result(get_gamenights(user.jwt), setFlashData)
.then(result => {
if (result !== undefined) {
setGamenights(result.gamenights);
}
});
}, [user]);
useEffect(() => {
if (appState === 'LoggedIn') {
refetchGamenights()
}
}, [appState, refetchGamenights])
useEffect(() => {
if (appState === 'LoggedIn') {
unpack_api_result(get_games(user.jwt), setFlashData)
.then(result => {
if (result !== undefined) {
setGames(result.games)
}
});
}
}, [appState, user])
useEffect(() => {
setUser(JSON.parse(localStorage.getItem(localStorageUserKey)));
}, []);
let mainview;
if(appState === 'LoggedOut') {
mainview = (
<div className="App"> <div className="App">
<header className="App-header"> <Login onChange={handleLogin}/>
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div> </div>
); );
} else if(appState === 'RegisterPage') {
mainview = (
<Register
onRegistered={onRegistered}
setFlash={setFlash}/>
);
} else if(appState === 'UserPage') {
mainview = (
<span>UserPage</span>
)
}else if(appState === 'GamenightDetails') {
mainview = (
<Gamenight
gamenightId={activeGamenightId}
onDismis={() => setActiveGamenightId(null)}
setFlash={setFlash}
user={user}
/>)
} else if(appState === 'LoggedIn') {
mainview = (
<Gamenights
user={user}
games={games}
setFlash={setFlash}
refetchGamenights={refetchGamenights}
gamenights={gamenights}
onSelectGamenight={(g) => setActiveGamenightId(g.id)}/>
);
} else if(appState === 'AdminPanel') {
mainview = (
<AdminPanel
user={user}
setFlash={setFlash}/>
);
}
let page = (
<>
<MenuBar
user={user}
onUser={onUser}
onRegister={onRegister}
onAdmin={onAdmin}
onLogout={onLogout}
onReset={onReset}/>
{mainview}
</>
);
return page;
} }
export default App; export default App;

126
frontend/src/api/Api.js Normal file
View File

@@ -0,0 +1,126 @@
import fetchResource from './FetchResource'
export function unpack_api_result(promise, onError) {
return promise.then(result => {
if(result.result !== 'Ok') {
throw new Error(result.message);
}
return result;
})
.catch(error => {
onError({
type: 'Error',
message: `${error.status === null ?? error.status} ${error.message}`
});
});
}
export function get_gamenights(token) {
return fetchResource('api/gamenights', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
},
});
}
export function get_gamenight(gamenight_id, token) {
return fetchResource(`api/gamenights/${gamenight_id}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
},
});
}
export function post_gamenight(input, token) {
return fetchResource('api/gamenights', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(input)
});
}
export function patch_gamenight(gamenight_id, input, token) {
return fetchResource(`api/gamenights/${gamenight_id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(input)
})
}
export function delete_gamenight(input, token) {
return fetchResource('api/gamenights', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(input)
});
}
export function get_games(token) {
return fetchResource('api/games', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
}
export function login(body) {
return fetchResource('api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
}
export function get_registration_tokens(token) {
return fetchResource('api/admin/registration_tokens', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
}
export function add_registration_token(token, registration_token) {
return fetchResource('api/admin/registration_tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(registration_token)
});
}
export function delete_registration_token(token, registration_token_id) {
return fetchResource(`api/admin/registration_tokens/${registration_token_id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
}
});
}
export function register(registration_token, input) {
return fetchResource(`api/register/${registration_token}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(input)
});
}

View File

@@ -0,0 +1,73 @@
const API_URL = '';
function ApiError(message, data, status) {
let response = null;
let isObject = false;
try {
response = JSON.parse(data);
isObject = true;
} catch (e) {
response = data;
}
this.response = response;
this.message = message;
this.status = status;
this.toString = function () {
return `${ this.message }\nResponse:\n${ isObject ? JSON.stringify(this.response, null, 2) : this.response }`;
};
}
const fetchResource = (path, userOptions = {}) => {
const defaultOptions = {};
const defaultHeaders = {};
const options = {
...defaultOptions,
...userOptions,
headers: {
...defaultHeaders,
...userOptions.headers,
},
};
const url = `${ API_URL }/${ path }`;
const isFile = options.body instanceof File;
if (options.body && typeof options.body === 'object' && !isFile) {
options.body = JSON.stringify(options.body);
}
let response = null;
return fetch(url, options)
.then(responseObject => {
response = responseObject;
if (response.status === 401) {
}
if (response.status < 200 || response.status >= 300) {
return response.text();
}
return response.json();
})
.then(parsedResponse => {
if (response.status < 200 || response.status >= 300) {
throw parsedResponse;
}
return parsedResponse;
})
.catch(error => {
if (response) {
throw new ApiError(`Request failed with status ${ response.status }.`, error, response.status);
} else {
throw new ApiError(error.toString(), null, 'REQUEST_FAILED');
}
});
};
export default fetchResource;

View File

@@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import IconButton from '@mui/material/IconButton';
import TextField from '@mui/material/TextField';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import { DateTimePicker } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import GameAdder from './GameAdder';
import { post_gamenight, unpack_api_result} from '../api/Api';
function AddGameNight(props) {
const [expanded, setExpanded] = useState(false);
const [gameName, setGameName] = useState("");
const [date, setDate] = useState(Date.now());
const [gameList, setGameList] = useState([]);
const handleNameChange = (event) => {
setGameName(event.target.value);
};
const onDateChange = (date) => {
setDate(date);
};
const onGamesListChange = (gameList) => {
setGameList(gameList);
}
useEffect(() => {
if(!expanded) {
setGameName("");
setDate(null);
}
}, [expanded]);
const handleAddGamenight = (event) => {
event.preventDefault();
if (props.user !== null) {
let input = {
name: gameName,
datetime: date,
game_list: gameList,
}
unpack_api_result(post_gamenight(input, props.user.jwt), props.setFlash)
.then(result => {
if(result !== undefined) {
setExpanded(false);
setGameName("");
setDate(null);
}
})
.then(() => props.refetchGamenights());
}
};
if(expanded) {
return (
<LocalizationProvider dateAdapter={AdapterDateFns}>
<div className="Add-GameNight">
<form autoComplete="off" onSubmit={e => { e.preventDefault(); }}>
<fieldset>
<legend>Gamenight</legend>
<TextField
id="game-name"
label="Name"
variant="standard"
value={gameName}
onChange={handleNameChange}/>
<DateTimePicker
label="Gamenight date and time"
variant="standard"
value={date}
onChange={onDateChange}
inputFormat="dd-MM-yyyy HH:mm"
renderInput={(params) => <TextField {...params} />}/>
<GameAdder games={props.games} onChange={onGamesListChange}/>
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
color="success"
onClick={handleAddGamenight}>
Create
</Button>
<Button
variant="outlined"
color="error"
onClick={() => setExpanded(false)}>
Discard
</Button>
</Stack>
</fieldset>
</form>
</div>
</LocalizationProvider>
);
} else {
return (
<IconButton
aria-label="add"
color="success"
onClick={(e) => setExpanded(true)}>
<AddIcon />
</IconButton>
);
}
}
export default AddGameNight

View File

@@ -0,0 +1,190 @@
import {useState, useEffect, useCallback} from 'react';
import Checkbox from '@mui/material/Checkbox';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TablePagination from '@mui/material/TablePagination';
import TableRow from '@mui/material/TableRow';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
import DeleteIcon from '@mui/icons-material/Delete';
import { DateTimePicker } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import moment from 'moment';
import {get_registration_tokens, add_registration_token, delete_registration_token, unpack_api_result} from '../api/Api';
function AdminPanel(props) {
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [registrationTokens, setRegistrationTokens] = useState([]);
const [expires, setExpires] = useState(null);
const [isSingleUse, setIsSingleUse] = useState(false);
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(+event.target.value);
setPage(0);
};
const refetchTokens = useCallback(() => {
if(props.user !== null) {
unpack_api_result(get_registration_tokens(props.user.jwt), props.setFlash)
.then(result => {
if(result !== undefined) {
setRegistrationTokens(result.registration_tokens);
}
});
}
}, [props.setFlash, props.user]);
const deleteToken = (id) => {
if(props.user !== null) {
unpack_api_result(delete_registration_token(props.user.jwt, id), props.setFlash)
.then(result => {
if(result !== undefined) {
refetchTokens();
}
});
}
}
const handleAddToken = () => {
let input = {
single_use: isSingleUse,
expires: expires,
}
if(props.user !== null) {
unpack_api_result(add_registration_token(props.user.jwt, input), props.setFlash)
.then(result => {
if(result !== undefined) {
refetchTokens();
}
});
}
}
useEffect(() => {
refetchTokens()
}, [refetchTokens])
let columns = [
{
id: 'single_use',
label: 'Single Use',
minWidth: 30,
format: value => (value ? "Yes" : "No")
},
{ id: 'token', label: 'Token', minwidht: 300},
{
id: 'expires',
label: 'Expires',
minwidth: 200,
format: value => (moment(value).format('LL HH:mm'))
},
{
id: 'delete_button',
label: '',
minwidth: 20,
}
];
return (
<>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<div className="Add-GameNight">
<form autoComplete="off" onSubmit={e => { e.preventDefault(); }}>
<DateTimePicker
label="Token expires at"
variant="standard"
value={expires}
onChange={setExpires}
inputFormat="dd-MM-yyyy HH:mm"
renderInput={(params) => <TextField {...params} />}/>
<Checkbox
label="Single use"
value={isSingleUse}
onChange={(e) => setIsSingleUse(e.target.checked)}/>
<Button
variant="outlined"
color="success"
onClick={handleAddToken}>
Create
</Button>
</form>
</div>
</LocalizationProvider>
<TableContainer sx={{ maxHeight: 440 }}>
<Table stickyHeader>
<TableHead>
<TableRow>
{columns.map((column) => (
<TableCell
key={column.id}
align={column.align}
style={{ minWidth: column.minWidth }}
>
{column.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{registrationTokens
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row) => {
return (
<TableRow hover role="checkbox" tabIndex={-1} key={row.code}>
{columns.map((column) => {
const value = row[column.id];
return (
<TableCell key={column.id} align={column.align}>
{column.format
? column.format(value)
: value}
</TableCell>
);
})}
<TableCell>
<IconButton
edge="end"
color="error"
onClick={(e) => {
e.stopPropagation();
deleteToken(row.id)
}}>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{registrationTokens.length > rowsPerPage && <TablePagination
rowsPerPageOptions={[10, 25, 100]}
component="div"
count={registrationTokens.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}/>
}
</>
);
}
export default AdminPanel

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import TextField from '@mui/material/TextField';
import Chip from '@mui/material/Chip';
import Autocomplete from '@mui/material/Autocomplete';
export default function GameAdder(props) {
const [value, setValue] = React.useState([]);
const emptyUuid = "00000000-0000-0000-0000-000000000000";
return (
<Autocomplete
multiple
id="tags-filled"
options={props.games}
value={value}
getOptionLabel={(option) => option.name}
freeSolo
selectOnFocus
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip variant="outlined" label={option.name} {...getTagProps({ index })} />
))
}
renderInput={(params) => (
<TextField
{...params}
variant="filled"
label="What games do you like to play?"
placeholder="monopoly"
/>
)}
onChange={(event, newValue) => {
newValue = newValue.map(option => {
if (typeof option === 'string') {
var match = props.games.find(g => g.name.toLowerCase() === option.toLowerCase());
if(match !== undefined) {
return match
} else {
return {id: emptyUuid, name: option};
}
}
else {
return option;
}
});
newValue = newValue.filter((value, index, self) =>
index === self.findIndex((t) => (
t.id === value.id && t.name === value.name
))
);
setValue(newValue);
props.onChange(newValue);
}}
/>
);
}

View File

@@ -0,0 +1,139 @@
import React, { useState, useEffect, useCallback } from 'react';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import moment from 'moment';
import {unpack_api_result, get_gamenight, patch_gamenight} from '../api/Api';
function Gamenight(props) {
const dense = true;
const [gamenight, setGamenight] = useState(null);
const fetchGamenight = useCallback(() => {
if (props.user !== null) {
unpack_api_result(get_gamenight(props.gamenightId, props.user.jwt), props.setFlash)
.then(result => {
if(result !== undefined) {
setGamenight(result.gamenight);
}
});
}
}, [props.gamenightId, props.user, props.setFlash]);
useEffect(fetchGamenight, [fetchGamenight]);
let games = gamenight?.game_list.map(g =>
(
<ListItem>
<ListItemText
primary={g.name}
/>
</ListItem>
)
);
const participants = gamenight?.participants.map(p =>
(
<ListItem>
<ListItemText
primary={p.username}
/>
</ListItem>
)
);
const Join = () => {
const input = {
action: 'AddParticipant'
};
unpack_api_result(patch_gamenight(gamenight.id, input, props.user.jwt), props.setFlash)
.then(result => {
if(result !== undefined) {
fetchGamenight();
}
});
};
const Leave = () => {
const input = {
action: 'RemoveParticipant',
};
unpack_api_result(patch_gamenight(gamenight.id, input, props.user.jwt), props.setFlash)
.then(result => {
if(result !== undefined) {
fetchGamenight();
}
});
};
let join_or_leave_button;
if(gamenight?.participants.find(p => p.id === props.user.id) === undefined) {
join_or_leave_button = (
<Button
variant="outlined"
color="success"
onClick={Join}>
Join
</Button>
)
} else {
join_or_leave_button = (
<Button
variant="outlined"
color="error"
onClick={Leave}>
Leave
</Button>
)
}
return (
<div>
<IconButton
aria-label="back"
onClick={(e) => props.onDismis()}>
<ArrowBackIcon />
</IconButton>
<Typography type="h3">
{gamenight?.name}
</Typography>
<Typography type="body1">
When: {moment(gamenight?.datetime).format('LL HH:mm')}
</Typography>
<List
dense={dense}
aria-labelledby="games-subheader"
subheader={
<ListSubheader component="div" id="games-subheader">
Games:
</ListSubheader>
}>
{games}
</List>
<List
dense={dense}
aria-labelledby="participants-subheader"
subheader={
<ListSubheader component="div" id="participants-subheader">
Participants:
</ListSubheader>
}>
{participants}
</List>
{join_or_leave_button}
</div>
)
}
export default Gamenight

View File

@@ -0,0 +1,79 @@
import * as React from 'react';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemText from '@mui/material/ListItemText';
import Avatar from '@mui/material/Avatar';
import IconButton from '@mui/material/IconButton';
import GamesIcon from '@mui/icons-material/Games';
import DeleteIcon from '@mui/icons-material/Delete';
import AddGameNight from './AddGameNight';
import {delete_gamenight, unpack_api_result} from '../api/Api';
function Gamenights(props) {
const dense = true;
const DeleteGamenight = (game_id) => {
if (props.user !== null) {
const input = { game_id: game_id };
unpack_api_result(delete_gamenight(input, props.user.jwt), props.setFlash)
.then(result => {
if(result !== undefined) {
console.log("hello?");
props.refetchGamenights();
}
});
}
}
let gamenights = props.gamenights.map(g => {
let secondaryAction;
if(props.user.id === g.owner_id || props.user.role === 'Admin') {
secondaryAction = (
<IconButton
edge="end"
aria-label="delete"
color="error"
onClick={(e) => {
e.stopPropagation();
DeleteGamenight(g.id)
}}>
<DeleteIcon />
</IconButton>
)
}
return (
<ListItem
component="nav"
onClick={(e) => props.onSelectGamenight(g)}
secondaryAction={
secondaryAction
}>
<ListItemAvatar>
<Avatar>
<GamesIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={g.name}
/>
</ListItem>
)
});
return (
<>
<AddGameNight
user={props.user}
games={props.games}
setFlash={props.setFlash}
refetchGamenights={props.refetchGamenights} />
<List dense={dense}>
{gamenights}
</List>
</>
);
}
export default Gamenights

View File

@@ -0,0 +1,42 @@
import React, { useState } from 'react';
function Login(props) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const handleUsernameChange = (event) => {
setUsername(event.target.value);
};
const handlePasswordChange = (event) => {
setPassword(event.target.value);
};
const handleLogin = (event) => {
props.onChange({ username: username, password: password });
event.preventDefault();
};
return (
<div className="Login-Component">
<form onSubmit={handleLogin}>
<fieldset>
<legend>Login</legend>
<label for="username">Username:</label>
<input id="username" name="username" type="text"
value={username}
onChange={handleUsernameChange} />
<label for="password">Password:</label>
<input id="password" name="password" type="password"
value={password}
onChange={handlePasswordChange} />
<input type="submit" value="Submit" />
</fieldset>
</form>
</div>
);
}
export default Login

View File

@@ -0,0 +1,79 @@
import * as React from 'react';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import MenuIcon from '@mui/icons-material/Menu';
function MenuBar(props) {
let userButton = null;
let logoutButton = null;
let adminPanelButton = null;
let registerButton = null;
if (props.user !== null) {
userButton = (
<Button
color="inherit"
onClick={props.onUser}>
{props.user.username}
</Button>
);
logoutButton = (
<Button
color="inherit"
onClick={props.onLogout}>
Logout
</Button>
);
if (props.user.role === 'Admin') {
adminPanelButton = (
<Button
color="inherit"
onClick={props.onAdmin}>
AdminPanel
</Button>
);
}
} else {
registerButton = (
<Button
color="inherit"
onClick={props.onRegister}>
Register
</Button>
)
}
return (
<AppBar position="static">
<Toolbar>
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}>
<MenuIcon />
</IconButton>
<Typography
style={{cursor:'pointer'}}
variant="h6"
component="div"
sx={{ flexGrow: 1 }}
onClick={props.onReset}>
Gamenight!
</Typography>
{userButton !== null && userButton}
{registerButton !== null && registerButton}
{adminPanelButton !== null && adminPanelButton}
{logoutButton !== null && logoutButton}
</Toolbar>
</AppBar>
);
}
export default MenuBar;

View File

@@ -0,0 +1,98 @@
import {useState} from 'react';
import FormControl from '@mui/material/FormControl';
import Input from '@mui/material/Input';
import FormHelperText from '@mui/material/FormHelperText';
import Button from '@mui/material/Button';
import {register, unpack_api_result} from '../api/Api';
function Register(props) {
const [registrationToken, setRegistrationToken] = useState("");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordRepeat, setPasswordRepeat] = useState("");
const onRegister = () => {
let input = {
username,
email,
password,
password_repeat: passwordRepeat
}
unpack_api_result(register(registrationToken, input), props.setFlash)
.then(result => {
if(result !== undefined) {
props.onRegistered(result.user);
}
});
}
return (
<FormControl>
<Input
id="registration_token"
aria-describedby="registration_token-helper-text"
value={registrationToken}
onChange={(e) => {setRegistrationToken(e.target.value)}} />
<FormHelperText
id="registration_token-helper-text">
Registration token given by a gamenight admin
</FormHelperText>
<Input
id="username"
aria-describedby="email-helper-text"
value={username}
onChange={(e) => {setUsername(e.target.value)}} />
<FormHelperText
id="username-helper-text">
Username to display everywhere
</FormHelperText>
<Input
id="email"
aria-describedby="email-helper-text"
value={email}
onChange={(e) => {setEmail(e.target.value)}} />
<FormHelperText
id="email-helper-text">
E-mail used for notifications and password resets
</FormHelperText>
<Input
id="password"
type="password"
aria-describedby="password-helper-text"
value={password}
onChange={(e) => {setPassword(e.target.value)}} />
<FormHelperText
id="password-helper-text">
Password atleast 10 characters long
</FormHelperText>
<Input
id="password_repeat"
type="password"
aria-describedby="password_repeat-helper-text"
value={passwordRepeat}
onChange={(e) => {setPasswordRepeat(e.target.value)}} />
<FormHelperText
id="password_repeat-helper-text">
Confirm your password
</FormHelperText>
<Button
variant="outlined"
color="success"
onClick={onRegister}>
Register
</Button>
</FormControl>
);
}
export default Register;