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:
Tiago Yamamoto 2026-01-03 20:21:51 -03:00 committed by GitHub
commit fa17b16b8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 402 additions and 45 deletions

View file

@ -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.

View file

@ -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")

View file

@ -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"`

View file

@ -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"`

View file

@ -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

View 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
}

View file

@ -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
}

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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.

View file

@ -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
}

View file

@ -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
}

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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">

View file

@ -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 || [];
},
};

View file

@ -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,
};

View file

@ -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;