33 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
aab60dcc11 Fixes 3 potential panics when querying the user and pwd table. 2022-04-21 21:51:57 +02:00
b5e9420c1f Makes seperate function for authorized and unauthorized request. 2022-04-21 21:35:14 +02:00
5f73d556c6 Fixes the review comments 2022-04-21 20:02:15 +02:00
81e65b1619 Ran rust fmt. 2022-04-21 19:12:16 +02:00
df8b553345 Adds some validation to new user registration. 2022-04-21 18:51:13 +02:00
af0dcee159 Added a user system with no proper user validation but working authorisation. 2022-04-20 22:28:00 +02:00
66 changed files with 32363 additions and 571 deletions

3
.gitignore vendored
View File

@@ -1,4 +1 @@
/target
.vscode
Rocket.toml
*.sqlite

View File

@@ -1,18 +0,0 @@
[package]
name = "gamenight"
version = "0.1.0"
authors = ["Dennis Brentjes <d.brentjes@gmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rocket = { version = "0.5.0-rc.1", features = ["default", "json"] }
libsqlite3-sys = { version = ">=0.8.0, <0.19.0", features = ["bundled"] }
rocket_sync_db_pools = { version = "0.1.0-rc.1", features = ["diesel_sqlite_pool"] }
diesel = { version = "1.4.8", features = ["sqlite"] }
diesel_migrations = "1.4.0"
rocket_dyn_templates = { version = "0.1.0-rc.1", features = ["handlebars"] }
chrono = "0.4.19"
serde = "1.0.136"

4
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target
.vscode
App.toml
*.sqlite

View File

@@ -1,4 +1,8 @@
#Copy this file over to Rocket.toml after changing all relevant values.
[default]
jwt_secret = "some really good secret"
[global.databases]
gamenight_database = { url = "gamenight.sqlite" }

File diff suppressed because it is too large Load Diff

26
backend/Cargo.toml Normal file
View File

@@ -0,0 +1,26 @@
[package]
name = "gamenight"
version = "0.1.0"
authors = ["Dennis Brentjes <d.brentjes@gmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rocket = { version = "0.5.0-rc.2", features = ["default", "json"] }
rocket_sync_db_pools = { version = "0.1.0-rc.2", features = ["diesel_postgres_pool"] }
rocket_dyn_templates = { version = "0.1.0-rc.2", features = ["handlebars"] }
diesel = {version = "1.4.8", features = ["uuidv07", "r2d2", "postgres", "chrono"]}
diesel_migrations = "1.4.0"
diesel-derive-enum = { version = "1.1", features = ["postgres"] }
chrono = {version = "0.4.19", features = ["serde"] }
serde = "1.0.136"
password-hash = "0.4"
argon2 = "0.4"
rand_core = { version = "0.6", features = ["std"] }
jsonwebtoken = "8.1"
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

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

View File

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

View File

@@ -0,0 +1,26 @@
CREATE TABLE users (
id UUID NOT NULL PRIMARY KEY,
username VARCHAR UNIQUE NOT NULL,
email VARCHAR UNIQUE NOT NULL,
role VARCHAR NOT NULL
);
CREATE TABLE pwd (
user_id UUID NOT NULL PRIMARY KEY,
password VARCHAR NOT NULL,
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;

3
backend/requests/gamenights.sh Executable file
View File

@@ -0,0 +1,3 @@
echo $JWT
curl -X GET -H "Authorization: Bearer ${JWT}" localhost:8000/api/gamenights

1
backend/requests/login.sh Executable file
View File

@@ -0,0 +1 @@
curl -X POST -H "Content-Type: application/json" -d '{"username": "a", "password": "c"}' localhost:8000/api/login

1
backend/requests/register.sh Executable file
View File

@@ -0,0 +1 @@
curl -X POST -H "Content-Type: application/json" -d '{"username": "roflin", "email": "user@example.com", "password": "oreokoekje123", "password_repeat": "oreokoekje123"}' localhost:8000/api/register

618
backend/src/api.rs Normal file
View File

@@ -0,0 +1,618 @@
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::AppConfig;
use chrono::DateTime;
use chrono::Utc;
use futures::future::join_all;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome, Request};
use rocket::serde::json::{json, Json, Value};
use rocket::State;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use uuid::Uuid;
use validator::ValidateArgs;
#[derive(Debug, Responder)]
pub enum ApiResponseVariant {
Status(Status),
Value(Value),
}
#[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)]
struct ApiResponse {
result: Cow<'static, str>,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<Cow<'static, str>>,
#[serde(flatten, skip_serializing_if = "Option::is_none")]
data: Option<ApiData>,
}
impl ApiResponse {
const SUCCES_RESULT: Cow<'static, str> = Cow::Borrowed("Ok");
const FAILURE_RESULT: Cow<'static, str> = Cow::Borrowed("Failure");
const SUCCES: Self = Self {
result: Self::SUCCES_RESULT,
message: None,
data: None,
};
fn error(message: String) -> Self {
Self {
result: Self::FAILURE_RESULT,
message: Some(Cow::Owned(message)),
data: None,
}
}
fn login_response(user: User, jwt: String) -> Self {
Self {
result: Self::SUCCES_RESULT,
message: None,
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)),
}
}
}
#[derive(Debug)]
pub enum ApiError {
RequestError(String),
}
const AUTH_HEADER: &str = "Authorization";
const BEARER: &str = "Bearer ";
#[rocket::async_trait]
impl<'r> FromRequest<'r> for User {
type Error = ApiError;
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let header = match req.headers().get_one(AUTH_HEADER) {
Some(header) => header,
None => return Outcome::Forward(()),
};
if !header.starts_with(BEARER) {
return Outcome::Forward(());
};
let app_config = req.guard::<&State<AppConfig>>().await.unwrap().inner();
let jwt = header.trim_start_matches(BEARER).to_owned();
let token = match decode::<Claims>(
&jwt,
&DecodingKey::from_secret(app_config.jwt_secret.as_bytes()),
&Validation::default(),
) {
Ok(token) => token,
Err(_) => return Outcome::Forward(()),
};
let id = token.claims.uid;
let conn = req.guard::<DbConn>().await.unwrap();
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")]
pub async fn gamenights(conn: DbConn, _user: User) -> ApiResponseVariant {
let gamenights = match get_all_gamenights(&conn).await {
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)]
pub async fn gamenights_unauthorized() -> ApiResponseVariant {
ApiResponseVariant::Status(Status::Unauthorized)
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GamenightInput {
pub name: String,
pub datetime: DateTime<Utc>,
pub owner_id: Option<Uuid>,
pub game_list: Vec<Game>,
}
impl Into<Gamenight> for GamenightInput {
fn into(self) -> Gamenight {
Gamenight {
id: Uuid::new_v4(),
name: self.name,
datetime: self.datetime,
owner_id: self.owner_id.unwrap(),
}
}
}
#[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(
conn: DbConn,
config: &State<AppConfig>,
register_json: Json<Register>,
registration_token: String,
) -> 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_clone = register.clone();
match conn
.run(move |c| register_clone.validate_args((c, c)))
.await
{
Ok(()) => (),
Err(error) => {
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 create_jwt_token(&user, config) {
Ok(token) => ApiResponseVariant::Value(json!(ApiResponse::login_response(user, token))),
Err(error) => ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string()))),
}
}
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
exp: i64,
uid: Uuid,
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>")]
pub async fn login_post_json(
conn: DbConn,
config: &State<AppConfig>,
login_json: Json<Login>,
) -> ApiResponseVariant {
match login(conn, login_json.into_inner()).await {
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
Ok(login_result) => {
if !login_result.result {
return ApiResponseVariant::Value(json!(ApiResponse::error(String::from(
"username and password didn't match"
))));
}
let user = login_result.user.unwrap();
match create_jwt_token(&user, config) {
Ok(token) => {
ApiResponseVariant::Value(json!(ApiResponse::login_response(user, token)))
}
Err(error) => {
ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string())))
}
}
}
}
}
#[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)
}

81
backend/src/main.rs Normal file
View File

@@ -0,0 +1,81 @@
#[macro_use]
extern crate rocket;
#[macro_use]
extern crate diesel_migrations;
#[macro_use]
extern crate diesel;
use rocket::{
fairing::AdHoc,
figment::{
providers::{Env, Format, Serialized, Toml},
Figment, Profile,
},
};
use rocket_dyn_templates::Template;
use serde::{Deserialize, Serialize};
mod api;
pub mod schema;
mod site;
#[derive(Debug, Deserialize, Serialize)]
pub struct AppConfig {
jwt_secret: String,
}
impl Default for AppConfig {
fn default() -> AppConfig {
AppConfig {
jwt_secret: String::from("secret"),
}
}
}
#[launch]
async fn rocket() -> _ {
let figment = Figment::from(rocket::Config::default())
.merge(Serialized::defaults(AppConfig::default()))
.merge(Toml::file("App.toml").nested())
.merge(Env::prefixed("APP_").global())
.select(Profile::from_env_or("APP_PROFILE", "default"));
let rocket = rocket::custom(figment)
.attach(schema::DbConn::fairing())
.attach(Template::fairing())
.attach(site::CORS)
.attach(AdHoc::on_ignite("Run Migrations", schema::run_migrations))
.attach(AdHoc::config::<AppConfig>())
.mount("/", routes![site::index, site::files])
.mount(
"/api",
routes![
api::gamenight,
api::patch_gamenight,
api::gamenights,
api::gamenights_unauthorized,
api::gamenights_post_json,
api::gamenights_post_json_unauthorized,
api::register_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,
],
);
rocket
}

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",
)),
}
}

42
backend/src/site.rs Normal file
View File

@@ -0,0 +1,42 @@
use rocket::fs::NamedFile;
use rocket::{
fairing::{Fairing, Info, Kind},
http::Header,
Request, Response,
};
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,
}
}
async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) {
response.set_header(Header::new("Access-Control-Allow-Origin", "*"));
response.set_header(Header::new(
"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("/<file..>", rank = 10)]
pub async fn files(file: PathBuf) -> Option<NamedFile> {
NamedFile::open(Path::new("../frontend/build/").join(file))
.await
.ok()
}
#[get("/")]
pub async fn index() -> io::Result<NamedFile> {
NamedFile::open("../frontend/build/index.html").await
}

View File

@@ -0,0 +1,19 @@
<html>
<head>
</head>
<body>
{{> flash flash }}
<form action="/api/register" method="post">
<label for="username">Username:</label><br>
<input type="text" id="username" name="username" required><br>
<label for="email">Email:</label><br>
<input type="text" id="email" name="email" required><br>
<label for="password">Password:</label><br>
<input type="password" id="password" name="password" required><br>
<label for="password_repeat">Repeat password:</label><br>
<input type="password" id="password_repeat" name="password_repeat" required><br>
<input type="submit">
</form>
</body>
</html>

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"

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

70
frontend/README.md Normal file
View File

@@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

28642
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
frontend/package.json Normal file
View File

@@ -0,0 +1,60 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"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/react": "^13.0.1",
"@testing-library/user-event": "^13.5.0",
"date-fns": "^2.28.0",
"moment": "^2.29.3",
"react": "^18.1.0",
"react-datetime": "^3.1.1",
"react-dom": "^18.1.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"watch": "npm-watch"
},
"watch": {
"build": {
"patterns": [
"src/"
],
"extensions": "js,jsx"
}
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"dotenv-cli": "^5.1.0",
"npm-watch": "^0.11.0"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
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`.
-->
<title>It's gamenight</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

51
frontend/src/App.css Normal file
View File

@@ -0,0 +1,51 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
fieldset label {
display: block;
text-align: left;
}
input {
display: block;
}
input[type=submit] {
margin:5px;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

159
frontend/src/App.js Normal file
View File

@@ -0,0 +1,159 @@
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() {
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">
<Login onChange={handleLogin}/>
</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;

8
frontend/src/App.test.js Normal file
View File

@@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

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;

13
frontend/src/index.css Normal file
View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

17
frontend/src/index.js Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
frontend/src/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

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

View File

@@ -1,58 +0,0 @@
use crate::schema;
use rocket::form::Form;
use rocket::serde::json::{Json, json, Value};
use rocket::http::Status;
use rocket::request::{self, Request, FromRequest};
use rocket::outcome::Outcome::{Success, Failure};
use rocket::response::{Redirect, Flash};
pub struct Referer(String);
#[derive(Debug)]
pub enum ReferrerError {
Missing,
MoreThanOne
}
#[derive(Debug, Responder)]
pub enum ApiResponse {
Status(Status),
Redirect(Redirect),
Value(Value),
Flash(Flash<Redirect>)
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Referer {
type Error = ReferrerError;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let referers : Vec<_> = req.headers().get("Referer").collect();
match referers.len() {
0 => Failure((Status::BadRequest, ReferrerError::Missing)),
1 => Success(Referer(referers[0].to_string())),
_ => Failure((Status::BadRequest, ReferrerError::MoreThanOne)),
}
}
}
#[get("/gamenights")]
pub async fn gamenights(conn: schema::DbConn) -> ApiResponse {
let gamenights = schema::get_all_gamenights(conn).await;
ApiResponse::Value(json!(gamenights))
}
#[post("/gamenight", format = "application/json", data = "<gamenight_json>")]
pub async fn gamenight_post_json(conn: schema::DbConn, gamenight_json: Json<schema::GameNightNoId>) -> ApiResponse {
schema::insert_gamenight(conn, gamenight_json.into_inner()).await;
ApiResponse::Status(Status::Accepted)
}
#[post("/gamenight", format = "application/x-www-form-urlencoded", data = "<gamenight_form>")]
pub async fn gamenight_post_form(referer: Option<Referer>, conn: schema::DbConn, gamenight_form: Form<schema::GameNightNoId>) -> ApiResponse {
schema::insert_gamenight(conn, gamenight_form.into_inner()).await;
match referer {
None => ApiResponse::Status(Status::Accepted),
Some(referer) => ApiResponse::Flash(Flash::success(Redirect::to(referer.0), "Added Gamenight."))
}
}

View File

@@ -1,20 +0,0 @@
#[macro_use] extern crate rocket;
#[macro_use] extern crate diesel_migrations;
#[macro_use] extern crate diesel;
use rocket::fairing::AdHoc;
use rocket_dyn_templates::Template;
mod api;
pub mod schema;
mod site;
#[launch]
fn rocket() -> _ {
rocket::build()
.attach(schema::DbConn::fairing())
.attach(Template::fairing())
.attach(AdHoc::on_ignite("Run Migrations", schema::run_migrations))
.mount("/", routes![site::index, site::gamenights, site::add_game_night])
.mount("/api", routes![api::gamenights, api::gamenight_post_form, api::gamenight_post_json])
}

View File

@@ -1,83 +0,0 @@
use rocket_sync_db_pools::database;
use serde::{Serialize, Deserialize};
use rocket::{Rocket, Build};
use diesel::RunQueryDsl;
#[database("gamenight_database")]
pub struct DbConn(diesel::SqliteConnection);
table! {
gamenight (id) {
id -> Integer,
game -> Text,
datetime -> Text,
}
}
table! {
known_games (game) {
id -> Integer,
game -> Text,
}
}
allow_tables_to_appear_in_same_query!(
gamenight,
known_games,
);
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 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(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,
}

View File

@@ -1,57 +0,0 @@
use serde::{Serialize, Deserialize};
use rocket_dyn_templates::Template;
use rocket::response::{Redirect};
use rocket::request::{FlashMessage};
use crate::schema;
#[derive(Serialize, Deserialize, Debug)]
struct FlashData {
has_data: bool,
kind: String,
message: String
}
#[derive(Serialize, Deserialize, Debug)]
struct GameNightsData {
gamenights: Vec::<schema::GameNight>,
flash: FlashData
}
#[get("/gamenights")]
pub async fn gamenights(conn: schema::DbConn) -> Template {
let gamenights = schema::get_all_gamenights(conn).await;
let data = GameNightsData {
gamenights: gamenights,
flash: FlashData { has_data: false, message: "".to_string(), kind: "".to_string() }
};
Template::render("gamenights", &data)
}
#[get("/")]
pub async fn index() -> Redirect {
Redirect::to(uri!(gamenights))
}
#[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 { has_data: false, message: "".to_string(), kind: "".to_string() },
Some(flash) => FlashData { has_data: true, message: flash.message().to_string(), kind: flash.kind().to_string() }
};
let data = GameNightAddData {
post_url: "/api/gamenight".to_string(),
flash: flash_data
};
Template::render("gamenight_add", &data)
}