feat(admin): handle user deletion
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Evan Reichard 2024-05-27 13:32:40 -04:00
parent db9629a618
commit f9277d3b32
6 changed files with 120 additions and 65 deletions

View File

@ -71,7 +71,7 @@ const (
type requestAdminUpdateUser struct { type requestAdminUpdateUser struct {
User string `form:"user"` User string `form:"user"`
Password *string `form:"password"` Password *string `form:"password"`
IsAdmin *string `form:"is_admin"` IsAdmin *bool `form:"is_admin"`
Operation operationType `form:"operation"` Operation operationType `form:"operation"`
} }
@ -268,21 +268,27 @@ func (api *API) appGetAdminUsers(c *gin.Context) {
func (api *API) appUpdateAdminUsers(c *gin.Context) { func (api *API) appUpdateAdminUsers(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin-users", c) templateVars, _ := api.getBaseTemplateVars("admin-users", c)
var rAdminUserUpdate requestAdminUpdateUser var rUpdate requestAdminUpdateUser
if err := c.ShouldBind(&rAdminUserUpdate); err != nil { if err := c.ShouldBind(&rUpdate); err != nil {
log.Error("Invalid URI Bind") log.Error("Invalid URI Bind")
appErrorPage(c, http.StatusNotFound, "Invalid user update") appErrorPage(c, http.StatusNotFound, "Invalid user parameters")
return
}
// Ensure Username
if rUpdate.User == "" {
appErrorPage(c, http.StatusInternalServerError, "User cannot be empty")
return return
} }
var err error var err error
switch rAdminUserUpdate.Operation { switch rUpdate.Operation {
case opCreate: case opCreate:
err = api.createUser(rAdminUserUpdate) err = api.createUser(rUpdate.User, rUpdate.Password, rUpdate.IsAdmin)
case opUpdate: case opUpdate:
err = api.updateUser(rAdminUserUpdate) err = api.updateUser(rUpdate.User, rUpdate.Password, rUpdate.IsAdmin)
case opDelete: case opDelete:
err = api.deleteUser(rAdminUserUpdate) err = api.deleteUser(rUpdate.User)
default: default:
appErrorPage(c, http.StatusNotFound, "Unknown user operation") appErrorPage(c, http.StatusNotFound, "Unknown user operation")
return return
@ -611,14 +617,6 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte
} }
defer backupFile.Close() defer backupFile.Close()
// Vacuum DB
_, err = api.db.DB.ExecContext(api.db.Ctx, "VACUUM;")
if err != nil {
log.Error("Unable to vacuum DB: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database")
return
}
// Save Backup File // Save Backup File
w := bufio.NewWriter(backupFile) w := bufio.NewWriter(backupFile)
err = api.createBackup(w, []string{"covers", "documents"}) err = api.createBackup(w, []string{"covers", "documents"})
@ -712,8 +710,13 @@ func (api *API) removeData() error {
} }
func (api *API) createBackup(w io.Writer, directories []string) error { func (api *API) createBackup(w io.Writer, directories []string) error {
ar := zip.NewWriter(w) // Vacuum DB
_, err := api.db.DB.ExecContext(api.db.Ctx, "VACUUM;")
if err != nil {
return errors.Wrap(err, "Unable to vacuum database")
}
ar := zip.NewWriter(w)
exportWalker := func(currentPath string, f fs.DirEntry, err error) error { exportWalker := func(currentPath string, f fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
@ -781,29 +784,43 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
return nil return nil
} }
func (api *API) createUser(createRequest requestAdminUpdateUser) error { func (api *API) isLastAdmin(userID string) (bool, error) {
// Validate Necessary Parameters allUsers, err := api.db.Queries.GetUsers(api.db.Ctx)
if createRequest.User == "" { if err != nil {
return fmt.Errorf("username can't be empty") return false, errors.Wrap(err, fmt.Sprintf("GetUsers DB Error: %v", err))
} }
if createRequest.Password == nil || *createRequest.Password == "" {
hasAdmin := false
for _, user := range allUsers {
if user.Admin && user.ID != userID {
hasAdmin = true
break
}
}
return !hasAdmin, nil
}
func (api *API) createUser(user string, rawPassword *string, isAdmin *bool) error {
// Validate Necessary Parameters
if rawPassword == nil || *rawPassword == "" {
return fmt.Errorf("password can't be empty") return fmt.Errorf("password can't be empty")
} }
// Base Params // Base Params
createParams := database.CreateUserParams{ createParams := database.CreateUserParams{
ID: createRequest.User, ID: user,
} }
// Handle Admin (Explicit or False) // Handle Admin (Explicit or False)
if createRequest.IsAdmin != nil { if isAdmin != nil {
createParams.Admin = *createRequest.IsAdmin == "true" createParams.Admin = *isAdmin
} else { } else {
createParams.Admin = false createParams.Admin = false
} }
// Parse Password // Parse Password
password := fmt.Sprintf("%x", md5.Sum([]byte(*createRequest.Password))) password := fmt.Sprintf("%x", md5.Sum([]byte(*rawPassword)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams) hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil { if err != nil {
return fmt.Errorf("unable to create hashed password") return fmt.Errorf("unable to create hashed password")
@ -830,25 +847,22 @@ func (api *API) createUser(createRequest requestAdminUpdateUser) error {
return nil return nil
} }
func (api *API) updateUser(updateRequest requestAdminUpdateUser) error { func (api *API) updateUser(user string, rawPassword *string, isAdmin *bool) error {
// Validate Necessary Parameters // Validate Necessary Parameters
if updateRequest.User == "" { if rawPassword == nil && isAdmin == nil {
return fmt.Errorf("username can't be empty")
}
if updateRequest.Password == nil && updateRequest.IsAdmin == nil {
return fmt.Errorf("nothing to update") return fmt.Errorf("nothing to update")
} }
// Base Params // Base Params
updateParams := database.UpdateUserParams{ updateParams := database.UpdateUserParams{
UserID: updateRequest.User, UserID: user,
} }
// Handle Admin (Update or Existing) // Handle Admin (Update or Existing)
if updateRequest.IsAdmin != nil { if isAdmin != nil {
updateParams.Admin = *updateRequest.IsAdmin == "true" updateParams.Admin = *isAdmin
} else { } else {
user, err := api.db.Queries.GetUser(api.db.Ctx, updateRequest.User) user, err := api.db.Queries.GetUser(api.db.Ctx, user)
if err != nil { if err != nil {
return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err)) return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err))
} }
@ -856,20 +870,20 @@ func (api *API) updateUser(updateRequest requestAdminUpdateUser) error {
} }
// Check Admins // Check Admins
if isLast, err := api.isLastAdmin(updateRequest.User); err != nil { if isLast, err := api.isLastAdmin(user); err != nil {
return err return err
} else if isLast { } else if isLast {
return fmt.Errorf("unable to demote %s - last admin", updateRequest.User) return fmt.Errorf("unable to demote %s - last admin", user)
} }
// Handle Password // Handle Password
if updateRequest.Password != nil { if rawPassword != nil {
if *updateRequest.Password == "" { if *rawPassword == "" {
return fmt.Errorf("password can't be empty") return fmt.Errorf("password can't be empty")
} }
// Parse Password // Parse Password
password := fmt.Sprintf("%x", md5.Sum([]byte(*updateRequest.Password))) password := fmt.Sprintf("%x", md5.Sum([]byte(*rawPassword)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams) hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil { if err != nil {
return fmt.Errorf("unable to create hashed password") return fmt.Errorf("unable to create hashed password")
@ -894,32 +908,34 @@ func (api *API) updateUser(updateRequest requestAdminUpdateUser) error {
return nil return nil
} }
func (api *API) deleteUser(updateRequest requestAdminUpdateUser) error { func (api *API) deleteUser(user string) error {
// Check Admins // Check Admins
if isLast, err := api.isLastAdmin(updateRequest.User); err != nil { if isLast, err := api.isLastAdmin(user); err != nil {
return err return err
} else if isLast { } else if isLast {
return fmt.Errorf("unable to demote %s - last admin", updateRequest.User) return fmt.Errorf("unable to delete %s - last admin", user)
} }
// TODO - Implementation // Create Backup File
backupFilePath := filepath.Join(api.cfg.ConfigPath, fmt.Sprintf("backups/AnthoLumeBackup_%s.zip", time.Now().Format("20060102150405")))
return errors.New("unimplemented") backupFile, err := os.Create(backupFilePath)
}
func (api *API) isLastAdmin(userID string) (bool, error) {
allUsers, err := api.db.Queries.GetUsers(api.db.Ctx)
if err != nil { if err != nil {
return false, errors.Wrap(err, fmt.Sprintf("GetUsers DB Error: %v", err)) return err
}
defer backupFile.Close()
// Save Backup File (DB Only)
w := bufio.NewWriter(backupFile)
err = api.createBackup(w, []string{})
if err != nil {
return err
} }
hasAdmin := false // Delete User
for _, user := range allUsers { _, err = api.db.Queries.DeleteUser(api.db.Ctx, user)
if user.Admin && user.ID != userID { if err != nil {
hasAdmin = true return errors.Wrap(err, fmt.Sprintf("DeleteUser DB Error: %v", err))
break
}
} }
return !hasAdmin, nil return nil
} }

File diff suppressed because one or more lines are too long

View File

@ -30,6 +30,9 @@ INSERT INTO users (id, pass, auth_hash, admin)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- name: DeleteUser :execrows
DELETE FROM users WHERE id = $id;
-- name: DeleteDocument :execrows -- name: DeleteDocument :execrows
UPDATE documents UPDATE documents
SET SET

View File

@ -153,6 +153,18 @@ func (q *Queries) DeleteDocument(ctx context.Context, id string) (int64, error)
return result.RowsAffected() return result.RowsAffected()
} }
const deleteUser = `-- name: DeleteUser :execrows
DELETE FROM users WHERE id = ?1
`
func (q *Queries) DeleteUser(ctx context.Context, id string) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteUser, id)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getActivity = `-- name: GetActivity :many const getActivity = `-- name: GetActivity :many
WITH filtered_activity AS ( WITH filtered_activity AS (
SELECT SELECT

View File

@ -189,3 +189,11 @@ UPDATE documents
SET updated_at = STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now') SET updated_at = STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = old.id; WHERE id = old.id;
END; END;
-- Delete User
CREATE TRIGGER IF NOT EXISTS user_deleted
BEFORE DELETE ON users BEGIN
DELETE FROM activity WHERE activity.user_id=OLD.id;
DELETE FROM devices WHERE devices.user_id=OLD.id;
DELETE FROM document_progress WHERE document_progress.user_id=OLD.id;
END;

View File

@ -27,7 +27,7 @@
type="submit">Create</button> type="submit">Create</button>
</form> </form>
</div> </div>
<div class="min-w-full overflow-hidden rounded shadow"> <div class="min-w-full overflow-scroll rounded shadow">
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm"> <table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
<thead class="text-gray-800 dark:text-gray-400"> <thead class="text-gray-800 dark:text-gray-400">
<tr> <tr>
@ -50,12 +50,27 @@
{{ end }} {{ end }}
{{ range $user := .Data }} {{ range $user := .Data }}
<tr> <tr>
<td class="p-3 border-b border-gray-200 text-gray-800 dark:text-gray-400 cursor-pointer"> <!-- User Deletion -->
{{ template "svg/delete" }} <td class="p-3 border-b border-gray-200 text-gray-800 dark:text-gray-400 cursor-pointer relative">
<label for="delete-{{ $user.ID }}-button" class="cursor-pointer">{{ template "svg/delete" }}</label>
<input type="checkbox"
id="delete-{{ $user.ID }}-button"
class="hidden css-button" />
<div class="absolute z-30 top-1.5 left-10 p-1.5 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<form method="POST"
action="./users"
class="text-black dark:text-white text-sm w-40">
<input type="hidden" id="operation" name="operation" value="DELETE" />
<input type="hidden" id="user" name="user" value="{{ $user.ID }}" />
{{ template "component/button" (dict "Title" (printf "Delete (%s)" $user.ID )) }}
</form>
</div>
</td> </td>
<!-- User ID -->
<td class="p-3 border-b border-gray-200"> <td class="p-3 border-b border-gray-200">
<p>{{ $user.ID }}</p> <p>{{ $user.ID }}</p>
</td> </td>
<!-- User Password Change -->
<td class="border-b border-gray-200 relative px-3"> <td class="border-b border-gray-200 relative px-3">
<label for="edit-{{ $user.ID }}-button" class="cursor-pointer"> <label for="edit-{{ $user.ID }}-button" class="cursor-pointer">
<span class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" <span class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
@ -64,7 +79,7 @@
<input type="checkbox" <input type="checkbox"
id="edit-{{ $user.ID }}-button" id="edit-{{ $user.ID }}-button"
class="hidden css-button" /> class="hidden css-button" />
<div class="absolute z-30 -bottom-1.5 left-16 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"> <div class="absolute z-30 top-1 left-16 ml-2 p-1.5 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<form method="POST" <form method="POST"
action="./users" action="./users"
class="flex flex gap-2 text-black dark:text-white text-sm"> class="flex flex gap-2 text-black dark:text-white text-sm">
@ -73,13 +88,14 @@
<input type="password" <input type="password"
id="password" id="password"
name="password" name="password"
placeholder="Password" placeholder="{{ printf "Password (%s)" $user.ID }}"
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white" /> class="p-1.5 bg-gray-300 text-black dark:bg-gray-700 dark:text-white" />
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" <button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Change</button> type="submit">Change</button>
</form> </form>
</div> </div>
</td> </td>
<!-- User Role -->
<td class="flex gap-2 justify-center p-3 border-b border-gray-200 text-center min-w-40"> <td class="flex gap-2 justify-center p-3 border-b border-gray-200 text-center min-w-40">
<!-- Set Admin & User Styles --> <!-- Set Admin & User Styles -->
{{ $adminStyle := "bg-gray-400 dark:bg-gray-600 cursor-pointer" }} {{ $adminStyle := "bg-gray-400 dark:bg-gray-600 cursor-pointer" }}