From 2dba1d6eb93270d5a5c540d2a6563af843c5dd18 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 23 Jan 2026 09:30:15 -0500 Subject: [PATCH] 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 --- bruno/user/Delete Account.bru | 39 ++++++ bruno/user/Update Email.bru | 46 +++++++ bruno/user/Update Password.bru | 50 +++++++ bruno/user/Update Username.bru | 46 +++++++ internal/database/connection.go | 5 +- internal/database/querier.go | 5 + internal/database/queries.sql.go | 62 +++++++++ internal/database/queries/queries.sql | 15 +++ internal/handlers/auth.go | 180 ++++++++++++++++++++++++++ templates/dashboard.html | 15 +-- templates/preferences.html | 180 ++++++++++++++++++++++++++ 11 files changed, 628 insertions(+), 15 deletions(-) create mode 100644 bruno/user/Delete Account.bru create mode 100644 bruno/user/Update Email.bru create mode 100644 bruno/user/Update Password.bru create mode 100644 bruno/user/Update Username.bru create mode 100644 templates/preferences.html diff --git a/bruno/user/Delete Account.bru b/bruno/user/Delete Account.bru new file mode 100644 index 0000000..b0e1ac5 --- /dev/null +++ b/bruno/user/Delete Account.bru @@ -0,0 +1,39 @@ +meta { + name: Delete Account + type: http + seq: 4 +} + +delete { + url: {{base_url}}/api/user/account + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + ## Delete Account + + Permanently deletes the authenticated user's account and all associated data. + + **Method:** DELETE + + **Endpoint:** /api/user/account + + **Authentication:** Required + + **Request Body:** None + + **Response:** + - `message` (string): Success message + + **Status Codes:** + - 200: Success + - 401: Unauthorized + + **Warning:** This action cannot be undone and will permanently delete all user data including ebooks, ratings, and progress. +} \ No newline at end of file diff --git a/bruno/user/Update Email.bru b/bruno/user/Update Email.bru new file mode 100644 index 0000000..bc30fdb --- /dev/null +++ b/bruno/user/Update Email.bru @@ -0,0 +1,46 @@ +meta { + name: Update Email + type: http + seq: 2 +} + +put { + url: {{base_url}}/api/user/email + body: json + auth: inherit +} + +body { + { + "email": "newemail@example.com" + } +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + ## Update Email + + Updates the authenticated user's email address. + + **Method:** PUT + + **Endpoint:** /api/user/email + + **Authentication:** Required + + **Request Body:** + - `email` (string, required): New email address (must be valid email format) + + **Response:** + - `message` (string): Success message + + **Status Codes:** + - 200: Success + - 400: Invalid email format + - 401: Unauthorized + - 409: Email already taken +} \ No newline at end of file diff --git a/bruno/user/Update Password.bru b/bruno/user/Update Password.bru new file mode 100644 index 0000000..5738213 --- /dev/null +++ b/bruno/user/Update Password.bru @@ -0,0 +1,50 @@ +meta { + name: Update Password + type: http + seq: 3 +} + +put { + url: {{base_url}}/api/user/password + body: json + auth: inherit +} + +body { + { + "current_password": "currentpassword", + "new_password": "newpassword123", + "confirm_password": "newpassword123" + } +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + ## Update Password + + Updates the authenticated user's password. + + **Method:** PUT + + **Endpoint:** /api/user/password + + **Authentication:** Required + + **Request Body:** + - `current_password` (string, required): Current password for verification + - `new_password` (string, required): New password (minimum 6 characters) + - `confirm_password` (string, required): Confirmation of new password + + **Response:** + - `message` (string): Success message + + **Status Codes:** + - 200: Success + - 400: Password validation failed + - 401: Current password incorrect + - 401: Unauthorized +} \ No newline at end of file diff --git a/bruno/user/Update Username.bru b/bruno/user/Update Username.bru new file mode 100644 index 0000000..6f97bae --- /dev/null +++ b/bruno/user/Update Username.bru @@ -0,0 +1,46 @@ +meta { + name: Update Username + type: http + seq: 1 +} + +put { + url: {{base_url}}/api/user/username + body: json + auth: inherit +} + +body { + { + "username": "newusername" + } +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + ## Update Username + + Updates the authenticated user's username. + + **Method:** PUT + + **Endpoint:** /api/user/username + + **Authentication:** Required + + **Request Body:** + - `username` (string, required): New username (3-50 characters) + + **Response:** + - `message` (string): Success message + + **Status Codes:** + - 200: Success + - 400: Invalid username + - 401: Unauthorized + - 409: Username already taken +} \ No newline at end of file diff --git a/internal/database/connection.go b/internal/database/connection.go index 763b441..113a1c8 100644 --- a/internal/database/connection.go +++ b/internal/database/connection.go @@ -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 } diff --git a/internal/database/querier.go b/internal/database/querier.go index 7a9556a..400cd1e 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -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) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 3d1fb65..b231098 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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 +} diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 8ad3f0b..1112ed4 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index cb28537..635c02c 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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, diff --git a/templates/dashboard.html b/templates/dashboard.html index bdacf87..0169f98 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -11,19 +11,8 @@

📚 Bookmann

- + Dashboard + Preferences Welcome, {{.User.Username}}! +
+ + + + +
+ +
+
+ +
+

Account Preferences

+

Manage your account settings and preferences

+
+
+
+ + +
+ +
+

Account Information

+ + +
+

Username

+
+ + +
+
+
+ + +
+

Email Address

+
+ + +
+
+
+ + +
+

Change Password

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ + +
+

Appearance

+ +
+

Theme

+
+ + +
+
+
+
+ + +
+

Danger Zone

+
+

Delete Account

+

+ Once you delete your account, there is no going back. Please be certain. +

+ +
+
+
+
+ + +{{end}} \ No newline at end of file