diff --git a/ui/spec/admin/reducers/chronografSpec.js b/ui/spec/admin/reducers/chronografSpec.js new file mode 100644 index 0000000000..7cb3d6dd8c --- /dev/null +++ b/ui/spec/admin/reducers/chronografSpec.js @@ -0,0 +1,141 @@ +import reducer from 'src/admin/reducers/chronograf' + +import {loadUsers} from 'src/admin/actions/chronograf' + +import { + MEMBER_ROLE, + VIEWER_ROLE, + EDITOR_ROLE, + ADMIN_ROLE, +} from 'src/auth/Authorized' + +let state + +const users = [ + { + id: '666', + name: 'bob@billietta.com', + roles: [ + { + name: 'admin', + organization: '0', + }, + { + name: 'member', + organization: '667', + }, + ], + provider: 'github', + scheme: 'oauth2', + superAdmin: true, + links: { + self: '/chronograf/v1/users/666', + }, + organizations: [ + { + id: '0', + name: 'Default', + }, + { + id: '667', + name: 'Engineering', + defaultRole: 'member', + }, + ], + }, + { + id: '831', + name: 'billybob@gmail.com', + roles: [ + { + name: 'member', + organization: '0', + }, + { + name: 'viewer', + organization: '667', + }, + { + name: 'editor', + organization: '1236', + }, + ], + provider: 'github', + scheme: 'oauth2', + superAdmin: false, + links: { + self: '/chronograf/v1/users/831', + }, + organizations: [ + { + id: '0', + name: 'Default', + }, + { + id: '667', + name: 'Engineering', + defaultRole: 'member', + }, + { + id: '1236', + name: 'PsyOps', + defaultRole: 'editor', + }, + ], + }, + { + id: '720', + name: 'shorty@gmail.com', + roles: [ + { + name: 'admin', + organization: '667', + }, + { + name: 'viewer', + organization: '1236', + }, + ], + provider: 'github', + scheme: 'oauth2', + superAdmin: false, + links: { + self: '/chronograf/v1/users/720', + }, + organizations: [ + { + id: '667', + name: 'Engineering', + defaultRole: 'member', + }, + { + id: '1236', + name: 'PsyOps', + defaultRole: 'editor', + }, + ], + }, + { + id: '271', + name: 'shawn.ofthe.dead@altavista.yop', + roles: [], + provider: 'github', + scheme: 'oauth2', + superAdmin: false, + links: { + self: '/chronograf/v1/users/271', + }, + organizations: [], + }, +] + +describe('Admin.Chronograf.Reducers', () => { + it('it can load all users', () => { + const actual = reducer(state, loadUsers({users})) + const expected = { + users, + } + + expect(actual.users).to.deep.equal(expected.users) + }) +}) diff --git a/ui/spec/admin/reducers/adminSpec.js b/ui/spec/admin/reducers/influxdbSpec.js similarity index 98% rename from ui/spec/admin/reducers/adminSpec.js rename to ui/spec/admin/reducers/influxdbSpec.js index 24941cf6d6..adef72a72e 100644 --- a/ui/spec/admin/reducers/adminSpec.js +++ b/ui/spec/admin/reducers/influxdbSpec.js @@ -1,4 +1,4 @@ -import reducer from 'src/admin/reducers/admin' +import reducer from 'src/admin/reducers/influxdb' import { addUser, @@ -21,7 +21,7 @@ import { filterUsers, addDatabaseDeleteCode, removeDatabaseDeleteCode, -} from 'src/admin/actions' +} from 'src/admin/actions/influxdb' import { NEW_DEFAULT_USER, @@ -138,7 +138,7 @@ const db2 = { deleteCode: 'DELETE', } -describe('Admin.Reducers', () => { +describe('Admin.InfluxDB.Reducers', () => { describe('Databases', () => { const state = {databases: [db1, db2]} diff --git a/ui/spec/shared/reducers/authSpec.js b/ui/spec/shared/reducers/authSpec.js index 170244f35f..7ac9fa14da 100644 --- a/ui/spec/shared/reducers/authSpec.js +++ b/ui/spec/shared/reducers/authSpec.js @@ -5,7 +5,7 @@ import { authRequested, authReceived, meRequested, - meReceived, + meReceivedNotUsingAuth, } from 'shared/actions/auth' const defaultAuth = { @@ -22,7 +22,6 @@ const defaultAuth = { const defaultMe = { name: 'wishful_modal@overlay.technology', - password: '', links: { self: '/chronograf/v1/users/wishful_modal@overlay.technology', }, @@ -58,9 +57,12 @@ describe('Shared.Reducers.authReducer', () => { expect(reducedState.isMeLoading).to.equal(true) }) - it('should handle ME_RECEIVED', () => { - const loadingState = Object.assign({}, initialState, {isMeLoading: true}) - const reducedState = authReducer(loadingState, meReceived(defaultMe)) + it('should handle ME_RECEIVED__NON_AUTH', () => { + const loadingState = {...initialState, isMeLoading: true} + const reducedState = authReducer( + loadingState, + meReceivedNotUsingAuth(defaultMe) + ) expect(reducedState.me).to.deep.equal(defaultMe) expect(reducedState.isAuthLoading).to.equal(false) diff --git a/ui/src/CheckSources.js b/ui/src/CheckSources.js index 6d2cfd03cc..5210d6f7c8 100644 --- a/ui/src/CheckSources.js +++ b/ui/src/CheckSources.js @@ -3,6 +3,8 @@ import {withRouter} from 'react-router' import {connect} from 'react-redux' import {bindActionCreators} from 'redux' +import {MEMBER_ROLE, VIEWER_ROLE} from 'src/auth/Authorized' + import {getSources} from 'shared/apis' import {showDatabases} from 'shared/apis/metaQuery' @@ -14,7 +16,7 @@ import {DEFAULT_HOME_PAGE} from 'shared/constants' // Acts as a 'router middleware'. The main `App` component is responsible for // getting the list of data nodes, but not every page requires them to function. // Routes that do require data nodes can be nested under this component. -const {arrayOf, func, node, shape, string} = PropTypes +const {arrayOf, bool, func, node, shape, string} = PropTypes const CheckSources = React.createClass({ propTypes: { sources: arrayOf( @@ -42,6 +44,15 @@ const CheckSources = React.createClass({ }).isRequired, loadSources: func.isRequired, errorThrown: func.isRequired, + auth: shape({ + isUsingAuth: bool, + me: shape({ + currentOrganization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + }), + }), }, childContextTypes: { @@ -83,7 +94,14 @@ const CheckSources = React.createClass({ }, async componentWillUpdate(nextProps, nextState) { - const {router, location, params, errorThrown, sources} = nextProps + const { + router, + location, + params, + errorThrown, + sources, + auth: {isUsingAuth, me}, + } = nextProps const {isFetching} = nextState const source = sources.find(s => s.id === params.sourceID) const defaultSource = sources.find(s => s.default === true) @@ -92,6 +110,23 @@ const CheckSources = React.createClass({ const rest = location.pathname.match(/\/sources\/\d+?\/(.+)/) const restString = rest === null ? DEFAULT_HOME_PAGE : rest[1] + if (isUsingAuth && me.role === MEMBER_ROLE) { + // if you're a member, go to purgatory. + return router.push('/purgatory') + } + + if (isUsingAuth && me.role === VIEWER_ROLE) { + if (defaultSource) { + return router.push(`/sources/${defaultSource.id}/${restString}`) + } else if (sources[0]) { + return router.push(`/sources/${sources[0].id}/${restString}`) + } + // if you're a viewer and there are no sources, go to purgatory. + return router.push('/purgatory') + } + + // if you're an editor or not using auth, try for sources or otherwise + // create one if (defaultSource) { return router.push(`/sources/${defaultSource.id}/${restString}`) } else if (sources[0]) { @@ -112,11 +147,21 @@ const CheckSources = React.createClass({ }, render() { - const {params, sources} = this.props + const { + params, + sources, + auth: {isUsingAuth, me, me: {currentOrganization}}, + } = this.props const {isFetching} = this.state const source = sources.find(s => s.id === params.sourceID) - if (isFetching || !source) { + if ( + isFetching || + !source || + typeof isUsingAuth !== 'boolean' || + (me && me.role === undefined) || // TODO: not sure this happens + !currentOrganization + ) { return
} @@ -132,8 +177,10 @@ const CheckSources = React.createClass({ }, }) -const mapStateToProps = ({sources}) => ({ +const mapStateToProps = ({sources, auth, me}) => ({ sources, + auth, + me, }) const mapDispatchToProps = dispatch => ({ diff --git a/ui/src/admin/actions/chronograf.js b/ui/src/admin/actions/chronograf.js new file mode 100644 index 0000000000..789e206472 --- /dev/null +++ b/ui/src/admin/actions/chronograf.js @@ -0,0 +1,211 @@ +import { + getUsers as getUsersAJAX, + getOrganizations as getOrganizationsAJAX, + createUser as createUserAJAX, + updateUser as updateUserAJAX, + deleteUser as deleteUserAJAX, + createOrganization as createOrganizationAJAX, + updateOrganization as updateOrganizationAJAX, + deleteOrganization as deleteOrganizationAJAX, +} from 'src/admin/apis/chronograf' + +import {publishAutoDismissingNotification} from 'shared/dispatchers' +import {errorThrown} from 'shared/actions/errors' + +// action creators + +// response contains `users` and `links` +export const loadUsers = ({users}) => ({ + type: 'CHRONOGRAF_LOAD_USERS', + payload: { + users, + }, +}) + +export const loadOrganizations = ({organizations}) => ({ + type: 'CHRONOGRAF_LOAD_ORGANIZATIONS', + payload: { + organizations, + }, +}) + +export const addUser = user => ({ + type: 'CHRONOGRAF_ADD_USER', + payload: { + user, + }, +}) + +export const updateUser = (user, updatedUser) => ({ + type: 'CHRONOGRAF_UPDATE_USER', + payload: { + user, + updatedUser, + }, +}) + +export const syncUser = (staleUser, syncedUser) => ({ + type: 'CHRONOGRAF_SYNC_USER', + payload: { + staleUser, + syncedUser, + }, +}) + +export const removeUser = user => ({ + type: 'CHRONOGRAF_REMOVE_USER', + payload: { + user, + }, +}) + +export const addOrganization = organization => ({ + type: 'CHRONOGRAF_ADD_ORGANIZATION', + payload: { + organization, + }, +}) + +export const renameOrganization = (organization, newName) => ({ + type: 'CHRONOGRAF_RENAME_ORGANIZATION', + payload: { + organization, + newName, + }, +}) + +export const syncOrganization = (staleOrganization, syncedOrganization) => ({ + type: 'CHRONOGRAF_SYNC_ORGANIZATION', + payload: { + staleOrganization, + syncedOrganization, + }, +}) + +export const removeOrganization = organization => ({ + type: 'CHRONOGRAF_REMOVE_ORGANIZATION', + payload: { + organization, + }, +}) + +// async actions (thunks) +export const loadUsersAsync = url => async dispatch => { + try { + const {data} = await getUsersAJAX(url) + dispatch(loadUsers(data)) + } catch (error) { + dispatch(errorThrown(error)) + } +} + +export const loadOrganizationsAsync = url => async dispatch => { + try { + const {data} = await getOrganizationsAJAX(url) + dispatch(loadOrganizations(data)) + } catch (error) { + dispatch(errorThrown(error)) + } +} + +export const createUserAsync = (url, user) => async dispatch => { + dispatch(addUser(user)) + try { + const {data} = await createUserAJAX(url, user) + dispatch(syncUser(user, data)) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(removeUser(user)) + } +} + +export const updateUserAsync = (user, updatedUser) => async dispatch => { + dispatch(updateUser(user, updatedUser)) + try { + // currently the request will be rejected if name, provider, or scheme, or + // no roles are sent with the request. + // TODO: remove the null assignments below so that the user request can have + // the original name, provider, and scheme once the change to allow this is + // implemented server-side + const {data} = await updateUserAJAX({ + ...updatedUser, + name: null, + provider: null, + scheme: null, + }) + dispatch( + publishAutoDismissingNotification( + 'success', + `User updated: ${user.scheme}::${user.provider}::${user.name}` + ) + ) + // it's not necessary to syncUser again but it's useful for good + // measure and for the clarity of insight in the redux story + dispatch(syncUser(user, data)) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(syncUser(user, user)) + } +} + +export const deleteUserAsync = user => async dispatch => { + dispatch(removeUser(user)) + try { + await deleteUserAJAX(user) + dispatch( + publishAutoDismissingNotification( + 'success', + `User removed from organization: ${user.scheme}::${user.provider}::${user.name}` + ) + ) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(addUser(user)) + } +} + +export const createOrganizationAsync = ( + url, + organization +) => async dispatch => { + dispatch(addOrganization(organization)) + try { + const {data} = await createOrganizationAJAX(url, organization) + dispatch(syncOrganization(organization, data)) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(removeOrganization(organization)) + } +} + +export const updateOrganizationAsync = ( + organization, + updatedOrganization +) => async dispatch => { + dispatch(renameOrganization(organization, updatedOrganization.name)) + try { + const {data} = await updateOrganizationAJAX(updatedOrganization) + // it's not necessary to syncOrganization again but it's useful for good + // measure and for the clarity of insight in the redux story + dispatch(syncOrganization(updatedOrganization, data)) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(syncOrganization(organization, organization)) // restore if fail + } +} + +export const deleteOrganizationAsync = organization => async dispatch => { + dispatch(removeOrganization(organization)) + try { + await deleteOrganizationAJAX(organization) + dispatch( + publishAutoDismissingNotification( + 'success', + `Organization deleted: ${organization.name}` + ) + ) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(addOrganization(organization)) + } +} diff --git a/ui/src/admin/actions/index.js b/ui/src/admin/actions/influxdb.js similarity index 91% rename from ui/src/admin/actions/index.js rename to ui/src/admin/actions/influxdb.js index c4f905b235..954e408dac 100644 --- a/ui/src/admin/actions/index.js +++ b/ui/src/admin/actions/influxdb.js @@ -14,7 +14,7 @@ import { updateRole as updateRoleAJAX, updateUser as updateUserAJAX, updateRetentionPolicy as updateRetentionPolicyAJAX, -} from 'src/admin/apis' +} from 'src/admin/apis/influxdb' import {killQuery as killQueryProxy} from 'shared/apis/metaQuery' @@ -25,54 +25,54 @@ import {REVERT_STATE_DELAY} from 'shared/constants' import _ from 'lodash' export const loadUsers = ({users}) => ({ - type: 'LOAD_USERS', + type: 'INFLUXDB_LOAD_USERS', payload: { users, }, }) export const loadRoles = ({roles}) => ({ - type: 'LOAD_ROLES', + type: 'INFLUXDB_LOAD_ROLES', payload: { roles, }, }) export const loadPermissions = ({permissions}) => ({ - type: 'LOAD_PERMISSIONS', + type: 'INFLUXDB_LOAD_PERMISSIONS', payload: { permissions, }, }) export const loadDatabases = databases => ({ - type: 'LOAD_DATABASES', + type: 'INFLUXDB_LOAD_DATABASES', payload: { databases, }, }) export const addUser = () => ({ - type: 'ADD_USER', + type: 'INFLUXDB_ADD_USER', }) export const addRole = () => ({ - type: 'ADD_ROLE', + type: 'INFLUXDB_ADD_ROLE', }) export const addDatabase = () => ({ - type: 'ADD_DATABASE', + type: 'INFLUXDB_ADD_DATABASE', }) export const addRetentionPolicy = database => ({ - type: 'ADD_RETENTION_POLICY', + type: 'INFLUXDB_ADD_RETENTION_POLICY', payload: { database, }, }) export const syncUser = (staleUser, syncedUser) => ({ - type: 'SYNC_USER', + type: 'INFLUXDB_SYNC_USER', payload: { staleUser, syncedUser, @@ -80,7 +80,7 @@ export const syncUser = (staleUser, syncedUser) => ({ }) export const syncRole = (staleRole, syncedRole) => ({ - type: 'SYNC_ROLE', + type: 'INFLUXDB_SYNC_ROLE', payload: { staleRole, syncedRole, @@ -88,7 +88,7 @@ export const syncRole = (staleRole, syncedRole) => ({ }) export const syncDatabase = (stale, synced) => ({ - type: 'SYNC_DATABASE', + type: 'INFLUXDB_SYNC_DATABASE', payload: { stale, synced, @@ -96,7 +96,7 @@ export const syncDatabase = (stale, synced) => ({ }) export const syncRetentionPolicy = (database, stale, synced) => ({ - type: 'SYNC_RETENTION_POLICY', + type: 'INFLUXDB_SYNC_RETENTION_POLICY', payload: { database, stale, @@ -105,7 +105,7 @@ export const syncRetentionPolicy = (database, stale, synced) => ({ }) export const editUser = (user, updates) => ({ - type: 'EDIT_USER', + type: 'INFLUXDB_EDIT_USER', payload: { user, updates, @@ -113,7 +113,7 @@ export const editUser = (user, updates) => ({ }) export const editRole = (role, updates) => ({ - type: 'EDIT_ROLE', + type: 'INFLUXDB_EDIT_ROLE', payload: { role, updates, @@ -121,7 +121,7 @@ export const editRole = (role, updates) => ({ }) export const editDatabase = (database, updates) => ({ - type: 'EDIT_DATABASE', + type: 'INFLUXDB_EDIT_DATABASE', payload: { database, updates, @@ -129,21 +129,21 @@ export const editDatabase = (database, updates) => ({ }) export const killQuery = queryID => ({ - type: 'KILL_QUERY', + type: 'INFLUXDB_KILL_QUERY', payload: { queryID, }, }) export const setQueryToKill = queryIDToKill => ({ - type: 'SET_QUERY_TO_KILL', + type: 'INFLUXDB_SET_QUERY_TO_KILL', payload: { queryIDToKill, }, }) export const loadQueries = queries => ({ - type: 'LOAD_QUERIES', + type: 'INFLUXDB_LOAD_QUERIES', payload: { queries, }, @@ -151,7 +151,7 @@ export const loadQueries = queries => ({ // TODO: change to 'removeUser' export const deleteUser = user => ({ - type: 'DELETE_USER', + type: 'INFLUXDB_DELETE_USER', payload: { user, }, @@ -159,21 +159,21 @@ export const deleteUser = user => ({ // TODO: change to 'removeRole' export const deleteRole = role => ({ - type: 'DELETE_ROLE', + type: 'INFLUXDB_DELETE_ROLE', payload: { role, }, }) export const removeDatabase = database => ({ - type: 'REMOVE_DATABASE', + type: 'INFLUXDB_REMOVE_DATABASE', payload: { database, }, }) export const removeRetentionPolicy = (database, retentionPolicy) => ({ - type: 'REMOVE_RETENTION_POLICY', + type: 'INFLUXDB_REMOVE_RETENTION_POLICY', payload: { database, retentionPolicy, @@ -181,35 +181,35 @@ export const removeRetentionPolicy = (database, retentionPolicy) => ({ }) export const filterUsers = text => ({ - type: 'FILTER_USERS', + type: 'INFLUXDB_FILTER_USERS', payload: { text, }, }) export const filterRoles = text => ({ - type: 'FILTER_ROLES', + type: 'INFLUXDB_FILTER_ROLES', payload: { text, }, }) export const addDatabaseDeleteCode = database => ({ - type: 'ADD_DATABASE_DELETE_CODE', + type: 'INFLUXDB_ADD_DATABASE_DELETE_CODE', payload: { database, }, }) export const removeDatabaseDeleteCode = database => ({ - type: 'REMOVE_DATABASE_DELETE_CODE', + type: 'INFLUXDB_REMOVE_DATABASE_DELETE_CODE', payload: { database, }, }) export const editRetentionPolicy = (database, retentionPolicy, updates) => ({ - type: 'EDIT_RETENTION_POLICY', + type: 'INFLUXDB_EDIT_RETENTION_POLICY', payload: { database, retentionPolicy, diff --git a/ui/src/admin/apis/chronograf.js b/ui/src/admin/apis/chronograf.js new file mode 100644 index 0000000000..3008849280 --- /dev/null +++ b/ui/src/admin/apis/chronograf.js @@ -0,0 +1,104 @@ +import AJAX from 'src/utils/ajax' + +export const getUsers = async url => { + try { + return await AJAX({ + method: 'GET', + url, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const getOrganizations = async url => { + try { + return await AJAX({ + method: 'GET', + url, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const createUser = async (url, user) => { + try { + return await AJAX({ + method: 'POST', + url, + data: user, + }) + } catch (error) { + console.error(error) + throw error + } +} + +// TODO: change updatedUserWithRolesOnly to a whole user that can have the +// original name, provider, and scheme once the change to allow this is +// implemented server-side +export const updateUser = async updatedUserWithRolesOnly => { + try { + return await AJAX({ + method: 'PATCH', + url: updatedUserWithRolesOnly.links.self, + data: updatedUserWithRolesOnly, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const deleteUser = async user => { + try { + return await AJAX({ + method: 'DELETE', + url: user.links.self, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const createOrganization = async (url, organization) => { + try { + return await AJAX({ + method: 'POST', + url, + data: organization, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const updateOrganization = async organization => { + try { + return await AJAX({ + method: 'PATCH', + url: organization.links.self, + data: organization, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const deleteOrganization = async organization => { + try { + return await AJAX({ + method: 'DELETE', + url: organization.links.self, + }) + } catch (error) { + console.error(error) + throw error + } +} diff --git a/ui/src/admin/apis/index.js b/ui/src/admin/apis/influxdb.js similarity index 100% rename from ui/src/admin/apis/index.js rename to ui/src/admin/apis/influxdb.js diff --git a/ui/src/admin/components/chronograf/AdminTabs.js b/ui/src/admin/components/chronograf/AdminTabs.js new file mode 100644 index 0000000000..c0f506684a --- /dev/null +++ b/ui/src/admin/components/chronograf/AdminTabs.js @@ -0,0 +1,84 @@ +import React, {PropTypes} from 'react' + +import { + isUserAuthorized, + ADMIN_ROLE, + SUPERADMIN_ROLE, +} from 'src/auth/Authorized' + +import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'shared/components/Tabs' +import OrganizationsPage from 'src/admin/containers/OrganizationsPage' +import UsersTable from 'src/admin/components/chronograf/UsersTable' + +const ORGANIZATIONS_TAB_NAME = 'Organizations' +const USERS_TAB_NAME = 'Users' + +const AdminTabs = ({ + meRole, + // UsersTable + users, + organization, + onCreateUser, + onUpdateUserRole, + onUpdateUserSuperAdmin, + onDeleteUser, +}) => { + const tabs = [ + { + requiredRole: SUPERADMIN_ROLE, + type: ORGANIZATIONS_TAB_NAME, + component: , + }, + { + requiredRole: ADMIN_ROLE, + type: USERS_TAB_NAME, + component: ( + + ), + }, + ].filter(t => isUserAuthorized(meRole, t.requiredRole)) + + return ( + + + {tabs.map((t, i) => + + {tabs[i].type} + + )} + + + {tabs.map((t, i) => + + {t.component} + + )} + + + ) +} + +const {arrayOf, func, shape, string} = PropTypes + +AdminTabs.propTypes = { + meRole: string.isRequired, + // UsersTable + users: arrayOf(shape()), + organization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + onCreateUser: func.isRequired, + onUpdateUserRole: func.isRequired, + onUpdateUserSuperAdmin: func.isRequired, + onDeleteUser: func.isRequired, +} + +export default AdminTabs diff --git a/ui/src/admin/components/chronograf/OrganizationsTable.js b/ui/src/admin/components/chronograf/OrganizationsTable.js new file mode 100644 index 0000000000..d29a677e6c --- /dev/null +++ b/ui/src/admin/components/chronograf/OrganizationsTable.js @@ -0,0 +1,102 @@ +import React, {Component, PropTypes} from 'react' + +import uuid from 'node-uuid' + +import OrganizationsTableRow from 'src/admin/components/chronograf/OrganizationsTableRow' +import OrganizationsTableRowDefault from 'src/admin/components/chronograf/OrganizationsTableRowDefault' +import OrganizationsTableRowNew from 'src/admin/components/chronograf/OrganizationsTableRowNew' + +import {DEFAULT_ORG_ID} from 'src/admin/constants/dummyUsers' + +class OrganizationsTable extends Component { + constructor(props) { + super(props) + + this.state = { + isCreatingOrganization: false, + } + } + handleClickCreateOrganization = () => { + this.setState({isCreatingOrganization: true}) + } + + handleCancelCreateOrganization = () => { + this.setState({isCreatingOrganization: false}) + } + + handleCreateOrganization = newOrganization => { + const {onCreateOrg} = this.props + onCreateOrg(newOrganization) + this.setState({isCreatingOrganization: false}) + } + + render() { + const {organizations, onDeleteOrg, onRenameOrg} = this.props + const {isCreatingOrganization} = this.state + + const tableTitle = `${organizations.length} Organization${organizations.length === + 1 + ? '' + : 's'}` + + return ( +
+
+

+ {tableTitle} +

+ +
+
+
+
ID
+
Name
+
Default Role
+
+
+ {isCreatingOrganization + ? + : null} + {organizations.map( + org => + org.id === DEFAULT_ORG_ID + ? + : + )} +
+
+ ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +OrganizationsTable.propTypes = { + organizations: arrayOf( + shape({ + id: string, // when optimistically created, organization will not have an id + name: string.isRequired, + }) + ).isRequired, + onCreateOrg: func.isRequired, + onDeleteOrg: func.isRequired, + onRenameOrg: func.isRequired, +} +export default OrganizationsTable diff --git a/ui/src/admin/components/chronograf/OrganizationsTableRow.js b/ui/src/admin/components/chronograf/OrganizationsTableRow.js new file mode 100644 index 0000000000..d701d8a55a --- /dev/null +++ b/ui/src/admin/components/chronograf/OrganizationsTableRow.js @@ -0,0 +1,159 @@ +import React, {Component, PropTypes} from 'react' + +import ConfirmButtons from 'shared/components/ConfirmButtons' +import Dropdown from 'shared/components/Dropdown' + +import {USER_ROLES} from 'src/admin/constants/dummyUsers' +import {MEMBER_ROLE} from 'src/auth/Authorized' + +class OrganizationsTableRow extends Component { + constructor(props) { + super(props) + + this.state = { + isEditing: false, + isDeleting: false, + workingName: this.props.organization.name, + defaultRole: MEMBER_ROLE, + } + } + + handleNameClick = () => { + this.setState({isEditing: true}) + } + + handleConfirmRename = () => { + const {onRename, organization} = this.props + const {workingName} = this.state + + onRename(organization, workingName) + this.setState({workingName, isEditing: false}) + } + + handleCancelRename = () => { + const {organization} = this.props + + this.setState({ + workingName: organization.name, + isEditing: false, + }) + } + + handleInputChange = e => { + this.setState({workingName: e.target.value}) + } + + handleInputBlur = () => { + const {organization} = this.props + const {workingName} = this.state + + if (organization.name === workingName) { + this.handleCancelRename() + } else { + this.handleConfirmRename() + } + } + + handleKeyDown = e => { + if (e.key === 'Enter') { + this.handleInputBlur() + } else if (e.key === 'Escape') { + this.handleCancelRename() + } + } + + handleFocus = e => { + e.target.select() + } + + handleDeleteClick = () => { + this.setState({isDeleting: true}) + } + + handleDismissDeleteConfirmation = () => { + this.setState({isDeleting: false}) + } + + handleDeleteOrg = organization => { + const {onDelete} = this.props + onDelete(organization) + } + + handleChooseDefaultRole = role => { + this.setState({defaultRole: role.name}) + } + + render() { + const {workingName, isEditing, isDeleting, defaultRole} = this.state + const {organization} = this.props + + const dropdownRolesItems = USER_ROLES.map(role => ({ + ...role, + text: role.name, + })) + + const defaultRoleClassName = isDeleting + ? 'orgs-table--default-role editing' + : 'orgs-table--default-role' + + return ( +
+
+ {organization.id} +
+ {isEditing + ? (this.inputRef = r)} + /> + :
+ {workingName} + +
} +
+ +
+ {isDeleting + ? + : } +
+ ) + } +} + +const {func, shape, string} = PropTypes + +OrganizationsTableRow.propTypes = { + organization: shape({ + id: string, // when optimistically created, organization will not have an id + name: string.isRequired, + }).isRequired, + onDelete: func.isRequired, + onRename: func.isRequired, +} + +export default OrganizationsTableRow diff --git a/ui/src/admin/components/chronograf/OrganizationsTableRowDefault.js b/ui/src/admin/components/chronograf/OrganizationsTableRowDefault.js new file mode 100644 index 0000000000..2c44fb9630 --- /dev/null +++ b/ui/src/admin/components/chronograf/OrganizationsTableRowDefault.js @@ -0,0 +1,34 @@ +import React, {PropTypes} from 'react' + +import {MEMBER_ROLE} from 'src/auth/Authorized' + +// This is a non-editable organization row, used currently for DEFAULT_ORG +const OrganizationsTableRowDefault = ({organization}) => +
+
+ {organization.id} +
+
+ {organization.name} +
+
+ {MEMBER_ROLE} +
+ +
+ +const {shape, string} = PropTypes + +OrganizationsTableRowDefault.propTypes = { + organization: shape({ + id: string, + name: string.isRequired, + }).isRequired, +} + +export default OrganizationsTableRowDefault diff --git a/ui/src/admin/components/chronograf/OrganizationsTableRowNew.js b/ui/src/admin/components/chronograf/OrganizationsTableRowNew.js new file mode 100644 index 0000000000..f67a40e0df --- /dev/null +++ b/ui/src/admin/components/chronograf/OrganizationsTableRowNew.js @@ -0,0 +1,99 @@ +import React, {Component, PropTypes} from 'react' + +import ConfirmButtons from 'shared/components/ConfirmButtons' +import Dropdown from 'shared/components/Dropdown' + +import {USER_ROLES} from 'src/admin/constants/dummyUsers' +import {MEMBER_ROLE} from 'src/auth/Authorized' + +class OrganizationsTableRowNew extends Component { + constructor(props) { + super(props) + + this.state = { + name: 'Untitled Organization', + defaultRole: MEMBER_ROLE, + } + } + + handleKeyDown = e => { + const {onCancelCreateOrganization} = this.props + + if (e.key === 'Escape') { + onCancelCreateOrganization() + } + if (e.key === 'Enter') { + this.handleClickSave() + } + } + + handleInputChange = e => { + this.setState({name: e.target.value.trim()}) + } + + handleInputFocus = e => { + e.target.select() + } + + handleClickSave = () => { + const {onCreateOrganization} = this.props + const {name, defaultRole} = this.state + + onCreateOrganization(name, defaultRole) + } + + handleChooseDefaultRole = role => { + this.setState({defaultRole: role.name}) + } + + render() { + const {name, defaultRole} = this.state + const {onCancelCreateOrganization} = this.props + + const isSaveDisabled = name === null || name === '' + + const dropdownRolesItems = USER_ROLES.map(role => ({ + ...role, + text: role.name, + })) + + return ( +
+
+ (this.inputRef = r)} + /> +
+ +
+ +
+ ) + } +} + +const {func} = PropTypes + +OrganizationsTableRowNew.propTypes = { + onCreateOrganization: func.isRequired, + onCancelCreateOrganization: func.isRequired, +} + +export default OrganizationsTableRowNew diff --git a/ui/src/admin/components/chronograf/PageHeader.js b/ui/src/admin/components/chronograf/PageHeader.js new file mode 100644 index 0000000000..2a06b32e28 --- /dev/null +++ b/ui/src/admin/components/chronograf/PageHeader.js @@ -0,0 +1,23 @@ +import React, {PropTypes} from 'react' + +const PageHeader = ({currentOrganization}) => +
+
+
+

+ {currentOrganization.name} +

+
+
+
+ +const {shape, string} = PropTypes + +PageHeader.propTypes = { + currentOrganization: shape({ + id: string.isRequired, + name: string.isRequired, + }).isRequired, +} + +export default PageHeader diff --git a/ui/src/admin/components/chronograf/UsersTable.js b/ui/src/admin/components/chronograf/UsersTable.js new file mode 100644 index 0000000000..4b769d88f7 --- /dev/null +++ b/ui/src/admin/components/chronograf/UsersTable.js @@ -0,0 +1,134 @@ +import React, {Component, PropTypes} from 'react' + +import uuid from 'node-uuid' + +import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' + +import UsersTableHeader from 'src/admin/components/chronograf/UsersTableHeader' +import UsersTableRowNew from 'src/admin/components/chronograf/UsersTableRowNew' +import UsersTableRow from 'src/admin/components/chronograf/UsersTableRow' + +import {USERS_TABLE} from 'src/admin/constants/chronografTableSizing' + +class UsersTable extends Component { + constructor(props) { + super(props) + + this.state = { + isCreatingUser: false, + } + } + + handleChangeUserRole = (user, currentRole) => newRole => { + this.props.onUpdateUserRole(user, currentRole, newRole) + } + + handleChangeSuperAdmin = (user, currentStatus) => newStatus => { + this.props.onUpdateUserSuperAdmin(user, currentStatus, newStatus) + } + + handleDeleteUser = user => { + this.props.onDeleteUser(user) + } + + handleClickCreateUser = () => { + this.setState({isCreatingUser: true}) + } + + handleBlurCreateUserRow = () => { + this.setState({isCreatingUser: false}) + } + + render() { + const {organization, users, onCreateUser} = this.props + + const {isCreatingUser} = this.state + const { + colRole, + colSuperAdmin, + colProvider, + colScheme, + colActions, + } = USERS_TABLE + + return ( +
+ +
+ + + + + + + + + + + + + + {isCreatingUser + ? + : null} + {users.length || !isCreatingUser + ? users.map(user => + + ) + : + +

No Users to display

+ + } + > +
+ + } + +
Username + Role + + SuperAdmin + ProviderScheme + +
+

No Users to display

+
+
+
+ ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +UsersTable.propTypes = { + users: arrayOf(shape()), + organization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + onCreateUser: func.isRequired, + onUpdateUserRole: func.isRequired, + onUpdateUserSuperAdmin: func.isRequired, + onDeleteUser: func.isRequired, +} +export default UsersTable diff --git a/ui/src/admin/components/chronograf/UsersTableHeader.js b/ui/src/admin/components/chronograf/UsersTableHeader.js new file mode 100644 index 0000000000..93c3e816e1 --- /dev/null +++ b/ui/src/admin/components/chronograf/UsersTableHeader.js @@ -0,0 +1,42 @@ +import React, {Component, PropTypes} from 'react' +import Authorized, {ADMIN_ROLE} from 'src/auth/Authorized' + +class UsersTableHeader extends Component { + constructor(props) { + super(props) + } + + render() { + const {onClickCreateUser, numUsers, isCreatingUser} = this.props + + const panelTitle = numUsers === 1 ? `${numUsers} User` : `${numUsers} Users` + + return ( +
+

+ {panelTitle} +

+ + + +
+ ) + } +} + +const {bool, func, number} = PropTypes + +UsersTableHeader.propTypes = { + numUsers: number.isRequired, + onClickCreateUser: func.isRequired, + isCreatingUser: bool.isRequired, +} + +export default UsersTableHeader diff --git a/ui/src/admin/components/chronograf/UsersTableRow.js b/ui/src/admin/components/chronograf/UsersTableRow.js new file mode 100644 index 0000000000..a9b620f10b --- /dev/null +++ b/ui/src/admin/components/chronograf/UsersTableRow.js @@ -0,0 +1,93 @@ +import React, {PropTypes} from 'react' + +import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' + +import Dropdown from 'shared/components/Dropdown' +import SlideToggle from 'shared/components/SlideToggle' +import DeleteConfirmTableCell from 'shared/components/DeleteConfirmTableCell' + +import {USER_ROLES} from 'src/admin/constants/dummyUsers' +import {USERS_TABLE} from 'src/admin/constants/chronografTableSizing' + +const UsersTableRow = ({ + user, + organization, + onChangeUserRole, + onChangeSuperAdmin, + onDelete, +}) => { + const { + colRole, + colSuperAdmin, + colProvider, + colScheme, + colActions, + } = USERS_TABLE + + const dropdownRolesItems = USER_ROLES.map(r => ({ + ...r, + text: r.name, + })) + const currentRole = user.roles.find( + role => role.organization === organization.id + ) + + return ( + + + + {user.name} + + + + + + + + + + + + + + {user.provider} + + + {user.scheme} + + + + + ) +} + +const {func, shape, string} = PropTypes + +UsersTableRow.propTypes = { + user: shape(), + organization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + onChangeUserRole: func.isRequired, + onChangeSuperAdmin: func.isRequired, + onDelete: func.isRequired, +} + +export default UsersTableRow diff --git a/ui/src/admin/components/chronograf/UsersTableRowNew.js b/ui/src/admin/components/chronograf/UsersTableRowNew.js new file mode 100644 index 0000000000..db7901a107 --- /dev/null +++ b/ui/src/admin/components/chronograf/UsersTableRowNew.js @@ -0,0 +1,153 @@ +import React, {Component, PropTypes} from 'react' + +import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' + +import Dropdown from 'shared/components/Dropdown' +import SlideToggle from 'shared/components/SlideToggle' + +import {USERS_TABLE} from 'src/admin/constants/chronografTableSizing' +import {USER_ROLES} from 'src/admin/constants/dummyUsers' +import {MEMBER_ROLE} from 'src/auth/Authorized' + +class UsersTableRowNew extends Component { + constructor(props) { + super(props) + + this.state = { + name: '', + provider: '', + scheme: 'oauth2', + role: MEMBER_ROLE, + superAdmin: false, + } + } + + handleInputChange = fieldName => e => { + this.setState({[fieldName]: e.target.value.trim()}) + } + + handleConfirmCreateUser = () => { + const {onBlur, onCreateUser, organization} = this.props + const {name, provider, scheme, role, superAdmin} = this.state + + const newUser = { + name, + provider, + scheme, + superAdmin: superAdmin.value, + roles: [ + { + name: role, + organization: organization.id, + }, + ], + } + + onCreateUser(newUser) + onBlur() + } + + handleInputFocus = e => { + e.target.select() + } + + handleSelectRole = newRole => { + this.setState({role: newRole.text}) + } + + handleSelectSuperAdmin = superAdmin => { + this.setState({superAdmin}) + } + + render() { + const { + colRole, + colProvider, + colScheme, + colSuperAdmin, + colActions, + } = USERS_TABLE + const {onBlur} = this.props + const {name, provider, scheme, role, superAdmin} = this.state + + const dropdownRolesItems = USER_ROLES.map(r => ({...r, text: r.name})) + const preventCreate = !name || !provider + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ) + } +} + +const {func, shape, string} = PropTypes + +UsersTableRowNew.propTypes = { + organization: shape({ + id: string.isRequired, + name: string.isRequired, + }), + onBlur: func.isRequired, + onCreateUser: func.isRequired, +} + +export default UsersTableRowNew diff --git a/ui/src/admin/constants/chronografTableSizing.js b/ui/src/admin/constants/chronografTableSizing.js new file mode 100644 index 0000000000..d6adf64993 --- /dev/null +++ b/ui/src/admin/constants/chronografTableSizing.js @@ -0,0 +1,7 @@ +export const USERS_TABLE = { + colRole: 120, + colSuperAdmin: 90, + colProvider: 170, + colScheme: 90, + colActions: 68, +} diff --git a/ui/src/admin/constants/dummyUsers.js b/ui/src/admin/constants/dummyUsers.js new file mode 100644 index 0000000000..122e54a505 --- /dev/null +++ b/ui/src/admin/constants/dummyUsers.js @@ -0,0 +1,35 @@ +import { + MEMBER_ROLE, + VIEWER_ROLE, + EDITOR_ROLE, + ADMIN_ROLE, +} from 'src/auth/Authorized' + +export const USER_ROLES = [ + {name: MEMBER_ROLE}, + {name: VIEWER_ROLE}, + {name: EDITOR_ROLE}, + {name: ADMIN_ROLE}, +] +export const DEFAULT_ORG_ID = '0' +export const DEFAULT_ORG_NAME = '__default' +export const DEFAULT_ORG = { + id: DEFAULT_ORG_ID, + name: DEFAULT_ORG_NAME, +} +export const NO_ORG = 'No Org' + +export const DUMMY_ORGS = [ + {id: DEFAULT_ORG_ID, name: DEFAULT_ORG_NAME}, + {name: NO_ORG}, + {id: '1', name: 'Red Team'}, + {id: '2', name: 'Blue Team'}, + {id: '3', name: 'Green Team'}, +] + +export const SUPERADMIN_OPTION_ITEMS = [ + {value: true, text: 'yes'}, + {value: false, text: 'no'}, +] + +export const ADD_TO_ORGANIZATION = 'Add to organization' diff --git a/ui/src/admin/containers/AdminChronografPage.js b/ui/src/admin/containers/AdminChronografPage.js new file mode 100644 index 0000000000..e9e2010820 --- /dev/null +++ b/ui/src/admin/containers/AdminChronografPage.js @@ -0,0 +1,133 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' + +import * as adminChronografActionCreators from 'src/admin/actions/chronograf' +import {publishAutoDismissingNotification} from 'shared/dispatchers' + +import PageHeader from 'src/admin/components/chronograf/PageHeader' +import AdminTabs from 'src/admin/components/chronograf/AdminTabs' +import FancyScrollbar from 'shared/components/FancyScrollbar' + +class AdminChronografPage extends Component { + // TODO: revisit this, possibly don't call setState if both are deep equal + componentWillReceiveProps(nextProps) { + const {currentOrganization} = nextProps + + const hasChangedCurrentOrganization = + currentOrganization.id !== this.props.currentOrganization.id + + if (hasChangedCurrentOrganization) { + this.loadUsers() + } + } + + componentDidMount() { + this.loadUsers() + } + + loadUsers = () => { + const {links, actions: {loadUsersAsync}} = this.props + + loadUsersAsync(links.users) + } + + // SINGLE USER ACTIONS + handleCreateUser = user => { + const {links, actions: {createUserAsync}} = this.props + + createUserAsync(links.users, user) + } + + handleUpdateUserRole = () => (user, currentRole, {name}) => { + const {actions: {updateUserAsync}} = this.props + + const updatedRole = {...currentRole, name} + const newRoles = user.roles.map( + r => (r.organization === currentRole.organization ? updatedRole : r) + ) + + updateUserAsync(user, {...user, roles: newRoles}) + } + handleUpdateUserSuperAdmin = () => (user, currentStatus, {value}) => { + const {actions: {updateUserAsync}} = this.props + + const updatedUser = {...user, superAdmin: value} + + updateUserAsync(user, updatedUser) + } + handleDeleteUser = () => user => { + const {actions: {deleteUserAsync}} = this.props + + deleteUserAsync(user) + } + + render() { + const {users, currentOrganization, meRole} = this.props + + return ( +
+ + + {users + ?
+
+
+ +
+
+
+ :
} + +
+ ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +AdminChronografPage.propTypes = { + links: shape({ + users: string.isRequired, + }), + users: arrayOf(shape), + currentOrganization: shape({ + id: string.isRequired, + name: string.isRequired, + }).isRequired, + meRole: string.isRequired, + actions: shape({ + loadUsersAsync: func.isRequired, + createUserAsync: func.isRequired, + updateUserAsync: func.isRequired, + deleteUserAsync: func.isRequired, + }), + notify: func.isRequired, +} + +const mapStateToProps = ({ + links, + adminChronograf: {users}, + auth: {me: {currentOrganization, role: meRole}}, +}) => ({ + links, + users, + currentOrganization, + meRole, +}) + +const mapDispatchToProps = dispatch => ({ + actions: bindActionCreators(adminChronografActionCreators, dispatch), + notify: bindActionCreators(publishAutoDismissingNotification, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(AdminChronografPage) diff --git a/ui/src/admin/containers/AdminPage.js b/ui/src/admin/containers/AdminInfluxDBPage.js similarity index 96% rename from ui/src/admin/containers/AdminPage.js rename to ui/src/admin/containers/AdminInfluxDBPage.js index 29d6d4c95b..22b9f6603c 100644 --- a/ui/src/admin/containers/AdminPage.js +++ b/ui/src/admin/containers/AdminInfluxDBPage.js @@ -22,7 +22,7 @@ import { updateUserPermissionsAsync, filterUsers as filterUsersAction, filterRoles as filterRolesAction, -} from 'src/admin/actions' +} from 'src/admin/actions/influxdb' import AdminTabs from 'src/admin/components/AdminTabs' import SourceIndicator from 'shared/components/SourceIndicator' @@ -40,7 +40,7 @@ const isValidRole = role => { return role.name.length >= minLen } -class AdminPage extends Component { +class AdminInfluxDBPage extends Component { constructor(props) { super(props) } @@ -151,7 +151,7 @@ class AdminPage extends Component {
-

Admin

+

InfluxDB Admin

@@ -198,7 +198,7 @@ class AdminPage extends Component { const {arrayOf, func, shape, string} = PropTypes -AdminPage.propTypes = { +AdminInfluxDBPage.propTypes = { source: shape({ id: string.isRequired, links: shape({ @@ -231,7 +231,7 @@ AdminPage.propTypes = { notify: func, } -const mapStateToProps = ({admin: {users, roles, permissions}}) => ({ +const mapStateToProps = ({adminInfluxDB: {users, roles, permissions}}) => ({ users, roles, permissions, @@ -267,4 +267,4 @@ const mapDispatchToProps = dispatch => ({ notify: bindActionCreators(publishAutoDismissingNotification, dispatch), }) -export default connect(mapStateToProps, mapDispatchToProps)(AdminPage) +export default connect(mapStateToProps, mapDispatchToProps)(AdminInfluxDBPage) diff --git a/ui/src/admin/containers/DatabaseManagerPage.js b/ui/src/admin/containers/DatabaseManagerPage.js index 71d7f36584..d5fc3d746f 100644 --- a/ui/src/admin/containers/DatabaseManagerPage.js +++ b/ui/src/admin/containers/DatabaseManagerPage.js @@ -5,7 +5,7 @@ import _ from 'lodash' import DatabaseManager from 'src/admin/components/DatabaseManager' -import * as adminActionCreators from 'src/admin/actions' +import * as adminActionCreators from 'src/admin/actions/influxdb' import {publishAutoDismissingNotification} from 'shared/dispatchers' class DatabaseManagerPage extends Component { @@ -155,7 +155,7 @@ DatabaseManagerPage.propTypes = { notify: func, } -const mapStateToProps = ({admin: {databases, retentionPolicies}}) => ({ +const mapStateToProps = ({adminInfluxDB: {databases, retentionPolicies}}) => ({ databases, retentionPolicies, }) diff --git a/ui/src/admin/containers/OrganizationsPage.js b/ui/src/admin/containers/OrganizationsPage.js new file mode 100644 index 0000000000..9ba53944aa --- /dev/null +++ b/ui/src/admin/containers/OrganizationsPage.js @@ -0,0 +1,78 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' + +import * as adminChronografActionCreators from 'src/admin/actions/chronograf' +import {publishAutoDismissingNotification} from 'shared/dispatchers' + +import OrganizationsTable from 'src/admin/components/chronograf/OrganizationsTable' + +class OrganizationsPage extends Component { + componentDidMount() { + const {links, actions: {loadOrganizationsAsync}} = this.props + + loadOrganizationsAsync(links.organizations) + } + + handleCreateOrganization = organizationName => { + const {links, actions: {createOrganizationAsync}} = this.props + createOrganizationAsync(links.organizations, {name: organizationName}) + } + + handleRenameOrganization = (organization, name) => { + const {actions: {updateOrganizationAsync}} = this.props + updateOrganizationAsync(organization, {...organization, name}) + } + + handleDeleteOrganization = organization => { + const {actions: {deleteOrganizationAsync}} = this.props + deleteOrganizationAsync(organization) + } + + render() { + const {organizations} = this.props + + return ( + + ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +OrganizationsPage.propTypes = { + links: shape({ + organizations: string.isRequired, + }), + organizations: arrayOf( + shape({ + id: string, // when optimistically created, it will not have an id + name: string.isRequired, + link: string, + }) + ), + actions: shape({ + loadOrganizationsAsync: func.isRequired, + createOrganizationAsync: func.isRequired, + updateOrganizationAsync: func.isRequired, + deleteOrganizationAsync: func.isRequired, + }), + notify: func.isRequired, +} + +const mapStateToProps = ({links, adminChronograf: {organizations}}) => ({ + links, + organizations, +}) + +const mapDispatchToProps = dispatch => ({ + actions: bindActionCreators(adminChronografActionCreators, dispatch), + notify: bindActionCreators(publishAutoDismissingNotification, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(OrganizationsPage) diff --git a/ui/src/admin/containers/QueriesPage.js b/ui/src/admin/containers/QueriesPage.js index 53bf93b2d9..5cfc753407 100644 --- a/ui/src/admin/containers/QueriesPage.js +++ b/ui/src/admin/containers/QueriesPage.js @@ -15,7 +15,7 @@ import { loadQueries as loadQueriesAction, setQueryToKill as setQueryToKillAction, killQueryAsync, -} from 'src/admin/actions' +} from 'src/admin/actions/influxdb' import {publishAutoDismissingNotification} from 'shared/dispatchers' @@ -100,7 +100,7 @@ QueriesPage.propTypes = { notify: func, } -const mapStateToProps = ({admin: {queries, queryIDToKill}}) => ({ +const mapStateToProps = ({adminInfluxDB: {queries, queryIDToKill}}) => ({ queries, queryIDToKill, }) diff --git a/ui/src/admin/index.js b/ui/src/admin/index.js index ef6e719b35..5663e7aeb3 100644 --- a/ui/src/admin/index.js +++ b/ui/src/admin/index.js @@ -1,2 +1,5 @@ -import AdminPage from './containers/AdminPage' -export {AdminPage} +import AdminInfluxDBPage from './containers/AdminInfluxDBPage' +import AdminChronografPage from './containers/AdminChronografPage' +import OrganizationsPage from './containers/OrganizationsPage' + +export {AdminChronografPage, AdminInfluxDBPage, OrganizationsPage} diff --git a/ui/src/admin/reducers/chronograf.js b/ui/src/admin/reducers/chronograf.js new file mode 100644 index 0000000000..cd48710e26 --- /dev/null +++ b/ui/src/admin/reducers/chronograf.js @@ -0,0 +1,92 @@ +import {isSameUser} from 'shared/reducers/helpers/auth' + +const initialState = { + users: [], + organizations: [], +} + +const adminChronograf = (state = initialState, action) => { + switch (action.type) { + case 'CHRONOGRAF_LOAD_USERS': { + return {...state, ...action.payload} + } + + case 'CHRONOGRAF_LOAD_ORGANIZATIONS': { + return {...state, ...action.payload} + } + + case 'CHRONOGRAF_ADD_USER': { + const {user} = action.payload + return {...state, users: [user, ...state.users]} + } + + case 'CHRONOGRAF_UPDATE_USER': { + const {user, updatedUser} = action.payload + return { + ...state, + users: state.users.map( + u => (u.links.self === user.links.self ? {...updatedUser} : u) + ), + } + } + case 'CHRONOGRAF_SYNC_USER': { + const {staleUser, syncedUser} = action.payload + return { + ...state, + users: state.users.map( + // stale user does not have links, so uniqueness is on name, provider, & scheme + u => (isSameUser(u, staleUser) ? {...syncedUser} : u) + ), + } + } + + case 'CHRONOGRAF_REMOVE_USER': { + const {user} = action.payload + return { + ...state, + // stale user does not have links, so uniqueness is on name, provider, & scheme + users: state.users.filter(u => !isSameUser(u, user)), + } + } + + case 'CHRONOGRAF_ADD_ORGANIZATION': { + const {organization} = action.payload + return {...state, organizations: [organization, ...state.organizations]} + } + + case 'CHRONOGRAF_RENAME_ORGANIZATION': { + const {organization, newName} = action.payload + return { + ...state, + organizations: state.organizations.map( + o => + o.links.self === organization.links.self ? {...o, name: newName} : o + ), + } + } + + case 'CHRONOGRAF_SYNC_ORGANIZATION': { + const {staleOrganization, syncedOrganization} = action.payload + return { + ...state, + organizations: state.organizations.map( + o => (o.name === staleOrganization.name ? {...syncedOrganization} : o) + ), + } + } + + case 'CHRONOGRAF_REMOVE_ORGANIZATION': { + const {organization} = action.payload + return { + ...state, + organizations: state.organizations.filter( + o => o.name !== organization.name + ), + } + } + } + + return state +} + +export default adminChronograf diff --git a/ui/src/admin/reducers/index.js b/ui/src/admin/reducers/index.js new file mode 100644 index 0000000000..7356e32f48 --- /dev/null +++ b/ui/src/admin/reducers/index.js @@ -0,0 +1,4 @@ +import adminChronograf from './chronograf' +import adminInfluxDB from './influxdb' + +export default {adminChronograf, adminInfluxDB} diff --git a/ui/src/admin/reducers/admin.js b/ui/src/admin/reducers/influxdb.js similarity index 85% rename from ui/src/admin/reducers/admin.js rename to ui/src/admin/reducers/influxdb.js index 7450a860fb..23b86492bd 100644 --- a/ui/src/admin/reducers/admin.js +++ b/ui/src/admin/reducers/influxdb.js @@ -16,25 +16,25 @@ const initialState = { databases: [], } -export default function admin(state = initialState, action) { +const adminInfluxDB = (state = initialState, action) => { switch (action.type) { - case 'LOAD_USERS': { + case 'INFLUXDB_LOAD_USERS': { return {...state, ...action.payload} } - case 'LOAD_ROLES': { + case 'INFLUXDB_LOAD_ROLES': { return {...state, ...action.payload} } - case 'LOAD_PERMISSIONS': { + case 'INFLUXDB_LOAD_PERMISSIONS': { return {...state, ...action.payload} } - case 'LOAD_DATABASES': { + case 'INFLUXDB_LOAD_DATABASES': { return {...state, ...action.payload} } - case 'ADD_USER': { + case 'INFLUXDB_ADD_USER': { const newUser = {...NEW_DEFAULT_USER, isEditing: true} return { ...state, @@ -42,7 +42,7 @@ export default function admin(state = initialState, action) { } } - case 'ADD_ROLE': { + case 'INFLUXDB_ADD_ROLE': { const newRole = {...NEW_DEFAULT_ROLE, isEditing: true} return { ...state, @@ -50,7 +50,7 @@ export default function admin(state = initialState, action) { } } - case 'ADD_DATABASE': { + case 'INFLUXDB_ADD_DATABASE': { const newDatabase = { ...NEW_DEFAULT_DATABASE, links: {self: `temp-ID${uuid.v4()}`}, @@ -63,7 +63,7 @@ export default function admin(state = initialState, action) { } } - case 'ADD_RETENTION_POLICY': { + case 'INFLUXDB_ADD_RETENTION_POLICY': { const {database} = action.payload const databases = state.databases.map( db => @@ -81,7 +81,7 @@ export default function admin(state = initialState, action) { return {...state, databases} } - case 'SYNC_USER': { + case 'INFLUXDB_SYNC_USER': { const {staleUser, syncedUser} = action.payload const newState = { users: state.users.map( @@ -91,7 +91,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'SYNC_ROLE': { + case 'INFLUXDB_SYNC_ROLE': { const {staleRole, syncedRole} = action.payload const newState = { roles: state.roles.map( @@ -101,7 +101,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'SYNC_DATABASE': { + case 'INFLUXDB_SYNC_DATABASE': { const {stale, synced} = action.payload const newState = { databases: state.databases.map( @@ -112,7 +112,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'SYNC_RETENTION_POLICY': { + case 'INFLUXDB_SYNC_RETENTION_POLICY': { const {database, stale, synced} = action.payload const newState = { databases: state.databases.map( @@ -132,7 +132,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'EDIT_USER': { + case 'INFLUXDB_EDIT_USER': { const {user, updates} = action.payload const newState = { users: state.users.map( @@ -142,7 +142,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'EDIT_ROLE': { + case 'INFLUXDB_EDIT_ROLE': { const {role, updates} = action.payload const newState = { roles: state.roles.map( @@ -152,7 +152,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'EDIT_DATABASE': { + case 'INFLUXDB_EDIT_DATABASE': { const {database, updates} = action.payload const newState = { databases: state.databases.map( @@ -164,7 +164,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'EDIT_RETENTION_POLICY': { + case 'INFLUXDB_EDIT_RETENTION_POLICY': { const {database, retentionPolicy, updates} = action.payload const newState = { @@ -187,7 +187,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'DELETE_USER': { + case 'INFLUXDB_DELETE_USER': { const {user} = action.payload const newState = { users: state.users.filter(u => u.links.self !== user.links.self), @@ -196,7 +196,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'DELETE_ROLE': { + case 'INFLUXDB_DELETE_ROLE': { const {role} = action.payload const newState = { roles: state.roles.filter(r => r.links.self !== role.links.self), @@ -205,7 +205,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'REMOVE_DATABASE': { + case 'INFLUXDB_REMOVE_DATABASE': { const {database} = action.payload const newState = { databases: state.databases.filter( @@ -216,7 +216,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'REMOVE_RETENTION_POLICY': { + case 'INFLUXDB_REMOVE_RETENTION_POLICY': { const {database, retentionPolicy} = action.payload const newState = { databases: state.databases.map( @@ -235,7 +235,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'ADD_DATABASE_DELETE_CODE': { + case 'INFLUXDB_ADD_DATABASE_DELETE_CODE': { const {database} = action.payload const newState = { databases: state.databases.map( @@ -247,7 +247,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'REMOVE_DATABASE_DELETE_CODE': { + case 'INFLUXDB_REMOVE_DATABASE_DELETE_CODE': { const {database} = action.payload delete database.deleteCode @@ -260,11 +260,11 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'LOAD_QUERIES': { + case 'INFLUXDB_LOAD_QUERIES': { return {...state, ...action.payload} } - case 'FILTER_USERS': { + case 'INFLUXDB_FILTER_USERS': { const {text} = action.payload const newState = { users: state.users.map(u => { @@ -275,7 +275,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'FILTER_ROLES': { + case 'INFLUXDB_FILTER_ROLES': { const {text} = action.payload const newState = { roles: state.roles.map(r => { @@ -286,7 +286,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'KILL_QUERY': { + case 'INFLUXDB_KILL_QUERY': { const {queryID} = action.payload const nextState = { queries: reject(state.queries, q => +q.id === +queryID), @@ -295,10 +295,12 @@ export default function admin(state = initialState, action) { return {...state, ...nextState} } - case 'SET_QUERY_TO_KILL': { + case 'INFLUXDB_SET_QUERY_TO_KILL': { return {...state, ...action.payload} } } return state } + +export default adminInfluxDB diff --git a/ui/src/auth/Authorized.js b/ui/src/auth/Authorized.js index e8eefc1bd9..ed66d783b4 100644 --- a/ui/src/auth/Authorized.js +++ b/ui/src/auth/Authorized.js @@ -1,7 +1,7 @@ import React, {PropTypes} from 'react' import {connect} from 'react-redux' -import _ from 'lodash' +export const MEMBER_ROLE = 'member' export const VIEWER_ROLE = 'viewer' export const EDITOR_ROLE = 'editor' export const ADMIN_ROLE = 'admin' @@ -26,21 +26,21 @@ export const isUserAuthorized = (meRole, requiredRole) => { return meRole === ADMIN_ROLE || meRole === SUPERADMIN_ROLE case SUPERADMIN_ROLE: return meRole === SUPERADMIN_ROLE + // 'member' is the default role and has no authorization for anything currently + case MEMBER_ROLE: default: return false } } -export const getMeRole = me => { - return _.get(_.first(_.get(me, 'roles', [])), 'name', 'none') // TODO: TBD if 'none' should be returned if none -} - const Authorized = ({ children, - me, + meRole, isUsingAuth, requiredRole, - replaceWith, + replaceWithIfNotAuthorized, + replaceWithIfNotUsingAuth, + replaceWithIfAuthorized, propsOverride, }) => { // if me response has not been received yet, render nothing @@ -51,38 +51,38 @@ const Authorized = ({ // React.isValidElement guards against multiple children wrapped by Authorized const firstChild = React.isValidElement(children) ? children : children[0] - const meRole = getMeRole(me) + if (!isUsingAuth) { + return replaceWithIfNotUsingAuth || firstChild + } - if (!isUsingAuth || isUserAuthorized(meRole, requiredRole)) { - return firstChild + if (isUserAuthorized(meRole, requiredRole)) { + return replaceWithIfAuthorized || firstChild } if (propsOverride) { return React.cloneElement(firstChild, {...propsOverride}) } - return replaceWith || null + return replaceWithIfNotAuthorized || null } -const {arrayOf, bool, node, shape, string} = PropTypes +const {bool, node, shape, string} = PropTypes Authorized.propTypes = { isUsingAuth: bool, - replaceWith: node, + replaceWithIfNotUsingAuth: node, + replaceWithIfAuthorized: node, + replaceWithIfNotAuthorized: node, children: node.isRequired, me: shape({ - roles: arrayOf( - shape({ - name: string.isRequired, - }) - ), + role: string.isRequired, }), requiredRole: string.isRequired, propsOverride: shape(), } -const mapStateToProps = ({auth: {me, isUsingAuth}}) => ({ - me, +const mapStateToProps = ({auth: {me: {role}, isUsingAuth}}) => ({ + meRole: role, isUsingAuth, }) diff --git a/ui/src/auth/Purgatory.js b/ui/src/auth/Purgatory.js new file mode 100644 index 0000000000..14774a9feb --- /dev/null +++ b/ui/src/auth/Purgatory.js @@ -0,0 +1,70 @@ +import React, {PropTypes} from 'react' +import {connect} from 'react-redux' + +import {MEMBER_ROLE} from 'src/auth/Authorized' + +const memberCopy = ( +

This role does not grant you sufficient permissions to view Chronograf.

+) +const viewerCopy = ( +

+ This organization does not have any configured sources
and your role + does not have permission to configure a source. +

+) + +const Purgatory = ({name, provider, scheme, currentOrganization, role}) => +
+
+
+
+
+

+ Logged in to {currentOrganization.name} as a{' '} + {role}. +

+ {role === MEMBER_ROLE ? memberCopy : viewerCopy} +

Contact your Administrator for assistance.

+
+
+            
+              username: {name}
+              
+ provider: {provider} +
+ scheme: {scheme} +
+
+
+
+

+ Made by InfluxData +

+
+
+
+ +const {shape, string} = PropTypes + +Purgatory.propTypes = { + name: string.isRequired, + provider: string.isRequired, + scheme: string.isRequired, + currentOrganization: shape({ + id: string.isRequired, + name: string.isRequired, + }).isRequired, + role: string.isRequired, +} + +const mapStateToProps = ({ + auth: {me: {name, provider, scheme, currentOrganization, role}}, +}) => ({ + name, + provider, + scheme, + currentOrganization, + role, +}) + +export default connect(mapStateToProps)(Purgatory) diff --git a/ui/src/auth/index.js b/ui/src/auth/index.js index 0b9eb546a7..9239661bea 100644 --- a/ui/src/auth/index.js +++ b/ui/src/auth/index.js @@ -1,7 +1,15 @@ import Login from './Login' +import Purgatory from './Purgatory' + import { UserIsAuthenticated, Authenticated, UserIsNotAuthenticated, } from './Authenticated' -export {Login, UserIsAuthenticated, Authenticated, UserIsNotAuthenticated} +export { + Login, + Purgatory, + UserIsAuthenticated, + Authenticated, + UserIsNotAuthenticated, +} diff --git a/ui/src/dashboards/components/DashboardHeader.js b/ui/src/dashboards/components/DashboardHeader.js index ce47d1504a..bf101c0373 100644 --- a/ui/src/dashboards/components/DashboardHeader.js +++ b/ui/src/dashboards/components/DashboardHeader.js @@ -50,7 +50,7 @@ const DashboardHeader = ({ {dashboard ? {activeDashboard} diff --git a/ui/src/dashboards/components/DashboardsTable.js b/ui/src/dashboards/components/DashboardsTable.js index 9f688ad47d..887c56d80b 100644 --- a/ui/src/dashboards/components/DashboardsTable.js +++ b/ui/src/dashboards/components/DashboardsTable.js @@ -38,7 +38,10 @@ const DashboardsTable = ({ ) : None} - }> + } + > { @@ -125,6 +142,7 @@ const Root = React.createClass({ + - + + diff --git a/ui/src/shared/actions/auth.js b/ui/src/shared/actions/auth.js index dc0334f4f9..43ab62ecc0 100644 --- a/ui/src/shared/actions/auth.js +++ b/ui/src/shared/actions/auth.js @@ -1,3 +1,8 @@ +import {updateMe as updateMeAJAX} from 'shared/apis/auth' + +import {publishAutoDismissingNotification} from 'shared/dispatchers' +import {errorThrown} from 'shared/actions/errors' + export const authExpired = auth => ({ type: 'AUTH_EXPIRED', payload: { @@ -20,16 +25,56 @@ export const meRequested = () => ({ type: 'ME_REQUESTED', }) -export const meReceived = me => ({ - type: 'ME_RECEIVED', +export const meReceivedNotUsingAuth = me => ({ + type: 'ME_RECEIVED__NON_AUTH', payload: { me, }, }) +export const meReceivedUsingAuth = me => ({ + type: 'ME_RECEIVED__AUTH', + payload: { + me, + }, +}) + +export const meChangeOrganizationRequested = () => ({ + type: 'ME_CHANGE_ORGANIZATION_REQUESTED', +}) + +export const meChangeOrganizationCompleted = () => ({ + type: 'ME_CHANGE_ORGANIZATION_COMPLETED', +}) + +export const meChangeOrganizationFailed = () => ({ + type: 'ME_CHANGE_ORGANIZATION_FAILED', +}) + export const logoutLinkReceived = logoutLink => ({ type: 'LOGOUT_LINK_RECEIVED', payload: { logoutLink, }, }) + +export const meChangeOrganizationAsync = ( + url, + organization +) => async dispatch => { + dispatch(meChangeOrganizationRequested()) + try { + const {data} = await updateMeAJAX(url, organization) + dispatch( + publishAutoDismissingNotification( + 'success', + `Now signed into ${data.currentOrganization.name}` + ) + ) + dispatch(meChangeOrganizationCompleted()) + dispatch(meReceivedUsingAuth(data)) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(meChangeOrganizationFailed()) + } +} diff --git a/ui/src/shared/apis/auth.js b/ui/src/shared/apis/auth.js new file mode 100644 index 0000000000..581dfadd8e --- /dev/null +++ b/ui/src/shared/apis/auth.js @@ -0,0 +1,14 @@ +import AJAX from 'src/utils/ajax' + +export const updateMe = async (url, updatedMe) => { + try { + return await AJAX({ + method: 'PUT', + url, + data: updatedMe, + }) + } catch (error) { + console.error(error) + throw error + } +} diff --git a/ui/src/shared/components/ConfirmButtons.js b/ui/src/shared/components/ConfirmButtons.js index 0272bc6614..6a7f59cef4 100644 --- a/ui/src/shared/components/ConfirmButtons.js +++ b/ui/src/shared/components/ConfirmButtons.js @@ -1,6 +1,8 @@ import React, {PropTypes, Component} from 'react' import classnames from 'classnames' +import OnClickOutside from 'shared/components/OnClickOutside' + class ConfirmButtons extends Component { constructor(props) { super(props) @@ -14,31 +16,54 @@ class ConfirmButtons extends Component { this.props.onCancel(item) } - render() { - const {item, buttonSize, isDisabled} = this.props + handleClickOutside = () => { + this.props.onClickOutside(this.props.item) + } - return ( -
- - -
- ) + render() { + const {item, buttonSize, isDisabled, confirmLeft} = this.props + + return confirmLeft + ?
+ + +
+ :
+ + +
} } @@ -50,9 +75,12 @@ ConfirmButtons.propTypes = { onCancel: func.isRequired, buttonSize: string, isDisabled: bool, + onClickOutside: func, + confirmLeft: bool, } ConfirmButtons.defaultProps = { buttonSize: 'btn-sm', + onClickOutside: () => {}, } -export default ConfirmButtons +export default OnClickOutside(ConfirmButtons) diff --git a/ui/src/shared/components/DeleteConfirmButtons.js b/ui/src/shared/components/DeleteConfirmButtons.js index 3f503727d2..4a7aec5836 100644 --- a/ui/src/shared/components/DeleteConfirmButtons.js +++ b/ui/src/shared/components/DeleteConfirmButtons.js @@ -4,14 +4,14 @@ import classnames from 'classnames' import OnClickOutside from 'shared/components/OnClickOutside' import ConfirmButtons from 'shared/components/ConfirmButtons' -const DeleteButton = ({onClickDelete, buttonSize}) => +const DeleteButton = ({onClickDelete, buttonSize, text}) => class DeleteConfirmButtons extends Component { @@ -37,7 +37,7 @@ class DeleteConfirmButtons extends Component { } render() { - const {onDelete, item, buttonSize} = this.props + const {onDelete, item, buttonSize, text} = this.props const {isConfirming} = this.state return isConfirming @@ -48,6 +48,7 @@ class DeleteConfirmButtons extends Component { buttonSize={buttonSize} /> : @@ -57,11 +58,17 @@ class DeleteConfirmButtons extends Component { const {func, oneOfType, shape, string} = PropTypes DeleteButton.propTypes = { + text: string.isRequired, onClickDelete: func.isRequired, buttonSize: string, } +DeleteButton.defaultProps = { + text: 'Delete', +} + DeleteConfirmButtons.propTypes = { + text: string, item: oneOfType([(string, shape())]), onDelete: func.isRequired, buttonSize: string, diff --git a/ui/src/shared/components/Dropdown.js b/ui/src/shared/components/Dropdown.js index 33d93aafb7..19e6cd887c 100644 --- a/ui/src/shared/components/Dropdown.js +++ b/ui/src/shared/components/Dropdown.js @@ -22,6 +22,7 @@ class Dropdown extends Component { buttonColor: 'btn-default', menuWidth: '100%', useAutoComplete: false, + disabled: false, } handleClickOutside = () => { @@ -29,6 +30,11 @@ class Dropdown extends Component { } handleClick = e => { + const {disabled} = this.props + + if (disabled) { + return + } this.toggleMenu(e) if (this.props.onClick) { this.props.onClick(e) @@ -208,10 +214,12 @@ class Dropdown extends Component { buttonColor, toggleStyle, useAutoComplete, + disabled, } = this.props const {isOpen, searchTerm, filteredItems} = this.state const menuItems = useAutoComplete ? filteredItems : items + const disabledClass = disabled ? 'disabled' : null return (
{useAutoComplete && isOpen ?
:
{iconName @@ -297,6 +305,7 @@ Dropdown.propTypes = { menuClass: string, useAutoComplete: bool, toggleStyle: shape(), + disabled: bool, } export default OnClickOutside(Dropdown) diff --git a/ui/src/shared/components/RoleIndicator.js b/ui/src/shared/components/RoleIndicator.js index 2cd5b96a09..e955ebb9fe 100644 --- a/ui/src/shared/components/RoleIndicator.js +++ b/ui/src/shared/components/RoleIndicator.js @@ -4,7 +4,7 @@ import {connect} from 'react-redux' import ReactTooltip from 'react-tooltip' -import {getMeRole} from 'src/auth/Authorized' +import {getMeRole} from 'shared/reducers/helpers/auth' const RoleIndicator = ({me, isUsingAuth}) => { if (!isUsingAuth) { @@ -39,6 +39,10 @@ const {arrayOf, bool, shape, string} = PropTypes RoleIndicator.propTypes = { isUsingAuth: bool.isRequired, me: shape({ + currentOrganization: shape({ + name: string.isRequired, + id: string.isRequired, + }), roles: arrayOf( shape({ name: string.isRequired, diff --git a/ui/src/shared/components/SlideToggle.js b/ui/src/shared/components/SlideToggle.js new file mode 100644 index 0000000000..f917ec7f88 --- /dev/null +++ b/ui/src/shared/components/SlideToggle.js @@ -0,0 +1,47 @@ +import React, {Component, PropTypes} from 'react' + +class SlideToggle extends Component { + constructor(props) { + super(props) + + this.state = { + active: this.props.active, + } + } + + handleClick = () => { + const {onToggle} = this.props + + this.setState({active: !this.state.active}, () => { + onToggle(this.state.active) + }) + } + + render() { + const {size} = this.props + const {active} = this.state + + const classNames = active + ? `slide-toggle slide-toggle__${size} active` + : `slide-toggle slide-toggle__${size}` + + return ( +
+
+
+ ) + } +} + +const {bool, func, string} = PropTypes + +SlideToggle.defaultProps = { + size: 'sm', +} +SlideToggle.propTypes = { + active: bool, + size: string, + onToggle: func.isRequired, +} + +export default SlideToggle diff --git a/ui/src/shared/reducers/auth.js b/ui/src/shared/reducers/auth.js index 09f406da61..cea6c1c340 100644 --- a/ui/src/shared/reducers/auth.js +++ b/ui/src/shared/reducers/auth.js @@ -6,6 +6,10 @@ const getInitialState = () => ({ logoutLink: null, }) +import {getMeRole} from 'shared/reducers/helpers/auth' + +import {DEFAULT_ORG_NAME} from 'src/admin/constants/dummyUsers' + export const initialState = getInitialState() const authReducer = (state = initialState, action) => { @@ -24,13 +28,30 @@ const authReducer = (state = initialState, action) => { case 'ME_REQUESTED': { return {...state, isMeLoading: true} } - case 'ME_RECEIVED': { + case 'ME_RECEIVED__NON_AUTH': { const {me} = action.payload - return {...state, me, isMeLoading: false} + return { + ...state, + me: {...me}, + isMeLoading: false, + } + } + case 'ME_RECEIVED__AUTH': { + const {me, me: {currentOrganization}} = action.payload + return { + ...state, + me: { + ...me, + role: getMeRole(me), + currentOrganization: currentOrganization || DEFAULT_ORG_NAME, // TODO: make sure currentOrganization is received as non-superadmin + }, + isMeLoading: false, + } } case 'LOGOUT_LINK_RECEIVED': { const {logoutLink} = action.payload - return {...state, logoutLink, isUsingAuth: !!logoutLink} + const isUsingAuth = !!logoutLink + return {...state, logoutLink, isUsingAuth} } } diff --git a/ui/src/shared/reducers/helpers/auth.js b/ui/src/shared/reducers/helpers/auth.js new file mode 100644 index 0000000000..e56ddf8308 --- /dev/null +++ b/ui/src/shared/reducers/helpers/auth.js @@ -0,0 +1,20 @@ +import _ from 'lodash' + +import {SUPERADMIN_ROLE, MEMBER_ROLE} from 'src/auth/Authorized' + +export const getMeRole = me => { + const currentRoleOrg = me.roles.find( + role => me.currentOrganization.id === role.organization + ) + const currentRole = _.get(currentRoleOrg, 'name', MEMBER_ROLE) + + return me.superAdmin ? SUPERADMIN_ROLE : currentRole +} + +export const isSameUser = (userA, userB) => { + return ( + userA.name === userB.name && + userA.provider === userB.provider && + userA.scheme === userB.scheme + ) +} diff --git a/ui/src/side_nav/components/UserNavBlock.js b/ui/src/side_nav/components/UserNavBlock.js new file mode 100644 index 0000000000..15c2472e41 --- /dev/null +++ b/ui/src/side_nav/components/UserNavBlock.js @@ -0,0 +1,126 @@ +import React, {PropTypes, Component} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' + +import classnames from 'classnames' + +import {meChangeOrganizationAsync} from 'shared/actions/auth' + +class UserNavBlock extends Component { + handleChangeCurrentOrganization = organizationID => () => { + const {links, meChangeOrganization} = this.props + meChangeOrganization(links.me, {organization: organizationID}) + } + + render() { + const { + logoutLink, + links: {external: {custom: customLinks}}, + me, + me: {currentOrganization, organizations, roles}, + } = this.props + + // TODO: find a better way to glean this information. + // Need this method for when a user is a superadmin, + // which doesn't reflect their role in the current org + const currentRole = roles.find(cr => { + return cr.organization === currentOrganization.id + }).name + + return ( +
+
+
+
+
+
+ {currentOrganization.name} ({currentRole}) +
+
+ {me.name} +
+ + Logout + +
Switch Organizations
+ {roles.map((r, i) => { + const isLinkCurrentOrg = currentOrganization.id === r.organization + return ( + + {organizations.find(o => o.id === r.organization).name}{' '} + ({r.name}) + + ) + })} + {customLinks + ?
Custom Links
+ : null} + {customLinks + ? customLinks.map((link, i) => + + {link.name} + + ) + : null} +
+
+
+ ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +UserNavBlock.propTypes = { + links: shape({ + me: string, + external: shape({ + custom: arrayOf( + shape({ + name: string.isRequired, + url: string.isRequired, + }) + ), + }), + }), + logoutLink: string.isRequired, + me: shape({ + currentOrganization: shape({ + id: string.isRequired, + name: string.isRequired, + }), + name: string, + organizations: arrayOf( + shape({ + id: string.isRequired, + name: string.isRequired, + }) + ), + roles: arrayOf( + shape({ + id: string, + name: string, + }) + ), + role: string, + }).isRequired, + meChangeOrganization: func.isRequired, +} + +const mapDispatchToProps = dispatch => ({ + meChangeOrganization: bindActionCreators(meChangeOrganizationAsync, dispatch), +}) + +export default connect(null, mapDispatchToProps)(UserNavBlock) diff --git a/ui/src/side_nav/containers/SideNav.js b/ui/src/side_nav/containers/SideNav.js index 141b7707ce..6a5bfa083f 100644 --- a/ui/src/side_nav/containers/SideNav.js +++ b/ui/src/side_nav/containers/SideNav.js @@ -4,6 +4,7 @@ import {connect} from 'react-redux' import Authorized, {ADMIN_ROLE} from 'src/auth/Authorized' +import UserNavBlock from 'src/side_nav/components/UserNavBlock' import { NavBar, NavBlock, @@ -26,37 +27,36 @@ const SideNav = React.createClass({ isHidden: bool.isRequired, isUsingAuth: bool, logoutLink: string, - customLinks: arrayOf( - shape({ - name: string.isRequired, - url: string.isRequired, - }) - ), - }, - - renderUserMenuBlockWithCustomLinks(customLinks, logoutLink) { - return [ - , - ...customLinks - .sort((a, b) => a.name.toLowerCase() > b.name.toLowerCase()) - .map(({name, url}, i) => - - {name} - + links: shape({ + me: string, + external: shape({ + custom: arrayOf( + shape({ + name: string.isRequired, + url: string.isRequired, + }) ), - - Logout - , - ] + }), + }), + me: shape({ + currentOrganization: shape({ + id: string.isRequired, + name: string.isRequired, + }), + name: string, + organizations: arrayOf( + shape({ + id: string.isRequired, + name: string.isRequired, + }) + ), + roles: arrayOf( + shape({ + id: string, + name: string, + }) + ), + }).isRequired, }, render() { @@ -66,7 +66,8 @@ const SideNav = React.createClass({ isHidden, isUsingAuth, logoutLink, - customLinks, + links, + me, } = this.props const sourcePrefix = `/sources/${sourceID}` @@ -123,13 +124,37 @@ const SideNav = React.createClass({ Create - + + + + + } + > - + + + Chronograf + + + InfluxDB + {isUsingAuth - ? - {customLinks - ? this.renderUserMenuBlockWithCustomLinks( - customLinks, - logoutLink - ) - : } - + ? : null} }, }) - const mapStateToProps = ({ - auth: {isUsingAuth, logoutLink}, + auth: {isUsingAuth, logoutLink, me}, app: {ephemeral: {inPresentationMode}}, - links: {external: {custom: customLinks}}, + links, }) => ({ isHidden: inPresentationMode, isUsingAuth, logoutLink, - customLinks, + links, + me, }) export default connect(mapStateToProps)(withRouter(SideNav)) diff --git a/ui/src/sources/components/InfluxTable.js b/ui/src/sources/components/InfluxTable.js index 91898debd2..7a0a1acdd7 100644 --- a/ui/src/sources/components/InfluxTable.js +++ b/ui/src/sources/components/InfluxTable.js @@ -144,7 +144,7 @@ const InfluxTable = ({
{s.name} diff --git a/ui/src/store/configureStore.js b/ui/src/store/configureStore.js index 3fad50db3f..9f7a3e6950 100644 --- a/ui/src/store/configureStore.js +++ b/ui/src/store/configureStore.js @@ -9,7 +9,7 @@ import {queryStringConfig} from 'shared/middleware/queryStringConfig' import statusReducers from 'src/status/reducers' import sharedReducers from 'shared/reducers' import dataExplorerReducers from 'src/data_explorer/reducers' -import adminReducer from 'src/admin/reducers/admin' +import adminReducers from 'src/admin/reducers' import kapacitorReducers from 'src/kapacitor/reducers' import dashboardUI from 'src/dashboards/reducers/ui' import dashTimeV1 from 'src/dashboards/reducers/dashTimeV1' @@ -20,7 +20,7 @@ const rootReducer = combineReducers({ ...sharedReducers, ...dataExplorerReducers, ...kapacitorReducers, - admin: adminReducer, + ...adminReducers, dashboardUI, dashTimeV1, routing: routerReducer, diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss index 79fbcc3e9d..90a73201ee 100644 --- a/ui/src/style/chronograf.scss +++ b/ui/src/style/chronograf.scss @@ -41,6 +41,7 @@ @import 'components/input-tag-list'; @import 'components/newsfeed'; @import 'components/opt-in'; +@import 'components/organizations-table'; @import 'components/page-header-dropdown'; @import 'components/page-header-editable'; @import 'components/page-spinner'; @@ -49,6 +50,7 @@ @import 'components/redacted-input'; @import 'components/resizer'; @import 'components/search-widget'; +@import 'components/slide-toggle'; @import 'components/info-indicators'; @import 'components/source-selector'; @import 'components/tables'; @@ -60,6 +62,7 @@ @import 'pages/kapacitor'; @import 'pages/dashboards'; @import 'pages/admin'; +@import 'pages/users'; // TODO @import 'unsorted'; diff --git a/ui/src/style/components/organizations-table.scss b/ui/src/style/components/organizations-table.scss new file mode 100644 index 0000000000..78942d9398 --- /dev/null +++ b/ui/src/style/components/organizations-table.scss @@ -0,0 +1,134 @@ +/* + Styles for the Manage Organizations Page + ------------------------------------------------------------------------------ + Is not actually a table +*/ + +.orgs-table--org { + width: 100%; + display: flex; + align-items: center; + margin-bottom: 8px; + position: relative; + + &:last-of-type { + margin-bottom: 0; + } +} +.orgs-table--id { + padding: 0 11px; + width: 60px; + height: 30px; + line-height: 30px; + font-size: 13px; + color: $g13-mist; + font-weight: 500; +} +.orgs-table--name, +.orgs-table--name-disabled, +input[type="text"].form-control.orgs-table--input { + flex: 1 0 0; + font-weight: 600; + font-size: 13px; + margin-right: 4px; +} +.orgs-table--name, +.orgs-table--name-disabled { + @include no-user-select(); + padding: 0 11px; + border-radius: 4px; + height: 30px; + line-height: 28px; + border-style: solid; + border-width: 2px; +} +.orgs-table--name { + border-color: $g2-kevlar; + background-color: $g2-kevlar; + color: $g13-mist; + position: relative; + transition: + color 0.4s ease, + background-color 0.4s ease, + border-color 0.4s ease; + + > span.icon { + position: absolute; + top: 50%; + right: 11px; + transform: translateY(-50%); + color: $g8-storm; + opacity: 0; + transition: opacity 0.25s ease; + } + + &:hover { + color: $g20-white; + background-color: $g5-pepper; + border-color: $g5-pepper; + cursor: text; + + > span.icon {opacity: 1;} + } +} +.orgs-table--name-disabled { + border-color: $g4-onyx; + background-color: $g4-onyx; + font-style: italic; + color: $g9-mountain; +} + +.orgs-table--default-role, +.orgs-table--default-role-disabled { + width: 130px; + height: 30px; + margin-right: 4px; +} +.orgs-table--default-role.editing { + width: 96px; +} +.orgs-table--default-role-disabled { + background-color: $g4-onyx; + font-style: italic; + color: $g9-mountain; + padding: 0 11px; + line-height: 30px; + font-size: 13px; + font-weight: 600; + @include no-user-select(); +} +.orgs-table--delete { + height: 30px; + width: 30px; +} + + +/* Table Headers */ +.orgs-table--org-labels { + display: flex; + align-items: center; + border-bottom: 2px solid $g5-pepper; + margin-bottom: 10px; + width: 100%; + @include no-user-select(); + + > .orgs-table--name, + > .orgs-table--name:hover { + transition: none; + background-color: transparent; + border-color: transparent; + } + + > .orgs-table--id, + > .orgs-table--name, + > .orgs-table--name:hover, + > .orgs-table--default-role { + color: $g15-platinum; + font-weight: 700; + } + > .orgs-table--default-role { + line-height: 30px; + font-size: 13px; + padding: 0 11px; + } +} diff --git a/ui/src/style/components/slide-toggle.scss b/ui/src/style/components/slide-toggle.scss new file mode 100644 index 0000000000..09f9b9c533 --- /dev/null +++ b/ui/src/style/components/slide-toggle.scss @@ -0,0 +1,63 @@ +/* + Slide Toggle Component + ------------------------------------------------------------------------------ +*/ +.slide-toggle { + background-color: $g1-raven; + position: relative; + padding: 0 4px; + display: inline-block; + transition: background-color 0.25s ease; + + &:hover { + cursor: pointer; + background-color: $g2-kevlar; + } +} +.slide-toggle--knob { + position: absolute; + top: 50%; + transition: + background-color 0.25s ease, + transform 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); + background-color: $g4-onyx; + transform: translate(0,-50%); + border-radius: 50%; + + .slide-toggle:hover & { + background-color: $g6-smoke; + } +} + +.slide-toggle.active .slide-toggle--knob { + background-color: $c-rainforest; + transform: translate(100%,-50%); +} +.slide-toggle.active:hover .slide-toggle--knob { + background-color: $c-honeydew; +} + +/* Size Modifiers */ + +.slide-toggle { + /* Extra Small */ + &.slide-toggle__xs { + .slide-toggle--knob { + width: 16px; + height: 16px; + } + height: 22px; + border-radius: 11px; + width: 40px; + } + /* Extra Small */ + &.slide-toggle__sm { + .slide-toggle--knob { + width: 22px; + height: 22px; + } + height: 30px; + border-radius: 15px; + width: 52px; + } +} diff --git a/ui/src/style/components/tables.scss b/ui/src/style/components/tables.scss index 9425cce4f8..8f6e91161c 100644 --- a/ui/src/style/components/tables.scss +++ b/ui/src/style/components/tables.scss @@ -97,10 +97,14 @@ table.table thead th.sortable-header, Empty State for Tables ---------------------------------------------- */ -.table-empty-state { +tr.table-empty-state, +.table-highlight tr.table-empty-state:hover { + background-color: transparent; + > th { text-align: center; - + @include no-user-select(); + > p { font-weight: 400; font-size: 18px; diff --git a/ui/src/style/layout/sidebar.scss b/ui/src/style/layout/sidebar.scss index 5ff2503fa5..c7c4fd2fe4 100644 --- a/ui/src/style/layout/sidebar.scss +++ b/ui/src/style/layout/sidebar.scss @@ -151,6 +151,7 @@ $sidebar-menu--gutter: 18px; cursor: pointer; } } +.sidebar-menu--item, .sidebar-menu--item:link, .sidebar-menu--item:active, .sidebar-menu--item:visited { @@ -163,13 +164,15 @@ $sidebar-menu--gutter: 18px; transition: none; // Rounding bottom outside corner of match container - &:last-child {border-bottom-right-radius: $radius;} + &:nth-last-child(2) {border-bottom-right-radius: $radius;} } +.sidebar-menu--item.active, .sidebar-menu--item.active:link, .sidebar-menu--item.active:active, .sidebar-menu--item.active:visited { @include gradient-h($sidebar-menu--item-bg,$sidebar-menu--item-bg-accent); color: $sidebar-menu--item-text-active; + font-weight: 700; } .sidebar-menu--item:hover, .sidebar-menu--item.active:hover { @@ -188,6 +191,9 @@ $sidebar-menu--gutter: 18px; font-weight: 400; padding: 0px $sidebar-menu--gutter; } +.sidebar-menu--item > strong { + opacity: 0.6; +} // Invisible triangle for easier mouse movement when navigating to sub items .sidebar-menu--item + .sidebar-menu--triangle { position: absolute; @@ -198,3 +204,25 @@ $sidebar-menu--gutter: 18px; left: 0px; transform: translate(-50%,-50%) rotate(45deg); } + +.sidebar-menu--section { + white-space: nowrap; + font-size: 13px; + line-height: 22px; + font-weight: 600; + padding: 4px $sidebar-menu--gutter; + text-transform: uppercase; + color: $c-hydrogen; + @include no-user-select(); + position: relative; + + &:after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 2px; + @include gradient-h($c-laser,$c-potassium); + } +} diff --git a/ui/src/style/pages/admin.scss b/ui/src/style/pages/admin.scss index 715dba70eb..a46587fbe9 100644 --- a/ui/src/style/pages/admin.scss +++ b/ui/src/style/pages/admin.scss @@ -1,5 +1,5 @@ /* - Styles for Admin Pages + Styles for InfluxDB Admin Page ---------------------------------------------------------------------------- */ diff --git a/ui/src/style/pages/auth-page.scss b/ui/src/style/pages/auth-page.scss index 75df36ae6c..e34d97f537 100644 --- a/ui/src/style/pages/auth-page.scss +++ b/ui/src/style/pages/auth-page.scss @@ -38,12 +38,14 @@ h1 { color: $g20-white; + @include no-user-select(); font-weight: 200; font-size: 52px; letter-spacing: -2px; } p { color: $g11-sidewalk; + @include no-user-select(); } .btn { @@ -65,12 +67,14 @@ height: 100px; } .auth-credits { + @include no-user-select(); + font-weight: 600; z-index: 90; position: absolute; bottom: ($sidebar--width / 4); left: 50%; transform: translateX(-50%); - font-size: 12px; + font-size: 13px; color: $g11-sidewalk; .icon { @@ -78,6 +82,31 @@ vertical-align: middle; position: relative; top: -1px; - margin-right: 1px; + margin-right: 2px; + } +} + +/* Purgatory */ +.auth--purgatory { + margin-top: 30px; + min-width: 400px; + background-color: $g3-castle; + border-radius: 4px; + min-height: 200px; + padding: 30px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + > h3 { + white-space: nowrap; + } + > p { + text-align: center; + } + + hr { + width: 100%; } } diff --git a/ui/src/style/pages/users.scss b/ui/src/style/pages/users.scss new file mode 100644 index 0000000000..e5b5855933 --- /dev/null +++ b/ui/src/style/pages/users.scss @@ -0,0 +1,112 @@ +/* + Styles for Chronograf Users Admin Page + ---------------------------------------------------------------------------- +*/ + +.chronograf-user--role, +.chronograf-user--org { + display: inline-block; + width: 100%; + height: 22px; + line-height: 22px; +} +.chronograf-user--role, +.chronograf-user--org, +table.table.chronograf-admin-table .dropdown { + margin-bottom: 2px; + + &:last-child { + margin: 0; + } +} +.chronograf-user--org { + padding-left: 7px; +} +table.table.chronograf-admin-table thead tr th.align-with-col-text { + padding-left: 15px; +} +.dropdown-label { + margin: 0 8px 0 0; + font-weight: 700; + color: $g13-mist; + font-size: 14px; +} +.panel-body.chronograf-admin-table--panel { + border-top-left-radius: 0; + border-top-right-radius: 0; + padding-top: 11px; +} +.chronograf-admin-table--batch { + border-radius: 5px 5px 0 0; + background-color: $g4-onyx; + padding: 11px 38px; + display: flex; + align-items: center; +} +.chronograf-admin-table--batch-actions { + display: flex; + align-items: center; + + > .dropdown, + > .btn { + margin-left: 4px; + } +} +.chronograf-admin-table--num-selected { + @include no-user-select(); + margin: 0px 11px 0 0; + display: inline-block; + height: 30px; + line-height: 30px; + font-size: 14px; + font-weight: 500; + color: $g13-mist; +} +.super-admin-toggle .dropdown-toggle { + width: 70px; +} + +/* Make dropdowns in admin table appear as plaintext until hover */ +table.table.chronograf-admin-table tbody tr.chronograf-admin-table--user td div.dropdown div.btn.dropdown-toggle { + transition: none; + background-color: $g3-castle; + color: $g13-mist; + + > .caret { + opacity: 0; + } +} +table.table.chronograf-admin-table tbody tr.chronograf-admin-table--user.selected td div.dropdown div.btn.dropdown-toggle { + background-color: $g5-pepper; +} +table.table.chronograf-admin-table tbody tr.chronograf-admin-table--user:hover td div.dropdown div.btn.dropdown-toggle { + background-color: $c-pool; + color: $g20-white; + + > .caret { + opacity: 1; + } + + &:hover { + background-color: $c-laser; + } +} +table.table.chronograf-admin-table tbody tr.chronograf-admin-table--user td div.dropdown.open div.btn.dropdown-toggle, +table.table.chronograf-admin-table tbody tr.chronograf-admin-table--user td div.dropdown.open div.btn.dropdown-toggle:hover { + background-color: $c-hydrogen; + color: $g20-white; + + > .caret { + opacity: 1; + } +} + +/* Styles for new user row */ +table.table.chronograf-admin-table tbody tr.chronograf-admin-table--new-user { + background-color: $g4-onyx; + + > td { + padding-top: 8px; + padding-bottom: 8px; + } +} diff --git a/ui/src/style/unsorted.scss b/ui/src/style/unsorted.scss index 53917ca210..3d4084420b 100644 --- a/ui/src/style/unsorted.scss +++ b/ui/src/style/unsorted.scss @@ -418,5 +418,15 @@ $dash-editable-header-padding: 7px; } } } - +} + +/* + Stretch to fit Dropdowns + ----------------------------------------------------------------------------- +*/ + +div.dropdown.dropdown-stretch, +div.dropdown.dropdown-stretch > div.dropdown-toggle, +div.dropdown.dropdown-stretch > button.dropdown-toggle { + width: 100%; } diff --git a/ui/src/utils/ajax.js b/ui/src/utils/ajax.js index 99c44ab761..69ee8bf04d 100644 --- a/ui/src/utils/ajax.js +++ b/ui/src/utils/ajax.js @@ -9,12 +9,18 @@ const addBasepath = (url, excludeBasepath) => { return excludeBasepath ? url : `${basepath}${url}` } -const generateResponseWithLinks = (response, {auth, logout, external}) => ({ - ...response, - auth: {links: auth}, - logoutLink: logout, - external, -}) +const generateResponseWithLinks = (response, newLinks) => { + const {auth, logout, external, users, organizations, me: meLink} = newLinks + return { + ...response, + auth: {links: auth}, + logoutLink: logout, + external, + users, + organizations, + meLink, + } +} const AJAX = async ( {url, resource, id, method = 'GET', data = {}, params = {}, headers = {}},