diff --git a/control-plane/api/cmd/api/main.go b/control-plane/api/cmd/api/main.go
index 5489eec..70f9516 100644
--- a/control-plane/api/cmd/api/main.go
+++ b/control-plane/api/cmd/api/main.go
@@ -16,6 +16,7 @@ import (
"github.com/omnex/control-plane/api/internal/demos"
"github.com/omnex/control-plane/api/internal/k8s"
"github.com/omnex/control-plane/api/internal/leads"
+ "github.com/omnex/control-plane/api/internal/profile"
"github.com/omnex/control-plane/api/internal/router"
"github.com/omnex/control-plane/api/internal/sav"
"github.com/omnex/control-plane/api/internal/session"
@@ -69,6 +70,7 @@ func main() {
var demoPool demos.Pool
var codeStore sub.Store
var contactStore sav.Store
+ var profileStore profile.Store
if cfg.DatabaseURL != "" {
gdb, err := db.Open(cfg.DatabaseURL)
if err != nil {
@@ -88,6 +90,7 @@ func main() {
demoStore = demos.NewGormStore(gdb)
demoPool = demos.NewGormPool(gdb)
contactStore = sav.NewGormStore(gdb)
+ profileStore = profile.NewGormStore(gdb)
log.Printf("persistance: PostgreSQL (GORM)")
} else {
userStore = seedMemUsers()
@@ -126,6 +129,7 @@ func main() {
DemosH: demos.NewHandler(demoSvc, helmProv),
SubH: sub.NewHandler(codeStore),
ContactH: sav.NewHandler(contactStore),
+ ProfileH: profile.NewHandler(profileStore),
}
r := router.New(deps)
diff --git a/control-plane/api/internal/auth/models.go b/control-plane/api/internal/auth/models.go
index 6f769e6..b3e42a4 100644
--- a/control-plane/api/internal/auth/models.go
+++ b/control-plane/api/internal/auth/models.go
@@ -5,6 +5,7 @@ import "time"
type User struct {
ID string `gorm:"type:uuid;primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
+ Telegram *string `gorm:"size:120;not null" json:"telegram"`
PasswordHash string `gorm:"not null" json:"-"`
Role Role `gorm:"size:20;not null" json:"role"`
TypeAbo string `gorm:"type:varchar(35);default:demo" json:"type_abonnement"`
diff --git a/control-plane/api/internal/leads/handler.go b/control-plane/api/internal/leads/handler.go
index 82a5427..81ad01b 100644
--- a/control-plane/api/internal/leads/handler.go
+++ b/control-plane/api/internal/leads/handler.go
@@ -2,7 +2,6 @@ package leads
import (
"html"
- "log"
"net/http"
"strings"
@@ -27,7 +26,6 @@ type createRequest struct {
// Les entrées sont nettoyées (trim + échappement HTML) avant stockage.
func (h *Handler) Create(c *gin.Context) {
var req createRequest
- log.Printf("request-debug: %s", req)
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return
diff --git a/control-plane/api/internal/profile/GormStore.go b/control-plane/api/internal/profile/GormStore.go
new file mode 100644
index 0000000..1f5b53c
--- /dev/null
+++ b/control-plane/api/internal/profile/GormStore.go
@@ -0,0 +1,36 @@
+package profile
+
+import (
+ "github.com/omnex/control-plane/api/internal/auth"
+ "gorm.io/gorm"
+)
+
+// GormStore : Store adossé à PostgreSQL via GORM.
+type GormStore struct {
+ db *gorm.DB
+}
+
+func NewGormStore(db *gorm.DB) *GormStore {
+ return &GormStore{db: db}
+}
+
+type Store interface {
+ UpdateUsername(id, newUsername string) (auth.User, error)
+}
+
+func (s *GormStore) UpdateUsername(id, newUsername string) (auth.User, error) {
+ result := s.db.Model(&auth.User{}).
+ Where("id = ?", id).
+ Update("username", newUsername)
+
+ 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
+}
diff --git a/control-plane/api/internal/profile/handler.go b/control-plane/api/internal/profile/handler.go
new file mode 100644
index 0000000..dbe2de1
--- /dev/null
+++ b/control-plane/api/internal/profile/handler.go
@@ -0,0 +1,42 @@
+package profile
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+type Handler struct {
+ store Store
+}
+
+func NewHandler(store Store) *Handler {
+ return &Handler{store: store}
+}
+
+type newUsername struct {
+ Username string `json:"username" binding:"required,min=2,max=120"`
+}
+
+func (h *Handler) UpdateUsernameById(c *gin.Context) {
+ var u newUsername
+
+ 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
+ }
+
+ user, err := h.store.UpdateUsername(id, u.Username)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"})
+ return
+ }
+
+ c.JSON(http.StatusOK, user)
+}
diff --git a/control-plane/api/internal/profile/mem.go b/control-plane/api/internal/profile/mem.go
new file mode 100644
index 0000000..ea7e647
--- /dev/null
+++ b/control-plane/api/internal/profile/mem.go
@@ -0,0 +1,16 @@
+package profile
+
+import (
+ "sync"
+
+ "github.com/omnex/control-plane/api/internal/auth"
+)
+
+type MemStore struct {
+ mu sync.RWMutex
+ items map[string]auth.User
+}
+
+func NewMemStore() *MemStore {
+ return &MemStore{items: make(map[string]auth.User)}
+}
diff --git a/control-plane/api/internal/router/router.go b/control-plane/api/internal/router/router.go
index 635da61..4a5d49d 100644
--- a/control-plane/api/internal/router/router.go
+++ b/control-plane/api/internal/router/router.go
@@ -11,6 +11,7 @@ import (
"github.com/omnex/control-plane/api/internal/demos"
"github.com/omnex/control-plane/api/internal/httpx"
"github.com/omnex/control-plane/api/internal/leads"
+ "github.com/omnex/control-plane/api/internal/profile"
"github.com/omnex/control-plane/api/internal/sav"
"github.com/omnex/control-plane/api/internal/session"
"github.com/omnex/control-plane/api/internal/sub"
@@ -26,6 +27,7 @@ type Deps struct {
DemosH *demos.Handler
SubH *sub.Handler
ContactH *sav.Handler
+ ProfileH *profile.Handler
}
// New construit l'engine Gin avec toute la chaîne de sécurité.
@@ -61,6 +63,7 @@ func New(d Deps) *gin.Engine {
client.GET("/leads", d.LeadsH.List)
client.PATCH("/leads/:id/status", d.LeadsH.SetStatus)
client.POST("/subscription", d.SubH.AddCode)
+ client.POST("/profile/username", d.ProfileH.UpdateUsernameById)
}
// Espace admin : provisioning des démos (admin uniquement).
@@ -77,7 +80,6 @@ func New(d Deps) *gin.Engine {
admin.DELETE("/demos/:id", d.DemosH.Delete)
admin.POST("/demos/:id/extend", d.DemosH.Extend)
admin.POST("/demos/details", d.DemosH.ListDetails)
-
}
}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 00e7c93..8b317d7 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -10,6 +10,7 @@ import { Demos } from './pages/backoffice/Demos'
import { Messages } from './pages/backoffice/Contact'
import { Codes } from './pages/backoffice/Codes'
import { Subscription } from './pages/backoffice/Subscription'
+import { Profile } from './pages/backoffice/Profile'
import { PublicLayout } from './components/PublicLayout'
import { BackofficeLayout } from './components/BackofficeLayout'
import { Contact } from './pages/Contact'
@@ -49,6 +50,7 @@ function BackofficeHome() {
return
case 'client':
return
+
default:
return
}
@@ -113,6 +115,7 @@ export function App() {
}
/>
} />
+ } />
} />
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 5d36925..beceb3c 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -100,6 +100,10 @@ export interface DemoDetails {
state: DemoState
}
+export interface UpdateUsername {
+ username: string
+}
+
// --- Endpoints ---
@@ -148,5 +152,7 @@ export const api = {
request<{ success: string }>('POST', '/send/message', { username, telegram, sujet, message }),
getMessage: () => request<{ messages: Contact[] }>('GET', '/messages'),
getDemoDetails: (username: string) =>
- request('POST', '/demos/details', { username }),
+ request('POST', '/demos/details', { username }),
+ updateUsername: (username: string) =>
+ request('POST', '/profile/username', { username }),
}
diff --git a/web/src/pages/backoffice/Profile.tsx b/web/src/pages/backoffice/Profile.tsx
new file mode 100644
index 0000000..1361593
--- /dev/null
+++ b/web/src/pages/backoffice/Profile.tsx
@@ -0,0 +1,185 @@
+import { useEffect, useState } from 'react'
+import {
+ Avatar,
+ Badge,
+ Box,
+ Button,
+ Container,
+ Divider,
+ Flex,
+ Heading,
+ HStack,
+ SimpleGrid,
+ Spinner,
+ Stack,
+ Stat,
+ StatLabel,
+ StatNumber,
+ Text,
+ useToast,
+} from '@chakra-ui/react'
+import { useNavigate } from 'react-router-dom'
+import { api, ApiError } from '../../lib/api'
+import { useAuth } from '../../lib/auth'
+
+interface MeResponse {
+ user_id: string
+ username: string
+ role: 'admin' | 'client'
+ type_abonnement: string
+ expired_at: string
+}
+
+function roleLabel(role: string) {
+ return role === 'admin' ? 'Administrateur' : 'Client'
+}
+
+function roleColor(role: string) {
+ return role === 'admin' ? 'purple' : 'blue'
+}
+
+function subscriptionColor(type: string) {
+ const t = type?.toLowerCase() ?? ''
+ if (t.includes('premium') || t.includes('pro')) return 'green'
+ if (t.includes('expired') || t === '') return 'red'
+ return 'gray'
+}
+
+function formatDate(iso: string) {
+ if (!iso) return '—'
+ const d = new Date(iso)
+ if (Number.isNaN(d.getTime())) return '—'
+ return d.toLocaleDateString('fr-FR', {
+ day: '2-digit',
+ month: 'long',
+ year: 'numeric',
+ })
+}
+
+export function Profile() {
+ const toast = useToast()
+ const navigate = useNavigate()
+ const { logout: authLogout } = useAuth()
+ const [me, setMe] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [loggingOut, setLoggingOut] = useState(false)
+
+ useEffect(() => {
+ let cancelled = false
+ ;(async () => {
+ try {
+ const res = await api.me()
+ if (!cancelled) setMe(res)
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 401) {
+ navigate('/login')
+ return
+ }
+ toast({ status: 'error', title: 'Impossible de charger le profil' })
+ } finally {
+ if (!cancelled) setLoading(false)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [navigate, toast])
+
+ const handleLogout = async () => {
+ setLoggingOut(true)
+ try {
+ await api.logout()
+ authLogout?.()
+ navigate('/login')
+ } catch {
+ toast({ status: 'error', title: 'Déconnexion impossible' })
+ } finally {
+ setLoggingOut(false)
+ }
+ }
+
+ return (
+
+
+
+ Mon profil
+
+ Informations de votre compte et de votre abonnement.
+
+
+
+
+ {loading ? (
+
+
+
+ ) : !me ? (
+ Aucune information disponible.
+ ) : (
+
+
+
+
+
+ {me.username}
+
+
+ {roleLabel(me.role)}
+ {me.type_abonnement && (
+
+ {me.type_abonnement}
+
+ )}
+
+
+
+
+
+
+
+
+ Identifiant
+
+ {me.user_id}
+
+
+
+ Rôle
+ {roleLabel(me.role)}
+
+
+ Type d'abonnement
+ {me.type_abonnement || '—'}
+
+
+ Expire le
+ {formatDate(me.expired_at)}
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ )
+}