fix incorrect gui refresh if theme is changed

implement custom clientstore
add new Password page
if password is set force entering password to successfully receive the token
add a new unsafe api call for init call only
This commit is contained in:
2021-03-14 12:49:24 +00:00
parent be40475615
commit 059b0af6e7
18 changed files with 422 additions and 106 deletions

View File

@ -1,6 +1,7 @@
import React from 'react';
import App from './App';
import {shallow} from 'enzyme';
import GlobalInfos from "./utils/GlobalInfos";
describe('<App/>', function () {
it('renders without crashing ', function () {
@ -10,34 +11,37 @@ describe('<App/>', function () {
it('renders title', () => {
const wrapper = shallow(<App/>);
wrapper.setState({password: false});
expect(wrapper.find('.navbrand').text()).toBe('OpenMediaCenter');
});
it('are navlinks correct', function () {
const wrapper = shallow(<App/>);
wrapper.setState({password: false});
expect(wrapper.find('.navitem')).toHaveLength(4);
});
it('test initial fetch from api', done => {
global.fetch = global.prepareFetchApi({
generalSettingsLoaded: true,
passwordsupport: true,
mediacentername: 'testname'
});
callAPIMock({
MediacenterName: 'testname'
})
GlobalInfos.enableDarkTheme = jest.fn((r) => {})
const wrapper = shallow(<App/>);
const func = jest.fn();
wrapper.instance().setState = func;
expect(global.fetch).toBeCalledTimes(1);
process.nextTick(() => {
expect(func).toBeCalledTimes(1);
expect(document.title).toBe('testname');
global.fetch.mockClear();
done();
});
});
it('test render of password page', function () {
const wrapper = shallow(<App/>);
wrapper.setState({password: true});
expect(wrapper.find('AuthenticationPage')).toHaveLength(1);
});
});

View File

@ -9,7 +9,7 @@ import style from './App.module.css';
import SettingsPage from './pages/SettingsPage/SettingsPage';
import CategoryPage from './pages/CategoryPage/CategoryPage';
import {APINode, callAPI} from './utils/Api';
import {APINode, apiTokenValid, callApiUnsafe, refreshAPIToken} from './utils/Api';
import {NoBackendConnectionPopup} from './elements/Popups/NoBackendConnectionPopup/NoBackendConnectionPopup';
import {BrowserRouter as Router, NavLink, Route, Switch} from 'react-router-dom';
@ -17,10 +17,10 @@ import Player from './pages/Player/Player';
import ActorOverviewPage from './pages/ActorOverviewPage/ActorOverviewPage';
import ActorPage from './pages/ActorPage/ActorPage';
import {SettingsTypes} from './types/ApiTypes';
import AuthenticationPage from "./pages/AuthenticationPage/AuthenticationPage";
interface state {
generalSettingsLoaded: boolean;
passwordsupport: boolean;
password: boolean | null; // null if uninitialized - true if pwd needed false if not needed
mediacentername: string;
onapierror: boolean;
}
@ -31,30 +31,48 @@ interface state {
class App extends React.Component<{}, state> {
constructor(props: {}) {
super(props);
let pwdneeded: boolean | null = null;
if (apiTokenValid()) {
pwdneeded = false;
} else {
refreshAPIToken((err) => {
if (err === 'invalid_client') {
this.setState({password: true})
} else if (err === '') {
this.setState({password: false})
} else {
console.log("unimplemented token error: " + err)
}
})
}
this.state = {
generalSettingsLoaded: false,
passwordsupport: false,
mediacentername: 'OpenMediaCenter',
onapierror: false
onapierror: false,
password: pwdneeded
};
GlobalInfos.onThemeChange(() => {
this.forceUpdate();
})
}
initialAPICall(): void {
// this is the first api call so if it fails we know there is no connection to backend
callAPI(APINode.Settings, {action: 'loadInitialData'}, (result: SettingsTypes.initialApiCallData) => {
callApiUnsafe(APINode.Init, {action: 'loadInitialData'}, (result: SettingsTypes.initialApiCallData) => {
// set theme
GlobalInfos.enableDarkTheme(result.DarkMode);
GlobalInfos.setVideoPath(result.VideoPath);
this.setState({
generalSettingsLoaded: true,
passwordsupport: result.Password,
mediacentername: result.Mediacenter_name,
mediacentername: result.MediacenterName,
onapierror: false
});
// set tab title to received mediacenter name
document.title = result.Mediacenter_name;
document.title = result.MediacenterName;
}, error => {
this.setState({onapierror: true});
});
@ -70,23 +88,44 @@ class App extends React.Component<{}, state> {
// add the main theme to the page body
document.body.className = themeStyle.backgroundcolor;
return (
<Router>
<div className={style.app}>
<div className={[style.navcontainer, themeStyle.backgroundcolor, themeStyle.textcolor, themeStyle.hrcolor].join(' ')}>
<div className={style.navbrand}>{this.state.mediacentername}</div>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/'} activeStyle={{opacity: '0.85'}}>Home</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/random'} activeStyle={{opacity: '0.85'}}>Random
Video</NavLink>
if (this.state.password === true) {
return (
<AuthenticationPage submit={(password): void => {
refreshAPIToken((error) => {
if (error !== '') {
console.log("wrong password!!!");
} else {
this.setState({password: false});
}
}, password);
}}/>
);
} else if (this.state.password === false) {
return (
<Router>
<div className={style.app}>
<div
className={[style.navcontainer, themeStyle.backgroundcolor, themeStyle.textcolor, themeStyle.hrcolor].join(' ')}>
<div className={style.navbrand}>{this.state.mediacentername}</div>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/'}
activeStyle={{opacity: '0.85'}}>Home</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/random'}
activeStyle={{opacity: '0.85'}}>Random
Video</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/categories'} activeStyle={{opacity: '0.85'}}>Categories</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/settings'} activeStyle={{opacity: '0.85'}}>Settings</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/categories'}
activeStyle={{opacity: '0.85'}}>Categories</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/settings'}
activeStyle={{opacity: '0.85'}}>Settings</NavLink>
</div>
{this.routing()}
</div>
{this.routing()}
</div>
{this.state.onapierror ? this.ApiError() : null}
</Router>
);
{this.state.onapierror ? this.ApiError() : null}
</Router>
);
} else {
return (<>still loading...</>);
}
}
routing(): JSX.Element {

View File

@ -0,0 +1,52 @@
.main {
background-color: #00b3ff;
margin-left: calc(50% - 125px);
margin-top: 5%;
padding-bottom: 15px;
width: 250px;
text-align: center;
border-radius: 10px;
}
.loginText {
font-size: xx-large;
text-align: center;
margin-bottom: 15px;
font-weight: bolder;
}
.openmediacenterlabel {
margin-top: 5%;
text-align: center;
font-size: xxx-large;
font-weight: bold;
text-transform: capitalize;
color: white;
}
.input {
margin-left: 10px;
margin-right: 10px;
width: calc(100% - 20px);
background: transparent;
border-width: 0 0 1px 0;
color: #505050;
border-color: #505050;
text-align: center;
margin-bottom: 25px;
font-size: larger;
}
::placeholder {
color: #505050;
opacity: 1;
}
*:focus {
outline: none;
}
.input:focus {
color: black;
border-color: black;
}

View File

@ -0,0 +1,21 @@
import React from 'react';
import AuthenticationPage from './AuthenticationPage';
import {shallow} from 'enzyme';
describe('<AuthenticationPage/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<AuthenticationPage submit={() => {}}/>);
wrapper.unmount();
});
it('test button click', function () {
let pass;
const func = jest.fn((pwd) => {pass = pwd});
const wrapper = shallow(<AuthenticationPage submit={func}/>);
wrapper.setState({pwdText: 'testpwd'});
wrapper.find('Button').simulate('click');
expect(func).toHaveBeenCalledTimes(1);
expect(pass).toBe('testpwd');
});
});

View File

@ -0,0 +1,46 @@
import React from "react";
import {Button} from "../../elements/GPElements/Button";
import style from './AuthenticationPage.module.css'
interface state {
pwdText: string
}
interface props {
submit: (password: string) => void
}
class AuthenticationPage extends React.Component<props, state> {
constructor(props: props) {
super(props);
this.state = {
pwdText: ''
}
}
render(): JSX.Element {
return (
<>
<div className={style.openmediacenterlabel}>OpenMediaCenter</div>
<div className={style.main}>
<div className={style.loginText}>Login</div>
<div>
<input className={style.input}
placeholder='Password'
type='password'
onChange={(ch): void => this.setState({pwdText: ch.target.value})}
value={this.state.pwdText}/>
</div>
<div>
<Button title='Submit' onClick={(): void => {
this.props.submit(this.state.pwdText);
}}/>
</div>
</div>
</>
);
}
}
export default AuthenticationPage;

View File

@ -37,6 +37,7 @@ global.prepareFailingFetchApi = () => {
global.callAPIMock = (resonse) => {
const helpers = require('./utils/Api');
helpers.callAPI = jest.fn().mockImplementation((_, __, func1) => {func1(resonse);});
helpers.callApiUnsafe = jest.fn().mockImplementation((_, __, func1) => {func1(resonse);});
};
// code to run before each test

View File

@ -33,7 +33,7 @@ export namespace SettingsTypes {
export interface initialApiCallData {
DarkMode: boolean;
Password: boolean;
Mediacenter_name: string;
MediacenterName: string;
VideoPath: string;
}

View File

@ -47,13 +47,14 @@ interface ApiBaseRequest {
let apiToken = ''
// a callback que to be called after api token refresh
let callQue: (() => void)[] = []
let callQue: ((error: string) => void)[] = []
// flag to check wheter a api refresh is currently pending
let refreshInProcess = false;
// store the expire seconds of token
let expireSeconds = -1;
interface APIToken {
error?: string;
access_token: string;
expires_in: number;
scope: string;
@ -63,8 +64,9 @@ interface APIToken {
/**
* refresh the api token or use that one in cookie if still valid
* @param callback to be called after successful refresh
* @param password
*/
export function refreshAPIToken(callback: () => void): void {
export function refreshAPIToken(callback: (error: string) => void, password?: string): void {
callQue.push(callback);
// check if already is a token refresh is in process
@ -76,30 +78,26 @@ export function refreshAPIToken(callback: () => void): void {
refreshInProcess = true;
}
// check if a cookie with token is available
const token = getTokenCookie();
if (token !== null) {
// check if token is at least valid for the next minute
if (token.expire > (new Date().getTime() / 1000) + 60) {
apiToken = token.token;
expireSeconds = token.expire;
callback();
console.log("token still valid...")
callFuncQue();
return;
}
if (apiTokenValid()) {
console.log("token still valid...")
callFuncQue('');
return;
}
const formData = new FormData();
formData.append("grant_type", "client_credentials");
formData.append("client_id", "openmediacenter");
formData.append("client_secret", 'openmediacenter');
formData.append("client_secret", password ? password : 'openmediacenter');
formData.append("scope", 'all');
fetch(getBackendDomain() + '/token', {method: 'POST', body: formData})
.then((response) => response.json()
.then((result: APIToken) => {
if (result.error) {
callFuncQue(result.error);
return;
}
console.log(result)
// set api token
apiToken = result.access_token;
@ -107,17 +105,32 @@ export function refreshAPIToken(callback: () => void): void {
expireSeconds = (new Date().getTime() / 1000) + result.expires_in;
setTokenCookie(apiToken, expireSeconds);
// call all handlers and release flag
callFuncQue();
callFuncQue('');
}));
}
export function apiTokenValid(): boolean {
// check if a cookie with token is available
const token = getTokenCookie();
if (token !== null) {
// check if token is at least valid for the next minute
if (token.expire > (new Date().getTime() / 1000) + 60) {
apiToken = token.token;
expireSeconds = token.expire;
return true;
}
}
return false;
}
/**
* call all qued callbacks
*/
function callFuncQue(): void {
function callFuncQue(error: string): void {
// call all pending handlers
callQue.map(func => {
return func();
return func(error);
})
// reset pending que
callQue = []
@ -221,6 +234,25 @@ export function callAPI<T>(apinode: APINode,
})
}
/**
* make a public unsafe api call (without token) -- use as rare as possible only for initialization (eg. check if pwd is neccessary)
* @param apinode
* @param fd
* @param callback
*/
export function callApiUnsafe<T>(apinode: APINode, fd: ApiBaseRequest, callback: (_: T) => void, errorcallback?: (_: string) => void): void {
fetch(getAPIDomain() + apinode, {method: 'POST', body: JSON.stringify(fd),}).then((response) => {
if (response.status !== 200) {
console.log('Error: ' + response.statusText);
// todo place error popup here
} else {
response.json().then((result: T) => {
callback(result);
})
}
}).catch(reason => errorcallback ? errorcallback(reason) : {})
}
/**
* A backend api call
* @param apinode which api backend handler to call
@ -249,5 +281,6 @@ export enum APINode {
Settings = 'settings',
Tags = 'tags',
Actor = 'actor',
Video = 'video'
Video = 'video',
Init = 'init'
}

View File

@ -23,6 +23,9 @@ class StaticInfos {
*/
enableDarkTheme(enable = true): void {
this.darktheme = enable;
this.handlers.map(func => {
return func();
})
}
/**
@ -33,6 +36,11 @@ class StaticInfos {
return this.isDarkTheme() ? darktheme : lighttheme;
}
handlers: (() => void)[] = [];
onThemeChange(func: () => void): void {
this.handlers.push(func);
}
/**
* set the current videopath
* @param vidpath videopath with beginning and ending slash