Set up the user interface state model and set up the admin user onboarding #283

Merged
savanni merged 12 commits from visions-admin into main 2024-12-18 14:18:16 +00:00
8 changed files with 243 additions and 116 deletions
Showing only changes of commit 2a616ef6c9 - Show all commits

View File

@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc};
use async_std::sync::RwLock;
use mime::Mime;
use result_extended::{fatal, ok, return_error, ResultExt};
use result_extended::{error, fatal, ok, return_error, ResultExt};
use serde::Serialize;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use typeshare::typeshare;
@ -10,7 +10,7 @@ use uuid::Uuid;
use crate::{
asset_db::{self, AssetId, Assets},
database::{CharacterId, Database, Error, UserId},
database::{CharacterId, Database, UserId},
types::{AppError, FatalError, Game, Message, Tabletop, User, RGB},
};
@ -23,7 +23,7 @@ const DEFAULT_BACKGROUND_COLOR: RGB = RGB {
#[derive(Clone, Serialize)]
#[typeshare]
pub struct Status {
admin_enabled: bool,
pub admin_enabled: bool,
}
#[derive(Debug)]
@ -61,19 +61,15 @@ impl Core {
pub async fn status(&self) -> ResultExt<Status, AppError, FatalError> {
let mut state = self.0.write().await;
let admin_user = match return_error!(state
.db
.user(UserId::from("admin"))
.await
.map_err(|_| AppError::Inaccessible("database stopped responding".to_owned())))
{
Some(admin_user) => admin_user,
None => {
let admin_user = return_error!(match state.db.user(UserId::from("admin")).await {
Ok(Some(admin_user)) => ok(admin_user),
Ok(None) => {
return ok(Status {
admin_enabled: false,
});
}
};
Err(err) => fatal(err),
});
ok(Status {
admin_enabled: !admin_user.password.is_empty(),
@ -113,29 +109,17 @@ impl Core {
pub async fn list_users(&self) -> ResultExt<Vec<User>, AppError, FatalError> {
let users = self.0.write().await.db.users().await;
match users {
ResultExt::Ok(users) => {
ResultExt::Ok(users.into_iter().map(|u| User::from(u)).collect())
}
ResultExt::Err(err) => {
println!("Database error: {:?}", err);
ResultExt::Ok(vec![])
}
ResultExt::Fatal(users) => ResultExt::Fatal(users),
Ok(users) => ok(users.into_iter().map(|u| User::from(u)).collect()),
Err(err) => fatal(err),
}
}
pub async fn list_games(&self) -> ResultExt<Vec<Game>, AppError, FatalError> {
let games = self.0.write().await.db.games().await;
match games {
ResultExt::Ok(games) => {
// ResultExt::Ok(games.into_iter().map(|u| Game::from(u)).collect())
unimplemented!();
}
ResultExt::Err(err) => {
println!("Database error: {:?}", err);
ResultExt::Ok(vec![])
}
ResultExt::Fatal(games) => ResultExt::Fatal(games),
// Ok(games) => ok(games.into_iter().map(|g| Game::from(g)).collect()),
Ok(games) => unimplemented!(),
Err(err) => fatal(err),
}
}
@ -199,10 +183,11 @@ impl Core {
) -> ResultExt<Option<serde_json::Value>, AppError, FatalError> {
let mut state = self.0.write().await;
let cr = state.db.character(id).await;
cr.map(|cr| cr.map(|cr| cr.data)).or_else(|err| {
println!("Database error: {:?}", err);
ResultExt::Ok(None)
})
match cr {
Ok(Some(row)) => ok(Some(row.data)),
Ok(None) => ok(None),
Err(err) => fatal(err),
}
}
pub async fn publish(&self, message: Message) {
@ -214,6 +199,27 @@ impl Core {
}
});
}
pub async fn set_password(
&self,
uuid: UserId,
password: String,
) -> ResultExt<(), AppError, FatalError> {
let mut state = self.0.write().await;
let user = match state.db.user(uuid.clone()).await {
Ok(Some(row)) => row,
Ok(None) => return error(AppError::NotFound(uuid.as_str().to_owned())),
Err(err) => return fatal(err),
};
match state
.db
.save_user(Some(uuid), &user.name, &password, user.admin, user.enabled)
.await
{
Ok(_) => ok(()),
Err(err) => fatal(err),
}
}
}
#[cfg(test)]

View File

@ -4,14 +4,12 @@ use async_std::channel::{bounded, Receiver, Sender};
use async_trait::async_trait;
use include_dir::{include_dir, Dir};
use lazy_static::lazy_static;
use result_extended::{error, fatal, ok, return_error, ResultExt};
use rusqlite::{
types::{FromSql, FromSqlResult, ValueRef},
Connection,
};
use rusqlite_migration::Migrations;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
use crate::types::FatalError;
@ -23,18 +21,13 @@ lazy_static! {
Migrations::from_directory(&MIGRATIONS_DIR).unwrap();
}
#[derive(Debug, Error)]
pub enum Error {
#[error("No response to request")]
NoResponse,
}
#[derive(Debug)]
enum Request {
Charsheet(CharacterId),
Games,
User(UserId),
Users,
SaveUser(Option<UserId>, String, String, bool, bool),
}
#[derive(Debug)]
@ -49,6 +42,7 @@ enum DatabaseResponse {
Games(Vec<GameRow>),
User(Option<UserRow>),
Users(Vec<UserRow>),
SaveUser(UserId),
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
@ -184,19 +178,22 @@ pub struct CharsheetRow {
#[async_trait]
pub trait Database: Send + Sync {
async fn user(
async fn user(&mut self, _: UserId) -> Result<Option<UserRow>, FatalError>;
async fn save_user(
&mut self,
_: UserId,
) -> result_extended::ResultExt<Option<UserRow>, Error, FatalError>;
user_id: Option<UserId>,
name: &str,
password: &str,
admin: bool,
enabled: bool,
) -> Result<UserId, FatalError>;
async fn users(&mut self) -> result_extended::ResultExt<Vec<UserRow>, Error, FatalError>;
async fn users(&mut self) -> Result<Vec<UserRow>, FatalError>;
async fn games(&mut self) -> result_extended::ResultExt<Vec<GameRow>, Error, FatalError>;
async fn games(&mut self) -> Result<Vec<GameRow>, FatalError>;
async fn character(
&mut self,
id: CharacterId,
) -> result_extended::ResultExt<Option<CharsheetRow>, Error, FatalError>;
async fn character(&mut self, id: CharacterId) -> Result<Option<CharsheetRow>, FatalError>;
}
pub struct DiskDb {
@ -268,27 +265,6 @@ impl DiskDb {
Ok(DiskDb { conn })
}
fn users(&self) -> Result<Vec<UserRow>, FatalError> {
let mut stmt = self
.conn
.prepare("SELECT * FROM users")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
let items = stmt
.query_map([], |row| {
Ok(UserRow {
id: row.get(0).unwrap(),
name: row.get(1).unwrap(),
password: row.get(2).unwrap(),
admin: row.get(3).unwrap(),
enabled: row.get(4).unwrap(),
})
})
.unwrap()
.collect::<Result<Vec<UserRow>, rusqlite::Error>>()
.unwrap();
Ok(items)
}
fn user(&self, id: UserId) -> Result<Option<UserRow>, FatalError> {
let mut stmt = self
.conn
@ -314,6 +290,27 @@ impl DiskDb {
}
}
fn users(&self) -> Result<Vec<UserRow>, FatalError> {
let mut stmt = self
.conn
.prepare("SELECT * FROM users")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
let items = stmt
.query_map([], |row| {
Ok(UserRow {
id: row.get(0).unwrap(),
name: row.get(1).unwrap(),
password: row.get(2).unwrap(),
admin: row.get(3).unwrap(),
enabled: row.get(4).unwrap(),
})
})
.unwrap()
.collect::<Result<Vec<UserRow>, rusqlite::Error>>()
.unwrap();
Ok(items)
}
fn save_user(
&self,
user_id: Option<UserId>,
@ -337,7 +334,7 @@ impl DiskDb {
let mut stmt = self
.conn
.prepare(
"UPDATE users SET name=?, password=?, admin=?, enbabled=? WHERE uuid=?",
"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()))
@ -398,7 +395,7 @@ impl DiskDb {
char_id: Option<CharacterId>,
game: GameId,
character: serde_json::Value,
) -> std::result::Result<CharacterId, Error> {
) -> std::result::Result<CharacterId, FatalError> {
match char_id {
None => {
let char_id = CharacterId::new();
@ -451,6 +448,15 @@ async fn db_handler(db: DiskDb, requestor: Receiver<DatabaseRequest>) {
err => panic!("{:?}", err),
}
}
Request::SaveUser(user_id, username, password, admin, enabled) => {
let user_id = db.save_user(user_id, username.as_ref(), password.as_ref(), admin, enabled);
match user_id {
Ok(user_id) => {
tx.send(DatabaseResponse::SaveUser(user_id)).await.unwrap();
}
err => panic!("{:?}", err),
}
}
Request::Users => {
let users = db.users();
match users {
@ -488,7 +494,7 @@ impl DbConn {
#[async_trait]
impl Database for DbConn {
async fn user(&mut self, uid: UserId) -> ResultExt<Option<UserRow>, Error, FatalError> {
async fn user(&mut self, uid: UserId) -> Result<Option<UserRow>, FatalError> {
let (tx, rx) = bounded::<DatabaseResponse>(1);
let request = DatabaseRequest {
@ -498,17 +504,50 @@ impl Database for DbConn {
match self.conn.send(request).await {
Ok(()) => (),
Err(_) => return fatal(FatalError::DatabaseConnectionLost),
Err(_) => return Err(FatalError::DatabaseConnectionLost),
};
match rx.recv().await {
Ok(DatabaseResponse::User(user)) => ok(user),
Ok(_) => fatal(FatalError::MessageMismatch),
Err(_) => error(Error::NoResponse),
Ok(DatabaseResponse::User(user)) => Ok(user),
Ok(_) => Err(FatalError::MessageMismatch),
Err(_) => Err(FatalError::DatabaseConnectionLost),
}
}
async fn users(&mut self) -> ResultExt<Vec<UserRow>, Error, FatalError> {
async fn save_user(
&mut self,
user_id: Option<UserId>,
name: &str,
password: &str,
admin: bool,
enabled: bool,
) -> Result<UserId, FatalError> {
let (tx, rx) = bounded::<DatabaseResponse>(1);
let request = DatabaseRequest {
tx,
req: Request::SaveUser(
user_id,
name.to_owned(),
password.to_owned(),
admin,
enabled,
),
};
match self.conn.send(request).await {
Ok(()) => (),
Err(_) => return Err(FatalError::DatabaseConnectionLost),
};
match rx.recv().await {
Ok(DatabaseResponse::SaveUser(user_id)) => Ok(user_id),
Ok(_) => Err(FatalError::MessageMismatch),
Err(_) => Err(FatalError::DatabaseConnectionLost),
}
}
async fn users(&mut self) -> Result<Vec<UserRow>, FatalError> {
let (tx, rx) = bounded::<DatabaseResponse>(1);
let request = DatabaseRequest {
@ -518,17 +557,17 @@ impl Database for DbConn {
match self.conn.send(request).await {
Ok(()) => (),
Err(_) => return fatal(FatalError::DatabaseConnectionLost),
Err(_) => return Err(FatalError::DatabaseConnectionLost),
};
match rx.recv().await {
Ok(DatabaseResponse::Users(lst)) => ok(lst),
Ok(_) => fatal(FatalError::MessageMismatch),
Err(_) => error(Error::NoResponse),
Ok(DatabaseResponse::Users(lst)) => Ok(lst),
Ok(_) => Err(FatalError::MessageMismatch),
Err(_) => Err(FatalError::DatabaseConnectionLost),
}
}
async fn games(&mut self) -> result_extended::ResultExt<Vec<GameRow>, Error, FatalError> {
async fn games(&mut self) -> Result<Vec<GameRow>, FatalError> {
let (tx, rx) = bounded::<DatabaseResponse>(1);
let request = DatabaseRequest {
@ -538,20 +577,17 @@ impl Database for DbConn {
match self.conn.send(request).await {
Ok(()) => (),
Err(_) => return fatal(FatalError::DatabaseConnectionLost),
Err(_) => return Err(FatalError::DatabaseConnectionLost),
};
match rx.recv().await {
Ok(DatabaseResponse::Games(lst)) => ok(lst),
Ok(_) => fatal(FatalError::MessageMismatch),
Err(_) => error(Error::NoResponse),
Ok(DatabaseResponse::Games(lst)) => Ok(lst),
Ok(_) => Err(FatalError::MessageMismatch),
Err(_) => Err(FatalError::DatabaseConnectionLost),
}
}
async fn character(
&mut self,
id: CharacterId,
) -> ResultExt<Option<CharsheetRow>, Error, FatalError> {
async fn character(&mut self, id: CharacterId) -> Result<Option<CharsheetRow>, FatalError> {
let (tx, rx) = bounded::<DatabaseResponse>(1);
let request = DatabaseRequest {
@ -561,13 +597,13 @@ impl Database for DbConn {
match self.conn.send(request).await {
Ok(()) => (),
Err(_) => return fatal(FatalError::DatabaseConnectionLost),
Err(_) => return Err(FatalError::DatabaseConnectionLost),
};
match rx.recv().await {
Ok(DatabaseResponse::Charsheet(row)) => ok(row),
Ok(_) => fatal(FatalError::MessageMismatch),
Err(_err) => error(Error::NoResponse),
Ok(DatabaseResponse::Charsheet(row)) => Ok(row),
Ok(_) => Err(FatalError::MessageMismatch),
Err(_) => Err(FatalError::DatabaseConnectionLost),
}
}
}

View File

@ -1,14 +1,14 @@
use std::future::Future;
use futures::{SinkExt, StreamExt};
use result_extended::{ok, return_error, ResultExt};
use result_extended::{error, ok, return_error, ResultExt};
use serde::{Deserialize, Serialize};
use warp::{http::Response, http::StatusCode, reply::Reply, ws::Message};
use crate::{
asset_db::AssetId,
core::Core,
database::CharacterId,
database::{CharacterId, UserId},
types::{AppError, FatalError},
};
@ -237,3 +237,21 @@ pub async fn handle_get_charsheet(core: Core, charid: String) -> impl Reply {
})
.await
}
pub async fn handle_set_admin_password(core: Core, password: String) -> impl Reply {
handler(async move {
let status = return_error!(core.status().await);
if status.admin_enabled {
return error(AppError::PermissionDenied);
}
core.set_password(UserId::from("admin"), password).await;
ok(Response::builder()
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "*")
.header("Content-Type", "application/json")
.body(vec![])
.unwrap())
})
.await
}

View File

@ -8,7 +8,7 @@ use asset_db::{AssetId, FsAssets};
use authdb::AuthError;
use database::DbConn;
use handlers::{
handle_available_images, handle_connect_websocket, handle_file, handle_get_charsheet, handle_get_users, handle_register_client, handle_server_status, handle_set_background_image, handle_unregister_client, RegisterRequest
handle_available_images, handle_connect_websocket, handle_file, handle_get_charsheet, handle_get_users, handle_register_client, handle_server_status, handle_set_admin_password, handle_set_background_image, handle_unregister_client, RegisterRequest
};
use warp::{
// header,
@ -104,7 +104,7 @@ pub async fn main() {
let core = core::Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn);
let log = warp::log("visions::api");
let server_status = warp::path!("api" / "v1" / "status")
let route_server_status = warp::path!("api" / "v1" / "status")
.and(warp::get())
.then({
let core = core.clone();
@ -181,7 +181,28 @@ pub async fn main() {
move |charid| handle_get_charsheet(core.clone(), charid)
});
let filter = server_status
let route_set_admin_password_options = warp::path!("api" / "v1" / "admin_password")
.and(warp::options())
.map({
move || {
Response::builder()
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "PUT")
.header("Access-Control-Allow-Headers", "content-type")
.header("Content-Type", "application/json")
.body("")
.unwrap()
}
});
let route_set_admin_password = warp::path!("api" / "v1" / "admin_password")
.and(warp::put())
.and(warp::body::json())
.then({
let core = core.clone();
move |body| handle_set_admin_password(core.clone(), body)
});
let filter = route_server_status
.or(route_register_client)
.or(route_unregister_client)
.or(route_websocket)
@ -191,6 +212,8 @@ pub async fn main() {
.or(route_set_bg_image)
.or(route_get_users)
.or(route_get_charsheet)
.or(route_set_admin_password_options)
.or(route_set_admin_password)
.recover(handle_rejection);
let server = warp::serve(filter);

View File

@ -33,6 +33,9 @@ pub enum AppError {
#[error("object inaccessible {0}")]
Inaccessible(String),
#[error("the requested operation is not allowed")]
PermissionDenied,
#[error("invalid json {0}")]
JsonError(serde_json::Error),

View File

@ -32,10 +32,10 @@ interface AuthedViewProps {
}
const AuthedView = ({ client, children }: PropsWithChildren<AuthedViewProps>) => {
const [state, dispatch] = useContext(StateContext);
const [state, manager] = useContext(StateContext);
return (
<Authentication onAdminPassword={(password) => {
dispatch({type: "SetAdminPassword", password });
manager.setAdminPassword(password);
}} onAuth={(username, password) => console.log(username, password)}>
{children}
</Authentication>

View File

@ -9,12 +9,6 @@ export class Client {
this.base = new URL("http://localhost:8001");
}
status() {
const url = new URL(this.base);
url.pathname = `/api/v1/status`;
return fetch(url).then((response) => response.json());
}
registerWebsocket() {
const url = new URL(this.base);
url.pathname = `api/v1/client`;
@ -62,4 +56,18 @@ export class Client {
url.pathname = `/api/v1/charsheet/${id}`;
return fetch(url).then((response) => response.json());
}
async setAdminPassword(password: string) {
const url = new URL(this.base);
url.pathname = `/api/v1/admin_password`;
console.log("setting the admin password to: ", password);
return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(password) });
}
async status() {
const url = new URL(this.base);
url.pathname = `/api/v1/status`;
return fetch(url).then((response) => response.json());
}
}

View File

@ -1,16 +1,16 @@
import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer } from "react";
import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer, useRef } from "react";
import { Status, Tabletop } from "visions-types";
import { Client } from "../../client";
import { assertNever } from "../../plugins/Candela";
type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", username: string };
type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", userid: string };
type AppState = {
auth: AuthState;
tabletop: Tabletop;
}
type Action = { type: "SetAdminPassword", password: string } | { type: "Auth", username: string, password: string };
type Action = { type: "SetAuthState", content: AuthState };
const initialState = (): AppState => (
{
@ -20,18 +20,51 @@ const initialState = (): AppState => (
);
const stateReducer = (state: AppState, action: Action): AppState => {
console.log("reducer: ", state, action);
return state;
return { ...state, auth: action.content }
}
export const StateContext = createContext<[AppState, React.Dispatch<any>]>([initialState(), () => { }]);
class StateManager {
client: Client | undefined;
dispatch: React.Dispatch<Action> | undefined;
constructor(client: Client | undefined, dispatch: React.Dispatch<any> | undefined) {
this.client = client;
this.dispatch = dispatch;
}
async status() {
if (!this.client || !this.dispatch) return;
const { admin_enabled } = await this.client.status();
if (!admin_enabled) {
this.dispatch({ type: "SetAuthState", content: { type: "NoAdmin" } });
} else {
this.dispatch({ type: "SetAuthState", content: { type: "Unauthed" } });
}
}
async setAdminPassword(password: string) {
if (!this.client || !this.dispatch) return;
await this.client.setAdminPassword(password);
await this.status();
}
}
export const StateContext = createContext<[AppState, StateManager]>([initialState(), new StateManager(undefined, undefined)]);
interface StateProviderProps { client: Client; }
export const StateProvider = ({ client, children }: PropsWithChildren<StateProviderProps>) => {
const [state, dispatch] = useReducer(stateReducer, initialState());
return <StateContext.Provider value={[state, dispatch]}>
const stateManager = useRef(new StateManager(client, dispatch));
useEffect(() => {
stateManager.current.status();
}, [stateManager]);
return <StateContext.Provider value={[state, stateManager.current]}>
{children}
</StateContext.Provider>;
}