diff --git a/README.md b/README.md index 0eee0df..7397849 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,9 @@ then `Goffee` locates the matching [handler](https://git.smarteching.com/goffee/ │ ├── config/ --------------------------> main configs │ ├── events/ --------------------------> contains events │ │ ├── jobs/ ------------------------> contains the event jobs -│ ├── controllers/ ------------------------> route's controllers +│ ├── controllers/ ---------------------> route's controllers │ ├── logs/ ----------------------------> app log files -│ ├── hooks/ ---------------------> app hooks +│ ├── hooks/ ---------------------------> app hooks │ ├── models/ --------------------------> database models │ ├── storage/ -------------------------> a place to store files │ ├── tls/ -----------------------------> tls certificates @@ -86,7 +86,7 @@ then `Goffee` locates the matching [handler](https://git.smarteching.com/goffee/ │ ├── main.go --------------------------> go main file │ ├── README.md ------------------------> readme file │ ├── register-events.go ---------------> register events and jobs -│ ├── register-global-hooks.go ---> register global middlewares +│ ├── register-global-hooks.go ---------> register global middlewares │ ├── routes.go ------------------------> app routes │ ├── run-auto-migrations.go -----------> database migrations ``` \ No newline at end of file diff --git a/controllers/adminusers.go b/controllers/adminusers.go new file mode 100644 index 0000000..b051308 --- /dev/null +++ b/controllers/adminusers.go @@ -0,0 +1,478 @@ +// Copyright (c) 2024 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package controllers + +import ( + "errors" + "fmt" + "strconv" + + "git.smarteching.com/goffee/core" + "git.smarteching.com/goffee/core/template/components" + "git.smarteching.com/goffee/cup/events" + "git.smarteching.com/goffee/cup/models" + "git.smarteching.com/goffee/cup/utils" + "gorm.io/gorm" +) + +func AdminUsersList(c *core.Context) *core.Response { + + var users []models.User + db := c.GetGorm() + db.Find(&users) + + // -- response template + type templateData struct { + TableUsers components.ContentTable + AddButton components.ContentHref + } + + cols := []components.ContentTableTH{ + { + Value: "UID", + ValueType: "number", + }, + { + Value: "Name", + ValueType: "string", + }, + { + Value: "Fullname", + ValueType: "string", + }, + { + Value: "Email", + ValueType: "string", + }, + { + Value: "Created", + }, + { + Value: "Updated", + }, + { + ValueType: "href", + }, + } + + rows := make([][]components.ContentTableTD, len(users)) + for i, u := range users { + row := []components.ContentTableTD{ + {Value: strconv.Itoa(int(u.ID))}, + {Value: u.Name}, + {Value: u.Fullname}, + {Value: u.Email}, + {Value: utils.FormatUnix(u.Created)}, + {Value: utils.FormatUnix(u.Updated)}, + {Value: components.ContentHref{ + Text: "edit", + Link: "/admin/users/edit/" + strconv.Itoa(int(u.ID)), + TypeClass: "outline-secondary", + }}, + } + rows[i] = row + } + + tmplData := templateData{ + TableUsers: components.ContentTable{ + ID: "table_demo", + AllTH: cols, + AllTD: rows, + }, + AddButton: components.ContentHref{ + Text: "Register", + Link: "/admin/users/add", + IsButton: true, + TypeClass: "outline-primary", + }, + } + return c.Response.Template("admin_userlist.html", tmplData) +} + +func AdminUsersAdd(c *core.Context) *core.Response { + + // check if is submit + submit := c.GetRequestParam("submit").(string) + + errormessages := make([]string, 0) + + name := "" + fullname := "" + email := "" + password := "" + + if submit != "" { + + name = c.GetRequestParam("name").(string) + fullname = c.GetRequestParam("fullname").(string) + email = c.GetRequestParam("email").(string) + password = c.GetRequestParam("password").(string) + + // check if email exists + var user models.User + res := c.GetGorm().Where("email = ?", c.CastToString(email)).First(&user) + if res.Error != nil && !errors.Is(res.Error, gorm.ErrRecordNotFound) { + c.GetLogger().Error(res.Error.Error()) + errormessages = append(errormessages, "Error") + } + if res.Error == nil { + errormessages = append(errormessages, "Error, email already exists in the database") + } + + // validation data + data := map[string]interface{}{ + "name": name, + "fullname": fullname, + "email": email, + "password": password, + } + // validation rules + rules := map[string]interface{}{ + "name": "required|alphaNumeric", + "fullname": "required", + "email": "required|email", + "password": "required|length:6,20", + } + // validate + v := c.GetValidator().Validate(data, rules) + if v.Failed() { + c.GetLogger().Error(v.GetErrorMessagesJson()) + for _, v := range v.GetErrorMessagesMap() { + errormessages = append(errormessages, v) + } + } + + if len(errormessages) == 0 { + + //hash the password + passwordHashed, _ := c.GetHashing().HashPassword(c.CastToString(password)) + // store the record in db + user = models.User{ + Name: c.CastToString(name), + Fullname: c.CastToString(fullname), + Email: c.CastToString(email), + Password: passwordHashed, + } + res = c.GetGorm().Create(&user) + if res.Error != nil { + c.GetLogger().Error(res.Error.Error()) + errormessages = append(errormessages, res.Error.Error()) + } else { + + // fire user registered event + err := c.GetEventsManager().Fire(&core.Event{Name: events.USER_REGISTERED, Payload: map[string]interface{}{ + "user": user, + }}) + if err != nil { + c.GetLogger().Error(err.Error()) + errormessages = append(errormessages, "Internal server error") + } else { + // redirect to list + return c.Response.Redirect("/admin/users") + } + } + } + } + // -- response template + type templateData struct { + FieldName components.FormInput + FieldFullname components.FormInput + FieldEmail components.FormInput + FieldPassword components.FormInput + ErrorMessages []string + SubmitButton components.FormButton + } + + tmplData := templateData{ + FieldName: components.FormInput{ + ID: "name", + Label: "Name", + Type: "text", + Value: name, + IsRequired: true, + }, + FieldFullname: components.FormInput{ + ID: "fullname", + Label: "Full name", + Type: "text", + Value: fullname, + IsRequired: true, + }, + FieldEmail: components.FormInput{ + ID: "email", + Label: "e-mail", + Type: "text", + Value: email, + //Autocomplete: true, + IsRequired: true, + }, + FieldPassword: components.FormInput{ + ID: "password", + Label: "Password", + Type: "password", + Value: password, + IsRequired: true, + }, + SubmitButton: components.FormButton{ + ID: "submit", + Text: "Add user", + Value: "submit", + IsSubmit: true, + TypeClass: "primary", + }, + ErrorMessages: errormessages, + } + return c.Response.Template("admin_useradd.html", tmplData) + +} + +func AdminUsersEdit(c *core.Context) *core.Response { + + // check if is submit + submit := c.GetRequestParam("submit").(string) + + user_id := c.GetPathParam("id") + + errormessages := make([]string, 0) + + var origin_user models.User + + db := c.GetGorm() + // check if existes + result_db := db.First(&origin_user, user_id) + if result_db.RowsAffected == 0 { + c.GetLogger().Error("User ID not found") + return c.Response.Redirect("/admin/users") + } + + name := origin_user.Name + fullname := origin_user.Fullname + email := origin_user.Email + password := "" + user_id_string := c.GetPathParam("id").(string) + + if submit != "" { + + name = c.GetRequestParam("name").(string) + fullname = c.GetRequestParam("fullname").(string) + email = c.GetRequestParam("email").(string) + password = c.GetRequestParam("password").(string) + key := c.GetRequestParam("key") + + // check if email exists + var user models.User + res := c.GetGorm().Where("email = ? AND id != ?", c.CastToString(email), key).First(&user) + if res.Error != nil && !errors.Is(res.Error, gorm.ErrRecordNotFound) { + c.GetLogger().Error(res.Error.Error()) + errormessages = append(errormessages, "Error") + } + if res.Error == nil { + errormessages = append(errormessages, "Error, email already exists to another user") + } + + // validation data + data := map[string]interface{}{ + "name": name, + "fullname": fullname, + "email": email, + } + // validation rules + rules := map[string]interface{}{ + "name": "required|alphaNumeric", + "fullname": "required", + "email": "required|email", + } + + // nee update password + if password != "" { + data["password"] = password + rules["password"] = "required|length:6,20" + } + // validate + v := c.GetValidator().Validate(data, rules) + if v.Failed() { + c.GetLogger().Error(v.GetErrorMessagesJson()) + for _, v := range v.GetErrorMessagesMap() { + errormessages = append(errormessages, v) + } + } + + if len(errormessages) == 0 { + + //hash the password + if password != "" { + passwordHashed, _ := c.GetHashing().HashPassword(c.CastToString(password)) + origin_user.Password = passwordHashed + } + // store the record in db + origin_user.Name = name + origin_user.Fullname = fullname + origin_user.Email = email + + result_db = db.Save(&origin_user) + if result_db.RowsAffected == 0 { + c.GetLogger().Error("Admin user: error updating") + errormessages = append(errormessages, fmt.Sprintf("Error updating user %s:", user_id_string)) + } else { + return c.Response.Redirect("/admin/users") + } + } + } + // -- response template + type templateData struct { + FieldName components.FormInput + FieldFullname components.FormInput + FieldEmail components.FormInput + FieldPassword components.FormInput + FieldKey components.FormInput + ErrorMessages []string + SubmitButton components.FormButton + DeleteButton components.FormButton + } + + tmplData := templateData{ + FieldName: components.FormInput{ + ID: "name", + Label: "Name", + Type: "text", + Value: name, + IsRequired: true, + }, + FieldFullname: components.FormInput{ + ID: "fullname", + Label: "Full name", + Type: "text", + Value: fullname, + IsRequired: true, + }, + FieldEmail: components.FormInput{ + ID: "email", + Label: "e-mail", + Type: "text", + Value: email, + //Autocomplete: true, + IsRequired: true, + }, + FieldPassword: components.FormInput{ + ID: "password", + Label: "Password", + Type: "password", + Hint: "Leave blank if you don't want to change it", + Value: password, + IsRequired: false, + }, + FieldKey: components.FormInput{ + ID: "key", + Type: "hidden", + Value: user_id_string, + }, + SubmitButton: components.FormButton{ + ID: "submit", + Text: "Update user", + Value: "submit", + IsSubmit: true, + TypeClass: "primary", + }, + DeleteButton: components.FormButton{ + ID: "submit", + Text: "Delete user", + Value: "submit", + IsSubmit: true, + TypeClass: "warning", + }, + ErrorMessages: errormessages, + } + return c.Response.Template("admin_useredit.html", tmplData) + +} + +func AdminUsersDelete(c *core.Context) *core.Response { + + user_id := c.GetRequestParam("key").(string) + + errormessages := make([]string, 0) + warningmessages := make([]string, 0) + + var origin_user models.User + + db := c.GetGorm() + // check if existes + result_db := db.First(&origin_user, user_id) + if result_db.RowsAffected == 0 { + errormessages = append(errormessages, "User ID not found") + } else { + // check if is the seed user + seed := "1" + if user_id == seed { + errormessages = append(errormessages, "You can't delete the seed user") + } + + } + + // sample warning message + warningmessages = append(warningmessages, fmt.Sprintf("Are you sure you want to cancel the account %s?", origin_user.Name)) + + // -- response template + type templateData struct { + ErrorMessages []string + WarningMessages []string + FieldKey components.FormInput + ConfirmButton components.FormButton + BackButton components.ContentHref + } + + tmplData := templateData{ + FieldKey: components.FormInput{ + ID: "key", + Type: "hidden", + Value: user_id, + }, + ConfirmButton: components.FormButton{ + ID: "submit", + Text: "Confirm", + Value: "submit", + IsSubmit: true, + TypeClass: "primary", + }, + BackButton: components.ContentHref{ + Link: "/admin/users", + Text: "Cancel", + IsButton: true, + }, + ErrorMessages: errormessages, + WarningMessages: warningmessages, + } + return c.Response.Template("admin_confirmuserdel.html", tmplData) + +} + +func AdminUsersDelConfirm(c *core.Context) *core.Response { + + user_id := c.GetRequestParam("key").(string) + + var origin_user models.User + + db := c.GetGorm() + // check if existes + result_db := db.First(&origin_user, user_id) + if result_db.RowsAffected != 0 { + // check if is the seed user + seed := "1" + if user_id != seed { + // Delete the user + // fire user delete event + err := c.GetEventsManager().Fire(&core.Event{Name: events.USER_DELETED, Payload: map[string]interface{}{ + "user": origin_user, + }}) + if err != nil { + c.GetLogger().Error(err.Error()) + } + result_db.Unscoped().Delete(&origin_user) + } + } + + return c.Response.Redirect("/admin/users") + +} diff --git a/controllers/authentication.go b/controllers/authentication.go index 9e7e940..9e3eff9 100644 --- a/controllers/authentication.go +++ b/controllers/authentication.go @@ -26,6 +26,7 @@ import ( func Signup(c *core.Context) *core.Response { name := c.GetRequestParam("name") + fullname := c.GetRequestParam("fullname") email := c.GetRequestParam("email") password := c.GetRequestParam("password") // check if email exists @@ -46,14 +47,16 @@ func Signup(c *core.Context) *core.Response { // validation data data := map[string]interface{}{ "name": name, + "fullname": fullname, "email": email, "password": password, } // validation rules rules := map[string]interface{}{ "name": "required|alphaNumeric", + "fullname": "required", "email": "required|email", - "password": "required|length:6,10", + "password": "required|length:6,20", } // validate v := c.GetValidator().Validate(data, rules) @@ -73,6 +76,7 @@ func Signup(c *core.Context) *core.Response { // store the record in db user = models.User{ Name: c.CastToString(name), + Fullname: c.CastToString(fullname), Email: c.CastToString(email), Password: passwordHashed, } diff --git a/events/event-names.go b/events/event-names.go index 0e5f431..14afec8 100644 --- a/events/event-names.go +++ b/events/event-names.go @@ -2,5 +2,6 @@ package events // event names const USER_REGISTERED = "user-registered" +const USER_DELETED = "user-deleted" const USER_PASSWORD_RESET_REQUESTED = "user-password-reset-requested" const PASSWORD_CHANGED = "password-changed" diff --git a/models/base.go b/models/base.go new file mode 100644 index 0000000..5350de2 --- /dev/null +++ b/models/base.go @@ -0,0 +1,11 @@ +// Copyright (c) 2024 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package models + +type BaseModel struct { + ID uint `json:"id" gorm:"primaryKey; column:id"` + Created int64 `gorm:"autoCreateTime"` + Updated int64 `gorm:"autoUpdateTime"` +} diff --git a/models/user.go b/models/user.go index c85a386..3a229d9 100644 --- a/models/user.go +++ b/models/user.go @@ -5,11 +5,10 @@ package models -import "gorm.io/gorm" - type User struct { - gorm.Model + BaseModel Name string + Fullname string Email string Password string } diff --git a/routes.go b/routes.go index 00f1906..785c59e 100644 --- a/routes.go +++ b/routes.go @@ -34,6 +34,18 @@ func registerRoutes() { controller.Post("/reset-password", controllers.ResetPasswordRequest) controller.Post("/reset-password/code/:code", controllers.SetNewPassword) + // Uncomment the lines below to enable user administration + controller.Get("/admin/users", controllers.AdminUsersList) + controller.Post("/admin/users", controllers.AdminUsersList) + controller.Get("/admin/users/add", controllers.AdminUsersAdd) + controller.Post("/admin/users/add", controllers.AdminUsersAdd) + controller.Get("/admin/users/edit/:id", controllers.AdminUsersEdit) + controller.Post("/admin/users/edit/:id", controllers.AdminUsersEdit) + controller.Post("/admin/users/delete", controllers.AdminUsersDelete) + controller.Post("/admin/users/deleteconfirm", controllers.AdminUsersDelConfirm) + //controller.Get("/admin/users/roles", controllers.Signout) + //controller.Get("/admin/users/permissions", controllers.ResetPasswordRequest) + controller.Get("/dashboard", controllers.WelcomeToDashboard, hooks.AuthCheck) // templates demos diff --git a/storage/templates/admin_confirmuserdel.html b/storage/templates/admin_confirmuserdel.html new file mode 100644 index 0000000..5fd0d71 --- /dev/null +++ b/storage/templates/admin_confirmuserdel.html @@ -0,0 +1,34 @@ + + + {{template "page_head" "Goffee"}} + +
+

User delete confirmation

+ {{if .ErrorMessages }} +
+ +
    + {{range $i, $a := .ErrorMessages}} +
  • {{$a}}
  • + {{end}} +
+
+ {{else}} + {{if .WarningMessages }} +
    + {{range $o, $u := .WarningMessages}} +
  • {{$u}}
  • + {{end}} +
+ {{end}} +
+ {{template "form_input" .FieldKey}} + {{template "form_button" .ConfirmButton}} +
+ {{end}} +
+ {{template "content_href" .BackButton}} +
+ {{template "page_footer"}} + + diff --git a/storage/templates/admin_useradd.html b/storage/templates/admin_useradd.html new file mode 100644 index 0000000..ccdb363 --- /dev/null +++ b/storage/templates/admin_useradd.html @@ -0,0 +1,26 @@ + + + {{template "page_head" "Goffee"}} + +
+

Add user form

+ {{if .ErrorMessages }}
+ +
    + {{range $i, $a := .ErrorMessages}} +
  • {{$a}}
  • + {{end}} +
+
{{end}} +
+ {{template "form_input" .FieldName}} + {{template "form_input" .FieldFullname}} + {{template "form_input" .FieldEmail}} + {{template "form_input" .FieldPassword}} +
+ {{template "form_button" .SubmitButton}} +
+
+ {{template "page_footer"}} + + diff --git a/storage/templates/admin_useredit.html b/storage/templates/admin_useredit.html new file mode 100644 index 0000000..36ebfdf --- /dev/null +++ b/storage/templates/admin_useredit.html @@ -0,0 +1,31 @@ + + + {{template "page_head" "Goffee"}} + +
+

Edit user form

+ {{if .ErrorMessages }}
+ +
    + {{range $i, $a := .ErrorMessages}} +
  • {{$a}}
  • + {{end}} +
+
{{end}} +
+ {{template "form_input" .FieldName}} + {{template "form_input" .FieldFullname}} + {{template "form_input" .FieldEmail}} + {{template "form_input" .FieldPassword}} + {{template "form_input" .FieldKey}} + {{template "form_button" .SubmitButton}} +
+
+
+ {{template "form_input" .FieldKey}} + {{template "form_button" .DeleteButton}} +
+
+ {{template "page_footer"}} + + diff --git a/storage/templates/admin_userlist.html b/storage/templates/admin_userlist.html new file mode 100644 index 0000000..9981d46 --- /dev/null +++ b/storage/templates/admin_userlist.html @@ -0,0 +1,18 @@ + + + {{template "page_head" "Goffee"}} + +
+
+

Users

+
+ {{template "content_href" .AddButton}} +
+
+
+ {{template "content_table" .TableUsers}} +
+
+ {{template "page_footer"}} + + diff --git a/utils/helpers.go b/utils/helpers.go index 00525b7..36ac76b 100644 --- a/utils/helpers.go +++ b/utils/helpers.go @@ -8,6 +8,7 @@ package utils import ( "crypto/md5" "fmt" + "time" ) // generate a hashed string to be used as key for caching auth jwt token @@ -17,3 +18,7 @@ func CreateAuthTokenHashedCacheKey(userID uint, userAgent string) string { return hashedCacheKey } + +func FormatUnix(value int64) string { + return time.Unix(value, 0).Format("2006-01-02 15:04:05") +}