Merge branch 'master' into alert-message-polish

pull/10616/head
Alex P 2017-03-13 16:10:04 -07:00
commit 5f1dd3e12c
70 changed files with 5653 additions and 2469 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

99
server/me.go Normal file
View File

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

168
server/me_test.go Normal file
View File

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

70
server/permissions.go Normal file
View File

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

112
server/permissions_test.go Normal file
View File

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

224
server/roles.go Normal file
View File

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

697
server/roles_test.go Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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": {

View File

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

View File

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

133
ui/src/admin/apis/index.js Normal file
View File

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

View File

@ -0,0 +1,135 @@
import React, {PropTypes} from 'react'
import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'src/shared/components/Tabs';
import UsersTable from 'src/admin/components/UsersTable'
import RolesTable from 'src/admin/components/RolesTable'
import QueriesPage from 'src/admin/containers/QueriesPage'
const AdminTabs = ({
users,
roles,
permissions,
source,
hasRoles,
isEditingUsers,
isEditingRoles,
onClickCreate,
onEditUser,
onSaveUser,
onCancelEditUser,
onEditRole,
onSaveRole,
onCancelEditRole,
onDeleteRole,
onDeleteUser,
onFilterRoles,
onFilterUsers,
onUpdateRoleUsers,
onUpdateRolePermissions,
onUpdateUserRoles,
onUpdateUserPermissions,
}) => {
let tabs = [
{
type: 'Users',
component: (
<UsersTable
users={users}
allRoles={roles}
hasRoles={hasRoles}
permissions={permissions}
isEditing={isEditingUsers}
onSave={onSaveUser}
onCancel={onCancelEditUser}
onClickCreate={onClickCreate}
onEdit={onEditUser}
onDelete={onDeleteUser}
onFilter={onFilterUsers}
onUpdatePermissions={onUpdateUserPermissions}
onUpdateRoles={onUpdateUserRoles}
/>
),
},
{
type: 'Roles',
component: (
<RolesTable
roles={roles}
allUsers={users}
permissions={permissions}
isEditing={isEditingRoles}
onClickCreate={onClickCreate}
onEdit={onEditRole}
onSave={onSaveRole}
onCancel={onCancelEditRole}
onDelete={onDeleteRole}
onFilter={onFilterRoles}
onUpdateRoleUsers={onUpdateRoleUsers}
onUpdateRolePermissions={onUpdateRolePermissions}
/>
),
},
{
type: 'Queries',
component: (<QueriesPage source={source} />),
},
]
if (!hasRoles) {
tabs = tabs.filter(t => t.type !== 'Roles')
}
return (
<Tabs className="row">
<TabList customClass="col-md-2 admin-tabs">
{
tabs.map((t, i) => (<Tab key={tabs[i].type}>{tabs[i].type}</Tab>))
}
</TabList>
<TabPanels customClass="col-md-10">
{
tabs.map((t, i) => (<TabPanel key={tabs[i].type}>{t.component}</TabPanel>))
}
</TabPanels>
</Tabs>
)
}
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
AdminTabs.propTypes = {
users: arrayOf(shape({
name: string.isRequired,
roles: arrayOf(shape({
name: string,
})),
})),
roles: arrayOf(shape()),
source: shape(),
permissions: arrayOf(string),
isEditingUsers: bool,
isEditingRoles: bool,
onClickCreate: func.isRequired,
onEditUser: func.isRequired,
onSaveUser: func.isRequired,
onCancelEditUser: func.isRequired,
onEditRole: func.isRequired,
onSaveRole: func.isRequired,
onCancelEditRole: func.isRequired,
onDeleteRole: func.isRequired,
onDeleteUser: func.isRequired,
onFilterRoles: func.isRequired,
onFilterUsers: func.isRequired,
onUpdateRoleUsers: func.isRequired,
onUpdateRolePermissions: func.isRequired,
hasRoles: bool.isRequired,
onUpdateUserPermissions: func,
onUpdateUserRoles: func,
}
export default AdminTabs

View File

@ -0,0 +1,31 @@
import React, {PropTypes} from 'react'
const ConfirmButtons = ({onConfirm, item, onCancel}) => (
<div>
<button
className="btn btn-xs btn-info"
onClick={() => onCancel(item)}
>
<span className="icon remove"></span>
</button>
<button
className="btn btn-xs btn-success"
onClick={() => onConfirm(item)}
>
<span className="icon checkmark"></span>
</button>
</div>
)
const {
func,
shape,
} = PropTypes
ConfirmButtons.propTypes = {
onConfirm: func.isRequired,
item: shape({}).isRequired,
onCancel: func.isRequired,
}
export default ConfirmButtons

View File

@ -0,0 +1,71 @@
import React, {PropTypes, Component} from 'react'
import OnClickOutside from 'shared/components/OnClickOutside'
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
const DeleteButton = ({onConfirm}) => (
<button
className="btn btn-xs btn-danger admin-table--delete"
onClick={onConfirm}
>
Delete
</button>
)
class DeleteRow extends Component {
constructor(props) {
super(props)
this.state = {
isConfirmed: false,
}
this.handleConfirm = ::this.handleConfirm
this.handleCancel = ::this.handleCancel
}
handleConfirm() {
this.setState({isConfirmed: true})
}
handleCancel() {
this.setState({isConfirmed: false})
}
handleClickOutside() {
this.setState({isConfirmed: false})
}
render() {
const {onDelete, item} = this.props
const {isConfirmed} = this.state
if (isConfirmed) {
return (
<ConfirmButtons
onConfirm={onDelete}
item={item}
onCancel={this.handleCancel}
/>
)
}
return (
<DeleteButton onConfirm={this.handleConfirm} />
)
}
}
const {
func,
shape,
} = PropTypes
DeleteButton.propTypes = {
onConfirm: func.isRequired,
}
DeleteRow.propTypes = {
item: shape({}),
onDelete: func.isRequired,
}
export default OnClickOutside(DeleteRow)

View File

@ -0,0 +1,19 @@
import React, {PropTypes} from 'react'
const EmptyRow = ({tableName}) => (
<tr className="table-empty-state">
<th colSpan="5">
<p>You don&#39;t have any {tableName},<br/>why not create one?</p>
</th>
</tr>
)
const {
string,
} = PropTypes
EmptyRow.propTypes = {
tableName: string.isRequired,
}
export default EmptyRow

View File

@ -0,0 +1,59 @@
import React, {Component, PropTypes} from 'react'
class FilterBar extends Component {
constructor(props) {
super(props)
this.state = {
filterText: '',
}
this.handleText = ::this.handleText
}
handleText(e) {
this.setState(
{filterText: e.target.value},
this.props.onFilter(e.target.value)
)
}
componentWillUnmount() {
this.props.onFilter('')
}
render() {
const {type, isEditing, onClickCreate} = this.props
return (
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
<div className="users__search-widget input-group admin__search-widget">
<input
type="text"
className="form-control"
placeholder={`Filter ${type}...`}
value={this.state.filterText}
onChange={this.handleText}
/>
<div className="input-group-addon">
<span className="icon search" aria-hidden="true"></span>
</div>
</div>
<button className="btn btn-primary" disabled={isEditing} onClick={() => onClickCreate(type)}>Create {type.substring(0, type.length - 1)}</button>
</div>
)
}
}
const {
bool,
func,
string,
} = PropTypes
FilterBar.propTypes = {
onFilter: func.isRequired,
type: string,
isEditing: bool,
onClickCreate: func,
}
export default FilterBar

View File

@ -0,0 +1,67 @@
import React, {PropTypes} from 'react'
const QueriesTable = ({queries, onKillQuery, onConfirm}) => (
<div>
<div className="panel panel-minimal">
<div className="panel-body">
<table className="table v-center">
<thead>
<tr>
<th>Database</th>
<th>Query</th>
<th>Running</th>
<th></th>
</tr>
</thead>
<tbody>
{queries.map((q) => {
return (
<tr key={q.id}>
<td>{q.database}</td>
<td><code>{q.query}</code></td>
<td>{q.duration}</td>
<td className="text-right">
<button className="btn btn-xs btn-link-danger" onClick={onKillQuery} data-toggle="modal" data-query-id={q.id} data-target="#killModal">
Kill
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<div className="modal fade" id="killModal" tabIndex="-1" role="dialog" aria-labelledby="myModalLabel">
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
<h4 className="modal-title" id="myModalLabel">Are you sure you want to kill this query?</h4>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-default" data-dismiss="modal">No</button>
<button type="button" className="btn btn-danger" data-dismiss="modal" onClick={onConfirm}>Yes, kill it!</button>
</div>
</div>
</div>
</div>
</div>
)
const {
arrayOf,
func,
shape,
} = PropTypes
QueriesTable.propTypes = {
queries: arrayOf(shape()),
onConfirm: func,
onKillQuery: func,
}
export default QueriesTable

View File

@ -0,0 +1,59 @@
import React, {Component, PropTypes} from 'react'
class RoleEditingRow extends Component {
constructor(props) {
super(props)
this.handleKeyPress = ::this.handleKeyPress
this.handleEdit = ::this.handleEdit
}
handleKeyPress(role) {
return (e) => {
if (e.key === 'Enter') {
this.props.onSave(role)
}
}
}
handleEdit(role) {
return (e) => {
this.props.onEdit(role, {[e.target.name]: e.target.value})
}
}
render() {
const {role} = this.props
return (
<td>
<div className="admin-table--edit-cell">
<input
className="form-control"
name="name"
type="text"
value={role.name || ''}
placeholder="role name"
onChange={this.handleEdit(role)}
onKeyPress={this.handleKeyPress(role)}
autoFocus={true}
/>
</div>
</td>
)
}
}
const {
bool,
func,
shape,
} = PropTypes
RoleEditingRow.propTypes = {
role: shape().isRequired,
isNew: bool,
onEdit: func.isRequired,
onSave: func.isRequired,
}
export default RoleEditingRow

View File

@ -0,0 +1,109 @@
import React, {PropTypes} from 'react'
import _ from 'lodash'
import RoleEditingRow from 'src/admin/components/RoleEditingRow'
import MultiSelectDropdown from 'shared/components/MultiSelectDropdown'
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
import DeleteRow from 'src/admin/components/DeleteRow'
const RoleRow = ({
role: {name, permissions, users},
role,
allUsers,
allPermissions,
isNew,
isEditing,
onEdit,
onSave,
onCancel,
onDelete,
onUpdateRoleUsers,
onUpdateRolePermissions,
}) => {
const handleUpdateUsers = (u) => {
onUpdateRoleUsers(role, u.map((n) => ({name: n})))
}
const handleUpdatePermissions = (allowed) => {
onUpdateRolePermissions(role, [{scope: 'all', allowed}])
}
const perms = _.get(permissions, ['0', 'allowed'], [])
if (isEditing) {
return (
<tr className="admin-table--edit-row">
<RoleEditingRow role={role} onEdit={onEdit} onSave={onSave} isNew={isNew} />
<td></td>
<td></td>
<td className="text-right" style={{width: "85px"}}>
<ConfirmButtons item={role} onConfirm={onSave} onCancel={onCancel} />
</td>
</tr>
)
}
return (
<tr>
<td>{name}</td>
<td>
{
allPermissions && allPermissions.length ?
<MultiSelectDropdown
items={allPermissions}
selectedItems={perms}
label={perms.length ? '' : 'Select Permissions'}
onApply={handleUpdatePermissions}
/> : null
}
</td>
<td>
{
allUsers && allUsers.length ?
<MultiSelectDropdown
items={allUsers.map((u) => u.name)}
selectedItems={users === undefined ? [] : users.map((u) => u.name)}
label={users && users.length ? '' : 'Select Users'}
onApply={handleUpdateUsers}
/> : null
}
</td>
<td className="text-right" style={{width: "85px"}}>
<DeleteRow onDelete={onDelete} item={role} />
</td>
</tr>
)
}
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
RoleRow.propTypes = {
role: shape({
name: string,
permissions: arrayOf(shape({
name: string,
})),
users: arrayOf(shape({
name: string,
})),
}).isRequired,
isNew: bool,
isEditing: bool,
onCancel: func,
onEdit: func,
onSave: func,
onDelete: func.isRequired,
allUsers: arrayOf(shape()),
allPermissions: arrayOf(string),
onUpdateRoleUsers: func.isRequired,
onUpdateRolePermissions: func.isRequired,
}
export default RoleRow

View File

@ -0,0 +1,90 @@
import React, {PropTypes} from 'react'
import RoleRow from 'src/admin/components/RoleRow'
import EmptyRow from 'src/admin/components/EmptyRow'
import FilterBar from 'src/admin/components/FilterBar'
const RolesTable = ({
roles,
allUsers,
permissions,
isEditing,
onClickCreate,
onEdit,
onSave,
onCancel,
onDelete,
onFilter,
onUpdateRoleUsers,
onUpdateRolePermissions,
}) => (
<div className="panel panel-info">
<FilterBar type="roles" onFilter={onFilter} isEditing={isEditing} onClickCreate={onClickCreate} />
<div className="panel-body">
<table className="table v-center admin-table">
<thead>
<tr>
<th>Name</th>
<th>Permissions</th>
<th>Users</th>
<th></th>
</tr>
</thead>
<tbody>
{
roles.length ?
roles.filter(r => !r.hidden).map((role) =>
<RoleRow
key={role.links.self}
allUsers={allUsers}
allPermissions={permissions}
role={role}
onEdit={onEdit}
onSave={onSave}
onCancel={onCancel}
onDelete={onDelete}
onUpdateRoleUsers={onUpdateRoleUsers}
onUpdateRolePermissions={onUpdateRolePermissions}
isEditing={role.isEditing}
isNew={role.isNew}
/>
) : <EmptyRow tableName={'Roles'} />
}
</tbody>
</table>
</div>
</div>
)
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
RolesTable.propTypes = {
roles: arrayOf(shape({
name: string.isRequired,
permissions: arrayOf(shape({
name: string,
scope: string.isRequired,
})),
users: arrayOf(shape({
name: string,
})),
})),
isEditing: bool,
onClickCreate: func.isRequired,
onEdit: func.isRequired,
onSave: func.isRequired,
onCancel: func.isRequired,
onDelete: func.isRequired,
onFilter: func,
allUsers: arrayOf(shape()),
permissions: arrayOf(string),
onUpdateRoleUsers: func.isRequired,
onUpdateRolePermissions: func.isRequired,
}
export default RolesTable

View File

@ -0,0 +1,72 @@
import React, {Component, PropTypes} from 'react'
class UserEditingRow extends Component {
constructor(props) {
super(props)
this.handleKeyPress = ::this.handleKeyPress
this.handleEdit = ::this.handleEdit
}
handleKeyPress(user) {
return (e) => {
if (e.key === 'Enter') {
this.props.onSave(user)
}
}
}
handleEdit(user) {
return (e) => {
this.props.onEdit(user, {[e.target.name]: e.target.value})
}
}
render() {
const {user, isNew} = this.props
return (
<td>
<div className="admin-table--edit-cell">
<input
className="form-control"
name="name"
type="text"
value={user.name || ''}
placeholder="Username"
onChange={this.handleEdit(user)}
onKeyPress={this.handleKeyPress(user)}
autoFocus={true}
/>
{
isNew ?
<input
className="form-control"
name="password"
type="text"
value={user.password || ''}
placeholder="Password"
onChange={this.handleEdit(user)}
onKeyPress={this.handleKeyPress(user)}
/> :
null
}
</div>
</td>
)
}
}
const {
bool,
func,
shape,
} = PropTypes
UserEditingRow.propTypes = {
user: shape().isRequired,
isNew: bool,
onEdit: func.isRequired,
onSave: func.isRequired,
}
export default UserEditingRow

View File

@ -0,0 +1,110 @@
import React, {PropTypes} from 'react'
import _ from 'lodash'
import UserEditingRow from 'src/admin/components/UserEditingRow'
import MultiSelectDropdown from 'shared/components/MultiSelectDropdown'
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
import DeleteRow from 'src/admin/components/DeleteRow'
const UserRow = ({
user: {name, roles, permissions},
user,
allRoles,
allPermissions,
hasRoles,
isNew,
isEditing,
onEdit,
onSave,
onCancel,
onDelete,
onUpdatePermissions,
onUpdateRoles,
}) => {
const handleUpdatePermissions = (allowed) => {
onUpdatePermissions(user, [{scope: 'all', allowed}])
}
const handleUpdateRoles = (roleNames) => {
onUpdateRoles(user, allRoles.filter(r => roleNames.find(rn => rn === r.name)))
}
if (isEditing) {
return (
<tr className="admin-table--edit-row">
<UserEditingRow user={user} onEdit={onEdit} onSave={onSave} isNew={isNew} />
{hasRoles ? <td></td> : null}
<td></td>
<td className="text-right" style={{width: "85px"}}>
<ConfirmButtons item={user} onConfirm={onSave} onCancel={onCancel} />
</td>
</tr>
)
}
return (
<tr>
<td>{name}</td>
{
hasRoles ?
<td>
<MultiSelectDropdown
items={allRoles.map((r) => r.name)}
selectedItems={roles ? roles.map((r) => r.name) : []/* TODO remove check when server returns empty list */}
label={roles && roles.length ? '' : 'Select Roles'}
onApply={handleUpdateRoles}
/>
</td> :
null
}
<td>
{
allPermissions && allPermissions.length ?
<MultiSelectDropdown
items={allPermissions}
selectedItems={_.get(permissions, ['0', 'allowed'], [])}
label={permissions && permissions.length ? '' : 'Select Permissions'}
onApply={handleUpdatePermissions}
/> : null
}
</td>
<td className="text-right" style={{width: "85px"}}>
<DeleteRow onDelete={onDelete} item={user} />
</td>
</tr>
)
}
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
UserRow.propTypes = {
user: shape({
name: string,
roles: arrayOf(shape({
name: string,
})),
permissions: arrayOf(shape({
name: string,
})),
}).isRequired,
allRoles: arrayOf(shape()),
allPermissions: arrayOf(string),
hasRoles: bool,
isNew: bool,
isEditing: bool,
onCancel: func,
onEdit: func,
onSave: func,
onDelete: func.isRequired,
onUpdatePermissions: func,
onUpdateRoles: func,
}
export default UserRow

View File

@ -0,0 +1,94 @@
import React, {PropTypes} from 'react'
import UserRow from 'src/admin/components/UserRow'
import EmptyRow from 'src/admin/components/EmptyRow'
import FilterBar from 'src/admin/components/FilterBar'
const UsersTable = ({
users,
allRoles,
hasRoles,
permissions,
isEditing,
onClickCreate,
onEdit,
onSave,
onCancel,
onDelete,
onFilter,
onUpdatePermissions,
onUpdateRoles,
}) => (
<div className="panel panel-info">
<FilterBar type="users" onFilter={onFilter} isEditing={isEditing} onClickCreate={onClickCreate} />
<div className="panel-body">
<table className="table v-center admin-table">
<thead>
<tr>
<th>User</th>
{hasRoles && <th>Roles</th>}
<th>Permissions</th>
<th></th>
</tr>
</thead>
<tbody>
{
users.length ?
users.filter(u => !u.hidden).map(user =>
<UserRow
key={user.links.self}
user={user}
onEdit={onEdit}
onSave={onSave}
onCancel={onCancel}
onDelete={onDelete}
isEditing={user.isEditing}
isNew={user.isNew}
allRoles={allRoles}
hasRoles={hasRoles}
allPermissions={permissions}
onUpdatePermissions={onUpdatePermissions}
onUpdateRoles={onUpdateRoles}
/>) :
<EmptyRow tableName={'Users'} />
}
</tbody>
</table>
</div>
</div>
)
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
UsersTable.propTypes = {
users: arrayOf(shape({
name: string.isRequired,
roles: arrayOf(shape({
name: string,
})),
permissions: arrayOf(shape({
name: string,
scope: string.isRequired,
})),
})),
isEditing: bool,
onClickCreate: func.isRequired,
onEdit: func.isRequired,
onSave: func.isRequired,
onCancel: func.isRequired,
onDelete: func.isRequired,
onFilter: func,
allRoles: arrayOf(shape()),
permissions: arrayOf(string),
hasRoles: bool.isRequired,
onUpdatePermissions: func,
onUpdateRoles: func,
}
export default UsersTable

View File

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

View File

@ -0,0 +1,263 @@
import React, {Component, PropTypes} from 'react'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import {
loadUsersAsync,
loadRolesAsync,
loadPermissionsAsync,
addUser,
addRole,
deleteUser, // TODO rename to removeUser throughout + tests
deleteRole, // TODO rename to removeUser throughout + tests
editUser,
editRole,
createUserAsync,
createRoleAsync,
deleteUserAsync,
deleteRoleAsync,
updateRoleUsersAsync,
updateRolePermissionsAsync,
updateUserPermissionsAsync,
updateUserRolesAsync,
filterUsers as filterUsersAction,
filterRoles as filterRolesAction,
} from 'src/admin/actions'
import AdminTabs from 'src/admin/components/AdminTabs'
const isValidUser = (user) => {
const minLen = 3
return (user.name.length >= minLen && user.password.length >= minLen)
}
const isValidRole = (role) => {
const minLen = 3
return (role.name.length >= minLen)
}
class AdminPage extends Component {
constructor(props) {
super(props)
this.handleClickCreate = ::this.handleClickCreate
this.handleEditUser = ::this.handleEditUser
this.handleEditRole = ::this.handleEditRole
this.handleSaveUser = ::this.handleSaveUser
this.handleSaveRole = ::this.handleSaveRole
this.handleCancelEditUser = ::this.handleCancelEditUser
this.handleCancelEditRole = ::this.handleCancelEditRole
this.handleDeleteRole = ::this.handleDeleteRole
this.handleDeleteUser = ::this.handleDeleteUser
this.handleUpdateRoleUsers = ::this.handleUpdateRoleUsers
this.handleUpdateRolePermissions = ::this.handleUpdateRolePermissions
this.handleUpdateUserPermissions = ::this.handleUpdateUserPermissions
this.handleUpdateUserRoles = ::this.handleUpdateUserRoles
}
componentDidMount() {
const {source, loadUsers, loadRoles, loadPermissions} = this.props
loadUsers(source.links.users)
loadPermissions(source.links.permissions)
if (source.links.roles) {
loadRoles(source.links.roles)
}
}
handleClickCreate(type) {
if (type === 'users') {
this.props.addUser()
} else if (type === 'roles') {
this.props.addRole()
}
}
handleEditUser(user, updates) {
this.props.editUser(user, updates)
}
handleEditRole(role, updates) {
this.props.editRole(role, updates)
}
async handleSaveUser(user) {
if (!isValidUser(user)) {
this.props.addFlashMessage({type: 'error', text: 'Username and/or password too short'})
return
}
if (user.isNew) {
this.props.createUser(this.props.source.links.users, user)
} else {
// TODO update user
}
}
async handleSaveRole(role) {
if (!isValidRole(role)) {
this.props.addFlashMessage({type: 'error', text: 'Role name too short'})
return
}
if (role.isNew) {
this.props.createRole(this.props.source.links.roles, role)
} else {
// TODO update role
// console.log('update')
}
}
handleCancelEditUser(user) {
this.props.removeUser(user)
}
handleCancelEditRole(role) {
this.props.removeRole(role)
}
handleDeleteRole(role) {
this.props.deleteRole(role, this.props.addFlashMessage)
}
handleDeleteUser(user) {
this.props.deleteUser(user, this.props.addFlashMessage)
}
handleUpdateRoleUsers(role, users) {
this.props.updateRoleUsers(role, users)
}
handleUpdateRolePermissions(role, permissions) {
this.props.updateRolePermissions(role, permissions)
}
handleUpdateUserPermissions(user, permissions) {
this.props.updateUserPermissions(user, permissions)
}
handleUpdateUserRoles(user, roles) {
this.props.updateUserRoles(user, roles)
}
render() {
const {users, roles, source, permissions, filterUsers, filterRoles} = this.props
const hasRoles = !!source.links.roles
const globalPermissions = permissions.find((p) => p.scope === 'all')
const allowed = globalPermissions ? globalPermissions.allowed : []
return (
<div className="page">
<div className="page-header">
<div className="page-header__container">
<div className="page-header__left">
<h1>
Admin
</h1>
</div>
</div>
</div>
<div className="page-contents">
<div className="container-fluid">
<div className="row">
{
users ?
<AdminTabs
users={users}
roles={roles}
source={source}
permissions={allowed}
hasRoles={hasRoles}
isEditingUsers={users.some(u => u.isEditing)}
isEditingRoles={roles.some(r => r.isEditing)}
onClickCreate={this.handleClickCreate}
onEditUser={this.handleEditUser}
onEditRole={this.handleEditRole}
onSaveUser={this.handleSaveUser}
onSaveRole={this.handleSaveRole}
onCancelEditUser={this.handleCancelEditUser}
onCancelEditRole={this.handleCancelEditRole}
onDeleteUser={this.handleDeleteUser}
onDeleteRole={this.handleDeleteRole}
onFilterUsers={filterUsers}
onFilterRoles={filterRoles}
onUpdateRoleUsers={this.handleUpdateRoleUsers}
onUpdateRolePermissions={this.handleUpdateRolePermissions}
onUpdateUserPermissions={this.handleUpdateUserPermissions}
onUpdateUserRoles={this.handleUpdateUserRoles}
/> :
<span>Loading...</span>
}
</div>
</div>
</div>
</div>
)
}
}
const {
arrayOf,
func,
shape,
string,
} = PropTypes
AdminPage.propTypes = {
source: shape({
id: string.isRequired,
links: shape({
users: string.isRequired,
}),
}).isRequired,
users: arrayOf(shape()),
roles: arrayOf(shape()),
permissions: arrayOf(shape()),
loadUsers: func,
loadRoles: func,
loadPermissions: func,
addUser: func,
addRole: func,
removeUser: func,
removeRole: func,
editUser: func,
editRole: func,
createUser: func,
createRole: func,
deleteRole: func,
deleteUser: func,
addFlashMessage: func,
filterRoles: func,
filterUsers: func,
updateRoleUsers: func,
updateRolePermissions: func,
updateUserPermissions: func,
updateUserRoles: func,
}
const mapStateToProps = ({admin: {users, roles, permissions}}) => ({
users,
roles,
permissions,
})
const mapDispatchToProps = (dispatch) => ({
loadUsers: bindActionCreators(loadUsersAsync, dispatch),
loadRoles: bindActionCreators(loadRolesAsync, dispatch),
loadPermissions: bindActionCreators(loadPermissionsAsync, dispatch),
addUser: bindActionCreators(addUser, dispatch),
addRole: bindActionCreators(addRole, dispatch),
removeUser: bindActionCreators(deleteUser, dispatch),
removeRole: bindActionCreators(deleteRole, dispatch),
editUser: bindActionCreators(editUser, dispatch),
editRole: bindActionCreators(editRole, dispatch),
createUser: bindActionCreators(createUserAsync, dispatch),
createRole: bindActionCreators(createRoleAsync, dispatch),
deleteUser: bindActionCreators(deleteUserAsync, dispatch),
deleteRole: bindActionCreators(deleteRoleAsync, dispatch),
filterUsers: bindActionCreators(filterUsersAction, dispatch),
filterRoles: bindActionCreators(filterRolesAction, dispatch),
updateRoleUsers: bindActionCreators(updateRoleUsersAsync, dispatch),
updateRolePermissions: bindActionCreators(updateRolePermissionsAsync, dispatch),
updateUserPermissions: bindActionCreators(updateUserPermissionsAsync, dispatch),
updateUserRoles: bindActionCreators(updateUserRolesAsync, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(AdminPage)

View File

@ -0,0 +1,134 @@
import React, {PropTypes, Component} from 'react'
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import flatten from 'lodash/flatten'
import uniqBy from 'lodash/uniqBy'
import {
showDatabases,
showQueries,
} from 'shared/apis/metaQuery'
import QueriesTable from 'src/admin/components/QueriesTable'
import showDatabasesParser from 'shared/parsing/showDatabases'
import showQueriesParser from 'shared/parsing/showQueries'
import {TIMES} from 'src/admin/constants'
import {
loadQueries as loadQueriesAction,
setQueryToKill as setQueryToKillAction,
killQueryAsync,
} from 'src/admin/actions'
class QueriesPage extends Component {
constructor(props) {
super(props)
this.updateQueries = ::this.updateQueries
this.handleConfirmKillQuery = ::this.handleConfirmKillQuery
this.handleKillQuery = ::this.handleKillQuery
}
componentDidMount() {
this.updateQueries()
const updateInterval = 5000
this.intervalID = setInterval(this.updateQueries, updateInterval)
}
componentWillUnmount() {
clearInterval(this.intervalID)
}
render() {
const {queries} = this.props;
return (
<QueriesTable queries={queries} onConfirm={this.handleConfirmKillQuery} onKillQuery={this.handleKillQuery} />
);
}
updateQueries() {
const {source, addFlashMessage, loadQueries} = this.props
showDatabases(source.links.proxy).then((resp) => {
const {databases, errors} = showDatabasesParser(resp.data)
if (errors.length) {
errors.forEach((message) => addFlashMessage({type: 'error', text: message}))
return;
}
const fetches = databases.map((db) => showQueries(source.links.proxy, db))
Promise.all(fetches).then((queryResponses) => {
const allQueries = [];
queryResponses.forEach((queryResponse) => {
const result = showQueriesParser(queryResponse.data);
if (result.errors.length) {
result.erorrs.forEach((message) => this.props.addFlashMessage({type: 'error', text: message}));
}
allQueries.push(...result.queries);
});
const queries = uniqBy(flatten(allQueries), (q) => q.id);
// sorting queries by magnitude, so generally longer queries will appear atop the list
const sortedQueries = queries.sort((a, b) => {
const aTime = TIMES.find((t) => a.duration.match(t.test));
const bTime = TIMES.find((t) => b.duration.match(t.test));
return +aTime.magnitude <= +bTime.magnitude;
});
loadQueries(sortedQueries)
});
});
}
handleKillQuery(e) {
e.stopPropagation();
const id = e.target.dataset.queryId;
this.props.setQueryToKill(id)
}
handleConfirmKillQuery() {
const {queryIDToKill, source, killQuery} = this.props;
if (queryIDToKill === null) {
return;
}
killQuery(source.links.proxy, queryIDToKill)
}
}
const {
arrayOf,
func,
string,
shape,
} = PropTypes
QueriesPage.propTypes = {
source: shape({
links: shape({
proxy: string,
}),
}),
queries: arrayOf(shape()),
addFlashMessage: func,
loadQueries: func,
queryIDToKill: string,
setQueryToKill: func,
killQuery: func,
}
const mapStateToProps = ({admin: {queries, queryIDToKill}}) => ({
queries,
queryIDToKill,
})
const mapDispatchToProps = (dispatch) => ({
loadQueries: bindActionCreators(loadQueriesAction, dispatch),
setQueryToKill: bindActionCreators(setQueryToKillAction, dispatch),
killQuery: bindActionCreators(killQueryAsync, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(QueriesPage)

2
ui/src/admin/index.js Normal file
View File

@ -0,0 +1,2 @@
import AdminPage from './containers/AdminPage';
export {AdminPage};

View File

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

View File

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

View File

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

View File

@ -1,106 +0,0 @@
import React, {PropTypes} from 'react';
import OnClickOutside from 'shared/components/OnClickOutside';
import classNames from 'classnames';
import _ from 'lodash';
const {func, arrayOf, string} = PropTypes;
const MultiSelectDropdown = React.createClass({
propTypes: {
onApply: func.isRequired,
items: arrayOf(PropTypes.string.isRequired).isRequired,
selectedItems: arrayOf(string.isRequired).isRequired,
},
getInitialState() {
return {
isOpen: false,
localSelectedItems: this.props.selectedItems,
};
},
componentWillReceiveProps(nextProps) {
if (!_.isEqual(this.state.localSelectedItems, nextProps.selectedItems)) {
this.setState({
localSelectedItems: nextProps.selectedItems,
});
}
},
handleClickOutside() {
this.setState({isOpen: false});
},
toggleMenu(e) {
e.stopPropagation();
this.setState({isOpen: !this.state.isOpen});
},
onSelect(item, e) {
e.stopPropagation();
const {localSelectedItems} = this.state;
let nextItems;
if (this.isSelected(item)) {
nextItems = localSelectedItems.filter((i) => i !== item);
} else {
nextItems = localSelectedItems.concat(item);
}
this.setState({localSelectedItems: nextItems});
},
isSelected(item) {
return this.state.localSelectedItems.indexOf(item) > -1;
},
onApplyFunctions(e) {
e.stopPropagation();
this.setState({isOpen: false});
this.props.onApply(this.state.localSelectedItems);
},
render() {
const {localSelectedItems} = this.state;
const {isOpen} = this.state;
const labelText = isOpen ? "0 Selected" : "Apply Function";
return (
<div className={classNames('dropdown multi-select-dropdown', {open: isOpen})}>
<div onClick={this.toggleMenu} className="btn btn-xs btn-info dropdown-toggle" type="button">
<span className="multi-select-dropdown__label">
{
localSelectedItems.length ? localSelectedItems.map((s) => s).join(', ') : labelText
}
</span>
<span className="caret"></span>
</div>
{this.renderMenu()}
</div>
);
},
renderMenu() {
const {items} = this.props;
return (
<div className="dropdown-options">
<li className="multi-select-dropdown__apply" onClick={this.onApplyFunctions}>
<div className="btn btn-xs btn-info btn-block">Apply</div>
</li>
<ul className="dropdown-menu multi-select-dropdown__menu" aria-labelledby="dropdownMenu1">
{items.map((listItem, i) => {
return (
<li className={classNames('multi-select-dropdown__item', {active: this.isSelected(listItem)})} key={i} onClick={_.wrap(listItem, this.onSelect)}>
<a href="#">{listItem}</a>
</li>
);
})}
</ul>
</div>
);
},
});
export default OnClickOutside(MultiSelectDropdown);

View File

@ -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({
<Route path="alert-rules" component={KapacitorRulesPage} />
<Route path="alert-rules/:ruleID" component={KapacitorRulePage} />
<Route path="alert-rules/new" component={KapacitorRulePage} />
<Route path="admin" component={AdminPage} />
</Route>
</Route>
<Route path="*" component={NotFound} />

View File

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

View File

@ -0,0 +1,134 @@
import React, {Component, PropTypes} from 'react'
import OnClickOutside from 'shared/components/OnClickOutside'
import classNames from 'classnames'
import _ from 'lodash'
const labelText = ({localSelectedItems, isOpen, label}) => {
if (label) {
return label
} else if (localSelectedItems.length) {
return localSelectedItems.map((s) => s).join(', ')
}
// TODO: be smarter about the text displayed here
if (isOpen) {
return '0 Selected'
}
return 'None'
}
class MultiSelectDropdown extends Component {
constructor(props) {
super(props)
this.state = {
isOpen: false,
localSelectedItems: this.props.selectedItems,
}
this.onSelect = ::this.onSelect
this.onApplyFunctions = ::this.onApplyFunctions
}
componentWillReceiveProps(nextProps) {
if (!_.isEqual(this.state.localSelectedItems, nextProps.selectedItems)) {
this.setState({
localSelectedItems: nextProps.selectedItems,
})
}
}
handleClickOutside() {
this.setState({isOpen: false})
}
toggleMenu(e) {
e.stopPropagation()
this.setState({isOpen: !this.state.isOpen})
}
onSelect(item, e) {
e.stopPropagation()
const {localSelectedItems} = this.state
let nextItems
if (this.isSelected(item)) {
nextItems = localSelectedItems.filter((i) => i !== item)
} else {
nextItems = localSelectedItems.concat(item)
}
this.setState({localSelectedItems: nextItems})
}
isSelected(item) {
return this.state.localSelectedItems.indexOf(item) > -1
}
onApplyFunctions(e) {
e.stopPropagation()
this.setState({isOpen: false})
this.props.onApply(this.state.localSelectedItems)
}
render() {
const {localSelectedItems, isOpen} = this.state
const {label} = this.props
return (
<div className={classNames('dropdown multi-select-dropdown', {open: isOpen})}>
<div onClick={::this.toggleMenu} className="btn btn-xs btn-info dropdown-toggle" type="button">
<div className="multi-select-dropdown__label">
{
labelText({localSelectedItems, isOpen, label})
}
</div>
<span className="caret"></span>
</div>
{this.renderMenu()}
</div>
)
}
renderMenu() {
const {items} = this.props
return (
<div className="dropdown-options">
<li className="multi-select-dropdown__apply" onClick={this.onApplyFunctions} style={{listStyle: 'none'}}>
<div className="btn btn-xs btn-info btn-block">Apply</div>
</li>
<ul className="dropdown-menu multi-select-dropdown__menu" aria-labelledby="dropdownMenu1">
{items.map((listItem, i) => {
return (
<li
key={i}
className={classNames('multi-select-dropdown__item', {active: this.isSelected(listItem)})}
onClick={_.wrap(listItem, this.onSelect)}
>
<a href="#">{listItem}</a>
</li>
)
})}
</ul>
</div>
)
}
}
const {
arrayOf,
func,
string,
} = PropTypes
MultiSelectDropdown.propTypes = {
onApply: func.isRequired,
items: arrayOf(string.isRequired).isRequired,
selectedItems: arrayOf(string.isRequired).isRequired,
label: string,
}
export default OnClickOutside(MultiSelectDropdown)

View File

@ -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 (
<div className={this.props.customClass}>
<div className="btn-group btn-group-lg tab-group">{children}</div>
</div>
);
}
return (
<div className="btn-group btn-group-lg tab-group">{children}</div>
);
@ -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 (
<div>
<div className={this.props.customClass ? this.props.customClass : null}>
{this.props.children[this.props.activeIndex]}
</div>
);

View File

@ -0,0 +1,21 @@
import React, {PropTypes} from 'react'
import ReactTooltip from 'react-tooltip'
const Tooltip = ({tip, children}) => (
<div>
<div data-tip={tip}>{children}</div>
<ReactTooltip effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
</div>
)
const {
shape,
string,
} = PropTypes
Tooltip.propTypes = {
tip: string,
children: shape({}),
}
export default Tooltip

View File

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

View File

@ -47,6 +47,9 @@ const SideNav = React.createClass({
<NavListItem link={`${sourcePrefix}/manage-sources`}>InfluxDB</NavListItem>
<NavListItem link={`${sourcePrefix}/kapacitor-config`}>Kapacitor</NavListItem>
</NavBlock>
<NavBlock icon="crown" link={`${sourcePrefix}/admin`}>
<NavHeader link={`${sourcePrefix}/admin`} title="Admin" />
</NavBlock>
{loggedIn ? (
<NavBlock icon="user-outline" className="sidebar__square-last">
<a className="sidebar__menu-item" href="/oauth/logout">Logout</a>

View File

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

View File

@ -47,6 +47,7 @@
@import 'pages/kapacitor';
@import 'pages/data-explorer';
@import 'pages/dashboards';
@import 'pages/admin';
// TODO
@import 'unsorted';

View File

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

View File

@ -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;
}
}
}
.admin__search-widget {
width: 300px;
}

View File

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

View File

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

View File

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

View File

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

View File

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

71
ui/stories/admin.js Normal file
View File

@ -0,0 +1,71 @@
import React from 'react'
import {storiesOf, action, linkTo} from '@kadira/storybook'
import Center from './components/Center'
import MultiSelectDropdown from 'shared/components/MultiSelectDropdown'
import Tooltip from 'shared/components/Tooltip'
storiesOf('MultiSelectDropdown', module)
.add('Select Roles w/label', () => (
<Center>
<MultiSelectDropdown
items={[
'Admin',
'User',
'Chrono Girafferoo',
'Prophet',
'Susford',
]}
selectedItems={[
'User',
'Chrono Girafferoo',
]}
label={'Select Roles'}
onApply={action('onApply')}
/>
</Center>
))
.add('Selected Item list', () => (
<Center>
<MultiSelectDropdown
items={[
'Admin',
'User',
'Chrono Giraffe',
'Prophet',
'Susford',
]}
selectedItems={[
'User',
'Chrono Giraffe',
]}
onApply={action('onApply')}
/>
</Center>
))
.add('0 selected items', () => (
<Center>
<MultiSelectDropdown
items={[
'Admin',
'User',
'Chrono Giraffe',
'Prophet',
'Susford',
]}
selectedItems={[]}
onApply={action('onApply')}
/>
</Center>
))
storiesOf('Tooltip', module)
.add('Delete', () => (
<Center>
<Tooltip tip={`Are you sure? TrashIcon`}>
<div className="btn btn-info btn-sm">
Delete
</div>
</Tooltip>
</Center>
))

View File

@ -0,0 +1,14 @@
import React from 'react'
const Center = ({children}) => (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%)',
}}>
{children}
</div>
)
export default Center

View File

@ -3,3 +3,4 @@ import 'src/style/chronograf.scss';
// Kapacitor Stories
import './kapacitor'
import './admin'

View File

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

19
ui/storybook.js Normal file
View File

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