diff --git a/Cargo.lock b/Cargo.lock index c398deb..2d0019f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5377,6 +5377,7 @@ dependencies = [ "authdb", "axum", "axum-test", + "chrono", "cool_asserts", "futures", "include_dir", diff --git a/visions/server/Cargo.toml b/visions/server/Cargo.toml index 619ae02..491972d 100644 --- a/visions/server/Cargo.toml +++ b/visions/server/Cargo.toml @@ -10,6 +10,7 @@ async-std = { version = "1.13.0" } async-trait = { version = "0.1.83" } authdb = { path = "../../authdb/" } axum = { version = "0.7.9", features = [ "macros" ] } +chrono = { version = "0.4.39", features = ["serde"] } futures = { version = "0.3.31" } include_dir = { version = "0.7.4" } lazy_static = { version = "1.5.0" } diff --git a/visions/server/migrations/01-initial-db/up.sql b/visions/server/migrations/01-initial-db/up.sql index 8d0a733..c0ff1ae 100644 --- a/visions/server/migrations/01-initial-db/up.sql +++ b/visions/server/migrations/01-initial-db/up.sql @@ -3,7 +3,8 @@ CREATE TABLE users( name TEXT UNIQUE, password TEXT, admin BOOLEAN, - enabled BOOLEAN + enabled BOOLEAN, + password_expires TEXT ); CREATE TABLE sessions( @@ -39,4 +40,4 @@ CREATE TABLE roles( FOREIGN KEY(game_id) REFERENCES games(uuid) ); -INSERT INTO users VALUES ('admin', 'admin', '', true, true); +INSERT INTO users VALUES ('admin', 'admin', '', true, true, datetime('now')); diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index 055556e..ae0cb2e 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -1,16 +1,18 @@ use std::{collections::HashMap, sync::Arc}; use async_std::sync::RwLock; +use chrono::{DateTime, TimeDelta, Utc}; use mime::Mime; use result_extended::{error, fatal, ok, return_error, ResultExt}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use typeshare::typeshare; use uuid::Uuid; use crate::{ asset_db::{self, AssetId, Assets}, - database::{CharacterId, Database, GameId, SessionId, UserId}, types::{AppError, FatalError, GameOverview, Message, Rgb, Tabletop, User, UserProfile}, + database::{CharacterId, Database, GameId, SessionId, UserId}, + types::{AppError, FatalError, GameOverview, Message, Rgb, Tabletop, User, UserOverview}, }; const DEFAULT_BACKGROUND_COLOR: Rgb = Rgb { @@ -25,6 +27,14 @@ pub struct Status { pub admin_enabled: bool, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", content = "content")] +#[typeshare] +pub enum AuthResponse { + Success(SessionId), + Expired(SessionId), +} + #[derive(Debug)] struct WebsocketClient { sender: Option>, @@ -117,28 +127,41 @@ impl Core { } } - pub async fn list_users(&self) -> ResultExt, AppError, FatalError> { + pub async fn list_users(&self) -> ResultExt, AppError, FatalError> { let users = self.0.write().await.db.users().await; match users { - Ok(users) => ok(users.into_iter().map(User::from).collect()), + Ok(users) => ok(users + .into_iter() + .map(|user| UserOverview { + id: user.id, + name: user.name, + is_admin: user.admin, + games: vec![], + }) + .collect()), Err(err) => fatal(err), } } - pub async fn user(&self, user_id: UserId) -> ResultExt, AppError, FatalError> { + pub async fn user( + &self, + user_id: UserId, + ) -> ResultExt, AppError, FatalError> { let users = return_error!(self.list_users().await); let games = return_error!(self.list_games().await); let user = match users.into_iter().find(|user| user.id == user_id) { Some(user) => user, None => return ok(None), }; - let user_games = games.into_iter().filter(|g| g.gm == user.id).collect(); - ok(Some(UserProfile { - id: user.id, + ok(Some(UserOverview { + id: user.id.clone(), name: user.name, - password: user.password, - games: user_games, - is_admin: user.admin, + is_admin: user.is_admin, + games: games + .into_iter() + .filter(|g| g.gm == user.id) + .map(|g| g.id) + .collect(), })) } @@ -146,13 +169,21 @@ impl Core { let state = self.0.read().await; match return_error!(self.user_by_username(username).await) { Some(_) => error(AppError::UsernameUnavailable), - None => match state.db.save_user(None, username, "", false, true).await { + None => match state + .db + .save_user(None, username, "", false, true, Utc::now()) + .await + { Ok(user_id) => ok(user_id), Err(err) => fatal(err), }, } } + pub async fn disable_user(&self, userid: UserId) -> ResultExt<(), AppError, FatalError> { + unimplemented!(); + } + pub async fn list_games(&self) -> ResultExt, AppError, FatalError> { let games = self.0.read().await.db.games().await; match games { @@ -162,7 +193,12 @@ impl Core { } } - pub async fn create_game(&self, gm: &UserId, game_type: &str, game_name: &str) -> ResultExt { + pub async fn create_game( + &self, + gm: &UserId, + game_type: &str, + game_name: &str, + ) -> ResultExt { let state = self.0.read().await; match state.db.save_game(None, gm, game_type, game_name).await { Ok(game_id) => ok(game_id), @@ -256,7 +292,7 @@ impl Core { let state = self.0.read().await; match state .db - .save_user(uuid, username, password, admin, enabled) + .save_user(uuid, username, password, admin, enabled, Utc::now()) .await { Ok(uuid) => ok(uuid), @@ -277,7 +313,14 @@ impl Core { }; match state .db - .save_user(Some(uuid), &user.name, &password, user.admin, user.enabled) + .save_user( + Some(uuid), + &user.name, + &password, + user.admin, + user.enabled, + Utc::now(), + ) .await { Ok(_) => ok(()), @@ -289,19 +332,35 @@ impl Core { &self, username: &str, password: &str, - ) -> ResultExt { - let state = self.0.write().await; + ) -> ResultExt { + let now = Utc::now(); + let state = self.0.read().await; match state.db.user_by_username(username).await { - Ok(Some(row)) if (row.password == password) => { - let session_id = state.db.create_session(&row.id).await.unwrap(); - ok(session_id) + Ok(Some(row)) + if (row.password == password) && row.enabled && row.password_expires.0 <= now => + { + match state.db.create_session(&row.id).await { + Ok(session_id) => ok(AuthResponse::Success(session_id)), + Err(err) => fatal(err), + } + } + Ok(Some(row)) + if (row.password == password) && row.enabled && row.password_expires.0 > now => + { + match state.db.create_session(&row.id).await { + Ok(session_id) => ok(AuthResponse::Expired(session_id)), + Err(err) => fatal(err), + } } Ok(_) => error(AppError::AuthFailed), Err(err) => fatal(err), } } - pub async fn session(&self, session_id: &SessionId) -> ResultExt, AppError, FatalError> { + pub async fn session( + &self, + session_id: &SessionId, + ) -> ResultExt, AppError, FatalError> { let state = self.0.read().await; match state.db.session(session_id).await { Ok(Some(user_row)) => ok(Some(User::from(user_row))), @@ -311,6 +370,10 @@ impl Core { } } +fn create_expiration_date() -> DateTime { + Utc::now() + TimeDelta::days(365) +} + #[cfg(test)] mod test { use std::path::PathBuf; @@ -351,10 +414,17 @@ mod test { ]); let memory_db: Option = None; let conn = DbConn::new(memory_db); - conn.save_user(Some(UserId::from("admin")), "admin", "aoeu", true, true) - .await - .unwrap(); - conn.save_user(None, "gm_1", "aoeu", false, true) + conn.save_user( + Some(UserId::from("admin")), + "admin", + "aoeu", + true, + true, + Utc::now(), + ) + .await + .unwrap(); + conn.save_user(None, "gm_1", "aoeu", false, true, Utc::now()) .await .unwrap(); Core::new(assets, conn) @@ -424,7 +494,7 @@ mod test { async fn it_creates_a_sessionid_on_successful_auth() { let core = test_core().await; match core.auth("admin", "aoeu").await { - ResultExt::Ok(session_id) => { + ResultExt::Ok(AuthResponse::Success(session_id)) => { let st = core.0.read().await; match st.db.session(&session_id).await { Ok(Some(user_row)) => assert_eq!(user_row.name, "admin"), @@ -432,6 +502,7 @@ mod test { Err(err) => panic!("{}", err), } } + ResultExt::Ok(AuthResponse::Expired(_)) => panic!("user has expired"), ResultExt::Err(err) => panic!("{}", err), ResultExt::Fatal(err) => panic!("{}", err), } diff --git a/visions/server/src/database/disk_db.rs b/visions/server/src/database/disk_db.rs index 5cc98eb..670ed82 100644 --- a/visions/server/src/database/disk_db.rs +++ b/visions/server/src/database/disk_db.rs @@ -1,6 +1,7 @@ use std::path::Path; use async_std::channel::Receiver; +use chrono::Utc; use include_dir::{include_dir, Dir}; use lazy_static::lazy_static; use rusqlite::Connection; @@ -12,7 +13,8 @@ use crate::{ }; use super::{ - types::GameId, CharacterId, CharsheetRow, DatabaseRequest, GameRow, SessionId, UserId, UserRow + types::{DateTime, GameId}, + CharacterId, CharsheetRow, DatabaseRequest, GameRow, SessionId, UserId, UserRow, }; static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); @@ -48,7 +50,7 @@ impl DiskDb { pub fn user(&self, id: &UserId) -> Result, FatalError> { let mut stmt = self .conn - .prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE uuid=?") + .prepare("SELECT * FROM users WHERE uuid=?") .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; let items: Vec = stmt .query_map([id.as_str()], |row| { @@ -58,6 +60,7 @@ impl DiskDb { password: row.get(2).unwrap(), admin: row.get(3).unwrap(), enabled: row.get(4).unwrap(), + password_expires: row.get(5).unwrap(), }) }) .unwrap() @@ -73,7 +76,7 @@ impl DiskDb { pub fn user_by_username(&self, username: &str) -> Result, FatalError> { let mut stmt = self .conn - .prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE name=?") + .prepare("SELECT * FROM users WHERE name=?") .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; let items: Vec = stmt .query_map([username], |row| { @@ -83,6 +86,7 @@ impl DiskDb { password: row.get(2).unwrap(), admin: row.get(3).unwrap(), enabled: row.get(4).unwrap(), + password_expires: row.get(5).unwrap(), }) }) .unwrap() @@ -102,25 +106,40 @@ impl DiskDb { password: &str, admin: bool, enabled: bool, + expiration: chrono::DateTime, ) -> Result { match user_id { None => { let user_id = UserId::default(); let mut stmt = self .conn - .prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)") + .prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?, ?)") .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((user_id.as_str(), name, password, admin, enabled)) - .unwrap(); + stmt.execute(( + user_id.as_str(), + name, + password, + admin, + enabled, + format!("{}", expiration.format("%Y-%m-%d %H:%M:%S")), + )) + .unwrap(); Ok(user_id) } Some(user_id) => { let mut stmt = self .conn - .prepare("UPDATE users SET name=?, password=?, admin=?, enabled=? WHERE uuid=?") + .prepare("UPDATE users SET name=?, password=?, admin=?, enabled=?, password_expires=? WHERE uuid=?") .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((name, password, admin, enabled, user_id.as_str())) - .unwrap(); + stmt.execute(( + name, + password, + admin, + enabled, + format!("{}", expiration.format("%Y-%m-%d %H:%M:%S")), + user_id.as_str(), + )) + .unwrap(); Ok(user_id) } } @@ -139,6 +158,7 @@ impl DiskDb { password: row.get(2).unwrap(), admin: row.get(3).unwrap(), enabled: row.get(4).unwrap(), + password_expires: row.get(5).unwrap(), }) }) .unwrap() @@ -210,6 +230,7 @@ impl DiskDb { password: row.get(2).unwrap(), admin: row.get(3).unwrap(), enabled: row.get(4).unwrap(), + password_expires: row.get(5).unwrap(), }) }) .unwrap() @@ -316,12 +337,10 @@ pub async fn db_handler(db: DiskDb, requestor: Receiver) { .await .unwrap(); } - Request::Games => { - match db.games() { - Ok(games) => tx.send(DatabaseResponse::Games(games)).await.unwrap(), - _ => unimplemented!("errors for Request::Games"), - } - } + Request::Games => match db.games() { + Ok(games) => tx.send(DatabaseResponse::Games(games)).await.unwrap(), + _ => unimplemented!("errors for Request::Games"), + }, Request::Game(_game_id) => { unimplemented!("Request::Game handler"); } @@ -350,13 +369,14 @@ pub async fn db_handler(db: DiskDb, requestor: Receiver) { err => panic!("{:?}", err), } } - Request::SaveUser(user_id, username, password, admin, enabled) => { + Request::SaveUser(user_id, username, password, admin, enabled, expiration) => { let user_id = db.save_user( user_id, username.as_ref(), password.as_ref(), admin, enabled, + expiration, ); match user_id { Ok(user_id) => { diff --git a/visions/server/src/database/mod.rs b/visions/server/src/database/mod.rs index a3ef97f..84133e2 100644 --- a/visions/server/src/database/mod.rs +++ b/visions/server/src/database/mod.rs @@ -5,6 +5,7 @@ use std::path::Path; use async_std::channel::{bounded, Sender}; use async_trait::async_trait; +use chrono::{DateTime, Utc}; use disk_db::{db_handler, DiskDb}; pub use types::{CharacterId, CharsheetRow, GameId, GameRow, SessionId, UserId, UserRow}; @@ -17,7 +18,7 @@ enum Request { Games, Game(GameId), SaveGame(Option, UserId, String, String), - SaveUser(Option, String, String, bool, bool), + SaveUser(Option, String, String, bool, bool, DateTime), Session(SessionId), User(UserId), UserByUsername(String), @@ -58,6 +59,7 @@ pub trait Database: Send + Sync { password: &str, admin: bool, enabled: bool, + expiration: DateTime, ) -> Result; async fn games(&self) -> Result, FatalError>; @@ -142,6 +144,7 @@ impl Database for DbConn { password: &str, admin: bool, enabled: bool, + expiration: DateTime, ) -> Result { send_request!(self, Request::SaveUser( @@ -150,6 +153,7 @@ impl Database for DbConn { password.to_owned(), admin, enabled, + expiration, ), DatabaseResponse::SaveUser(user_id) => Ok(user_id)) } @@ -201,7 +205,9 @@ mod test { let no_path: Option = None; let db = DiskDb::new(no_path).unwrap(); - db.save_user(Some(UserId::from("admin")), "admin", "abcdefg", true, true) + let now = Utc::now(); + + db.save_user(Some(UserId::from("admin")), "admin", "abcdefg", true, true, now) .unwrap(); let game_id = db.save_game(None, &UserId::from("admin"), "Candela", "Circle of the Winter Solstice").unwrap(); (db, game_id) diff --git a/visions/server/src/database/types.rs b/visions/server/src/database/types.rs index 6a24996..680ee87 100644 --- a/visions/server/src/database/types.rs +++ b/visions/server/src/database/types.rs @@ -1,6 +1,7 @@ use std::fmt; -use rusqlite::types::{FromSql, FromSqlResult, ValueRef}; +use chrono::{NaiveDateTime, Utc}; +use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef}; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use uuid::Uuid; @@ -166,6 +167,7 @@ pub struct UserRow { pub password: String, pub admin: bool, pub enabled: bool, + pub password_expires: DateTime, } #[derive(Clone, Debug)] @@ -196,5 +198,20 @@ pub struct SessionRow { user_id: SessionId, } +#[derive(Clone, Debug)] +pub struct DateTime(pub chrono::DateTime); - +impl FromSql for DateTime { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + match value { + ValueRef::Text(text) => String::from_utf8(text.to_vec()) + .map_err(|_err| FromSqlError::InvalidType) + .and_then(|s| { + NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S") + .map_err(|_err| FromSqlError::InvalidType) + }) + .and_then(|dt| Ok(DateTime(dt.and_utc()))), + _ => Err(FromSqlError::InvalidType), + } + } +} diff --git a/visions/server/src/handlers/user_management.rs b/visions/server/src/handlers/user_management.rs index d770649..d211053 100644 --- a/visions/server/src/handlers/user_management.rs +++ b/visions/server/src/handlers/user_management.rs @@ -8,9 +8,9 @@ use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::{ - core::Core, + core::{AuthResponse, Core}, database::{SessionId, UserId}, - types::{AppError, FatalError, User, UserProfile}, + types::{AppError, FatalError, User, UserOverview}, }; #[derive(Clone, Debug, Deserialize, Serialize)] @@ -99,16 +99,20 @@ where pub async fn check_password( core: Core, req: Json, -) -> ResultExt { +) -> ResultExt { let Json(AuthRequest { username, password }) = req; - core.auth(&username, &password).await + unimplemented!() + /* + match core.auth(&username, &password).await { + } + */ } pub async fn get_user( core: Core, headers: HeaderMap, user_id: Option, -) -> ResultExt, AppError, FatalError> { +) -> ResultExt, AppError, FatalError> { auth_required(core.clone(), headers, |user| async move { match user_id { Some(user_id) => core.user(user_id).await, @@ -117,6 +121,15 @@ pub async fn get_user( }).await } +pub async fn get_users( + core: Core, + headers: HeaderMap, +) -> ResultExt, AppError, FatalError> { + auth_required(core.clone(), headers, |_user| async move { + core.list_users().await + }).await +} + pub async fn create_user( core: Core, headers: HeaderMap, @@ -140,14 +153,3 @@ pub async fn set_password( } }).await } - -pub async fn set_admin_password( - core: Core, - req: String, -) -> ResultExt<(), AppError, FatalError> { - match return_error!(core.user(UserId::from("admin")).await) { - Some(admin) if admin.password.is_empty() => core.set_password(UserId::from("admin"), req).await, - Some(_) => error(AppError::PermissionDenied), - None => fatal(FatalError::DatabaseKeyMissing), - } -} diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs index 25c3a4c..9c2a231 100644 --- a/visions/server/src/routes.rs +++ b/visions/server/src/routes.rs @@ -13,9 +13,8 @@ use crate::{ core::Core, database::UserId, handlers::{ - check_password, create_game, create_user, get_user, healthcheck, set_admin_password, - set_password, wrap_handler, AuthRequest, CreateGameRequest, CreateUserRequest, - SetAdminPasswordRequest, SetPasswordRequest, + check_password, create_game, create_user, get_user, get_users, healthcheck, set_password, + wrap_handler, AuthRequest, CreateGameRequest, CreateUserRequest, SetPasswordRequest, }, }; @@ -37,23 +36,6 @@ pub fn routes(core: Core) -> Router { .allow_origin(Any), ), ) - .route( - "/api/v1/admin_password", - put({ - let core = core.clone(); - move |req: Json| { - let Json(req) = req; - println!("set admin password: {:?}", req); - wrap_handler(|| set_admin_password(core, req)) - } - }) - .layer( - CorsLayer::new() - .allow_methods([Method::PUT]) - .allow_headers([CONTENT_TYPE]) - .allow_origin(Any), - ), - ) .route( "/api/v1/auth", post({ @@ -88,6 +70,19 @@ pub fn routes(core: Core) -> Router { } }), ) + .route( + "/api/v1/users", + get({ + let core = core.clone(); + move |headers: HeaderMap| wrap_handler(|| get_users(core, headers)) + }) + .layer( + CorsLayer::new() + .allow_methods([Method::GET]) + .allow_headers([AUTHORIZATION]) + .allow_origin(Any), + ), + ) .route( "/api/v1/user/password", put({ @@ -132,13 +127,13 @@ mod test { use super::*; use crate::{ asset_db::FsAssets, - core::Core, + core::{AuthResponse, Core}, database::{Database, DbConn, GameId, SessionId, UserId}, handlers::CreateGameRequest, - types::UserProfile, + types::UserOverview, }; - fn setup_without_admin() -> (Core, TestServer) { + fn initialize_test_server() -> (Core, TestServer) { let memory_db: Option = None; let conn = DbConn::new(memory_db); let core = Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); @@ -147,20 +142,26 @@ mod test { (core, server) } - async fn setup_admin_enabled() -> (Core, TestServer) { - let memory_db: Option = None; - let conn = DbConn::new(memory_db); - conn.save_user(Some(UserId::from("admin")), "admin", "aoeu", true, true) - .await - .unwrap(); - let core = Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); - let app = routes(core.clone()); - let server = TestServer::new(app).unwrap(); + async fn setup_with_admin() -> (Core, TestServer) { + let (core, server) = initialize_test_server(); + core.set_password(UserId::from("admin"), "aoeu".to_owned()) + .await; + (core, server) + } + + async fn setup_with_disabled_user() -> (Core, TestServer) { + let (core, server) = setup_with_admin().await; + let uuid = match core.create_user("shephard").await { + ResultExt::Ok(uuid) => uuid, + ResultExt::Err(err) => panic!("{}", err), + ResultExt::Fatal(err) => panic!("{}", err), + }; + core.disable_user(uuid).await; (core, server) } async fn setup_with_user() -> (Core, TestServer) { - let (core, server) = setup_admin_enabled().await; + let (core, server) = setup_with_admin().await; let response = server .post("/api/v1/auth") .json(&AuthRequest { @@ -195,18 +196,7 @@ mod test { #[tokio::test] async fn it_returns_a_healthcheck() { - let (core, server) = setup_without_admin(); - - let response = server.get("/api/v1/health").await; - response.assert_status_ok(); - let b: crate::handlers::HealthCheck = response.json(); - assert_eq!(b, crate::handlers::HealthCheck { ok: false }); - - assert_matches!( - core.save_user(Some(UserId::from("admin")), "admin", "aoeu", true, true) - .await, - ResultExt::Ok(_) - ); + let (_core, server) = initialize_test_server(); let response = server.get("/api/v1/health").await; response.assert_status_ok(); @@ -214,9 +204,55 @@ mod test { assert_eq!(b, crate::handlers::HealthCheck { ok: true }); } + #[tokio::test] + async fn a_new_user_has_an_expired_password() { + let (_core, server) = setup_with_admin().await; + + let response = server + .post("/api/v1/auth") + .add_header("Content-Type", "application/json") + .json(&AuthRequest{ username: "admin".to_owned(), password: "aoeu".to_owned() }) + .await; + response.assert_status_ok(); + let session_id = response.json::>().unwrap(); + + let session_id = match session_id { + AuthResponse::Success(session_id) => session_id, + AuthResponse::Expired(_) => panic!("admin user is already expired"), + }; + + let response = server + .put("/api/v1/user") + .add_header("Authorization", format!("Bearer {}", session_id)) + .json("savanni") + .await; + response.assert_status_ok(); + + let response = server + .post("/api/v1/auth") + .add_header("Content-Type", "application/json") + .json(&AuthRequest{ username: "savanni".to_owned(), password: "".to_owned() }) + .await; + response.assert_status_ok(); + let session = response.json::>().unwrap(); + assert_matches!(session, AuthResponse::Expired(_)); + } + + #[tokio::test] + async fn it_refuses_to_authenticate_a_disabled_user() { + let (_core, server) = setup_with_disabled_user().await; + unimplemented!() + } + + #[tokio::test] + async fn it_forces_changing_expired_password() { + let (_core, server) = setup_with_user().await; + unimplemented!() + } + #[tokio::test] async fn it_authenticates_a_user() { - let (_core, server) = setup_admin_enabled().await; + let (_core, server) = setup_with_admin().await; let response = server .post("/api/v1/auth") @@ -250,7 +286,7 @@ mod test { #[tokio::test] async fn it_returns_user_profile() { - let (_core, server) = setup_admin_enabled().await; + let (_core, server) = setup_with_admin().await; let response = server.get("/api/v1/user").await; response.assert_status(StatusCode::UNAUTHORIZED); @@ -271,19 +307,12 @@ mod test { .add_header("Authorization", format!("Bearer {}", session_id)) .await; response.assert_status_ok(); - let profile: Option = response.json(); + let profile: Option = response.json(); let profile = profile.unwrap(); assert_eq!(profile.id, UserId::from("admin")); assert_eq!(profile.name, "admin"); } - #[tokio::test] - async fn an_admin_can_create_a_user() { - // All of the contents of this test are basically required for any test on individual - // users, so I moved it all into the setup code. - let (_core, _server) = setup_with_user().await; - } - #[tokio::test] async fn a_user_can_get_any_user_profile() { let (core, server) = setup_with_user().await; @@ -310,7 +339,7 @@ mod test { .add_header("Authorization", format!("Bearer {}", session_id)) .await; response.assert_status_ok(); - let profile: Option = response.json(); + let profile: Option = response.json(); let profile = profile.unwrap(); assert_eq!(profile.name, "savanni"); @@ -319,7 +348,7 @@ mod test { .add_header("Authorization", format!("Bearer {}", session_id)) .await; response.assert_status_ok(); - let profile: Option = response.json(); + let profile: Option = response.json(); let profile = profile.unwrap(); assert_eq!(profile.name, "admin"); } @@ -342,7 +371,7 @@ mod test { .get("/api/v1/user") .add_header("Authorization", format!("Bearer {}", session_id)) .await; - let profile = response.json::>().unwrap(); + let profile = response.json::>().unwrap(); assert_eq!(profile.name, "savanni"); let response = server diff --git a/visions/server/src/types.rs b/visions/server/src/types.rs index 4a3e8c0..afe183e 100644 --- a/visions/server/src/types.rs +++ b/visions/server/src/types.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -79,6 +80,7 @@ pub struct User { pub password: String, pub admin: bool, pub enabled: bool, + pub expiration: DateTime, } impl From for User { @@ -89,6 +91,7 @@ impl From for User { password: row.password.to_owned(), admin: row.admin, enabled: row.enabled, + expiration: row.password_expires.0, } } } @@ -135,12 +138,11 @@ pub enum Message { #[derive(Deserialize, Serialize)] #[typeshare] -pub struct UserProfile { +pub struct UserOverview { pub id: UserId, pub name: String, - pub password: String, - pub games: Vec, pub is_admin: bool, + pub games: Vec, } #[derive(Deserialize, Serialize)] diff --git a/visions/ui/src/client.ts b/visions/ui/src/client.ts index f2a30f9..81c1a12 100644 --- a/visions/ui/src/client.ts +++ b/visions/ui/src/client.ts @@ -48,10 +48,13 @@ export class Client { return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(name) }); } - async users() { + async users(sessionId: string) { const url = new URL(this.base); url.pathname = '/api/v1/users/'; - return fetch(url).then((response) => response.json()); + return fetch(url, { + method: 'GET', + headers: [['Authorization', `Bearer ${sessionId}`]] + }).then((response) => response.json()); } async charsheet(id: string) { diff --git a/visions/ui/src/components/Profile/Profile.css b/visions/ui/src/components/Profile/Profile.css index e69de29..03fd3a6 100644 --- a/visions/ui/src/components/Profile/Profile.css +++ b/visions/ui/src/components/Profile/Profile.css @@ -0,0 +1,12 @@ +.profile { + margin: var(--margin-s); +} + +.profile_columns { + display: flex; + justify-content: space-between; +} + +.profile_columns > div { + width: 45%; +} diff --git a/visions/ui/src/components/Profile/Profile.tsx b/visions/ui/src/components/Profile/Profile.tsx index edbe93b..162b701 100644 --- a/visions/ui/src/components/Profile/Profile.tsx +++ b/visions/ui/src/components/Profile/Profile.tsx @@ -1,30 +1,28 @@ import { UserProfile } from 'visions-types'; -import { CardElement } from '../Card/Card'; -import { GameOverviewElement } from '../GameOverview/GameOverview'; +import { CardElement, GameOverviewElement, UserManagementElement } from '..'; +import './Profile.css'; -export const ProfileElement = ({ name, games, is_admin }: UserProfile) => { - const adminNote = is_admin ?
Note: this user is an admin
: <>; +interface ProfileProps { + profile: UserProfile, + users: UserProfile[], +} - return (
- -
Games: {games.map((game) => { +export const ProfileElement = ({ profile, users }: ProfileProps) => { + const adminNote = profile.is_admin ?
Note: this user is an admin
: <>; + + return (
+ +
Games: {profile.games.map((game) => { return {game.game_name} ({game.game_type}); }) }
{adminNote}
-
- -
    -
  • Savanni
  • -
  • Shephard
  • -
  • Vakarian
  • -
  • vas Normandy
  • -
-
+
+
- {games.map((game) => )} + {profile.games.map((game) => )}
) diff --git a/visions/ui/src/components/UserManagement/UserManagement.css b/visions/ui/src/components/UserManagement/UserManagement.css new file mode 100644 index 0000000..e69de29 diff --git a/visions/ui/src/components/UserManagement/UserManagement.tsx b/visions/ui/src/components/UserManagement/UserManagement.tsx new file mode 100644 index 0000000..14ef213 --- /dev/null +++ b/visions/ui/src/components/UserManagement/UserManagement.tsx @@ -0,0 +1,16 @@ +import { UserProfile } from "visions-types" +import { CardElement } from ".." + +interface UserManagementProps { + users: UserProfile[] +} + +export const UserManagementElement = ({ users }: UserManagementProps ) => { + return ( + +
    + {users.map((user) =>
  • {user.name}
  • )} +
+
+ ) +} diff --git a/visions/ui/src/components/index.ts b/visions/ui/src/components/index.ts index f777d4c..e9b3455 100644 --- a/visions/ui/src/components/index.ts +++ b/visions/ui/src/components/index.ts @@ -4,5 +4,6 @@ import { ProfileElement } from './Profile/Profile' import { SimpleGuage } from './Guages/SimpleGuage' import { ThumbnailElement } from './Thumbnail/Thumbnail' import { TabletopElement } from './Tabletop/Tabletop' +import { UserManagementElement } from './UserManagement/UserManagement' -export { CardElement, ProfileElement, ThumbnailElement, TabletopElement, SimpleGuage } +export { CardElement, GameOverviewElement, UserManagementElement, ProfileElement, ThumbnailElement, TabletopElement, SimpleGuage } diff --git a/visions/ui/src/views/Admin/Admin.tsx b/visions/ui/src/views/Admin/Admin.tsx index 086faf5..c588c16 100644 --- a/visions/ui/src/views/Admin/Admin.tsx +++ b/visions/ui/src/views/Admin/Admin.tsx @@ -32,7 +32,7 @@ export const Admin = ({ client }: AdminProps) => { const [users, setUsers] = useState>([]); useEffect(() => { - client.users().then((u) => { + client.users("aoeu").then((u) => { console.log(u); setUsers(u); }); diff --git a/visions/ui/src/views/Main/Main.tsx b/visions/ui/src/views/Main/Main.tsx index 89d69ec..14f1126 100644 --- a/visions/ui/src/views/Main/Main.tsx +++ b/visions/ui/src/views/Main/Main.tsx @@ -11,18 +11,19 @@ interface MainProps { export const MainView = ({ client }: MainProps) => { const [state, _manager] = useContext(StateContext) const [profile, setProfile] = useState(undefined) + const [users, setUsers] = useState([]) const sessionId = getSessionId(state) useEffect(() => { if (sessionId) { client.profile(sessionId, undefined).then((profile) => setProfile(profile)) + client.users(sessionId).then((users) => setUsers(users)) } }, [sessionId, client]) return (
-
Session ID: {sessionId}
- {profile && } + {profile && }
) } diff --git a/visions/visions-types/package-lock.json b/visions/visions-types/package-lock.json index 6e7699d..0004a4d 100644 --- a/visions/visions-types/package-lock.json +++ b/visions/visions-types/package-lock.json @@ -9,13 +9,13 @@ "version": "0.0.1", "license": "ISC", "dependencies": { - "typescript": "^5.7.2" + "typescript": "^5.7.3" } }, "node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/visions/visions-types/package.json b/visions/visions-types/package.json index 25aa15c..6d972c1 100644 --- a/visions/visions-types/package.json +++ b/visions/visions-types/package.json @@ -9,6 +9,6 @@ "author": "", "license": "ISC", "dependencies": { - "typescript": "^5.7.2" + "typescript": "^5.7.3" } }