1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-27 00:19:39 +01:00

Merge pull request #99 from Unleash/oauth2

Add support for "builtin" and Custom (Oauth2) auhtentications
This commit is contained in:
Ivar Conradi Østhus 2018-01-17 15:49:31 +01:00 committed by GitHub
commit 1f40dbba3c
25 changed files with 315 additions and 206 deletions

View File

@ -18,7 +18,7 @@ import { Link } from 'react-router';
import styles from './styles.scss';
import ErrorContainer from './error/error-container';
import UserContainer from './user/user-container';
import AuthenticationContainer from './user/authentication-container';
import ShowUserContainer from './user/show-user-container';
import ShowApiDetailsContainer from './api/show-api-details-container';
import { ScrollContainer } from 'react-router-scroll';
@ -136,7 +136,7 @@ export default class App extends Component {
return (
<div className={styles.container}>
<UserContainer />
<AuthenticationContainer />
<Layout fixedHeader>
<Header title={this.getTitleWithLinks()}>
<Navigation>
@ -184,6 +184,7 @@ export default class App extends Component {
{createListItem('/history', 'Event History')}
{createListItem('/archive', 'Archived Toggles')}
{createListItem('/applications', 'Applications')}
<a href="/api/admin/user/logout">Sign out</a>
</FooterLinkList>
</FooterDropDownSection>
<FooterDropDownSection title="Clients">

View File

@ -12,7 +12,7 @@ const mapStateToProps = createMapper({
try {
[, name] = document.location.hash.match(/name=([a-z0-9-_.]+)/i);
} catch (e) {
// nothing
// hide error
}
return { name };
},

View File

@ -25,6 +25,7 @@ export default class FeatureListComponent extends React.PureComponent {
this.props.fetchFeatureToggles();
this.props.fetchFeatureMetrics();
this.timer = setInterval(() => {
// this.props.fetchFeatureToggles();
this.props.fetchFeatureMetrics();
}, 5000);
}

View File

@ -53,7 +53,7 @@ export default connect(
try {
[, name] = document.location.hash.match(/name=([a-z0-9-_.]+)/i);
} catch (e) {
// nothing
// hide error
}
return { name };
},

View File

@ -0,0 +1,77 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Card, CardTitle, CardText } from 'react-mdl';
import Modal from 'react-modal';
import AuthenticationSimpleComponent from './authentication-simple-component';
import AuthenticationCustomComponent from './authentication-custom-component';
const SIMPLE_TYPE = 'unsecure';
const customStyles = {
overlay: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
zIndex: 99999,
},
content: {
top: '50%',
left: '50%',
right: 'auto',
bottom: 'auto',
marginRight: '-50%',
transform: 'translate(-50%, -50%)',
backgroundColor: 'transparent',
padding: 0,
overflow: 'none',
},
};
class AuthComponent extends React.Component {
static propTypes = {
user: PropTypes.object.isRequired,
unsecureLogin: PropTypes.func.isRequired,
fetchFeatureToggles: PropTypes.func.isRequired,
};
render() {
const authDetails = this.props.user.authDetails;
if (!authDetails) return null;
let content;
if (authDetails.type === SIMPLE_TYPE) {
content = (
<AuthenticationSimpleComponent
unsecureLogin={this.props.unsecureLogin}
authDetails={authDetails}
fetchFeatureToggles={this.props.fetchFeatureToggles}
/>
);
} else {
content = <AuthenticationCustomComponent authDetails={authDetails} />;
}
return (
<div>
<Modal isOpen={this.props.user.showDialog} contentLabel="test" style={customStyles}>
<Card shadow={0}>
<CardTitle
expand
style={{
color: '#fff',
background: '#000',
}}
>
Action required
</CardTitle>
<CardText>{content}</CardText>
</Card>
</Modal>
</div>
);
}
}
export default AuthComponent;

View File

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import AuthenticationComponent from './authentication-component';
import { unsecureLogin } from '../../store/user/actions';
import { fetchFeatureToggles } from '../../store/feature-actions';
const mapDispatchToProps = {
unsecureLogin,
fetchFeatureToggles,
};
const mapStateToProps = state => ({
user: state.user.toJS(),
});
export default connect(mapStateToProps, mapDispatchToProps)(AuthenticationComponent);

View File

@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import { CardActions, Button } from 'react-mdl';
class AuthenticationCustomComponent extends React.Component {
static propTypes = {
authDetails: PropTypes.object.isRequired,
};
render() {
const authDetails = this.props.authDetails;
return (
<div>
<p>{authDetails.message}</p>
<CardActions style={{ textAlign: 'center' }}>
<a href={authDetails.path}>
<Button raised colored>
Sign In
</Button>
</a>
</CardActions>
</div>
);
}
}
export default AuthenticationCustomComponent;

View File

@ -0,0 +1,52 @@
import React from 'react';
import PropTypes from 'prop-types';
import { hashHistory } from 'react-router';
import { CardActions, Button, Textfield } from 'react-mdl';
class SimpleAuthenticationComponent extends React.Component {
static propTypes = {
authDetails: PropTypes.object.isRequired,
unsecureLogin: PropTypes.func.isRequired,
fetchFeatureToggles: PropTypes.func.isRequired,
};
handleSubmit = evt => {
evt.preventDefault();
const email = this.refs.email.inputRef.value;
const user = { email };
const path = evt.target.action;
this.props
.unsecureLogin(path, user)
.then(this.props.fetchFeatureToggles)
.then(() => {
hashHistory.push('/features');
});
};
render() {
const authDetails = this.props.authDetails;
return (
<form onSubmit={this.handleSubmit} action={authDetails.path}>
<p>{authDetails.message}</p>
<p>
This instance of Unleash is not set up with a secure authentication provider. You can read more
about{' '}
<a href="https://github.com/Unleash/unleash/blob/master/docs/securing-unleash.md" target="_blank">
securing Unleash on GitHub
</a>
</p>
<Textfield label="Email" name="email" required type="email" ref="email" />
<br />
<CardActions style={{ textAlign: 'center' }}>
<Button raised colored>
Sign in
</Button>
</CardActions>
</form>
);
}
}
export default SimpleAuthenticationComponent;

View File

@ -1,25 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Icon, Tooltip } from 'react-mdl';
import styles from './user.scss';
export default class ShowUserComponent extends React.Component {
static propTypes = {
user: PropTypes.object.isRequired,
openEdit: PropTypes.func.isRequired,
profile: PropTypes.object,
};
openEdit = evt => {
evt.preventDefault();
this.props.openEdit();
};
componentDidMount() {
this.props.fetchUser();
}
render() {
const email = this.props.profile ? this.props.profile.email : '';
const imageUrl = email ? this.props.profile.imageUrl : '';
return (
<a className="mdl-navigation__link" href="#edit-user" onClick={this.openEdit}>
<Tooltip label={this.props.user.userName || 'Unknown'} large>
<Icon name="account_circle" />
</Tooltip>
</a>
<div className={styles.showUser}>
<img src={imageUrl} title={email} alt={email} />
</div>
);
}
}

View File

@ -1,13 +1,13 @@
import { connect } from 'react-redux';
import ShowUserComponent from './show-user-component';
import { openEdit } from '../../store/user/actions';
import { fetchUser } from '../../store/user/actions';
const mapDispatchToProps = {
openEdit,
fetchUser,
};
const mapStateToProps = state => ({
user: state.user.toJS(),
profile: state.user.get('profile'),
});
export default connect(mapStateToProps, mapDispatchToProps)(ShowUserComponent);

View File

@ -1,66 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Textfield, Button } from 'react-mdl';
import Modal from 'react-modal';
const customStyles = {
overlay: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
zIndex: 99999,
},
content: {
top: '50%',
left: '50%',
right: 'auto',
bottom: 'auto',
marginRight: '-50%',
transform: 'translate(-50%, -50%)',
backgroundColor: '#FFFFFF',
},
};
class EditUserComponent extends React.Component {
static propTypes = {
user: PropTypes.object.isRequired,
updateUserName: PropTypes.func.isRequired,
save: PropTypes.func.isRequired,
};
handleSubmit = evt => {
evt.preventDefault();
this.props.save();
};
render() {
return (
<div>
<Modal isOpen={this.props.user.showDialog} contentLabel="test" style={customStyles}>
<h2>Action required</h2>
<div>
<p>You have to specify a username to use Unleash. This will allow us to track your changes.</p>
<form onSubmit={this.handleSubmit}>
<Textfield
label="Username"
name="username"
required
value={this.props.user.userName}
onChange={e => this.props.updateUserName(e.target.value)}
/>
<br />
<Button raised accent>
Save
</Button>
</form>
</div>
</Modal>
</div>
);
}
}
export default EditUserComponent;

View File

@ -1,14 +0,0 @@
import { connect } from 'react-redux';
import UserComponent from './user-component';
import { updateUserName, save } from '../../store/user/actions';
const mapDispatchToProps = {
updateUserName,
save,
};
const mapStateToProps = state => ({
user: state.user.toJS(),
});
export default connect(mapStateToProps, mapDispatchToProps)(UserComponent);

View File

@ -0,0 +1,5 @@
.showUser img {
border-radius: 25px;
height: 32px;
border: 2px solid #ffffff;
}

View File

@ -7,9 +7,29 @@ function extractLegacyMsg(body) {
return body && body.length > 0 ? body[0].msg : defaultErrorMessage;
}
class ServiceError extends Error {
constructor() {
super(defaultErrorMessage);
this.name = 'ServiceError';
}
}
export class AuthenticationError extends Error {
constructor(statusCode, body) {
super('Authentication required');
this.name = 'AuthenticationError';
this.statusCode = statusCode;
this.body = body;
}
}
export function throwIfNotSuccess(response) {
if (!response.ok) {
if (response.status > 399 && response.status < 404) {
if (response.status === 401) {
return new Promise((resolve, reject) => {
response.json().then(body => reject(new AuthenticationError(response.status, body)));
});
} else if (response.status > 399 && response.status < 404) {
return new Promise((resolve, reject) => {
response.json().then(body => {
const errorMsg = body && body.isJoi ? extractJoiMsg(body) : extractLegacyMsg(body);
@ -19,7 +39,7 @@ export function throwIfNotSuccess(response) {
});
});
} else {
return Promise.reject(new Error(defaultErrorMessage));
return Promise.reject(new ServiceError());
}
}
return Promise.resolve(response);

View File

@ -0,0 +1,20 @@
import { throwIfNotSuccess, headers } from './helper';
const URI = 'api/admin/user';
function fetchUser() {
return fetch(URI, { credentials: 'include' })
.then(throwIfNotSuccess)
.then(response => response.json());
}
function unsecureLogin(path, user) {
return fetch(path, { method: 'POST', credentials: 'include', headers, body: JSON.stringify(user) })
.then(throwIfNotSuccess)
.then(response => response.json());
}
export default {
fetchUser,
unsecureLogin,
};

View File

@ -1,4 +1,5 @@
import api from '../../data/applications-api';
import { dispatchAndThrow } from '../util';
export const RECEIVE_ALL_APPLICATIONS = 'RECEIVE_ALL_APPLICATIONS';
export const ERROR_RECEIVE_ALL_APPLICATIONS = 'ERROR_RECEIVE_ALL_APPLICATIONS';
@ -16,24 +17,19 @@ const recieveApplication = json => ({
value: json,
});
const errorReceiveApplications = (statusCode, type = ERROR_RECEIVE_ALL_APPLICATIONS) => ({
type,
statusCode,
});
export function fetchAll() {
return dispatch =>
api
.fetchAll()
.then(json => dispatch(recieveAllApplications(json)))
.catch(error => dispatch(errorReceiveApplications(error)));
.catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_ALL_APPLICATIONS));
}
export function storeApplicationMetaData(appName, key, value) {
return dispatch =>
api
.storeApplicationMetaData(appName, key, value)
.catch(error => dispatch(errorReceiveApplications(error, ERROR_UPDATING_APPLICATION_DATA)));
.catch(dispatchAndThrow(dispatch, ERROR_UPDATING_APPLICATION_DATA));
}
export function fetchApplication(appName) {
@ -41,5 +37,5 @@ export function fetchApplication(appName) {
api
.fetchApplication(appName)
.then(json => dispatch(recieveApplication(json)))
.catch(error => dispatch(errorReceiveApplications(error)));
.catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_ALL_APPLICATIONS));
}

View File

@ -1,4 +1,5 @@
import api from '../data/archive-api';
import { dispatchAndThrow } from './util';
export const REVIVE_TOGGLE = 'REVIVE_TOGGLE';
export const RECEIVE_ARCHIVE = 'RECEIVE_ARCHIVE';
@ -14,17 +15,12 @@ const reviveToggle = archiveFeatureToggle => ({
value: archiveFeatureToggle,
});
const errorReceiveArchive = statusCode => ({
type: ERROR_RECEIVE_ARCHIVE,
statusCode,
});
export function revive(featureToggle) {
return dispatch =>
api
.revive(featureToggle)
.then(() => dispatch(reviveToggle(featureToggle)))
.catch(error => dispatch(errorReceiveArchive(error)));
.catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_ARCHIVE));
}
export function fetchArchive() {
@ -32,5 +28,5 @@ export function fetchArchive() {
api
.fetchAll()
.then(json => dispatch(receiveArchive(json)))
.catch(error => dispatch(errorReceiveArchive(error)));
.catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_ARCHIVE));
}

View File

@ -9,6 +9,8 @@ import {
import { ERROR_UPDATING_STRATEGY, ERROR_CREATING_STRATEGY, ERROR_RECEIVE_STRATEGIES } from './strategy/actions';
import { FORBIDDEN } from './util';
const debug = require('debug')('unleash:error-store');
function getInitState() {
@ -35,6 +37,8 @@ const strategies = (state = getInitState(), action) => {
case ERROR_CREATING_STRATEGY:
case ERROR_RECEIVE_STRATEGIES:
return addErrorIfNotAlreadyInList(state, action.error.message);
case FORBIDDEN:
return addErrorIfNotAlreadyInList(state, '403 Forbidden');
case MUTE_ERROR:
return state.update('list', list => list.remove(list.indexOf(action.error)));
default:

View File

@ -1,5 +1,6 @@
import api from '../data/feature-api';
const debug = require('debug')('unleash:feature-actions');
import { dispatchAndThrow } from './util';
export const ADD_FEATURE_TOGGLE = 'ADD_FEATURE_TOGGLE';
export const REMOVE_FEATURE_TOGGLE = 'REMOVE_FEATURE_TOGGLE';
@ -38,13 +39,6 @@ function receiveFeatureToggles(json) {
};
}
function dispatchAndThrow(dispatch, type) {
return error => {
dispatch({ type, error, receivedAt: Date.now() });
throw error;
};
}
export function fetchFeatureToggles() {
debug('Start fetching feature toggles');
return dispatch => {

View File

@ -27,7 +27,7 @@ function receiveSeenApps(json) {
function dispatchAndThrow(dispatch, type) {
return error => {
dispatch({ type, error, receivedAt: Date.now() });
throw error;
// throw error;
};
}

View File

@ -1,4 +1,5 @@
import api from '../data/history-api';
import { dispatchAndThrow } from './util';
export const RECEIVE_HISTORY = 'RECEIVE_HISTORY';
export const ERROR_RECEIVE_HISTORY = 'ERROR_RECEIVE_HISTORY';
@ -15,17 +16,12 @@ const receiveHistoryforToggle = json => ({
value: json,
});
const errorReceiveHistory = statusCode => ({
type: ERROR_RECEIVE_HISTORY,
statusCode,
});
export function fetchHistory() {
return dispatch =>
api
.fetchAll()
.then(json => dispatch(receiveHistory(json)))
.catch(error => dispatch(errorReceiveHistory(error)));
.catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_HISTORY));
}
export function fetchHistoryForToggle(toggleName) {
@ -33,5 +29,5 @@ export function fetchHistoryForToggle(toggleName) {
api
.fetchHistoryForToggle(toggleName)
.then(json => dispatch(receiveHistoryforToggle(json)))
.catch(error => dispatch(errorReceiveHistory(error)));
.catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_HISTORY));
}

View File

@ -1,5 +1,6 @@
import api from '../../data/strategy-api';
import applicationApi from '../../data/applications-api';
import { dispatchAndThrow } from '../util';
export const ADD_STRATEGY = 'ADD_STRATEGY';
export const UPDATE_STRATEGY = 'UPDATE_STRATEGY';
@ -26,20 +27,8 @@ const receiveStrategies = json => ({
const startCreate = () => ({ type: START_CREATE_STRATEGY });
const errorReceiveStrategies = statusCode => ({
type: ERROR_RECEIVE_STRATEGIES,
statusCode,
});
const startUpdate = () => ({ type: START_UPDATE_STRATEGY });
function dispatchAndThrow(dispatch, type) {
return error => {
dispatch({ type, error, receivedAt: Date.now() });
throw error;
};
}
export function fetchStrategies() {
return dispatch => {
dispatch(startRequest());
@ -47,7 +36,7 @@ export function fetchStrategies() {
return api
.fetchAll()
.then(json => dispatch(receiveStrategies(json)))
.catch(error => dispatch(errorReceiveStrategies(error)));
.catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_STRATEGIES));
};
}

View File

@ -1,16 +1,38 @@
export const USER_UPDATE_USERNAME = 'USER_UPDATE_USERNAME';
export const USER_SAVE = 'USER_SAVE';
export const USER_EDIT = 'USER_EDIT';
import api from '../../data/user-api';
import { dispatchAndThrow } from '../util';
export const UPDATE_USER = 'UPDATE_USER';
export const START_FETCH_USER = 'START_FETCH_USER';
export const ERROR_FETCH_USER = 'ERROR_FETCH_USER';
const debug = require('debug')('unleash:user-actions');
export const updateUserName = value => ({
type: USER_UPDATE_USERNAME,
const updateUser = value => ({
type: UPDATE_USER,
value,
});
export const save = () => ({
type: USER_SAVE,
});
function handleError(error) {
debug(error);
}
export const openEdit = () => ({
type: USER_EDIT,
});
export function fetchUser() {
debug('Start fetching user');
return dispatch => {
dispatch({ type: START_FETCH_USER });
return api
.fetchUser()
.then(json => dispatch(updateUser(json)))
.catch(dispatchAndThrow(dispatch, ERROR_FETCH_USER));
};
}
export function unsecureLogin(path, user) {
return dispatch => {
dispatch({ type: START_FETCH_USER });
return api
.unsecureLogin(path, user)
.then(json => dispatch(updateUser(json)))
.catch(handleError);
};
}

View File

@ -1,59 +1,21 @@
import { Map as $Map } from 'immutable';
import { USER_UPDATE_USERNAME, USER_SAVE, USER_EDIT } from './actions';
import { UPDATE_USER } from './actions';
import { AUTH_REQUIRED } from '../util';
const COOKIE_NAME = 'username';
// Ref: http://stackoverflow.com/questions/10730362/get-cookie-by-name
function readCookie() {
const nameEQ = `${COOKIE_NAME}=`;
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
// eslint-disable-next-line eqeqeq
while (c.charAt(0) == ' ') {
c = c.substring(1, c.length);
}
if (c.indexOf(nameEQ) === 0) {
return c.substring(nameEQ.length, c.length);
}
}
}
function writeCookie(userName) {
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(userName)}; expires=Thu, 18 Dec 2099 12:00:00 UTC`;
}
function getInitState() {
const userName = decodeURIComponent(readCookie(COOKIE_NAME));
const showDialog = !userName;
return new $Map({ userName, showDialog });
}
function updateUserName(state, action) {
return state.set('userName', action.value);
}
function save(state) {
const userName = state.get('userName');
if (userName) {
writeCookie(userName);
return state.set('showDialog', false);
} else {
return state;
}
}
const settingStore = (state = getInitState(), action) => {
const userStore = (state = new $Map(), action) => {
switch (action.type) {
case USER_UPDATE_USERNAME:
return updateUserName(state, action);
case USER_SAVE:
return save(state);
case USER_EDIT:
return state.set('showDialog', true);
case UPDATE_USER:
state = state
.set('profile', action.value)
.set('showDialog', false)
.set('authDetails', undefined);
return state;
case AUTH_REQUIRED:
state = state.set('authDetails', action.error.body).set('showDialog', true);
return state;
default:
return state;
}
};
export default settingStore;
export default userStore;

View File

@ -0,0 +1,14 @@
export const AUTH_REQUIRED = 'AUTH_REQUIRED';
export const FORBIDDEN = 'FORBIDDEN';
export function dispatchAndThrow(dispatch, type) {
return error => {
if (error.statusCode === 401) {
dispatch({ type: AUTH_REQUIRED, error, receivedAt: Date.now() });
} else if (error.statusCode === 403) {
dispatch({ type: FORBIDDEN, error, receivedAt: Date.now() });
} else {
dispatch({ type, error, receivedAt: Date.now() });
}
};
}