diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b68717fab..3642bed1a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,14 +1,26 @@
## v1.2.0 [unreleased]
### 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
+### 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
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]
diff --git a/README.md b/README.md
index f4050f0623..83c257c957 100644
--- a/README.md
+++ b/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
* 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.
### OAuth Login
@@ -121,7 +128,7 @@ Change the default root path of the Chronograf server with the `--basepath` opti
## 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.
Spotted a bug or have a feature request?
diff --git a/enterprise/enterprise.go b/enterprise/enterprise.go
index bc7e3af731..189e5a1507 100644
--- a/enterprise/enterprise.go
+++ b/enterprise/enterprise.go
@@ -32,6 +32,8 @@ type Ctrl interface {
DeleteRole(ctx context.Context, name string) error
SetRolePerms(ctx context.Context, name string, perms Permissions) 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
diff --git a/enterprise/meta.go b/enterprise/meta.go
index 399b057b33..cd08730b78 100644
--- a/enterprise/meta.go
+++ b/enterprise/meta.go
@@ -272,32 +272,52 @@ func (m *MetaClient) SetRolePerms(ctx context.Context, name string, perms Permis
return m.Post(ctx, "/role", a, nil)
}
-// RemoveAllRoleUsers removes all users from a role
-func (m *MetaClient) RemoveAllRoleUsers(ctx context.Context, name string) error {
+// SetRoleUsers removes all users and then adds the requested users to role
+func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []string) error {
role, err := m.Role(ctx, name)
if err != nil {
return err
}
-
- // No users to remove
- 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 {
+ revoke, add := Difference(users, role.Users)
+ if err := m.RemoveRoleUsers(ctx, name, revoke); err != nil {
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
if len(users) == 0 {
return nil
@@ -313,6 +333,23 @@ func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []stri
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
func (m *MetaClient) Post(ctx context.Context, path string, action interface{}, params map[string]string) error {
b, err := json.Marshal(action)
diff --git a/enterprise/meta_test.go b/enterprise/meta_test.go
index 32d05bdeae..96150d054e 100644
--- a/enterprise/meta_test.go
+++ b/enterprise/meta_test.go
@@ -1252,12 +1252,11 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
name string
fields fields
args args
- wantRm string
- wantAdd string
+ wants []string
wantErr bool
}{
{
- name: "Successful set users role",
+ name: "Successful set users role (remove user from role)",
fields: fields{
URL: &url.URL{
Host: "twinpinesmall.net:8091",
@@ -1274,7 +1273,7 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
ctx: context.Background(),
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",
@@ -1285,7 +1284,7 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
},
client: NewMockClient(
http.StatusOK,
- []byte(`{"roles":[{"name":"admin","users":["marty"],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`),
+ []byte(`{"roles":[{"name":"admin","users":[],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`),
nil,
nil,
),
@@ -1295,8 +1294,9 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
name: "admin",
users: []string{"marty"},
},
- wantRm: `{"action":"remove-users","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`,
- wantAdd: `{"action":"add-users","role":{"name":"admin","users":["marty"]}}`,
+ wants: []string{
+ `{"action":"add-users","role":{"name":"admin","users":["marty"]}}`,
+ },
},
}
for _, tt := range tests {
@@ -1312,8 +1312,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
continue
}
reqs := tt.fields.client.(*MockClient).Requests
- if len(reqs) < 2 {
- t.Errorf("%q. MetaClient.SetRoleUsers() expected 2 but got %d", tt.name, len(reqs))
+ if len(reqs) != len(tt.wants)+1 {
+ t.Errorf("%q. MetaClient.SetRoleUsers() expected %d but got %d", tt.name, len(tt.wants)+1, len(reqs))
continue
}
@@ -1324,21 +1324,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
if usr.URL.Path != "/role" {
t.Errorf("%q. MetaClient.SetRoleUsers() expected /user path but got %s", tt.name, usr.URL.Path)
}
-
- prm := reqs[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]
+ for i := range tt.wants {
+ prm := reqs[i+1]
if prm.Method != "POST" {
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)
- if string(got) != tt.wantAdd {
- t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wantAdd)
+ if string(got) != tt.wants[i] {
+ t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wants[i])
}
}
}
diff --git a/enterprise/mocks_test.go b/enterprise/mocks_test.go
index 08e0253624..6e9cc95cd5 100644
--- a/enterprise/mocks_test.go
+++ b/enterprise/mocks_test.go
@@ -88,6 +88,14 @@ func (cc *ControlClient) SetRoleUsers(ctx context.Context, name string, users []
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 {
URLs []string
Response Response
diff --git a/enterprise/roles.go b/enterprise/roles.go
index de238dd556..73d28085f8 100644
--- a/enterprise/roles.go
+++ b/enterprise/roles.go
@@ -66,16 +66,20 @@ func (c *RolesStore) Get(ctx context.Context, name string) (*chronograf.Role, er
// Update the Role's permissions and roles
func (c *RolesStore) Update(ctx context.Context, u *chronograf.Role) error {
- perms := ToEnterprise(u.Permissions)
- if err := c.Ctrl.SetRolePerms(ctx, u.Name, perms); err != nil {
- return err
+ if u.Permissions != nil {
+ perms := ToEnterprise(u.Permissions)
+ if err := c.Ctrl.SetRolePerms(ctx, u.Name, perms); err != nil {
+ return err
+ }
}
-
- users := make([]string, len(u.Users))
- for i, u := range u.Users {
- users[i] = u.Name
+ if u.Users != nil {
+ users := make([]string, len(u.Users))
+ for i, u := range u.Users {
+ 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
diff --git a/enterprise/users.go b/enterprise/users.go
index ff8059a680..68c04d193c 100644
--- a/enterprise/users.go
+++ b/enterprise/users.go
@@ -18,10 +18,17 @@ func (c *UserStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.Us
return nil, err
}
perms := ToEnterprise(u.Permissions)
+
if err := c.Ctrl.SetUserPerms(ctx, u.Name, perms); err != nil {
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
@@ -62,6 +69,43 @@ func (c *UserStore) Update(ctx context.Context, u *chronograf.User) error {
if 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)
return c.Ctrl.SetUserPerms(ctx, u.Name, perms)
}
diff --git a/enterprise/users_test.go b/enterprise/users_test.go
index 54dc7f5027..9cc0cddc50 100644
--- a/enterprise/users_test.go
+++ b/enterprise/users_test.go
@@ -36,6 +36,22 @@ func TestClient_Add(t *testing.T) {
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{}, nil
+ },
},
},
args: args{
@@ -46,8 +62,82 @@ func TestClient_Add(t *testing.T) {
},
},
want: &chronograf.User{
- Name: "marty",
- Passwd: "johnny be good",
+ Name: "marty",
+ 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
}
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 {
return nil
},
+ userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
+ return map[string]enterprise.Roles{}, nil
+ },
},
},
args: args{
@@ -369,6 +462,40 @@ func TestClient_Update(t *testing.T) {
},
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",
fields: fields{
@@ -376,6 +503,9 @@ func TestClient_Update(t *testing.T) {
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.")
},
+ userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
+ return map[string]enterprise.Roles{}, nil
+ },
},
},
args: args{
@@ -573,12 +703,14 @@ type mockCtrl struct {
userRoles func(ctx context.Context) (map[string]enterprise.Roles, error)
- roles func(ctx context.Context, name *string) (*enterprise.Roles, error)
- role func(ctx context.Context, name string) (*enterprise.Role, error)
- createRole 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
- setRoleUsers func(ctx context.Context, name string, users []string) error
+ roles func(ctx context.Context, name *string) (*enterprise.Roles, error)
+ role func(ctx context.Context, name string) (*enterprise.Role, error)
+ createRole 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
+ 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) {
@@ -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 {
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)
+}
diff --git a/influx/permissions.go b/influx/permissions.go
index 1548e794f2..809aff9535 100644
--- a/influx/permissions.go
+++ b/influx/permissions.go
@@ -8,8 +8,10 @@ import (
)
var (
- // AllowAll means a user gets both read and write permissions
- AllowAll = chronograf.Allowances{"WRITE", "READ"}
+ // AllowAllDB means a user gets both read and write permissions for a db
+ 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 = chronograf.Allowances{"READ"}
// 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{
{
Scope: chronograf.AllScope,
- Allowed: AllowAll,
+ Allowed: AllowAllAdmin,
},
{
Scope: chronograf.DBScope,
- Allowed: AllowAll,
+ Allowed: AllowAllDB,
},
}
}
@@ -90,7 +92,7 @@ func (r *showResults) Permissions() chronograf.Permissions {
}
switch priv {
case AllPrivileges, All:
- c.Allowed = AllowAll
+ c.Allowed = AllowAllDB
case Read:
c.Allowed = AllowRead
case Write:
@@ -111,7 +113,7 @@ func adminPerms() chronograf.Permissions {
return []chronograf.Permission{
{
Scope: chronograf.AllScope,
- Allowed: AllowAll,
+ Allowed: AllowAllAdmin,
},
}
}
diff --git a/influx/permissions_test.go b/influx/permissions_test.go
index 956e706a89..9aca7aa74e 100644
--- a/influx/permissions_test.go
+++ b/influx/permissions_test.go
@@ -318,7 +318,7 @@ func Test_showResults_Users(t *testing.T) {
Permissions: chronograf.Permissions{
{
Scope: chronograf.AllScope,
- Allowed: chronograf.Allowances{"WRITE", "READ"},
+ Allowed: chronograf.Allowances{"ALL"},
},
},
},
diff --git a/influx/users.go b/influx/users.go
index bca83fec31..a8e10bcfa1 100644
--- a/influx/users.go
+++ b/influx/users.go
@@ -16,8 +16,12 @@ func (c *Client) Add(ctx context.Context, u *chronograf.User) (*chronograf.User,
if err != nil {
return nil, err
}
-
- return u, nil
+ for _, p := range u.Permissions {
+ if err := c.grantPermission(ctx, u.Name, p); err != nil {
+ return nil, err
+ }
+ }
+ return c.Get(ctx, u.Name)
}
// Delete the User from InfluxDB
diff --git a/influx/users_test.go b/influx/users_test.go
index f486e13a95..9922d4e517 100644
--- a/influx/users_test.go
+++ b/influx/users_test.go
@@ -97,12 +97,12 @@ func TestClient_Add(t *testing.T) {
u *chronograf.User
}
tests := []struct {
- name string
- args args
- status int
- want *chronograf.User
- wantQuery string
- wantErr bool
+ name string
+ args args
+ status int
+ want *chronograf.User
+ wantQueries []string
+ wantErr bool
}{
{
name: "Create User",
@@ -114,10 +114,57 @@ func TestClient_Add(t *testing.T) {
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{
- Name: "docbrown",
- Passwd: "Dont Need Roads",
+ Name: "docbrown",
+ 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",
},
},
- wantQuery: `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`,
- wantErr: true,
+ wantQueries: []string{`CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`},
+ wantErr: true,
},
}
for _, tt := range tests {
- query := ""
+ queries := []string{}
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if path := r.URL.Path; path != "/query" {
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.Write([]byte(`{"results":[{}]}`))
+ rw.Write([]byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`))
}))
u, _ := url.Parse(ts.URL)
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)
continue
}
- if tt.wantQuery != query {
- t.Errorf("%q. Client.Add() query = %v, want %v", tt.name, query, tt.wantQuery)
+ if len(tt.wantQueries) != len(queries) {
+ 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) {
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{
chronograf.Permission{
Scope: "all",
- Allowed: []string{"WRITE", "READ"},
+ Allowed: []string{"ALL"},
},
chronograf.Permission{
Scope: "database",
@@ -548,7 +602,7 @@ func TestClient_All(t *testing.T) {
Permissions: chronograf.Permissions{
chronograf.Permission{
Scope: "all",
- Allowed: []string{"WRITE", "READ"},
+ Allowed: []string{"ALL"},
},
chronograf.Permission{
Scope: "database",
@@ -562,7 +616,7 @@ func TestClient_All(t *testing.T) {
Permissions: chronograf.Permissions{
chronograf.Permission{
Scope: "all",
- Allowed: []string{"WRITE", "READ"},
+ Allowed: []string{"ALL"},
},
chronograf.Permission{
Scope: "database",
@@ -688,7 +742,7 @@ func TestClient_Update(t *testing.T) {
Permissions: chronograf.Permissions{
{
Scope: "all",
- Allowed: []string{"WRITE", "READ"},
+ Allowed: []string{"all"},
},
{
Scope: "database",
@@ -743,7 +797,7 @@ func TestClient_Update(t *testing.T) {
Permissions: chronograf.Permissions{
{
Scope: "all",
- Allowed: []string{"WRITE", "READ"},
+ Allowed: []string{"all"},
},
{
Scope: "database",
@@ -800,6 +854,34 @@ func TestClient_Update(t *testing.T) {
`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",
statusUsers: http.StatusBadRequest,
diff --git a/server/admin.go b/server/admin.go
deleted file mode 100644
index 6cc84e0b91..0000000000
--- a/server/admin.go
+++ /dev/null
@@ -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)
-}
diff --git a/server/admin_test.go b/server/admin_test.go
deleted file mode 100644
index ac65f5146b..0000000000
--- a/server/admin_test.go
+++ /dev/null
@@ -1,1476 +0,0 @@
-package server_test
-
-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"
- "github.com/influxdata/chronograf/server"
-)
-
-func TestService_NewSourceUser(t *testing.T) {
- type fields struct {
- SourcesStore chronograf.SourcesStore
- TimeSeries server.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
- },
- UsersF: func(ctx context.Context) chronograf.UsersStore {
- return &mocks.UsersStore{
- AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
- return u, nil
- },
- }
- },
- },
- },
- ID: "1",
- wantStatus: http.StatusCreated,
- wantContentType: "application/json",
- wantBody: `{"name":"marty","links":{"self":"/chronograf/v1/sources/1/users/marty"}}
-`,
- },
- {
- name: "Error adding user",
- 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
- },
- UsersF: func(ctx context.Context) chronograf.UsersStore {
- return &mocks.UsersStore{
- AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
- return nil, fmt.Errorf("Weight Has Nothing to Do With It")
- },
- }
- },
- },
- },
- ID: "1",
- wantStatus: http.StatusBadRequest,
- wantContentType: "application/json",
- wantBody: `{"code":400,"message":"Weight Has Nothing to Do With It"}`,
- },
- {
- name: "Failure connecting to user store",
- 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 fmt.Errorf("Biff just happens to be my supervisor")
- },
- },
- },
- ID: "1",
- wantStatus: http.StatusBadRequest,
- wantContentType: "application/json",
- wantBody: `{"code":400,"message":"Unable to connect to source 1: Biff just happens to be my supervisor"}`,
- },
- {
- name: "Failure getting 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{}, fmt.Errorf("No McFly ever amounted to anything in the history of Hill Valley")
- },
- },
- },
- ID: "1",
- wantStatus: http.StatusNotFound,
- wantContentType: "application/json",
- wantBody: `{"code":404,"message":"ID 1 not found"}`,
- },
- {
- name: "Bad ID",
- 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),
- },
- ID: "BAD",
- wantStatus: http.StatusUnprocessableEntity,
- wantContentType: "application/json",
- wantBody: `{"code":422,"message":"Error converting ID BAD"}`,
- },
- {
- name: "Bad name",
- args: args{
- w: httptest.NewRecorder(),
- r: httptest.NewRequest(
- "POST",
- "http://server.local/chronograf/v1/sources/1",
- ioutil.NopCloser(
- bytes.NewReader([]byte(`{"password": "the_lake"}`)))),
- },
- fields: fields{
- UseAuth: true,
- Logger: log.New(log.DebugLevel),
- },
- ID: "BAD",
- wantStatus: http.StatusUnprocessableEntity,
- wantContentType: "application/json",
- wantBody: `{"code":422,"message":"Username required"}`,
- },
- {
- name: "Bad JSON",
- args: args{
- w: httptest.NewRecorder(),
- r: httptest.NewRequest(
- "POST",
- "http://server.local/chronograf/v1/sources/1",
- ioutil.NopCloser(
- bytes.NewReader([]byte(`{password}`)))),
- },
- fields: fields{
- UseAuth: true,
- Logger: log.New(log.DebugLevel),
- },
- ID: "BAD",
- wantStatus: http.StatusBadRequest,
- wantContentType: "application/json",
- wantBody: `{"code":400,"message":"Unparsable JSON"}`,
- },
- }
- for _, tt := range tests {
- tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
- context.Background(),
- httprouter.Params{
- {
- Key: "id",
- Value: tt.ID,
- },
- }))
-
- h := &server.Service{
- SourcesStore: tt.fields.SourcesStore,
- TimeSeriesClient: tt.fields.TimeSeries,
- Logger: tt.fields.Logger,
- UseAuth: tt.fields.UseAuth,
- }
-
- h.NewSourceUser(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. NewSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
- }
- if tt.wantContentType != "" && content != tt.wantContentType {
- t.Errorf("%q. NewSourceUser() = %v, want %v", tt.name, content, tt.wantContentType)
- }
- if tt.wantBody != "" && string(body) != tt.wantBody {
- t.Errorf("%q. NewSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
- }
- }
-}
-
-func TestService_SourceUsers(t *testing.T) {
- type fields struct {
- SourcesStore chronograf.SourcesStore
- TimeSeries server.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: "All users for data source",
- args: args{
- w: httptest.NewRecorder(),
- r: httptest.NewRequest(
- "GET",
- "http://server.local/chronograf/v1/sources/1",
- nil),
- },
- 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
- },
- UsersF: func(ctx context.Context) chronograf.UsersStore {
- return &mocks.UsersStore{
- AllF: func(ctx context.Context) ([]chronograf.User, error) {
- return []chronograf.User{
- {
- Name: "strickland",
- Passwd: "discipline",
- Permissions: chronograf.Permissions{
- {
- Scope: chronograf.AllScope,
- Allowed: chronograf.Allowances{"READ"},
- },
- },
- },
- }, nil
- },
- }
- },
- },
- },
- ID: "1",
- wantStatus: http.StatusOK,
- wantContentType: "application/json",
- wantBody: `{"users":[{"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"links":{"self":"/chronograf/v1/sources/1/users/strickland"}}]}
-`,
- },
- }
- for _, tt := range tests {
- tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
- context.Background(),
- httprouter.Params{
- {
- Key: "id",
- Value: tt.ID,
- },
- }))
- h := &server.Service{
- SourcesStore: tt.fields.SourcesStore,
- TimeSeriesClient: tt.fields.TimeSeries,
- Logger: tt.fields.Logger,
- UseAuth: tt.fields.UseAuth,
- }
-
- h.SourceUsers(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. SourceUsers() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
- }
- if tt.wantContentType != "" && content != tt.wantContentType {
- t.Errorf("%q. SourceUsers() = %v, want %v", tt.name, content, tt.wantContentType)
- }
- if tt.wantBody != "" && string(body) != tt.wantBody {
- t.Errorf("%q. SourceUsers() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
- }
- }
-}
-
-func TestService_SourceUserID(t *testing.T) {
- type fields struct {
- SourcesStore chronograf.SourcesStore
- TimeSeries server.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
- UID string
- wantStatus int
- wantContentType string
- wantBody string
- }{
- {
- name: "Single user for data source",
- args: args{
- w: httptest.NewRecorder(),
- r: httptest.NewRequest(
- "GET",
- "http://server.local/chronograf/v1/sources/1",
- nil),
- },
- 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
- },
- UsersF: func(ctx context.Context) chronograf.UsersStore {
- return &mocks.UsersStore{
- GetF: func(ctx context.Context, uid string) (*chronograf.User, error) {
- return &chronograf.User{
- Name: "strickland",
- Passwd: "discipline",
- Permissions: chronograf.Permissions{
- {
- Scope: chronograf.AllScope,
- Allowed: chronograf.Allowances{"READ"},
- },
- },
- }, nil
- },
- }
- },
- },
- },
- ID: "1",
- UID: "strickland",
- wantStatus: http.StatusOK,
- wantContentType: "application/json",
- wantBody: `{"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"links":{"self":"/chronograf/v1/sources/1/users/strickland"}}
-`,
- },
- }
- for _, tt := range tests {
- tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
- context.Background(),
- httprouter.Params{
- {
- Key: "id",
- Value: tt.ID,
- },
- }))
- h := &server.Service{
- SourcesStore: tt.fields.SourcesStore,
- TimeSeriesClient: tt.fields.TimeSeries,
- Logger: tt.fields.Logger,
- UseAuth: tt.fields.UseAuth,
- }
-
- h.SourceUserID(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. SourceUserID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
- }
- if tt.wantContentType != "" && content != tt.wantContentType {
- t.Errorf("%q. SourceUserID() = %v, want %v", tt.name, content, tt.wantContentType)
- }
- if tt.wantBody != "" && string(body) != tt.wantBody {
- t.Errorf("%q. SourceUserID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
- }
- }
-}
-
-func TestService_RemoveSourceUser(t *testing.T) {
- type fields struct {
- SourcesStore chronograf.SourcesStore
- TimeSeries server.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
- UID string
- wantStatus int
- wantContentType string
- wantBody string
- }{
- {
- name: "Delete user for data source",
- args: args{
- w: httptest.NewRecorder(),
- r: httptest.NewRequest(
- "GET",
- "http://server.local/chronograf/v1/sources/1",
- nil),
- },
- 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
- },
- UsersF: func(ctx context.Context) chronograf.UsersStore {
- return &mocks.UsersStore{
- DeleteF: func(ctx context.Context, u *chronograf.User) error {
- return nil
- },
- }
- },
- },
- },
- ID: "1",
- UID: "strickland",
- wantStatus: http.StatusNoContent,
- },
- }
- for _, tt := range tests {
- tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
- context.Background(),
- httprouter.Params{
- {
- Key: "id",
- Value: tt.ID,
- },
- }))
- h := &server.Service{
- SourcesStore: tt.fields.SourcesStore,
- TimeSeriesClient: tt.fields.TimeSeries,
- Logger: tt.fields.Logger,
- UseAuth: tt.fields.UseAuth,
- }
- h.RemoveSourceUser(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. RemoveSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
- }
- if tt.wantContentType != "" && content != tt.wantContentType {
- t.Errorf("%q. RemoveSourceUser() = %v, want %v", tt.name, content, tt.wantContentType)
- }
- if tt.wantBody != "" && string(body) != tt.wantBody {
- t.Errorf("%q. RemoveSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
- }
- }
-}
-
-func TestService_UpdateSourceUser(t *testing.T) {
- type fields struct {
- SourcesStore chronograf.SourcesStore
- TimeSeries server.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
- UID string
- wantStatus int
- wantContentType string
- wantBody string
- }{
- {
- name: "Update user password 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
- },
- UsersF: func(ctx context.Context) chronograf.UsersStore {
- return &mocks.UsersStore{
- UpdateF: func(ctx context.Context, u *chronograf.User) error {
- return nil
- },
- }
- },
- },
- },
- ID: "1",
- UID: "marty",
- wantStatus: http.StatusOK,
- wantContentType: "application/json",
- wantBody: `{"name":"marty","links":{"self":"/chronograf/v1/sources/1/users/marty"}}
-`,
- },
- {
- name: "Invalid update JSON",
- args: args{
- w: httptest.NewRecorder(),
- r: httptest.NewRequest(
- "POST",
- "http://server.local/chronograf/v1/sources/1",
- ioutil.NopCloser(
- bytes.NewReader([]byte(`{"name": "marty"}`)))),
- },
- fields: fields{
- UseAuth: true,
- Logger: log.New(log.DebugLevel),
- },
- ID: "1",
- UID: "marty",
- wantStatus: http.StatusUnprocessableEntity,
- wantContentType: "application/json",
- wantBody: `{"code":422,"message":"No fields to update"}`,
- },
- }
- for _, tt := range tests {
- tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
- context.Background(),
- httprouter.Params{
- {
- Key: "id",
- Value: tt.ID,
- },
- {
- Key: "uid",
- Value: tt.UID,
- },
- }))
- h := &server.Service{
- SourcesStore: tt.fields.SourcesStore,
- TimeSeriesClient: tt.fields.TimeSeries,
- Logger: tt.fields.Logger,
- UseAuth: tt.fields.UseAuth,
- }
- h.UpdateSourceUser(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. UpdateSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
- }
- if tt.wantContentType != "" && content != tt.wantContentType {
- t.Errorf("%q. UpdateSourceUser() = %v, want %v", tt.name, content, tt.wantContentType)
- }
- if tt.wantBody != "" && string(body) != tt.wantBody {
- t.Errorf("%q. UpdateSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
- }
- }
-}
-
-func TestService_Permissions(t *testing.T) {
- type fields struct {
- SourcesStore chronograf.SourcesStore
- TimeSeries server.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 := &server.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)
- }
- }
-}
-
-func TestService_NewSourceRole(t *testing.T) {
- type fields struct {
- SourcesStore chronograf.SourcesStore
- TimeSeries server.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")
- },
- }, 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
- },
- }, nil
- },
- },
- },
- ID: "1",
- wantStatus: http.StatusCreated,
- wantContentType: "application/json",
- wantBody: `{"users":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/3-d"}}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}
-`,
- },
- }
- for _, tt := range tests {
- h := &server.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 server.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":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/3-d"}}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}
-`,
- },
- }
- for _, tt := range tests {
- h := &server.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 server.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":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/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 := &server.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 server.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 := &server.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 server.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":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/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 := &server.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)
- }
- }
-}
diff --git a/server/me.go b/server/me.go
new file mode 100644
index 0000000000..48d66ed6b2
--- /dev/null
+++ b/server/me.go
@@ -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)
+}
diff --git a/server/me_test.go b/server/me_test.go
new file mode 100644
index 0000000000..147bf8f3ae
--- /dev/null
+++ b/server/me_test.go
@@ -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)
+ }
+ }
+}
diff --git a/server/permissions.go b/server/permissions.go
new file mode 100644
index 0000000000..cd353d8b24
--- /dev/null
+++ b/server/permissions.go
@@ -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
+}
diff --git a/server/permissions_test.go b/server/permissions_test.go
new file mode 100644
index 0000000000..092a89c45b
--- /dev/null
+++ b/server/permissions_test.go
@@ -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)
+ }
+ }
+}
diff --git a/server/roles.go b/server/roles.go
new file mode 100644
index 0000000000..d738cbc42f
--- /dev/null
+++ b/server/roles.go
@@ -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),
+ }
+}
diff --git a/server/roles_test.go b/server/roles_test.go
new file mode 100644
index 0000000000..7f6da27c27
--- /dev/null
+++ b/server/roles_test.go
@@ -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)
+ }
+ }
+}
diff --git a/server/users.go b/server/users.go
index bbf4f61db1..cacf5da01b 100644
--- a/server/users.go
+++ b/server/users.go
@@ -1,99 +1,317 @@
package server
import (
+ "context"
+ "encoding/json"
"fmt"
"net/http"
"net/url"
- "golang.org/x/net/context"
-
+ "github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
- "github.com/influxdata/chronograf/oauth2"
)
-type userLinks struct {
- Self string `json:"self"` // Self link mapping to this resource
-}
-
-type userResponse struct {
- *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)
+// NewSourceUser adds user to source
+func (h *Service) NewSourceUser(w http.ResponseWriter, r *http.Request) {
+ var req userRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ invalidJSON(w, h.Logger)
return
}
- email, err := getEmail(ctx)
- if err != nil {
+ if err := req.ValidCreate(); err != nil {
invalidData(w, err, h.Logger)
return
}
- usr, err := h.UsersStore.Get(ctx, email)
- if err == nil {
- 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)
+ ctx := r.Context()
+ srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
- msg := fmt.Errorf("error storing user %s: %v", user.Name, err)
- unknownErrorWithMessage(w, msg, h.Logger)
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)
}
+
+// 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),
+ }
+}
diff --git a/server/users_test.go b/server/users_test.go
index 147bf8f3ae..c46106d667 100644
--- a/server/users_test.go
+++ b/server/users_test.go
@@ -1,6 +1,7 @@
-package server
+package server_test
import (
+ "bytes"
"context"
"fmt"
"io/ioutil"
@@ -8,19 +9,19 @@ import (
"net/http/httptest"
"testing"
+ "github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/log"
"github.com/influxdata/chronograf/mocks"
- "github.com/influxdata/chronograf/oauth2"
+ "github.com/influxdata/chronograf/server"
)
-type MockUsers struct{}
-
-func TestService_Me(t *testing.T) {
+func TestService_NewSourceUser(t *testing.T) {
type fields struct {
- UsersStore chronograf.UsersStore
- Logger chronograf.Logger
- UseAuth bool
+ SourcesStore chronograf.SourcesStore
+ TimeSeries server.TimeSeriesClient
+ Logger chronograf.Logger
+ UseAuth bool
}
type args struct {
w *httptest.ResponseRecorder
@@ -30,139 +31,900 @@ func TestService_Me(t *testing.T) {
name string
fields fields
args args
- principal oauth2.Principal
+ ID string
wantStatus int
wantContentType string
wantBody string
}{
{
- name: "Existing user",
+ name: "New user for data source",
args: args{
w: httptest.NewRecorder(),
- r: httptest.NewRequest("GET", "http://example.com/foo", nil),
+ 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,
- UsersStore: &mocks.UsersStore{
- GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
- return &chronograf.User{
- Name: "me",
- Passwd: "hunter2",
+ 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
},
},
- },
- 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")
+ TimeSeries: &mocks.TimeSeries{
+ ConnectF: func(ctx context.Context, src *chronograf.Source) error {
+ return nil
},
- AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
- return u, nil
+ UsersF: func(ctx context.Context) chronograf.UsersStore {
+ return &mocks.UsersStore{
+ AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
+ return u, nil
+ },
+ }
+ },
+ RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
+ return nil, fmt.Errorf("no roles")
},
},
},
- principal: oauth2.Principal{
- Subject: "secret",
- },
- wantStatus: http.StatusOK,
+ ID: "1",
+ wantStatus: http.StatusCreated,
wantContentType: "application/json",
- wantBody: `{"name":"secret","password":"","links":{"self":"/chronograf/v1/users/secret"}}
+ wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[]}
+`,
+ },
+ {
+ name: "New user for data source with roles",
+ 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
+ },
+ UsersF: func(ctx context.Context) chronograf.UsersStore {
+ return &mocks.UsersStore{
+ AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
+ return u, nil
+ },
+ }
+ },
+ RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
+ return nil, nil
+ },
+ },
+ },
+ ID: "1",
+ wantStatus: http.StatusCreated,
+ wantContentType: "application/json",
+ wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[],"roles":[]}
`,
},
{
name: "Error adding user",
args: args{
w: httptest.NewRecorder(),
- r: httptest.NewRequest("GET", "http://example.com/foo", nil),
+ 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,
- 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),
+ 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
+ },
+ UsersF: func(ctx context.Context) chronograf.UsersStore {
+ return &mocks.UsersStore{
+ AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
+ return nil, fmt.Errorf("Weight Has Nothing to Do With It")
+ },
+ }
},
},
- Logger: log.New(log.DebugLevel),
},
- principal: oauth2.Principal{
- Subject: "secret",
- },
- wantStatus: http.StatusInternalServerError,
+ ID: "1",
+ wantStatus: http.StatusBadRequest,
wantContentType: "application/json",
- wantBody: `{"code":500,"message":"Unknown error: error storing user secret: Why Heavy?"}`,
+ wantBody: `{"code":400,"message":"Weight Has Nothing to Do With It"}`,
},
{
- name: "No Auth",
+ name: "Failure connecting to user store",
args: args{
w: httptest.NewRecorder(),
- r: httptest.NewRequest("GET", "http://example.com/foo", nil),
+ r: httptest.NewRequest(
+ "POST",
+ "http://server.local/chronograf/v1/sources/1",
+ ioutil.NopCloser(
+ bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))),
},
fields: fields{
- UseAuth: false,
+ 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 fmt.Errorf("Biff just happens to be my supervisor")
+ },
+ },
},
- wantStatus: http.StatusOK,
+ ID: "1",
+ wantStatus: http.StatusBadRequest,
wantContentType: "application/json",
- wantBody: `{"links":{"self":"/chronograf/v1/users/me"}}
-`,
+ wantBody: `{"code":400,"message":"Unable to connect to source 1: Biff just happens to be my supervisor"}`,
},
{
- name: "Empty Principal",
+ name: "Failure getting source",
args: args{
w: httptest.NewRecorder(),
- r: httptest.NewRequest("GET", "http://example.com/foo", nil),
+ 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{}, fmt.Errorf("No McFly ever amounted to anything in the history of Hill Valley")
+ },
+ },
+ },
+ ID: "1",
+ wantStatus: http.StatusNotFound,
+ wantContentType: "application/json",
+ wantBody: `{"code":404,"message":"ID 1 not found"}`,
+ },
+ {
+ name: "Bad ID",
+ 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),
},
- wantStatus: http.StatusUnprocessableEntity,
- principal: oauth2.Principal{
- Subject: "",
+ ID: "BAD",
+ wantStatus: http.StatusUnprocessableEntity,
+ wantContentType: "application/json",
+ wantBody: `{"code":422,"message":"Error converting ID BAD"}`,
+ },
+ {
+ name: "Bad name",
+ args: args{
+ w: httptest.NewRecorder(),
+ r: httptest.NewRequest(
+ "POST",
+ "http://server.local/chronograf/v1/sources/1",
+ ioutil.NopCloser(
+ bytes.NewReader([]byte(`{"password": "the_lake"}`)))),
},
+ fields: fields{
+ UseAuth: true,
+ Logger: log.New(log.DebugLevel),
+ },
+ ID: "BAD",
+ wantStatus: http.StatusUnprocessableEntity,
+ wantContentType: "application/json",
+ wantBody: `{"code":422,"message":"Username required"}`,
+ },
+ {
+ name: "Bad JSON",
+ args: args{
+ w: httptest.NewRecorder(),
+ r: httptest.NewRequest(
+ "POST",
+ "http://server.local/chronograf/v1/sources/1",
+ ioutil.NopCloser(
+ bytes.NewReader([]byte(`{password}`)))),
+ },
+ fields: fields{
+ UseAuth: true,
+ Logger: log.New(log.DebugLevel),
+ },
+ ID: "BAD",
+ wantStatus: http.StatusBadRequest,
+ wantContentType: "application/json",
+ wantBody: `{"code":400,"message":"Unparsable JSON"}`,
},
}
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,
+ tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
+ context.Background(),
+ httprouter.Params{
+ {
+ Key: "id",
+ Value: tt.ID,
+ },
+ }))
+
+ h := &server.Service{
+ SourcesStore: tt.fields.SourcesStore,
+ TimeSeriesClient: tt.fields.TimeSeries,
+ Logger: tt.fields.Logger,
+ UseAuth: tt.fields.UseAuth,
}
- h.Me(tt.args.w, tt.args.r)
+ h.NewSourceUser(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)
+ t.Errorf("%q. NewSourceUser() = %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)
+ t.Errorf("%q. NewSourceUser() = %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)
+ t.Errorf("%q. NewSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
+ }
+ }
+}
+
+func TestService_SourceUsers(t *testing.T) {
+ type fields struct {
+ SourcesStore chronograf.SourcesStore
+ TimeSeries server.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: "All users for data source",
+ args: args{
+ w: httptest.NewRecorder(),
+ r: httptest.NewRequest(
+ "GET",
+ "http://server.local/chronograf/v1/sources/1",
+ nil),
+ },
+ 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
+ },
+ RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
+ return nil, fmt.Errorf("no roles")
+ },
+ UsersF: func(ctx context.Context) chronograf.UsersStore {
+ return &mocks.UsersStore{
+ AllF: func(ctx context.Context) ([]chronograf.User, error) {
+ return []chronograf.User{
+ {
+ Name: "strickland",
+ Passwd: "discipline",
+ Permissions: chronograf.Permissions{
+ {
+ Scope: chronograf.AllScope,
+ Allowed: chronograf.Allowances{"READ"},
+ },
+ },
+ },
+ }, nil
+ },
+ }
+ },
+ },
+ },
+ ID: "1",
+ wantStatus: http.StatusOK,
+ wantContentType: "application/json",
+ wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}]}]}
+`,
+ },
+ {
+ name: "All users for data source with roles",
+ args: args{
+ w: httptest.NewRecorder(),
+ r: httptest.NewRequest(
+ "GET",
+ "http://server.local/chronograf/v1/sources/1",
+ nil),
+ },
+ 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
+ },
+ RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
+ return nil, nil
+ },
+ UsersF: func(ctx context.Context) chronograf.UsersStore {
+ return &mocks.UsersStore{
+ AllF: func(ctx context.Context) ([]chronograf.User, error) {
+ return []chronograf.User{
+ {
+ Name: "strickland",
+ Passwd: "discipline",
+ Permissions: chronograf.Permissions{
+ {
+ Scope: chronograf.AllScope,
+ Allowed: chronograf.Allowances{"READ"},
+ },
+ },
+ },
+ }, nil
+ },
+ }
+ },
+ },
+ },
+ ID: "1",
+ wantStatus: http.StatusOK,
+ wantContentType: "application/json",
+ wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"roles":[]}]}
+`,
+ },
+ }
+ for _, tt := range tests {
+ tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
+ context.Background(),
+ httprouter.Params{
+ {
+ Key: "id",
+ Value: tt.ID,
+ },
+ }))
+ h := &server.Service{
+ SourcesStore: tt.fields.SourcesStore,
+ TimeSeriesClient: tt.fields.TimeSeries,
+ Logger: tt.fields.Logger,
+ UseAuth: tt.fields.UseAuth,
+ }
+
+ h.SourceUsers(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. SourceUsers() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
+ }
+ if tt.wantContentType != "" && content != tt.wantContentType {
+ t.Errorf("%q. SourceUsers() = %v, want %v", tt.name, content, tt.wantContentType)
+ }
+ if tt.wantBody != "" && string(body) != tt.wantBody {
+ t.Errorf("%q. SourceUsers() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
+ }
+ }
+}
+
+func TestService_SourceUserID(t *testing.T) {
+ type fields struct {
+ SourcesStore chronograf.SourcesStore
+ TimeSeries server.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
+ UID string
+ wantStatus int
+ wantContentType string
+ wantBody string
+ }{
+ {
+ name: "Single user for data source",
+ args: args{
+ w: httptest.NewRecorder(),
+ r: httptest.NewRequest(
+ "GET",
+ "http://server.local/chronograf/v1/sources/1",
+ nil),
+ },
+ 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
+ },
+ RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
+ return nil, fmt.Errorf("no roles")
+ },
+ UsersF: func(ctx context.Context) chronograf.UsersStore {
+ return &mocks.UsersStore{
+ GetF: func(ctx context.Context, uid string) (*chronograf.User, error) {
+ return &chronograf.User{
+ Name: "strickland",
+ Passwd: "discipline",
+ Permissions: chronograf.Permissions{
+ {
+ Scope: chronograf.AllScope,
+ Allowed: chronograf.Allowances{"READ"},
+ },
+ },
+ }, nil
+ },
+ }
+ },
+ },
+ },
+ ID: "1",
+ UID: "strickland",
+ wantStatus: http.StatusOK,
+ wantContentType: "application/json",
+ wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}]}
+`,
+ },
+ {
+ name: "Single user for data source",
+ args: args{
+ w: httptest.NewRecorder(),
+ r: httptest.NewRequest(
+ "GET",
+ "http://server.local/chronograf/v1/sources/1",
+ nil),
+ },
+ 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
+ },
+ RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
+ return nil, nil
+ },
+ UsersF: func(ctx context.Context) chronograf.UsersStore {
+ return &mocks.UsersStore{
+ GetF: func(ctx context.Context, uid string) (*chronograf.User, error) {
+ return &chronograf.User{
+ Name: "strickland",
+ Passwd: "discipline",
+ Permissions: chronograf.Permissions{
+ {
+ Scope: chronograf.AllScope,
+ Allowed: chronograf.Allowances{"READ"},
+ },
+ },
+ }, nil
+ },
+ }
+ },
+ },
+ },
+ ID: "1",
+ UID: "strickland",
+ wantStatus: http.StatusOK,
+ wantContentType: "application/json",
+ wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"roles":[]}
+`,
+ },
+ }
+ for _, tt := range tests {
+ tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
+ context.Background(),
+ httprouter.Params{
+ {
+ Key: "id",
+ Value: tt.ID,
+ },
+ }))
+ h := &server.Service{
+ SourcesStore: tt.fields.SourcesStore,
+ TimeSeriesClient: tt.fields.TimeSeries,
+ Logger: tt.fields.Logger,
+ UseAuth: tt.fields.UseAuth,
+ }
+
+ h.SourceUserID(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. SourceUserID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
+ }
+ if tt.wantContentType != "" && content != tt.wantContentType {
+ t.Errorf("%q. SourceUserID() = %v, want %v", tt.name, content, tt.wantContentType)
+ }
+ if tt.wantBody != "" && string(body) != tt.wantBody {
+ t.Errorf("%q. SourceUserID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
+ }
+ }
+}
+
+func TestService_RemoveSourceUser(t *testing.T) {
+ type fields struct {
+ SourcesStore chronograf.SourcesStore
+ TimeSeries server.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
+ UID string
+ wantStatus int
+ wantContentType string
+ wantBody string
+ }{
+ {
+ name: "Delete user for data source",
+ args: args{
+ w: httptest.NewRecorder(),
+ r: httptest.NewRequest(
+ "GET",
+ "http://server.local/chronograf/v1/sources/1",
+ nil),
+ },
+ 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
+ },
+ UsersF: func(ctx context.Context) chronograf.UsersStore {
+ return &mocks.UsersStore{
+ DeleteF: func(ctx context.Context, u *chronograf.User) error {
+ return nil
+ },
+ }
+ },
+ },
+ },
+ ID: "1",
+ UID: "strickland",
+ wantStatus: http.StatusNoContent,
+ },
+ }
+ for _, tt := range tests {
+ tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
+ context.Background(),
+ httprouter.Params{
+ {
+ Key: "id",
+ Value: tt.ID,
+ },
+ }))
+ h := &server.Service{
+ SourcesStore: tt.fields.SourcesStore,
+ TimeSeriesClient: tt.fields.TimeSeries,
+ Logger: tt.fields.Logger,
+ UseAuth: tt.fields.UseAuth,
+ }
+ h.RemoveSourceUser(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. RemoveSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
+ }
+ if tt.wantContentType != "" && content != tt.wantContentType {
+ t.Errorf("%q. RemoveSourceUser() = %v, want %v", tt.name, content, tt.wantContentType)
+ }
+ if tt.wantBody != "" && string(body) != tt.wantBody {
+ t.Errorf("%q. RemoveSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
+ }
+ }
+}
+
+func TestService_UpdateSourceUser(t *testing.T) {
+ type fields struct {
+ SourcesStore chronograf.SourcesStore
+ TimeSeries server.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
+ UID string
+ wantStatus int
+ wantContentType string
+ wantBody string
+ }{
+ {
+ name: "Update user password 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
+ },
+ RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
+ return nil, fmt.Errorf("no roles")
+ },
+ UsersF: func(ctx context.Context) chronograf.UsersStore {
+ return &mocks.UsersStore{
+ UpdateF: func(ctx context.Context, u *chronograf.User) error {
+ return nil
+ },
+ GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
+ return &chronograf.User{
+ Name: "marty",
+ }, nil
+ },
+ }
+ },
+ },
+ },
+ ID: "1",
+ UID: "marty",
+ wantStatus: http.StatusOK,
+ wantContentType: "application/json",
+ wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[]}
+`,
+ },
+ {
+ name: "Update user password for data source with roles",
+ 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
+ },
+ RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
+ return nil, nil
+ },
+ UsersF: func(ctx context.Context) chronograf.UsersStore {
+ return &mocks.UsersStore{
+ UpdateF: func(ctx context.Context, u *chronograf.User) error {
+ return nil
+ },
+ GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
+ return &chronograf.User{
+ Name: "marty",
+ }, nil
+ },
+ }
+ },
+ },
+ },
+ ID: "1",
+ UID: "marty",
+ wantStatus: http.StatusOK,
+ wantContentType: "application/json",
+ wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[],"roles":[]}
+`,
+ },
+ {
+ name: "Invalid update JSON",
+ args: args{
+ w: httptest.NewRecorder(),
+ r: httptest.NewRequest(
+ "POST",
+ "http://server.local/chronograf/v1/sources/1",
+ ioutil.NopCloser(
+ bytes.NewReader([]byte(`{"name": "marty"}`)))),
+ },
+ fields: fields{
+ UseAuth: true,
+ Logger: log.New(log.DebugLevel),
+ },
+ ID: "1",
+ UID: "marty",
+ wantStatus: http.StatusUnprocessableEntity,
+ wantContentType: "application/json",
+ wantBody: `{"code":422,"message":"No fields to update"}`,
+ },
+ }
+ for _, tt := range tests {
+ tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
+ context.Background(),
+ httprouter.Params{
+ {
+ Key: "id",
+ Value: tt.ID,
+ },
+ {
+ Key: "uid",
+ Value: tt.UID,
+ },
+ }))
+ h := &server.Service{
+ SourcesStore: tt.fields.SourcesStore,
+ TimeSeriesClient: tt.fields.TimeSeries,
+ Logger: tt.fields.Logger,
+ UseAuth: tt.fields.UseAuth,
+ }
+ h.UpdateSourceUser(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. UpdateSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
+ }
+ if tt.wantContentType != "" && content != tt.wantContentType {
+ t.Errorf("%q. UpdateSourceUser() = %v, want %v", tt.name, content, tt.wantContentType)
+ }
+ if tt.wantBody != "" && string(body) != tt.wantBody {
+ t.Errorf("%q. UpdateSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}
diff --git a/ui/.eslintrc b/ui/.eslintrc
index a0605ff3a0..564d38c56a 100644
--- a/ui/.eslintrc
+++ b/ui/.eslintrc
@@ -46,7 +46,7 @@
'arrow-parens': 0,
'comma-dangle': [2, 'always-multiline'],
'no-cond-assign': 2,
- 'no-console': 2,
+ 'no-console': ['error', {allow: ['error']}],
'no-constant-condition': 2,
'no-control-regex': 2,
'no-debugger': 2,
diff --git a/ui/corsless.js b/ui/corsless.js
deleted file mode 100644
index 8db30d407b..0000000000
--- a/ui/corsless.js
+++ /dev/null
@@ -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')
-});
diff --git a/ui/package.json b/ui/package.json
index 765e675060..a969d82787 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -17,9 +17,7 @@
"test:lint": "npm run lint; npm run test",
"test:dev": "nodemon --exec npm run test:lint",
"clean": "rm -rf build",
- "storybook": "start-storybook -p 6006",
- "build-storybook": "build-storybook",
- "proxy": "node ./corsless"
+ "storybook": "node ./storybook"
},
"author": "",
"eslintConfig": {
diff --git a/ui/spec/admin/reducers/adminSpec.js b/ui/spec/admin/reducers/adminSpec.js
new file mode 100644
index 0000000000..9c293b3ddd
--- /dev/null
+++ b/ui/spec/admin/reducers/adminSpec.js
@@ -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)
+ })
+})
diff --git a/ui/src/admin/actions/index.js b/ui/src/admin/actions/index.js
new file mode 100644
index 0000000000..a662ca8acd
--- /dev/null
+++ b/ui/src/admin/actions/index.js
@@ -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}`))
+ }
+}
diff --git a/ui/src/admin/apis/index.js b/ui/src/admin/apis/index.js
new file mode 100644
index 0000000000..6c7d747ae1
--- /dev/null
+++ b/ui/src/admin/apis/index.js
@@ -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
+ }
+}
diff --git a/ui/src/admin/components/AdminTabs.js b/ui/src/admin/components/AdminTabs.js
new file mode 100644
index 0000000000..da9e1f0d21
--- /dev/null
+++ b/ui/src/admin/components/AdminTabs.js
@@ -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: (
+
+ ),
+ },
+ {
+ type: 'Roles',
+ component: (
+
+ ),
+ },
+ {
+ type: 'Queries',
+ component: (),
+ },
+ ]
+
+ if (!hasRoles) {
+ tabs = tabs.filter(t => t.type !== 'Roles')
+ }
+
+ return (
+
+
+ {
+ tabs.map((t, i) => ({tabs[i].type}))
+ }
+
+
+ {
+ tabs.map((t, i) => ({t.component}))
+ }
+
+
+ )
+}
+
+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
diff --git a/ui/src/admin/components/ConfirmButtons.js b/ui/src/admin/components/ConfirmButtons.js
new file mode 100644
index 0000000000..a1f93218ce
--- /dev/null
+++ b/ui/src/admin/components/ConfirmButtons.js
@@ -0,0 +1,31 @@
+import React, {PropTypes} from 'react'
+
+const ConfirmButtons = ({onConfirm, item, onCancel}) => (
+
+
+
+
+)
+
+const {
+ func,
+ shape,
+} = PropTypes
+
+ConfirmButtons.propTypes = {
+ onConfirm: func.isRequired,
+ item: shape({}).isRequired,
+ onCancel: func.isRequired,
+}
+
+export default ConfirmButtons
diff --git a/ui/src/admin/components/DeleteRow.js b/ui/src/admin/components/DeleteRow.js
new file mode 100644
index 0000000000..a22c2ee0c2
--- /dev/null
+++ b/ui/src/admin/components/DeleteRow.js
@@ -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}) => (
+
+)
+
+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 (
+
+ )
+ }
+
+ return (
+
+ )
+ }
+}
+
+const {
+ func,
+ shape,
+} = PropTypes
+
+DeleteButton.propTypes = {
+ onConfirm: func.isRequired,
+}
+
+DeleteRow.propTypes = {
+ item: shape({}),
+ onDelete: func.isRequired,
+}
+
+export default OnClickOutside(DeleteRow)
diff --git a/ui/src/admin/components/EmptyRow.js b/ui/src/admin/components/EmptyRow.js
new file mode 100644
index 0000000000..402e4eb4ca
--- /dev/null
+++ b/ui/src/admin/components/EmptyRow.js
@@ -0,0 +1,19 @@
+import React, {PropTypes} from 'react'
+
+const EmptyRow = ({tableName}) => (
+
+
+ You don't have any {tableName}, why not create one?
+ |
+
+)
+
+const {
+ string,
+} = PropTypes
+
+EmptyRow.propTypes = {
+ tableName: string.isRequired,
+}
+
+export default EmptyRow
\ No newline at end of file
diff --git a/ui/src/admin/components/FilterBar.js b/ui/src/admin/components/FilterBar.js
new file mode 100644
index 0000000000..e8cdae1a7b
--- /dev/null
+++ b/ui/src/admin/components/FilterBar.js
@@ -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 (
+
+
+
+
+ )
+ }
+}
+
+const {
+ bool,
+ func,
+ string,
+} = PropTypes
+
+FilterBar.propTypes = {
+ onFilter: func.isRequired,
+ type: string,
+ isEditing: bool,
+ onClickCreate: func,
+}
+
+export default FilterBar
diff --git a/ui/src/admin/components/QueriesTable.js b/ui/src/admin/components/QueriesTable.js
new file mode 100644
index 0000000000..902df2b0ce
--- /dev/null
+++ b/ui/src/admin/components/QueriesTable.js
@@ -0,0 +1,67 @@
+import React, {PropTypes} from 'react'
+
+const QueriesTable = ({queries, onKillQuery, onConfirm}) => (
+
+
+
+
+
+
+ Database |
+ Query |
+ Running |
+ |
+
+
+
+ {queries.map((q) => {
+ return (
+
+ {q.database} |
+ {q.query} |
+ {q.duration} |
+
+
+ |
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
Are you sure you want to kill this query?
+
+
+
+
+
+
+
+
+
+)
+
+const {
+ arrayOf,
+ func,
+ shape,
+} = PropTypes
+
+QueriesTable.propTypes = {
+ queries: arrayOf(shape()),
+ onConfirm: func,
+ onKillQuery: func,
+}
+
+export default QueriesTable
diff --git a/ui/src/admin/components/RoleEditingRow.js b/ui/src/admin/components/RoleEditingRow.js
new file mode 100644
index 0000000000..8545fe0205
--- /dev/null
+++ b/ui/src/admin/components/RoleEditingRow.js
@@ -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 (
+
+
+
+
+ |
+ )
+ }
+}
+
+const {
+ bool,
+ func,
+ shape,
+} = PropTypes
+
+RoleEditingRow.propTypes = {
+ role: shape().isRequired,
+ isNew: bool,
+ onEdit: func.isRequired,
+ onSave: func.isRequired,
+}
+
+export default RoleEditingRow
diff --git a/ui/src/admin/components/RoleRow.js b/ui/src/admin/components/RoleRow.js
new file mode 100644
index 0000000000..669a76e568
--- /dev/null
+++ b/ui/src/admin/components/RoleRow.js
@@ -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 (
+
+
+ |
+ |
+
+
+ |
+
+ )
+ }
+
+ return (
+
+ {name} |
+
+ {
+ allPermissions && allPermissions.length ?
+ : null
+ }
+ |
+
+ {
+ allUsers && allUsers.length ?
+ u.name)}
+ selectedItems={users === undefined ? [] : users.map((u) => u.name)}
+ label={users && users.length ? '' : 'Select Users'}
+ onApply={handleUpdateUsers}
+ /> : null
+ }
+ |
+
+
+ |
+
+ )
+}
+
+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
diff --git a/ui/src/admin/components/RolesTable.js b/ui/src/admin/components/RolesTable.js
new file mode 100644
index 0000000000..73409a7b6f
--- /dev/null
+++ b/ui/src/admin/components/RolesTable.js
@@ -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,
+}) => (
+
+
+
+
+
+
+ Name |
+ Permissions |
+ Users |
+ |
+
+
+
+ {
+ roles.length ?
+ roles.filter(r => !r.hidden).map((role) =>
+
+ ) :
+ }
+
+
+
+
+)
+
+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
diff --git a/ui/src/admin/components/UserEditingRow.js b/ui/src/admin/components/UserEditingRow.js
new file mode 100644
index 0000000000..01e1083182
--- /dev/null
+++ b/ui/src/admin/components/UserEditingRow.js
@@ -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 (
+
+
+
+ {
+ isNew ?
+ :
+ null
+ }
+
+ |
+ )
+ }
+}
+
+const {
+ bool,
+ func,
+ shape,
+} = PropTypes
+
+UserEditingRow.propTypes = {
+ user: shape().isRequired,
+ isNew: bool,
+ onEdit: func.isRequired,
+ onSave: func.isRequired,
+}
+
+export default UserEditingRow
diff --git a/ui/src/admin/components/UserRow.js b/ui/src/admin/components/UserRow.js
new file mode 100644
index 0000000000..6d5e513c30
--- /dev/null
+++ b/ui/src/admin/components/UserRow.js
@@ -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 (
+
+
+ {hasRoles ? | : null}
+ |
+
+
+ |
+
+ )
+ }
+
+ return (
+
+ {name} |
+ {
+ hasRoles ?
+
+ 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}
+ />
+ | :
+ null
+ }
+
+ {
+ allPermissions && allPermissions.length ?
+ : null
+ }
+ |
+
+
+ |
+
+ )
+}
+
+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
diff --git a/ui/src/admin/components/UsersTable.js b/ui/src/admin/components/UsersTable.js
new file mode 100644
index 0000000000..a8bd24e834
--- /dev/null
+++ b/ui/src/admin/components/UsersTable.js
@@ -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,
+}) => (
+
+
+
+
+
+
+ User |
+ {hasRoles && Roles | }
+ Permissions |
+ |
+
+
+
+ {
+ users.length ?
+ users.filter(u => !u.hidden).map(user =>
+ ) :
+
+ }
+
+
+
+
+)
+
+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
diff --git a/ui/src/admin/constants/index.js b/ui/src/admin/constants/index.js
new file mode 100644
index 0000000000..6ed9a315d1
--- /dev/null
+++ b/ui/src/admin/constants/index.js
@@ -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
diff --git a/ui/src/admin/containers/AdminPage.js b/ui/src/admin/containers/AdminPage.js
new file mode 100644
index 0000000000..3ec74b6350
--- /dev/null
+++ b/ui/src/admin/containers/AdminPage.js
@@ -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 (
+
+
+
+
+
+ {
+ users ?
+
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}
+ /> :
+ Loading...
+ }
+
+
+
+
+ )
+ }
+}
+
+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)
diff --git a/ui/src/admin/containers/QueriesPage.js b/ui/src/admin/containers/QueriesPage.js
new file mode 100644
index 0000000000..868df3816e
--- /dev/null
+++ b/ui/src/admin/containers/QueriesPage.js
@@ -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 (
+
+ );
+ }
+
+ 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)
diff --git a/ui/src/admin/index.js b/ui/src/admin/index.js
new file mode 100644
index 0000000000..f17834228c
--- /dev/null
+++ b/ui/src/admin/index.js
@@ -0,0 +1,2 @@
+import AdminPage from './containers/AdminPage';
+export {AdminPage};
diff --git a/ui/src/admin/reducers/admin.js b/ui/src/admin/reducers/admin.js
new file mode 100644
index 0000000000..189a18a599
--- /dev/null
+++ b/ui/src/admin/reducers/admin.js
@@ -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
+}
diff --git a/ui/src/alerts/containers/AlertsApp.js b/ui/src/alerts/containers/AlertsApp.js
index 3e27cd5c68..d58752bee8 100644
--- a/ui/src/alerts/containers/AlertsApp.js
+++ b/ui/src/alerts/containers/AlertsApp.js
@@ -6,10 +6,6 @@ import AJAX from 'utils/ajax';
import _ from 'lodash';
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({
propTypes: {
source: PropTypes.shape({
diff --git a/ui/src/data_explorer/components/FieldListItem.js b/ui/src/data_explorer/components/FieldListItem.js
index 5a0de1c6dd..c532d37891 100644
--- a/ui/src/data_explorer/components/FieldListItem.js
+++ b/ui/src/data_explorer/components/FieldListItem.js
@@ -2,7 +2,7 @@ import React, {PropTypes} from 'react';
import classNames from 'classnames';
import _ from 'lodash';
-import MultiSelectDropdown from './MultiSelectDropdown';
+import MultiSelectDropdown from 'src/shared/components/MultiSelectDropdown';
import Dropdown from 'src/shared/components/Dropdown';
import {INFLUXQL_FUNCTIONS} from '../constants';
diff --git a/ui/src/data_explorer/components/MultiSelectDropdown.js b/ui/src/data_explorer/components/MultiSelectDropdown.js
deleted file mode 100644
index d48f7c7dbd..0000000000
--- a/ui/src/data_explorer/components/MultiSelectDropdown.js
+++ /dev/null
@@ -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 (
-
-
-
- {
- localSelectedItems.length ? localSelectedItems.map((s) => s).join(', ') : labelText
- }
-
-
-
- {this.renderMenu()}
-
- );
- },
-
- renderMenu() {
- const {items} = this.props;
-
- return (
-
-
- Apply
-
-
- {items.map((listItem, i) => {
- return (
- -
- {listItem}
-
- );
- })}
-
-
- );
- },
-});
-
-export default OnClickOutside(MultiSelectDropdown);
diff --git a/ui/src/index.js b/ui/src/index.js
index e45201ad6d..d8eb4fde54 100644
--- a/ui/src/index.js
+++ b/ui/src/index.js
@@ -14,6 +14,7 @@ import {KapacitorPage, KapacitorRulePage, KapacitorRulesPage, KapacitorTasksPage
import DataExplorer from 'src/data_explorer';
import {DashboardsPage, DashboardPage} from 'src/dashboards';
import {CreateSource, SourcePage, ManageSources} from 'src/sources';
+import {AdminPage} from 'src/admin';
import NotFound from 'src/shared/components/NotFound';
import configureStore from 'src/store/configureStore';
import {getMe, getSources} from 'shared/apis';
@@ -127,6 +128,7 @@ const Root = React.createClass({
+
diff --git a/ui/src/shared/apis/metaQuery.js b/ui/src/shared/apis/metaQuery.js
index 6074ae63c4..076ddca2e9 100644
--- a/ui/src/shared/apis/metaQuery.js
+++ b/ui/src/shared/apis/metaQuery.js
@@ -7,18 +7,16 @@ export function showDatabases(source) {
return proxy({source, query});
}
-export function showQueries(host, db, clusterID) {
- const statement = 'SHOW QUERIES';
- const url = buildInfluxUrl({host, statement, database: db});
+export function showQueries(source, db) {
+ const query = 'SHOW QUERIES';
- return proxy(url, clusterID);
+ return proxy({source, query, db});
}
-export function killQuery(host, queryId, clusterID) {
- const statement = `KILL QUERY ${queryId}`;
- const url = buildInfluxUrl({host, statement});
+export function killQuery(source, queryId) {
+ const query = `KILL QUERY ${queryId}`;
- return proxy(url, clusterID);
+ return proxy({source, query});
}
export function showMeasurements(source, db) {
diff --git a/ui/src/shared/components/MultiSelectDropdown.js b/ui/src/shared/components/MultiSelectDropdown.js
new file mode 100644
index 0000000000..6235cc1c19
--- /dev/null
+++ b/ui/src/shared/components/MultiSelectDropdown.js
@@ -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 (
+
+
+
+ {
+ labelText({localSelectedItems, isOpen, label})
+ }
+
+
+
+ {this.renderMenu()}
+
+ )
+ }
+
+ renderMenu() {
+ const {items} = this.props
+
+ return (
+
+
+ Apply
+
+
+ {items.map((listItem, i) => {
+ return (
+ -
+ {listItem}
+
+ )
+ })}
+
+
+ )
+ }
+}
+
+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)
diff --git a/ui/src/shared/components/Tabs.js b/ui/src/shared/components/Tabs.js
index 7d451d8651..de396c9b96 100644
--- a/ui/src/shared/components/Tabs.js
+++ b/ui/src/shared/components/Tabs.js
@@ -28,6 +28,7 @@ export const TabList = React.createClass({
activeIndex: number,
onActivate: func,
isKapacitorTabs: string,
+ customClass: string,
},
getDefaultProps() {
@@ -53,6 +54,14 @@ export const TabList = React.createClass({
);
}
+ if (this.props.customClass) {
+ return (
+
+ );
+ }
+
return (
{children}
);
@@ -63,11 +72,13 @@ export const TabPanels = React.createClass({
propTypes: {
children: node.isRequired,
activeIndex: number,
+ customClass: string,
},
+ // if only 1 child, children array index lookup will fail
render() {
return (
-
+
{this.props.children[this.props.activeIndex]}
);
diff --git a/ui/src/shared/components/Tooltip.js b/ui/src/shared/components/Tooltip.js
new file mode 100644
index 0000000000..d8d0205528
--- /dev/null
+++ b/ui/src/shared/components/Tooltip.js
@@ -0,0 +1,21 @@
+import React, {PropTypes} from 'react'
+import ReactTooltip from 'react-tooltip'
+
+const Tooltip = ({tip, children}) => (
+
+)
+
+const {
+ shape,
+ string,
+} = PropTypes
+
+Tooltip.propTypes = {
+ tip: string,
+ children: shape({}),
+}
+
+export default Tooltip
diff --git a/ui/src/shared/constants/index.js b/ui/src/shared/constants/index.js
index f6f5bb7b68..d238e7bb7a 100644
--- a/ui/src/shared/constants/index.js
+++ b/ui/src/shared/constants/index.js
@@ -471,4 +471,6 @@ export const STROKE_WIDTH = {
export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds.
export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds.
+export const RES_UNAUTHORIZED = 401
+
export const AUTOREFRESH_DEFAULT = 15000 // in milliseconds
diff --git a/ui/src/side_nav/components/SideNav.js b/ui/src/side_nav/components/SideNav.js
index 72c45d1bac..2318af3abc 100644
--- a/ui/src/side_nav/components/SideNav.js
+++ b/ui/src/side_nav/components/SideNav.js
@@ -47,6 +47,9 @@ const SideNav = React.createClass({
InfluxDB
Kapacitor
+
+
+
{loggedIn ? (
Logout
diff --git a/ui/src/store/configureStore.js b/ui/src/store/configureStore.js
index 5aa6b77eee..e4ef15996d 100644
--- a/ui/src/store/configureStore.js
+++ b/ui/src/store/configureStore.js
@@ -3,6 +3,7 @@ import {combineReducers} from 'redux';
import thunkMiddleware from 'redux-thunk';
import makeQueryExecuter from 'src/shared/middleware/queryExecuter';
import resizeLayout from 'src/shared/middleware/resizeLayout';
+import adminReducer from 'src/admin/reducers/admin';
import sharedReducers from 'src/shared/reducers';
import dataExplorerReducers from 'src/data_explorer/reducers';
import rulesReducer from 'src/kapacitor/reducers/rules';
@@ -12,6 +13,7 @@ import persistStateEnhancer from './persistStateEnhancer';
const rootReducer = combineReducers({
...sharedReducers,
...dataExplorerReducers,
+ admin: adminReducer,
rules: rulesReducer,
dashboardUI,
});
diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss
index e45e32ba48..0ab6ebb92d 100644
--- a/ui/src/style/chronograf.scss
+++ b/ui/src/style/chronograf.scss
@@ -47,6 +47,7 @@
@import 'pages/kapacitor';
@import 'pages/data-explorer';
@import 'pages/dashboards';
+@import 'pages/admin';
// TODO
@import 'unsorted';
diff --git a/ui/src/style/components/multi-select-dropdown.scss b/ui/src/style/components/multi-select-dropdown.scss
index d349c2633c..7d9502015a 100644
--- a/ui/src/style/components/multi-select-dropdown.scss
+++ b/ui/src/style/components/multi-select-dropdown.scss
@@ -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__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 {
width: 110px;
+
+ &.btn-xs {
+ height: 22px;
+ line-height: 22px;
+ padding-left: 0;
+ padding-right: 0;
+ }
}
&__apply {
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 */
.multi-select-dropdown.open {
.dropdown-options {
@@ -56,3 +146,14 @@
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;
+}
diff --git a/ui/src/style/components/search-widget.scss b/ui/src/style/components/search-widget.scss
index d9ce2e6c38..28fbf225cf 100644
--- a/ui/src/style/components/search-widget.scss
+++ b/ui/src/style/components/search-widget.scss
@@ -2,10 +2,13 @@
Custom Search Widget
----------------------------------------------
*/
+$search-widget-height: 36px;
+
.users__search-widget {
position: relative;
input.form-control {
+ height: $search-widget-height;
position: relative;
width: 100%;
z-index: 1;
@@ -19,7 +22,7 @@
.input-group-addon {
padding: 0;
text-align: center;
- line-height: 38px;
+ line-height: calc(#{$search-widget-height} - 2px);
position: absolute;
color: $g10-wolf;
top: 0;
@@ -32,4 +35,7 @@
transition:
color 0.25s ease;
}
-}
\ No newline at end of file
+}
+.admin__search-widget {
+ width: 300px;
+}
diff --git a/ui/src/style/components/tables.scss b/ui/src/style/components/tables.scss
index c2c3a0cdca..a98b38de57 100644
--- a/ui/src/style/components/tables.scss
+++ b/ui/src/style/components/tables.scss
@@ -2,6 +2,29 @@
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 {
font-family: $code-font;
letter-spacing: 0px;
@@ -119,4 +142,4 @@ table .monotype {
border-color: $g5-pepper;
@include custom-scrollbar($g5-pepper, $c-pool);
}
-}
\ No newline at end of file
+}
diff --git a/ui/src/style/pages/admin.scss b/ui/src/style/pages/admin.scss
new file mode 100644
index 0000000000..a3a87812b9
--- /dev/null
+++ b/ui/src/style/pages/admin.scss
@@ -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;}
+ }
+}
\ No newline at end of file
diff --git a/ui/src/style/theme/theme-dark.scss b/ui/src/style/theme/theme-dark.scss
index 8bf921302b..0a45f8067c 100644
--- a/ui/src/style/theme/theme-dark.scss
+++ b/ui/src/style/theme/theme-dark.scss
@@ -29,7 +29,6 @@
.panel-title {
color: $g10-wolf !important;
}
-
.panel-body {
padding: 30px;
background-color: $g3-castle;
@@ -42,23 +41,30 @@
> *:last-child {
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 {
@include no-user-select();
}
@@ -259,7 +265,6 @@ input {
max-width: 100%;
margin: 0 !important;
padding: 0 !important;
- min-height: 70px;
max-height: 290px;
overflow: auto;
@include custom-scrollbar($c-pool, $c-laser);
@@ -751,10 +756,6 @@ $form-static-checkbox-size: 16px;
}
}
-
-
-
-
br {
@include no-user-select();
-}
\ No newline at end of file
+}
diff --git a/ui/src/utils/ajax.js b/ui/src/utils/ajax.js
index 654e4dbfc2..e37f01cea1 100644
--- a/ui/src/utils/ajax.js
+++ b/ui/src/utils/ajax.js
@@ -2,7 +2,7 @@ import axios from 'axios';
let links
-const UNAUTHORIZED = 401
+import {RES_UNAUTHORIZED} from 'shared/constants'
export default async function AJAX({
url,
@@ -13,10 +13,9 @@ export default async function AJAX({
params = {},
headers = {},
}) {
- let response
-
try {
const basepath = window.basepath || ''
+ let response
url = `${basepath}${url}`
@@ -47,9 +46,11 @@ export default async function AJAX({
...response,
}
} 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
const {auth} = links
throw {auth, ...response} // eslint-disable-line no-throw-literal
}
diff --git a/ui/src/utils/queryUrlGenerator.js b/ui/src/utils/queryUrlGenerator.js
index 79c99ca061..eecf77e05c 100644
--- a/ui/src/utils/queryUrlGenerator.js
+++ b/ui/src/utils/queryUrlGenerator.js
@@ -1,19 +1,17 @@
import AJAX from 'utils/ajax';
-// TODO: delete this once all references
-// to it have been removed
-export function buildInfluxUrl() {
- return "You dont need me anymore";
-}
-
-export function proxy({source, query, db, rp}) {
- return AJAX({
- method: 'POST',
- url: source,
- data: {
- query,
- db,
- rp,
- },
- });
+export const proxy = async ({source, query, db, rp}) => {
+ try {
+ return await AJAX({
+ method: 'POST',
+ url: source,
+ data: {
+ query,
+ db,
+ rp,
+ },
+ })
+ } catch (error) {
+ console.error(error) // eslint-disable-line no-console
+ }
}
diff --git a/ui/stories/admin.js b/ui/stories/admin.js
new file mode 100644
index 0000000000..5f6d3a3914
--- /dev/null
+++ b/ui/stories/admin.js
@@ -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', () => (
+
+
+
+ ))
+ .add('Selected Item list', () => (
+
+
+
+ ))
+ .add('0 selected items', () => (
+
+
+
+ ))
+
+storiesOf('Tooltip', module)
+ .add('Delete', () => (
+
+
+
+ Delete
+
+
+
+ ))
diff --git a/ui/stories/components/Center.js b/ui/stories/components/Center.js
new file mode 100644
index 0000000000..e955c0c191
--- /dev/null
+++ b/ui/stories/components/Center.js
@@ -0,0 +1,14 @@
+import React from 'react'
+
+const Center = ({children}) => (
+
+ {children}
+
+)
+
+export default Center
diff --git a/ui/stories/index.js b/ui/stories/index.js
index 9bec78fd49..a4135a2cda 100644
--- a/ui/stories/index.js
+++ b/ui/stories/index.js
@@ -3,3 +3,4 @@ import 'src/style/chronograf.scss';
// Kapacitor Stories
import './kapacitor'
+import './admin'
diff --git a/ui/stories/kapacitor.js b/ui/stories/kapacitor.js
index f30c9b2a53..d7e3f22e7c 100644
--- a/ui/stories/kapacitor.js
+++ b/ui/stories/kapacitor.js
@@ -12,7 +12,7 @@ import queryConfigs from './stubs/queryConfigs';
// Actions for Spies
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
import KapacitorRule from 'src/kapacitor/components/KapacitorRule';
diff --git a/ui/storybook.js b/ui/storybook.js
new file mode 100644
index 0000000000..b12ace5625
--- /dev/null
+++ b/ui/storybook.js
@@ -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')
+})