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
11 changed files with 167 additions and 55 deletions
Showing only changes of commit af0ab5d020 - Show all commits

View File

@ -99,7 +99,7 @@ impl<A, E, FE> ResultExt<A, E, FE> {
} }
} }
/// Convert from a normal `Result` type to a `Result` type. The error condition for a `Result` will /// Convert from a normal `Result` type to a `ResultExt` type. The error condition for a `Result` will
/// be treated as `Result::Err`, never `Result::Fatal`. /// be treated as `Result::Err`, never `Result::Fatal`.
impl<A, E, FE> From<std::result::Result<A, E>> for ResultExt<A, E, FE> { impl<A, E, FE> From<std::result::Result<A, E>> for ResultExt<A, E, FE> {
fn from(r: std::result::Result<A, E>) -> Self { fn from(r: std::result::Result<A, E>) -> Self {

View File

@ -1,12 +0,0 @@
CREATE TABLE games(
uuid TEXT PRIMARY KEY,
name TEXT
);
CREATE TABLE characters(
uuid TEXT PRIMARY KEY,
game TEXT,
data TEXT,
FOREIGN KEY(game) REFERENCES games(uuid)
);

View File

@ -6,6 +6,19 @@ CREATE TABLE users(
enabled BOOLEAN enabled BOOLEAN
); );
CREATE TABLE games(
uuid TEXT PRIMARY KEY,
name TEXT
);
CREATE TABLE characters(
uuid TEXT PRIMARY KEY,
game TEXT,
data TEXT,
FOREIGN KEY(game) REFERENCES games(uuid)
);
CREATE TABLE roles( CREATE TABLE roles(
user_id TEXT, user_id TEXT,
game_id TEXT, game_id TEXT,
@ -14,3 +27,6 @@ CREATE TABLE roles(
FOREIGN KEY(user_id) REFERENCES users(uuid), FOREIGN KEY(user_id) REFERENCES users(uuid),
FOREIGN KEY(game_id) REFERENCES games(uuid) FOREIGN KEY(game_id) REFERENCES games(uuid)
); );
INSERT INTO users VALUES ("admin", "admin", "", true, true);

View File

@ -2,8 +2,10 @@ use std::{collections::HashMap, sync::Arc};
use async_std::sync::RwLock; use async_std::sync::RwLock;
use mime::Mime; use mime::Mime;
use result_extended::{fatal, ok, ResultExt}; use result_extended::{fatal, ok, return_error, ResultExt};
use serde::Serialize;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use typeshare::typeshare;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@ -18,6 +20,12 @@ const DEFAULT_BACKGROUND_COLOR: RGB = RGB {
blue: 0xbb, blue: 0xbb,
}; };
#[derive(Clone, Serialize)]
#[typeshare]
pub struct Status {
admin_enabled: bool,
}
#[derive(Debug)] #[derive(Debug)]
struct WebsocketClient { struct WebsocketClient {
sender: Option<UnboundedSender<Message>>, sender: Option<UnboundedSender<Message>>,
@ -51,6 +59,27 @@ 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 => {
return ok(Status {
admin_enabled: false,
});
}
};
ok(Status {
admin_enabled: !admin_user.password.is_empty(),
})
}
pub async fn register_client(&self) -> String { pub async fn register_client(&self) -> String {
let mut state = self.0.write().await; let mut state = self.0.write().await;
let uuid = Uuid::new_v4().simple().to_string(); let uuid = Uuid::new_v4().simple().to_string();

View File

@ -5,7 +5,10 @@ use async_trait::async_trait;
use include_dir::{include_dir, Dir}; use include_dir::{include_dir, Dir};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use result_extended::{error, fatal, ok, return_error, ResultExt}; use result_extended::{error, fatal, ok, return_error, ResultExt};
use rusqlite::{types::{FromSql, FromSqlResult, ValueRef}, Connection}; use rusqlite::{
types::{FromSql, FromSqlResult, ValueRef},
Connection,
};
use rusqlite_migration::Migrations; use rusqlite_migration::Migrations;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
@ -30,6 +33,7 @@ pub enum Error {
enum Request { enum Request {
Charsheet(CharacterId), Charsheet(CharacterId),
Games, Games,
User(UserId),
Users, Users,
} }
@ -43,6 +47,7 @@ struct DatabaseRequest {
enum DatabaseResponse { enum DatabaseResponse {
Charsheet(Option<CharsheetRow>), Charsheet(Option<CharsheetRow>),
Games(Vec<GameRow>), Games(Vec<GameRow>),
User(Option<UserRow>),
Users(Vec<UserRow>), Users(Vec<UserRow>),
} }
@ -179,6 +184,11 @@ pub struct CharsheetRow {
#[async_trait] #[async_trait]
pub trait Database: Send + Sync { pub trait Database: Send + Sync {
async fn user(
&mut self,
_: UserId,
) -> result_extended::ResultExt<Option<UserRow>, Error, FatalError>;
async fn users(&mut self) -> result_extended::ResultExt<Vec<UserRow>, Error, FatalError>; async fn users(&mut self) -> result_extended::ResultExt<Vec<UserRow>, Error, FatalError>;
async fn games(&mut self) -> result_extended::ResultExt<Vec<GameRow>, Error, FatalError>; async fn games(&mut self) -> result_extended::ResultExt<Vec<GameRow>, Error, FatalError>;
@ -193,6 +203,7 @@ pub struct DiskDb {
conn: Connection, conn: Connection,
} }
/*
fn setup_test_database(conn: &Connection) -> Result<(), FatalError> { fn setup_test_database(conn: &Connection) -> Result<(), FatalError> {
let mut gamecount_stmt = conn.prepare("SELECT count(*) FROM games").unwrap(); let mut gamecount_stmt = conn.prepare("SELECT count(*) FROM games").unwrap();
let mut count = gamecount_stmt.query([]).unwrap(); let mut count = gamecount_stmt.query([]).unwrap();
@ -236,6 +247,7 @@ fn setup_test_database(conn: &Connection) -> Result<(), FatalError> {
Ok(()) Ok(())
} }
*/
impl DiskDb { impl DiskDb {
pub fn new<P>(path: Option<P>) -> Result<Self, FatalError> pub fn new<P>(path: Option<P>) -> Result<Self, FatalError>
@ -251,30 +263,36 @@ 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)?; // setup_test_database(&conn)?;
Ok(DiskDb { conn }) Ok(DiskDb { conn })
} }
fn users(&self) -> Result<Vec<UserRow>, FatalError> { fn users(&self) -> Result<Vec<UserRow>, FatalError> {
let mut stmt = self.conn.prepare("SELECT * FROM USERS") let mut stmt = self
.conn
.prepare("SELECT * FROM users")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
let items = stmt.query_map([], |row| { let items = stmt
Ok(UserRow { .query_map([], |row| {
id: row.get(0).unwrap(), Ok(UserRow {
name: row.get(1).unwrap(), id: row.get(0).unwrap(),
password: row.get(2).unwrap(), name: row.get(1).unwrap(),
admin: row.get(3).unwrap(), password: row.get(2).unwrap(),
enabled: row.get(4).unwrap(), admin: row.get(3).unwrap(),
enabled: row.get(4).unwrap(),
})
}) })
}).unwrap().collect::<Result<Vec<UserRow>, rusqlite::Error>>().unwrap(); .unwrap()
.collect::<Result<Vec<UserRow>, rusqlite::Error>>()
.unwrap();
Ok(items) Ok(items)
} }
fn user(&self, id: UserId) -> Result<Option<UserRow>, FatalError> { fn user(&self, id: UserId) -> Result<Option<UserRow>, FatalError> {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT uuid, name, password, admin, enabled WHERE uuid=?") .prepare("SELECT uuid, name, password, admin, enabled 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<UserRow> = stmt
.query_map([id.as_str()], |row| { .query_map([id.as_str()], |row| {
@ -329,11 +347,7 @@ impl DiskDb {
} }
} }
fn save_game( fn save_game(&self, game_id: Option<GameId>, name: &str) -> Result<GameId, FatalError> {
&self,
game_id: Option<GameId>,
name: &str,
) -> Result<GameId, FatalError> {
match game_id { match game_id {
None => { None => {
let game_id = GameId::new(); let game_id = GameId::new();
@ -341,19 +355,15 @@ impl DiskDb {
.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(), name)) stmt.execute((game_id.as_str(), name)).unwrap();
.unwrap();
Ok(game_id) Ok(game_id)
} }
Some(game_id) => { Some(game_id) => {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare( .prepare("UPDATE games SET name=? WHERE uuid=?")
"UPDATE games SET name=? WHERE uuid=?",
)
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
stmt.execute((name, game_id.as_str())) stmt.execute((name, game_id.as_str())).unwrap();
.unwrap();
Ok(game_id) Ok(game_id)
} }
} }
@ -432,6 +442,15 @@ async fn db_handler(db: DiskDb, requestor: Receiver<DatabaseRequest>) {
Request::Games => { Request::Games => {
unimplemented!(); unimplemented!();
} }
Request::User(uid) => {
let user = db.user(uid);
match user {
Ok(user) => {
tx.send(DatabaseResponse::User(user)).await.unwrap();
}
err => panic!("{:?}", err),
}
}
Request::Users => { Request::Users => {
let users = db.users(); let users = db.users();
match users { match users {
@ -469,6 +488,26 @@ impl DbConn {
#[async_trait] #[async_trait]
impl Database for DbConn { impl Database for DbConn {
async fn user(&mut self, uid: UserId) -> ResultExt<Option<UserRow>, Error, FatalError> {
let (tx, rx) = bounded::<DatabaseResponse>(1);
let request = DatabaseRequest {
tx,
req: Request::User(uid),
};
match self.conn.send(request).await {
Ok(()) => (),
Err(_) => return fatal(FatalError::DatabaseConnectionLost),
};
match rx.recv().await {
Ok(DatabaseResponse::User(user)) => ok(user),
Ok(_) => fatal(FatalError::MessageMismatch),
Err(_) => error(Error::NoResponse),
}
}
async fn users(&mut self) -> ResultExt<Vec<UserRow>, Error, FatalError> { async fn users(&mut self) -> ResultExt<Vec<UserRow>, Error, FatalError> {
let (tx, rx) = bounded::<DatabaseResponse>(1); let (tx, rx) = bounded::<DatabaseResponse>(1);
@ -559,9 +598,7 @@ mod test {
assert_matches!(db.character(CharacterId::from("1")), Ok(None)); assert_matches!(db.character(CharacterId::from("1")), Ok(None));
let js: serde_json::Value = serde_json::from_str(soren).unwrap(); let js: serde_json::Value = serde_json::from_str(soren).unwrap();
let soren_id = db let soren_id = db.save_character(None, game_id, js.clone()).unwrap();
.save_character(None, game_id, js.clone())
.unwrap();
assert_matches!(db.character(soren_id).unwrap(), Some(CharsheetRow{ data, .. }) => assert_eq!(js, data)); assert_matches!(db.character(soren_id).unwrap(), Some(CharsheetRow{ data, .. }) => assert_eq!(js, data));
} }

View File

@ -56,11 +56,23 @@ where
} }
} }
pub async fn handle_server_status(core: Core) -> impl Reply {
handler(async move {
let status = return_error!(core.status().await);
ok(Response::builder()
.header("Access-Control-Allow-Origin", "*")
.header("Content-Type", "application/json")
.body(serde_json::to_vec(&status).unwrap())
.unwrap())
})
.await
}
pub async fn handle_file(core: Core, asset_id: AssetId) -> impl Reply { pub async fn handle_file(core: Core, asset_id: AssetId) -> impl Reply {
handler(async move { handler(async move {
let (mime, bytes) = return_error!(core.get_asset(asset_id).await); let (mime, bytes) = return_error!(core.get_asset(asset_id).await);
ok(Response::builder() ok(Response::builder()
.header("application-type", mime.to_string()) .header("content-type", mime.to_string())
.body(bytes) .body(bytes)
.unwrap()) .unwrap())
}) })

View File

@ -8,7 +8,7 @@ use asset_db::{AssetId, FsAssets};
use authdb::AuthError; use authdb::AuthError;
use database::DbConn; use database::DbConn;
use handlers::{ use handlers::{
handle_available_images, handle_connect_websocket, handle_file, handle_get_charsheet, handle_get_users, handle_register_client, 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_background_image, handle_unregister_client, RegisterRequest
}; };
use warp::{ use warp::{
// header, // header,
@ -104,6 +104,13 @@ pub async fn main() {
let core = core::Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); let core = core::Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn);
let log = warp::log("visions::api"); let log = warp::log("visions::api");
let server_status = warp::path!("api" / "v1" / "status")
.and(warp::get())
.then({
let core = core.clone();
move || handle_server_status(core.clone())
});
let route_image = warp::path!("api" / "v1" / "image" / String) let route_image = warp::path!("api" / "v1" / "image" / String)
.and(warp::get()) .and(warp::get())
.then({ .then({
@ -174,7 +181,8 @@ pub async fn main() {
move |charid| handle_get_charsheet(core.clone(), charid) move |charid| handle_get_charsheet(core.clone(), charid)
}); });
let filter = route_register_client let filter = server_status
.or(route_register_client)
.or(route_unregister_client) .or(route_unregister_client)
.or(route_websocket) .or(route_websocket)
.or(route_image) .or(route_image)

View File

@ -9,6 +9,7 @@ import { PlayerView } from './views/PlayerView/PlayerView';
import { Admin } from './views/Admin/Admin'; import { Admin } from './views/Admin/Admin';
import Candela from './plugins/Candela'; import Candela from './plugins/Candela';
import { Authentication } from './views/Authentication/Authentication'; import { Authentication } from './views/Authentication/Authentication';
import { StateProvider } from './components/StateProvider';
const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803"; const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803";
@ -38,7 +39,8 @@ const App = ({ client }: AppProps) => {
createBrowserRouter([ createBrowserRouter([
{ {
path: "/", path: "/",
element: websocketUrl ? <WebsocketProvider websocketUrl={websocketUrl}> <Authentication client={client}> <PlayerView client={client} /> </Authentication> </WebsocketProvider> : <div> </div> element: <StateProvider client={client}> <Authentication> <PlayerView client={client} /> </Authentication> </StateProvider>
}, },
{ {
path: "/gm", path: "/gm",

View File

@ -9,6 +9,12 @@ export class Client {
this.base = new URL("http://localhost:8001"); 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() { registerWebsocket() {
const url = new URL(this.base); const url = new URL(this.base);
url.pathname = `api/v1/client`; url.pathname = `api/v1/client`;

View File

@ -1,5 +1,6 @@
import React, { createContext, PropsWithChildren, useEffect, useReducer } from "react"; import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer } from "react";
import { Tabletop } from "visions-types"; import { Status, Tabletop } from "visions-types";
import { Client } from "../client";
import { assertNever } from "../plugins/Candela"; import { assertNever } from "../plugins/Candela";
type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", username: string }; type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", username: string };
@ -10,7 +11,7 @@ type TabletopState = {
} }
type StateAction = { type: "SetAuthState", state: AuthState } type StateAction = { type: "SetAuthState", state: AuthState }
| { type: "HandleMessage" }; | { type: "HandleMessage" };
const initialState = (): TabletopState => ( const initialState = (): TabletopState => (
{ {
@ -21,12 +22,27 @@ const initialState = (): TabletopState => (
export const AppContext = createContext<TabletopState>(initialState()); export const AppContext = createContext<TabletopState>(initialState());
interface StateProviderProps { } interface StateProviderProps { client: Client; }
export const StateProvider = ({ children }: PropsWithChildren<StateProviderProps>) => { export const StateProvider = ({ client, children }: PropsWithChildren<StateProviderProps>) => {
console.log("StateProvider");
const [state, dispatch] = useReducer(stateReducer, initialState()); const [state, dispatch] = useReducer(stateReducer, initialState());
return <AppContext.Provider value={initialState()}> useEffect(() => {
console.log("useCallback");
client.status().then((status: Status) => {
console.log("status: ", status);
if (status.admin_enabled) {
dispatch({ type: "SetAuthState", state: { type: "Unauthed" } });
} else {
dispatch({ type: "SetAuthState", state: { type: "NoAdmin" } });
}
})
},
[client]
);
return <AppContext.Provider value={state}>
{children} {children}
</AppContext.Provider>; </AppContext.Provider>;
} }

View File

@ -1,14 +1,12 @@
import React, { PropsWithChildren, ReactNode, useContext, useEffect, useState } from 'react'; import React, { PropsWithChildren, useContext } from 'react';
import { Client } from '../../client';
import { AppContext } from '../../components/StateProvider'; import { AppContext } from '../../components/StateProvider';
import { assertNever } from '../../plugins/Candela'; import { assertNever } from '../../plugins/Candela';
import './Authentication.css'; import './Authentication.css';
interface AuthenticationProps { interface AuthenticationProps {
client: Client;
} }
export const Authentication = ({ client, children }: PropsWithChildren<AuthenticationProps>) => { export const Authentication = ({ 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