From 72ad42789989e12ce16f59d29a23c4732911240a Mon Sep 17 00:00:00 2001 From: HannaFar Date: Wed, 9 Feb 2022 18:25:06 +0100 Subject: [PATCH 1/2] feat(fbcnms-ui): Add DataGrid component Signed-off-by: HannaFar --- .../fbcnms-ui/components/DataGrid.js | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 fbcnms-packages/fbcnms-ui/components/DataGrid.js diff --git a/fbcnms-packages/fbcnms-ui/components/DataGrid.js b/fbcnms-packages/fbcnms-ui/components/DataGrid.js new file mode 100644 index 0000000..9b7476f --- /dev/null +++ b/fbcnms-packages/fbcnms-ui/components/DataGrid.js @@ -0,0 +1,155 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ +import typeof SvgIcon from '@material-ui/core/@@SvgIcon'; + +import Card from '@material-ui/core/Card'; +import CardHeader from '@material-ui/core/CardHeader'; +import Grid from '@material-ui/core/Grid'; +import React from 'react'; + +import {brightGray, comet, concrete} from '@fbcnms/ui/theme/colors'; +import {makeStyles} from '@material-ui/styles'; + +const useStyles = makeStyles(theme => ({ + dataBlock: { + boxShadow: `0 0 0 1px ${concrete}`, + }, + dataLabel: { + fontWeight: '500', + color: comet, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + dataValue: { + fontSize: '18px', + color: brightGray, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + width: props => + props.hasStatus + ? 'calc(100% - 16px)' + : props.hasIcon + ? 'calc(100% - 32px)' + : '100%', + }, + dataBox: { + width: '100%', + padding: props => (props.collapsed ? '0' : null), + + '& > div': { + width: '100%', + }, + }, + dataIcon: { + display: 'flex', + alignItems: 'center', + + '& svg': { + fill: comet, + marginRight: theme.spacing(1), + }, + }, + list: { + padding: 0, + }, +})); + +// Data Icon adds an icon to the left of the value +function DataIcon(icon: SvgIcon, val: string) { + const props = {hasIcon: true}; + const classes = useStyles(props); + const Icon = icon; + return ( + + + + + + {val} + + + ); +} + +type Data = { + icon?: SvgIcon, + category?: string, + value: number | string, + unit?: string, + statusCircle?: boolean, + statusInactive?: boolean, + status?: boolean, + tooltip?: string, +}; + +export type DataRows = Data[]; + +type Props = {data: DataRows[], testID?: string}; + +export default function DataGrid(props: Props) { + const classes = useStyles(); + const dataGrid = props.data.map((row, i) => ( + + {row.map((data, j) => { + const dataEntryValue = data.value + (data.unit ?? ''); + + return ( + + + + + + + + ); + })} + + )); + return ( + + + {dataGrid} + + + ); +} From 01ce1a17b241a71f99e4e4ad5b6beeff51584724 Mon Sep 17 00:00:00 2001 From: HannaFar Date: Wed, 23 Mar 2022 18:09:30 +0100 Subject: [PATCH 2/2] feat(fbcnms-ui): Add Organization detail + edit Signed-off-by: HannaFar --- .../fbcnms-ui/components/ActionTable.js | 5 - .../fbcnms-ui/host/OrganizationDialog.js | 151 +++-- .../fbcnms-ui/host/OrganizationEdit.js | 517 ++++++++---------- .../fbcnms-ui/host/OrganizationInfoDialog.js | 256 +++++++-- .../fbcnms-ui/host/OrganizationSummary.js | 47 ++ .../fbcnms-ui/host/OrganizationUserDialog.js | 161 +++--- .../fbcnms-ui/host/OrganizationUsersTable.js | 160 ++++++ .../fbcnms-ui/host/Organizations.js | 216 ++++---- fbcnms-packages/fbcnms-ui/theme/colors.js | 1 + fbcnms-packages/fbcnms-ui/theme/default.js | 7 +- 10 files changed, 966 insertions(+), 555 deletions(-) create mode 100644 fbcnms-packages/fbcnms-ui/host/OrganizationSummary.js create mode 100644 fbcnms-packages/fbcnms-ui/host/OrganizationUsersTable.js diff --git a/fbcnms-packages/fbcnms-ui/components/ActionTable.js b/fbcnms-packages/fbcnms-ui/components/ActionTable.js index cdb9676..0d09f40 100644 --- a/fbcnms-packages/fbcnms-ui/components/ActionTable.js +++ b/fbcnms-packages/fbcnms-ui/components/ActionTable.js @@ -40,11 +40,6 @@ import {makeStyles} from '@material-ui/styles'; import {useState} from 'react'; const useStyles = makeStyles(theme => ({ - inputRoot: { - '&.MuiOutlinedInput-root': { - padding: 0, - }, - }, cardTitleRow: { marginBottom: theme.spacing(1), minHeight: '36px', diff --git a/fbcnms-packages/fbcnms-ui/host/OrganizationDialog.js b/fbcnms-packages/fbcnms-ui/host/OrganizationDialog.js index abe3a8f..aad251c 100644 --- a/fbcnms-packages/fbcnms-ui/host/OrganizationDialog.js +++ b/fbcnms-packages/fbcnms-ui/host/OrganizationDialog.js @@ -7,12 +7,14 @@ * @flow strict-local * @format */ - +import type {EditUser} from './OrganizationEdit'; import type {OrganizationPlainAttributes} from '@fbcnms/sequelize-models/models/organization'; +import AppContext from '@fbcnms/ui/context/AppContext'; import Button from '@fbcnms/ui/components/design-system/Button'; import Dialog from '@material-ui/core/Dialog'; import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; import DialogTitle from '@material-ui/core/DialogTitle'; import LoadingFillerBackdrop from '@fbcnms/ui/components/LoadingFillerBackdrop'; import OrganizationInfoDialog from './OrganizationInfoDialog'; @@ -22,43 +24,77 @@ import Tab from '@material-ui/core/Tab'; import Tabs from '@material-ui/core/Tabs'; import {UserRoles} from '@fbcnms/auth/types'; -import {brightGray, white} from '@fbcnms/ui/theme/colors'; +import {brightGray, concrete, mirage, white} from '../theme/colors'; import {makeStyles} from '@material-ui/styles'; import {useAxios} from '@fbcnms/ui/hooks'; -import {useState} from 'react'; +import {useContext, useEffect, useState} from 'react'; const useStyles = makeStyles(_ => ({ - input: { - display: 'inline-flex', - margin: '5px 0', - width: '100%', - }, tabBar: { backgroundColor: brightGray, color: white, }, + dialog: { + backgroundColor: concrete, + }, + dialogActions: { + backgroundColor: white, + padding: '20px', + zIndex: '1', + }, + dialogContent: { + padding: '32px', + minHeight: '480px', + }, + dialogTitle: { + backgroundColor: mirage, + padding: '16px 24px', + color: white, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + }, })); +type TabType = + | 'automation' + | 'admin' + | 'inventory' + | 'nms' + | 'workorders' + | 'hub'; export type DialogProps = { error: string, - user: CreateUserType, + user: EditUser, organization: OrganizationPlainAttributes, - onUserChange: CreateUserType => void, + onUserChange: EditUser => void, onOrganizationChange: OrganizationPlainAttributes => void, // Array of networks ids allNetworks: Array, // If true, enable all networks for an organization shouldEnableAllNetworks: boolean, setShouldEnableAllNetworks: boolean => void, + edit: boolean, + getProjectTabs?: () => Array<{id: TabType, name: string}>, + // flag to display advanced fields + hideAdvancedFields: boolean, }; type Props = { onClose: () => void, - onCreateOrg: (org: CreateOrgType) => void, + onCreateOrg: (org: $Shape) => void, onCreateUser: (user: CreateUserType) => void, // flag to display create user tab addUser: boolean, setAddUser: () => void, + user: ?EditUser, + open: boolean, + organization: ?OrganizationPlainAttributes, + // editing organization + edit: boolean, + // flag to display advanced fields + hideAdvancedFields: boolean, }; type CreateUserType = { @@ -68,15 +104,10 @@ type CreateUserType = { organization?: string, role: ?string, tabs?: Array, - password: string, + password: ?string, passwordConfirmation?: string, }; -type CreateOrgType = { - name: string, - networkIDs: Array, - customDomains?: Array, -}; /** * Create Orgnization Dilaog * This component displays a dialog with 2 tabs @@ -84,6 +115,7 @@ type CreateOrgType = { * Second tab: OrganizationUserDialog, to create a user that belongs to the new organization */ export default function (props: Props) { + const {ssoEnabled} = useContext(AppContext); const classes = useStyles(); const {error, isLoading, response} = useAxios({ method: 'get', @@ -91,23 +123,38 @@ export default function (props: Props) { }); const [organization, setOrganization] = useState( - {}, + props.organization || {}, ); - const [currentTab, setCurrentTab] = useState(props.addUser ? 1 : 0); + const [currentTab, setCurrentTab] = useState(0); const [shouldEnableAllNetworks, setShouldEnableAllNetworks] = useState(false); - const [user, setUser] = useState({}); + const [user, setUser] = useState(props.user || {}); const [createError, setCreateError] = useState(''); const allNetworks = error || !response ? [] : response.data.sort(); + const organizationDialogTitle = + currentTab === 1 + ? 'Add User' + : props.edit + ? 'Edit Organization' + : 'Add Organization'; + + useEffect(() => { + setCurrentTab(props.addUser ? 1 : 0); + }, [props.addUser]); + + useEffect(() => { + setOrganization(props.organization || {}); + setCreateError(''); + setUser(props.user || {}); + }, [props.open, props.organization, props.user]); if (isLoading) { return ; } - const createProps = { user, organization, error: createError, - onUserChange: (user: CreateUserType) => { + onUserChange: (user: EditUser) => { setUser(user); }, onOrganizationChange: (organization: OrganizationPlainAttributes) => { @@ -116,6 +163,8 @@ export default function (props: Props) { allNetworks, shouldEnableAllNetworks, setShouldEnableAllNetworks, + edit: props.edit, + hideAdvancedFields: props.hideAdvancedFields, }; const onSave = async () => { if (currentTab === 0) { @@ -123,35 +172,44 @@ export default function (props: Props) { setCreateError('Name cannot be empty'); return; } - const payload = { + const newOrg = { name: organization.name, networkIDs: shouldEnableAllNetworks ? allNetworks : Array.from(organization.networkIDs || []), customDomains: [], // TODO - // tabs: Array.from(tabs), + // default tab is nms + tabs: Array.from(organization.tabs || ['nms']), + csvCharset: organization.csvCharset, + ssoSelectedType: organization.ssoSelectedType, + ssoCert: organization.ssoCert, + ssoEntrypoint: organization.ssoEntrypoint, + ssoIssuer: organization.ssoIssuer, + ssoOidcClientID: organization.ssoOidcClientID, + ssoOidcClientSecret: organization.ssoOidcClientSecret, + ssoOidcConfigurationURL: organization.ssoOidcConfigurationURL, }; - props.onCreateOrg(payload); + props.onCreateOrg(newOrg); setCurrentTab(currentTab + 1); setCreateError(''); props.setAddUser(); } else { - if (!user.email) { + if (user.password != user.passwordConfirmation) { + setCreateError('Passwords must match'); + return; + } + if (!user?.email) { setCreateError('Email cannot be empty'); return; } - if (!user.password) { + if ((!user?.password ?? false) && !ssoEnabled && !user.id) { setCreateError('Password cannot be empty'); return; } - if (user.password != user.passwordConfirmation) { - setCreateError('Passwords must match'); - return; - } - const payload: CreateUserType = { + const newUser: CreateUserType = { email: user.email, password: user.password, role: user.role, @@ -160,35 +218,46 @@ export default function (props: Props) { ? [] : Array.from(user.networkIDs || []), }; - props.onCreateUser(payload); + if ((user.id || ssoEnabled) && !user?.password) { + delete newUser.password; + } + props.onCreateUser(newUser); } }; return ( - Add Organization + + {Object.keys(user).length > 0 ? 'Edit User' : organizationDialogTitle} + setCurrentTab(v)}> - <> + {currentTab === 0 && } {currentTab === 1 && } - - + + - diff --git a/fbcnms-packages/fbcnms-ui/host/OrganizationEdit.js b/fbcnms-packages/fbcnms-ui/host/OrganizationEdit.js index e2d0695..40d0ed1 100644 --- a/fbcnms-packages/fbcnms-ui/host/OrganizationEdit.js +++ b/fbcnms-packages/fbcnms-ui/host/OrganizationEdit.js @@ -9,111 +9,108 @@ */ import type {Organization} from './Organizations'; -import type {SSOSelectedType} from '@fbcnms/types/auth'; import type {Tab} from '@fbcnms/types/tabs'; +import type {WithAlert} from '@fbcnms/ui/components/Alert/withAlert'; +import ArrowBackIcon from '@material-ui/icons/ArrowBack'; import Button from '@fbcnms/ui/components/design-system/Button'; -import Checkbox from '@material-ui/core/Checkbox'; -import FormControl from '@material-ui/core/FormControl'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import FormGroup from '@material-ui/core/FormGroup'; import Grid from '@material-ui/core/Grid'; -import Input from '@material-ui/core/Input'; -import InputLabel from '@material-ui/core/InputLabel'; -import ListItemText from '@material-ui/core/ListItemText'; +import IconButton from '@material-ui/core/IconButton'; import LoadingFiller from '@fbcnms/ui/components/LoadingFiller'; -import MenuItem from '@material-ui/core/MenuItem'; -import Paper from '@material-ui/core/Paper'; +import OrganizationDialog from './OrganizationDialog'; +import OrganizationSummary from './OrganizationSummary'; +import OrganizationUsersTable from './OrganizationUsersTable'; import React from 'react'; -import SaveIcon from '@material-ui/icons/Save'; -import Select from '@material-ui/core/Select'; import Text from '@fbcnms/ui/components/design-system/Text'; -import TextField from '@material-ui/core/TextField'; -import TypedSelect from '@fbcnms/ui/components/TypedSelect'; import axios from 'axios'; -import renderList from '@fbcnms/util/renderList'; -import symphony from '@fbcnms/ui/theme/symphony'; -import {getProjectTabs as getAllProjectTabs} from '@fbcnms/projects/projects'; -import {intersection} from 'lodash'; +import withAlert from '@fbcnms/ui/components/Alert/withAlert'; + import {makeStyles} from '@material-ui/styles'; import {useAxios, useRouter} from '@fbcnms/ui/hooks'; -import {useCallback, useEffect, useState} from 'react'; +import {useCallback, useState} from 'react'; import {useEnqueueSnackbar} from '@fbcnms/ui/hooks/useSnackbar'; -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles(_ => ({ + arrowBack: { + paddingRight: '0px', + color: 'black', + }, + container: { + margin: '40px 32px', + }, header: { margin: '10px', display: 'flex', justifyContent: 'space-between', + textTransform: 'capitalize', }, - leftIcon: { - marginRight: theme.spacing(), - color: symphony.palette.white, - }, - root: { - ...theme.mixins.gutters(), - paddingTop: theme.spacing(2), - paddingBottom: theme.spacing(2), - }, - textField: { - marginLeft: theme.spacing(), - marginRight: theme.spacing(), - width: 500, - }, - networks: { - flexDirection: 'row', - flexWrap: 'nowrap', - marginTop: '16px', - marginBottom: '8px', - }, - flexGrow: { - flexGrow: 1, + titleRow: { + margin: '16px 0', }, })); +export type EditUser = { + id: string | number, + email: string, + role: string, + networkIDs?: string[], + organization?: string, + tabs?: Array, + password?: string, + passwordConfirmation?: string, +}; +type TitleRowProps = { + title: string, + buttonTitle: string, + onClick: () => void, +}; type Props = { getProjectTabs?: () => Array<{id: Tab, name: string}>, + hideAdvancedFields?: boolean, }; -export default function OrganizationEdit(props: Props) { - const {match} = useRouter(); +function TitleRow(props: TitleRowProps) { + const classes = useStyles(); + return ( + + {props.title} + + + ); +} + +/** + * Organization detail view and Organization edit dialog + * This component displays an Organization basic information (OrganizationSummary) + * and its users (OrganizationUsersTable) + */ +function OrganizationEdit(props: WithAlert & Props) { + const {match, history} = useRouter(); + const [addUser, setAddUser] = useState(false); const classes = useStyles(); const enqueueSnackbar = useEnqueueSnackbar(); const [name, setName] = useState(''); - const [csvCharset, setCsvCharset] = useState(''); - const [tabs, setTabs] = useState>(new Set()); - const [shouldEnableAllNetworks, setShouldEnableAllNetworks] = useState(false); const [networkIds, setNetworkIds] = useState>(new Set()); - const [ssoSelectedType, setSSOSelectedType] = useState( - 'saml', - ); - const [ssoIssuer, setSSOIssuer] = useState(''); - const [ssoEntrypoint, setSSOEntrypoint] = useState(''); - const [ssoCert, setSSOCert] = useState(''); - const [ssoOidcClientID, setSSOOidcClientID] = useState(''); - const [ssoOidcClientSecret, setSSOOidcClientSecret] = useState(''); - const [ - ssoOidcConfigurationURL, - setSSOOidcConfigurationURL, - ] = useState(''); - + const [dialog, setDialog] = useState(false); + const [createError, setCreateError] = useState(''); + const [refresh, setRefresh] = useState(Date.now()); + const [user, setUser] = useState(null); + const [editUser, setEditUser] = useState(false); const orgRequest = useAxios({ method: 'get', url: '/host/organization/async/' + match.params.name, - onResponse: useCallback(res => { - const {organization} = res.data; - setName(organization.name); - setTabs(new Set(organization.tabs)); - setNetworkIds(new Set(organization.networkIDs)); - setCsvCharset(organization.csvCharset); - setSSOSelectedType(organization.ssoSelectedType); - setSSOIssuer(organization.ssoIssuer); - setSSOEntrypoint(organization.ssoEntrypoint); - setSSOCert(organization.ssoCert); - setSSOOidcClientID(organization.ssoOidcClientID); - setSSOOidcClientSecret(organization.ssoOidcClientSecret); - setSSOOidcConfigurationURL(organization.ssoOidcConfigurationURL); - }, []), + onResponse: useCallback( + res => { + const {organization} = res.data; + setName(organization.name); + setNetworkIds(new Set(organization.networkIDs)); + }, + [refresh], + ), }); const networksRequest = useAxios({ @@ -121,16 +118,6 @@ export default function OrganizationEdit(props: Props) { url: '/host/networks/async', }); - useEffect(() => { - if (orgRequest.response && networksRequest.response) { - const allNetworks: string[] = networksRequest.response.data; - const networkIDs = orgRequest.response.data.organization.networkIDs; - if (intersection(allNetworks, networkIDs).length === allNetworks.length) { - setShouldEnableAllNetworks(true); - } - } - }, [orgRequest.response, networksRequest.response]); - if ( orgRequest.isLoading || networksRequest.isLoading || @@ -140,28 +127,10 @@ export default function OrganizationEdit(props: Props) { } const organization = orgRequest.response.data.organization; - const allNetworks = - networksRequest.error || !networksRequest.response - ? [] - : networksRequest.response.data.sort(); - const onSave = () => { + const onSave = org => { axios - .put('/host/organization/async/' + match.params.name, { - name, - tabs: Array.from(tabs), - networkIDs: shouldEnableAllNetworks - ? allNetworks - : Array.from(networkIds), - csvCharset, - ssoSelectedType, - ssoIssuer, - ssoEntrypoint, - ssoCert, - ssoOidcClientID, - ssoOidcClientSecret, - ssoOidcConfigurationURL, - }) + .put('/host/organization/async/' + match.params.name, org) .then(_res => { enqueueSnackbar('Updated organization successfully', { variant: 'success', @@ -175,205 +144,149 @@ export default function OrganizationEdit(props: Props) { }); }; - const allTabs = props.getProjectTabs - ? props.getProjectTabs() - : getAllProjectTabs(); - return ( - - - - Organization: {organization.name} - - - - - - Basic Info -
- - setName(evt.target.value)} - margin="normal" - /> - - - - Accessible Tabs - }> - {allTabs.map(tab => ( - - - - - ))} - - - - - - setShouldEnableAllNetworks(target.checked) - } - color="primary" - margin="normal" - /> - } - label="Enable All Networks" - /> - {!shouldEnableAllNetworks && ( - - - Accessible Networks - - }> - {allNetworks.map(network => ( - - - - - ))} - - - )} - -
-
- - Advanced Settings -
- - setCsvCharset(evt.target.value)} - margin="normal" - /> - -
-
- - Single Sign-On -
- - setSSOSelectedType(value)} - input={} - /> - - {ssoSelectedType === 'saml' ? ( - <> - - setSSOIssuer(evt.target.value)} - margin="normal" - /> - - - setSSOEntrypoint(evt.target.value)} - margin="normal" - /> - - - setSSOCert(evt.target.value)} - className={classes.textField} - margin="normal" - variant="filled" - /> - - - ) : null} - {ssoSelectedType === 'oidc' ? ( - <> - - setSSOOidcClientID(evt.target.value)} - margin="normal" - /> - - - setSSOOidcClientSecret(evt.target.value)} - margin="normal" - /> - - - - setSSOOidcConfigurationURL(evt.target.value) - } - margin="normal" - /> - - - ) : null} -
-
- -
- -
-
-
-
+ /> + +
+ + + { + setUser(null); + setAddUser(true); + setDialog(true); + }} + /> + { + setUser(newUser); + setAddUser(true); + setDialog(true); + setEditUser(true); + }} + refresh={new Date(refresh)} + setRefresh={() => setRefresh(Date.now())} + /> + + + + + + ); } + +export default withAlert(OrganizationEdit); diff --git a/fbcnms-packages/fbcnms-ui/host/OrganizationInfoDialog.js b/fbcnms-packages/fbcnms-ui/host/OrganizationInfoDialog.js index fb0e5a7..a18bfea 100644 --- a/fbcnms-packages/fbcnms-ui/host/OrganizationInfoDialog.js +++ b/fbcnms-packages/fbcnms-ui/host/OrganizationInfoDialog.js @@ -7,13 +7,13 @@ * @flow strict-local * @format */ +import {getProjectTabs as getAllProjectTabs} from '@fbcnms/projects/projects'; import type {DialogProps} from './OrganizationDialog'; import ArrowDropDown from '@material-ui/icons/ArrowDropDown'; import Button from '@fbcnms/ui/components/design-system/Button'; import Checkbox from '@material-ui/core/Checkbox'; import Collapse from '@material-ui/core/Collapse'; -import DialogContent from '@material-ui/core/DialogContent'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import FormHelperText from '@material-ui/core/FormHelperText'; import FormLabel from '@material-ui/core/FormLabel'; @@ -25,7 +25,7 @@ import OutlinedInput from '@material-ui/core/OutlinedInput'; import React from 'react'; import Select from '@material-ui/core/Select'; -import {AltFormField} from '@fbcnms/ui/components/design-system/FormField/FormField'; +import {AltFormField} from '../components/design-system/FormField/FormField'; import {useState} from 'react'; const ENABLE_ALL_NETWORKS_HELPER = @@ -35,41 +35,73 @@ const ENABLE_ALL_NETWORKS_HELPER = * Create Organization Tab * This component displays a form used to create an organization */ -export default function OrganizationInfoDialog(props: DialogProps) { +export default function (props: DialogProps) { const { organization, allNetworks, shouldEnableAllNetworks, setShouldEnableAllNetworks, + edit, + hideAdvancedFields, } = props; const [open, setOpen] = useState(false); + const allTabs = props.getProjectTabs + ? props.getProjectTabs() + : getAllProjectTabs(); return ( - - - {props.error && ( - - {props.error} - - )} - - { - props.onOrganizationChange({...organization, name: target.value}); - }} - /> + + {props.error && ( + + {props.error} - - - - - + )} + + { + props.onOrganizationChange({...organization, name: target.value}); + }} + /> + + {!hideAdvancedFields && ( + <> + + + + + )} + + + + + + {!shouldEnableAllNetworks && ( - - setShouldEnableAllNetworks(!shouldEnableAllNetworks) - } + )} + + setShouldEnableAllNetworks(!shouldEnableAllNetworks) + } + /> + } + /> + {ENABLE_ALL_NETWORKS_HELPER} + + {!hideAdvancedFields && ( + <> + + { + props.onOrganizationChange({ + ...organization, + csvCharset: target.value, + }); + }} /> - } - /> - {ENABLE_ALL_NETWORKS_HELPER} - - - + + + + + + {organization.ssoSelectedType === 'saml' ? ( + <> + + { + props.onOrganizationChange({ + ...organization, + ssoIssuer: target.value, + }); + }} + /> + + + + { + props.onOrganizationChange({ + ...organization, + ssoEntrypoint: target.value, + }); + }} + /> + + + + { + props.onOrganizationChange({ + ...organization, + ssoCert: target.value, + }); + }} + /> + + + ) : null} + {organization.ssoSelectedType === 'oidc' ? ( + <> + + { + props.onOrganizationChange({ + ...organization, + ssoOidcClientID: target.value, + }); + }} + /> + + + + { + props.onOrganizationChange({ + ...organization, + ssoOidcClientSecret: target.value, + }); + }} + /> + + + + { + props.onOrganizationChange({ + ...organization, + ssoOidcConfigurationURL: target.value, + }); + }} + /> + + + ) : null} + + )} + + ); } diff --git a/fbcnms-packages/fbcnms-ui/host/OrganizationSummary.js b/fbcnms-packages/fbcnms-ui/host/OrganizationSummary.js new file mode 100644 index 0000000..bef03f8 --- /dev/null +++ b/fbcnms-packages/fbcnms-ui/host/OrganizationSummary.js @@ -0,0 +1,47 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {DataRows} from '../components/DataGrid'; + +import DataGrid from '../components/DataGrid'; +import React from 'react'; + +type OverviewProps = { + name: string, + networkIds: Set, +}; + +/** + * Organization basic information + */ +export default function OrganizationSummary(props: OverviewProps) { + const {name, networkIds} = props; + const kpiData: DataRows[] = [ + [ + { + category: 'Organization Name', + value: name, + }, + ], + [ + { + category: 'Accessible Networks', + value: [...(networkIds || [])].join(', ') || '-', + }, + ], + [ + { + category: 'Link to Organization Portal', + value: `link to ${name} org`, + }, + ], + ]; + return ; +} diff --git a/fbcnms-packages/fbcnms-ui/host/OrganizationUserDialog.js b/fbcnms-packages/fbcnms-ui/host/OrganizationUserDialog.js index 0a890c5..25dce6b 100644 --- a/fbcnms-packages/fbcnms-ui/host/OrganizationUserDialog.js +++ b/fbcnms-packages/fbcnms-ui/host/OrganizationUserDialog.js @@ -10,7 +10,6 @@ import type {DialogProps} from './OrganizationDialog'; -import DialogContent from '@material-ui/core/DialogContent'; import FormLabel from '@material-ui/core/FormLabel'; import List from '@material-ui/core/List'; import ListItemText from '@material-ui/core/ListItemText'; @@ -19,8 +18,20 @@ import OutlinedInput from '@material-ui/core/OutlinedInput'; import React from 'react'; import Select from '@material-ui/core/Select'; -import {AltFormField} from '@fbcnms/ui/components/design-system/FormField/FormField'; +import {AltFormField} from '../components/design-system/FormField/FormField'; import {UserRoles} from '@fbcnms/auth/types'; +import {makeStyles} from '@material-ui/styles'; + +const useStyles = makeStyles(_ => ({ + addButton: { + minWidth: '150px', + }, + selectItem: { + fontSize: '12px', + fontFamily: '"Inter", sans-serif', + fontWeight: '600', + }, +})); /** * Create User Tab @@ -28,76 +39,86 @@ import {UserRoles} from '@fbcnms/auth/types'; */ export default function OrganizationUserDialog(props: DialogProps) { const {user} = props; + const classes = useStyles(); return ( - - - {props.error && ( - - {props.error} - - )} - - { - props.onUserChange({...user, email: target.value}); - }} - /> - - - { - props.onUserChange({...user, password: target.value}); - }} - /> - - - { - props.onUserChange({...user, passwordConfirmation: target.value}); - }} - /> - - - + + {props.error && ( + + {props.error} - - + )} + + { + props.onUserChange({...user, email: target.value}); + }} + /> + + + { + props.onUserChange({...user, password: target.value}); + }} + /> + + + { + props.onUserChange({...user, passwordConfirmation: target.value}); + }} + /> + + + + + ); } diff --git a/fbcnms-packages/fbcnms-ui/host/OrganizationUsersTable.js b/fbcnms-packages/fbcnms-ui/host/OrganizationUsersTable.js new file mode 100644 index 0000000..a95c959 --- /dev/null +++ b/fbcnms-packages/fbcnms-ui/host/OrganizationUsersTable.js @@ -0,0 +1,160 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import ActionTable from '../components/ActionTable'; +import React from 'react'; +import Text from '@fbcnms/ui/components/design-system/Text'; +import axios from 'axios'; +import withAlert from '@fbcnms/ui/components/Alert/withAlert'; +import type {EditUser} from './OrganizationEdit'; +import type {WithAlert} from '@fbcnms/ui/components/Alert/withAlert'; + +import {UserRoles} from '@fbcnms/auth/types'; +import {useEffect, useState} from 'react'; +import {useEnqueueSnackbar} from '@fbcnms/ui/hooks/useSnackbar'; +import {useRouter} from '@fbcnms/ui/hooks'; + +type OrganizationUsersTableProps = WithAlert & { + refresh: Date, + setRefresh: number => void, + editUser: (user: ?EditUser) => void, +}; + +/** + * Table of users that belong to a specific organization + */ +function OrganizationUsersTable(props: OrganizationUsersTableProps) { + const tableRef = React.createRef(); + const enqueueSnackbar = useEnqueueSnackbar(); + const [users, setUsers] = React.useState>([]); + const [currRow, setCurrRow] = useState({}); + const {match} = useRouter(); + + // refresh data on subscriber add + useEffect(() => { + tableRef.current?.onQueryChange(); + }, [props.refresh]); + + const onDeleteUser = user => { + props + .confirm({ + message: ( + + {'Are you sure you want to delete the user '} + {user.email}? + + ), + confirmLabel: 'Delete', + }) + .then(confirmed => { + if (confirmed) { + axios + .delete('/user/async/' + user.id) + .then(() => { + props.setRefresh(Date.now()); + }) + .catch(error => { + const message = error.response?.data?.error || error; + enqueueSnackbar(`Unable to save organization: ${message}`, { + variant: 'error', + }); + }); + } + }); + }; + + const menuItems = [ + { + name: 'Edit', + handleFunc: () => { + const user: ?EditUser = users.find(user => user.id === currRow.id); + props.editUser?.(user); + }, + }, + { + name: 'Remove', + handleFunc: () => { + onDeleteUser(currRow); + }, + }, + ]; + const columnStruct = [ + { + title: '', + field: '', + width: '40px', + render: rowData => ( + {rowData.tableData?.id + 1} + ), + }, + { + title: 'Email', + field: 'email', + }, + { + title: 'Role', + field: 'role', + render: rowData => { + const userRole = Object.keys(UserRoles).find( + role => UserRoles[role] === rowData.role, + ); + return <>{userRole}; + }, + }, + ]; + + return ( + <> + + new Promise((resolve, _reject) => { + axios + .get(`/host/organization/async/${match.params.name}/users`) + .then(result => { + const users: Array = result.data.map(user => { + return { + email: user.email, + role: user.role, + id: user.id, + networkIDs: user.networkIDs, + organization: user.organization, + }; + }); + setUsers(users); + resolve({ + data: users, + }); + }); + }) + } + columns={columnStruct} + handleCurrRow={(row: EditUser) => { + setCurrRow(row); + }} + menuItems={menuItems} + localization={{ + // hide 'Actions' in table header + header: {actions: ''}, + }} + options={{ + actionsColumnIndex: -1, + sorting: true, + // hide table title and toolbar + toolbar: false, + paging: false, + pageSizeOptions: [100, 200], + }} + /> + + ); +} + +export default withAlert(OrganizationUsersTable); diff --git a/fbcnms-packages/fbcnms-ui/host/Organizations.js b/fbcnms-packages/fbcnms-ui/host/Organizations.js index 0780f25..423c58b 100644 --- a/fbcnms-packages/fbcnms-ui/host/Organizations.js +++ b/fbcnms-packages/fbcnms-ui/host/Organizations.js @@ -8,23 +8,25 @@ * @format */ +import type {EnqueueSnackbarOptions} from 'notistack'; import type {OrganizationPlainAttributes} from '@fbcnms/sequelize-models/models/organization'; import type {UserType} from '@fbcnms/sequelize-models/models/user.js'; import type {WithAlert} from '@fbcnms/ui/components/Alert/withAlert'; import ActionTable from '../components/ActionTable'; -import Button from '@material-ui/core/Button'; +import BusinessIcon from '@material-ui/icons/Business'; +import Button from '@fbcnms/ui/components/design-system/Button'; import Dialog from '@material-ui/core/Dialog'; import DialogActions from '@material-ui/core/DialogActions'; import DialogContent from '@material-ui/core/DialogContent'; import DialogContentText from '@material-ui/core/DialogContentText'; import DialogTitle from '@material-ui/core/DialogTitle'; +import ExitToAppIcon from '@material-ui/icons/ExitToApp'; import Grid from '@material-ui/core/Grid'; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import LoadingFiller from '@fbcnms/ui/components/LoadingFiller'; -import NestedRouteLink from '@fbcnms/ui/components/NestedRouteLink'; import OrganizationDialog from './OrganizationDialog'; import PersonAdd from '@material-ui/icons/PersonAdd'; import PersonIcon from '@material-ui/icons/Person'; @@ -33,13 +35,12 @@ import Text from '../components/design-system/Text'; import axios from 'axios'; import withAlert from '@fbcnms/ui/components/Alert/withAlert'; -import {Route} from 'react-router-dom'; -import {comet, concrete} from '@fbcnms/ui/theme/colors'; +import {comet, concrete, gullGray} from '../theme/colors'; import {makeStyles} from '@material-ui/styles'; import {useAxios, useRouter} from '@fbcnms/ui/hooks'; import {useCallback, useEffect, useState} from 'react'; import {useEnqueueSnackbar} from '@fbcnms/ui/hooks/useSnackbar'; -import {useRelativePath, useRelativeUrl} from '@fbcnms/ui/hooks/useRouter'; +import {useRelativeUrl} from '@fbcnms/ui/hooks/useRouter'; export type Organization = OrganizationPlainAttributes; @@ -48,6 +49,9 @@ const ORGANIZATION_DESCRIPTION = 'As a host user, you can create and manage organizations here. You can also create users for these organizations.'; const useStyles = makeStyles(_ => ({ + addButton: { + minWidth: '150px', + }, description: { margin: '20px 0', }, @@ -59,50 +63,53 @@ const useStyles = makeStyles(_ => ({ paper: { margin: '40px 32px', }, - dialogTitle: { + onBoardingDialog: { + padding: '24px 0', + }, + onBoardingDialogTitle: { + padding: '0 24px', fontSize: '24px', color: comet, backgroundColor: concrete, }, - dialog: { + onBoardingDialogContent: { minHeight: '200px', - padding: '24px', + padding: '16px 24px', }, - dialogActions: { + onBoardingDialogActions: { + padding: '0 24px', backgroundColor: concrete, boxShadow: 'none', }, - dialogButton: { - backgroundColor: comet, - color: concrete, - '&:hover': { - backgroundColor: concrete, - color: comet, - }, + onBoardingDialogButton: { + minWidth: '120px', }, - subtitle: { margin: '16px 0', }, + index: { + color: gullGray, + }, })); -type Props = {...WithAlert}; +type Props = {...WithAlert, hideAdvancedFields: boolean}; function OnboardingDialog() { const classes = useStyles(); const [open, setOpen] = useState(true); return ( setOpen(false)} aria-describedby="alert-dialog-slide-description"> - + {'Welcome to Magma Host Portal'} - + In this portal, you can add and edit organizations and its user. @@ -111,7 +118,7 @@ function OnboardingDialog() { - + Add an organization @@ -123,7 +130,7 @@ function OnboardingDialog() { - + Log in to the organization portal with the user account you @@ -133,9 +140,10 @@ function OnboardingDialog() { - + @@ -147,6 +155,10 @@ function OnboardingDialog() { async function getUsers( organizations: Organization[], setUsers: (Array) => void, + enqueueSnackbar: ( + msg: string, + cfg: EnqueueSnackbarOptions, + ) => ?(string | number), ) { const requests = organizations.map(async organization => { try { @@ -154,7 +166,11 @@ async function getUsers( `/host/organization/async/${organization.name}/users`, ); return response.data; - } catch (error) {} + } catch (error) { + enqueueSnackbar('Organization added successfully', { + variant: 'success', + }); + } }); const organizationUsers = await Promise.all(requests); if (organizationUsers) { @@ -165,13 +181,13 @@ async function getUsers( function Organizations(props: Props) { const classes = useStyles(); const relativeUrl = useRelativeUrl(); - const relativePath = useRelativePath(); const {history} = useRouter(); const [organizations, setOrganizations] = useState(null); const [addingUserFor, setAddingUserFor] = useState(null); const [currRow, setCurrRow] = useState({}); const [users, setUsers] = useState>([]); const [showOnboardingDialog, setShowOnboardingDialog] = useState(false); + const [showOrganizationDialog, setShowOrganizationDialog] = useState(false); const [addUser, setAddUser] = useState(false); const enqueueSnackbar = useEnqueueSnackbar(); const {error, isLoading} = useAxios({ @@ -185,7 +201,7 @@ function Organizations(props: Props) { }); useEffect(() => { if (organizations?.length) { - getUsers(organizations, setUsers); + getUsers(organizations, setUsers, enqueueSnackbar); } }, [organizations, addingUserFor]); @@ -195,7 +211,7 @@ function Organizations(props: Props) { const onDelete = org => { props - .confirm('Are you sure you want to delete this org?') + .confirm('Are you sure you want to delete this organization?') .then(async confirm => { if (!confirm) return; await axios.delete(`/host/organization/async/${org.id}`); @@ -231,11 +247,13 @@ function Organizations(props: Props) { Organizations - - - + {ORGANIZATION_DESCRIPTION} @@ -251,13 +269,25 @@ function Organizations(props: Props) { width: '40px', editable: 'never', render: rowData => ( - - {rowData.tableData?.id + 1 || ''} + + {rowData.tableData?.index + 1 || ''} ), }, {title: 'Name', field: 'name'}, - {title: 'Accessible Networks', field: 'networks'}, + { + title: 'Accessible Networks', + field: 'networks', + render: rowData => { + // only diplay 3 networks if more + if (rowData.networks.length > 2) { + return `${rowData.networks.slice(0, 3).join(', ')} + ${ + rowData.networks.length - 3 + } more`; + } + return rowData.networks.join(', '); + }, + }, {title: 'Link to Organization Portal', field: 'portalLink'}, {title: 'Number of Users', field: 'userNumber'}, ]} @@ -270,6 +300,8 @@ function Organizations(props: Props) { tooltip: 'Add User', onClick: (event, row) => { setAddingUserFor(row); + setAddUser(true); + setShowOrganizationDialog(true); }, }, ]} @@ -294,65 +326,63 @@ function Organizations(props: Props) { }} /> - ( - setAddUser(true)} - onClose={() => { - setAddUser(false); - history.push(relativeUrl('')); - }} - onCreateOrg={org => { - let newOrg = null; - axios - .post('/host/organization/async', org) - .then(() => { - enqueueSnackbar('Organization added successfully', { - variant: 'success', - }); - axios - .get(`/host/organization/async/${org.name}`) - .then(resp => { - newOrg = resp.data.organization; - if (newOrg) { - setOrganizations([...organizations, newOrg]); - setAddingUserFor(newOrg); - } - }); - }) - .catch(error => { - setAddUser(false); - history.push(relativeUrl('')); - enqueueSnackbar(error?.response?.data?.error || error, { - variant: 'error', - }); - }); - }} - onCreateUser={user => { + setAddUser(true)} + onClose={() => { + setShowOrganizationDialog(false); + setAddUser(false); + }} + onCreateOrg={org => { + let newOrg = null; + axios + .post('/host/organization/async', org) + .then(() => { + enqueueSnackbar('Organization added successfully', { + variant: 'success', + }); axios - .post( - `/host/organization/async/${ - addingUserFor?.name || '' - }/add_user`, - user, - ) - .then(() => { - enqueueSnackbar('User added successfully', { - variant: 'success', - }); - setAddingUserFor(null); - history.push(relativeUrl('')); - }) - .catch(error => { - enqueueSnackbar(error?.response?.data?.error || error, { - variant: 'error', - }); + .get(`/host/organization/async/${org?.name ?? ''}`) + .then(resp => { + newOrg = resp.data.organization; + if (newOrg) { + setOrganizations([...organizations, newOrg]); + setAddingUserFor(newOrg); + } }); - }} - /> - )} + }) + .catch(error => { + enqueueSnackbar(error?.response?.data?.error || error, { + variant: 'error', + }); + }); + }} + onCreateUser={user => { + axios + .post( + `/host/organization/async/${ + addingUserFor?.name || '' + }/add_user`, + user, + ) + .then(() => { + enqueueSnackbar('User added successfully', { + variant: 'success', + }); + setAddingUserFor(null); + setShowOrganizationDialog(false); + }) + .catch(error => { + enqueueSnackbar(error?.response?.data?.error || error, { + variant: 'error', + }); + }); + }} /> diff --git a/fbcnms-packages/fbcnms-ui/theme/colors.js b/fbcnms-packages/fbcnms-ui/theme/colors.js index 82f49a4..16c8053 100644 --- a/fbcnms-packages/fbcnms-ui/theme/colors.js +++ b/fbcnms-packages/fbcnms-ui/theme/colors.js @@ -51,6 +51,7 @@ export const green30 = '#00af5b'; export const green = '#66bb6a'; export const gullGray = '#9DA7BB'; export const lightBlue = '#42a5f5'; +export const mirage = '#171B25'; export const orange = '#f9a825'; export const pink = '#f48fb1'; export const primaryText = '#1c1e21'; diff --git a/fbcnms-packages/fbcnms-ui/theme/default.js b/fbcnms-packages/fbcnms-ui/theme/default.js index 9d0dc79..85309ad 100644 --- a/fbcnms-packages/fbcnms-ui/theme/default.js +++ b/fbcnms-packages/fbcnms-ui/theme/default.js @@ -15,13 +15,13 @@ import { blue60, blueGrayDark, brightGray, + comet, fadedBlue, gray0, gray00, gray1, gray13, gray50, - gullGray, primaryText, red, redwood, @@ -176,7 +176,7 @@ export default createMuiTheme({ }, }, input: { - color: gullGray, + color: comet, fontFamily: '"Inter", sans-serif', fontWeight: 600, fontSize: '12px', @@ -186,6 +186,9 @@ export default createMuiTheme({ paddingBottom: '12px', paddingTop: '12px', height: '24px', + '&::placeholder': { + color: '#8895ad', + }, '&::-webkit-input-placeholder': { opacity: 1, },