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`.
impl<A, E, FE> From<std::result::Result<A, E>> for ResultExt<A, E, FE> {
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
);
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(
user_id TEXT,
game_id TEXT,
@ -14,3 +27,6 @@ CREATE TABLE roles(
FOREIGN KEY(user_id) REFERENCES users(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 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 typeshare::typeshare;
use uuid::Uuid;
use crate::{
@ -18,6 +20,12 @@ const DEFAULT_BACKGROUND_COLOR: RGB = RGB {
blue: 0xbb,
};
#[derive(Clone, Serialize)]
#[typeshare]
pub struct Status {
admin_enabled: bool,
}
#[derive(Debug)]
struct WebsocketClient {
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 {
let mut state = self.0.write().await;
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 lazy_static::lazy_static;
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 serde::{Deserialize, Serialize};
use thiserror::Error;
@ -30,6 +33,7 @@ pub enum Error {
enum Request {
Charsheet(CharacterId),
Games,
User(UserId),
Users,
}
@ -43,6 +47,7 @@ struct DatabaseRequest {
enum DatabaseResponse {
Charsheet(Option<CharsheetRow>),
Games(Vec<GameRow>),
User(Option<UserRow>),
Users(Vec<UserRow>),
}
@ -179,6 +184,11 @@ pub struct CharsheetRow {
#[async_trait]
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 games(&mut self) -> result_extended::ResultExt<Vec<GameRow>, Error, FatalError>;
@ -193,6 +203,7 @@ pub struct DiskDb {
conn: Connection,
}
/*
fn setup_test_database(conn: &Connection) -> Result<(), FatalError> {
let mut gamecount_stmt = conn.prepare("SELECT count(*) FROM games").unwrap();
let mut count = gamecount_stmt.query([]).unwrap();
@ -236,6 +247,7 @@ fn setup_test_database(conn: &Connection) -> Result<(), FatalError> {
Ok(())
}
*/
impl DiskDb {
pub fn new<P>(path: Option<P>) -> Result<Self, FatalError>
@ -251,30 +263,36 @@ impl DiskDb {
.to_latest(&mut conn)
.map_err(|err| FatalError::DatabaseMigrationFailure(format!("{}", err)))?;
setup_test_database(&conn)?;
// setup_test_database(&conn)?;
Ok(DiskDb { conn })
}
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)))?;
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(),
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();
.unwrap()
.collect::<Result<Vec<UserRow>, rusqlite::Error>>()
.unwrap();
Ok(items)
}
fn user(&self, id: UserId) -> Result<Option<UserRow>, FatalError> {
let mut stmt = self
.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)))?;
let items: Vec<UserRow> = stmt
.query_map([id.as_str()], |row| {
@ -329,11 +347,7 @@ impl DiskDb {
}
}
fn save_game(
&self,
game_id: Option<GameId>,
name: &str,
) -> Result<GameId, FatalError> {
fn save_game(&self, game_id: Option<GameId>, name: &str) -> Result<GameId, FatalError> {
match game_id {
None => {
let game_id = GameId::new();
@ -341,19 +355,15 @@ impl DiskDb {
.conn
.prepare("INSERT INTO games VALUES (?, ?)")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
stmt.execute((game_id.as_str(), name))
.unwrap();
stmt.execute((game_id.as_str(), name)).unwrap();
Ok(game_id)
}
Some(game_id) => {
let mut stmt = self
.conn
.prepare(
"UPDATE games SET name=? WHERE uuid=?",
)
.prepare("UPDATE games SET name=? WHERE uuid=?")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
stmt.execute((name, game_id.as_str()))
.unwrap();
stmt.execute((name, game_id.as_str())).unwrap();
Ok(game_id)
}
}
@ -432,6 +442,15 @@ async fn db_handler(db: DiskDb, requestor: Receiver<DatabaseRequest>) {
Request::Games => {
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 => {
let users = db.users();
match users {
@ -469,6 +488,26 @@ impl DbConn {
#[async_trait]
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> {
let (tx, rx) = bounded::<DatabaseResponse>(1);
@ -559,9 +598,7 @@ mod test {
assert_matches!(db.character(CharacterId::from("1")), Ok(None));
let js: serde_json::Value = serde_json::from_str(soren).unwrap();
let soren_id = db
.save_character(None, game_id, js.clone())
.unwrap();
let soren_id = db.save_character(None, game_id, js.clone()).unwrap();
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 {
handler(async move {
let (mime, bytes) = return_error!(core.get_asset(asset_id).await);
ok(Response::builder()
.header("application-type", mime.to_string())
.header("content-type", mime.to_string())
.body(bytes)
.unwrap())
})

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_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::{
// header,
@ -104,6 +104,13 @@ 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")
.and(warp::get())
.then({
let core = core.clone();
move || handle_server_status(core.clone())
});
let route_image = warp::path!("api" / "v1" / "image" / String)
.and(warp::get())
.then({
@ -174,7 +181,8 @@ pub async fn main() {
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_websocket)
.or(route_image)

View File

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

View File

@ -9,6 +9,12 @@ 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`;

View File

@ -1,5 +1,6 @@
import React, { createContext, PropsWithChildren, useEffect, useReducer } from "react";
import { Tabletop } from "visions-types";
import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer } 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 };
@ -10,7 +11,7 @@ type TabletopState = {
}
type StateAction = { type: "SetAuthState", state: AuthState }
| { type: "HandleMessage" };
| { type: "HandleMessage" };
const initialState = (): TabletopState => (
{
@ -21,12 +22,27 @@ const initialState = (): TabletopState => (
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());
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}
</AppContext.Provider>;
}

View File

@ -1,14 +1,12 @@
import React, { PropsWithChildren, ReactNode, useContext, useEffect, useState } from 'react';
import { Client } from '../../client';
import React, { PropsWithChildren, useContext } from 'react';
import { AppContext } from '../../components/StateProvider';
import { assertNever } from '../../plugins/Candela';
import './Authentication.css';
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
// Password set, nobody logged in: prompt for login
// User logged in: show the children