Set up the user interface state model and set up the admin user onboarding #283
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
);
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
@ -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())
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue