Webatrice: KnownHosts component (#4456)

* refactor dexie services for future schema updates

Co-authored-by: Jeremy Letto <jeremy.letto@datasite.com>
This commit is contained in:
Jeremy Letto 2021-11-25 21:12:23 -06:00 committed by GitHub
parent 37879c4255
commit 6ce346af4a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 1381 additions and 1291 deletions

File diff suppressed because it is too large Load diff

View file

@ -8,12 +8,14 @@
"@material-ui/styles": "^4.11.4", "@material-ui/styles": "^4.11.4",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"dexie": "^3.0.3", "dexie": "^3.0.3",
"final-form": "^4.20.4",
"jquery": "^3.6.0", "jquery": "^3.6.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"protobufjs": "^6.11.2", "protobufjs": "^6.11.2",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-final-form": "^6.5.7",
"react-redux": "^7.2.6", "react-redux": "^7.2.6",
"react-router-dom": "^5.3.0", "react-router-dom": "^5.3.0",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",

View file

@ -1,29 +1,28 @@
import { StatusEnum, User } from 'types'; import { StatusEnum, User, WebSocketConnectReason, WebSocketConnectOptions } from 'types';
import { SessionCommands, webClient } from 'websocket'; import { SessionCommands, webClient } from 'websocket';
import { WebSocketConnectReason, WebSocketOptions } from '../websocket/services/WebSocketService';
export default class AuthenticationService { export default class AuthenticationService {
static connect(options: WebSocketOptions): void { static login(options: WebSocketConnectOptions): void {
SessionCommands.connect(options, WebSocketConnectReason.LOGIN); SessionCommands.connect(options, WebSocketConnectReason.LOGIN);
} }
static register(options: WebSocketOptions): void { static register(options: WebSocketConnectOptions): void {
SessionCommands.connect(options, WebSocketConnectReason.REGISTER); SessionCommands.connect(options, WebSocketConnectReason.REGISTER);
} }
static activateAccount(options: WebSocketOptions): void { static activateAccount(options: WebSocketConnectOptions): void {
SessionCommands.connect(options, WebSocketConnectReason.ACTIVATE_ACCOUNT); SessionCommands.connect(options, WebSocketConnectReason.ACTIVATE_ACCOUNT);
} }
static resetPasswordRequest(options: WebSocketOptions): void { static resetPasswordRequest(options: WebSocketConnectOptions): void {
SessionCommands.connect(options, WebSocketConnectReason.PASSWORD_RESET_REQUEST); SessionCommands.connect(options, WebSocketConnectReason.PASSWORD_RESET_REQUEST);
} }
static resetPasswordChallenge(options: WebSocketOptions): void { static resetPasswordChallenge(options: WebSocketConnectOptions): void {
SessionCommands.connect(options, WebSocketConnectReason.PASSWORD_RESET_CHALLENGE); SessionCommands.connect(options, WebSocketConnectReason.PASSWORD_RESET_CHALLENGE);
} }
static resetPassword(options: WebSocketOptions): void { static resetPassword(options: WebSocketConnectOptions): void {
SessionCommands.connect(options, WebSocketConnectReason.PASSWORD_RESET); SessionCommands.connect(options, WebSocketConnectReason.PASSWORD_RESET);
} }
@ -44,4 +43,8 @@ export default class AuthenticationService {
static isAdmin() { static isAdmin() {
} }
static connectionAttemptMade() {
return webClient.connectionAttemptMade;
}
} }

View file

@ -1,5 +0,0 @@
.dialog-title {
display: flex;
justify-content: space-between;
align-items: center;
}

View file

@ -13,14 +13,13 @@ import MenuRoundedIcon from '@material-ui/icons/MenuRounded';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { AuthenticationService, RoomsService } from 'api'; import { AuthenticationService, RoomsService } from 'api';
import { CardImportDialog } from 'dialogs';
import { Images } from 'images'; import { Images } from 'images';
import { RoomsSelectors, ServerSelectors } from 'store'; import { RoomsSelectors, ServerSelectors } from 'store';
import { Room, RouteEnum, User } from 'types'; import { Room, RouteEnum, User } from 'types';
import './Header.css'; import './Header.css';
import CardImportDialog from '../CardImportDialog/CardImportDialog';
class Header extends Component<HeaderProps> { class Header extends Component<HeaderProps> {
state: HeaderState; state: HeaderState;
options: string[] = [ options: string[] = [

View file

@ -1,8 +1,8 @@
.inputField { .InputField {
position: relative; position: relative;
} }
.inputField-validation { .InputField-validation {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
@ -10,11 +10,11 @@
font-weight: bold; font-weight: bold;
} }
.inputField-error { .InputField-error {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.inputField-error svg { .InputField-error svg {
margin-left: 4px; margin-left: 4px;
} }

View file

@ -1,47 +1,55 @@
import React from 'react'; import React from 'react';
import { styled } from '@material-ui/core/styles'; import { makeStyles } from '@material-ui/core/styles';
import TextField from '@material-ui/core/TextField'; import TextField from '@material-ui/core/TextField';
import ErrorOutlinedIcon from '@material-ui/icons/ErrorOutlined'; import ErrorOutlinedIcon from '@material-ui/icons/ErrorOutlined';
import './InputField.css'; import './InputField.css';
const InputField = ({ input, label, name, autoComplete, type, meta: { touched, error, warning } }) => ( const useStyles = makeStyles(theme => ({
<div className="inputField"> root: {
{ touched && ( '& .InputField-error': {
<div className="inputField-validation"> color: theme.palette.error.main
{ },
(error &&
<ThemedFieldError className="inputField-error">
{error}
<ErrorOutlinedIcon style={{ fontSize: 'small', fontWeight: 'bold' }} />
</ThemedFieldError>
) ||
(warning && <ThemedFieldWarning className="inputField-warning">{warning}</ThemedFieldWarning>) '& .InputField-warning': {
} color: theme.palette.warning.main
</div> }
) } },
<TextField
className="rounded"
variant="outlined"
margin="dense"
fullWidth={true}
label={label}
name={name}
type={type}
autoComplete={autoComplete}
{ ...input }
/>
</div>
);
const ThemedFieldError = styled('div')(({ theme }) => ({
color: theme.palette.error.main
})); }));
const ThemedFieldWarning = styled('div')(({ theme }) => ({ const InputField = ({ input, label, name, autoComplete, type, meta: { touched, error, warning } }) => {
color: theme.palette.warning.main const classes = useStyles();
}));
return (
<div className={'InputField ' + classes.root}>
{ touched && (
<div className="InputField-validation">
{
(error &&
<div className="InputField-error">
{error}
<ErrorOutlinedIcon style={{ fontSize: 'small', fontWeight: 'bold' }} />
</div>
) ||
(warning && <div className="InputField-warning">{warning}</div>)
}
</div>
) }
<TextField
className="rounded"
variant="outlined"
margin="dense"
fullWidth={true}
label={label}
name={name}
type={type}
autoComplete={autoComplete}
{ ...input }
/>
</div>
);
};
export default InputField; export default InputField;

View file

@ -1,5 +1,6 @@
.KnownHosts { .KnownHosts {
width: 100%; width: 100%;
position: relative;
} }
.KnownHosts-item__label { .KnownHosts-item__label {
@ -15,6 +16,27 @@
font-size: .9em; font-size: .9em;
} }
.KnownHosts-validation {
position: absolute;
top: 0;
right: 0;
transform: translateY(-100%);
font-weight: bold;
}
.KnownHosts-error {
display: flex;
align-items: center;
}
.KnownHosts-error svg {
margin-left: 4px;
}
.Mui-selected .KnownHosts-item__label svg { .Mui-selected .KnownHosts-item__label svg {
display: block; display: block;
} }
.MuiSelect-selectMenu .KnownHosts-item__edit {
display: none;
}

View file

@ -1,85 +1,209 @@
// eslint-disable-next-line // eslint-disable-next-line
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Select, MenuItem } from '@material-ui/core'; import { Select, MenuItem } from '@material-ui/core';
import Button from '@material-ui/core/Button'; import Button from '@material-ui/core/Button';
import FormControl from '@material-ui/core/FormControl'; import FormControl from '@material-ui/core/FormControl';
import IconButton from '@material-ui/core/IconButton'; import IconButton from '@material-ui/core/IconButton';
import InputLabel from '@material-ui/core/InputLabel'; import InputLabel from '@material-ui/core/InputLabel';
import { makeStyles } from '@material-ui/core/styles';
import Check from '@material-ui/icons/Check'; import Check from '@material-ui/icons/Check';
import AddIcon from '@material-ui/icons/Add';
import EditRoundedIcon from '@material-ui/icons/Edit'; import EditRoundedIcon from '@material-ui/icons/Edit';
import ErrorOutlinedIcon from '@material-ui/icons/ErrorOutlined';
import { KnownHostDialog } from 'dialogs';
import { HostDTO } from 'services'; import { HostDTO } from 'services';
import { DefaultHosts, getHostPort } from 'types'; import { DefaultHosts, Host, getHostPort } from 'types';
import './KnownHosts.css'; import './KnownHosts.css';
const KnownHosts = ({ onChange }) => { const useStyles = makeStyles(theme => ({
const [state, setState] = useState({ root: {
'& .KnownHosts-error': {
color: theme.palette.error.main
},
'& .KnownHosts-warning': {
color: theme.palette.warning.main
}
},
}));
const KnownHosts = ({ input: { onChange }, meta: { touched, error, warning } }) => {
const classes = useStyles();
const [hostsState, setHostsState] = useState({
hosts: [], hosts: [],
selectedHost: 0, selectedHost: {} as any,
}); });
useEffect(() => { const [dialogState, setDialogState] = useState({
HostDTO.getAll().then(async hosts => { open: false,
if (hosts?.length) { edit: null,
setState(s => ({ ...s, hosts })); });
} else {
setState(s => ({ ...s, hosts: DefaultHosts })); const loadKnownHosts = useCallback(async () => {
await HostDTO.bulkAdd(DefaultHosts); const hosts = await HostDTO.getAll();
}
}); if (!hosts?.length) {
// @TODO: find a better pattern to seeding default data in indexedDB
await HostDTO.bulkAdd(DefaultHosts);
loadKnownHosts();
} else {
const selectedHost = hosts.find(({ lastSelected }) => lastSelected) || hosts[0];
setHostsState(s => ({ ...s, hosts, selectedHost }));
}
}, []); }, []);
useEffect(() => { useEffect(() => {
if (state.hosts.length) { loadKnownHosts();
onChange(getHostPort(state.hosts[state.selectedHost])); }, [loadKnownHosts]);
useEffect(() => {
const { hosts, selectedHost } = hostsState;
if (selectedHost?.id) {
updateLastSelectedHost(selectedHost.id).then(() => {
onChange(selectedHost);
});
} }
}, [state, onChange]); }, [hostsState, onChange]);
const selectHost = (selectedHost) => { const selectHost = (selectedHost) => {
setState(s => ({ ...s, selectedHost })); setHostsState(s => ({ ...s, selectedHost }));
}; };
const addKnownHost = () => { const openAddKnownHostDialog = () => {
console.log('KnownHosts->addKnownHost'); setDialogState(s => ({ ...s, open: true, edit: null }));
}; };
const editKnownHost = (hostIndex) => { const openEditKnownHostDialog = (host: HostDTO) => {
console.log('KnownHosts->editKnownHost: ', state.hosts[hostIndex]); setDialogState(s => ({ ...s, open: true, edit: host }));
};
const closeKnownHostDialog = () => {
setDialogState(s => ({ ...s, open: false }));
}
const handleDialogRemove = async ({ id }) => {
setHostsState(s => ({
...s,
hosts: s.hosts.filter(host => host.id !== id),
selectedHost: s.selectedHost.id === id ? s.hosts[0] : s.selectedHost,
}));
closeKnownHostDialog();
HostDTO.delete(id);
};
const handleDialogSubmit = async ({ id, name, host, port }) => {
if (id) {
const hostDTO = await HostDTO.get(id);
hostDTO.name = name;
hostDTO.host = host;
hostDTO.port = port;
await hostDTO.save();
setHostsState(s => ({
...s,
hosts: s.hosts.map(h => h.id === id ? hostDTO : h),
selectedHost: hostDTO
}));
} else {
const newHost: Host = { name, host, port, editable: true };
newHost.id = await HostDTO.add(newHost) as number;
setHostsState(s => ({
...s,
hosts: [...s.hosts, newHost],
selectedHost: newHost,
}));
}
closeKnownHostDialog();
};
const updateLastSelectedHost = (hostId): Promise<any[]> => {
return HostDTO.getAll().then(hosts =>
hosts.map(async host => {
if (host.id === hostId) {
host.lastSelected = true;
return await host.save();
}
if (host.lastSelected) {
host.lastSelected = false;
return await host.save();
}
return host;
})
);
}; };
return ( return (
<FormControl variant='outlined' className='KnownHosts'> <div>
<InputLabel id='KnownHosts-select'>Host</InputLabel> <FormControl variant='outlined' className={'KnownHosts ' + classes.root}>
<Select { touched && (
id='KnownHosts-select' <div className="KnownHosts-validation">
labelId='KnownHosts-label' {
label='Host' (error &&
margin='dense' <div className="KnownHosts-error">
value={state.selectedHost} {error}
fullWidth={true} <ErrorOutlinedIcon style={{ fontSize: 'small', fontWeight: 'bold' }} />
onChange={e => selectHost(e.target.value)} </div>
> ) ||
<Button value={state.selectedHost} onClick={addKnownHost}>Add</Button>
{ (warning && <div className="KnownHosts-warning">{warning}</div>)
state.hosts.map((host, index) => ( }
<MenuItem className='KnownHosts-item' value={index} key={index}> </div>
<div className='KnownHosts-item__label'> ) }
<Check />
<span>{host.name} ({ getHostPort(state.hosts[index]).host }:{getHostPort(state.hosts[index]).port})</span>
</div>
{ host.editable && ( <InputLabel id='KnownHosts-select'>Host</InputLabel>
<IconButton size='small' color='primary' onClick={() => editKnownHost(index)}> <Select
<EditRoundedIcon fontSize='small' /> id='KnownHosts-select'
</IconButton> labelId='KnownHosts-label'
) } label='Host'
</MenuItem> margin='dense'
)) name='host'
} value={hostsState.selectedHost}
</Select> fullWidth={true}
</FormControl> onChange={e => selectHost(e.target.value)}
>
<Button value={hostsState.selectedHost} onClick={openAddKnownHostDialog}>
<span>Add new host</span>
<AddIcon fontSize='small' color='primary' />
</Button>
{
hostsState.hosts.map((host, index) => (
<MenuItem className='KnownHosts-item' value={host} key={index}>
<div className='KnownHosts-item__label'>
<Check />
<span>{host.name} ({ getHostPort(hostsState.hosts[index]).host }:{getHostPort(hostsState.hosts[index]).port})</span>
</div>
{ host.editable && (
<IconButton className='KnownHosts-item__edit' size='small' color='primary' onClick={(e) => {
openEditKnownHostDialog(hostsState.hosts[index]);
}}>
<EditRoundedIcon fontSize='small' />
</IconButton>
) }
</MenuItem>
))
}
</Select>
</FormControl>
<KnownHostDialog
isOpen={dialogState.open}
host={dialogState.edit}
onRemove={handleDialogRemove}
onSubmit={handleDialogSubmit}
handleClose={closeKnownHostDialog}
/>
</div>
) )
}; };

View file

@ -12,13 +12,11 @@ export { default as ThreePaneLayout } from './ThreePaneLayout/ThreePaneLayout';
export { default as CheckboxField } from './CheckboxField/CheckboxField'; export { default as CheckboxField } from './CheckboxField/CheckboxField';
export { default as SelectField } from './SelectField/SelectField'; export { default as SelectField } from './SelectField/SelectField';
export { default as ScrollToBottomOnChanges } from './ScrollToBottomOnChanges/ScrollToBottomOnChanges'; export { default as ScrollToBottomOnChanges } from './ScrollToBottomOnChanges/ScrollToBottomOnChanges';
export { default as RegistrationDialog } from './RegistrationDialog/RegistrationDialog';
// Guards // Guards
export { default as AuthGuard } from './Guard/AuthGuard'; export { default as AuthGuard } from './Guard/AuthGuard';
export { default as ModGuard } from './Guard/ModGuard'; export { default as ModGuard } from './Guard/ModGuard';
// Dialogs // Dialogs
export { default as CardImportDialog } from './CardImportDialog/CardImportDialog';
export { default as RequestPasswordResetDialog } from './RequestPasswordResetDialog/RequestPasswordResetDialog'; export { default as RequestPasswordResetDialog } from './RequestPasswordResetDialog/RequestPasswordResetDialog';
export { default as ResetPasswordDialog } from './ResetPasswordDialog/ResetPasswordDialog'; export { default as ResetPasswordDialog } from './ResetPasswordDialog/ResetPasswordDialog';

View file

@ -1,5 +1,5 @@
// eslint-disable-next-line // eslint-disable-next-line
import React, {useState} from "react"; import React, { useState, useCallback } from "react";
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { makeStyles } from '@material-ui/core/styles'; import { makeStyles } from '@material-ui/core/styles';
@ -13,9 +13,10 @@ import { RequestPasswordResetDialog, ResetPasswordDialog } from 'components';
import { LoginForm } from 'forms'; import { LoginForm } from 'forms';
import { useReduxEffect } from 'hooks'; import { useReduxEffect } from 'hooks';
import { Images } from 'images'; import { Images } from 'images';
import { RouteEnum, StatusEnum } from 'types'; import { HostDTO } from 'services';
import { RouteEnum, WebSocketConnectOptions, getHostPort } from 'types';
import { ServerSelectors, ServerTypes } from 'store'; import { ServerSelectors, ServerTypes } from 'store';
import { SessionCommands } from 'websocket';
import './Login.css'; import './Login.css';
const useStyles = makeStyles(theme => ({ const useStyles = makeStyles(theme => ({
@ -59,6 +60,7 @@ const Login = ({ state, description }: LoginProps) => {
const classes = useStyles(); const classes = useStyles();
const isConnected = AuthenticationService.isConnected(state); const isConnected = AuthenticationService.isConnected(state);
const [hostIdToRemember, setHostIdToRemember] = useState(null);
const [dialogState, setDialogState] = useState({ const [dialogState, setDialogState] = useState({
passwordResetRequestDialog: false, passwordResetRequestDialog: false,
resetPasswordDialog: false resetPasswordDialog: false
@ -73,6 +75,15 @@ const Login = ({ state, description }: LoginProps) => {
closeResetPasswordDialog(); closeResetPasswordDialog();
}, ServerTypes.RESET_PASSWORD_SUCCESS, []); }, ServerTypes.RESET_PASSWORD_SUCCESS, []);
useReduxEffect(({ options: { hashedPassword } }) => {
if (hostIdToRemember) {
HostDTO.get(hostIdToRemember).then(host => {
host.hashedPassword = hashedPassword;
host.save();
});
}
}, ServerTypes.LOGIN_SUCCESSFUL, [hostIdToRemember]);
const showDescription = () => { const showDescription = () => {
return !isConnected && description?.length; return !isConnected && description?.length;
}; };
@ -81,6 +92,47 @@ const Login = ({ state, description }: LoginProps) => {
console.log('Login.createAccount->openForgotPasswordDialog'); console.log('Login.createAccount->openForgotPasswordDialog');
}; };
const onSubmit = useCallback((loginForm) => {
const {
userName,
password,
selectedHost,
selectedHost: {
id: hostId,
hashedPassword
},
remember
} = loginForm;
updateHost(loginForm);
if (remember) {
setHostIdToRemember(hostId);
}
const options: WebSocketConnectOptions = {
...getHostPort(selectedHost),
userName,
password
};
if (!password) {
options.hashedPassword = hashedPassword;
}
AuthenticationService.login(options as WebSocketConnectOptions);
}, []);
const updateHost = ({ selectedHost, userName, hashedPassword, remember }) => {
HostDTO.get(selectedHost.id).then(hostDTO => {
hostDTO.remember = remember;
hostDTO.userName = remember ? userName : null;
hostDTO.hashedPassword = remember ? hashedPassword : null;
hostDTO.save();
});
};
const handleRequestPasswordResetDialogSubmit = async ({ user, email, host, port }) => { const handleRequestPasswordResetDialogSubmit = async ({ user, email, host, port }) => {
if (email) { if (email) {
AuthenticationService.resetPasswordChallenge({ user, email, host, port } as any); AuthenticationService.resetPasswordChallenge({ user, email, host, port } as any);
@ -123,7 +175,7 @@ const Login = ({ state, description }: LoginProps) => {
<Typography variant="h1">Login</Typography> <Typography variant="h1">Login</Typography>
<Typography variant="subtitle1">A cross-platform virtual tabletop for multiplayer card games.</Typography> <Typography variant="subtitle1">A cross-platform virtual tabletop for multiplayer card games.</Typography>
<div className="login-form"> <div className="login-form">
<LoginForm onSubmit={AuthenticationService.connect} /> <LoginForm onSubmit={onSubmit} />
</div> </div>
{ {

View file

@ -0,0 +1,5 @@
.dialog-title {
display: flex;
justify-content: space-between;
align-items: center;
}

View file

@ -18,7 +18,7 @@ const CardImportDialog = ({ classes, handleClose, isOpen }: any) => {
return ( return (
<Dialog onClose={handleOnClose} open={isOpen}> <Dialog onClose={handleOnClose} open={isOpen}>
<DialogTitle disableTypography className="dialog-title"> <DialogTitle disableTypography className="dialog-title">
<Typography variant="h6">Import Cards</Typography> <Typography variant="h2">Import Cards</Typography>
{handleOnClose ? ( {handleOnClose ? (
<IconButton onClick={handleOnClose}> <IconButton onClick={handleOnClose}>

View file

@ -0,0 +1,26 @@
.KnownHostDialog {
}
.KnownHostDialog .MuiDialog-paper {
width: 100%;
max-width: 420px;
}
.dialog-title__wrapper {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid;
padding-bottom: 10px;
}
.dialog-title__label {
display: flex;
align-items: center;
}
.dialog-content__subtitle.MuiTypography-root {
margin-bottom: 20px;
}

View file

@ -0,0 +1,55 @@
import React from 'react';
import Dialog from '@material-ui/core/Dialog';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import IconButton from '@material-ui/core/IconButton';
import { makeStyles } from '@material-ui/core/styles';
import AddIcon from '@material-ui/icons/Add';
import CloseIcon from '@material-ui/icons/Close';
import Typography from '@material-ui/core/Typography';
import { KnownHostForm } from 'forms';
import './KnownHostDialog.css';
const useStyles = makeStyles(theme => ({
root: {
'& .dialog-title__wrapper': {
borderColor: theme.palette.grey[300]
}
},
}));
const KnownHostDialog = ({ handleClose, onRemove, onSubmit, isOpen, host }: any) => {
const classes = useStyles();
const handleOnClose = () => {
if (handleClose) {
handleClose();
}
};
return (
<Dialog className={'KnownHostDialog ' + classes.root} onClose={handleOnClose} open={isOpen}>
<DialogTitle disableTypography className='dialog-title'>
<div className='dialog-title__wrapper'>
<Typography variant='h2'>{ host ? 'Edit' : 'Add' } Known Host</Typography>
{handleClose ? (
<IconButton onClick={handleClose}>
<CloseIcon fontSize='large' />
</IconButton>
) : null}
</div>
</DialogTitle>
<DialogContent className='dialog-content'>
<Typography className='dialog-content__subtitle' variant='subtitle1'>
Adding a new host allows you to connect to different servers. Enter the details below to your host list.
</Typography>
<KnownHostForm onRemove={onRemove} onSubmit={onSubmit} host={host}></KnownHostForm>
</DialogContent>
</Dialog>
);
};
export default KnownHostDialog;

View file

@ -0,0 +1,3 @@
export { default as CardImportDialog } from './CardImportDialog/CardImportDialog';
export { default as KnownHostDialog } from './KnownHostDialog/KnownHostDialog';
export { default as RegistrationDialog } from './RegistrationDialog/RegistrationDialog';

View file

@ -0,0 +1,21 @@
.KnownHostForm {
width: 100%;
}
.KnownHostForm-item {
display: flex;
flex-direction: column;
margin-bottom: 20px;
}
.KnownHostForm-submit {
width: 100%;
}
.KnownHostForm-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin: 5px 0 10px;
color: red;
}

View file

@ -0,0 +1,83 @@
// eslint-disable-next-line
import React, { useState } from "react";
import { connect } from 'react-redux';
import { Form, Field } from 'react-final-form'
import Button from '@material-ui/core/Button';
import AnchorLink from '@material-ui/core/Link';
import { InputField } from 'components';
import './KnownHostForm.css';
function KnownHostForm({ host, onRemove, onSubmit }) {
const [confirmDelete, setConfirmDelete] = useState(false);
return (
<Form
initialValues={{
id: host?.id,
name: host?.name,
host: host?.host,
port: host?.port,
}}
onSubmit={onSubmit}
validate={values => {
const errors: any = {};
if (!values.name) {
errors.name = 'Required'
}
if (!values.host) {
errors.host = 'Required'
}
if (!values.port) {
errors.port = 'Required'
}
if (Object.keys(errors).length) {
return errors;
}
}}
>
{({ handleSubmit }) => (
<form className="KnownHostForm" onSubmit={handleSubmit}>
<div className="KnownHostForm-item">
<Field label="Host Name" name="name" component={InputField} />
</div>
<div className="KnownHostForm-item">
<Field label="Host Address" name="host" component={InputField} />
</div>
<div className="KnownHostForm-item">
<Field label="Port" name="port" type="number" component={InputField} />
</div>
<Button className="KnownHostForm-submit" color="primary" variant="contained" type="submit">
{host ? 'Save Changes' : 'Add Host' }
</Button>
<div className="KnownHostForm-actions">
<div className="KnownHostForm-actions__delete">
{ host && (
<Button color="inherit" onClick={() => !confirmDelete ? setConfirmDelete(true) : onRemove(host)}>
{ !confirmDelete ? 'Delete' : 'Are you sure?' }
</Button>
) }
</div>
<AnchorLink href='https://github.com/Cockatrice/Cockatrice/wiki/Public-Servers' target='_blank'>
Need help adding a new host?
</AnchorLink>
</div>
</form>
) }
</Form>
);
}
const mapStateToProps = () => ({
});
export default connect(mapStateToProps)(KnownHostForm);

View file

@ -1,46 +1,130 @@
// eslint-disable-next-line // eslint-disable-next-line
import React from "react"; import React, { Component, useCallback, useEffect, useState, useRef } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Form, Field, reduxForm, change } from 'redux-form' import { Form, Field, reduxForm, change, FormSubmitHandler } from 'redux-form'
import Button from '@material-ui/core/Button'; import Button from '@material-ui/core/Button';
import { InputField, KnownHosts } from 'components'; import { AuthenticationService } from 'api';
// import { ServerDispatch } from "store"; import { CheckboxField, InputField, KnownHosts } from 'components';
import { FormKey } from 'types'; import { useAutoConnect } from 'hooks';
import { HostDTO, SettingDTO } from 'services';
import { FormKey, APP_USER } from 'types';
import './LoginForm.css'; import './LoginForm.css';
const LoginForm = (props) => { const PASSWORD_LABEL = 'Password';
const { dispatch, handleSubmit } = props; const STORED_PASSWORD_LABEL = '* SAVED *';
const LoginForm: any = ({ dispatch, form, submit, handleSubmit }: LoginFormProps) => {
const password: any = useRef();
const [host, setHost] = useState(null);
const [remember, setRemember] = useState(false);
const [passwordLabel, setPasswordLabel] = useState(PASSWORD_LABEL);
const [hasStoredPassword, useStoredPassword] = useState(false);
const [autoConnect, setAutoConnect] = useAutoConnect(() => {
dispatch(change(form, 'autoConnect', autoConnect));
if (autoConnect && !remember) {
setRemember(true);
}
});
useEffect(() => {
SettingDTO.get(APP_USER).then((userSetting: SettingDTO) => {
if (userSetting?.autoConnect && !AuthenticationService.connectionAttemptMade()) {
HostDTO.getAll().then(hosts => {
let lastSelectedHost = hosts.find(({ lastSelected }) => lastSelected);
if (lastSelectedHost?.remember && lastSelectedHost?.hashedPassword) {
dispatch(change(form, 'selectedHost', lastSelectedHost));
dispatch(change(form, 'userName', lastSelectedHost.userName));
dispatch(change(form, 'remember', true));
setPasswordLabel(STORED_PASSWORD_LABEL);
dispatch(submit);
}
});
}
});
}, [submit, dispatch, form]);
useEffect(() => {
dispatch(change(form, 'remember', remember));
if (!remember) {
setAutoConnect(false);
}
if (!remember) {
useStoredPassword(false);
setPasswordLabel(PASSWORD_LABEL);
} else if (host?.hashedPassword) {
useStoredPassword(true);
setPasswordLabel(STORED_PASSWORD_LABEL);
}
}, [remember, dispatch, form]);
useEffect(() => {
if (!host) {
return
}
dispatch(change(form, 'userName', host.userName));
dispatch(change(form, 'password', ''));
setRemember(host.remember);
setAutoConnect(host.remember && autoConnect);
if (host.remember && host.hashedPassword) {
// TODO: check if this causes a double render (maybe try combined state)
// try deriving useStoredPassword
useStoredPassword(true);
setPasswordLabel(STORED_PASSWORD_LABEL);
} else {
useStoredPassword(false);
setPasswordLabel(PASSWORD_LABEL);
}
}, [host, dispatch, form]);
const onRememberChange = event => setRemember(event.target.checked);
const onAutoConnectChange = event => setAutoConnect(event.target.checked);
const onHostChange = h => setHost(h);
const forgotPassword = () => { const forgotPassword = () => {
console.log('Show recover password dialog, then AuthService.forgotPasswordRequest'); console.log('Show recover password dialog, then AuthService.forgotPasswordRequest');
}; };
const onHostChange = ({ host, port }) => {
dispatch(change(FormKey.LOGIN, 'host', host));
dispatch(change(FormKey.LOGIN, 'port', port));
}
return ( return (
<Form className="loginForm" onSubmit={handleSubmit}> <Form className='loginForm' onSubmit={handleSubmit}>
<div className="loginForm-items"> <div className='loginForm-items'>
<div className="loginForm-item"> <div className='loginForm-item'>
<Field label="Username" name="user" component={InputField} autoComplete="username" /> <Field label='Username' name='userName' component={InputField} autoComplete='off' />
</div> </div>
<div className="loginForm-item"> <div className='loginForm-item'>
<Field label="Password" name="pass" type="password" component={InputField} autoComplete="current-password" /> <Field
label={passwordLabel}
ref={password}
onFocus={() => setPasswordLabel(PASSWORD_LABEL)}
onBlur={() => !password.current.value && hasStoredPassword && setPasswordLabel(STORED_PASSWORD_LABEL)}
name='password'
type='password'
component={InputField}
autoComplete='new-password'
/>
</div> </div>
<div className="loginForm-actions"> <div className='loginForm-actions'>
<span>Remember Me</span> <Field label='Save Password' name='remember' component={CheckboxField} onChange={onRememberChange} />
<Button color="primary" onClick={forgotPassword}>Forgot Password</Button> <Button color='primary' onClick={forgotPassword}>Forgot Password</Button>
</div> </div>
<div className="loginForm-item"> <div className='loginForm-item'>
<KnownHosts onChange={onHostChange} /> <Field name='selectedHost' component={KnownHosts} onChange={onHostChange} />
</div>
<div className='loginForm-actions'>
<Field label='Auto Connect' name='autoConnect' component={CheckboxField} onChange={onAutoConnectChange} />
</div> </div>
</div> </div>
<Button className="loginForm-submit rounded tall" color="primary" variant="contained" type="submit"> <Button className='loginForm-submit rounded tall' color='primary' variant='contained' type='submit'>
Login Login
</Button> </Button>
</Form> </Form>
@ -55,27 +139,28 @@ const propsMap = {
if (!values.user) { if (!values.user) {
errors.user = 'Required'; errors.user = 'Required';
} }
if (!values.pass) {
errors.pass = 'Required'; if (!values.password && !values.selectedHost?.hashedPassword) {
errors.password = 'Required';
} }
if (!values.host) {
errors.host = 'Required'; if (!values.selectedHost) {
} errors.selectedHost = 'Required';
if (!values.port) {
errors.port = 'Required';
} }
return errors; return errors;
} }
}; };
const mapStateToProps = () => ({ interface LoginFormProps {
initialValues: { form: string;
// host: "mtg.tetrarch.co/servatrice", dispatch: Function;
// port: "443" submit: Function;
// host: "server.cockatrice.us", handleSubmit: FormSubmitHandler;
// port: "4748" }
}
const mapStateToProps = (state) => ({
}); });
export default connect(mapStateToProps)(reduxForm(propsMap)(LoginForm)); export default connect(mapStateToProps)(reduxForm(propsMap)(LoginForm));

View file

@ -1,5 +1,5 @@
// eslint-disable-next-line // eslint-disable-next-line
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Form, Field, reduxForm, change } from 'redux-form' import { Form, Field, reduxForm, change } from 'redux-form'
@ -13,15 +13,16 @@ import './RegisterForm.css';
const RegisterForm = (props) => { const RegisterForm = (props) => {
const { dispatch, handleSubmit } = props; const { dispatch, handleSubmit } = props;
const onHostChange = ({ host, port }) => { const onHostChange: any = ({ host, port }) => {
dispatch(change(FormKey.REGISTER, 'host', host)); dispatch(change(FormKey.REGISTER, 'host', host));
dispatch(change(FormKey.REGISTER, 'port', port)); dispatch(change(FormKey.REGISTER, 'port', port));
} }
return ( return (
<Form className="registerForm row" onSubmit={handleSubmit} autoComplete="off"> <Form className="registerForm row" onSubmit={handleSubmit} autoComplete="off">
<div className="leftRegisterForm column" > <div className="leftRegisterForm column" >
<div className="registerForm-item"> <div className="registerForm-item">
<KnownHosts onChange={onHostChange} /> <Field name="selectedHost" component={KnownHosts} onChange={onHostChange} />
{ /* Padding is off */ } { /* Padding is off */ }
</div> </div>
<div className="registerForm-item"> <div className="registerForm-item">

View file

@ -17,7 +17,7 @@ const RequestPasswordResetForm = (props) => {
const [errorMessage, setErrorMessage] = useState(false); const [errorMessage, setErrorMessage] = useState(false);
const [isMFA, setIsMFA] = useState(false); const [isMFA, setIsMFA] = useState(false);
const onHostChange = ({ host, port }) => { const onHostChange: any = ({ host, port }) => {
dispatch(change(FormKey.RESET_PASSWORD_REQUEST, 'host', host)); dispatch(change(FormKey.RESET_PASSWORD_REQUEST, 'host', host));
dispatch(change(FormKey.RESET_PASSWORD_REQUEST, 'port', port)); dispatch(change(FormKey.RESET_PASSWORD_REQUEST, 'port', port));
} }
@ -51,7 +51,7 @@ const RequestPasswordResetForm = (props) => {
</div> </div>
) : null} ) : null}
<div className="RequestPasswordResetForm-item"> <div className="RequestPasswordResetForm-item">
<KnownHosts onChange={onHostChange} /> <Field name='selectedHost' component={KnownHosts} onChange={onHostChange} />
</div> </div>
</div> </div>
<Button className="RequestPasswordResetForm-submit rounded tall" color="primary" variant="contained" type="submit"> <Button className="RequestPasswordResetForm-submit rounded tall" color="primary" variant="contained" type="submit">

View file

@ -18,7 +18,7 @@ const ResetPasswordForm = (props) => {
const [errorMessage, setErrorMessage] = useState(false); const [errorMessage, setErrorMessage] = useState(false);
const onHostChange = ({ host, port }) => { const onHostChange: any = ({ host, port }) => {
dispatch(change(FormKey.RESET_PASSWORD, 'host', host)); dispatch(change(FormKey.RESET_PASSWORD, 'host', host));
dispatch(change(FormKey.RESET_PASSWORD, 'port', port)); dispatch(change(FormKey.RESET_PASSWORD, 'port', port));
} }
@ -47,7 +47,7 @@ const ResetPasswordForm = (props) => {
<Field label="Password Again" name="passwordAgain" component={InputField} /> <Field label="Password Again" name="passwordAgain" component={InputField} />
</div> </div>
<div className="ResetPasswordForm-item"> <div className="ResetPasswordForm-item">
<KnownHosts onChange={onHostChange} /> <Field name='selectedHost' component={KnownHosts} onChange={onHostChange} />
</div> </div>
</div> </div>
<Button className="ResetPasswordForm-submit rounded tall" color="primary" variant="contained" type="submit"> <Button className="ResetPasswordForm-submit rounded tall" color="primary" variant="contained" type="submit">

View file

@ -1,6 +1,7 @@
export { default as CardImportForm } from './CardImportForm/CardImportForm'; export { default as CardImportForm } from './CardImportForm/CardImportForm';
export { default as ConnectForm } from './ConnectForm/ConnectForm'; export { default as ConnectForm } from './ConnectForm/ConnectForm';
export { default as LoginForm } from './LoginForm/LoginForm'; export { default as LoginForm } from './LoginForm/LoginForm';
export { default as KnownHostForm } from './KnownHostForm/KnownHostForm';
export { default as RegisterForm } from './RegisterForm/RegisterForm'; export { default as RegisterForm } from './RegisterForm/RegisterForm';
export { default as SearchForm } from './SearchForm/SearchForm'; export { default as SearchForm } from './SearchForm/SearchForm';
export { default as RequestPasswordResetForm } from './RequestPasswordResetForm/RequestPasswordResetForm'; export { default as RequestPasswordResetForm } from './RequestPasswordResetForm/RequestPasswordResetForm';

View file

@ -1 +1,3 @@
export * from './useDebounce';
export * from './useAutoConnect';
export * from './useReduxEffect'; export * from './useReduxEffect';

View file

@ -0,0 +1,40 @@
import { useEffect, useState } from 'react';
import { debounce, DebouncedFunc } from 'lodash';
import { SettingDTO } from 'services';
import { APP_USER } from 'types';
type OnChange = () => void;
export function useAutoConnect(onChange: OnChange) {
const [setting, setSetting] = useState(undefined);
const [autoConnect, setAutoConnect] = useState(undefined);
useEffect(() => {
SettingDTO.get(APP_USER).then((setting: SettingDTO) => {
if (!setting) {
setting = new SettingDTO(APP_USER);
setting.save();
}
setSetting(setting);
});
}, []);
useEffect(() => {
if (setting) {
setAutoConnect(setting.autoConnect);
}
}, [setting]);
useEffect(() => {
if (setting) {
setting.autoConnect = autoConnect;
setting.save();
onChange();
}
}, [setting, autoConnect]);
return [autoConnect, setAutoConnect];
}

View file

@ -0,0 +1,13 @@
import { useCallback } from 'react';
import { debounce, DebouncedFunc } from 'lodash';
type UseDebounceType = (...args: any) => any;
const DEBOUNCE_DELAY = 250;
export function useDebounce<T extends UseDebounceType>(
fn: T,
deps: any[] = [],
timeout: number = DEBOUNCE_DELAY
): DebouncedFunc<T> {
return useCallback(debounce(fn, timeout), deps);
}

View file

@ -6,8 +6,8 @@ File is adapted from https://github.com/Qeepsake/use-redux-effect under MIT Lice
import { useRef, useEffect, DependencyList } from 'react' import { useRef, useEffect, DependencyList } from 'react'
import { useStore } from 'react-redux' import { useStore } from 'react-redux'
import { castArray } from 'lodash'
import { AnyAction } from 'redux' import { AnyAction } from 'redux'
import { castArray } from 'lodash'
export type ReduxEffect = (action: AnyAction) => void export type ReduxEffect = (action: AnyAction) => void
@ -23,25 +23,25 @@ export function useReduxEffect(
type: string | string[], type: string | string[],
deps: DependencyList = [], deps: DependencyList = [],
): void { ): void {
const currentValue = useRef(null) const currentValue = useRef(null);
const store = useStore() const store = useStore();
const handleChange = (): void => { const handleChange = (): void => {
const state = store.getState() const state = store.getState();
const action = state.action const action = state.action;
const previousValue = currentValue.current const previousValue = currentValue.current;
currentValue.current = action.count currentValue.current = action.count;
if ( if (
previousValue !== action.count && previousValue !== action.count &&
castArray(type).includes(action.type) castArray(type).includes(action.type)
) { ) {
effect(action) effect(action);
} }
} }
useEffect(() => { useEffect(() => {
const unsubscribe = store.subscribe(handleChange) const unsubscribe = store.subscribe(handleChange);
return (): void => unsubscribe() return (): void => unsubscribe();
}, deps) }, deps)
} }

View file

@ -11,6 +11,22 @@ const palette = {
dark: '#401C7F', dark: '#401C7F',
contrastText: '#FFFFFF', contrastText: '#FFFFFF',
}, },
grey: {
50: '#fafafa',
100: '#f5f5f5',
200: '#eeeeee',
300: '#e0e0e0',
400: '#bdbdbd',
500: '#9e9e9e',
600: '#757575',
700: '#616161',
800: '#424242',
900: '#212121',
A100: '#d5d5d5',
A200: '#aaaaaa',
A400: '#303030',
A700: '#616161',
},
// secondary: { // secondary: {
// main: '', // main: '',
// light: '', // light: '',
@ -67,6 +83,29 @@ export const materialTheme = createTheme({
}, },
}, },
MuiCheckbox: {
root: {
'& .MuiSvgIcon-root': {
width: '.75em',
height: '.75em',
},
},
},
MuiFormControlLabel: {
label: {
fontSize: 12,
fontWeight: 'bold',
color: palette.primary.main,
},
},
MuiLink: {
root: {
fontWeight: 'bold',
},
},
MuiList: { MuiList: {
root: { root: {
padding: '8px', padding: '8px',
@ -135,7 +174,10 @@ export const materialTheme = createTheme({
fontSize: 28, fontSize: 28,
fontWeight: 'bold', fontWeight: 'bold',
}, },
// h2: {}, h2: {
fontSize: 24,
fontWeight: 'bold',
},
// h3: {}, // h3: {},
// h4: {}, // h4: {},
// h5: {}, // h5: {},

View file

@ -8,11 +8,11 @@ export class HostDTO extends Host {
return dexieService.hosts.put(this); return dexieService.hosts.put(this);
} }
static add(host: HostDTO): Promise<IndexableType> { static add(host: Host): Promise<IndexableType> {
return dexieService.hosts.add(host); return dexieService.hosts.add(host);
} }
static get(id): Promise<HostDTO> { static get(id: number): Promise<HostDTO> {
return dexieService.hosts.where('id').equals(id).first(); return dexieService.hosts.where('id').equals(id).first();
} }
@ -23,6 +23,10 @@ export class HostDTO extends Host {
static bulkAdd(hosts: Host[]): Promise<IndexableType> { static bulkAdd(hosts: Host[]): Promise<IndexableType> {
return dexieService.hosts.bulkAdd(hosts); return dexieService.hosts.bulkAdd(hosts);
} }
static delete(id: string): Promise<void> {
return dexieService.hosts.delete(id);
}
}; };
dexieService.hosts.mapToClass(HostDTO); dexieService.hosts.mapToClass(HostDTO);

View file

@ -16,4 +16,4 @@ export class SetDTO extends Set {
} }
}; };
dexieService.cards.mapToClass(SetDTO); dexieService.sets.mapToClass(SetDTO);

View file

@ -0,0 +1,22 @@
import { Setting } from 'types';
import { dexieService } from '../DexieService';
export class SettingDTO extends Setting {
constructor(user) {
super();
this.user = user;
this.autoConnect = false;
}
save() {
return dexieService.settings.put(this);
}
static get(user) {
return dexieService.settings.where('user').equalsIgnoreCase(user).first();
}
};
dexieService.settings.mapToClass(SettingDTO);

View file

@ -1,4 +1,5 @@
export * from './CardDTO'; export * from './CardDTO';
export * from './SetDTO'; export * from './SetDTO';
export * from './SettingDTO';
export * from './TokenDTO'; export * from './TokenDTO';
export * from './HostDTO'; export * from './HostDTO';

View file

@ -0,0 +1,17 @@
export enum Stores {
SETTINGS = 'settings',
CARDS = 'cards',
SETS = 'sets',
TOKENS = 'tokens',
HOSTS = 'hosts',
}
export const schemaV1 = (db) => {
db.version(1).stores({
[Stores.CARDS]: 'name',
[Stores.SETS]: 'code',
[Stores.SETTINGS]: 'user',
[Stores.TOKENS]: 'name.value',
[Stores.HOSTS]: '++id',
});
}

View file

@ -1,24 +1,16 @@
import Dexie from 'dexie'; import Dexie from 'dexie';
enum Stores { import { Stores, schemaV1 } from './DexieSchemas/v1.schema';
CARDS = 'cards',
SETS = 'sets',
TOKENS = 'tokens',
HOSTS = 'hosts',
}
const StoreKeyIndexes = {
[Stores.CARDS]: 'name',
[Stores.SETS]: 'code',
[Stores.TOKENS]: 'name.value',
[Stores.HOSTS]: '++id,name',
};
class DexieService { class DexieService {
private db: Dexie = new Dexie('Webatrice'); private db: Dexie = new Dexie('Webatrice');
constructor() { constructor() {
this.db.version(1).stores(StoreKeyIndexes); schemaV1(this.db);
}
get settings() {
return this.db.table(Stores.SETTINGS);
} }
get cards() { get cards() {

View file

@ -0,0 +1 @@
export * from './DexieDTOs';

View file

@ -1,2 +1,2 @@
export * from './CardImporterService'; export * from './CardImporterService';
export * from './DexieDTOs'; export * from './dexie';

View file

@ -1,10 +1,16 @@
import { Type } from 'protobufjs'; import { Type } from 'protobufjs';
import { WebSocketConnectOptions } from 'types';
import { Types } from './server.types'; import { Types } from './server.types';
export const Actions = { export const Actions = {
clearStore: () => ({ clearStore: () => ({
type: Types.CLEAR_STORE type: Types.CLEAR_STORE
}), }),
loginSuccessful: (options: WebSocketConnectOptions) => ({
type: Types.LOGIN_SUCCESSFUL,
options
}),
connectionClosed: reason => ({ connectionClosed: reason => ({
type: Types.CONNECTION_CLOSED, type: Types.CONNECTION_CLOSED,
reason reason

View file

@ -6,6 +6,9 @@ export const Dispatch = {
clearStore: () => { clearStore: () => {
store.dispatch(Actions.clearStore()); store.dispatch(Actions.clearStore());
}, },
loginSuccessful: options => {
store.dispatch(Actions.loginSuccessful(options));
},
connectionClosed: reason => { connectionClosed: reason => {
store.dispatch(Actions.connectionClosed(reason)); store.dispatch(Actions.connectionClosed(reason));
}, },

View file

@ -3,24 +3,26 @@ import { Log, SortBy, User, UserSortField } from 'types';
export interface ServerConnectParams { export interface ServerConnectParams {
host: string; host: string;
port: string; port: string;
user: string; userName: string;
pass: string; password: string;
} }
export interface ServerRegisterParams { export interface ServerRegisterParams {
host: string; host: string;
port: string; port: string;
user: string; userName: string;
pass: string; password: string;
passAgain: string;
email: string; email: string;
country: string; country: string;
realName: string; realName: string;
} }
export interface RequestPasswordSaltParams {
userName: string;
}
export interface ForgotPasswordParams { export interface ForgotPasswordParams {
user: string; userName: string;
clientid: string;
} }
export interface ForgotPasswordChallengeParams extends ForgotPasswordParams { export interface ForgotPasswordChallengeParams extends ForgotPasswordParams {
@ -33,8 +35,7 @@ export interface ForgotPasswordResetParams extends ForgotPasswordParams {
} }
export interface AccountActivationParams extends ServerRegisterParams { export interface AccountActivationParams extends ServerRegisterParams {
activationCode: string; token: string;
clientid: string;
} }
export interface ServerState { export interface ServerState {
@ -68,7 +69,3 @@ export interface ServerStateLogs {
export interface ServerStateSortUsersBy extends SortBy { export interface ServerStateSortUsersBy extends SortBy {
field: UserSortField field: UserSortField
} }
export interface RequestPasswordSaltParams {
user: string;
}

View file

@ -1,5 +1,6 @@
export const Types = { export const Types = {
CLEAR_STORE: '[Server] Clear Store', CLEAR_STORE: '[Server] Clear Store',
LOGIN_SUCCESSFUL: '[Server] Login Successful',
CONNECTION_CLOSED: '[Server] Connection Closed', CONNECTION_CLOSED: '[Server] Connection Closed',
SERVER_MESSAGE: '[Server] Server Message', SERVER_MESSAGE: '[Server] Server Message',
UPDATE_BUDDY_LIST: '[Server] Update Buddy List', UPDATE_BUDDY_LIST: '[Server] Update Buddy List',

View file

@ -10,3 +10,4 @@ export * from './routes';
export * from './sort'; export * from './sort';
export * from './forms'; export * from './forms';
export * from './message'; export * from './message';
export * from './settings';

View file

@ -12,6 +12,29 @@ export enum StatusEnum {
DISCONNECTING = 99 DISCONNECTING = 99
} }
export interface WebSocketConnectOptions {
host?: string;
port?: string;
userName?: string;
password?: string;
hashedPassword?: string;
newPassword?: string;
email?: string;
autojoinrooms?: boolean;
keepalive?: number;
clientid?: string;
reason?: WebSocketConnectReason;
}
export enum WebSocketConnectReason {
LOGIN,
REGISTER,
ACTIVATE_ACCOUNT,
PASSWORD_RESET_REQUEST,
PASSWORD_RESET_CHALLENGE,
PASSWORD_RESET
}
export class Host { export class Host {
id?: number; id?: number;
name: string; name: string;
@ -20,6 +43,10 @@ export class Host {
localHost?: string; localHost?: string;
localPort?: string; localPort?: string;
editable: boolean; editable: boolean;
lastSelected?: boolean;
userName?: string;
hashedPassword?: string;
remember?: boolean;
} }
export const DefaultHosts: Host[] = [ export const DefaultHosts: Host[] = [
@ -40,7 +67,7 @@ export const DefaultHosts: Host[] = [
{ {
name: 'Tetrarch', name: 'Tetrarch',
host: 'mtg.tetrarch.co/servatrice', host: 'mtg.tetrarch.co/servatrice',
port: '4748', port: '443',
editable: false, editable: false,
}, },
]; ];

View file

@ -0,0 +1,6 @@
export class Setting {
user: string;
autoConnect?: boolean;
}
export const APP_USER = '*app';

View file

@ -1,7 +1,7 @@
import { ServerStatus, StatusEnum } from 'types'; import { ServerStatus, StatusEnum, WebSocketConnectOptions } from 'types';
import { ProtobufService } from './services/ProtobufService'; import { ProtobufService } from './services/ProtobufService';
import { WebSocketOptions, WebSocketService } from './services/WebSocketService'; import { WebSocketService } from './services/WebSocketService';
import { RoomPersistence, SessionPersistence } from './persistence'; import { RoomPersistence, SessionPersistence } from './persistence';
@ -30,11 +30,12 @@ export class WebClient {
] ]
}; };
public options: WebSocketOptions = { public options: WebSocketConnectOptions = {
host: '', host: '',
port: '', port: '',
user: '', userName: '',
pass: '', password: '',
hashedPassword: '',
newPassword: '', newPassword: '',
email: '', email: '',
clientid: null, clientid: null,
@ -43,6 +44,8 @@ export class WebClient {
keepalive: 5000 keepalive: 5000
}; };
public connectionAttemptMade = false;
constructor() { constructor() {
this.socket.message$.subscribe((message: MessageEvent) => { this.socket.message$.subscribe((message: MessageEvent) => {
this.protobuf.handleMessageEvent(message); this.protobuf.handleMessageEvent(message);
@ -55,7 +58,8 @@ export class WebClient {
console.log(this); console.log(this);
} }
public connect(options: WebSocketOptions) { public connect(options: WebSocketConnectOptions) {
this.connectionAttemptMade = true;
this.options = { ...this.options, ...options }; this.options = { ...this.options, ...options };
this.socket.connect(this.options); this.socket.connect(this.options);
} }

View file

@ -1,9 +1,9 @@
import { StatusEnum } from 'types'; import { HostDTO } from 'services';
import { StatusEnum, WebSocketConnectReason, WebSocketConnectOptions } from 'types';
import { RoomPersistence, SessionPersistence } from '../persistence'; import { RoomPersistence, SessionPersistence } from '../persistence';
import webClient from '../WebClient'; import webClient from '../WebClient';
import { guid, hashPassword } from '../utils'; import { guid, hashPassword } from '../utils';
import { WebSocketConnectReason, WebSocketOptions } from '../services/WebSocketService';
import { import {
AccountActivationParams, AccountActivationParams,
ForgotPasswordChallengeParams, ForgotPasswordChallengeParams,
@ -15,7 +15,7 @@ import {
import NormalizeService from '../utils/NormalizeService'; import NormalizeService from '../utils/NormalizeService';
export class SessionCommands { export class SessionCommands {
static connect(options: WebSocketOptions, reason: WebSocketConnectReason): void { static connect(options: WebSocketConnectOptions, reason: WebSocketConnectReason): void {
switch (reason) { switch (reason) {
case WebSocketConnectReason.LOGIN: case WebSocketConnectReason.LOGIN:
case WebSocketConnectReason.REGISTER: case WebSocketConnectReason.REGISTER:
@ -38,16 +38,18 @@ export class SessionCommands {
} }
static login(passwordSalt?: string): void { static login(passwordSalt?: string): void {
const { userName, password, hashedPassword } = webClient.options;
const loginConfig: any = { const loginConfig: any = {
...webClient.clientConfig, ...webClient.clientConfig,
userName: webClient.options.user, clientid: 'webatrice',
clientid: guid() userName,
}; };
if (passwordSalt) { if (passwordSalt) {
loginConfig.hashedPassword = hashPassword(passwordSalt, webClient.options.pass); loginConfig.hashedPassword = hashedPassword || hashPassword(passwordSalt, password);
} else { } else {
loginConfig.password = webClient.options.pass; loginConfig.password = password;
} }
const CmdLogin = webClient.protobuf.controller.Command_Login.create(loginConfig); const CmdLogin = webClient.protobuf.controller.Command_Login.create(loginConfig);
@ -65,11 +67,13 @@ export class SessionCommands {
SessionPersistence.updateBuddyList(buddyList); SessionPersistence.updateBuddyList(buddyList);
SessionPersistence.updateIgnoreList(ignoreList); SessionPersistence.updateIgnoreList(ignoreList);
SessionPersistence.updateUser(userInfo); SessionPersistence.updateUser(userInfo);
SessionPersistence.loginSuccessful(loginConfig);
SessionCommands.listUsers(); SessionCommands.listUsers();
SessionCommands.listRooms(); SessionCommands.listRooms();
SessionCommands.updateStatus(StatusEnum.LOGGED_IN, 'Logged in.'); SessionCommands.updateStatus(StatusEnum.LOGGED_IN, 'Logged in.');
return; return;
} }
@ -117,11 +121,11 @@ export class SessionCommands {
} }
static requestPasswordSalt(): void { static requestPasswordSalt(): void {
const options = webClient.options as unknown as RequestPasswordSaltParams; const { userName } = webClient.options as unknown as RequestPasswordSaltParams;
const registerConfig = { const registerConfig = {
...webClient.clientConfig, ...webClient.clientConfig,
userName: options.user, userName,
}; };
const CmdRequestPasswordSalt = webClient.protobuf.controller.Command_RequestPasswordSalt.create(registerConfig); const CmdRequestPasswordSalt = webClient.protobuf.controller.Command_RequestPasswordSalt.create(registerConfig);
@ -132,35 +136,35 @@ export class SessionCommands {
webClient.protobuf.sendSessionCommand(sc, raw => { webClient.protobuf.sendSessionCommand(sc, raw => {
switch (raw.responseCode) { switch (raw.responseCode) {
case webClient.protobuf.controller.Response.ResponseCode.RespOk: case webClient.protobuf.controller.Response.ResponseCode.RespOk: {
const passwordSalt = raw['.Response_PasswordSalt.ext'].passwordSalt; const passwordSalt = raw['.Response_PasswordSalt.ext'].passwordSalt;
SessionCommands.login(passwordSalt); SessionCommands.login(passwordSalt);
break; break;
}
case webClient.protobuf.controller.Response.ResponseCode.RespRegistrationRequired: case webClient.protobuf.controller.Response.ResponseCode.RespRegistrationRequired: {
SessionCommands.updateStatus(StatusEnum.DISCONNECTED, 'Login failed: incorrect username or password'); SessionCommands.updateStatus(StatusEnum.DISCONNECTED, 'Login failed: incorrect username or password');
SessionCommands.disconnect(); SessionCommands.disconnect();
break; break;
}
default: default: {
SessionCommands.updateStatus(StatusEnum.DISCONNECTED, 'Login failed: Unknown Reason'); SessionCommands.updateStatus(StatusEnum.DISCONNECTED, 'Login failed: Unknown Reason');
SessionCommands.disconnect(); SessionCommands.disconnect();
break; }
} }
}); });
} }
static register(): void { static register(): void {
const options = webClient.options as unknown as ServerRegisterParams; const { userName, password, email, country, realName } = webClient.options as unknown as ServerRegisterParams;
const registerConfig = { const registerConfig = {
...webClient.clientConfig, ...webClient.clientConfig,
userName: options.user, clientid: 'webatrice',
password: options.pass, userName,
email: options.email, password,
country: options.country, email,
realName: options.realName, country,
clientid: 'webatrice' realName,
}; };
const CmdRegister = webClient.protobuf.controller.Command_Register.create(registerConfig); const CmdRegister = webClient.protobuf.controller.Command_Register.create(registerConfig);
@ -222,13 +226,13 @@ export class SessionCommands {
}; };
static activateAccount(): void { static activateAccount(): void {
const options = webClient.options as unknown as AccountActivationParams; const { userName, token } = webClient.options as unknown as AccountActivationParams;
const accountActivationConfig = { const accountActivationConfig = {
...webClient.clientConfig, ...webClient.clientConfig,
userName: options.user, clientid: 'webatrice',
clientid: options.clientid, userName,
token: options.activationCode token,
}; };
const CmdActivate = webClient.protobuf.controller.Command_Activate.create(accountActivationConfig); const CmdActivate = webClient.protobuf.controller.Command_Activate.create(accountActivationConfig);
@ -249,12 +253,12 @@ export class SessionCommands {
} }
static resetPasswordRequest(): void { static resetPasswordRequest(): void {
const options = webClient.options as unknown as ForgotPasswordParams; const { userName } = webClient.options as unknown as ForgotPasswordParams;
const forgotPasswordConfig = { const forgotPasswordConfig = {
...webClient.clientConfig, ...webClient.clientConfig,
userName: options.user, clientid: 'webatrice',
clientid: options.clientid userName,
}; };
const CmdForgotPasswordRequest = webClient.protobuf.controller.Command_ForgotPasswordRequest.create(forgotPasswordConfig); const CmdForgotPasswordRequest = webClient.protobuf.controller.Command_ForgotPasswordRequest.create(forgotPasswordConfig);
@ -284,13 +288,13 @@ export class SessionCommands {
} }
static resetPasswordChallenge(): void { static resetPasswordChallenge(): void {
const options = webClient.options as unknown as ForgotPasswordChallengeParams; const { userName, email } = webClient.options as unknown as ForgotPasswordChallengeParams;
const forgotPasswordChallengeConfig = { const forgotPasswordChallengeConfig = {
...webClient.clientConfig, ...webClient.clientConfig,
userName: options.user, clientid: 'webatrice',
clientid: options.clientid, userName,
email: options.email email,
}; };
const CmdForgotPasswordChallenge = webClient.protobuf.controller.Command_ForgotPasswordChallenge.create(forgotPasswordChallengeConfig); const CmdForgotPasswordChallenge = webClient.protobuf.controller.Command_ForgotPasswordChallenge.create(forgotPasswordChallengeConfig);
@ -313,14 +317,14 @@ export class SessionCommands {
} }
static resetPassword(): void { static resetPassword(): void {
const options = webClient.options as unknown as ForgotPasswordResetParams; const { userName, token, newPassword } = webClient.options as unknown as ForgotPasswordResetParams;
const forgotPasswordResetConfig = { const forgotPasswordResetConfig = {
...webClient.clientConfig, ...webClient.clientConfig,
userName: options.user, clientid: 'webatrice',
clientid: options.clientid, userName,
token: options.token, token,
newPassword: options.newPassword newPassword,
}; };
const CmdForgotPasswordReset = webClient.protobuf.controller.Command_ForgotPasswordReset.create(forgotPasswordResetConfig); const CmdForgotPasswordReset = webClient.protobuf.controller.Command_ForgotPasswordReset.create(forgotPasswordResetConfig);

View file

@ -1,10 +1,9 @@
import { Room, StatusEnum, User } from 'types'; import { Room, StatusEnum, User, WebSocketConnectReason } from 'types';
import { SessionCommands } from '../commands'; import { SessionCommands } from '../commands';
import { RoomPersistence, SessionPersistence } from '../persistence'; import { RoomPersistence, SessionPersistence } from '../persistence';
import { ProtobufEvents } from '../services/ProtobufService'; import { ProtobufEvents } from '../services/ProtobufService';
import webClient from '../WebClient'; import webClient from '../WebClient';
import { WebSocketConnectReason } from '../services/WebSocketService';
export const SessionEvents: ProtobufEvents = { export const SessionEvents: ProtobufEvents = {
'.Event_AddToList.ext': addToList, '.Event_AddToList.ext': addToList,

View file

@ -1,5 +1,5 @@
import { ServerDispatch } from 'store'; import { ServerDispatch } from 'store';
import { Log, StatusEnum, User } from 'types'; import { Log, StatusEnum, User, WebSocketConnectOptions } from 'types';
import { sanitizeHtml } from 'websocket/utils'; import { sanitizeHtml } from 'websocket/utils';
import NormalizeService from '../utils/NormalizeService'; import NormalizeService from '../utils/NormalizeService';
@ -9,6 +9,10 @@ export class SessionPersistence {
ServerDispatch.clearStore(); ServerDispatch.clearStore();
} }
static loginSuccessful(options: WebSocketConnectOptions) {
ServerDispatch.loginSuccessful(options);
}
static connectionClosed(reason: number) { static connectionClosed(reason: number) {
ServerDispatch.connectionClosed(reason); ServerDispatch.connectionClosed(reason);
} }

View file

@ -1,32 +1,10 @@
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { ServerStatus, StatusEnum } from 'types'; import { ServerStatus, StatusEnum, WebSocketConnectOptions } from 'types';
import { KeepAliveService } from './KeepAliveService'; import { KeepAliveService } from './KeepAliveService';
import { WebClient } from '../WebClient'; import { WebClient } from '../WebClient';
export interface WebSocketOptions {
host: string;
port: string;
user: string;
pass: string;
newPassword: string;
email: string;
autojoinrooms: boolean;
keepalive: number;
clientid: string;
reason: WebSocketConnectReason;
}
export enum WebSocketConnectReason {
LOGIN,
REGISTER,
ACTIVATE_ACCOUNT,
PASSWORD_RESET_REQUEST,
PASSWORD_RESET_CHALLENGE,
PASSWORD_RESET
}
export class WebSocketService { export class WebSocketService {
private socket: WebSocket; private socket: WebSocket;
private webClient: WebClient; private webClient: WebClient;
@ -48,7 +26,7 @@ export class WebSocketService {
}); });
} }
public connect(options: WebSocketOptions, protocol: string = 'wss'): void { public connect(options: WebSocketConnectOptions, protocol: string = 'wss'): void {
if (window.location.hostname === 'localhost') { if (window.location.hostname === 'localhost') {
protocol = 'ws'; protocol = 'ws';
} }

View file

@ -17,13 +17,13 @@ export function sanitizeHtml(msg: string): string {
} }
function enforceTagWhitelist($el: JQuery<HTMLElement>, tags: string): void { function enforceTagWhitelist($el: JQuery<HTMLElement>, tags: string): void {
$el.find('*').not(tags).each(() => { $el.find('*').not(tags).each(function enforceTag() {
$(this).replaceWith(this.innerHTML); $(this).replaceWith(this.innerHTML);
}); });
} }
function enforceAttrWhitelist($el: JQuery<HTMLElement>, attrs: string[]): void { function enforceAttrWhitelist($el: JQuery<HTMLElement>, attrs: string[]): void {
$el.find('*').each(() => { $el.find('*').each(function enforceAttribute() {
const attributes = this.attributes; const attributes = this.attributes;
let i = attributes.length; let i = attributes.length;
while (i--) { while (i--) {
@ -36,7 +36,7 @@ function enforceAttrWhitelist($el: JQuery<HTMLElement>, attrs: string[]): void {
} }
function enforceHrefWhitelist($el: JQuery<HTMLElement>, hrefs: string[]): void { function enforceHrefWhitelist($el: JQuery<HTMLElement>, hrefs: string[]): void {
$el.find('[href]').each(() => { $el.find('[href]').each(function enforceHref() {
const $_el = $(this); const $_el = $(this);
const attributeValue = $_el.attr('href'); const attributeValue = $_el.attr('href');

View file

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": "src", "baseUrl": "src",
"target": "es5", "target": "es6",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",