Merge branch 'master' into alert-message-polish
commit
5f1dd3e12c
18
CHANGELOG.md
18
CHANGELOG.md
|
@ -1,14 +1,26 @@
|
||||||
## v1.2.0 [unreleased]
|
## v1.2.0 [unreleased]
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
1. [#936](https://github.com/influxdata/chronograf/pull/936): Fix leaking sockets for InfluxQL queries
|
|
||||||
2. [#967](https://github.com/influxdata/chronograf/pull/967): Fix flash of empty graph on auto-refresh when no results were previously returned from a query.
|
|
||||||
3. [#968](https://github.com/influxdata/chronograf/issue/968): Fix wrong database used in dashboards
|
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
### UI Improvements
|
||||||
|
|
||||||
|
## v1.2.0-beta5 [2017-03-10]
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
1. [#936](https://github.com/influxdata/chronograf/pull/936): Fix leaking sockets for InfluxQL queries
|
||||||
|
2. [#967](https://github.com/influxdata/chronograf/pull/967): Fix flash of empty graph on auto-refresh when no results were previously returned from a query
|
||||||
|
3. [#968](https://github.com/influxdata/chronograf/issue/968): Fix wrong database used in dashboards
|
||||||
|
|
||||||
|
### Features
|
||||||
|
1. [#993](https://github.com/influxdata/chronograf/pull/993): Add Admin page for managing users, roles, and permissions for [OSS InfluxDB](https://github.com/influxdata/influxdb) and InfluxData's [Enterprise](https://docs.influxdata.com/enterprise/v1.2/) product
|
||||||
|
2. [#993](https://github.com/influxdata/chronograf/pull/993): Add Query Management features including the ability to view active queries and stop queries
|
||||||
|
|
||||||
### UI Improvements
|
### UI Improvements
|
||||||
1. [#989](https://github.com/influxdata/chronograf/pull/989) Add a canned dashboard for mesos
|
1. [#989](https://github.com/influxdata/chronograf/pull/989) Add a canned dashboard for mesos
|
||||||
|
2. [#993](https://github.com/influxdata/chronograf/pull/993): Improve the multi-select dropdown
|
||||||
|
3. [#993](https://github.com/influxdata/chronograf/pull/993): Provide better error information to users
|
||||||
|
|
||||||
## v1.2.0-beta4 [2017-02-24]
|
## v1.2.0-beta4 [2017-02-24]
|
||||||
|
|
||||||
|
|
11
README.md
11
README.md
|
@ -110,7 +110,14 @@ A UI for [Kapacitor](https://github.com/influxdata/kapacitor) alert creation and
|
||||||
* View all active alerts at a glance on the alerting dashboard
|
* View all active alerts at a glance on the alerting dashboard
|
||||||
* Enable and disable existing alert rules with the check of a box
|
* Enable and disable existing alert rules with the check of a box
|
||||||
|
|
||||||
### TLS/HTTPS support
|
### User and Query Management
|
||||||
|
|
||||||
|
Manage users, roles, permissions for [OSS InfluxDB](https://github.com/influxdata/influxdb) and InfluxData's [Enterprise](https://docs.influxdata.com/enterprise/v1.2/) product.
|
||||||
|
View actively running queries and stop expensive queries on the Query Management page.
|
||||||
|
|
||||||
|
These features are new in Chronograf version 1.2.0-beta5. We recommend using them in a non-production environment only. Should you come across any bugs or unexpected behavior please open [an issue](https://github.com/influxdata/chronograf/issues/new). We appreciate the feedback as we work to finalize and improve the user and query management features!
|
||||||
|
|
||||||
|
### TLS/HTTPS Support
|
||||||
See [Chronograf with TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) for more information.
|
See [Chronograf with TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) for more information.
|
||||||
|
|
||||||
### OAuth Login
|
### OAuth Login
|
||||||
|
@ -121,7 +128,7 @@ Change the default root path of the Chronograf server with the `--basepath` opti
|
||||||
|
|
||||||
## Versions
|
## Versions
|
||||||
|
|
||||||
Chronograf v1.2.0-beta4 is a beta release.
|
Chronograf v1.2.0-beta5 is a beta release.
|
||||||
We will be iterating quickly based on user feedback and recommend using the [nightly builds](https://www.influxdata.com/downloads/) for the time being.
|
We will be iterating quickly based on user feedback and recommend using the [nightly builds](https://www.influxdata.com/downloads/) for the time being.
|
||||||
|
|
||||||
Spotted a bug or have a feature request?
|
Spotted a bug or have a feature request?
|
||||||
|
|
|
@ -32,6 +32,8 @@ type Ctrl interface {
|
||||||
DeleteRole(ctx context.Context, name string) error
|
DeleteRole(ctx context.Context, name string) error
|
||||||
SetRolePerms(ctx context.Context, name string, perms Permissions) error
|
SetRolePerms(ctx context.Context, name string, perms Permissions) error
|
||||||
SetRoleUsers(ctx context.Context, name string, users []string) error
|
SetRoleUsers(ctx context.Context, name string, users []string) error
|
||||||
|
AddRoleUsers(ctx context.Context, name string, users []string) error
|
||||||
|
RemoveRoleUsers(ctx context.Context, name string, users []string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client is a device for retrieving time series data from an Influx Enterprise
|
// Client is a device for retrieving time series data from an Influx Enterprise
|
||||||
|
|
|
@ -272,32 +272,52 @@ func (m *MetaClient) SetRolePerms(ctx context.Context, name string, perms Permis
|
||||||
return m.Post(ctx, "/role", a, nil)
|
return m.Post(ctx, "/role", a, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveAllRoleUsers removes all users from a role
|
// SetRoleUsers removes all users and then adds the requested users to role
|
||||||
func (m *MetaClient) RemoveAllRoleUsers(ctx context.Context, name string) error {
|
func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []string) error {
|
||||||
role, err := m.Role(ctx, name)
|
role, err := m.Role(ctx, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
revoke, add := Difference(users, role.Users)
|
||||||
// No users to remove
|
if err := m.RemoveRoleUsers(ctx, name, revoke); err != nil {
|
||||||
if len(role.Users) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
a := &RoleAction{
|
|
||||||
Action: "remove-users",
|
|
||||||
Role: role,
|
|
||||||
}
|
|
||||||
return m.Post(ctx, "/role", a, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetRoleUsers removes all users and then adds the requested users to role
|
|
||||||
func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []string) error {
|
|
||||||
err := m.RemoveAllRoleUsers(ctx, name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return m.AddRoleUsers(ctx, name, add)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Difference compares two sets and returns a set to be removed and a set to be added
|
||||||
|
func Difference(wants []string, haves []string) (revoke []string, add []string) {
|
||||||
|
for _, want := range wants {
|
||||||
|
found := false
|
||||||
|
for _, got := range haves {
|
||||||
|
if want != got {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
add = append(add, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, got := range haves {
|
||||||
|
found := false
|
||||||
|
for _, want := range wants {
|
||||||
|
if want != got {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
revoke = append(revoke, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRoleUsers updates a role to have additional users.
|
||||||
|
func (m *MetaClient) AddRoleUsers(ctx context.Context, name string, users []string) error {
|
||||||
// No permissions to add, so, role is in the right state
|
// No permissions to add, so, role is in the right state
|
||||||
if len(users) == 0 {
|
if len(users) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
@ -313,6 +333,23 @@ func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []stri
|
||||||
return m.Post(ctx, "/role", a, nil)
|
return m.Post(ctx, "/role", a, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveRoleUsers updates a role to remove some users.
|
||||||
|
func (m *MetaClient) RemoveRoleUsers(ctx context.Context, name string, users []string) error {
|
||||||
|
// No permissions to add, so, role is in the right state
|
||||||
|
if len(users) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
a := &RoleAction{
|
||||||
|
Action: "remove-users",
|
||||||
|
Role: &Role{
|
||||||
|
Name: name,
|
||||||
|
Users: users,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return m.Post(ctx, "/role", a, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// Post is a helper function to POST to Influx Enterprise
|
// Post is a helper function to POST to Influx Enterprise
|
||||||
func (m *MetaClient) Post(ctx context.Context, path string, action interface{}, params map[string]string) error {
|
func (m *MetaClient) Post(ctx context.Context, path string, action interface{}, params map[string]string) error {
|
||||||
b, err := json.Marshal(action)
|
b, err := json.Marshal(action)
|
||||||
|
|
|
@ -1252,12 +1252,11 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
|
||||||
name string
|
name string
|
||||||
fields fields
|
fields fields
|
||||||
args args
|
args args
|
||||||
wantRm string
|
wants []string
|
||||||
wantAdd string
|
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Successful set users role",
|
name: "Successful set users role (remove user from role)",
|
||||||
fields: fields{
|
fields: fields{
|
||||||
URL: &url.URL{
|
URL: &url.URL{
|
||||||
Host: "twinpinesmall.net:8091",
|
Host: "twinpinesmall.net:8091",
|
||||||
|
@ -1274,7 +1273,7 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
name: "admin",
|
name: "admin",
|
||||||
},
|
},
|
||||||
wantRm: `{"action":"remove-users","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`,
|
wants: []string{`{"action":"remove-users","role":{"name":"admin","users":["marty"]}}`},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Successful set single user role",
|
name: "Successful set single user role",
|
||||||
|
@ -1285,7 +1284,7 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
|
||||||
},
|
},
|
||||||
client: NewMockClient(
|
client: NewMockClient(
|
||||||
http.StatusOK,
|
http.StatusOK,
|
||||||
[]byte(`{"roles":[{"name":"admin","users":["marty"],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`),
|
[]byte(`{"roles":[{"name":"admin","users":[],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`),
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
),
|
),
|
||||||
|
@ -1295,8 +1294,9 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
|
||||||
name: "admin",
|
name: "admin",
|
||||||
users: []string{"marty"},
|
users: []string{"marty"},
|
||||||
},
|
},
|
||||||
wantRm: `{"action":"remove-users","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`,
|
wants: []string{
|
||||||
wantAdd: `{"action":"add-users","role":{"name":"admin","users":["marty"]}}`,
|
`{"action":"add-users","role":{"name":"admin","users":["marty"]}}`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
@ -1312,8 +1312,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
reqs := tt.fields.client.(*MockClient).Requests
|
reqs := tt.fields.client.(*MockClient).Requests
|
||||||
if len(reqs) < 2 {
|
if len(reqs) != len(tt.wants)+1 {
|
||||||
t.Errorf("%q. MetaClient.SetRoleUsers() expected 2 but got %d", tt.name, len(reqs))
|
t.Errorf("%q. MetaClient.SetRoleUsers() expected %d but got %d", tt.name, len(tt.wants)+1, len(reqs))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1324,21 +1324,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
|
||||||
if usr.URL.Path != "/role" {
|
if usr.URL.Path != "/role" {
|
||||||
t.Errorf("%q. MetaClient.SetRoleUsers() expected /user path but got %s", tt.name, usr.URL.Path)
|
t.Errorf("%q. MetaClient.SetRoleUsers() expected /user path but got %s", tt.name, usr.URL.Path)
|
||||||
}
|
}
|
||||||
|
for i := range tt.wants {
|
||||||
prm := reqs[1]
|
prm := reqs[i+1]
|
||||||
if prm.Method != "POST" {
|
|
||||||
t.Errorf("%q. MetaClient.SetRoleUsers() expected GET method", tt.name)
|
|
||||||
}
|
|
||||||
if prm.URL.Path != "/role" {
|
|
||||||
t.Errorf("%q. MetaClient.SetRoleUsers() expected /role path but got %s", tt.name, prm.URL.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, _ := ioutil.ReadAll(prm.Body)
|
|
||||||
if string(got) != tt.wantRm {
|
|
||||||
t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wantRm)
|
|
||||||
}
|
|
||||||
if tt.wantAdd != "" {
|
|
||||||
prm := reqs[2]
|
|
||||||
if prm.Method != "POST" {
|
if prm.Method != "POST" {
|
||||||
t.Errorf("%q. MetaClient.SetRoleUsers() expected GET method", tt.name)
|
t.Errorf("%q. MetaClient.SetRoleUsers() expected GET method", tt.name)
|
||||||
}
|
}
|
||||||
|
@ -1347,8 +1334,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := ioutil.ReadAll(prm.Body)
|
got, _ := ioutil.ReadAll(prm.Body)
|
||||||
if string(got) != tt.wantAdd {
|
if string(got) != tt.wants[i] {
|
||||||
t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wantAdd)
|
t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wants[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,6 +88,14 @@ func (cc *ControlClient) SetRoleUsers(ctx context.Context, name string, users []
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cc *ControlClient) AddRoleUsers(ctx context.Context, name string, users []string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cc *ControlClient) RemoveRoleUsers(ctx context.Context, name string, users []string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type TimeSeries struct {
|
type TimeSeries struct {
|
||||||
URLs []string
|
URLs []string
|
||||||
Response Response
|
Response Response
|
||||||
|
|
|
@ -66,16 +66,20 @@ func (c *RolesStore) Get(ctx context.Context, name string) (*chronograf.Role, er
|
||||||
|
|
||||||
// Update the Role's permissions and roles
|
// Update the Role's permissions and roles
|
||||||
func (c *RolesStore) Update(ctx context.Context, u *chronograf.Role) error {
|
func (c *RolesStore) Update(ctx context.Context, u *chronograf.Role) error {
|
||||||
perms := ToEnterprise(u.Permissions)
|
if u.Permissions != nil {
|
||||||
if err := c.Ctrl.SetRolePerms(ctx, u.Name, perms); err != nil {
|
perms := ToEnterprise(u.Permissions)
|
||||||
return err
|
if err := c.Ctrl.SetRolePerms(ctx, u.Name, perms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if u.Users != nil {
|
||||||
users := make([]string, len(u.Users))
|
users := make([]string, len(u.Users))
|
||||||
for i, u := range u.Users {
|
for i, u := range u.Users {
|
||||||
users[i] = u.Name
|
users[i] = u.Name
|
||||||
|
}
|
||||||
|
return c.Ctrl.SetRoleUsers(ctx, u.Name, users)
|
||||||
}
|
}
|
||||||
return c.Ctrl.SetRoleUsers(ctx, u.Name, users)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// All is all Roles in influx
|
// All is all Roles in influx
|
||||||
|
|
|
@ -18,10 +18,17 @@ func (c *UserStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.Us
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
perms := ToEnterprise(u.Permissions)
|
perms := ToEnterprise(u.Permissions)
|
||||||
|
|
||||||
if err := c.Ctrl.SetUserPerms(ctx, u.Name, perms); err != nil {
|
if err := c.Ctrl.SetUserPerms(ctx, u.Name, perms); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return u, nil
|
for _, role := range u.Roles {
|
||||||
|
if err := c.Ctrl.AddRoleUsers(ctx, role.Name, []string{u.Name}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Get(ctx, u.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the User from Influx Enterprise
|
// Delete the User from Influx Enterprise
|
||||||
|
@ -62,6 +69,43 @@ func (c *UserStore) Update(ctx context.Context, u *chronograf.User) error {
|
||||||
if u.Passwd != "" {
|
if u.Passwd != "" {
|
||||||
return c.Ctrl.ChangePassword(ctx, u.Name, u.Passwd)
|
return c.Ctrl.ChangePassword(ctx, u.Name, u.Passwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make a list of the roles we want this user to have:
|
||||||
|
want := make([]string, len(u.Roles))
|
||||||
|
for i, r := range u.Roles {
|
||||||
|
want[i] = r.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the list of all roles this user is currently in
|
||||||
|
userRoles, err := c.UserRoles(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Make a list of the roles the user currently has
|
||||||
|
roles := userRoles[u.Name]
|
||||||
|
have := make([]string, len(roles.Roles))
|
||||||
|
for i, r := range roles.Roles {
|
||||||
|
have[i] = r.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the roles the user will be removed from and the roles the user
|
||||||
|
// will be added to.
|
||||||
|
revoke, add := Difference(want, have)
|
||||||
|
|
||||||
|
// First, add the user to the new roles
|
||||||
|
for _, role := range add {
|
||||||
|
if err := c.Ctrl.AddRoleUsers(ctx, role, []string{u.Name}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... and now remove the user from an extra roles
|
||||||
|
for _, role := range revoke {
|
||||||
|
if err := c.Ctrl.RemoveRoleUsers(ctx, role, []string{u.Name}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
perms := ToEnterprise(u.Permissions)
|
perms := ToEnterprise(u.Permissions)
|
||||||
return c.Ctrl.SetUserPerms(ctx, u.Name, perms)
|
return c.Ctrl.SetUserPerms(ctx, u.Name, perms)
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,6 +36,22 @@ func TestClient_Add(t *testing.T) {
|
||||||
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
|
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
user: func(ctx context.Context, name string) (*enterprise.User, error) {
|
||||||
|
return &enterprise.User{
|
||||||
|
Name: "marty",
|
||||||
|
Password: "johnny be good",
|
||||||
|
Permissions: map[string][]string{
|
||||||
|
"": {
|
||||||
|
"ViewChronograf",
|
||||||
|
"ReadData",
|
||||||
|
"WriteData",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
|
||||||
|
return map[string]enterprise.Roles{}, nil
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
args: args{
|
args: args{
|
||||||
|
@ -46,8 +62,82 @@ func TestClient_Add(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: &chronograf.User{
|
want: &chronograf.User{
|
||||||
Name: "marty",
|
Name: "marty",
|
||||||
Passwd: "johnny be good",
|
Permissions: chronograf.Permissions{
|
||||||
|
{
|
||||||
|
Scope: chronograf.AllScope,
|
||||||
|
Allowed: chronograf.Allowances{"ViewChronograf", "ReadData", "WriteData"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Roles: []chronograf.Role{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Successful Create User with roles",
|
||||||
|
fields: fields{
|
||||||
|
Ctrl: &mockCtrl{
|
||||||
|
createUser: func(ctx context.Context, name, passwd string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
user: func(ctx context.Context, name string) (*enterprise.User, error) {
|
||||||
|
return &enterprise.User{
|
||||||
|
Name: "marty",
|
||||||
|
Password: "johnny be good",
|
||||||
|
Permissions: map[string][]string{
|
||||||
|
"": {
|
||||||
|
"ViewChronograf",
|
||||||
|
"ReadData",
|
||||||
|
"WriteData",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
|
||||||
|
return map[string]enterprise.Roles{
|
||||||
|
"marty": enterprise.Roles{
|
||||||
|
Roles: []enterprise.Role{
|
||||||
|
{
|
||||||
|
Name: "admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
addRoleUsers: func(ctx context.Context, name string, users []string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: args{
|
||||||
|
ctx: context.Background(),
|
||||||
|
u: &chronograf.User{
|
||||||
|
Name: "marty",
|
||||||
|
Passwd: "johnny be good",
|
||||||
|
Roles: []chronograf.Role{
|
||||||
|
{
|
||||||
|
Name: "admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: &chronograf.User{
|
||||||
|
Name: "marty",
|
||||||
|
Permissions: chronograf.Permissions{
|
||||||
|
{
|
||||||
|
Scope: chronograf.AllScope,
|
||||||
|
Allowed: chronograf.Allowances{"ViewChronograf", "ReadData", "WriteData"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Roles: []chronograf.Role{
|
||||||
|
{
|
||||||
|
Name: "admin",
|
||||||
|
Users: []chronograf.User{},
|
||||||
|
Permissions: chronograf.Permissions{},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -80,7 +170,7 @@ func TestClient_Add(t *testing.T) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
t.Errorf("%q. Client.Add() = %v, want %v", tt.name, got, tt.want)
|
t.Errorf("%q. Client.Add() = \n%#v\n, want \n%#v\n", tt.name, got, tt.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -353,6 +443,9 @@ func TestClient_Update(t *testing.T) {
|
||||||
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
|
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
|
||||||
|
return map[string]enterprise.Roles{}, nil
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
args: args{
|
args: args{
|
||||||
|
@ -369,6 +462,40 @@ func TestClient_Update(t *testing.T) {
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Success setting permissions and roles for user",
|
||||||
|
fields: fields{
|
||||||
|
Ctrl: &mockCtrl{
|
||||||
|
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
addRoleUsers: func(ctx context.Context, name string, users []string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
|
||||||
|
return map[string]enterprise.Roles{}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: args{
|
||||||
|
ctx: context.Background(),
|
||||||
|
u: &chronograf.User{
|
||||||
|
Name: "marty",
|
||||||
|
Permissions: chronograf.Permissions{
|
||||||
|
{
|
||||||
|
Scope: chronograf.AllScope,
|
||||||
|
Allowed: chronograf.Allowances{"ViewChronograf", "KapacitorAPI"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Roles: []chronograf.Role{
|
||||||
|
{
|
||||||
|
Name: "adminrole",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Failure setting permissions User",
|
name: "Failure setting permissions User",
|
||||||
fields: fields{
|
fields: fields{
|
||||||
|
@ -376,6 +503,9 @@ func TestClient_Update(t *testing.T) {
|
||||||
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
|
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
|
||||||
return fmt.Errorf("They found me, I don't know how, but they found me.")
|
return fmt.Errorf("They found me, I don't know how, but they found me.")
|
||||||
},
|
},
|
||||||
|
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
|
||||||
|
return map[string]enterprise.Roles{}, nil
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
args: args{
|
args: args{
|
||||||
|
@ -573,12 +703,14 @@ type mockCtrl struct {
|
||||||
|
|
||||||
userRoles func(ctx context.Context) (map[string]enterprise.Roles, error)
|
userRoles func(ctx context.Context) (map[string]enterprise.Roles, error)
|
||||||
|
|
||||||
roles func(ctx context.Context, name *string) (*enterprise.Roles, error)
|
roles func(ctx context.Context, name *string) (*enterprise.Roles, error)
|
||||||
role func(ctx context.Context, name string) (*enterprise.Role, error)
|
role func(ctx context.Context, name string) (*enterprise.Role, error)
|
||||||
createRole func(ctx context.Context, name string) error
|
createRole func(ctx context.Context, name string) error
|
||||||
deleteRole func(ctx context.Context, name string) error
|
deleteRole func(ctx context.Context, name string) error
|
||||||
setRolePerms func(ctx context.Context, name string, perms enterprise.Permissions) error
|
setRolePerms func(ctx context.Context, name string, perms enterprise.Permissions) error
|
||||||
setRoleUsers func(ctx context.Context, name string, users []string) error
|
setRoleUsers func(ctx context.Context, name string, users []string) error
|
||||||
|
addRoleUsers func(ctx context.Context, name string, users []string) error
|
||||||
|
removeRoleUsers func(ctx context.Context, name string, users []string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockCtrl) ShowCluster(ctx context.Context) (*enterprise.Cluster, error) {
|
func (m *mockCtrl) ShowCluster(ctx context.Context) (*enterprise.Cluster, error) {
|
||||||
|
@ -636,3 +768,11 @@ func (m *mockCtrl) SetRolePerms(ctx context.Context, name string, perms enterpri
|
||||||
func (m *mockCtrl) SetRoleUsers(ctx context.Context, name string, users []string) error {
|
func (m *mockCtrl) SetRoleUsers(ctx context.Context, name string, users []string) error {
|
||||||
return m.setRoleUsers(ctx, name, users)
|
return m.setRoleUsers(ctx, name, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockCtrl) AddRoleUsers(ctx context.Context, name string, users []string) error {
|
||||||
|
return m.addRoleUsers(ctx, name, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockCtrl) RemoveRoleUsers(ctx context.Context, name string, users []string) error {
|
||||||
|
return m.removeRoleUsers(ctx, name, users)
|
||||||
|
}
|
||||||
|
|
|
@ -8,8 +8,10 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// AllowAll means a user gets both read and write permissions
|
// AllowAllDB means a user gets both read and write permissions for a db
|
||||||
AllowAll = chronograf.Allowances{"WRITE", "READ"}
|
AllowAllDB = chronograf.Allowances{"WRITE", "READ"}
|
||||||
|
// AllowAllAdmin means a user gets both read and write permissions for an admin
|
||||||
|
AllowAllAdmin = chronograf.Allowances{"ALL"}
|
||||||
// AllowRead means a user is only able to read the database.
|
// AllowRead means a user is only able to read the database.
|
||||||
AllowRead = chronograf.Allowances{"READ"}
|
AllowRead = chronograf.Allowances{"READ"}
|
||||||
// AllowWrite means a user is able to only write to the database
|
// AllowWrite means a user is able to only write to the database
|
||||||
|
@ -31,11 +33,11 @@ func (c *Client) Permissions(context.Context) chronograf.Permissions {
|
||||||
return chronograf.Permissions{
|
return chronograf.Permissions{
|
||||||
{
|
{
|
||||||
Scope: chronograf.AllScope,
|
Scope: chronograf.AllScope,
|
||||||
Allowed: AllowAll,
|
Allowed: AllowAllAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Scope: chronograf.DBScope,
|
Scope: chronograf.DBScope,
|
||||||
Allowed: AllowAll,
|
Allowed: AllowAllDB,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -90,7 +92,7 @@ func (r *showResults) Permissions() chronograf.Permissions {
|
||||||
}
|
}
|
||||||
switch priv {
|
switch priv {
|
||||||
case AllPrivileges, All:
|
case AllPrivileges, All:
|
||||||
c.Allowed = AllowAll
|
c.Allowed = AllowAllDB
|
||||||
case Read:
|
case Read:
|
||||||
c.Allowed = AllowRead
|
c.Allowed = AllowRead
|
||||||
case Write:
|
case Write:
|
||||||
|
@ -111,7 +113,7 @@ func adminPerms() chronograf.Permissions {
|
||||||
return []chronograf.Permission{
|
return []chronograf.Permission{
|
||||||
{
|
{
|
||||||
Scope: chronograf.AllScope,
|
Scope: chronograf.AllScope,
|
||||||
Allowed: AllowAll,
|
Allowed: AllowAllAdmin,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -318,7 +318,7 @@ func Test_showResults_Users(t *testing.T) {
|
||||||
Permissions: chronograf.Permissions{
|
Permissions: chronograf.Permissions{
|
||||||
{
|
{
|
||||||
Scope: chronograf.AllScope,
|
Scope: chronograf.AllScope,
|
||||||
Allowed: chronograf.Allowances{"WRITE", "READ"},
|
Allowed: chronograf.Allowances{"ALL"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -16,8 +16,12 @@ func (c *Client) Add(ctx context.Context, u *chronograf.User) (*chronograf.User,
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
for _, p := range u.Permissions {
|
||||||
return u, nil
|
if err := c.grantPermission(ctx, u.Name, p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.Get(ctx, u.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the User from InfluxDB
|
// Delete the User from InfluxDB
|
||||||
|
|
|
@ -97,12 +97,12 @@ func TestClient_Add(t *testing.T) {
|
||||||
u *chronograf.User
|
u *chronograf.User
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
args args
|
args args
|
||||||
status int
|
status int
|
||||||
want *chronograf.User
|
want *chronograf.User
|
||||||
wantQuery string
|
wantQueries []string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Create User",
|
name: "Create User",
|
||||||
|
@ -114,10 +114,57 @@ func TestClient_Add(t *testing.T) {
|
||||||
Passwd: "Dont Need Roads",
|
Passwd: "Dont Need Roads",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantQuery: `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`,
|
wantQueries: []string{
|
||||||
|
`CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`,
|
||||||
|
`SHOW USERS`,
|
||||||
|
`SHOW GRANTS FOR "docbrown"`,
|
||||||
|
},
|
||||||
want: &chronograf.User{
|
want: &chronograf.User{
|
||||||
Name: "docbrown",
|
Name: "docbrown",
|
||||||
Passwd: "Dont Need Roads",
|
Permissions: chronograf.Permissions{
|
||||||
|
chronograf.Permission{
|
||||||
|
Scope: chronograf.AllScope,
|
||||||
|
Allowed: chronograf.Allowances{
|
||||||
|
"ALL",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Create User with permissions",
|
||||||
|
status: http.StatusOK,
|
||||||
|
args: args{
|
||||||
|
ctx: context.Background(),
|
||||||
|
u: &chronograf.User{
|
||||||
|
Name: "docbrown",
|
||||||
|
Passwd: "Dont Need Roads",
|
||||||
|
Permissions: chronograf.Permissions{
|
||||||
|
chronograf.Permission{
|
||||||
|
Scope: chronograf.AllScope,
|
||||||
|
Allowed: chronograf.Allowances{
|
||||||
|
"ALL",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantQueries: []string{
|
||||||
|
`CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`,
|
||||||
|
`GRANT ALL PRIVILEGES TO "docbrown"`,
|
||||||
|
`SHOW USERS`,
|
||||||
|
`SHOW GRANTS FOR "docbrown"`,
|
||||||
|
},
|
||||||
|
want: &chronograf.User{
|
||||||
|
Name: "docbrown",
|
||||||
|
Permissions: chronograf.Permissions{
|
||||||
|
chronograf.Permission{
|
||||||
|
Scope: chronograf.AllScope,
|
||||||
|
Allowed: chronograf.Allowances{
|
||||||
|
"ALL",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -130,19 +177,19 @@ func TestClient_Add(t *testing.T) {
|
||||||
Passwd: "Dont Need Roads",
|
Passwd: "Dont Need Roads",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantQuery: `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`,
|
wantQueries: []string{`CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
query := ""
|
queries := []string{}
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
if path := r.URL.Path; path != "/query" {
|
if path := r.URL.Path; path != "/query" {
|
||||||
t.Error("Expected the path to contain `/query` but was", path)
|
t.Error("Expected the path to contain `/query` but was", path)
|
||||||
}
|
}
|
||||||
query = r.URL.Query().Get("q")
|
queries = append(queries, r.URL.Query().Get("q"))
|
||||||
rw.WriteHeader(tt.status)
|
rw.WriteHeader(tt.status)
|
||||||
rw.Write([]byte(`{"results":[{}]}`))
|
rw.Write([]byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`))
|
||||||
}))
|
}))
|
||||||
u, _ := url.Parse(ts.URL)
|
u, _ := url.Parse(ts.URL)
|
||||||
c := &Client{
|
c := &Client{
|
||||||
|
@ -155,9 +202,16 @@ func TestClient_Add(t *testing.T) {
|
||||||
t.Errorf("%q. Client.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr)
|
t.Errorf("%q. Client.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if tt.wantQuery != query {
|
if len(tt.wantQueries) != len(queries) {
|
||||||
t.Errorf("%q. Client.Add() query = %v, want %v", tt.name, query, tt.wantQuery)
|
t.Errorf("%q. Client.Add() queries = %v, want %v", tt.name, queries, tt.wantQueries)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
for i := range tt.wantQueries {
|
||||||
|
if tt.wantQueries[i] != queries[i] {
|
||||||
|
t.Errorf("%q. Client.Add() query = %v, want %v", tt.name, queries[i], tt.wantQueries[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
t.Errorf("%q. Client.Add() = %v, want %v", tt.name, got, tt.want)
|
t.Errorf("%q. Client.Add() = %v, want %v", tt.name, got, tt.want)
|
||||||
}
|
}
|
||||||
|
@ -275,7 +329,7 @@ func TestClient_Get(t *testing.T) {
|
||||||
Permissions: chronograf.Permissions{
|
Permissions: chronograf.Permissions{
|
||||||
chronograf.Permission{
|
chronograf.Permission{
|
||||||
Scope: "all",
|
Scope: "all",
|
||||||
Allowed: []string{"WRITE", "READ"},
|
Allowed: []string{"ALL"},
|
||||||
},
|
},
|
||||||
chronograf.Permission{
|
chronograf.Permission{
|
||||||
Scope: "database",
|
Scope: "database",
|
||||||
|
@ -548,7 +602,7 @@ func TestClient_All(t *testing.T) {
|
||||||
Permissions: chronograf.Permissions{
|
Permissions: chronograf.Permissions{
|
||||||
chronograf.Permission{
|
chronograf.Permission{
|
||||||
Scope: "all",
|
Scope: "all",
|
||||||
Allowed: []string{"WRITE", "READ"},
|
Allowed: []string{"ALL"},
|
||||||
},
|
},
|
||||||
chronograf.Permission{
|
chronograf.Permission{
|
||||||
Scope: "database",
|
Scope: "database",
|
||||||
|
@ -562,7 +616,7 @@ func TestClient_All(t *testing.T) {
|
||||||
Permissions: chronograf.Permissions{
|
Permissions: chronograf.Permissions{
|
||||||
chronograf.Permission{
|
chronograf.Permission{
|
||||||
Scope: "all",
|
Scope: "all",
|
||||||
Allowed: []string{"WRITE", "READ"},
|
Allowed: []string{"ALL"},
|
||||||
},
|
},
|
||||||
chronograf.Permission{
|
chronograf.Permission{
|
||||||
Scope: "database",
|
Scope: "database",
|
||||||
|
@ -688,7 +742,7 @@ func TestClient_Update(t *testing.T) {
|
||||||
Permissions: chronograf.Permissions{
|
Permissions: chronograf.Permissions{
|
||||||
{
|
{
|
||||||
Scope: "all",
|
Scope: "all",
|
||||||
Allowed: []string{"WRITE", "READ"},
|
Allowed: []string{"all"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Scope: "database",
|
Scope: "database",
|
||||||
|
@ -743,7 +797,7 @@ func TestClient_Update(t *testing.T) {
|
||||||
Permissions: chronograf.Permissions{
|
Permissions: chronograf.Permissions{
|
||||||
{
|
{
|
||||||
Scope: "all",
|
Scope: "all",
|
||||||
Allowed: []string{"WRITE", "READ"},
|
Allowed: []string{"all"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Scope: "database",
|
Scope: "database",
|
||||||
|
@ -800,6 +854,34 @@ func TestClient_Update(t *testing.T) {
|
||||||
`REVOKE ALL PRIVILEGES FROM "docbrown"`,
|
`REVOKE ALL PRIVILEGES FROM "docbrown"`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Revoke some",
|
||||||
|
statusUsers: http.StatusOK,
|
||||||
|
showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",false],["reader",false]]}]}]}`),
|
||||||
|
statusGrants: http.StatusOK,
|
||||||
|
showGrants: []byte(`{"results":[]}`),
|
||||||
|
statusRevoke: http.StatusOK,
|
||||||
|
revoke: []byte(`{"results":[]}`),
|
||||||
|
statusGrant: http.StatusOK,
|
||||||
|
grant: []byte(`{"results":[]}`),
|
||||||
|
args: args{
|
||||||
|
ctx: context.Background(),
|
||||||
|
u: &chronograf.User{
|
||||||
|
Name: "docbrown",
|
||||||
|
Permissions: chronograf.Permissions{
|
||||||
|
{
|
||||||
|
Scope: "all",
|
||||||
|
Allowed: []string{"ALL"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: []string{
|
||||||
|
`SHOW USERS`,
|
||||||
|
`SHOW GRANTS FOR "docbrown"`,
|
||||||
|
`GRANT ALL PRIVILEGES TO "docbrown"`,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Fail users",
|
name: "Fail users",
|
||||||
statusUsers: http.StatusBadRequest,
|
statusUsers: http.StatusBadRequest,
|
||||||
|
|
545
server/admin.go
545
server/admin.go
|
@ -1,545 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/bouk/httprouter"
|
|
||||||
"github.com/influxdata/chronograf"
|
|
||||||
)
|
|
||||||
|
|
||||||
func validPermissions(perms *chronograf.Permissions) error {
|
|
||||||
if perms == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, perm := range *perms {
|
|
||||||
if perm.Scope != chronograf.AllScope && perm.Scope != chronograf.DBScope {
|
|
||||||
return fmt.Errorf("Invalid permission scope")
|
|
||||||
}
|
|
||||||
if perm.Scope == chronograf.DBScope && perm.Name == "" {
|
|
||||||
return fmt.Errorf("Database scoped permission requires a name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type sourceUserRequest struct {
|
|
||||||
Username string `json:"name,omitempty"` // Username for new account
|
|
||||||
Password string `json:"password,omitempty"` // Password for new account
|
|
||||||
Permissions chronograf.Permissions `json:"permissions,omitempty"` // Optional permissions
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *sourceUserRequest) ValidCreate() error {
|
|
||||||
if r.Username == "" {
|
|
||||||
return fmt.Errorf("Username required")
|
|
||||||
}
|
|
||||||
if r.Password == "" {
|
|
||||||
return fmt.Errorf("Password required")
|
|
||||||
}
|
|
||||||
return validPermissions(&r.Permissions)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *sourceUserRequest) ValidUpdate() error {
|
|
||||||
if r.Password == "" && len(r.Permissions) == 0 {
|
|
||||||
return fmt.Errorf("No fields to update")
|
|
||||||
}
|
|
||||||
return validPermissions(&r.Permissions)
|
|
||||||
}
|
|
||||||
|
|
||||||
type sourceUser struct {
|
|
||||||
Username string `json:"name,omitempty"` // Username for new account
|
|
||||||
Permissions chronograf.Permissions `json:"permissions,omitempty"` // Account's permissions
|
|
||||||
Roles []roleResponse `json:"roles,omitempty"` // Roles if source uses them
|
|
||||||
Links selfLinks `json:"links"` // Links are URI locations related to user
|
|
||||||
}
|
|
||||||
|
|
||||||
type selfLinks struct {
|
|
||||||
Self string `json:"self"` // Self link mapping to this resource
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSelfLinks(id int, parent, resource string) selfLinks {
|
|
||||||
httpAPISrcs := "/chronograf/v1/sources"
|
|
||||||
u := &url.URL{Path: resource}
|
|
||||||
encodedResource := u.String()
|
|
||||||
return selfLinks{
|
|
||||||
Self: fmt.Sprintf("%s/%d/%s/%s", httpAPISrcs, id, parent, encodedResource),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSourceUser adds user to source
|
|
||||||
func (h *Service) NewSourceUser(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req sourceUserRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
invalidJSON(w, h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := req.ValidCreate(); err != nil {
|
|
||||||
invalidData(w, err, h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
srcID, store, err := h.sourceUsersStore(ctx, w, r)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user := &chronograf.User{
|
|
||||||
Name: req.Username,
|
|
||||||
Passwd: req.Password,
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := store.Add(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
su := sourceUser{
|
|
||||||
Username: res.Name,
|
|
||||||
Permissions: req.Permissions,
|
|
||||||
Links: newSelfLinks(srcID, "users", res.Name),
|
|
||||||
}
|
|
||||||
w.Header().Add("Location", su.Links.Self)
|
|
||||||
encodeJSON(w, http.StatusCreated, su, h.Logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
type sourceUsers struct {
|
|
||||||
Users []sourceUser `json:"users"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SourceUsers retrieves all users from source.
|
|
||||||
func (h *Service) SourceUsers(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
srcID, store, err := h.sourceUsersStore(ctx, w, r)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
users, err := store.All(ctx)
|
|
||||||
if err != nil {
|
|
||||||
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
su := []sourceUser{}
|
|
||||||
for _, u := range users {
|
|
||||||
res := sourceUser{
|
|
||||||
Username: u.Name,
|
|
||||||
Permissions: u.Permissions,
|
|
||||||
Links: newSelfLinks(srcID, "users", u.Name),
|
|
||||||
}
|
|
||||||
if len(u.Roles) > 0 {
|
|
||||||
rr := make([]roleResponse, len(u.Roles))
|
|
||||||
for i, role := range u.Roles {
|
|
||||||
rr[i] = newRoleResponse(srcID, &role)
|
|
||||||
}
|
|
||||||
res.Roles = rr
|
|
||||||
}
|
|
||||||
su = append(su, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
res := sourceUsers{
|
|
||||||
Users: su,
|
|
||||||
}
|
|
||||||
|
|
||||||
encodeJSON(w, http.StatusOK, res, h.Logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SourceUserID retrieves a user with ID from store.
|
|
||||||
func (h *Service) SourceUserID(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
uid := httprouter.GetParamFromContext(ctx, "uid")
|
|
||||||
|
|
||||||
srcID, store, err := h.sourceUsersStore(ctx, w, r)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := store.Get(ctx, uid)
|
|
||||||
if err != nil {
|
|
||||||
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res := sourceUser{
|
|
||||||
Username: u.Name,
|
|
||||||
Permissions: u.Permissions,
|
|
||||||
Links: newSelfLinks(srcID, "users", u.Name),
|
|
||||||
}
|
|
||||||
if len(u.Roles) > 0 {
|
|
||||||
rr := make([]roleResponse, len(u.Roles))
|
|
||||||
for i, role := range u.Roles {
|
|
||||||
rr[i] = newRoleResponse(srcID, &role)
|
|
||||||
}
|
|
||||||
res.Roles = rr
|
|
||||||
}
|
|
||||||
encodeJSON(w, http.StatusOK, res, h.Logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveSourceUser removes the user from the InfluxDB source
|
|
||||||
func (h *Service) RemoveSourceUser(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
uid := httprouter.GetParamFromContext(ctx, "uid")
|
|
||||||
|
|
||||||
_, store, err := h.sourceUsersStore(ctx, w, r)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.Delete(ctx, &chronograf.User{Name: uid}); err != nil {
|
|
||||||
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateSourceUser changes the password or permissions of a source user
|
|
||||||
func (h *Service) UpdateSourceUser(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req sourceUserRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
invalidJSON(w, h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := req.ValidUpdate(); err != nil {
|
|
||||||
invalidData(w, err, h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
uid := httprouter.GetParamFromContext(ctx, "uid")
|
|
||||||
srcID, store, err := h.sourceUsersStore(ctx, w, r)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user := &chronograf.User{
|
|
||||||
Name: uid,
|
|
||||||
Passwd: req.Password,
|
|
||||||
Permissions: req.Permissions,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.Update(ctx, user); err != nil {
|
|
||||||
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
su := sourceUser{
|
|
||||||
Username: user.Name,
|
|
||||||
Permissions: user.Permissions,
|
|
||||||
Links: newSelfLinks(srcID, "users", user.Name),
|
|
||||||
}
|
|
||||||
w.Header().Add("Location", su.Links.Self)
|
|
||||||
encodeJSON(w, http.StatusOK, su, h.Logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Service) sourcesSeries(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.TimeSeries, error) {
|
|
||||||
srcID, err := paramID("id", r)
|
|
||||||
if err != nil {
|
|
||||||
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
|
|
||||||
return 0, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := h.SourcesStore.Get(ctx, srcID)
|
|
||||||
if err != nil {
|
|
||||||
notFound(w, srcID, h.Logger)
|
|
||||||
return 0, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ts, err := h.TimeSeries(src)
|
|
||||||
if err != nil {
|
|
||||||
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
|
|
||||||
Error(w, http.StatusBadRequest, msg, h.Logger)
|
|
||||||
return 0, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = ts.Connect(ctx, &src); err != nil {
|
|
||||||
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
|
|
||||||
Error(w, http.StatusBadRequest, msg, h.Logger)
|
|
||||||
return 0, nil, err
|
|
||||||
}
|
|
||||||
return srcID, ts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Service) sourceUsersStore(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.UsersStore, error) {
|
|
||||||
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
|
||||||
if err != nil {
|
|
||||||
return 0, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
store := ts.Users(ctx)
|
|
||||||
return srcID, store, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasRoles checks if the influx source has roles or not
|
|
||||||
func (h *Service) hasRoles(ctx context.Context, ts chronograf.TimeSeries) (chronograf.RolesStore, bool) {
|
|
||||||
store, err := ts.Roles(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return store, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permissions returns all possible permissions for this source.
|
|
||||||
func (h *Service) Permissions(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
srcID, err := paramID("id", r)
|
|
||||||
if err != nil {
|
|
||||||
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := h.SourcesStore.Get(ctx, srcID)
|
|
||||||
if err != nil {
|
|
||||||
notFound(w, srcID, h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ts, err := h.TimeSeries(src)
|
|
||||||
if err != nil {
|
|
||||||
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
|
|
||||||
Error(w, http.StatusBadRequest, msg, h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = ts.Connect(ctx, &src); err != nil {
|
|
||||||
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
|
|
||||||
Error(w, http.StatusBadRequest, msg, h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
perms := ts.Permissions(ctx)
|
|
||||||
if err != nil {
|
|
||||||
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
httpAPISrcs := "/chronograf/v1/sources"
|
|
||||||
res := struct {
|
|
||||||
Permissions chronograf.Permissions `json:"permissions"`
|
|
||||||
Links map[string]string `json:"links"` // Links are URI locations related to user
|
|
||||||
}{
|
|
||||||
Permissions: perms,
|
|
||||||
Links: map[string]string{
|
|
||||||
"self": fmt.Sprintf("%s/%d/permissions", httpAPISrcs, srcID),
|
|
||||||
"source": fmt.Sprintf("%s/%d", httpAPISrcs, srcID),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
encodeJSON(w, http.StatusOK, res, h.Logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
type sourceRoleRequest struct {
|
|
||||||
chronograf.Role
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *sourceRoleRequest) ValidCreate() error {
|
|
||||||
if r.Name == "" || len(r.Name) > 254 {
|
|
||||||
return fmt.Errorf("Name is required for a role")
|
|
||||||
}
|
|
||||||
for _, user := range r.Users {
|
|
||||||
if user.Name == "" {
|
|
||||||
return fmt.Errorf("Username required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return validPermissions(&r.Permissions)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *sourceRoleRequest) ValidUpdate() error {
|
|
||||||
if len(r.Name) > 254 {
|
|
||||||
return fmt.Errorf("Username too long; must be less than 254 characters")
|
|
||||||
}
|
|
||||||
for _, user := range r.Users {
|
|
||||||
if user.Name == "" {
|
|
||||||
return fmt.Errorf("Username required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return validPermissions(&r.Permissions)
|
|
||||||
}
|
|
||||||
|
|
||||||
type roleResponse struct {
|
|
||||||
Users []sourceUser `json:"users,omitempty"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Permissions chronograf.Permissions `json:"permissions"`
|
|
||||||
Links selfLinks `json:"links"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRoleResponse(srcID int, res *chronograf.Role) roleResponse {
|
|
||||||
su := make([]sourceUser, len(res.Users))
|
|
||||||
for i := range res.Users {
|
|
||||||
name := res.Users[i].Name
|
|
||||||
su[i] = sourceUser{
|
|
||||||
Username: name,
|
|
||||||
Links: newSelfLinks(srcID, "users", name),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if res.Permissions == nil {
|
|
||||||
res.Permissions = make(chronograf.Permissions, 0)
|
|
||||||
}
|
|
||||||
return roleResponse{
|
|
||||||
Name: res.Name,
|
|
||||||
Permissions: res.Permissions,
|
|
||||||
Users: su,
|
|
||||||
Links: newSelfLinks(srcID, "roles", res.Name),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRole adds role to source
|
|
||||||
func (h *Service) NewRole(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req sourceRoleRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
invalidJSON(w, h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := req.ValidCreate(); err != nil {
|
|
||||||
invalidData(w, err, h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
roles, ok := h.hasRoles(ctx, ts)
|
|
||||||
if !ok {
|
|
||||||
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := roles.Add(ctx, &req.Role)
|
|
||||||
if err != nil {
|
|
||||||
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rr := newRoleResponse(srcID, res)
|
|
||||||
w.Header().Add("Location", rr.Links.Self)
|
|
||||||
encodeJSON(w, http.StatusCreated, rr, h.Logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateRole changes the permissions or users of a role
|
|
||||||
func (h *Service) UpdateRole(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req sourceRoleRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
invalidJSON(w, h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := req.ValidUpdate(); err != nil {
|
|
||||||
invalidData(w, err, h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
roles, ok := h.hasRoles(ctx, ts)
|
|
||||||
if !ok {
|
|
||||||
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rid := httprouter.GetParamFromContext(ctx, "rid")
|
|
||||||
req.Name = rid
|
|
||||||
|
|
||||||
if err := roles.Update(ctx, &req.Role); err != nil {
|
|
||||||
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
role, err := roles.Get(ctx, req.Name)
|
|
||||||
if err != nil {
|
|
||||||
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rr := newRoleResponse(srcID, role)
|
|
||||||
w.Header().Add("Location", rr.Links.Self)
|
|
||||||
encodeJSON(w, http.StatusOK, rr, h.Logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RoleID retrieves a role with ID from store.
|
|
||||||
func (h *Service) RoleID(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
roles, ok := h.hasRoles(ctx, ts)
|
|
||||||
if !ok {
|
|
||||||
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rid := httprouter.GetParamFromContext(ctx, "rid")
|
|
||||||
role, err := roles.Get(ctx, rid)
|
|
||||||
if err != nil {
|
|
||||||
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rr := newRoleResponse(srcID, role)
|
|
||||||
encodeJSON(w, http.StatusOK, rr, h.Logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Roles retrieves all roles from the store
|
|
||||||
func (h *Service) Roles(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
store, ok := h.hasRoles(ctx, ts)
|
|
||||||
if !ok {
|
|
||||||
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
roles, err := store.All(ctx)
|
|
||||||
if err != nil {
|
|
||||||
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rr := make([]roleResponse, len(roles))
|
|
||||||
for i, role := range roles {
|
|
||||||
rr[i] = newRoleResponse(srcID, &role)
|
|
||||||
}
|
|
||||||
|
|
||||||
res := struct {
|
|
||||||
Roles []roleResponse `json:"roles"`
|
|
||||||
}{rr}
|
|
||||||
encodeJSON(w, http.StatusOK, res, h.Logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveRole removes role from data source.
|
|
||||||
func (h *Service) RemoveRole(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
roles, ok := h.hasRoles(ctx, ts)
|
|
||||||
if !ok {
|
|
||||||
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rid := httprouter.GetParamFromContext(ctx, "rid")
|
|
||||||
if err := roles.Delete(ctx, &chronograf.Role{Name: rid}); err != nil {
|
|
||||||
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
1476
server/admin_test.go
1476
server/admin_test.go
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,99 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"golang.org/x/net/context"
|
||||||
|
|
||||||
|
"github.com/influxdata/chronograf"
|
||||||
|
"github.com/influxdata/chronograf/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type meLinks struct {
|
||||||
|
Self string `json:"self"` // Self link mapping to this resource
|
||||||
|
}
|
||||||
|
|
||||||
|
type meResponse struct {
|
||||||
|
*chronograf.User
|
||||||
|
Links meLinks `json:"links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// If new user response is nil, return an empty meResponse because it
|
||||||
|
// indicates authentication is not needed
|
||||||
|
func newMeResponse(usr *chronograf.User) meResponse {
|
||||||
|
base := "/chronograf/v1/users"
|
||||||
|
name := "me"
|
||||||
|
if usr != nil {
|
||||||
|
// TODO: Change to urls.PathEscape for go 1.8
|
||||||
|
u := &url.URL{Path: usr.Name}
|
||||||
|
name = u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return meResponse{
|
||||||
|
User: usr,
|
||||||
|
Links: meLinks{
|
||||||
|
Self: fmt.Sprintf("%s/%s", base, name),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEmail(ctx context.Context) (string, error) {
|
||||||
|
principal, err := getPrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if principal.Subject == "" {
|
||||||
|
return "", fmt.Errorf("Token not found")
|
||||||
|
}
|
||||||
|
return principal.Subject, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPrincipal(ctx context.Context) (oauth2.Principal, error) {
|
||||||
|
principal, ok := ctx.Value(oauth2.PrincipalKey).(oauth2.Principal)
|
||||||
|
if !ok {
|
||||||
|
return oauth2.Principal{}, fmt.Errorf("Token not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return principal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me does a findOrCreate based on the email in the context
|
||||||
|
func (h *Service) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
if !h.UseAuth {
|
||||||
|
// If there's no authentication, return an empty user
|
||||||
|
res := newMeResponse(nil)
|
||||||
|
encodeJSON(w, http.StatusOK, res, h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email, err := getEmail(ctx)
|
||||||
|
if err != nil {
|
||||||
|
invalidData(w, err, h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usr, err := h.UsersStore.Get(ctx, email)
|
||||||
|
if err == nil {
|
||||||
|
res := newMeResponse(usr)
|
||||||
|
encodeJSON(w, http.StatusOK, res, h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Because we didnt find a user, making a new one
|
||||||
|
user := &chronograf.User{
|
||||||
|
Name: email,
|
||||||
|
}
|
||||||
|
|
||||||
|
newUser, err := h.UsersStore.Add(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Errorf("error storing user %s: %v", user.Name, err)
|
||||||
|
unknownErrorWithMessage(w, msg, h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res := newMeResponse(newUser)
|
||||||
|
encodeJSON(w, http.StatusOK, res, h.Logger)
|
||||||
|
}
|
|
@ -0,0 +1,168 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/influxdata/chronograf"
|
||||||
|
"github.com/influxdata/chronograf/log"
|
||||||
|
"github.com/influxdata/chronograf/mocks"
|
||||||
|
"github.com/influxdata/chronograf/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockUsers struct{}
|
||||||
|
|
||||||
|
func TestService_Me(t *testing.T) {
|
||||||
|
type fields struct {
|
||||||
|
UsersStore chronograf.UsersStore
|
||||||
|
Logger chronograf.Logger
|
||||||
|
UseAuth bool
|
||||||
|
}
|
||||||
|
type args struct {
|
||||||
|
w *httptest.ResponseRecorder
|
||||||
|
r *http.Request
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
args args
|
||||||
|
principal oauth2.Principal
|
||||||
|
wantStatus int
|
||||||
|
wantContentType string
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Existing user",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
UseAuth: true,
|
||||||
|
UsersStore: &mocks.UsersStore{
|
||||||
|
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
|
||||||
|
return &chronograf.User{
|
||||||
|
Name: "me",
|
||||||
|
Passwd: "hunter2",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
principal: oauth2.Principal{
|
||||||
|
Subject: "me",
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"name":"me","password":"hunter2","links":{"self":"/chronograf/v1/users/me"}}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "New user",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
UseAuth: true,
|
||||||
|
UsersStore: &mocks.UsersStore{
|
||||||
|
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
|
||||||
|
return nil, fmt.Errorf("Unknown User")
|
||||||
|
},
|
||||||
|
AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
|
||||||
|
return u, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
principal: oauth2.Principal{
|
||||||
|
Subject: "secret",
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"name":"secret","password":"","links":{"self":"/chronograf/v1/users/secret"}}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Error adding user",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
UseAuth: true,
|
||||||
|
UsersStore: &mocks.UsersStore{
|
||||||
|
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
|
||||||
|
return nil, fmt.Errorf("Unknown User")
|
||||||
|
},
|
||||||
|
AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
|
||||||
|
return nil, fmt.Errorf("Why Heavy?")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
},
|
||||||
|
principal: oauth2.Principal{
|
||||||
|
Subject: "secret",
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"code":500,"message":"Unknown error: error storing user secret: Why Heavy?"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No Auth",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
UseAuth: false,
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"links":{"self":"/chronograf/v1/users/me"}}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty Principal",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
UseAuth: true,
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusUnprocessableEntity,
|
||||||
|
principal: oauth2.Principal{
|
||||||
|
Subject: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt.args.r = tt.args.r.WithContext(context.WithValue(context.Background(), oauth2.PrincipalKey, tt.principal))
|
||||||
|
h := &Service{
|
||||||
|
UsersStore: tt.fields.UsersStore,
|
||||||
|
Logger: tt.fields.Logger,
|
||||||
|
UseAuth: tt.fields.UseAuth,
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Me(tt.args.w, tt.args.r)
|
||||||
|
|
||||||
|
resp := tt.args.w.Result()
|
||||||
|
content := resp.Header.Get("Content-Type")
|
||||||
|
body, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != tt.wantStatus {
|
||||||
|
t.Errorf("%q. Me() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if tt.wantContentType != "" && content != tt.wantContentType {
|
||||||
|
t.Errorf("%q. Me() = %v, want %v", tt.name, content, tt.wantContentType)
|
||||||
|
}
|
||||||
|
if tt.wantBody != "" && string(body) != tt.wantBody {
|
||||||
|
t.Errorf("%q. Me() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/influxdata/chronograf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Permissions returns all possible permissions for this source.
|
||||||
|
func (h *Service) Permissions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
srcID, err := paramID("id", r)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := h.SourcesStore.Get(ctx, srcID)
|
||||||
|
if err != nil {
|
||||||
|
notFound(w, srcID, h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ts, err := h.TimeSeries(src)
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
|
||||||
|
Error(w, http.StatusBadRequest, msg, h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = ts.Connect(ctx, &src); err != nil {
|
||||||
|
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
|
||||||
|
Error(w, http.StatusBadRequest, msg, h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perms := ts.Permissions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpAPISrcs := "/chronograf/v1/sources"
|
||||||
|
res := struct {
|
||||||
|
Permissions chronograf.Permissions `json:"permissions"`
|
||||||
|
Links map[string]string `json:"links"` // Links are URI locations related to user
|
||||||
|
}{
|
||||||
|
Permissions: perms,
|
||||||
|
Links: map[string]string{
|
||||||
|
"self": fmt.Sprintf("%s/%d/permissions", httpAPISrcs, srcID),
|
||||||
|
"source": fmt.Sprintf("%s/%d", httpAPISrcs, srcID),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
encodeJSON(w, http.StatusOK, res, h.Logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPermissions(perms *chronograf.Permissions) error {
|
||||||
|
if perms == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, perm := range *perms {
|
||||||
|
if perm.Scope != chronograf.AllScope && perm.Scope != chronograf.DBScope {
|
||||||
|
return fmt.Errorf("Invalid permission scope")
|
||||||
|
}
|
||||||
|
if perm.Scope == chronograf.DBScope && perm.Name == "" {
|
||||||
|
return fmt.Errorf("Database scoped permission requires a name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,112 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/bouk/httprouter"
|
||||||
|
"github.com/influxdata/chronograf"
|
||||||
|
"github.com/influxdata/chronograf/log"
|
||||||
|
"github.com/influxdata/chronograf/mocks"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestService_Permissions(t *testing.T) {
|
||||||
|
type fields struct {
|
||||||
|
SourcesStore chronograf.SourcesStore
|
||||||
|
TimeSeries TimeSeriesClient
|
||||||
|
Logger chronograf.Logger
|
||||||
|
UseAuth bool
|
||||||
|
}
|
||||||
|
type args struct {
|
||||||
|
w *httptest.ResponseRecorder
|
||||||
|
r *http.Request
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
args args
|
||||||
|
ID string
|
||||||
|
wantStatus int
|
||||||
|
wantContentType string
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "New user for data source",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest(
|
||||||
|
"POST",
|
||||||
|
"http://server.local/chronograf/v1/sources/1",
|
||||||
|
ioutil.NopCloser(
|
||||||
|
bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
UseAuth: true,
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
SourcesStore: &mocks.SourcesStore{
|
||||||
|
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
|
||||||
|
return chronograf.Source{
|
||||||
|
ID: 1,
|
||||||
|
Name: "muh source",
|
||||||
|
Username: "name",
|
||||||
|
Password: "hunter2",
|
||||||
|
URL: "http://localhost:8086",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TimeSeries: &mocks.TimeSeries{
|
||||||
|
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
PermissionsF: func(ctx context.Context) chronograf.Permissions {
|
||||||
|
return chronograf.Permissions{
|
||||||
|
{
|
||||||
|
Scope: chronograf.AllScope,
|
||||||
|
Allowed: chronograf.Allowances{"READ", "WRITE"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ID: "1",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"permissions":[{"scope":"all","allowed":["READ","WRITE"]}],"links":{"self":"/chronograf/v1/sources/1/permissions","source":"/chronograf/v1/sources/1"}}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
|
||||||
|
context.Background(),
|
||||||
|
httprouter.Params{
|
||||||
|
{
|
||||||
|
Key: "id",
|
||||||
|
Value: tt.ID,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
h := &Service{
|
||||||
|
SourcesStore: tt.fields.SourcesStore,
|
||||||
|
TimeSeriesClient: tt.fields.TimeSeries,
|
||||||
|
Logger: tt.fields.Logger,
|
||||||
|
UseAuth: tt.fields.UseAuth,
|
||||||
|
}
|
||||||
|
h.Permissions(tt.args.w, tt.args.r)
|
||||||
|
resp := tt.args.w.Result()
|
||||||
|
content := resp.Header.Get("Content-Type")
|
||||||
|
body, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != tt.wantStatus {
|
||||||
|
t.Errorf("%q. Permissions() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if tt.wantContentType != "" && content != tt.wantContentType {
|
||||||
|
t.Errorf("%q. Permissions() = %v, want %v", tt.name, content, tt.wantContentType)
|
||||||
|
}
|
||||||
|
if tt.wantBody != "" && string(body) != tt.wantBody {
|
||||||
|
t.Errorf("%q. Permissions() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,224 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/bouk/httprouter"
|
||||||
|
"github.com/influxdata/chronograf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRole adds role to source
|
||||||
|
func (h *Service) NewRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req sourceRoleRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
invalidJSON(w, h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.ValidCreate(); err != nil {
|
||||||
|
invalidData(w, err, h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, ok := h.hasRoles(ctx, ts)
|
||||||
|
if !ok {
|
||||||
|
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := roles.Get(ctx, req.Name); err == nil {
|
||||||
|
Error(w, http.StatusBadRequest, fmt.Sprintf("Source %d already has role %s", srcID, req.Name), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := roles.Add(ctx, &req.Role)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := newRoleResponse(srcID, res)
|
||||||
|
w.Header().Add("Location", rr.Links.Self)
|
||||||
|
encodeJSON(w, http.StatusCreated, rr, h.Logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRole changes the permissions or users of a role
|
||||||
|
func (h *Service) UpdateRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req sourceRoleRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
invalidJSON(w, h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := req.ValidUpdate(); err != nil {
|
||||||
|
invalidData(w, err, h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, ok := h.hasRoles(ctx, ts)
|
||||||
|
if !ok {
|
||||||
|
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rid := httprouter.GetParamFromContext(ctx, "rid")
|
||||||
|
req.Name = rid
|
||||||
|
|
||||||
|
if err := roles.Update(ctx, &req.Role); err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role, err := roles.Get(ctx, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rr := newRoleResponse(srcID, role)
|
||||||
|
w.Header().Add("Location", rr.Links.Self)
|
||||||
|
encodeJSON(w, http.StatusOK, rr, h.Logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleID retrieves a role with ID from store.
|
||||||
|
func (h *Service) RoleID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, ok := h.hasRoles(ctx, ts)
|
||||||
|
if !ok {
|
||||||
|
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rid := httprouter.GetParamFromContext(ctx, "rid")
|
||||||
|
role, err := roles.Get(ctx, rid)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rr := newRoleResponse(srcID, role)
|
||||||
|
encodeJSON(w, http.StatusOK, rr, h.Logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles retrieves all roles from the store
|
||||||
|
func (h *Service) Roles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store, ok := h.hasRoles(ctx, ts)
|
||||||
|
if !ok {
|
||||||
|
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := store.All(ctx)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := make([]roleResponse, len(roles))
|
||||||
|
for i, role := range roles {
|
||||||
|
rr[i] = newRoleResponse(srcID, &role)
|
||||||
|
}
|
||||||
|
|
||||||
|
res := struct {
|
||||||
|
Roles []roleResponse `json:"roles"`
|
||||||
|
}{rr}
|
||||||
|
encodeJSON(w, http.StatusOK, res, h.Logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveRole removes role from data source.
|
||||||
|
func (h *Service) RemoveRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, ok := h.hasRoles(ctx, ts)
|
||||||
|
if !ok {
|
||||||
|
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rid := httprouter.GetParamFromContext(ctx, "rid")
|
||||||
|
if err := roles.Delete(ctx, &chronograf.Role{Name: rid}); err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceRoleRequest is the format used for both creating and updating roles
|
||||||
|
type sourceRoleRequest struct {
|
||||||
|
chronograf.Role
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sourceRoleRequest) ValidCreate() error {
|
||||||
|
if r.Name == "" || len(r.Name) > 254 {
|
||||||
|
return fmt.Errorf("Name is required for a role")
|
||||||
|
}
|
||||||
|
for _, user := range r.Users {
|
||||||
|
if user.Name == "" {
|
||||||
|
return fmt.Errorf("Username required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validPermissions(&r.Permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sourceRoleRequest) ValidUpdate() error {
|
||||||
|
if len(r.Name) > 254 {
|
||||||
|
return fmt.Errorf("Username too long; must be less than 254 characters")
|
||||||
|
}
|
||||||
|
for _, user := range r.Users {
|
||||||
|
if user.Name == "" {
|
||||||
|
return fmt.Errorf("Username required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validPermissions(&r.Permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
type roleResponse struct {
|
||||||
|
Users []*userResponse `json:"users"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Permissions chronograf.Permissions `json:"permissions"`
|
||||||
|
Links selfLinks `json:"links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRoleResponse(srcID int, res *chronograf.Role) roleResponse {
|
||||||
|
su := make([]*userResponse, len(res.Users))
|
||||||
|
for i := range res.Users {
|
||||||
|
name := res.Users[i].Name
|
||||||
|
su[i] = newUserResponse(srcID, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Permissions == nil {
|
||||||
|
res.Permissions = make(chronograf.Permissions, 0)
|
||||||
|
}
|
||||||
|
return roleResponse{
|
||||||
|
Name: res.Name,
|
||||||
|
Permissions: res.Permissions,
|
||||||
|
Users: su,
|
||||||
|
Links: newSelfLinks(srcID, "roles", res.Name),
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,697 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/bouk/httprouter"
|
||||||
|
"github.com/influxdata/chronograf"
|
||||||
|
"github.com/influxdata/chronograf/log"
|
||||||
|
"github.com/influxdata/chronograf/mocks"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestService_NewSourceRole(t *testing.T) {
|
||||||
|
type fields struct {
|
||||||
|
SourcesStore chronograf.SourcesStore
|
||||||
|
TimeSeries TimeSeriesClient
|
||||||
|
Logger chronograf.Logger
|
||||||
|
}
|
||||||
|
type args struct {
|
||||||
|
w *httptest.ResponseRecorder
|
||||||
|
r *http.Request
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
args args
|
||||||
|
ID string
|
||||||
|
wantStatus int
|
||||||
|
wantContentType string
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Bad JSON",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest(
|
||||||
|
"POST",
|
||||||
|
"http://server.local/chronograf/v1/sources/1/roles",
|
||||||
|
ioutil.NopCloser(
|
||||||
|
bytes.NewReader([]byte(`{BAD}`)))),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"code":400,"message":"Unparsable JSON"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest(
|
||||||
|
"POST",
|
||||||
|
"http://server.local/chronograf/v1/sources/1/roles",
|
||||||
|
ioutil.NopCloser(
|
||||||
|
bytes.NewReader([]byte(`{"name": ""}`)))),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
},
|
||||||
|
ID: "1",
|
||||||
|
wantStatus: http.StatusUnprocessableEntity,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"code":422,"message":"Name is required for a role"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid source ID",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest(
|
||||||
|
"POST",
|
||||||
|
"http://server.local/chronograf/v1/sources/1/roles",
|
||||||
|
ioutil.NopCloser(
|
||||||
|
bytes.NewReader([]byte(`{"name": "newrole"}`)))),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
},
|
||||||
|
ID: "BADROLE",
|
||||||
|
wantStatus: http.StatusUnprocessableEntity,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"code":422,"message":"Error converting ID BADROLE"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Source doesn't support roles",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest(
|
||||||
|
"POST",
|
||||||
|
"http://server.local/chronograf/v1/sources/1/roles",
|
||||||
|
ioutil.NopCloser(
|
||||||
|
bytes.NewReader([]byte(`{"name": "role"}`)))),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
SourcesStore: &mocks.SourcesStore{
|
||||||
|
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
|
||||||
|
return chronograf.Source{
|
||||||
|
ID: 1,
|
||||||
|
Name: "muh source",
|
||||||
|
Username: "name",
|
||||||
|
Password: "hunter2",
|
||||||
|
URL: "http://localhost:8086",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TimeSeries: &mocks.TimeSeries{
|
||||||
|
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
|
||||||
|
return nil, fmt.Errorf("roles not supported")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ID: "1",
|
||||||
|
wantStatus: http.StatusNotFound,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"code":404,"message":"Source 1 does not have role capability"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unable to add role to server",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest(
|
||||||
|
"POST",
|
||||||
|
"http://server.local/chronograf/v1/sources/1/roles",
|
||||||
|
ioutil.NopCloser(
|
||||||
|
bytes.NewReader([]byte(`{"name": "role"}`)))),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
SourcesStore: &mocks.SourcesStore{
|
||||||
|
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
|
||||||
|
return chronograf.Source{
|
||||||
|
ID: 1,
|
||||||
|
Name: "muh source",
|
||||||
|
Username: "name",
|
||||||
|
Password: "hunter2",
|
||||||
|
URL: "http://localhost:8086",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TimeSeries: &mocks.TimeSeries{
|
||||||
|
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
|
||||||
|
return &mocks.RolesStore{
|
||||||
|
AddF: func(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) {
|
||||||
|
return nil, fmt.Errorf("server had and issue")
|
||||||
|
},
|
||||||
|
GetF: func(ctx context.Context, name string) (*chronograf.Role, error) {
|
||||||
|
return nil, fmt.Errorf("No such role")
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ID: "1",
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"code":400,"message":"server had and issue"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "New role for data source",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest(
|
||||||
|
"POST",
|
||||||
|
"http://server.local/chronograf/v1/sources/1/roles",
|
||||||
|
ioutil.NopCloser(
|
||||||
|
bytes.NewReader([]byte(`{"name": "biffsgang","users": [{"name": "match"},{"name": "skinhead"},{"name": "3-d"}]}`)))),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
SourcesStore: &mocks.SourcesStore{
|
||||||
|
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
|
||||||
|
return chronograf.Source{
|
||||||
|
ID: 1,
|
||||||
|
Name: "muh source",
|
||||||
|
Username: "name",
|
||||||
|
Password: "hunter2",
|
||||||
|
URL: "http://localhost:8086",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TimeSeries: &mocks.TimeSeries{
|
||||||
|
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
|
||||||
|
return &mocks.RolesStore{
|
||||||
|
AddF: func(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) {
|
||||||
|
return u, nil
|
||||||
|
},
|
||||||
|
GetF: func(ctx context.Context, name string) (*chronograf.Role, error) {
|
||||||
|
return nil, fmt.Errorf("no such role")
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ID: "1",
|
||||||
|
wantStatus: http.StatusCreated,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
h := &Service{
|
||||||
|
SourcesStore: tt.fields.SourcesStore,
|
||||||
|
TimeSeriesClient: tt.fields.TimeSeries,
|
||||||
|
Logger: tt.fields.Logger,
|
||||||
|
}
|
||||||
|
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
|
||||||
|
context.Background(),
|
||||||
|
httprouter.Params{
|
||||||
|
{
|
||||||
|
Key: "id",
|
||||||
|
Value: tt.ID,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
h.NewRole(tt.args.w, tt.args.r)
|
||||||
|
|
||||||
|
resp := tt.args.w.Result()
|
||||||
|
content := resp.Header.Get("Content-Type")
|
||||||
|
body, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != tt.wantStatus {
|
||||||
|
t.Errorf("%q. NewRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if tt.wantContentType != "" && content != tt.wantContentType {
|
||||||
|
t.Errorf("%q. NewRole() = %v, want %v", tt.name, content, tt.wantContentType)
|
||||||
|
}
|
||||||
|
if tt.wantBody != "" && string(body) != tt.wantBody {
|
||||||
|
t.Errorf("%q. NewRole() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_UpdateRole(t *testing.T) {
|
||||||
|
type fields struct {
|
||||||
|
SourcesStore chronograf.SourcesStore
|
||||||
|
TimeSeries TimeSeriesClient
|
||||||
|
Logger chronograf.Logger
|
||||||
|
}
|
||||||
|
type args struct {
|
||||||
|
w *httptest.ResponseRecorder
|
||||||
|
r *http.Request
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
args args
|
||||||
|
ID string
|
||||||
|
RoleID string
|
||||||
|
wantStatus int
|
||||||
|
wantContentType string
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Update role for data source",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest(
|
||||||
|
"POST",
|
||||||
|
"http://server.local/chronograf/v1/sources/1/roles",
|
||||||
|
ioutil.NopCloser(
|
||||||
|
bytes.NewReader([]byte(`{"name": "biffsgang","users": [{"name": "match"},{"name": "skinhead"},{"name": "3-d"}]}`)))),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
SourcesStore: &mocks.SourcesStore{
|
||||||
|
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
|
||||||
|
return chronograf.Source{
|
||||||
|
ID: 1,
|
||||||
|
Name: "muh source",
|
||||||
|
Username: "name",
|
||||||
|
Password: "hunter2",
|
||||||
|
URL: "http://localhost:8086",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TimeSeries: &mocks.TimeSeries{
|
||||||
|
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
|
||||||
|
return &mocks.RolesStore{
|
||||||
|
UpdateF: func(ctx context.Context, u *chronograf.Role) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
GetF: func(ctx context.Context, name string) (*chronograf.Role, error) {
|
||||||
|
return &chronograf.Role{
|
||||||
|
Name: "biffsgang",
|
||||||
|
Users: []chronograf.User{
|
||||||
|
{
|
||||||
|
Name: "match",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "skinhead",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "3-d",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ID: "1",
|
||||||
|
RoleID: "biffsgang",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
h := &Service{
|
||||||
|
SourcesStore: tt.fields.SourcesStore,
|
||||||
|
TimeSeriesClient: tt.fields.TimeSeries,
|
||||||
|
Logger: tt.fields.Logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
|
||||||
|
context.Background(),
|
||||||
|
httprouter.Params{
|
||||||
|
{
|
||||||
|
Key: "id",
|
||||||
|
Value: tt.ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "rid",
|
||||||
|
Value: tt.RoleID,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
h.UpdateRole(tt.args.w, tt.args.r)
|
||||||
|
|
||||||
|
resp := tt.args.w.Result()
|
||||||
|
content := resp.Header.Get("Content-Type")
|
||||||
|
body, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != tt.wantStatus {
|
||||||
|
t.Errorf("%q. NewRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if tt.wantContentType != "" && content != tt.wantContentType {
|
||||||
|
t.Errorf("%q. NewRole() = %v, want %v", tt.name, content, tt.wantContentType)
|
||||||
|
}
|
||||||
|
if tt.wantBody != "" && string(body) != tt.wantBody {
|
||||||
|
t.Errorf("%q. NewRole() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_RoleID(t *testing.T) {
|
||||||
|
type fields struct {
|
||||||
|
SourcesStore chronograf.SourcesStore
|
||||||
|
TimeSeries TimeSeriesClient
|
||||||
|
Logger chronograf.Logger
|
||||||
|
}
|
||||||
|
type args struct {
|
||||||
|
w *httptest.ResponseRecorder
|
||||||
|
r *http.Request
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
args args
|
||||||
|
ID string
|
||||||
|
RoleID string
|
||||||
|
wantStatus int
|
||||||
|
wantContentType string
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Get role for data source",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest(
|
||||||
|
"GET",
|
||||||
|
"http://server.local/chronograf/v1/sources/1/roles/biffsgang",
|
||||||
|
nil),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
SourcesStore: &mocks.SourcesStore{
|
||||||
|
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
|
||||||
|
return chronograf.Source{
|
||||||
|
ID: 1,
|
||||||
|
Name: "muh source",
|
||||||
|
Username: "name",
|
||||||
|
Password: "hunter2",
|
||||||
|
URL: "http://localhost:8086",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TimeSeries: &mocks.TimeSeries{
|
||||||
|
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
|
||||||
|
return &mocks.RolesStore{
|
||||||
|
GetF: func(ctx context.Context, name string) (*chronograf.Role, error) {
|
||||||
|
return &chronograf.Role{
|
||||||
|
Name: "biffsgang",
|
||||||
|
Permissions: chronograf.Permissions{
|
||||||
|
{
|
||||||
|
Name: "grays_sports_almanac",
|
||||||
|
Scope: "DBScope",
|
||||||
|
Allowed: chronograf.Allowances{
|
||||||
|
"ReadData",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Users: []chronograf.User{
|
||||||
|
{
|
||||||
|
Name: "match",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "skinhead",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "3-d",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ID: "1",
|
||||||
|
RoleID: "biffsgang",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[{"scope":"DBScope","name":"grays_sports_almanac","allowed":["ReadData"]}],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
h := &Service{
|
||||||
|
SourcesStore: tt.fields.SourcesStore,
|
||||||
|
TimeSeriesClient: tt.fields.TimeSeries,
|
||||||
|
Logger: tt.fields.Logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
|
||||||
|
context.Background(),
|
||||||
|
httprouter.Params{
|
||||||
|
{
|
||||||
|
Key: "id",
|
||||||
|
Value: tt.ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "rid",
|
||||||
|
Value: tt.RoleID,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
h.RoleID(tt.args.w, tt.args.r)
|
||||||
|
|
||||||
|
resp := tt.args.w.Result()
|
||||||
|
content := resp.Header.Get("Content-Type")
|
||||||
|
body, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != tt.wantStatus {
|
||||||
|
t.Errorf("%q. RoleID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if tt.wantContentType != "" && content != tt.wantContentType {
|
||||||
|
t.Errorf("%q. RoleID() = %v, want %v", tt.name, content, tt.wantContentType)
|
||||||
|
}
|
||||||
|
if tt.wantBody != "" && string(body) != tt.wantBody {
|
||||||
|
t.Errorf("%q. RoleID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_RemoveRole(t *testing.T) {
|
||||||
|
type fields struct {
|
||||||
|
SourcesStore chronograf.SourcesStore
|
||||||
|
TimeSeries TimeSeriesClient
|
||||||
|
Logger chronograf.Logger
|
||||||
|
}
|
||||||
|
type args struct {
|
||||||
|
w *httptest.ResponseRecorder
|
||||||
|
r *http.Request
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
args args
|
||||||
|
ID string
|
||||||
|
RoleID string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "remove role for data source",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest(
|
||||||
|
"GET",
|
||||||
|
"http://server.local/chronograf/v1/sources/1/roles/biffsgang",
|
||||||
|
nil),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
SourcesStore: &mocks.SourcesStore{
|
||||||
|
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
|
||||||
|
return chronograf.Source{
|
||||||
|
ID: 1,
|
||||||
|
Name: "muh source",
|
||||||
|
Username: "name",
|
||||||
|
Password: "hunter2",
|
||||||
|
URL: "http://localhost:8086",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TimeSeries: &mocks.TimeSeries{
|
||||||
|
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
|
||||||
|
return &mocks.RolesStore{
|
||||||
|
DeleteF: func(context.Context, *chronograf.Role) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ID: "1",
|
||||||
|
RoleID: "biffsgang",
|
||||||
|
wantStatus: http.StatusNoContent,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
h := &Service{
|
||||||
|
SourcesStore: tt.fields.SourcesStore,
|
||||||
|
TimeSeriesClient: tt.fields.TimeSeries,
|
||||||
|
Logger: tt.fields.Logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
|
||||||
|
context.Background(),
|
||||||
|
httprouter.Params{
|
||||||
|
{
|
||||||
|
Key: "id",
|
||||||
|
Value: tt.ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "rid",
|
||||||
|
Value: tt.RoleID,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
h.RemoveRole(tt.args.w, tt.args.r)
|
||||||
|
|
||||||
|
resp := tt.args.w.Result()
|
||||||
|
if resp.StatusCode != tt.wantStatus {
|
||||||
|
t.Errorf("%q. RemoveRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_Roles(t *testing.T) {
|
||||||
|
type fields struct {
|
||||||
|
SourcesStore chronograf.SourcesStore
|
||||||
|
TimeSeries TimeSeriesClient
|
||||||
|
Logger chronograf.Logger
|
||||||
|
}
|
||||||
|
type args struct {
|
||||||
|
w *httptest.ResponseRecorder
|
||||||
|
r *http.Request
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
args args
|
||||||
|
ID string
|
||||||
|
RoleID string
|
||||||
|
wantStatus int
|
||||||
|
wantContentType string
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Get roles for data source",
|
||||||
|
args: args{
|
||||||
|
w: httptest.NewRecorder(),
|
||||||
|
r: httptest.NewRequest(
|
||||||
|
"GET",
|
||||||
|
"http://server.local/chronograf/v1/sources/1/roles",
|
||||||
|
nil),
|
||||||
|
},
|
||||||
|
fields: fields{
|
||||||
|
Logger: log.New(log.DebugLevel),
|
||||||
|
SourcesStore: &mocks.SourcesStore{
|
||||||
|
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
|
||||||
|
return chronograf.Source{
|
||||||
|
ID: 1,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TimeSeries: &mocks.TimeSeries{
|
||||||
|
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
|
||||||
|
return &mocks.RolesStore{
|
||||||
|
AllF: func(ctx context.Context) ([]chronograf.Role, error) {
|
||||||
|
return []chronograf.Role{
|
||||||
|
chronograf.Role{
|
||||||
|
Name: "biffsgang",
|
||||||
|
Permissions: chronograf.Permissions{
|
||||||
|
{
|
||||||
|
Name: "grays_sports_almanac",
|
||||||
|
Scope: "DBScope",
|
||||||
|
Allowed: chronograf.Allowances{
|
||||||
|
"ReadData",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Users: []chronograf.User{
|
||||||
|
{
|
||||||
|
Name: "match",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "skinhead",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "3-d",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ID: "1",
|
||||||
|
RoleID: "biffsgang",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: `{"roles":[{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[{"scope":"DBScope","name":"grays_sports_almanac","allowed":["ReadData"]}],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}]}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
h := &Service{
|
||||||
|
SourcesStore: tt.fields.SourcesStore,
|
||||||
|
TimeSeriesClient: tt.fields.TimeSeries,
|
||||||
|
Logger: tt.fields.Logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
|
||||||
|
context.Background(),
|
||||||
|
httprouter.Params{
|
||||||
|
{
|
||||||
|
Key: "id",
|
||||||
|
Value: tt.ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "rid",
|
||||||
|
Value: tt.RoleID,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
h.Roles(tt.args.w, tt.args.r)
|
||||||
|
|
||||||
|
resp := tt.args.w.Result()
|
||||||
|
content := resp.Header.Get("Content-Type")
|
||||||
|
body, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != tt.wantStatus {
|
||||||
|
t.Errorf("%q. RoleID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if tt.wantContentType != "" && content != tt.wantContentType {
|
||||||
|
t.Errorf("%q. RoleID() = %v, want %v", tt.name, content, tt.wantContentType)
|
||||||
|
}
|
||||||
|
if tt.wantBody != "" && string(body) != tt.wantBody {
|
||||||
|
t.Errorf("%q. RoleID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
370
server/users.go
370
server/users.go
|
@ -1,99 +1,317 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"golang.org/x/net/context"
|
"github.com/bouk/httprouter"
|
||||||
|
|
||||||
"github.com/influxdata/chronograf"
|
"github.com/influxdata/chronograf"
|
||||||
"github.com/influxdata/chronograf/oauth2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type userLinks struct {
|
// NewSourceUser adds user to source
|
||||||
Self string `json:"self"` // Self link mapping to this resource
|
func (h *Service) NewSourceUser(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
var req userRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
type userResponse struct {
|
invalidJSON(w, h.Logger)
|
||||||
*chronograf.User
|
|
||||||
Links userLinks `json:"links"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// If new user response is nil, return an empty userResponse because it
|
|
||||||
// indicates authentication is not needed
|
|
||||||
func newUserResponse(usr *chronograf.User) userResponse {
|
|
||||||
base := "/chronograf/v1/users"
|
|
||||||
name := "me"
|
|
||||||
if usr != nil {
|
|
||||||
// TODO: Change to usrl.PathEscape for go 1.8
|
|
||||||
u := &url.URL{Path: usr.Name}
|
|
||||||
name = u.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
return userResponse{
|
|
||||||
User: usr,
|
|
||||||
Links: userLinks{
|
|
||||||
Self: fmt.Sprintf("%s/%s", base, name),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEmail(ctx context.Context) (string, error) {
|
|
||||||
principal, err := getPrincipal(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if principal.Subject == "" {
|
|
||||||
return "", fmt.Errorf("Token not found")
|
|
||||||
}
|
|
||||||
return principal.Subject, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPrincipal(ctx context.Context) (oauth2.Principal, error) {
|
|
||||||
principal, ok := ctx.Value(oauth2.PrincipalKey).(oauth2.Principal)
|
|
||||||
if !ok {
|
|
||||||
return oauth2.Principal{}, fmt.Errorf("Token not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return principal, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Me does a findOrCreate based on the email in the context
|
|
||||||
func (h *Service) Me(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
if !h.UseAuth {
|
|
||||||
// If there's no authentication, return an empty user
|
|
||||||
res := newUserResponse(nil)
|
|
||||||
encodeJSON(w, http.StatusOK, res, h.Logger)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email, err := getEmail(ctx)
|
if err := req.ValidCreate(); err != nil {
|
||||||
if err != nil {
|
|
||||||
invalidData(w, err, h.Logger)
|
invalidData(w, err, h.Logger)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
usr, err := h.UsersStore.Get(ctx, email)
|
ctx := r.Context()
|
||||||
if err == nil {
|
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
||||||
res := newUserResponse(usr)
|
|
||||||
encodeJSON(w, http.StatusOK, res, h.Logger)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Because we didnt find a user, making a new one
|
|
||||||
user := &chronograf.User{
|
|
||||||
Name: email,
|
|
||||||
}
|
|
||||||
|
|
||||||
newUser, err := h.UsersStore.Add(ctx, user)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := fmt.Errorf("error storing user %s: %v", user.Name, err)
|
|
||||||
unknownErrorWithMessage(w, msg, h.Logger)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res := newUserResponse(newUser)
|
store := ts.Users(ctx)
|
||||||
|
user := &chronograf.User{
|
||||||
|
Name: req.Username,
|
||||||
|
Passwd: req.Password,
|
||||||
|
Permissions: req.Permissions,
|
||||||
|
Roles: req.Roles,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := store.Add(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
su := newUserResponse(srcID, res.Name).WithPermissions(res.Permissions)
|
||||||
|
if _, hasRoles := h.hasRoles(ctx, ts); hasRoles {
|
||||||
|
su.WithRoles(srcID, res.Roles)
|
||||||
|
}
|
||||||
|
w.Header().Add("Location", su.Links.Self)
|
||||||
|
encodeJSON(w, http.StatusCreated, su, h.Logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceUsers retrieves all users from source.
|
||||||
|
func (h *Service) SourceUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store := ts.Users(ctx)
|
||||||
|
users, err := store.All(ctx)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, hasRoles := h.hasRoles(ctx, ts)
|
||||||
|
ur := make([]userResponse, len(users))
|
||||||
|
for i, u := range users {
|
||||||
|
usr := newUserResponse(srcID, u.Name).WithPermissions(u.Permissions)
|
||||||
|
if hasRoles {
|
||||||
|
usr.WithRoles(srcID, u.Roles)
|
||||||
|
}
|
||||||
|
ur[i] = *usr
|
||||||
|
}
|
||||||
|
|
||||||
|
res := usersResponse{
|
||||||
|
Users: ur,
|
||||||
|
}
|
||||||
|
|
||||||
encodeJSON(w, http.StatusOK, res, h.Logger)
|
encodeJSON(w, http.StatusOK, res, h.Logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SourceUserID retrieves a user with ID from store.
|
||||||
|
func (h *Service) SourceUserID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
uid := httprouter.GetParamFromContext(ctx, "uid")
|
||||||
|
|
||||||
|
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store := ts.Users(ctx)
|
||||||
|
u, err := store.Get(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res := newUserResponse(srcID, u.Name).WithPermissions(u.Permissions)
|
||||||
|
if _, hasRoles := h.hasRoles(ctx, ts); hasRoles {
|
||||||
|
res.WithRoles(srcID, u.Roles)
|
||||||
|
}
|
||||||
|
encodeJSON(w, http.StatusOK, res, h.Logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSourceUser removes the user from the InfluxDB source
|
||||||
|
func (h *Service) RemoveSourceUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
uid := httprouter.GetParamFromContext(ctx, "uid")
|
||||||
|
|
||||||
|
_, store, err := h.sourceUsersStore(ctx, w, r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Delete(ctx, &chronograf.User{Name: uid}); err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSourceUser changes the password or permissions of a source user
|
||||||
|
func (h *Service) UpdateSourceUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req userRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
invalidJSON(w, h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := req.ValidUpdate(); err != nil {
|
||||||
|
invalidData(w, err, h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
uid := httprouter.GetParamFromContext(ctx, "uid")
|
||||||
|
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &chronograf.User{
|
||||||
|
Name: uid,
|
||||||
|
Passwd: req.Password,
|
||||||
|
Permissions: req.Permissions,
|
||||||
|
Roles: req.Roles,
|
||||||
|
}
|
||||||
|
store := ts.Users(ctx)
|
||||||
|
|
||||||
|
if err := store.Update(ctx, user); err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := store.Get(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res := newUserResponse(srcID, u.Name).WithPermissions(u.Permissions)
|
||||||
|
if _, hasRoles := h.hasRoles(ctx, ts); hasRoles {
|
||||||
|
res.WithRoles(srcID, u.Roles)
|
||||||
|
}
|
||||||
|
w.Header().Add("Location", res.Links.Self)
|
||||||
|
encodeJSON(w, http.StatusOK, res, h.Logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Service) sourcesSeries(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.TimeSeries, error) {
|
||||||
|
srcID, err := paramID("id", r)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := h.SourcesStore.Get(ctx, srcID)
|
||||||
|
if err != nil {
|
||||||
|
notFound(w, srcID, h.Logger)
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ts, err := h.TimeSeries(src)
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
|
||||||
|
Error(w, http.StatusBadRequest, msg, h.Logger)
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = ts.Connect(ctx, &src); err != nil {
|
||||||
|
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
|
||||||
|
Error(w, http.StatusBadRequest, msg, h.Logger)
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
return srcID, ts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Service) sourceUsersStore(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.UsersStore, error) {
|
||||||
|
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
store := ts.Users(ctx)
|
||||||
|
return srcID, store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasRoles checks if the influx source has roles or not
|
||||||
|
func (h *Service) hasRoles(ctx context.Context, ts chronograf.TimeSeries) (chronograf.RolesStore, bool) {
|
||||||
|
store, err := ts.Roles(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return store, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type userRequest struct {
|
||||||
|
Username string `json:"name,omitempty"` // Username for new account
|
||||||
|
Password string `json:"password,omitempty"` // Password for new account
|
||||||
|
Permissions chronograf.Permissions `json:"permissions,omitempty"` // Optional permissions
|
||||||
|
Roles []chronograf.Role `json:"roles,omitempty"` // Optional roles
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRequest) ValidCreate() error {
|
||||||
|
if r.Username == "" {
|
||||||
|
return fmt.Errorf("Username required")
|
||||||
|
}
|
||||||
|
if r.Password == "" {
|
||||||
|
return fmt.Errorf("Password required")
|
||||||
|
}
|
||||||
|
return validPermissions(&r.Permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
type usersResponse struct {
|
||||||
|
Users []userResponse `json:"users"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRequest) ValidUpdate() error {
|
||||||
|
if r.Password == "" && len(r.Permissions) == 0 && len(r.Roles) == 0 {
|
||||||
|
return fmt.Errorf("No fields to update")
|
||||||
|
}
|
||||||
|
return validPermissions(&r.Permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
type userResponse struct {
|
||||||
|
Name string // Username for new account
|
||||||
|
Permissions chronograf.Permissions // Account's permissions
|
||||||
|
Roles []roleResponse // Roles if source uses them
|
||||||
|
Links selfLinks // Links are URI locations related to user
|
||||||
|
hasPermissions bool
|
||||||
|
hasRoles bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *userResponse) MarshalJSON() ([]byte, error) {
|
||||||
|
res := map[string]interface{}{
|
||||||
|
"name": u.Name,
|
||||||
|
"links": u.Links,
|
||||||
|
}
|
||||||
|
if u.hasRoles {
|
||||||
|
res["roles"] = u.Roles
|
||||||
|
}
|
||||||
|
if u.hasPermissions {
|
||||||
|
res["permissions"] = u.Permissions
|
||||||
|
}
|
||||||
|
return json.Marshal(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newUserResponse creates an HTTP JSON response for a user w/o roles
|
||||||
|
func newUserResponse(srcID int, name string) *userResponse {
|
||||||
|
self := newSelfLinks(srcID, "users", name)
|
||||||
|
return &userResponse{
|
||||||
|
Name: name,
|
||||||
|
Links: self,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *userResponse) WithPermissions(perms chronograf.Permissions) *userResponse {
|
||||||
|
u.hasPermissions = true
|
||||||
|
if perms == nil {
|
||||||
|
perms = make(chronograf.Permissions, 0)
|
||||||
|
}
|
||||||
|
u.Permissions = perms
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRoles adds roles to the HTTP JSON response for a user
|
||||||
|
func (u *userResponse) WithRoles(srcID int, roles []chronograf.Role) *userResponse {
|
||||||
|
u.hasRoles = true
|
||||||
|
rr := make([]roleResponse, len(roles))
|
||||||
|
for i, role := range roles {
|
||||||
|
rr[i] = newRoleResponse(srcID, &role)
|
||||||
|
}
|
||||||
|
u.Roles = rr
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
type selfLinks struct {
|
||||||
|
Self string `json:"self"` // Self link mapping to this resource
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSelfLinks(id int, parent, resource string) selfLinks {
|
||||||
|
httpAPISrcs := "/chronograf/v1/sources"
|
||||||
|
u := &url.URL{Path: resource}
|
||||||
|
encodedResource := u.String()
|
||||||
|
return selfLinks{
|
||||||
|
Self: fmt.Sprintf("%s/%d/%s/%s", httpAPISrcs, id, parent, encodedResource),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -46,7 +46,7 @@
|
||||||
'arrow-parens': 0,
|
'arrow-parens': 0,
|
||||||
'comma-dangle': [2, 'always-multiline'],
|
'comma-dangle': [2, 'always-multiline'],
|
||||||
'no-cond-assign': 2,
|
'no-cond-assign': 2,
|
||||||
'no-console': 2,
|
'no-console': ['error', {allow: ['error']}],
|
||||||
'no-constant-condition': 2,
|
'no-constant-condition': 2,
|
||||||
'no-control-regex': 2,
|
'no-control-regex': 2,
|
||||||
'no-debugger': 2,
|
'no-debugger': 2,
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
const express = require('express');
|
|
||||||
const request = require('request');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
app.use('/', (req, res) => {
|
|
||||||
console.log(`${req.method} ${req.url}`);
|
|
||||||
|
|
||||||
const headers = {};
|
|
||||||
headers['Access-Control-Allow-Origin'] = '*';
|
|
||||||
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, DELETE, OPTIONS';
|
|
||||||
headers['Access-Control-Allow-Credentials'] = false;
|
|
||||||
headers['Access-Control-Max-Age'] = '86400'; // 24 hours
|
|
||||||
headers['Access-Control-Allow-Headers'] = 'X-Requested-With, X-HTTP-Method-Override, Content-Type, Accept';
|
|
||||||
res.writeHead(200, headers);
|
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const url = 'http://localhost:8888' + req.url;
|
|
||||||
req.pipe(request(url)).pipe(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(3888, () => {
|
|
||||||
console.log('corsless proxy server now running')
|
|
||||||
});
|
|
|
@ -17,9 +17,7 @@
|
||||||
"test:lint": "npm run lint; npm run test",
|
"test:lint": "npm run lint; npm run test",
|
||||||
"test:dev": "nodemon --exec npm run test:lint",
|
"test:dev": "nodemon --exec npm run test:lint",
|
||||||
"clean": "rm -rf build",
|
"clean": "rm -rf build",
|
||||||
"storybook": "start-storybook -p 6006",
|
"storybook": "node ./storybook"
|
||||||
"build-storybook": "build-storybook",
|
|
||||||
"proxy": "node ./corsless"
|
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
|
|
@ -0,0 +1,292 @@
|
||||||
|
import reducer from 'src/admin/reducers/admin'
|
||||||
|
|
||||||
|
import {
|
||||||
|
addUser,
|
||||||
|
addRole,
|
||||||
|
syncUser,
|
||||||
|
syncRole,
|
||||||
|
editUser,
|
||||||
|
editRole,
|
||||||
|
loadRoles,
|
||||||
|
loadPermissions,
|
||||||
|
deleteRole,
|
||||||
|
deleteUser,
|
||||||
|
filterRoles,
|
||||||
|
filterUsers,
|
||||||
|
} from 'src/admin/actions'
|
||||||
|
|
||||||
|
let state = undefined
|
||||||
|
|
||||||
|
// Users
|
||||||
|
const u1 = {
|
||||||
|
name: 'acidburn',
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
name: 'hax0r',
|
||||||
|
permissions: {
|
||||||
|
allowed: [
|
||||||
|
'ViewAdmin',
|
||||||
|
'ViewChronograf',
|
||||||
|
'CreateDatabase',
|
||||||
|
'CreateUserAndRole',
|
||||||
|
'AddRemoveNode',
|
||||||
|
'DropDatabase',
|
||||||
|
'DropData',
|
||||||
|
'ReadData',
|
||||||
|
'WriteData',
|
||||||
|
'Rebalance',
|
||||||
|
'ManageShard',
|
||||||
|
'ManageContinuousQuery',
|
||||||
|
'ManageQuery',
|
||||||
|
'ManageSubscription',
|
||||||
|
'Monitor',
|
||||||
|
'CopyShard',
|
||||||
|
'KapacitorAPI',
|
||||||
|
'KapacitorConfigAPI'
|
||||||
|
],
|
||||||
|
scope: 'all',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
permissions: [],
|
||||||
|
links: {self: '/chronograf/v1/sources/1/users/acidburn'},
|
||||||
|
}
|
||||||
|
const u2 = {
|
||||||
|
name: 'zerocool',
|
||||||
|
roles: [],
|
||||||
|
permissions: [],
|
||||||
|
links: {self: '/chronograf/v1/sources/1/users/zerocool'},
|
||||||
|
}
|
||||||
|
const users = [u1, u2]
|
||||||
|
const newDefaultUser = {
|
||||||
|
name: '',
|
||||||
|
password: '',
|
||||||
|
roles: [],
|
||||||
|
permissions: [],
|
||||||
|
links: {self: ''},
|
||||||
|
isNew: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles
|
||||||
|
const r1 = {
|
||||||
|
name: 'hax0r',
|
||||||
|
users: [],
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
allowed: [
|
||||||
|
'ViewAdmin',
|
||||||
|
'ViewChronograf',
|
||||||
|
'CreateDatabase',
|
||||||
|
'CreateUserAndRole',
|
||||||
|
'AddRemoveNode',
|
||||||
|
'DropDatabase',
|
||||||
|
'DropData',
|
||||||
|
'ReadData',
|
||||||
|
'WriteData',
|
||||||
|
'Rebalance',
|
||||||
|
'ManageShard',
|
||||||
|
'ManageContinuousQuery',
|
||||||
|
'ManageQuery',
|
||||||
|
'ManageSubscription',
|
||||||
|
'Monitor',
|
||||||
|
'CopyShard',
|
||||||
|
'KapacitorAPI',
|
||||||
|
'KapacitorConfigAPI'
|
||||||
|
],
|
||||||
|
scope: 'all',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: {self: '/chronograf/v1/sources/1/roles/hax0r'}
|
||||||
|
}
|
||||||
|
const r2 = {
|
||||||
|
name: 'l33tus3r',
|
||||||
|
links: {self: '/chronograf/v1/sources/1/roles/l33tus3r'}
|
||||||
|
}
|
||||||
|
const roles = [r1, r2]
|
||||||
|
const newDefaultRole = {
|
||||||
|
name: '',
|
||||||
|
users: [],
|
||||||
|
permissions: [],
|
||||||
|
links: {self: ''},
|
||||||
|
isNew: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
const global = {scope: 'all', allowed: ['p1', 'p2']}
|
||||||
|
const scoped = {scope: 'db1', allowed: ['p1', 'p3']}
|
||||||
|
const permissions = [global, scoped]
|
||||||
|
|
||||||
|
describe('Admin.Reducers', () => {
|
||||||
|
it('it can add a user', () => {
|
||||||
|
state = {
|
||||||
|
users: [
|
||||||
|
u1,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const actual = reducer(state, addUser())
|
||||||
|
const expected = {
|
||||||
|
users: [
|
||||||
|
{...newDefaultUser, isEditing: true},
|
||||||
|
u1,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actual.users).to.deep.equal(expected.users)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('it can sync a stale user', () => {
|
||||||
|
const staleUser = {...u1, roles: []}
|
||||||
|
state = {users: [u2, staleUser]}
|
||||||
|
|
||||||
|
const actual = reducer(state, syncUser(staleUser, u1))
|
||||||
|
const expected = {
|
||||||
|
users: [u2, u1],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actual.users).to.deep.equal(expected.users)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('it can edit a user', () => {
|
||||||
|
const updates = {name: 'onecool'}
|
||||||
|
state = {
|
||||||
|
users: [u2, u1],
|
||||||
|
}
|
||||||
|
|
||||||
|
const actual = reducer(state, editUser(u2, updates))
|
||||||
|
const expected = {
|
||||||
|
users: [{...u2, ...updates}, u1]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actual.users).to.deep.equal(expected.users)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('it can add a role', () => {
|
||||||
|
state = {
|
||||||
|
roles: [
|
||||||
|
r1,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const actual = reducer(state, addRole())
|
||||||
|
const expected = {
|
||||||
|
roles: [
|
||||||
|
{...newDefaultRole, isEditing: true},
|
||||||
|
r1,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actual.roles).to.deep.equal(expected.roles)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('it can sync a stale role', () => {
|
||||||
|
const staleRole = {...r1, permissions: []}
|
||||||
|
state = {roles: [r2, staleRole]}
|
||||||
|
|
||||||
|
const actual = reducer(state, syncRole(staleRole, r1))
|
||||||
|
const expected = {
|
||||||
|
roles: [r2, r1],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actual.roles).to.deep.equal(expected.roles)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('it can edit a role', () => {
|
||||||
|
const updates = {name: 'onecool'}
|
||||||
|
state = {
|
||||||
|
roles: [r2, r1],
|
||||||
|
}
|
||||||
|
|
||||||
|
const actual = reducer(state, editRole(r2, updates))
|
||||||
|
const expected = {
|
||||||
|
roles: [{...r2, ...updates}, r1]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actual.roles).to.deep.equal(expected.roles)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('it can load the roles', () => {
|
||||||
|
const actual = reducer(state, loadRoles({roles}))
|
||||||
|
const expected = {
|
||||||
|
roles,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actual.roles).to.deep.equal(expected.roles)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('it can delete a role', () => {
|
||||||
|
state = {
|
||||||
|
roles: [
|
||||||
|
r1,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const actual = reducer(state, deleteRole(r1))
|
||||||
|
const expected = {
|
||||||
|
roles: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actual.roles).to.deep.equal(expected.roles)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('it can delete a user', () => {
|
||||||
|
state = {
|
||||||
|
users: [
|
||||||
|
u1,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const actual = reducer(state, deleteUser(u1))
|
||||||
|
const expected = {
|
||||||
|
users: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actual.users).to.deep.equal(expected.users)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can filter roles w/ "x" text', () => {
|
||||||
|
state = {
|
||||||
|
roles,
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = 'x'
|
||||||
|
|
||||||
|
const actual = reducer(state, filterRoles(text))
|
||||||
|
const expected = {
|
||||||
|
roles: [
|
||||||
|
{...r1, hidden: false},
|
||||||
|
{...r2, hidden: true},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actual.roles).to.deep.equal(expected.roles)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can filter users w/ "zero" text', () => {
|
||||||
|
state = {
|
||||||
|
users,
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = 'zero'
|
||||||
|
|
||||||
|
const actual = reducer(state, filterUsers(text))
|
||||||
|
const expected = {
|
||||||
|
users: [
|
||||||
|
{...u1, hidden: true},
|
||||||
|
{...u2, hidden: false},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actual.users).to.deep.equal(expected.users)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
it('it can load the permissions', () => {
|
||||||
|
const actual = reducer(state, loadPermissions({permissions}))
|
||||||
|
const expected = {
|
||||||
|
permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actual.permissions).to.deep.equal(expected.permissions)
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,230 @@
|
||||||
|
import {
|
||||||
|
getUsers as getUsersAJAX,
|
||||||
|
getRoles as getRolesAJAX,
|
||||||
|
getPermissions as getPermissionsAJAX,
|
||||||
|
createUser as createUserAJAX,
|
||||||
|
createRole as createRoleAJAX,
|
||||||
|
deleteUser as deleteUserAJAX,
|
||||||
|
deleteRole as deleteRoleAJAX,
|
||||||
|
updateRole as updateRoleAJAX,
|
||||||
|
updateUser as updateUserAJAX,
|
||||||
|
} from 'src/admin/apis'
|
||||||
|
|
||||||
|
import {killQuery as killQueryProxy} from 'shared/apis/metaQuery'
|
||||||
|
import {publishNotification} from 'src/shared/actions/notifications';
|
||||||
|
import {ADMIN_NOTIFICATION_DELAY} from 'src/admin/constants'
|
||||||
|
|
||||||
|
export const loadUsers = ({users}) => ({
|
||||||
|
type: 'LOAD_USERS',
|
||||||
|
payload: {
|
||||||
|
users,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadRoles = ({roles}) => ({
|
||||||
|
type: 'LOAD_ROLES',
|
||||||
|
payload: {
|
||||||
|
roles,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadPermissions = ({permissions}) => ({
|
||||||
|
type: 'LOAD_PERMISSIONS',
|
||||||
|
payload: {
|
||||||
|
permissions,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const addUser = () => ({
|
||||||
|
type: 'ADD_USER',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const addRole = () => ({
|
||||||
|
type: 'ADD_ROLE',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const syncUser = (staleUser, syncedUser) => ({
|
||||||
|
type: 'SYNC_USER',
|
||||||
|
payload: {
|
||||||
|
staleUser,
|
||||||
|
syncedUser,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const syncRole = (staleRole, syncedRole) => ({
|
||||||
|
type: 'SYNC_ROLE',
|
||||||
|
payload: {
|
||||||
|
staleRole,
|
||||||
|
syncedRole,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const editUser = (user, updates) => ({
|
||||||
|
type: 'EDIT_USER',
|
||||||
|
payload: {
|
||||||
|
user,
|
||||||
|
updates,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const editRole = (role, updates) => ({
|
||||||
|
type: 'EDIT_ROLE',
|
||||||
|
payload: {
|
||||||
|
role,
|
||||||
|
updates,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const killQuery = (queryID) => ({
|
||||||
|
type: 'KILL_QUERY',
|
||||||
|
payload: {
|
||||||
|
queryID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const setQueryToKill = (queryIDToKill) => ({
|
||||||
|
type: 'SET_QUERY_TO_KILL',
|
||||||
|
payload: {
|
||||||
|
queryIDToKill,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadQueries = (queries) => ({
|
||||||
|
type: 'LOAD_QUERIES',
|
||||||
|
payload: {
|
||||||
|
queries,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteUser = (user) => ({
|
||||||
|
type: 'DELETE_USER',
|
||||||
|
payload: {
|
||||||
|
user,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteRole = (role) => ({
|
||||||
|
type: 'DELETE_ROLE',
|
||||||
|
payload: {
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const filterUsers = (text) => ({
|
||||||
|
type: 'FILTER_USERS',
|
||||||
|
payload: {
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const filterRoles = (text) => ({
|
||||||
|
type: 'FILTER_ROLES',
|
||||||
|
payload: {
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// async actions
|
||||||
|
export const loadUsersAsync = (url) => async (dispatch) => {
|
||||||
|
const {data} = await getUsersAJAX(url)
|
||||||
|
dispatch(loadUsers(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadRolesAsync = (url) => async (dispatch) => {
|
||||||
|
const {data} = await getRolesAJAX(url)
|
||||||
|
dispatch(loadRoles(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadPermissionsAsync = (url) => async (dispatch) => {
|
||||||
|
const {data} = await getPermissionsAJAX(url)
|
||||||
|
dispatch(loadPermissions(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUserAsync = (url, user) => async (dispatch) => {
|
||||||
|
try {
|
||||||
|
const {data} = await createUserAJAX(url, user)
|
||||||
|
dispatch(publishNotification('success', 'User created successfully'))
|
||||||
|
dispatch(syncUser(user, data))
|
||||||
|
} catch (error) {
|
||||||
|
// undo optimistic update
|
||||||
|
dispatch(publishNotification('error', `Failed to create user: ${error.data.message}`))
|
||||||
|
setTimeout(() => dispatch(deleteUser(user)), ADMIN_NOTIFICATION_DELAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createRoleAsync = (url, role) => async (dispatch) => {
|
||||||
|
try {
|
||||||
|
const {data} = await createRoleAJAX(url, role)
|
||||||
|
dispatch(publishNotification('success', 'Role created successfully'))
|
||||||
|
dispatch(syncRole(role, data))
|
||||||
|
} catch (error) {
|
||||||
|
// undo optimistic update
|
||||||
|
dispatch(publishNotification('error', `Failed to create role: ${error.data.message}`))
|
||||||
|
setTimeout(() => dispatch(deleteRole(role)), ADMIN_NOTIFICATION_DELAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const killQueryAsync = (source, queryID) => (dispatch) => {
|
||||||
|
// optimistic update
|
||||||
|
dispatch(killQuery(queryID))
|
||||||
|
dispatch(setQueryToKill(null))
|
||||||
|
|
||||||
|
// kill query on server
|
||||||
|
killQueryProxy(source, queryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteRoleAsync = (role, addFlashMessage) => (dispatch) => {
|
||||||
|
// optimistic update
|
||||||
|
dispatch(deleteRole(role))
|
||||||
|
|
||||||
|
// delete role on server
|
||||||
|
deleteRoleAJAX(role.links.self, addFlashMessage, role.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteUserAsync = (user, addFlashMessage) => (dispatch) => {
|
||||||
|
// optimistic update
|
||||||
|
dispatch(deleteUser(user))
|
||||||
|
|
||||||
|
// delete user on server
|
||||||
|
deleteUserAJAX(user.links.self, addFlashMessage, user.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateRoleUsersAsync = (role, users) => async (dispatch) => {
|
||||||
|
try {
|
||||||
|
const {data} = await updateRoleAJAX(role.links.self, users, role.permissions)
|
||||||
|
dispatch(publishNotification('success', 'Role users updated'))
|
||||||
|
dispatch(syncRole(role, data))
|
||||||
|
} catch (error) {
|
||||||
|
dispatch(publishNotification('error', `Failed to update role: ${error.data.message}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateRolePermissionsAsync = (role, permissions) => async (dispatch) => {
|
||||||
|
try {
|
||||||
|
const {data} = await updateRoleAJAX(role.links.self, role.users, permissions)
|
||||||
|
dispatch(publishNotification('success', 'Role permissions updated'))
|
||||||
|
dispatch(syncRole(role, data))
|
||||||
|
} catch (error) {
|
||||||
|
dispatch(publishNotification('error', `Failed to updated role: ${error.data.message}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateUserPermissionsAsync = (user, permissions) => async (dispatch) => {
|
||||||
|
try {
|
||||||
|
const {data} = await updateUserAJAX(user.links.self, user.roles, permissions)
|
||||||
|
dispatch(publishNotification('success', 'User permissions updated'))
|
||||||
|
dispatch(syncUser(user, data))
|
||||||
|
} catch (error) {
|
||||||
|
dispatch(publishNotification('error', `Failed to updated user: ${error.data.message}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateUserRolesAsync = (user, roles) => async (dispatch) => {
|
||||||
|
try {
|
||||||
|
const {data} = await updateUserAJAX(user.links.self, roles, user.permissions)
|
||||||
|
dispatch(publishNotification('success', 'User roles updated'))
|
||||||
|
dispatch(syncUser(user, data))
|
||||||
|
} catch (error) {
|
||||||
|
dispatch(publishNotification('error', `Failed to updated user: ${error.data.message}`))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,133 @@
|
||||||
|
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 getRoles = async (url) => {
|
||||||
|
try {
|
||||||
|
return await AJAX({
|
||||||
|
method: 'GET',
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPermissions = 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) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createRole = async (url, role) => {
|
||||||
|
try {
|
||||||
|
return await AJAX({
|
||||||
|
method: 'POST',
|
||||||
|
url,
|
||||||
|
data: role,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteRole = async (url, addFlashMessage, rolename) => {
|
||||||
|
try {
|
||||||
|
const response = await AJAX({
|
||||||
|
method: 'DELETE',
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
addFlashMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: `${rolename} successfully deleted.`,
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
addFlashMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: `Error deleting: ${rolename}.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteUser = async (url, addFlashMessage, username) => {
|
||||||
|
try {
|
||||||
|
const response = await AJAX({
|
||||||
|
method: 'DELETE',
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
addFlashMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: `${username} successfully deleted.`,
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
addFlashMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: `Error deleting: ${username}.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateRole = async (url, users, permissions) => {
|
||||||
|
try {
|
||||||
|
return await AJAX({
|
||||||
|
method: 'PATCH',
|
||||||
|
url,
|
||||||
|
data: {
|
||||||
|
users,
|
||||||
|
permissions,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateUser = async (url, roles, permissions) => {
|
||||||
|
try {
|
||||||
|
return await AJAX({
|
||||||
|
method: 'PATCH',
|
||||||
|
url,
|
||||||
|
data: {
|
||||||
|
roles,
|
||||||
|
permissions,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,135 @@
|
||||||
|
import React, {PropTypes} from 'react'
|
||||||
|
import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'src/shared/components/Tabs';
|
||||||
|
import UsersTable from 'src/admin/components/UsersTable'
|
||||||
|
import RolesTable from 'src/admin/components/RolesTable'
|
||||||
|
import QueriesPage from 'src/admin/containers/QueriesPage'
|
||||||
|
|
||||||
|
const AdminTabs = ({
|
||||||
|
users,
|
||||||
|
roles,
|
||||||
|
permissions,
|
||||||
|
source,
|
||||||
|
hasRoles,
|
||||||
|
isEditingUsers,
|
||||||
|
isEditingRoles,
|
||||||
|
onClickCreate,
|
||||||
|
onEditUser,
|
||||||
|
onSaveUser,
|
||||||
|
onCancelEditUser,
|
||||||
|
onEditRole,
|
||||||
|
onSaveRole,
|
||||||
|
onCancelEditRole,
|
||||||
|
onDeleteRole,
|
||||||
|
onDeleteUser,
|
||||||
|
onFilterRoles,
|
||||||
|
onFilterUsers,
|
||||||
|
onUpdateRoleUsers,
|
||||||
|
onUpdateRolePermissions,
|
||||||
|
onUpdateUserRoles,
|
||||||
|
onUpdateUserPermissions,
|
||||||
|
}) => {
|
||||||
|
let tabs = [
|
||||||
|
{
|
||||||
|
type: 'Users',
|
||||||
|
component: (
|
||||||
|
<UsersTable
|
||||||
|
users={users}
|
||||||
|
allRoles={roles}
|
||||||
|
hasRoles={hasRoles}
|
||||||
|
permissions={permissions}
|
||||||
|
isEditing={isEditingUsers}
|
||||||
|
onSave={onSaveUser}
|
||||||
|
onCancel={onCancelEditUser}
|
||||||
|
onClickCreate={onClickCreate}
|
||||||
|
onEdit={onEditUser}
|
||||||
|
onDelete={onDeleteUser}
|
||||||
|
onFilter={onFilterUsers}
|
||||||
|
onUpdatePermissions={onUpdateUserPermissions}
|
||||||
|
onUpdateRoles={onUpdateUserRoles}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Roles',
|
||||||
|
component: (
|
||||||
|
<RolesTable
|
||||||
|
roles={roles}
|
||||||
|
allUsers={users}
|
||||||
|
permissions={permissions}
|
||||||
|
isEditing={isEditingRoles}
|
||||||
|
onClickCreate={onClickCreate}
|
||||||
|
onEdit={onEditRole}
|
||||||
|
onSave={onSaveRole}
|
||||||
|
onCancel={onCancelEditRole}
|
||||||
|
onDelete={onDeleteRole}
|
||||||
|
onFilter={onFilterRoles}
|
||||||
|
onUpdateRoleUsers={onUpdateRoleUsers}
|
||||||
|
onUpdateRolePermissions={onUpdateRolePermissions}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Queries',
|
||||||
|
component: (<QueriesPage source={source} />),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!hasRoles) {
|
||||||
|
tabs = tabs.filter(t => t.type !== 'Roles')
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{
|
||||||
|
tabs.map((t, i) => (<TabPanel key={tabs[i].type}>{t.component}</TabPanel>))
|
||||||
|
}
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
arrayOf,
|
||||||
|
bool,
|
||||||
|
func,
|
||||||
|
shape,
|
||||||
|
string,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
AdminTabs.propTypes = {
|
||||||
|
users: arrayOf(shape({
|
||||||
|
name: string.isRequired,
|
||||||
|
roles: arrayOf(shape({
|
||||||
|
name: string,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
roles: arrayOf(shape()),
|
||||||
|
source: shape(),
|
||||||
|
permissions: arrayOf(string),
|
||||||
|
isEditingUsers: bool,
|
||||||
|
isEditingRoles: bool,
|
||||||
|
onClickCreate: func.isRequired,
|
||||||
|
onEditUser: func.isRequired,
|
||||||
|
onSaveUser: func.isRequired,
|
||||||
|
onCancelEditUser: func.isRequired,
|
||||||
|
onEditRole: func.isRequired,
|
||||||
|
onSaveRole: func.isRequired,
|
||||||
|
onCancelEditRole: func.isRequired,
|
||||||
|
onDeleteRole: func.isRequired,
|
||||||
|
onDeleteUser: func.isRequired,
|
||||||
|
onFilterRoles: func.isRequired,
|
||||||
|
onFilterUsers: func.isRequired,
|
||||||
|
onUpdateRoleUsers: func.isRequired,
|
||||||
|
onUpdateRolePermissions: func.isRequired,
|
||||||
|
hasRoles: bool.isRequired,
|
||||||
|
onUpdateUserPermissions: func,
|
||||||
|
onUpdateUserRoles: func,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminTabs
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React, {PropTypes} from 'react'
|
||||||
|
|
||||||
|
const ConfirmButtons = ({onConfirm, item, onCancel}) => (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="btn btn-xs btn-info"
|
||||||
|
onClick={() => onCancel(item)}
|
||||||
|
>
|
||||||
|
<span className="icon remove"></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-xs btn-success"
|
||||||
|
onClick={() => onConfirm(item)}
|
||||||
|
>
|
||||||
|
<span className="icon checkmark"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
func,
|
||||||
|
shape,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
ConfirmButtons.propTypes = {
|
||||||
|
onConfirm: func.isRequired,
|
||||||
|
item: shape({}).isRequired,
|
||||||
|
onCancel: func.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfirmButtons
|
|
@ -0,0 +1,71 @@
|
||||||
|
import React, {PropTypes, Component} from 'react'
|
||||||
|
|
||||||
|
import OnClickOutside from 'shared/components/OnClickOutside'
|
||||||
|
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
|
||||||
|
|
||||||
|
const DeleteButton = ({onConfirm}) => (
|
||||||
|
<button
|
||||||
|
className="btn btn-xs btn-danger admin-table--delete"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
class DeleteRow extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = {
|
||||||
|
isConfirmed: false,
|
||||||
|
}
|
||||||
|
this.handleConfirm = ::this.handleConfirm
|
||||||
|
this.handleCancel = ::this.handleCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
handleConfirm() {
|
||||||
|
this.setState({isConfirmed: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancel() {
|
||||||
|
this.setState({isConfirmed: false})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClickOutside() {
|
||||||
|
this.setState({isConfirmed: false})
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {onDelete, item} = this.props
|
||||||
|
const {isConfirmed} = this.state
|
||||||
|
|
||||||
|
if (isConfirmed) {
|
||||||
|
return (
|
||||||
|
<ConfirmButtons
|
||||||
|
onConfirm={onDelete}
|
||||||
|
item={item}
|
||||||
|
onCancel={this.handleCancel}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DeleteButton onConfirm={this.handleConfirm} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
func,
|
||||||
|
shape,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
DeleteButton.propTypes = {
|
||||||
|
onConfirm: func.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
DeleteRow.propTypes = {
|
||||||
|
item: shape({}),
|
||||||
|
onDelete: func.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OnClickOutside(DeleteRow)
|
|
@ -0,0 +1,19 @@
|
||||||
|
import React, {PropTypes} from 'react'
|
||||||
|
|
||||||
|
const EmptyRow = ({tableName}) => (
|
||||||
|
<tr className="table-empty-state">
|
||||||
|
<th colSpan="5">
|
||||||
|
<p>You don't have any {tableName},<br/>why not create one?</p>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
string,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
EmptyRow.propTypes = {
|
||||||
|
tableName: string.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmptyRow
|
|
@ -0,0 +1,59 @@
|
||||||
|
import React, {Component, PropTypes} from 'react'
|
||||||
|
|
||||||
|
class FilterBar extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = {
|
||||||
|
filterText: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleText = ::this.handleText
|
||||||
|
}
|
||||||
|
|
||||||
|
handleText(e) {
|
||||||
|
this.setState(
|
||||||
|
{filterText: e.target.value},
|
||||||
|
this.props.onFilter(e.target.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.props.onFilter('')
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {type, isEditing, onClickCreate} = this.props
|
||||||
|
return (
|
||||||
|
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
|
||||||
|
<div className="users__search-widget input-group admin__search-widget">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder={`Filter ${type}...`}
|
||||||
|
value={this.state.filterText}
|
||||||
|
onChange={this.handleText}
|
||||||
|
/>
|
||||||
|
<div className="input-group-addon">
|
||||||
|
<span className="icon search" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" disabled={isEditing} onClick={() => onClickCreate(type)}>Create {type.substring(0, type.length - 1)}</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
bool,
|
||||||
|
func,
|
||||||
|
string,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
FilterBar.propTypes = {
|
||||||
|
onFilter: func.isRequired,
|
||||||
|
type: string,
|
||||||
|
isEditing: bool,
|
||||||
|
onClickCreate: func,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilterBar
|
|
@ -0,0 +1,67 @@
|
||||||
|
import React, {PropTypes} from 'react'
|
||||||
|
|
||||||
|
const QueriesTable = ({queries, onKillQuery, onConfirm}) => (
|
||||||
|
<div>
|
||||||
|
<div className="panel panel-minimal">
|
||||||
|
<div className="panel-body">
|
||||||
|
<table className="table v-center">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Database</th>
|
||||||
|
<th>Query</th>
|
||||||
|
<th>Running</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{queries.map((q) => {
|
||||||
|
return (
|
||||||
|
<tr key={q.id}>
|
||||||
|
<td>{q.database}</td>
|
||||||
|
<td><code>{q.query}</code></td>
|
||||||
|
<td>{q.duration}</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<button className="btn btn-xs btn-link-danger" onClick={onKillQuery} data-toggle="modal" data-query-id={q.id} data-target="#killModal">
|
||||||
|
Kill
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal fade" id="killModal" tabIndex="-1" role="dialog" aria-labelledby="myModalLabel">
|
||||||
|
<div className="modal-dialog" role="document">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<button type="button" className="close" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
<h4 className="modal-title" id="myModalLabel">Are you sure you want to kill this query?</h4>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-default" data-dismiss="modal">No</button>
|
||||||
|
<button type="button" className="btn btn-danger" data-dismiss="modal" onClick={onConfirm}>Yes, kill it!</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
arrayOf,
|
||||||
|
func,
|
||||||
|
shape,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
QueriesTable.propTypes = {
|
||||||
|
queries: arrayOf(shape()),
|
||||||
|
onConfirm: func,
|
||||||
|
onKillQuery: func,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QueriesTable
|
|
@ -0,0 +1,59 @@
|
||||||
|
import React, {Component, PropTypes} from 'react'
|
||||||
|
|
||||||
|
class RoleEditingRow extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
|
||||||
|
this.handleKeyPress = ::this.handleKeyPress
|
||||||
|
this.handleEdit = ::this.handleEdit
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyPress(role) {
|
||||||
|
return (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
this.props.onSave(role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEdit(role) {
|
||||||
|
return (e) => {
|
||||||
|
this.props.onEdit(role, {[e.target.name]: e.target.value})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {role} = this.props
|
||||||
|
return (
|
||||||
|
<td>
|
||||||
|
<div className="admin-table--edit-cell">
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={role.name || ''}
|
||||||
|
placeholder="role name"
|
||||||
|
onChange={this.handleEdit(role)}
|
||||||
|
onKeyPress={this.handleKeyPress(role)}
|
||||||
|
autoFocus={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
bool,
|
||||||
|
func,
|
||||||
|
shape,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
RoleEditingRow.propTypes = {
|
||||||
|
role: shape().isRequired,
|
||||||
|
isNew: bool,
|
||||||
|
onEdit: func.isRequired,
|
||||||
|
onSave: func.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RoleEditingRow
|
|
@ -0,0 +1,109 @@
|
||||||
|
import React, {PropTypes} from 'react'
|
||||||
|
|
||||||
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
import RoleEditingRow from 'src/admin/components/RoleEditingRow'
|
||||||
|
import MultiSelectDropdown from 'shared/components/MultiSelectDropdown'
|
||||||
|
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
|
||||||
|
import DeleteRow from 'src/admin/components/DeleteRow'
|
||||||
|
|
||||||
|
const RoleRow = ({
|
||||||
|
role: {name, permissions, users},
|
||||||
|
role,
|
||||||
|
allUsers,
|
||||||
|
allPermissions,
|
||||||
|
isNew,
|
||||||
|
isEditing,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
onDelete,
|
||||||
|
onUpdateRoleUsers,
|
||||||
|
onUpdateRolePermissions,
|
||||||
|
}) => {
|
||||||
|
const handleUpdateUsers = (u) => {
|
||||||
|
onUpdateRoleUsers(role, u.map((n) => ({name: n})))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdatePermissions = (allowed) => {
|
||||||
|
onUpdateRolePermissions(role, [{scope: 'all', allowed}])
|
||||||
|
}
|
||||||
|
|
||||||
|
const perms = _.get(permissions, ['0', 'allowed'], [])
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<tr className="admin-table--edit-row">
|
||||||
|
<RoleEditingRow role={role} onEdit={onEdit} onSave={onSave} isNew={isNew} />
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td className="text-right" style={{width: "85px"}}>
|
||||||
|
<ConfirmButtons item={role} onConfirm={onSave} onCancel={onCancel} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>{name}</td>
|
||||||
|
<td>
|
||||||
|
{
|
||||||
|
allPermissions && allPermissions.length ?
|
||||||
|
<MultiSelectDropdown
|
||||||
|
items={allPermissions}
|
||||||
|
selectedItems={perms}
|
||||||
|
label={perms.length ? '' : 'Select Permissions'}
|
||||||
|
onApply={handleUpdatePermissions}
|
||||||
|
/> : null
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{
|
||||||
|
allUsers && allUsers.length ?
|
||||||
|
<MultiSelectDropdown
|
||||||
|
items={allUsers.map((u) => u.name)}
|
||||||
|
selectedItems={users === undefined ? [] : users.map((u) => u.name)}
|
||||||
|
label={users && users.length ? '' : 'Select Users'}
|
||||||
|
onApply={handleUpdateUsers}
|
||||||
|
/> : null
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td className="text-right" style={{width: "85px"}}>
|
||||||
|
<DeleteRow onDelete={onDelete} item={role} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
arrayOf,
|
||||||
|
bool,
|
||||||
|
func,
|
||||||
|
shape,
|
||||||
|
string,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
RoleRow.propTypes = {
|
||||||
|
role: shape({
|
||||||
|
name: string,
|
||||||
|
permissions: arrayOf(shape({
|
||||||
|
name: string,
|
||||||
|
})),
|
||||||
|
users: arrayOf(shape({
|
||||||
|
name: string,
|
||||||
|
})),
|
||||||
|
}).isRequired,
|
||||||
|
isNew: bool,
|
||||||
|
isEditing: bool,
|
||||||
|
onCancel: func,
|
||||||
|
onEdit: func,
|
||||||
|
onSave: func,
|
||||||
|
onDelete: func.isRequired,
|
||||||
|
allUsers: arrayOf(shape()),
|
||||||
|
allPermissions: arrayOf(string),
|
||||||
|
onUpdateRoleUsers: func.isRequired,
|
||||||
|
onUpdateRolePermissions: func.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RoleRow
|
|
@ -0,0 +1,90 @@
|
||||||
|
import React, {PropTypes} from 'react'
|
||||||
|
import RoleRow from 'src/admin/components/RoleRow'
|
||||||
|
import EmptyRow from 'src/admin/components/EmptyRow'
|
||||||
|
import FilterBar from 'src/admin/components/FilterBar'
|
||||||
|
|
||||||
|
const RolesTable = ({
|
||||||
|
roles,
|
||||||
|
allUsers,
|
||||||
|
permissions,
|
||||||
|
isEditing,
|
||||||
|
onClickCreate,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
onDelete,
|
||||||
|
onFilter,
|
||||||
|
onUpdateRoleUsers,
|
||||||
|
onUpdateRolePermissions,
|
||||||
|
}) => (
|
||||||
|
<div className="panel panel-info">
|
||||||
|
<FilterBar type="roles" onFilter={onFilter} isEditing={isEditing} onClickCreate={onClickCreate} />
|
||||||
|
<div className="panel-body">
|
||||||
|
<table className="table v-center admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Permissions</th>
|
||||||
|
<th>Users</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{
|
||||||
|
roles.length ?
|
||||||
|
roles.filter(r => !r.hidden).map((role) =>
|
||||||
|
<RoleRow
|
||||||
|
key={role.links.self}
|
||||||
|
allUsers={allUsers}
|
||||||
|
allPermissions={permissions}
|
||||||
|
role={role}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onSave={onSave}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onUpdateRoleUsers={onUpdateRoleUsers}
|
||||||
|
onUpdateRolePermissions={onUpdateRolePermissions}
|
||||||
|
isEditing={role.isEditing}
|
||||||
|
isNew={role.isNew}
|
||||||
|
/>
|
||||||
|
) : <EmptyRow tableName={'Roles'} />
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
arrayOf,
|
||||||
|
bool,
|
||||||
|
func,
|
||||||
|
shape,
|
||||||
|
string,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
RolesTable.propTypes = {
|
||||||
|
roles: arrayOf(shape({
|
||||||
|
name: string.isRequired,
|
||||||
|
permissions: arrayOf(shape({
|
||||||
|
name: string,
|
||||||
|
scope: string.isRequired,
|
||||||
|
})),
|
||||||
|
users: arrayOf(shape({
|
||||||
|
name: string,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
isEditing: bool,
|
||||||
|
onClickCreate: func.isRequired,
|
||||||
|
onEdit: func.isRequired,
|
||||||
|
onSave: func.isRequired,
|
||||||
|
onCancel: func.isRequired,
|
||||||
|
onDelete: func.isRequired,
|
||||||
|
onFilter: func,
|
||||||
|
allUsers: arrayOf(shape()),
|
||||||
|
permissions: arrayOf(string),
|
||||||
|
onUpdateRoleUsers: func.isRequired,
|
||||||
|
onUpdateRolePermissions: func.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RolesTable
|
|
@ -0,0 +1,72 @@
|
||||||
|
import React, {Component, PropTypes} from 'react'
|
||||||
|
|
||||||
|
class UserEditingRow extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
|
||||||
|
this.handleKeyPress = ::this.handleKeyPress
|
||||||
|
this.handleEdit = ::this.handleEdit
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyPress(user) {
|
||||||
|
return (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
this.props.onSave(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEdit(user) {
|
||||||
|
return (e) => {
|
||||||
|
this.props.onEdit(user, {[e.target.name]: e.target.value})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {user, isNew} = this.props
|
||||||
|
return (
|
||||||
|
<td>
|
||||||
|
<div className="admin-table--edit-cell">
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={user.name || ''}
|
||||||
|
placeholder="Username"
|
||||||
|
onChange={this.handleEdit(user)}
|
||||||
|
onKeyPress={this.handleKeyPress(user)}
|
||||||
|
autoFocus={true}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
isNew ?
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
name="password"
|
||||||
|
type="text"
|
||||||
|
value={user.password || ''}
|
||||||
|
placeholder="Password"
|
||||||
|
onChange={this.handleEdit(user)}
|
||||||
|
onKeyPress={this.handleKeyPress(user)}
|
||||||
|
/> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
bool,
|
||||||
|
func,
|
||||||
|
shape,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
UserEditingRow.propTypes = {
|
||||||
|
user: shape().isRequired,
|
||||||
|
isNew: bool,
|
||||||
|
onEdit: func.isRequired,
|
||||||
|
onSave: func.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserEditingRow
|
|
@ -0,0 +1,110 @@
|
||||||
|
import React, {PropTypes} from 'react'
|
||||||
|
|
||||||
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
import UserEditingRow from 'src/admin/components/UserEditingRow'
|
||||||
|
import MultiSelectDropdown from 'shared/components/MultiSelectDropdown'
|
||||||
|
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
|
||||||
|
import DeleteRow from 'src/admin/components/DeleteRow'
|
||||||
|
|
||||||
|
const UserRow = ({
|
||||||
|
user: {name, roles, permissions},
|
||||||
|
user,
|
||||||
|
allRoles,
|
||||||
|
allPermissions,
|
||||||
|
hasRoles,
|
||||||
|
isNew,
|
||||||
|
isEditing,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
onDelete,
|
||||||
|
onUpdatePermissions,
|
||||||
|
onUpdateRoles,
|
||||||
|
}) => {
|
||||||
|
const handleUpdatePermissions = (allowed) => {
|
||||||
|
onUpdatePermissions(user, [{scope: 'all', allowed}])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateRoles = (roleNames) => {
|
||||||
|
onUpdateRoles(user, allRoles.filter(r => roleNames.find(rn => rn === r.name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<tr className="admin-table--edit-row">
|
||||||
|
<UserEditingRow user={user} onEdit={onEdit} onSave={onSave} isNew={isNew} />
|
||||||
|
{hasRoles ? <td></td> : null}
|
||||||
|
<td></td>
|
||||||
|
<td className="text-right" style={{width: "85px"}}>
|
||||||
|
<ConfirmButtons item={user} onConfirm={onSave} onCancel={onCancel} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>{name}</td>
|
||||||
|
{
|
||||||
|
hasRoles ?
|
||||||
|
<td>
|
||||||
|
<MultiSelectDropdown
|
||||||
|
items={allRoles.map((r) => r.name)}
|
||||||
|
selectedItems={roles ? roles.map((r) => r.name) : []/* TODO remove check when server returns empty list */}
|
||||||
|
label={roles && roles.length ? '' : 'Select Roles'}
|
||||||
|
onApply={handleUpdateRoles}
|
||||||
|
/>
|
||||||
|
</td> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
<td>
|
||||||
|
{
|
||||||
|
allPermissions && allPermissions.length ?
|
||||||
|
<MultiSelectDropdown
|
||||||
|
items={allPermissions}
|
||||||
|
selectedItems={_.get(permissions, ['0', 'allowed'], [])}
|
||||||
|
label={permissions && permissions.length ? '' : 'Select Permissions'}
|
||||||
|
onApply={handleUpdatePermissions}
|
||||||
|
/> : null
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td className="text-right" style={{width: "85px"}}>
|
||||||
|
<DeleteRow onDelete={onDelete} item={user} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
arrayOf,
|
||||||
|
bool,
|
||||||
|
func,
|
||||||
|
shape,
|
||||||
|
string,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
UserRow.propTypes = {
|
||||||
|
user: shape({
|
||||||
|
name: string,
|
||||||
|
roles: arrayOf(shape({
|
||||||
|
name: string,
|
||||||
|
})),
|
||||||
|
permissions: arrayOf(shape({
|
||||||
|
name: string,
|
||||||
|
})),
|
||||||
|
}).isRequired,
|
||||||
|
allRoles: arrayOf(shape()),
|
||||||
|
allPermissions: arrayOf(string),
|
||||||
|
hasRoles: bool,
|
||||||
|
isNew: bool,
|
||||||
|
isEditing: bool,
|
||||||
|
onCancel: func,
|
||||||
|
onEdit: func,
|
||||||
|
onSave: func,
|
||||||
|
onDelete: func.isRequired,
|
||||||
|
onUpdatePermissions: func,
|
||||||
|
onUpdateRoles: func,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserRow
|
|
@ -0,0 +1,94 @@
|
||||||
|
import React, {PropTypes} from 'react'
|
||||||
|
|
||||||
|
import UserRow from 'src/admin/components/UserRow'
|
||||||
|
import EmptyRow from 'src/admin/components/EmptyRow'
|
||||||
|
import FilterBar from 'src/admin/components/FilterBar'
|
||||||
|
|
||||||
|
const UsersTable = ({
|
||||||
|
users,
|
||||||
|
allRoles,
|
||||||
|
hasRoles,
|
||||||
|
permissions,
|
||||||
|
isEditing,
|
||||||
|
onClickCreate,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
onDelete,
|
||||||
|
onFilter,
|
||||||
|
onUpdatePermissions,
|
||||||
|
onUpdateRoles,
|
||||||
|
}) => (
|
||||||
|
<div className="panel panel-info">
|
||||||
|
<FilterBar type="users" onFilter={onFilter} isEditing={isEditing} onClickCreate={onClickCreate} />
|
||||||
|
<div className="panel-body">
|
||||||
|
<table className="table v-center admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
{hasRoles && <th>Roles</th>}
|
||||||
|
<th>Permissions</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{
|
||||||
|
users.length ?
|
||||||
|
users.filter(u => !u.hidden).map(user =>
|
||||||
|
<UserRow
|
||||||
|
key={user.links.self}
|
||||||
|
user={user}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onSave={onSave}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onDelete={onDelete}
|
||||||
|
isEditing={user.isEditing}
|
||||||
|
isNew={user.isNew}
|
||||||
|
allRoles={allRoles}
|
||||||
|
hasRoles={hasRoles}
|
||||||
|
allPermissions={permissions}
|
||||||
|
onUpdatePermissions={onUpdatePermissions}
|
||||||
|
onUpdateRoles={onUpdateRoles}
|
||||||
|
/>) :
|
||||||
|
<EmptyRow tableName={'Users'} />
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
arrayOf,
|
||||||
|
bool,
|
||||||
|
func,
|
||||||
|
shape,
|
||||||
|
string,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
UsersTable.propTypes = {
|
||||||
|
users: arrayOf(shape({
|
||||||
|
name: string.isRequired,
|
||||||
|
roles: arrayOf(shape({
|
||||||
|
name: string,
|
||||||
|
})),
|
||||||
|
permissions: arrayOf(shape({
|
||||||
|
name: string,
|
||||||
|
scope: string.isRequired,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
isEditing: bool,
|
||||||
|
onClickCreate: func.isRequired,
|
||||||
|
onEdit: func.isRequired,
|
||||||
|
onSave: func.isRequired,
|
||||||
|
onCancel: func.isRequired,
|
||||||
|
onDelete: func.isRequired,
|
||||||
|
onFilter: func,
|
||||||
|
allRoles: arrayOf(shape()),
|
||||||
|
permissions: arrayOf(string),
|
||||||
|
hasRoles: bool.isRequired,
|
||||||
|
onUpdatePermissions: func,
|
||||||
|
onUpdateRoles: func,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsersTable
|
|
@ -0,0 +1,11 @@
|
||||||
|
export const TIMES = [
|
||||||
|
{test: /ns/, magnitude: 0},
|
||||||
|
{test: /µs/, magnitude: 1},
|
||||||
|
{test: /u/, magnitude: 1},
|
||||||
|
{test: /^\d*ms/, magnitude: 2},
|
||||||
|
{test: /^\d*s/, magnitude: 3},
|
||||||
|
{test: /^\d*m\d*s/, magnitude: 4},
|
||||||
|
{test: /^\d*h\d*m\d*s/, magnitude: 5},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ADMIN_NOTIFICATION_DELAY = 1500 // milliseconds
|
|
@ -0,0 +1,263 @@
|
||||||
|
import React, {Component, PropTypes} from 'react'
|
||||||
|
import {connect} from 'react-redux'
|
||||||
|
import {bindActionCreators} from 'redux'
|
||||||
|
import {
|
||||||
|
loadUsersAsync,
|
||||||
|
loadRolesAsync,
|
||||||
|
loadPermissionsAsync,
|
||||||
|
addUser,
|
||||||
|
addRole,
|
||||||
|
deleteUser, // TODO rename to removeUser throughout + tests
|
||||||
|
deleteRole, // TODO rename to removeUser throughout + tests
|
||||||
|
editUser,
|
||||||
|
editRole,
|
||||||
|
createUserAsync,
|
||||||
|
createRoleAsync,
|
||||||
|
deleteUserAsync,
|
||||||
|
deleteRoleAsync,
|
||||||
|
updateRoleUsersAsync,
|
||||||
|
updateRolePermissionsAsync,
|
||||||
|
updateUserPermissionsAsync,
|
||||||
|
updateUserRolesAsync,
|
||||||
|
filterUsers as filterUsersAction,
|
||||||
|
filterRoles as filterRolesAction,
|
||||||
|
} from 'src/admin/actions'
|
||||||
|
|
||||||
|
import AdminTabs from 'src/admin/components/AdminTabs'
|
||||||
|
|
||||||
|
const isValidUser = (user) => {
|
||||||
|
const minLen = 3
|
||||||
|
return (user.name.length >= minLen && user.password.length >= minLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidRole = (role) => {
|
||||||
|
const minLen = 3
|
||||||
|
return (role.name.length >= minLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminPage extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
|
||||||
|
this.handleClickCreate = ::this.handleClickCreate
|
||||||
|
this.handleEditUser = ::this.handleEditUser
|
||||||
|
this.handleEditRole = ::this.handleEditRole
|
||||||
|
this.handleSaveUser = ::this.handleSaveUser
|
||||||
|
this.handleSaveRole = ::this.handleSaveRole
|
||||||
|
this.handleCancelEditUser = ::this.handleCancelEditUser
|
||||||
|
this.handleCancelEditRole = ::this.handleCancelEditRole
|
||||||
|
this.handleDeleteRole = ::this.handleDeleteRole
|
||||||
|
this.handleDeleteUser = ::this.handleDeleteUser
|
||||||
|
this.handleUpdateRoleUsers = ::this.handleUpdateRoleUsers
|
||||||
|
this.handleUpdateRolePermissions = ::this.handleUpdateRolePermissions
|
||||||
|
this.handleUpdateUserPermissions = ::this.handleUpdateUserPermissions
|
||||||
|
this.handleUpdateUserRoles = ::this.handleUpdateUserRoles
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const {source, loadUsers, loadRoles, loadPermissions} = this.props
|
||||||
|
|
||||||
|
loadUsers(source.links.users)
|
||||||
|
loadPermissions(source.links.permissions)
|
||||||
|
if (source.links.roles) {
|
||||||
|
loadRoles(source.links.roles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClickCreate(type) {
|
||||||
|
if (type === 'users') {
|
||||||
|
this.props.addUser()
|
||||||
|
} else if (type === 'roles') {
|
||||||
|
this.props.addRole()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEditUser(user, updates) {
|
||||||
|
this.props.editUser(user, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEditRole(role, updates) {
|
||||||
|
this.props.editRole(role, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSaveUser(user) {
|
||||||
|
if (!isValidUser(user)) {
|
||||||
|
this.props.addFlashMessage({type: 'error', text: 'Username and/or password too short'})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (user.isNew) {
|
||||||
|
this.props.createUser(this.props.source.links.users, user)
|
||||||
|
} else {
|
||||||
|
// TODO update user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSaveRole(role) {
|
||||||
|
if (!isValidRole(role)) {
|
||||||
|
this.props.addFlashMessage({type: 'error', text: 'Role name too short'})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (role.isNew) {
|
||||||
|
this.props.createRole(this.props.source.links.roles, role)
|
||||||
|
} else {
|
||||||
|
// TODO update role
|
||||||
|
// console.log('update')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancelEditUser(user) {
|
||||||
|
this.props.removeUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancelEditRole(role) {
|
||||||
|
this.props.removeRole(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDeleteRole(role) {
|
||||||
|
this.props.deleteRole(role, this.props.addFlashMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDeleteUser(user) {
|
||||||
|
this.props.deleteUser(user, this.props.addFlashMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdateRoleUsers(role, users) {
|
||||||
|
this.props.updateRoleUsers(role, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdateRolePermissions(role, permissions) {
|
||||||
|
this.props.updateRolePermissions(role, permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdateUserPermissions(user, permissions) {
|
||||||
|
this.props.updateUserPermissions(user, permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdateUserRoles(user, roles) {
|
||||||
|
this.props.updateUserRoles(user, roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {users, roles, source, permissions, filterUsers, filterRoles} = this.props
|
||||||
|
const hasRoles = !!source.links.roles
|
||||||
|
const globalPermissions = permissions.find((p) => p.scope === 'all')
|
||||||
|
const allowed = globalPermissions ? globalPermissions.allowed : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<div className="page-header__container">
|
||||||
|
<div className="page-header__left">
|
||||||
|
<h1>
|
||||||
|
Admin
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="page-contents">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row">
|
||||||
|
{
|
||||||
|
users ?
|
||||||
|
<AdminTabs
|
||||||
|
users={users}
|
||||||
|
roles={roles}
|
||||||
|
source={source}
|
||||||
|
permissions={allowed}
|
||||||
|
hasRoles={hasRoles}
|
||||||
|
isEditingUsers={users.some(u => u.isEditing)}
|
||||||
|
isEditingRoles={roles.some(r => r.isEditing)}
|
||||||
|
onClickCreate={this.handleClickCreate}
|
||||||
|
onEditUser={this.handleEditUser}
|
||||||
|
onEditRole={this.handleEditRole}
|
||||||
|
onSaveUser={this.handleSaveUser}
|
||||||
|
onSaveRole={this.handleSaveRole}
|
||||||
|
onCancelEditUser={this.handleCancelEditUser}
|
||||||
|
onCancelEditRole={this.handleCancelEditRole}
|
||||||
|
onDeleteUser={this.handleDeleteUser}
|
||||||
|
onDeleteRole={this.handleDeleteRole}
|
||||||
|
onFilterUsers={filterUsers}
|
||||||
|
onFilterRoles={filterRoles}
|
||||||
|
onUpdateRoleUsers={this.handleUpdateRoleUsers}
|
||||||
|
onUpdateRolePermissions={this.handleUpdateRolePermissions}
|
||||||
|
onUpdateUserPermissions={this.handleUpdateUserPermissions}
|
||||||
|
onUpdateUserRoles={this.handleUpdateUserRoles}
|
||||||
|
/> :
|
||||||
|
<span>Loading...</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
arrayOf,
|
||||||
|
func,
|
||||||
|
shape,
|
||||||
|
string,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
AdminPage.propTypes = {
|
||||||
|
source: shape({
|
||||||
|
id: string.isRequired,
|
||||||
|
links: shape({
|
||||||
|
users: string.isRequired,
|
||||||
|
}),
|
||||||
|
}).isRequired,
|
||||||
|
users: arrayOf(shape()),
|
||||||
|
roles: arrayOf(shape()),
|
||||||
|
permissions: arrayOf(shape()),
|
||||||
|
loadUsers: func,
|
||||||
|
loadRoles: func,
|
||||||
|
loadPermissions: func,
|
||||||
|
addUser: func,
|
||||||
|
addRole: func,
|
||||||
|
removeUser: func,
|
||||||
|
removeRole: func,
|
||||||
|
editUser: func,
|
||||||
|
editRole: func,
|
||||||
|
createUser: func,
|
||||||
|
createRole: func,
|
||||||
|
deleteRole: func,
|
||||||
|
deleteUser: func,
|
||||||
|
addFlashMessage: func,
|
||||||
|
filterRoles: func,
|
||||||
|
filterUsers: func,
|
||||||
|
updateRoleUsers: func,
|
||||||
|
updateRolePermissions: func,
|
||||||
|
updateUserPermissions: func,
|
||||||
|
updateUserRoles: func,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapStateToProps = ({admin: {users, roles, permissions}}) => ({
|
||||||
|
users,
|
||||||
|
roles,
|
||||||
|
permissions,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
|
loadUsers: bindActionCreators(loadUsersAsync, dispatch),
|
||||||
|
loadRoles: bindActionCreators(loadRolesAsync, dispatch),
|
||||||
|
loadPermissions: bindActionCreators(loadPermissionsAsync, dispatch),
|
||||||
|
addUser: bindActionCreators(addUser, dispatch),
|
||||||
|
addRole: bindActionCreators(addRole, dispatch),
|
||||||
|
removeUser: bindActionCreators(deleteUser, dispatch),
|
||||||
|
removeRole: bindActionCreators(deleteRole, dispatch),
|
||||||
|
editUser: bindActionCreators(editUser, dispatch),
|
||||||
|
editRole: bindActionCreators(editRole, dispatch),
|
||||||
|
createUser: bindActionCreators(createUserAsync, dispatch),
|
||||||
|
createRole: bindActionCreators(createRoleAsync, dispatch),
|
||||||
|
deleteUser: bindActionCreators(deleteUserAsync, dispatch),
|
||||||
|
deleteRole: bindActionCreators(deleteRoleAsync, dispatch),
|
||||||
|
filterUsers: bindActionCreators(filterUsersAction, dispatch),
|
||||||
|
filterRoles: bindActionCreators(filterRolesAction, dispatch),
|
||||||
|
updateRoleUsers: bindActionCreators(updateRoleUsersAsync, dispatch),
|
||||||
|
updateRolePermissions: bindActionCreators(updateRolePermissionsAsync, dispatch),
|
||||||
|
updateUserPermissions: bindActionCreators(updateUserPermissionsAsync, dispatch),
|
||||||
|
updateUserRoles: bindActionCreators(updateUserRolesAsync, dispatch),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(AdminPage)
|
|
@ -0,0 +1,134 @@
|
||||||
|
import React, {PropTypes, Component} from 'react'
|
||||||
|
import {connect} from 'react-redux';
|
||||||
|
import {bindActionCreators} from 'redux';
|
||||||
|
|
||||||
|
import flatten from 'lodash/flatten'
|
||||||
|
import uniqBy from 'lodash/uniqBy'
|
||||||
|
|
||||||
|
import {
|
||||||
|
showDatabases,
|
||||||
|
showQueries,
|
||||||
|
} from 'shared/apis/metaQuery'
|
||||||
|
|
||||||
|
import QueriesTable from 'src/admin/components/QueriesTable'
|
||||||
|
import showDatabasesParser from 'shared/parsing/showDatabases'
|
||||||
|
import showQueriesParser from 'shared/parsing/showQueries'
|
||||||
|
import {TIMES} from 'src/admin/constants'
|
||||||
|
import {
|
||||||
|
loadQueries as loadQueriesAction,
|
||||||
|
setQueryToKill as setQueryToKillAction,
|
||||||
|
killQueryAsync,
|
||||||
|
} from 'src/admin/actions'
|
||||||
|
|
||||||
|
class QueriesPage extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.updateQueries = ::this.updateQueries
|
||||||
|
this.handleConfirmKillQuery = ::this.handleConfirmKillQuery
|
||||||
|
this.handleKillQuery = ::this.handleKillQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.updateQueries()
|
||||||
|
const updateInterval = 5000
|
||||||
|
this.intervalID = setInterval(this.updateQueries, updateInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
clearInterval(this.intervalID)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {queries} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueriesTable queries={queries} onConfirm={this.handleConfirmKillQuery} onKillQuery={this.handleKillQuery} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQueries() {
|
||||||
|
const {source, addFlashMessage, loadQueries} = this.props
|
||||||
|
showDatabases(source.links.proxy).then((resp) => {
|
||||||
|
const {databases, errors} = showDatabasesParser(resp.data)
|
||||||
|
if (errors.length) {
|
||||||
|
errors.forEach((message) => addFlashMessage({type: 'error', text: message}))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetches = databases.map((db) => showQueries(source.links.proxy, db))
|
||||||
|
|
||||||
|
Promise.all(fetches).then((queryResponses) => {
|
||||||
|
const allQueries = [];
|
||||||
|
queryResponses.forEach((queryResponse) => {
|
||||||
|
const result = showQueriesParser(queryResponse.data);
|
||||||
|
if (result.errors.length) {
|
||||||
|
result.erorrs.forEach((message) => this.props.addFlashMessage({type: 'error', text: message}));
|
||||||
|
}
|
||||||
|
|
||||||
|
allQueries.push(...result.queries);
|
||||||
|
});
|
||||||
|
|
||||||
|
const queries = uniqBy(flatten(allQueries), (q) => q.id);
|
||||||
|
|
||||||
|
// sorting queries by magnitude, so generally longer queries will appear atop the list
|
||||||
|
const sortedQueries = queries.sort((a, b) => {
|
||||||
|
const aTime = TIMES.find((t) => a.duration.match(t.test));
|
||||||
|
const bTime = TIMES.find((t) => b.duration.match(t.test));
|
||||||
|
return +aTime.magnitude <= +bTime.magnitude;
|
||||||
|
});
|
||||||
|
|
||||||
|
loadQueries(sortedQueries)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKillQuery(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = e.target.dataset.queryId;
|
||||||
|
|
||||||
|
this.props.setQueryToKill(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleConfirmKillQuery() {
|
||||||
|
const {queryIDToKill, source, killQuery} = this.props;
|
||||||
|
if (queryIDToKill === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
killQuery(source.links.proxy, queryIDToKill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
arrayOf,
|
||||||
|
func,
|
||||||
|
string,
|
||||||
|
shape,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
QueriesPage.propTypes = {
|
||||||
|
source: shape({
|
||||||
|
links: shape({
|
||||||
|
proxy: string,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
queries: arrayOf(shape()),
|
||||||
|
addFlashMessage: func,
|
||||||
|
loadQueries: func,
|
||||||
|
queryIDToKill: string,
|
||||||
|
setQueryToKill: func,
|
||||||
|
killQuery: func,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapStateToProps = ({admin: {queries, queryIDToKill}}) => ({
|
||||||
|
queries,
|
||||||
|
queryIDToKill,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
|
loadQueries: bindActionCreators(loadQueriesAction, dispatch),
|
||||||
|
setQueryToKill: bindActionCreators(setQueryToKillAction, dispatch),
|
||||||
|
killQuery: bindActionCreators(killQueryAsync, dispatch),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(QueriesPage)
|
|
@ -0,0 +1,2 @@
|
||||||
|
import AdminPage from './containers/AdminPage';
|
||||||
|
export {AdminPage};
|
|
@ -0,0 +1,154 @@
|
||||||
|
import reject from 'lodash/reject'
|
||||||
|
|
||||||
|
const newDefaultUser = {
|
||||||
|
name: '',
|
||||||
|
password: '',
|
||||||
|
roles: [],
|
||||||
|
permissions: [],
|
||||||
|
links: {self: ''},
|
||||||
|
isNew: true,
|
||||||
|
}
|
||||||
|
const newDefaultRole = {
|
||||||
|
name: '',
|
||||||
|
permissions: [],
|
||||||
|
users: [],
|
||||||
|
links: {self: ''},
|
||||||
|
isNew: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
users: null,
|
||||||
|
roles: [],
|
||||||
|
permissions: [],
|
||||||
|
queries: [],
|
||||||
|
queryIDToKill: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function admin(state = initialState, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'LOAD_USERS': {
|
||||||
|
return {...state, ...action.payload}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'LOAD_ROLES': {
|
||||||
|
return {...state, ...action.payload}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'LOAD_PERMISSIONS': {
|
||||||
|
return {...state, ...action.payload}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ADD_USER': {
|
||||||
|
const newUser = {...newDefaultUser, isEditing: true}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
users: [
|
||||||
|
newUser,
|
||||||
|
...state.users,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ADD_ROLE': {
|
||||||
|
const newRole = {...newDefaultRole, isEditing: true}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
roles: [
|
||||||
|
newRole,
|
||||||
|
...state.roles,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SYNC_USER': {
|
||||||
|
const {staleUser, syncedUser} = action.payload
|
||||||
|
const newState = {
|
||||||
|
users: state.users.map(u => u.links.self === staleUser.links.self ? {...syncedUser} : u),
|
||||||
|
}
|
||||||
|
return {...state, ...newState}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SYNC_ROLE': {
|
||||||
|
const {staleRole, syncedRole} = action.payload
|
||||||
|
const newState = {
|
||||||
|
roles: state.roles.map(r => r.links.self === staleRole.links.self ? {...syncedRole} : r),
|
||||||
|
}
|
||||||
|
return {...state, ...newState}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'EDIT_USER': {
|
||||||
|
const {user, updates} = action.payload
|
||||||
|
const newState = {
|
||||||
|
users: state.users.map(u => u.links.self === user.links.self ? {...u, ...updates} : u),
|
||||||
|
}
|
||||||
|
return {...state, ...newState}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'EDIT_ROLE': {
|
||||||
|
const {role, updates} = action.payload
|
||||||
|
const newState = {
|
||||||
|
roles: state.roles.map(r => r.links.self === role.links.self ? {...r, ...updates} : r),
|
||||||
|
}
|
||||||
|
return {...state, ...newState}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'DELETE_USER': {
|
||||||
|
const {user} = action.payload
|
||||||
|
const newState = {
|
||||||
|
users: state.users.filter(u => u.links.self !== user.links.self),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {...state, ...newState}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'DELETE_ROLE': {
|
||||||
|
const {role} = action.payload
|
||||||
|
const newState = {
|
||||||
|
roles: state.roles.filter(r => r.links.self !== role.links.self),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {...state, ...newState}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'LOAD_QUERIES': {
|
||||||
|
return {...state, ...action.payload}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'FILTER_USERS': {
|
||||||
|
const {text} = action.payload
|
||||||
|
const newState = {
|
||||||
|
users: state.users.map(u => {
|
||||||
|
u.hidden = !u.name.toLowerCase().includes(text)
|
||||||
|
return u
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return {...state, ...newState}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'FILTER_ROLES': {
|
||||||
|
const {text} = action.payload
|
||||||
|
const newState = {
|
||||||
|
roles: state.roles.map(r => {
|
||||||
|
r.hidden = !r.name.toLowerCase().includes(text)
|
||||||
|
return r
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return {...state, ...newState}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'KILL_QUERY': {
|
||||||
|
const {queryID} = action.payload
|
||||||
|
const nextState = {
|
||||||
|
queries: reject(state.queries, (q) => +q.id === +queryID),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {...state, ...nextState}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SET_QUERY_TO_KILL': {
|
||||||
|
return {...state, ...action.payload}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
|
@ -6,10 +6,6 @@ import AJAX from 'utils/ajax';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import NoKapacitorError from '../../shared/components/NoKapacitorError';
|
import NoKapacitorError from '../../shared/components/NoKapacitorError';
|
||||||
|
|
||||||
// Kevin: because we were getting strange errors saying
|
|
||||||
// "Failed prop type: Required prop `source` was not specified in `AlertsApp`."
|
|
||||||
// Tim and I decided to make the source and addFlashMessage props not required.
|
|
||||||
// FIXME: figure out why that wasn't working
|
|
||||||
const AlertsApp = React.createClass({
|
const AlertsApp = React.createClass({
|
||||||
propTypes: {
|
propTypes: {
|
||||||
source: PropTypes.shape({
|
source: PropTypes.shape({
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React, {PropTypes} from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
import MultiSelectDropdown from './MultiSelectDropdown';
|
import MultiSelectDropdown from 'src/shared/components/MultiSelectDropdown';
|
||||||
import Dropdown from 'src/shared/components/Dropdown';
|
import Dropdown from 'src/shared/components/Dropdown';
|
||||||
|
|
||||||
import {INFLUXQL_FUNCTIONS} from '../constants';
|
import {INFLUXQL_FUNCTIONS} from '../constants';
|
||||||
|
|
|
@ -1,106 +0,0 @@
|
||||||
import React, {PropTypes} from 'react';
|
|
||||||
import OnClickOutside from 'shared/components/OnClickOutside';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import _ from 'lodash';
|
|
||||||
|
|
||||||
const {func, arrayOf, string} = PropTypes;
|
|
||||||
const MultiSelectDropdown = React.createClass({
|
|
||||||
propTypes: {
|
|
||||||
onApply: func.isRequired,
|
|
||||||
items: arrayOf(PropTypes.string.isRequired).isRequired,
|
|
||||||
selectedItems: arrayOf(string.isRequired).isRequired,
|
|
||||||
},
|
|
||||||
|
|
||||||
getInitialState() {
|
|
||||||
return {
|
|
||||||
isOpen: false,
|
|
||||||
localSelectedItems: this.props.selectedItems,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
componentWillReceiveProps(nextProps) {
|
|
||||||
if (!_.isEqual(this.state.localSelectedItems, nextProps.selectedItems)) {
|
|
||||||
this.setState({
|
|
||||||
localSelectedItems: nextProps.selectedItems,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
handleClickOutside() {
|
|
||||||
this.setState({isOpen: false});
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleMenu(e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.setState({isOpen: !this.state.isOpen});
|
|
||||||
},
|
|
||||||
|
|
||||||
onSelect(item, e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const {localSelectedItems} = this.state;
|
|
||||||
|
|
||||||
let nextItems;
|
|
||||||
if (this.isSelected(item)) {
|
|
||||||
nextItems = localSelectedItems.filter((i) => i !== item);
|
|
||||||
} else {
|
|
||||||
nextItems = localSelectedItems.concat(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({localSelectedItems: nextItems});
|
|
||||||
},
|
|
||||||
|
|
||||||
isSelected(item) {
|
|
||||||
return this.state.localSelectedItems.indexOf(item) > -1;
|
|
||||||
},
|
|
||||||
|
|
||||||
onApplyFunctions(e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
this.setState({isOpen: false});
|
|
||||||
this.props.onApply(this.state.localSelectedItems);
|
|
||||||
},
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {localSelectedItems} = this.state;
|
|
||||||
const {isOpen} = this.state;
|
|
||||||
const labelText = isOpen ? "0 Selected" : "Apply Function";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames('dropdown multi-select-dropdown', {open: isOpen})}>
|
|
||||||
<div onClick={this.toggleMenu} className="btn btn-xs btn-info dropdown-toggle" type="button">
|
|
||||||
<span className="multi-select-dropdown__label">
|
|
||||||
{
|
|
||||||
localSelectedItems.length ? localSelectedItems.map((s) => s).join(', ') : labelText
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<span className="caret"></span>
|
|
||||||
</div>
|
|
||||||
{this.renderMenu()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderMenu() {
|
|
||||||
const {items} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dropdown-options">
|
|
||||||
<li className="multi-select-dropdown__apply" onClick={this.onApplyFunctions}>
|
|
||||||
<div className="btn btn-xs btn-info btn-block">Apply</div>
|
|
||||||
</li>
|
|
||||||
<ul className="dropdown-menu multi-select-dropdown__menu" aria-labelledby="dropdownMenu1">
|
|
||||||
{items.map((listItem, i) => {
|
|
||||||
return (
|
|
||||||
<li className={classNames('multi-select-dropdown__item', {active: this.isSelected(listItem)})} key={i} onClick={_.wrap(listItem, this.onSelect)}>
|
|
||||||
<a href="#">{listItem}</a>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default OnClickOutside(MultiSelectDropdown);
|
|
|
@ -14,6 +14,7 @@ import {KapacitorPage, KapacitorRulePage, KapacitorRulesPage, KapacitorTasksPage
|
||||||
import DataExplorer from 'src/data_explorer';
|
import DataExplorer from 'src/data_explorer';
|
||||||
import {DashboardsPage, DashboardPage} from 'src/dashboards';
|
import {DashboardsPage, DashboardPage} from 'src/dashboards';
|
||||||
import {CreateSource, SourcePage, ManageSources} from 'src/sources';
|
import {CreateSource, SourcePage, ManageSources} from 'src/sources';
|
||||||
|
import {AdminPage} from 'src/admin';
|
||||||
import NotFound from 'src/shared/components/NotFound';
|
import NotFound from 'src/shared/components/NotFound';
|
||||||
import configureStore from 'src/store/configureStore';
|
import configureStore from 'src/store/configureStore';
|
||||||
import {getMe, getSources} from 'shared/apis';
|
import {getMe, getSources} from 'shared/apis';
|
||||||
|
@ -127,6 +128,7 @@ const Root = React.createClass({
|
||||||
<Route path="alert-rules" component={KapacitorRulesPage} />
|
<Route path="alert-rules" component={KapacitorRulesPage} />
|
||||||
<Route path="alert-rules/:ruleID" component={KapacitorRulePage} />
|
<Route path="alert-rules/:ruleID" component={KapacitorRulePage} />
|
||||||
<Route path="alert-rules/new" component={KapacitorRulePage} />
|
<Route path="alert-rules/new" component={KapacitorRulePage} />
|
||||||
|
<Route path="admin" component={AdminPage} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" component={NotFound} />
|
<Route path="*" component={NotFound} />
|
||||||
|
|
|
@ -7,18 +7,16 @@ export function showDatabases(source) {
|
||||||
return proxy({source, query});
|
return proxy({source, query});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showQueries(host, db, clusterID) {
|
export function showQueries(source, db) {
|
||||||
const statement = 'SHOW QUERIES';
|
const query = 'SHOW QUERIES';
|
||||||
const url = buildInfluxUrl({host, statement, database: db});
|
|
||||||
|
|
||||||
return proxy(url, clusterID);
|
return proxy({source, query, db});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function killQuery(host, queryId, clusterID) {
|
export function killQuery(source, queryId) {
|
||||||
const statement = `KILL QUERY ${queryId}`;
|
const query = `KILL QUERY ${queryId}`;
|
||||||
const url = buildInfluxUrl({host, statement});
|
|
||||||
|
|
||||||
return proxy(url, clusterID);
|
return proxy({source, query});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showMeasurements(source, db) {
|
export function showMeasurements(source, db) {
|
||||||
|
|
|
@ -0,0 +1,134 @@
|
||||||
|
import React, {Component, PropTypes} from 'react'
|
||||||
|
import OnClickOutside from 'shared/components/OnClickOutside'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
const labelText = ({localSelectedItems, isOpen, label}) => {
|
||||||
|
if (label) {
|
||||||
|
return label
|
||||||
|
} else if (localSelectedItems.length) {
|
||||||
|
return localSelectedItems.map((s) => s).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: be smarter about the text displayed here
|
||||||
|
if (isOpen) {
|
||||||
|
return '0 Selected'
|
||||||
|
}
|
||||||
|
return 'None'
|
||||||
|
}
|
||||||
|
|
||||||
|
class MultiSelectDropdown extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isOpen: false,
|
||||||
|
localSelectedItems: this.props.selectedItems,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onSelect = ::this.onSelect
|
||||||
|
this.onApplyFunctions = ::this.onApplyFunctions
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
if (!_.isEqual(this.state.localSelectedItems, nextProps.selectedItems)) {
|
||||||
|
this.setState({
|
||||||
|
localSelectedItems: nextProps.selectedItems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClickOutside() {
|
||||||
|
this.setState({isOpen: false})
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMenu(e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
this.setState({isOpen: !this.state.isOpen})
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(item, e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
const {localSelectedItems} = this.state
|
||||||
|
|
||||||
|
let nextItems
|
||||||
|
if (this.isSelected(item)) {
|
||||||
|
nextItems = localSelectedItems.filter((i) => i !== item)
|
||||||
|
} else {
|
||||||
|
nextItems = localSelectedItems.concat(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({localSelectedItems: nextItems})
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelected(item) {
|
||||||
|
return this.state.localSelectedItems.indexOf(item) > -1
|
||||||
|
}
|
||||||
|
|
||||||
|
onApplyFunctions(e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
this.setState({isOpen: false})
|
||||||
|
this.props.onApply(this.state.localSelectedItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {localSelectedItems, isOpen} = this.state
|
||||||
|
const {label} = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames('dropdown multi-select-dropdown', {open: isOpen})}>
|
||||||
|
<div onClick={::this.toggleMenu} className="btn btn-xs btn-info dropdown-toggle" type="button">
|
||||||
|
<div className="multi-select-dropdown__label">
|
||||||
|
{
|
||||||
|
labelText({localSelectedItems, isOpen, label})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span className="caret"></span>
|
||||||
|
</div>
|
||||||
|
{this.renderMenu()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMenu() {
|
||||||
|
const {items} = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dropdown-options">
|
||||||
|
<li className="multi-select-dropdown__apply" onClick={this.onApplyFunctions} style={{listStyle: 'none'}}>
|
||||||
|
<div className="btn btn-xs btn-info btn-block">Apply</div>
|
||||||
|
</li>
|
||||||
|
<ul className="dropdown-menu multi-select-dropdown__menu" aria-labelledby="dropdownMenu1">
|
||||||
|
{items.map((listItem, i) => {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className={classNames('multi-select-dropdown__item', {active: this.isSelected(listItem)})}
|
||||||
|
onClick={_.wrap(listItem, this.onSelect)}
|
||||||
|
>
|
||||||
|
<a href="#">{listItem}</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
arrayOf,
|
||||||
|
func,
|
||||||
|
string,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
MultiSelectDropdown.propTypes = {
|
||||||
|
onApply: func.isRequired,
|
||||||
|
items: arrayOf(string.isRequired).isRequired,
|
||||||
|
selectedItems: arrayOf(string.isRequired).isRequired,
|
||||||
|
label: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OnClickOutside(MultiSelectDropdown)
|
|
@ -28,6 +28,7 @@ export const TabList = React.createClass({
|
||||||
activeIndex: number,
|
activeIndex: number,
|
||||||
onActivate: func,
|
onActivate: func,
|
||||||
isKapacitorTabs: string,
|
isKapacitorTabs: string,
|
||||||
|
customClass: string,
|
||||||
},
|
},
|
||||||
|
|
||||||
getDefaultProps() {
|
getDefaultProps() {
|
||||||
|
@ -53,6 +54,14 @@ export const TabList = React.createClass({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.props.customClass) {
|
||||||
|
return (
|
||||||
|
<div className={this.props.customClass}>
|
||||||
|
<div className="btn-group btn-group-lg tab-group">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="btn-group btn-group-lg tab-group">{children}</div>
|
<div className="btn-group btn-group-lg tab-group">{children}</div>
|
||||||
);
|
);
|
||||||
|
@ -63,11 +72,13 @@ export const TabPanels = React.createClass({
|
||||||
propTypes: {
|
propTypes: {
|
||||||
children: node.isRequired,
|
children: node.isRequired,
|
||||||
activeIndex: number,
|
activeIndex: number,
|
||||||
|
customClass: string,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// if only 1 child, children array index lookup will fail
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={this.props.customClass ? this.props.customClass : null}>
|
||||||
{this.props.children[this.props.activeIndex]}
|
{this.props.children[this.props.activeIndex]}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React, {PropTypes} from 'react'
|
||||||
|
import ReactTooltip from 'react-tooltip'
|
||||||
|
|
||||||
|
const Tooltip = ({tip, children}) => (
|
||||||
|
<div>
|
||||||
|
<div data-tip={tip}>{children}</div>
|
||||||
|
<ReactTooltip effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
shape,
|
||||||
|
string,
|
||||||
|
} = PropTypes
|
||||||
|
|
||||||
|
Tooltip.propTypes = {
|
||||||
|
tip: string,
|
||||||
|
children: shape({}),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tooltip
|
|
@ -471,4 +471,6 @@ export const STROKE_WIDTH = {
|
||||||
export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds.
|
export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds.
|
||||||
export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds.
|
export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds.
|
||||||
|
|
||||||
|
export const RES_UNAUTHORIZED = 401
|
||||||
|
|
||||||
export const AUTOREFRESH_DEFAULT = 15000 // in milliseconds
|
export const AUTOREFRESH_DEFAULT = 15000 // in milliseconds
|
||||||
|
|
|
@ -47,6 +47,9 @@ const SideNav = React.createClass({
|
||||||
<NavListItem link={`${sourcePrefix}/manage-sources`}>InfluxDB</NavListItem>
|
<NavListItem link={`${sourcePrefix}/manage-sources`}>InfluxDB</NavListItem>
|
||||||
<NavListItem link={`${sourcePrefix}/kapacitor-config`}>Kapacitor</NavListItem>
|
<NavListItem link={`${sourcePrefix}/kapacitor-config`}>Kapacitor</NavListItem>
|
||||||
</NavBlock>
|
</NavBlock>
|
||||||
|
<NavBlock icon="crown" link={`${sourcePrefix}/admin`}>
|
||||||
|
<NavHeader link={`${sourcePrefix}/admin`} title="Admin" />
|
||||||
|
</NavBlock>
|
||||||
{loggedIn ? (
|
{loggedIn ? (
|
||||||
<NavBlock icon="user-outline" className="sidebar__square-last">
|
<NavBlock icon="user-outline" className="sidebar__square-last">
|
||||||
<a className="sidebar__menu-item" href="/oauth/logout">Logout</a>
|
<a className="sidebar__menu-item" href="/oauth/logout">Logout</a>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {combineReducers} from 'redux';
|
||||||
import thunkMiddleware from 'redux-thunk';
|
import thunkMiddleware from 'redux-thunk';
|
||||||
import makeQueryExecuter from 'src/shared/middleware/queryExecuter';
|
import makeQueryExecuter from 'src/shared/middleware/queryExecuter';
|
||||||
import resizeLayout from 'src/shared/middleware/resizeLayout';
|
import resizeLayout from 'src/shared/middleware/resizeLayout';
|
||||||
|
import adminReducer from 'src/admin/reducers/admin';
|
||||||
import sharedReducers from 'src/shared/reducers';
|
import sharedReducers from 'src/shared/reducers';
|
||||||
import dataExplorerReducers from 'src/data_explorer/reducers';
|
import dataExplorerReducers from 'src/data_explorer/reducers';
|
||||||
import rulesReducer from 'src/kapacitor/reducers/rules';
|
import rulesReducer from 'src/kapacitor/reducers/rules';
|
||||||
|
@ -12,6 +13,7 @@ import persistStateEnhancer from './persistStateEnhancer';
|
||||||
const rootReducer = combineReducers({
|
const rootReducer = combineReducers({
|
||||||
...sharedReducers,
|
...sharedReducers,
|
||||||
...dataExplorerReducers,
|
...dataExplorerReducers,
|
||||||
|
admin: adminReducer,
|
||||||
rules: rulesReducer,
|
rules: rulesReducer,
|
||||||
dashboardUI,
|
dashboardUI,
|
||||||
});
|
});
|
||||||
|
|
|
@ -47,6 +47,7 @@
|
||||||
@import 'pages/kapacitor';
|
@import 'pages/kapacitor';
|
||||||
@import 'pages/data-explorer';
|
@import 'pages/data-explorer';
|
||||||
@import 'pages/dashboards';
|
@import 'pages/dashboards';
|
||||||
|
@import 'pages/admin';
|
||||||
|
|
||||||
// TODO
|
// TODO
|
||||||
@import 'unsorted';
|
@import 'unsorted';
|
||||||
|
|
|
@ -1,6 +1,80 @@
|
||||||
|
$ms-normal-left-padding: 9px;
|
||||||
|
$ms-item-height: 26px;
|
||||||
|
$ms-checkbox-size: 14px;
|
||||||
|
$ms-checkbox-dot-size: 6px;
|
||||||
|
$ms-checkbox-bg: $c-sapphire;
|
||||||
|
$ms-checkbox-bg-hover: $c-ocean;
|
||||||
|
$ms-checkbox-dot: $g20-white;
|
||||||
|
|
||||||
.multi-select-dropdown {
|
.multi-select-dropdown {
|
||||||
|
.multi-select-dropdown__item > a {
|
||||||
|
color: $c-neutrino !important;
|
||||||
|
height: $ms-item-height;
|
||||||
|
line-height: $ms-item-height;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
padding-right: $ms-normal-left-padding;
|
||||||
|
padding-left: ($ms-normal-left-padding + $ms-checkbox-size + ($ms-normal-left-padding - 2px));
|
||||||
|
|
||||||
|
&,
|
||||||
|
&:focus,
|
||||||
|
&:active,
|
||||||
|
&:active:focus {
|
||||||
|
background: none !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $c-pool;
|
||||||
|
background: -moz-linear-gradient(left, $c-pool 0%, $c-pool 100%) !important;
|
||||||
|
background: -webkit-linear-gradient(left, $c-pool 0%,$c-pool 100%) !important;
|
||||||
|
background: linear-gradient(to right, $c-pool 0%,$c-pool 100%) !important;
|
||||||
|
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$c-pool', endColorstr='$c-pool',GradientType=1 ) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Shared Checkbox Styles */
|
||||||
|
&:before,
|
||||||
|
&:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
top: 50%;
|
||||||
|
}
|
||||||
|
/* Before = Checkbox */
|
||||||
|
&:before {
|
||||||
|
width: $ms-checkbox-size;
|
||||||
|
height: $ms-checkbox-size;
|
||||||
|
border-radius: $radius-small;
|
||||||
|
background-color: $ms-checkbox-bg;
|
||||||
|
left: $ms-normal-left-padding;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
/* After = Dot */
|
||||||
|
&:after {
|
||||||
|
width: $ms-checkbox-dot-size;
|
||||||
|
height: $ms-checkbox-dot-size;
|
||||||
|
background-color: $ms-checkbox-dot;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%,-50%) scale(2,2);
|
||||||
|
opacity: 0;
|
||||||
|
left: ($ms-normal-left-padding + ($ms-checkbox-size / 2));
|
||||||
|
transition:
|
||||||
|
opacity 0.25s ease,
|
||||||
|
transform 0.25s ease;
|
||||||
|
}
|
||||||
|
/* Hover State */
|
||||||
|
&:hover {
|
||||||
|
color: $g20-white !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
.dropdown-toggle {
|
.dropdown-toggle {
|
||||||
width: 110px;
|
width: 110px;
|
||||||
|
|
||||||
|
&.btn-xs {
|
||||||
|
height: 22px;
|
||||||
|
line-height: 22px;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&__apply {
|
&__apply {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -45,6 +119,22 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Checked State */
|
||||||
|
.multi-select-dropdown li.multi-select-dropdown__item.active > a {
|
||||||
|
&,
|
||||||
|
&:focus,
|
||||||
|
&:active,
|
||||||
|
&:active:focus {
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
color: $g20-white !important;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
transform: translate(-50%,-50%) scale(1,1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
/* Open State */
|
/* Open State */
|
||||||
.multi-select-dropdown.open {
|
.multi-select-dropdown.open {
|
||||||
.dropdown-options {
|
.dropdown-options {
|
||||||
|
@ -56,3 +146,14 @@
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.multi-select-dropdown__label {
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-right: 10px;
|
||||||
|
position: absolute;
|
||||||
|
width: calc(100% - #{($ms-normal-left-padding * 2)});
|
||||||
|
left: $ms-normal-left-padding;
|
||||||
|
}
|
||||||
|
|
|
@ -2,10 +2,13 @@
|
||||||
Custom Search Widget
|
Custom Search Widget
|
||||||
----------------------------------------------
|
----------------------------------------------
|
||||||
*/
|
*/
|
||||||
|
$search-widget-height: 36px;
|
||||||
|
|
||||||
.users__search-widget {
|
.users__search-widget {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
input.form-control {
|
input.form-control {
|
||||||
|
height: $search-widget-height;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
@ -19,7 +22,7 @@
|
||||||
.input-group-addon {
|
.input-group-addon {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 38px;
|
line-height: calc(#{$search-widget-height} - 2px);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
color: $g10-wolf;
|
color: $g10-wolf;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
@ -32,4 +35,7 @@
|
||||||
transition:
|
transition:
|
||||||
color 0.25s ease;
|
color 0.25s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.admin__search-widget {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,29 @@
|
||||||
Stuff for making Tables of Data more readable
|
Stuff for making Tables of Data more readable
|
||||||
----------------------------------------------
|
----------------------------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
table {
|
||||||
|
thead th {
|
||||||
|
color: $g17-whisper !important;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: $g5-pepper !important;
|
||||||
|
}
|
||||||
|
tbody td {
|
||||||
|
font-weight: 500;
|
||||||
|
color: $g14-chromium !important;
|
||||||
|
border: 0 !important;
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
}
|
||||||
|
tbody tr:hover {
|
||||||
|
background-color: $g5-pepper;
|
||||||
|
|
||||||
|
td {
|
||||||
|
color: $g19-ghost !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
table .monotype {
|
table .monotype {
|
||||||
font-family: $code-font;
|
font-family: $code-font;
|
||||||
letter-spacing: 0px;
|
letter-spacing: 0px;
|
||||||
|
@ -119,4 +142,4 @@ table .monotype {
|
||||||
border-color: $g5-pepper;
|
border-color: $g5-pepper;
|
||||||
@include custom-scrollbar($g5-pepper, $c-pool);
|
@include custom-scrollbar($g5-pepper, $c-pool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,122 @@
|
||||||
|
/*
|
||||||
|
Styles for Admin Pages
|
||||||
|
----------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
Admin Tabs
|
||||||
|
----------------------------------------------
|
||||||
|
*/
|
||||||
|
.admin-tabs {
|
||||||
|
padding-right: 0;
|
||||||
|
|
||||||
|
& + div {
|
||||||
|
padding-left: 0;
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.admin-tabs .btn-group {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
font-weight: 500 !important;
|
||||||
|
border-radius: $radius 0 0 $radius !important;
|
||||||
|
margin-bottom: 2px !important;
|
||||||
|
transition:
|
||||||
|
background-color 0.25s ease,
|
||||||
|
color 0.25s ease !important;
|
||||||
|
border: 0 !important;
|
||||||
|
text-align: left;
|
||||||
|
height: 60px !important;
|
||||||
|
line-height: 60px !important;
|
||||||
|
padding: 0 0 0 16px !important;
|
||||||
|
font-size: 17px;
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: $g11-sidewalk !important;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&:active:hover {
|
||||||
|
background-color: $g3-castle !important;
|
||||||
|
color: $g15-platinum !important;
|
||||||
|
}
|
||||||
|
&.active {
|
||||||
|
background-color: $g3-castle !important;
|
||||||
|
color: $g18-cloud !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
Admin Table
|
||||||
|
----------------------------------------------
|
||||||
|
*/
|
||||||
|
.admin-table {
|
||||||
|
.multi-select-dropdown {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
.admin-table--delete {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.dropdown-toggle {
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $g14-chromium;
|
||||||
|
width: 100%;
|
||||||
|
transition: none !important;
|
||||||
|
|
||||||
|
.caret {opacity: 0;}
|
||||||
|
.multi-select-dropdown__label {left: 0;}
|
||||||
|
}
|
||||||
|
tbody tr:hover {
|
||||||
|
.admin-table--delete {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.dropdown-toggle {
|
||||||
|
color: $g20-white !important;
|
||||||
|
background-color: $c-pool;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.caret {opacity: 1;}
|
||||||
|
.multi-select-dropdown__label {left: 9px;}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transition: background-color 0.25s ease;
|
||||||
|
background-color: $c-laser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.admin-table--edit-row {
|
||||||
|
background-color: $g4-onyx;
|
||||||
|
}
|
||||||
|
.admin-table--edit-cell {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 !important;
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
> input {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 9px;
|
||||||
|
flex-grow: 1;
|
||||||
|
margin: 0 2px;
|
||||||
|
min-width: 110px;
|
||||||
|
|
||||||
|
&:first-child {margin-left: 0;}
|
||||||
|
&:last-child {margin-right: 0;}
|
||||||
|
}
|
||||||
|
}
|
|
@ -29,7 +29,6 @@
|
||||||
.panel-title {
|
.panel-title {
|
||||||
color: $g10-wolf !important;
|
color: $g10-wolf !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-body {
|
.panel-body {
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
background-color: $g3-castle;
|
background-color: $g3-castle;
|
||||||
|
@ -42,23 +41,30 @@
|
||||||
> *:last-child {
|
> *:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
|
||||||
th,td {
|
|
||||||
border-color: $g5-pepper;
|
|
||||||
}
|
|
||||||
th {
|
|
||||||
color: $g17-whisper;
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
color: $g14-chromium;
|
|
||||||
}
|
|
||||||
tbody tr:last-child td {
|
|
||||||
border-bottom: 2px solid $g5-pepper;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.panel.panel-info {
|
||||||
|
background-color: $g3-castle;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
.panel-body,
|
||||||
|
.panel-heading {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
.panel-body {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
.panel-heading {
|
||||||
|
padding: 0 30px;
|
||||||
|
height: 60px;
|
||||||
|
border: 0px;
|
||||||
|
.panel-title { color: $g14-chromium;}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.panel .panel-body table {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
table thead th {
|
table thead th {
|
||||||
@include no-user-select();
|
@include no-user-select();
|
||||||
}
|
}
|
||||||
|
@ -259,7 +265,6 @@ input {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
min-height: 70px;
|
|
||||||
max-height: 290px;
|
max-height: 290px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@include custom-scrollbar($c-pool, $c-laser);
|
@include custom-scrollbar($c-pool, $c-laser);
|
||||||
|
@ -751,10 +756,6 @@ $form-static-checkbox-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
br {
|
br {
|
||||||
@include no-user-select();
|
@include no-user-select();
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import axios from 'axios';
|
||||||
|
|
||||||
let links
|
let links
|
||||||
|
|
||||||
const UNAUTHORIZED = 401
|
import {RES_UNAUTHORIZED} from 'shared/constants'
|
||||||
|
|
||||||
export default async function AJAX({
|
export default async function AJAX({
|
||||||
url,
|
url,
|
||||||
|
@ -13,10 +13,9 @@ export default async function AJAX({
|
||||||
params = {},
|
params = {},
|
||||||
headers = {},
|
headers = {},
|
||||||
}) {
|
}) {
|
||||||
let response
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const basepath = window.basepath || ''
|
const basepath = window.basepath || ''
|
||||||
|
let response
|
||||||
|
|
||||||
url = `${basepath}${url}`
|
url = `${basepath}${url}`
|
||||||
|
|
||||||
|
@ -47,9 +46,11 @@ export default async function AJAX({
|
||||||
...response,
|
...response,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!response.status === UNAUTHORIZED) {
|
const {response} = error
|
||||||
|
if (!response.status === RES_UNAUTHORIZED) {
|
||||||
console.error(error) // eslint-disable-line no-console
|
console.error(error) // eslint-disable-line no-console
|
||||||
}
|
}
|
||||||
|
// console.error(error) // eslint-disable-line no-console
|
||||||
const {auth} = links
|
const {auth} = links
|
||||||
throw {auth, ...response} // eslint-disable-line no-throw-literal
|
throw {auth, ...response} // eslint-disable-line no-throw-literal
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +1,17 @@
|
||||||
import AJAX from 'utils/ajax';
|
import AJAX from 'utils/ajax';
|
||||||
|
|
||||||
// TODO: delete this once all references
|
export const proxy = async ({source, query, db, rp}) => {
|
||||||
// to it have been removed
|
try {
|
||||||
export function buildInfluxUrl() {
|
return await AJAX({
|
||||||
return "You dont need me anymore";
|
method: 'POST',
|
||||||
}
|
url: source,
|
||||||
|
data: {
|
||||||
export function proxy({source, query, db, rp}) {
|
query,
|
||||||
return AJAX({
|
db,
|
||||||
method: 'POST',
|
rp,
|
||||||
url: source,
|
},
|
||||||
data: {
|
})
|
||||||
query,
|
} catch (error) {
|
||||||
db,
|
console.error(error) // eslint-disable-line no-console
|
||||||
rp,
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {storiesOf, action, linkTo} from '@kadira/storybook'
|
||||||
|
import Center from './components/Center'
|
||||||
|
|
||||||
|
import MultiSelectDropdown from 'shared/components/MultiSelectDropdown'
|
||||||
|
import Tooltip from 'shared/components/Tooltip'
|
||||||
|
|
||||||
|
storiesOf('MultiSelectDropdown', module)
|
||||||
|
.add('Select Roles w/label', () => (
|
||||||
|
<Center>
|
||||||
|
<MultiSelectDropdown
|
||||||
|
items={[
|
||||||
|
'Admin',
|
||||||
|
'User',
|
||||||
|
'Chrono Girafferoo',
|
||||||
|
'Prophet',
|
||||||
|
'Susford',
|
||||||
|
]}
|
||||||
|
selectedItems={[
|
||||||
|
'User',
|
||||||
|
'Chrono Girafferoo',
|
||||||
|
]}
|
||||||
|
label={'Select Roles'}
|
||||||
|
onApply={action('onApply')}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
))
|
||||||
|
.add('Selected Item list', () => (
|
||||||
|
<Center>
|
||||||
|
<MultiSelectDropdown
|
||||||
|
items={[
|
||||||
|
'Admin',
|
||||||
|
'User',
|
||||||
|
'Chrono Giraffe',
|
||||||
|
'Prophet',
|
||||||
|
'Susford',
|
||||||
|
]}
|
||||||
|
selectedItems={[
|
||||||
|
'User',
|
||||||
|
'Chrono Giraffe',
|
||||||
|
]}
|
||||||
|
onApply={action('onApply')}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
))
|
||||||
|
.add('0 selected items', () => (
|
||||||
|
<Center>
|
||||||
|
<MultiSelectDropdown
|
||||||
|
items={[
|
||||||
|
'Admin',
|
||||||
|
'User',
|
||||||
|
'Chrono Giraffe',
|
||||||
|
'Prophet',
|
||||||
|
'Susford',
|
||||||
|
]}
|
||||||
|
selectedItems={[]}
|
||||||
|
onApply={action('onApply')}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
))
|
||||||
|
|
||||||
|
storiesOf('Tooltip', module)
|
||||||
|
.add('Delete', () => (
|
||||||
|
<Center>
|
||||||
|
<Tooltip tip={`Are you sure? TrashIcon`}>
|
||||||
|
<div className="btn btn-info btn-sm">
|
||||||
|
Delete
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Center>
|
||||||
|
))
|
|
@ -0,0 +1,14 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const Center = ({children}) => (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%)',
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Center
|
|
@ -3,3 +3,4 @@ import 'src/style/chronograf.scss';
|
||||||
|
|
||||||
// Kapacitor Stories
|
// Kapacitor Stories
|
||||||
import './kapacitor'
|
import './kapacitor'
|
||||||
|
import './admin'
|
||||||
|
|
|
@ -12,7 +12,7 @@ import queryConfigs from './stubs/queryConfigs';
|
||||||
|
|
||||||
// Actions for Spies
|
// Actions for Spies
|
||||||
import * as kapacitorActions from 'src/kapacitor/actions/view'
|
import * as kapacitorActions from 'src/kapacitor/actions/view'
|
||||||
import * as queryActions from 'src/chronograf/actions/view';
|
import * as queryActions from 'src/data_explorer/actions/view';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import KapacitorRule from 'src/kapacitor/components/KapacitorRule';
|
import KapacitorRule from 'src/kapacitor/components/KapacitorRule';
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
const express = require('express')
|
||||||
|
const request = require('request')
|
||||||
|
const {default: storybook} = require('@kadira/storybook/dist/server/middleware')
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
const handler = (req, res) => {
|
||||||
|
console.log(`${req.method} ${req.url}`)
|
||||||
|
const url = 'http://localhost:8888' + req.url
|
||||||
|
req.pipe(request(url)).pipe(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(storybook('./.storybook'))
|
||||||
|
app.get('/chronograf/v1/*', handler)
|
||||||
|
app.post('/chronograf/v1/*', handler)
|
||||||
|
|
||||||
|
app.listen(6006, () => {
|
||||||
|
console.log('storybook proxy server now running')
|
||||||
|
})
|
Loading…
Reference in New Issue