Fix profile updates, avatar upload, and settings
This commit is contained in:
parent
1e1558aa3c
commit
aeb57f325a
18 changed files with 402 additions and 45 deletions
|
|
@ -25,6 +25,7 @@ type CoreHandlers struct {
|
||||||
listUsersUC *user.ListUsersUseCase
|
listUsersUC *user.ListUsersUseCase
|
||||||
deleteUserUC *user.DeleteUserUseCase
|
deleteUserUC *user.DeleteUserUseCase
|
||||||
updateUserUC *user.UpdateUserUseCase
|
updateUserUC *user.UpdateUserUseCase
|
||||||
|
updatePasswordUC *user.UpdatePasswordUseCase
|
||||||
listCompaniesUC *tenant.ListCompaniesUseCase
|
listCompaniesUC *tenant.ListCompaniesUseCase
|
||||||
auditService *services.AuditService
|
auditService *services.AuditService
|
||||||
notificationService *services.NotificationService
|
notificationService *services.NotificationService
|
||||||
|
|
@ -33,7 +34,7 @@ type CoreHandlers struct {
|
||||||
credentialsService *services.CredentialsService
|
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{
|
return &CoreHandlers{
|
||||||
loginUC: l,
|
loginUC: l,
|
||||||
registerCandidateUC: reg,
|
registerCandidateUC: reg,
|
||||||
|
|
@ -42,6 +43,7 @@ func NewCoreHandlers(l *auth.LoginUseCase, reg *auth.RegisterCandidateUseCase, c
|
||||||
listUsersUC: list,
|
listUsersUC: list,
|
||||||
deleteUserUC: del,
|
deleteUserUC: del,
|
||||||
updateUserUC: upd,
|
updateUserUC: upd,
|
||||||
|
updatePasswordUC: updatePasswordUC,
|
||||||
listCompaniesUC: lc,
|
listCompaniesUC: lc,
|
||||||
auditService: auditService,
|
auditService: auditService,
|
||||||
notificationService: notificationService,
|
notificationService: notificationService,
|
||||||
|
|
@ -1024,6 +1026,47 @@ func (h *CoreHandlers) UpdateMyProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
json.NewEncoder(w).Encode(resp)
|
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.
|
// UploadMyAvatar handles profile picture upload.
|
||||||
// @Summary Upload Avatar
|
// @Summary Upload Avatar
|
||||||
// @Description Uploads a profile picture for the current user.
|
// @Description Uploads a profile picture for the current user.
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,17 @@ func (h *StorageHandler) GetUploadURL(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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
|
// Return simple JSON
|
||||||
resp := map[string]string{
|
resp := map[string]string{
|
||||||
"url": url,
|
"url": url,
|
||||||
"key": key, // Client needs key to save to DB profile
|
"key": key, // Client needs key to save to DB profile
|
||||||
|
"publicUrl": publicURL, // Public URL for immediate use
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ type User struct {
|
||||||
TenantID string `json:"tenant_id"` // Link to Company
|
TenantID string `json:"tenant_id"` // Link to Company
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
Phone *string `json:"phone,omitempty"`
|
||||||
|
Bio *string `json:"bio,omitempty"`
|
||||||
PasswordHash string `json:"-"`
|
PasswordHash string `json:"-"`
|
||||||
AvatarUrl string `json:"avatar_url"`
|
AvatarUrl string `json:"avatar_url"`
|
||||||
Roles []Role `json:"roles"`
|
Roles []Role `json:"roles"`
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ type CreateUserRequest struct {
|
||||||
type UpdateUserRequest struct {
|
type UpdateUserRequest struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
Email *string `json:"email,omitempty"`
|
Email *string `json:"email,omitempty"`
|
||||||
|
Phone *string `json:"phone,omitempty"`
|
||||||
|
Bio *string `json:"bio,omitempty"`
|
||||||
Active *bool `json:"active,omitempty"`
|
Active *bool `json:"active,omitempty"`
|
||||||
Status *string `json:"status,omitempty"`
|
Status *string `json:"status,omitempty"`
|
||||||
Roles *[]string `json:"roles,omitempty"`
|
Roles *[]string `json:"roles,omitempty"`
|
||||||
|
|
@ -38,9 +40,16 @@ type UserResponse struct {
|
||||||
Roles []string `json:"roles"`
|
Roles []string `json:"roles"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
AvatarUrl string `json:"avatar_url"`
|
AvatarUrl string `json:"avatar_url"`
|
||||||
|
Phone *string `json:"phone,omitempty"`
|
||||||
|
Bio *string `json:"bio,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdatePasswordRequest struct {
|
||||||
|
CurrentPassword string `json:"currentPassword"`
|
||||||
|
NewPassword string `json:"newPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
type RegisterCandidateRequest struct {
|
type RegisterCandidateRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,9 @@ func (uc *LoginUseCase) Execute(ctx context.Context, input dto.LoginRequest) (*d
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Roles: roles,
|
Roles: roles,
|
||||||
Status: user.Status,
|
Status: user.Status,
|
||||||
|
AvatarUrl: user.AvatarUrl,
|
||||||
|
Phone: user.Phone,
|
||||||
|
Bio: user.Bio,
|
||||||
CreatedAt: user.CreatedAt,
|
CreatedAt: user.CreatedAt,
|
||||||
},
|
},
|
||||||
}, nil
|
}, 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 {
|
if input.Email != nil {
|
||||||
user.Email = *input.Email
|
user.Email = *input.Email
|
||||||
}
|
}
|
||||||
|
if input.Phone != nil {
|
||||||
|
user.Phone = input.Phone
|
||||||
|
}
|
||||||
|
if input.Bio != nil {
|
||||||
|
user.Bio = input.Bio
|
||||||
|
}
|
||||||
if input.Status != nil {
|
if input.Status != nil {
|
||||||
user.Status = *input.Status
|
user.Status = *input.Status
|
||||||
} else if input.Active != nil {
|
} else if input.Active != nil {
|
||||||
|
|
@ -81,6 +87,8 @@ func (uc *UpdateUserUseCase) Execute(ctx context.Context, id, tenantID string, i
|
||||||
Roles: roles,
|
Roles: roles,
|
||||||
Status: updated.Status,
|
Status: updated.Status,
|
||||||
AvatarUrl: updated.AvatarUrl,
|
AvatarUrl: updated.AvatarUrl,
|
||||||
|
Phone: updated.Phone,
|
||||||
|
Bio: updated.Bio,
|
||||||
CreatedAt: updated.CreatedAt,
|
CreatedAt: updated.CreatedAt,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,9 @@ type User struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
Phone *string `json:"phone,omitempty"`
|
||||||
|
Bio *string `json:"bio,omitempty"`
|
||||||
|
AvatarUrl *string `json:"avatarUrl,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
CompanyID *string `json:"companyId,omitempty"`
|
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) {
|
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity.User, error) {
|
||||||
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
|
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, '')
|
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`
|
FROM users WHERE email = $1 OR identifier = $1`
|
||||||
row := r.db.QueryRowContext(ctx, query, email)
|
row := r.db.QueryRowContext(ctx, query, email)
|
||||||
|
|
||||||
u := &entity.User{}
|
u := &entity.User{}
|
||||||
var dbID string
|
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 != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil // Return nil if not found
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
u.ID = dbID
|
u.ID = dbID
|
||||||
|
u.Phone = nullStringPtr(phone)
|
||||||
|
u.Bio = nullStringPtr(bio)
|
||||||
u.Roles, _ = r.getRoles(ctx, dbID)
|
u.Roles, _ = r.getRoles(ctx, dbID)
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
|
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
|
||||||
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
|
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, '')
|
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''),
|
||||||
|
phone, bio
|
||||||
FROM users WHERE id = $1`
|
FROM users WHERE id = $1`
|
||||||
row := r.db.QueryRowContext(ctx, query, id)
|
row := r.db.QueryRowContext(ctx, query, id)
|
||||||
|
|
||||||
u := &entity.User{}
|
u := &entity.User{}
|
||||||
var dbID string
|
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 != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
u.ID = dbID
|
u.ID = dbID
|
||||||
|
u.Phone = nullStringPtr(phone)
|
||||||
|
u.Bio = nullStringPtr(bio)
|
||||||
u.Roles, _ = r.getRoles(ctx, dbID)
|
u.Roles, _ = r.getRoles(ctx, dbID)
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
@ -124,8 +158,9 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, l
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
|
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, '')
|
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''),
|
||||||
|
phone, bio
|
||||||
FROM users
|
FROM users
|
||||||
WHERE tenant_id = $1
|
WHERE tenant_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
|
|
@ -140,10 +175,26 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, l
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
u := &entity.User{}
|
u := &entity.User{}
|
||||||
var dbID string
|
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
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
u.ID = dbID
|
u.ID = dbID
|
||||||
|
u.Phone = nullStringPtr(phone)
|
||||||
|
u.Bio = nullStringPtr(bio)
|
||||||
u.Roles, _ = r.getRoles(ctx, dbID)
|
u.Roles, _ = r.getRoles(ctx, dbID)
|
||||||
users = append(users, u)
|
users = append(users, u)
|
||||||
}
|
}
|
||||||
|
|
@ -166,8 +217,22 @@ func (r *UserRepository) Update(ctx context.Context, user *entity.User) (*entity
|
||||||
primaryRole = user.Roles[0].Name
|
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`
|
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.Email, user.Status, primaryRole, user.UpdatedAt, user.AvatarUrl, user.ID)
|
_, 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -225,3 +290,10 @@ func (r *UserRepository) getRoles(ctx context.Context, userID string) ([]entity.
|
||||||
}
|
}
|
||||||
return roles, nil
|
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)
|
listUsersUC := userUC.NewListUsersUseCase(userRepo)
|
||||||
deleteUserUC := userUC.NewDeleteUserUseCase(userRepo)
|
deleteUserUC := userUC.NewDeleteUserUseCase(userRepo)
|
||||||
updateUserUC := userUC.NewUpdateUserUseCase(userRepo)
|
updateUserUC := userUC.NewUpdateUserUseCase(userRepo)
|
||||||
|
updatePasswordUC := userUC.NewUpdatePasswordUseCase(userRepo, authService)
|
||||||
auditService := services.NewAuditService(database.DB)
|
auditService := services.NewAuditService(database.DB)
|
||||||
notificationService := services.NewNotificationService(database.DB, fcmService)
|
notificationService := services.NewNotificationService(database.DB, fcmService)
|
||||||
ticketService := services.NewTicketService(database.DB)
|
ticketService := services.NewTicketService(database.DB)
|
||||||
|
|
@ -87,6 +88,7 @@ func NewRouter() http.Handler {
|
||||||
listUsersUC,
|
listUsersUC,
|
||||||
deleteUserUC,
|
deleteUserUC,
|
||||||
updateUserUC,
|
updateUserUC,
|
||||||
|
updatePasswordUC,
|
||||||
listCompaniesUC,
|
listCompaniesUC,
|
||||||
auditService,
|
auditService,
|
||||||
notificationService, // Added
|
notificationService, // Added
|
||||||
|
|
@ -178,6 +180,7 @@ func NewRouter() http.Handler {
|
||||||
// Public /api/v1/users/me (Authenticated)
|
// Public /api/v1/users/me (Authenticated)
|
||||||
mux.Handle("GET /api/v1/users/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.Me)))
|
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/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)
|
// /api/v1/admin/companies -> Handled by coreHandlers.ListCompanies (Smart Branching)
|
||||||
// Needs to be wired with Optional Auth to support both Public and Admin.
|
// 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
|
// GetUser fetches a user by ID
|
||||||
func (s *AdminService) GetUser(ctx context.Context, id string) (*dto.User, error) {
|
func (s *AdminService) GetUser(ctx context.Context, id string) (*dto.User, error) {
|
||||||
query := `
|
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
|
FROM users WHERE id = $1
|
||||||
`
|
`
|
||||||
var u dto.User
|
var u dto.User
|
||||||
var roleStr string
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
u.Role = roleStr
|
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
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
|
@ -29,26 +30,34 @@ type UploadConfig struct {
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StorageService) getClient(ctx context.Context) (*s3.PresignClient, string, error) {
|
func (s *StorageService) getConfig(ctx context.Context) (UploadConfig, error) {
|
||||||
// 1. Fetch Credentials from DB (Encrypted Payload)
|
|
||||||
payload, err := s.credentialsService.GetDecryptedKey(ctx, "storage")
|
payload, err := s.credentialsService.GetDecryptedKey(ctx, "storage")
|
||||||
if err != nil {
|
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
|
var uCfg UploadConfig
|
||||||
if err := json.Unmarshal([]byte(payload), &uCfg); err != nil {
|
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 == "" {
|
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 == "" {
|
if uCfg.Region == "" {
|
||||||
uCfg.Region = "auto"
|
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
|
// 2. Setup S3 V2 Client
|
||||||
cfg, err := config.LoadDefaultConfig(ctx,
|
cfg, err := config.LoadDefaultConfig(ctx,
|
||||||
config.WithRegion(uCfg.Region),
|
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.
|
// 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
|
// Refetch raw creds to make a standard client
|
||||||
payload, err := s.credentialsService.GetDecryptedKey(ctx, "storage")
|
uCfg, err := s.getConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get storage credentials: %w", err)
|
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,
|
cfg, err := config.LoadDefaultConfig(ctx,
|
||||||
config.WithRegion(uCfg.Region),
|
config.WithRegion(uCfg.Region),
|
||||||
|
|
@ -140,3 +145,13 @@ func (s *StorageService) TestConnection(ctx context.Context) error {
|
||||||
|
|
||||||
return nil
|
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 { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { RichTextEditor } from "@/components/rich-text-editor"
|
||||||
import { Save, Loader2, Building2 } from "lucide-react"
|
import { Save, Loader2, Building2 } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { profileApi, authApi, adminCompaniesApi, type AdminCompany } from "@/lib/api"
|
import { profileApi, authApi, adminCompaniesApi, type AdminCompany } from "@/lib/api"
|
||||||
|
|
@ -27,6 +28,12 @@ export default function ProfilePage() {
|
||||||
bio: "",
|
bio: "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [passwordData, setPasswordData] = useState({
|
||||||
|
currentPassword: "",
|
||||||
|
newPassword: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
})
|
||||||
|
|
||||||
const [companyData, setCompanyData] = useState({
|
const [companyData, setCompanyData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
|
|
@ -35,6 +42,17 @@ export default function ProfilePage() {
|
||||||
description: "",
|
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(() => {
|
useEffect(() => {
|
||||||
const currentUser = getCurrentUser()
|
const currentUser = getCurrentUser()
|
||||||
setIsAdmin(isAdminUser(currentUser))
|
setIsAdmin(isAdminUser(currentUser))
|
||||||
|
|
@ -89,6 +107,10 @@ export default function ProfilePage() {
|
||||||
setCompanyData(prev => ({ ...prev, [field]: value }))
|
setCompanyData(prev => ({ ...prev, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePasswordChange = (field: string, value: string) => {
|
||||||
|
setPasswordData(prev => ({ ...prev, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaving(true)
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
|
|
@ -153,16 +201,18 @@ export default function ProfilePage() {
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<ProfilePictureUpload
|
<ProfilePictureUpload
|
||||||
fallbackText={formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U"}
|
fallbackText={formData.fullName ? getInitials(formData.fullName) : "U"}
|
||||||
size="xl"
|
size="xl"
|
||||||
useDatabase={false}
|
useDatabase={false}
|
||||||
onImageChange={async (file, url) => {
|
onImageChange={async (file, url) => {
|
||||||
if (file) {
|
if (file) {
|
||||||
try {
|
try {
|
||||||
|
console.log("[PROFILE_FLOW] Uploading avatar:", { name: file.name, size: file.size })
|
||||||
await profileApi.uploadAvatar(file)
|
await profileApi.uploadAvatar(file)
|
||||||
loadProfile()
|
loadProfile()
|
||||||
toast.success("Avatar updated")
|
toast.success("Avatar updated")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error("[PROFILE_FLOW] Avatar upload failed:", err)
|
||||||
toast.error("Failed to upload avatar")
|
toast.error("Failed to upload avatar")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -203,11 +253,11 @@ export default function ProfilePage() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="bio">Bio</Label>
|
<Label htmlFor="bio">Bio</Label>
|
||||||
<Textarea
|
<RichTextEditor
|
||||||
id="bio"
|
|
||||||
value={formData.bio}
|
value={formData.bio}
|
||||||
onChange={(e) => handleInputChange("bio", e.target.value)}
|
onChange={(value) => handleInputChange("bio", value)}
|
||||||
rows={4}
|
placeholder="Tell us about yourself..."
|
||||||
|
minHeight="140px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -219,6 +269,52 @@ export default function ProfilePage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Company Card - Only for Admin users who have a company */}
|
||||||
{isAdmin && company && (
|
{isAdmin && company && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -293,4 +389,3 @@ export default function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { settingsApi, credentialsApi, ConfiguredService, storageApi } from "@/lib/api"
|
import { settingsApi, credentialsApi, ConfiguredService, storageApi } from "@/lib/api"
|
||||||
import { toast } from "sonner"
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -45,7 +45,6 @@ export default function SettingsPage() {
|
||||||
const [selectedService, setSelectedService] = useState<string | null>(null)
|
const [selectedService, setSelectedService] = useState<string | null>(null)
|
||||||
const [credentialPayload, setCredentialPayload] = useState<any>({})
|
const [credentialPayload, setCredentialPayload] = useState<any>({})
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
|
||||||
const [testingConnection, setTestingConnection] = useState(false)
|
const [testingConnection, setTestingConnection] = useState(false)
|
||||||
|
|
||||||
const handleTestStorageConnection = async () => {
|
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) => {
|
const handleOpenCredentialDialog = (serviceName: string) => {
|
||||||
setSelectedService(serviceName)
|
setSelectedService(serviceName)
|
||||||
setCredentialPayload({})
|
setCredentialPayload({})
|
||||||
|
|
@ -215,6 +224,8 @@ export default function SettingsPage() {
|
||||||
// Note: State definition needs update to object payload
|
// Note: State definition needs update to object payload
|
||||||
// const [credentialPayload, setCredentialPayload] = useState<any>({})
|
// const [credentialPayload, setCredentialPayload] = useState<any>({})
|
||||||
|
|
||||||
|
const isSelectedConfigured = selectedService ? configuredMap.has(selectedService) : false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header ... */}
|
{/* Header ... */}
|
||||||
|
|
@ -297,7 +308,7 @@ export default function SettingsPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<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 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="space-y-2 mb-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -347,7 +358,7 @@ export default function SettingsPage() {
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Configure {selectedService && (schemas[selectedService]?.label || selectedService)}</DialogTitle>
|
<DialogTitle>Configure {selectedService && (schemas[selectedService]?.label || selectedService)}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Enter credentials. Keys are encrypted before storage.
|
Enter credentials. Keys are encrypted before storage and hidden after saving.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
|
|
@ -360,6 +371,7 @@ export default function SettingsPage() {
|
||||||
className="font-mono text-xs min-h-[80px]"
|
className="font-mono text-xs min-h-[80px]"
|
||||||
value={(credentialPayload as any)[field.key] || ""}
|
value={(credentialPayload as any)[field.key] || ""}
|
||||||
onChange={(e) => setCredentialPayload({ ...credentialPayload, [field.key]: e.target.value })}
|
onChange={(e) => setCredentialPayload({ ...credentialPayload, [field.key]: e.target.value })}
|
||||||
|
placeholder={isSelectedConfigured ? "Stored securely" : `Enter ${field.label}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -368,7 +380,7 @@ export default function SettingsPage() {
|
||||||
// TODO: Checkbox handling if needed
|
// TODO: Checkbox handling if needed
|
||||||
value={(credentialPayload as any)[field.key] || ""}
|
value={(credentialPayload as any)[field.key] || ""}
|
||||||
onChange={(e) => setCredentialPayload({ ...credentialPayload, [field.key]: e.target.value })}
|
onChange={(e) => setCredentialPayload({ ...credentialPayload, [field.key]: e.target.value })}
|
||||||
placeholder={`Enter ${field.label}`}
|
placeholder={isSelectedConfigured ? "Stored securely" : `Enter ${field.label}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,7 @@ export function DashboardHeader() {
|
||||||
>
|
>
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={
|
src={user?.avatarUrl || undefined}
|
||||||
user?.email
|
|
||||||
? `https://avatar.vercel.sh/${user.email}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
alt={user?.name}
|
alt={user?.name}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback className="bg-primary/10 text-primary">
|
<AvatarFallback className="bg-primary/10 text-primary">
|
||||||
|
|
|
||||||
|
|
@ -529,9 +529,15 @@ export const profileApi = {
|
||||||
body: JSON.stringify(data),
|
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) {
|
async uploadAvatar(file: File) {
|
||||||
// 1. Get Presigned URL
|
// 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`
|
`/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.
|
// 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").
|
// For now, assuming saving the key is what's requested ("salvando as chaves").
|
||||||
// We use the generic updateProfile method.
|
// 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`, {
|
const res = await fetch(`${getApiUrl()}/api/v1/users/me/profile`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
credentials: "include", // Use httpOnly cookie
|
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");
|
if (!res.ok) throw new Error("Failed to update profile avatar");
|
||||||
|
|
@ -824,4 +833,3 @@ export const locationsApi = {
|
||||||
return res || [];
|
return res || [];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ interface LoginResponse {
|
||||||
roles: string[];
|
roles: string[];
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
avatar_url?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,6 +62,7 @@ export async function login(
|
||||||
email: data.user.email,
|
email: data.user.email,
|
||||||
role: userRole,
|
role: userRole,
|
||||||
roles: data.user.roles, // Extend User type if needed, or just keep it here
|
roles: data.user.roles, // Extend User type if needed, or just keep it here
|
||||||
|
avatarUrl: data.user.avatar_url,
|
||||||
profileComplete: 80, // Mocked for now
|
profileComplete: 80, // Mocked for now
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -148,6 +150,7 @@ export async function refreshSession(): Promise<User | null> {
|
||||||
email: userData.email,
|
email: userData.email,
|
||||||
role: mapRoleFromBackend(userData.roles || []),
|
role: mapRoleFromBackend(userData.roles || []),
|
||||||
roles: userData.roles || [],
|
roles: userData.roles || [],
|
||||||
|
avatarUrl: userData.avatarUrl || userData.avatar_url,
|
||||||
profileComplete: 80,
|
profileComplete: 80,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export interface User {
|
||||||
role: "candidate" | "admin" | "company" | "superadmin";
|
role: "candidate" | "admin" | "company" | "superadmin";
|
||||||
roles?: string[]; // Added to match backend response structure
|
roles?: string[]; // Added to match backend response structure
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
area?: string;
|
area?: string;
|
||||||
profileComplete?: number;
|
profileComplete?: number;
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue