diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b1780c5..e082e90 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -238,6 +238,7 @@ dependencies = [ "libc", "num-integer", "num-traits", + "serde", "time 0.1.44", "winapi 0.3.9", ] @@ -338,6 +339,7 @@ checksum = "b28135ecf6b7d446b43e27e225622a038cc4e2930a1022f51cdb97ada19b8e4d" dependencies = [ "bitflags", "byteorder", + "chrono", "diesel_derives", "pq-sys", "r2d2", @@ -598,6 +600,7 @@ name = "gamenight" version = "0.1.0" dependencies = [ "argon2", + "base64", "chrono", "diesel", "diesel-derive-enum", @@ -605,6 +608,7 @@ dependencies = [ "futures", "jsonwebtoken", "password-hash", + "rand", "rand_core", "rocket", "rocket_dyn_templates", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 36f4224..0bf45fe 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -10,10 +10,10 @@ edition = "2018" 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"]} +diesel = {version = "1.4.8", features = ["uuidv07", "r2d2", "postgres", "chrono"]} diesel_migrations = "1.4.0" diesel-derive-enum = { version = "1.1", features = ["postgres"] } -chrono = "0.4.19" +chrono = {version = "0.4.19", features = ["serde"] } serde = "1.0.136" password-hash = "0.4" argon2 = "0.4" @@ -21,4 +21,6 @@ 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" \ No newline at end of file +futures = "0.3.21" +rand = "0.8.5" +base64 = "0.13.0" \ No newline at end of file diff --git a/backend/migrations/2022-06-04-141858_registration_tokens/down.sql b/backend/migrations/2022-06-04-141858_registration_tokens/down.sql new file mode 100644 index 0000000..a238b3b --- /dev/null +++ b/backend/migrations/2022-06-04-141858_registration_tokens/down.sql @@ -0,0 +1,6 @@ +-- This file should undo anything in `up.sql` + +drop table registration_tokens; + +ALTER TABLE gamenight +ALTER datetime TYPE VARCHAR; \ No newline at end of file diff --git a/backend/migrations/2022-06-04-141858_registration_tokens/up.sql b/backend/migrations/2022-06-04-141858_registration_tokens/up.sql new file mode 100644 index 0000000..118a1e2 --- /dev/null +++ b/backend/migrations/2022-06-04-141858_registration_tokens/up.sql @@ -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; diff --git a/backend/src/api.rs b/backend/src/api.rs index 10c19a8..58fa305 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -1,8 +1,11 @@ +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}; @@ -31,6 +34,8 @@ pub enum ApiData { Gamenight(GamenightOutput), #[serde(rename = "games")] Games(Vec), + #[serde(rename = "registration_tokens")] + RegistrationTokens(Vec), } #[derive(Serialize, Deserialize, Debug)] @@ -94,6 +99,14 @@ impl ApiResponse { data: Some(ApiData::Games(games)), } } + + fn registration_tokens_response(tokens: Vec) -> Self { + Self { + result: Self::SUCCES_RESULT, + message: None, + data: Some(ApiData::RegistrationTokens(tokens)), + } + } } #[derive(Debug)] @@ -249,7 +262,7 @@ pub async fn gamenights_unauthorized() -> ApiResponseVariant { #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GamenightInput { pub name: String, - pub datetime: String, + pub datetime: DateTime, pub owner_id: Option, pub game_list: Vec, } @@ -474,3 +487,87 @@ pub async fn delete_participants( pub async fn delete_participants_unauthorized() -> ApiResponseVariant { ApiResponseVariant::Status(Status::Unauthorized) } + +#[derive(Deserialize)] +pub struct RegistrationTokenData { + single_use: bool, + expires: Option>, +} + +impl Into 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 = "" +)] +pub async fn add_registration_token( + conn: DbConn, + user: User, + token_json: Json, +) -> 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/")] +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) +} diff --git a/backend/src/main.rs b/backend/src/main.rs index cf809a2..d0fb8ad 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -68,6 +68,12 @@ async fn rocket() -> _ { 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, ], ); diff --git a/backend/src/schema/admin.rs b/backend/src/schema/admin.rs new file mode 100644 index 0000000..218a783 --- /dev/null +++ b/backend/src/schema/admin.rs @@ -0,0 +1,54 @@ +use crate::schema::{DatabaseError, DbConn}; +use chrono::DateTime; +use chrono::Utc; +use diesel::{QueryDsl, RunQueryDsl, ExpressionMethods}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +table! { + registration_tokens (id) { + id -> diesel::sql_types::Uuid, + token -> Char, + single_use -> Bool, + expires -> Nullable, + } +} + +#[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>, +} + +pub async fn get_all_registration_tokens( + conn: &DbConn, +) -> Result, DatabaseError> { + Ok(conn + .run(|c| registration_tokens::table.load::(c)) + .await?) +} + +pub async fn add_registration_token( + conn: &DbConn, + token: RegistrationToken, +) -> Result { + 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 { + Ok(conn + .run(move |c| { + diesel::delete(registration_tokens::table.filter(registration_tokens::id.eq(id))) + .execute(c) + }) + .await?) +} diff --git a/backend/src/schema/gamenight.rs b/backend/src/schema/gamenight.rs index b307fb5..fc90c69 100644 --- a/backend/src/schema/gamenight.rs +++ b/backend/src/schema/gamenight.rs @@ -1,5 +1,6 @@ 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; @@ -8,7 +9,7 @@ table! { gamenight (id) { id -> diesel::sql_types::Uuid, name -> VarChar, - datetime -> VarChar, + datetime -> Timestamptz, owner_id -> Uuid, } } @@ -46,7 +47,7 @@ pub struct Game { pub struct Gamenight { pub id: Uuid, pub name: String, - pub datetime: String, + pub datetime: DateTime, pub owner_id: Uuid, } diff --git a/backend/src/schema/mod.rs b/backend/src/schema/mod.rs index 60598b5..e4ae643 100644 --- a/backend/src/schema/mod.rs +++ b/backend/src/schema/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod gamenight; pub mod users; diff --git a/frontend/src/App.js b/frontend/src/App.js index 248c75d..00f2da9 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -4,32 +4,56 @@ 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 { 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 => { setUser(result.user); localStorage.setItem(localStorageUserKey, JSON.stringify(result.user)); - }); + }) + .then(() => setAppState('LoggedIn')) }; + useEffect(() => { + if(activeGamenightId !== null) { + setAppState('GamenightDetails'); + } else { + setAppState('LoggedIn') + } + + }, [activeGamenightId]) + const onLogout = () => { setUser(null); localStorage.removeItem(localStorageUserKey); + setAppState('LoggedOut') }; + const onAdmin = () => { + setAppState('AdminPanel') + } + + const onUser = () => { + setAppState('UserPage') + } + + const onReset = () => { + setAppState('LoggedIn') + } + const setFlash = (data) => { setFlashData(data); }; @@ -38,64 +62,67 @@ function App() { setUser({...user}); }; - const dismissActiveGamenight = () => { - setActiveGamenightId(null); - }; - useEffect(() => { - if (user !== null) { + if (appState === 'LoggedIn') { unpack_api_result(get_gamenights(user.jwt), setFlashData) .then(result => setGamenights(result.gamenights)); } - }, [user]) + }, [appState]) useEffect(() => { - if (user !== null) { + if (appState === 'LoggedIn') { unpack_api_result(get_games(user.jwt), setFlashData) .then(result => setGames(result.games)); } - }, [user]) + }, [appState]) useEffect(() => { setUser(JSON.parse(localStorage.getItem(localStorageUserKey))); }, []); - let page; - if(user === null) { - page = ( + let mainview; + if(appState === 'LoggedOut') { + return (
); - } else { - let mainview; - if(activeGamenightId === null) { - mainview = ( - setActiveGamenightId(g.id)}/> - ) - } else { - mainview = ( - setActiveGamenightId(null)} + setFlash={setFlash} + user={user} + />) + } else if(appState === 'LoggedIn') { + mainview = ( + ) - } - - page = ( - <> - - {mainview} - + games={games} + setFlash={setFlash} + refetchGamenights={refetchGamenights} + gamenights={gamenights} + onSelectGamenight={(g) => setActiveGamenightId(g.id)}/> + ); + } else if(appState === 'AdminPanel') { + mainview = ( + ); } + let page = ( + <> + + {mainview} + + ); return page; } diff --git a/frontend/src/api/Api.js b/frontend/src/api/Api.js index 3630501..35262d3 100644 --- a/frontend/src/api/Api.js +++ b/frontend/src/api/Api.js @@ -87,4 +87,33 @@ export function login(body) { }, 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}`, + } + }); } \ No newline at end of file diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx new file mode 100644 index 0000000..2a1b50b --- /dev/null +++ b/frontend/src/components/AdminPanel.jsx @@ -0,0 +1,178 @@ +import {useState, useEffect} 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 = () => { + if(props.user !== null) { + unpack_api_result(get_registration_tokens(props.user.jwt), props.setFlash) + .then(result => setRegistrationTokens(result.registration_tokens)); + } + } + + const deleteToken = (id) => { + if(props.user !== null) { + unpack_api_result(delete_registration_token(props.user.jwt, id), props.setFlash) + .then(() => 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(() => refetchTokens()) + } + } + + useEffect(() => { + 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 ( + <> + +
+
{ e.preventDefault(); }}> + }/> + + setIsSingleUse(e.target.checked)}/> + + + +
+
+ + + + + + {columns.map((column) => ( + + {column.label} + + ))} + + + + {registrationTokens + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((row) => { + return ( + + {columns.map((column) => { + const value = row[column.id]; + return ( + + {column.format + ? column.format(value) + : value} + + ); + })} + + { + e.stopPropagation(); + deleteToken(row.id) + }}> + + + + + ); + })} + +
+
+ {registrationTokens.length > rowsPerPage && + } + + ); +} + +export default AdminPanel diff --git a/frontend/src/components/MenuBar.jsx b/frontend/src/components/MenuBar.jsx index 573f77c..93258b5 100644 --- a/frontend/src/components/MenuBar.jsx +++ b/frontend/src/components/MenuBar.jsx @@ -7,6 +7,29 @@ import IconButton from '@mui/material/IconButton'; import MenuIcon from '@mui/icons-material/Menu'; function MenuBar(props) { + + let adminPanelButton = null; + if (props.user?.role === 'Admin') { + adminPanelButton = ( + + ); + } + + let userButton = null; + if (props.user != null) { + userButton = ( + + ); + } + return ( @@ -15,16 +38,19 @@ function MenuBar(props) { edge="start" color="inherit" aria-label="menu" - sx={{ mr: 2 }} - > + sx={{ mr: 2 }}> + sx={{ flexGrow: 1 }} + onClick={props.onReset}> Gamenight! + {userButton !== null && userButton} + {adminPanelButton !== null && adminPanelButton}