Compare commits

..

9 Commits

33 changed files with 808 additions and 409 deletions

1
Cargo.lock generated
View File

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

View File

@ -156,6 +156,13 @@ pub fn fatal<A, E: Error, FE: FatalError>(err: FE) -> ResultExt<A, E, FE> {
ResultExt::Fatal(err) ResultExt::Fatal(err)
} }
pub fn result_as_fatal<A, E: Error, FE: FatalError>(result: Result<A, FE>) -> ResultExt<A, E, FE> {
match result {
Ok(a) => ResultExt::Ok(a),
Err(err) => ResultExt::Fatal(err),
}
}
/// Return early from the current function if the value is a fatal error. /// Return early from the current function if the value is a fatal error.
#[macro_export] #[macro_export]
macro_rules! return_fatal { macro_rules! return_fatal {

View File

@ -10,6 +10,7 @@ async-std = { version = "1.13.0" }
async-trait = { version = "0.1.83" } async-trait = { version = "0.1.83" }
authdb = { path = "../../authdb/" } authdb = { path = "../../authdb/" }
axum = { version = "0.7.9", features = [ "macros" ] } axum = { version = "0.7.9", features = [ "macros" ] }
chrono = { version = "0.4.39", features = ["serde"] }
futures = { version = "0.3.31" } futures = { version = "0.3.31" }
include_dir = { version = "0.7.4" } include_dir = { version = "0.7.4" }
lazy_static = { version = "1.5.0" } lazy_static = { version = "1.5.0" }

View File

@ -3,7 +3,7 @@ version: '3'
tasks: tasks:
build: build:
cmds: cmds:
- cargo build - cargo watch -x build
test: test:
cmds: cmds:

View File

@ -3,7 +3,7 @@ CREATE TABLE users(
name TEXT UNIQUE, name TEXT UNIQUE,
password TEXT, password TEXT,
admin BOOLEAN, admin BOOLEAN,
enabled BOOLEAN state TEXT
); );
CREATE TABLE sessions( CREATE TABLE sessions(
@ -14,9 +14,9 @@ CREATE TABLE sessions(
); );
CREATE TABLE games( CREATE TABLE games(
uuid TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
type_ TEXT,
gm TEXT, gm TEXT,
game_type TEXT,
name TEXT, name TEXT,
FOREIGN KEY(gm) REFERENCES users(uuid) FOREIGN KEY(gm) REFERENCES users(uuid)
@ -39,4 +39,3 @@ CREATE TABLE roles(
FOREIGN KEY(game_id) REFERENCES games(uuid) FOREIGN KEY(game_id) REFERENCES games(uuid)
); );
INSERT INTO users VALUES ('admin', 'admin', '', true, true);

View File

@ -1,16 +1,19 @@
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use async_std::sync::RwLock; use async_std::sync::RwLock;
use chrono::{DateTime, TimeDelta, Utc};
use mime::Mime; use mime::Mime;
use result_extended::{error, fatal, ok, return_error, ResultExt}; use result_extended::{error, fatal, ok, result_as_fatal, return_error, ResultExt};
use serde::Serialize; use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use typeshare::typeshare; use typeshare::typeshare;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
asset_db::{self, AssetId, Assets}, 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::AccountState,
types::{AppError, FatalError, GameOverview, Message, Rgb, Tabletop, User, UserOverview},
}; };
const DEFAULT_BACKGROUND_COLOR: Rgb = Rgb { const DEFAULT_BACKGROUND_COLOR: Rgb = Rgb {
@ -22,7 +25,16 @@ const DEFAULT_BACKGROUND_COLOR: Rgb = Rgb {
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
#[typeshare] #[typeshare]
pub struct Status { pub struct Status {
pub admin_enabled: bool, pub ok: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "content")]
#[typeshare]
pub enum AuthResponse {
Success(SessionId),
PasswordReset(SessionId),
Locked,
} }
#[derive(Debug)] #[derive(Debug)]
@ -59,6 +71,7 @@ impl Core {
} }
pub async fn status(&self) -> ResultExt<Status, AppError, FatalError> { pub async fn status(&self) -> ResultExt<Status, AppError, FatalError> {
/*
let state = self.0.write().await; let state = self.0.write().await;
let admin_user = return_error!(match state.db.user(&UserId::from("admin")).await { let admin_user = return_error!(match state.db.user(&UserId::from("admin")).await {
Ok(Some(admin_user)) => ok(admin_user), Ok(Some(admin_user)) => ok(admin_user),
@ -71,8 +84,10 @@ impl Core {
}); });
ok(Status { ok(Status {
admin_enabled: !admin_user.password.is_empty(), ok: !admin_user.password.is_empty(),
}) })
*/
ok(Status { ok: true })
} }
pub async fn register_client(&self) -> String { pub async fn register_client(&self) -> String {
@ -117,27 +132,34 @@ 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; let users = self.0.write().await.db.users().await;
match users { 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,
})
.collect()),
Err(err) => fatal(err), 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 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) { let user = match users.into_iter().find(|user| user.id == user_id) {
Some(user) => user, Some(user) => user,
None => return ok(None), None => return ok(None),
}; };
let user_games = games.into_iter().filter(|g| g.gm == user.id).collect(); ok(Some(UserOverview {
ok(Some(UserProfile { id: user.id.clone(),
id: user.id,
name: user.name, name: user.name,
games: user_games, is_admin: user.is_admin,
is_admin: user.admin,
})) }))
} }
@ -145,25 +167,47 @@ impl Core {
let state = self.0.read().await; let state = self.0.read().await;
match return_error!(self.user_by_username(username).await) { match return_error!(self.user_by_username(username).await) {
Some(_) => error(AppError::UsernameUnavailable), Some(_) => error(AppError::UsernameUnavailable),
None => match state.db.save_user(None, username, "", false, true).await { None => match state
.db
.create_user(username, "", false, AccountState::PasswordReset(Utc::now()))
.await
{
Ok(user_id) => ok(user_id), Ok(user_id) => ok(user_id),
Err(err) => fatal(err), 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> { pub async fn list_games(&self) -> ResultExt<Vec<GameOverview>, AppError, FatalError> {
let games = self.0.read().await.db.games().await; let games = self.0.read().await.db.games().await;
match games { match games {
// Ok(games) => ok(games.into_iter().map(|g| Game::from(g)).collect()), // Ok(games) => ok(games.into_iter().map(|g| Game::from(g)).collect()),
Ok(games) => ok(games.into_iter().map(GameOverview::from).collect()), Ok(games) => ok(games
.into_iter()
.map(|game| GameOverview {
id: game.id,
type_: "".to_owned(),
name: game.name,
gm: game.gm,
players: game.players,
})
.collect::<Vec<GameOverview>>()),
Err(err) => fatal(err), Err(err) => fatal(err),
} }
} }
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; let state = self.0.read().await;
match state.db.save_game(None, gm, game_type, game_name).await { match state.db.create_game(gm, game_type, game_name).await {
Ok(game_id) => ok(game_id), Ok(game_id) => ok(game_id),
Err(err) => fatal(err), Err(err) => fatal(err),
} }
@ -246,16 +290,22 @@ impl Core {
pub async fn save_user( pub async fn save_user(
&self, &self,
uuid: Option<UserId>, id: UserId,
username: &str, name: &str,
password: &str, password: &str,
admin: bool, admin: bool,
enabled: bool, account_state: AccountState,
) -> ResultExt<UserId, AppError, FatalError> { ) -> ResultExt<UserId, AppError, FatalError> {
let state = self.0.read().await; let state = self.0.read().await;
match state match state
.db .db
.save_user(uuid, username, password, admin, enabled) .save_user(User {
id,
name: name.to_owned(),
password: password.to_owned(),
admin,
state: account_state,
})
.await .await
{ {
Ok(uuid) => ok(uuid), Ok(uuid) => ok(uuid),
@ -276,7 +326,11 @@ impl Core {
}; };
match state match state
.db .db
.save_user(Some(uuid), &user.name, &password, user.admin, user.enabled) .save_user(User {
password,
state: AccountState::Normal,
..user
})
.await .await
{ {
Ok(_) => ok(()), Ok(_) => ok(()),
@ -288,19 +342,36 @@ impl Core {
&self, &self,
username: &str, username: &str,
password: &str, password: &str,
) -> ResultExt<SessionId, AppError, FatalError> { ) -> ResultExt<AuthResponse, AppError, FatalError> {
let state = self.0.write().await; let now = Utc::now();
match state.db.user_by_username(username).await { let state = self.0.read().await;
Ok(Some(row)) if (row.password == password) => { let user_info = return_error!(match state.db.user_by_username(username).await {
let session_id = state.db.create_session(&row.id).await.unwrap(); Ok(Some(row)) if row.password == password => ok(row),
ok(session_id)
}
Ok(_) => error(AppError::AuthFailed), Ok(_) => error(AppError::AuthFailed),
Err(err) => fatal(err), Err(err) => fatal(err),
});
match user_info.state {
AccountState::Normal => result_as_fatal(state.db.create_session(&user_info.id).await)
.map(|session_id| AuthResponse::Success(session_id)),
AccountState::PasswordReset(exp) => {
if exp < now {
error(AppError::AuthFailed)
} else {
result_as_fatal(state.db.create_session(&user_info.id).await)
.map(|session_id| AuthResponse::PasswordReset(session_id))
} }
} }
pub async fn session(&self, session_id: &SessionId) -> ResultExt<Option<User>, AppError, FatalError> { AccountState::Locked => ok(AuthResponse::Locked),
}
}
pub async fn session(
&self,
session_id: &SessionId,
) -> ResultExt<Option<User>, AppError, FatalError> {
let state = self.0.read().await; let state = self.0.read().await;
match state.db.session(session_id).await { match state.db.session(session_id).await {
Ok(Some(user_row)) => ok(Some(User::from(user_row))), Ok(Some(user_row)) => ok(Some(User::from(user_row))),
@ -310,6 +381,10 @@ impl Core {
} }
} }
fn create_expiration_date() -> DateTime<Utc> {
Utc::now() + TimeDelta::days(365)
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use std::path::PathBuf; use std::path::PathBuf;
@ -350,10 +425,15 @@ mod test {
]); ]);
let memory_db: Option<PathBuf> = None; let memory_db: Option<PathBuf> = None;
let conn = DbConn::new(memory_db); let conn = DbConn::new(memory_db);
conn.save_user(Some(UserId::from("admin")), "admin", "aoeu", true, true) conn.create_user("admin", "aoeu", true, AccountState::Normal)
.await .await
.unwrap(); .unwrap();
conn.save_user(None, "gm_1", "aoeu", false, true) conn.create_user(
"gm_1",
"aoeu",
false,
AccountState::PasswordReset(Utc::now()),
)
.await .await
.unwrap(); .unwrap();
Core::new(assets, conn) Core::new(assets, conn)
@ -423,7 +503,7 @@ mod test {
async fn it_creates_a_sessionid_on_successful_auth() { async fn it_creates_a_sessionid_on_successful_auth() {
let core = test_core().await; let core = test_core().await;
match core.auth("admin", "aoeu").await { match core.auth("admin", "aoeu").await {
ResultExt::Ok(session_id) => { ResultExt::Ok(AuthResponse::Success(session_id)) => {
let st = core.0.read().await; let st = core.0.read().await;
match st.db.session(&session_id).await { match st.db.session(&session_id).await {
Ok(Some(user_row)) => assert_eq!(user_row.name, "admin"), Ok(Some(user_row)) => assert_eq!(user_row.name, "admin"),
@ -431,6 +511,8 @@ mod test {
Err(err) => panic!("{}", err), Err(err) => panic!("{}", err),
} }
} }
ResultExt::Ok(AuthResponse::PasswordReset(_)) => panic!("user is in password reset state"),
ResultExt::Ok(AuthResponse::Locked) => panic!("user has been locked"),
ResultExt::Err(err) => panic!("{}", err), ResultExt::Err(err) => panic!("{}", err),
ResultExt::Fatal(err) => panic!("{}", err), ResultExt::Fatal(err) => panic!("{}", err),
} }

View File

@ -8,12 +8,10 @@ use rusqlite_migration::Migrations;
use crate::{ use crate::{
database::{DatabaseResponse, Request}, database::{DatabaseResponse, Request},
types::FatalError, types::{AccountState, FatalError, Game, User},
}; };
use super::{ use super::{types::GameId, CharacterId, CharsheetRow, DatabaseRequest, SessionId, UserId};
types::GameId, CharacterId, CharsheetRow, DatabaseRequest, GameRow, SessionId, UserId, UserRow
};
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
@ -40,28 +38,26 @@ impl DiskDb {
.to_latest(&mut conn) .to_latest(&mut conn)
.map_err(|err| FatalError::DatabaseMigrationFailure(format!("{}", err)))?; .map_err(|err| FatalError::DatabaseMigrationFailure(format!("{}", err)))?;
// setup_test_database(&conn)?;
Ok(DiskDb { conn }) Ok(DiskDb { conn })
} }
pub fn user(&self, id: &UserId) -> Result<Option<UserRow>, FatalError> { pub fn user(&self, id: &UserId) -> Result<Option<User>, FatalError> {
let mut stmt = self let mut stmt = self
.conn .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)))?; .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
let items: Vec<UserRow> = stmt let items: Vec<User> = stmt
.query_map([id.as_str()], |row| { .query_map([id.as_str()], |row| {
Ok(UserRow { Ok(User {
id: row.get(0).unwrap(), id: row.get(0).unwrap(),
name: row.get(1).unwrap(), name: row.get(1).unwrap(),
password: row.get(2).unwrap(), password: row.get(2).unwrap(),
admin: row.get(3).unwrap(), admin: row.get(3).unwrap(),
enabled: row.get(4).unwrap(), state: row.get(4).unwrap(),
}) })
}) })
.unwrap() .unwrap()
.collect::<Result<Vec<UserRow>, rusqlite::Error>>() .collect::<Result<Vec<User>, rusqlite::Error>>()
.unwrap(); .unwrap();
match &items[..] { match &items[..] {
[] => Ok(None), [] => Ok(None),
@ -70,23 +66,23 @@ impl DiskDb {
} }
} }
pub fn user_by_username(&self, username: &str) -> Result<Option<UserRow>, FatalError> { pub fn user_by_username(&self, username: &str) -> Result<Option<User>, FatalError> {
let mut stmt = self let mut stmt = self
.conn .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)))?; .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
let items: Vec<UserRow> = stmt let items: Vec<User> = stmt
.query_map([username], |row| { .query_map([username], |row| {
Ok(UserRow { Ok(User {
id: row.get(0).unwrap(), id: row.get(0).unwrap(),
name: row.get(1).unwrap(), name: row.get(1).unwrap(),
password: row.get(2).unwrap(), password: row.get(2).unwrap(),
admin: row.get(3).unwrap(), admin: row.get(3).unwrap(),
enabled: row.get(4).unwrap(), state: row.get(4).unwrap(),
}) })
}) })
.unwrap() .unwrap()
.collect::<Result<Vec<UserRow>, rusqlite::Error>>() .collect::<Result<Vec<User>, rusqlite::Error>>()
.unwrap(); .unwrap();
match &items[..] { match &items[..] {
[] => Ok(None), [] => Ok(None),
@ -95,125 +91,124 @@ impl DiskDb {
} }
} }
pub fn save_user( pub fn create_user(
&self, &self,
user_id: Option<UserId>,
name: &str, name: &str,
password: &str, password: &str,
admin: bool, admin: bool,
enabled: bool, state: AccountState,
) -> Result<UserId, FatalError> { ) -> Result<UserId, FatalError> {
match user_id {
None => {
let user_id = UserId::default(); let user_id = UserId::default();
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)") .prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
stmt.execute((user_id.as_str(), name, password, admin, enabled)) stmt.execute((user_id.as_str(), name, password, admin, state))
.unwrap(); .unwrap();
Ok(user_id) Ok(user_id)
} }
Some(user_id) => {
let mut stmt = self
.conn
.prepare("UPDATE users SET name=?, password=?, admin=?, enabled=? WHERE uuid=?")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
stmt.execute((name, password, admin, enabled, user_id.as_str()))
.unwrap();
Ok(user_id)
}
}
}
pub fn users(&self) -> Result<Vec<UserRow>, FatalError> { pub fn save_user(&self, user: User) -> Result<UserId, FatalError> {
let mut stmt = self
.conn
.prepare("UPDATE users SET name=?, password=?, admin=?, state=? WHERE uuid=?")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
stmt.execute((
user.name,
user.password,
user.admin,
user.state,
user.id.as_str(),
))
.unwrap();
Ok(user.id)
}
pub fn users(&self) -> Result<Vec<User>, FatalError> {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT * FROM users") .prepare("SELECT * FROM users")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
let items = stmt let items = stmt
.query_map([], |row| { .query_map([], |row| {
Ok(UserRow { Ok(User {
id: row.get(0).unwrap(), id: row.get(0).unwrap(),
name: row.get(1).unwrap(), name: row.get(1).unwrap(),
password: row.get(2).unwrap(), password: row.get(2).unwrap(),
admin: row.get(3).unwrap(), admin: row.get(3).unwrap(),
enabled: row.get(4).unwrap(), state: row.get(4).unwrap(),
}) })
}) })
.unwrap() .unwrap()
.collect::<Result<Vec<UserRow>, rusqlite::Error>>() .collect::<Result<Vec<User>, rusqlite::Error>>()
.unwrap(); .unwrap();
Ok(items) Ok(items)
} }
pub fn save_game( pub fn create_game(
&self, &self,
game_id: Option<GameId>,
gm: &UserId, gm: &UserId,
game_type: &str, game_type: &str,
name: &str, name: &str,
) -> Result<GameId, FatalError> { ) -> Result<GameId, FatalError> {
match game_id {
None => {
let game_id = GameId::new(); let game_id = GameId::new();
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("INSERT INTO games VALUES (?, ?, ?, ?)") .prepare("INSERT INTO games VALUES (?, ?, ?, ?)")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
stmt.execute((game_id.as_str(), gm.as_str(), game_type, name)) stmt.execute((game_id.as_str(), game_type, gm.as_str(), name))
.unwrap(); .unwrap();
Ok(game_id) Ok(game_id)
} }
Some(game_id) => {
let mut stmt = self
.conn
.prepare("UPDATE games SET gm=? game_type=? name=? WHERE uuid=?")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
stmt.execute((gm.as_str(), game_type, name, game_id.as_str()))
.unwrap();
Ok(game_id)
}
}
}
pub fn games(&self) -> Result<Vec<GameRow>, FatalError> { pub fn save_game(&self, game: Game) -> Result<(), FatalError> {
let mut stmt = self
.conn
.prepare("UPDATE games SET gm=? type_=? name=? WHERE id=?")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
stmt.execute((game.gm.as_str(), game.type_, game.name, game.id.as_str()))
.unwrap();
Ok(())
}
pub fn games(&self) -> Result<Vec<Game>, FatalError> {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT * FROM games") .prepare("SELECT * FROM games")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
let items = stmt let items = stmt
.query_map([], |row| { .query_map([], |row| {
Ok(GameRow { Ok(Game {
id: row.get(0).unwrap(), id: row.get(0).unwrap(),
gm: row.get(1).unwrap(), type_: row.get(1).unwrap(),
game_type: row.get(2).unwrap(), gm: row.get(2).unwrap(),
name: row.get(3).unwrap(), name: row.get(3).unwrap(),
players: vec![],
}) })
}) })
.unwrap() .unwrap()
.collect::<Result<Vec<GameRow>, rusqlite::Error>>() .collect::<Result<Vec<Game>, rusqlite::Error>>()
.unwrap(); .unwrap();
Ok(items) Ok(items)
} }
pub fn session(&self, session_id: &SessionId) -> Result<Option<UserRow>, FatalError> { pub fn session(&self, session_id: &SessionId) -> Result<Option<User>, FatalError> {
let mut stmt = self.conn let mut stmt = self.conn
.prepare("SELECT u.uuid, u.name, u.password, u.admin, u.enabled FROM sessions s INNER JOIN users u ON u.uuid = s.user_id WHERE s.id = ?") .prepare("SELECT u.uuid, u.name, u.password, u.admin, u.state FROM sessions s INNER JOIN users u ON u.uuid = s.user_id WHERE s.id = ?")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
let items: Vec<UserRow> = stmt let items: Vec<User> = stmt
.query_map([session_id.as_str()], |row| { .query_map([session_id.as_str()], |row| {
Ok(UserRow { Ok(User {
id: row.get(0).unwrap(), id: row.get(0).unwrap(),
name: row.get(1).unwrap(), name: row.get(1).unwrap(),
password: row.get(2).unwrap(), password: row.get(2).unwrap(),
admin: row.get(3).unwrap(), admin: row.get(3).unwrap(),
enabled: row.get(4).unwrap(), state: row.get(4).unwrap(),
}) })
}) })
.unwrap() .unwrap()
.collect::<Result<Vec<UserRow>, rusqlite::Error>>() .collect::<Result<Vec<User>, rusqlite::Error>>()
.unwrap(); .unwrap();
match &items[..] { match &items[..] {
[] => Ok(None), [] => Ok(None),
@ -310,26 +305,30 @@ pub async fn db_handler(db: DiskDb, requestor: Receiver<DatabaseRequest>) {
_ => unimplemented!("errors for Charsheet"), _ => unimplemented!("errors for Charsheet"),
} }
} }
Request::CreateUser(username, password, admin, state) => {
let user_id = db.create_user(&username, &password, admin, state).unwrap();
tx.send(DatabaseResponse::SaveUser(user_id)).await.unwrap();
}
Request::CreateGame(_, _, _) => {}
Request::CreateSession(id) => { Request::CreateSession(id) => {
let session_id = db.create_session(&id).unwrap(); let session_id = db.create_session(&id).unwrap();
tx.send(DatabaseResponse::CreateSession(session_id)) tx.send(DatabaseResponse::CreateSession(session_id))
.await .await
.unwrap(); .unwrap();
} }
Request::Games => { Request::Games => match db.games() {
match db.games() {
Ok(games) => tx.send(DatabaseResponse::Games(games)).await.unwrap(), Ok(games) => tx.send(DatabaseResponse::Games(games)).await.unwrap(),
_ => unimplemented!("errors for Request::Games"), _ => unimplemented!("errors for Request::Games"),
} },
}
Request::Game(_game_id) => { Request::Game(_game_id) => {
unimplemented!("Request::Game handler"); unimplemented!("Request::Game handler");
} }
Request::SaveGame(game_id, user_id, game_type, game_name) => { Request::SaveGame(game) => {
let game_id = db.save_game(game_id, &user_id, &game_type, &game_name); let id = game.id.clone();
match game_id { let save_result = db.save_game(game);
Ok(game_id) => { match save_result {
tx.send(DatabaseResponse::SaveGame(game_id)).await.unwrap(); Ok(_) => {
tx.send(DatabaseResponse::SaveGame(id)).await.unwrap();
} }
err => panic!("{:?}", err), err => panic!("{:?}", err),
} }
@ -350,14 +349,8 @@ pub async fn db_handler(db: DiskDb, requestor: Receiver<DatabaseRequest>) {
err => panic!("{:?}", err), err => panic!("{:?}", err),
} }
} }
Request::SaveUser(user_id, username, password, admin, enabled) => { Request::SaveUser(user) => {
let user_id = db.save_user( let user_id = db.save_user(user);
user_id,
username.as_ref(),
password.as_ref(),
admin,
enabled,
);
match user_id { match user_id {
Ok(user_id) => { Ok(user_id) => {
tx.send(DatabaseResponse::SaveUser(user_id)).await.unwrap(); tx.send(DatabaseResponse::SaveUser(user_id)).await.unwrap();
@ -378,7 +371,7 @@ pub async fn db_handler(db: DiskDb, requestor: Receiver<DatabaseRequest>) {
Ok(users) => { Ok(users) => {
tx.send(DatabaseResponse::Users(users)).await.unwrap(); tx.send(DatabaseResponse::Users(users)).await.unwrap();
} }
_ => unimplemented!(), _ => unimplemented!("request::Users"),
} }
} }
} }

View File

@ -6,18 +6,20 @@ use std::path::Path;
use async_std::channel::{bounded, Sender}; use async_std::channel::{bounded, Sender};
use async_trait::async_trait; use async_trait::async_trait;
use disk_db::{db_handler, DiskDb}; use disk_db::{db_handler, DiskDb};
pub use types::{CharacterId, CharsheetRow, GameId, GameRow, SessionId, UserId, UserRow}; pub use types::{CharacterId, CharsheetRow, GameId, SessionId, UserId};
use crate::types::FatalError; use crate::types::{AccountState, FatalError, Game, User};
#[derive(Debug)] #[derive(Debug)]
enum Request { enum Request {
Charsheet(CharacterId), Charsheet(CharacterId),
CreateGame(UserId, String, String),
CreateSession(UserId), CreateSession(UserId),
Games, CreateUser(String, String, bool, AccountState),
Game(GameId), Game(GameId),
SaveGame(Option<GameId>, UserId, String, String), Games,
SaveUser(Option<UserId>, String, String, bool, bool), SaveGame(Game),
SaveUser(User),
Session(SessionId), Session(SessionId),
User(UserId), User(UserId),
UserByUsername(String), UserByUsername(String),
@ -34,49 +36,43 @@ struct DatabaseRequest {
enum DatabaseResponse { enum DatabaseResponse {
Charsheet(Option<CharsheetRow>), Charsheet(Option<CharsheetRow>),
CreateSession(SessionId), CreateSession(SessionId),
Games(Vec<GameRow>), Games(Vec<Game>),
Game(Option<GameRow>), Game(Option<Game>),
SaveGame(GameId), SaveGame(GameId),
SaveUser(UserId), SaveUser(UserId),
Session(Option<UserRow>), Session(Option<User>),
User(Option<UserRow>), User(Option<User>),
Users(Vec<UserRow>), Users(Vec<User>),
} }
#[async_trait] #[async_trait]
pub trait Database: Send + Sync { pub trait Database: Send + Sync {
async fn users(&self) -> Result<Vec<UserRow>, FatalError>; async fn create_session(&self, id: &UserId) -> Result<SessionId, FatalError>;
async fn session(&self, id: &SessionId) -> Result<Option<User>, FatalError>;
async fn user(&self, _: &UserId) -> Result<Option<UserRow>, FatalError>;
async fn user_by_username(&self, _: &str) -> Result<Option<UserRow>, FatalError>;
async fn save_user(
&self,
user_id: Option<UserId>,
name: &str,
password: &str,
admin: bool,
enabled: bool,
) -> Result<UserId, FatalError>;
async fn games(&self) -> Result<Vec<GameRow>, FatalError>;
async fn game(&self, _: &GameId) -> Result<Option<GameRow>, FatalError>;
async fn save_game(
&self,
game_id: Option<GameId>,
gm: &UserId,
game_type: &str,
game_name: &str,
) -> Result<GameId, FatalError>;
async fn character(&self, id: &CharacterId) -> Result<Option<CharsheetRow>, FatalError>; async fn character(&self, id: &CharacterId) -> Result<Option<CharsheetRow>, FatalError>;
async fn session(&self, id: &SessionId) -> Result<Option<UserRow>, FatalError>; async fn create_game(
&self,
gm: &UserId,
game_type: &str,
name: &str,
) -> Result<GameId, FatalError>;
async fn save_game(&self, game: Game) -> Result<GameId, FatalError>;
async fn game(&self, _: &GameId) -> Result<Option<Game>, FatalError>;
async fn games(&self) -> Result<Vec<Game>, FatalError>;
async fn create_session(&self, id: &UserId) -> Result<SessionId, FatalError>; async fn create_user(
&self,
name: &str,
password: &str,
admin: bool,
state: AccountState,
) -> Result<UserId, FatalError>;
async fn save_user(&self, user: User) -> Result<UserId, FatalError>;
async fn user(&self, _: &UserId) -> Result<Option<User>, FatalError>;
async fn user_by_username(&self, _: &str) -> Result<Option<User>, FatalError>;
async fn users(&self) -> Result<Vec<User>, FatalError>;
} }
pub struct DbConn { pub struct DbConn {
@ -123,65 +119,58 @@ macro_rules! send_request {
#[async_trait] #[async_trait]
impl Database for DbConn { impl Database for DbConn {
async fn users(&self) -> Result<Vec<UserRow>, FatalError> { async fn create_session(&self, id: &UserId) -> Result<SessionId, FatalError> {
send_request!(self, Request::Users, DatabaseResponse::Users(lst) => Ok(lst)) send_request!(self, Request::CreateSession(id.to_owned()), DatabaseResponse::CreateSession(session_id) => Ok(session_id))
} }
async fn session(&self, id: &SessionId) -> Result<Option<User>, FatalError> {
async fn user(&self, uid: &UserId) -> Result<Option<UserRow>, FatalError> { send_request!(self, Request::Session(id.to_owned()), DatabaseResponse::Session(row) => Ok(row))
send_request!(self, Request::User(uid.clone()), DatabaseResponse::User(user) => Ok(user))
} }
async fn user_by_username(&self, username: &str) -> Result<Option<UserRow>, FatalError> {
send_request!(self, Request::UserByUsername(username.to_owned()), DatabaseResponse::User(user) => Ok(user))
}
async fn save_user(
&self,
user_id: Option<UserId>,
name: &str,
password: &str,
admin: bool,
enabled: bool,
) -> Result<UserId, FatalError> {
send_request!(self,
Request::SaveUser(
user_id,
name.to_owned(),
password.to_owned(),
admin,
enabled,
),
DatabaseResponse::SaveUser(user_id) => Ok(user_id))
}
async fn games(&self) -> Result<Vec<GameRow>, FatalError> {
send_request!(self, Request::Games, DatabaseResponse::Games(lst) => Ok(lst))
}
async fn game(&self, game_id: &GameId) -> Result<Option<GameRow>, FatalError> {
send_request!(self, Request::Game(game_id.clone()), DatabaseResponse::Game(game) => Ok(game))
}
async fn save_game(
&self,
game_id: Option<GameId>,
user_id: &UserId,
game_type: &str,
game_name: &str,
) -> Result<GameId, FatalError> {
send_request!(self, Request::SaveGame(game_id, user_id.to_owned(), game_type.to_owned(), game_name.to_owned()), DatabaseResponse::SaveGame(game_id) => Ok(game_id))
}
async fn character(&self, id: &CharacterId) -> Result<Option<CharsheetRow>, FatalError> { async fn character(&self, id: &CharacterId) -> Result<Option<CharsheetRow>, FatalError> {
send_request!(self, Request::Charsheet(id.to_owned()), DatabaseResponse::Charsheet(row) => Ok(row)) send_request!(self, Request::Charsheet(id.to_owned()), DatabaseResponse::Charsheet(row) => Ok(row))
} }
async fn session(&self, id: &SessionId) -> Result<Option<UserRow>, FatalError> { async fn create_game(
send_request!(self, Request::Session(id.to_owned()), DatabaseResponse::Session(row) => Ok(row)) &self,
user_id: &UserId,
game_type: &str,
game_name: &str,
) -> Result<GameId, FatalError> {
send_request!(self, Request::CreateGame(user_id.to_owned(), game_type.to_owned(), game_name.to_owned()), DatabaseResponse::SaveGame(game_id) => Ok(game_id))
}
async fn save_game(&self, game: Game) -> Result<GameId, FatalError> {
send_request!(self, Request::SaveGame(game), DatabaseResponse::SaveGame(game_id) => Ok(game_id))
}
async fn game(&self, game_id: &GameId) -> Result<Option<Game>, FatalError> {
send_request!(self, Request::Game(game_id.clone()), DatabaseResponse::Game(game) => Ok(game))
}
async fn games(&self) -> Result<Vec<Game>, FatalError> {
send_request!(self, Request::Games, DatabaseResponse::Games(lst) => Ok(lst))
} }
async fn create_session(&self, id: &UserId) -> Result<SessionId, FatalError> { async fn create_user(
send_request!(self, Request::CreateSession(id.to_owned()), DatabaseResponse::CreateSession(session_id) => Ok(session_id)) &self,
name: &str,
password: &str,
admin: bool,
state: AccountState,
) -> Result<UserId, FatalError> {
send_request!(self,
Request::CreateUser(name.to_owned(), password.to_owned(), admin, state),
DatabaseResponse::SaveUser(user_id) => Ok(user_id))
}
async fn save_user(&self, user: User) -> Result<UserId, FatalError> {
send_request!(self,
Request::SaveUser(user),
DatabaseResponse::SaveUser(user_id) => Ok(user_id))
}
async fn user(&self, uid: &UserId) -> Result<Option<User>, FatalError> {
send_request!(self, Request::User(uid.clone()), DatabaseResponse::User(user) => Ok(user))
}
async fn user_by_username(&self, username: &str) -> Result<Option<User>, FatalError> {
send_request!(self, Request::UserByUsername(username.to_owned()), DatabaseResponse::User(user) => Ok(user))
}
async fn users(&self) -> Result<Vec<User>, FatalError> {
send_request!(self, Request::Users, DatabaseResponse::Users(lst) => Ok(lst))
} }
} }
@ -201,12 +190,19 @@ mod test {
let no_path: Option<PathBuf> = None; let no_path: Option<PathBuf> = None;
let db = DiskDb::new(no_path).unwrap(); let db = DiskDb::new(no_path).unwrap();
db.save_user(Some(UserId::from("admin")), "admin", "abcdefg", true, true) db.create_user("admin", "abcdefg", true, AccountState::Normal)
.unwrap();
let game_id = db
.create_game(
&UserId::from("admin"),
"Candela",
"Circle of the Winter Solstice",
)
.unwrap(); .unwrap();
let game_id = db.save_game(None, &UserId::from("admin"), "Candela", "Circle of the Winter Solstice").unwrap();
(db, game_id) (db, game_id)
} }
#[ignore]
#[test] #[test]
fn it_can_retrieve_a_character() { fn it_can_retrieve_a_character() {
let (db, game_id) = setup_db(); let (db, game_id) = setup_db();

View File

@ -1,6 +1,7 @@
use std::fmt; 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 serde::{Deserialize, Serialize};
use typeshare::typeshare; use typeshare::typeshare;
use uuid::Uuid; use uuid::Uuid;
@ -159,15 +160,6 @@ impl FromSql for CharacterId {
} }
} }
#[derive(Clone, Debug)]
pub struct UserRow {
pub id: UserId,
pub name: String,
pub password: String,
pub admin: bool,
pub enabled: bool,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Role { pub struct Role {
userid: UserId, userid: UserId,
@ -175,6 +167,7 @@ pub struct Role {
role: String, role: String,
} }
/*
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct GameRow { pub struct GameRow {
pub id: GameId, pub id: GameId,
@ -182,6 +175,7 @@ pub struct GameRow {
pub game_type: String, pub game_type: String,
pub name: String, pub name: String,
} }
*/
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct CharsheetRow { pub struct CharsheetRow {
@ -196,5 +190,20 @@ pub struct SessionRow {
user_id: SessionId, 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

@ -1,23 +1,20 @@
mod game_management; mod game_management;
mod user_management; mod user_management;
mod types;
use axum::{http::StatusCode, Json}; use axum::{http::StatusCode, Json};
use futures::Future; use futures::Future;
pub use game_management::*; pub use game_management::*;
pub use user_management::*; pub use user_management::*;
pub use types::*;
use result_extended::ResultExt; use result_extended::ResultExt;
use serde::{Deserialize, Serialize};
use crate::{ use crate::{
core::Core, core::Core,
types::{AppError, FatalError}, types::{AppError, FatalError},
}; };
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct HealthCheck {
pub ok: bool,
}
pub async fn wrap_handler<F, A, Fut>(f: F) -> (StatusCode, Json<Option<A>>) pub async fn wrap_handler<F, A, Fut>(f: F) -> (StatusCode, Json<Option<A>>)
where where
F: FnOnce() -> Fut, F: FnOnce() -> Fut,
@ -45,7 +42,7 @@ where
pub async fn healthcheck(core: Core) -> Vec<u8> { pub async fn healthcheck(core: Core) -> Vec<u8> {
match core.status().await { match core.status().await {
ResultExt::Ok(s) => serde_json::to_vec(&HealthCheck { ResultExt::Ok(s) => serde_json::to_vec(&HealthCheck {
ok: s.admin_enabled, ok: s.ok,
}) })
.unwrap(), .unwrap(),
ResultExt::Err(_) => serde_json::to_vec(&HealthCheck { ok: false }).unwrap(), ResultExt::Err(_) => serde_json::to_vec(&HealthCheck { ok: false }).unwrap(),

View File

@ -0,0 +1,83 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::database::UserId;
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct HealthCheck {
pub ok: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "content")]
#[typeshare]
pub enum AccountState {
Normal,
PasswordReset(String),
Locked,
}
impl From<crate::types::AccountState> for AccountState {
fn from(s: crate::types::AccountState) -> Self {
match s {
crate::types::AccountState::Normal => Self::Normal,
crate::types::AccountState::PasswordReset(r) => {
Self::PasswordReset(format!("{}", r.format("%Y-%m-%d %H:%M:%S")))
}
crate::types::AccountState::Locked => Self::Locked,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
#[typeshare]
pub struct User {
pub id: UserId,
pub name: String,
pub password: String,
pub admin: bool,
pub state: AccountState,
}
impl From<crate::types::User> for User {
fn from(u: crate::types::User) -> Self {
Self {
id: u.id,
name: u.name,
password: u.password,
admin: u.admin,
state: AccountState::from(u.state),
}
}
}
/*
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
#[typeshare]
pub struct UserOverview {
pub id: UserId,
pub name: String,
pub is_admin: bool,
pub games: Vec<crate::types::GameOverview>,
}
impl UserOverview {
pub fn new(user: crate::types::UserOverview, games: Vec<crate::types::GameOverview>) -> Self {
let s = Self::from(user);
Self{ games, ..s }
}
}
impl From<crate::types::UserOverview> for UserOverview {
fn from(input: crate::types::UserOverview) -> Self {
Self {
id: input.id,
name: input.name,
is_admin: input.is_admin,
games: vec![],
}
}
}
*/

View File

@ -1,16 +1,13 @@
use axum::{ use axum::{http::HeaderMap, Json};
http::HeaderMap,
Json,
};
use futures::Future; use futures::Future;
use result_extended::{error, ok, return_error, ResultExt}; use result_extended::{error, ok, return_error, ResultExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use typeshare::typeshare; use typeshare::typeshare;
use crate::{ use crate::{
core::Core, core::{AuthResponse, Core},
database::{SessionId, UserId}, database::{SessionId, UserId},
types::{AppError, FatalError, User, UserProfile}, types::{AppError, FatalError, User},
}; };
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
@ -33,12 +30,19 @@ pub struct SetPasswordRequest {
pub password_2: String, pub password_2: String,
} }
#[derive(Deserialize, Serialize)]
#[typeshare]
pub struct SetAdminPasswordRequest {
pub password: String,
}
async fn check_session( async fn check_session(
core: &Core, core: &Core,
headers: HeaderMap, headers: HeaderMap,
) -> ResultExt<Option<User>, AppError, FatalError> { ) -> ResultExt<Option<User>, AppError, FatalError> {
match headers.get("Authorization") { match headers.get("Authorization") {
Some(token) => { Some(token) => {
println!("check_session: {:?}", token);
match token match token
.to_str() .to_str()
.unwrap() .unwrap()
@ -93,7 +97,7 @@ where
pub async fn check_password( pub async fn check_password(
core: Core, core: Core,
req: Json<AuthRequest>, req: Json<AuthRequest>,
) -> ResultExt<SessionId, AppError, FatalError> { ) -> ResultExt<AuthResponse, AppError, FatalError> {
let Json(AuthRequest { username, password }) = req; let Json(AuthRequest { username, password }) = req;
core.auth(&username, &password).await core.auth(&username, &password).await
} }
@ -102,7 +106,7 @@ pub async fn get_user(
core: Core, core: Core,
headers: HeaderMap, headers: HeaderMap,
user_id: Option<UserId>, user_id: Option<UserId>,
) -> ResultExt<Option<UserProfile>, AppError, FatalError> { ) -> ResultExt<Option<crate::types::UserOverview>, AppError, FatalError> {
auth_required(core.clone(), headers, |user| async move { auth_required(core.clone(), headers, |user| async move {
match user_id { match user_id {
Some(user_id) => core.user(user_id).await, Some(user_id) => core.user(user_id).await,
@ -111,6 +115,16 @@ pub async fn get_user(
}).await }).await
} }
pub async fn get_users(
core: Core,
headers: HeaderMap,
) -> ResultExt<Vec<crate::types::UserOverview>, AppError, FatalError> {
auth_required(core.clone(), headers, |_user| async move {
core.list_users().await
})
.await
}
pub async fn create_user( pub async fn create_user(
core: Core, core: Core,
headers: HeaderMap, headers: HeaderMap,
@ -118,7 +132,8 @@ pub async fn create_user(
) -> ResultExt<UserId, AppError, FatalError> { ) -> ResultExt<UserId, AppError, FatalError> {
admin_required(core.clone(), headers, |_admin| async { admin_required(core.clone(), headers, |_admin| async {
core.create_user(&req.username).await core.create_user(&req.username).await
}).await })
.await
} }
pub async fn set_password( pub async fn set_password(
@ -132,5 +147,6 @@ pub async fn set_password(
} else { } else {
error(AppError::BadRequest) error(AppError::BadRequest)
} }
}).await })
.await
} }

View File

@ -1,6 +1,9 @@
use axum::{ use axum::{
extract::Path, extract::Path,
http::{header::{AUTHORIZATION, CONTENT_TYPE}, HeaderMap, Method}, http::{
header::{AUTHORIZATION, CONTENT_TYPE},
HeaderMap, Method,
},
routing::{get, post, put}, routing::{get, post, put},
Json, Router, Json, Router,
}; };
@ -10,7 +13,7 @@ use crate::{
core::Core, core::Core,
database::UserId, database::UserId,
handlers::{ handlers::{
check_password, create_game, create_user, get_user, healthcheck, set_password, check_password, create_game, create_user, get_user, get_users, healthcheck, set_password,
wrap_handler, AuthRequest, CreateGameRequest, CreateUserRequest, SetPasswordRequest, wrap_handler, AuthRequest, CreateGameRequest, CreateUserRequest, SetPasswordRequest,
}, },
}; };
@ -63,6 +66,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( .route(
"/api/v1/user/password", "/api/v1/user/password",
put({ put({
@ -97,35 +113,34 @@ pub fn routes(core: Core) -> Router {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use std::path::PathBuf; use std::{path::PathBuf, time::Duration};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum_test::TestServer; use axum_test::TestServer;
use chrono::Utc;
use cool_asserts::assert_matches; use cool_asserts::assert_matches;
use result_extended::ResultExt; use result_extended::ResultExt;
use super::*; use super::*;
use crate::{ use crate::{
asset_db::FsAssets, asset_db::FsAssets,
core::Core, core::{AuthResponse, Core},
database::{Database, DbConn, GameId, SessionId, UserId}, database::{Database, DbConn, GameId, SessionId, UserId},
handlers::CreateGameRequest, handlers::CreateGameRequest,
types::UserProfile, types::UserOverview,
}; };
fn setup_without_admin() -> (Core, TestServer) { async fn initialize_test_server() -> (Core, TestServer) {
let password_exp = Utc::now() + Duration::from_secs(5);
let memory_db: Option<PathBuf> = None; let memory_db: Option<PathBuf> = None;
let conn = DbConn::new(memory_db); let conn = DbConn::new(memory_db);
let core = Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); let _admin_id = conn
let app = routes(core.clone()); .create_user(
let server = TestServer::new(app).unwrap(); "admin",
(core, server) "aoeu",
} true,
crate::types::AccountState::PasswordReset(password_exp),
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 .await
.unwrap(); .unwrap();
let core = Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); let core = Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn);
@ -134,8 +149,26 @@ mod test {
(core, server) (core, server)
} }
async fn setup_with_admin() -> (Core, TestServer) {
let (core, server) = initialize_test_server().await;
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) { async fn setup_with_user() -> (Core, TestServer) {
let (core, server) = setup_admin_enabled().await; let (core, server) = setup_with_admin().await;
let response = server let response = server
.post("/api/v1/auth") .post("/api/v1/auth")
.json(&AuthRequest { .json(&AuthRequest {
@ -170,18 +203,7 @@ mod test {
#[tokio::test] #[tokio::test]
async fn it_returns_a_healthcheck() { async fn it_returns_a_healthcheck() {
let (core, server) = setup_without_admin(); let (_core, server) = initialize_test_server().await;
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 response = server.get("/api/v1/health").await; let response = server.get("/api/v1/health").await;
response.assert_status_ok(); response.assert_status_ok();
@ -189,9 +211,65 @@ mod test {
assert_eq!(b, crate::handlers::HealthCheck { ok: true }); assert_eq!(b, crate::handlers::HealthCheck { ok: true });
} }
#[ignore]
#[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::PasswordReset(session_id) => session_id,
AuthResponse::Success(_) => panic!("admin user password has already been set"),
AuthResponse::Locked => 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::PasswordReset(_));
}
#[ignore]
#[tokio::test]
async fn it_refuses_to_authenticate_a_disabled_user() {
let (_core, _server) = setup_with_disabled_user().await;
unimplemented!()
}
#[ignore]
#[tokio::test]
async fn it_forces_changing_expired_password() {
let (_core, _server) = setup_with_user().await;
unimplemented!()
}
#[tokio::test] #[tokio::test]
async fn it_authenticates_a_user() { async fn it_authenticates_a_user() {
let (_core, server) = setup_admin_enabled().await; let (_core, server) = setup_with_admin().await;
let response = server let response = server
.post("/api/v1/auth") .post("/api/v1/auth")
@ -219,13 +297,12 @@ mod test {
}) })
.await; .await;
response.assert_status_ok(); response.assert_status_ok();
let session_id: Option<SessionId> = response.json(); assert_matches!(response.json(), Some(AuthResponse::PasswordReset(_)));
assert!(session_id.is_some());
} }
#[tokio::test] #[tokio::test]
async fn it_returns_user_profile() { 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; let response = server.get("/api/v1/user").await;
response.assert_status(StatusCode::UNAUTHORIZED); response.assert_status(StatusCode::UNAUTHORIZED);
@ -238,27 +315,19 @@ mod test {
}) })
.await; .await;
response.assert_status_ok(); response.assert_status_ok();
let session_id: Option<SessionId> = response.json(); let session_id = assert_matches!(response.json(), Some(AuthResponse::PasswordReset(session_id)) => session_id);
let session_id = session_id.unwrap();
let response = server let response = server
.get("/api/v1/user") .get("/api/v1/user")
.add_header("Authorization", format!("Bearer {}", session_id)) .add_header("Authorization", format!("Bearer {}", session_id))
.await; .await;
response.assert_status_ok(); response.assert_status_ok();
let profile: Option<UserProfile> = response.json(); let profile: Option<UserOverview> = response.json();
let profile = profile.unwrap(); let profile = profile.unwrap();
assert_eq!(profile.id, UserId::from("admin"));
assert_eq!(profile.name, "admin"); assert_eq!(profile.name, "admin");
} }
#[tokio::test] #[ignore]
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] #[tokio::test]
async fn a_user_can_get_any_user_profile() { async fn a_user_can_get_any_user_profile() {
let (core, server) = setup_with_user().await; let (core, server) = setup_with_user().await;
@ -285,7 +354,7 @@ mod test {
.add_header("Authorization", format!("Bearer {}", session_id)) .add_header("Authorization", format!("Bearer {}", session_id))
.await; .await;
response.assert_status_ok(); response.assert_status_ok();
let profile: Option<UserProfile> = response.json(); let profile: Option<UserOverview> = response.json();
let profile = profile.unwrap(); let profile = profile.unwrap();
assert_eq!(profile.name, "savanni"); assert_eq!(profile.name, "savanni");
@ -294,11 +363,12 @@ mod test {
.add_header("Authorization", format!("Bearer {}", session_id)) .add_header("Authorization", format!("Bearer {}", session_id))
.await; .await;
response.assert_status_ok(); response.assert_status_ok();
let profile: Option<UserProfile> = response.json(); let profile: Option<UserOverview> = response.json();
let profile = profile.unwrap(); let profile = profile.unwrap();
assert_eq!(profile.name, "admin"); assert_eq!(profile.name, "admin");
} }
#[ignore]
#[tokio::test] #[tokio::test]
async fn a_user_can_change_their_password() { async fn a_user_can_change_their_password() {
let (_core, server) = setup_with_user().await; let (_core, server) = setup_with_user().await;
@ -317,7 +387,7 @@ mod test {
.get("/api/v1/user") .get("/api/v1/user")
.add_header("Authorization", format!("Bearer {}", session_id)) .add_header("Authorization", format!("Bearer {}", session_id))
.await; .await;
let profile = response.json::<Option<UserProfile>>().unwrap(); let profile = response.json::<Option<UserOverview>>().unwrap();
assert_eq!(profile.name, "savanni"); assert_eq!(profile.name, "savanni");
let response = server let response = server
@ -350,6 +420,7 @@ mod test {
response.assert_status(StatusCode::OK); response.assert_status(StatusCode::OK);
} }
#[ignore]
#[tokio::test] #[tokio::test]
async fn a_user_can_create_a_game() { async fn a_user_can_create_a_game() {
let (_core, server) = setup_with_user().await; let (_core, server) = setup_with_user().await;

View File

@ -1,3 +1,8 @@
use chrono::{DateTime, NaiveDateTime, Utc};
use rusqlite::{
types::{FromSql, FromSqlError, ToSqlOutput, Value, ValueRef},
ToSql,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
@ -5,7 +10,7 @@ use typeshare::typeshare;
use crate::{ use crate::{
asset_db::AssetId, asset_db::AssetId,
database::{GameId, GameRow, UserId, UserRow}, database::{GameId, UserId},
}; };
#[derive(Debug, Error)] #[derive(Debug, Error)]
@ -70,27 +75,55 @@ pub struct Rgb {
pub blue: u32, pub blue: u32,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug)]
#[serde(rename_all = "camelCase")] pub enum AccountState {
#[typeshare] Normal,
PasswordReset(DateTime<Utc>),
Locked,
}
impl FromSql for AccountState {
fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
if let ValueRef::Text(text) = value {
let text = String::from_utf8(text.to_vec()).unwrap();
if text.starts_with("Normal") {
Ok(AccountState::Normal)
} else if text.starts_with("PasswordReset") {
let exp_str = text.strip_prefix("PasswordReset ").unwrap();
let exp = NaiveDateTime::parse_from_str(exp_str, "%Y-%m-%d %H:%M:%S")
.unwrap()
.and_utc();
Ok(AccountState::PasswordReset(exp))
} else if text.starts_with("Locked") {
Ok(AccountState::Locked)
} else {
Err(FromSqlError::InvalidType)
}
} else {
Err(FromSqlError::InvalidType)
}
}
}
impl ToSql for AccountState {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
match self {
AccountState::Normal => Ok(ToSqlOutput::Borrowed(ValueRef::Text("Normal".as_bytes()))),
AccountState::PasswordReset(expiration) => Ok(ToSqlOutput::Owned(Value::Text(
format!("PasswordReset {}", expiration.format("%Y-%m-%d %H:%M:%S")),
))),
AccountState::Locked => Ok(ToSqlOutput::Borrowed(ValueRef::Text("Locked".as_bytes()))),
}
}
}
#[derive(Clone, Debug)]
pub struct User { pub struct User {
pub id: UserId, pub id: UserId,
pub name: String, pub name: String,
pub password: String, pub password: String,
pub admin: bool, pub admin: bool,
pub enabled: bool, pub state: AccountState,
}
impl From<UserRow> for User {
fn from(row: UserRow) -> Self {
Self {
id: row.id,
name: row.name.to_owned(),
password: row.password.to_owned(),
admin: row.admin,
enabled: row.enabled,
}
}
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
@ -112,7 +145,8 @@ pub struct Player {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[typeshare] #[typeshare]
pub struct Game { pub struct Game {
pub id: String, pub id: GameId,
pub type_: String,
pub name: String, pub name: String,
pub gm: UserId, pub gm: UserId,
pub players: Vec<UserId>, pub players: Vec<UserId>,
@ -133,25 +167,27 @@ pub enum Message {
UpdateTabletop(Tabletop), UpdateTabletop(Tabletop),
} }
#[derive(Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
#[typeshare] #[typeshare]
pub struct UserProfile { pub struct UserOverview {
pub id: UserId, pub id: UserId,
pub name: String, pub name: String,
pub games: Vec<GameOverview>,
pub is_admin: bool, pub is_admin: bool,
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
#[typeshare] #[typeshare]
pub struct GameOverview { pub struct GameOverview {
pub id: GameId, pub id: GameId,
pub game_type: String, pub type_: String,
pub game_name: String, pub name: String,
pub gm: UserId, pub gm: UserId,
pub players: Vec<UserId>, pub players: Vec<UserId>,
} }
/*
impl From<GameRow> for GameOverview { impl From<GameRow> for GameOverview {
fn from(row: GameRow) -> Self { fn from(row: GameRow) -> Self {
Self { Self {
@ -163,3 +199,4 @@ impl From<GameRow> for GameOverview {
} }
} }
} }
*/

View File

@ -33,7 +33,7 @@
"version": "0.0.1", "version": "0.0.1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.3"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {

View File

@ -32,8 +32,8 @@ interface AuthedViewProps {
const AuthedView = ({ client, children }: PropsWithChildren<AuthedViewProps>) => { const AuthedView = ({ client, children }: PropsWithChildren<AuthedViewProps>) => {
const [state, manager] = useContext(StateContext) const [state, manager] = useContext(StateContext)
return ( return (
<Authentication onAdminPassword={(password) => { <Authentication onSetPassword={(password1, password2) => {
manager.setAdminPassword(password) manager.setPassword(password1, password2)
}} onAuth={(username, password) => manager.auth(username, password)}> }} onAuth={(username, password) => manager.auth(username, password)}>
{children} {children}
</Authentication> </Authentication>

View File

@ -1,4 +1,4 @@
import { SessionId, UserId, UserProfile } from "visions-types"; import { AuthResponse, SessionId, UserId, UserOverview } from "visions-types";
export type PlayingField = { export type PlayingField = {
backgroundImage: string; backgroundImage: string;
@ -48,10 +48,13 @@ export class Client {
return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(name) }); 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); const url = new URL(this.base);
url.pathname = '/api/v1/users/'; 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) { async charsheet(id: string) {
@ -60,14 +63,18 @@ export class Client {
return fetch(url).then((response) => response.json()); return fetch(url).then((response) => response.json());
} }
async setAdminPassword(password: string) { async setPassword(password_1: string, password_2: string) {
const url = new URL(this.base); const url = new URL(this.base);
url.pathname = `/api/v1/admin_password`; url.pathname = `/api/v1/user/password`;
console.log("setting the admin password to: ", password);
return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(password) }); return fetch(url, {
method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify({
password_1, password_2,
})
});
} }
async auth(username: string, password: string): Promise<SessionId | undefined> { async auth(username: string, password: string): Promise<AuthResponse> {
const url = new URL(this.base); const url = new URL(this.base);
url.pathname = `/api/v1/auth` url.pathname = `/api/v1/auth`
const response = await fetch(url, { const response = await fetch(url, {
@ -75,11 +82,10 @@ export class Client {
headers: [['Content-Type', 'application/json']], headers: [['Content-Type', 'application/json']],
body: JSON.stringify({ 'username': username, 'password': password }) body: JSON.stringify({ 'username': username, 'password': password })
}); });
const session_id: SessionId = await response.json(); return await response.json();
return session_id;
} }
async profile(sessionId: SessionId, userId: UserId | undefined): Promise<UserProfile | undefined> { async profile(sessionId: SessionId, userId: UserId | undefined): Promise<UserOverview | undefined> {
const url = new URL(this.base); const url = new URL(this.base);
if (userId) { if (userId) {
url.pathname = `/api/v1/user${userId}` url.pathname = `/api/v1/user${userId}`
@ -96,7 +102,10 @@ export class Client {
async health() { async health() {
const url = new URL(this.base); const url = new URL(this.base);
url.pathname = `/api/v1/health`; url.pathname = `/api/v1/health`;
return fetch(url).then((response) => response.json()); return fetch(url).then((response) => response.json()).then((response) => {
console.log("health response: ", response);
return response;
});
} }
} }

View File

@ -0,0 +1,8 @@
.card {
border: var(--border-standard);
border-radius: var(--border-radius-standard);
box-shadow: var(--border-shadow-shallow);
padding: var(--padding-m);
}

View File

@ -0,0 +1,16 @@
import { PropsWithChildren } from 'react';
import './Card.css';
interface CardElementProps {
name?: string,
}
export const CardElement = ({ name, children }: PropsWithChildren<CardElementProps>) => (
<div className="card">
{name && <h1 className="card__title">{name}</h1> }
<div className="card__body">
{children}
</div>
</div>
)

View File

@ -0,0 +1,12 @@
import { GameOverview } from "visions-types"
import { CardElement } from '../Card/Card';
export const GameOverviewElement = ({ name, gm, players }: GameOverview) => {
return (<CardElement name={name}>
<p><i>GM</i> {gm}</p>
<ul>
{players.map((player) => player)}
</ul>
</CardElement>)
}

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,12 +1,21 @@
import { UserProfile } from 'visions-types'; import { GameOverview, UserOverview } from 'visions-types';
import { CardElement, GameOverviewElement, UserManagementElement } from '..';
import './Profile.css';
export const ProfileElement = ({ name, games, is_admin }: UserProfile) => { interface ProfileProps {
const adminNote = is_admin ? <div> <i>Note: this user is an admin</i> </div> : <></>; profile: UserOverview,
games: GameOverview[],
}
return ( export const ProfileElement = ({ profile, games }: ProfileProps) => {
<div className="card"> const adminNote = profile.isAdmin ? <div> <i>Note: this user is an admin</i> </div> : <></>;
<h1>{name}</h1>
<div>Games: {games.map((game) => <>{game.game_name} ({game.game_type})</>).join(', ')}</div> return (<div className="profile">
<CardElement name={profile.name}>
<div>Games: {games.map((game) => {
return <span key={game.id}>{game.name} ({game.type})</span>;
}) }</div>
{adminNote} {adminNote}
</CardElement>
</div>) </div>)
} }

View File

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

View File

@ -1,6 +1,9 @@
import { CardElement } from './Card/Card'
import { GameOverviewElement } from './GameOverview/GameOverview'
import { ProfileElement } from './Profile/Profile' import { ProfileElement } from './Profile/Profile'
import { SimpleGuage } from './Guages/SimpleGuage' import { SimpleGuage } from './Guages/SimpleGuage'
import { ThumbnailElement } from './Thumbnail/Thumbnail' import { ThumbnailElement } from './Thumbnail/Thumbnail'
import { TabletopElement } from './Tabletop/Tabletop' import { TabletopElement } from './Tabletop/Tabletop'
import { UserManagementElement } from './UserManagement/UserManagement'
export { ProfileElement, ThumbnailElement, TabletopElement, SimpleGuage } export { CardElement, GameOverviewElement, UserManagementElement, ProfileElement, ThumbnailElement, TabletopElement, SimpleGuage }

View File

@ -6,10 +6,3 @@
--margin-s: 4px; --margin-s: 4px;
} }
.card {
border: var(--border-standard);
border-radius: var(--border-radius-standard);
box-shadow: var(--border-shadow-shallow);
padding: var(--padding-m);
}

View File

@ -3,7 +3,7 @@ import { SessionId, Status, Tabletop } from "visions-types";
import { Client } from "../../client"; import { Client } from "../../client";
import { assertNever } from "../../utils"; import { assertNever } from "../../utils";
type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", sessionId: string }; type AuthState = { type: "Unauthed" } | { type: "Authed", sessionId: string } | { type: "PasswordReset", sessionId: string };
export enum LoadingState { export enum LoadingState {
Loading, Loading,
@ -21,7 +21,7 @@ type Action = { type: "SetAuthState", content: AuthState };
const initialState = (): AppState => { const initialState = (): AppState => {
let state: AppState = { let state: AppState = {
state: LoadingState.Ready, state: LoadingState.Ready,
auth: { type: "NoAdmin" }, auth: { type: "Unauthed" },
tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined }, tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined },
} }
@ -36,6 +36,7 @@ const initialState = (): AppState => {
const stateReducer = (state: AppState, action: Action): AppState => { const stateReducer = (state: AppState, action: Action): AppState => {
switch (action.type) { switch (action.type) {
case "SetAuthState": { case "SetAuthState": {
console.log("setReducer: ", action);
return { ...state, auth: action.content } return { ...state, auth: action.content }
} }
/* /*
@ -47,11 +48,13 @@ const stateReducer = (state: AppState, action: Action): AppState => {
} }
} }
export const authState = (state: AppState): AuthState => state.auth
export const getSessionId = (state: AppState): SessionId | undefined => { export const getSessionId = (state: AppState): SessionId | undefined => {
switch (state.auth.type) { switch (state.auth.type) {
case "NoAdmin": return undefined
case "Unauthed": return undefined case "Unauthed": return undefined
case "Authed": return state.auth.sessionId case "Authed": return state.auth.sessionId
case "PasswordReset": return state.auth.sessionId
default: { default: {
assertNever(state.auth) assertNever(state.auth)
return undefined return undefined
@ -68,30 +71,38 @@ class StateManager {
this.dispatch = dispatch; this.dispatch = dispatch;
} }
async status() { async setPassword(password1: string, password2: string) {
if (!this.client || !this.dispatch) return; if (!this.client || !this.dispatch) return;
const { admin_enabled } = await this.client.health(); await this.client.setPassword(password1, password2);
if (!admin_enabled) {
this.dispatch({ type: "SetAuthState", content: { type: "NoAdmin" } });
}
}
async setAdminPassword(password: string) {
if (!this.client || !this.dispatch) return;
await this.client.setAdminPassword(password);
await this.status();
} }
async auth(username: string, password: string) { async auth(username: string, password: string) {
if (!this.client || !this.dispatch) return; if (!this.client || !this.dispatch) return;
let sessionId = await this.client.auth(username, password); let authResponse = await this.client.auth(username, password);
switch (authResponse.type) {
case "Success": {
window.localStorage.setItem("sessionId", authResponse.content);
this.dispatch({ type: "SetAuthState", content: { type: "Authed", sessionId: authResponse.content } });
break;
}
case "PasswordReset": {
window.localStorage.setItem("sessionId", authResponse.content);
this.dispatch({ type: "SetAuthState", content: { type: "PasswordReset", sessionId: authResponse.content } });
break;
}
case "Locked": {
this.dispatch({ type: "SetAuthState", content: { type: "Unauthed" } });
break;
}
}
/*
if (sessionId) { if (sessionId) {
window.localStorage.setItem("sessionId", sessionId); window.localStorage.setItem("sessionId", sessionId);
this.dispatch({ type: "SetAuthState", content: { type: "Authed", sessionId } }); this.dispatch({ type: "SetAuthState", content: { type: "Authed", sessionId } });
} }
*/
} }
} }
@ -104,10 +115,6 @@ export const StateProvider = ({ client, children }: PropsWithChildren<StateProvi
const stateManager = useRef(new StateManager(client, dispatch)); const stateManager = useRef(new StateManager(client, dispatch));
useEffect(() => {
stateManager.current.status();
}, [stateManager]);
return <StateContext.Provider value={[state, stateManager.current]}> return <StateContext.Provider value={[state, stateManager.current]}>
{children} {children}
</StateContext.Provider>; </StateContext.Provider>;

View File

@ -7,10 +7,27 @@ interface UserRowProps {
} }
const UserRow = ({ user }: UserRowProps) => { const UserRow = ({ user }: UserRowProps) => {
let accountState = "Normal";
switch (user.state.type) {
case "Normal": {
accountState = "Normal";
break;
}
case "PasswordReset": {
accountState = `PasswordReset until ${user.state.content}`;
break;
}
case "Locked": {
accountState = "Locked";
break;
}
}
return (<tr> return (<tr>
<td> {user.name} </td> <td> {user.name} </td>
<td> {user.admin && "admin"} </td> <td> {user.admin && "admin"} </td>
<td> {user.enabled && "enabled"} </td> <td> {accountState} </td>
</tr>); </tr>);
} }
@ -32,7 +49,7 @@ export const Admin = ({ client }: AdminProps) => {
const [users, setUsers] = useState<Array<User>>([]); const [users, setUsers] = useState<Array<User>>([]);
useEffect(() => { useEffect(() => {
client.users().then((u) => { client.users("aoeu").then((u) => {
console.log(u); console.log(u);
setUsers(u); setUsers(u);
}); });

View File

@ -4,43 +4,36 @@ import { assertNever } from '../../utils';
import './Authentication.css'; import './Authentication.css';
interface AuthenticationProps { interface AuthenticationProps {
onAdminPassword: (password: string) => void; onSetPassword: (password1: string, password2: string) => void;
onAuth: (username: string, password: string) => void; onAuth: (username: string, password: string) => void;
} }
export const Authentication = ({ onAdminPassword, onAuth, children }: PropsWithChildren<AuthenticationProps>) => { export const Authentication = ({ onSetPassword, onAuth, children }: PropsWithChildren<AuthenticationProps>) => {
// No admin password set: prompt for the admin password // No admin password set: prompt for the admin password
// Password set, nobody logged in: prompt for login // Password set, nobody logged in: prompt for login
// User logged in: show the children // User logged in: show the children
let [userField, setUserField] = useState<string>(""); let [userField, setUserField] = useState<string>("");
let [pwField, setPwField] = useState<string>(""); let [pwField1, setPwField1] = useState<string>("");
let [pwField2, setPwField2] = useState<string>("");
let [state, _] = useContext(StateContext); let [state, _] = useContext(StateContext);
console.log("Authentication component", state.state);
switch (state.state) { switch (state.state) {
case LoadingState.Loading: { case LoadingState.Loading: {
return <div>Loading</div> return <div>Loading</div>
} }
case LoadingState.Ready: { case LoadingState.Ready: {
switch (state.auth.type) { switch (state.auth.type) {
case "NoAdmin": {
return <div className="auth">
<div className="card">
<h1> Welcome to your new Visions VTT Instance </h1>
<p> Set your admin password: </p>
<input type="password" placeholder="Password" onChange={(evt) => setPwField(evt.target.value)} />
<input type="submit" value="Submit" onClick={() => onAdminPassword(pwField)} />
</div>
</div>;
}
case "Unauthed": { case "Unauthed": {
return <div className="auth card"> return <div className="auth card">
<div className="card"> <div className="card">
<h1> Welcome to Visions VTT </h1> <h1> Welcome to Visions VTT </h1>
<div className="auth__input-line"> <div className="auth__input-line">
<input type="text" placeholder="Username" onChange={(evt) => setUserField(evt.target.value)} /> <input type="text" placeholder="Username" onChange={(evt) => setUserField(evt.target.value)} />
<input type="password" placeholder="Password" onChange={(evt) => setPwField(evt.target.value)} /> <input type="password" placeholder="Password" onChange={(evt) => setPwField1(evt.target.value)} />
<input type="submit" value="Sign in" onClick={() => onAuth(userField, pwField)} /> <input type="submit" value="Sign in" onClick={() => onAuth(userField, pwField1)} />
</div> </div>
</div> </div>
</div>; </div>;
@ -48,6 +41,17 @@ export const Authentication = ({ onAdminPassword, onAuth, children }: PropsWithC
case "Authed": { case "Authed": {
return <div> {children} </div>; return <div> {children} </div>;
} }
case "PasswordReset": {
return <div className="auth">
<div className="card">
<h1> Password Reset </h1>
<p> Your password currently requires a reset. </p>
<input type="password" placeholder="Password" onChange={(evt) => setPwField1(evt.target.value)} />
<input type="password" placeholder="Retype your Password" onChange={(evt) => setPwField2(evt.target.value)} />
<input type="submit" value="Submit" onClick={() => onSetPassword(pwField1, pwField2)} />
</div>
</div>;
}
default: { default: {
assertNever(state.auth); assertNever(state.auth);
return <div></div>; return <div></div>;

View File

@ -1,5 +1,5 @@
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { UserProfile } from 'visions-types'; import { UserOverview } from 'visions-types';
import { Client } from '../../client'; import { Client } from '../../client';
import { ProfileElement } from '../../components'; import { ProfileElement } from '../../components';
import { getSessionId, StateContext, StateProvider } from '../../providers/StateProvider/StateProvider'; import { getSessionId, StateContext, StateProvider } from '../../providers/StateProvider/StateProvider';
@ -10,19 +10,20 @@ interface MainProps {
export const MainView = ({ client }: MainProps) => { export const MainView = ({ client }: MainProps) => {
const [state, _manager] = useContext(StateContext) const [state, _manager] = useContext(StateContext)
const [profile, setProfile] = useState<UserProfile | undefined>(undefined) const [profile, setProfile] = useState<UserOverview | undefined>(undefined)
const [users, setUsers] = useState<UserOverview[]>([])
const sessionId = getSessionId(state) const sessionId = getSessionId(state)
useEffect(() => { useEffect(() => {
if (sessionId) { if (sessionId) {
client.profile(sessionId, undefined).then((profile) => setProfile(profile)) client.profile(sessionId, undefined).then((profile) => setProfile(profile))
client.users(sessionId).then((users) => setUsers(users))
} }
}, [sessionId, client]) }, [sessionId, client])
return ( return (
<div> <div>
<div>Session ID: {sessionId}</div> {profile && <ProfileElement profile={profile} games={[]} />}
{profile && <ProfileElement {...profile} />}
</div> </div>
) )
} }

View File

@ -9,13 +9,13 @@
"version": "0.0.1", "version": "0.0.1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.3"
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.7.2", "version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

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