Merge pull request #2180 from influxdata/multitenancy_ui_superadmin_admin_panel

Implement Admin & Superadmin UI with Org switching
pull/10616/head
Jared Scheib 2017-11-10 03:41:36 -08:00 committed by GitHub
commit e26e80ca87
58 changed files with 2824 additions and 213 deletions

View File

@ -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)
})
})

View File

@ -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]}

View File

@ -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)

View File

@ -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 <div className="page-spinner" />
}
@ -132,8 +177,10 @@ const CheckSources = React.createClass({
},
})
const mapStateToProps = ({sources}) => ({
const mapStateToProps = ({sources, auth, me}) => ({
sources,
auth,
me,
})
const mapDispatchToProps = dispatch => ({

View File

@ -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))
}
}

View File

@ -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,

View File

@ -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
}
}

View File

@ -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: <OrganizationsPage />,
},
{
requiredRole: ADMIN_ROLE,
type: USERS_TAB_NAME,
component: (
<UsersTable
users={users}
organization={organization}
onCreateUser={onCreateUser}
onUpdateUserRole={onUpdateUserRole}
onUpdateUserSuperAdmin={onUpdateUserSuperAdmin}
onDeleteUser={onDeleteUser}
/>
),
},
].filter(t => isUserAuthorized(meRole, t.requiredRole))
return (
<Tabs className="row">
<TabList customClass="col-md-2 admin-tabs">
{tabs.map((t, i) =>
<Tab key={tabs[i].type}>
{tabs[i].type}
</Tab>
)}
</TabList>
<TabPanels customClass="col-md-10 admin-tabs--content">
{tabs.map((t, i) =>
<TabPanel key={tabs[i].type}>
{t.component}
</TabPanel>
)}
</TabPanels>
</Tabs>
)
}
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

View File

@ -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 (
<div className="panel panel-default">
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
<h2 className="panel-title">
{tableTitle}
</h2>
<button
className="btn btn-sm btn-primary"
onClick={this.handleClickCreateOrganization}
disabled={isCreatingOrganization}
>
<span className="icon plus" /> Create Organization
</button>
</div>
<div className="panel-body">
<div className="orgs-table--org-labels">
<div className="orgs-table--id">ID</div>
<div className="orgs-table--name">Name</div>
<div className="orgs-table--default-role">Default Role</div>
<div className="orgs-table--delete" />
</div>
{isCreatingOrganization
? <OrganizationsTableRowNew
onCreateOrganization={this.handleCreateOrganization}
onCancelCreateOrganization={this.handleCancelCreateOrganization}
/>
: null}
{organizations.map(
org =>
org.id === DEFAULT_ORG_ID
? <OrganizationsTableRowDefault
key={uuid.v4()}
organization={org}
/>
: <OrganizationsTableRow
key={uuid.v4()}
organization={org}
onDelete={onDeleteOrg}
onRename={onRenameOrg}
/>
)}
</div>
</div>
)
}
}
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

View File

@ -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 (
<div className="orgs-table--org">
<div className="orgs-table--id">
{organization.id}
</div>
{isEditing
? <input
type="text"
className="form-control input-sm orgs-table--input"
defaultValue={workingName}
onChange={this.handleInputChange}
onBlur={this.handleInputBlur}
onKeyDown={this.handleKeyDown}
placeholder="Name this Organization..."
autoFocus={true}
onFocus={this.handleFocus}
ref={r => (this.inputRef = r)}
/>
: <div className="orgs-table--name" onClick={this.handleNameClick}>
{workingName}
<span className="icon pencil" />
</div>}
<div className={defaultRoleClassName}>
<Dropdown
items={dropdownRolesItems}
onChoose={this.handleChooseDefaultRole}
selected={defaultRole}
className="dropdown-stretch"
/>
</div>
{isDeleting
? <ConfirmButtons
item={organization}
onCancel={this.handleDismissDeleteConfirmation}
onConfirm={this.handleDeleteOrg}
onClickOutside={this.handleDismissDeleteConfirmation}
confirmLeft={true}
/>
: <button
className="btn btn-sm btn-default btn-square"
onClick={this.handleDeleteClick}
>
<span className="icon trash" />
</button>}
</div>
)
}
}
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

View File

@ -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}) =>
<div className="orgs-table--org">
<div className="orgs-table--id">
{organization.id}
</div>
<div className="orgs-table--name-disabled">
{organization.name}
</div>
<div className="orgs-table--default-role-disabled">
{MEMBER_ROLE}
</div>
<button
className="btn btn-sm btn-default btn-square orgs-table--delete"
disabled={true}
>
<span className="icon trash" />
</button>
</div>
const {shape, string} = PropTypes
OrganizationsTableRowDefault.propTypes = {
organization: shape({
id: string,
name: string.isRequired,
}).isRequired,
}
export default OrganizationsTableRowDefault

View File

@ -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 (
<div className="orgs-table--org orgs-table--new-org">
<div className="orgs-table--id">&mdash;</div>
<input
type="text"
className="form-control input-sm orgs-table--input"
value={name}
onKeyDown={this.handleKeyDown}
onChange={this.handleInputChange}
onFocus={this.handleInputFocus}
placeholder="Name this Organization..."
autoFocus={true}
ref={r => (this.inputRef = r)}
/>
<div className="orgs-table--default-role editing">
<Dropdown
items={dropdownRolesItems}
onChoose={this.handleChooseDefaultRole}
selected={defaultRole}
className="dropdown-stretch"
/>
</div>
<ConfirmButtons
disabled={isSaveDisabled}
onCancel={onCancelCreateOrganization}
onConfirm={this.handleClickSave}
/>
</div>
)
}
}
const {func} = PropTypes
OrganizationsTableRowNew.propTypes = {
onCreateOrganization: func.isRequired,
onCancelCreateOrganization: func.isRequired,
}
export default OrganizationsTableRowNew

View File

@ -0,0 +1,23 @@
import React, {PropTypes} from 'react'
const PageHeader = ({currentOrganization}) =>
<div className="page-header">
<div className="page-header__container">
<div className="page-header__left">
<h1 className="page-header__title">
{currentOrganization.name}
</h1>
</div>
</div>
</div>
const {shape, string} = PropTypes
PageHeader.propTypes = {
currentOrganization: shape({
id: string.isRequired,
name: string.isRequired,
}).isRequired,
}
export default PageHeader

View File

@ -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 (
<div className="panel panel-default">
<UsersTableHeader
numUsers={users.length}
onClickCreateUser={this.handleClickCreateUser}
isCreatingUser={isCreatingUser}
/>
<div className="panel-body">
<table className="table table-highlight v-center chronograf-admin-table">
<thead>
<tr>
<th>Username</th>
<th style={{width: colRole}} className="align-with-col-text">
Role
</th>
<Authorized requiredRole={SUPERADMIN_ROLE}>
<th style={{width: colSuperAdmin}} className="text-center">
SuperAdmin
</th>
</Authorized>
<th style={{width: colProvider}}>Provider</th>
<th style={{width: colScheme}}>Scheme</th>
<th className="text-right" style={{width: colActions}} />
<th /* for DeleteConfirmTableCell */ />
</tr>
</thead>
<tbody>
{isCreatingUser
? <UsersTableRowNew
organization={organization}
onBlur={this.handleBlurCreateUserRow}
onCreateUser={onCreateUser}
/>
: null}
{users.length || !isCreatingUser
? users.map(user =>
<UsersTableRow
user={user}
key={uuid.v4()}
organization={organization}
onChangeUserRole={this.handleChangeUserRole}
onChangeSuperAdmin={this.handleChangeSuperAdmin}
onDelete={this.handleDeleteUser}
/>
)
: <tr className="table-empty-state">
<Authorized
requiredRole={SUPERADMIN_ROLE}
replaceWithIfNotAuthorized={
<th colSpan="5">
<p>No Users to display</p>
</th>
}
>
<th colSpan="6">
<p>No Users to display</p>
</th>
</Authorized>
</tr>}
</tbody>
</table>
</div>
</div>
)
}
}
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

View File

@ -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 (
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
<h2 className="panel-title">
{panelTitle}
</h2>
<Authorized requiredRole={ADMIN_ROLE}>
<button
className="btn btn-primary btn-sm"
onClick={onClickCreateUser}
disabled={isCreatingUser}
>
<span className="icon plus" />
Create User
</button>
</Authorized>
</div>
)
}
}
const {bool, func, number} = PropTypes
UsersTableHeader.propTypes = {
numUsers: number.isRequired,
onClickCreateUser: func.isRequired,
isCreatingUser: bool.isRequired,
}
export default UsersTableHeader

View File

@ -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 (
<tr className={'chronograf-admin-table--user'}>
<td>
<strong>
{user.name}
</strong>
</td>
<td style={{width: colRole}}>
<span className="chronograf-user--role">
<Dropdown
items={dropdownRolesItems}
selected={currentRole.name}
onChoose={onChangeUserRole(user, currentRole)}
buttonColor="btn-primary"
buttonSize="btn-xs"
className="dropdown-stretch"
/>
</span>
</td>
<Authorized requiredRole={SUPERADMIN_ROLE}>
<td style={{width: colSuperAdmin}} className="text-center">
<SlideToggle
active={user.superAdmin}
onToggle={onChangeSuperAdmin(user, user.superAdmin)}
size="xs"
/>
</td>
</Authorized>
<td style={{width: colProvider}}>
{user.provider}
</td>
<td style={{width: colScheme}}>
{user.scheme}
</td>
<td className="text-right" style={{width: colActions}} />
<DeleteConfirmTableCell
text="Remove"
onDelete={onDelete}
item={user}
buttonSize="btn-xs"
/>
</tr>
)
}
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

View File

@ -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 (
<tr className="chronograf-admin-table--new-user">
<td>
<input
className="form-control input-xs"
type="text"
placeholder="OAuth Username..."
autoFocus={true}
value={name}
onChange={this.handleInputChange('name')}
/>
</td>
<td style={{width: colRole}}>
<Dropdown
items={dropdownRolesItems}
selected={role}
onChoose={this.handleSelectRole}
buttonColor="btn-primary"
buttonSize="btn-xs"
className="dropdown-stretch"
/>
</td>
<Authorized requiredRole={SUPERADMIN_ROLE}>
<td style={{width: colSuperAdmin}} className="text-center">
<SlideToggle
active={superAdmin}
size="xs"
onToggle={this.handleSelectSuperAdmin}
/>
</td>
</Authorized>
<td style={{width: colProvider}}>
<input
className="form-control input-xs"
type="text"
placeholder="OAuth Provider..."
value={provider}
onChange={this.handleInputChange('provider')}
/>
</td>
<td style={{width: colScheme}}>
<input
className="form-control input-xs disabled"
type="text"
disabled={true}
placeholder="OAuth Scheme..."
value={scheme}
/>
</td>
<td className="text-right" style={{width: colActions}}>
<button className="btn btn-xs btn-square btn-info" onClick={onBlur}>
<span className="icon remove" />
</button>
<button
className="btn btn-xs btn-square btn-success"
disabled={preventCreate}
onClick={this.handleConfirmCreateUser}
>
<span className="icon checkmark" />
</button>
</td>
</tr>
)
}
}
const {func, shape, string} = PropTypes
UsersTableRowNew.propTypes = {
organization: shape({
id: string.isRequired,
name: string.isRequired,
}),
onBlur: func.isRequired,
onCreateUser: func.isRequired,
}
export default UsersTableRowNew

View File

@ -0,0 +1,7 @@
export const USERS_TABLE = {
colRole: 120,
colSuperAdmin: 90,
colProvider: 170,
colScheme: 90,
colActions: 68,
}

View File

@ -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'

View File

@ -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 (
<div className="page">
<PageHeader currentOrganization={currentOrganization} />
<FancyScrollbar className="page-contents">
{users
? <div className="container-fluid">
<div className="row">
<div className="col-xs-12">
<AdminTabs
meRole={meRole}
// UsersTable
users={users}
organization={currentOrganization}
onCreateUser={this.handleCreateUser}
onUpdateUserRole={this.handleUpdateUserRole()}
onUpdateUserSuperAdmin={this.handleUpdateUserSuperAdmin()}
onDeleteUser={this.handleDeleteUser()}
/>
</div>
</div>
</div>
: <div className="page-spinner" />}
</FancyScrollbar>
</div>
)
}
}
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)

View File

@ -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 {
<div className="page-header">
<div className="page-header__container">
<div className="page-header__left">
<h1 className="page-header__title">Admin</h1>
<h1 className="page-header__title">InfluxDB Admin</h1>
</div>
<div className="page-header__right">
<SourceIndicator />
@ -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)

View File

@ -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,
})

View File

@ -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 (
<OrganizationsTable
organizations={organizations}
onCreateOrg={this.handleCreateOrganization}
onDeleteOrg={this.handleDeleteOrganization}
onRenameOrg={this.handleRenameOrganization}
/>
)
}
}
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)

View File

@ -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,
})

View File

@ -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}

View File

@ -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

View File

@ -0,0 +1,4 @@
import adminChronograf from './chronograf'
import adminInfluxDB from './influxdb'
export default {adminChronograf, adminInfluxDB}

View File

@ -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

View File

@ -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,
})

70
ui/src/auth/Purgatory.js Normal file
View File

@ -0,0 +1,70 @@
import React, {PropTypes} from 'react'
import {connect} from 'react-redux'
import {MEMBER_ROLE} from 'src/auth/Authorized'
const memberCopy = (
<p>This role does not grant you sufficient permissions to view Chronograf.</p>
)
const viewerCopy = (
<p>
This organization does not have any configured sources<br />and your role
does not have permission to configure a source.
</p>
)
const Purgatory = ({name, provider, scheme, currentOrganization, role}) =>
<div>
<div className="auth-page">
<div className="auth-box">
<div className="auth-logo" />
<div className="auth--purgatory">
<h3>
Logged in to <strong>{currentOrganization.name}</strong> as a{' '}
<em>{role}</em>.
</h3>
{role === MEMBER_ROLE ? memberCopy : viewerCopy}
<p>Contact your Administrator for assistance.</p>
<hr />
<pre>
<code>
username: {name}
<br />
provider: {provider}
<br />
scheme: {scheme}
</code>
</pre>
</div>
</div>
<p className="auth-credits">
Made by <span className="icon cubo-uniform" />InfluxData
</p>
<div className="auth-image" />
</div>
</div>
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)

View File

@ -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,
}

View File

@ -50,7 +50,7 @@ const DashboardHeader = ({
{dashboard
? <Authorized
requiredRole={EDITOR_ROLE}
replaceWith={
replaceWithIfNotAuthorized={
<h1 className="page-header__title">
{activeDashboard}
</h1>

View File

@ -38,7 +38,10 @@ const DashboardsTable = ({
)
: <span className="empty-string">None</span>}
</td>
<Authorized requiredRole={EDITOR_ROLE} replaceWith={<td />}>
<Authorized
requiredRole={EDITOR_ROLE}
replaceWithIfNotAuthorized={<td />}
>
<DeleteConfirmTableCell
onDelete={onDeleteDashboard}
item={dashboard}

View File

@ -11,7 +11,12 @@ import configureStore from 'src/store/configureStore'
import {loadLocalStorage} from 'src/localStorage'
import App from 'src/App'
import {Login, UserIsAuthenticated, UserIsNotAuthenticated} from 'src/auth'
import {
Login,
UserIsAuthenticated,
UserIsNotAuthenticated,
Purgatory,
} from 'src/auth'
import CheckSources from 'src/CheckSources'
import {StatusPage} from 'src/status'
import {HostsPage, HostPage} from 'src/hosts'
@ -25,7 +30,7 @@ import {
KapacitorTasksPage,
TickscriptPage,
} from 'src/kapacitor'
import {AdminPage} from 'src/admin'
import {AdminChronografPage, AdminInfluxDBPage} from 'src/admin'
import {SourcePage, ManageSources} from 'src/sources'
import NotFound from 'shared/components/NotFound'
@ -36,7 +41,8 @@ import {
authRequested,
authReceived,
meRequested,
meReceived,
meReceivedNotUsingAuth,
meReceivedUsingAuth,
logoutLinkReceived,
} from 'shared/actions/auth'
import {linksReceived} from 'shared/actions/links'
@ -93,12 +99,23 @@ const Root = React.createClass({
async startHeartbeat({shouldDispatchResponse}) {
try {
// These non-me objects are added to every response by some AJAX trickery
const {data: me, auth, logoutLink, external} = await getMe()
const {
data: me,
auth,
logoutLink,
external,
users,
organizations,
meLink,
} = await getMe()
if (shouldDispatchResponse) {
const isUsingAuth = !!logoutLink
dispatch(
isUsingAuth ? meReceivedUsingAuth(me) : meReceivedNotUsingAuth(me)
)
dispatch(authReceived(auth))
dispatch(meReceived(me))
dispatch(logoutLinkReceived(logoutLink))
dispatch(linksReceived({external}))
dispatch(linksReceived({external, users, organizations, me: meLink}))
}
setTimeout(() => {
@ -125,6 +142,7 @@ const Root = React.createClass({
<Router history={history}>
<Route path="/" component={UserIsAuthenticated(CheckSources)} />
<Route path="/login" component={UserIsNotAuthenticated(Login)} />
<Route path="/purgatory" component={UserIsAuthenticated(Purgatory)} />
<Route
path="/sources/new"
component={UserIsAuthenticated(SourcePage)}
@ -146,7 +164,8 @@ const Root = React.createClass({
<Route path="kapacitors/new" component={KapacitorPage} />
<Route path="kapacitors/:id/edit" component={KapacitorPage} />
<Route path="kapacitor-tasks" component={KapacitorTasksPage} />
<Route path="admin" component={AdminPage} />
<Route path="admin-chronograf" component={AdminChronografPage} />
<Route path="admin-influxdb" component={AdminInfluxDBPage} />
<Route path="manage-sources" component={ManageSources} />
<Route path="manage-sources/new" component={SourcePage} />
<Route path="manage-sources/:id/edit" component={SourcePage} />

View File

@ -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())
}
}

View File

@ -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
}
}

View File

@ -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 (
<div className="confirm-buttons">
<button
className={classnames('btn btn-info btn-square', {
[buttonSize]: buttonSize,
})}
onClick={this.handleCancel(item)}
>
<span className="icon remove" />
</button>
<button
className={classnames('btn btn-success btn-square', {
[buttonSize]: buttonSize,
})}
disabled={isDisabled}
title={isDisabled ? 'Cannot Save' : 'Save'}
onClick={this.handleConfirm(item)}
>
<span className="icon checkmark" />
</button>
</div>
)
render() {
const {item, buttonSize, isDisabled, confirmLeft} = this.props
return confirmLeft
? <div className="confirm-buttons">
<button
className={classnames('btn btn-success btn-square', {
[buttonSize]: buttonSize,
})}
disabled={isDisabled}
title={isDisabled ? 'Cannot Save' : 'Save'}
onClick={this.handleConfirm(item)}
>
<span className="icon checkmark" />
</button>
<button
className={classnames('btn btn-info btn-square', {
[buttonSize]: buttonSize,
})}
onClick={this.handleCancel(item)}
>
<span className="icon remove" />
</button>
</div>
: <div className="confirm-buttons">
<button
className={classnames('btn btn-info btn-square', {
[buttonSize]: buttonSize,
})}
onClick={this.handleCancel(item)}
>
<span className="icon remove" />
</button>
<button
className={classnames('btn btn-success btn-square', {
[buttonSize]: buttonSize,
})}
disabled={isDisabled}
title={isDisabled ? 'Cannot Save' : 'Save'}
onClick={this.handleConfirm(item)}
>
<span className="icon checkmark" />
</button>
</div>
}
}
@ -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)

View File

@ -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}) =>
<button
className={classnames('btn btn-danger table--show-on-row-hover', {
[buttonSize]: buttonSize,
})}
onClick={onClickDelete}
>
Delete
{text}
</button>
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}
/>
: <DeleteButton
text={text}
onClickDelete={this.handleClickDelete}
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,

View File

@ -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 (
<div
onClick={this.handleClick}
@ -222,7 +230,7 @@ class Dropdown extends Component {
>
{useAutoComplete && isOpen
? <div
className={`dropdown-autocomplete dropdown-toggle ${buttonSize} ${buttonColor}`}
className={`dropdown-autocomplete dropdown-toggle ${buttonSize} ${buttonColor} ${disabledClass}`}
style={toggleStyle}
>
<input
@ -239,7 +247,7 @@ class Dropdown extends Component {
<span className="caret" />
</div>
: <div
className={`btn dropdown-toggle ${buttonSize} ${buttonColor}`}
className={`btn dropdown-toggle ${buttonSize} ${buttonColor} ${disabledClass}`}
style={toggleStyle}
>
{iconName
@ -297,6 +305,7 @@ Dropdown.propTypes = {
menuClass: string,
useAutoComplete: bool,
toggleStyle: shape(),
disabled: bool,
}
export default OnClickOutside(Dropdown)

View File

@ -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,

View File

@ -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 (
<div className={classNames} onClick={this.handleClick}>
<div className="slide-toggle--knob" />
</div>
)
}
}
const {bool, func, string} = PropTypes
SlideToggle.defaultProps = {
size: 'sm',
}
SlideToggle.propTypes = {
active: bool,
size: string,
onToggle: func.isRequired,
}
export default SlideToggle

View File

@ -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}
}
}

View File

@ -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
)
}

View File

@ -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 (
<div className="sidebar--item">
<div className="sidebar--square">
<div className="sidebar--icon icon user" />
</div>
<div className="sidebar-menu">
<div className="sidebar-menu--heading">
{currentOrganization.name} ({currentRole})
</div>
<div className="sidebar-menu--section">
{me.name}
</div>
<a className="sidebar-menu--item" href={logoutLink}>
Logout
</a>
<div className="sidebar-menu--section">Switch Organizations</div>
{roles.map((r, i) => {
const isLinkCurrentOrg = currentOrganization.id === r.organization
return (
<span
key={i}
className={classnames({
'sidebar-menu--item': true,
active: isLinkCurrentOrg,
})}
onClick={this.handleChangeCurrentOrganization(r.organization)}
>
{organizations.find(o => o.id === r.organization).name}{' '}
<strong>({r.name})</strong>
</span>
)
})}
{customLinks
? <div className="sidebar-menu--section">Custom Links</div>
: null}
{customLinks
? customLinks.map((link, i) =>
<a
key={i}
className="sidebar-menu--item"
href={link.url}
target="_blank"
>
{link.name}
</a>
)
: null}
<div className="sidebar-menu--triangle" />
</div>
</div>
)
}
}
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)

View File

@ -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 [
<NavHeader key={0} title="User" />,
...customLinks
.sort((a, b) => a.name.toLowerCase() > b.name.toLowerCase())
.map(({name, url}, i) =>
<NavListItem
key={i + 1}
useAnchor={true}
isExternal={true}
link={url}
>
{name}
</NavListItem>
links: shape({
me: string,
external: shape({
custom: arrayOf(
shape({
name: string.isRequired,
url: string.isRequired,
})
),
<NavListItem
key={customLinks.length + 1}
useAnchor={true}
link={logoutLink}
>
Logout
</NavListItem>,
]
}),
}),
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
</NavListItem>
</NavBlock>
<Authorized requiredRole={ADMIN_ROLE}>
<Authorized
requiredRole={ADMIN_ROLE}
replaceWithIfNotUsingAuth={
<NavBlock
icon="crown2"
link={`${sourcePrefix}/admin-influxdb`}
location={location}
>
<NavHeader
link={`${sourcePrefix}/admin-influxdb`}
title="InfluxDB Admin"
/>
</NavBlock>
}
>
<NavBlock
icon="crown2"
link={`${sourcePrefix}/admin`}
link={`${sourcePrefix}/admin-chronograf`}
location={location}
>
<NavHeader link={`${sourcePrefix}/admin`} title="Admin" />
<NavHeader
link={`${sourcePrefix}/admin-chronograf`}
title="Admin"
/>
<NavListItem link={`${sourcePrefix}/admin-chronograf`}>
Chronograf
</NavListItem>
<NavListItem link={`${sourcePrefix}/admin-influxdb`}>
InfluxDB
</NavListItem>
</NavBlock>
</Authorized>
<NavBlock
@ -143,32 +168,26 @@ const SideNav = React.createClass({
/>
</NavBlock>
{isUsingAuth
? <NavBlock icon="user" location={location}>
{customLinks
? this.renderUserMenuBlockWithCustomLinks(
customLinks,
logoutLink
)
: <NavHeader
useAnchor={true}
link={logoutLink}
title="Logout"
/>}
</NavBlock>
? <UserNavBlock
logoutLink={logoutLink}
links={links}
me={me}
sourcePrefix={sourcePrefix}
/>
: null}
</NavBar>
},
})
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))

View File

@ -144,7 +144,7 @@ const InfluxTable = ({
<h5 className="margin-zero">
<Authorized
requiredRole={EDITOR_ROLE}
replaceWith={
replaceWithIfNotAuthorized={
<strong>
{s.name}
</strong>

View File

@ -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,

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -97,9 +97,13 @@ 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;

View File

@ -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);
}
}

View File

@ -1,5 +1,5 @@
/*
Styles for Admin Pages
Styles for InfluxDB Admin Page
----------------------------------------------------------------------------
*/

View File

@ -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%;
}
}

View File

@ -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;
}
}

View File

@ -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%;
}

View File

@ -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 = {}},