feat: add profile route and ui
ci-api / test (push) Failing after 34m42s
ci-web / test (push) Failing after 24m16s

This commit is contained in:
Nuxgrid
2026-07-28 22:00:46 +02:00
parent cb651a04e2
commit ef280799c1
10 changed files with 297 additions and 4 deletions
+4
View File
@@ -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)
@@ -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"`
@@ -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
@@ -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
}
@@ -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)
}
+16
View File
@@ -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)}
}
+3 -1
View File
@@ -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)
}
}
+3
View File
@@ -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 <Navigate to="/app/demos" replace />
case 'client':
return <Navigate to="/app/subscription" replace />
default:
return <Navigate to="/app/leads" replace />
}
@@ -113,6 +115,7 @@ export function App() {
}
/>
<Route path="subscription" element={<Subscription />} />
<Route path="profile" element={<Profile />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+7 -1
View File
@@ -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<DemoDetails>('POST', '/demos/details', { username }),
request<DemoDetails>('POST', '/demos/details', { username }),
updateUsername: (username: string) =>
request<UpdateUsername>('POST', '/profile/username', { username }),
}
+185
View File
@@ -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<MeResponse | null>(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 (
<Box bg="chakra-subtle-bg" py={{ base: 10, md: 14 }} minH="100%">
<Container maxW="container.md">
<Stack spacing={3} mb={8}>
<Heading size="lg">Mon profil</Heading>
<Text color="gray.400" fontSize="md">
Informations de votre compte et de votre abonnement.
</Text>
</Stack>
<Box
bg="bg-surface"
borderWidth="1px"
borderColor="chakra-border-color"
borderRadius="xl"
p={{ base: 6, md: 10 }}
boxShadow="lg"
>
{loading ? (
<Flex justify="center" py={10}>
<Spinner />
</Flex>
) : !me ? (
<Text color="gray.500">Aucune information disponible.</Text>
) : (
<Stack spacing={8}>
<Flex align="center" gap={5} wrap="wrap">
<Avatar name={me.username} size="xl" />
<Box>
<Heading size="md" fontFamily="mono">
{me.username}
</Heading>
<HStack mt={2} spacing={2}>
<Badge colorScheme={roleColor(me.role)}>{roleLabel(me.role)}</Badge>
{me.type_abonnement && (
<Badge colorScheme={subscriptionColor(me.type_abonnement)}>
{me.type_abonnement}
</Badge>
)}
</HStack>
</Box>
</Flex>
<Divider borderColor="chakra-border-color" />
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={6}>
<Stat>
<StatLabel>Identifiant</StatLabel>
<StatNumber fontSize="md" fontFamily="mono">
{me.user_id}
</StatNumber>
</Stat>
<Stat>
<StatLabel>Rôle</StatLabel>
<StatNumber fontSize="md">{roleLabel(me.role)}</StatNumber>
</Stat>
<Stat>
<StatLabel>Type d'abonnement</StatLabel>
<StatNumber fontSize="md">{me.type_abonnement || ''}</StatNumber>
</Stat>
<Stat>
<StatLabel>Expire le</StatLabel>
<StatNumber fontSize="md">{formatDate(me.expired_at)}</StatNumber>
</Stat>
</SimpleGrid>
<Divider borderColor="chakra-border-color" />
<Flex justify="flex-end">
<Button
colorScheme="red"
variant="outline"
isLoading={loggingOut}
onClick={handleLogout}
>
Se déconnecter
</Button>
</Flex>
</Stack>
)}
</Box>
</Container>
</Box>
)
}