Improve typings for AuthFlow and mark some future TODOs

This commit is contained in:
ErickSkrauch 2024-08-28 13:07:23 +02:00
parent 12f5e711c4
commit ba2876e534
No known key found for this signature in database
GPG Key ID: 669339FCBB30EE0E
23 changed files with 148 additions and 156 deletions

View File

@ -180,8 +180,6 @@ class PanelTransition extends React.PureComponent<Props, State> {
if (this.props.children) { if (this.props.children) {
return this.props.children; return this.props.children;
} else if (!Title || !Body || !Footer || !Links) {
throw new Error('Title, Body, Footer and Links are required');
} }
const { const {

View File

@ -182,7 +182,7 @@ export function register({
); );
} }
export function activate({ key = '' }: { key: string }): AppAction<Promise<Account>> { export function activate(key: string): AppAction<Promise<Account>> {
return wrapInLoader((dispatch) => return wrapInLoader((dispatch) =>
activateEndpoint(key) activateEndpoint(key)
.then(authHandler(dispatch)) .then(authHandler(dispatch))

View File

@ -1,7 +1,8 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import State from './State';
import { AuthContext } from 'app/services/authFlow'; import { AuthContext } from 'app/services/authFlow';
export default class AbstractState { export default class AbstractState implements State {
resolve(context: AuthContext, payload: Record<string, any>): Promise<void> | void {} resolve(context: AuthContext, payload: Record<string, any>): Promise<void> | void {}
goBack(context: AuthContext): void { goBack(context: AuthContext): void {
throw new Error('There is no way back'); throw new Error('There is no way back');

View File

@ -57,9 +57,9 @@ describe('ActivationState', () => {
describe('#resolve', () => { describe('#resolve', () => {
it('should call activate with payload', () => { it('should call activate with payload', () => {
const payload = {}; const payload = { key: 'mock' };
expectRun(mock, 'activate', sinon.match.same(payload)).returns(new Promise(() => {})); expectRun(mock, 'activate', sinon.match.same('mock')).returns(new Promise(() => {}));
state.resolve(context, payload); state.resolve(context, payload);
}); });
@ -70,7 +70,7 @@ describe('ActivationState', () => {
mock.expects('run').returns(promise); mock.expects('run').returns(promise);
expectState(mock, CompleteState); expectState(mock, CompleteState);
state.resolve(context, {}); state.resolve(context, { key: 'mock' });
return promise; return promise;
}); });
@ -81,7 +81,7 @@ describe('ActivationState', () => {
mock.expects('run').returns(promise); mock.expects('run').returns(promise);
mock.expects('setState').never(); mock.expects('setState').never();
state.resolve(context, {}); state.resolve(context, { key: 'mock' });
return promise.catch(mock.verify.bind(mock)); return promise.catch(mock.verify.bind(mock));
}); });

View File

@ -11,9 +11,9 @@ export default class ActivationState extends AbstractState {
context.navigate(url); context.navigate(url);
} }
resolve(context: AuthContext, payload: Record<string, any>): Promise<void> | void { resolve(context: AuthContext, payload: { key: string }): Promise<void> | void {
context context
.run('activate', payload) .run('activate', payload.key)
.then(() => context.setState(new CompleteState())) .then(() => context.setState(new CompleteState()))
.catch((err = {}) => err.errors || logger.warn('Error activating account', err)); .catch((err = {}) => err.errors || logger.warn('Error activating account', err));
} }

View File

@ -255,11 +255,6 @@ describe('AuthFlow', () => {
// @ts-ignore // @ts-ignore
return expect(flow.run('test'), 'to be fulfilled with', expected); return expect(flow.run('test'), 'to be fulfilled with', expected);
}); });
it('throws when running unexisted action', () => {
// @ts-ignore
expect(() => flow.run('123'), 'to throw', 'Action 123 does not exists');
});
}); });
describe('#goBack', () => { describe('#goBack', () => {

View File

@ -1,7 +1,15 @@
import { browserHistory } from 'app/services/history'; import { browserHistory } from 'app/services/history';
import logger from 'app/services/logger'; import logger from 'app/services/logger';
import localStorage from 'app/services/localStorage'; import localStorage from 'app/services/localStorage';
import { Store, State as RootState } from 'app/types'; import { Store, State as RootState, Dispatch } from 'app/types';
import {
activate as activateAccount,
authenticate,
logoutAll as logout,
remove as removeAccount,
} from 'app/components/accounts/actions';
import * as actions from 'app/components/auth/actions';
import { updateUser } from 'app/components/user/actions';
import RegisterState from './RegisterState'; import RegisterState from './RegisterState';
import LoginState from './LoginState'; import LoginState from './LoginState';
@ -12,7 +20,7 @@ import ActivationState from './ActivationState';
import CompleteState from './CompleteState'; import CompleteState from './CompleteState';
import ChooseAccountState from './ChooseAccountState'; import ChooseAccountState from './ChooseAccountState';
import ResendActivationState from './ResendActivationState'; import ResendActivationState from './ResendActivationState';
import AbstractState from './AbstractState'; import State from './State';
type Request = { type Request = {
path: string; path: string;
@ -20,53 +28,53 @@ type Request = {
params: Record<string, any>; params: Record<string, any>;
}; };
// TODO: temporary added to improve typing without major refactoring export const availableActions = {
type ActionId = updateUser,
| 'updateUser' authenticate,
| 'authenticate' activateAccount,
| 'activateAccount' removeAccount,
| 'removeAccount' logout,
| 'logout' goBack: actions.goBack,
| 'goBack' redirect: actions.redirect,
| 'redirect' login: actions.login,
| 'login' acceptRules: actions.acceptRules,
| 'acceptRules' forgotPassword: actions.forgotPassword,
| 'forgotPassword' recoverPassword: actions.recoverPassword,
| 'recoverPassword' register: actions.register,
| 'register' activate: actions.activate,
| 'activate' resendActivation: actions.resendActivation,
| 'resendActivation' contactUs: actions.contactUs,
| 'contactUs' setLogin: actions.setLogin,
| 'setLogin' setAccountSwitcher: actions.setAccountSwitcher,
| 'setAccountSwitcher' setErrors: actions.setErrors,
| 'setErrors' clearErrors: actions.clearErrors,
| 'clearErrors' oAuthValidate: actions.oAuthValidate,
| 'oAuthValidate' oAuthComplete: actions.oAuthComplete,
| 'oAuthComplete' setClient: actions.setClient,
| 'setClient' resetOAuth: actions.resetOAuth,
| 'resetOAuth' resetAuth: actions.resetAuth,
| 'resetAuth' setOAuthRequest: actions.setOAuthRequest,
| 'setOAuthRequest' setOAuthCode: actions.setOAuthCode,
| 'setOAuthCode' requirePermissionsAccept: actions.requirePermissionsAccept,
| 'requirePermissionsAccept' setScopes: actions.setScopes,
| 'setScopes' setLoadingState: actions.setLoadingState,
| 'setLoadingState'; };
type ActionId = keyof typeof availableActions;
export interface AuthContext { export interface AuthContext {
run(actionId: ActionId, payload?: any): Promise<any>; run<T extends ActionId>(actionId: T, payload?: Parameters<typeof availableActions[T]>[0]): Promise<any>; // TODO: can't find a way to explain to TS the returned type
setState(newState: AbstractState): Promise<void> | void; setState(newState: State): Promise<void> | void; // TODO: always return promise
getState(): RootState; getState(): RootState;
navigate(route: string, options?: { replace?: boolean }): void; navigate(route: string, options?: { replace?: boolean }): void;
getRequest(): Request; getRequest(): Request;
prevState: AbstractState; prevState: State;
} }
export type ActionsDict = Record<ActionId, (action: any) => Record<string, any>>;
export default class AuthFlow implements AuthContext { export default class AuthFlow implements AuthContext {
actions: Readonly<ActionsDict>; actions: Readonly<typeof availableActions>;
state: AbstractState; state: State;
prevState: AbstractState; prevState: State;
/** /**
* A callback from router, that allows to replace (perform redirect) route * A callback from router, that allows to replace (perform redirect) route
* during route transition * during route transition
@ -76,10 +84,10 @@ export default class AuthFlow implements AuthContext {
navigate: (route: string, options: { replace?: boolean }) => void; navigate: (route: string, options: { replace?: boolean }) => void;
currentRequest: Partial<Request> = {}; currentRequest: Partial<Request> = {};
oAuthStateRestored = false; oAuthStateRestored = false;
dispatch: (action: Record<string, any>) => void; dispatch: Dispatch;
getState: () => RootState; getState: () => RootState;
constructor(actions: ActionsDict) { constructor(actions: typeof availableActions) {
this.actions = Object.freeze(actions); this.actions = Object.freeze(actions);
} }
@ -106,11 +114,11 @@ export default class AuthFlow implements AuthContext {
this.dispatch = store.dispatch.bind(store); this.dispatch = store.dispatch.bind(store);
} }
resolve(payload: { [key: string]: any } = {}) { resolve(payload: Record<string, any> = {}) {
this.state.resolve(this, payload); this.state.resolve(this, payload);
} }
reject(payload: { [key: string]: any } = {}) { reject(payload: Record<string, any> = {}) {
this.state.reject(this, payload); this.state.reject(this, payload);
} }
@ -118,17 +126,12 @@ export default class AuthFlow implements AuthContext {
this.state.goBack(this); this.state.goBack(this);
} }
run(actionId: ActionId, payload?: Record<string, any>): Promise<any> { run<T extends ActionId>(actionId: T, payload?: Parameters<typeof availableActions[T]>[0]): Promise<any> {
const action = this.actions[actionId]; // @ts-ignore the extended version of redux with thunk will return the correct promise
return Promise.resolve(this.dispatch(this.actions[actionId](payload)));
if (!action) {
throw new Error(`Action ${actionId} does not exists`);
}
return Promise.resolve(this.dispatch(action(payload)));
} }
setState(state: AbstractState) { setState(state: State) {
if (!state) { if (!state) {
throw new Error('State is required'); throw new Error('State is required');
} }

View File

@ -1,12 +1,22 @@
import expect from 'app/test/unexpected'; import expect from 'app/test/unexpected';
import sinon, { SinonMock } from 'sinon'; import sinon, { SinonMock } from 'sinon';
import { Account } from 'app/components/accounts';
import ChooseAccountState from 'app/services/authFlow/ChooseAccountState'; import ChooseAccountState from 'app/services/authFlow/ChooseAccountState';
import CompleteState from 'app/services/authFlow/CompleteState'; import CompleteState from 'app/services/authFlow/CompleteState';
import LoginState from 'app/services/authFlow/LoginState'; import LoginState from 'app/services/authFlow/LoginState';
import { bootstrap, expectState, expectNavigate, expectRun, MockedAuthContext } from './helpers'; import { bootstrap, expectState, expectNavigate, expectRun, MockedAuthContext } from './helpers';
const mockAccount: Account = {
id: 1,
username: '',
email: '',
token: '',
refreshToken: '',
isDeleted: false,
};
describe('ChooseAccountState', () => { describe('ChooseAccountState', () => {
let state: ChooseAccountState; let state: ChooseAccountState;
let context: MockedAuthContext; let context: MockedAuthContext;
@ -52,17 +62,11 @@ describe('ChooseAccountState', () => {
describe('#resolve', () => { describe('#resolve', () => {
it('should transition to complete if an existing account was chosen', () => { it('should transition to complete if an existing account was chosen', () => {
expectRun( expectRun(mock, 'authenticate', sinon.match(mockAccount)).returns(Promise.resolve());
mock,
'authenticate',
sinon.match({
id: 123,
}),
).returns(Promise.resolve());
expectRun(mock, 'setAccountSwitcher', false); expectRun(mock, 'setAccountSwitcher', false);
expectState(mock, CompleteState); expectState(mock, CompleteState);
return expect(state.resolve(context, { id: 123 }), 'to be fulfilled'); return expect(state.resolve(context, mockAccount), 'to be fulfilled');
}); });
it('should transition to login if user wants to add new account', () => { it('should transition to login if user wants to add new account', () => {
@ -71,6 +75,7 @@ describe('ChooseAccountState', () => {
expectState(mock, LoginState); expectState(mock, LoginState);
// Assert nothing returned // Assert nothing returned
// @ts-ignore
return expect(state.resolve(context, {}), 'to be undefined'); return expect(state.resolve(context, {}), 'to be undefined');
}); });
}); });

View File

@ -16,9 +16,11 @@ export default class ChooseAccountState extends AbstractState {
} }
} }
resolve(context: AuthContext, payload: Account | Record<string, any>): Promise<void> | void { // This method might be called with an empty object to mention that user wants to login into a new account.
// Currently, I can't correctly provide typing since there is no type for an empty object.
// So if there is no `id` property, it's an empty object
resolve(context: AuthContext, payload: Account): Promise<void> | void {
if (payload.id) { if (payload.id) {
// payload is Account
return context return context
.run('authenticate', payload) .run('authenticate', payload)
.then(() => context.run('setAccountSwitcher', false)) .then(() => context.run('setAccountSwitcher', false))

View File

@ -106,7 +106,7 @@ export default class CompleteState extends AbstractState {
return context.run('oAuthComplete', data).then( return context.run('oAuthComplete', data).then(
(resp: { redirectUri: string }) => { (resp: { redirectUri: string }) => {
// TODO: пусть в стейт попадает флаг или тип авторизации // TODO: пусть в стейт попадает флаг или тип авторизации
// вместо волшебства над редирект урлой // вместо волшебства над редирект урлой
if (resp.redirectUri.includes('static_page')) { if (resp.redirectUri.includes('static_page')) {
context.setState(new FinishState()); context.setState(new FinishState());
} else { } else {

View File

@ -43,7 +43,7 @@ describe('ForgotPasswordState', () => {
}), }),
).returns(new Promise(() => {})); ).returns(new Promise(() => {}));
state.resolve(context, { login: expectedLogin }); state.resolve(context, { login: expectedLogin, captcha: '' });
}); });
it('should transition to recoverPassword state on success', () => { it('should transition to recoverPassword state on success', () => {
@ -53,7 +53,7 @@ describe('ForgotPasswordState', () => {
mock.expects('run').twice().returns(promise); mock.expects('run').twice().returns(promise);
expectState(mock, RecoverPasswordState); expectState(mock, RecoverPasswordState);
state.resolve(context, { login: expectedLogin }); state.resolve(context, { login: expectedLogin, captcha: '' });
return promise; return promise;
}); });
@ -66,7 +66,7 @@ describe('ForgotPasswordState', () => {
expectState(mock, RecoverPasswordState); expectState(mock, RecoverPasswordState);
mock.expects('run').withArgs('setLogin', expectedLogin); mock.expects('run').withArgs('setLogin', expectedLogin);
state.resolve(context, { login: expectedLogin }); state.resolve(context, { login: expectedLogin, captcha: '' });
return promise; return promise;
}); });

View File

@ -10,12 +10,7 @@ export default class ForgotPasswordState extends AbstractState {
context.navigate('/forgot-password'); context.navigate('/forgot-password');
} }
resolve( resolve(context: AuthContext, payload: { login: string; captcha: string }): Promise<void> | void {
context: AuthContext,
payload: {
login?: string;
} = {},
): Promise<void> | void {
context context
.run('forgotPassword', payload) .run('forgotPassword', payload)
.then(() => { .then(() => {

View File

@ -23,6 +23,7 @@ export default class MfaState extends AbstractState {
return context return context
.run('login', { .run('login', {
// @ts-ignore there will be no invalid value
login, login,
password, password,
totp, totp,

View File

@ -9,13 +9,13 @@ export default class OAuthState extends AbstractState {
return context return context
.run('oAuthValidate', { .run('oAuthValidate', {
clientId: query.get('client_id') || params.clientId, clientId: query.get('client_id') || params.clientId,
redirectUrl: query.get('redirect_uri'), redirectUrl: query.get('redirect_uri')!,
responseType: query.get('response_type'), responseType: query.get('response_type')!,
description: query.get('description'), description: query.get('description')!,
scope: (query.get('scope') || '').replace(/,/g, ' '), scope: (query.get('scope') || '').replace(/,/g, ' '),
prompt: query.get('prompt'), prompt: query.get('prompt')!,
loginHint: query.get('login_hint'), loginHint: query.get('login_hint')!,
state: query.get('state'), state: query.get('state')!,
}) })
.then(() => context.setState(new CompleteState())); .then(() => context.setState(new CompleteState()));
} }

View File

@ -39,6 +39,7 @@ export default class PasswordState extends AbstractState {
.run('login', { .run('login', {
password, password,
rememberMe, rememberMe,
// @ts-ignore there will be no null value
login, login,
}) })
.then(() => { .then(() => {

View File

@ -48,7 +48,7 @@ describe('RecoverPasswordState', () => {
describe('#resolve', () => { describe('#resolve', () => {
it('should call recoverPassword with provided payload', () => { it('should call recoverPassword with provided payload', () => {
const expectedPayload = { const expectedPayload = {
key: 123, key: '123',
newPassword: '123', newPassword: '123',
newRePassword: '123', newRePassword: '123',
}; };
@ -64,7 +64,7 @@ describe('RecoverPasswordState', () => {
mock.expects('run').returns(promise); mock.expects('run').returns(promise);
expectState(mock, CompleteState); expectState(mock, CompleteState);
state.resolve(context, {}); state.resolve(context, { key: '', newPassword: '', newRePassword: '' });
return promise; return promise;
}); });
@ -75,7 +75,7 @@ describe('RecoverPasswordState', () => {
mock.expects('run').returns(promise); mock.expects('run').returns(promise);
mock.expects('setState').never(); mock.expects('setState').never();
state.resolve(context, {}); state.resolve(context, { key: '', newPassword: '', newRePassword: '' });
return promise.catch(mock.verify.bind(mock)); return promise.catch(mock.verify.bind(mock));
}); });

View File

@ -13,7 +13,10 @@ export default class RecoverPasswordState extends AbstractState {
context.navigate(url); context.navigate(url);
} }
resolve(context: AuthContext, payload: Record<string, any>): Promise<void> | void { resolve(
context: AuthContext,
payload: { key: string; newPassword: string; newRePassword: string },
): Promise<void> | void {
context context
.run('recoverPassword', payload) .run('recoverPassword', payload)
.then(() => context.setState(new CompleteState())) .then(() => context.setState(new CompleteState()))

View File

@ -12,6 +12,15 @@ describe('RegisterState', () => {
let context: MockedAuthContext; let context: MockedAuthContext;
let mock: SinonMock; let mock: SinonMock;
const mockPayload = {
username: '',
email: '',
password: '',
rePassword: '',
rulesAgreement: true,
captcha: '',
};
beforeEach(() => { beforeEach(() => {
state = new RegisterState(); state = new RegisterState();
@ -38,21 +47,18 @@ describe('RegisterState', () => {
describe('#resolve', () => { describe('#resolve', () => {
it('should register on resolve', () => { it('should register on resolve', () => {
const payload = {}; expectRun(mock, 'register', sinon.match.same(mockPayload)).returns(new Promise(() => {}));
expectRun(mock, 'register', sinon.match.same(payload)).returns(new Promise(() => {})); state.resolve(context, mockPayload);
state.resolve(context, payload);
}); });
it('should transition to complete after register', () => { it('should transition to complete after register', () => {
const payload = {};
const promise = Promise.resolve(); const promise = Promise.resolve();
mock.expects('run').returns(promise); mock.expects('run').returns(promise);
expectState(mock, CompleteState); expectState(mock, CompleteState);
state.resolve(context, payload); state.resolve(context, mockPayload);
return promise; return promise;
}); });
@ -63,7 +69,7 @@ describe('RegisterState', () => {
mock.expects('run').returns(promise); mock.expects('run').returns(promise);
mock.expects('setState').never(); mock.expects('setState').never();
state.resolve(context, {}); state.resolve(context, mockPayload);
return promise.catch(mock.verify.bind(mock)); return promise.catch(mock.verify.bind(mock));
}); });

View File

@ -11,7 +11,17 @@ export default class RegisterState extends AbstractState {
context.navigate('/register'); context.navigate('/register');
} }
resolve(context: AuthContext, payload: Record<string, any>): Promise<void> | void { resolve(
context: AuthContext,
payload: {
email: string;
username: string;
password: string;
rePassword: string;
captcha: string;
rulesAgreement: boolean;
},
): Promise<void> | void {
context context
.run('register', payload) .run('register', payload)
.then(() => context.setState(new CompleteState())) .then(() => context.setState(new CompleteState()))

View File

@ -11,6 +11,11 @@ describe('ResendActivationState', () => {
let context: MockedAuthContext; let context: MockedAuthContext;
let mock: SinonMock; let mock: SinonMock;
const mockPayload = {
email: 'foo@bar.com',
captcha: '',
};
beforeEach(() => { beforeEach(() => {
state = new ResendActivationState(); state = new ResendActivationState();
@ -52,11 +57,9 @@ describe('ResendActivationState', () => {
describe('#resolve', () => { describe('#resolve', () => {
it('should call resendActivation with payload', () => { it('should call resendActivation with payload', () => {
const payload = { email: 'foo@bar.com' }; expectRun(mock, 'resendActivation', sinon.match.same(mockPayload)).returns(new Promise(() => {}));
expectRun(mock, 'resendActivation', sinon.match.same(payload)).returns(new Promise(() => {})); state.resolve(context, mockPayload);
state.resolve(context, payload);
}); });
it('should transition to complete state on success', () => { it('should transition to complete state on success', () => {
@ -65,7 +68,7 @@ describe('ResendActivationState', () => {
mock.expects('run').returns(promise); mock.expects('run').returns(promise);
expectState(mock, ActivationState); expectState(mock, ActivationState);
state.resolve(context, {}); state.resolve(context, mockPayload);
return promise; return promise;
}); });
@ -76,7 +79,7 @@ describe('ResendActivationState', () => {
mock.expects('run').returns(promise); mock.expects('run').returns(promise);
mock.expects('setState').never(); mock.expects('setState').never();
state.resolve(context, {}); state.resolve(context, mockPayload);
return promise.catch(mock.verify.bind(mock)); return promise.catch(mock.verify.bind(mock));
}); });

View File

@ -10,7 +10,7 @@ export default class ResendActivationState extends AbstractState {
context.navigate('/resend-activation'); context.navigate('/resend-activation');
} }
resolve(context: AuthContext, payload: Record<string, any>): Promise<void> | void { resolve(context: AuthContext, payload: { email: string; captcha: string }): Promise<void> | void {
context context
.run('resendActivation', payload) .run('resendActivation', payload)
.then(() => context.setState(new ActivationState())) .then(() => context.setState(new ActivationState()))

View File

@ -0,0 +1,10 @@
import { AuthContext } from 'app/services/authFlow';
export default interface State {
resolve(context: AuthContext, payload: Record<string, any>): Promise<void> | void;
goBack(context: AuthContext): void;
// TODO: remove this method and handle next state resolution via Resolve method
reject(context: AuthContext, payload?: Record<string, any>): void;
enter(context: AuthContext): Promise<void> | void;
leave(context: AuthContext): void;
}

View File

@ -1,45 +1,4 @@
import * as actions from 'app/components/auth/actions'; import AuthFlow, { availableActions, AuthContext as TAuthContext } from './AuthFlow';
import { updateUser } from 'app/components/user/actions';
import {
authenticate,
logoutAll as logout,
remove as removeAccount,
activate as activateAccount,
} from 'app/components/accounts/actions';
import AuthFlow, { ActionsDict, AuthContext as TAuthContext } from './AuthFlow';
const availableActions: ActionsDict = {
updateUser,
authenticate,
activateAccount,
removeAccount,
logout,
goBack: actions.goBack,
redirect: actions.redirect,
login: actions.login,
acceptRules: actions.acceptRules,
forgotPassword: actions.forgotPassword,
recoverPassword: actions.recoverPassword,
register: actions.register,
activate: actions.activate,
resendActivation: actions.resendActivation,
contactUs: actions.contactUs,
setLogin: actions.setLogin,
setAccountSwitcher: actions.setAccountSwitcher,
setErrors: actions.setErrors,
clearErrors: actions.clearErrors,
oAuthValidate: actions.oAuthValidate,
oAuthComplete: actions.oAuthComplete,
setClient: actions.setClient,
resetOAuth: actions.resetOAuth,
resetAuth: actions.resetAuth,
setOAuthRequest: actions.setOAuthRequest,
setOAuthCode: actions.setOAuthCode,
requirePermissionsAccept: actions.requirePermissionsAccept,
setScopes: actions.setScopes,
setLoadingState: actions.setLoadingState,
};
export type AuthContext = TAuthContext; export type AuthContext = TAuthContext;
export default new AuthFlow(availableActions); export default new AuthFlow(availableActions);