Compare commits

...

2 Commits

17 changed files with 1696 additions and 2963 deletions

View File

@ -1,7 +1,7 @@
use std::{collections::HashMap, sync::Arc};
use async_std::sync::RwLock;
use chrono::{DateTime, TimeDelta, Utc};
use chrono::{DateTime, Duration, TimeDelta, Utc};
use mime::Mime;
use result_extended::{error, fatal, ok, result_as_fatal, return_error, ResultExt};
use serde::{Deserialize, Serialize};
@ -165,7 +165,7 @@ impl Core {
Some(_) => error(AppError::UsernameUnavailable),
None => match state
.db
.create_user(username, "", false, AccountState::PasswordReset(Utc::now()))
.create_user(username, "", false, AccountState::PasswordReset(Utc::now() + Duration::minutes(60)))
.await
{
Ok(user_id) => ok(user_id),
@ -341,6 +341,7 @@ impl Core {
) -> ResultExt<AuthResponse, AppError, FatalError> {
let now = Utc::now();
let state = self.0.read().await;
let user = state.db.user_by_username(username).await.unwrap().unwrap();
let user_info = return_error!(match state.db.user_by_username(username).await {
Ok(Some(row)) if row.password == password => ok(row),
Ok(_) => error(AppError::AuthFailed),
@ -375,6 +376,14 @@ impl Core {
Err(fatal_error) => fatal(fatal_error),
}
}
pub async fn delete_session(&self, session_id: &SessionId) -> ResultExt<(), AppError, FatalError> {
let state = self.0.read().await;
match state.db.delete_session(session_id).await {
Ok(_) => ok(()),
Err(err) => fatal(err),
}
}
}
fn create_expiration_date() -> DateTime<Utc> {

View File

@ -237,6 +237,21 @@ impl DiskDb {
}
}
fn delete_session(&self, session_id: &SessionId) -> Result<(), FatalError> {
match self.session(session_id) {
Ok(Some(_)) => {
let mut stmt = self.conn.prepare("DELETE FROM sessions WHERE id = ?")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
let session_id = SessionId::new();
stmt.execute((session_id.as_str(),)).unwrap();
Ok(())
}
Ok(None) => Err(FatalError::DatabaseKeyMissing),
Err(err) => Err(err),
}
}
pub fn character(&self, id: CharacterId) -> Result<Option<CharsheetRow>, FatalError> {
let mut stmt = self
.conn
@ -316,6 +331,10 @@ pub async fn db_handler(db: DiskDb, requestor: Receiver<DatabaseRequest>) {
.await
.unwrap();
}
Request::DeleteSession(id) => {
db.delete_session(&id).unwrap();
tx.send(DatabaseResponse::DeleteSession).await.unwrap();
}
Request::Games => match db.games() {
Ok(games) => tx.send(DatabaseResponse::Games(games)).await.unwrap(),
_ => unimplemented!("errors for Request::Games"),

View File

@ -16,6 +16,7 @@ enum Request {
CreateGame(UserId, String, String),
CreateSession(UserId),
CreateUser(String, String, bool, AccountState),
DeleteSession(SessionId),
Game(GameId),
Games,
SaveGame(Game),
@ -36,6 +37,7 @@ struct DatabaseRequest {
enum DatabaseResponse {
Charsheet(Option<CharsheetRow>),
CreateSession(SessionId),
DeleteSession,
Games(Vec<Game>),
Game(Option<Game>),
SaveGame(GameId),
@ -49,6 +51,7 @@ enum DatabaseResponse {
pub trait Database: Send + Sync {
async fn create_session(&self, id: &UserId) -> Result<SessionId, FatalError>;
async fn session(&self, id: &SessionId) -> Result<Option<User>, FatalError>;
async fn delete_session(&self, id: &SessionId) -> Result<(), FatalError>;
async fn character(&self, id: &CharacterId) -> Result<Option<CharsheetRow>, FatalError>;
@ -125,6 +128,9 @@ impl Database for DbConn {
async fn session(&self, id: &SessionId) -> Result<Option<User>, FatalError> {
send_request!(self, Request::Session(id.to_owned()), DatabaseResponse::Session(row) => Ok(row))
}
async fn delete_session(&self, id: &SessionId) -> Result<(), FatalError> {
send_request!(self, Request::DeleteSession(id.to_owned()), DatabaseResponse::DeleteSession => Ok(()))
}
async fn character(&self, id: &CharacterId) -> Result<Option<CharsheetRow>, FatalError> {
send_request!(self, Request::Charsheet(id.to_owned()), DatabaseResponse::Charsheet(row) => Ok(row))
}

View File

@ -38,10 +38,7 @@ pub struct SetAdminPasswordRequest {
pub password: String,
}
async fn check_session(
core: &Core,
headers: HeaderMap,
) -> ResultExt<Option<User>, AppError, FatalError> {
fn parse_session_header(headers: HeaderMap) -> ResultExt<Option<SessionId>, AppError, FatalError> {
match headers.get("Authorization") {
Some(token) => {
println!("check_session: {:?}", token);
@ -52,7 +49,7 @@ async fn check_session(
.collect::<Vec<&str>>()
.as_slice()
{
[_schema, token] => core.session(&SessionId::from(token.to_owned())).await,
[_schema, token] => ok(Some(SessionId::from(*token))),
_ => error(AppError::BadRequest),
}
}
@ -60,6 +57,16 @@ async fn check_session(
}
}
async fn check_session(
core: &Core,
headers: HeaderMap,
) -> ResultExt<Option<User>, AppError, FatalError> {
match return_error!(parse_session_header(headers)) {
Some(session_id) => core.session(&session_id).await,
None => ok(None),
}
}
pub async fn auth_required<F, A, Fut>(
core: Core,
headers: HeaderMap,
@ -101,7 +108,27 @@ pub async fn check_password(
req: Json<AuthRequest>,
) -> ResultExt<AuthResponse, AppError, FatalError> {
let Json(AuthRequest { username, password }) = req;
core.auth(&username, &password).await
println!("check_password: {} {}", username, password);
let result = core.auth(&username, &password).await;
println!("auth result: {:?}", result);
return result;
}
pub async fn delete_session(core: Core, headers: HeaderMap,) -> ResultExt<(), AppError, FatalError> {
/*
auth_required(core.clone(), headers, |user| async move {
match user_id {
Some(user_id) => core.delete_session
None => (),
}
}).await
*/
match return_error!(parse_session_header(headers)) {
Some(session_id) => core.delete_session(&session_id).await,
None => error(AppError::AuthFailed),
}
// await core.delete_session(session_id);
}
pub async fn get_user(

View File

@ -13,8 +13,7 @@ use crate::{
core::Core,
database::UserId,
handlers::{
check_password, create_game, create_user, get_user, get_users, healthcheck, set_password,
wrap_handler, AuthRequest, CreateGameRequest, CreateUserRequest, SetPasswordRequest,
check_password, create_game, create_user, delete_session, get_user, get_users, healthcheck, set_password, wrap_handler, AuthRequest, CreateGameRequest, CreateUserRequest, SetPasswordRequest
},
};
@ -38,10 +37,14 @@ pub fn routes(core: Core) -> Router {
let core = core.clone();
move |req: Json<AuthRequest>| wrap_handler(|| check_password(core, req))
})
.delete({
let core = core.clone();
move |headers: HeaderMap| wrap_handler(|| delete_session(core, headers))
})
.layer(
CorsLayer::new()
.allow_methods([Method::POST])
.allow_headers([CONTENT_TYPE])
.allow_methods([Method::DELETE, Method::POST])
.allow_headers([AUTHORIZATION, CONTENT_TYPE])
.allow_origin(Any),
),
)
@ -93,7 +96,13 @@ pub fn routes(core: Core) -> Router {
let Json(req) = req;
wrap_handler(|| set_password(core, headers, req))
}
}),
})
.layer(
CorsLayer::new()
.allow_methods([Method::PUT])
.allow_headers([AUTHORIZATION, CONTENT_TYPE])
.allow_origin(Any),
),
)
.route(
"/api/v1/user/:user_id",

View File

@ -7,3 +7,8 @@ tasks:
- npm install
- npm run start
test:
cmds:
- cd ../visions-types && task build
- npm install
- npm run test

File diff suppressed because it is too large Load Diff

View File

@ -3,9 +3,10 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.119",
"@types/react": "^18.3.12",

View File

@ -1,9 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
// render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -15,6 +15,7 @@ interface AppProps {
client: Client
}
/*
const CandelaCharsheet = ({ client }: { client: Client }) => {
let [sheet, setSheet] = useState(undefined)
useEffect(
@ -24,21 +25,7 @@ const CandelaCharsheet = ({ client }: { client: Client }) => {
return sheet ? <Candela.CharsheetElement sheet={sheet} /> : <div> </div>
}
interface AuthedViewProps {
client: Client
}
const AuthedView = ({ client, children }: PropsWithChildren<AuthedViewProps>) => {
const [state, manager] = useContext(StateContext)
return (
<Authentication onSetPassword={(password1, password2) => {
manager.setPassword(password1, password2)
}} onAuth={(username, password) => manager.auth(username, password)}>
{children}
</Authentication>
)
}
*/
const App = ({ client }: AppProps) => {
console.log("rendering app")
@ -64,10 +51,12 @@ const App = ({ client }: AppProps) => {
path: "/admin",
element: <AdminView client={client} />
},
/*
{
path: "/candela",
element: <CandelaCharsheet client={client} />
},
*/
{
path: "/design",
element: <DesignPage />

View File

@ -4,7 +4,17 @@ export type PlayingField = {
backgroundImage: string;
}
export class Client {
export type ServerResponse<A> = { type: "Unauthorized" } | { type: "Unexpected", status: number } | A;
export interface Client {
users: (sessionId: SessionId) => Promise<Array<UserOverview>>;
createUser: (sessionId: SessionId, username: string) => Promise<UserId>;
auth: (username: string, password: string) => Promise<ServerResponse<AuthResponse>>;
setPassword: (sessionId: SessionId, password_1: string, password_2: string) => Promise<void>;
logout: (sessionId: SessionId) => Promise<void>;
}
export class Connection implements Client {
private base: URL;
private sessionId: string | undefined;
@ -48,7 +58,7 @@ export class Client {
return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(name) });
}
async users(sessionId: string) {
async users(sessionId: string): Promise<Array<UserOverview>> {
const url = new URL(this.base);
url.pathname = '/api/v1/users';
return fetch(url, {
@ -63,29 +73,31 @@ export class Client {
return fetch(url).then((response) => response.json());
}
async createUser(sessionId: string, username: string) {
async createUser(sessionId: string, username: string): Promise<UserId> {
const url = new URL(this.base);
url.pathname = '/api/v1/user';
return fetch(url, {
const response: Response = await fetch(url, {
method: 'PUT',
headers: [['Authorization', `Bearer: ${sessionId}`],
['Content-Type', 'application/json']],
headers: [['Authorization', `Bearer ${sessionId}`],
['Content-Type', 'application/json']],
body: JSON.stringify({ username }),
}).then((response) => response.json())
});
const userId: UserId = await response.json();
return userId;
}
async setPassword(password_1: string, password_2: string) {
async setPassword(sessionId: string, password_1: string, password_2: string) {
const url = new URL(this.base);
url.pathname = `/api/v1/user/password`;
return fetch(url, {
method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify({
await fetch(url, {
method: 'PUT', headers: [['Authorization', `Bearer ${sessionId}`], ['Content-Type', 'application/json']], body: JSON.stringify({
password_1, password_2,
})
});
}
async auth(username: string, password: string): Promise<AuthResponse> {
async auth(username: string, password: string): Promise<ServerResponse<AuthResponse>> {
const url = new URL(this.base);
url.pathname = `/api/v1/auth`
const response = await fetch(url, {
@ -93,7 +105,22 @@ export class Client {
headers: [['Content-Type', 'application/json']],
body: JSON.stringify({ 'username': username, 'password': password })
});
return await response.json();
if (response.ok) {
return await response.json();
} else if (response.status == 401) {
return await response.json().then(() => ({ type: "Unauthorized" }));
} else {
return await response.json().then(() => ({ type: "Unexpected", status: response.status }));
}
}
async logout(sessionId: string) {
const url = new URL(this.base);
url.pathname = `/api/v1/auth`
await fetch(url, {
method: 'DELETE',
headers: [['Authorization', `Bearer ${sessionId}`]],
});
}
async profile(sessionId: SessionId, userId: UserId | undefined): Promise<UserOverview | undefined> {

View File

@ -3,9 +3,9 @@ import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { Client } from './client';
import { Connection } from './client';
const client = new Client();
const client = new Connection();
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement

View File

@ -0,0 +1,52 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { UserOverview, AuthResponse, SessionId, UserId } from "visions-types";
import { Client, ServerResponse } from "../../client";
import { StateProvider } from "./StateProvider";
import { AuthedView, Authentication } from '../../views/Authentication/Authentication';
class MockClient implements Client {
validUsers: {[username: string]: string};
constructor() {
this.validUsers = { "vakarian": "aoeu", "shephard": "rubbish" }
}
async users(sessionId: SessionId): Promise<UserOverview[]> {
return [];
}
async createUser(sessionId: SessionId, username: string): Promise<UserId> {
return "abcdefg";
}
async setPassword(sessionId: SessionId, password_1: string, password_2: string): Promise<void> {
}
async auth(username: string, password: string): Promise<ServerResponse<AuthResponse>> {
if (this.validUsers[username] === password) {
return { type: "Success", content: "session-id" };
} else {
return { type: "Unauthorized" };
}
}
async logout(sessionId: SessionId): Promise<void> {
}
}
test('a user is able to authenticate', async () => {
const client = new MockClient;
render(<StateProvider client={client}>
<AuthedView>
<p>Hi, authentication complete</p>
</AuthedView>
</StateProvider>);
expect(screen.getByText(/Welcome to Visions VTT/i)).toBeInTheDocument();
await act(async () => {
fireEvent.change(screen.getByPlaceholderText("Username"), { target: { value: "vakarian" } });
fireEvent.change(screen.getByPlaceholderText("Password"), { target: { value: "aoeu" } });
fireEvent.click(screen.getByRole("button"));
});
expect(screen.getByText(/Hi, authentication complete/i)).toBeInTheDocument();
})

View File

@ -62,26 +62,49 @@ export const getSessionId = (state: AppState): SessionId | undefined => {
}
}
class StateManager {
client: Client | undefined;
dispatch: React.Dispatch<Action> | undefined;
interface StateManagerInterface {
setPassword: (password1: string, password2: string) => void;
auth: (username: string, password: string) => void;
logout: () => void;
createUser: (username: string) => void;
}
constructor(client: Client | undefined, dispatch: React.Dispatch<any> | undefined) {
class NullManager implements StateManagerInterface {
constructor() { }
async setPassword(_password1: string, _password2: string) { }
async auth(_username: string, _password: string) { }
async logout() { }
async createUser(_username: string) { }
}
class StateManager implements StateManagerInterface{
client: Client;
state: AppState;
dispatch: React.Dispatch<Action>;
constructor(client: Client, state: AppState, dispatch: React.Dispatch<any>) {
this.client = client;
this.state = state;
this.dispatch = dispatch;
}
async setPassword(password1: string, password2: string) {
if (!this.client || !this.dispatch) return;
await this.client.setPassword(password1, password2);
let sessionId = getSessionId(this.state);
console.log(`StateManager.setPassword: ${sessionId}`);
if (sessionId) {
await this.client.setPassword(sessionId, password1, password2);
}
}
async auth(username: string, password: string) {
if (!this.client || !this.dispatch) return;
let authResponse = await this.client.auth(username, password);
switch (authResponse.type) {
case "Unauthorized": break;
case "Unexpected": break;
case "Success": {
window.localStorage.setItem("sessionId", authResponse.content);
this.dispatch({ type: "SetAuthState", content: { type: "Authed", sessionId: authResponse.content } });
@ -99,24 +122,33 @@ class StateManager {
}
}
async createUser(username: string) {
if (!this.client || !this.dispatch) return;
async logout() {
const sessionId = getSessionId(this.state);
if (sessionId) {
await this.client.logout(sessionId);
window.localStorage.removeItem("sessionId");
this.dispatch({ type: "SetAuthState", content: { type: "Unauthed" } });
}
}
const sessionId = window.localStorage.getItem("sessionId");
async createUser(username: string) {
console.log("order to createUser", username);
const sessionId = getSessionId(this.state);
if (sessionId) {
let createUserResponse = await this.client.createUser(sessionId, username);
console.log("createUser: ", createUserResponse);
}
}
}
export const StateContext = createContext<[AppState, StateManager]>([initialState(), new StateManager(undefined, undefined)]);
export const StateContext = createContext<[AppState, StateManagerInterface]>([initialState(), new NullManager()]);
interface StateProviderProps { client: Client; }
export const StateProvider = ({ client, children }: PropsWithChildren<StateProviderProps>) => {
const [state, dispatch] = useReducer(stateReducer, initialState());
const stateManager = useRef(new StateManager(client, dispatch));
const stateManager = useRef(new StateManager(client, state, dispatch));
return <StateContext.Provider value={[state, stateManager.current]}>
{children}

View File

@ -48,14 +48,14 @@ interface AdminProps {
export const AdminView = ({ client }: AdminProps) => {
const [users, setUsers] = useState<Array<User>>([]);
/*
useEffect(() => {
client.users("aoeu").then((u) => {
console.log(u);
setUsers(u);
});
}, [client]);
console.log(users);
*/
return (<table>
<tbody>
{users.map((user) => <UserRow user={user} />)}

View File

@ -6,9 +6,10 @@ import './Authentication.css';
interface AuthenticationProps {
onSetPassword: (password1: string, password2: string) => void;
onAuth: (username: string, password: string) => void;
onLogout: () => void;
}
export const Authentication = ({ onSetPassword, onAuth, children }: PropsWithChildren<AuthenticationProps>) => {
export const Authentication = ({ onSetPassword, onAuth, onLogout, 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
@ -18,8 +19,6 @@ export const Authentication = ({ onSetPassword, onAuth, children }: PropsWithChi
let [pwField2, setPwField2] = useState<string>("");
let [state, _] = useContext(StateContext);
console.log("Authentication component", state.state);
switch (state.state) {
case LoadingState.Loading: {
return <div>Loading</div>
@ -39,7 +38,10 @@ export const Authentication = ({ onSetPassword, onAuth, children }: PropsWithChi
</div>;
}
case "Authed": {
return <div> {children} </div>;
return (<div>
<div> <button onClick={onLogout}>Logout</button> </div>
<div> {children} </div>
</div>);
}
case "PasswordReset": {
return <div className="auth">
@ -48,7 +50,9 @@ export const Authentication = ({ onSetPassword, onAuth, children }: PropsWithChi
<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)} />
<input type="submit" value="Submit" onClick={() => {
onSetPassword(pwField1, pwField2);
}} />
</div>
</div>;
}
@ -59,5 +63,20 @@ export const Authentication = ({ onSetPassword, onAuth, children }: PropsWithChi
}
}
}
}
interface AuthedViewProps {}
export const AuthedView = ({ children }: PropsWithChildren<AuthedViewProps>) => {
const [_, manager] = useContext(StateContext)
return (
<Authentication onSetPassword={(password1, password2) => {
manager.setPassword(password1, password2)
}} onAuth={(username, password) => manager.auth(username, password)}
onLogout={() => manager.logout()}>
{children}
</Authentication>
)
}

View File

@ -15,6 +15,7 @@ export const MainView = ({ client }: MainProps) => {
const [users, setUsers] = useState<UserOverview[]>([])
const sessionId = getSessionId(state)
/*
useEffect(() => {
if (sessionId) {
client.profile(sessionId, undefined).then((profile) => setProfile(profile))
@ -24,6 +25,7 @@ export const MainView = ({ client }: MainProps) => {
})
}
}, [sessionId, client])
*/
return (
<div>