feat: add update password
ci-api / test (push) Successful in 24m40s
ci-web / test (push) Canceled after 8m5s

This commit is contained in:
Nuxgrid
2026-07-29 14:18:14 +02:00
parent eda6c0e98a
commit 5a0c1bfe3f
5 changed files with 105 additions and 2 deletions
@@ -16,6 +16,7 @@ func NewGormStore(db *gorm.DB) *GormStore {
type Store interface { type Store interface {
UpdateUsername(id, newUsername string) (auth.User, error) UpdateUsername(id, newUsername string) (auth.User, error)
UpdatePassword(id, passwordHahs string) (auth.User, error)
} }
func (s *GormStore) UpdateUsername(id, newUsername string) (auth.User, error) { func (s *GormStore) UpdateUsername(id, newUsername string) (auth.User, error) {
@@ -34,3 +35,15 @@ func (s *GormStore) UpdateUsername(id, newUsername string) (auth.User, error) {
return user, nil return user, nil
} }
func (s *GormStore) UpdatePassword(id, passwordHash string) (auth.User, error) {
result := s.db.Model(&auth.User{}).Where("id = ?", id).Update("password", passwordHash)
if result.Error != nil {
return auth.User{}, result.Error
}
var user auth.User
if err := s.db.First(&user, "id = ?", id).Error; err != nil {
return auth.User{}, err
}
return user, nil
}
@@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/omnex/control-plane/api/internal/auth"
) )
type Handler struct { type Handler struct {
@@ -18,6 +19,10 @@ type newUsername struct {
Username string `json:"username" binding:"required,min=2,max=120"` Username string `json:"username" binding:"required,min=2,max=120"`
} }
type newPassword struct {
Password string `json:"password" binding:"required,min=8"`
}
func (h *Handler) UpdateUsernameById(c *gin.Context) { func (h *Handler) UpdateUsernameById(c *gin.Context) {
var u newUsername var u newUsername
@@ -40,3 +45,31 @@ func (h *Handler) UpdateUsernameById(c *gin.Context) {
c.JSON(http.StatusOK, user) c.JSON(http.StatusOK, user)
} }
func (h *Handler) UpdatePasswordById(c *gin.Context) {
var u newPassword
if err := c.ShouldBindJSON(&u); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return
}
id, ok := c.MustGet("id").(string)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"})
return
}
hash, err := auth.HashPassword(u.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
return
}
user, err := h.store.UpdatePassword(id, hash)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return
}
c.JSON(http.StatusOK, user)
}
@@ -64,6 +64,7 @@ func New(d Deps) *gin.Engine {
client.PATCH("/leads/:id/status", d.LeadsH.SetStatus) client.PATCH("/leads/:id/status", d.LeadsH.SetStatus)
client.POST("/subscription", d.SubH.AddCode) client.POST("/subscription", d.SubH.AddCode)
client.POST("/profile/username", d.ProfileH.UpdateUsernameById) client.POST("/profile/username", d.ProfileH.UpdateUsernameById)
client.POST("/profile/password", d.ProfileH.UpdatePasswordById)
} }
// Espace admin : provisioning des démos (admin uniquement). // Espace admin : provisioning des démos (admin uniquement).
@@ -73,6 +74,8 @@ func New(d Deps) *gin.Engine {
admin.GET("/codes", d.SubH.ListCodes) admin.GET("/codes", d.SubH.ListCodes)
admin.POST("/codes", d.SubH.CreateCodeForBuy) admin.POST("/codes", d.SubH.CreateCodeForBuy)
admin.GET("/messages", d.ContactH.GetMessage) admin.GET("/messages", d.ContactH.GetMessage)
admin.POST("/profile/username", d.ProfileH.UpdateUsernameById)
admin.POST("/profile/password", d.ProfileH.UpdatePasswordById)
if d.DemosH != nil { if d.DemosH != nil {
admin.POST("/demos", d.DemosH.Create) admin.POST("/demos", d.DemosH.Create)
admin.GET("/demos", d.DemosH.List) admin.GET("/demos", d.DemosH.List)
+5 -1
View File
@@ -104,7 +104,9 @@ export interface UpdateUsername {
username: string username: string
} }
export interface UpdatePassword {
password: string
}
// --- Endpoints --- // --- Endpoints ---
export const api = { export const api = {
@@ -155,4 +157,6 @@ export const api = {
request<DemoDetails>('POST', '/demos/details', { username }), request<DemoDetails>('POST', '/demos/details', { username }),
updateUsername: (username: string) => updateUsername: (username: string) =>
request<UpdateUsername>('POST', '/profile/username', { username }), request<UpdateUsername>('POST', '/profile/username', { username }),
updatePassword: (password: string) =>
request<UpdatePassword>('POST', '/profile/password', { password })
} }
+51 -1
View File
@@ -99,7 +99,8 @@ export function Profile() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loggingOut, setLoggingOut] = useState(false) const [loggingOut, setLoggingOut] = useState(false)
const [updatingUsername, setUpdatingUsername] = useState(false) const [updatingUsername, setUpdatingUsername] = useState(false)
const [updatingPassword, setUpdatingPassword] = useState(false)
const [passwordVersion, setPasswordVersion] = useState(0)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
@@ -145,6 +146,36 @@ export function Profile() {
} }
} }
const handleUpdatePassword = async (newPassword: string) => {
const trimmed = newPassword.trim()
if (!me || trimmed.length < 8) {
if (trimmed.length > 0) {
toast({ status: 'warning', title: 'Le mot de passe doit contenir au moins 8 caractères' })
}
setPasswordVersion(v => v + 1) // reset le champ même en cas d'annulation
return
}
setUpdatingPassword(true)
try {
await api.updatePassword(trimmed)
toast({ status: 'success', title: 'Mot de passe mis à jour' })
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
navigate('/login')
return
}
toast({
status: 'error',
title: 'Impossible de mettre à jour le mot de passe',
description: err instanceof ApiError ? err.message : undefined,
})
} finally {
setUpdatingPassword(false)
setPasswordVersion(v => v + 1) // vide le champ après tentative
}
}
const handleLogout = async () => { const handleLogout = async () => {
setLoggingOut(true) setLoggingOut(true)
try { try {
@@ -236,6 +267,25 @@ export function Profile() {
<StatLabel>Type d'abonnement</StatLabel> <StatLabel>Type d'abonnement</StatLabel>
<StatNumber fontSize="md">{me.type_abonnement || ''}</StatNumber> <StatNumber fontSize="md">{me.type_abonnement || ''}</StatNumber>
</Stat> </Stat>
<Stat>
<StatLabel>Mot de passe</StatLabel>
<Editable
key={passwordVersion}
defaultValue=""
placeholder="••••••••"
onSubmit={handleUpdatePassword}
isDisabled={updatingPassword}
submitOnBlur={false}
>
<HStack spacing={2}>
<EditablePreview as={StatNumber} fontSize="md" fontFamily="mono" />
<EditableInput type="password" fontSize="md" fontFamily="mono" />
<EditableUsernameControls />
</HStack>
</Editable>
</Stat>
<Stat> <Stat>
<StatLabel>Expire le</StatLabel> <StatLabel>Expire le</StatLabel>
<StatNumber fontSize="md">{formatDate(me.expired_at)}</StatNumber> <StatNumber fontSize="md">{formatDate(me.expired_at)}</StatNumber>