Disable a lot of code and start setting up tests for the authentication view

This commit is contained in:
Savanni D'Gerinel 2025-02-10 00:17:28 -05:00
parent 94a821d657
commit 4a0dc5b87a
12 changed files with 1613 additions and 2950 deletions

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>