Merge pull request #36 from rede5/codex/fix-data-editing-and-image-upload
Profile: persist phone/bio, support avatar public URLs, add password update and settings improvements
This commit is contained in:
commit
fa17b16b8b
18 changed files with 402 additions and 45 deletions
|
|
@ -25,6 +25,7 @@ type CoreHandlers struct {
|
|||
listUsersUC *user.ListUsersUseCase
|
||||
deleteUserUC *user.DeleteUserUseCase
|
||||
updateUserUC *user.UpdateUserUseCase
|
||||
updatePasswordUC *user.UpdatePasswordUseCase
|
||||
listCompaniesUC *tenant.ListCompaniesUseCase
|
||||
auditService *services.AuditService
|
||||
notificationService *services.NotificationService
|
||||
|
|
@ -33,7 +34,7 @@ type CoreHandlers struct {
|
|||
credentialsService *services.CredentialsService
|
||||
}
|
||||
|
||||
func NewCoreHandlers(l *auth.LoginUseCase, reg *auth.RegisterCandidateUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, upd *user.UpdateUserUseCase, lc *tenant.ListCompaniesUseCase, auditService *services.AuditService, notificationService *services.NotificationService, ticketService *services.TicketService, adminService *services.AdminService, credentialsService *services.CredentialsService) *CoreHandlers {
|
||||
func NewCoreHandlers(l *auth.LoginUseCase, reg *auth.RegisterCandidateUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, upd *user.UpdateUserUseCase, updatePasswordUC *user.UpdatePasswordUseCase, lc *tenant.ListCompaniesUseCase, auditService *services.AuditService, notificationService *services.NotificationService, ticketService *services.TicketService, adminService *services.AdminService, credentialsService *services.CredentialsService) *CoreHandlers {
|
||||
return &CoreHandlers{
|
||||
loginUC: l,
|
||||
registerCandidateUC: reg,
|
||||
|
|
@ -42,6 +43,7 @@ func NewCoreHandlers(l *auth.LoginUseCase, reg *auth.RegisterCandidateUseCase, c
|
|||
listUsersUC: list,
|
||||
deleteUserUC: del,
|
||||
updateUserUC: upd,
|
||||
updatePasswordUC: updatePasswordUC,
|
||||
listCompaniesUC: lc,
|
||||
auditService: auditService,
|
||||
notificationService: notificationService,
|
||||
|
|
@ -1024,6 +1026,47 @@ func (h *CoreHandlers) UpdateMyProfile(w http.ResponseWriter, r *http.Request) {
|
|||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// UpdateMyPassword updates the authenticated user's password.
|
||||
// @Summary Update My Password
|
||||
// @Description Updates the current user's password.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param password body object true "Password Details"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "Invalid Request"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 500 {string} string "Internal Server Error"
|
||||
// @Router /api/v1/users/me/password [patch]
|
||||
func (h *CoreHandlers) UpdateMyPassword(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
userIDVal := ctx.Value(middleware.ContextUserID)
|
||||
userID, ok := userIDVal.(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdatePasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.CurrentPassword == "" || req.NewPassword == "" {
|
||||
http.Error(w, "Current and new password are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.updatePasswordUC.Execute(ctx, userID, req.CurrentPassword, req.NewPassword); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// UploadMyAvatar handles profile picture upload.
|
||||
// @Summary Upload Avatar
|
||||
// @Description Uploads a profile picture for the current user.
|
||||
|
|
|
|||
|
|
@ -72,10 +72,17 @@ func (h *StorageHandler) GetUploadURL(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
publicURL, err := h.storageService.GetPublicURL(r.Context(), key)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to generate public URL: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return simple JSON
|
||||
resp := map[string]string{
|
||||
"url": url,
|
||||
"key": key, // Client needs key to save to DB profile
|
||||
"url": url,
|
||||
"key": key, // Client needs key to save to DB profile
|
||||
"publicUrl": publicURL, // Public URL for immediate use
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ type User struct {
|
|||
TenantID string `json:"tenant_id"` // Link to Company
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
PasswordHash string `json:"-"`
|
||||
AvatarUrl string `json:"avatar_url"`
|
||||
Roles []Role `json:"roles"`
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ type CreateUserRequest struct {
|
|||
type UpdateUserRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Roles *[]string `json:"roles,omitempty"`
|
||||
|
|
@ -38,9 +40,16 @@ type UserResponse struct {
|
|||
Roles []string `json:"roles"`
|
||||
Status string `json:"status"`
|
||||
AvatarUrl string `json:"avatar_url"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type UpdatePasswordRequest struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type RegisterCandidateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
|
|
|
|||
|
|
@ -80,6 +80,9 @@ func (uc *LoginUseCase) Execute(ctx context.Context, input dto.LoginRequest) (*d
|
|||
Email: user.Email,
|
||||
Roles: roles,
|
||||
Status: user.Status,
|
||||
AvatarUrl: user.AvatarUrl,
|
||||
Phone: user.Phone,
|
||||
Bio: user.Bio,
|
||||
CreatedAt: user.CreatedAt,
|
||||
},
|
||||
}, nil
|
||||
|
|
|
|||
55
backend/internal/core/usecases/user/update_password.go
Normal file
55
backend/internal/core/usecases/user/update_password.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
||||
)
|
||||
|
||||
type UpdatePasswordUseCase struct {
|
||||
userRepo ports.UserRepository
|
||||
authService ports.AuthService
|
||||
}
|
||||
|
||||
func NewUpdatePasswordUseCase(userRepo ports.UserRepository, authService ports.AuthService) *UpdatePasswordUseCase {
|
||||
return &UpdatePasswordUseCase{
|
||||
userRepo: userRepo,
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *UpdatePasswordUseCase) Execute(ctx context.Context, userID, currentPassword, newPassword string) error {
|
||||
if newPassword == "" {
|
||||
return errors.New("new password is required")
|
||||
}
|
||||
if len(newPassword) < 8 {
|
||||
return errors.New("new password must be at least 8 characters")
|
||||
}
|
||||
|
||||
user, err := uc.userRepo.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return errors.New("user not found")
|
||||
}
|
||||
|
||||
if !uc.authService.VerifyPassword(user.PasswordHash, currentPassword) {
|
||||
return errors.New("current password is invalid")
|
||||
}
|
||||
|
||||
hashed, err := uc.authService.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user.PasswordHash = hashed
|
||||
if user.Status == entity.UserStatusForceChangePassword {
|
||||
user.Status = entity.UserStatusActive
|
||||
}
|
||||
|
||||
_, err = uc.userRepo.Update(ctx, user)
|
||||
return err
|
||||
}
|
||||
|
|
@ -42,6 +42,12 @@ func (uc *UpdateUserUseCase) Execute(ctx context.Context, id, tenantID string, i
|
|||
if input.Email != nil {
|
||||
user.Email = *input.Email
|
||||
}
|
||||
if input.Phone != nil {
|
||||
user.Phone = input.Phone
|
||||
}
|
||||
if input.Bio != nil {
|
||||
user.Bio = input.Bio
|
||||
}
|
||||
if input.Status != nil {
|
||||
user.Status = *input.Status
|
||||
} else if input.Active != nil {
|
||||
|
|
@ -81,6 +87,8 @@ func (uc *UpdateUserUseCase) Execute(ctx context.Context, id, tenantID string, i
|
|||
Roles: roles,
|
||||
Status: updated.Status,
|
||||
AvatarUrl: updated.AvatarUrl,
|
||||
Phone: updated.Phone,
|
||||
Bio: updated.Bio,
|
||||
CreatedAt: updated.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ type User struct {
|
|||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
AvatarUrl *string `json:"avatarUrl,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CompanyID *string `json:"companyId,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,14 +81,29 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U
|
|||
}
|
||||
|
||||
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity.User, error) {
|
||||
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
|
||||
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, '')
|
||||
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
|
||||
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''),
|
||||
phone, bio
|
||||
FROM users WHERE email = $1 OR identifier = $1`
|
||||
row := r.db.QueryRowContext(ctx, query, email)
|
||||
|
||||
u := &entity.User{}
|
||||
var dbID string
|
||||
err := row.Scan(&dbID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt, &u.AvatarUrl)
|
||||
var phone sql.NullString
|
||||
var bio sql.NullString
|
||||
err := row.Scan(
|
||||
&dbID,
|
||||
&u.TenantID,
|
||||
&u.Name,
|
||||
&u.Email,
|
||||
&u.PasswordHash,
|
||||
&u.Status,
|
||||
&u.CreatedAt,
|
||||
&u.UpdatedAt,
|
||||
&u.AvatarUrl,
|
||||
&phone,
|
||||
&bio,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // Return nil if not found
|
||||
|
|
@ -96,23 +111,42 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity
|
|||
return nil, err
|
||||
}
|
||||
u.ID = dbID
|
||||
u.Phone = nullStringPtr(phone)
|
||||
u.Bio = nullStringPtr(bio)
|
||||
u.Roles, _ = r.getRoles(ctx, dbID)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
|
||||
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
|
||||
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, '')
|
||||
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
|
||||
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''),
|
||||
phone, bio
|
||||
FROM users WHERE id = $1`
|
||||
row := r.db.QueryRowContext(ctx, query, id)
|
||||
|
||||
u := &entity.User{}
|
||||
var dbID string
|
||||
err := row.Scan(&dbID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt, &u.AvatarUrl)
|
||||
var phone sql.NullString
|
||||
var bio sql.NullString
|
||||
err := row.Scan(
|
||||
&dbID,
|
||||
&u.TenantID,
|
||||
&u.Name,
|
||||
&u.Email,
|
||||
&u.PasswordHash,
|
||||
&u.Status,
|
||||
&u.CreatedAt,
|
||||
&u.UpdatedAt,
|
||||
&u.AvatarUrl,
|
||||
&phone,
|
||||
&bio,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.ID = dbID
|
||||
u.Phone = nullStringPtr(phone)
|
||||
u.Bio = nullStringPtr(bio)
|
||||
u.Roles, _ = r.getRoles(ctx, dbID)
|
||||
return u, nil
|
||||
}
|
||||
|
|
@ -124,8 +158,9 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, l
|
|||
return nil, 0, err
|
||||
}
|
||||
|
||||
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
|
||||
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, '')
|
||||
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
|
||||
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''),
|
||||
phone, bio
|
||||
FROM users
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC
|
||||
|
|
@ -140,10 +175,26 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, l
|
|||
for rows.Next() {
|
||||
u := &entity.User{}
|
||||
var dbID string
|
||||
if err := rows.Scan(&dbID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt, &u.AvatarUrl); err != nil {
|
||||
var phone sql.NullString
|
||||
var bio sql.NullString
|
||||
if err := rows.Scan(
|
||||
&dbID,
|
||||
&u.TenantID,
|
||||
&u.Name,
|
||||
&u.Email,
|
||||
&u.PasswordHash,
|
||||
&u.Status,
|
||||
&u.CreatedAt,
|
||||
&u.UpdatedAt,
|
||||
&u.AvatarUrl,
|
||||
&phone,
|
||||
&bio,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
u.ID = dbID
|
||||
u.Phone = nullStringPtr(phone)
|
||||
u.Bio = nullStringPtr(bio)
|
||||
u.Roles, _ = r.getRoles(ctx, dbID)
|
||||
users = append(users, u)
|
||||
}
|
||||
|
|
@ -166,8 +217,22 @@ func (r *UserRepository) Update(ctx context.Context, user *entity.User) (*entity
|
|||
primaryRole = user.Roles[0].Name
|
||||
}
|
||||
|
||||
query := `UPDATE users SET name=$1, email=$2, status=$3, role=$4, updated_at=$5, avatar_url=$6 WHERE id=$7`
|
||||
_, err = tx.ExecContext(ctx, query, user.Name, user.Email, user.Status, primaryRole, user.UpdatedAt, user.AvatarUrl, user.ID)
|
||||
query := `UPDATE users SET name=$1, full_name=$2, email=$3, status=$4, role=$5, updated_at=$6, avatar_url=$7, phone=$8, bio=$9, password_hash=$10 WHERE id=$11`
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
query,
|
||||
user.Name,
|
||||
user.Name,
|
||||
user.Email,
|
||||
user.Status,
|
||||
primaryRole,
|
||||
user.UpdatedAt,
|
||||
user.AvatarUrl,
|
||||
user.Phone,
|
||||
user.Bio,
|
||||
user.PasswordHash,
|
||||
user.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -225,3 +290,10 @@ func (r *UserRepository) getRoles(ctx context.Context, userID string) ([]entity.
|
|||
}
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func nullStringPtr(value sql.NullString) *string {
|
||||
if value.Valid {
|
||||
return &value.String
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ func NewRouter() http.Handler {
|
|||
listUsersUC := userUC.NewListUsersUseCase(userRepo)
|
||||
deleteUserUC := userUC.NewDeleteUserUseCase(userRepo)
|
||||
updateUserUC := userUC.NewUpdateUserUseCase(userRepo)
|
||||
updatePasswordUC := userUC.NewUpdatePasswordUseCase(userRepo, authService)
|
||||
auditService := services.NewAuditService(database.DB)
|
||||
notificationService := services.NewNotificationService(database.DB, fcmService)
|
||||
ticketService := services.NewTicketService(database.DB)
|
||||
|
|
@ -87,6 +88,7 @@ func NewRouter() http.Handler {
|
|||
listUsersUC,
|
||||
deleteUserUC,
|
||||
updateUserUC,
|
||||
updatePasswordUC,
|
||||
listCompaniesUC,
|
||||
auditService,
|
||||
notificationService, // Added
|
||||
|
|
@ -178,6 +180,7 @@ func NewRouter() http.Handler {
|
|||
// Public /api/v1/users/me (Authenticated)
|
||||
mux.Handle("GET /api/v1/users/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.Me)))
|
||||
mux.Handle("PATCH /api/v1/users/me/profile", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateMyProfile)))
|
||||
mux.Handle("PATCH /api/v1/users/me/password", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateMyPassword)))
|
||||
|
||||
// /api/v1/admin/companies -> Handled by coreHandlers.ListCompanies (Smart Branching)
|
||||
// Needs to be wired with Optional Auth to support both Public and Admin.
|
||||
|
|
|
|||
|
|
@ -579,15 +579,37 @@ func (s *AdminService) getTagByID(ctx context.Context, id int) (*models.Tag, err
|
|||
// GetUser fetches a user by ID
|
||||
func (s *AdminService) GetUser(ctx context.Context, id string) (*dto.User, error) {
|
||||
query := `
|
||||
SELECT id, full_name, email, role, created_at
|
||||
SELECT id, full_name, email, role, COALESCE(status, 'active'), created_at, phone, bio, avatar_url
|
||||
FROM users WHERE id = $1
|
||||
`
|
||||
var u dto.User
|
||||
var roleStr string
|
||||
if err := s.DB.QueryRowContext(ctx, query, id).Scan(&u.ID, &u.Name, &u.Email, &roleStr, &u.CreatedAt); err != nil {
|
||||
var phone sql.NullString
|
||||
var bio sql.NullString
|
||||
var avatarURL sql.NullString
|
||||
if err := s.DB.QueryRowContext(ctx, query, id).Scan(
|
||||
&u.ID,
|
||||
&u.Name,
|
||||
&u.Email,
|
||||
&roleStr,
|
||||
&u.Status,
|
||||
&u.CreatedAt,
|
||||
&phone,
|
||||
&bio,
|
||||
&avatarURL,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Role = roleStr
|
||||
if phone.Valid {
|
||||
u.Phone = &phone.String
|
||||
}
|
||||
if bio.Valid {
|
||||
u.Bio = &bio.String
|
||||
}
|
||||
if avatarURL.Valid {
|
||||
u.AvatarUrl = &avatarURL.String
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
|
|
@ -29,26 +30,34 @@ type UploadConfig struct {
|
|||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
func (s *StorageService) getClient(ctx context.Context) (*s3.PresignClient, string, error) {
|
||||
// 1. Fetch Credentials from DB (Encrypted Payload)
|
||||
func (s *StorageService) getConfig(ctx context.Context) (UploadConfig, error) {
|
||||
payload, err := s.credentialsService.GetDecryptedKey(ctx, "storage")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to get storage credentials: %w", err)
|
||||
return UploadConfig{}, fmt.Errorf("failed to get storage credentials: %w", err)
|
||||
}
|
||||
|
||||
var uCfg UploadConfig
|
||||
if err := json.Unmarshal([]byte(payload), &uCfg); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to parse storage credentials: %w", err)
|
||||
return UploadConfig{}, fmt.Errorf("failed to parse storage credentials: %w", err)
|
||||
}
|
||||
|
||||
if uCfg.Endpoint == "" || uCfg.AccessKey == "" || uCfg.SecretKey == "" || uCfg.Bucket == "" {
|
||||
return nil, "", fmt.Errorf("storage credentials incomplete (all fields required)")
|
||||
return UploadConfig{}, fmt.Errorf("storage credentials incomplete (all fields required)")
|
||||
}
|
||||
|
||||
if uCfg.Region == "" {
|
||||
uCfg.Region = "auto"
|
||||
}
|
||||
|
||||
return uCfg, nil
|
||||
}
|
||||
|
||||
func (s *StorageService) getClient(ctx context.Context) (*s3.PresignClient, string, error) {
|
||||
uCfg, err := s.getConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// 2. Setup S3 V2 Client
|
||||
cfg, err := config.LoadDefaultConfig(ctx,
|
||||
config.WithRegion(uCfg.Region),
|
||||
|
|
@ -104,14 +113,10 @@ func (s *StorageService) TestConnection(ctx context.Context) error {
|
|||
// For now, let's refactor `getClient` slightly to expose specific logic or just create a one-off checker here.
|
||||
|
||||
// Refetch raw creds to make a standard client
|
||||
payload, err := s.credentialsService.GetDecryptedKey(ctx, "storage")
|
||||
uCfg, err := s.getConfig(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get storage credentials: %w", err)
|
||||
}
|
||||
var uCfg UploadConfig
|
||||
if err := json.Unmarshal([]byte(payload), &uCfg); err != nil {
|
||||
return fmt.Errorf("failed to parse storage credentials: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := config.LoadDefaultConfig(ctx,
|
||||
config.WithRegion(uCfg.Region),
|
||||
|
|
@ -140,3 +145,13 @@ func (s *StorageService) TestConnection(ctx context.Context) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StorageService) GetPublicURL(ctx context.Context, key string) (string, error) {
|
||||
uCfg, err := s.getConfig(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
endpoint := strings.TrimRight(uCfg.Endpoint, "/")
|
||||
return fmt.Sprintf("%s/%s/%s", endpoint, uCfg.Bucket, key), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
|||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { RichTextEditor } from "@/components/rich-text-editor"
|
||||
import { Save, Loader2, Building2 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { profileApi, authApi, adminCompaniesApi, type AdminCompany } from "@/lib/api"
|
||||
|
|
@ -27,6 +28,12 @@ export default function ProfilePage() {
|
|||
bio: "",
|
||||
})
|
||||
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
})
|
||||
|
||||
const [companyData, setCompanyData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
|
|
@ -35,6 +42,17 @@ export default function ProfilePage() {
|
|||
description: "",
|
||||
})
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
const initials = name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
return initials.slice(0, 2) || "U"
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentUser = getCurrentUser()
|
||||
setIsAdmin(isAdminUser(currentUser))
|
||||
|
|
@ -89,6 +107,10 @@ export default function ProfilePage() {
|
|||
setCompanyData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handlePasswordChange = (field: string, value: string) => {
|
||||
setPasswordData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
|
|
@ -133,6 +155,32 @@ export default function ProfilePage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
console.log("[PROFILE_FLOW] Requesting password reset/update")
|
||||
|
||||
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
||||
toast.error("Passwords do not match")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await profileApi.updatePassword({
|
||||
currentPassword: passwordData.currentPassword,
|
||||
newPassword: passwordData.newPassword,
|
||||
})
|
||||
toast.success("Password updated")
|
||||
setPasswordData({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[PROFILE_FLOW] Error updating password:", error)
|
||||
toast.error("Failed to update password")
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
|
|
@ -153,16 +201,18 @@ export default function ProfilePage() {
|
|||
<CardContent className="space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<ProfilePictureUpload
|
||||
fallbackText={formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U"}
|
||||
fallbackText={formData.fullName ? getInitials(formData.fullName) : "U"}
|
||||
size="xl"
|
||||
useDatabase={false}
|
||||
onImageChange={async (file, url) => {
|
||||
if (file) {
|
||||
try {
|
||||
console.log("[PROFILE_FLOW] Uploading avatar:", { name: file.name, size: file.size })
|
||||
await profileApi.uploadAvatar(file)
|
||||
loadProfile()
|
||||
toast.success("Avatar updated")
|
||||
} catch (err) {
|
||||
console.error("[PROFILE_FLOW] Avatar upload failed:", err)
|
||||
toast.error("Failed to upload avatar")
|
||||
}
|
||||
}
|
||||
|
|
@ -203,11 +253,11 @@ export default function ProfilePage() {
|
|||
|
||||
<div>
|
||||
<Label htmlFor="bio">Bio</Label>
|
||||
<Textarea
|
||||
id="bio"
|
||||
<RichTextEditor
|
||||
value={formData.bio}
|
||||
onChange={(e) => handleInputChange("bio", e.target.value)}
|
||||
rows={4}
|
||||
onChange={(value) => handleInputChange("bio", value)}
|
||||
placeholder="Tell us about yourself..."
|
||||
minHeight="140px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -219,6 +269,52 @@ export default function ProfilePage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Password Reset</CardTitle>
|
||||
<CardDescription>Update your account password securely.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="currentPassword">Current password</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
value={passwordData.currentPassword}
|
||||
onChange={(e) => handlePasswordChange("currentPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="newPassword">New password</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={passwordData.newPassword}
|
||||
onChange={(e) => handlePasswordChange("newPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="confirmPassword">Confirm new password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={passwordData.confirmPassword}
|
||||
onChange={(e) => handlePasswordChange("confirmPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" size="lg">
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Reset password
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Company Card - Only for Admin users who have a company */}
|
||||
{isAdmin && company && (
|
||||
<Card>
|
||||
|
|
@ -293,4 +389,3 @@ export default function ProfilePage() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label"
|
|||
import { Separator } from "@/components/ui/separator"
|
||||
import { settingsApi, credentialsApi, ConfiguredService, storageApi } from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
import { Loader2, Check, Key, Trash2, Eye, EyeOff } from "lucide-react"
|
||||
import { Loader2, Check, Key, Trash2 } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -45,7 +45,6 @@ export default function SettingsPage() {
|
|||
const [selectedService, setSelectedService] = useState<string | null>(null)
|
||||
const [credentialPayload, setCredentialPayload] = useState<any>({})
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [testingConnection, setTestingConnection] = useState(false)
|
||||
|
||||
const handleTestStorageConnection = async () => {
|
||||
|
|
@ -188,6 +187,16 @@ export default function SettingsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const configuredMap = new Map(credentials.map((svc) => [svc.service_name, svc]))
|
||||
const servicesToRender: ConfiguredService[] = Array.from(
|
||||
new Set([...Object.keys(schemas), ...credentials.map((svc) => svc.service_name)])
|
||||
).map((serviceName) => configuredMap.get(serviceName) || {
|
||||
service_name: serviceName,
|
||||
updated_at: "",
|
||||
updated_by: "",
|
||||
is_configured: false,
|
||||
})
|
||||
|
||||
const handleOpenCredentialDialog = (serviceName: string) => {
|
||||
setSelectedService(serviceName)
|
||||
setCredentialPayload({})
|
||||
|
|
@ -215,6 +224,8 @@ export default function SettingsPage() {
|
|||
// Note: State definition needs update to object payload
|
||||
// const [credentialPayload, setCredentialPayload] = useState<any>({})
|
||||
|
||||
const isSelectedConfigured = selectedService ? configuredMap.has(selectedService) : false
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header ... */}
|
||||
|
|
@ -297,7 +308,7 @@ export default function SettingsPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{credentials.map((svc) => (
|
||||
{servicesToRender.map((svc) => (
|
||||
<div key={svc.service_name} className="flex flex-col justify-between border rounded-lg p-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -347,7 +358,7 @@ export default function SettingsPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Configure {selectedService && (schemas[selectedService]?.label || selectedService)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter credentials. Keys are encrypted before storage.
|
||||
Enter credentials. Keys are encrypted before storage and hidden after saving.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
|
|
@ -360,6 +371,7 @@ export default function SettingsPage() {
|
|||
className="font-mono text-xs min-h-[80px]"
|
||||
value={(credentialPayload as any)[field.key] || ""}
|
||||
onChange={(e) => setCredentialPayload({ ...credentialPayload, [field.key]: e.target.value })}
|
||||
placeholder={isSelectedConfigured ? "Stored securely" : `Enter ${field.label}`}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
|
|
@ -368,7 +380,7 @@ export default function SettingsPage() {
|
|||
// TODO: Checkbox handling if needed
|
||||
value={(credentialPayload as any)[field.key] || ""}
|
||||
onChange={(e) => setCredentialPayload({ ...credentialPayload, [field.key]: e.target.value })}
|
||||
placeholder={`Enter ${field.label}`}
|
||||
placeholder={isSelectedConfigured ? "Stored securely" : `Enter ${field.label}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -61,11 +61,7 @@ export function DashboardHeader() {
|
|||
>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage
|
||||
src={
|
||||
user?.email
|
||||
? `https://avatar.vercel.sh/${user.email}`
|
||||
: undefined
|
||||
}
|
||||
src={user?.avatarUrl || undefined}
|
||||
alt={user?.name}
|
||||
/>
|
||||
<AvatarFallback className="bg-primary/10 text-primary">
|
||||
|
|
|
|||
|
|
@ -529,9 +529,15 @@ export const profileApi = {
|
|||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
updatePassword: (data: { currentPassword: string; newPassword: string }) => {
|
||||
return apiRequest<void>("/api/v1/users/me/password", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
async uploadAvatar(file: File) {
|
||||
// 1. Get Presigned URL
|
||||
const { url, key } = await apiRequest<{ url: string; key: string }>(
|
||||
const { url, key, publicUrl } = await apiRequest<{ url: string; key: string; publicUrl?: string }>(
|
||||
`/api/v1/storage/upload-url?filename=${encodeURIComponent(file.name)}&contentType=${encodeURIComponent(file.type)}&folder=avatars`
|
||||
);
|
||||
|
||||
|
|
@ -552,13 +558,16 @@ export const profileApi = {
|
|||
// We save the key. The frontend or backend should resolve it to a full URL if needed.
|
||||
// For now, assuming saving the key is what's requested ("salvando as chaves").
|
||||
// We use the generic updateProfile method.
|
||||
const avatarUrl = publicUrl || key;
|
||||
console.log("[PROFILE_FLOW] Upload complete. Saving avatar URL:", avatarUrl);
|
||||
|
||||
const res = await fetch(`${getApiUrl()}/api/v1/users/me/profile`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include", // Use httpOnly cookie
|
||||
body: JSON.stringify({ avatarUrl: key })
|
||||
body: JSON.stringify({ avatarUrl })
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to update profile avatar");
|
||||
|
|
@ -824,4 +833,3 @@ export const locationsApi = {
|
|||
return res || [];
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface LoginResponse {
|
|||
roles: string[];
|
||||
status: string;
|
||||
created_at: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +62,7 @@ export async function login(
|
|||
email: data.user.email,
|
||||
role: userRole,
|
||||
roles: data.user.roles, // Extend User type if needed, or just keep it here
|
||||
avatarUrl: data.user.avatar_url,
|
||||
profileComplete: 80, // Mocked for now
|
||||
};
|
||||
|
||||
|
|
@ -148,6 +150,7 @@ export async function refreshSession(): Promise<User | null> {
|
|||
email: userData.email,
|
||||
role: mapRoleFromBackend(userData.roles || []),
|
||||
roles: userData.roles || [],
|
||||
avatarUrl: userData.avatarUrl || userData.avatar_url,
|
||||
profileComplete: 80,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface User {
|
|||
role: "candidate" | "admin" | "company" | "superadmin";
|
||||
roles?: string[]; // Added to match backend response structure
|
||||
avatar?: string;
|
||||
avatarUrl?: string;
|
||||
area?: string;
|
||||
profileComplete?: number;
|
||||
companyId?: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue