Create a user expiration time and make new users immediately expired

This is to give the ability to force the user to create a new password as soon as they log in.
This commit is contained in:
Savanni D'Gerinel 2025-01-05 17:16:47 -05:00
parent f9e903da54
commit d0ba8d921d
20 changed files with 334 additions and 153 deletions

1
Cargo.lock generated
View File

@ -5377,6 +5377,7 @@ dependencies = [
"authdb",
"axum",
"axum-test",
"chrono",
"cool_asserts",
"futures",
"include_dir",

View File

@ -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" }

View File

@ -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'));

View File

@ -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<UnboundedSender<Message>>,
@ -117,28 +127,41 @@ impl Core {
}
}
pub async fn list_users(&self) -> ResultExt<Vec<User>, AppError, FatalError> {
pub async fn list_users(&self) -> ResultExt<Vec<UserOverview>, 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<Option<UserProfile>, AppError, FatalError> {
pub async fn user(
&self,
user_id: UserId,
) -> ResultExt<Option<UserOverview>, 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<Vec<GameOverview>, 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<GameId, AppError, FatalError> {
pub async fn create_game(
&self,
gm: &UserId,
game_type: &str,
game_name: &str,
) -> ResultExt<GameId, AppError, FatalError> {
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<SessionId, AppError, FatalError> {
let state = self.0.write().await;
) -> ResultExt<AuthResponse, AppError, FatalError> {
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<Option<User>, AppError, FatalError> {
pub async fn session(
&self,
session_id: &SessionId,
) -> ResultExt<Option<User>, 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> {
Utc::now() + TimeDelta::days(365)
}
#[cfg(test)]
mod test {
use std::path::PathBuf;
@ -351,10 +414,17 @@ mod test {
]);
let memory_db: Option<PathBuf> = 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),
}

View File

@ -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<Option<UserRow>, 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<UserRow> = 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<Option<UserRow>, 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<UserRow> = 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<Utc>,
) -> Result<UserId, FatalError> {
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<DatabaseRequest>) {
.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<DatabaseRequest>) {
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) => {

View File

@ -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<GameId>, UserId, String, String),
SaveUser(Option<UserId>, String, String, bool, bool),
SaveUser(Option<UserId>, String, String, bool, bool, DateTime<Utc>),
Session(SessionId),
User(UserId),
UserByUsername(String),
@ -58,6 +59,7 @@ pub trait Database: Send + Sync {
password: &str,
admin: bool,
enabled: bool,
expiration: DateTime<Utc>,
) -> Result<UserId, FatalError>;
async fn games(&self) -> Result<Vec<GameRow>, FatalError>;
@ -142,6 +144,7 @@ impl Database for DbConn {
password: &str,
admin: bool,
enabled: bool,
expiration: DateTime<Utc>,
) -> Result<UserId, FatalError> {
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<PathBuf> = 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)

View File

@ -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<Utc>);
impl FromSql for DateTime {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
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),
}
}
}

View File

@ -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<AuthRequest>,
) -> ResultExt<SessionId, AppError, FatalError> {
) -> ResultExt<AuthResponse, AppError, FatalError> {
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<UserId>,
) -> ResultExt<Option<UserProfile>, AppError, FatalError> {
) -> ResultExt<Option<UserOverview>, 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<Vec<UserOverview>, 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),
}
}

View File

@ -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<String>| {
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<PathBuf> = 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<PathBuf> = 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::<Option<AuthResponse>>().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::<Option<AuthResponse>>().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<UserProfile> = response.json();
let profile: Option<UserOverview> = 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<UserProfile> = response.json();
let profile: Option<UserOverview> = 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<UserProfile> = response.json();
let profile: Option<UserOverview> = 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::<Option<UserProfile>>().unwrap();
let profile = response.json::<Option<UserOverview>>().unwrap();
assert_eq!(profile.name, "savanni");
let response = server

View File

@ -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<Utc>,
}
impl From<UserRow> for User {
@ -89,6 +91,7 @@ impl From<UserRow> 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<GameOverview>,
pub is_admin: bool,
pub games: Vec<GameId>,
}
#[derive(Deserialize, Serialize)]

View File

@ -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) {

View File

@ -0,0 +1,12 @@
.profile {
margin: var(--margin-s);
}
.profile_columns {
display: flex;
justify-content: space-between;
}
.profile_columns > div {
width: 45%;
}

View File

@ -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 ? <div> <i>Note: this user is an admin</i> </div> : <></>;
interface ProfileProps {
profile: UserProfile,
users: UserProfile[],
}
return (<div>
<CardElement name={name}>
<div>Games: {games.map((game) => {
export const ProfileElement = ({ profile, users }: ProfileProps) => {
const adminNote = profile.is_admin ? <div> <i>Note: this user is an admin</i> </div> : <></>;
return (<div className="profile">
<CardElement name={profile.name}>
<div>Games: {profile.games.map((game) => {
return <span key={game.id}>{game.game_name} ({game.game_type})</span>;
}) }</div>
{adminNote}
</CardElement>
<div>
<CardElement>
<ul>
<li> Savanni </li>
<li> Shephard </li>
<li> Vakarian </li>
<li> vas Normandy </li>
</ul>
</CardElement>
<div className="profile_columns">
<UserManagementElement users={users} />
<div>
{games.map((game) => <GameOverviewElement {...game} />)}
{profile.games.map((game) => <GameOverviewElement {...game} />)}
</div>
</div>
</div>)

View File

@ -0,0 +1,16 @@
import { UserProfile } from "visions-types"
import { CardElement } from ".."
interface UserManagementProps {
users: UserProfile[]
}
export const UserManagementElement = ({ users }: UserManagementProps ) => {
return (
<CardElement>
<ul>
{users.map((user) => <li key={user.id}>{user.name}</li>)}
</ul>
</CardElement>
)
}

View File

@ -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 }

View File

@ -32,7 +32,7 @@ export const Admin = ({ client }: AdminProps) => {
const [users, setUsers] = useState<Array<User>>([]);
useEffect(() => {
client.users().then((u) => {
client.users("aoeu").then((u) => {
console.log(u);
setUsers(u);
});

View File

@ -11,18 +11,19 @@ interface MainProps {
export const MainView = ({ client }: MainProps) => {
const [state, _manager] = useContext(StateContext)
const [profile, setProfile] = useState<UserProfile | undefined>(undefined)
const [users, setUsers] = useState<UserProfile[]>([])
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 (
<div>
<div>Session ID: {sessionId}</div>
{profile && <ProfileElement {...profile} />}
{profile && <ProfileElement profile={profile} users={[]} />}
</div>
)
}

View File

@ -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"

View File

@ -9,6 +9,6 @@
"author": "",
"license": "ISC",
"dependencies": {
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}