1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

Feat/add new user (#273)

* chore: update changelog

* chore: update changelog

* fix: refactor AddUser

* feat: add screens for email and copy

* fix: remove interface

* fix: admin constant in userlist

* chore: fix changelog

* feat: user data fetching with useSWR

* feat: flesh out dialogues

* fix: remove useRequest

* refactor: remove redux for user admin

* refactor: remove from store

* refactor: userListItem

* fix: change type

* feat: add initial loading

* fix: useLayoutEffeect in useLoading

* fix: remove useEffect

* fix: update snapshots

* fix: remove status code

* fix: remove roles from store
This commit is contained in:
Fredrik Strand Oseberg 2021-04-23 10:59:11 +02:00 committed by GitHub
parent a82feadf01
commit 0ca753e7e5
39 changed files with 1333 additions and 553 deletions

View File

@ -460,4 +460,4 @@ The latest version of this document is always available in
## [2.1.0] - 2017-01-20
- Adjust header #51 #52
- Adjust header #51 #52

View File

@ -3,7 +3,12 @@ import { makeStyles } from '@material-ui/styles';
export const useCommonStyles = makeStyles(theme => ({
contentSpacingY: {
'& > *': {
margin: '0.6rem 0',
margin: '0.5rem 0',
},
},
contentSpacingYLarge: {
'& > *': {
margin: '1.5rem 0',
},
},
contentSpacingX: {
@ -26,6 +31,7 @@ export const useCommonStyles = makeStyles(theme => ({
},
flexRow: {
display: 'flex',
alignItems: 'center',
},
flexColumn: {
display: 'flex',

View File

@ -9,9 +9,9 @@ import { routes } from './menu/routes';
import styles from './styles.module.scss';
import IUser from '../interfaces/user';
import IAuthStatus from '../interfaces/user';
interface IAppProps extends RouteComponentProps {
user: IUser;
user: IAuthStatus;
}
const App = ({ location, user }: IAppProps) => {

View File

@ -144,7 +144,7 @@ exports[`renders correctly with permissions 1`] = `
</div>
<div>
<div
className="MuiPaper-root makeStyles-tabNav-8 MuiPaper-elevation1 MuiPaper-rounded"
className="MuiPaper-root makeStyles-tabNav-10 MuiPaper-elevation1 MuiPaper-rounded"
>
<div
className="MuiTabs-root"
@ -192,7 +192,7 @@ exports[`renders correctly with permissions 1`] = `
Application overview
</span>
<span
className="PrivateTabIndicator-root-9 PrivateTabIndicator-colorPrimary-10 MuiTabs-indicator"
className="PrivateTabIndicator-root-11 PrivateTabIndicator-colorPrimary-12 MuiTabs-indicator"
style={Object {}}
/>
</button>
@ -272,7 +272,7 @@ exports[`renders correctly with permissions 1`] = `
>
<span
aria-disabled={true}
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-13 MuiSwitch-switchBase MuiSwitch-colorSecondary PrivateSwitchBase-disabled-15 Mui-disabled Mui-disabled Mui-disabled"
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-15 MuiSwitch-switchBase MuiSwitch-colorSecondary PrivateSwitchBase-disabled-17 Mui-disabled Mui-disabled Mui-disabled"
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
@ -290,7 +290,7 @@ exports[`renders correctly with permissions 1`] = `
className="MuiIconButton-label"
>
<input
className="PrivateSwitchBase-input-16 MuiSwitch-input"
className="PrivateSwitchBase-input-18 MuiSwitch-input"
disabled={true}
onChange={[Function]}
type="checkbox"

View File

@ -1,7 +1,14 @@
import React from 'react';
import { Dialog, DialogTitle, DialogActions, DialogContent, Button } from '@material-ui/core';
import {
Dialog,
DialogTitle,
DialogActions,
DialogContent,
Button,
} from '@material-ui/core';
import PropTypes from 'prop-types';
import ConditionallyRender from '../ConditionallyRender/ConditionallyRender';
import { useStyles } from './Dialogue.styles';
const ConfirmDialogue = ({
children,
@ -12,25 +19,53 @@ const ConfirmDialogue = ({
primaryButtonText,
secondaryButtonText,
fullWidth = false,
}) => (
<Dialog
open={open}
onClose={onClose}
fullWidth={fullWidth}
aria-labelledby={'simple-modal-title'}
aria-describedby={'simple-modal-description'}
>
<DialogTitle>{title}</DialogTitle>
<ConditionallyRender condition={children} show={<DialogContent>{children}</DialogContent>} />
}) => {
const styles = useStyles();
return (
<Dialog
open={open}
onClose={onClose}
fullWidth={fullWidth}
aria-labelledby={'simple-modal-title'}
aria-describedby={'simple-modal-description'}
>
<DialogTitle className={styles.dialogTitle}>{title}</DialogTitle>
<ConditionallyRender
condition={children}
show={
<DialogContent className={styles.dialogContentPadding}>
{children}
</DialogContent>
}
/>
<DialogActions>
<Button color="primary" onClick={onClick} autoFocus>
{primaryButtonText || "Yes, I'm sure"}
</Button>
<Button onClick={onClose}>{secondaryButtonText || 'No take me back.'} </Button>
</DialogActions>
</Dialog>
);
<DialogActions>
<ConditionallyRender
condition={onClick}
show={
<Button
color="primary"
variant="contained"
onClick={onClick}
autoFocus
>
{primaryButtonText || "Yes, I'm sure"}
</Button>
}
/>
<ConditionallyRender
condition={onClose}
show={
<Button onClick={onClose}>
{secondaryButtonText || 'No take me back.'}{' '}
</Button>
}
/>
</DialogActions>
</Dialog>
);
};
ConfirmDialogue.propTypes = {
primaryButtonText: PropTypes.string,

View File

@ -0,0 +1,14 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
dialogTitle: {
backgroundColor: theme.palette.primary.main,
color: theme.palette.dialogue.title.main,
height: '150px',
padding: '2rem 3rem',
clipPath: ' ellipse(130% 115px at 120% 20%)',
},
dialogContentPadding: {
padding: '2rem 3rem',
},
}));

View File

@ -473,10 +473,10 @@ exports[`renders correctly with with variants 1`] = `
</svg>
<fieldset
aria-hidden={true}
className="PrivateNotchedOutline-root-13 MuiOutlinedInput-notchedOutline"
className="PrivateNotchedOutline-root-16 MuiOutlinedInput-notchedOutline"
>
<legend
className="PrivateNotchedOutline-legendLabelled-15 PrivateNotchedOutline-legendNotched-16"
className="PrivateNotchedOutline-legendLabelled-18 PrivateNotchedOutline-legendNotched-19"
>
<span>
Stickiness

View File

@ -140,10 +140,10 @@ exports[`renders correctly with one feature 1`] = `
</svg>
<fieldset
aria-hidden={true}
className="PrivateNotchedOutline-root-13 MuiOutlinedInput-notchedOutline"
className="PrivateNotchedOutline-root-14 MuiOutlinedInput-notchedOutline"
>
<legend
className="PrivateNotchedOutline-legendLabelled-15"
className="PrivateNotchedOutline-legendLabelled-16"
>
<span>
Project
@ -175,7 +175,7 @@ exports[`renders correctly with one feature 1`] = `
>
<span
aria-disabled={false}
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-17 MuiSwitch-switchBase MuiSwitch-colorSecondary"
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-18 MuiSwitch-switchBase MuiSwitch-colorSecondary"
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
@ -194,7 +194,7 @@ exports[`renders correctly with one feature 1`] = `
>
<input
checked={false}
className="PrivateSwitchBase-input-20 MuiSwitch-input"
className="PrivateSwitchBase-input-21 MuiSwitch-input"
disabled={false}
onChange={[Function]}
type="checkbox"
@ -318,7 +318,7 @@ exports[`renders correctly with one feature 1`] = `
</div>
<hr />
<div
className="MuiPaper-root makeStyles-tabNav-21 MuiPaper-elevation1 MuiPaper-rounded"
className="MuiPaper-root makeStyles-tabNav-22 MuiPaper-elevation1 MuiPaper-rounded"
>
<div
className="MuiTabs-root"
@ -366,7 +366,7 @@ exports[`renders correctly with one feature 1`] = `
Activation
</span>
<span
className="PrivateTabIndicator-root-22 PrivateTabIndicator-colorPrimary-23 MuiTabs-indicator"
className="PrivateTabIndicator-root-23 PrivateTabIndicator-colorPrimary-24 MuiTabs-indicator"
style={Object {}}
/>
</button>

View File

@ -6,6 +6,7 @@ export const useStyles = makeStyles(theme => ({
borderRadius: '3px',
right: '100px',
color: '#44606e',
maxWidth: '350px',
},
headerContainer: { display: 'flex', padding: '0.5rem' },
divider: {

View File

@ -1,3 +1,5 @@
export const BAD_REQUEST = 400;
export const OK = 200;
export const NOT_FOUND = 404;
export const FORBIDDEN = 403;
export const UNAUTHORIZED = 401;

View File

@ -0,0 +1,167 @@
import { useState } from 'react';
import {
BAD_REQUEST,
FORBIDDEN,
NOT_FOUND,
OK,
UNAUTHORIZED,
} from '../constants/statusCodes';
import { IUserPayload } from '../interfaces/user';
import {
AuthenticationError,
ForbiddenError,
headers,
NotFoundError,
} from '../store/api-helper';
export interface IUserApiErrors {
addUser?: string;
removeUser?: string;
updateUser?: string;
changePassword?: string;
validatePassword?: string;
}
export const ADD_USER_ERROR = 'addUser';
export const UPDATE_USER_ERROR = 'updateUser';
export const REMOVE_USER_ERROR = 'removeUser';
export const CHANGE_PASSWORD_ERROR = 'changePassword';
export const VALIDATE_PASSWORD_ERROR = 'validatePassword';
const useAdminUsersApi = () => {
const [userApiErrors, setUserApiErrors] = useState({});
const [userLoading, setUserLoading] = useState(false);
const defaultOptions: RequestInit = {
headers,
credentials: 'include',
};
const makeRequest = async (
apiCaller: any,
type: string
): Promise<Response> => {
setUserLoading(true);
try {
const res = await apiCaller();
setUserLoading(false);
if (res.status > 299) {
await handleResponses(res, type);
}
if (res.status === OK) {
setUserApiErrors({});
}
return res;
} catch (e) {
setUserLoading(false);
throw e;
}
};
const addUser = async (user: IUserPayload) => {
return makeRequest(() => {
return fetch('api/admin/user-admin', {
...defaultOptions,
method: 'POST',
body: JSON.stringify(user),
});
}, 'addUser');
};
const removeUser = async (user: IUserPayload) => {
return makeRequest(() => {
return fetch(`api/admin/user-admin/${user.id}`, {
...defaultOptions,
method: 'DELETE',
});
}, 'removeUser');
};
const updateUser = async (user: IUserPayload) => {
return makeRequest(() => {
return fetch(`api/admin/user-admin/${user.id}`, {
...defaultOptions,
method: 'PUT',
body: JSON.stringify(user),
});
}, 'updateUser');
};
const changePassword = async (user: IUserPayload, password: string) => {
return makeRequest(() => {
return fetch(`api/admin/user-admin/${user.id}/change-password`, {
...defaultOptions,
method: 'POST',
body: JSON.stringify({ password }),
});
}, 'changePassword');
};
const validatePassword = async (password: string) => {
return makeRequest(() => {
return fetch(`api/admin/user-admin/validate-password`, {
...defaultOptions,
method: 'POST',
body: JSON.stringify({ password }),
});
}, 'validatePassword');
};
const handleResponses = async (res: Response, type: string) => {
if (res.status === BAD_REQUEST) {
const data = await res.json();
setUserApiErrors(prev => ({
...prev,
[type]: data[0].msg,
}));
throw new Error();
}
if (res.status === NOT_FOUND) {
setUserApiErrors(prev => ({
...prev,
[type]: 'Could not find the requested resource.',
}));
throw new NotFoundError(res.status);
}
if (res.status === UNAUTHORIZED) {
const data = await res.json();
setUserApiErrors(prev => ({
...prev,
[type]: data[0].msg,
}));
throw new AuthenticationError(res.status);
}
if (res.status === FORBIDDEN) {
const data = await res.json();
setUserApiErrors(prev => ({
...prev,
[type]: data[0].msg,
}));
throw new ForbiddenError(res.status);
}
};
return {
addUser,
updateUser,
removeUser,
changePassword,
validatePassword,
userApiErrors,
userLoading,
};
};
export default useAdminUsersApi;

View File

@ -1,10 +1,10 @@
import { useEffect, createRef } from 'react';
import { createRef, useLayoutEffect } from 'react';
type refElement = HTMLDivElement;
const useLoading = (loading: boolean) => {
const ref = createRef<refElement>();
useEffect(() => {
useLayoutEffect(() => {
if (ref.current) {
const elements = ref.current.querySelectorAll('[data-loading]');

View File

@ -0,0 +1,30 @@
import useSWR, { mutate } from 'swr';
import { useState, useEffect } from 'react';
const useUsers = () => {
const fetcher = () =>
fetch(`api/admin/user-admin`, {
method: 'GET',
}).then(res => res.json());
const { data, error } = useSWR(`api/admin/user-admin`, fetcher);
const [loading, setLoading] = useState(!error && !data);
const refetch = () => {
mutate(`api/admin/user-admin`);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
users: data?.users || [],
roles: data?.rootRoles || [],
error,
loading,
refetch,
};
};
export default useUsers;

View File

@ -0,0 +1,17 @@
<svg width="197" height="177" viewBox="0 0 197 177" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path d="M195.272 67.6884C195.201 67.6884 195.132 67.6668 195.074 67.6264L99.4918 1.00044C99.2009 0.798626 98.8553 0.690774 98.5013 0.691378C98.1474 0.691982 97.8021 0.801012 97.5119 1.00382L2.61779 67.6257C2.54273 67.6784 2.44984 67.6991 2.35954 67.6832C2.26924 67.6674 2.18893 67.6163 2.13629 67.5411C2.08364 67.466 2.06297 67.373 2.07881 67.2826C2.09466 67.1923 2.14573 67.1119 2.22079 67.0592L97.115 0.437299C97.5213 0.153417 98.0047 0.000817369 98.5002 2.3283e-06C98.9957 -0.000812712 99.4796 0.150195 99.8868 0.432738L195.469 67.0585C195.53 67.1006 195.575 67.1609 195.599 67.2306C195.623 67.3004 195.624 67.3758 195.602 67.4461C195.58 67.5164 195.536 67.5778 195.476 67.6214C195.417 67.6649 195.345 67.6884 195.272 67.6884L195.272 67.6884Z" fill="#3F3D56"/>
<path d="M8.04028 70.0501L98.5953 2.87773L189.837 74.8297L103.261 126.199L56.2576 115.476L8.04028 70.0501Z" fill="#E6E6E6"/>
<path d="M60.2251 157.928H15.4887C15.214 157.928 14.942 157.875 14.6881 157.77C14.4342 157.665 14.2035 157.511 14.0091 157.316C13.8147 157.122 13.6606 156.891 13.5554 156.637C13.4502 156.383 13.396 156.111 13.396 155.836C13.396 155.561 13.4502 155.289 13.5554 155.035C13.6606 154.781 13.8147 154.55 14.0091 154.356C14.2035 154.162 14.4342 154.008 14.6881 153.903C14.942 153.798 15.214 153.744 15.4887 153.744H60.2251C60.4998 153.744 60.7719 153.798 61.0258 153.903C61.2797 154.008 61.5104 154.162 61.7048 154.356C61.8991 154.55 62.0533 154.781 62.1585 155.035C62.2637 155.289 62.3179 155.561 62.3179 155.836C62.3179 156.111 62.2637 156.383 62.1585 156.637C62.0533 156.891 61.8991 157.122 61.7048 157.316C61.5104 157.511 61.2797 157.665 61.0258 157.77C60.7719 157.875 60.4998 157.928 60.2251 157.928Z" fill="#607D8B"/>
<path d="M31.5392 148.934H15.4887C15.214 148.934 14.942 148.88 14.6881 148.776C14.4342 148.671 14.2035 148.516 14.0091 148.322C13.8147 148.128 13.6606 147.897 13.5554 147.643C13.4502 147.389 13.396 147.117 13.396 146.842C13.396 146.567 13.4502 146.295 13.5554 146.041C13.6606 145.787 13.8147 145.556 14.0091 145.362C14.2035 145.168 14.4342 145.014 14.6881 144.909C14.942 144.804 15.214 144.75 15.4887 144.75H31.5392C31.8139 144.75 32.0859 144.804 32.3398 144.909C32.5937 145.014 32.8244 145.168 33.0188 145.362C33.2132 145.556 33.3674 145.787 33.4726 146.041C33.5778 146.295 33.6319 146.567 33.6319 146.842C33.6319 147.117 33.5778 147.389 33.4726 147.643C33.3674 147.897 33.2132 148.128 33.0188 148.322C32.8244 148.516 32.5937 148.671 32.3398 148.776C32.0859 148.88 31.8139 148.934 31.5392 148.934Z" fill="#607D8B"/>
<path d="M99.8687 107.464C99.5287 107.465 99.1918 107.399 98.8771 107.27L43.0291 84.0761V11.4759C43.0298 10.8339 43.2849 10.2184 43.7385 9.76446C44.192 9.31051 44.8069 9.05515 45.4484 9.0544H152.589C153.23 9.05515 153.845 9.31051 154.299 9.76446C154.752 10.2184 155.007 10.8339 155.008 11.4759V84.1267L154.903 84.1717L100.89 107.258C100.567 107.394 100.22 107.464 99.8687 107.464Z" fill="white"/>
<path d="M99.8687 107.637C99.5061 107.637 99.1469 107.567 98.8112 107.43L42.8562 84.1916V11.4759C42.857 10.788 43.1303 10.1286 43.6163 9.64218C44.1022 9.1558 44.7611 8.88221 45.4483 8.88143H152.589C153.276 8.88221 153.935 9.1558 154.421 9.64218C154.907 10.1286 155.18 10.788 155.181 11.4759V84.2409L100.958 107.417C100.613 107.563 100.243 107.637 99.8687 107.637ZM43.5474 83.7295L99.0748 106.79C99.5928 107.001 100.173 106.997 100.688 106.781L154.49 83.7842V11.4759C154.489 10.9714 154.288 10.4879 153.932 10.1312C153.576 9.77452 153.093 9.57387 152.589 9.57328H45.4483C44.9444 9.57387 44.4612 9.77452 44.1049 10.1312C43.7485 10.4879 43.548 10.9714 43.5474 11.4759L43.5474 83.7295Z" fill="#3F3D56"/>
<path d="M194.581 66.9965H194.512L154.835 83.9537L100.55 107.155C100.337 107.244 100.11 107.291 99.8797 107.292C99.6496 107.293 99.4215 107.249 99.2084 107.162L43.2018 83.9052L2.55059 67.0242L2.48849 66.9965H2.4193C1.77788 66.9972 1.16293 67.2526 0.70938 67.7066C0.255828 68.1605 0.000711706 68.776 0 69.418V174.579C0.000712619 175.221 0.255828 175.836 0.70938 176.29C1.16293 176.744 1.77788 176.999 2.4193 177H194.581C195.222 176.999 195.837 176.744 196.291 176.29C196.744 175.836 196.999 175.221 197 174.579V69.418C196.999 68.776 196.744 68.1605 196.291 67.7066C195.837 67.2526 195.222 66.9972 194.581 66.9965ZM196.309 174.579C196.309 175.037 196.126 175.477 195.802 175.801C195.478 176.126 195.039 176.308 194.581 176.308H2.4193C1.96104 176.308 1.52161 176.126 1.19757 175.801C0.873536 175.477 0.691411 175.037 0.691228 174.579V69.418C0.691855 68.9707 0.865197 68.541 1.17501 68.2187C1.48483 67.8963 1.90713 67.7063 2.35365 67.6884L43.2018 84.6524L98.9425 107.802C99.5459 108.047 100.222 108.044 100.823 107.791L154.835 84.7043L194.65 67.6884C195.096 67.7076 195.517 67.8982 195.826 68.2203C196.135 68.5425 196.308 68.9714 196.309 69.418V174.579Z" fill="#3F3D56"/>
<path d="M99.3104 86.8775C97.2487 86.8813 95.2416 86.214 93.5919 84.9762L93.4896 84.8994L71.9541 68.3963C70.9567 67.6314 70.1197 66.6774 69.4907 65.5888C68.8617 64.5001 68.4531 63.2981 68.2882 62.0514C68.1233 60.8047 68.2054 59.5377 68.5297 58.3228C68.8541 57.1078 69.4143 55.9687 70.1785 54.9704C70.9426 53.9722 71.8958 53.1344 72.9835 52.5048C74.0711 51.8753 75.2721 51.4663 76.5177 51.3013C77.7632 51.1362 79.0291 51.2184 80.243 51.543C81.4569 51.8676 82.595 52.4284 83.5923 53.1932L97.5414 63.8988L130.505 20.8706C131.27 19.8726 132.223 19.0351 133.311 18.4059C134.399 17.7767 135.6 17.3682 136.846 17.2036C138.091 17.039 139.357 17.1216 140.571 17.4467C141.785 17.7718 142.923 18.333 143.92 19.0982L143.715 19.3767L143.925 19.1023C145.936 20.6494 147.252 22.9319 147.584 25.449C147.916 27.9661 147.237 30.5122 145.696 32.5287L106.922 83.1402C106.026 84.3065 104.872 85.2503 103.552 85.8984C102.232 86.5466 100.781 86.8816 99.3104 86.8775Z" fill="#607D8B"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="197" height="177" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@ -0,0 +1,9 @@
interface IRole {
id: number;
name: string;
project: string | null;
description: string;
type: string;
}
export default IRole;

View File

@ -1,7 +1,7 @@
interface IUser {
interface IAuthStatus {
authDetails: IAuthDetails;
showDialog: boolean;
profile?: IProfile;
profile?: IUser;
}
interface IAuthDetails {
@ -11,14 +11,28 @@ interface IAuthDetails {
options: string[];
}
interface IProfile {
export interface IUser {
id: number;
email: string;
name: string;
createdAt: string;
imageUrl: string;
loginAttempts: number;
permissions: string[];
seenAt: string;
username: string;
permissions: string[] | null;
inviteLink: string;
rootRole: number;
seenAt: string | null;
username?: string;
}
export default IUser;
export interface IUserPayload {
name: string;
email: string;
id?: string;
}
export interface IAddedUser extends IUser {
emailSent?: boolean;
}
export default IAuthStatus;

View File

@ -0,0 +1,88 @@
import { useState } from 'react';
import Dialogue from '../../../../component/common/Dialogue';
import { IUserApiErrors } from '../../../../hooks/useAdminUsersApi';
import IRole from '../../../../interfaces/role';
import AddUserForm from './AddUserForm/AddUserForm';
interface IAddUserProps {
showDialog: boolean;
closeDialog: () => void;
addUser: (data: any) => any;
validatePassword: () => boolean;
userLoading: boolean;
userApiErrors: IUserApiErrors;
roles: IRole[];
}
interface IAddUserFormData {
name: string;
email: string;
rootRole: number;
}
const EDITOR_ROLE_ID = 2;
const initialData = { email: '', name: '', rootRole: EDITOR_ROLE_ID };
const AddUser = ({
showDialog,
closeDialog,
userLoading,
addUser,
userApiErrors,
roles,
}: IAddUserProps) => {
const [data, setData] = useState<IAddUserFormData>(initialData);
const [error, setError] = useState({});
const submit = async (e: Event) => {
e.preventDefault();
if (!data.email) {
setError({ general: 'You must specify the email address' });
return;
}
if (!data.rootRole) {
setError({ general: 'You must specify a role for the user' });
return;
}
await addUser(data);
setData(initialData);
setError({});
};
const onCancel = (e: Event) => {
e.preventDefault();
setData(initialData);
setError({});
closeDialog();
};
return (
<Dialogue
onClick={e => {
submit(e);
}}
open={showDialog}
onClose={onCancel}
primaryButtonText="Add user"
secondaryButtonText="Cancel"
title="Add team member"
fullWidth
>
<AddUserForm
userApiErrors={userApiErrors}
data={data}
setData={setData}
roles={roles}
submit={submit}
error={error}
userLoading={userLoading}
/>
</Dialogue>
);
};
export default AddUser;

View File

@ -0,0 +1,175 @@
import PropTypes from 'prop-types';
import classnames from 'classnames';
import {
TextField,
DialogContent,
RadioGroup,
Radio,
FormControl,
FormControlLabel,
Typography,
} from '@material-ui/core';
import { trim } from '../../../../../component/common/util';
import { useCommonStyles } from '../../../../../common.styles';
import ConditionallyRender from '../../../../../component/common/ConditionallyRender';
import { useStyles } from './AddUserForm.styles';
import useLoading from '../../../../../hooks/useLoading';
import {
ADD_USER_ERROR,
UPDATE_USER_ERROR,
} from '../../../../../hooks/useAdminUsersApi';
import { Alert } from '@material-ui/lab';
function AddUserForm({
submit,
data,
error,
setData,
roles,
userLoading,
userApiErrors,
}) {
const ref = useLoading(userLoading);
const commonStyles = useCommonStyles();
const styles = useStyles();
const updateField = e => {
setData({
...data,
[e.target.name]: e.target.value,
});
};
const updateFieldWithTrim = e => {
setData({
...data,
[e.target.name]: trim(e.target.value),
});
};
const updateNumberField = e => {
setData({
...data,
[e.target.name]: +e.target.value,
});
};
const apiError =
userApiErrors[ADD_USER_ERROR] || userApiErrors[UPDATE_USER_ERROR];
return (
<div ref={ref}>
<form onSubmit={submit}>
<DialogContent>
<ConditionallyRender
condition={apiError}
show={
<Alert
className={styles.errorAlert}
severity="error"
data-loading
>
{apiError}
</Alert>
}
/>
<div
className={classnames(
commonStyles.contentSpacingY,
commonStyles.flexColumn,
styles.userInfoContainer
)}
>
<Typography variant="subtitle1" data-loading>
Who is your team member?
</Typography>
<ConditionallyRender
condition={error.general}
show={
<p data-loading style={{ color: 'red' }}>
{error.general}
</p>
}
/>
<TextField
label="Full name"
data-loading
name="name"
value={data.name || ''}
error={error.name !== undefined}
helperText={error.name}
type="name"
variant="outlined"
size="small"
onChange={updateField}
/>
<TextField
label="Email"
data-loading
name="email"
required
value={data.email || ''}
error={error.email !== undefined}
helperText={error.email}
variant="outlined"
size="small"
type="email"
onChange={updateFieldWithTrim}
/>
<br />
<br />
</div>
<FormControl>
<Typography
variant="subtitle1"
className={styles.roleSubtitle}
data-loading
>
What is your team member allowed to do?
</Typography>
<RadioGroup
name="rootRole"
value={data.rootRole || ''}
onChange={updateNumberField}
data-loading
>
{roles.map(role => (
<FormControlLabel
key={`role-${role.id}`}
labelPlacement="end"
className={styles.roleBox}
label={
<div>
<strong>{role.name}</strong>
<Typography variant="body2">
{role.description}
</Typography>
</div>
}
control={
<Radio
checked={role.id === data.rootRole}
className={styles.roleRadio}
/>
}
value={role.id}
/>
))}
</RadioGroup>
</FormControl>
</DialogContent>
</form>
</div>
);
}
AddUserForm.propTypes = {
data: PropTypes.object.isRequired,
error: PropTypes.object.isRequired,
submit: PropTypes.func.isRequired,
setData: PropTypes.func.isRequired,
roles: PropTypes.array.isRequired,
};
export default AddUserForm;

View File

@ -0,0 +1,21 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
roleBox: {
margin: '3px 0',
border: '1px solid #EFEFEF',
padding: '1rem',
},
userInfoContainer: {
margin: '-20px 0',
},
roleRadio: {
marginRight: '15px',
},
roleSubtitle: {
marginBottom: '0.5rem',
},
errorAlert: {
marginBottom: '1rem',
},
}));

View File

@ -0,0 +1,30 @@
import ConfirmUserEmail from './ConfirmUserEmail/ConfirmUserEmail';
import ConfirmUserLink from './ConfirmUserLink/ConfirmUserLink';
interface IConfirmUserAddedProps {
open: boolean;
closeConfirm: () => void;
inviteLink: string;
emailSent: boolean;
}
const ConfirmUserAdded = ({
open,
closeConfirm,
emailSent,
inviteLink,
}: IConfirmUserAddedProps) => {
if (emailSent) {
return <ConfirmUserEmail open={open} closeConfirm={closeConfirm} />;
}
return (
<ConfirmUserLink
open={open}
closeConfirm={closeConfirm}
inviteLink={inviteLink}
/>
);
};
export default ConfirmUserAdded;

View File

@ -0,0 +1,11 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles({
iconContainer: {
width: '100%',
textAlign: 'center',
},
emailIcon: {
margin: '2rem auto',
},
});

View File

@ -0,0 +1,33 @@
import { Typography } from '@material-ui/core';
import Dialogue from '../../../../../component/common/Dialogue';
import { ReactComponent as EmailIcon } from '../../../../../icons/email.svg';
import { useStyles } from './ConfirmUserEmail.styles';
interface IConfirmUserEmailProps {
open: boolean;
closeConfirm: () => void;
}
const ConfirmUserEmail = ({ open, closeConfirm }: IConfirmUserEmailProps) => {
const styles = useStyles();
return (
<Dialogue
open={open}
title="Team member added"
primaryButtonText="Close"
onClick={closeConfirm}
>
<Typography>
A new team member has been added. Weve sent an email on your
behalf to inform them of their new account and role. No further
steps are required.
</Typography>
<div className={styles.iconContainer}>
<EmailIcon className={styles.emailIcon} />
</div>
</Dialogue>
);
};
export default ConfirmUserEmail;

View File

@ -0,0 +1,58 @@
import { Typography } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import { useCommonStyles } from '../../../../../common.styles';
import Dialogue from '../../../../../component/common/Dialogue';
import UserInviteLink from './UserInviteLink/UserInviteLink';
interface IConfirmUserLink {
open: boolean;
closeConfirm: () => void;
inviteLink: string;
}
const ConfirmUserLink = ({
open,
closeConfirm,
inviteLink,
}: IConfirmUserLink) => {
const commonStyles = useCommonStyles();
return (
<Dialogue
open={open}
onClick={closeConfirm}
primaryButtonText="Close"
title="Team member added"
>
<div className={commonStyles.contentSpacingYLarge}>
<Typography variant="body1">
A new team member has been added. Please provide them with
the following link to get started:
</Typography>
<UserInviteLink inviteLink={inviteLink} />
<Typography variant="body1">
Copy the link and send it to the user. This will allow them
to set up their password and get started with their Unleash
account.
</Typography>
<Alert severity="info">
<Typography variant="body2">
Want to avoid this step in the future?{' '}
{/* TODO - ADD LINK HERE ONCE IT EXISTS*/}
<a
href="https://docs.getunleash.ai/"
target="_blank"
rel="noopener noreferrer"
>
If you configure an email server for Unleash
</a>{' '}
we'll automatically send informational getting started
emails to new users once you add them.
</Typography>
</Alert>
</div>
</Dialogue>
);
};
export default ConfirmUserLink;

View File

@ -0,0 +1,73 @@
import { useState } from 'react';
import { IconButton } from '@material-ui/core';
import CopyIcon from '@material-ui/icons/FileCopy';
import { Snackbar } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
interface IInviteLinkProps {
inviteLink: string;
}
interface ISnackbar {
show: boolean;
type: 'success' | 'error';
text: string;
}
const UserInviteLink = ({ inviteLink }: IInviteLinkProps) => {
const [snackbar, setSnackbar] = useState<ISnackbar>({
show: false,
type: 'success',
text: '',
});
const handleCopy = () => {
return navigator.clipboard
.writeText(inviteLink)
.then(() => {
setSnackbar({
show: true,
type: 'success',
text: 'Successfully copied invite link.',
});
})
.catch(() => {
setSnackbar({
show: true,
type: 'error',
text: 'Could not copy invite link.',
});
});
};
return (
<div
style={{
backgroundColor: '#efefef',
padding: '2rem',
borderRadius: '3px',
margin: '1rem 0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
wordBreak: 'break-all',
}}
>
{inviteLink}
<IconButton onClick={handleCopy}>
<CopyIcon />
</IconButton>
<Snackbar
open={snackbar.show}
autoHideDuration={6000}
onClose={() =>
setSnackbar({ show: false, type: 'success', text: '' })
}
>
<Alert severity={snackbar.type}>{snackbar.text}</Alert>
</Snackbar>
</div>
);
};
export default UserInviteLink;

View File

@ -0,0 +1,108 @@
import {
TableRow,
TableCell,
Avatar,
IconButton,
Icon,
Typography,
} from '@material-ui/core';
import { SyntheticEvent, useContext } from 'react';
import { ADMIN } from '../../../../../component/AccessProvider/permissions';
import ConditionallyRender from '../../../../../component/common/ConditionallyRender';
import { formatDateWithLocale } from '../../../../../component/common/util';
import AccessContext from '../../../../../contexts/AccessContext';
import { IUser } from '../../../../../interfaces/user';
interface IUserListItemProps {
user: IUser;
renderRole: (roleId: number) => string;
openUpdateDialog: (user: IUser) => (e: SyntheticEvent) => void;
openPwDialog: (user: IUser) => (e: SyntheticEvent) => void;
openDelDialog: (user: IUser) => (e: SyntheticEvent) => void;
location: ILocation;
}
interface ILocation {
locale: string;
}
const UserListItem = ({
user,
renderRole,
openDelDialog,
openPwDialog,
openUpdateDialog,
location,
}: IUserListItemProps) => {
const { hasAccess } = useContext(AccessContext);
return (
<TableRow key={user.id}>
<TableCell>
<Avatar
data-loading
variant="rounded"
alt={user.name}
src={user.imageUrl}
title={`${user.name || user.email || user.username} (id: ${
user.id
})`}
/>
</TableCell>
<TableCell>
<span data-loading>
{formatDateWithLocale(user.createdAt, location.locale)}
</span>
</TableCell>
<TableCell style={{ textAlign: 'left' }}>
<Typography variant="body2" data-loading>
{user.name}
</Typography>
</TableCell>
<TableCell style={{ textAlign: 'left' }}>
<Typography variant="body2" data-loading>
{user.username || user.email}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="body2" data-loading>
{renderRole(user.rootRole)}
</Typography>
</TableCell>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={
<TableCell align="right">
<IconButton
data-loading
aria-label="Edit"
title="Edit"
onClick={openUpdateDialog(user)}
>
<Icon>edit</Icon>
</IconButton>
<IconButton
data-loading
aria-label="Change password"
title="Change password"
onClick={openPwDialog(user)}
>
<Icon>lock</Icon>
</IconButton>
<IconButton
data-loading
aria-label="Remove user"
title="Remove user"
onClick={openDelDialog(user)}
>
<Icon>delete</Icon>
</IconButton>
</TableCell>
}
elseShow={<TableCell />}
/>
</TableRow>
);
};
export default UserListItem;

View File

@ -1,33 +1,50 @@
/* eslint-disable no-alert */
import React, { useContext, useEffect, useState } from 'react';
import { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { Button, Icon, IconButton, Table, TableBody, TableCell, TableHead, TableRow, Avatar } from '@material-ui/core';
import { formatDateWithLocale } from '../../../../component/common/util';
import AddUser from '../add-user-component';
import {
Button,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from '@material-ui/core';
import AddUser from '../AddUser/AddUser';
import ChangePassword from '../change-password-component';
import UpdateUser from '../update-user-component';
import DelUser from '../del-user-component';
import ConditionallyRender from '../../../../component/common/ConditionallyRender/ConditionallyRender';
import AccessContext from '../../../../contexts/AccessContext';
import { ADMIN } from '../../../../component/AccessProvider/permissions';
import ConfirmUserAdded from '../ConfirmUserAdded/ConfirmUserAdded';
import useUsers from '../../../../hooks/useUsers';
import useAdminUsersApi from '../../../../hooks/useAdminUsersApi';
import UserListItem from './UserListItem/UserListItem';
import loadingData from './loadingData';
import useLoading from '../../../../hooks/useLoading';
function UsersList({
roles,
fetchUsers,
removeUser,
addUser,
updateUser,
changePassword,
users,
location,
validatePassword,
}) {
function UsersList({ location }) {
const { users, roles, refetch, loading } = useUsers();
const {
addUser,
removeUser,
updateUser,
changePassword,
validatePassword,
userLoading,
userApiErrors,
} = useAdminUsersApi();
const { hasAccess } = useContext(AccessContext);
const [showDialog, setDialog] = useState(false);
const [pwDialog, setPwDialog] = useState({ open: false });
const [delDialog, setDelDialog] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [emailSent, setEmailSent] = useState(false);
const [inviteLink, setInviteLink] = useState('');
const [delUser, setDelUser] = useState();
const [updateDialog, setUpdateDialog] = useState({ open: false });
const ref = useLoading(loading);
const openDialog = e => {
e.preventDefault();
setDialog(true);
@ -65,18 +82,85 @@ function UsersList({
setUpdateDialog({ open: false });
};
useEffect(() => {
fetchUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onAddUser = data => {
addUser(data)
.then(res => res.json())
.then(user => {
setEmailSent(user.emailSent);
setInviteLink(user.inviteLink);
closeDialog();
refetch();
setShowConfirm(true);
})
.catch(handleCatch);
};
const onDeleteUser = () => {
removeUser(delUser)
.then(() => {
refetch();
closeDelDialog();
})
.catch(handleCatch);
};
const onUpdateUser = data => {
updateUser(data)
.then(() => {
refetch();
closeUpdateDialog();
})
.catch(handleCatch);
};
const handleCatch = () =>
console.log('An exception was thrown and handled.');
const closeConfirm = () => {
setShowConfirm(false);
setEmailSent(false);
setInviteLink('');
};
const renderRole = roleId => {
const role = roles.find(r => r.id === roleId);
return role ? role.name : '';
}
};
const renderUsers = () => {
if (loading) {
return loadingData.map(user => (
<UserListItem
key={user.id}
user={user}
openUpdateDialog={openUpdateDialog}
openPwDialog={openPwDialog}
openDelDialog={openDelDialog}
location={location}
renderRole={renderRole}
/>
));
}
return users.map(user => {
return (
<UserListItem
key={user.id}
user={user}
openUpdateDialog={openUpdateDialog}
openPwDialog={openPwDialog}
openDelDialog={openDelDialog}
location={location}
renderRole={renderRole}
/>
);
});
};
if (!users) return null;
return (
<div>
<div ref={ref}>
<Table>
<TableHead>
<TableRow>
@ -85,63 +169,55 @@ function UsersList({
<TableCell>Name</TableCell>
<TableCell>Username</TableCell>
<TableCell align="center">Role</TableCell>
<TableCell align="right">{hasAccess('ADMIN') ? 'Action' : ''}</TableCell>
<TableCell align="right">
{hasAccess(ADMIN) ? 'Action' : ''}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map(item => (
<TableRow key={item.id}>
<TableCell><Avatar variant="rounded" alt={item.name} src={item.imageUrl} title={`${item.name || item.email || item.username} (id: ${item.id})`} /></TableCell>
<TableCell>{formatDateWithLocale(item.createdAt, location.locale)}</TableCell>
<TableCell style={{ textAlign: 'left' }}>{item.name}</TableCell>
<TableCell style={{ textAlign: 'left' }}>{item.username || item.email}</TableCell>
<TableCell align="center">{renderRole(item.rootRole)}</TableCell>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={
<TableCell align="right">
<IconButton aria-label="Edit" title="Edit" onClick={openUpdateDialog(item)}>
<Icon>edit</Icon>
</IconButton>
<IconButton aria-label="Change password" title="Change password" onClick={openPwDialog(item)}>
<Icon>lock</Icon>
</IconButton>
<IconButton aria-label="Remove user" title="Remove user" onClick={openDelDialog(item)}>
<Icon>delete</Icon>
</IconButton>
</TableCell>
}
elseShow={<TableCell />}
/>
</TableRow>
))}
</TableBody>
<TableBody>{renderUsers()}</TableBody>
</Table>
<br />
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={
<Button variant="contained" color="primary" onClick={openDialog}>
<Button
variant="contained"
color="primary"
onClick={openDialog}
>
Add new user
</Button>
}
elseShow={<small>PS! Only admins can add/remove users.</small>}
/>
<ConfirmUserAdded
open={showConfirm}
closeConfirm={closeConfirm}
emailSent={emailSent}
inviteLink={inviteLink}
/>
<AddUser
showDialog={showDialog}
closeDialog={closeDialog}
addUser={addUser}
addUser={onAddUser}
userLoading={userLoading}
validatePassword={validatePassword}
userApiErrors={userApiErrors}
roles={roles}
/>
{updateDialog.open && <UpdateUser
<UpdateUser
showDialog={updateDialog.open}
closeDialog={closeUpdateDialog}
updateUser={updateUser}
updateUser={onUpdateUser}
userLoading={userLoading}
userApiErrors={userApiErrors}
user={updateDialog.user}
roles={roles}
/>}
/>
<ChangePassword
showDialog={pwDialog.open}
closeDialog={closePwDialog}
@ -154,10 +230,9 @@ function UsersList({
showDialog={delDialog}
closeDialog={closeDelDialog}
user={delUser}
removeUser={() => {
removeUser(delUser);
closeDelDialog();
}}
removeUser={onDeleteUser}
userLoading={userLoading}
userApiErrors={userApiErrors}
/>
)}
</div>

View File

@ -1,27 +1,10 @@
import { connect } from 'react-redux';
import UsersList from './UsersList';
import {
fetchUsers,
removeUser,
addUser,
changePassword,
updateUser,
validatePassword,
} from '../../../../store/e-user-admin/actions';
const mapStateToProps = state => ({
users: state.userAdmin.toJS(),
roles: state.roles.get('root').toJS() || [],
location: state.settings.toJS().location || {},
});
const Container = connect(mapStateToProps, {
fetchUsers,
removeUser,
addUser,
changePassword,
updateUser,
validatePassword,
})(UsersList);
const Container = connect(mapStateToProps)(UsersList);
export default Container;

View File

@ -0,0 +1,72 @@
import { IUser } from '../../../../interfaces/user';
const loadingData: IUser[] = [
{
id: 1,
username: 'admin',
email: 'some-email@email.com',
name: 'admin',
permissions: ['ADMIN'],
imageUrl:
'https://gravatar.com/avatar/21232f297a57a5a743894a0e4a801fc3?size=42&default=retro',
seenAt: null,
loginAttempts: 0,
createdAt: '2021-04-21T12:09:55.923Z',
rootRole: 1,
inviteLink: '',
},
{
id: 16,
name: 'test',
email: 'test@test.no',
permissions: [],
imageUrl:
'https://gravatar.com/avatar/879fdbb54e4a6cdba456fcb11abe5971?size=42&default=retro',
seenAt: null,
loginAttempts: 0,
createdAt: '2021-04-21T15:54:02.765Z',
rootRole: 2,
inviteLink: '',
},
{
id: 3,
name: 'Testesen',
email: 'test@test.com',
permissions: [],
imageUrl:
'https://gravatar.com/avatar/6c15d63f08137733ec0828cd0a3a5dc4?size=42&default=retro',
seenAt: '2021-04-21T14:34:31.515Z',
loginAttempts: 0,
createdAt: '2021-04-21T12:33:17.712Z',
rootRole: 1,
inviteLink: '',
},
{
id: 4,
name: 'test',
email: 'test@test.io',
permissions: [],
imageUrl:
'https://gravatar.com/avatar/879fdbb54e4a6cdba456fcb11abe5971?size=42&default=retro',
seenAt: null,
loginAttempts: 0,
createdAt: '2021-04-21T15:54:02.765Z',
rootRole: 2,
inviteLink: '',
},
{
id: 5,
name: 'Testesen',
email: 'test@test.uk',
permissions: [],
imageUrl:
'https://gravatar.com/avatar/6c15d63f08137733ec0828cd0a3a5dc4?size=42&default=retro',
seenAt: '2021-04-21T14:34:31.515Z',
loginAttempts: 0,
createdAt: '2021-04-21T12:33:17.712Z',
rootRole: 1,
inviteLink: '',
},
];
export default loadingData;

View File

@ -1,64 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import Dialogue from '../../../component/common/Dialogue';
import UserForm from './user-form';
function AddUser({ showDialog, closeDialog, addUser, roles }) {
const [data, setData] = useState({});
const [error, setError] = useState({});
const submit = async e => {
e.preventDefault();
if (!data.email) {
setError({ general: 'You must specify the email address' });
return;
}
if (!data.rootRole) {
setError({ general: 'You must specify a role for the user' });
return;
}
try {
await addUser(data);
setData({});
setError({});
closeDialog();
} catch (error) {
const msg = error.message || 'Could not create user';
setError({ general: msg });
}
};
const onCancel = e => {
e.preventDefault();
setData({});
setError({});
closeDialog();
};
return (
<Dialogue
onClick={e => {
submit(e);
}}
open={showDialog}
onClose={onCancel}
primaryButtonText="Add user"
secondaryButtonText="Cancel"
fullWidth
>
<UserForm title="Add new user" data={data} setData={setData} roles={roles} submit={submit} error={error} />
</Dialogue>
);
}
AddUser.propTypes = {
showDialog: PropTypes.bool.isRequired,
closeDialog: PropTypes.func.isRequired,
addUser: PropTypes.func.isRequired,
validatePassword: PropTypes.func.isRequired,
roles: PropTypes.array.isRequired,
};
export default AddUser;

View File

@ -1,16 +1,29 @@
import React, { useState } from 'react';
import { useState } from 'react';
import PropTypes from 'prop-types';
import { TextField, DialogTitle, DialogContent } from '@material-ui/core';
import classnames from 'classnames';
import { TextField, Typography, Avatar } from '@material-ui/core';
import { trim } from '../../../component/common/util';
import { modalStyles } from './util';
import Dialogue from '../../../component/common/Dialogue/Dialogue';
import commonStyles from '../../../component/common/common.module.scss';
import PasswordChecker from '../../../component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker';
import { useCommonStyles } from '../../../common.styles';
import PasswordMatcher from '../../../component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher';
import ConditionallyRender from '../../../component/common/ConditionallyRender';
import { Alert } from '@material-ui/lab';
function ChangePassword({ showDialog, closeDialog, changePassword, validatePassword, user = {} }) {
function ChangePassword({
showDialog,
closeDialog,
changePassword,
user = {},
}) {
const [data, setData] = useState({});
const [error, setError] = useState({});
const [validPassword, setValidPassword] = useState(false);
const commonStyles = useCommonStyles();
const updateField = e => {
setError({});
setData({
...data,
[e.target.name]: trim(e.target.value),
@ -19,13 +32,19 @@ function ChangePassword({ showDialog, closeDialog, changePassword, validatePassw
const submit = async e => {
e.preventDefault();
if (!data.password || data.password.length < 8) {
setError({ password: 'You must specify a password with at least 8 chars.' });
return;
}
if (!(data.password === data.confirm)) {
setError({ confirm: 'Passwords does not match' });
return;
if (!validPassword) {
if (!data.password || data.password.length < 8) {
setError({
password:
'You must specify a password with at least 8 chars.',
});
return;
}
if (!(data.password === data.confirm)) {
setError({ confirm: 'Passwords does not match' });
return;
}
}
try {
@ -38,19 +57,6 @@ function ChangePassword({ showDialog, closeDialog, changePassword, validatePassw
}
};
const onPasswordBlur = async e => {
e.preventDefault();
setError({ password: '' });
if (data.password) {
try {
await validatePassword(data.password);
} catch (error) {
const msg = error.message || '';
setError({ password: msg });
}
}
};
const onCancel = e => {
e.preventDefault();
setData({});
@ -64,40 +70,70 @@ function ChangePassword({ showDialog, closeDialog, changePassword, validatePassw
style={modalStyles}
onClose={onCancel}
primaryButtonText="Save"
title="Update password"
secondaryButtonText="Cancel"
>
<form onSubmit={submit}>
<DialogTitle>Update password</DialogTitle>
<DialogContent
className={commonStyles.contentSpacing}
style={{ display: 'flex', flexDirection: 'column' }}
>
<p>User: {user.username || user.email}</p>
<p style={{ color: 'red' }}>{error.general}</p>
<TextField
label="New password"
name="password"
type="password"
value={data.password}
error={error.password !== undefined}
helperText={error.password}
onChange={updateField}
onBlur={onPasswordBlur}
variant="outlined"
size="small"
<form
onSubmit={submit}
className={classnames(
commonStyles.contentSpacingY,
commonStyles.flexColumn
)}
>
<ConditionallyRender
condition={error.general}
show={<Alert severity="error">{error.general}</Alert>}
/>
<Typography variant="subtitle1">
Changing password for user
</Typography>
<div className={commonStyles.flexRow}>
<Avatar
variant="rounded"
alt={user.name}
src={user.imageUrl}
title={`${
user.name || user.email || user.username
} (id: ${user.id})`}
/>
<TextField
label="Confirm password"
name="confirm"
type="password"
value={data.confirm}
error={error.confirm !== undefined}
helperText={error.confirm}
onChange={updateField}
variant="outlined"
size="small"
/>
</DialogContent>
<Typography
variant="subtitle1"
style={{ marginLeft: '1rem' }}
>
{user.username || user.email}
</Typography>
</div>
<PasswordChecker
password={data.password}
callback={setValidPassword}
/>
<p style={{ color: 'red' }}>{error.general}</p>
<TextField
label="New password"
name="password"
type="password"
value={data.password}
helperText={error.password}
onChange={updateField}
variant="outlined"
size="small"
/>
<TextField
label="Confirm password"
name="confirm"
type="password"
value={data.confirm}
error={error.confirm !== undefined}
helperText={error.confirm}
onChange={updateField}
variant="outlined"
size="small"
/>
<PasswordMatcher
started={data.password && data.confirm}
matchingPasswords={data.password === data.confirm}
/>
</form>
</Dialogue>
);

View File

@ -2,27 +2,78 @@ import React from 'react';
import Dialogue from '../../../component/common/Dialogue/Dialogue';
import ConditionallyRender from '../../../component/common/ConditionallyRender/ConditionallyRender';
import propTypes from 'prop-types';
import { REMOVE_USER_ERROR } from '../../../hooks/useAdminUsersApi';
import { Alert } from '@material-ui/lab';
import useLoading from '../../../hooks/useLoading';
import { Avatar, Typography } from '@material-ui/core';
import { useCommonStyles } from '../../../common.styles';
const DelUserComponent = ({ showDialog, closeDialog, user, removeUser }) => (
<ConditionallyRender
condition={user}
show={
<Dialogue
open={showDialog}
title="Really delete user?"
onClose={closeDialog}
onClick={() => removeUser(user)}
primaryButtonText="Delete user"
secondaryButtonText="Cancel"
>
<div>
Are you sure you want to delete{' '}
{user ? `${user.name || 'user'} (${user.email || user.username})` : ''}?
const DelUserComponent = ({
showDialog,
closeDialog,
user,
userLoading,
removeUser,
userApiErrors,
}) => {
const ref = useLoading(userLoading);
const commonStyles = useCommonStyles();
return (
<Dialogue
open={showDialog}
title="Really delete user?"
onClose={closeDialog}
onClick={() => removeUser(user)}
primaryButtonText="Delete user"
secondaryButtonText="Cancel"
>
<div ref={ref}>
<ConditionallyRender
condition={userApiErrors[REMOVE_USER_ERROR]}
show={
<Alert
data-loading
severity="error"
style={{ margin: '1rem 0' }}
>
{userApiErrors[REMOVE_USER_ERROR]}
</Alert>
}
/>
<div data-loading className={commonStyles.flexRow}>
<Avatar
variant="rounded"
alt={user.name}
src={user.imageUrl}
title={`${
user.name || user.email || user.username
} (id: ${user.id})`}
/>
<Typography
variant="subtitle1"
style={{ marginLeft: '1rem' }}
>
{user.username || user.email}
</Typography>
</div>
</Dialogue>
}
/>
);
<Typography
data-loading
variant="body1"
style={{ marginTop: '1rem' }}
>
Are you sure you want to delete{' '}
{user
? `${user.name || 'user'} (${
user.email || user.username
})`
: ''}
?
</Typography>
</div>
</Dialogue>
);
};
DelUserComponent.propTypes = {
showDialog: propTypes.bool.isRequired,

View File

@ -1,22 +1,25 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import Dialogue from '../../../component/common/Dialogue';
import UserForm from './user-form';
import UserForm from './AddUser/AddUserForm/AddUserForm';
function AddUser({ user = {}, showDialog, closeDialog, updateUser, roles }) {
function AddUser({
user,
showDialog,
closeDialog,
updateUser,
roles,
userApiErrors,
userLoading,
}) {
const [data, setData] = useState({});
const [error, setError] = useState({});
useEffect(() => {
setData({
id: user.id,
email: user.email || '',
rootRole: user.rootRole || '',
name: user.name || '',
...user,
});
}, [user])
}, [user]);
if (!user) {
return null;
@ -29,7 +32,6 @@ function AddUser({ user = {}, showDialog, closeDialog, updateUser, roles }) {
await updateUser(data);
setData({});
setError({});
closeDialog();
} catch (error) {
setError({ general: 'Could not update user' });
}
@ -51,9 +53,18 @@ function AddUser({ user = {}, showDialog, closeDialog, updateUser, roles }) {
onClose={onCancel}
primaryButtonText="Update user"
secondaryButtonText="Cancel"
title="Update team member"
fullWidth
>
<UserForm title="Update user" data={data} setData={setData} roles={roles} submit={submit} error={error} />
<UserForm
data={data}
setData={setData}
roles={roles}
submit={submit}
error={error}
userApiErrors={userApiErrors}
userLoading={userLoading}
/>
</Dialogue>
);
}

View File

@ -1,103 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
TextField,
DialogTitle,
DialogContent,
RadioGroup,
Radio,
FormControl,
FormLabel,
FormControlLabel,
} from '@material-ui/core';
import commonStyles from '../../../component/common/common.module.scss';
import { trim } from '../../../component/common/util';
function UserForm({ title, submit, data, error, setData, roles }) {
const updateField = e => {
setData({
...data,
[e.target.name]: e.target.value,
});
};
const updateFieldWithTrim = e => {
setData({
...data,
[e.target.name]: trim(e.target.value),
});
};
const updateNumberField = e => {
setData({
...data,
[e.target.name]: +e.target.value,
});
};
return (
<form onSubmit={submit}>
<DialogTitle>{title}</DialogTitle>
<DialogContent className={commonStyles.contentSpacing} style={{ display: 'flex', flexDirection: 'column' }}>
<p style={{ color: 'red' }}>{error.general}</p>
<TextField
label="Full name"
name="name"
value={data.name || ''}
error={error.name !== undefined}
helperText={error.name}
type="name"
variant="outlined"
size="small"
onChange={updateField}
/>
<TextField
label="Email"
name="email"
required
value={data.email || ''}
error={error.email !== undefined}
helperText={error.email}
variant="outlined"
size="small"
type="email"
onChange={updateFieldWithTrim}
/>
<br />
<br />
<FormControl>
<FormLabel component="legend">Role</FormLabel>
<RadioGroup name="rootRole" value={data.rootRole || ''} onChange={updateNumberField}>
{roles.map(role => (
<FormControlLabel
key={`role-${role.id}`}
labelPlacement="end"
style={{ margin: '3px 0', border: '1px solid #EFEFEF' }}
label={
<div>
<strong>{role.name}</strong>
<p>{role.description}</p>
</div>
}
control={<Radio />}
value={role.id}
/>
))}
</RadioGroup>
</FormControl>
</DialogContent>
</form>
);
}
UserForm.propTypes = {
title: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
error: PropTypes.object.isRequired,
submit: PropTypes.func.isRequired,
setData: PropTypes.func.isRequired,
roles: PropTypes.array.isRequired,
};
export default UserForm;

View File

@ -1,64 +0,0 @@
import api from './api';
import { dispatchError } from '../util';
export const START_FETCH_USERS = 'START_FETCH_USERS';
export const RECEIVE_USERS = 'RECEIVE_USERS';
export const ERROR_FETCH_USERS = 'ERROR_FETCH_USERS';
export const REMOVE_USER = 'REMOVE_USER';
export const REMOVE_USER_ERROR = 'REMOVE_USER_ERROR';
export const ADD_USER = 'ADD_USER';
export const ADD_USER_ERROR = 'ADD_USER_ERROR';
export const UPDATE_USER = 'UPDATE_USER';
export const UPDATE_USER_ERROR = 'UPDATE_USER_ERROR';
export const CHANGE_PASSWORD_ERROR = 'CHANGE_PASSWORD_ERROR';
export const VALIDATE_PASSWORD_ERROR = 'VALIDATE_PASSWORD_ERROR';
const debug = require('debug')('unleash:e-user-admin-actions');
const gotUsers = value => ({
type: RECEIVE_USERS,
value,
});
export function fetchUsers() {
debug('Start fetching user');
return dispatch => {
dispatch({ type: START_FETCH_USERS });
return api
.fetchAll()
.then(json => dispatch(gotUsers(json)))
.catch(dispatchError(dispatch, ERROR_FETCH_USERS));
};
}
export function removeUser(user) {
return dispatch =>
api
.remove(user)
.then(() => dispatch({ type: REMOVE_USER, user }))
.catch(dispatchError(dispatch, REMOVE_USER_ERROR));
}
export function addUser(user) {
return dispatch =>
api
.create(user)
.then(newUser => dispatch({ type: ADD_USER, user: newUser }))
.catch(dispatchError(dispatch, ADD_USER_ERROR));
}
export function updateUser(user) {
return dispatch =>
api
.update(user)
.then(newUser => dispatch({ type: UPDATE_USER, user: newUser }))
.catch(dispatchError(dispatch, UPDATE_USER_ERROR));
}
export function changePassword(user, newPassword) {
return dispatch => api.changePassword(user, newPassword).catch(dispatchError(dispatch, CHANGE_PASSWORD_ERROR));
}
export function validatePassword(password) {
return dispatch => api.validatePassword(password).catch(dispatchError(dispatch, VALIDATE_PASSWORD_ERROR));
}

View File

@ -1,66 +0,0 @@
import { throwIfNotSuccess, headers } from '../api-helper';
const URI = 'api/admin/user-admin';
function fetchAll() {
return fetch(URI, { headers, credentials: 'include' })
.then(throwIfNotSuccess)
.then(response => response.json());
}
function create(user) {
return fetch(URI, {
method: 'POST',
headers,
body: JSON.stringify(user),
credentials: 'include',
})
.then(throwIfNotSuccess)
.then(response => response.json());
}
function update(user) {
return fetch(`${URI}/${user.id}`, {
method: 'PUT',
headers,
body: JSON.stringify(user),
credentials: 'include',
})
.then(throwIfNotSuccess)
.then(response => response.json());
}
function changePassword(user, newPassword) {
return fetch(`${URI}/${user.id}/change-password`, {
method: 'POST',
headers,
body: JSON.stringify({ password: newPassword }),
credentials: 'include',
}).then(throwIfNotSuccess);
}
function validatePassword(password) {
return fetch(`${URI}/validate-password`, {
method: 'POST',
headers,
body: JSON.stringify({ password }),
credentials: 'include',
}).then(throwIfNotSuccess);
}
function remove(user) {
return fetch(`${URI}/${user.id}`, {
method: 'DELETE',
headers,
credentials: 'include',
}).then(throwIfNotSuccess);
}
export default {
fetchAll,
create,
update,
changePassword,
validatePassword,
remove,
};

View File

@ -1,25 +0,0 @@
import { List } from 'immutable';
import { RECEIVE_USERS, ADD_USER, REMOVE_USER, UPDATE_USER } from './actions';
const store = (state = new List(), action) => {
switch (action.type) {
case RECEIVE_USERS:
return new List(action.value.users);
case ADD_USER:
return state.push(action.user);
case UPDATE_USER:
return state.map(user => {
if (user.id === action.user.id) {
return action.user;
} else {
return user;
}
});
case REMOVE_USER:
return state.filter(v => v.id !== action.user.id);
default:
return state;
}
};
export default store;

View File

@ -1,19 +0,0 @@
import { List, fromJS } from 'immutable';
import { RECEIVE_USERS } from './actions';
function getInitialState() {
return fromJS({
root: [],
});
}
const store = (state = getInitialState(), action) => {
switch (action.type) {
case RECEIVE_USERS:
return state.set('root', new List(action.value.rootRoles));
default:
return state;
}
};
export default store;

View File

@ -6,7 +6,7 @@ import featureTags from './feature-tags';
import tagTypes from './tag-type';
import tags from './tag';
import strategies from './strategy';
import history from "./history"; // eslint-disable-line
import history from './history'; // eslint-disable-line
import archive from './archive';
import error from './error';
import settings from './settings';
@ -16,8 +16,6 @@ import uiConfig from './ui-config';
import context from './context';
import projects from './project';
import addons from './addons';
import userAdmin from './e-user-admin';
import roles from './e-user-admin/roles-store';
import apiAdmin from './e-api-admin';
import authAdmin from './e-admin-auth';
import apiCalls from './api-calls';
@ -40,8 +38,6 @@ const unleashStore = combineReducers({
context,
projects,
addons,
userAdmin,
roles,
apiAdmin,
authAdmin,
apiCalls,

View File

@ -73,6 +73,11 @@ const theme = createMuiTheme({
},
main: '#fff',
},
dialogue: {
title: {
main: '#fff',
},
},
},
padding: {
pageContent: {