1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-09 13:47:13 +02:00

refactor: port login auth to TS/SWR (#680)

* refactor: allow existing tsc errors

* refactor: add missing component key

* refactor: port login auth to TS/SWR

* refactor: replace incorrect CREATE_TAG_TYPE with UPDATE_TAG_TYPE

* refactor: fix AccessProvider permission mocks

* refactor: add types to AccessContext

* refactor: fix file extension

* refactor: remove default export

* refactor: remove unused IAddedUser interface

* refactor: comment on the permissions prop

* refactor: split auth hooks

* feat: auth tests

* fix: setup separate e2e tests

* fix: naming

* fix: lint

* fix: spec path

* fix: missing store

* feat: add more tests

Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com>
This commit is contained in:
olav 2022-02-10 17:04:10 +01:00 committed by GitHub
parent 608c82870b
commit 213e8950d3
62 changed files with 839 additions and 652 deletions

1
frontend/.env Normal file
View File

@ -0,0 +1 @@
TSC_COMPILE_ON_ERROR=true

View File

@ -1,4 +1,4 @@
name: e2e-tests name: e2e:auth
# https://docs.github.com/en/actions/reference/events-that-trigger-workflows # https://docs.github.com/en/actions/reference/events-that-trigger-workflows
on: [deployment_status] on: [deployment_status]
jobs: jobs:
@ -20,5 +20,6 @@ jobs:
env: AUTH_TOKEN=${{ secrets.UNLEASH_TOKEN }},DEFAULT_ENV="development" env: AUTH_TOKEN=${{ secrets.UNLEASH_TOKEN }},DEFAULT_ENV="development"
config: baseUrl=${{ github.event.deployment_status.target_url }} config: baseUrl=${{ github.event.deployment_status.target_url }}
record: true record: true
spec: cypress/integration/auth/auth.spec.js
env: env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

View File

@ -0,0 +1,25 @@
name: e2e:feature
# https://docs.github.com/en/actions/reference/events-that-trigger-workflows
on: [deployment_status]
jobs:
e2e:
# only runs this job on successful deploy
if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
steps:
- name: Dump GitHub context
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: |
echo "$GITHUB_CONTEXT"
- name: Checkout
uses: actions/checkout@v2
- name: Run Cypress
uses: cypress-io/github-action@v2
with:
env: AUTH_TOKEN=${{ secrets.UNLEASH_TOKEN }},DEFAULT_ENV="development"
config: baseUrl=${{ github.event.deployment_status.target_url }}
record: true
spec: cypress/integration/feature-toggle/feature.spec.js
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

View File

@ -0,0 +1,204 @@
/* eslint-disable jest/no-conditional-expect */
/// <reference types="cypress" />
// Welcome to Cypress!
//
// This spec file contains a variety of sample tests
// for a todo list app that are designed to demonstrate
// the power of writing tests in Cypress.
//
// To learn more about how Cypress works and
// what makes it such an awesome testing tool,
// please read our getting started guide:
// https://on.cypress.io/introduction-to-cypress
const username = 'test@test.com';
const password = 'qY70$NDcJNXA';
describe('auth', () => {
it('renders the password login', () => {
cy.intercept('GET', '/api/admin/user', {
statusCode: 401,
body: {
defaultHidden: false,
message: 'You must sign in in order to use Unleash',
options: [],
path: '/auth/simple/login',
type: 'password',
},
});
cy.visit('/');
cy.intercept('POST', '/auth/simple/login', req => {
expect(req.body.username).to.equal(username);
expect(req.body.password).to.equal(password);
}).as('passwordLogin');
cy.get('[data-test="LOGIN_EMAIL_ID"]').type(username);
cy.get('[data-test="LOGIN_PASSWORD_ID"]').type(password);
cy.get("[data-test='LOGIN_BUTTON']").click();
});
it('renders does not render password login if defaultHidden is true', () => {
cy.intercept('GET', '/api/admin/user', {
statusCode: 401,
body: {
defaultHidden: true,
message: 'You must sign in in order to use Unleash',
options: [],
path: '/auth/simple/login',
type: 'password',
},
});
cy.visit('/');
cy.get('[data-test="LOGIN_EMAIL_ID"]').should('not.exist');
cy.get('[data-test="LOGIN_PASSWORD_ID"]').should('not.exist');
});
it('renders google auth when options are specified', () => {
const ssoPath = '/auth/google/login';
cy.intercept('GET', '/api/admin/user', {
statusCode: 401,
body: {
defaultHidden: true,
message: 'You must sign in in order to use Unleash',
options: [
{
type: 'google',
message: 'Sign in with Google',
path: ssoPath,
},
],
path: '/auth/simple/login',
type: 'password',
},
});
cy.visit('/');
cy.get('[data-test="LOGIN_EMAIL_ID"]').should('not.exist');
cy.get('[data-test="LOGIN_PASSWORD_ID"]').should('not.exist');
cy.get('[data-test="SSO_LOGIN_BUTTON-google"]')
.should('exist')
.should('have.attr', 'href', ssoPath);
});
it('renders oidc auth when options are specified', () => {
const ssoPath = '/auth/oidc/login';
cy.intercept('GET', '/api/admin/user', {
statusCode: 401,
body: {
defaultHidden: true,
message: 'You must sign in in order to use Unleash',
options: [
{
type: 'oidc',
message: 'Sign in with OpenId Connect',
path: ssoPath,
},
],
path: '/auth/simple/login',
type: 'password',
},
});
cy.visit('/');
cy.get('[data-test="LOGIN_EMAIL_ID"]').should('not.exist');
cy.get('[data-test="LOGIN_PASSWORD_ID"]').should('not.exist');
cy.get('[data-test="SSO_LOGIN_BUTTON-oidc"]')
.should('exist')
.should('have.attr', 'href', ssoPath);
});
it('renders saml auth when options are specified', () => {
const ssoPath = '/auth/saml/login';
cy.intercept('GET', '/api/admin/user', {
statusCode: 401,
body: {
defaultHidden: true,
message: 'You must sign in in order to use Unleash',
options: [
{
type: 'saml',
message: 'Sign in with SAML 2.0',
path: ssoPath,
},
],
path: '/auth/simple/login',
type: 'password',
},
});
cy.visit('/');
cy.get('[data-test="LOGIN_EMAIL_ID"]').should('not.exist');
cy.get('[data-test="LOGIN_PASSWORD_ID"]').should('not.exist');
cy.get('[data-test="SSO_LOGIN_BUTTON-saml"]')
.should('exist')
.should('have.attr', 'href', ssoPath);
});
it('can visit forgot password when password auth is enabled', () => {
cy.intercept('GET', '/api/admin/user', {
statusCode: 401,
body: {
defaultHidden: false,
message: 'You must sign in in order to use Unleash',
options: [],
path: '/auth/simple/login',
type: 'password',
},
});
cy.visit('/forgotten-password');
cy.get('[data-test="FORGOTTEN_PASSWORD_FIELD"').type('me@myemail.com');
});
it('renders demo auth correctly', () => {
const email = 'hello@hello.com';
cy.intercept('GET', '/api/admin/user', {
statusCode: 401,
body: {
defaultHidden: false,
message: 'You must sign in in order to use Unleash',
options: [],
path: '/auth/demo/login',
type: 'demo',
},
});
cy.intercept('POST', '/auth/demo/login', req => {
expect(req.body.email).to.equal(email);
}).as('passwordLogin');
cy.visit('/');
cy.get('[data-test="LOGIN_EMAIL_ID"]').type(email);
cy.get("[data-test='LOGIN_BUTTON']").click();
});
it('renders email auth correctly', () => {
const email = 'hello@hello.com';
cy.intercept('GET', '/api/admin/user', {
statusCode: 401,
body: {
defaultHidden: false,
message: 'You must sign in in order to use Unleash',
options: [],
path: '/auth/unsecure/login',
type: 'unsecure',
},
});
cy.intercept('POST', '/auth/unsecure/login', req => {
expect(req.body.email).to.equal(email);
}).as('passwordLogin');
cy.visit('/');
cy.get('[data-test="LOGIN_EMAIL_ID"]').type(email);
cy.get("[data-test='LOGIN_BUTTON']").click();
});
});

View File

@ -1,12 +1,11 @@
import { Map as $MAp } from 'immutable'; import { Map as $MAp } from 'immutable';
export const createFakeStore = (permissions) => { export const createFakeStore = permissions => {
return { return {
getState: () => ({ getState: () => ({
user: user: new $MAp({
new $MAp({ permissions,
permissions }),
})
}), }),
} };
} };

View File

@ -1,57 +1,37 @@
import { connect } from 'react-redux';
import { Redirect, Route, Switch } from 'react-router-dom';
import { RouteComponentProps } from 'react-router';
import ProtectedRoute from './common/ProtectedRoute/ProtectedRoute';
import LayoutPicker from './layout/LayoutPicker/LayoutPicker';
import { routes } from './menu/routes';
import styles from './styles.module.scss';
import IAuthStatus from '../interfaces/user';
import { useState, useEffect } from 'react';
import NotFound from './common/NotFound/NotFound';
import Feedback from './common/Feedback/Feedback';
import SWRProvider from './providers/SWRProvider/SWRProvider';
import ConditionallyRender from './common/ConditionallyRender'; import ConditionallyRender from './common/ConditionallyRender';
import EnvironmentSplash from './common/EnvironmentSplash/EnvironmentSplash'; import EnvironmentSplash from './common/EnvironmentSplash/EnvironmentSplash';
import Feedback from './common/Feedback/Feedback';
import LayoutPicker from './layout/LayoutPicker/LayoutPicker';
import Loader from './common/Loader/Loader'; import Loader from './common/Loader/Loader';
import useUser from '../hooks/api/getters/useUser/useUser'; import NotFound from './common/NotFound/NotFound';
import ProtectedRoute from './common/ProtectedRoute/ProtectedRoute';
import SWRProvider from './providers/SWRProvider/SWRProvider';
import ToastRenderer from './common/ToastRenderer/ToastRenderer'; import ToastRenderer from './common/ToastRenderer/ToastRenderer';
import styles from './styles.module.scss';
import { Redirect, Route, Switch } from 'react-router-dom';
import { RouteComponentProps } from 'react-router';
import { routes } from './menu/routes';
import { useEffect } from 'react';
import { useAuthDetails } from '../hooks/api/getters/useAuth/useAuthDetails';
import { useAuthUser } from '../hooks/api/getters/useAuth/useAuthUser';
import { useAuthSplash } from '../hooks/api/getters/useAuth/useAuthSplash';
interface IAppProps extends RouteComponentProps { interface IAppProps extends RouteComponentProps {
user: IAuthStatus; fetchUiBootstrap: () => void;
fetchUiBootstrap: any;
} }
const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
// because we need the userId when the component load.
const { splash, user: userFromUseUser, authDetails } = useUser();
const [showSplash, setShowSplash] = useState(false); export const App = ({ fetchUiBootstrap }: IAppProps) => {
const [showLoader, setShowLoader] = useState(false); const { splash, refetchSplash } = useAuthSplash();
const { authDetails } = useAuthDetails();
const { user } = useAuthUser();
const isLoggedIn = Boolean(user?.id);
const hasFetchedAuth = Boolean(authDetails || user);
const showEnvSplash = isLoggedIn && splash?.environment === false;
useEffect(() => { useEffect(() => {
fetchUiBootstrap(); fetchUiBootstrap();
/* eslint-disable-next-line */ }, [fetchUiBootstrap, authDetails?.type]);
}, [user.authDetails?.type]);
useEffect(() => {
// Temporary duality until redux store is removed
if (!isUnauthorized() && !userFromUseUser?.id && !authDetails) {
setShowLoader(true);
return;
}
setShowLoader(false);
/* eslint-disable-next-line */
}, [user.authDetails, userFromUseUser.id]);
useEffect(() => {
if (splash?.environment === undefined) return;
if (!splash?.environment && !isUnauthorized()) {
setShowSplash(true);
}
/* eslint-disable-next-line */
}, [splash.environment]);
const renderMainLayoutRoutes = () => { const renderMainLayoutRoutes = () => {
return routes.filter(route => route.layout === 'main').map(renderRoute); return routes.filter(route => route.layout === 'main').map(renderRoute);
@ -63,10 +43,8 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
.map(renderRoute); .map(renderRoute);
}; };
const isUnauthorized = () => { const isUnauthorized = (): boolean => {
// authDetails only exists if the user is not logged in. return !isLoggedIn;
//if (user?.permissions.length === 0) return true;
return user?.authDetails !== undefined;
}; };
// Change this to IRoute once snags with HashRouter and TS is worked out // Change this to IRoute once snags with HashRouter and TS is worked out
@ -91,7 +69,7 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
<route.component <route.component
{...props} {...props}
isUnauthorized={isUnauthorized} isUnauthorized={isUnauthorized}
authDetails={user.authDetails} authDetails={authDetails}
/> />
)} )}
/> />
@ -99,21 +77,18 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
}; };
return ( return (
<SWRProvider <SWRProvider isUnauthorized={isUnauthorized}>
isUnauthorized={isUnauthorized}
setShowLoader={setShowLoader}
>
<ConditionallyRender <ConditionallyRender
condition={showLoader} condition={!hasFetchedAuth}
show={<Loader />} show={<Loader />}
elseShow={ elseShow={
<div className={styles.container}> <div className={styles.container}>
<ToastRenderer /> <ToastRenderer />
<ConditionallyRender <ConditionallyRender
condition={showSplash} condition={showEnvSplash}
show={ show={
<EnvironmentSplash onFinish={setShowSplash} /> <EnvironmentSplash onFinish={refetchSplash} />
} }
elseShow={ elseShow={
<LayoutPicker location={location}> <LayoutPicker location={location}>
@ -133,9 +108,7 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
/> />
<Redirect to="/404" /> <Redirect to="/404" />
</Switch> </Switch>
<Feedback <Feedback openUrl="http://feedback.unleash.run" />
openUrl="http://feedback.unleash.run"
/>
</LayoutPicker> </LayoutPicker>
} }
/> />
@ -145,10 +118,3 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
</SWRProvider> </SWRProvider>
); );
}; };
// Set state to any for now, to avoid typing up entire state object while converting to tsx.
const mapStateToProps = (state: any) => ({
user: state.user.toJS(),
});
export default connect(mapStateToProps)(App);

View File

@ -1,14 +1,5 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import App from './App'; import { App } from './App';
import { fetchUiBootstrap } from '../store/ui-bootstrap/actions'; import { fetchUiBootstrap } from '../store/ui-bootstrap/actions';
const mapDispatchToProps = { export default connect(null, { fetchUiBootstrap })(App);
fetchUiBootstrap,
};
const mapStateToProps = (state: any) => ({
user: state.user.toJS(),
});
export default connect(mapStateToProps, mapDispatchToProps)(App);

View File

@ -1,17 +1,17 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ApiTokenList from '../api-token/ApiTokenList/ApiTokenList'; import ApiTokenList from '../api-token/ApiTokenList/ApiTokenList';
import AdminMenu from '../menu/AdminMenu'; import AdminMenu from '../menu/AdminMenu';
import usePermissions from '../../../hooks/usePermissions';
import ConditionallyRender from '../../common/ConditionallyRender'; import ConditionallyRender from '../../common/ConditionallyRender';
import AccessContext from '../../../contexts/AccessContext';
import { useContext } from 'react';
const ApiPage = ({ history }) => { const ApiPage = ({ history }) => {
const { isAdmin } = usePermissions(); const { isAdmin } = useContext(AccessContext);
return ( return (
<div> <div>
<ConditionallyRender <ConditionallyRender
condition={isAdmin()} condition={isAdmin}
show={<AdminMenu history={history} />} show={<AdminMenu history={history} />}
/> />
<ApiTokenList /> <ApiTokenList />

View File

@ -1,11 +1,12 @@
import Breadcrumbs from '@material-ui/core/Breadcrumbs'; import Breadcrumbs from '@material-ui/core/Breadcrumbs';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import usePermissions from '../../../hooks/usePermissions';
import ConditionallyRender from '../ConditionallyRender'; import ConditionallyRender from '../ConditionallyRender';
import { useStyles } from './BreadcrumbNav.styles'; import { useStyles } from './BreadcrumbNav.styles';
import AccessContext from '../../../contexts/AccessContext';
import { useContext } from 'react';
const BreadcrumbNav = () => { const BreadcrumbNav = () => {
const { isAdmin } = usePermissions(); const { isAdmin } = useContext(AccessContext);
const styles = useStyles(); const styles = useStyles();
const location = useLocation(); const location = useLocation();
@ -32,7 +33,7 @@ const BreadcrumbNav = () => {
return ( return (
<ConditionallyRender <ConditionallyRender
condition={ condition={
(location.pathname.includes('admin') && isAdmin()) || (location.pathname.includes('admin') && isAdmin) ||
!location.pathname.includes('admin') !location.pathname.includes('admin')
} }
show={ show={

View File

@ -2,7 +2,6 @@ import { useContext, useState } from 'react';
import { Button, IconButton } from '@material-ui/core'; import { Button, IconButton } from '@material-ui/core';
import classnames from 'classnames'; import classnames from 'classnames';
import CloseIcon from '@material-ui/icons/Close'; import CloseIcon from '@material-ui/icons/Close';
import { ReactComponent as Logo } from '../../../assets/icons/logo-plain.svg'; import { ReactComponent as Logo } from '../../../assets/icons/logo-plain.svg';
import { useCommonStyles } from '../../../common.styles'; import { useCommonStyles } from '../../../common.styles';
import { useStyles } from './Feedback.styles'; import { useStyles } from './Feedback.styles';
@ -10,8 +9,8 @@ import AnimateOnMount from '../AnimateOnMount/AnimateOnMount';
import ConditionallyRender from '../ConditionallyRender'; import ConditionallyRender from '../ConditionallyRender';
import { formatApiPath } from '../../../utils/format-path'; import { formatApiPath } from '../../../utils/format-path';
import UIContext from '../../../contexts/UIContext'; import UIContext from '../../../contexts/UIContext';
import useUser from '../../../hooks/api/getters/useUser/useUser';
import { PNPS_FEEDBACK_ID, showPnpsFeedback } from '../util'; import { PNPS_FEEDBACK_ID, showPnpsFeedback } from '../util';
import { useAuthFeedback } from '../../../hooks/api/getters/useAuth/useAuthFeedback';
interface IFeedbackProps { interface IFeedbackProps {
openUrl: string; openUrl: string;
@ -19,7 +18,7 @@ interface IFeedbackProps {
const Feedback = ({ openUrl }: IFeedbackProps) => { const Feedback = ({ openUrl }: IFeedbackProps) => {
const { showFeedback, setShowFeedback } = useContext(UIContext); const { showFeedback, setShowFeedback } = useContext(UIContext);
const { refetch, feedback } = useUser(); const { feedback, refetchFeedback } = useAuthFeedback();
const [answeredNotNow, setAnsweredNotNow] = useState(false); const [answeredNotNow, setAnsweredNotNow] = useState(false);
const styles = useStyles(); const styles = useStyles();
const commonStyles = useCommonStyles(); const commonStyles = useCommonStyles();
@ -37,7 +36,7 @@ const Feedback = ({ openUrl }: IFeedbackProps) => {
}, },
body: JSON.stringify({ feedbackId }), body: JSON.stringify({ feedbackId }),
}); });
await refetch(); await refetchFeedback();
} catch (err) { } catch (err) {
console.warn(err); console.warn(err);
setShowFeedback(false); setShowFeedback(false);
@ -65,7 +64,7 @@ const Feedback = ({ openUrl }: IFeedbackProps) => {
}, },
body: JSON.stringify({ feedbackId, neverShow: true }), body: JSON.stringify({ feedbackId, neverShow: true }),
}); });
await refetch(); await refetchFeedback();
} catch (err) { } catch (err) {
console.warn(err); console.warn(err);
setShowFeedback(false); setShowFeedback(false);

View File

@ -64,7 +64,7 @@ const Splash: React.FC<ISplashProps> = ({
); );
} }
return <FiberManualRecordOutlined />; return <FiberManualRecordOutlined key={index} />;
}); });
}; };

View File

@ -4,7 +4,6 @@ import FeatureTypeSelect from '../FeatureView/FeatureSettings/FeatureSettingsMet
import { CF_DESC_ID, CF_NAME_ID, CF_TYPE_ID } from '../../../testIds'; import { CF_DESC_ID, CF_NAME_ID, CF_TYPE_ID } from '../../../testIds';
import useFeatureTypes from '../../../hooks/api/getters/useFeatureTypes/useFeatureTypes'; import useFeatureTypes from '../../../hooks/api/getters/useFeatureTypes/useFeatureTypes';
import { KeyboardArrowDownOutlined } from '@material-ui/icons'; import { KeyboardArrowDownOutlined } from '@material-ui/icons';
import useUser from '../../../hooks/api/getters/useUser/useUser';
import { projectFilterGenerator } from '../../../utils/project-filter-generator'; import { projectFilterGenerator } from '../../../utils/project-filter-generator';
import FeatureProjectSelect from '../FeatureView/FeatureSettings/FeatureSettingsProject/FeatureProjectSelect/FeatureProjectSelect'; import FeatureProjectSelect from '../FeatureView/FeatureSettings/FeatureSettingsProject/FeatureProjectSelect/FeatureProjectSelect';
import ConditionallyRender from '../../common/ConditionallyRender'; import ConditionallyRender from '../../common/ConditionallyRender';
@ -12,6 +11,8 @@ import { trim } from '../../common/util';
import Input from '../../common/Input/Input'; import Input from '../../common/Input/Input';
import { CREATE_FEATURE } from '../../providers/AccessProvider/permissions'; import { CREATE_FEATURE } from '../../providers/AccessProvider/permissions';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import React from 'react';
import { useAuthPermissions } from '../../../hooks/api/getters/useAuth/useAuthPermissions';
interface IFeatureToggleForm { interface IFeatureToggleForm {
type: string; type: string;
@ -54,7 +55,7 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
const styles = useStyles(); const styles = useStyles();
const { featureTypes } = useFeatureTypes(); const { featureTypes } = useFeatureTypes();
const history = useHistory(); const history = useHistory();
const { permissions } = useUser(); const { permissions } = useAuthPermissions()
const editable = mode !== 'Edit'; const editable = mode !== 'Edit';
const renderToggleDescription = () => { const renderToggleDescription = () => {
@ -114,7 +115,7 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
}} }}
enabled={editable} enabled={editable}
filter={projectFilterGenerator( filter={projectFilterGenerator(
{ permissions }, permissions,
CREATE_FEATURE CREATE_FEATURE
)} )}
IconComponent={KeyboardArrowDownOutlined} IconComponent={KeyboardArrowDownOutlined}

View File

@ -1,15 +1,10 @@
import React from 'react'; import React from 'react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from '@material-ui/core'; import { ThemeProvider } from '@material-ui/core';
import FeatureToggleList from '../FeatureToggleList'; import FeatureToggleList from '../FeatureToggleList';
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
import theme from '../../../../themes/main-theme'; import theme from '../../../../themes/main-theme';
import { createFakeStore } from '../../../../accessStoreFake'; import { CREATE_FEATURE } from '../../../providers/AccessProvider/permissions';
import {
ADMIN,
CREATE_FEATURE,
} from '../../../providers/AccessProvider/permissions';
import AccessProvider from '../../../providers/AccessProvider/AccessProvider'; import AccessProvider from '../../../providers/AccessProvider/AccessProvider';
jest.mock('../FeatureToggleListItem', () => ({ jest.mock('../FeatureToggleListItem', () => ({
@ -29,9 +24,7 @@ test('renders correctly with one feature', () => {
const tree = renderer.create( const tree = renderer.create(
<MemoryRouter> <MemoryRouter>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<AccessProvider <AccessProvider permissions={[{ permission: CREATE_FEATURE }]}>
store={createFakeStore([{ permission: CREATE_FEATURE }])}
>
<FeatureToggleList <FeatureToggleList
updateSetting={jest.fn()} updateSetting={jest.fn()}
filter={{}} filter={{}}
@ -59,9 +52,7 @@ test('renders correctly with one feature without permissions', () => {
const tree = renderer.create( const tree = renderer.create(
<MemoryRouter> <MemoryRouter>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<AccessProvider <AccessProvider permissions={[{ permission: CREATE_FEATURE }]}>
store={createFakeStore([{ permission: CREATE_FEATURE }])}
>
<FeatureToggleList <FeatureToggleList
filter={{}} filter={{}}
setFilter={jest.fn()} setFilter={jest.fn()}

View File

@ -3,7 +3,6 @@ import { useHistory, useParams } from 'react-router';
import AccessContext from '../../../../../contexts/AccessContext'; import AccessContext from '../../../../../contexts/AccessContext';
import useFeatureApi from '../../../../../hooks/api/actions/useFeatureApi/useFeatureApi'; import useFeatureApi from '../../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import useFeature from '../../../../../hooks/api/getters/useFeature/useFeature'; import useFeature from '../../../../../hooks/api/getters/useFeature/useFeature';
import useUser from '../../../../../hooks/api/getters/useUser/useUser';
import useToast from '../../../../../hooks/useToast'; import useToast from '../../../../../hooks/useToast';
import { IFeatureViewParams } from '../../../../../interfaces/params'; import { IFeatureViewParams } from '../../../../../interfaces/params';
import { MOVE_FEATURE_TOGGLE } from '../../../../providers/AccessProvider/permissions'; import { MOVE_FEATURE_TOGGLE } from '../../../../providers/AccessProvider/permissions';
@ -12,6 +11,7 @@ import PermissionButton from '../../../../common/PermissionButton/PermissionButt
import FeatureProjectSelect from './FeatureProjectSelect/FeatureProjectSelect'; import FeatureProjectSelect from './FeatureProjectSelect/FeatureProjectSelect';
import FeatureSettingsProjectConfirm from './FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm'; import FeatureSettingsProjectConfirm from './FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm';
import { IPermission } from '../../../../../interfaces/user'; import { IPermission } from '../../../../../interfaces/user';
import { useAuthPermissions } from '../../../../../hooks/api/getters/useAuth/useAuthPermissions';
const FeatureSettingsProject = () => { const FeatureSettingsProject = () => {
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
@ -21,11 +21,12 @@ const FeatureSettingsProject = () => {
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const editable = hasAccess(MOVE_FEATURE_TOGGLE, projectId); const editable = hasAccess(MOVE_FEATURE_TOGGLE, projectId);
const { permissions } = useUser(); const { permissions = [] } = useAuthPermissions()
const { changeFeatureProject } = useFeatureApi(); const { changeFeatureProject } = useFeatureApi();
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const history = useHistory(); const history = useHistory();
useEffect(() => { useEffect(() => {
if (project !== feature.project) { if (project !== feature.project) {
setDirty(true); setDirty(true);
@ -43,7 +44,7 @@ const FeatureSettingsProject = () => {
setProject(projectId); setProject(projectId);
} }
/* eslint-disable-next-line */ /* eslint-disable-next-line */
}, [permissions?.length]); }, [permissions.length]);
const updateProject = async () => { const updateProject = async () => {
const newProject = project; const newProject = project;

View File

@ -15,19 +15,19 @@ import { useStyles } from './Header.styles';
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
import { useCommonStyles } from '../../../common.styles'; import { useCommonStyles } from '../../../common.styles';
import { ADMIN } from '../../providers/AccessProvider/permissions'; import { ADMIN } from '../../providers/AccessProvider/permissions';
import useUser from '../../../hooks/api/getters/useUser/useUser';
import { IPermission } from '../../../interfaces/user'; import { IPermission } from '../../../interfaces/user';
import NavigationMenu from './NavigationMenu/NavigationMenu'; import NavigationMenu from './NavigationMenu/NavigationMenu';
import { getRoutes } from '../routes'; import { getRoutes } from '../routes';
import { KeyboardArrowDown } from '@material-ui/icons'; import { KeyboardArrowDown } from '@material-ui/icons';
import { filterByFlags } from '../../common/util'; import { filterByFlags } from '../../common/util';
import { useAuthPermissions } from '../../../hooks/api/getters/useAuth/useAuthPermissions';
const Header = () => { const Header = () => {
const theme = useTheme(); const theme = useTheme();
const [anchorEl, setAnchorEl] = useState(); const [anchorEl, setAnchorEl] = useState();
const [anchorElAdvanced, setAnchorElAdvanced] = useState(); const [anchorElAdvanced, setAnchorElAdvanced] = useState();
const [admin, setAdmin] = useState(false); const [admin, setAdmin] = useState(false);
const { permissions } = useUser(); const { permissions } = useAuthPermissions()
const commonStyles = useCommonStyles(); const commonStyles = useCommonStyles();
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const smallScreen = useMediaQuery(theme.breakpoints.down('sm')); const smallScreen = useMediaQuery(theme.breakpoints.down('sm'));
@ -39,7 +39,7 @@ const Header = () => {
const handleCloseAdvanced = () => setAnchorElAdvanced(null); const handleCloseAdvanced = () => setAnchorElAdvanced(null);
useEffect(() => { useEffect(() => {
const admin = permissions.find( const admin = permissions?.find(
(element: IPermission) => element.permission === ADMIN (element: IPermission) => element.permission === ADMIN
); );

View File

@ -15,7 +15,7 @@ import AdminUsers from '../admin/users/UsersAdmin';
import { AuthSettings } from '../admin/auth/AuthSettings'; import { AuthSettings } from '../admin/auth/AuthSettings';
import Login from '../user/Login/Login'; import Login from '../user/Login/Login';
import { P, C, E, EEA, RE } from '../common/flags'; import { P, C, E, EEA, RE } from '../common/flags';
import NewUser from '../user/NewUser'; import { NewUser } from '../user/NewUser/NewUser';
import ResetPassword from '../user/ResetPassword/ResetPassword'; import ResetPassword from '../user/ResetPassword/ResetPassword';
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword'; import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
import ProjectListNew from '../project/ProjectList/ProjectList'; import ProjectListNew from '../project/ProjectList/ProjectList';

View File

@ -5,13 +5,13 @@ import ProjectForm from '../ProjectForm/ProjectForm';
import useProjectForm from '../hooks/useProjectForm'; import useProjectForm from '../hooks/useProjectForm';
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
import useToast from '../../../../hooks/useToast'; import useToast from '../../../../hooks/useToast';
import useUser from '../../../../hooks/api/getters/useUser/useUser';
import PermissionButton from '../../../common/PermissionButton/PermissionButton'; import PermissionButton from '../../../common/PermissionButton/PermissionButton';
import { CREATE_PROJECT } from '../../../providers/AccessProvider/permissions'; import { CREATE_PROJECT } from '../../../providers/AccessProvider/permissions';
import { useAuthUser } from '../../../../hooks/api/getters/useAuth/useAuthUser';
const CreateProject = () => { const CreateProject = () => {
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const { refetch } = useUser(); const { refetchUser } = useAuthUser();
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const history = useHistory(); const history = useHistory();
const { const {
@ -40,7 +40,7 @@ const CreateProject = () => {
const payload = getProjectPayload(); const payload = getProjectPayload();
try { try {
await createProject(payload); await createProject(payload);
refetch(); refetchUser();
history.push(`/projects/${projectId}`); history.push(`/projects/${projectId}`);
setToastData({ setToastData({
title: 'Project created', title: 'Project created',

View File

@ -1,88 +1,111 @@
import { FC } from 'react'; import { ReactElement, ReactNode, useCallback, useMemo } from 'react';
import AccessContext, { IAccessContext } from '../../../contexts/AccessContext';
import AccessContext from '../../../contexts/AccessContext';
import useUser from '../../../hooks/api/getters/useUser/useUser';
import { ADMIN } from './permissions'; import { ADMIN } from './permissions';
import { IPermission } from '../../../interfaces/user';
import { useAuthPermissions } from '../../../hooks/api/getters/useAuth/useAuthPermissions';
// TODO: Type up redux store interface IAccessProviderProps {
interface IAccessProvider { children: ReactNode;
store: any; permissions: IPermission[];
} }
interface IPermission { // TODO(olav): Mock useAuth instead of using props.permissions in tests.
permission: string; const AccessProvider = (props: IAccessProviderProps): ReactElement => {
project?: string | null; const auth = useAuthPermissions();
environment: string | null; const permissions = props.permissions ?? auth.permissions;
}
const AccessProvider: FC<IAccessProvider> = ({ store, children }) => { const isAdmin: boolean = useMemo(() => {
const { permissions } = useUser(); return checkAdmin(permissions);
const isAdminHigherOrder = () => { }, [permissions]);
let called = false;
let result = false;
return () => { const hasAccess = useCallback(
if (called) return result; (permission: string, project?: string, environment?: string) => {
const permissions = store.getState().user.get('permissions') || []; return checkPermissions(
result = permissions.some( permissions,
(p: IPermission) => p.permission === ADMIN permission,
project,
environment
); );
},
[permissions]
);
if (permissions.length > 0) { const value: IAccessContext = useMemo(
called = true; () => ({
} isAdmin,
}; hasAccess,
}; }),
[isAdmin, hasAccess]
const isAdmin = isAdminHigherOrder(); );
const hasAccess = (
permission: string,
project: string,
environment?: string
) => {
const result = permissions.some((p: IPermission) => {
if (p.permission === ADMIN) {
return true;
}
if (
p.permission === permission &&
(p.project === project || p.project === '*') &&
(p.environment === environment || p.environment === '*')
) {
return true;
}
if (
p.permission === permission &&
(p.project === project || p.project === '*') &&
p.environment === null
) {
return true;
}
if (
p.permission === permission &&
p.project === undefined &&
p.environment === null
) {
return true;
}
return false;
});
return result;
};
const context = { hasAccess, isAdmin };
return ( return (
<AccessContext.Provider value={context}> <AccessContext.Provider value={value}>
{children} {props.children}
</AccessContext.Provider> </AccessContext.Provider>
); );
}; };
const checkAdmin = (permissions: IPermission[] | undefined): boolean => {
if (!permissions) {
return false;
}
return permissions.some(p => {
return p.permission === ADMIN;
});
};
const checkPermissions = (
permissions: IPermission[] | undefined,
permission: string,
project?: string,
environment?: string
): boolean => {
if (!permissions) {
return false;
}
return permissions.some(p => {
return checkPermission(p, permission, project, environment);
});
};
const checkPermission = (
p: IPermission,
permission: string,
project?: string,
environment?: string
): boolean => {
if (!permission) {
console.warn(`Missing permission for AccessProvider: ${permission}`)
return false
}
if (p.permission === ADMIN) {
return true;
}
if (
p.permission === permission &&
(p.project === project || p.project === '*') &&
(p.environment === environment || p.environment === '*')
) {
return true;
}
if (
p.permission === permission &&
(p.project === project || p.project === '*') &&
p.environment === null
) {
return true;
}
return (
p.permission === permission &&
p.project === undefined &&
p.environment === null
);
};
export default AccessProvider; export default AccessProvider;

View File

@ -1,11 +1,11 @@
import { USER_CACHE_KEY } from '../../../hooks/api/getters/useUser/useUser';
import { mutate, SWRConfig, useSWRConfig } from 'swr'; import { mutate, SWRConfig, useSWRConfig } from 'swr';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import useToast from '../../../hooks/useToast'; import useToast from '../../../hooks/useToast';
import { formatApiPath } from '../../../utils/format-path'; import { formatApiPath } from '../../../utils/format-path';
import React from 'react';
import { USER_ENDPOINT_PATH } from '../../../hooks/api/getters/useAuth/useAuthEndpoint';
interface ISWRProviderProps { interface ISWRProviderProps {
setShowLoader: React.Dispatch<React.SetStateAction<boolean>>;
isUnauthorized: () => boolean; isUnauthorized: () => boolean;
} }
@ -14,20 +14,18 @@ const INVALID_TOKEN_ERROR = 'InvalidTokenError';
const SWRProvider: React.FC<ISWRProviderProps> = ({ const SWRProvider: React.FC<ISWRProviderProps> = ({
children, children,
isUnauthorized, isUnauthorized,
setShowLoader,
}) => { }) => {
const { cache } = useSWRConfig(); const { cache } = useSWRConfig();
const history = useHistory(); const history = useHistory();
const { setToastApiError } = useToast(); const { setToastApiError } = useToast();
const handleFetchError = error => { const handleFetchError = error => {
setShowLoader(false);
if (error.status === 401) { if (error.status === 401) {
const path = location.pathname; const path = location.pathname;
// Only populate user with authDetails if 401 and // Only populate user with authDetails if 401 and
// error is not invalid token // error is not invalid token
if (error?.info?.name !== INVALID_TOKEN_ERROR) { if (error?.info?.name !== INVALID_TOKEN_ERROR) {
mutate(USER_CACHE_KEY, { ...error.info }, false); mutate(USER_ENDPOINT_PATH, { ...error.info }, false);
} }
if ( if (

View File

@ -1,13 +1,10 @@
import React from 'react'; import React from 'react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from '@material-ui/core'; import { ThemeProvider } from '@material-ui/core';
import StrategiesListComponent from '../StrategiesList/StrategiesList'; import StrategiesListComponent from '../StrategiesList/StrategiesList';
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
import theme from '../../../themes/main-theme'; import theme from '../../../themes/main-theme';
import AccessProvider from '../../providers/AccessProvider/AccessProvider'; import AccessProvider from '../../providers/AccessProvider/AccessProvider';
import { createFakeStore } from '../../../accessStoreFake';
import { ADMIN } from '../../providers/AccessProvider/permissions';
test('renders correctly with one strategy', () => { test('renders correctly with one strategy', () => {
const strategy = { const strategy = {
@ -17,7 +14,7 @@ test('renders correctly with one strategy', () => {
const tree = renderer.create( const tree = renderer.create(
<MemoryRouter> <MemoryRouter>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<AccessProvider store={createFakeStore()}> <AccessProvider>
<StrategiesListComponent <StrategiesListComponent
strategies={[strategy]} strategies={[strategy]}
fetchStrategies={jest.fn()} fetchStrategies={jest.fn()}
@ -42,9 +39,7 @@ test('renders correctly with one strategy without permissions', () => {
const tree = renderer.create( const tree = renderer.create(
<MemoryRouter> <MemoryRouter>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<AccessProvider <AccessProvider>
store={createFakeStore([{ permission: ADMIN }])}
>
<StrategiesListComponent <StrategiesListComponent
strategies={[strategy]} strategies={[strategy]}
fetchStrategies={jest.fn()} fetchStrategies={jest.fn()}

View File

@ -4,7 +4,6 @@ import StrategyDetails from '../strategy-details-component';
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import theme from '../../../themes/main-theme'; import theme from '../../../themes/main-theme';
import { createFakeStore } from '../../../accessStoreFake';
import AccessProvider from '../../providers/AccessProvider/AccessProvider'; import AccessProvider from '../../providers/AccessProvider/AccessProvider';
test('renders correctly with one strategy', () => { test('renders correctly with one strategy', () => {
@ -35,7 +34,7 @@ test('renders correctly with one strategy', () => {
]; ];
const tree = renderer.create( const tree = renderer.create(
<MemoryRouter> <MemoryRouter>
<AccessProvider store={createFakeStore()}> <AccessProvider>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<StrategyDetails <StrategyDetails
strategyName={'Another'} strategyName={'Another'}

View File

@ -4,13 +4,11 @@ import renderer from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from '@material-ui/styles'; import { ThemeProvider } from '@material-ui/styles';
import theme from '../../../../themes/main-theme'; import theme from '../../../../themes/main-theme';
import { createFakeStore } from '../../../../accessStoreFake';
import AccessProvider from '../../../providers/AccessProvider/AccessProvider'; import AccessProvider from '../../../providers/AccessProvider/AccessProvider';
import { import {
ADMIN, ADMIN,
CREATE_TAG_TYPE,
UPDATE_TAG_TYPE,
DELETE_TAG_TYPE, DELETE_TAG_TYPE,
UPDATE_TAG_TYPE,
} from '../../../providers/AccessProvider/permissions'; } from '../../../providers/AccessProvider/permissions';
import UIProvider from '../../../providers/UIProvider/UIProvider'; import UIProvider from '../../../providers/UIProvider/UIProvider';
@ -19,9 +17,7 @@ test('renders an empty list correctly', () => {
<MemoryRouter> <MemoryRouter>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<UIProvider> <UIProvider>
<AccessProvider <AccessProvider permissions={[{ permission: ADMIN }]}>
store={createFakeStore([{ permission: ADMIN }])}
>
<TagTypeList <TagTypeList
tagTypes={[]} tagTypes={[]}
fetchTagTypes={jest.fn()} fetchTagTypes={jest.fn()}
@ -42,11 +38,10 @@ test('renders a list with elements correctly', () => {
<MemoryRouter> <MemoryRouter>
<UIProvider> <UIProvider>
<AccessProvider <AccessProvider
store={createFakeStore([ permissions={[
{ permission: CREATE_TAG_TYPE },
{ permission: UPDATE_TAG_TYPE }, { permission: UPDATE_TAG_TYPE },
{ permission: DELETE_TAG_TYPE }, { permission: DELETE_TAG_TYPE },
])} ]}
> >
<TagTypeList <TagTypeList
tagTypes={[ tagTypes={[

View File

@ -28,7 +28,35 @@ exports[`renders a list with elements correctly 1`] = `
</div> </div>
<div <div
className="makeStyles-headerActions-7" className="makeStyles-headerActions-7"
/> >
<button
className="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
type="button"
>
<span
className="MuiButton-label"
>
Add new tag type
</span>
<span
className="MuiTouchRipple-root"
/>
</button>
</div>
</div> </div>
</div> </div>
<div <div
@ -76,7 +104,32 @@ exports[`renders an empty list correctly 1`] = `
</div> </div>
<div <div
className="makeStyles-headerActions-7" className="makeStyles-headerActions-7"
/> >
<button
className="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
type="button"
>
<span
className="MuiButton-label"
>
Add new tag type
</span>
</button>
</div>
</div> </div>
</div> </div>
<div <div

View File

@ -2,8 +2,7 @@ import SimpleAuth from '../SimpleAuth/SimpleAuth';
import AuthenticationCustomComponent from '../authentication-custom-component'; import AuthenticationCustomComponent from '../authentication-custom-component';
import PasswordAuth from '../PasswordAuth/PasswordAuth'; import PasswordAuth from '../PasswordAuth/PasswordAuth';
import HostedAuth from '../HostedAuth/HostedAuth'; import HostedAuth from '../HostedAuth/HostedAuth';
import DemoAuth from '../DemoAuth'; import DemoAuth from '../DemoAuth/DemoAuth';
import { import {
SIMPLE_TYPE, SIMPLE_TYPE,
DEMO_TYPE, DEMO_TYPE,
@ -11,27 +10,13 @@ import {
HOSTED_TYPE, HOSTED_TYPE,
} from '../../../constants/authTypes'; } from '../../../constants/authTypes';
import SecondaryLoginActions from '../common/SecondaryLoginActions/SecondaryLoginActions'; import SecondaryLoginActions from '../common/SecondaryLoginActions/SecondaryLoginActions';
import useUser from '../../../hooks/api/getters/useUser/useUser';
import { IUser } from '../../../interfaces/user';
import { useHistory } from 'react-router';
import useQueryParams from '../../../hooks/useQueryParams'; import useQueryParams from '../../../hooks/useQueryParams';
import ConditionallyRender from '../../common/ConditionallyRender'; import ConditionallyRender from '../../common/ConditionallyRender';
import { Alert } from '@material-ui/lab'; import { Alert } from '@material-ui/lab';
import { useAuthDetails } from '../../../hooks/api/getters/useAuth/useAuthDetails';
interface IAuthenticationProps { const Authentication = () => {
insecureLogin: (path: string, user: IUser) => void; const { authDetails } = useAuthDetails();
passwordLogin: (path: string, user: IUser) => void;
demoLogin: (path: string, user: IUser) => void;
history: any;
}
const Authentication = ({
insecureLogin,
passwordLogin,
demoLogin,
}: IAuthenticationProps) => {
const { authDetails } = useUser();
const history = useHistory();
const params = useQueryParams(); const params = useQueryParams();
const error = params.get('errorMsg'); const error = params.get('errorMsg');
@ -41,11 +26,7 @@ const Authentication = ({
if (authDetails.type === PASSWORD_TYPE) { if (authDetails.type === PASSWORD_TYPE) {
content = ( content = (
<> <>
<PasswordAuth <PasswordAuth authDetails={authDetails} />
passwordLogin={passwordLogin}
authDetails={authDetails}
history={history}
/>
<ConditionallyRender <ConditionallyRender
condition={!authDetails.defaultHidden} condition={!authDetails.defaultHidden}
show={<SecondaryLoginActions />} show={<SecondaryLoginActions />}
@ -53,29 +34,13 @@ const Authentication = ({
</> </>
); );
} else if (authDetails.type === SIMPLE_TYPE) { } else if (authDetails.type === SIMPLE_TYPE) {
content = ( content = <SimpleAuth authDetails={authDetails} />;
<SimpleAuth
insecureLogin={insecureLogin}
authDetails={authDetails}
history={history}
/>
);
} else if (authDetails.type === DEMO_TYPE) { } else if (authDetails.type === DEMO_TYPE) {
content = ( content = <DemoAuth authDetails={authDetails} />;
<DemoAuth
demoLogin={demoLogin}
authDetails={authDetails}
history={history}
/>
);
} else if (authDetails.type === HOSTED_TYPE) { } else if (authDetails.type === HOSTED_TYPE) {
content = ( content = (
<> <>
<HostedAuth <HostedAuth authDetails={authDetails} />
passwordLogin={passwordLogin}
authDetails={authDetails}
history={history}
/>
<ConditionallyRender <ConditionallyRender
condition={!authDetails.defaultHidden} condition={!authDetails.defaultHidden}
show={<SecondaryLoginActions />} show={<SecondaryLoginActions />}

View File

@ -1,15 +0,0 @@
import { connect } from 'react-redux';
import AuthenticationComponent from './Authentication';
import {
insecureLogin,
passwordLogin,
demoLogin,
} from '../../../store/user/actions';
const mapDispatchToProps = (dispatch, props) => ({
demoLogin: (path, user) => demoLogin(path, user)(dispatch),
insecureLogin: (path, user) => insecureLogin(path, user)(dispatch),
passwordLogin: (path, user) => passwordLogin(path, user)(dispatch),
});
export default connect(null, mapDispatchToProps)(AuthenticationComponent);

View File

@ -1,27 +1,31 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button, TextField } from '@material-ui/core'; import { Button, TextField } from '@material-ui/core';
import useUser from '../../../hooks/api/getters/useUser/useUser';
import styles from './DemoAuth.module.scss'; import styles from './DemoAuth.module.scss';
import { ReactComponent as Logo } from '../../../assets/img/logo.svg'; import { ReactComponent as Logo } from '../../../assets/img/logo.svg';
import { LOGIN_BUTTON, LOGIN_EMAIL_ID } from '../../../testIds'; import { LOGIN_BUTTON, LOGIN_EMAIL_ID } from '../../../testIds';
import { useHistory } from 'react-router-dom';
import { useAuthApi } from '../../../hooks/api/actions/useAuthApi/useAuthApi';
import { useAuthUser } from '../../../hooks/api/getters/useAuth/useAuthUser';
import useToast from '../../../hooks/useToast';
const DemoAuth = ({ demoLogin, history, authDetails }) => { const DemoAuth = ({ authDetails }) => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const history = useHistory();
const { refetchUser } = useAuthUser();
const { emailAuth } = useAuthApi();
const { setToastApiError } = useToast();
const { refetch } = useUser(); const handleSubmit = async evt => {
const handleSubmit = evt => {
evt.preventDefault(); evt.preventDefault();
const user = { email };
const path = evt.target.action;
demoLogin(path, user).then(() => { try {
refetch(); await emailAuth(authDetails.path, email);
refetchUser();
history.push(`/`); history.push(`/`);
}); } catch (e) {
setToastApiError(e.toString());
}
}; };
const handleChange = e => { const handleChange = e => {
@ -30,7 +34,7 @@ const DemoAuth = ({ demoLogin, history, authDetails }) => {
}; };
return ( return (
<form onSubmit={handleSubmit} action={authDetails.path}> <form onSubmit={handleSubmit}>
<Logo className={styles.logo} /> <Logo className={styles.logo} />
<div className={styles.container}> <div className={styles.container}>
<h2>Access the Unleash demo instance</h2> <h2>Access the Unleash demo instance</h2>
@ -86,8 +90,6 @@ const DemoAuth = ({ demoLogin, history, authDetails }) => {
DemoAuth.propTypes = { DemoAuth.propTypes = {
authDetails: PropTypes.object.isRequired, authDetails: PropTypes.object.isRequired,
demoLogin: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
}; };
export default DemoAuth; export default DemoAuth;

View File

@ -1,3 +0,0 @@
import DemoAuth from './DemoAuth';
export default DemoAuth;

View File

@ -5,6 +5,7 @@ import { SyntheticEvent, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useCommonStyles } from '../../../common.styles'; import { useCommonStyles } from '../../../common.styles';
import useLoading from '../../../hooks/useLoading'; import useLoading from '../../../hooks/useLoading';
import { FORGOTTEN_PASSWORD_FIELD } from '../../../testIds';
import { formatApiPath } from '../../../utils/format-path'; import { formatApiPath } from '../../../utils/format-path';
import ConditionallyRender from '../../common/ConditionallyRender'; import ConditionallyRender from '../../common/ConditionallyRender';
import DividerText from '../../common/DividerText/DividerText'; import DividerText from '../../common/DividerText/DividerText';
@ -96,6 +97,7 @@ const ForgottenPassword = () => {
placeholder="email" placeholder="email"
type="email" type="email"
data-loading data-loading
data-test={FORGOTTEN_PASSWORD_FIELD}
value={email} value={email}
onChange={e => { onChange={e => {
setEmail(e.target.value); setEmail(e.target.value);

View File

@ -9,15 +9,17 @@ import useQueryParams from '../../../hooks/useQueryParams';
import AuthOptions from '../common/AuthOptions/AuthOptions'; import AuthOptions from '../common/AuthOptions/AuthOptions';
import DividerText from '../../common/DividerText/DividerText'; import DividerText from '../../common/DividerText/DividerText';
import ConditionallyRender from '../../common/ConditionallyRender'; import ConditionallyRender from '../../common/ConditionallyRender';
import useUser from '../../../hooks/api/getters/useUser/useUser';
import PasswordField from '../../common/PasswordField/PasswordField'; import PasswordField from '../../common/PasswordField/PasswordField';
import { useAuthApi } from "../../../hooks/api/actions/useAuthApi/useAuthApi";
import { useAuthUser } from '../../../hooks/api/getters/useAuth/useAuthUser';
const HostedAuth = ({ authDetails, passwordLogin }) => { const HostedAuth = ({ authDetails }) => {
const commonStyles = useCommonStyles(); const commonStyles = useCommonStyles();
const styles = useStyles(); const styles = useStyles();
const { refetch } = useUser(); const { refetchUser } = useAuthUser();
const history = useHistory(); const history = useHistory();
const params = useQueryParams(); const params = useQueryParams();
const { passwordAuth } = useAuthApi()
const [username, setUsername] = useState(params.get('email') || ''); const [username, setUsername] = useState(params.get('email') || '');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [errors, setErrors] = useState({ const [errors, setErrors] = useState({
@ -45,12 +47,9 @@ const HostedAuth = ({ authDetails, passwordLogin }) => {
return; return;
} }
const user = { username, password };
const path = evt.target.action;
try { try {
await passwordLogin(path, user); await passwordAuth(authDetails.path, username, password);
refetch(); refetchUser();
history.push(`/`); history.push(`/`);
} catch (error) { } catch (error) {
if (error.statusCode === 404 || error.statusCode === 400) { if (error.statusCode === 404 || error.statusCode === 400) {
@ -86,7 +85,7 @@ const HostedAuth = ({ authDetails, passwordLogin }) => {
<ConditionallyRender <ConditionallyRender
condition={!authDetails.defaultHidden} condition={!authDetails.defaultHidden}
show={ show={
<form onSubmit={handleSubmit} action={authDetails.path}> <form onSubmit={handleSubmit}>
<Typography <Typography
variant="subtitle2" variant="subtitle2"
className={styles.apiError} className={styles.apiError}
@ -138,8 +137,6 @@ const HostedAuth = ({ authDetails, passwordLogin }) => {
HostedAuth.propTypes = { HostedAuth.propTypes = {
authDetails: PropTypes.object.isRequired, authDetails: PropTypes.object.isRequired,
passwordLogin: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
}; };
export default HostedAuth; export default HostedAuth;

View File

@ -1,28 +1,28 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import AuthenticationContainer from '../Authentication';
import ConditionallyRender from '../../common/ConditionallyRender'; import ConditionallyRender from '../../common/ConditionallyRender';
import { useStyles } from './Login.styles'; import { useStyles } from './Login.styles';
import useQueryParams from '../../../hooks/useQueryParams'; import useQueryParams from '../../../hooks/useQueryParams';
import ResetPasswordSuccess from '../common/ResetPasswordSuccess/ResetPasswordSuccess'; import ResetPasswordSuccess from '../common/ResetPasswordSuccess/ResetPasswordSuccess';
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout'; import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
import { DEMO_TYPE } from '../../../constants/authTypes'; import { DEMO_TYPE } from '../../../constants/authTypes';
import useUser from '../../../hooks/api/getters/useUser/useUser';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import Authentication from "../Authentication/Authentication";
import { useAuthDetails } from '../../../hooks/api/getters/useAuth/useAuthDetails';
import { useAuthPermissions } from '../../../hooks/api/getters/useAuth/useAuthPermissions';
const Login = () => { const Login = () => {
const styles = useStyles(); const styles = useStyles();
const { permissions, authDetails } = useUser(); const { authDetails } = useAuthDetails();
const { permissions } = useAuthPermissions();
const query = useQueryParams(); const query = useQueryParams();
const history = useHistory(); const history = useHistory();
useEffect(() => { useEffect(() => {
if (permissions?.length > 0) { if (permissions?.length) {
history.push('features'); history.push('features');
} }
/* eslint-disable-next-line */ /* eslint-disable-next-line */
}, [permissions.length]); }, [permissions?.length]);
const resetPassword = query.get('reset') === 'true'; const resetPassword = query.get('reset') === 'true';
return ( return (
@ -41,7 +41,7 @@ const Login = () => {
condition={resetPassword} condition={resetPassword}
show={<ResetPasswordSuccess />} show={<ResetPasswordSuccess />}
/> />
<AuthenticationContainer history={history} /> <Authentication />
</div> </div>
</StandaloneLayout> </StandaloneLayout>
); );

View File

@ -1,23 +1,18 @@
import useLoading from '../../../hooks/useLoading'; import useLoading from '../../../hooks/useLoading';
import { TextField, Typography } from '@material-ui/core'; import { TextField, Typography } from '@material-ui/core';
import StandaloneBanner from '../StandaloneBanner/StandaloneBanner'; import StandaloneBanner from '../StandaloneBanner/StandaloneBanner';
import ResetPasswordDetails from '../common/ResetPasswordDetails/ResetPasswordDetails'; import ResetPasswordDetails from '../common/ResetPasswordDetails/ResetPasswordDetails';
import { useStyles } from './NewUser.styles'; import { useStyles } from './NewUser.styles';
import useResetPassword from '../../../hooks/api/getters/useResetPassword/useResetPassword'; import useResetPassword from '../../../hooks/api/getters/useResetPassword/useResetPassword';
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout'; import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
import ConditionallyRender from '../../common/ConditionallyRender'; import ConditionallyRender from '../../common/ConditionallyRender';
import InvalidToken from '../common/InvalidToken/InvalidToken'; import InvalidToken from '../common/InvalidToken/InvalidToken';
import { IAuthStatus } from '../../../interfaces/user';
import AuthOptions from '../common/AuthOptions/AuthOptions'; import AuthOptions from '../common/AuthOptions/AuthOptions';
import DividerText from '../../common/DividerText/DividerText'; import DividerText from '../../common/DividerText/DividerText';
import { useAuthDetails } from '../../../hooks/api/getters/useAuth/useAuthDetails';
interface INewUserProps { export const NewUser = () => {
user: IAuthStatus; const { authDetails } = useAuthDetails();
}
const NewUser = ({ user }: INewUserProps) => {
const { token, data, loading, setLoading, invalidToken } = const { token, data, loading, setLoading, invalidToken } =
useResetPassword(); useResetPassword();
const ref = useLoading(loading); const ref = useLoading(loading);
@ -75,10 +70,9 @@ const NewUser = ({ user }: INewUserProps) => {
/> />
<div className={styles.roleContainer}> <div className={styles.roleContainer}>
<ConditionallyRender <ConditionallyRender
condition={ condition={Boolean(
user?.authDetails?.options?.length > authDetails?.options?.length
0 )}
}
show={ show={
<> <>
<DividerText <DividerText
@ -88,8 +82,7 @@ const NewUser = ({ user }: INewUserProps) => {
<AuthOptions <AuthOptions
options={ options={
user?.authDetails authDetails?.options
?.options
} }
/> />
<DividerText <DividerText
@ -116,5 +109,3 @@ const NewUser = ({ user }: INewUserProps) => {
</div> </div>
); );
}; };
export default NewUser;

View File

@ -1,8 +0,0 @@
import { connect } from 'react-redux';
import NewUser from './NewUser';
const mapStateToProps = (state: any) => ({
user: state.user.toJS(),
});
export default connect(mapStateToProps)(NewUser);

View File

@ -12,20 +12,22 @@ import DividerText from '../../common/DividerText/DividerText';
import { Alert } from '@material-ui/lab'; import { Alert } from '@material-ui/lab';
import { import {
LOGIN_BUTTON, LOGIN_BUTTON,
LOGIN_PASSWORD_ID,
LOGIN_EMAIL_ID, LOGIN_EMAIL_ID,
LOGIN_PASSWORD_ID,
} from '../../../testIds'; } from '../../../testIds';
import useUser from '../../../hooks/api/getters/useUser/useUser';
import PasswordField from '../../common/PasswordField/PasswordField'; import PasswordField from '../../common/PasswordField/PasswordField';
import { useAuthApi } from '../../../hooks/api/actions/useAuthApi/useAuthApi';
import { useAuthUser } from '../../../hooks/api/getters/useAuth/useAuthUser';
const PasswordAuth = ({ authDetails, passwordLogin }) => { const PasswordAuth = ({ authDetails }) => {
const commonStyles = useCommonStyles(); const commonStyles = useCommonStyles();
const styles = useStyles(); const styles = useStyles();
const history = useHistory(); const history = useHistory();
const { refetch } = useUser(); const { refetchUser } = useAuthUser();
const params = useQueryParams(); const params = useQueryParams();
const [username, setUsername] = useState(params.get('email') || ''); const [username, setUsername] = useState(params.get('email') || '');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const { passwordAuth } = useAuthApi();
const [errors, setErrors] = useState({ const [errors, setErrors] = useState({
usernameError: '', usernameError: '',
passwordError: '', passwordError: '',
@ -51,12 +53,9 @@ const PasswordAuth = ({ authDetails, passwordLogin }) => {
return; return;
} }
const user = { username, password };
const path = evt.target.action;
try { try {
await passwordLogin(path, user); await passwordAuth(authDetails.path, username, password);
refetch(); refetchUser();
history.push(`/`); history.push(`/`);
} catch (error) { } catch (error) {
if (error.statusCode === 404 || error.statusCode === 400) { if (error.statusCode === 404 || error.statusCode === 400) {
@ -85,7 +84,7 @@ const PasswordAuth = ({ authDetails, passwordLogin }) => {
<ConditionallyRender <ConditionallyRender
condition={!authDetails.defaultHidden} condition={!authDetails.defaultHidden}
show={ show={
<form onSubmit={handleSubmit} action={authDetails.path}> <form onSubmit={handleSubmit}>
<ConditionallyRender <ConditionallyRender
condition={apiError} condition={apiError}
show={ show={
@ -169,8 +168,6 @@ const PasswordAuth = ({ authDetails, passwordLogin }) => {
PasswordAuth.propTypes = { PasswordAuth.propTypes = {
authDetails: PropTypes.object.isRequired, authDetails: PropTypes.object.isRequired,
passwordLogin: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
}; };
export default PasswordAuth; export default PasswordAuth;

View File

@ -1,23 +1,30 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button, TextField } from '@material-ui/core'; import { Button, TextField } from '@material-ui/core';
import styles from './SimpleAuth.module.scss'; import styles from './SimpleAuth.module.scss';
import useUser from '../../../hooks/api/getters/useUser/useUser'; import { useHistory } from 'react-router-dom';
import { useAuthApi } from '../../../hooks/api/actions/useAuthApi/useAuthApi';
import { useAuthUser } from '../../../hooks/api/getters/useAuth/useAuthUser';
import { LOGIN_BUTTON, LOGIN_EMAIL_ID } from '../../../testIds';
import useToast from '../../../hooks/useToast';
const SimpleAuth = ({ insecureLogin, history, authDetails }) => { const SimpleAuth = ({ authDetails }) => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const { refetch } = useUser(); const { refetchUser } = useAuthUser();
const { emailAuth } = useAuthApi();
const history = useHistory();
const { setToastApiError } = useToast();
const handleSubmit = evt => { const handleSubmit = async evt => {
evt.preventDefault(); evt.preventDefault();
const user = { email };
const path = evt.target.action;
insecureLogin(path, user).then(() => { try {
refetch(); await emailAuth(authDetails.path, email);
refetchUser();
history.push(`/`); history.push(`/`);
}); } catch (e) {
setToastApiError(e.toString());
}
}; };
const handleChange = e => { const handleChange = e => {
@ -26,7 +33,7 @@ const SimpleAuth = ({ insecureLogin, history, authDetails }) => {
}; };
return ( return (
<form onSubmit={handleSubmit} action={authDetails.path}> <form onSubmit={handleSubmit}>
<div className={styles.container}> <div className={styles.container}>
<p>{authDetails.message}</p> <p>{authDetails.message}</p>
<p> <p>
@ -50,6 +57,7 @@ const SimpleAuth = ({ insecureLogin, history, authDetails }) => {
name="email" name="email"
required required
type="email" type="email"
data-test={LOGIN_EMAIL_ID}
/> />
<br /> <br />
@ -58,8 +66,8 @@ const SimpleAuth = ({ insecureLogin, history, authDetails }) => {
type="submit" type="submit"
variant="contained" variant="contained"
color="primary" color="primary"
data-test="login-submit"
className={styles.button} className={styles.button}
data-test={LOGIN_BUTTON}
> >
Sign in Sign in
</Button> </Button>
@ -71,8 +79,6 @@ const SimpleAuth = ({ insecureLogin, history, authDetails }) => {
SimpleAuth.propTypes = { SimpleAuth.propTypes = {
authDetails: PropTypes.object.isRequired, authDetails: PropTypes.object.isRequired,
insecureLogin: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
}; };
export default SimpleAuth; export default SimpleAuth;

View File

@ -1,3 +0,0 @@
import SimpleAuth from './SimpleAuth';
export default SimpleAuth;

View File

@ -1,16 +1,20 @@
import useUser from '../../../hooks/api/getters/useUser/useUser';
import UserProfile from './UserProfile'; import UserProfile from './UserProfile';
import { useLocationSettings } from '../../../hooks/useLocationSettings'; import { useLocationSettings } from '../../../hooks/useLocationSettings';
import { useAuthUser } from '../../../hooks/api/getters/useAuth/useAuthUser';
const UserProfileContainer = () => { const UserProfileContainer = () => {
const user = useUser();
const { locationSettings, setLocationSettings } = useLocationSettings(); const { locationSettings, setLocationSettings } = useLocationSettings();
const { user } = useAuthUser();
if (!user) {
return null;
}
return ( return (
<UserProfile <UserProfile
locationSettings={locationSettings} locationSettings={locationSettings}
setLocationSettings={setLocationSettings} setLocationSettings={setLocationSettings}
profile={user.user} profile={user}
/> />
); );
}; };

View File

@ -1,10 +1,11 @@
import { Button } from '@material-ui/core'; import { Button } from '@material-ui/core';
import classnames from 'classnames'; import classnames from 'classnames';
import { useCommonStyles } from '../../../../common.styles'; import { useCommonStyles } from '../../../../common.styles';
import { IAuthOptions } from '../../../../interfaces/user';
import { ReactComponent as GoogleSvg } from '../../../../assets/icons/google.svg'; import { ReactComponent as GoogleSvg } from '../../../../assets/icons/google.svg';
import LockRounded from '@material-ui/icons/LockRounded'; import LockRounded from '@material-ui/icons/LockRounded';
import ConditionallyRender from '../../../common/ConditionallyRender'; import ConditionallyRender from '../../../common/ConditionallyRender';
import { IAuthOptions } from '../../../../hooks/api/getters/useAuth/useAuthEndpoint';
import { SSO_LOGIN_BUTTON } from '../../../../testIds';
interface IAuthOptionProps { interface IAuthOptionProps {
options?: IAuthOptions[]; options?: IAuthOptions[];
@ -28,6 +29,7 @@ const AuthOptions = ({ options }: IAuthOptionProps) => {
variant="outlined" variant="outlined"
href={o.path} href={o.path}
size="small" size="small"
data-test={`${SSO_LOGIN_BUTTON}-${o.type}`}
style={{ height: '40px', color: '#000' }} style={{ height: '40px', color: '#000' }}
startIcon={ startIcon={
<ConditionallyRender <ConditionallyRender

View File

@ -1,5 +0,0 @@
import React from 'react';
const AccessContext = React.createContext()
export default AccessContext;

View File

@ -0,0 +1,21 @@
import React from 'react';
export interface IAccessContext {
isAdmin: boolean;
hasAccess: (
permission: string,
project?: string,
environment?: string
) => boolean;
}
const hasAccessPlaceholder = () => {
throw new Error('hasAccess called outside AccessContext');
};
const AccessContext = React.createContext<IAccessContext>({
isAdmin: false,
hasAccess: hasAccessPlaceholder,
});
export default AccessContext;

View File

@ -1,5 +1,3 @@
import { IUserPayload } from '../../../../interfaces/user';
import useAPI from '../useApi/useApi'; import useAPI from '../useApi/useApi';
import { import {
handleBadRequest, handleBadRequest,
@ -16,6 +14,12 @@ export interface IUserApiErrors {
validatePassword?: string; validatePassword?: string;
} }
interface IUserPayload {
name: string;
email: string;
id?: string;
}
export const ADD_USER_ERROR = 'addUser'; export const ADD_USER_ERROR = 'addUser';
export const UPDATE_USER_ERROR = 'updateUser'; export const UPDATE_USER_ERROR = 'updateUser';
export const REMOVE_USER_ERROR = 'removeUser'; export const REMOVE_USER_ERROR = 'removeUser';

View File

@ -0,0 +1,51 @@
import useAPI from '../useApi/useApi';
type PasswordLogin = (
path: string,
username: string,
password: string
) => Promise<Response>;
type EmailLogin = (path: string, email: string) => Promise<Response>;
interface IUseAuthApiOutput {
passwordAuth: PasswordLogin;
emailAuth: EmailLogin;
errors: Record<string, string>;
loading: boolean;
}
export const useAuthApi = (): IUseAuthApiOutput => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const passwordAuth = (path: string, username: string, password: string) => {
const req = createRequest(ensureRelativePath(path), {
method: 'POST',
body: JSON.stringify({ username, password }),
});
return makeRequest(req.caller, req.id);
};
const emailAuth = (path: string, email: string) => {
const req = createRequest(ensureRelativePath(path), {
method: 'POST',
body: JSON.stringify({ email }),
});
return makeRequest(req.caller, req.id);
};
return {
passwordAuth,
emailAuth,
errors,
loading,
};
};
const ensureRelativePath = (path: string): string => {
return path.replace(/^\//, '');
};

View File

@ -0,0 +1,24 @@
import {
IAuthEndpointDetailsResponse,
useAuthEndpoint,
} from './useAuthEndpoint';
interface IUseAuthDetailsOutput {
authDetails?: IAuthEndpointDetailsResponse;
refetchAuthDetails: () => void;
loading: boolean;
error?: Error;
}
export const useAuthDetails = (): IUseAuthDetailsOutput => {
const auth = useAuthEndpoint();
const authDetails =
auth.data && 'type' in auth.data ? auth.data : undefined;
return {
authDetails,
refetchAuthDetails: auth.refetchAuth,
loading: auth.loading,
error: auth.error,
};
};

View File

@ -0,0 +1,84 @@
import useSWR, { mutate } from 'swr';
import { useCallback } from 'react';
import { formatApiPath } from '../../../../utils/format-path';
import { IPermission, IUser } from '../../../../interfaces/user';
// The auth endpoint returns different things depending on the auth status.
// When the user is logged in, the endpoint returns user data and permissions.
// When the user is logged out, the endpoint returns details on how to log in.
type AuthEndpointResponse =
| IAuthEndpointUserResponse
| IAuthEndpointDetailsResponse;
export interface IAuthEndpointUserResponse {
user: IUser;
feedback: IAuthFeedback[];
permissions: IPermission[];
splash: IAuthSplash;
}
export interface IAuthEndpointDetailsResponse {
type: string;
path: string;
message: string;
defaultHidden: boolean;
options: IAuthOptions[];
}
export interface IAuthOptions {
type: string;
message: string;
path: string;
}
export interface IAuthFeedback {
neverShow: boolean;
feedbackId: string;
given?: string;
userId: number;
}
export interface IAuthSplash {
[key: string]: boolean;
}
interface IUseAuthEndpointOutput {
data?: AuthEndpointResponse;
refetchAuth: () => void;
loading: boolean;
error?: Error;
}
// This helper hook returns the raw response data from the user auth endpoint.
// Check out the other hooks in this directory for more ergonomic alternatives.
export const useAuthEndpoint = (): IUseAuthEndpointOutput => {
const { data, error } = useSWR<AuthEndpointResponse>(
USER_ENDPOINT_PATH,
fetchAuthStatus,
swrConfig
);
const refetchAuth = useCallback(() => {
mutate(USER_ENDPOINT_PATH).catch(console.warn);
}, []);
return {
data,
refetchAuth,
loading: !error && !data,
error,
};
};
const fetchAuthStatus = (): Promise<AuthEndpointResponse> => {
return fetch(USER_ENDPOINT_PATH).then(res => res.json());
};
const swrConfig = {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshInterval: 15000,
};
export const USER_ENDPOINT_PATH = formatApiPath(`api/admin/user`);

View File

@ -0,0 +1,21 @@
import { IAuthFeedback, useAuthEndpoint } from './useAuthEndpoint';
interface IUseAuthFeedbackOutput {
feedback?: IAuthFeedback[];
refetchFeedback: () => void;
loading: boolean;
error?: Error;
}
export const useAuthFeedback = (): IUseAuthFeedbackOutput => {
const auth = useAuthEndpoint();
const feedback =
auth.data && 'feedback' in auth.data ? auth.data.feedback : undefined;
return {
feedback,
refetchFeedback: auth.refetchAuth,
loading: auth.loading,
error: auth.error,
};
};

View File

@ -0,0 +1,24 @@
import { IPermission } from '../../../../interfaces/user';
import { useAuthEndpoint } from './useAuthEndpoint';
interface IUseAuthPermissionsOutput {
permissions?: IPermission[];
refetchPermissions: () => void;
loading: boolean;
error?: Error;
}
export const useAuthPermissions = (): IUseAuthPermissionsOutput => {
const auth = useAuthEndpoint();
const permissions =
auth.data && 'permissions' in auth.data
? auth.data.permissions
: undefined;
return {
permissions,
refetchPermissions: auth.refetchAuth,
loading: auth.loading,
error: auth.error,
};
};

View File

@ -0,0 +1,21 @@
import { IAuthSplash, useAuthEndpoint } from './useAuthEndpoint';
interface IUseAuthSplashOutput {
splash?: IAuthSplash;
refetchSplash: () => void;
loading: boolean;
error?: Error;
}
export const useAuthSplash = (): IUseAuthSplashOutput => {
const auth = useAuthEndpoint();
const splash =
auth.data && 'splash' in auth.data ? auth.data.splash : undefined;
return {
splash,
refetchSplash: auth.refetchAuth,
loading: auth.loading,
error: auth.error,
};
};

View File

@ -0,0 +1,21 @@
import { IUser } from '../../../../interfaces/user';
import { useAuthEndpoint } from './useAuthEndpoint';
interface IUseAuthUserOutput {
user?: IUser;
refetchUser: () => void;
loading: boolean;
error?: Error;
}
export const useAuthUser = (): IUseAuthUserOutput => {
const auth = useAuthEndpoint();
const user = auth.data && 'user' in auth.data ? auth.data.user : undefined;
return {
user,
refetchUser: auth.refetchAuth,
loading: auth.loading,
error: auth.error,
};
};

View File

@ -1,58 +0,0 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import { formatApiPath } from '../../../../utils/format-path';
import { IPermission } from '../../../../interfaces/user';
import handleErrorResponses from '../httpErrorResponseHandler';
export const USER_CACHE_KEY = `api/admin/user`;
const NO_AUTH_USERNAME = 'unknown';
const useUser = (
options: SWRConfiguration = {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshInterval: 15000,
}
) => {
const fetcher = () => {
const path = formatApiPath(`api/admin/user`);
return fetch(path, {
method: 'GET',
})
.then(handleErrorResponses('User info'))
.then(res => res.json());
};
const { data, error } = useSWR(USER_CACHE_KEY, fetcher, options);
const [loading, setLoading] = useState(!error && !data);
const refetch = () => {
mutate(USER_CACHE_KEY);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
let user = data?.user;
// Set a user id if no authentication is on
// to cancel the loader.
if (data && user?.username === NO_AUTH_USERNAME) {
user = { ...user, id: 1 };
}
return {
user: user || {},
permissions: (data?.permissions || []) as IPermission[],
feedback: data?.feedback || [],
splash: data?.splash || {},
authDetails: data || undefined,
error,
loading,
refetch,
};
};
export default useUser;

View File

@ -1,8 +0,0 @@
import { useContext } from 'react';
import AccessContext from '../contexts/AccessContext';
const usePermissions = () => {
return useContext(AccessContext);
};
export default usePermissions;

View File

@ -43,7 +43,7 @@ ReactDOM.render(
<Provider store={unleashStore}> <Provider store={unleashStore}>
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<UIProvider> <UIProvider>
<AccessProvider store={unleashStore}> <AccessProvider>
<Router basename={`${getBasePath()}`}> <Router basename={`${getBasePath()}`}>
<ThemeProvider theme={mainTheme}> <ThemeProvider theme={mainTheme}>
<StylesProvider injectFirst> <StylesProvider injectFirst>

View File

@ -1,34 +1,3 @@
export interface IAuthStatus {
authDetails: IAuthDetails;
showDialog: boolean;
profile?: IUser;
permissions: IPermission[];
splash: ISplash;
}
export interface ISplash {
[key: string]: boolean;
}
export interface IPermission {
permission: string;
project: string;
displayName: string;
}
interface IAuthDetails {
type: string;
path: string;
message: string;
options: IAuthOptions[];
}
export interface IAuthOptions {
type: string;
message: string;
path: string;
}
export interface IUser { export interface IUser {
id: number; id: number;
email: string; email: string;
@ -43,14 +12,8 @@ export interface IUser {
username?: string; username?: string;
} }
export interface IUserPayload { export interface IPermission {
name: string; permission: string;
email: string; project?: string;
id?: string; environment?: string;
} }
export interface IAddedUser extends IUser {
emailSent?: boolean;
}
export default IAuthStatus;

View File

@ -1,6 +1,5 @@
import { fromJS, List, Map } from 'immutable'; import { fromJS, List, Map } from 'immutable';
import { RECEIVE_ALL_APPLICATIONS, RECEIVE_APPLICATION, UPDATE_APPLICATION_FIELD, DELETE_APPLICATION } from './actions'; import { RECEIVE_ALL_APPLICATIONS, RECEIVE_APPLICATION, UPDATE_APPLICATION_FIELD, DELETE_APPLICATION } from './actions';
import { USER_LOGOUT, USER_LOGIN } from '../user/actions';
function getInitState() { function getInitState() {
return fromJS({ list: [], apps: {} }); return fromJS({ list: [], apps: {} });
@ -19,9 +18,6 @@ const store = (state = getInitState(), action) => {
const result = state.removeIn(['list', index]); const result = state.removeIn(['list', index]);
return result.removeIn(['apps', action.appName]); return result.removeIn(['apps', action.appName]);
} }
case USER_LOGOUT:
case USER_LOGIN:
return getInitState();
default: default:
return state; return state;
} }

View File

@ -9,8 +9,6 @@ import {
TOGGLE_FEATURE_TOGGLE, TOGGLE_FEATURE_TOGGLE,
} from './actions'; } from './actions';
import { USER_LOGOUT, USER_LOGIN } from '../user/actions';
const debug = require('debug')('unleash:feature-store'); const debug = require('debug')('unleash:feature-store');
const features = (state = new List([]), action) => { const features = (state = new List([]), action) => {
@ -62,10 +60,6 @@ const features = (state = new List([]), action) => {
case RECEIVE_FEATURE_TOGGLES: case RECEIVE_FEATURE_TOGGLES:
debug(RECEIVE_FEATURE_TOGGLES, action); debug(RECEIVE_FEATURE_TOGGLES, action);
return new List(action.featureToggles.map($Map)); return new List(action.featureToggles.map($Map));
case USER_LOGIN:
case USER_LOGOUT:
debug(USER_LOGOUT, action);
return new List([]);
default: default:
return state; return state;
} }

View File

@ -2,7 +2,6 @@ import { combineReducers } from 'redux';
import features from './feature-toggle'; import features from './feature-toggle';
import strategies from './strategy'; import strategies from './strategy';
import error from './error'; import error from './error';
import user from './user';
import applications from './application'; import applications from './application';
import projects from './project'; import projects from './project';
import apiCalls from './api-calls'; import apiCalls from './api-calls';
@ -11,7 +10,6 @@ const unleashStore = combineReducers({
features, features,
strategies, strategies,
error, error,
user,
applications, applications,
projects, projects,
apiCalls, apiCalls,

View File

@ -1,6 +1,5 @@
import { List } from 'immutable'; import { List } from 'immutable';
import { RECEIVE_PROJECT, REMOVE_PROJECT, ADD_PROJECT, UPDATE_PROJECT } from './actions'; import { RECEIVE_PROJECT, REMOVE_PROJECT, ADD_PROJECT, UPDATE_PROJECT } from './actions';
import { USER_LOGOUT, USER_LOGIN } from '../user/actions';
const DEFAULT_PROJECTS = [{ id: 'default', name: 'Default', initial: true }]; const DEFAULT_PROJECTS = [{ id: 'default', name: 'Default', initial: true }];
@ -22,9 +21,6 @@ const strategies = (state = getInitState(), action) => {
const index = state.findIndex(item => item.id === action.project.id); const index = state.findIndex(item => item.id === action.project.id);
return state.set(index, action.project); return state.set(index, action.project);
} }
case USER_LOGOUT:
case USER_LOGIN:
return getInitState();
default: default:
return state; return state;
} }

View File

@ -1,62 +0,0 @@
import api from './api';
import { dispatchError } from '../util';
export const USER_CHANGE_CURRENT = 'USER_CHANGE_CURRENT';
export const USER_LOGOUT = 'USER_LOGOUT';
export const USER_LOGIN = 'USER_LOGIN';
export const START_FETCH_USER = 'START_FETCH_USER';
export const ERROR_FETCH_USER = 'ERROR_FETCH_USER';
const debug = require('debug')('unleash:user-actions');
const updateUser = value => ({
type: USER_CHANGE_CURRENT,
value,
});
function handleError(error) {
debug(error);
}
export function fetchUser() {
debug('Start fetching user');
return dispatch => {
dispatch({ type: START_FETCH_USER });
return api
.fetchUser()
.then(json => dispatch(updateUser(json)))
.catch(dispatchError(dispatch, ERROR_FETCH_USER));
};
}
export function insecureLogin(path, user) {
return dispatch => {
dispatch({ type: START_FETCH_USER });
return api
.insecureLogin(path, user)
.then(json => dispatch(updateUser(json)))
.catch(handleError);
};
}
export function demoLogin(path, user) {
return dispatch => {
dispatch({ type: START_FETCH_USER });
return api
.demoLogin(path, user)
.then(json => dispatch(updateUser(json)))
.catch(handleError);
};
}
export function passwordLogin(path, user) {
return dispatch => {
dispatch({ type: START_FETCH_USER });
return api
.passwordLogin(path, user)
.then(json => dispatch(updateUser(json)))
.then(() => dispatch({ type: USER_LOGIN }));
};
}

View File

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

View File

@ -1,28 +0,0 @@
import { Map as $Map } from 'immutable';
import { USER_CHANGE_CURRENT, USER_LOGOUT } from './actions';
import { AUTH_REQUIRED } from '../util';
const userStore = (state = new $Map({ permissions: [] }), action) => {
switch (action.type) {
case USER_CHANGE_CURRENT:
state = state
.set('profile', action.value.user)
.set('permissions', action.value.permissions || [])
.set('feedback', action.value.feedback || [])
.set('splash', action.value.splash || {})
.set('showDialog', false)
.set('authDetails', undefined);
return state;
case AUTH_REQUIRED:
state = state
.set('authDetails', action.error.body)
.set('showDialog', true);
return state;
case USER_LOGOUT:
return new $Map({ permissions: [] });
default:
return state;
}
};
export default userStore;

View File

@ -15,6 +15,8 @@ export const CF_CREATE_BTN_ID = 'CF_CREATE_BTN_ID';
export const LOGIN_EMAIL_ID = 'LOGIN_EMAIL_ID'; export const LOGIN_EMAIL_ID = 'LOGIN_EMAIL_ID';
export const LOGIN_BUTTON = 'LOGIN_BUTTON'; export const LOGIN_BUTTON = 'LOGIN_BUTTON';
export const LOGIN_PASSWORD_ID = 'LOGIN_PASSWORD_ID'; export const LOGIN_PASSWORD_ID = 'LOGIN_PASSWORD_ID';
export const SSO_LOGIN_BUTTON = 'SSO_LOGIN_BUTTON';
export const FORGOTTEN_PASSWORD_FIELD = 'FORGOTTEN_PASSWORD_FIELD';
/* STRATEGY */ /* STRATEGY */
export const ADD_NEW_STRATEGY_ID = 'ADD_NEW_STRATEGY_ID'; export const ADD_NEW_STRATEGY_ID = 'ADD_NEW_STRATEGY_ID';

View File

@ -1,16 +1,16 @@
import { ADMIN } from '../component/providers/AccessProvider/permissions'; import { ADMIN } from '../component/providers/AccessProvider/permissions';
import IAuthStatus, { IPermission } from '../interfaces/user'; import { IPermission } from '../interfaces/user';
type objectIdx = { type objectIdx = {
[key: string]: string; [key: string]: string;
}; };
export const projectFilterGenerator = ( export const projectFilterGenerator = (
user: IAuthStatus, permissions: IPermission[] = [],
matcherPermission: string matcherPermission: string
) => { ) => {
let admin = false; let admin = false;
const permissionMap: objectIdx = user.permissions.reduce( const permissionMap: objectIdx = permissions.reduce(
(acc: objectIdx, current: IPermission) => { (acc: objectIdx, current: IPermission) => {
if (current.permission === ADMIN) { if (current.permission === ADMIN) {
admin = true; admin = true;