feat: add user preferences dashboard
- Add /preferences route and preferences.html template for user settings - Implement username, email, password, and theme update functionality - Add account deletion feature with confirmation - Add navigation link to preferences from dashboard - Create API endpoints: - PUT /api/user/username - Update username - PUT /api/user/email - Update email address - PUT /api/user/password - Change password with verification - DELETE /api/user/account - Delete user account - Add database queries for user updates and account deletion - Create Bruno API testing files for all user preference endpoints - Add proper validation, error handling, and security checks
This commit is contained in:
@@ -7,12 +7,13 @@ import (
|
||||
)
|
||||
|
||||
func NewConnection(databaseURL string) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(context.Background(), databaseURL)
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
pool, err := pgxpool.NewWithConfig(context.Background(), config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ type Querier interface {
|
||||
DeleteEbook(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error
|
||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) error
|
||||
GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
|
||||
GetEbookByFilePath(ctx context.Context, filePath string) (Ebooks, error)
|
||||
@@ -29,12 +30,16 @@ type Querier interface {
|
||||
GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (Users, error)
|
||||
GetUserEbookFolders(ctx context.Context, userID pgtype.UUID) ([]UserEbookFolders, error)
|
||||
GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error)
|
||||
ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error)
|
||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||
UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error)
|
||||
UpdateEbookRating(ctx context.Context, arg UpdateEbookRatingParams) (EbookRatings, error)
|
||||
UpdateEmail(ctx context.Context, arg UpdateEmailParams) error
|
||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
|
||||
@@ -198,6 +198,15 @@ func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingPr
|
||||
return err
|
||||
}
|
||||
|
||||
const DeleteUser = `-- name: DeleteUser :exec
|
||||
DELETE FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, DeleteUser, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :exec
|
||||
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2
|
||||
`
|
||||
@@ -477,6 +486,17 @@ func (q *Queries) GetUserEbookFolders(ctx context.Context, userID pgtype.UUID) (
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const GetUserPasswordHash = `-- name: GetUserPasswordHash :one
|
||||
SELECT password_hash FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error) {
|
||||
row := q.db.QueryRow(ctx, GetUserPasswordHash, id)
|
||||
var password_hash string
|
||||
err := row.Scan(&password_hash)
|
||||
return password_hash, err
|
||||
}
|
||||
|
||||
const ListEbooks = `-- name: ListEbooks :many
|
||||
SELECT id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at, series, series_number, tags, asin, date_published, publisher, contributors FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2
|
||||
`
|
||||
@@ -668,6 +688,34 @@ func (q *Queries) UpdateEbookRating(ctx context.Context, arg UpdateEbookRatingPa
|
||||
return i, err
|
||||
}
|
||||
|
||||
const UpdateEmail = `-- name: UpdateEmail :exec
|
||||
UPDATE users SET email = $2, updated_at = NOW() WHERE id = $1
|
||||
`
|
||||
|
||||
type UpdateEmailParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateEmail(ctx context.Context, arg UpdateEmailParams) error {
|
||||
_, err := q.db.Exec(ctx, UpdateEmail, arg.ID, arg.Email)
|
||||
return err
|
||||
}
|
||||
|
||||
const UpdatePassword = `-- name: UpdatePassword :exec
|
||||
UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1
|
||||
`
|
||||
|
||||
type UpdatePasswordParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error {
|
||||
_, err := q.db.Exec(ctx, UpdatePassword, arg.ID, arg.PasswordHash)
|
||||
return err
|
||||
}
|
||||
|
||||
const UpdateReadingProgress = `-- name: UpdateReadingProgress :one
|
||||
INSERT INTO reading_progress (ebook_id, user_id, current_page, total_pages, last_read_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
@@ -718,3 +766,17 @@ func (q *Queries) UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams
|
||||
_, err := q.db.Exec(ctx, UpdateUserTheme, arg.ID, arg.Theme)
|
||||
return err
|
||||
}
|
||||
|
||||
const UpdateUsername = `-- name: UpdateUsername :exec
|
||||
UPDATE users SET username = $2, updated_at = NOW() WHERE id = $1
|
||||
`
|
||||
|
||||
type UpdateUsernameParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Username string `db:"username" json:"username"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error {
|
||||
_, err := q.db.Exec(ctx, UpdateUsername, arg.ID, arg.Username)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ SELECT * FROM users WHERE email = $1 OR username = $1;
|
||||
-- name: GetUser :one
|
||||
SELECT id, email, username, theme, created_at, updated_at FROM users WHERE id = $1;
|
||||
|
||||
-- name: GetUserPasswordHash :one
|
||||
SELECT password_hash FROM users WHERE id = $1;
|
||||
|
||||
-- name: ListUsers :many
|
||||
SELECT id, email, username, theme, created_at, updated_at FROM users ORDER BY created_at DESC;
|
||||
|
||||
@@ -69,6 +72,18 @@ DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2;
|
||||
-- name: UpdateUserTheme :exec
|
||||
UPDATE users SET theme = $2, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- name: UpdateUsername :exec
|
||||
UPDATE users SET username = $2, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- name: UpdateEmail :exec
|
||||
UPDATE users SET email = $2, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- name: UpdatePassword :exec
|
||||
UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- name: DeleteUser :exec
|
||||
DELETE FROM users WHERE id = $1;
|
||||
|
||||
-- name: CreateEbookRating :one
|
||||
INSERT INTO ebook_ratings (ebook_id, user_id, rating)
|
||||
VALUES ($1, $2, $3)
|
||||
|
||||
@@ -408,6 +408,186 @@ func (h *AuthHandler) DeleteEbookFolder(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "ebook folder removed"})
|
||||
}
|
||||
|
||||
type UpdateThemeRequest struct {
|
||||
Theme string `json:"theme" validate:"required"`
|
||||
}
|
||||
|
||||
// UpdateTheme handles PUT /api/auth/theme
|
||||
func (h *AuthHandler) UpdateTheme(c echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
var req UpdateThemeRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
err = h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
|
||||
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Theme: pgtype.Text{String: req.Theme, Valid: req.Theme != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "theme updated successfully"})
|
||||
}
|
||||
|
||||
type UpdateUsernameRequest struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50"`
|
||||
}
|
||||
|
||||
// UpdateUsername handles PUT /api/user/username
|
||||
func (h *AuthHandler) UpdateUsername(c echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
var req UpdateUsernameRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Check if username is already taken by another user
|
||||
existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
|
||||
if err == nil && uuid.UUID(existingUser.ID.Bytes) != userUUID {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
|
||||
}
|
||||
|
||||
// Update username
|
||||
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
|
||||
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Username: req.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "username updated successfully"})
|
||||
}
|
||||
|
||||
type UpdateEmailRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
}
|
||||
|
||||
// UpdateEmail handles PUT /api/user/email
|
||||
func (h *AuthHandler) UpdateEmail(c echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
var req UpdateEmailRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Check if email is already taken by another user
|
||||
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
|
||||
if err == nil && uuid.UUID(existingUser.ID.Bytes) != userUUID {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
|
||||
}
|
||||
|
||||
// Update email
|
||||
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
|
||||
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Email: req.Email,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "email updated successfully"})
|
||||
}
|
||||
|
||||
type UpdatePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password" validate:"required"`
|
||||
NewPassword string `json:"new_password" validate:"required,min=6"`
|
||||
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
||||
}
|
||||
|
||||
// UpdatePassword handles PUT /api/user/password
|
||||
func (h *AuthHandler) UpdatePassword(c echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
var req UpdatePasswordRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Check if new passwords match
|
||||
if req.NewPassword != req.ConfirmPassword {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "new passwords do not match"})
|
||||
}
|
||||
|
||||
// Get current user's password hash
|
||||
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get user"})
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword)); err != nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"})
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
||||
}
|
||||
|
||||
// Update password
|
||||
err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{
|
||||
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
PasswordHash: string(hashedPassword),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "password updated successfully"})
|
||||
}
|
||||
|
||||
// DeleteAccount handles DELETE /api/user/account
|
||||
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
// Delete user (this will cascade to delete all related data)
|
||||
err = h.db.DeleteUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "account deleted successfully"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) generateJWT(userID string) (string, error) {
|
||||
claims := jwtgo.MapClaims{
|
||||
"user_id": userID,
|
||||
|
||||
Reference in New Issue
Block a user