Commit a4269430 authored by Joe Bowers's avatar Joe Bowers

Merge pull request #140 from joeatwork/disable-users-api

Expose API to enable and disable users
parents 05adce3e 2ed28598
...@@ -97,10 +97,29 @@ func (r *userRepo) Create(tx repo.Transaction, usr user.User) (err error) { ...@@ -97,10 +97,29 @@ func (r *userRepo) Create(tx repo.Transaction, usr user.User) (err error) {
} }
err = r.insert(tx, usr) err = r.insert(tx, usr)
return err
}
func (r *userRepo) Disable(tx repo.Transaction, userID string, disable bool) error {
if userID == "" {
return user.ErrorInvalidID
}
qt := pq.QuoteIdentifier(userTableName)
ex := r.executor(tx)
result, err := ex.Exec(fmt.Sprintf("UPDATE %s SET disabled = $2 WHERE id = $1", qt), userID, disable)
if err != nil { if err != nil {
return err return err
} }
ct, err := result.RowsAffected()
switch {
case err != nil:
return err
case ct == 0:
return user.ErrorNotFound
}
return nil return nil
} }
......
...@@ -35,6 +35,7 @@ var ( ...@@ -35,6 +35,7 @@ var (
ID: "ID-2", ID: "ID-2",
Email: "Email-2@example.com", Email: "Email-2@example.com",
CreatedAt: time.Now(), CreatedAt: time.Now(),
Disabled: true,
}, },
RemoteIdentities: []user.RemoteIdentity{ RemoteIdentities: []user.RemoteIdentity{
{ {
...@@ -232,6 +233,61 @@ func TestUpdateUser(t *testing.T) { ...@@ -232,6 +233,61 @@ func TestUpdateUser(t *testing.T) {
} }
} }
func TestDisableUser(t *testing.T) {
tests := []struct {
id string
disable bool
err error
}{
{
id: "ID-1",
},
{
id: "ID-1",
disable: true,
},
{
id: "ID-2",
},
{
id: "ID-2",
disable: true,
},
{
id: "NO SUCH ID",
err: user.ErrorNotFound,
},
{
id: "NO SUCH ID",
err: user.ErrorNotFound,
disable: true,
},
{
id: "",
err: user.ErrorInvalidID,
},
}
for i, tt := range tests {
repo := makeTestUserRepo()
err := repo.Disable(nil, tt.id, tt.disable)
switch {
case err != tt.err:
t.Errorf("case %d: want=%q, got=%q", i, tt.err, err)
case tt.err == nil:
gotUser, err := repo.Get(nil, tt.id)
if err != nil {
t.Fatalf("case %d: want nil err, got %q", i, err)
}
if gotUser.Disabled != tt.disable {
t.Errorf("case %d: disabled status want=%v got=%v",
i, tt.disable, gotUser.Disabled)
}
}
}
}
func TestAttachRemoteIdentity(t *testing.T) { func TestAttachRemoteIdentity(t *testing.T) {
tests := []struct { tests := []struct {
id string id string
......
...@@ -141,6 +141,7 @@ func makeUserAPITestFixtures() *userAPITestFixtures { ...@@ -141,6 +141,7 @@ func makeUserAPITestFixtures() *userAPITestFixtures {
f.trans = &tokenHandlerTransport{ f.trans = &tokenHandlerTransport{
Handler: usrSrv.HTTPHandler(), Handler: usrSrv.HTTPHandler(),
Token: userGoodToken,
} }
hc := &http.Client{ hc := &http.Client{
Transport: f.trans, Transport: f.trans,
...@@ -530,6 +531,48 @@ func TestCreateUser(t *testing.T) { ...@@ -530,6 +531,48 @@ func TestCreateUser(t *testing.T) {
} }
} }
func TestDisableUser(t *testing.T) {
tests := []struct {
id string
disable bool
}{
{
id: "ID-2",
disable: true,
},
{
id: "ID-4",
disable: false,
},
}
for i, tt := range tests {
f := makeUserAPITestFixtures()
usr, err := f.client.Users.Get(tt.id).Do()
if err != nil {
t.Fatalf("case %v: unexpected error: %v", i, err)
}
if usr.User.Disabled == tt.disable {
t.Fatalf("case %v: misconfigured test, initial disabled state should be %v but was %v", i, !tt.disable, usr.User.Disabled)
}
_, err = f.client.Users.Disable(tt.id, &schema.UserDisableRequest{
Disable: tt.disable,
}).Do()
if err != nil {
t.Fatalf("case %v: unexpected error: %v", i, err)
}
usr, err = f.client.Users.Get(tt.id).Do()
if err != nil {
t.Fatalf("case %v: unexpected error: %v", i, err)
}
if usr.User.Disabled != tt.disable {
t.Errorf("case %v: user disabled state incorrect. wanted: %v found: %v", i, tt.disable, usr.User.Disabled)
}
}
}
type testEmailer struct { type testEmailer struct {
cantEmail bool cantEmail bool
lastEmail string lastEmail string
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
// //
// Usage example: // Usage example:
// //
// import "github.com/coreos/dex/Godeps/_workspace/src/google.golang.org/api/adminschema/v1" // import "google.golang.org/api/adminschema/v1"
// ... // ...
// adminschemaService, err := adminschema.New(oauthHttpClient) // adminschemaService, err := adminschema.New(oauthHttpClient)
package adminschema package adminschema
......
...@@ -108,6 +108,8 @@ type User struct { ...@@ -108,6 +108,8 @@ type User struct {
CreatedAt string `json:"createdAt,omitempty"` CreatedAt string `json:"createdAt,omitempty"`
Disabled bool `json:"disabled,omitempty"`
DisplayName string `json:"displayName,omitempty"` DisplayName string `json:"displayName,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
...@@ -134,6 +136,15 @@ type UserCreateResponse struct { ...@@ -134,6 +136,15 @@ type UserCreateResponse struct {
type UserCreateResponseUser struct { type UserCreateResponseUser struct {
} }
type UserDisableRequest struct {
// Disable: If true, disable this user, if false, enable them
Disable bool `json:"disable,omitempty"`
}
type UserDisableResponse struct {
Ok bool `json:"ok,omitempty"`
}
type UserResponse struct { type UserResponse struct {
User *User `json:"user,omitempty"` User *User `json:"user,omitempty"`
} }
...@@ -355,6 +366,89 @@ func (c *UsersCreateCall) Do() (*UserCreateResponse, error) { ...@@ -355,6 +366,89 @@ func (c *UsersCreateCall) Do() (*UserCreateResponse, error) {
} }
// method id "dex.User.Disable":
type UsersDisableCall struct {
s *Service
id string
userdisablerequest *UserDisableRequest
opt_ map[string]interface{}
}
// Disable: Enable or disable a user.
func (r *UsersService) Disable(id string, userdisablerequest *UserDisableRequest) *UsersDisableCall {
c := &UsersDisableCall{s: r.s, opt_: make(map[string]interface{})}
c.id = id
c.userdisablerequest = userdisablerequest
return c
}
// Fields allows partial responses to be retrieved.
// See https://developers.google.com/gdata/docs/2.0/basics#PartialResponse
// for more information.
func (c *UsersDisableCall) Fields(s ...googleapi.Field) *UsersDisableCall {
c.opt_["fields"] = googleapi.CombineFields(s)
return c
}
func (c *UsersDisableCall) Do() (*UserDisableResponse, error) {
var body io.Reader = nil
body, err := googleapi.WithoutDataWrapper.JSONReader(c.userdisablerequest)
if err != nil {
return nil, err
}
ctype := "application/json"
params := make(url.Values)
params.Set("alt", "json")
if v, ok := c.opt_["fields"]; ok {
params.Set("fields", fmt.Sprintf("%v", v))
}
urls := googleapi.ResolveRelative(c.s.BasePath, "users/{id}/disable")
urls += "?" + params.Encode()
req, _ := http.NewRequest("POST", urls, body)
googleapi.Expand(req.URL, map[string]string{
"id": c.id,
})
req.Header.Set("Content-Type", ctype)
req.Header.Set("User-Agent", "google-api-go-client/0.5")
res, err := c.s.client.Do(req)
if err != nil {
return nil, err
}
defer googleapi.CloseBody(res)
if err := googleapi.CheckResponse(res); err != nil {
return nil, err
}
var ret *UserDisableResponse
if err := json.NewDecoder(res.Body).Decode(&ret); err != nil {
return nil, err
}
return ret, nil
// {
// "description": "Enable or disable a user.",
// "httpMethod": "POST",
// "id": "dex.User.Disable",
// "parameterOrder": [
// "id"
// ],
// "parameters": {
// "id": {
// "location": "path",
// "required": true,
// "type": "string"
// }
// },
// "path": "users/{id}/disable",
// "request": {
// "$ref": "UserDisableRequest"
// },
// "response": {
// "$ref": "UserDisableResponse"
// }
// }
}
// method id "dex.User.Get": // method id "dex.User.Get":
type UsersGetCall struct { type UsersGetCall struct {
...@@ -363,7 +457,7 @@ type UsersGetCall struct { ...@@ -363,7 +457,7 @@ type UsersGetCall struct {
opt_ map[string]interface{} opt_ map[string]interface{}
} }
// Get: Get a single use object. // Get: Get a single User object by id.
func (r *UsersService) Get(id string) *UsersGetCall { func (r *UsersService) Get(id string) *UsersGetCall {
c := &UsersGetCall{s: r.s, opt_: make(map[string]interface{})} c := &UsersGetCall{s: r.s, opt_: make(map[string]interface{})}
c.id = id c.id = id
...@@ -406,7 +500,7 @@ func (c *UsersGetCall) Do() (*UserResponse, error) { ...@@ -406,7 +500,7 @@ func (c *UsersGetCall) Do() (*UserResponse, error) {
} }
return ret, nil return ret, nil
// { // {
// "description": "Get a single use object.", // "description": "Get a single User object by id.",
// "httpMethod": "GET", // "httpMethod": "GET",
// "id": "dex.User.Get", // "id": "dex.User.Get",
// "parameterOrder": [ // "parameterOrder": [
......
package workerschema package workerschema
// //
// This file is automatically generated by schema/generator // This file is automatically generated by schema/generator
// //
...@@ -109,6 +108,9 @@ const DiscoveryJSON = `{ ...@@ -109,6 +108,9 @@ const DiscoveryJSON = `{
"admin": { "admin": {
"type": "boolean" "type": "boolean"
}, },
"disabled": {
"type": "boolean"
},
"createdAt": { "createdAt": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
...@@ -167,6 +169,25 @@ const DiscoveryJSON = `{ ...@@ -167,6 +169,25 @@ const DiscoveryJSON = `{
"type": "boolean" "type": "boolean"
} }
} }
},
"UserDisableRequest": {
"id": "UserDisableRequest",
"type": "object",
"properties": {
"disable": {
"type": "boolean",
"description": "If true, disable this user, if false, enable them"
}
}
},
"UserDisableResponse": {
"id": "UserDisableResponse",
"type": "object",
"properties": {
"ok": {
"type": "boolean"
}
}
} }
}, },
"resources": { "resources": {
...@@ -224,7 +245,7 @@ const DiscoveryJSON = `{ ...@@ -224,7 +245,7 @@ const DiscoveryJSON = `{
}, },
"Get": { "Get": {
"id": "dex.User.Get", "id": "dex.User.Get",
"description": "Get a single use object.", "description": "Get a single User object by id.",
"httpMethod": "GET", "httpMethod": "GET",
"path": "users/{id}", "path": "users/{id}",
"parameters": { "parameters": {
...@@ -252,9 +273,31 @@ const DiscoveryJSON = `{ ...@@ -252,9 +273,31 @@ const DiscoveryJSON = `{
"response": { "response": {
"$ref": "UserCreateResponse" "$ref": "UserCreateResponse"
} }
},
"Disable": {
"id": "dex.User.Disable",
"description": "Enable or disable a user.",
"httpMethod": "POST",
"path": "users/{id}/disable",
"parameters": {
"id": {
"type": "string",
"required": true,
"location": "path"
}
},
"parameterOrder": [
"id"
],
"request": {
"$ref": "UserDisableRequest"
},
"response": {
"$ref": "UserDisableResponse"
}
} }
} }
} }
} }
} }
` `
\ No newline at end of file
...@@ -102,6 +102,9 @@ ...@@ -102,6 +102,9 @@
"admin": { "admin": {
"type": "boolean" "type": "boolean"
}, },
"disabled": {
"type": "boolean"
},
"createdAt": { "createdAt": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
...@@ -160,6 +163,25 @@ ...@@ -160,6 +163,25 @@
"type": "boolean" "type": "boolean"
} }
} }
},
"UserDisableRequest": {
"id": "UserDisableRequest",
"type": "object",
"properties": {
"disable": {
"type": "boolean",
"description": "If true, disable this user, if false, enable them. No error is signaled if the user state doesn't change."
}
}
},
"UserDisableResponse": {
"id": "UserDisableResponse",
"type": "object",
"properties": {
"ok": {
"type": "boolean"
}
}
} }
}, },
"resources": { "resources": {
...@@ -217,7 +239,7 @@ ...@@ -217,7 +239,7 @@
}, },
"Get": { "Get": {
"id": "dex.User.Get", "id": "dex.User.Get",
"description": "Get a single use object.", "description": "Get a single User object by id.",
"httpMethod": "GET", "httpMethod": "GET",
"path": "users/{id}", "path": "users/{id}",
"parameters": { "parameters": {
...@@ -245,6 +267,28 @@ ...@@ -245,6 +267,28 @@
"response": { "response": {
"$ref": "UserCreateResponse" "$ref": "UserCreateResponse"
} }
},
"Disable": {
"id": "dex.User.Disable",
"description": "Enable or disable a user.",
"httpMethod": "POST",
"path": "users/{id}/disable",
"parameters": {
"id": {
"type": "string",
"required": true,
"location": "path"
}
},
"parameterOrder": [
"id"
],
"request": {
"$ref": "UserDisableRequest"
},
"response": {
"$ref": "UserDisableResponse"
}
} }
} }
} }
......
...@@ -244,7 +244,11 @@ func (s *Server) HTTPHandler() http.Handler { ...@@ -244,7 +244,11 @@ func (s *Server) HTTPHandler() http.Handler {
mux.Handle(path.Join(apiBasePath, clientPath), s.NewClientTokenAuthHandler(clientHandler)) mux.Handle(path.Join(apiBasePath, clientPath), s.NewClientTokenAuthHandler(clientHandler))
usersAPI := usersapi.NewUsersAPI(s.UserManager, s.ClientIdentityRepo, s.UserEmailer, s.localConnectorID) usersAPI := usersapi.NewUsersAPI(s.UserManager, s.ClientIdentityRepo, s.UserEmailer, s.localConnectorID)
mux.Handle(path.Join(apiBasePath, UsersSubTree), NewUserMgmtServer(usersAPI, s.JWTVerifierFactory(), s.UserManager, s.ClientIdentityRepo).HTTPHandler()) handler := NewUserMgmtServer(usersAPI, s.JWTVerifierFactory(), s.UserManager, s.ClientIdentityRepo).HTTPHandler()
path := path.Join(apiBasePath, UsersSubTree)
mux.Handle(path, handler)
mux.Handle(path+"/", handler)
return http.Handler(mux) return http.Handler(mux)
} }
......
...@@ -23,10 +23,11 @@ const ( ...@@ -23,10 +23,11 @@ const (
) )
var ( var (
UsersSubTree = "/users" UsersSubTree = "/users"
UsersListEndpoint = addBasePath(UsersSubTree) UsersListEndpoint = addBasePath(UsersSubTree)
UsersCreateEndpoint = addBasePath(UsersSubTree) UsersCreateEndpoint = addBasePath(UsersSubTree)
UsersGetEndpoint = addBasePath(UsersSubTree + "/:id") UsersGetEndpoint = addBasePath(UsersSubTree + "/:id")
UsersDisableEndpoint = addBasePath(UsersSubTree + "/:id/disable")
) )
type UserMgmtServer struct { type UserMgmtServer struct {
...@@ -51,6 +52,7 @@ func (s *UserMgmtServer) HTTPHandler() http.Handler { ...@@ -51,6 +52,7 @@ func (s *UserMgmtServer) HTTPHandler() http.Handler {
r.RedirectFixedPath = false r.RedirectFixedPath = false
r.GET(UsersListEndpoint, s.listUsers) r.GET(UsersListEndpoint, s.listUsers)
r.POST(UsersCreateEndpoint, s.createUser) r.POST(UsersCreateEndpoint, s.createUser)
r.POST(UsersDisableEndpoint, s.disableUser)
r.GET(UsersGetEndpoint, s.getUser) r.GET(UsersGetEndpoint, s.getUser)
return r return r
} }
...@@ -140,6 +142,35 @@ func (s *UserMgmtServer) createUser(w http.ResponseWriter, r *http.Request, ps h ...@@ -140,6 +142,35 @@ func (s *UserMgmtServer) createUser(w http.ResponseWriter, r *http.Request, ps h
writeResponseWithBody(w, http.StatusOK, createdResponse) writeResponseWithBody(w, http.StatusOK, createdResponse)
} }
func (s *UserMgmtServer) disableUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
creds, err := s.getCreds(r)
if err != nil {
s.writeError(w, err)
return
}
id := ps.ByName("id")
if id == "" {
writeAPIError(w, http.StatusBadRequest,
newAPIError(errorInvalidRequest, "id is required"))
return
}
disableReq := schema.UserDisableRequest{}
err = json.NewDecoder(r.Body).Decode(&disableReq)
if err != nil {
writeInvalidRequest(w, "cannot parse JSON body")
}
resp, err := s.api.DisableUser(creds, id, disableReq.Disable)
if err != nil {
s.writeError(w, err)
return
}
writeResponseWithBody(w, http.StatusOK, resp)
}
func (s *UserMgmtServer) writeError(w http.ResponseWriter, err error) { func (s *UserMgmtServer) writeError(w http.ResponseWriter, err error) {
log.Errorf("Error calling user management API: %v: ", err) log.Errorf("Error calling user management API: %v: ", err)
if apiErr, ok := err.(api.Error); ok { if apiErr, ok := err.(api.Error); ok {
......
...@@ -121,6 +121,21 @@ func (u *UsersAPI) GetUser(creds Creds, id string) (schema.User, error) { ...@@ -121,6 +121,21 @@ func (u *UsersAPI) GetUser(creds Creds, id string) (schema.User, error) {
return userToSchemaUser(usr), nil return userToSchemaUser(usr), nil
} }
func (u *UsersAPI) DisableUser(creds Creds, userID string, disable bool) (schema.UserDisableResponse, error) {
log.Infof("userAPI: DisableUser")
if !u.Authorize(creds) {
return schema.UserDisableResponse{}, ErrorUnauthorized
}
if err := u.manager.Disable(userID, disable); err != nil {
return schema.UserDisableResponse{}, mapError(err)
}
return schema.UserDisableResponse{
Ok: true,
}, nil
}
func (u *UsersAPI) CreateUser(creds Creds, usr schema.User, redirURL url.URL) (schema.UserCreateResponse, error) { func (u *UsersAPI) CreateUser(creds Creds, usr schema.User, redirURL url.URL) (schema.UserCreateResponse, error) {
log.Infof("userAPI: CreateUser") log.Infof("userAPI: CreateUser")
if !u.Authorize(creds) { if !u.Authorize(creds) {
...@@ -207,6 +222,7 @@ func userToSchemaUser(usr user.User) schema.User { ...@@ -207,6 +222,7 @@ func userToSchemaUser(usr user.User) schema.User {
EmailVerified: usr.EmailVerified, EmailVerified: usr.EmailVerified,
DisplayName: usr.DisplayName, DisplayName: usr.DisplayName,
Admin: usr.Admin, Admin: usr.Admin,
Disabled: usr.Disabled,
CreatedAt: usr.CreatedAt.UTC().Format(time.RFC3339), CreatedAt: usr.CreatedAt.UTC().Format(time.RFC3339),
} }
} }
...@@ -218,6 +234,7 @@ func schemaUserToUser(usr schema.User) user.User { ...@@ -218,6 +234,7 @@ func schemaUserToUser(usr schema.User) user.User {
EmailVerified: usr.EmailVerified, EmailVerified: usr.EmailVerified,
DisplayName: usr.DisplayName, DisplayName: usr.DisplayName,
Admin: usr.Admin, Admin: usr.Admin,
Disabled: usr.Disabled,
} }
} }
......
...@@ -94,6 +94,13 @@ func makeTestFixtures() (*UsersAPI, *testEmailer) { ...@@ -94,6 +94,13 @@ func makeTestFixtures() (*UsersAPI, *testEmailer) {
Email: "id3@example.com", Email: "id3@example.com",
CreatedAt: clock.Now(), CreatedAt: clock.Now(),
}, },
}, {
User: user.User{
ID: "ID-4",
Email: "id4@example.com",
CreatedAt: clock.Now(),
Disabled: true,
},
}, },
}) })
pwr := user.NewPasswordInfoRepoFromPasswordInfos([]user.PasswordInfo{ pwr := user.NewPasswordInfoRepoFromPasswordInfos([]user.PasswordInfo{
...@@ -369,3 +376,44 @@ func TestCreateUser(t *testing.T) { ...@@ -369,3 +376,44 @@ func TestCreateUser(t *testing.T) {
} }
} }
} }
func TestDisableUsers(t *testing.T) {
tests := []struct {
id string
disable bool
}{
{
id: "ID-1",
disable: true,
},
{
id: "ID-1",
disable: false,
},
{
id: "ID-4",
disable: true,
},
{
id: "ID-4",
disable: false,
},
}
for i, tt := range tests {
api, _ := makeTestFixtures()
_, err := api.DisableUser(goodCreds, tt.id, tt.disable)
if err != nil {
t.Fatalf("case %d: unexpected error: %v", i, err)
}
usr, err := api.GetUser(goodCreds, tt.id)
if err != nil {
t.Fatalf("case %d: unexpected error: %v", i, err)
}
if usr.Disabled != tt.disable {
t.Errorf("case %d: user disable state wrong. wanted: %v got: %v", i, tt.disable, usr.Disabled)
}
}
}
...@@ -102,6 +102,22 @@ func (m *Manager) CreateUser(user User, hashedPassword Password, connID string) ...@@ -102,6 +102,22 @@ func (m *Manager) CreateUser(user User, hashedPassword Password, connID string)
return user.ID, nil return user.ID, nil
} }
func (m *Manager) Disable(userID string, disabled bool) error {
tx, err := m.begin()
if err = m.userRepo.Disable(tx, userID, disabled); err != nil {
rollback(tx)
return err
}
if err = tx.Commit(); err != nil {
rollback(tx)
return err
}
return nil
}
// RegisterWithRemoteIdentity creates new user and attaches the given remote identity. // RegisterWithRemoteIdentity creates new user and attaches the given remote identity.
func (m *Manager) RegisterWithRemoteIdentity(email string, emailVerified bool, rid RemoteIdentity) (string, error) { func (m *Manager) RegisterWithRemoteIdentity(email string, emailVerified bool, rid RemoteIdentity) (string, error) {
tx, err := m.begin() tx, err := m.begin()
......
...@@ -80,6 +80,8 @@ type UserRepo interface { ...@@ -80,6 +80,8 @@ type UserRepo interface {
GetByEmail(tx repo.Transaction, email string) (User, error) GetByEmail(tx repo.Transaction, email string) (User, error)
Disable(tx repo.Transaction, id string, disabled bool) error
Update(repo.Transaction, User) error Update(repo.Transaction, User) error
GetByRemoteIdentity(repo.Transaction, RemoteIdentity) (User, error) GetByRemoteIdentity(repo.Transaction, RemoteIdentity) (User, error)
...@@ -254,6 +256,19 @@ func (r *memUserRepo) Update(_ repo.Transaction, user User) error { ...@@ -254,6 +256,19 @@ func (r *memUserRepo) Update(_ repo.Transaction, user User) error {
return nil return nil
} }
func (r *memUserRepo) Disable(_ repo.Transaction, id string, disable bool) error {
if id == "" {
return ErrorInvalidID
}
user, ok := r.usersByID[id]
if !ok {
return ErrorNotFound
}
user.Disabled = disable
r.set(user)
return nil
}
func (r *memUserRepo) AddRemoteIdentity(_ repo.Transaction, userID string, ri RemoteIdentity) error { func (r *memUserRepo) AddRemoteIdentity(_ repo.Transaction, userID string, ri RemoteIdentity) error {
_, ok := r.usersByID[userID] _, ok := r.usersByID[userID]
if !ok { if !ok {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment