From ef280799c17be2c2ce56051e1ae6b854f3a59817 Mon Sep 17 00:00:00 2001 From: Nuxgrid Date: Tue, 28 Jul 2026 22:00:46 +0200 Subject: [PATCH] feat: add profile route and ui --- control-plane/api/cmd/api/main.go | 4 + control-plane/api/internal/auth/models.go | 1 + control-plane/api/internal/leads/handler.go | 2 - .../api/internal/profile/GormStore.go | 36 ++++ control-plane/api/internal/profile/handler.go | 42 ++++ control-plane/api/internal/profile/mem.go | 16 ++ control-plane/api/internal/router/router.go | 4 +- web/src/App.tsx | 3 + web/src/lib/api.ts | 8 +- web/src/pages/backoffice/Profile.tsx | 185 ++++++++++++++++++ 10 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 control-plane/api/internal/profile/GormStore.go create mode 100644 control-plane/api/internal/profile/handler.go create mode 100644 control-plane/api/internal/profile/mem.go create mode 100644 web/src/pages/backoffice/Profile.tsx 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)} + + + + + + + + + + )} + + + + ) +}