Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
import React, {ReactElement, Suspense, lazy, useState, useEffect} from 'react';
import PropTypes from 'prop-types';
import {MuiThemeProvider} from '@material-ui/core/styles';
import muiTheme from './theme';
import Spinner from './components/Spinner';
import * as version from './version';
import {QsferaContext} from './qsferaContext';
const LazyMain = lazy(() => import(/* webpackChunkName: "identifier-main" */ './Main'));
console.info(`Kopano Identifier build version: ${version.build}`); // eslint-disable-line no-console
const App = ({ bgImg }): ReactElement => {
const [theme, setTheme] = useState(null);
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const configResponse = await fetch('/config.json');
const configData = await configResponse.json();
setConfig(configData);
const themeResponse = await fetch(configData.theme);
const themeData = await themeResponse.json();
setTheme(themeData);
} catch (error) {
console.error('Error fetching config/theme data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) {
return <Spinner/>;
}
return (
<QsferaContext.Provider value={{theme, config}}>
<div
className={`oc-login-bg ${bgImg ? 'oc-login-bg-image' : ''}`}
style={{backgroundImage: bgImg ? `url(${bgImg})` : undefined}}
>
<MuiThemeProvider theme={muiTheme}>
<Suspense fallback={<Spinner/>}>
<LazyMain/>
</Suspense>
</MuiThemeProvider>
{!bgImg &&
<img
src={`${process.env.PUBLIC_URL}/static/icon-lilac.svg`}
className={'oc-login-bg-icon'}
alt=''
/>
}
</div>
</QsferaContext.Provider>
);
}
App.propTypes = {
bgImg: PropTypes.string
};
export default App;
+45
View File
@@ -0,0 +1,45 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import Routes from './Routes';
class Main extends PureComponent {
render() {
const { hello, pathPrefix } = this.props;
return (
<BrowserRouter basename={pathPrefix}>
<Routes hello={hello}/>
</BrowserRouter>
);
}
reload(event) {
event.preventDefault();
window.location.reload();
}
}
Main.propTypes = {
classes: PropTypes.object.isRequired,
hello: PropTypes.object,
updateAvailable: PropTypes.bool.isRequired,
pathPrefix: PropTypes.string.isRequired
};
const mapStateToProps = (state) => {
const { hello, updateAvailable, pathPrefix } = state.common;
return {
hello,
updateAvailable,
pathPrefix
};
};
export default connect(mapStateToProps)(Main);
+8
View File
@@ -0,0 +1,8 @@
all: images
.PHONY: images
images:
@$(MAKE) -C images
clean:
@$(MAKE) -C images clean
+39
View File
@@ -0,0 +1,39 @@
import React, { ReactElement, lazy } from 'react';
import PropTypes from 'prop-types';
import { Route, Switch } from 'react-router-dom';
import PrivateRoute from './components/PrivateRoute';
const AsyncLogin = lazy(() =>
import(/* webpackChunkName: "containers-login" */ './containers/Login'));
const AsyncWelcome = lazy(() =>
import(/* webpackChunkName: "containers-welcome" */ './containers/Welcome'));
const AsyncGoodbye = lazy(() =>
import(/* webpackChunkName: "containers-goodbye" */ './containers/Goodbye'));
const Routes = ({ hello }: {hello: PropTypes.object}): ReactElement => (
<Switch>
<PrivateRoute
path="/welcome"
exact
component={AsyncWelcome}
hello={hello}
/>
<Route
path="/goodbye"
exact
component={AsyncGoodbye}
/>
<Route
path="/"
component={AsyncLogin}
/>
</Switch>
);
Routes.propTypes = {
hello: PropTypes.object
};
export default Routes;
+133
View File
@@ -0,0 +1,133 @@
import axios from 'axios';
import { newHelloRequest } from '../models/hello';
import { withClientRequestState } from '../utils';
import {
ExtendedError,
ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS,
ERROR_HTTP_UNEXPECTED_RESPONSE_STATE
} from '../errors';
import { handleAxiosError } from './utils';
import * as types from './types';
export function receiveError(error) {
return {
type: types.RECEIVE_ERROR,
error
};
}
export function resetHello() {
return {
type: types.RESET_HELLO
};
}
export function receiveHello(hello) {
const { success, username, displayName } = hello;
return {
type: types.RECEIVE_HELLO,
state: success === true,
username,
displayName,
hello
};
}
export function executeHello() {
return function(dispatch, getState) {
dispatch(resetHello());
const { flow, query } = getState().common;
const r = withClientRequestState(newHelloRequest(flow, query));
return axios.post('./identifier/_/hello', r, {
headers: {
'Kopano-Konnect-XSRF': '1'
}
}).then(response => {
switch (response.status) {
case 200:
// success.
return response.data;
case 204:
// not signed-in.
return {
success: false,
state: response.headers['kopano-konnect-state']
};
default:
// error.
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS, response);
}
}).then(response => {
if (response.state !== r.state) {
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATE, response);
}
dispatch(receiveHello(response));
return Promise.resolve(response);
}).catch(error => {
error = handleAxiosError(error);
dispatch(receiveError(error));
});
};
}
export function retryHello() {
return function(dispatch) {
dispatch(receiveError(null));
return dispatch(executeHello());
};
}
export function requestLogoff() {
return {
type: types.REQUEST_LOGOFF
};
}
export function receiveLogoff(state) {
return {
type: types.RECEIVE_LOGOFF,
state
};
}
export function executeLogoff() {
return function(dispatch) {
dispatch(resetHello());
dispatch(requestLogoff());
const r = withClientRequestState({});
return axios.post('./identifier/_/logoff', r, {
headers: {
'Kopano-Konnect-XSRF': '1'
}
}).then(response => {
switch (response.status) {
case 200:
// success.
return response.data;
default:
// error.
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS, response);
}
}).then(response => {
if (response.state !== r.state) {
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATE, response);
}
dispatch(receiveLogoff(response.success === true));
return Promise.resolve(response);
}).catch(error => {
error = handleAxiosError(error);
dispatch(receiveError(error));
});
};
}
+293
View File
@@ -0,0 +1,293 @@
import axios from 'axios';
import queryString from 'query-string';
import { newHelloRequest } from '../models/hello';
import { withClientRequestState } from '../utils';
import {
ExtendedError,
ERROR_LOGIN_VALIDATE_MISSINGUSERNAME,
ERROR_LOGIN_VALIDATE_MISSINGPASSWORD,
ERROR_LOGIN_FAILED,
ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS,
ERROR_HTTP_UNEXPECTED_RESPONSE_STATE
} from '../errors';
import * as types from './types';
import { receiveHello } from './common';
import { handleAxiosError } from './utils';
// Modes for logon.
export const ModeLogonUsernameEmptyPasswordCookie = '0';
export const ModeLogonUsernamePassword = '1';
export function updateInput(name, value) {
return {
type: types.UPDATE_INPUT,
name,
value
};
}
export function receiveValidateLogon(errors) {
return {
type: types.RECEIVE_VALIDATE_LOGON,
errors
};
}
export function requestLogon(username, password) {
return {
type: types.REQUEST_LOGON,
username,
password
};
}
export function receiveLogon(logon) {
const { success, errors } = logon;
return {
type: types.RECEIVE_LOGON,
success,
errors
};
}
export function requestConsent(allow=false) {
return {
type: allow ? types.REQUEST_CONSENT_ALLOW : types.REQUEST_CONSENT_CANCEL
};
}
export function receiveConsent(logon) {
const { success, errors } = logon;
return {
type: types.RECEIVE_CONSENT,
success,
errors
};
}
export function executeLogon(username, password, mode=ModeLogonUsernamePassword) {
return function(dispatch, getState) {
dispatch(requestLogon(username, password));
dispatch(receiveHello({
username
})); // Reset any hello state on logon.
const { flow, query } = getState().common;
// Prepare params based on mode.
const params = [];
switch (mode) {
case ModeLogonUsernamePassword:
// Username with password.
params.push(username, password, mode);
break;
case ModeLogonUsernameEmptyPasswordCookie:
// Username with empty password - this only works when the user is already signed in.
params.push(username, '', mode);
break;
// no default
}
const r = withClientRequestState({
params: params,
hello: newHelloRequest(flow, query)
});
return axios.post('./identifier/_/logon', r, {
headers: {
'Kopano-Konnect-XSRF': '1'
}
}).then(response => {
switch (response.status) {
case 200:
// success.
return response.data;
case 204:
// login failed.
return {
success: false,
state: response.headers['kopano-konnect-state'],
errors: {
http: new Error(ERROR_LOGIN_FAILED)
}
};
default:
// error.
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS, response);
}
}).then(response => {
if (response.state !== r.state) {
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATE, response);
}
let { hello } = response;
if (!hello) {
hello = {
success: response.success,
username
};
}
dispatch(receiveHello(hello));
dispatch(receiveLogon(response));
return Promise.resolve(response);
}).catch(error => {
error = handleAxiosError(error);
const errors = {
http: error
};
dispatch(receiveValidateLogon(errors));
return {
success: false,
errors: errors
};
});
};
}
export function executeConsent(allow=false, scope='') {
return function(dispatch, getState) {
dispatch(requestConsent(allow));
const { query } = getState().common;
const r = withClientRequestState({
allow,
scope,
client_id: query.client_id || '', // eslint-disable-line camelcase
redirect_uri: query.redirect_uri || '', // eslint-disable-line camelcase
ref: query.state || '',
flow_nonce: query.nonce || '' // eslint-disable-line camelcase
});
return axios.post('./identifier/_/consent', r, {
headers: {
'Kopano-Konnect-XSRF': '1'
}
}).then(response => {
switch (response.status) {
case 200:
// success.
return response.data;
case 204:
// cancel reply.
return {
success: true,
state: response.headers['kopano-konnect-state']
};
default:
// error.
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS, response);
}
}).then(response => {
if (response.state !== r.state) {
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATE, response);
}
dispatch(receiveConsent(response));
return Promise.resolve(response);
}).catch(error => {
error = handleAxiosError(error);
const errors = {
http: error
};
dispatch(receiveValidateLogon(errors));
return {
success: false,
errors: errors
};
});
};
}
export function validateUsernamePassword(username, password, isSignedIn) {
return function(dispatch) {
return new Promise((resolve, reject) => {
const errors = {};
if (!username) {
errors.username = new Error(ERROR_LOGIN_VALIDATE_MISSINGUSERNAME);
}
if (!password && !isSignedIn) {
errors.password = new Error(ERROR_LOGIN_VALIDATE_MISSINGPASSWORD);
}
dispatch(receiveValidateLogon(errors));
if (Object.keys(errors).length === 0) {
resolve(errors);
} else {
reject(errors);
}
});
};
}
export function executeLogonIfFormValid(username, password, isSignedIn) {
return (dispatch) => {
return dispatch(
validateUsernamePassword(username, password, isSignedIn)
).then(() => {
const mode = isSignedIn ? ModeLogonUsernameEmptyPasswordCookie : ModeLogonUsernamePassword;
return dispatch(executeLogon(username, password, mode));
}).catch((errors) => {
return {
success: false,
errors: errors
};
});
};
}
export function advanceLogonFlow(success, history, done=false, extraQuery={}) {
return (dispatch, getState) => {
if (!success) {
return;
}
const { flow, query, hello } = getState().common;
const q = Object.assign({}, query, extraQuery);
switch (flow) {
case 'oauth':
case 'consent':
case 'oidc':
if (hello.details.flow !== flow) {
// Ignore requested flow if hello flow does not match.
break;
}
if (!done && hello.details.next === 'consent') {
history.replace(`/consent${history.location.search}${history.location.hash}`);
return;
}
if (hello.details.continue_uri) {
q.prompt = 'none';
window.location.replace(hello.details.continue_uri + '?' + queryString.stringify(q));
return;
}
break;
default:
// Legacy stupid modes.
if (q.continue && q.continue.indexOf(document.location.origin) === 0) {
window.location.replace(q.continue);
return;
}
}
// Default action.
let target = '/welcome';
if (history.action === 'REPLACE') {
target = target + history.location.search + history.location.hash;
}
dispatch(receiveValidateLogon({})); // XXX(longsleep): hack to reset loading and errors.
history.push(target);
};
}
+25
View File
@@ -0,0 +1,25 @@
export const RECEIVE_ERROR = 'RECEIVE_ERROR';
export const RESET_HELLO = 'RESET_HELLO';
export const EXECUTE_HELLO = 'EXECUTE_HELLO';
export const RECEIVE_HELLO = 'RECEIVE_HELLO';
export const RECEIVE_VALIDATE_LOGON = 'RECEIVE_VALIDATE_LOGON';
export const REQUEST_LOGON = 'REQUEST_LOGON';
export const EXECUTE_LOGON = 'EXECUTE_LOGON';
export const RECEIVE_LOGON = 'RECEIVE_LOGON';
export const UPDATE_INPUT = 'UPDATE_INPUT';
export const REQUEST_CONSENT_ALLOW = 'REQUEST_CONSENT_ALLOW';
export const REQUEST_CONSENT_CANCEL = 'REQUEST_CONSENT_CANCEL';
export const EXECUTE_CONSENT = 'EXECUTE_CONSENT';
export const RECEIVE_CONSENT = 'RECEIVE_CONSENT';
export const REQUEST_LOGOFF = 'REQUEST_LOGOFF';
export const EXECUTE_LOGOFF = 'EXECUTE_LOGOFF';
export const RECEIVE_LOGOFF = 'RECEIVE_LOGOFF';
export const SERVICE_WORKER_NEW_CONTENT = 'SERVICE_WORKER_NEW_CONTENT';
export const SERVICE_WORKER_READY = 'SERVICE_WORKER_READY';
export const SERVICE_WORKER_ERROR = 'SERVICE_WORKER_ERROR';
export const SERVICE_WORKER_OFFLINE = 'SERVICE_WORKER_OFFLINE';
+18
View File
@@ -0,0 +1,18 @@
import {
ExtendedError,
ERROR_HTTP_NETWORK_ERROR,
ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS
} from '../errors';
export function handleAxiosError(error) {
if (error.request) {
// Axios errors.
if (error.response) {
error = new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS, error.response);
} else {
error = new ExtendedError(ERROR_HTTP_NETWORK_ERROR);
}
}
return error;
}
+221
View File
@@ -0,0 +1,221 @@
@font-face {
font-family: QSfera;
src: url('./fonts/QSfera500-Regular.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: QSfera;
src: url('./fonts/QSfera750-Bold.woff2') format('woff2');
font-weight: bold;
font-style: normal;
}
html {
font-feature-settings: "cv11";
color: #20434f !important;
height: 100%;
}
body {
font-family: QSfera, sans-serif;
height: 100%;
margin: 0;
padding: 0;
}
strong {
font-weight: 600;
}
#root {
height: 100%;
display: flex;
}
.oc-font-weight-light {
font-weight: 300;
}
.oc-login-bg {
background: #20434F;
display: flex;
width: 100%;
}
.oc-login-bg-icon {
position: fixed;
right: -3vw;
bottom: -3vh;
height: 50vh;
z-index: 0;
}
.oc-login-bg-image {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
}
#loader {
/* NOTE(longsleep): White here needed because of the background image */
color: white;
text-shadow: #000 0 0 1px;
}
.oc-logo {
height: 80px;
z-index: 0;
}
.oc-logo-container {
margin-bottom: 40px;
}
.oc-progress {
/* Needs to be important to overwrite material-ui */
background-color: rgba(78, 133, 200, 0.8) !important;
height: 4px;
width: 100px;
}
.oc-progress > div {
/* Needs to be important to overwrite material-ui */
background-color: #4a76ac !important;
}
.oc-input {
border: 1px solid #20434f;
border-radius: 4px;
height: 45px;
width: 100%;
padding: 16px;
box-sizing: border-box;
font-size: 1rem;
}
.oc-label {
display: block;
margin-bottom: 12px;
font-weight: 700;
}
.oc-input.error {
outline: none;
border: 1px solid #fe4600;
}
.MuiTypography-colorError {
color: #fe4600 !important;
}
.oc-input:focus {
outline: 2px solid #e2baff;
border: 1px solid #fff;
}
.oc-input + .oc-input {
margin-top: 15px;
}
.MuiTouchRipple-root {
display: none !important;
}
.oc-card {
background: white;
display: inline-flex;
width: 500px;
}
.oc-card-body {
padding: 40px;
width: 100%;
}
.oc-button {
/* Needs to be important to overwrite material-ui */
font-size: 1.0625rem !important;
border-radius: 100px !important;
box-shadow: none !important;
font-weight: 700 !important;
}
.oc-button-primary {
/* Needs to be important to overwrite material-ui */
width: 100%;
background: #e2baff !important;
border-radius: 100px !important;
color: #20434f !important;
box-shadow: none !important;
font-weight: 700 !important;
}
.oc-button-secondary {
/* Needs to be important to overwrite material-ui */
width: 100%;
border-radius: 100px !important;
background: #F1F3F4 !important;
color: #20434f !important;
box-shadow: none !important;
}
.oc-footer-message {
color: white;
padding: 10px;
font-size: 0.8rem;
}
@media only screen and (max-width: 768px) {
.oc-logo {
height: 60px;
top: -90px;
}
.oc-login-bg-icon {
display: none;
}
.oc-card {
min-width: 50vw !important;
}
}
@media only screen and (max-width: 600px) {
.oc-card {
width: 100vw !important;
}
}
/* Helpers */
.oc-mt-l {
margin-top: 24px !important;
}
.oc-mb-m {
margin-bottom: 20px !important;
}
.oc-login-form div:not(:last-of-type) {
margin-bottom: 20px;
}
/*
* Special SR classes
* Used to hide an element visually, but keeping it accessible for accessibility tools.
*/
.oc-invisible-sr {
border: 0 !important;
clip: rect(1px, 1px, 1px, 1px) !important;
height: 1px !important;
overflow: hidden !important;
padding: 0 !important;
/* Need to make sure we override any existing styles. */
position: absolute !important;
top: 0;
white-space: nowrap;
width: 1px !important;
}
@@ -0,0 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
const ClientDisplayName = ({ client, ...rest }) => (
<span {...rest}>{client.display_name ? client.display_name : client.id}</span>
);
ClientDisplayName.propTypes = {
client: PropTypes.object.isRequired
};
export default ClientDisplayName;
@@ -0,0 +1,70 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { withTranslation } from 'react-i18next';
import LinearProgress from '@material-ui/core/LinearProgress';
import Grid from '@material-ui/core/Grid';
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
import { retryHello } from '../actions/common';
import { ErrorMessage } from '../errors';
class Loading extends React.PureComponent {
render() {
const { error, t } = this.props;
return (
<Grid item align="center">
{error === null && (
<LinearProgress className="oc-progress" />
)}
{error !== null && (
<div>
<Typography variant="h5" gutterBottom align="center">
{t("konnect.loading.error.headline", "Failed to connect to server")}
</Typography>
<Typography align="center" color="error">
<ErrorMessage error={error}></ErrorMessage>
</Typography>
<Button
autoFocus
color="primary"
variant="outlined"
className="oc-button-primary oc-mt-l"
onClick={(event) => this.retry(event)}
>
{t("konnect.login.retryButton.label", "Retry")}
</Button>
</div>
)}
</Grid>
);
}
retry(event) {
event.preventDefault();
this.props.dispatch(retryHello());
}
}
Loading.propTypes = {
classes: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
error: PropTypes.object,
dispatch: PropTypes.func.isRequired,
};
const mapStateToProps = (state) => {
const { error } = state.common;
return {
error
};
};
export default connect(mapStateToProps)(withTranslation()(Loading));
@@ -0,0 +1,76 @@
import React, { useCallback, useMemo, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import MenuItem from '@material-ui/core/MenuItem';
import Select from '@material-ui/core/Select';
import allLocales from '../locales';
function LocaleSelect({ locales: localesProp, ...other } = {}) {
const { i18n, ready } = useTranslation();
const handleChange = useCallback((event) => {
i18n.changeLanguage(event.target.value);
}, [ i18n ])
const locales = useMemo(() => {
if (!localesProp) {
return allLocales;
}
const supported = allLocales.filter(locale => {
return localesProp.includes(locale.locale);
});
return supported;
}, [localesProp]);
useEffect(() => {
if (locales) {
const found = locales.find((locale) => {
return locale.locale === i18n.language;
});
if (found) {
// Have language -> is supported all good.
return;
}
const wanted = i18n.modules.languageDetector.detectors.navigator.lookup();
i18n.modules.languageDetector.services.languageUtils.options.supportedLngs = locales.map(locale => locale.locale);
i18n.modules.languageDetector.services.languageUtils.options.fallbackLng = null;
let best = i18n.modules.languageDetector.services.languageUtils.getBestMatchFromCodes(wanted);
if (!best) {
best = locales[0].locale;
}
// Auto change language to best one found if the current selected one is not enabled.
if (i18n.language !== best) {
i18n.changeLanguage(best);
}
}
}, [i18n, locales]);
if (!ready || !locales || locales.length < 2) {
return null;
}
return <Select
value={i18n.language}
onChange={handleChange}
{...other}
>
{locales.map(language => {
return <MenuItem
key={language.locale}
value={language.locale}>
{language.nativeName}
</MenuItem>;
})}
</Select>;
}
LocaleSelect.propTypes = {
locales: PropTypes.arrayOf(PropTypes.string),
};
export default LocaleSelect;
@@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Route } from 'react-router-dom';
import RedirectWithQuery from './RedirectWithQuery';
const PrivateRoute = ({ component: Target, hello, ...rest }) => (
<Route {...rest} render={props => (
hello ? (
<Target {...props}/>
) : (
<RedirectWithQuery target='/identifier' />
)
)}/>
);
PrivateRoute.propTypes = {
component: PropTypes.elementType.isRequired,
hello: PropTypes.object
};
export default PrivateRoute;
@@ -0,0 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router';
import { Redirect } from 'react-router-dom';
const RedirectWithQuery = ({target, location, ...rest}) => {
const to = {
pathname: target,
search: location.search,
hash: location.hash
};
return (
<Redirect to={to} {...rest}></Redirect>
);
};
RedirectWithQuery.propTypes = {
target: PropTypes.string.isRequired,
location: PropTypes.object.isRequired
};
export default withRouter(RedirectWithQuery);
@@ -0,0 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import Dialog from '@material-ui/core/Dialog';
import withMobileDialog from '@material-ui/core/withMobileDialog';
const ResponsiveDialog = (props) => {
return <Dialog {...props}/>;
};
ResponsiveDialog.propTypes = {
fullScreen: PropTypes.bool.isRequired
};
export default withMobileDialog()(ResponsiveDialog);
@@ -0,0 +1,114 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { Trans } from 'react-i18next';
import { withStyles } from '@material-ui/core/styles';
import Grid from '@material-ui/core/Grid';
import DialogContent from '@material-ui/core/DialogContent';
import Loading from './Loading';
import { QsferaContext } from "../qsferaContext";
const styles = theme => ({
root: {
display: 'flex',
flex: 1,
zIndex: 999
},
content: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center'
},
actions: {
marginTop: -40,
justifyContent: 'flex-start',
paddingLeft: theme.spacing(3),
paddingRight: theme.spacing(3)
},
wrapper: {
display: 'flex',
flex: 1,
alignItems: 'center'
}
});
const ResponsiveScreen = (props) => {
const {
classes,
withoutLogo,
withoutPadding,
loading,
children,
className,
branding,
...other
} = props;
const { theme } = useContext(QsferaContext);
const logo = (theme && !withoutLogo) ? (
<img src={'/' + theme.common?.logo} className="oc-logo" alt="КуСфера logo"/>
) : null;
const content = loading ? <Loading/> : (withoutPadding ? children : <DialogContent>{children}</DialogContent>);
return (
<Grid
container
justifyContent="center"
alignItems="center"
direction="column"
spacing={0}
className={[classes.root, className].filter(Boolean).join(' ')}
{...other}
>
<div className={classes.wrapper}>
<div className={classes.content}>
{branding?.signinPageLogoURI ? (
<a
href={branding.signinPageLogoURI}
target="_blank"
rel="noopener noreferrer"
className='oc-logo-container'
>
{logo}
</a>
) : (
<div className='oc-logo-container'>
{logo}
</div>
)}
<div className={"oc-card"}>
<div className={"oc-card-body"}>{content}</div>
</div>
</div>
</div>
<footer className="oc-footer-message">
<Trans i18nKey="konnect.footer.slogan">
<strong>КуСфера</strong>
</Trans>
</footer>
</Grid>
);
};
ResponsiveScreen.defaultProps = {
withoutLogo: false,
withoutPadding: false,
loading: false
};
ResponsiveScreen.propTypes = {
classes: PropTypes.object.isRequired,
withoutLogo: PropTypes.bool,
withoutPadding: PropTypes.bool,
loading: PropTypes.bool,
branding: PropTypes.object,
children: PropTypes.node.isRequired,
className: PropTypes.string,
PaperProps: PropTypes.object,
DialogProps: PropTypes.object
};
export default withStyles(styles)(ResponsiveScreen);
@@ -0,0 +1,90 @@
import React from 'react';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import { withStyles } from '@material-ui/core/styles';
import PropTypes from 'prop-types';
import Checkbox from '@material-ui/core/Checkbox';
import { useTranslation } from 'react-i18next';
const styles = () => ({
row: {
paddingTop: 0,
paddingBottom: 0
}
});
const ScopesList = ({scopes, meta, classes, ...rest}) => {
const { mapping, definitions } = meta;
const { t } = useTranslation();
const rows = [];
const known = {};
// TODO(longsleep): Sort scopes according to priority.
for (let scope in scopes) {
if (!scopes[scope]) {
continue;
}
let id = mapping[scope];
if (id) {
if (known[id]) {
continue;
}
known[id] = true;
} else {
id = scope;
}
let definition = definitions[id];
let label;
if (definition) {
switch (definition.id) {
case 'scope_alias_basic':
label = t("konnect.scopeDescription.aliasBasic", "Access your basic account information");
break;
case 'scope_offline_access':
label = t("konnect.scopeDescription.offlineAccess", "Keep the allowed access persistently and forever");
break;
default:
}
if (!label) {
label = definition.description;
}
}
if (!label) {
label = t("konnect.scopeDescription.scope", "Scope: {{scope}}", { scope });
}
rows.push(
<ListItem
disableGutters
dense
key={id}
className={classes.row}
><Checkbox
checked
disableRipple
disabled
/>
<ListItemText primary={label} />
</ListItem>
);
}
return (
<List {...rest}>
{rows}
</List>
);
};
ScopesList.propTypes = {
classes: PropTypes.object.isRequired,
scopes: PropTypes.object.isRequired,
meta: PropTypes.object.isRequired
};
export default withStyles(styles)(ScopesList);
@@ -0,0 +1,34 @@
import React from 'react';
import {
Fade,
CircularProgress,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles(() => ({
root: {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
},
}));
const Spinner = () => {
const classes = useStyles();
return <div className={classes.root}>
<Fade
in
style={{
transitionDelay: '800ms',
}}
unmountOnExit
>
<CircularProgress size={70} thickness={1}/>
</Fade>
</div>;
}
export default Spinner;
@@ -0,0 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';
const TextInput = (props) => {
const label = props.label;
const extraClassName = props.extraClassName;
delete props.label;
delete props.extraClassName;
return (
<div>
<label className="oc-label"
htmlFor={props.id}>{label}</label>
<input className={`oc-input ${extraClassName ? extraClassName : ''}`} {...props}
placeholder={props.placeholder ? props.placeholder : null}/>
</div>);
};
TextInput.propTypes = {
placeholder: PropTypes.object,
label: PropTypes.object,
id: PropTypes.string,
extraClassName: PropTypes.string,
}
export default TextInput;
@@ -0,0 +1,115 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { withTranslation } from 'react-i18next';
import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import DialogActions from '@material-ui/core/DialogActions';
import ResponsiveScreen from '../../components/ResponsiveScreen';
import { executeHello, executeLogoff } from '../../actions/common';
const styles = theme => ({
subHeader: {
marginBottom: theme.spacing(5)
},
wrapper: {
marginTop: theme.spacing(5),
position: 'relative',
display: 'inline-block'
}
});
class Goodbyescreen extends React.PureComponent {
componentDidMount() {
this.props.dispatch(executeHello());
}
render() {
const { classes, branding, hello, t } = this.props;
const loading = hello === null;
return (
<ResponsiveScreen loading={loading} branding={branding}>
{hello !== null && !hello.state && (
<div>
<Typography variant="h5" component="h3">
{t("konnect.goodbye.headline", "Goodbye")}
</Typography>
<Typography variant="subtitle1" className={classes.subHeader}>
{t("konnect.goodbye.subHeader", "you have been signed out from your account")}
</Typography>
<Typography gutterBottom>
{t("konnect.goodbye.message.close", "You can close this window now.")}
</Typography>
</div>
)}
{hello !== null && hello.state === true && (
<div>
<Typography variant="h5" component="h3">
{t("konnect.goodbye.confirm.headline", "Hello {{displayName}}", { displayName: hello.displayName })}
</Typography>
<Typography variant="subtitle1" className={classes.subHeader}>
{t("konnect.goodbye.confirm.subHeader", "please confirm sign out")}
</Typography>
<Typography gutterBottom>
{t("konnect.goodbye.message.confirm", "Press the button below, to sign out from your account now.")}
</Typography>
<DialogActions>
<div className={classes.wrapper}>
<Button
color="secondary"
variant="outlined"
className={classes.button}
onClick={(event) => this.logoff(event)}
>
{t("konnect.goodbye.signoutButton.label", "Sign out")}
</Button>
</div>
</DialogActions>
</div>
)}
</ResponsiveScreen>
);
}
logoff(event) {
event.preventDefault();
this.props.dispatch(executeLogoff()).then((response) => {
const { history } = this.props;
if (response.success) {
this.props.dispatch(executeHello());
history.push('/goodbye');
}
});
}
}
Goodbyescreen.propTypes = {
classes: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
branding: PropTypes.object,
hello: PropTypes.object,
dispatch: PropTypes.func.isRequired,
history: PropTypes.object.isRequired
};
const mapStateToProps = (state) => {
const { branding, hello } = state.common;
return {
branding,
hello
};
};
export default connect(mapStateToProps)(withStyles(styles)(withTranslation()(Goodbyescreen)));
@@ -0,0 +1 @@
export { default } from './Goodbyescreen';
@@ -0,0 +1,150 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { withTranslation } from 'react-i18next';
import { withStyles } from '@material-ui/core/styles';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import Avatar from '@material-ui/core/Avatar';
import Typography from '@material-ui/core/Typography';
import DialogContent from '@material-ui/core/DialogContent';
import { executeLogonIfFormValid, advanceLogonFlow } from '../../actions/login';
import { ErrorMessage } from '../../errors';
const styles = theme => ({
content: {
overflowY: 'visible',
},
subHeader: {
marginBottom: theme.spacing(2)
},
message: {
marginTop: theme.spacing(2)
},
accountList: {
marginLeft: theme.spacing(-5),
marginRight: theme.spacing(-5)
},
accountListItem: {
paddingLeft: theme.spacing(5),
paddingRight: theme.spacing(5)
}
});
class Chooseaccount extends React.PureComponent {
componentDidMount() {
const { hello, history } = this.props;
if ((!hello || !hello.state) && history.action !== 'PUSH') {
history.replace(`/identifier${history.location.search}${history.location.hash}`);
}
}
render() {
const { loading, errors, classes, hello, t } = this.props;
let errorMessage = null;
if (errors.http) {
errorMessage = <Typography color="error" className={classes.message}>
<ErrorMessage error={errors.http}></ErrorMessage>
</Typography>;
}
let username = '';
if (hello && hello.state) {
username = hello.username;
}
return (
<DialogContent className={classes.content}>
<Typography variant="h5" component="h3">
{t("konnect.chooseaccount.headline", "Choose an account")}
</Typography>
<Typography variant="subtitle1" className={classes.subHeader}>
{t("konnect.chooseaccount.subHeader", "to sign in")}
</Typography>
<form action="" onSubmit={(event) => this.logon(event)}>
<List disablePadding className={classes.accountList}>
<ListItem
button
disableGutters
className={classes.accountListItem}
disabled={!!loading}
onClick={(event) => this.logon(event)}
><ListItemAvatar><Avatar>{username.substr(0, 1)}</Avatar></ListItemAvatar>
<ListItemText primary={username} />
</ListItem>
<ListItem
button
disableGutters
className={classes.accountListItem}
disabled={!!loading}
onClick={(event) => this.logoff(event)}
>
<ListItemAvatar>
<Avatar>
{t("konnect.chooseaccount.useOther.persona.label", "?")}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
t("konnect.chooseaccount.useOther.label", "Use another account")
}
/>
</ListItem>
</List>
{errorMessage}
</form>
</DialogContent>
);
}
logon(event) {
event.preventDefault();
const { hello, dispatch, history } = this.props;
dispatch(executeLogonIfFormValid(hello.username, '', true)).then((response) => {
if (response.success) {
dispatch(advanceLogonFlow(response.success, history));
}
});
}
logoff(event) {
event.preventDefault();
const { history} = this.props;
history.push(`/identifier${history.location.search}${history.location.hash}`);
}
}
Chooseaccount.propTypes = {
classes: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
loading: PropTypes.string.isRequired,
errors: PropTypes.object.isRequired,
hello: PropTypes.object,
dispatch: PropTypes.func.isRequired,
history: PropTypes.object.isRequired
};
const mapStateToProps = (state) => {
const { loading, errors } = state.login;
const { hello } = state.common;
return {
loading,
errors,
hello
};
};
export default connect(mapStateToProps)(withStyles(styles)(withTranslation()(Chooseaccount)));
@@ -0,0 +1,202 @@
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {withTranslation, Trans} from 'react-i18next';
import {withStyles} from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import BaseTooltip from '@material-ui/core/Tooltip';
import CircularProgress from '@material-ui/core/CircularProgress';
import green from '@material-ui/core/colors/green';
import Typography from '@material-ui/core/Typography';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import {executeConsent, advanceLogonFlow, receiveValidateLogon} from '../../actions/login';
import {ErrorMessage} from '../../errors';
import {REQUEST_CONSENT_ALLOW} from '../../actions/types';
import ClientDisplayName from '../../components/ClientDisplayName';
import ScopesList from '../../components/ScopesList';
const styles = theme => ({
button: {
margin: theme.spacing(1),
minWidth: 100
},
buttonProgress: {
color: green[500],
position: 'absolute',
top: '50%',
left: '50%',
marginTop: -12,
marginLeft: -12
},
header: {
margin: 0,
},
subHeader: {
marginBottom: theme.spacing(2)
},
scopesList: {
marginBottom: theme.spacing(2)
},
wrapper: {
marginTop: theme.spacing(2),
position: 'relative',
display: 'inline-block'
},
dialogActions: {
gap: theme.spacing(1)
},
message: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2)
}
});
const Tooltip = ({children, ...other} = {}) => {
// Ensures that there is only a single child for the tooltip element to
// make it compatible with the Trans component.
return <BaseTooltip {...other}><span>{children}</span></BaseTooltip>;
}
Tooltip.propTypes = {
children: PropTypes.node,
};
class Consent extends React.PureComponent {
componentDidMount() {
const {dispatch, hello, history, client} = this.props;
if ((!hello || !hello.state || !client) && history.action !== 'PUSH') {
history.replace(`/identifier${history.location.search}${history.location.hash}`);
}
dispatch(receiveValidateLogon({})); // XXX(longsleep): hack to reset loading and errors.
}
action = (allow = false, scopes = {}) => (event) => {
event.preventDefault();
if (allow === undefined) {
return;
}
// Convert all scopes which are true to a scope value.
const scope = Object.keys(scopes).filter(scope => {
return !!scopes[scope];
}).join(' ');
const {dispatch, history} = this.props;
dispatch(executeConsent(allow, scope)).then((response) => {
if (response.success) {
dispatch(advanceLogonFlow(response.success, history, true, {konnect: response.state}));
}
});
}
render() {
const {classes, loading, hello, errors, client, t} = this.props;
const scopes = hello.details.scopes || {};
const meta = hello.details.meta || {};
return (
<DialogContent>
<h1 className={classes.header}>
{t("konnect.consent.headline", "Hi {{displayName}}", {displayName: hello.displayName})}
</h1>
<Typography variant="subtitle1" className={classes.subHeader + " oc-mb-m"}>
{hello.username}
</Typography>
<Typography variant="subtitle1" gutterBottom>
<Trans t={t} i18nKey="konnect.consent.message">
<Tooltip
placement="bottom"
title={t("konnect.consent.tooltip.client", 'Clicking "Allow" will redirect you to: {{redirectURI}}', {redirectURI: client.redirect_uri})}
>
<span className={'oc-font-weight-light'}><ClientDisplayName client={client}/></span>
</Tooltip> wants to
</Trans>
</Typography>
<ScopesList dense disablePadding className={classes.scopesList} scopes={scopes}
meta={meta.scopes}></ScopesList>
<Typography variant="subtitle1" gutterBottom>
<Trans t={t} i18nKey="konnect.consent.question">
Allow <span className={'oc-font-weight-light'}><ClientDisplayName client={client}/></span> to do
this?
</Trans>
</Typography>
<Typography>
{t("konnect.consent.consequence", "By clicking Allow, you allow this app to use your information.")}
</Typography>
<form action="" onSubmit={this.action(undefined, scopes)}>
<DialogActions className={classes.dialogActions}>
<div className={classes.wrapper}>
<Button
color="secondary"
className={classes.button + ' oc-button-secondary'}
disabled={!!loading}
onClick={this.action(false, scopes)}
>
{t("konnect.consent.cancelButton.label", "Cancel")}
</Button>
{(loading && loading !== REQUEST_CONSENT_ALLOW) &&
<CircularProgress size={24} className={classes.buttonProgress}/>}
</div>
<div className={classes.wrapper}>
<Button
type="submit"
color="primary"
variant="contained"
className="oc-button-primary"
disabled={!!loading}
onClick={this.action(true, scopes)}
>
{t("konnect.consent.allowButton.label", "Allow")}
</Button>
{loading === REQUEST_CONSENT_ALLOW &&
<CircularProgress size={24} className={classes.buttonProgress}/>}
</div>
</DialogActions>
{errors.http && (
<Typography variant="subtitle2" color="error" className={classes.message}>
<ErrorMessage error={errors.http}></ErrorMessage>
</Typography>
)}
</form>
</DialogContent>
);
}
}
Consent.propTypes = {
classes: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
loading: PropTypes.string.isRequired,
errors: PropTypes.object.isRequired,
hello: PropTypes.object,
client: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
history: PropTypes.object.isRequired
};
const mapStateToProps = (state) => {
const {hello} = state.common;
const {loading, errors} = state.login;
return {
loading: loading,
errors,
hello,
client: hello.details.client || {}
};
};
export default connect(mapStateToProps)(withStyles(styles)(withTranslation()(Consent)));
@@ -0,0 +1,238 @@
import React, { useEffect, useMemo } from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { useTranslation } from "react-i18next";
import { withStyles } from "@material-ui/core/styles";
import Button from "@material-ui/core/Button";
import CircularProgress from "@material-ui/core/CircularProgress";
import green from "@material-ui/core/colors/green";
import Typography from "@material-ui/core/Typography";
import Link from "@material-ui/core/Link";
import TextInput from "../../components/TextInput";
import {
updateInput,
executeLogonIfFormValid,
advanceLogonFlow,
} from "../../actions/login";
import { ErrorMessage } from "../../errors";
const styles = (theme) => ({
buttonProgress: {
color: green[500],
position: "absolute",
top: "50%",
left: "50%",
marginTop: -12,
marginLeft: -12,
},
main: {
width: "100%",
},
header: {
textAlign: "center",
marginTop: 0,
marginBottom: theme.spacing(6),
},
wrapper: {
position: "relative",
width: "100%",
textAlign: "center",
},
message: {
marginTop: 5,
marginBottom: 5,
},
signinPageText: {
marginBottom: 12
}
});
function Login(props) {
const {
hello,
query,
dispatch,
history,
loading,
errors,
classes,
username,
password,
passwordResetLink,
branding,
} = props;
const { t } = useTranslation();
const loginFailed = errors.http;
const hasError = errors.http || errors.username || errors.password;
const errorMessage = errors.http ? (
<ErrorMessage error={errors.http}></ErrorMessage>
) : errors.username ? (
<ErrorMessage error={errors.username}></ErrorMessage>
) : (
<ErrorMessage error={errors.password}></ErrorMessage>
);
const extraPropsUsername = {
"aria-invalid": errors.username || errors.http ? "true" : "false",
};
const extraPropsPassword = {
"aria-invalid": errors.password || errors.http ? "true" : "false",
};
if (errors.username || errors.http) {
extraPropsUsername["extraClassName"] = "error";
extraPropsUsername["aria-describedby"] = "oc-login-error-message";
}
if (errors.password || errors.http) {
extraPropsPassword["extraClassName"] = "error";
extraPropsPassword["aria-describedby"] = "oc-login-error-message";
}
useEffect(() => {
if (hello && hello.state && history.action !== "PUSH") {
if (!query.prompt || query.prompt.indexOf("select_account") === -1) {
dispatch(advanceLogonFlow(true, history));
return;
}
history.replace(
`/chooseaccount${history.location.search}${history.location.hash}`
);
return;
}
});
const handleChange = (name) => (event) => {
dispatch(updateInput(name, event.target.value));
};
const handleNextClick = (event) => {
event.preventDefault();
dispatch(executeLogonIfFormValid(username, password, false)).then(
(response) => {
if (response.success) {
dispatch(advanceLogonFlow(response.success, history));
}
}
);
};
return (
<div className={classes.main}>
<h1 className={classes.header}>
{" "}
{t("konnect.login.headline", "Sign in")}
</h1>
{branding?.signinPageText && (
<Typography
className={classes.signinPageText}
variant="body2"
dangerouslySetInnerHTML={{ __html: branding.signinPageText }}
/>
)}
<form
action=""
className="oc-login-form"
onSubmit={(event) => handleNextClick(event)}
>
<TextInput
autoFocus
autoCapitalize="off"
spellCheck="false"
value={username}
onChange={handleChange("username")}
autoComplete="username"
placeholder={t("konnect.login.usernameField.label", "Username")}
label={t("konnect.login.usernameField.label", "Username")}
id="oc-login-username"
{...extraPropsUsername}
/>
<TextInput
type="password"
margin="normal"
onChange={handleChange("password")}
autoComplete="current-password"
placeholder={t("konnect.login.passwordField.label", "Password")}
label={t("konnect.login.passwordField.label", "Password")}
id="oc-login-password"
{...extraPropsPassword}
/>
{hasError && (
<Typography
id="oc-login-error-message"
variant="subtitle2"
component="span"
color="error"
className={classes.message}
>
{errorMessage}
</Typography>
)}
<div className={classes.wrapper}>
{loginFailed && passwordResetLink && (
<Link
id="oc-login-password-reset"
href={passwordResetLink}
variant="subtitle2"
>
{"Reset password?"}
</Link>
)}
<Button
type="submit"
color="primary"
variant="contained"
className="oc-button-primary oc-mt-l"
disabled={!!loading}
onClick={handleNextClick}
>
{t("konnect.login.nextButton.label", "Log in")}
</Button>
{loading && (
<CircularProgress size={24} className={classes.buttonProgress} />
)}
</div>
</form>
</div>
);
}
Login.propTypes = {
classes: PropTypes.object.isRequired,
loading: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
password: PropTypes.string.isRequired,
passwordResetLink: PropTypes.string.isRequired,
errors: PropTypes.object.isRequired,
branding: PropTypes.object,
hello: PropTypes.object,
query: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
};
const mapStateToProps = (state) => {
const { loading, username, password, errors } = state.login;
const { branding, hello, query, passwordResetLink } = state.common;
return {
loading,
username,
password,
errors,
branding,
hello,
query,
passwordResetLink,
};
};
export default connect(mapStateToProps)(withStyles(styles)(Login));
@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Route, Switch } from 'react-router-dom';
import { withStyles } from '@material-ui/core/styles';
import ResponsiveScreen from '../../components/ResponsiveScreen';
import RedirectWithQuery from '../../components/RedirectWithQuery';
import { executeHello } from '../../actions/common';
import Login from './Login';
import Chooseaccount from './Chooseaccount';
import Consent from './Consent';
const styles = () => ({
});
class Loginscreen extends React.PureComponent {
componentDidMount() {
this.props.dispatch(executeHello());
}
render() {
const { branding, hello } = this.props;
const loading = hello === null;
return (
<ResponsiveScreen loading={loading} branding={branding} withoutPadding>
<Switch>
<Route path="/identifier" exact component={Login}></Route>
<Route path="/chooseaccount" exact component={Chooseaccount}></Route>
<Route path="/consent" exact component={Consent}></Route>
<RedirectWithQuery target="/identifier"/>
</Switch>
</ResponsiveScreen>
);
}
}
Loginscreen.propTypes = {
classes: PropTypes.object.isRequired,
branding: PropTypes.object,
hello: PropTypes.object,
dispatch: PropTypes.func.isRequired
};
const mapStateToProps = (state) => {
const { branding, hello } = state.common;
return {
branding,
hello
};
};
export default connect(mapStateToProps)(withStyles(styles)(Loginscreen));
@@ -0,0 +1 @@
export { default } from './Loginscreen';
@@ -0,0 +1,90 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { withTranslation } from 'react-i18next';
import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import DialogActions from '@material-ui/core/DialogActions';
import ResponsiveScreen from '../../components/ResponsiveScreen';
import { executeLogoff } from '../../actions/common';
const styles = theme => ({
button: {
margin: theme.spacing(1),
minWidth: 100
},
subHeader: {
marginBottom: theme.spacing(5)
}
});
class Welcomescreen extends React.PureComponent {
render() {
const { classes, branding, hello, t } = this.props;
const loading = hello === null;
return (
<ResponsiveScreen loading={loading} branding={branding}>
<Typography variant="h5" component="h3">
{t("konnect.welcome.headline", "Welcome {{displayName}}", {displayName: hello.displayName})}
</Typography>
<Typography variant="subtitle1" className={classes.subHeader}>
{hello.username}
</Typography>
<Typography gutterBottom>
{t("konnect.welcome.message", "You are signed in - awesome!")}
</Typography>
<DialogActions>
<Button
color="secondary"
className={classes.button}
variant="contained"
onClick={(event) => this.logoff(event)}
>
{t("konnect.welcome.signoutButton.label", "Sign out")}
</Button>
</DialogActions>
</ResponsiveScreen>
);
}
logoff(event) {
event.preventDefault();
this.props.dispatch(executeLogoff()).then((response) => {
const { history } = this.props;
if (response.success) {
history.push('/identifier');
}
});
}
}
Welcomescreen.propTypes = {
classes: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
branding: PropTypes.object,
hello: PropTypes.object,
dispatch: PropTypes.func.isRequired,
history: PropTypes.object.isRequired
};
const mapStateToProps = (state) => {
const { branding, hello } = state.common;
return {
branding,
hello
};
};
export default connect(mapStateToProps)(withStyles(styles)(withTranslation()(Welcomescreen)));
@@ -0,0 +1 @@
export { default } from './Welcomescreen';
+67
View File
@@ -0,0 +1,67 @@
import { withTranslation } from 'react-i18next';
import PropTypes from 'prop-types';
export const ERROR_LOGIN_VALIDATE_MISSINGUSERNAME = 'konnect.error.login.validate.missingUsername';
export const ERROR_LOGIN_VALIDATE_MISSINGPASSWORD = 'konnect.error.login.validate.missingPassword';
export const ERROR_LOGIN_FAILED = 'konnect.error.login.failed';
export const ERROR_HTTP_NETWORK_ERROR = 'konnect.error.http.networkError';
export const ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS = 'konnect.error.http.unexpectedResponseStatus';
export const ERROR_HTTP_UNEXPECTED_RESPONSE_STATE = 'konnect.error.http.unexpectedResponseState';
// Error with values.
export class ExtendedError extends Error {
values = undefined;
constructor(message, values) {
super(message);
if (Error.captureStackTrace !== undefined) {
Error.captureStackTrace(this, ExtendedError);
}
this.values = values;
}
}
// Component to translate error text with values.
function ErrorMessageComponent(props) {
const { error, t, values } = props;
if (!error) {
return null;
}
const id = error.id ? error.id : error.message;
const messageDescriptor = Object.assign({}, {
id,
defaultMessage: error.id ? error.message : undefined,
values: {
...error.values,
...values,
},
});
switch (messageDescriptor.id) {
case ERROR_LOGIN_VALIDATE_MISSINGUSERNAME:
return t("konnect.error.login.validate.missingUsername", "Enter a valid value.", messageDescriptor.values);
case ERROR_LOGIN_VALIDATE_MISSINGPASSWORD:
return t("konnect.error.login.validate.missingPassword", "Enter your password.");
case ERROR_LOGIN_FAILED:
return t("konnect.error.login.failed", "Logon failed. Please verify your credentials and try again.");
case ERROR_HTTP_NETWORK_ERROR:
return t("konnect.error.http.networkError", "Network error. Please check your connection and try again.");
case ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS:
return t("konnect.error.http.unexpectedResponseStatus", "Unexpected HTTP response: {{status}}. Please check your connection and try again.", messageDescriptor.values);
case ERROR_HTTP_UNEXPECTED_RESPONSE_STATE:
return t("konnect.error.http.unexpectedResponseState", "Unexpected response state: {{state}}", messageDescriptor.values);
default:
}
const f = t;
return f(messageDescriptor.defaultMessage, messageDescriptor.values);
}
ErrorMessageComponent.propTypes = {
error: PropTypes.object,
t: PropTypes.func,
values: PropTypes.any,
};
export const ErrorMessage = withTranslation()(ErrorMessageComponent);
@@ -0,0 +1,17 @@
/* not so fancy background */
#bg {
background-color: white;
}
#bg > div {
position: fixed;
top: 0;
width: 100%;
height: 100%;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
background-image: url(./images/background.png);
z-index: 0;
}
Binary file not shown.
+80
View File
@@ -0,0 +1,80 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import LanguageDetector, { CustomDetector } from 'i18next-browser-languagedetector';
import queryString from 'query-string';
import locales from './locales';
const config = {
uiLocalesQueryName: 'ui_locales', // Same as OIDC uses.
uiLocaleCookieName: 'ui_locale', // For domain wide syncing, not set here.
uiLocaleLocalStorageName: 'lico.identifier_ui_locale', // Sufficiently unique, set here.
}
const supportedLanguages = ['en-GB', 'en', ...locales.map((locale) => {
return locale.locale;
})];
const queryUiLocalesDetector: CustomDetector = {
name: 'queryUiLocales',
lookup: (options): string | string[] | undefined => {
const query = queryString.parse(document.location.search);
const ui_locales = query[config.uiLocalesQueryName];
if (!ui_locales) {
return;
}
if (Array.isArray(ui_locales)) {
return ui_locales as string[];
} else {
return ui_locales.split(' ');
}
},
}
const languageDetector = new LanguageDetector();
languageDetector.addDetector(queryUiLocalesDetector);
i18n
.use(resourcesToBackend((language, namespace, callback) => {
import(
/* webpackMode: "lazy-once" */
/* webpackInclude: /\.json$/ */
/* webpackChunkName: "all-i18n-data" */
/* webpackPrefetch: true */
`./locales/${language}/${namespace}.json`
)
.then((resources) => {
callback(null, resources)
})
.catch((error) => {
callback(error, null)
})
}))
.use(languageDetector)
.use(initReactI18next)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
fallbackLng: 'en-GB',
supportedLngs: [...supportedLanguages],
cleanCode: true,
returnEmptyString: false,
debug: false,
detection: {
/* https://github.com/i18next/i18next-browser-languageDetector */
order: [queryUiLocalesDetector.name, 'cookie', 'localStorage', 'navigator'],
lookupCookie: config.uiLocaleCookieName,
lookupLocalStorage: config.uiLocaleLocalStorageName,
caches: ['localStorage'],
},
interpolation: {
escapeValue: false,
},
});
export default i18n;
@@ -0,0 +1,2 @@
app-icon.svg
app-icon-*.png
+42
View File
@@ -0,0 +1,42 @@
# Tools
CONVERT ?= convert
IDENTIFY ?= identify
BASE64 ?= base64
ENVSUBST ?= envsubst
SCOUR ?= scour
INKSCAPE ?= inkscape
# Variables
STATIC ?= ../../public/static
ICON ?= lico-icon.svg
# Build
all: app-icon.svg
.PHONY: icons
icons: $(STATIC)/favicon.ico
.PHONY: $(STATIC)/favicon.ico
$(STATIC)/favicon.ico: app-icon-rounded-256x256.png
$(CONVERT) -background transparent $< -define icon:auto-resize=16,32,48,64,128,256 $@
app-icon.svg: $(ICON)
cp -vaf $< $@
app-icon-whitebox-256x256.png: app-icon.svg
$(INKSCAPE) -z -e $@.tmp -w 204.8 -h 204.8 -b white -y 1.0 $<
$(CONVERT) $@.tmp -background white -gravity center -extent 256x256 $@
@$(RM) $@.tmp
app-icon-rounded-256x256.png: app-icon-whitebox-256x256.png
$(CONVERT) -size 256x256 xc:none -draw "roundrectangle 2,2,252,252,126,126" $@.tmp.png
$(CONVERT) $< -matte $@.tmp.png -compose DstIn -composite $@
@$(RM) $@.tmp.png
.PHONY: clean
clean:
$(RM) app-icon-*.png || true
$(RM) app-icon.svg || true
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 180 180" role="img" aria-label="КуСфера">
<path fill="#061D2B" d="M34,12h112C158,12 168,22 168,34v112C168,158 158,168 146,168H34C22,168 12,158 12,146V34C12,22 22,12 34,12z"/>
<path fill="#123E55" fill-opacity="0.74" d="M12,45C48,19 87,20 121,48C145,68 158,68 168,60V34C168,22 158,12 146,12H34C22,12 12,22 12,34z"/>
<path fill="#19B8C9" fill-opacity="0.18" d="M12,126C46,105 82,104 114,124C139,139 156,139 168,128V146C168,158 158,168 146,168H34C22,168 12,158 12,146z"/>
<path fill="none" stroke="#20D6E6" stroke-linecap="round" stroke-width="10" d="M75,47C100,29 138,39 153,70C167,100 151,135 121,147"/>
<path fill="none" stroke="#9AF8FF" stroke-linecap="round" stroke-width="4" d="M81,45C101,35 130,39 146,62"/>
<path fill="#21D1D6" d="M48,52h25v50h-25z"/>
<path fill="#F8FCFF" d="M48,102h25v42h-25z"/>
<path fill="#18BFD0" d="M77,94L111,55H143L99,103z"/>
<path fill="#F8FCFF" d="M77,104L100,82L148,144H116z"/>
<path fill="#9DF8FF" fill-opacity="0.45" d="M48,52h25v8h-25z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="КуСфера">
<path fill="#061D2B" d="M34,12h112C158,12 168,22 168,34v112C168,158 158,168 146,168H34C22,168 12,158 12,146V34C12,22 22,12 34,12z"/>
<path fill="#123E55" fill-opacity="0.74" d="M12,45C48,19 87,20 121,48C145,68 158,68 168,60V34C168,22 158,12 146,12H34C22,12 12,22 12,34z"/>
<path fill="#19B8C9" fill-opacity="0.18" d="M12,126C46,105 82,104 114,124C139,139 156,139 168,128V146C168,158 158,168 146,168H34C22,168 12,158 12,146z"/>
<path fill="none" stroke="#20D6E6" stroke-linecap="round" stroke-width="10" d="M75,47C100,29 138,39 153,70C167,100 151,135 121,147"/>
<path fill="none" stroke="#9AF8FF" stroke-linecap="round" stroke-width="4" d="M81,45C101,35 130,39 146,62"/>
<path fill="#21D1D6" d="M48,52h25v50h-25z"/>
<path fill="#F8FCFF" d="M48,102h25v42h-25z"/>
<path fill="#18BFD0" d="M77,94L111,55H143L99,103z"/>
<path fill="#F8FCFF" d="M77,104L100,82L148,144H116z"/>
<path fill="#9DF8FF" fill-opacity="0.45" d="M48,52h25v8h-25z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="КуСфера">
<path fill="#061D2B" d="M34,12h112C158,12 168,22 168,34v112C168,158 158,168 146,168H34C22,168 12,158 12,146V34C12,22 22,12 34,12z"/>
<path fill="#123E55" fill-opacity="0.74" d="M12,45C48,19 87,20 121,48C145,68 158,68 168,60V34C168,22 158,12 146,12H34C22,12 12,22 12,34z"/>
<path fill="#19B8C9" fill-opacity="0.18" d="M12,126C46,105 82,104 114,124C139,139 156,139 168,128V146C168,158 158,168 146,168H34C22,168 12,158 12,146z"/>
<path fill="none" stroke="#20D6E6" stroke-linecap="round" stroke-width="10" d="M75,47C100,29 138,39 153,70C167,100 151,135 121,147"/>
<path fill="none" stroke="#9AF8FF" stroke-linecap="round" stroke-width="4" d="M81,45C101,35 130,39 146,62"/>
<path fill="#21D1D6" d="M48,52h25v50h-25z"/>
<path fill="#F8FCFF" d="M48,102h25v42h-25z"/>
<path fill="#18BFD0" d="M77,94L111,55H143L99,103z"/>
<path fill="#F8FCFF" d="M77,104L100,82L148,144H116z"/>
<path fill="#9DF8FF" fill-opacity="0.45" d="M48,52h25v8h-25z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import './i18n';
import App from './App';
import store from './store';
import './app.css';
const root = document.getElementById('root')
// if a custom background image has been configured, make use of it
const bgImg = root.getAttribute('data-bg-img')
ReactDOM.render(
<React.StrictMode>
<Provider store={store as any}>
<App bgImg={bgImg}/>
</Provider>
</React.StrictMode>,
root
);
@@ -0,0 +1,95 @@
{
"konnect": {
"consent": {
"message": "<0><0><0></0></0></0> möchte",
"allowButton": {
"label": "Einverstanden"
},
"question": "<1><0></0></1> den Zugriff gestatten?",
"consequence": "Wenn Sie Einverstanden klicken, erhält die App Zugriff auf Ihre Informationen.",
"cancelButton": {
"label": "Abbrechen"
},
"tooltip": {
"client": "Wenn Sie \"Einverstanden\" klicken werden Sie zu {{redirectURI}} weitergeleitet"
},
"headline": "Hallo {{displayName}}"
},
"chooseaccount": {
"useOther": {
"persona": {
"label": "?"
},
"label": "Anderes Konto"
},
"headline": "Konto auswählen",
"subHeader": "das angemeldet werden soll"
},
"scopeDescription": {
"aliasBasic": "Zugriff auf Ihre grundlegenden Benutzerinformationen",
"offlineAccess": "Dauerhaften Zugriff (läuft nicht ab)",
"scope": "Geltungsbereich: {{scope}}"
},
"login": {
"usernameField": {
"label": "Benutzername",
"placeholder": {
"email": "E-Mail",
"identity": "Identität",
"username": "Benutzername"
}
},
"nextButton": {
"label": "Weiter"
},
"passwordField": {
"label": "Passwort"
},
"retryButton": {
"label": "Wiederholen"
},
"headline": "Anmelden"
},
"error": {
"login": {
"validate": {
"missingUsername": "Geben Sie einen gültigen Wert ein.",
"missingPassword": "Geben Sie ein Passwort ein."
},
"failed": "Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Eingabe und versuchen Sie es noch einmal."
},
"http": {
"networkError": "Netzwerkfehler. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es noch einmal.",
"unexpectedResponseStatus": "Unerwartete HTTP-Antwort: {{status}}. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es noch einmal.",
"unexpectedResponseState": "Unerwarteter Antwort-Status: {{state}}"
}
},
"loading": {
"error": {
"headline": "Verbindung zum Server fehlgeschlagen"
}
},
"goodbye": {
"headline": "Bis bald",
"confirm": {
"headline": "Hallo {{displayName}}",
"subHeader": "Bitte bestätigen Sie, dass Sie sich abmelden möchten"
},
"message": {
"confirm": "Klicken Sie auf die Schaltfläche unten um sich aus Ihrem Konto abzumelden.",
"close": "Sie können dieses Fenster jetzt schließen."
},
"signoutButton": {
"label": "Abmelden"
},
"subHeader": "sie sind jetzt abgemeldet"
},
"welcome": {
"signoutButton": {
"label": "Abmelden"
},
"headline": "Willkommen {{displayName}}",
"message": "Sie sind angemeldet - super!"
}
}
}
@@ -0,0 +1,40 @@
{
"konnect": {
"consent": {
"allowButton": {},
"cancelButton": {},
"tooltip": {}
},
"chooseaccount": {
"useOther": {
"persona": {}
}
},
"scopeDescription": {},
"login": {
"usernameField": {
"placeholder": {}
},
"nextButton": {},
"passwordField": {},
"retryButton": {}
},
"error": {
"login": {
"validate": {}
},
"http": {}
},
"loading": {
"error": {}
},
"goodbye": {
"confirm": {},
"message": {},
"signoutButton": {}
},
"welcome": {
"signoutButton": {}
}
}
}
@@ -0,0 +1,91 @@
{
"konnect": {
"consent": {
"message": "<0><0><0></0></0></0> souhaite",
"allowButton": {
"label": "Autoriser"
},
"question": "Autoriser <1><0></0></1> à faire cela?",
"consequence": "En cliquant, vous autoriser l'app à accéder à vos informations.",
"cancelButton": {
"label": "Annuler"
},
"tooltip": {
"client": "En cliquant \"Autoriser\" vous serez redirigé vers: {{redirectURI}}"
},
"headline": "Bonjour {{displayName}}"
},
"chooseaccount": {
"useOther": {
"persona": {
"label": "?"
},
"label": "Utiliser un autre compte"
},
"headline": "Choisir un compte",
"subHeader": "identification"
},
"scopeDescription": {
"aliasBasic": "Consulter les informations de base de votre compte",
"offlineAccess": "Conserver les autorisations d'accès à l'avenir",
"scope": "Portée : {{scope}}"
},
"login": {
"usernameField": {
"placeholder": {
"username": "Utilisateur"
}
},
"nextButton": {
"label": "Suivant"
},
"passwordField": {
"label": "Mot de passe"
},
"retryButton": {
"label": "Réessayer"
},
"headline": "Identification"
},
"error": {
"login": {
"validate": {
"missingPassword": "Saisir un mot de passe."
},
"failed": "Echec de connexion. Vérifier vos identifiants et essayer à nouveau."
},
"http": {
"networkError": "Erreur réseau. Vérifier votre connexion, et réessayer.",
"unexpectedResponseStatus": "Erreur HTTP inattendue : {{status}}. Vérifier votre connexion et réessayer.",
"unexpectedResponseState": "Erreur d'état inattendue : {{state}}"
}
},
"loading": {
"error": {
"headline": "La connexion au serveur a échoué"
}
},
"goodbye": {
"headline": "Au revoir",
"confirm": {
"headline": "Bonjour {{displayName}}",
"subHeader": "confirmer votre déconnexion"
},
"message": {
"confirm": "Cliquer le bouton ci-dessous, pour quitter.",
"close": "Vous pouvez fermer cette fenêtre à présent."
},
"signoutButton": {
"label": "Quitter"
},
"subHeader": "vous avez été déconnecté"
},
"welcome": {
"signoutButton": {
"label": "Quitter"
},
"headline": "Bienvenue {{displayName}}",
"message": "Magnifique - Vous êtes connecté!"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import allLocales from './locales.json';
interface Locale {
locale: string,
name: string,
nativeName: string,
}
function enableLocales(locales: Locale[]): Locale[] {
return locales;
}
export const locales = enableLocales(allLocales);
export default locales;
@@ -0,0 +1,40 @@
{
"konnect": {
"consent": {
"allowButton": {},
"cancelButton": {},
"tooltip": {}
},
"chooseaccount": {
"useOther": {
"persona": {}
}
},
"scopeDescription": {},
"login": {
"usernameField": {
"placeholder": {}
},
"nextButton": {},
"passwordField": {},
"retryButton": {}
},
"error": {
"login": {
"validate": {}
},
"http": {}
},
"loading": {
"error": {}
},
"goodbye": {
"confirm": {},
"message": {},
"signoutButton": {}
},
"welcome": {
"signoutButton": {}
}
}
}
@@ -0,0 +1,66 @@
[
{
"locale": "de",
"name": "German",
"nativeName": "Deutsch",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
}
},
{
"locale": "en-GB",
"name": "English",
"nativeName": "English",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
}
},
{
"locale": "es",
"name": "Spanish",
"nativeName": "Español",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
}
},
{
"locale": "fr",
"name": "French",
"nativeName": "Français",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
}
},
{
"locale": "it",
"name": "Italian",
"nativeName": "Italiano",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
}
},
{
"locale": "nl",
"name": "Dutch",
"nativeName": "Nederlands",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
}
},
{
"locale": "zh-CN",
"name": "Simplified Chinese",
"nativeName": "简体中文",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
},
"ietf": "zh_hans"
}
]
@@ -0,0 +1,91 @@
{
"konnect": {
"consent": {
"message": "<0><0><0></0></0></0> wil",
"allowButton": {
"label": "Toestaan"
},
"question": "<1><0></0></1> toestaan dit te doen?",
"consequence": "Door op Toestaan te klikken, krijgt deze app toestemming je informatie te gebruiken.",
"cancelButton": {
"label": "Annuleren"
},
"tooltip": {
"client": "Door op \"Toestaan\" te klikken word je doorverwezen naar: {{redirectURI}}"
},
"headline": "Hoi {{displayName}}"
},
"chooseaccount": {
"useOther": {
"persona": {
"label": "?"
},
"label": "Gebruik een ander account"
},
"headline": "Account kiezen",
"subHeader": "aanmelden"
},
"scopeDescription": {
"aliasBasic": "Basis accountgegevens weergeven",
"offlineAccess": "De toestemming voor altijd onthouden",
"scope": "Scope: {{scope}}"
},
"login": {
"usernameField": {
"placeholder": {
"username": "Gebruikersnaam"
}
},
"nextButton": {
"label": "Volgende"
},
"passwordField": {
"label": "Wachtwoord"
},
"retryButton": {
"label": "Opnieuw"
},
"headline": "Aanmelden"
},
"error": {
"login": {
"validate": {
"missingPassword": "Voer een wachtwoord in."
},
"failed": "Inloggen mislukt. Controleer logingegevens en probeer opnieuw."
},
"http": {
"networkError": "Netwerk probleem. Controleer je verbinding en probeer opnieuw.",
"unexpectedResponseStatus": "Onverwachte HTTP respons: {{status}}. Controleer je verbinding en probeer opnieuw.",
"unexpectedResponseState": "Onverwachte respons status: {{state}}"
}
},
"loading": {
"error": {
"headline": "Kon niet met server verbinden"
}
},
"goodbye": {
"headline": "Tot ziens",
"confirm": {
"headline": "Hallo {{displayName}}",
"subHeader": "bevestig afmelden"
},
"message": {
"confirm": "Klik op onderstaande knop om af te melden van je account.",
"close": "Dit venster kan nu worden gesloten."
},
"signoutButton": {
"label": "Afmelden"
},
"subHeader": "je bent afgemeld van je account"
},
"welcome": {
"signoutButton": {
"label": "Afmelden"
},
"headline": "Welkom {{displayName}}",
"message": "Je bent aangemeld - fantastisch!"
}
}
}
@@ -0,0 +1,40 @@
{
"konnect": {
"consent": {
"allowButton": {},
"cancelButton": {},
"tooltip": {}
},
"chooseaccount": {
"useOther": {
"persona": {}
}
},
"scopeDescription": {},
"login": {
"usernameField": {
"placeholder": {}
},
"nextButton": {},
"passwordField": {},
"retryButton": {}
},
"error": {
"login": {
"validate": {}
},
"http": {}
},
"loading": {
"error": {}
},
"goodbye": {
"confirm": {},
"message": {},
"signoutButton": {}
},
"welcome": {
"signoutButton": {}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
export function newHelloRequest(flow, query) {
const r = {};
if (query.prompt) {
// TODO(longsleep): Validate prompt values?
r.prompt = query.prompt;
}
let selectedFlow = flow;
switch (flow) {
case 'oauth':
case 'consent':
case 'oidc':
r.scope = query.scope || '';
r.client_id = query.client_id || ''; // eslint-disable-line camelcase
r.redirect_uri = query.redirect_uri || ''; // eslint-disable-line camelcase
if (query.id_token_hint) {
r.id_token_hint = query.id_token_hint; // eslint-disable-line camelcase
}
if (query.max_age) {
r.max_age = query.max_age; // eslint-disable-line camelcase
}
if (query.claims_scope) {
// Add additional scopes from claims request if given.
r.scope += ' ' + query.claims_scope;
}
break;
default:
selectedFlow = null;
}
if (selectedFlow) {
r.flow = selectedFlow;
}
return r;
}
+6
View File
@@ -0,0 +1,6 @@
import { createContext } from 'react';
export const QsferaContext = createContext({
theme: null,
config: null,
});
+1
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
@@ -0,0 +1,78 @@
import {
RECEIVE_ERROR,
RESET_HELLO,
RECEIVE_HELLO,
SERVICE_WORKER_NEW_CONTENT
} from '../actions/types';
import queryString from 'query-string';
const query = queryString.parse(document.location.search);
const flow = query.flow || '';
delete query.flow;
const defaultPathPrefix = (() => {
const root = document.getElementById('root');
let pathPrefix = root ? root.getAttribute('data-path-prefix') : null;
if (!pathPrefix || pathPrefix === '__PATH_PREFIX__') {
// Not replaced, probably we are running in debug mode or whatever. Use sane default.
pathPrefix = '/signin/v1';
}
return pathPrefix;
})();
const defaultPasswordResetLink = (() => {
const root = document.getElementById('root');
let link = root ? root.getAttribute('passwort-reset-link') : null;
if (!link || link === '__PASSWORD_RESET_LINK__') {
// Not replaced, probably we are running in debug mode or whatever. Use sane default.
link = '';
}
return link;
})();
const defaultState = {
hello: null,
branding: null,
error: null,
flow: flow,
query: query,
updateAvailable: false,
pathPrefix: defaultPathPrefix,
passwordResetLink: defaultPasswordResetLink
};
function commonReducer(state = defaultState, action) {
switch (action.type) {
case RECEIVE_ERROR:
return Object.assign({}, state, {
error: action.error
});
case RESET_HELLO:
return Object.assign({}, state, {
hello: null,
branding: null
});
case RECEIVE_HELLO:
return Object.assign({}, state, {
hello: {
state: action.state,
username: action.username,
displayName: action.displayName,
details: action.hello
},
branding: action.hello.branding ? action.hello.branding : state.branding
});
case SERVICE_WORKER_NEW_CONTENT:
return Object.assign({}, state, {
updateAvailable: true
});
default:
return state;
}
}
export default commonReducer;
+11
View File
@@ -0,0 +1,11 @@
import { combineReducers } from 'redux';
import commonReducer from './common';
import loginReducer from './login';
const rootReducer = combineReducers({
common: commonReducer,
login: loginReducer
});
export default rootReducer;
+57
View File
@@ -0,0 +1,57 @@
import {
RECEIVE_VALIDATE_LOGON,
REQUEST_LOGON,
RECEIVE_LOGON,
RECEIVE_LOGOFF,
REQUEST_CONSENT_ALLOW,
REQUEST_CONSENT_CANCEL,
RECEIVE_CONSENT,
UPDATE_INPUT
} from '../actions/types';
function loginReducer(state = {
loading: '',
username: '',
password: '',
errors: {}
}, action) {
switch (action.type) {
case RECEIVE_VALIDATE_LOGON:
return Object.assign({}, state, {
errors: action.errors,
loading: ''
});
case REQUEST_CONSENT_ALLOW:
case REQUEST_CONSENT_CANCEL:
case REQUEST_LOGON:
return Object.assign({}, state, {
loading: action.type,
errors: {}
});
case RECEIVE_CONSENT:
case RECEIVE_LOGON:
return Object.assign({}, state, {
errors: !action.success && action.errors ? action.errors : {},
loading: ''
});
case RECEIVE_LOGOFF:
return Object.assign({}, state, {
username: '',
password: ''
});
case UPDATE_INPUT:
delete state.errors[action.name];
return Object.assign({}, state, {
[action.name]: action.value
});
default:
return state;
}
}
export default loginReducer;
+24
View File
@@ -0,0 +1,24 @@
import { createStore, applyMiddleware, compose } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { createLogger } from 'redux-logger';
import rootReducer from './reducers';
const middlewares = [
thunkMiddleware
];
if (process.env.NODE_ENV === 'development') { // eslint-disable-line no-undef
middlewares.push(createLogger()); // must be last middleware in the chain.
}
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
rootReducer,
composeEnhancers(applyMiddleware(
...middlewares,
))
);
export default store;
+39
View File
@@ -0,0 +1,39 @@
import { unstable_createMuiStrictModeTheme as createMuiTheme } from '@material-ui/core';
import blueGrey from '@material-ui/core/colors/blueGrey';
import blue from '@material-ui/core/colors/blue';
import red from '@material-ui/core/colors/red';
const primaryColor = blue;
const secondaryColor = blueGrey;
const errorColor = red;
// All the following keys are optional.
// We try our best to provide a great default value.
const theme = createMuiTheme({
palette: {
primary: primaryColor,
secondary: secondaryColor,
error: errorColor,
// Used by `getContrastText()` to maximize the contrast between the background and
// the text.
// NOTE(longsleep): KopanoBlue is too light and thus needs 2.4 contrastThreshold
// to make sure the default 500 color is still using white text. It will
// show warnings in development mode that the contrast is too low as W3C
// recommends the threshold to be 3 or more. This cannot be helped.
contrastThreshold: 2.4,
// Used to shift a color's luminance by approximately
// two indexes within its tonal palette.
// E.g., shift from Red 500 to Red 300 or Red 700.
tonalOffset: 0.2,
},
typography: {
fontSize: 16,
useNextVariants: true,
button: {
textTransform: 'none',
},
},
});
export default theme;
+16
View File
@@ -0,0 +1,16 @@
export function withClientRequestState(obj) {
obj.state = generateState(16);
return obj;
}
function dec2hex (dec) {
return dec.toString(16).padStart(2, "0")
}
// generateState :: Integer -> String
function generateState (len) {
var arr = new Uint8Array((len || 16) / 2)
window.crypto.getRandomValues(arr)
return Array.from(arr, dec2hex).join('')
}
+5
View File
@@ -0,0 +1,5 @@
const build = process.env.REACT_APP_KOPANO_BUILD || '0.0.0-no-proper-build';
export {
build
};