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:
@@ -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.
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -7,12 +7,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewConnection(databaseURL string) (*pgxpool.Pool, error) {
|
func NewConnection(databaseURL string) (*pgxpool.Pool, error) {
|
||||||
pool, err := pgxpool.New(context.Background(), databaseURL)
|
config, err := pgxpool.ParseConfig(databaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := pool.Ping(context.Background()); err != nil {
|
pool, err := pgxpool.NewWithConfig(context.Background(), config)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Querier interface {
|
|||||||
DeleteEbook(ctx context.Context, id pgtype.UUID) error
|
DeleteEbook(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error
|
DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error
|
||||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
||||||
|
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) error
|
DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) error
|
||||||
GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
|
GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
|
||||||
GetEbookByFilePath(ctx context.Context, filePath string) (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)
|
GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error)
|
||||||
GetUserByUsername(ctx context.Context, username string) (Users, error)
|
GetUserByUsername(ctx context.Context, username string) (Users, error)
|
||||||
GetUserEbookFolders(ctx context.Context, userID pgtype.UUID) ([]UserEbookFolders, 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)
|
ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error)
|
||||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||||
UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error)
|
UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error)
|
||||||
UpdateEbookRating(ctx context.Context, arg UpdateEbookRatingParams) (EbookRatings, 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)
|
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||||
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
||||||
|
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Querier = (*Queries)(nil)
|
var _ Querier = (*Queries)(nil)
|
||||||
|
|||||||
@@ -198,6 +198,15 @@ func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingPr
|
|||||||
return err
|
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
|
const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :exec
|
||||||
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2
|
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
|
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
|
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
|
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
|
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
|
const UpdateReadingProgress = `-- name: UpdateReadingProgress :one
|
||||||
INSERT INTO reading_progress (ebook_id, user_id, current_page, total_pages, last_read_at)
|
INSERT INTO reading_progress (ebook_id, user_id, current_page, total_pages, last_read_at)
|
||||||
VALUES ($1, $2, $3, $4, NOW())
|
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)
|
_, err := q.db.Exec(ctx, UpdateUserTheme, arg.ID, arg.Theme)
|
||||||
return err
|
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
|
-- name: GetUser :one
|
||||||
SELECT id, email, username, theme, created_at, updated_at FROM users WHERE id = $1;
|
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
|
-- name: ListUsers :many
|
||||||
SELECT id, email, username, theme, created_at, updated_at FROM users ORDER BY created_at DESC;
|
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
|
-- name: UpdateUserTheme :exec
|
||||||
UPDATE users SET theme = $2, updated_at = NOW() WHERE id = $1;
|
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
|
-- name: CreateEbookRating :one
|
||||||
INSERT INTO ebook_ratings (ebook_id, user_id, rating)
|
INSERT INTO ebook_ratings (ebook_id, user_id, rating)
|
||||||
VALUES ($1, $2, $3)
|
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"})
|
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) {
|
func (h *AuthHandler) generateJWT(userID string) (string, error) {
|
||||||
claims := jwtgo.MapClaims{
|
claims := jwtgo.MapClaims{
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
|
|||||||
@@ -11,19 +11,8 @@
|
|||||||
<h1 class="text-xl font-bold" style="color: var(--text-primary)">📚 Bookmann</h1>
|
<h1 class="text-xl font-bold" style="color: var(--text-primary)">📚 Bookmann</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<select id="theme-select" class="px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" onchange="changeTheme()">
|
<a href="/" class="px-3 py-2 text-sm hover:opacity-80" style="color: var(--text-secondary)">Dashboard</a>
|
||||||
<option value="tokyo-night">Tokyo Night</option>
|
<a href="/preferences" class="px-3 py-2 text-sm hover:opacity-80" style="color: var(--text-secondary)">Preferences</a>
|
||||||
<option value="dracula">Dracula</option>
|
|
||||||
<option value="nord">Nord</option>
|
|
||||||
<option value="solarized-dark">Solarized Dark</option>
|
|
||||||
<option value="monokai">Monokai</option>
|
|
||||||
<option value="one-dark-pro">One Dark Pro</option>
|
|
||||||
<option value="material-dark">Material Dark</option>
|
|
||||||
<option value="catppuccin-mocha">Catppuccin Mocha</option>
|
|
||||||
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
|
|
||||||
<option value="catppuccin-frappe">Catppuccin Frappé</option>
|
|
||||||
<option value="catppuccin-latte">Catppuccin Latte</option>
|
|
||||||
</select>
|
|
||||||
<span style="color: var(--text-secondary)">Welcome, {{.User.Username}}!</span>
|
<span style="color: var(--text-secondary)">Welcome, {{.User.Username}}!</span>
|
||||||
<button onclick="logout()" class="btn-primary px-4 py-2 rounded text-sm">
|
<button onclick="logout()" class="btn-primary px-4 py-2 rounded text-sm">
|
||||||
Logout
|
Logout
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
{{template "base.html" .}}
|
||||||
|
|
||||||
|
{{define "title"}}Preferences - Bookmann{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<!-- Navigation Header -->
|
||||||
|
<nav class="border-b" style="border-color: var(--border); background-color: var(--bg-secondary)">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="text-xl font-bold" style="color: var(--text-primary)">📚 Bookmann</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="/" class="px-3 py-2 text-sm hover:opacity-80" style="color: var(--text-secondary)">Dashboard</a>
|
||||||
|
<span style="color: var(--text-secondary)">Welcome, {{.User.Username}}!</span>
|
||||||
|
<button onclick="logout()" class="btn-primary px-4 py-2 rounded text-sm">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<button onclick="window.history.back()" class="btn-secondary px-4 py-2 rounded-lg font-medium">
|
||||||
|
← Back to Dashboard
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold" style="color: var(--text-primary)">Account Preferences</h2>
|
||||||
|
<p style="color: var(--text-secondary)">Manage your account settings and preferences</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preferences Sections -->
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Account Information -->
|
||||||
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
|
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Account Information</h3>
|
||||||
|
|
||||||
|
<!-- Update Username -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-medium mb-3" style="color: var(--text-primary)">Username</h4>
|
||||||
|
<form hx-put="/api/user/username" hx-target="#username-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="flex space-x-3">
|
||||||
|
<input type="text" name="username" value="{{.User.Username}}" class="flex-1 px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||||
|
<button type="submit" class="btn-primary px-6 py-2 rounded">Update Username</button>
|
||||||
|
</form>
|
||||||
|
<div id="username-result" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Update Email -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-medium mb-3" style="color: var(--text-primary)">Email Address</h4>
|
||||||
|
<form hx-put="/api/user/email" hx-target="#email-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="flex space-x-3">
|
||||||
|
<input type="email" name="email" value="{{.User.Email}}" class="flex-1 px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||||
|
<button type="submit" class="btn-primary px-6 py-2 rounded">Update Email</button>
|
||||||
|
</form>
|
||||||
|
<div id="email-result" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Update Password -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-medium mb-3" style="color: var(--text-primary)">Change Password</h4>
|
||||||
|
<form hx-put="/api/user/password" hx-target="#password-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Current Password</label>
|
||||||
|
<input type="password" name="current_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">New Password</label>
|
||||||
|
<input type="password" name="new_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required minlength="6">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm New Password</label>
|
||||||
|
<input type="password" name="confirm_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required minlength="6">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary px-6 py-2 rounded">Update Password</button>
|
||||||
|
</form>
|
||||||
|
<div id="password-result" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Appearance Settings -->
|
||||||
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
|
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Appearance</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-medium mb-3" style="color: var(--text-primary)">Theme</h4>
|
||||||
|
<form hx-put="/api/auth/theme" hx-target="#theme-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="flex space-x-3">
|
||||||
|
<select name="theme" id="theme-select" class="flex-1 px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||||
|
<option value="tokyo-night">Tokyo Night</option>
|
||||||
|
<option value="dracula">Dracula</option>
|
||||||
|
<option value="nord">Nord</option>
|
||||||
|
<option value="solarized-dark">Solarized Dark</option>
|
||||||
|
<option value="monokai">Monokai</option>
|
||||||
|
<option value="one-dark-pro">One Dark Pro</option>
|
||||||
|
<option value="material-dark">Material Dark</option>
|
||||||
|
<option value="catppuccin-mocha">Catppuccin Mocha</option>
|
||||||
|
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
|
||||||
|
<option value="catppuccin-frappe">Catppuccin Frappé</option>
|
||||||
|
<option value="catppuccin-latte">Catppuccin Latte</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn-primary px-6 py-2 rounded">Update Theme</button>
|
||||||
|
</form>
|
||||||
|
<div id="theme-result" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Danger Zone -->
|
||||||
|
<div class="card p-6 rounded-lg border border-red-500" style="background-color: var(--bg-secondary);">
|
||||||
|
<h3 class="text-xl font-semibold mb-6 text-red-500">Danger Zone</h3>
|
||||||
|
<div class="border-t border-red-500 pt-6">
|
||||||
|
<h4 class="text-lg font-medium mb-3" style="color: var(--text-primary)">Delete Account</h4>
|
||||||
|
<p style="color: var(--text-secondary)" class="mb-4">
|
||||||
|
Once you delete your account, there is no going back. Please be certain.
|
||||||
|
</p>
|
||||||
|
<button onclick="deleteAccount()" class="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded font-medium">
|
||||||
|
Delete Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAccount() {
|
||||||
|
if (confirm('Are you absolutely sure you want to delete your account? This action cannot be undone and will permanently delete all your ebooks and data.')) {
|
||||||
|
fetch('/api/user/account', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + localStorage.getItem('token)',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
alert('Error deleting account. Please try again.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error deleting account:', error);
|
||||||
|
alert('Error deleting account. Please try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUserTheme() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
fetch('/api/auth/profile', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
}).then(res => res.json()).then(data => {
|
||||||
|
if (data.theme) {
|
||||||
|
document.getElementById('theme-select').value = data.theme;
|
||||||
|
applyTheme(data.theme);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load theme on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadTheme();
|
||||||
|
loadUserTheme();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user