feat: add profile route and ui
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/omnex/control-plane/api/internal/demos"
|
"github.com/omnex/control-plane/api/internal/demos"
|
||||||
"github.com/omnex/control-plane/api/internal/k8s"
|
"github.com/omnex/control-plane/api/internal/k8s"
|
||||||
"github.com/omnex/control-plane/api/internal/leads"
|
"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/router"
|
||||||
"github.com/omnex/control-plane/api/internal/sav"
|
"github.com/omnex/control-plane/api/internal/sav"
|
||||||
"github.com/omnex/control-plane/api/internal/session"
|
"github.com/omnex/control-plane/api/internal/session"
|
||||||
@@ -69,6 +70,7 @@ func main() {
|
|||||||
var demoPool demos.Pool
|
var demoPool demos.Pool
|
||||||
var codeStore sub.Store
|
var codeStore sub.Store
|
||||||
var contactStore sav.Store
|
var contactStore sav.Store
|
||||||
|
var profileStore profile.Store
|
||||||
if cfg.DatabaseURL != "" {
|
if cfg.DatabaseURL != "" {
|
||||||
gdb, err := db.Open(cfg.DatabaseURL)
|
gdb, err := db.Open(cfg.DatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -88,6 +90,7 @@ func main() {
|
|||||||
demoStore = demos.NewGormStore(gdb)
|
demoStore = demos.NewGormStore(gdb)
|
||||||
demoPool = demos.NewGormPool(gdb)
|
demoPool = demos.NewGormPool(gdb)
|
||||||
contactStore = sav.NewGormStore(gdb)
|
contactStore = sav.NewGormStore(gdb)
|
||||||
|
profileStore = profile.NewGormStore(gdb)
|
||||||
log.Printf("persistance: PostgreSQL (GORM)")
|
log.Printf("persistance: PostgreSQL (GORM)")
|
||||||
} else {
|
} else {
|
||||||
userStore = seedMemUsers()
|
userStore = seedMemUsers()
|
||||||
@@ -126,6 +129,7 @@ func main() {
|
|||||||
DemosH: demos.NewHandler(demoSvc, helmProv),
|
DemosH: demos.NewHandler(demoSvc, helmProv),
|
||||||
SubH: sub.NewHandler(codeStore),
|
SubH: sub.NewHandler(codeStore),
|
||||||
ContactH: sav.NewHandler(contactStore),
|
ContactH: sav.NewHandler(contactStore),
|
||||||
|
ProfileH: profile.NewHandler(profileStore),
|
||||||
}
|
}
|
||||||
|
|
||||||
r := router.New(deps)
|
r := router.New(deps)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import "time"
|
|||||||
type User struct {
|
type User struct {
|
||||||
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||||
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
|
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:"-"`
|
PasswordHash string `gorm:"not null" json:"-"`
|
||||||
Role Role `gorm:"size:20;not null" json:"role"`
|
Role Role `gorm:"size:20;not null" json:"role"`
|
||||||
TypeAbo string `gorm:"type:varchar(35);default:demo" json:"type_abonnement"`
|
TypeAbo string `gorm:"type:varchar(35);default:demo" json:"type_abonnement"`
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package leads
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"html"
|
"html"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -27,7 +26,6 @@ type createRequest struct {
|
|||||||
// Les entrées sont nettoyées (trim + échappement HTML) avant stockage.
|
// Les entrées sont nettoyées (trim + échappement HTML) avant stockage.
|
||||||
func (h *Handler) Create(c *gin.Context) {
|
func (h *Handler) Create(c *gin.Context) {
|
||||||
var req createRequest
|
var req createRequest
|
||||||
log.Printf("request-debug: %s", req)
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
return
|
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)
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/omnex/control-plane/api/internal/demos"
|
"github.com/omnex/control-plane/api/internal/demos"
|
||||||
"github.com/omnex/control-plane/api/internal/httpx"
|
"github.com/omnex/control-plane/api/internal/httpx"
|
||||||
"github.com/omnex/control-plane/api/internal/leads"
|
"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/sav"
|
||||||
"github.com/omnex/control-plane/api/internal/session"
|
"github.com/omnex/control-plane/api/internal/session"
|
||||||
"github.com/omnex/control-plane/api/internal/sub"
|
"github.com/omnex/control-plane/api/internal/sub"
|
||||||
@@ -26,6 +27,7 @@ type Deps struct {
|
|||||||
DemosH *demos.Handler
|
DemosH *demos.Handler
|
||||||
SubH *sub.Handler
|
SubH *sub.Handler
|
||||||
ContactH *sav.Handler
|
ContactH *sav.Handler
|
||||||
|
ProfileH *profile.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// New construit l'engine Gin avec toute la chaîne de sécurité.
|
// 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.GET("/leads", d.LeadsH.List)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Espace admin : provisioning des démos (admin uniquement).
|
// 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.DELETE("/demos/:id", d.DemosH.Delete)
|
||||||
admin.POST("/demos/:id/extend", d.DemosH.Extend)
|
admin.POST("/demos/:id/extend", d.DemosH.Extend)
|
||||||
admin.POST("/demos/details", d.DemosH.ListDetails)
|
admin.POST("/demos/details", d.DemosH.ListDetails)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Demos } from './pages/backoffice/Demos'
|
|||||||
import { Messages } from './pages/backoffice/Contact'
|
import { Messages } from './pages/backoffice/Contact'
|
||||||
import { Codes } from './pages/backoffice/Codes'
|
import { Codes } from './pages/backoffice/Codes'
|
||||||
import { Subscription } from './pages/backoffice/Subscription'
|
import { Subscription } from './pages/backoffice/Subscription'
|
||||||
|
import { Profile } from './pages/backoffice/Profile'
|
||||||
import { PublicLayout } from './components/PublicLayout'
|
import { PublicLayout } from './components/PublicLayout'
|
||||||
import { BackofficeLayout } from './components/BackofficeLayout'
|
import { BackofficeLayout } from './components/BackofficeLayout'
|
||||||
import { Contact } from './pages/Contact'
|
import { Contact } from './pages/Contact'
|
||||||
@@ -49,6 +50,7 @@ function BackofficeHome() {
|
|||||||
return <Navigate to="/app/demos" replace />
|
return <Navigate to="/app/demos" replace />
|
||||||
case 'client':
|
case 'client':
|
||||||
return <Navigate to="/app/subscription" replace />
|
return <Navigate to="/app/subscription" replace />
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <Navigate to="/app/leads" replace />
|
return <Navigate to="/app/leads" replace />
|
||||||
}
|
}
|
||||||
@@ -113,6 +115,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="subscription" element={<Subscription />} />
|
<Route path="subscription" element={<Subscription />} />
|
||||||
|
<Route path="profile" element={<Profile />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
+7
-1
@@ -100,6 +100,10 @@ export interface DemoDetails {
|
|||||||
state: DemoState
|
state: DemoState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateUsername {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Endpoints ---
|
// --- Endpoints ---
|
||||||
|
|
||||||
@@ -148,5 +152,7 @@ export const api = {
|
|||||||
request<{ success: string }>('POST', '/send/message', { username, telegram, sujet, message }),
|
request<{ success: string }>('POST', '/send/message', { username, telegram, sujet, message }),
|
||||||
getMessage: () => request<{ messages: Contact[] }>('GET', '/messages'),
|
getMessage: () => request<{ messages: Contact[] }>('GET', '/messages'),
|
||||||
getDemoDetails: (username: string) =>
|
getDemoDetails: (username: string) =>
|
||||||
request<DemoDetails>('POST', '/demos/details', { username }),
|
request<DemoDetails>('POST', '/demos/details', { username }),
|
||||||
|
updateUsername: (username: string) =>
|
||||||
|
request<UpdateUsername>('POST', '/profile/username', { username }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user