package api import ( "crypto/rand" "errors" "net/http" "net/url" "strings" "time" "gardomatic.kleiax.de/internal/auth" "gardomatic.kleiax.de/internal/platform/validate" "gardomatic.kleiax.de/internal/storage" ) const adminInvitationTTL = 3 * 24 * time.Hour func (app *application) listAdminUsersHandler(w http.ResponseWriter, r *http.Request) { users, err := app.models.Users.GetAll() if err != nil { app.serverErrorResponse(w, r, err) return } if err := app.writeJSON(w, http.StatusOK, envelope{"users": users}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) inviteAdminUserHandler(w http.ResponseWriter, r *http.Request) { var input struct { Name string `json:"name"` Email string `json:"email"` } if err := app.readJSON(w, r, &input); err != nil { app.badRequestResponse(w, r, err) return } input.Name = strings.TrimSpace(input.Name) input.Email = strings.ToLower(strings.TrimSpace(input.Email)) candidate := storage.User{Name: input.Name, Email: input.Email, Activated: false} if passwordErr := candidate.Password.Set(rand.Text()); passwordErr != nil { app.serverErrorResponse(w, r, passwordErr) return } v := validate.New() if storage.ValidateUser(v, candidate); !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } user, err := app.models.Users.GetByEmail(input.Email) switch { case err == nil && user.Activated: app.failedValidationResponse(w, r, map[string]string{"email": "a user with this email address already exists"}) return case err == nil: // Re-sending for an inactive account lets an administrator recover from // an earlier delivery failure without creating a duplicate account. case errors.Is(err, storage.ErrRecordNotFound): user, err = app.models.Users.Insert(candidate) if err != nil { if errors.Is(err, storage.ErrDuplicateEmail) { app.failedValidationResponse(w, r, map[string]string{"email": "a user with this email address already exists"}) } else { app.serverErrorResponse(w, r, err) } return } default: app.serverErrorResponse(w, r, err) return } if err = app.models.Tokens.DeleteAllForUser(auth.ScopeActivation, user.ID); err != nil { app.serverErrorResponse(w, r, err) return } token, err := app.models.Tokens.New(user.ID, adminInvitationTTL, auth.ScopeActivation) if err != nil { app.serverErrorResponse(w, r, err) return } activationURL := strings.TrimRight(app.config.WebBaseURL, "/") + "/activate?token=" + url.QueryEscape(token.Plaintext) + "&set-password=1" if err = app.mailer.Send(user.Email, "user_invitation.tmpl", map[string]any{"name": user.Name, "activationURL": activationURL}); err != nil { app.serverErrorResponse(w, r, err) return } if err = app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) updateAdminUserRoleHandler(w http.ResponseWriter, r *http.Request) { userID, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } actor, _ := app.contextGetAuthenticatedUser(r) if actor.ID == userID { app.permissionDeniedResponse(w, r) return } var input struct { Role storage.ApplicationRole `json:"role"` } if err := app.readJSON(w, r, &input); err != nil { app.badRequestResponse(w, r, err) return } role, err := app.models.Roles.Get(string(input.Role)) if err != nil || role.Scope != storage.RoleScopeApplication { app.failedValidationResponse(w, r, map[string]string{"role": "must be an application role"}) return } user, err := app.models.Users.UpdateRole(userID, input.Role) if err != nil { if errors.Is(err, storage.ErrRecordNotFound) { app.notFoundResponse(w, r) } else { app.serverErrorResponse(w, r, err) } return } if err := app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) deleteAdminUserHandler(w http.ResponseWriter, r *http.Request) { userID, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } actor, _ := app.contextGetAuthenticatedUser(r) if actor.ID == userID { app.permissionDeniedResponse(w, r) return } if err = app.models.Users.Delete(userID); err != nil { switch { case errors.Is(err, storage.ErrRecordNotFound): app.notFoundResponse(w, r) case errors.Is(err, storage.ErrConflict): app.conflictResponse(w, r) default: app.serverErrorResponse(w, r, err) } return } w.WriteHeader(http.StatusNoContent) }