1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +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
on: [deployment_status]
jobs:
@ -20,5 +20,6 @@ jobs:
env: AUTH_TOKEN=${{ secrets.UNLEASH_TOKEN }},DEFAULT_ENV="development"
config: baseUrl=${{ github.event.deployment_status.target_url }}
record: true
spec: cypress/integration/auth/auth.spec.js
env:
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';
export const createFakeStore = (permissions) => {
export const createFakeStore = permissions => {
return {
getState: () => ({
user:
new $MAp({
permissions
})
user: new $MAp({
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 EnvironmentSplash from './common/EnvironmentSplash/EnvironmentSplash';
import Feedback from './common/Feedback/Feedback';
import LayoutPicker from './layout/LayoutPicker/LayoutPicker';
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 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 {
user: IAuthStatus;
fetchUiBootstrap: any;
fetchUiBootstrap: () => void;
}
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);
const [showLoader, setShowLoader] = useState(false);
export const App = ({ fetchUiBootstrap }: IAppProps) => {
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(() => {
fetchUiBootstrap();
/* eslint-disable-next-line */
}, [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]);
}, [fetchUiBootstrap, authDetails?.type]);
const renderMainLayoutRoutes = () => {
return routes.filter(route => route.layout === 'main').map(renderRoute);
@ -63,10 +43,8 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
.map(renderRoute);
};
const isUnauthorized = () => {
// authDetails only exists if the user is not logged in.
//if (user?.permissions.length === 0) return true;
return user?.authDetails !== undefined;
const isUnauthorized = (): boolean => {
return !isLoggedIn;
};
// 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
{...props}
isUnauthorized={isUnauthorized}
authDetails={user.authDetails}
authDetails={authDetails}
/>
)}
/>
@ -99,21 +77,18 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
};
return (
<SWRProvider
isUnauthorized={isUnauthorized}
setShowLoader={setShowLoader}
>
<SWRProvider isUnauthorized={isUnauthorized}>
<ConditionallyRender
condition={showLoader}
condition={!hasFetchedAuth}
show={<Loader />}
elseShow={
<div className={styles.container}>
<ToastRenderer />
<ConditionallyRender
condition={showSplash}
condition={showEnvSplash}
show={
<EnvironmentSplash onFinish={setShowSplash} />
<EnvironmentSplash onFinish={refetchSplash} />
}
elseShow={
<LayoutPicker location={location}>
@ -133,9 +108,7 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
/>
<Redirect to="/404" />
</Switch>
<Feedback
openUrl="http://feedback.unleash.run"
/>
<Feedback openUrl="http://feedback.unleash.run" />
</LayoutPicker>
}
/>
@ -145,10 +118,3 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
</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 App from './App';
import { App } from './App';
import { fetchUiBootstrap } from '../store/ui-bootstrap/actions';
const mapDispatchToProps = {
fetchUiBootstrap,
};
const mapStateToProps = (state: any) => ({
user: state.user.toJS(),
});
export default connect(mapStateToProps, mapDispatchToProps)(App);
export default connect(null, { fetchUiBootstrap })(App);

View File

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

View File

@ -1,11 +1,12 @@
import Breadcrumbs from '@material-ui/core/Breadcrumbs';
import { Link, useLocation } from 'react-router-dom';
import usePermissions from '../../../hooks/usePermissions';
import ConditionallyRender from '../ConditionallyRender';
import { useStyles } from './BreadcrumbNav.styles';
import AccessContext from '../../../contexts/AccessContext';
import { useContext } from 'react';
const BreadcrumbNav = () => {
const { isAdmin } = usePermissions();
const { isAdmin } = useContext(AccessContext);
const styles = useStyles();
const location = useLocation();
@ -23,16 +24,16 @@ const BreadcrumbNav = () => {
item !== 'copy' &&
item !== 'strategies' &&
item !== 'features' &&
item !== 'features2' &&
item !== 'create-toggle'&&
item !== 'settings'
item !== 'features2' &&
item !== 'create-toggle'&&
item !== 'settings'
);
return (
<ConditionallyRender
condition={
(location.pathname.includes('admin') && isAdmin()) ||
(location.pathname.includes('admin') && isAdmin) ||
!location.pathname.includes('admin')
}
show={

View File

@ -2,7 +2,6 @@ import { useContext, useState } from 'react';
import { Button, IconButton } from '@material-ui/core';
import classnames from 'classnames';
import CloseIcon from '@material-ui/icons/Close';
import { ReactComponent as Logo } from '../../../assets/icons/logo-plain.svg';
import { useCommonStyles } from '../../../common.styles';
import { useStyles } from './Feedback.styles';
@ -10,8 +9,8 @@ import AnimateOnMount from '../AnimateOnMount/AnimateOnMount';
import ConditionallyRender from '../ConditionallyRender';
import { formatApiPath } from '../../../utils/format-path';
import UIContext from '../../../contexts/UIContext';
import useUser from '../../../hooks/api/getters/useUser/useUser';
import { PNPS_FEEDBACK_ID, showPnpsFeedback } from '../util';
import { useAuthFeedback } from '../../../hooks/api/getters/useAuth/useAuthFeedback';
interface IFeedbackProps {
openUrl: string;
@ -19,7 +18,7 @@ interface IFeedbackProps {
const Feedback = ({ openUrl }: IFeedbackProps) => {
const { showFeedback, setShowFeedback } = useContext(UIContext);
const { refetch, feedback } = useUser();
const { feedback, refetchFeedback } = useAuthFeedback();
const [answeredNotNow, setAnsweredNotNow] = useState(false);
const styles = useStyles();
const commonStyles = useCommonStyles();
@ -37,7 +36,7 @@ const Feedback = ({ openUrl }: IFeedbackProps) => {
},
body: JSON.stringify({ feedbackId }),
});
await refetch();
await refetchFeedback();
} catch (err) {
console.warn(err);
setShowFeedback(false);
@ -65,7 +64,7 @@ const Feedback = ({ openUrl }: IFeedbackProps) => {
},
body: JSON.stringify({ feedbackId, neverShow: true }),
});
await refetch();
await refetchFeedback();
} catch (err) {
console.warn(err);
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 useFeatureTypes from '../../../hooks/api/getters/useFeatureTypes/useFeatureTypes';
import { KeyboardArrowDownOutlined } from '@material-ui/icons';
import useUser from '../../../hooks/api/getters/useUser/useUser';
import { projectFilterGenerator } from '../../../utils/project-filter-generator';
import FeatureProjectSelect from '../FeatureView/FeatureSettings/FeatureSettingsProject/FeatureProjectSelect/FeatureProjectSelect';
import ConditionallyRender from '../../common/ConditionallyRender';
@ -12,6 +11,8 @@ import { trim } from '../../common/util';
import Input from '../../common/Input/Input';
import { CREATE_FEATURE } from '../../providers/AccessProvider/permissions';
import { useHistory } from 'react-router-dom';
import React from 'react';
import { useAuthPermissions } from '../../../hooks/api/getters/useAuth/useAuthPermissions';
interface IFeatureToggleForm {
type: string;
@ -54,7 +55,7 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
const styles = useStyles();
const { featureTypes } = useFeatureTypes();
const history = useHistory();
const { permissions } = useUser();
const { permissions } = useAuthPermissions()
const editable = mode !== 'Edit';
const renderToggleDescription = () => {
@ -114,7 +115,7 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
}}
enabled={editable}
filter={projectFilterGenerator(
{ permissions },
permissions,
CREATE_FEATURE
)}
IconComponent={KeyboardArrowDownOutlined}

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ import AdminUsers from '../admin/users/UsersAdmin';
import { AuthSettings } from '../admin/auth/AuthSettings';
import Login from '../user/Login/Login';
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 ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
import ProjectListNew from '../project/ProjectList/ProjectList';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,7 +28,35 @@ exports[`renders a list with elements correctly 1`] = `
</div>
<div
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
@ -76,7 +104,32 @@ exports[`renders an empty list correctly 1`] = `
</div>
<div
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

View File

@ -2,8 +2,7 @@ import SimpleAuth from '../SimpleAuth/SimpleAuth';
import AuthenticationCustomComponent from '../authentication-custom-component';
import PasswordAuth from '../PasswordAuth/PasswordAuth';
import HostedAuth from '../HostedAuth/HostedAuth';
import DemoAuth from '../DemoAuth';
import DemoAuth from '../DemoAuth/DemoAuth';
import {
SIMPLE_TYPE,
DEMO_TYPE,
@ -11,27 +10,13 @@ import {
HOSTED_TYPE,
} from '../../../constants/authTypes';
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 ConditionallyRender from '../../common/ConditionallyRender';
import { Alert } from '@material-ui/lab';
import { useAuthDetails } from '../../../hooks/api/getters/useAuth/useAuthDetails';
interface IAuthenticationProps {
insecureLogin: (path: string, user: IUser) => void;
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 Authentication = () => {
const { authDetails } = useAuthDetails();
const params = useQueryParams();
const error = params.get('errorMsg');
@ -41,11 +26,7 @@ const Authentication = ({
if (authDetails.type === PASSWORD_TYPE) {
content = (
<>
<PasswordAuth
passwordLogin={passwordLogin}
authDetails={authDetails}
history={history}
/>
<PasswordAuth authDetails={authDetails} />
<ConditionallyRender
condition={!authDetails.defaultHidden}
show={<SecondaryLoginActions />}
@ -53,29 +34,13 @@ const Authentication = ({
</>
);
} else if (authDetails.type === SIMPLE_TYPE) {
content = (
<SimpleAuth
insecureLogin={insecureLogin}
authDetails={authDetails}
history={history}
/>
);
content = <SimpleAuth authDetails={authDetails} />;
} else if (authDetails.type === DEMO_TYPE) {
content = (
<DemoAuth
demoLogin={demoLogin}
authDetails={authDetails}
history={history}
/>
);
content = <DemoAuth authDetails={authDetails} />;
} else if (authDetails.type === HOSTED_TYPE) {
content = (
<>
<HostedAuth
passwordLogin={passwordLogin}
authDetails={authDetails}
history={history}
/>
<HostedAuth authDetails={authDetails} />
<ConditionallyRender
condition={!authDetails.defaultHidden}
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 PropTypes from 'prop-types';
import { Button, TextField } from '@material-ui/core';
import useUser from '../../../hooks/api/getters/useUser/useUser';
import styles from './DemoAuth.module.scss';
import { ReactComponent as Logo } from '../../../assets/img/logo.svg';
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 history = useHistory();
const { refetchUser } = useAuthUser();
const { emailAuth } = useAuthApi();
const { setToastApiError } = useToast();
const { refetch } = useUser();
const handleSubmit = evt => {
const handleSubmit = async evt => {
evt.preventDefault();
const user = { email };
const path = evt.target.action;
demoLogin(path, user).then(() => {
refetch();
try {
await emailAuth(authDetails.path, email);
refetchUser();
history.push(`/`);
});
} catch (e) {
setToastApiError(e.toString());
}
};
const handleChange = e => {
@ -30,7 +34,7 @@ const DemoAuth = ({ demoLogin, history, authDetails }) => {
};
return (
<form onSubmit={handleSubmit} action={authDetails.path}>
<form onSubmit={handleSubmit}>
<Logo className={styles.logo} />
<div className={styles.container}>
<h2>Access the Unleash demo instance</h2>
@ -86,8 +90,6 @@ const DemoAuth = ({ demoLogin, history, authDetails }) => {
DemoAuth.propTypes = {
authDetails: PropTypes.object.isRequired,
demoLogin: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
};
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 { useCommonStyles } from '../../../common.styles';
import useLoading from '../../../hooks/useLoading';
import { FORGOTTEN_PASSWORD_FIELD } from '../../../testIds';
import { formatApiPath } from '../../../utils/format-path';
import ConditionallyRender from '../../common/ConditionallyRender';
import DividerText from '../../common/DividerText/DividerText';
@ -96,6 +97,7 @@ const ForgottenPassword = () => {
placeholder="email"
type="email"
data-loading
data-test={FORGOTTEN_PASSWORD_FIELD}
value={email}
onChange={e => {
setEmail(e.target.value);

View File

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

View File

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

View File

@ -1,23 +1,18 @@
import useLoading from '../../../hooks/useLoading';
import { TextField, Typography } from '@material-ui/core';
import StandaloneBanner from '../StandaloneBanner/StandaloneBanner';
import ResetPasswordDetails from '../common/ResetPasswordDetails/ResetPasswordDetails';
import { useStyles } from './NewUser.styles';
import useResetPassword from '../../../hooks/api/getters/useResetPassword/useResetPassword';
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
import ConditionallyRender from '../../common/ConditionallyRender';
import InvalidToken from '../common/InvalidToken/InvalidToken';
import { IAuthStatus } from '../../../interfaces/user';
import AuthOptions from '../common/AuthOptions/AuthOptions';
import DividerText from '../../common/DividerText/DividerText';
import { useAuthDetails } from '../../../hooks/api/getters/useAuth/useAuthDetails';
interface INewUserProps {
user: IAuthStatus;
}
const NewUser = ({ user }: INewUserProps) => {
export const NewUser = () => {
const { authDetails } = useAuthDetails();
const { token, data, loading, setLoading, invalidToken } =
useResetPassword();
const ref = useLoading(loading);
@ -75,10 +70,9 @@ const NewUser = ({ user }: INewUserProps) => {
/>
<div className={styles.roleContainer}>
<ConditionallyRender
condition={
user?.authDetails?.options?.length >
0
}
condition={Boolean(
authDetails?.options?.length
)}
show={
<>
<DividerText
@ -88,8 +82,7 @@ const NewUser = ({ user }: INewUserProps) => {
<AuthOptions
options={
user?.authDetails
?.options
authDetails?.options
}
/>
<DividerText
@ -116,5 +109,3 @@ const NewUser = ({ user }: INewUserProps) => {
</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 {
LOGIN_BUTTON,
LOGIN_PASSWORD_ID,
LOGIN_EMAIL_ID,
LOGIN_PASSWORD_ID,
} from '../../../testIds';
import useUser from '../../../hooks/api/getters/useUser/useUser';
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 styles = useStyles();
const history = useHistory();
const { refetch } = useUser();
const { refetchUser } = useAuthUser();
const params = useQueryParams();
const [username, setUsername] = useState(params.get('email') || '');
const [password, setPassword] = useState('');
const { passwordAuth } = useAuthApi();
const [errors, setErrors] = useState({
usernameError: '',
passwordError: '',
@ -51,12 +53,9 @@ const PasswordAuth = ({ authDetails, passwordLogin }) => {
return;
}
const user = { username, password };
const path = evt.target.action;
try {
await passwordLogin(path, user);
refetch();
await passwordAuth(authDetails.path, username, password);
refetchUser();
history.push(`/`);
} catch (error) {
if (error.statusCode === 404 || error.statusCode === 400) {
@ -85,7 +84,7 @@ const PasswordAuth = ({ authDetails, passwordLogin }) => {
<ConditionallyRender
condition={!authDetails.defaultHidden}
show={
<form onSubmit={handleSubmit} action={authDetails.path}>
<form onSubmit={handleSubmit}>
<ConditionallyRender
condition={apiError}
show={
@ -169,8 +168,6 @@ const PasswordAuth = ({ authDetails, passwordLogin }) => {
PasswordAuth.propTypes = {
authDetails: PropTypes.object.isRequired,
passwordLogin: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
};
export default PasswordAuth;

View File

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

View File

@ -1,10 +1,11 @@
import { Button } from '@material-ui/core';
import classnames from 'classnames';
import { useCommonStyles } from '../../../../common.styles';
import { IAuthOptions } from '../../../../interfaces/user';
import { ReactComponent as GoogleSvg } from '../../../../assets/icons/google.svg';
import LockRounded from '@material-ui/icons/LockRounded';
import ConditionallyRender from '../../../common/ConditionallyRender';
import { IAuthOptions } from '../../../../hooks/api/getters/useAuth/useAuthEndpoint';
import { SSO_LOGIN_BUTTON } from '../../../../testIds';
interface IAuthOptionProps {
options?: IAuthOptions[];
@ -28,6 +29,7 @@ const AuthOptions = ({ options }: IAuthOptionProps) => {
variant="outlined"
href={o.path}
size="small"
data-test={`${SSO_LOGIN_BUTTON}-${o.type}`}
style={{ height: '40px', color: '#000' }}
startIcon={
<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 {
handleBadRequest,
@ -16,6 +14,12 @@ export interface IUserApiErrors {
validatePassword?: string;
}
interface IUserPayload {
name: string;
email: string;
id?: string;
}
export const ADD_USER_ERROR = 'addUser';
export const UPDATE_USER_ERROR = 'updateUser';
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}>
<DndProvider backend={HTML5Backend}>
<UIProvider>
<AccessProvider store={unleashStore}>
<AccessProvider>
<Router basename={`${getBasePath()}`}>
<ThemeProvider theme={mainTheme}>
<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 {
id: number;
email: string;
@ -43,14 +12,8 @@ export interface IUser {
username?: string;
}
export interface IUserPayload {
name: string;
email: string;
id?: string;
export interface IPermission {
permission: string;
project?: 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 { RECEIVE_ALL_APPLICATIONS, RECEIVE_APPLICATION, UPDATE_APPLICATION_FIELD, DELETE_APPLICATION } from './actions';
import { USER_LOGOUT, USER_LOGIN } from '../user/actions';
function getInitState() {
return fromJS({ list: [], apps: {} });
@ -19,9 +18,6 @@ const store = (state = getInitState(), action) => {
const result = state.removeIn(['list', index]);
return result.removeIn(['apps', action.appName]);
}
case USER_LOGOUT:
case USER_LOGIN:
return getInitState();
default:
return state;
}

View File

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

View File

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

View File

@ -1,6 +1,5 @@
import { List } from 'immutable';
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 }];
@ -22,9 +21,6 @@ const strategies = (state = getInitState(), action) => {
const index = state.findIndex(item => item.id === action.project.id);
return state.set(index, action.project);
}
case USER_LOGOUT:
case USER_LOGIN:
return getInitState();
default:
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_BUTTON = 'LOGIN_BUTTON';
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 */
export const ADD_NEW_STRATEGY_ID = 'ADD_NEW_STRATEGY_ID';

View File

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