This commit is contained in:
lfirmin
2026-05-10 18:07:51 +02:00
parent 50af2452b1
commit a5f888b3c1
59 changed files with 6682 additions and 108 deletions
+157
View File
@@ -0,0 +1,157 @@
package main
import (
"context"
"database/sql"
"fmt"
"os"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// handleAdminCLI processes "ticketbot admin <subcommand>" and exits.
// Returns true if an admin subcommand was handled.
func handleAdminCLI(sqldb *sql.DB) bool {
if len(os.Args) < 2 || os.Args[1] != "admin" {
return false
}
repo := db.NewPanelAdminRepo(sqldb)
ctx := context.Background()
if len(os.Args) < 3 {
printAdminUsage()
os.Exit(1)
}
switch os.Args[2] {
case "add":
cliAddAdmin(ctx, repo)
case "remove":
cliRemoveAdmin(ctx, repo)
case "list":
cliListAdmins(ctx, repo)
case "reset-totp":
cliResetTOTP(ctx, repo)
case "reset-password":
cliResetPassword(ctx, repo)
default:
fmt.Fprintf(os.Stderr, "Unknown admin subcommand: %s\n", os.Args[2])
printAdminUsage()
os.Exit(1)
}
return true
}
func cliAddAdmin(ctx context.Context, repo *db.PanelAdminRepo) {
args := os.Args[3:]
discordID := flagValue(args, "--discord-id")
password := flagValue(args, "--password")
superadmin := containsFlag(args, "--superadmin")
if discordID == "" {
fmt.Fprintln(os.Stderr, "Usage: admin add --discord-id <id> [--password <pass>] [--superadmin]")
os.Exit(1)
}
var hash string
if password != "" {
var err error
hash, err = panelauth.HashPassword(password)
must(err, "hash password")
}
a, err := repo.Create(ctx, discordID, "", hash, superadmin)
must(err, "create admin")
fmt.Printf("Admin added: discord_id=%s id=%d superadmin=%v\n", a.DiscordID, a.ID, a.IsSuperadmin)
}
func cliRemoveAdmin(ctx context.Context, repo *db.PanelAdminRepo) {
discordID := flagValue(os.Args[3:], "--discord-id")
if discordID == "" {
fmt.Fprintln(os.Stderr, "Usage: admin remove --discord-id <id>")
os.Exit(1)
}
must(repo.Delete(ctx, discordID), "remove admin")
fmt.Printf("Admin removed: %s\n", discordID)
}
func cliListAdmins(ctx context.Context, repo *db.PanelAdminRepo) {
admins, err := repo.List(ctx)
must(err, "list admins")
if len(admins) == 0 {
fmt.Println("No admins configured.")
return
}
fmt.Printf("%-20s %-30s %-12s %-12s\n", "Discord ID", "Username", "Superadmin", "TOTP")
for _, a := range admins {
fmt.Printf("%-20s %-30s %-12v %-12v\n", a.DiscordID, a.DiscordUsername, a.IsSuperadmin, a.TOTPEnabled)
}
}
func cliResetTOTP(ctx context.Context, repo *db.PanelAdminRepo) {
discordID := flagValue(os.Args[3:], "--discord-id")
if discordID == "" {
fmt.Fprintln(os.Stderr, "Usage: admin reset-totp --discord-id <id>")
os.Exit(1)
}
a, err := repo.GetByDiscordID(ctx, discordID)
must(err, "get admin")
if a == nil {
fmt.Fprintf(os.Stderr, "Admin not found: %s\n", discordID)
os.Exit(1)
}
must(repo.ResetTOTP(ctx, a.ID), "reset totp")
fmt.Printf("TOTP reset for %s — admin must re-setup TOTP on next login.\n", discordID)
}
func cliResetPassword(ctx context.Context, repo *db.PanelAdminRepo) {
args := os.Args[3:]
discordID := flagValue(args, "--discord-id")
password := flagValue(args, "--password")
if discordID == "" || password == "" {
fmt.Fprintln(os.Stderr, "Usage: admin reset-password --discord-id <id> --password <pass>")
os.Exit(1)
}
a, err := repo.GetByDiscordID(ctx, discordID)
must(err, "get admin")
if a == nil {
fmt.Fprintf(os.Stderr, "Admin not found: %s\n", discordID)
os.Exit(1)
}
hash, err := panelauth.HashPassword(password)
must(err, "hash password")
must(repo.UpdatePassword(ctx, a.ID, hash), "update password")
fmt.Printf("Password updated for %s\n", discordID)
}
func flagValue(args []string, flag string) string {
for i := 0; i < len(args)-1; i++ {
if args[i] == flag {
return args[i+1]
}
}
return ""
}
func containsFlag(args []string, flag string) bool {
for _, a := range args {
if a == flag {
return true
}
}
return false
}
func printAdminUsage() {
fmt.Fprintln(os.Stderr, `Usage: ticketbot admin <subcommand> [flags]
Subcommands:
add --discord-id <id> --password <pass> [--superadmin]
remove --discord-id <id>
list
reset-totp --discord-id <id>
reset-password --discord-id <id> --password <pass>`)
}
func newAdminRepo(sqldb *sql.DB) *db.PanelAdminRepo {
return db.NewPanelAdminRepo(sqldb)
}
+67 -2
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"log/slog" "log/slog"
"os" "os"
"os/signal" "os/signal"
@@ -15,6 +16,7 @@ import (
"github.com/leolionad58/ticketbot/internal/discord/components" "github.com/leolionad58/ticketbot/internal/discord/components"
"github.com/leolionad58/ticketbot/internal/discord/events" "github.com/leolionad58/ticketbot/internal/discord/events"
"github.com/leolionad58/ticketbot/internal/logger" "github.com/leolionad58/ticketbot/internal/logger"
"github.com/leolionad58/ticketbot/internal/panel"
"github.com/leolionad58/ticketbot/internal/tickets" "github.com/leolionad58/ticketbot/internal/tickets"
"github.com/leolionad58/ticketbot/internal/transcript" "github.com/leolionad58/ticketbot/internal/transcript"
) )
@@ -34,8 +36,23 @@ func main() {
must(err, "open db") must(err, "open db")
defer sqldb.Close() defer sqldb.Close()
// CLI admin subcommand — handle and exit before starting the bot
if handleAdminCLI(sqldb) {
return
}
// Migrate YAML panels to DB on first boot
panelRepo := db.NewPanelConfigRepo(sqldb)
if err := panel.MigrateYAMLPanels(context.Background(), panelRepo, cfg, os.Getenv("GUILD_ID")); err != nil {
slog.Warn("YAML panel migration failed", "err", err)
}
ticketRepo := db.NewTicketRepo(sqldb) ticketRepo := db.NewTicketRepo(sqldb)
claimRepo := db.NewClaimMessageRepo(sqldb) claimRepo := db.NewClaimMessageRepo(sqldb)
convocRepo := db.NewConvocationConfigRepo(sqldb)
adminRepo := db.NewPanelAdminRepo(sqldb)
sessionRepo := db.NewPanelSessionRepo(sqldb)
auditRepo := db.NewAuditLogRepo(sqldb)
token := os.Getenv("DISCORD_TOKEN") token := os.Getenv("DISCORD_TOKEN")
if token == "" { if token == "" {
@@ -45,12 +62,27 @@ func main() {
appID := os.Getenv("DISCORD_APP_ID") appID := os.Getenv("DISCORD_APP_ID")
guildID := os.Getenv("GUILD_ID") guildID := os.Getenv("GUILD_ID")
bot, err := discordbot.New(token, cfgProvider, ticketRepo, claimRepo, guildID) // Build guild allowlist: config yaml takes priority, env GUILD_ID is always included
allowedGuilds := append([]string{}, cfg.Bot.AllowedGuilds...)
if guildID != "" {
found := false
for _, id := range allowedGuilds {
if id == guildID {
found = true
break
}
}
if !found {
allowedGuilds = append(allowedGuilds, guildID)
}
}
bot, err := discordbot.New(token, cfgProvider, ticketRepo, claimRepo, guildID, allowedGuilds)
must(err, "create bot") must(err, "create bot")
// Services // Services
auth := tickets.NewAuthService(cfgProvider) auth := tickets.NewAuthService(cfgProvider)
ticketSvc := tickets.NewService(ticketRepo, auth, bot.Session, cfgProvider) ticketSvc := tickets.NewService(ticketRepo, panelRepo, auth, bot.Session, cfgProvider)
claimMgr := claim.NewManager(cfgProvider, ticketRepo, claimRepo) claimMgr := claim.NewManager(cfgProvider, ticketRepo, claimRepo)
logSvc := logger.NewDiscordLogger(bot.Session, cfgProvider) logSvc := logger.NewDiscordLogger(bot.Session, cfgProvider)
transcriptGen := transcript.NewGenerator("transcripts", bot.Session) transcriptGen := transcript.NewGenerator("transcripts", bot.Session)
@@ -76,6 +108,7 @@ func main() {
panelComp := &components.PanelComponent{ panelComp := &components.PanelComponent{
TicketSvc: ticketSvc, TicketSvc: ticketSvc,
TicketRepo: ticketRepo, TicketRepo: ticketRepo,
PanelRepo: panelRepo,
ClaimMgr: claimMgr, ClaimMgr: claimMgr,
LogSvc: logSvc, LogSvc: logSvc,
Config: cfgProvider, Config: cfgProvider,
@@ -88,6 +121,12 @@ func main() {
Transcript: transcriptGen, Transcript: transcriptGen,
Auth: auth, Auth: auth,
} }
convocComp := &components.ConvocationComponent{
ConvocRepo: convocRepo,
TicketRepo: ticketRepo,
Auth: auth,
LogSvc: logSvc,
}
// Router // Router
router := &discordbot.Router{ router := &discordbot.Router{
@@ -96,6 +135,8 @@ func main() {
ConvocCmd: convocCmd, ConvocCmd: convocCmd,
PanelComp: panelComp, PanelComp: panelComp,
TicketComp: ticketComp, TicketComp: ticketComp,
ConvocComp: convocComp,
IsGuildAllowed: bot.IsGuildAllowed,
} }
bot.Session.AddHandler(router.Handle) bot.Session.AddHandler(router.Handle)
@@ -123,6 +164,30 @@ func main() {
slog.Warn("config watcher failed to start", "err", err) slog.Warn("config watcher failed to start", "err", err)
} }
// Start panel HTTP server if configured
if cfg.Panel.BaseURL != "" || cfg.Panel.Port > 0 {
botSvc := discordbot.NewBotService(bot.Session, panelRepo, ticketRepo, convocRepo, guildID, allowedGuilds)
panelSrv := panel.NewServer(
cfg.Panel,
ticketRepo,
adminRepo,
sessionRepo,
auditRepo,
panelRepo,
convocRepo,
guildID,
botSvc,
)
ctx, cancelPanel := context.WithCancel(context.Background())
go func() {
slog.Info("panel server starting", "port", cfg.Panel.Port)
if err := panelSrv.Start(ctx); err != nil {
slog.Error("panel server stopped", "err", err)
}
}()
defer cancelPanel()
}
slog.Info("ticketbot ready, Ctrl+C to stop") slog.Info("ticketbot ready, Ctrl+C to stop")
stop := make(chan os.Signal, 1) stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
+8 -16
View File
@@ -3,20 +3,12 @@ bot:
logs_channel: "1499924634655658004" # channel de logs (open/claim/close) logs_channel: "1499924634655658004" # channel de logs (open/claim/close)
claim_channel: "1499924671884431380" # channel où défilent les tickets à claim claim_channel: "1499924671884431380" # channel où défilent les tickets à claim
convocation_category: "1499924426358263950" # catégorie pour /convocation convocation_category: "1499924426358263950" # catégorie pour /convocation
claim_reup_minutes: 1 # délai avant re-up et ping staff claim_reup_minutes: 1
allowed_guilds: ["1346947332536795180", "1498425866437529761"] # délai avant re-up et ping staff
panels: panel:
support_panel: port: 8080
embed_title: "Ouvrir un ticket" base_url: "http://localhost:8080" # ton domaine en prod (ex: https://panel.tonsite.com)
embed_description: "Choisis le type de ticket ci-dessous." oauth_client_id: "1415152714559782922" # Discord app → OAuth2 → Client ID
embed_color: "#2b2d31" oauth_client_secret: "H5HvZ1AYvhT3VirKFLQmGvgRYuxzvy4m" # Discord app → OAuth2 → Client Secret
types: session_timeout_minutes: 30
support:
button_label: "Support"
button_color: "primary" # primary / secondary / success / danger
button_emoji: "🛠️" # optionnel
embed_color: "#5865f2"
embed_title: "Ticket support"
embed_text: "Décris ton problème, un staff va te répondre."
staff_role: "1499923512683659314"
category: "1499924426358263950"
+5
View File
@@ -3,16 +3,21 @@ module github.com/leolionad58/ticketbot
go 1.26.2 go 1.26.2
require ( require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bwmarrin/discordgo v0.29.0 // indirect github.com/bwmarrin/discordgo v0.29.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.10.0 // indirect github.com/fsnotify/fsnotify v1.10.0 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect github.com/gorilla/websocket v1.4.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect github.com/joho/godotenv v1.5.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.72.0 // indirect modernc.org/libc v1.72.0 // indirect
+14
View File
@@ -1,9 +1,14 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M=
github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
@@ -14,11 +19,20 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+26 -6
View File
@@ -63,8 +63,12 @@ func (m *Manager) DeleteClaimMessage(ctx context.Context, s *discordgo.Session,
if err != nil || cm == nil { if err != nil || cm == nil {
return return
} }
cfg := m.cfg.Get() // Resolve claim channel: look up ticket for per-ticket channel, fall back to config.
if err := s.ChannelMessageDelete(cfg.Bot.ClaimChannel, cm.MessageID); err != nil { claimChannelID := m.cfg.Get().Bot.ClaimChannel
if t, err := m.repo.GetByID(ctx, ticketID); err == nil && t != nil && t.ClaimChannelID != "" {
claimChannelID = t.ClaimChannelID
}
if err := s.ChannelMessageDelete(claimChannelID, cm.MessageID); err != nil {
slog.Warn("claim: delete message", "ticket_id", ticketID, "err", err) slog.Warn("claim: delete message", "ticket_id", ticketID, "err", err)
} }
m.claims.Delete(ctx, ticketID) //nolint m.claims.Delete(ctx, ticketID) //nolint
@@ -80,6 +84,9 @@ func (m *Manager) MarkClaimed(ctx context.Context, s *discordgo.Session, ticket
} }
cfg := m.cfg.Get() cfg := m.cfg.Get()
claimChannelID := cfg.Bot.ClaimChannel claimChannelID := cfg.Bot.ClaimChannel
if ticket.ClaimChannelID != "" {
claimChannelID = ticket.ClaimChannelID
}
if claimChannelID == "" { if claimChannelID == "" {
m.claims.Delete(ctx, ticket.ID) //nolint m.claims.Delete(ctx, ticket.ID) //nolint
return return
@@ -131,7 +138,11 @@ func (m *Manager) startReupLoop(s *discordgo.Session, ticket *db.Ticket, lastReu
}() }()
cfg := m.cfg.Get() cfg := m.cfg.Get()
interval := time.Duration(cfg.Bot.ClaimReupMinutes) * time.Minute reupMins := cfg.Bot.ClaimReupMinutes
if ticket.ClaimReupMinutes > 0 {
reupMins = ticket.ClaimReupMinutes
}
interval := time.Duration(reupMins) * time.Minute
elapsed := time.Since(lastReupAt) elapsed := time.Since(lastReupAt)
next := interval - elapsed next := interval - elapsed
if next <= 0 { if next <= 0 {
@@ -175,7 +186,11 @@ func (m *Manager) startReupLoop(s *discordgo.Session, ticket *db.Ticket, lastReu
// Re-read config in case of hot reload // Re-read config in case of hot reload
cfg = m.cfg.Get() cfg = m.cfg.Get()
interval = time.Duration(cfg.Bot.ClaimReupMinutes) * time.Minute reupMins = cfg.Bot.ClaimReupMinutes
if ticket.ClaimReupMinutes > 0 {
reupMins = ticket.ClaimReupMinutes
}
interval = time.Duration(reupMins) * time.Minute
timer.Reset(interval) timer.Reset(interval)
} }
} }
@@ -185,21 +200,26 @@ func (m *Manager) startReupLoop(s *discordgo.Session, ticket *db.Ticket, lastReu
func (m *Manager) postClaim(ctx context.Context, s *discordgo.Session, ticket *db.Ticket, withPing bool) (string, error) { func (m *Manager) postClaim(ctx context.Context, s *discordgo.Session, ticket *db.Ticket, withPing bool) (string, error) {
cfg := m.cfg.Get() cfg := m.cfg.Get()
claimChannelID := cfg.Bot.ClaimChannel claimChannelID := cfg.Bot.ClaimChannel
if ticket.ClaimChannelID != "" {
claimChannelID = ticket.ClaimChannelID
}
if claimChannelID == "" { if claimChannelID == "" {
return "", fmt.Errorf("claim_channel not configured") return "", fmt.Errorf("claim_channel not configured")
} }
// Determine embed color from type config, fallback to blurple // Determine embed color and staff role — prefer ticket fields (set at open time for DB panels)
embedColor := 0x5865f2 embedColor := 0x5865f2
var staffRoleID string staffRoleID := ticket.StaffRoleID
if panel, ok := cfg.Panels[ticket.Panel]; ok { if panel, ok := cfg.Panels[ticket.Panel]; ok {
if t, ok := panel.Types[ticket.Type]; ok { if t, ok := panel.Types[ticket.Type]; ok {
if c := parseHexColor(t.EmbedColor); c != 0 { if c := parseHexColor(t.EmbedColor); c != 0 {
embedColor = c embedColor = c
} }
if staffRoleID == "" {
staffRoleID = t.StaffRole staffRoleID = t.StaffRole
} }
} }
}
fields := []*discordgo.MessageEmbedField{ fields := []*discordgo.MessageEmbedField{
{Name: "Auteur", Value: fmt.Sprintf("<@%s>", ticket.UserID), Inline: true}, {Name: "Auteur", Value: fmt.Sprintf("<@%s>", ticket.UserID), Inline: true},
+16
View File
@@ -41,10 +41,20 @@ type Bot struct {
ClaimChannel string `yaml:"claim_channel"` ClaimChannel string `yaml:"claim_channel"`
ConvocationCategory string `yaml:"convocation_category"` ConvocationCategory string `yaml:"convocation_category"`
ClaimReupMinutes int `yaml:"claim_reup_minutes"` ClaimReupMinutes int `yaml:"claim_reup_minutes"`
AllowedGuilds []string `yaml:"allowed_guilds"` // whitelist; empty = use GUILD_ID env only
}
type PanelWebConfig struct {
Port int `yaml:"port"`
BaseURL string `yaml:"base_url"`
OAuthClientID string `yaml:"oauth_client_id"`
OAuthClientSecret string `yaml:"oauth_client_secret"`
SessionTimeoutMinutes int `yaml:"session_timeout_minutes"`
} }
type Config struct { type Config struct {
Bot Bot `yaml:"bot"` Bot Bot `yaml:"bot"`
Panel PanelWebConfig `yaml:"panel"`
Panels map[string]Panel `yaml:"panels"` Panels map[string]Panel `yaml:"panels"`
} }
@@ -55,6 +65,12 @@ func (c *Config) Validate() error {
if c.Bot.ClaimReupMinutes <= 0 { if c.Bot.ClaimReupMinutes <= 0 {
c.Bot.ClaimReupMinutes = 30 c.Bot.ClaimReupMinutes = 30
} }
if c.Panel.Port <= 0 {
c.Panel.Port = 8080
}
if c.Panel.SessionTimeoutMinutes <= 0 {
c.Panel.SessionTimeoutMinutes = 30
}
return nil return nil
} }
+114
View File
@@ -0,0 +1,114 @@
package db
import (
"context"
"database/sql"
"time"
)
type AuditLogEntry struct {
ID int64
AdminID int64
Action string
EntityType string
EntityID sql.NullInt64
OldValue sql.NullString
NewValue sql.NullString
IPAddress string
CreatedAt time.Time
}
// Audit action constants
const (
AuditCreatePanel = "create_panel"
AuditUpdatePanel = "update_panel"
AuditDeletePanel = "delete_panel"
AuditCreateType = "create_type"
AuditUpdateType = "update_type"
AuditDeleteType = "delete_type"
AuditReorderTypes = "reorder_types"
AuditUpdateConvoc = "update_convocation"
AuditSendPanelDiscord = "send_panel_discord"
AuditDeletePanelMsg = "delete_panel_discord"
AuditRevokeSession = "revoke_session"
AuditRevokeAllSess = "revoke_all_sessions"
AuditAdminLogin = "admin_login"
AuditAdminLoginFail = "admin_login_failed"
AuditAdminLocked = "admin_locked"
)
type AuditFilter struct {
AdminID int64
Action string
EntityType string
From, To time.Time
}
type AuditLogRepo struct{ db *sql.DB }
func NewAuditLogRepo(db *sql.DB) *AuditLogRepo { return &AuditLogRepo{db: db} }
func (r *AuditLogRepo) Insert(ctx context.Context, e *AuditLogEntry) error {
e.CreatedAt = time.Now().UTC()
_, err := r.db.ExecContext(ctx,
`INSERT INTO audit_log(admin_id,action,entity_type,entity_id,old_value,new_value,ip_address,created_at)
VALUES(?,?,?,?,?,?,?,?)`,
e.AdminID, e.Action, e.EntityType, e.EntityID, e.OldValue, e.NewValue, e.IPAddress, e.CreatedAt)
return err
}
func (r *AuditLogRepo) List(ctx context.Context, f AuditFilter, page, pageSize int) ([]*AuditLogEntry, int, error) {
where := "WHERE 1=1"
args := []any{}
if f.AdminID != 0 {
where += " AND admin_id=?"
args = append(args, f.AdminID)
}
if f.Action != "" {
where += " AND action=?"
args = append(args, f.Action)
}
if f.EntityType != "" {
where += " AND entity_type=?"
args = append(args, f.EntityType)
}
if !f.From.IsZero() {
where += " AND created_at>=?"
args = append(args, f.From)
}
if !f.To.IsZero() {
where += " AND created_at<=?"
args = append(args, f.To)
}
var total int
countArgs := make([]any, len(args))
copy(countArgs, args)
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log `+where, countArgs...).Scan(&total); err != nil {
return nil, 0, err
}
if page < 1 {
page = 1
}
offset := (page - 1) * pageSize
args = append(args, pageSize, offset)
rows, err := r.db.QueryContext(ctx,
`SELECT id,admin_id,action,entity_type,entity_id,old_value,new_value,ip_address,created_at
FROM audit_log `+where+` ORDER BY created_at DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []*AuditLogEntry
for rows.Next() {
var e AuditLogEntry
if err := rows.Scan(&e.ID, &e.AdminID, &e.Action, &e.EntityType, &e.EntityID,
&e.OldValue, &e.NewValue, &e.IPAddress, &e.CreatedAt); err != nil {
return nil, 0, err
}
out = append(out, &e)
}
return out, total, rows.Err()
}
+88
View File
@@ -0,0 +1,88 @@
package db
import (
"context"
"database/sql"
"time"
)
// ConvocationConfig holds per-guild convocation configuration.
type ConvocationConfig struct {
ID int64
GuildID string
CategoryID string
LogChannelID string
ModalEnabled bool
UpdatedAt time.Time
PanelChannelID string
PanelMessageID string
PanelEmbedTitle string
PanelEmbedDescription string
PanelEmbedColor string
}
// ConvocationConfigRepo handles CRUD for convocation_config.
type ConvocationConfigRepo struct{ db *sql.DB }
// NewConvocationConfigRepo creates a ConvocationConfigRepo.
func NewConvocationConfigRepo(db *sql.DB) *ConvocationConfigRepo {
return &ConvocationConfigRepo{db: db}
}
// Get returns the convocation config for the given guild.
// Returns an empty (zero-value) config without error if none exists yet.
func (r *ConvocationConfigRepo) Get(ctx context.Context, guildID string) (*ConvocationConfig, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id,guild_id,category_id,log_channel_id,modal_enabled,updated_at,
panel_channel_id,panel_message_id,panel_embed_title,panel_embed_description,panel_embed_color
FROM convocation_config WHERE guild_id=?`, guildID)
var c ConvocationConfig
var modalEnabled int
err := row.Scan(&c.ID, &c.GuildID, &c.CategoryID, &c.LogChannelID, &modalEnabled, &c.UpdatedAt,
&c.PanelChannelID, &c.PanelMessageID, &c.PanelEmbedTitle, &c.PanelEmbedDescription, &c.PanelEmbedColor)
if err == sql.ErrNoRows {
c.GuildID = guildID
c.ModalEnabled = true
c.PanelEmbedTitle = "Créer une convocation"
c.PanelEmbedColor = "#5865f2"
return &c, nil
}
if err != nil {
return nil, err
}
c.ModalEnabled = modalEnabled != 0
return &c, nil
}
// Upsert inserts or replaces the convocation config for a guild.
func (r *ConvocationConfigRepo) Upsert(ctx context.Context, c *ConvocationConfig) error {
c.UpdatedAt = time.Now().UTC()
modalInt := 0
if c.ModalEnabled {
modalInt = 1
}
_, err := r.db.ExecContext(ctx,
`INSERT INTO convocation_config(guild_id,category_id,log_channel_id,modal_enabled,updated_at,
panel_channel_id,panel_message_id,panel_embed_title,panel_embed_description,panel_embed_color)
VALUES(?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(guild_id) DO UPDATE SET
category_id=excluded.category_id,
log_channel_id=excluded.log_channel_id,
modal_enabled=excluded.modal_enabled,
updated_at=excluded.updated_at,
panel_channel_id=excluded.panel_channel_id,
panel_embed_title=excluded.panel_embed_title,
panel_embed_description=excluded.panel_embed_description,
panel_embed_color=excluded.panel_embed_color`,
c.GuildID, c.CategoryID, c.LogChannelID, modalInt, c.UpdatedAt,
c.PanelChannelID, c.PanelMessageID, c.PanelEmbedTitle, c.PanelEmbedDescription, c.PanelEmbedColor)
return err
}
// SetConvocPanelMessage stores the Discord message ID for the convocation panel.
func (r *ConvocationConfigRepo) SetConvocPanelMessage(ctx context.Context, guildID, channelID, messageID string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE convocation_config SET panel_channel_id=?, panel_message_id=?, updated_at=? WHERE guild_id=?`,
channelID, messageID, time.Now().UTC(), guildID)
return err
}
+103
View File
@@ -0,0 +1,103 @@
package db
import (
"context"
"database/sql"
"time"
)
// ConvocationPanel is a single convocation panel embed+button entry.
type ConvocationPanel struct {
ID int64
GuildID string
EmbedTitle string
EmbedDesc string
EmbedColor string
ChannelID string
MessageID string
CreatedAt time.Time
UpdatedAt time.Time
}
type ConvocationPanelRepo struct{ db *sql.DB }
func NewConvocationPanelRepo(db *sql.DB) *ConvocationPanelRepo {
return &ConvocationPanelRepo{db: db}
}
func (r *ConvocationPanelRepo) List(ctx context.Context, guildID string) ([]*ConvocationPanel, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id,guild_id,embed_title,embed_description,embed_color,channel_id,message_id,created_at,updated_at
FROM convocation_panels WHERE guild_id=? ORDER BY id`, guildID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*ConvocationPanel
for rows.Next() {
p, err := scanConvocPanel(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (r *ConvocationPanelRepo) GetByID(ctx context.Context, id int64) (*ConvocationPanel, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id,guild_id,embed_title,embed_description,embed_color,channel_id,message_id,created_at,updated_at
FROM convocation_panels WHERE id=?`, id)
var p ConvocationPanel
err := row.Scan(&p.ID, &p.GuildID, &p.EmbedTitle, &p.EmbedDesc, &p.EmbedColor,
&p.ChannelID, &p.MessageID, &p.CreatedAt, &p.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &p, err
}
func (r *ConvocationPanelRepo) Create(ctx context.Context, p *ConvocationPanel) error {
now := time.Now().UTC()
res, err := r.db.ExecContext(ctx,
`INSERT INTO convocation_panels(guild_id,embed_title,embed_description,embed_color,channel_id,message_id,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`,
p.GuildID, p.EmbedTitle, p.EmbedDesc, p.EmbedColor, p.ChannelID, p.MessageID, now, now)
if err != nil {
return err
}
p.ID, _ = res.LastInsertId()
p.CreatedAt = now
p.UpdatedAt = now
return nil
}
func (r *ConvocationPanelRepo) Update(ctx context.Context, p *ConvocationPanel) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx,
`UPDATE convocation_panels SET embed_title=?,embed_description=?,embed_color=?,channel_id=?,updated_at=? WHERE id=?`,
p.EmbedTitle, p.EmbedDesc, p.EmbedColor, p.ChannelID, now, p.ID)
if err == nil {
p.UpdatedAt = now
}
return err
}
func (r *ConvocationPanelRepo) SetMessage(ctx context.Context, id int64, messageID string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE convocation_panels SET message_id=?,updated_at=? WHERE id=?`,
messageID, time.Now().UTC(), id)
return err
}
func (r *ConvocationPanelRepo) Delete(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM convocation_panels WHERE id=?`, id)
return err
}
func scanConvocPanel(rows *sql.Rows) (*ConvocationPanel, error) {
var p ConvocationPanel
err := rows.Scan(&p.ID, &p.GuildID, &p.EmbedTitle, &p.EmbedDesc, &p.EmbedColor,
&p.ChannelID, &p.MessageID, &p.CreatedAt, &p.UpdatedAt)
return &p, err
}
+122
View File
@@ -63,8 +63,130 @@ func migrate(db *sql.DB) error {
// v2: user-supplied ticket title and description (from modal) // v2: user-supplied ticket title and description (from modal)
`ALTER TABLE tickets ADD COLUMN ticket_title TEXT; `ALTER TABLE tickets ADD COLUMN ticket_title TEXT;
ALTER TABLE tickets ADD COLUMN ticket_description TEXT;`, ALTER TABLE tickets ADD COLUMN ticket_description TEXT;`,
// v3: panel web admin tables
`CREATE TABLE IF NOT EXISTS panel_admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
discord_id TEXT NOT NULL UNIQUE,
discord_username TEXT NOT NULL DEFAULT '',
discord_avatar TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
is_superadmin INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS panel_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
csrf_token TEXT NOT NULL,
admin_id INTEGER NOT NULL REFERENCES panel_admins(id) ON DELETE CASCADE,
ip_address TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
last_activity DATETIME NOT NULL,
created_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON panel_sessions(token);
CREATE TABLE IF NOT EXISTS panel_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
embed_title TEXT NOT NULL DEFAULT '',
embed_description TEXT,
embed_color TEXT NOT NULL DEFAULT '#5865f2',
guild_id TEXT NOT NULL DEFAULT '',
channel_id TEXT,
message_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS panel_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
panel_id INTEGER NOT NULL REFERENCES panel_configs(id) ON DELETE CASCADE,
name TEXT NOT NULL,
button_label TEXT NOT NULL DEFAULT '',
button_color TEXT NOT NULL DEFAULT 'primary',
button_emoji TEXT NOT NULL DEFAULT '',
embed_color TEXT NOT NULL DEFAULT '#5865f2',
embed_title TEXT NOT NULL DEFAULT '',
embed_text TEXT NOT NULL DEFAULT '',
staff_role_id TEXT NOT NULL DEFAULT '',
category_id TEXT NOT NULL DEFAULT '',
claim_channel_id TEXT NOT NULL DEFAULT '',
log_channel_id TEXT NOT NULL DEFAULT '',
max_per_user INTEGER NOT NULL DEFAULT 1,
claim_mode INTEGER NOT NULL DEFAULT 1,
close_rule TEXT NOT NULL DEFAULT 'staff_only',
modal_enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE(panel_id, name)
);
CREATE TABLE IF NOT EXISTS convocation_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL UNIQUE,
category_id TEXT NOT NULL DEFAULT '',
log_channel_id TEXT NOT NULL DEFAULT '',
modal_enabled INTEGER NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL REFERENCES panel_admins(id),
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
old_value TEXT,
new_value TEXT,
ip_address TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_admin ON audit_log(admin_id);
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
CREATE TABLE IF NOT EXISTS user_blacklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
discord_id TEXT NOT NULL UNIQUE,
reason TEXT,
blacklisted_by TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL
);`,
} }
migrations = append(migrations,
// v4: embed image fields on panel_types
`ALTER TABLE panel_types ADD COLUMN thumbnail_url TEXT NOT NULL DEFAULT '';
ALTER TABLE panel_types ADD COLUMN image_url TEXT NOT NULL DEFAULT '';`,
// v5: embed image/thumbnail on panel_configs (panel-level)
`ALTER TABLE panel_configs ADD COLUMN embed_image TEXT NOT NULL DEFAULT '';
ALTER TABLE panel_configs ADD COLUMN embed_thumbnail TEXT NOT NULL DEFAULT '';`,
// v6: per-ticket guild + channel routing + convoc panel config
`ALTER TABLE tickets ADD COLUMN guild_id TEXT NOT NULL DEFAULT '';
ALTER TABLE tickets ADD COLUMN claim_channel_id TEXT NOT NULL DEFAULT '';
ALTER TABLE tickets ADD COLUMN log_channel_id TEXT NOT NULL DEFAULT '';
ALTER TABLE convocation_config ADD COLUMN panel_channel_id TEXT NOT NULL DEFAULT '';
ALTER TABLE convocation_config ADD COLUMN panel_message_id TEXT NOT NULL DEFAULT '';
ALTER TABLE convocation_config ADD COLUMN panel_embed_title TEXT NOT NULL DEFAULT 'Créer une convocation';
ALTER TABLE convocation_config ADD COLUMN panel_embed_description TEXT NOT NULL DEFAULT '';
ALTER TABLE convocation_config ADD COLUMN panel_embed_color TEXT NOT NULL DEFAULT '#5865f2';`,
// v7: per-ticket staff role + reup time, per-type reup time, convocation panels table
`ALTER TABLE tickets ADD COLUMN staff_role_id TEXT NOT NULL DEFAULT '';
ALTER TABLE tickets ADD COLUMN claim_reup_minutes INTEGER NOT NULL DEFAULT 0;
ALTER TABLE panel_types ADD COLUMN claim_reup_minutes INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS convocation_panels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL,
embed_title TEXT NOT NULL DEFAULT 'Créer une convocation',
embed_description TEXT NOT NULL DEFAULT '',
embed_color TEXT NOT NULL DEFAULT '#5865f2',
channel_id TEXT NOT NULL DEFAULT '',
message_id TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_convoc_panels_guild ON convocation_panels(guild_id);`,
)
for i, m := range migrations { for i, m := range migrations {
v := i + 1 v := i + 1
if v <= version { if v <= version {
+131
View File
@@ -0,0 +1,131 @@
package db
import (
"context"
"database/sql"
"time"
)
type PanelAdmin struct {
ID int64
DiscordID string
DiscordUsername string
DiscordAvatar string
PasswordHash string
TOTPSecret sql.NullString
TOTPEnabled bool
IsSuperadmin bool
CreatedAt time.Time
UpdatedAt time.Time
}
type PanelAdminRepo struct{ db *sql.DB }
func NewPanelAdminRepo(db *sql.DB) *PanelAdminRepo { return &PanelAdminRepo{db: db} }
const adminCols = `id,discord_id,discord_username,discord_avatar,password_hash,totp_secret,totp_enabled,is_superadmin,created_at,updated_at`
func (r *PanelAdminRepo) Create(ctx context.Context, discordID, username, passwordHash string, isSuperadmin bool) (*PanelAdmin, error) {
now := time.Now().UTC()
res, err := r.db.ExecContext(ctx,
`INSERT INTO panel_admins(discord_id,discord_username,password_hash,is_superadmin,created_at,updated_at)
VALUES(?,?,?,?,?,?)`,
discordID, username, passwordHash, boolInt(isSuperadmin), now, now)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return r.GetByID(ctx, id)
}
func (r *PanelAdminRepo) GetByID(ctx context.Context, id int64) (*PanelAdmin, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+adminCols+` FROM panel_admins WHERE id=?`, id)
return scanAdmin(row)
}
func (r *PanelAdminRepo) GetByDiscordID(ctx context.Context, discordID string) (*PanelAdmin, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+adminCols+` FROM panel_admins WHERE discord_id=?`, discordID)
return scanAdmin(row)
}
func (r *PanelAdminRepo) List(ctx context.Context) ([]*PanelAdmin, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+adminCols+` FROM panel_admins ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PanelAdmin
for rows.Next() {
a, err := scanAdminRows(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (r *PanelAdminRepo) UpdateDiscordProfile(ctx context.Context, id int64, username, avatar string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE panel_admins SET discord_username=?,discord_avatar=?,updated_at=? WHERE id=?`,
username, avatar, time.Now().UTC(), id)
return err
}
func (r *PanelAdminRepo) UpdatePassword(ctx context.Context, id int64, hash string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE panel_admins SET password_hash=?,updated_at=? WHERE id=?`,
hash, time.Now().UTC(), id)
return err
}
func (r *PanelAdminRepo) UpdateTOTP(ctx context.Context, id int64, secret string, enabled bool) error {
_, err := r.db.ExecContext(ctx,
`UPDATE panel_admins SET totp_secret=?,totp_enabled=?,updated_at=? WHERE id=?`,
secret, boolInt(enabled), time.Now().UTC(), id)
return err
}
func (r *PanelAdminRepo) ResetTOTP(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx,
`UPDATE panel_admins SET totp_secret=NULL,totp_enabled=0,updated_at=? WHERE id=?`,
time.Now().UTC(), id)
return err
}
func (r *PanelAdminRepo) Delete(ctx context.Context, discordID string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM panel_admins WHERE discord_id=?`, discordID)
return err
}
func scanAdmin(row *sql.Row) (*PanelAdmin, error) {
a, err := scanAdminFields(func(dest ...any) error { return row.Scan(dest...) })
if err == sql.ErrNoRows {
return nil, nil
}
return a, err
}
func scanAdminRows(rows *sql.Rows) (*PanelAdmin, error) {
return scanAdminFields(func(dest ...any) error { return rows.Scan(dest...) })
}
func scanAdminFields(scan func(...any) error) (*PanelAdmin, error) {
var a PanelAdmin
var totpEnabled, isSuperadmin int
err := scan(&a.ID, &a.DiscordID, &a.DiscordUsername, &a.DiscordAvatar,
&a.PasswordHash, &a.TOTPSecret, &totpEnabled, &isSuperadmin, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, err
}
a.TOTPEnabled = totpEnabled != 0
a.IsSuperadmin = isSuperadmin != 0
return &a, nil
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
+282
View File
@@ -0,0 +1,282 @@
package db
import (
"context"
"database/sql"
"time"
)
type PanelConfig struct {
ID int64
Name string
EmbedTitle string
EmbedDescription sql.NullString
EmbedColor string
EmbedImage string
EmbedThumbnail string
GuildID string
ChannelID sql.NullString
MessageID sql.NullString
SortOrder int
CreatedAt time.Time
UpdatedAt time.Time
Types []*PanelType
}
type PanelType struct {
ID int64
PanelID int64
Name string
ButtonLabel string
ButtonColor string
ButtonEmoji string
EmbedColor string
EmbedTitle string
EmbedText string
ThumbnailURL string
ImageURL string
StaffRoleID string
CategoryID string
ClaimChannelID string
LogChannelID string
MaxPerUser int
ClaimMode int
ClaimReupMinutes int
CloseRule string
ModalEnabled bool
SortOrder int
CreatedAt time.Time
UpdatedAt time.Time
}
type PanelConfigRepo struct{ db *sql.DB }
func NewPanelConfigRepo(db *sql.DB) *PanelConfigRepo { return &PanelConfigRepo{db: db} }
const panelCols = `id,name,embed_title,embed_description,embed_color,embed_image,embed_thumbnail,guild_id,channel_id,message_id,sort_order,created_at,updated_at`
const typeCols = `id,panel_id,name,button_label,button_color,button_emoji,embed_color,embed_title,embed_text,thumbnail_url,image_url,` +
`staff_role_id,category_id,claim_channel_id,log_channel_id,max_per_user,claim_mode,claim_reup_minutes,close_rule,modal_enabled,sort_order,created_at,updated_at`
func (r *PanelConfigRepo) List(ctx context.Context) ([]*PanelConfig, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+panelCols+` FROM panel_configs ORDER BY sort_order,name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PanelConfig
for rows.Next() {
p, err := scanPanelConfig(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Load types for each panel in one extra pass
for _, p := range out {
p.Types, _ = r.ListTypes(ctx, p.ID)
}
return out, nil
}
func (r *PanelConfigRepo) GetByID(ctx context.Context, id int64) (*PanelConfig, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+panelCols+` FROM panel_configs WHERE id=?`, id)
var p PanelConfig
err := row.Scan(&p.ID, &p.Name, &p.EmbedTitle, &p.EmbedDescription, &p.EmbedColor,
&p.EmbedImage, &p.EmbedThumbnail, &p.GuildID, &p.ChannelID, &p.MessageID, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
types, err := r.ListTypes(ctx, id)
if err != nil {
return nil, err
}
p.Types = types
return &p, nil
}
func (r *PanelConfigRepo) Create(ctx context.Context, p *PanelConfig) error {
now := time.Now().UTC()
res, err := r.db.ExecContext(ctx,
`INSERT INTO panel_configs(name,embed_title,embed_description,embed_color,embed_image,embed_thumbnail,guild_id,channel_id,sort_order,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
p.Name, p.EmbedTitle, p.EmbedDescription, p.EmbedColor, p.EmbedImage, p.EmbedThumbnail,
p.GuildID, p.ChannelID, p.SortOrder, now, now)
if err != nil {
return err
}
p.ID, _ = res.LastInsertId()
p.CreatedAt = now
p.UpdatedAt = now
return nil
}
func (r *PanelConfigRepo) Update(ctx context.Context, p *PanelConfig) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx,
`UPDATE panel_configs SET name=?,embed_title=?,embed_description=?,embed_color=?,embed_image=?,embed_thumbnail=?,
guild_id=?,channel_id=?,sort_order=?,updated_at=? WHERE id=?`,
p.Name, p.EmbedTitle, p.EmbedDescription, p.EmbedColor, p.EmbedImage, p.EmbedThumbnail,
p.GuildID, p.ChannelID, p.SortOrder, now, p.ID)
if err == nil {
p.UpdatedAt = now
}
return err
}
func (r *PanelConfigRepo) Delete(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM panel_configs WHERE id=?`, id)
return err
}
// ReplaceTypes deletes all types for a panel and inserts the provided list.
func (r *PanelConfigRepo) ReplaceTypes(ctx context.Context, panelID int64, types []*PanelType) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint
if _, err := tx.ExecContext(ctx, `DELETE FROM panel_types WHERE panel_id=?`, panelID); err != nil {
return err
}
now := time.Now().UTC()
for i, t := range types {
t.PanelID = panelID
t.SortOrder = i
res, err := tx.ExecContext(ctx,
`INSERT INTO panel_types(panel_id,name,button_label,button_color,button_emoji,embed_color,embed_title,embed_text,thumbnail_url,image_url,
staff_role_id,category_id,claim_channel_id,log_channel_id,max_per_user,claim_mode,claim_reup_minutes,close_rule,modal_enabled,sort_order,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.PanelID, t.Name, t.ButtonLabel, t.ButtonColor, t.ButtonEmoji, t.EmbedColor, t.EmbedTitle, t.EmbedText, t.ThumbnailURL, t.ImageURL,
t.StaffRoleID, t.CategoryID, t.ClaimChannelID, t.LogChannelID, t.MaxPerUser, t.ClaimMode, t.ClaimReupMinutes,
t.CloseRule, boolInt(t.ModalEnabled), t.SortOrder, now, now)
if err != nil {
return err
}
t.ID, _ = res.LastInsertId()
}
return tx.Commit()
}
// ReorderTypes updates sort_order for the given type IDs in order.
func (r *PanelConfigRepo) ReorderTypes(ctx context.Context, panelID int64, typeIDs []int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint
for i, id := range typeIDs {
if _, err := tx.ExecContext(ctx,
`UPDATE panel_types SET sort_order=? WHERE id=? AND panel_id=?`, i, id, panelID); err != nil {
return err
}
}
return tx.Commit()
}
func (r *PanelConfigRepo) SetDiscordMessage(ctx context.Context, id int64, channelID, messageID string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE panel_configs SET channel_id=?,message_id=?,updated_at=? WHERE id=?`,
channelID, messageID, time.Now().UTC(), id)
return err
}
func (r *PanelConfigRepo) GetByName(ctx context.Context, name string) (*PanelConfig, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+panelCols+` FROM panel_configs WHERE name=?`, name)
var p PanelConfig
err := row.Scan(&p.ID, &p.Name, &p.EmbedTitle, &p.EmbedDescription, &p.EmbedColor,
&p.EmbedImage, &p.EmbedThumbnail, &p.GuildID, &p.ChannelID, &p.MessageID, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
p.Types, _ = r.ListTypes(ctx, p.ID)
return &p, nil
}
func (r *PanelConfigRepo) IsEmpty(ctx context.Context) (bool, error) {
var count int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM panel_configs`).Scan(&count)
return count == 0, err
}
// --- Types ---
func (r *PanelConfigRepo) ListTypes(ctx context.Context, panelID int64) ([]*PanelType, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT `+typeCols+` FROM panel_types WHERE panel_id=? ORDER BY sort_order,name`, panelID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PanelType
for rows.Next() {
t, err := scanPanelType(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (r *PanelConfigRepo) CreateType(ctx context.Context, t *PanelType) error {
now := time.Now().UTC()
res, err := r.db.ExecContext(ctx,
`INSERT INTO panel_types(panel_id,name,button_label,button_color,button_emoji,embed_color,embed_title,embed_text,thumbnail_url,image_url,
staff_role_id,category_id,claim_channel_id,log_channel_id,max_per_user,claim_mode,claim_reup_minutes,close_rule,modal_enabled,sort_order,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.PanelID, t.Name, t.ButtonLabel, t.ButtonColor, t.ButtonEmoji, t.EmbedColor, t.EmbedTitle, t.EmbedText, t.ThumbnailURL, t.ImageURL,
t.StaffRoleID, t.CategoryID, t.ClaimChannelID, t.LogChannelID, t.MaxPerUser, t.ClaimMode, t.ClaimReupMinutes,
t.CloseRule, boolInt(t.ModalEnabled), t.SortOrder, now, now)
if err != nil {
return err
}
t.ID, _ = res.LastInsertId()
t.CreatedAt = now
t.UpdatedAt = now
return nil
}
func (r *PanelConfigRepo) UpdateType(ctx context.Context, t *PanelType) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx,
`UPDATE panel_types SET name=?,button_label=?,button_color=?,button_emoji=?,embed_color=?,embed_title=?,embed_text=?,thumbnail_url=?,image_url=?,
staff_role_id=?,category_id=?,claim_channel_id=?,log_channel_id=?,max_per_user=?,claim_mode=?,claim_reup_minutes=?,close_rule=?,modal_enabled=?,sort_order=?,updated_at=?
WHERE id=?`,
t.Name, t.ButtonLabel, t.ButtonColor, t.ButtonEmoji, t.EmbedColor, t.EmbedTitle, t.EmbedText, t.ThumbnailURL, t.ImageURL,
t.StaffRoleID, t.CategoryID, t.ClaimChannelID, t.LogChannelID, t.MaxPerUser, t.ClaimMode, t.ClaimReupMinutes,
t.CloseRule, boolInt(t.ModalEnabled), t.SortOrder, now, t.ID)
return err
}
func (r *PanelConfigRepo) DeleteType(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM panel_types WHERE id=?`, id)
return err
}
func scanPanelConfig(rows *sql.Rows) (*PanelConfig, error) {
var p PanelConfig
err := rows.Scan(&p.ID, &p.Name, &p.EmbedTitle, &p.EmbedDescription, &p.EmbedColor,
&p.EmbedImage, &p.EmbedThumbnail, &p.GuildID, &p.ChannelID, &p.MessageID, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
return &p, err
}
func scanPanelType(rows *sql.Rows) (*PanelType, error) {
var t PanelType
var modalEnabled int
err := rows.Scan(&t.ID, &t.PanelID, &t.Name, &t.ButtonLabel, &t.ButtonColor, &t.ButtonEmoji,
&t.EmbedColor, &t.EmbedTitle, &t.EmbedText, &t.ThumbnailURL, &t.ImageURL,
&t.StaffRoleID, &t.CategoryID, &t.ClaimChannelID, &t.LogChannelID,
&t.MaxPerUser, &t.ClaimMode, &t.ClaimReupMinutes, &t.CloseRule, &modalEnabled, &t.SortOrder, &t.CreatedAt, &t.UpdatedAt)
t.ModalEnabled = modalEnabled != 0
return &t, err
}
+102
View File
@@ -0,0 +1,102 @@
package db
import (
"context"
"database/sql"
"time"
)
type PanelSession struct {
ID int64
Token string
CSRFToken string
AdminID int64
IPAddress string
UserAgent string
LastActivity time.Time
CreatedAt time.Time
}
type PanelSessionRepo struct{ db *sql.DB }
func NewPanelSessionRepo(db *sql.DB) *PanelSessionRepo { return &PanelSessionRepo{db: db} }
func (r *PanelSessionRepo) Create(ctx context.Context, s *PanelSession) error {
res, err := r.db.ExecContext(ctx,
`INSERT INTO panel_sessions(token,csrf_token,admin_id,ip_address,user_agent,last_activity,created_at)
VALUES(?,?,?,?,?,?,?)`,
s.Token, s.CSRFToken, s.AdminID, s.IPAddress, s.UserAgent, s.LastActivity, s.CreatedAt)
if err != nil {
return err
}
s.ID, _ = res.LastInsertId()
return nil
}
func (r *PanelSessionRepo) GetByToken(ctx context.Context, token string) (*PanelSession, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id,token,csrf_token,admin_id,ip_address,user_agent,last_activity,created_at
FROM panel_sessions WHERE token=?`, token)
var s PanelSession
err := row.Scan(&s.ID, &s.Token, &s.CSRFToken, &s.AdminID,
&s.IPAddress, &s.UserAgent, &s.LastActivity, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &s, nil
}
func (r *PanelSessionRepo) UpdateLastActivity(ctx context.Context, token string, t time.Time) error {
_, err := r.db.ExecContext(ctx,
`UPDATE panel_sessions SET last_activity=? WHERE token=?`, t, token)
return err
}
func (r *PanelSessionRepo) DeleteByToken(ctx context.Context, token string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM panel_sessions WHERE token=?`, token)
return err
}
func (r *PanelSessionRepo) DeleteByID(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM panel_sessions WHERE id=?`, id)
return err
}
// DeleteAllExcept deletes all sessions for adminID except the one with exceptToken.
func (r *PanelSessionRepo) DeleteAllExcept(ctx context.Context, adminID int64, exceptToken string) error {
_, err := r.db.ExecContext(ctx,
`DELETE FROM panel_sessions WHERE admin_id=? AND token!=?`, adminID, exceptToken)
return err
}
func (r *PanelSessionRepo) List(ctx context.Context) ([]*PanelSession, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id,token,csrf_token,admin_id,ip_address,user_agent,last_activity,created_at
FROM panel_sessions ORDER BY last_activity DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanSessions(rows)
}
func (r *PanelSessionRepo) DeleteExpiredBefore(ctx context.Context, before time.Time) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM panel_sessions WHERE last_activity<?`, before)
return err
}
func scanSessions(rows *sql.Rows) ([]*PanelSession, error) {
var out []*PanelSession
for rows.Next() {
var s PanelSession
if err := rows.Scan(&s.ID, &s.Token, &s.CSRFToken, &s.AdminID,
&s.IPAddress, &s.UserAgent, &s.LastActivity, &s.CreatedAt); err != nil {
return nil, err
}
out = append(out, &s)
}
return out, rows.Err()
}
+239 -12
View File
@@ -14,6 +14,11 @@ type Ticket struct {
Panel string Panel string
Type string Type string
ChannelID string ChannelID string
GuildID string
ClaimChannelID string
LogChannelID string
StaffRoleID string
ClaimReupMinutes int
OpenedAt time.Time OpenedAt time.Time
ClaimedBy sql.NullString ClaimedBy sql.NullString
ClaimedAt sql.NullTime ClaimedAt sql.NullTime
@@ -53,10 +58,10 @@ func (r *TicketRepo) Insert(ctx context.Context, t *Ticket) error {
t.TicketNumber = n t.TicketNumber = n
res, err := tx.ExecContext(ctx, ` res, err := tx.ExecContext(ctx, `
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status,ticket_title,ticket_description) INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,status,ticket_title,ticket_description)
VALUES(?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
n, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open", n, t.UserID, t.Panel, t.Type, t.ChannelID, t.GuildID, t.ClaimChannelID, t.LogChannelID,
t.TicketTitle, t.TicketDescription, t.StaffRoleID, t.ClaimReupMinutes, t.OpenedAt.UTC(), "open", t.TicketTitle, t.TicketDescription,
) )
if err != nil { if err != nil {
return fmt.Errorf("insert ticket: %w", err) return fmt.Errorf("insert ticket: %w", err)
@@ -69,10 +74,10 @@ func (r *TicketRepo) Insert(ctx context.Context, t *Ticket) error {
// InsertWithNumber inserts a ticket where TicketNumber is already set (e.g. convocations). // InsertWithNumber inserts a ticket where TicketNumber is already set (e.g. convocations).
func (r *TicketRepo) InsertWithNumber(ctx context.Context, t *Ticket) error { func (r *TicketRepo) InsertWithNumber(ctx context.Context, t *Ticket) error {
res, err := r.db.ExecContext(ctx, ` res, err := r.db.ExecContext(ctx, `
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status,ticket_title,ticket_description) INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,status,ticket_title,ticket_description)
VALUES(?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.TicketNumber, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open", t.TicketNumber, t.UserID, t.Panel, t.Type, t.ChannelID, t.GuildID, t.ClaimChannelID, t.LogChannelID,
t.TicketTitle, t.TicketDescription, t.StaffRoleID, t.ClaimReupMinutes, t.OpenedAt.UTC(), "open", t.TicketTitle, t.TicketDescription,
) )
if err != nil { if err != nil {
return fmt.Errorf("insert ticket with number: %w", err) return fmt.Errorf("insert ticket with number: %w", err)
@@ -84,7 +89,7 @@ func (r *TicketRepo) InsertWithNumber(ctx context.Context, t *Ticket) error {
func (r *TicketRepo) GetByChannelID(ctx context.Context, channelID string) (*Ticket, error) { func (r *TicketRepo) GetByChannelID(ctx context.Context, channelID string) (*Ticket, error) {
row := r.db.QueryRowContext(ctx, ` row := r.db.QueryRowContext(ctx, `
SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,
claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description
FROM tickets WHERE channel_id=?`, channelID) FROM tickets WHERE channel_id=?`, channelID)
return scanTicket(row) return scanTicket(row)
@@ -92,7 +97,7 @@ func (r *TicketRepo) GetByChannelID(ctx context.Context, channelID string) (*Tic
func (r *TicketRepo) GetByID(ctx context.Context, id int64) (*Ticket, error) { func (r *TicketRepo) GetByID(ctx context.Context, id int64) (*Ticket, error) {
row := r.db.QueryRowContext(ctx, ` row := r.db.QueryRowContext(ctx, `
SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,
claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description
FROM tickets WHERE id=?`, id) FROM tickets WHERE id=?`, id)
return scanTicket(row) return scanTicket(row)
@@ -100,7 +105,7 @@ func (r *TicketRepo) GetByID(ctx context.Context, id int64) (*Ticket, error) {
func (r *TicketRepo) HasOpenTicket(ctx context.Context, userID, ticketType string) (*Ticket, error) { func (r *TicketRepo) HasOpenTicket(ctx context.Context, userID, ticketType string) (*Ticket, error) {
row := r.db.QueryRowContext(ctx, ` row := r.db.QueryRowContext(ctx, `
SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,
claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description
FROM tickets WHERE user_id=? AND type=? AND status IN('open','claimed') FROM tickets WHERE user_id=? AND type=? AND status IN('open','claimed')
LIMIT 1`, userID, ticketType) LIMIT 1`, userID, ticketType)
@@ -111,6 +116,44 @@ func (r *TicketRepo) HasOpenTicket(ctx context.Context, userID, ticketType strin
return t, err return t, err
} }
// CountByStatus returns the number of tickets with the given status.
func (r *TicketRepo) CountByStatus(ctx context.Context, status string) (int, error) {
var n int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tickets WHERE status=?`, status).Scan(&n)
return n, err
}
// CountClosedSince returns the number of tickets closed on or after `since`.
func (r *TicketRepo) CountClosedSince(ctx context.Context, since time.Time) (int, error) {
var n int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tickets WHERE status='closed' AND closed_at>=?`, since).Scan(&n)
return n, err
}
// AvgClaimMinutes returns the average minutes between opened_at and claimed_at for tickets claimed since `since`.
func (r *TicketRepo) AvgClaimMinutes(ctx context.Context, since time.Time) (int, error) {
var avg sql.NullFloat64
err := r.db.QueryRowContext(ctx,
`SELECT AVG((julianday(claimed_at)-julianday(opened_at))*1440)
FROM tickets WHERE claimed_at IS NOT NULL AND claimed_at>=?`, since).Scan(&avg)
if err != nil || !avg.Valid {
return 0, err
}
return int(avg.Float64), nil
}
// AvgResolutionMinutes returns the average minutes between opened_at and closed_at for tickets closed since `since`.
func (r *TicketRepo) AvgResolutionMinutes(ctx context.Context, since time.Time) (int, error) {
var avg sql.NullFloat64
err := r.db.QueryRowContext(ctx,
`SELECT AVG((julianday(closed_at)-julianday(opened_at))*1440)
FROM tickets WHERE closed_at IS NOT NULL AND closed_at>=?`, since).Scan(&avg)
if err != nil || !avg.Valid {
return 0, err
}
return int(avg.Float64), nil
}
func (r *TicketRepo) SetClaimed(ctx context.Context, id int64, staffID string, at time.Time) error { func (r *TicketRepo) SetClaimed(ctx context.Context, id int64, staffID string, at time.Time) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE tickets SET status='claimed', claimed_by=?, claimed_at=? WHERE id=?`, `UPDATE tickets SET status='claimed', claimed_by=?, claimed_at=? WHERE id=?`,
@@ -136,7 +179,7 @@ func (r *TicketRepo) SetClosedByChannel(ctx context.Context, channelID, reason s
func (r *TicketRepo) ListOpen(ctx context.Context) ([]*Ticket, error) { func (r *TicketRepo) ListOpen(ctx context.Context) ([]*Ticket, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,
claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description
FROM tickets WHERE status='open'`) FROM tickets WHERE status='open'`)
if err != nil { if err != nil {
@@ -164,6 +207,188 @@ func (r *TicketRepo) NextConvocationNumber(ctx context.Context) (int, error) {
return n, tx.Commit() return n, tx.Commit()
} }
// DailyCount holds the ticket count for one day.
type DailyCount struct {
Day string // "2006-01-02"
Count int
}
// DailyTicketCounts returns the count of tickets opened per day for the last n days.
func (r *TicketRepo) DailyTicketCounts(ctx context.Context, days int) ([]DailyCount, error) {
since := time.Now().AddDate(0, 0, -days+1).Truncate(24 * time.Hour)
rows, err := r.db.QueryContext(ctx, `
SELECT strftime('%Y-%m-%d', opened_at) AS day, COUNT(*) AS cnt
FROM tickets
WHERE opened_at >= ?
GROUP BY day
ORDER BY day`, since.UTC())
if err != nil {
return nil, err
}
defer rows.Close()
var out []DailyCount
for rows.Next() {
var dc DailyCount
if err := rows.Scan(&dc.Day, &dc.Count); err != nil {
return nil, err
}
out = append(out, dc)
}
return out, rows.Err()
}
// StaffStat holds per-staff ticket stats.
type StaffStat struct {
StaffID string
Claimed int
Closed int
AvgClaimMinutes int
AvgResolutionMinutes int
}
// StaffStats returns per-staff statistics for tickets claimed in the last n days.
func (r *TicketRepo) StaffStats(ctx context.Context, days int) ([]StaffStat, error) {
since := time.Now().AddDate(0, 0, -days)
rows, err := r.db.QueryContext(ctx, `
SELECT
claimed_by,
COUNT(*) AS claimed,
SUM(CASE WHEN status='closed' THEN 1 ELSE 0 END) AS closed,
CAST(AVG(CASE WHEN claimed_at IS NOT NULL
THEN (julianday(claimed_at) - julianday(opened_at)) * 1440 END) AS INTEGER) AS avg_claim,
CAST(AVG(CASE WHEN closed_at IS NOT NULL AND claimed_at IS NOT NULL
THEN (julianday(closed_at) - julianday(opened_at)) * 1440 END) AS INTEGER) AS avg_resolution
FROM tickets
WHERE claimed_by IS NOT NULL AND claimed_at >= ?
GROUP BY claimed_by
ORDER BY claimed DESC`, since.UTC())
if err != nil {
return nil, err
}
defer rows.Close()
var out []StaffStat
for rows.Next() {
var s StaffStat
var avgClaim, avgRes sql.NullInt64
if err := rows.Scan(&s.StaffID, &s.Claimed, &s.Closed, &avgClaim, &avgRes); err != nil {
return nil, err
}
s.AvgClaimMinutes = int(avgClaim.Int64)
s.AvgResolutionMinutes = int(avgRes.Int64)
out = append(out, s)
}
return out, rows.Err()
}
// ListFiltered returns tickets matching optional filters with pagination.
type TicketFilter struct {
Status string
Type string
StaffID string
GuildID string
Search string
From, To time.Time
Page int
PageSize int
}
func (r *TicketRepo) ListFiltered(ctx context.Context, f TicketFilter) ([]*Ticket, int, error) {
if f.PageSize <= 0 {
f.PageSize = 20
}
if f.Page <= 0 {
f.Page = 1
}
offset := (f.Page - 1) * f.PageSize
args := []any{}
where := "1=1"
if f.Status != "" {
where += " AND status=?"
args = append(args, f.Status)
}
if f.Type != "" {
where += " AND type=?"
args = append(args, f.Type)
}
if f.StaffID != "" {
where += " AND claimed_by=?"
args = append(args, f.StaffID)
}
if f.GuildID != "" {
where += " AND guild_id=?"
args = append(args, f.GuildID)
}
if f.Search != "" {
where += " AND (ticket_title LIKE ? OR user_id LIKE ?)"
args = append(args, "%"+f.Search+"%", "%"+f.Search+"%")
}
if !f.From.IsZero() {
where += " AND opened_at >= ?"
args = append(args, f.From.UTC())
}
if !f.To.IsZero() {
where += " AND opened_at <= ?"
args = append(args, f.To.UTC())
}
var total int
countArgs := make([]any, len(args))
copy(countArgs, args)
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM tickets WHERE "+where, countArgs...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, f.PageSize, offset)
rows, err := r.db.QueryContext(ctx, `
SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,
claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description
FROM tickets WHERE `+where+` ORDER BY opened_at DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
list, err := scanTickets(rows)
return list, total, err
}
// ListDistinctTypes returns all distinct ticket types present in the tickets table.
func (r *TicketRepo) ListDistinctTypes(ctx context.Context) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT type FROM tickets ORDER BY type`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// ListDistinctStaff returns all distinct staff IDs (claimed_by) from the tickets table.
func (r *TicketRepo) ListDistinctStaff(ctx context.Context) ([]string, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT claimed_by FROM tickets WHERE claimed_by IS NOT NULL ORDER BY claimed_by`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// NullStr returns a valid NullString for non-empty strings, invalid (NULL) for empty. // NullStr returns a valid NullString for non-empty strings, invalid (NULL) for empty.
func NullStr(s string) sql.NullString { func NullStr(s string) sql.NullString {
return sql.NullString{String: s, Valid: s != ""} return sql.NullString{String: s, Valid: s != ""}
@@ -180,6 +405,7 @@ func (r *TicketRepo) SetTitleDescription(ctx context.Context, id int64, title, d
func scanTicket(row *sql.Row) (*Ticket, error) { func scanTicket(row *sql.Row) (*Ticket, error) {
var t Ticket var t Ticket
err := row.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID, err := row.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID,
&t.GuildID, &t.ClaimChannelID, &t.LogChannelID, &t.StaffRoleID, &t.ClaimReupMinutes,
&t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy, &t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy,
&t.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription) &t.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription)
if err != nil { if err != nil {
@@ -193,6 +419,7 @@ func scanTickets(rows *sql.Rows) ([]*Ticket, error) {
for rows.Next() { for rows.Next() {
var t Ticket var t Ticket
if err := rows.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID, if err := rows.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID,
&t.GuildID, &t.ClaimChannelID, &t.LogChannelID, &t.StaffRoleID, &t.ClaimReupMinutes,
&t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy, &t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy,
&t.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription); err != nil { &t.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription); err != nil {
return nil, err return nil, err
+55
View File
@@ -0,0 +1,55 @@
package db
import (
"context"
"database/sql"
"time"
)
type BlacklistedUser struct {
ID int64
DiscordID string
Reason sql.NullString
BlacklistedBy string
CreatedAt time.Time
}
type UserBlacklistRepo struct{ db *sql.DB }
func NewUserBlacklistRepo(db *sql.DB) *UserBlacklistRepo { return &UserBlacklistRepo{db: db} }
func (r *UserBlacklistRepo) Add(ctx context.Context, discordID, reason, blacklistedBy string) error {
_, err := r.db.ExecContext(ctx,
`INSERT OR REPLACE INTO user_blacklist(discord_id,reason,blacklisted_by,created_at) VALUES(?,?,?,?)`,
discordID, NullStr(reason), blacklistedBy, time.Now().UTC())
return err
}
func (r *UserBlacklistRepo) Remove(ctx context.Context, discordID string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM user_blacklist WHERE discord_id=?`, discordID)
return err
}
func (r *UserBlacklistRepo) IsBlacklisted(ctx context.Context, discordID string) (bool, error) {
var count int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_blacklist WHERE discord_id=?`, discordID).Scan(&count)
return count > 0, err
}
func (r *UserBlacklistRepo) List(ctx context.Context) ([]*BlacklistedUser, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id,discord_id,reason,blacklisted_by,created_at FROM user_blacklist ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*BlacklistedUser
for rows.Next() {
var u BlacklistedUser
if err := rows.Scan(&u.ID, &u.DiscordID, &u.Reason, &u.BlacklistedBy, &u.CreatedAt); err != nil {
return nil, err
}
out = append(out, &u)
}
return out, rows.Err()
}
+28 -1
View File
@@ -15,9 +15,10 @@ type Bot struct {
Tickets *db.TicketRepo Tickets *db.TicketRepo
Claims *db.ClaimMessageRepo Claims *db.ClaimMessageRepo
GuildID string GuildID string
AllowedGuilds map[string]bool // set of allowed guild IDs; if empty, all guilds allowed
} }
func New(token string, cfg *config.Provider, tickets *db.TicketRepo, claims *db.ClaimMessageRepo, guildID string) (*Bot, error) { func New(token string, cfg *config.Provider, tickets *db.TicketRepo, claims *db.ClaimMessageRepo, guildID string, allowedGuilds []string) (*Bot, error) {
s, err := discordgo.New("Bot " + token) s, err := discordgo.New("Bot " + token)
if err != nil { if err != nil {
return nil, fmt.Errorf("create session: %w", err) return nil, fmt.Errorf("create session: %w", err)
@@ -27,15 +28,29 @@ func New(token string, cfg *config.Provider, tickets *db.TicketRepo, claims *db.
discordgo.IntentsGuildMessages | discordgo.IntentsGuildMessages |
discordgo.IntentsMessageContent discordgo.IntentsMessageContent
allowed := make(map[string]bool, len(allowedGuilds))
for _, id := range allowedGuilds {
if id != "" {
allowed[id] = true
}
}
return &Bot{ return &Bot{
Session: s, Session: s,
Config: cfg, Config: cfg,
Tickets: tickets, Tickets: tickets,
Claims: claims, Claims: claims,
GuildID: guildID, GuildID: guildID,
AllowedGuilds: allowed,
}, nil }, nil
} }
func (b *Bot) IsGuildAllowed(guildID string) bool {
if len(b.AllowedGuilds) == 0 {
return true
}
return b.AllowedGuilds[guildID]
}
func (b *Bot) Open() error { func (b *Bot) Open() error {
return b.Session.Open() return b.Session.Open()
} }
@@ -46,6 +61,17 @@ func (b *Bot) Close() {
func (b *Bot) RegisterCommands(appID string) error { func (b *Bot) RegisterCommands(appID string) error {
cmds := ApplicationCommands() cmds := ApplicationCommands()
for guildID := range b.AllowedGuilds {
for _, cmd := range cmds {
_, err := b.Session.ApplicationCommandCreate(appID, guildID, cmd)
if err != nil {
return fmt.Errorf("register %s in guild %s: %w", cmd.Name, guildID, err)
}
slog.Info("registered command", "name", cmd.Name, "guild_id", guildID)
}
}
// If no allowed guilds configured, register globally (or in default guild)
if len(b.AllowedGuilds) == 0 {
for _, cmd := range cmds { for _, cmd := range cmds {
_, err := b.Session.ApplicationCommandCreate(appID, b.GuildID, cmd) _, err := b.Session.ApplicationCommandCreate(appID, b.GuildID, cmd)
if err != nil { if err != nil {
@@ -53,5 +79,6 @@ func (b *Bot) RegisterCommands(appID string) error {
} }
slog.Info("registered command", "name", cmd.Name) slog.Info("registered command", "name", cmd.Name)
} }
}
return nil return nil
} }
+403
View File
@@ -0,0 +1,403 @@
package discord
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/db"
"github.com/leolionad58/ticketbot/internal/panel/handlers"
)
// BotServiceImpl implements panel.BotService and handlers.DiscordInfo.
type BotServiceImpl struct {
session *discordgo.Session
panelRepo *db.PanelConfigRepo
ticketRepo *db.TicketRepo
convocRepo *db.ConvocationConfigRepo
guildID string
allowedGuilds []string
startedAt time.Time
}
func NewBotService(
s *discordgo.Session,
panelRepo *db.PanelConfigRepo,
ticketRepo *db.TicketRepo,
convocRepo *db.ConvocationConfigRepo,
guildID string,
allowedGuilds []string,
) *BotServiceImpl {
return &BotServiceImpl{
session: s,
panelRepo: panelRepo,
ticketRepo: ticketRepo,
convocRepo: convocRepo,
guildID: guildID,
allowedGuilds: allowedGuilds,
startedAt: time.Now(),
}
}
// GetBotStatus returns live bot metrics.
func (b *BotServiceImpl) GetBotStatus() handlers.BotStatus {
guildName := ""
if g, err := b.session.State.Guild(b.guildID); err == nil && g != nil {
guildName = g.Name
}
return handlers.BotStatus{
Online: true,
StartedAt: b.startedAt,
LatencyMs: b.session.HeartbeatLatency().Milliseconds(),
GuildName: guildName,
}
}
// GetGuildList returns all whitelisted guilds by name.
func (b *BotServiceImpl) GetGuildList() []handlers.GuildInfo {
seen := map[string]bool{}
ids := append([]string{}, b.allowedGuilds...)
if b.guildID != "" && !seen[b.guildID] {
ids = append(ids, b.guildID)
}
out := make([]handlers.GuildInfo, 0, len(ids))
for _, id := range ids {
if seen[id] || id == "" {
continue
}
seen[id] = true
name := id
if g, err := b.session.State.Guild(id); err == nil && g != nil {
name = g.Name
}
out = append(out, handlers.GuildInfo{ID: id, Name: name})
}
return out
}
// SendPanel sends or updates the Discord panel message for the given panel ID.
func (b *BotServiceImpl) SendPanel(panelID int64) error {
ctx := context.Background()
p, err := b.panelRepo.GetByID(ctx, panelID)
if err != nil {
return fmt.Errorf("get panel: %w", err)
}
if p == nil {
return fmt.Errorf("panel %d not found", panelID)
}
if !p.ChannelID.Valid || p.ChannelID.String == "" {
return fmt.Errorf("panel %d has no channel configured", panelID)
}
embed := &discordgo.MessageEmbed{
Title: p.EmbedTitle,
Description: p.EmbedDescription.String,
Color: parseHexColor(p.EmbedColor),
}
if p.EmbedThumbnail != "" {
embed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: p.EmbedThumbnail}
}
if p.EmbedImage != "" {
embed.Image = &discordgo.MessageEmbedImage{URL: p.EmbedImage}
}
// Build action rows (max 5 buttons per row)
var components []discordgo.MessageComponent
var row []discordgo.MessageComponent
for i, t := range p.Types {
btn := discordgo.Button{
Label: t.ButtonLabel,
Style: buttonStyle(t.ButtonColor),
CustomID: fmt.Sprintf("panel:open:%s:%s", p.Name, t.Name),
}
if t.ButtonEmoji != "" {
emoji := discordgo.ComponentEmoji{Name: t.ButtonEmoji}
btn.Emoji = &emoji
}
row = append(row, btn)
if len(row) == 5 || i == len(p.Types)-1 {
components = append(components, discordgo.ActionsRow{Components: row})
row = nil
}
}
channelID := p.ChannelID.String
if p.MessageID.Valid && p.MessageID.String != "" {
_, editErr := b.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
Channel: channelID,
ID: p.MessageID.String,
Embeds: &[]*discordgo.MessageEmbed{embed},
Components: &components,
})
if editErr == nil {
return nil
}
// Message no longer exists — clear the stored ID and fall through to send new.
_ = b.panelRepo.SetDiscordMessage(ctx, panelID, channelID, "")
}
msg, err := b.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{embed},
Components: components,
})
if err != nil {
return fmt.Errorf("send panel message: %w", err)
}
return b.panelRepo.SetDiscordMessage(ctx, panelID, channelID, msg.ID)
}
// DeletePanelMessage deletes the Discord panel message.
func (b *BotServiceImpl) DeletePanelMessage(panelID int64) error {
ctx := context.Background()
p, err := b.panelRepo.GetByID(ctx, panelID)
if err != nil || p == nil {
return nil
}
if p.MessageID.Valid && p.ChannelID.Valid && p.MessageID.String != "" {
_ = b.session.ChannelMessageDelete(p.ChannelID.String, p.MessageID.String)
_ = b.panelRepo.SetDiscordMessage(ctx, panelID, "", "")
}
return nil
}
// GetGuildRoles returns all roles for the given guild.
func (b *BotServiceImpl) GetGuildRoles(guildID string) ([]handlers.GuildRole, error) {
roles, err := b.session.GuildRoles(guildID)
if err != nil {
return nil, err
}
out := make([]handlers.GuildRole, 0, len(roles))
for _, r := range roles {
if r.Name == "@everyone" {
continue
}
color := "#99aab5"
if r.Color != 0 {
color = fmt.Sprintf("#%06x", r.Color)
}
out = append(out, handlers.GuildRole{ID: r.ID, Name: r.Name, Color: color})
}
return out, nil
}
// GetGuildChannels returns all channels for the given guild.
func (b *BotServiceImpl) GetGuildChannels(guildID string) ([]handlers.GuildChannel, error) {
channels, err := b.session.GuildChannels(guildID)
if err != nil {
return nil, err
}
out := make([]handlers.GuildChannel, 0, len(channels))
for _, c := range channels {
out = append(out, handlers.GuildChannel{
ID: c.ID,
Name: c.Name,
Type: int(c.Type),
})
}
return out, nil
}
// CreateConvocation creates a convocation channel on Discord and records it in the DB.
// staffDiscordID is the Discord ID of the admin creating the convocation.
func (b *BotServiceImpl) CreateConvocation(guildID, staffDiscordID, targetDiscordID, reason string) (string, error) {
ctx := context.Background()
// Get convocation category from DB config
cfg, err := b.convocRepo.Get(ctx, guildID)
if err != nil {
return "", fmt.Errorf("get convoc config: %w", err)
}
categoryID := ""
if cfg != nil {
categoryID = cfg.CategoryID
}
if categoryID == "" {
return "", fmt.Errorf("aucune catégorie de convocation configurée pour cette guild")
}
// Get target member info
target, err := b.session.GuildMember(guildID, targetDiscordID)
if err != nil {
return "", fmt.Errorf("utilisateur introuvable dans la guild : %w", err)
}
n, err := b.ticketRepo.NextConvocationNumber(ctx)
if err != nil {
return "", fmt.Errorf("next convocation number: %w", err)
}
username := target.User.Username
channelName := fmt.Sprintf("convoc-%s-%04d", sanitizeConvocUsername(username), n)
if len(channelName) > 100 {
channelName = channelName[:100]
}
ch, err := b.session.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
Name: channelName,
Type: discordgo.ChannelTypeGuildText,
ParentID: categoryID,
PermissionOverwrites: []*discordgo.PermissionOverwrite{
{
ID: guildID,
Type: discordgo.PermissionOverwriteTypeRole,
Deny: discordgo.PermissionViewChannel,
},
{
ID: staffDiscordID,
Type: discordgo.PermissionOverwriteTypeMember,
Allow: discordgo.PermissionViewChannel |
discordgo.PermissionSendMessages |
discordgo.PermissionReadMessageHistory |
discordgo.PermissionAttachFiles,
},
{
ID: targetDiscordID,
Type: discordgo.PermissionOverwriteTypeMember,
Allow: discordgo.PermissionViewChannel |
discordgo.PermissionSendMessages |
discordgo.PermissionReadMessageHistory,
},
},
})
if err != nil {
return "", fmt.Errorf("create channel: %w", err)
}
deleteCustomID := fmt.Sprintf("convoc:delete:%s:%s", ch.ID, staffDiscordID)
if len(deleteCustomID) > 100 {
deleteCustomID = "convoc:delete:" + ch.ID
}
b.session.ChannelMessageSendComplex(ch.ID, &discordgo.MessageSend{ //nolint
Content: fmt.Sprintf("<@%s>", targetDiscordID),
Embeds: []*discordgo.MessageEmbed{
{
Title: "Convocation",
Description: fmt.Sprintf("Tu as été convoqué par <@%s>.\n\n**Raison :** %s", staffDiscordID, reason),
Color: 0x5865f2,
},
},
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Supprimer",
Style: discordgo.DangerButton,
CustomID: deleteCustomID,
},
}},
},
})
ticket := &db.Ticket{
UserID: targetDiscordID,
Panel: "convocation",
Type: "convocation",
ChannelID: ch.ID,
OpenedAt: time.Now(),
Status: "open",
TicketNumber: n,
}
_ = b.ticketRepo.InsertWithNumber(ctx, ticket)
return ch.ID, nil
}
// SendConvocPanel sends (or updates) the convocation panel embed+button in the configured Discord channel.
func (b *BotServiceImpl) SendConvocPanel(guildID string) error {
ctx := context.Background()
cfg, err := b.convocRepo.Get(ctx, guildID)
if err != nil {
return fmt.Errorf("get convoc config: %w", err)
}
if cfg == nil || cfg.PanelChannelID == "" {
return fmt.Errorf("aucun salon de panel convocation configuré")
}
title := cfg.PanelEmbedTitle
if title == "" {
title = "Créer une convocation"
}
color := parseHexColor(cfg.PanelEmbedColor)
if color == 0 {
color = 0x5865f2
}
embed := &discordgo.MessageEmbed{
Title: title,
Description: cfg.PanelEmbedDescription,
Color: color,
}
components := []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Créer une convocation",
Style: discordgo.PrimaryButton,
CustomID: "convoc:panel:" + guildID,
},
}},
}
if cfg.PanelMessageID != "" {
_, editErr := b.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
Channel: cfg.PanelChannelID,
ID: cfg.PanelMessageID,
Embeds: &[]*discordgo.MessageEmbed{embed},
Components: &components,
})
if editErr == nil {
return nil
}
// Message deleted — fall through to send new
_ = b.convocRepo.SetConvocPanelMessage(ctx, guildID, cfg.PanelChannelID, "")
}
msg, err := b.session.ChannelMessageSendComplex(cfg.PanelChannelID, &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{embed},
Components: components,
})
if err != nil {
return fmt.Errorf("send convoc panel: %w", err)
}
return b.convocRepo.SetConvocPanelMessage(ctx, guildID, cfg.PanelChannelID, msg.ID)
}
func sanitizeConvocUsername(username string) string {
var out []byte
for _, b := range []byte(username) {
if (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '-' {
out = append(out, b)
} else if b >= 'A' && b <= 'Z' {
out = append(out, b+32)
}
}
if len(out) == 0 {
return "user"
}
if len(out) > 20 {
out = out[:20]
}
return string(out)
}
func parseHexColor(s string) int {
s = strings.TrimPrefix(s, "#")
n, _ := strconv.ParseInt(s, 16, 64)
return int(n)
}
func buttonStyle(color string) discordgo.ButtonStyle {
switch color {
case "secondary":
return discordgo.SecondaryButton
case "success":
return discordgo.SuccessButton
case "danger":
return discordgo.DangerButton
default:
return discordgo.PrimaryButton
}
}
+253
View File
@@ -0,0 +1,253 @@
package components
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/db"
"github.com/leolionad58/ticketbot/internal/logger"
"github.com/leolionad58/ticketbot/internal/tickets"
)
// ConvocationComponent handles the convocation panel button and modal.
type ConvocationComponent struct {
ConvocRepo *db.ConvocationConfigRepo
TicketRepo *db.TicketRepo
Auth *tickets.AuthService
LogSvc *logger.DiscordLogger
}
// HandlePanel handles clicks on convoc:panel:<guildID> buttons.
// Shows a modal asking for target user ID and reason.
func (c *ConvocationComponent) HandlePanel(s *discordgo.Session, i *discordgo.InteractionCreate) {
staffID := interactionUserID(i)
memberRoles := memberRoleIDs(i)
if !c.Auth.Can(memberRoles, tickets.ActionConvocation, nil) {
ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.")
slog.Warn("convoc panel: denied", "user_id", staffID)
return
}
guildID := interactionGuildID(i)
parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 3)
if len(parts) == 3 && parts[2] != "" {
guildID = parts[2]
}
modalID := "modal:convoc:" + guildID
if len(modalID) > 100 {
modalID = modalID[:100]
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: modalID,
Title: "Créer une convocation",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "target",
Label: "ID Discord de l'utilisateur",
Style: discordgo.TextInputShort,
Required: true,
MaxLength: 50,
Placeholder: "123456789012345678",
},
}},
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "reason",
Label: "Raison de la convocation",
Style: discordgo.TextInputParagraph,
Required: true,
MaxLength: 500,
Placeholder: "Motif de la convocation...",
},
}},
},
},
})
}
// HandleModalSubmit handles modal:convoc:<guildID> submissions.
func (c *ConvocationComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
parts := strings.SplitN(i.ModalSubmitData().CustomID, ":", 3)
if len(parts) != 3 {
return
}
guildID := parts[2]
if guildID == "" {
guildID = interactionGuildID(i)
}
var targetID, reason string
for _, row := range i.ModalSubmitData().Components {
if ar, ok := row.(*discordgo.ActionsRow); ok {
for _, comp := range ar.Components {
if ti, ok := comp.(*discordgo.TextInput); ok {
switch ti.CustomID {
case "target":
targetID = strings.TrimSpace(ti.Value)
case "reason":
reason = strings.TrimSpace(ti.Value)
}
}
}
}
}
if targetID == "" || reason == "" {
ephemeral(s, i, "L'ID utilisateur et la raison sont requis.")
return
}
// Strip mention format <@ID> or <@!ID>
targetID = strings.TrimPrefix(targetID, "<@!")
targetID = strings.TrimPrefix(targetID, "<@")
targetID = strings.TrimSuffix(targetID, ">")
targetID = strings.TrimSpace(targetID)
staffID := interactionUserID(i)
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
})
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cfg, err := c.ConvocRepo.Get(ctx, guildID)
if err != nil || cfg == nil || cfg.CategoryID == "" {
followup(s, i, "La catégorie de convocation n'est pas configurée.")
return
}
target, err := s.GuildMember(guildID, targetID)
if err != nil {
followup(s, i, fmt.Sprintf("Utilisateur introuvable dans ce serveur : %s", targetID))
return
}
if target.User.Bot {
followup(s, i, "Tu ne peux pas convoquer un bot.")
return
}
if target.User.ID == staffID {
followup(s, i, "Tu ne peux pas te convoquer toi-même.")
return
}
n, err := c.TicketRepo.NextConvocationNumber(ctx)
if err != nil {
slog.Error("convoc modal: next number", "err", err)
followup(s, i, "Erreur interne.")
return
}
username := target.User.Username
channelName := fmt.Sprintf("convoc-%s-%04d", sanitizeConvocN(username), n)
if len(channelName) > 100 {
channelName = channelName[:100]
}
ch, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
Name: channelName,
Type: discordgo.ChannelTypeGuildText,
ParentID: cfg.CategoryID,
PermissionOverwrites: []*discordgo.PermissionOverwrite{
{
ID: guildID,
Type: discordgo.PermissionOverwriteTypeRole,
Deny: discordgo.PermissionViewChannel,
},
{
ID: staffID,
Type: discordgo.PermissionOverwriteTypeMember,
Allow: discordgo.PermissionViewChannel |
discordgo.PermissionSendMessages |
discordgo.PermissionReadMessageHistory |
discordgo.PermissionAttachFiles,
},
{
ID: target.User.ID,
Type: discordgo.PermissionOverwriteTypeMember,
Allow: discordgo.PermissionViewChannel |
discordgo.PermissionSendMessages |
discordgo.PermissionReadMessageHistory,
},
},
})
if err != nil {
slog.Error("convoc modal: create channel", "err", err)
followup(s, i, "Erreur lors de la création du salon.")
return
}
deleteCustomID := fmt.Sprintf("convoc:delete:%s:%s", ch.ID, staffID)
if len(deleteCustomID) > 100 {
deleteCustomID = "convoc:delete:" + ch.ID
}
s.ChannelMessageSendComplex(ch.ID, &discordgo.MessageSend{ //nolint
Content: fmt.Sprintf("<@%s>", target.User.ID),
Embeds: []*discordgo.MessageEmbed{
{
Title: "Convocation",
Description: fmt.Sprintf("Tu as été convoqué par <@%s>.\n\n**Raison :** %s", staffID, reason),
Color: 0x5865f2,
},
},
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Supprimer",
Style: discordgo.DangerButton,
CustomID: deleteCustomID,
},
}},
},
})
ticket := &db.Ticket{
UserID: target.User.ID,
Panel: "convocation",
Type: "convocation",
ChannelID: ch.ID,
GuildID: guildID,
LogChannelID: cfg.LogChannelID,
OpenedAt: time.Now(),
Status: "open",
TicketNumber: n,
}
if err := c.TicketRepo.InsertWithNumber(ctx, ticket); err != nil {
slog.Error("convoc modal: insert DB", "err", err)
}
c.LogSvc.LogConvocationOpened(staffID, target.User.ID, reason, ch.ID)
slog.Info("convocation created via panel", "channel_id", ch.ID, "staff_id", staffID, "target_id", target.User.ID)
followup(s, i, fmt.Sprintf("Convocation créée : <#%s>", ch.ID))
}
func sanitizeConvocN(username string) string {
var out []byte
for _, b := range []byte(username) {
if (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '-' {
out = append(out, b)
} else if b >= 'A' && b <= 'Z' {
out = append(out, b+32)
}
}
if len(out) == 0 {
return "user"
}
if len(out) > 20 {
out = out[:20]
}
return string(out)
}
+47 -8
View File
@@ -19,6 +19,7 @@ import (
type PanelComponent struct { type PanelComponent struct {
TicketSvc *tickets.Service TicketSvc *tickets.Service
TicketRepo *db.TicketRepo TicketRepo *db.TicketRepo
PanelRepo *db.PanelConfigRepo
ClaimMgr *claim.Manager ClaimMgr *claim.Manager
LogSvc *logger.DiscordLogger LogSvc *logger.DiscordLogger
Config *config.Provider Config *config.Provider
@@ -123,26 +124,48 @@ func (c *PanelComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.In
ticket.TicketTitle = db.NullStr(ticketTitle) ticket.TicketTitle = db.NullStr(ticketTitle)
ticket.TicketDescription = db.NullStr(ticketDescription) ticket.TicketDescription = db.NullStr(ticketDescription)
cfg := c.Config.Get() var embedTitle, embedText, embedColor, thumbnailURL, imageURL string
var embedTitle, embedText, embedColor string yamlCfg := c.Config.Get()
if panel, ok := cfg.Panels[panelName]; ok { if panel, ok := yamlCfg.Panels[panelName]; ok {
if typeCfg, ok := panel.Types[ticketType]; ok { if typeCfg, ok := panel.Types[ticketType]; ok {
embedTitle = typeCfg.EmbedTitle embedTitle = typeCfg.EmbedTitle
embedText = typeCfg.EmbedText embedText = typeCfg.EmbedText
embedColor = typeCfg.EmbedColor embedColor = typeCfg.EmbedColor
} }
} else if c.PanelRepo != nil {
if dbPanel, err := c.PanelRepo.GetByName(context.Background(), panelName); err == nil && dbPanel != nil {
for _, t := range dbPanel.Types {
if t.Name == ticketType {
embedTitle = t.EmbedTitle
embedText = t.EmbedText
embedColor = t.EmbedColor
thumbnailURL = t.ThumbnailURL
imageURL = t.ImageURL
break
}
}
}
} }
// a) Welcome embed (bot identity, no webhook) // a) Welcome embed (bot identity, no webhook)
welcomeEmbed := &discordgo.MessageEmbed{
Title: embedTitle,
Description: embedText,
Color: parseColor(embedColor),
}
if thumbnailURL != "" {
welcomeEmbed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: thumbnailURL}
}
if imageURL != "" {
welcomeEmbed.Image = &discordgo.MessageEmbedImage{URL: imageURL}
}
deleteCustomID := "ticket:delete:confirm:" + ticket.ChannelID deleteCustomID := "ticket:delete:confirm:" + ticket.ChannelID
if _, err := s.ChannelMessageSendComplex(ticket.ChannelID, &discordgo.MessageSend{ if _, err := s.ChannelMessageSendComplex(ticket.ChannelID, &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{ Embeds: []*discordgo.MessageEmbed{welcomeEmbed},
{Title: embedTitle, Description: embedText, Color: parseColor(embedColor)},
},
Components: []discordgo.MessageComponent{ Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{ discordgo.Button{
Label: "Supprimer le ticket", Label: "Fermer le ticket",
Style: discordgo.DangerButton, Style: discordgo.DangerButton,
CustomID: deleteCustomID, CustomID: deleteCustomID,
}, },
@@ -155,8 +178,21 @@ func (c *PanelComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.In
// b) Webhook impersonation message // b) Webhook impersonation message
postImpersonationMessage(s, ticket, uID, ticketTitle, ticketDescription, i.GuildID) postImpersonationMessage(s, ticket, uID, ticketTitle, ticketDescription, i.GuildID)
// c) Claim channel message (includes title + description) // c) Claim channel message — only if claim mode is enabled for this panel type
claimEnabled := true
if c.PanelRepo != nil {
if dbPanel, err2 := c.PanelRepo.GetByName(context.Background(), panelName); err2 == nil && dbPanel != nil {
for _, t := range dbPanel.Types {
if t.Name == ticketType {
claimEnabled = t.ClaimMode != 0
break
}
}
}
}
if claimEnabled {
c.ClaimMgr.StartTicket(ctx, s, ticket) c.ClaimMgr.StartTicket(ctx, s, ticket)
}
c.LogSvc.LogTicketOpened(ticket, uID) c.LogSvc.LogTicketOpened(ticket, uID)
slog.Info("ticket opened via modal", "ticket_id", ticket.ID, "channel_id", ticket.ChannelID, "user_id", uID) slog.Info("ticket opened via modal", "ticket_id", ticket.ID, "channel_id", ticket.ChannelID, "user_id", uID)
@@ -175,6 +211,9 @@ func postImpersonationMessage(s *discordgo.Session, ticket *db.Ticket, userID, t
user := member.User user := member.User
avatarURL := user.AvatarURL("128") avatarURL := user.AvatarURL("128")
displayName := user.Username displayName := user.Username
if user.GlobalName != "" {
displayName = user.GlobalName
}
if member.Nick != "" { if member.Nick != "" {
displayName = member.Nick displayName = member.Nick
} }
+2 -2
View File
@@ -60,11 +60,11 @@ func (c *TicketComponent) HandleDeleteConfirm(s *discordgo.Session, i *discordgo
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{ Data: &discordgo.InteractionResponseData{
Content: "Es-tu sûr de vouloir supprimer ce ticket ? Cette action est irréversible.", Content: "Es-tu sûr de vouloir fermer ce ticket ?",
Flags: discordgo.MessageFlagsEphemeral, Flags: discordgo.MessageFlagsEphemeral,
Components: []discordgo.MessageComponent{ Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{ discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{Label: "Oui, supprimer", Style: discordgo.DangerButton, CustomID: yesID}, discordgo.Button{Label: "Oui, fermer", Style: discordgo.DangerButton, CustomID: yesID},
discordgo.Button{Label: "Annuler", Style: discordgo.SecondaryButton, CustomID: cancelID}, discordgo.Button{Label: "Annuler", Style: discordgo.SecondaryButton, CustomID: cancelID},
}}, }},
}, },
+14
View File
@@ -16,9 +16,15 @@ type Router struct {
ConvocCmd *commands.ConvocationCommand ConvocCmd *commands.ConvocationCommand
PanelComp *components.PanelComponent PanelComp *components.PanelComponent
TicketComp *components.TicketComponent TicketComp *components.TicketComponent
ConvocComp *components.ConvocationComponent
IsGuildAllowed func(guildID string) bool
} }
func (r *Router) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { func (r *Router) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
if r.IsGuildAllowed != nil && !r.IsGuildAllowed(i.GuildID) {
slog.Debug("ignoring interaction from unlisted guild", "guild_id", i.GuildID)
return
}
switch i.Type { switch i.Type {
case discordgo.InteractionApplicationCommand: case discordgo.InteractionApplicationCommand:
r.routeCommand(s, i) r.routeCommand(s, i)
@@ -61,6 +67,10 @@ func (r *Router) routeComponent(s *discordgo.Session, i *discordgo.InteractionCr
r.TicketComp.HandleDeleteCancel(s, i) r.TicketComp.HandleDeleteCancel(s, i)
case strings.HasPrefix(customID, "convoc:delete:"): case strings.HasPrefix(customID, "convoc:delete:"):
r.TicketComp.HandleConvocationDelete(s, i) r.TicketComp.HandleConvocationDelete(s, i)
case strings.HasPrefix(customID, "convoc:panel:"):
if r.ConvocComp != nil {
r.ConvocComp.HandlePanel(s, i)
}
default: default:
slog.Warn("unknown component", "custom_id", customID) slog.Warn("unknown component", "custom_id", customID)
} }
@@ -72,6 +82,10 @@ func (r *Router) routeModal(s *discordgo.Session, i *discordgo.InteractionCreate
switch { switch {
case strings.HasPrefix(customID, "modal:ticket:"): case strings.HasPrefix(customID, "modal:ticket:"):
r.PanelComp.HandleModalSubmit(s, i) r.PanelComp.HandleModalSubmit(s, i)
case strings.HasPrefix(customID, "modal:convoc:"):
if r.ConvocComp != nil {
r.ConvocComp.HandleModalSubmit(s, i)
}
default: default:
slog.Warn("unknown modal", "custom_id", customID) slog.Warn("unknown modal", "custom_id", customID)
} }
+19 -6
View File
@@ -23,13 +23,27 @@ func (l *DiscordLogger) send(embed *discordgo.MessageEmbed) {
if cfg.Bot.LogsChannel == "" { if cfg.Bot.LogsChannel == "" {
return return
} }
if _, err := l.Session.ChannelMessageSendEmbed(cfg.Bot.LogsChannel, embed); err != nil { l.sendTo(cfg.Bot.LogsChannel, embed)
}
func (l *DiscordLogger) sendTo(channelID string, embed *discordgo.MessageEmbed) {
if channelID == "" {
return
}
if _, err := l.Session.ChannelMessageSendEmbed(channelID, embed); err != nil {
slog.Error("discord log: send", "err", err) slog.Error("discord log: send", "err", err)
} }
} }
func (l *DiscordLogger) logChannel(ticket *db.Ticket) string {
if ticket.LogChannelID != "" {
return ticket.LogChannelID
}
return l.Config.Get().Bot.LogsChannel
}
func (l *DiscordLogger) LogTicketOpened(ticket *db.Ticket, userID string) { func (l *DiscordLogger) LogTicketOpened(ticket *db.Ticket, userID string) {
l.send(&discordgo.MessageEmbed{ l.sendTo(l.logChannel(ticket), &discordgo.MessageEmbed{
Title: "Ticket ouvert", Title: "Ticket ouvert",
Color: 0x57f287, Color: 0x57f287,
Fields: []*discordgo.MessageEmbedField{ Fields: []*discordgo.MessageEmbedField{
@@ -42,7 +56,7 @@ func (l *DiscordLogger) LogTicketOpened(ticket *db.Ticket, userID string) {
} }
func (l *DiscordLogger) LogTicketClaimed(ticket *db.Ticket, staffID string) { func (l *DiscordLogger) LogTicketClaimed(ticket *db.Ticket, staffID string) {
l.send(&discordgo.MessageEmbed{ l.sendTo(l.logChannel(ticket), &discordgo.MessageEmbed{
Title: "Ticket claim", Title: "Ticket claim",
Color: 0xfee75c, Color: 0xfee75c,
Fields: []*discordgo.MessageEmbedField{ Fields: []*discordgo.MessageEmbedField{
@@ -53,7 +67,7 @@ func (l *DiscordLogger) LogTicketClaimed(ticket *db.Ticket, staffID string) {
} }
func (l *DiscordLogger) LogTicketClosed(ticket *db.Ticket, staffID, reason, transcriptPath string) { func (l *DiscordLogger) LogTicketClosed(ticket *db.Ticket, staffID, reason, transcriptPath string) {
embed := &discordgo.MessageEmbed{ l.sendTo(l.logChannel(ticket), &discordgo.MessageEmbed{
Title: "Ticket fermé", Title: "Ticket fermé",
Color: 0xed4245, Color: 0xed4245,
Fields: []*discordgo.MessageEmbedField{ Fields: []*discordgo.MessageEmbedField{
@@ -61,8 +75,7 @@ func (l *DiscordLogger) LogTicketClosed(ticket *db.Ticket, staffID, reason, tran
{Name: "Ticket", Value: fmt.Sprintf("%s-%04d", ticket.Type, ticket.TicketNumber), Inline: true}, {Name: "Ticket", Value: fmt.Sprintf("%s-%04d", ticket.Type, ticket.TicketNumber), Inline: true},
{Name: "Raison", Value: reason}, {Name: "Raison", Value: reason},
}, },
} })
l.send(embed)
} }
func (l *DiscordLogger) LogConvocationOpened(staffID, targetID, reason, channelID string) { func (l *DiscordLogger) LogConvocationOpened(staffID, targetID, reason, channelID string) {
+173
View File
@@ -0,0 +1,173 @@
package auth
import (
"context"
"net/http"
"time"
"github.com/leolionad58/ticketbot/internal/db"
)
type ctxKey int
const (
ctxAdmin ctxKey = iota
ctxSession ctxKey = iota
)
// WithAdmin injects an admin into the request context.
func WithAdmin(r *http.Request, admin *db.PanelAdmin) *http.Request {
return r.WithContext(context.WithValue(r.Context(), ctxAdmin, admin))
}
// AdminFromContext retrieves the admin from the request context (nil if not set).
func AdminFromContext(ctx context.Context) *db.PanelAdmin {
v, _ := ctx.Value(ctxAdmin).(*db.PanelAdmin)
return v
}
// SessionFromContext retrieves the session from the request context.
func SessionFromContext(ctx context.Context) *db.PanelSession {
v, _ := ctx.Value(ctxSession).(*db.PanelSession)
return v
}
// Middleware wires authentication and CSRF checking onto protected routes.
type Middleware struct {
svc *Service
admins *db.PanelAdminRepo
}
func NewMiddleware(svc *Service, admins *db.PanelAdminRepo) *Middleware {
return &Middleware{svc: svc, admins: admins}
}
// RequireAuth is an HTTP middleware that validates the session cookie and injects
// the admin into the request context. Redirects to /login if not authenticated.
func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("panel_session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := r.Context()
sess, err := m.svc.ValidateSession(ctx, cookie.Value)
if err != nil || sess == nil {
clearSessionCookie(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// CSRF check for state-changing methods
if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete {
csrfHeader := r.Header.Get("X-CSRF-Token")
if csrfHeader == "" {
// Also accept form field for non-HTMX posts
csrfHeader = r.FormValue("csrf_token")
}
if csrfHeader != sess.CSRFToken {
http.Error(w, "CSRF token invalid", http.StatusForbidden)
return
}
}
// Touch session (update last_activity)
_ = m.svc.TouchSession(ctx, cookie.Value)
// Load admin
admin, err := m.admins.GetByID(ctx, sess.AdminID)
if err != nil || admin == nil {
clearSessionCookie(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx = context.WithValue(ctx, ctxAdmin, admin)
ctx = context.WithValue(ctx, ctxSession, sess)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequireSuperadmin wraps RequireAuth and additionally checks is_superadmin.
func (m *Middleware) RequireSuperadmin(next http.Handler) http.Handler {
return m.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
admin := AdminFromContext(r.Context())
if admin == nil || !admin.IsSuperadmin {
http.Error(w, "Accès refusé", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}))
}
// isSecure returns true if the request arrived over HTTPS (direct TLS or behind a proxy).
func isSecure(r *http.Request) bool {
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
}
// SetSessionCookie writes the main session cookie.
func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
http.SetCookie(w, &http.Cookie{
Name: "panel_session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: isSecure(r),
SameSite: http.SameSiteStrictMode,
})
}
// SetPendingAuthCookie stores the Discord ID between OAuth callback and password verification.
func SetPendingAuthCookie(w http.ResponseWriter, r *http.Request, discordID string) {
http.SetCookie(w, &http.Cookie{
Name: "panel_pending_auth",
Value: discordID,
Path: "/auth",
HttpOnly: true,
Secure: isSecure(r),
SameSite: http.SameSiteLaxMode, // Lax: survives the redirect from /oauth/callback to /auth/*
MaxAge: 300, // 5 minutes
})
}
// PendingAuthDiscordID reads the pending auth cookie.
func PendingAuthDiscordID(r *http.Request) string {
c, err := r.Cookie("panel_pending_auth")
if err != nil {
return ""
}
return c.Value
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "panel_session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: isSecure(r),
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
func clearPendingAuthCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "panel_pending_auth",
Value: "",
Path: "/auth",
HttpOnly: true,
Secure: isSecure(r),
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
// ClearPendingAuthCookie is exported for use by handlers.
func ClearPendingAuthCookie(w http.ResponseWriter, r *http.Request) {
clearPendingAuthCookie(w, r)
}
+74
View File
@@ -0,0 +1,74 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"golang.org/x/oauth2"
)
// DiscordEndpoint is the Discord OAuth2 endpoint.
var DiscordEndpoint = oauth2.Endpoint{
AuthURL: "https://discord.com/api/oauth2/authorize",
TokenURL: "https://discord.com/api/oauth2/token",
}
// DiscordUser holds the data returned by /users/@me.
type DiscordUser struct {
ID string `json:"id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
// AvatarURL returns the full Discord CDN URL for the user's avatar.
func (u *DiscordUser) AvatarURL() string {
if u.Avatar == "" {
return ""
}
return fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png?size=128", u.ID, u.Avatar)
}
// NewOAuthConfig builds an oauth2.Config for Discord.
func NewOAuthConfig(clientID, clientSecret, callbackURL string) *oauth2.Config {
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: callbackURL,
Scopes: []string{"identify"},
Endpoint: DiscordEndpoint,
}
}
// AuthURL returns the Discord authorization URL with the given state.
func AuthURL(cfg *oauth2.Config, state string) string {
return cfg.AuthCodeURL(state, oauth2.AccessTypeOnline)
}
// FetchDiscordUser exchanges the authorization code for a token, then calls /users/@me.
func FetchDiscordUser(ctx context.Context, cfg *oauth2.Config, code string) (*DiscordUser, error) {
token, err := cfg.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("exchange oauth code: %w", err)
}
client := cfg.Client(ctx, token)
resp, err := client.Get("https://discord.com/api/v10/users/@me")
if err != nil {
return nil, fmt.Errorf("fetch discord user: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("discord api %d: %s", resp.StatusCode, body)
}
var user DiscordUser
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decode discord user: %w", err)
}
return &user, nil
}
+92
View File
@@ -0,0 +1,92 @@
package auth
import (
"sync"
"time"
)
const (
maxAttempts = 5
windowMinutes = 15
lockMinutes = 15
)
type entry struct {
count int
firstAt time.Time
lockedUntil time.Time
}
// RateLimiter tracks failed login attempts per Discord ID in memory.
// Resets on bot restart (by design — avoids DB writes on every auth failure).
type RateLimiter struct {
mu sync.Mutex
entries map[string]*entry
}
func NewRateLimiter() *RateLimiter {
return &RateLimiter{entries: make(map[string]*entry)}
}
// Allow returns true if the given ID is not currently rate-limited.
func (r *RateLimiter) Allow(id string) bool {
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.entries[id]
if !ok {
return true
}
if !e.lockedUntil.IsZero() {
if time.Now().Before(e.lockedUntil) {
return false
}
// Lock expired — reset
delete(r.entries, id)
}
return true
}
// Record records a failed attempt. Returns true if the account is now locked.
func (r *RateLimiter) Record(id string) bool {
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.entries[id]
if !ok {
e = &entry{firstAt: time.Now()}
r.entries[id] = e
}
// Reset window if outside window duration
if time.Since(e.firstAt) > windowMinutes*time.Minute {
e.count = 0
e.firstAt = time.Now()
e.lockedUntil = time.Time{}
}
e.count++
if e.count >= maxAttempts {
e.lockedUntil = time.Now().Add(lockMinutes * time.Minute)
return true
}
return false
}
// Reset clears the rate limit entry for a Discord ID (call on successful login).
func (r *RateLimiter) Reset(id string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.entries, id)
}
// RemainingLock returns the remaining lock duration (zero if not locked).
func (r *RateLimiter) RemainingLock(id string) time.Duration {
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.entries[id]
if !ok || e.lockedUntil.IsZero() {
return 0
}
remaining := time.Until(e.lockedUntil)
if remaining < 0 {
return 0
}
return remaining
}
+82
View File
@@ -0,0 +1,82 @@
package auth
import (
"testing"
"time"
)
func TestRateLimiterAllow(t *testing.T) {
r := NewRateLimiter()
if !r.Allow("user1") {
t.Error("fresh user should be allowed")
}
}
func TestRateLimiterLockAfterMaxAttempts(t *testing.T) {
r := NewRateLimiter()
id := "user2"
for i := 0; i < maxAttempts-1; i++ {
locked := r.Record(id)
if locked {
t.Fatalf("should not be locked before max attempts (attempt %d)", i+1)
}
if !r.Allow(id) {
t.Fatalf("should be allowed before lock (attempt %d)", i+1)
}
}
locked := r.Record(id)
if !locked {
t.Error("should be locked after max attempts")
}
if r.Allow(id) {
t.Error("locked user should not be allowed")
}
}
func TestRateLimiterReset(t *testing.T) {
r := NewRateLimiter()
id := "user3"
for i := 0; i < maxAttempts; i++ {
r.Record(id) //nolint
}
if r.Allow(id) {
t.Error("should be locked before reset")
}
r.Reset(id)
if !r.Allow(id) {
t.Error("should be allowed after reset")
}
}
func TestRateLimiterWindowReset(t *testing.T) {
r := NewRateLimiter()
id := "user4"
// Simulate 4 attempts within window then manually expire the window
for i := 0; i < maxAttempts-1; i++ {
r.Record(id) //nolint
}
// Manually expire the window
r.mu.Lock()
r.entries[id].firstAt = time.Now().Add(-windowMinutes*time.Minute - time.Second)
r.mu.Unlock()
// Next attempt should reset the window, not lock
locked := r.Record(id)
if locked {
t.Error("should not lock after window expired")
}
}
func TestRateLimiterRemainingLock(t *testing.T) {
r := NewRateLimiter()
id := "user5"
if r.RemainingLock(id) != 0 {
t.Error("no lock expected for fresh user")
}
for i := 0; i < maxAttempts; i++ {
r.Record(id) //nolint
}
if r.RemainingLock(id) == 0 {
t.Error("lock expected after max attempts")
}
}
+192
View File
@@ -0,0 +1,192 @@
package auth
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"image/png"
"time"
"github.com/pquerna/otp/totp"
qrcode "github.com/skip2/go-qrcode"
"golang.org/x/crypto/bcrypt"
"github.com/leolionad58/ticketbot/internal/db"
)
const bcryptCost = 12
// Service handles authentication operations: password, TOTP, sessions.
type Service struct {
admins *db.PanelAdminRepo
sessions *db.PanelSessionRepo
limiter *RateLimiter
ttl time.Duration
}
func NewService(admins *db.PanelAdminRepo, sessions *db.PanelSessionRepo, ttlMinutes int) *Service {
return &Service{
admins: admins,
sessions: sessions,
limiter: NewRateLimiter(),
ttl: time.Duration(ttlMinutes) * time.Minute,
}
}
// HashPassword returns a bcrypt hash of the password.
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPassword checks a plaintext password against a bcrypt hash.
func VerifyPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// Allow reports whether the Discord ID is not currently rate-limited.
func (s *Service) Allow(discordID string) bool { return s.limiter.Allow(discordID) }
// RecordFailure records a failed login attempt and returns true if now locked.
func (s *Service) RecordFailure(discordID string) bool { return s.limiter.Record(discordID) }
// ResetLimit clears the rate limit for the Discord ID.
func (s *Service) ResetLimit(discordID string) { s.limiter.Reset(discordID) }
// VerifyCredentials checks password and TOTP code for the given admin.
// Returns a unified error ("invalid credentials") regardless of which check fails
// to prevent information leakage.
func (s *Service) VerifyCredentials(admin *db.PanelAdmin, password, totpCode string) bool {
if !VerifyPassword(admin.PasswordHash, password) {
return false
}
if admin.TOTPEnabled {
if !admin.TOTPSecret.Valid {
return false
}
if !totp.Validate(totpCode, admin.TOTPSecret.String) {
return false
}
}
return true
}
// GenerateTOTP generates a new TOTP key for the admin and returns the QR code as a base64 PNG
// and the raw secret for manual entry.
func GenerateTOTP(issuer, accountName string) (qrBase64, secret string, err error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: issuer,
AccountName: accountName,
})
if err != nil {
return "", "", fmt.Errorf("generate totp key: %w", err)
}
img, err := key.Image(256, 256)
if err != nil {
// Fallback: use skip2/go-qrcode
var pngBytes []byte
pngBytes, err = qrcode.Encode(key.URL(), qrcode.Medium, 256)
if err != nil {
return "", "", fmt.Errorf("generate qr code: %w", err)
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes), key.Secret(), nil
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return "", "", fmt.Errorf("encode qr png: %w", err)
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), key.Secret(), nil
}
// ValidateTOTPCode validates a TOTP code against a secret.
func ValidateTOTPCode(code, secret string) bool {
return totp.Validate(code, secret)
}
// ConfirmTOTP saves the TOTP secret to the admin record and enables TOTP.
func (s *Service) ConfirmTOTP(ctx context.Context, adminID int64, secret, code string) error {
if !totp.Validate(code, secret) {
return fmt.Errorf("invalid totp code")
}
return s.admins.UpdateTOTP(ctx, adminID, secret, true)
}
// CreateSession generates a new session for the given admin.
func (s *Service) CreateSession(ctx context.Context, adminID int64, ip, ua string) (*db.PanelSession, error) {
token, err := randomHex(64)
if err != nil {
return nil, err
}
csrf, err := randomHex(32)
if err != nil {
return nil, err
}
now := time.Now().UTC()
sess := &db.PanelSession{
Token: token,
CSRFToken: csrf,
AdminID: adminID,
IPAddress: ip,
UserAgent: ua,
LastActivity: now,
CreatedAt: now,
}
if err := s.sessions.Create(ctx, sess); err != nil {
return nil, err
}
return sess, nil
}
// ValidateSession looks up a session by token and checks it hasn't timed out.
// Returns nil if not found or expired (and deletes it in that case).
func (s *Service) ValidateSession(ctx context.Context, token string) (*db.PanelSession, error) {
sess, err := s.sessions.GetByToken(ctx, token)
if err != nil || sess == nil {
return nil, err
}
if time.Since(sess.LastActivity) > s.ttl {
_ = s.sessions.DeleteByToken(ctx, token)
return nil, nil
}
return sess, nil
}
// TouchSession updates the last_activity timestamp.
func (s *Service) TouchSession(ctx context.Context, token string) error {
return s.sessions.UpdateLastActivity(ctx, token, time.Now().UTC())
}
// InvalidateSession deletes a session.
func (s *Service) InvalidateSession(ctx context.Context, token string) error {
return s.sessions.DeleteByToken(ctx, token)
}
// InvalidateAllExcept deletes all sessions for adminID except exceptToken.
func (s *Service) InvalidateAllExcept(ctx context.Context, adminID int64, exceptToken string) error {
return s.sessions.DeleteAllExcept(ctx, adminID, exceptToken)
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// GenerateState generates a random state string for OAuth2 CSRF protection.
func GenerateState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+70
View File
@@ -0,0 +1,70 @@
package auth
import (
"testing"
"time"
"github.com/pquerna/otp/totp"
)
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("correct-horse-battery-staple")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if !VerifyPassword(hash, "correct-horse-battery-staple") {
t.Error("VerifyPassword should return true for correct password")
}
if VerifyPassword(hash, "wrong-password") {
t.Error("VerifyPassword should return false for wrong password")
}
}
func TestGenerateTOTP(t *testing.T) {
qr, secret, err := GenerateTOTP("TestApp", "user@test")
if err != nil {
t.Fatalf("GenerateTOTP: %v", err)
}
if secret == "" {
t.Error("secret should not be empty")
}
if qr == "" {
t.Error("qr should not be empty")
}
}
func TestValidateTOTPCode(t *testing.T) {
_, secret, err := GenerateTOTP("TestApp", "user@test")
if err != nil {
t.Fatalf("GenerateTOTP: %v", err)
}
// Generate a valid code
code, err := totp.GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
if !ValidateTOTPCode(code, secret) {
t.Error("ValidateTOTPCode should return true for valid code")
}
if ValidateTOTPCode("000000", secret) {
t.Error("ValidateTOTPCode should return false for invalid code")
}
}
func TestGenerateState(t *testing.T) {
s1, err := GenerateState()
if err != nil {
t.Fatalf("GenerateState: %v", err)
}
s2, err := GenerateState()
if err != nil {
t.Fatalf("GenerateState: %v", err)
}
if s1 == s2 {
t.Error("GenerateState should return unique values")
}
if len(s1) != 32 { // 16 bytes → 32 hex chars
t.Errorf("GenerateState length = %d, want 32", len(s1))
}
}
+14
View File
@@ -0,0 +1,14 @@
package panel
import (
"github.com/leolionad58/ticketbot/internal/panel/handlers"
)
// BotService is the interface the panel calls into the live bot.
type BotService interface {
GetBotStatus() handlers.BotStatus
SendPanel(panelID int64) error
DeletePanelMessage(panelID int64) error
CreateConvocation(guildID, staffDiscordID, targetDiscordID, reason string) (channelID string, err error)
SendConvocPanel(guildID string) error
}
+90
View File
@@ -0,0 +1,90 @@
package panel
import (
"embed"
"fmt"
"html/template"
"io/fs"
"net/http"
"os"
)
//go:embed templates static
var embeddedFS embed.FS
var templateFuncs = template.FuncMap{
"percent": func(count, max int) int {
if max <= 0 {
return 0
}
v := count * 100 / max
if v > 100 {
return 100
}
return v
},
"fmtMin": func(m int) string {
if m <= 0 {
return "—"
}
if m < 60 {
return fmt.Sprintf("%dm", m)
}
return fmt.Sprintf("%dh%dm", m/60, m%60)
},
"add": func(a, b int) int { return a + b },
// dict builds a map[string]any for passing multiple values to a sub-template.
"dict": func(pairs ...any) map[string]any {
m := make(map[string]any, len(pairs)/2)
for i := 0; i+1 < len(pairs); i += 2 {
k, _ := pairs[i].(string)
m[k] = pairs[i+1]
}
return m
},
"buttonClass": func(color string) string {
switch color {
case "secondary":
return "bg-gray-600"
case "success":
return "bg-green-600"
case "danger":
return "bg-red-600"
default:
return "bg-indigo-600"
}
},
"not": func(v bool) bool { return !v },
}
// StaticFS returns the file system to use for /static/* routes.
// In dev mode (PANEL_DEV=true), serves from disk for hot reload.
func StaticFS() http.FileSystem {
if os.Getenv("PANEL_DEV") == "true" {
return http.Dir("internal/panel/static")
}
sub, _ := fs.Sub(embeddedFS, "static")
return http.FS(sub)
}
// ParseTemplates parses a set of template files by name with global template functions.
// In dev mode templates are loaded from disk on every call (hot reload).
func ParseTemplates(names ...string) (*template.Template, error) {
var paths []string
for _, n := range names {
paths = append(paths, "templates/"+n)
}
base := template.New("").Funcs(templateFuncs)
if os.Getenv("PANEL_DEV") == "true" {
return base.ParseFiles(prependDir("internal/panel/", paths)...)
}
return base.ParseFS(embeddedFS, paths...)
}
func prependDir(dir string, paths []string) []string {
out := make([]string, len(paths))
for i, p := range paths {
out[i] = dir + p
}
return out
}
+111
View File
@@ -0,0 +1,111 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/leolionad58/ticketbot/internal/db"
)
// AuditHandler serves GET /audit.
type AuditHandler struct {
AuditRepo *db.AuditLogRepo
Renderer *Renderer
}
// auditActions lists all known audit action constants for the filter dropdown.
var auditActions = []string{
db.AuditCreatePanel,
db.AuditUpdatePanel,
db.AuditDeletePanel,
db.AuditCreateType,
db.AuditUpdateType,
db.AuditDeleteType,
db.AuditReorderTypes,
db.AuditUpdateConvoc,
db.AuditSendPanelDiscord,
db.AuditDeletePanelMsg,
db.AuditRevokeSession,
db.AuditRevokeAllSess,
db.AuditAdminLogin,
db.AuditAdminLoginFail,
db.AuditAdminLocked,
}
type auditData struct {
baseData
Entries []*db.AuditLogEntry
Total int
Page int
Pages int
Actions []string
// Filter values
FAdminID string
FAction string
FFrom string
FTo string
}
// HandleList serves GET /audit.
func (h *AuditHandler) HandleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
base := h.Renderer.base(r, "audit")
q := r.URL.Query()
fAdminID := q.Get("admin_id")
fAction := q.Get("action")
fFrom := q.Get("from")
fTo := q.Get("to")
page, _ := strconv.Atoi(q.Get("page"))
if page < 1 {
page = 1
}
var adminID int64
if fAdminID != "" {
adminID, _ = strconv.ParseInt(fAdminID, 10, 64)
}
filter := db.AuditFilter{
AdminID: adminID,
Action: fAction,
}
if fFrom != "" {
if t, err := time.Parse("2006-01-02", fFrom); err == nil {
filter.From = t
}
}
if fTo != "" {
if t, err := time.Parse("2006-01-02", fTo); err == nil {
filter.To = t.Add(24*time.Hour - time.Second)
}
}
const pageSize = 50
entries, total, err := h.AuditRepo.List(ctx, filter, page, pageSize)
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
pages := total / pageSize
if total%pageSize != 0 {
pages++
}
if pages < 1 {
pages = 1
}
h.Renderer.Page(w, "audit", auditData{
baseData: base,
Entries: entries,
Total: total,
Page: page,
Pages: pages,
Actions: auditActions,
FAdminID: fAdminID,
FAction: fAction,
FFrom: fFrom,
FTo: fTo,
})
}
+256
View File
@@ -0,0 +1,256 @@
package handlers
import (
"context"
"html/template"
"log/slog"
"net/http"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// AuthHandler handles TOTP setup, credential verification, and logout.
type AuthHandler struct {
Admins *db.PanelAdminRepo
Auth *panelauth.Service
AuditLog *db.AuditLogRepo
Issuer string
Renderer *Renderer
}
// HandlePasswordSetupGET serves GET /auth/password-setup (first login).
func (h *AuthHandler) HandlePasswordSetupGET(w http.ResponseWriter, r *http.Request) {
if panelauth.PendingAuthDiscordID(r) == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
h.Renderer.Auth(w, "password_setup", struct{ Error string }{})
}
// HandlePasswordSetupPOST serves POST /auth/password-setup.
func (h *AuthHandler) HandlePasswordSetupPOST(w http.ResponseWriter, r *http.Request) {
discordID := panelauth.PendingAuthDiscordID(r)
if discordID == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
renderErr := func(msg string) {
h.Renderer.Auth(w, "password_setup", struct{ Error string }{Error: msg})
}
password := r.FormValue("password")
confirm := r.FormValue("confirm")
if len(password) < 12 {
renderErr("Le mot de passe doit faire au moins 12 caractères.")
return
}
if password != confirm {
renderErr("Les mots de passe ne correspondent pas.")
return
}
ctx := r.Context()
admin, err := h.Admins.GetByDiscordID(ctx, discordID)
if err != nil || admin == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
hash, err := panelauth.HashPassword(password)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := h.Admins.UpdatePassword(ctx, admin.ID, hash); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !admin.TOTPEnabled {
http.Redirect(w, r, "/auth/totp-setup", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/auth/verify", http.StatusSeeOther)
}
// HandleTOTPSetupGET serves GET /auth/totp-setup.
func (h *AuthHandler) HandleTOTPSetupGET(w http.ResponseWriter, r *http.Request) {
discordID := panelauth.PendingAuthDiscordID(r)
if discordID == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := r.Context()
admin, err := h.Admins.GetByDiscordID(ctx, discordID)
if err != nil || admin == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
qr, secret, err := panelauth.GenerateTOTP(h.Issuer, admin.DiscordUsername)
if err != nil {
slog.Error("totp setup: generate", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
type totpSetupData struct {
QRCodeBase64 template.URL
Secret string
Error string
}
h.Renderer.Auth(w, "totp_setup", totpSetupData{QRCodeBase64: template.URL(qr), Secret: secret})
}
// HandleTOTPSetupPOST serves POST /auth/totp-setup.
func (h *AuthHandler) HandleTOTPSetupPOST(w http.ResponseWriter, r *http.Request) {
discordID := panelauth.PendingAuthDiscordID(r)
if discordID == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
secret := r.FormValue("secret")
code := r.FormValue("code")
ctx := r.Context()
admin, err := h.Admins.GetByDiscordID(ctx, discordID)
if err != nil || admin == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
type totpSetupData struct {
QRCodeBase64 template.URL
Secret string
Error string
}
if err := h.Auth.ConfirmTOTP(ctx, admin.ID, secret, code); err != nil {
qr, sec, _ := panelauth.GenerateTOTP(h.Issuer, admin.DiscordUsername)
h.Renderer.Auth(w, "totp_setup", totpSetupData{QRCodeBase64: template.URL(qr), Secret: sec, Error: "Code invalide. Réessaie."})
return
}
slog.Info("totp setup completed", "discord_id", discordID)
http.Redirect(w, r, "/auth/verify", http.StatusSeeOther)
}
// HandleVerifyGET serves GET /auth/verify.
func (h *AuthHandler) HandleVerifyGET(w http.ResponseWriter, r *http.Request) {
discordID := panelauth.PendingAuthDiscordID(r)
if discordID == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := r.Context()
admin, err := h.Admins.GetByDiscordID(ctx, discordID)
if err != nil || admin == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
h.Renderer.Auth(w, "totp_verify", struct {
Admin *db.PanelAdmin
Error string
}{Admin: admin})
}
// HandleVerifyPOST serves POST /auth/verify.
func (h *AuthHandler) HandleVerifyPOST(w http.ResponseWriter, r *http.Request) {
discordID := panelauth.PendingAuthDiscordID(r)
if discordID == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := r.Context()
admin, err := h.Admins.GetByDiscordID(ctx, discordID)
if err != nil || admin == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
renderErr := func(msg string) {
h.Renderer.Auth(w, "totp_verify", struct {
Admin *db.PanelAdmin
Error string
}{Admin: admin, Error: msg})
}
ip := clientIP(r)
// Rate limit check
if !h.Auth.Allow(discordID) {
h.auditLoginFail(ctx, admin, ip, db.AuditAdminLocked)
renderErr("Trop de tentatives. Réessaie dans 15 minutes.")
return
}
password := r.FormValue("password")
totpCode := r.FormValue("totp")
if !h.Auth.VerifyCredentials(admin, password, totpCode) {
locked := h.Auth.RecordFailure(discordID)
h.auditLoginFail(ctx, admin, ip, db.AuditAdminLoginFail)
if locked {
h.auditLoginFail(ctx, admin, ip, db.AuditAdminLocked)
slog.Warn("admin account locked", "discord_id", discordID, "ip", ip)
}
renderErr("Identifiants invalides.")
return
}
// Success — clear rate limit and pending cookie, create session
h.Auth.ResetLimit(discordID)
panelauth.ClearPendingAuthCookie(w, r)
sess, err := h.Auth.CreateSession(ctx, admin.ID, ip, r.UserAgent())
if err != nil {
slog.Error("verify: create session", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
panelauth.SetSessionCookie(w, r, sess.Token)
h.auditLogin(ctx, admin, ip)
slog.Info("admin logged in", "discord_id", discordID, "ip", clientIP(r))
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// HandleLogout serves POST /auth/logout.
func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("panel_session"); err == nil {
_ = h.Auth.InvalidateSession(r.Context(), c.Value)
}
http.SetCookie(w, &http.Cookie{Name: "panel_session", MaxAge: -1, Path: "/"})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (h *AuthHandler) auditLogin(ctx context.Context, admin *db.PanelAdmin, ip string) {
if h.AuditLog == nil {
return
}
_ = h.AuditLog.Insert(ctx, &db.AuditLogEntry{
AdminID: admin.ID,
Action: db.AuditAdminLogin,
EntityType: "admin",
IPAddress: ip,
})
}
func (h *AuthHandler) auditLoginFail(ctx context.Context, admin *db.PanelAdmin, ip, action string) {
if h.AuditLog == nil {
return
}
_ = h.AuditLog.Insert(ctx, &db.AuditLogEntry{
AdminID: admin.ID,
Action: action,
EntityType: "admin",
IPAddress: ip,
})
}
+91
View File
@@ -0,0 +1,91 @@
package handlers
import (
"html/template"
"net/http"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// ParseFunc is the signature of panel.ParseTemplates — injected to avoid import cycles.
type ParseFunc func(names ...string) (*template.Template, error)
// Renderer provides renderAuth and renderPage to handlers without importing the panel package.
type Renderer struct {
parse ParseFunc
getBotStatus func() BotStatus
}
// NewRenderer creates a Renderer backed by the given parse function.
func NewRenderer(f ParseFunc) *Renderer { return &Renderer{parse: f} }
// SetBotStatus wires a live bot-status function so BotOnline is set on every page.
func (rnd *Renderer) SetBotStatus(fn func() BotStatus) { rnd.getBotStatus = fn }
// base builds baseData with BotOnline populated from the live bot status.
func (rnd *Renderer) base(r *http.Request, active string) baseData {
b := newBase(r, active)
if rnd.getBotStatus != nil {
b.BotOnline = rnd.getBotStatus().Online
}
return b
}
func (rnd *Renderer) Auth(w http.ResponseWriter, page string, data any) {
t, err := rnd.parse("auth_layout.html", page+".html")
if err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "auth_layout", data); err != nil {
http.Error(w, "render error: "+err.Error(), http.StatusInternalServerError)
}
}
func (rnd *Renderer) Page(w http.ResponseWriter, page string, data any) {
t, err := rnd.parse("layout.html", page+".html")
if err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, "render error: "+err.Error(), http.StatusInternalServerError)
}
}
type baseData struct {
CSRFToken string
Admin *db.PanelAdmin
ActivePage string
BotOnline bool
Flash string
Error string
}
func newBase(r *http.Request, active string) baseData {
sess := panelauth.SessionFromContext(r.Context())
admin := panelauth.AdminFromContext(r.Context())
csrf := ""
if sess != nil {
csrf = sess.CSRFToken
}
return baseData{
CSRFToken: csrf,
Admin: admin,
ActivePage: active,
Flash: r.URL.Query().Get("flash"),
}
}
func clientIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return ip
}
return r.RemoteAddr
}
+252
View File
@@ -0,0 +1,252 @@
package handlers
import (
"net/http"
"strings"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// ConvocBotService is the subset of BotService needed for convocation operations.
type ConvocBotService interface {
CreateConvocation(guildID, staffDiscordID, targetDiscordID, reason string) (channelID string, err error)
SendConvocPanel(guildID string) error
SendConvocPanelByID(panelID int64) error
}
// ConvocDiscordInfo is the subset of DiscordInfo for guild selection in convocations.
type ConvocDiscordInfo interface {
GetGuildList() []GuildInfo
}
// ConvocationsHandler serves /convocations.
type ConvocationsHandler struct {
TicketRepo *db.TicketRepo
ConvocRepo *db.ConvocationConfigRepo
ConvocPanelRepo *db.ConvocationPanelRepo
GuildID string
BotService ConvocBotService
DiscordInfo ConvocDiscordInfo
Renderer *Renderer
}
type convocationsData struct {
baseData
Config *db.ConvocationConfig
ConvocPanels []*db.ConvocationPanel
Tickets []*db.Ticket
Total int
Page int
Pages int
Guilds []GuildInfo
}
// HandleGET serves GET /convocations.
func (h *ConvocationsHandler) HandleGET(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
base := h.Renderer.base(r, "convocations")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
cfg, err := h.ConvocRepo.Get(ctx, h.GuildID)
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
var convocPanels []*db.ConvocationPanel
if h.ConvocPanelRepo != nil {
convocPanels, _ = h.ConvocPanelRepo.List(ctx, h.GuildID)
}
page := 1
tickets, total, err := h.TicketRepo.ListFiltered(ctx, db.TicketFilter{
Type: "convocation",
Page: page,
PageSize: 20,
})
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
pages := total / 20
if total%20 != 0 {
pages++
}
if pages < 1 {
pages = 1
}
var guilds []GuildInfo
if h.DiscordInfo != nil {
guilds = h.DiscordInfo.GetGuildList()
}
h.Renderer.Page(w, "convocations", convocationsData{
baseData: base,
Config: cfg,
ConvocPanels: convocPanels,
Tickets: tickets,
Total: total,
Page: page,
Pages: pages,
Guilds: guilds,
})
}
// HandlePOST serves POST /convocations — saves the convocation config.
func (h *ConvocationsHandler) HandlePOST(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Load existing config to preserve panel_message_id (not sent by form)
existing, _ := h.ConvocRepo.Get(ctx, h.GuildID)
panelMsgID := ""
panelChannelID := strings.TrimSpace(r.FormValue("panel_channel_id"))
if existing != nil {
panelMsgID = existing.PanelMessageID
// If channel changed, clear stored message ID so SendConvocPanel sends a new message
if existing.PanelChannelID != panelChannelID {
panelMsgID = ""
}
}
embedColor := strings.TrimSpace(r.FormValue("panel_embed_color"))
if embedColor == "" {
embedColor = "#5865f2"
}
cfg := &db.ConvocationConfig{
GuildID: h.GuildID,
CategoryID: strings.TrimSpace(r.FormValue("category_id")),
LogChannelID: strings.TrimSpace(r.FormValue("log_channel_id")),
ModalEnabled: r.FormValue("modal_enabled") == "1",
PanelChannelID: panelChannelID,
PanelMessageID: panelMsgID,
PanelEmbedTitle: strings.TrimSpace(r.FormValue("panel_embed_title")),
PanelEmbedDescription: strings.TrimSpace(r.FormValue("panel_embed_description")),
PanelEmbedColor: embedColor,
}
if err := h.ConvocRepo.Upsert(ctx, cfg); err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/convocations?flash=Configuration+sauvegardée", http.StatusSeeOther)
}
// HandleCreatePanel serves POST /convocations/panels/new — creates a new convocation panel entry.
func (h *ConvocationsHandler) HandleCreatePanel(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if h.ConvocPanelRepo == nil {
http.Redirect(w, r, "/convocations?error=Non+configuré", http.StatusSeeOther)
return
}
color := strings.TrimSpace(r.FormValue("embed_color"))
if color == "" {
color = "#5865f2"
}
p := &db.ConvocationPanel{
GuildID: h.GuildID,
EmbedTitle: strings.TrimSpace(r.FormValue("embed_title")),
EmbedDesc: strings.TrimSpace(r.FormValue("embed_description")),
EmbedColor: color,
ChannelID: strings.TrimSpace(r.FormValue("channel_id")),
}
if err := h.ConvocPanelRepo.Create(ctx, p); err != nil {
http.Redirect(w, r, "/convocations?error="+urlEncode(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/convocations?flash=Panel+créé", http.StatusSeeOther)
}
// HandleDeletePanel serves POST /convocations/panels/{id}/delete.
func (h *ConvocationsHandler) HandleDeletePanel(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
idStr := chi.URLParam(r, "id")
var id int64
fmt.Sscanf(idStr, "%d", &id)
if h.ConvocPanelRepo != nil {
h.ConvocPanelRepo.Delete(ctx, id) //nolint
}
http.Redirect(w, r, "/convocations?flash=Panel+supprimé", http.StatusSeeOther)
}
// HandleSendPanel serves POST /convocations/panels/{id}/send.
func (h *ConvocationsHandler) HandleSendPanel(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
var id int64
fmt.Sscanf(idStr, "%d", &id)
if h.BotService == nil {
http.Redirect(w, r, "/convocations?error=Bot+non+disponible", http.StatusSeeOther)
return
}
if err := h.BotService.SendConvocPanelByID(id); err != nil {
http.Redirect(w, r, "/convocations?error="+urlEncode(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/convocations?flash=Panel+envoyé+sur+Discord", http.StatusSeeOther)
}
// HandleCreate serves POST /convocations/create — creates a convocation via Discord.
func (h *ConvocationsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if h.BotService == nil {
http.Error(w, "bot non disponible", http.StatusServiceUnavailable)
return
}
guildID := strings.TrimSpace(r.FormValue("guild_id"))
if guildID == "" {
guildID = h.GuildID
}
targetID := strings.TrimSpace(r.FormValue("target_id"))
reason := strings.TrimSpace(r.FormValue("reason"))
if targetID == "" || reason == "" {
http.Redirect(w, r, "/convocations?error=ID+utilisateur+et+raison+requis", http.StatusSeeOther)
return
}
admin := panelauth.AdminFromContext(r.Context())
staffDiscordID := ""
if admin != nil {
staffDiscordID = admin.DiscordID
}
channelID, err := h.BotService.CreateConvocation(guildID, staffDiscordID, targetID, reason)
if err != nil {
http.Redirect(w, r, "/convocations?error="+urlEncode(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/convocations?flash=Convocation+créée+dans+<#"+channelID+">", http.StatusSeeOther)
}
func urlEncode(s string) string {
var out strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
out.WriteRune(r)
case r == '-' || r == '_' || r == '.' || r == '~':
out.WriteRune(r)
default:
out.WriteString("+" + strings.ReplaceAll(string(r), " ", "+"))
}
}
return out.String()
}
+161
View File
@@ -0,0 +1,161 @@
package handlers
import (
"context"
"fmt"
"net/http"
"time"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// BotStatus holds live bot state, populated by the BotService.
type BotStatus struct {
Online bool
StartedAt time.Time
LatencyMs int64
GuildName string
}
func (s BotStatus) Uptime() string {
if !s.Online {
return "—"
}
d := time.Since(s.StartedAt).Truncate(time.Second)
h := int(d.Hours())
m := int(d.Minutes()) % 60
sec := int(d.Seconds()) % 60
if h > 0 {
return fmt.Sprintf("%dh %dm", h, m)
}
return fmt.Sprintf("%dm %ds", m, sec)
}
// DashboardStats holds pre-computed stats for the dashboard.
type DashboardStats struct {
OpenTickets int
ClosedLast30 int
AvgClaimMinutes int
AvgResolutionMinutes int
}
// ChartBar is one bar in the daily chart.
type ChartBar struct {
Day string
Count int
}
// DashboardHandler serves the main dashboard page.
type DashboardHandler struct {
TicketRepo *db.TicketRepo
PanelRepo *db.PanelConfigRepo
GetBotStatus func() BotStatus
Renderer *Renderer
}
func (h *DashboardHandler) Handle(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
base := h.Renderer.base(r, "dashboard")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
stats := h.loadStats(ctx)
chart := h.loadChart(ctx)
staff := h.loadStaff(ctx)
panels, _ := h.PanelRepo.List(ctx)
status := BotStatus{}
if h.GetBotStatus != nil {
status = h.GetBotStatus()
}
h.Renderer.Page(w, "dashboard", struct {
baseData
Stats DashboardStats
Chart []ChartBar
ChartMax int
Staff []db.StaffStat
Panels []*db.PanelConfig
BotOnline bool
Uptime string
LatencyMs int64
GuildName string
}{
baseData: base,
Stats: stats,
Chart: chart,
ChartMax: chartMax(chart),
Staff: staff,
Panels: panels,
BotOnline: status.Online,
Uptime: status.Uptime(),
LatencyMs: status.LatencyMs,
GuildName: status.GuildName,
})
}
func (h *DashboardHandler) loadStats(ctx context.Context) DashboardStats {
var stats DashboardStats
if h.TicketRepo == nil {
return stats
}
open, _ := h.TicketRepo.CountByStatus(ctx, "open")
claimed, _ := h.TicketRepo.CountByStatus(ctx, "claimed")
stats.OpenTickets = open + claimed
since := time.Now().AddDate(0, 0, -30)
stats.ClosedLast30, _ = h.TicketRepo.CountClosedSince(ctx, since)
stats.AvgClaimMinutes, _ = h.TicketRepo.AvgClaimMinutes(ctx, since)
stats.AvgResolutionMinutes, _ = h.TicketRepo.AvgResolutionMinutes(ctx, since)
return stats
}
func (h *DashboardHandler) loadChart(ctx context.Context) []ChartBar {
if h.TicketRepo == nil {
return nil
}
daily, _ := h.TicketRepo.DailyTicketCounts(ctx, 30)
dayMap := make(map[string]int, len(daily))
for _, d := range daily {
dayMap[d.Day] = d.Count
}
bars := make([]ChartBar, 30)
for i := range bars {
t := time.Now().AddDate(0, 0, -29+i)
day := t.Format("2006-01-02")
bars[i] = ChartBar{Day: t.Format("02/01"), Count: dayMap[day]}
}
return bars
}
func (h *DashboardHandler) loadStaff(ctx context.Context) []db.StaffStat {
if h.TicketRepo == nil {
return nil
}
stats, _ := h.TicketRepo.StaffStats(ctx, 30)
return stats
}
func chartMax(bars []ChartBar) int {
m := 1
for _, b := range bars {
if b.Count > m {
m = b.Count
}
}
return m
}
func fmtMinutes(m int) string {
if m <= 0 {
return "—"
}
if m < 60 {
return fmt.Sprintf("%dm", m)
}
return fmt.Sprintf("%dh%dm", m/60, m%60)
}
+110
View File
@@ -0,0 +1,110 @@
package handlers
import (
"log/slog"
"net/http"
"time"
"golang.org/x/oauth2"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// LoginHandler handles the Discord OAuth2 login flow.
type LoginHandler struct {
Admins *db.PanelAdminRepo
Auth *panelauth.Service
OAuthCfg *oauth2.Config
Renderer *Renderer
}
// HandleLogin serves GET /login.
func (h *LoginHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
state, err := panelauth.GenerateState()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: state,
Path: "/oauth",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode, // Lax to survive the Discord redirect
MaxAge: 60,
})
type loginData struct {
OAuthURL string
Error string
}
h.Renderer.Auth(w, "login", loginData{
OAuthURL: panelauth.AuthURL(h.OAuthCfg, state),
})
}
// HandleCallback serves GET /oauth/callback.
func (h *LoginHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
// Validate state
stateCookie, err := r.Cookie("oauth_state")
if err != nil || stateCookie.Value != r.URL.Query().Get("state") {
type loginData struct{ OAuthURL, Error string }
h.Renderer.Auth(w, "login", loginData{Error: "Session expirée, réessaie."})
return
}
// Clear state cookie
http.SetCookie(w, &http.Cookie{Name: "oauth_state", Path: "/oauth", MaxAge: -1, Expires: time.Unix(0, 0)})
code := r.URL.Query().Get("code")
if code == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := r.Context()
discordUser, err := panelauth.FetchDiscordUser(ctx, h.OAuthCfg, code)
if err != nil {
slog.Warn("oauth callback: fetch discord user", "err", err)
type loginData struct{ OAuthURL, Error string }
h.Renderer.Auth(w, "login", loginData{
OAuthURL: panelauth.AuthURL(h.OAuthCfg, ""),
Error: "Erreur lors de la connexion Discord. Réessaie.",
})
return
}
// Check admin whitelist
admin, err := h.Admins.GetByDiscordID(ctx, discordUser.ID)
if err != nil {
slog.Error("oauth callback: get admin", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if admin == nil {
// Not in whitelist — show denied page
h.Renderer.Auth(w, "denied", nil)
return
}
// Update Discord profile info
_ = h.Admins.UpdateDiscordProfile(ctx, admin.ID, discordUser.Username, discordUser.Avatar)
admin.DiscordUsername = discordUser.Username
admin.DiscordAvatar = discordUser.Avatar
// Store pending auth cookie
panelauth.SetPendingAuthCookie(w, r, admin.DiscordID)
if admin.PasswordHash == "" {
http.Redirect(w, r, "/auth/password-setup", http.StatusSeeOther)
return
}
if !admin.TOTPEnabled {
http.Redirect(w, r, "/auth/totp-setup", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/auth/verify", http.StatusSeeOther)
}
+433
View File
@@ -0,0 +1,433 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// BotSender is the subset of BotService used by PanelsHandler.
type BotSender interface {
SendPanel(panelID int64) error
DeletePanelMessage(panelID int64) error
}
// GuildRole is a Discord role passed to the panel form.
type GuildRole struct {
ID string
Name string
Color string
}
// GuildChannel is a Discord channel/category passed to the panel form.
type GuildChannel struct {
ID string
Name string
Type int // 0=text, 2=voice, 4=category
}
// GuildInfo is a minimal guild descriptor for the guild selector.
type GuildInfo struct {
ID string
Name string
}
// DiscordInfo can fetch guild roles and channels for the form dropdowns.
type DiscordInfo interface {
GetGuildList() []GuildInfo
GetGuildRoles(guildID string) ([]GuildRole, error)
GetGuildChannels(guildID string) ([]GuildChannel, error)
}
// PanelsHandler serves all /panels/* routes.
type PanelsHandler struct {
PanelRepo *db.PanelConfigRepo
AuditLog *db.AuditLogRepo
BotService BotSender
DiscordInfo DiscordInfo
Renderer *Renderer
}
// HandleList serves GET /panels.
func (h *PanelsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
base := h.Renderer.base(r, "panels")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
panels, _ := h.PanelRepo.List(ctx)
guildNames := map[string]string{}
if h.DiscordInfo != nil {
for _, g := range h.DiscordInfo.GetGuildList() {
guildNames[g.ID] = g.Name
}
}
h.Renderer.Page(w, "panels_list", struct {
baseData
Panels []*db.PanelConfig
GuildNames map[string]string
}{baseData: base, Panels: panels, GuildNames: guildNames})
}
// HandleNew serves GET /panels/new.
func (h *PanelsHandler) HandleNew(w http.ResponseWriter, r *http.Request) {
base := h.Renderer.base(r, "panels")
guildID := r.URL.Query().Get("guild_id")
if guildID == "" && h.DiscordInfo != nil {
if guilds := h.DiscordInfo.GetGuildList(); len(guilds) > 0 {
guildID = guilds[0].ID
}
}
h.renderForm(w, base, &db.PanelConfig{EmbedColor: "#5865f2", GuildID: guildID}, true, nil)
}
// HandleCreate serves POST /panels/new.
func (h *PanelsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
base := h.Renderer.base(r, "panels")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
p, types, errs := parsePanel(r)
if len(errs) > 0 {
p.Types = types
h.renderForm(w, base, p, true, errs)
return
}
if err := h.PanelRepo.Create(ctx, p); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
h.renderForm(w, base, p, true, map[string]string{"name": "Ce nom est déjà utilisé."})
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if len(types) > 0 {
if err := h.PanelRepo.ReplaceTypes(ctx, p.ID, types); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
if r.FormValue("action") == "save_send" && h.BotService != nil {
_ = h.BotService.SendPanel(p.ID)
}
http.Redirect(w, r, "/panels", http.StatusSeeOther)
}
// HandleEdit serves GET /panels/{id}/edit.
func (h *PanelsHandler) HandleEdit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
p, err := h.PanelRepo.GetByID(ctx, id)
if err != nil || p == nil {
http.NotFound(w, r)
return
}
base := h.Renderer.base(r, "panels")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
h.renderForm(w, base, p, false, nil)
}
// HandleUpdate serves POST /panels/{id}/edit.
func (h *PanelsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
base := h.Renderer.base(r, "panels")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
p, types, errs := parsePanel(r)
p.ID = id
if len(errs) > 0 {
p.Types = types
h.renderForm(w, base, p, false, errs)
return
}
if err := h.PanelRepo.Update(ctx, p); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := h.PanelRepo.ReplaceTypes(ctx, id, types); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if r.FormValue("action") == "save_send" && h.BotService != nil {
_ = h.BotService.SendPanel(id)
}
http.Redirect(w, r, "/panels", http.StatusSeeOther)
}
// HandleDelete serves POST /panels/{id}/delete.
func (h *PanelsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err := h.PanelRepo.Delete(ctx, id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/panels", http.StatusSeeOther)
}
// HandleDuplicate serves POST /panels/{id}/duplicate.
func (h *PanelsHandler) HandleDuplicate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
src, err := h.PanelRepo.GetByID(ctx, id)
if err != nil || src == nil {
http.NotFound(w, r)
return
}
copyP := &db.PanelConfig{
Name: src.Name + "-copie",
EmbedTitle: src.EmbedTitle,
EmbedDescription: src.EmbedDescription,
EmbedColor: src.EmbedColor,
GuildID: src.GuildID,
}
if err := h.PanelRepo.Create(ctx, copyP); err != nil {
http.Error(w, "duplicate failed: "+err.Error(), http.StatusInternalServerError)
return
}
// Copy types (without IDs so they get fresh ones)
var newTypes []*db.PanelType
for _, t := range src.Types {
newTypes = append(newTypes, &db.PanelType{
Name: t.Name, ButtonLabel: t.ButtonLabel, ButtonColor: t.ButtonColor,
ButtonEmoji: t.ButtonEmoji, EmbedColor: t.EmbedColor, EmbedTitle: t.EmbedTitle,
EmbedText: t.EmbedText, StaffRoleID: t.StaffRoleID, CategoryID: t.CategoryID,
ClaimChannelID: t.ClaimChannelID, LogChannelID: t.LogChannelID,
MaxPerUser: t.MaxPerUser, ClaimMode: t.ClaimMode, CloseRule: t.CloseRule,
ModalEnabled: t.ModalEnabled, SortOrder: t.SortOrder,
})
}
if len(newTypes) > 0 {
_ = h.PanelRepo.ReplaceTypes(ctx, copyP.ID, newTypes)
}
http.Redirect(w, r, fmt.Sprintf("/panels/%d/edit", copyP.ID), http.StatusSeeOther)
}
// HandlePreview serves POST /panels/preview — returns an HTML fragment.
func (h *PanelsHandler) HandlePreview(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
color := r.FormValue("embed_color")
if color == "" {
color = "#5865f2"
}
title := r.FormValue("embed_title")
if title == "" {
title = "Titre du panel"
}
desc := r.FormValue("embed_description")
type btn struct{ Label, Color, Emoji string }
var buttons []btn
for i := 0; i < 25; i++ {
label := r.FormValue(fmt.Sprintf("types[%d][button_label]", i))
if label == "" {
break
}
buttons = append(buttons, btn{
Label: label,
Color: r.FormValue(fmt.Sprintf("types[%d][button_color]", i)),
Emoji: r.FormValue(fmt.Sprintf("types[%d][button_emoji]", i)),
})
}
btnColorMap := map[string]string{
"primary": "bg-indigo-600", "secondary": "bg-gray-600",
"success": "bg-green-600", "danger": "bg-red-600",
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="rounded-lg overflow-hidden border-l-4 bg-[#2b2d31] p-4" style="border-left-color:%s">`, color)
fmt.Fprintf(w, `<p class="font-semibold text-white mb-1">%s</p>`, title)
if desc != "" {
fmt.Fprintf(w, `<p class="text-gray-300 text-sm mb-3">%s</p>`, desc)
}
if len(buttons) > 0 {
fmt.Fprint(w, `<div class="flex flex-wrap gap-2 mt-2">`)
for _, b := range buttons {
cls := btnColorMap[b.Color]
if cls == "" {
cls = "bg-indigo-600"
}
fmt.Fprintf(w, `<span class="%s text-white text-sm px-3 py-1.5 rounded">%s %s</span>`, cls, b.Emoji, b.Label)
}
fmt.Fprint(w, `</div>`)
}
fmt.Fprint(w, `</div>`)
}
// HandleReorderTypes serves POST /panels/{id}/types/reorder.
func (h *PanelsHandler) HandleReorderTypes(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var ids []int64
for _, s := range r.Form["ids[]"] {
n, _ := strconv.ParseInt(s, 10, 64)
ids = append(ids, n)
}
if err := h.PanelRepo.ReorderTypes(ctx, id, ids); err != nil {
http.Error(w, "reorder failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PanelsHandler) renderForm(w http.ResponseWriter, base baseData, p *db.PanelConfig, isNew bool, errs map[string]string) {
if errs == nil {
errs = map[string]string{}
}
var roles []GuildRole
var textChans, categories []GuildChannel
var guilds []GuildInfo
if h.DiscordInfo != nil {
guilds = h.DiscordInfo.GetGuildList()
gid := p.GuildID
if gid == "" && len(guilds) > 0 {
gid = guilds[0].ID
p.GuildID = gid
}
if gid != "" {
roles, _ = h.DiscordInfo.GetGuildRoles(gid)
chans, _ := h.DiscordInfo.GetGuildChannels(gid)
for _, c := range chans {
switch c.Type {
case 0:
textChans = append(textChans, c)
case 4:
categories = append(categories, c)
}
}
}
}
h.Renderer.Page(w, "panel_form", struct {
baseData
Panel *db.PanelConfig
IsNew bool
Errors map[string]string
Guilds []GuildInfo
Roles []GuildRole
TextChans []GuildChannel
Categories []GuildChannel
}{
baseData: base,
Panel: p,
IsNew: isNew,
Errors: errs,
Guilds: guilds,
Roles: roles,
TextChans: textChans,
Categories: categories,
})
}
// parsePanel extracts a PanelConfig + types from the posted form.
func parsePanel(r *http.Request) (*db.PanelConfig, []*db.PanelType, map[string]string) {
errs := map[string]string{}
p := &db.PanelConfig{
Name: strings.TrimSpace(r.FormValue("name")),
EmbedTitle: strings.TrimSpace(r.FormValue("embed_title")),
EmbedDescription: db.NullStr(strings.TrimSpace(r.FormValue("embed_description"))),
EmbedColor: strings.TrimSpace(r.FormValue("embed_color")),
EmbedImage: strings.TrimSpace(r.FormValue("embed_image")),
EmbedThumbnail: strings.TrimSpace(r.FormValue("embed_thumbnail")),
GuildID: strings.TrimSpace(r.FormValue("guild_id")),
ChannelID: db.NullStr(strings.TrimSpace(r.FormValue("channel_id"))),
}
if p.Name == "" {
errs["name"] = "Le nom est requis."
}
if p.EmbedTitle == "" {
errs["embed_title"] = "Le titre est requis."
}
if p.EmbedColor == "" {
p.EmbedColor = "#5865f2"
}
var types []*db.PanelType
for i := 0; i < 25; i++ {
prefix := fmt.Sprintf("types[%d]", i)
name := strings.TrimSpace(r.FormValue(prefix + "[name]"))
if name == "" {
break
}
claimMode := 0
if r.FormValue(prefix+"[claim_mode]") == "1" {
claimMode = 1
}
claimReupMinutes, _ := strconv.Atoi(r.FormValue(prefix + "[claim_reup_minutes]"))
modalEnabled := r.FormValue(prefix+"[modal_enabled]") != "0"
closeRule := r.FormValue(prefix + "[close_rule]")
if closeRule == "" {
closeRule = "staff_only"
}
maxPU, _ := strconv.Atoi(r.FormValue(prefix + "[max_per_user]"))
if maxPU <= 0 {
maxPU = 1
}
embedColor := strings.TrimSpace(r.FormValue(prefix + "[embed_color]"))
if embedColor == "" {
embedColor = "#5865f2"
}
buttonColor := strings.TrimSpace(r.FormValue(prefix + "[button_color]"))
if buttonColor == "" {
buttonColor = "primary"
}
types = append(types, &db.PanelType{
Name: name,
ButtonLabel: strings.TrimSpace(r.FormValue(prefix + "[button_label]")),
ButtonColor: buttonColor,
ButtonEmoji: strings.TrimSpace(r.FormValue(prefix + "[button_emoji]")),
EmbedColor: embedColor,
EmbedTitle: strings.TrimSpace(r.FormValue(prefix + "[embed_title]")),
EmbedText: strings.TrimSpace(r.FormValue(prefix + "[embed_text]")),
ThumbnailURL: strings.TrimSpace(r.FormValue(prefix + "[thumbnail_url]")),
ImageURL: strings.TrimSpace(r.FormValue(prefix + "[image_url]")),
StaffRoleID: strings.TrimSpace(r.FormValue(prefix + "[staff_role_id]")),
CategoryID: strings.TrimSpace(r.FormValue(prefix + "[category_id]")),
ClaimChannelID: strings.TrimSpace(r.FormValue(prefix + "[claim_channel_id]")),
LogChannelID: strings.TrimSpace(r.FormValue(prefix + "[log_channel_id]")),
MaxPerUser: maxPU,
ClaimMode: claimMode,
ClaimReupMinutes: claimReupMinutes,
CloseRule: closeRule,
ModalEnabled: modalEnabled,
SortOrder: i,
})
}
p.Types = types
return p, types, errs
}
+131
View File
@@ -0,0 +1,131 @@
package handlers
import (
"database/sql"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// SessionsHandler serves /sessions routes (superadmin only).
type SessionsHandler struct {
SessionRepo *db.PanelSessionRepo
AdminRepo *db.PanelAdminRepo
AuditLog *db.AuditLogRepo
Renderer *Renderer
}
// sessionWithAdmin joins a PanelSession with the admin's username.
type sessionWithAdmin struct {
*db.PanelSession
DiscordUsername string
IsCurrent bool
}
type sessionsData struct {
baseData
Sessions []*sessionWithAdmin
}
// HandleList serves GET /sessions.
func (h *SessionsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
base := h.Renderer.base(r, "sessions")
currentSess := panelauth.SessionFromContext(ctx)
currentToken := ""
if currentSess != nil {
currentToken = currentSess.Token
}
sessions, err := h.SessionRepo.List(ctx)
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
var enriched []*sessionWithAdmin
for _, s := range sessions {
swa := &sessionWithAdmin{
PanelSession: s,
IsCurrent: s.Token == currentToken,
}
if admin, err := h.AdminRepo.GetByID(ctx, s.AdminID); err == nil && admin != nil {
swa.DiscordUsername = admin.DiscordUsername
}
enriched = append(enriched, swa)
}
h.Renderer.Page(w, "sessions", sessionsData{
baseData: base,
Sessions: enriched,
})
}
// HandleRevoke serves POST /sessions/{id}/revoke.
func (h *SessionsHandler) HandleRevoke(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if err := h.SessionRepo.DeleteByID(ctx, id); err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
// Audit log
if h.AuditLog != nil {
admin := panelauth.AdminFromContext(ctx)
if admin != nil {
_ = h.AuditLog.Insert(ctx, &db.AuditLogEntry{
AdminID: admin.ID,
Action: db.AuditRevokeSession,
EntityType: "panel_session",
EntityID: sql.NullInt64{Int64: id, Valid: true},
IPAddress: clientIP(r),
})
}
}
http.Redirect(w, r, "/sessions?flash=Session+révoquée", http.StatusSeeOther)
}
// HandleRevokeAll serves POST /sessions/revoke-all.
func (h *SessionsHandler) HandleRevokeAll(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
admin := panelauth.AdminFromContext(ctx)
if admin == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
currentSess := panelauth.SessionFromContext(ctx)
currentToken := ""
if currentSess != nil {
currentToken = currentSess.Token
}
if err := h.SessionRepo.DeleteAllExcept(ctx, admin.ID, currentToken); err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
// Audit log
if h.AuditLog != nil {
_ = h.AuditLog.Insert(ctx, &db.AuditLogEntry{
AdminID: admin.ID,
Action: db.AuditRevokeAllSess,
EntityType: "panel_session",
IPAddress: clientIP(r),
})
}
http.Redirect(w, r, "/sessions?flash=Toutes+les+sessions+révoquées", http.StatusSeeOther)
}
+188
View File
@@ -0,0 +1,188 @@
package handlers
import (
"fmt"
"net/http"
"os"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/leolionad58/ticketbot/internal/db"
)
// TranscriptsHandler serves /transcripts routes.
type TranscriptsHandler struct {
TicketRepo *db.TicketRepo
Renderer *Renderer
DiscordInfo DiscordInfo
}
type transcriptsData struct {
baseData
Tickets []*db.Ticket
Total int
Page int
Pages int
Types []string
StaffList []string
Guilds []GuildInfo
GuildNames map[string]string
// Filter values echoed back to the form
FStatus string
FType string
FStaff string
FGuild string
FFrom string
FTo string
FSearch string
}
// HandleList serves GET /transcripts with search/filter/pagination.
func (h *TranscriptsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
base := h.Renderer.base(r, "transcripts")
q := r.URL.Query()
fStatus := q.Get("status")
fType := q.Get("type")
fStaff := q.Get("staff")
fGuild := q.Get("guild")
fFrom := q.Get("from")
fTo := q.Get("to")
fSearch := q.Get("search")
page, _ := strconv.Atoi(q.Get("page"))
if page < 1 {
page = 1
}
filter := db.TicketFilter{
Status: fStatus,
Type: fType,
StaffID: fStaff,
GuildID: fGuild,
Search: fSearch,
Page: page,
PageSize: 20,
}
if fFrom != "" {
if t, err := time.Parse("2006-01-02", fFrom); err == nil {
filter.From = t
}
}
if fTo != "" {
if t, err := time.Parse("2006-01-02", fTo); err == nil {
filter.To = t.Add(24*time.Hour - time.Second)
}
}
tickets, total, err := h.TicketRepo.ListFiltered(ctx, filter)
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
pages := total / 20
if total%20 != 0 {
pages++
}
if pages < 1 {
pages = 1
}
types, _ := h.TicketRepo.ListDistinctTypes(ctx)
staff, _ := h.TicketRepo.ListDistinctStaff(ctx)
var guilds []GuildInfo
guildNames := map[string]string{}
if h.DiscordInfo != nil {
guilds = h.DiscordInfo.GetGuildList()
for _, g := range guilds {
guildNames[g.ID] = g.Name
}
}
h.Renderer.Page(w, "transcripts", transcriptsData{
baseData: base,
Tickets: tickets,
Total: total,
Page: page,
Pages: pages,
Types: types,
StaffList: staff,
Guilds: guilds,
GuildNames: guildNames,
FStatus: fStatus,
FType: fType,
FStaff: fStaff,
FGuild: fGuild,
FFrom: fFrom,
FTo: fTo,
FSearch: fSearch,
})
}
// HandleView serves GET /transcripts/view/{id} — serves the HTML transcript file directly.
// This handler must NOT be wrapped with securityHeaders to avoid CSP restrictions.
func (h *TranscriptsHandler) HandleView(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
ticket, err := h.TicketRepo.GetByID(ctx, id)
if err != nil || ticket == nil {
http.NotFound(w, r)
return
}
if !ticket.TranscriptPath.Valid || ticket.TranscriptPath.String == "" {
http.Error(w, "no transcript for this ticket", http.StatusNotFound)
return
}
path := ticket.TranscriptPath.String
data, err := os.ReadFile(path)
if err != nil {
http.Error(w, fmt.Sprintf("transcript file not found: %s", path), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
// HandleDownload serves GET /transcripts/download/{id} — serves the HTML transcript as attachment.
func (h *TranscriptsHandler) HandleDownload(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
ticket, err := h.TicketRepo.GetByID(ctx, id)
if err != nil || ticket == nil {
http.NotFound(w, r)
return
}
if !ticket.TranscriptPath.Valid || ticket.TranscriptPath.String == "" {
http.Error(w, "no transcript for this ticket", http.StatusNotFound)
return
}
path := ticket.TranscriptPath.String
data, err := os.ReadFile(path)
if err != nil {
http.Error(w, fmt.Sprintf("transcript file not found: %s", path), http.StatusNotFound)
return
}
filename := fmt.Sprintf("transcript_ticket_%d.html", ticket.TicketNumber)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
+71
View File
@@ -0,0 +1,71 @@
package panel
import (
"context"
"log/slog"
"time"
"github.com/leolionad58/ticketbot/internal/config"
"github.com/leolionad58/ticketbot/internal/db"
)
// MigrateYAMLPanels copies panel configuration from the YAML config into panel_configs and
// panel_types tables. It runs only once: if panel_configs is already non-empty it is a no-op.
func MigrateYAMLPanels(ctx context.Context, repo *db.PanelConfigRepo, cfg *config.Config, guildID string) error {
empty, err := repo.IsEmpty(ctx)
if err != nil {
return err
}
if !empty || len(cfg.Panels) == 0 {
return nil
}
slog.Info("migrating YAML panels to DB", "count", len(cfg.Panels))
order := 0
for panelName, panel := range cfg.Panels {
pc := &db.PanelConfig{
Name: panelName,
EmbedTitle: panel.EmbedTitle,
EmbedColor: panel.EmbedColor,
GuildID: guildID,
SortOrder: order,
}
if panel.EmbedDescription != "" {
pc.EmbedDescription.String = panel.EmbedDescription
pc.EmbedDescription.Valid = true
}
if err := repo.Create(ctx, pc); err != nil {
return err
}
typeOrder := 0
for typeName, t := range panel.Types {
pt := &db.PanelType{
PanelID: pc.ID,
Name: typeName,
ButtonLabel: t.ButtonLabel,
ButtonColor: string(t.ButtonColor),
ButtonEmoji: t.ButtonEmoji,
EmbedColor: t.EmbedColor,
EmbedTitle: t.EmbedTitle,
EmbedText: t.EmbedText,
StaffRoleID: t.StaffRole,
CategoryID: t.Category,
MaxPerUser: 1,
ClaimMode: 1,
CloseRule: "staff_only",
ModalEnabled: true,
SortOrder: typeOrder,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if err := repo.CreateType(ctx, pt); err != nil {
return err
}
typeOrder++
}
order++
}
slog.Info("YAML panels migrated to DB", "panels", len(cfg.Panels))
return nil
}
+208
View File
@@ -0,0 +1,208 @@
package panel
import (
"context"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/leolionad58/ticketbot/internal/config"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
"github.com/leolionad58/ticketbot/internal/panel/handlers"
)
// Server is the HTTP panel server.
type Server struct {
http *http.Server
}
// NewServer builds the panel HTTP server with all routes wired up.
func NewServer(
cfg config.PanelWebConfig,
sqlDB *db.TicketRepo,
adminRepo *db.PanelAdminRepo,
sessionRepo *db.PanelSessionRepo,
auditRepo *db.AuditLogRepo,
panelRepo *db.PanelConfigRepo,
convocRepo *db.ConvocationConfigRepo,
guildID string,
bot BotService,
) *Server {
authSvc := panelauth.NewService(adminRepo, sessionRepo, cfg.SessionTimeoutMinutes)
authMw := panelauth.NewMiddleware(authSvc, adminRepo)
oauthCfg := panelauth.NewOAuthConfig(
cfg.OAuthClientID,
cfg.OAuthClientSecret,
cfg.BaseURL+"/oauth/callback",
)
rnd := handlers.NewRenderer(ParseTemplates)
if bot != nil {
rnd.SetBotStatus(bot.GetBotStatus)
}
loginH := &handlers.LoginHandler{
Admins: adminRepo,
Auth: authSvc,
OAuthCfg: oauthCfg,
Renderer: rnd,
}
authH := &handlers.AuthHandler{
Admins: adminRepo,
Auth: authSvc,
AuditLog: auditRepo,
Issuer: "TicketBot",
Renderer: rnd,
}
var getBotStatus func() handlers.BotStatus
if bot != nil {
getBotStatus = bot.GetBotStatus
}
dashH := &handlers.DashboardHandler{
TicketRepo: sqlDB,
PanelRepo: panelRepo,
GetBotStatus: getBotStatus,
Renderer: rnd,
}
var discordInfo handlers.DiscordInfo
if bot != nil {
if di, ok := bot.(handlers.DiscordInfo); ok {
discordInfo = di
}
}
panelsH := &handlers.PanelsHandler{
PanelRepo: panelRepo,
AuditLog: auditRepo,
BotService: bot,
DiscordInfo: discordInfo,
Renderer: rnd,
}
var convocBot handlers.ConvocBotService
var convocInfo handlers.ConvocDiscordInfo
if bot != nil {
convocBot = bot
if di, ok := bot.(handlers.ConvocDiscordInfo); ok {
convocInfo = di
}
}
convocH := &handlers.ConvocationsHandler{
TicketRepo: sqlDB,
ConvocRepo: convocRepo,
GuildID: guildID,
BotService: convocBot,
DiscordInfo: convocInfo,
Renderer: rnd,
}
transcriptsH := &handlers.TranscriptsHandler{
TicketRepo: sqlDB,
Renderer: rnd,
DiscordInfo: discordInfo,
}
auditH := &handlers.AuditHandler{
AuditRepo: auditRepo,
Renderer: rnd,
}
sessH := &handlers.SessionsHandler{
SessionRepo: sessionRepo,
AdminRepo: adminRepo,
AuditLog: auditRepo,
Renderer: rnd,
}
r := chi.NewRouter()
r.Use(chimiddleware.Recoverer)
r.Use(securityHeaders)
// Static files
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(StaticFS())))
// Transcript view — served without CSP headers (must be outside securityHeaders group)
r.Get("/transcripts/view/{id}", transcriptsH.HandleView)
// Public auth routes
r.Get("/login", loginH.HandleLogin)
r.Get("/oauth/callback", loginH.HandleCallback)
r.Get("/auth/password-setup", authH.HandlePasswordSetupGET)
r.Post("/auth/password-setup", authH.HandlePasswordSetupPOST)
r.Get("/auth/totp-setup", authH.HandleTOTPSetupGET)
r.Post("/auth/totp-setup", authH.HandleTOTPSetupPOST)
r.Get("/auth/verify", authH.HandleVerifyGET)
r.Post("/auth/verify", authH.HandleVerifyPOST)
r.Post("/auth/logout", authH.HandleLogout)
// Protected routes
r.Group(func(r chi.Router) {
r.Use(authMw.RequireAuth)
r.Get("/", dashH.Handle)
// Panels CRUD
r.Get("/panels", panelsH.HandleList)
r.Get("/panels/new", panelsH.HandleNew)
r.Post("/panels/new", panelsH.HandleCreate)
r.Post("/panels/preview", panelsH.HandlePreview)
r.Get("/panels/{id}/edit", panelsH.HandleEdit)
r.Post("/panels/{id}/edit", panelsH.HandleUpdate)
r.Post("/panels/{id}/delete", panelsH.HandleDelete)
r.Post("/panels/{id}/duplicate", panelsH.HandleDuplicate)
r.Post("/panels/{id}/types/reorder", panelsH.HandleReorderTypes)
// Convocations
r.Get("/convocations", convocH.HandleGET)
r.Post("/convocations", convocH.HandlePOST)
r.Post("/convocations/send-panel", convocH.HandleSendPanel)
// Transcripts (list + download only; view is outside securityHeaders)
r.Get("/transcripts", transcriptsH.HandleList)
r.Get("/transcripts/download/{id}", transcriptsH.HandleDownload)
// Audit log
r.Get("/audit", auditH.HandleList)
// Sessions (superadmin only)
r.Group(func(r chi.Router) {
r.Use(authMw.RequireSuperadmin)
r.Get("/sessions", sessH.HandleList)
r.Post("/sessions/{id}/revoke", sessH.HandleRevoke)
r.Post("/sessions/revoke-all", sessH.HandleRevokeAll)
})
})
addr := fmt.Sprintf(":%d", cfg.Port)
return &Server{
http: &http.Server{
Addr: addr,
Handler: r,
},
}
}
// Start runs the HTTP server until ctx is cancelled.
func (s *Server) Start(ctx context.Context) error {
errCh := make(chan error, 1)
go func() { errCh <- s.http.ListenAndServe() }()
select {
case <-ctx.Done():
return s.http.Shutdown(context.Background())
case err := <-errCh:
return err
}
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.tailwindcss.com https://cdn.jsdelivr.net; "+
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "+
"img-src 'self' https://cdn.discordapp.com data:; frame-src 'self'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "0")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
next.ServeHTTP(w, r)
})
}
+23
View File
@@ -0,0 +1,23 @@
/* Custom styles — Tailwind handles most of the heavy lifting */
/* Scrollbar — Discord-dark style */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #1e1f22; }
::-webkit-scrollbar-thumb { background: #3f4147; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #5865f2; }
/* HTMX loading indicator */
.htmx-indicator { opacity: 0; transition: opacity 150ms; }
.htmx-request .htmx-indicator { opacity: 1; }
/* Color picker alignment */
input[type="color"] { padding: 2px; cursor: pointer; }
/* Toast animations */
@keyframes slideIn { from { transform: translateX(120%); } to { transform: translateX(0); } }
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
.toast-enter { animation: slideIn 0.2s ease-out; }
.toast-leave { animation: fadeOut 0.3s ease-in forwards; }
/* Sidebar active link */
.sidebar-link-active { background-color: #404249; color: #fff; }
+103
View File
@@ -0,0 +1,103 @@
{{define "title"}}Audit Log{{end}}
{{define "header"}}Audit Log{{end}}
{{define "content"}}
<!-- Filters -->
<form method="GET" action="/audit" class="bg-discord-card rounded-lg border border-discord-border p-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 mb-3">
<div>
<label class="block text-xs text-gray-400 mb-1">Admin ID (interne)</label>
<input type="text" name="admin_id" value="{{.FAdminID}}" placeholder="ex: 1"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Action</label>
<select name="action" class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
<option value="">Toutes les actions</option>
{{range .Actions}}
<option value="{{.}}" {{if eq . $.FAction}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Du</label>
<input type="date" name="from" value="{{.FFrom}}"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Au</label>
<input type="date" name="to" value="{{.FTo}}"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
</div>
</div>
<div class="flex items-center gap-2">
<button type="submit"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors">
Filtrer
</button>
<a href="/audit" class="px-4 py-2 text-sm text-gray-400 hover:text-white border border-discord-border rounded-lg transition-colors">
Réinitialiser
</a>
<span class="text-xs text-gray-500 ml-auto">{{.Total}} entrée{{if gt .Total 1}}s{{end}}</span>
</div>
</form>
<!-- Audit table -->
<div class="bg-discord-card rounded-lg border border-discord-border overflow-hidden">
{{if .Entries}}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase border-b border-discord-border">
<th class="px-4 py-2 text-left">Date</th>
<th class="px-4 py-2 text-left">Admin ID</th>
<th class="px-4 py-2 text-left">Action</th>
<th class="px-4 py-2 text-left">Entité</th>
<th class="px-4 py-2 text-left">Entité ID</th>
<th class="px-4 py-2 text-left">IP</th>
</tr>
</thead>
<tbody class="divide-y divide-discord-border">
{{range .Entries}}
<tr class="hover:bg-white/5 transition-colors">
<td class="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">{{.CreatedAt.Format "02/01/2006 15:04:05"}}</td>
<td class="px-4 py-2 text-gray-300 font-mono text-xs">{{.AdminID}}</td>
<td class="px-4 py-2">
<span class="px-2 py-0.5 rounded text-xs font-mono bg-discord-hover text-indigo-300 border border-discord-border">{{.Action}}</span>
</td>
<td class="px-4 py-2 text-gray-400 text-xs">{{.EntityType}}</td>
<td class="px-4 py-2 text-gray-400 text-xs font-mono">
{{if .EntityID.Valid}}{{.EntityID.Int64}}{{else}}—{{end}}
</td>
<td class="px-4 py-2 text-gray-500 text-xs font-mono">{{.IPAddress}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if gt .Pages 1}}
<div class="px-5 py-3 border-t border-discord-border flex items-center gap-2 text-sm">
{{if gt .Page 1}}
<a href="?page={{add .Page -1}}&admin_id={{.FAdminID}}&action={{.FAction}}&from={{.FFrom}}&to={{.FTo}}"
class="px-3 py-1 rounded border border-discord-border text-gray-300 hover:text-white hover:border-gray-500 transition-colors">← Précédent</a>
{{end}}
<span class="text-gray-400">Page {{.Page}} / {{.Pages}}</span>
{{if lt .Page .Pages}}
<a href="?page={{add .Page 1}}&admin_id={{.FAdminID}}&action={{.FAction}}&from={{.FFrom}}&to={{.FTo}}"
class="px-3 py-1 rounded border border-discord-border text-gray-300 hover:text-white hover:border-gray-500 transition-colors">Suivant →</a>
{{end}}
</div>
{{end}}
{{else}}
<div class="px-5 py-12 text-center text-gray-500 text-sm">
Aucune entrée d'audit pour ces critères.
</div>
{{end}}
</div>
{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{define "auth_layout"}}
<!DOCTYPE html>
<html lang="fr" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}TicketBot Admin{{end}}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/static/app.css">
<script>
tailwind.config = {
theme: { extend: { colors: { discord: { bg: '#1e1f22', card: '#2b2d31', accent: '#5865f2', hover: '#404249', border: '#3f4147' } } } }
}
</script>
</head>
<body class="h-full bg-discord-bg text-gray-100 flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white">TicketBot</h1>
<p class="text-gray-400 text-sm mt-1">Panel d'administration</p>
</div>
<div class="bg-discord-card rounded-lg shadow-xl p-8 border border-discord-border">
{{block "content" .}}{{end}}
</div>
</div>
</body>
</html>
{{end}}
+158
View File
@@ -0,0 +1,158 @@
{{define "title"}}Convocations{{end}}
{{define "header"}}Convocations{{end}}
{{define "content"}}
<!-- Config form -->
<div class="bg-discord-card rounded-lg border border-discord-border p-5 mb-6">
<h3 class="font-semibold text-white mb-4">Configuration des convocations</h3>
<form method="POST" action="/convocations" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div>
<label class="block text-xs text-gray-400 mb-1">ID de catégorie Discord (pour les salons)</label>
<input type="text" name="category_id" value="{{if .Config}}{{.Config.CategoryID}}{{end}}"
placeholder="ex: 123456789012345678"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">ID du salon de logs</label>
<input type="text" name="log_channel_id" value="{{if .Config}}{{.Config.LogChannelID}}{{end}}"
placeholder="ex: 123456789012345678"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent">
</div>
<div class="sm:col-span-2 flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="hidden" name="modal_enabled" value="0">
<input type="checkbox" name="modal_enabled" value="1"
{{if and .Config .Config.ModalEnabled}}checked{{end}}
class="w-4 h-4 rounded accent-indigo-500">
<span class="text-sm text-gray-300">Activer le modal (titre + description)</span>
</label>
</div>
<div class="sm:col-span-2 border-t border-discord-border pt-4 mt-1">
<p class="text-xs text-gray-400 uppercase font-semibold tracking-wide mb-3">Panel Discord (bouton convocation)</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs text-gray-400 mb-1">Salon d'envoi du panel</label>
<input type="text" name="panel_channel_id" value="{{if .Config}}{{.Config.PanelChannelID}}{{end}}"
placeholder="ex: 123456789012345678"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Couleur de l'embed</label>
<input type="color" name="panel_embed_color" value="{{if and .Config .Config.PanelEmbedColor}}{{.Config.PanelEmbedColor}}{{else}}#5865f2{{end}}"
class="h-10 w-full rounded cursor-pointer bg-discord-hover border border-discord-border">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Titre de l'embed</label>
<input type="text" name="panel_embed_title" value="{{if .Config}}{{.Config.PanelEmbedTitle}}{{else}}Créer une convocation{{end}}"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent"
placeholder="Créer une convocation">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Description de l'embed</label>
<textarea name="panel_embed_description" rows="2"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent resize-none"
placeholder="Cliquez sur le bouton pour ouvrir une convocation...">{{if .Config}}{{.Config.PanelEmbedDescription}}{{end}}</textarea>
</div>
</div>
</div>
<div class="sm:col-span-2 flex items-center gap-3 justify-end">
<button type="submit"
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors">
Sauvegarder
</button>
</div>
</form>
<!-- Send panel button (separate form) -->
{{if and .Config .Config.PanelChannelID}}
<div class="mt-3 pt-3 border-t border-discord-border flex items-center gap-3">
<form method="POST" action="/convocations/send-panel">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors">
Envoyer le panel sur Discord
</button>
</form>
{{if and .Config .Config.PanelMessageID}}
<span class="text-xs text-green-400 bg-green-900/30 px-2 py-1 rounded">Panel envoyé</span>
{{end}}
</div>
{{end}}
</div>
<!-- Convocation list -->
<div class="bg-discord-card rounded-lg border border-discord-border overflow-hidden">
<div class="px-5 py-4 border-b border-discord-border flex items-center justify-between">
<h3 class="font-semibold text-white">Convocations passées</h3>
<span class="text-xs text-gray-400">{{.Total}} au total</span>
</div>
{{if .Tickets}}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase border-b border-discord-border">
<th class="px-4 py-2 text-left">#</th>
<th class="px-4 py-2 text-left">Utilisateur</th>
<th class="px-4 py-2 text-left">Titre</th>
<th class="px-4 py-2 text-left">Staff</th>
<th class="px-4 py-2 text-left">Statut</th>
<th class="px-4 py-2 text-left">Ouvert le</th>
<th class="px-4 py-2 text-left">Fermé le</th>
</tr>
</thead>
<tbody class="divide-y divide-discord-border">
{{range .Tickets}}
<tr class="hover:bg-white/5 transition-colors">
<td class="px-4 py-2 text-indigo-400 font-mono">{{.TicketNumber}}</td>
<td class="px-4 py-2 text-gray-300 font-mono text-xs">{{.UserID}}</td>
<td class="px-4 py-2 text-white">
{{if .TicketTitle.Valid}}{{.TicketTitle.String}}{{else}}<span class="text-gray-500"></span>{{end}}
</td>
<td class="px-4 py-2 text-gray-400 font-mono text-xs">
{{if .ClaimedBy.Valid}}{{.ClaimedBy.String}}{{else}}—{{end}}
</td>
<td class="px-4 py-2">
{{if eq .Status "open"}}
<span class="px-2 py-0.5 rounded text-xs bg-green-900/40 text-green-400 border border-green-800">Ouvert</span>
{{else if eq .Status "claimed"}}
<span class="px-2 py-0.5 rounded text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-800">Claimé</span>
{{else}}
<span class="px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400">Fermé</span>
{{end}}
</td>
<td class="px-4 py-2 text-gray-400 text-xs">{{.OpenedAt.Format "02/01/2006 15:04"}}</td>
<td class="px-4 py-2 text-gray-400 text-xs">
{{if .ClosedAt.Valid}}{{.ClosedAt.Time.Format "02/01/2006 15:04"}}{{else}}—{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if gt .Pages 1}}
<div class="px-5 py-3 border-t border-discord-border flex items-center gap-2 text-sm">
{{if gt .Page 1}}
<a href="?page={{add .Page -1}}" class="px-3 py-1 rounded border border-discord-border text-gray-300 hover:text-white hover:border-gray-500 transition-colors">← Précédent</a>
{{end}}
<span class="text-gray-400">Page {{.Page}} / {{.Pages}}</span>
{{if lt .Page .Pages}}
<a href="?page={{add .Page 1}}" class="px-3 py-1 rounded border border-discord-border text-gray-300 hover:text-white hover:border-gray-500 transition-colors">Suivant →</a>
{{end}}
</div>
{{end}}
{{else}}
<div class="px-5 py-12 text-center text-gray-500 text-sm">
Aucune convocation enregistrée.
</div>
{{end}}
</div>
{{end}}
+140
View File
@@ -0,0 +1,140 @@
{{define "title"}}Dashboard{{end}}
{{define "header"}}Dashboard{{end}}
{{define "content"}}
<!-- Stat cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
<div class="bg-discord-card rounded-lg p-5 border border-discord-border">
<p class="text-xs text-gray-400 uppercase tracking-wide mb-1">Tickets ouverts</p>
<p class="text-3xl font-bold text-white">{{.Stats.OpenTickets}}</p>
</div>
<div class="bg-discord-card rounded-lg p-5 border border-discord-border">
<p class="text-xs text-gray-400 uppercase tracking-wide mb-1">Fermés (30j)</p>
<p class="text-3xl font-bold text-white">{{.Stats.ClosedLast30}}</p>
</div>
<div class="bg-discord-card rounded-lg p-5 border border-discord-border">
<p class="text-xs text-gray-400 uppercase tracking-wide mb-1">Moy. claim (30j)</p>
<p class="text-3xl font-bold text-white">
{{if .Stats.AvgClaimMinutes}}{{.Stats.AvgClaimMinutes}}<span class="text-lg text-gray-400">min</span>{{else}}—{{end}}
</p>
</div>
<div class="bg-discord-card rounded-lg p-5 border border-discord-border">
<p class="text-xs text-gray-400 uppercase tracking-wide mb-1">Moy. résolution (30j)</p>
<p class="text-3xl font-bold text-white">
{{if .Stats.AvgResolutionMinutes}}{{.Stats.AvgResolutionMinutes}}<span class="text-lg text-gray-400">min</span>{{else}}—{{end}}
</p>
</div>
</div>
<!-- Chart + Bot status -->
<div class="grid grid-cols-1 xl:grid-cols-4 gap-6 mb-6">
<!-- Chart tickets / jour -->
<div class="xl:col-span-3 bg-discord-card rounded-lg p-5 border border-discord-border">
<h3 class="font-semibold text-white mb-4">Tickets ouverts — 30 derniers jours</h3>
{{if .Chart}}
<div class="flex items-end gap-1 h-32">
{{range .Chart}}
<div class="flex-1 flex flex-col items-center gap-1 group relative">
<div class="w-full rounded-t bg-indigo-500/70 hover:bg-indigo-400 transition-colors"
style="height: {{if $.ChartMax}}{{percent .Count $.ChartMax}}%{{else}}0%{{end}}; min-height: {{if .Count}}2px{{else}}0{{end}}">
</div>
<span class="text-[9px] text-gray-500 rotate-45 origin-left whitespace-nowrap">{{.Day}}</span>
<span class="absolute bottom-8 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-xs px-1.5 py-0.5 rounded opacity-0 group-hover:opacity-100 pointer-events-none">{{.Count}}</span>
</div>
{{end}}
</div>
{{else}}
<p class="text-gray-500 text-sm">Pas encore de données.</p>
{{end}}
</div>
<!-- Bot status -->
<div class="bg-discord-card rounded-lg p-5 border border-discord-border">
<h3 class="font-semibold text-white mb-4">Statut du bot</h3>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-400">Connexion</dt>
<dd class="{{if .BotOnline}}text-green-400{{else}}text-red-400{{end}} font-medium flex items-center gap-1">
<span class="w-2 h-2 rounded-full {{if .BotOnline}}bg-green-400{{else}}bg-red-400{{end}}"></span>
{{if .BotOnline}}En ligne{{else}}Hors ligne{{end}}
</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-400">Uptime</dt>
<dd class="text-white">{{.Uptime}}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-400">Latence</dt>
<dd class="text-white">{{if .LatencyMs}}{{.LatencyMs}}ms{{else}}—{{end}}</dd>
</div>
{{if .GuildName}}
<div class="flex justify-between">
<dt class="text-gray-400">Serveur</dt>
<dd class="text-white truncate max-w-[120px]">{{.GuildName}}</dd>
</div>
{{end}}
</dl>
</div>
</div>
<!-- Staff table + Panels -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Staff stats -->
<div class="xl:col-span-2 bg-discord-card rounded-lg border border-discord-border overflow-hidden">
<div class="px-5 py-4 border-b border-discord-border">
<h3 class="font-semibold text-white">Tickets par staff (30j)</h3>
</div>
{{if .Staff}}
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase">
<th class="px-5 py-2 text-left">Staff</th>
<th class="px-4 py-2 text-right">Claimés</th>
<th class="px-4 py-2 text-right">Fermés</th>
<th class="px-4 py-2 text-right">Moy. claim</th>
<th class="px-4 py-2 text-right">Moy. résol.</th>
</tr>
</thead>
<tbody class="divide-y divide-discord-border">
{{range .Staff}}
<tr class="hover:bg-white/5 transition-colors">
<td class="px-5 py-3 text-white font-mono text-xs">{{.StaffID}}</td>
<td class="px-4 py-3 text-right text-indigo-400 font-semibold">{{.Claimed}}</td>
<td class="px-4 py-3 text-right text-gray-300">{{.Closed}}</td>
<td class="px-4 py-3 text-right text-gray-400">{{fmtMin .AvgClaimMinutes}}</td>
<td class="px-4 py-3 text-right text-gray-400">{{fmtMin .AvgResolutionMinutes}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="px-5 py-8 text-gray-500 text-sm text-center">Aucun ticket claimé sur les 30 derniers jours.</p>
{{end}}
</div>
<!-- Panels actifs -->
<div class="bg-discord-card rounded-lg border border-discord-border overflow-hidden">
<div class="px-5 py-4 border-b border-discord-border flex items-center justify-between">
<h3 class="font-semibold text-white">Panels</h3>
<a href="/panels/new" class="text-xs text-indigo-400 hover:text-indigo-300">+ Créer</a>
</div>
{{if .Panels}}
<ul class="divide-y divide-discord-border">
{{range .Panels}}
<li class="px-5 py-3 flex items-center justify-between text-sm">
<span class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color:{{.EmbedColor}}"></span>
<span class="text-white font-medium">{{.Name}}</span>
<span class="text-gray-500">{{len .Types}}</span>
</span>
<a href="/panels/{{.ID}}/edit" class="text-indigo-400 hover:text-indigo-300">Éditer →</a>
</li>
{{end}}
</ul>
{{else}}
<p class="px-5 py-8 text-gray-500 text-sm text-center">
<a href="/panels/new" class="text-indigo-400 hover:text-indigo-300">Créer un panel →</a>
</p>
{{end}}
</div>
</div>
{{end}}
+13
View File
@@ -0,0 +1,13 @@
{{define "content"}}
<div class="text-center py-4">
<div class="text-5xl mb-4">🚫</div>
<h2 class="text-xl font-semibold text-white mb-2">Accès refusé</h2>
<p class="text-gray-400 text-sm mb-6">
Ton compte Discord n'est pas autorisé à accéder à ce panel.<br>
Contacte un administrateur pour obtenir l'accès.
</p>
<a href="/login" class="text-indigo-400 hover:text-indigo-300 text-sm transition-colors">
← Retour à la connexion
</a>
</div>
{{end}}
+125
View File
@@ -0,0 +1,125 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="fr" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{if .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}">{{end}}
<title>{{block "title" .}}Dashboard{{end}} — TicketBot Admin</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<link rel="stylesheet" href="/static/app.css">
<script>
tailwind.config = {
theme: { extend: { colors: { discord: { bg: '#1e1f22', card: '#2b2d31', accent: '#5865f2', hover: '#404249', border: '#3f4147', sidebar: '#1e1f22' } } } }
}
// HTMX CSRF
document.addEventListener('DOMContentLoaded', function() {
document.body.addEventListener('htmx:configRequest', function(e) {
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) e.detail.headers['X-CSRF-Token'] = meta.content;
});
});
</script>
</head>
<body class="h-full bg-discord-bg text-gray-100 flex overflow-hidden">
<!-- Sidebar -->
<aside class="w-60 flex-shrink-0 bg-discord-sidebar flex flex-col h-screen border-r border-discord-border">
<div class="p-4 border-b border-discord-border">
<span class="font-bold text-white text-lg">TicketBot</span>
<span class="text-xs text-gray-400 block">Admin Panel</span>
</div>
<nav class="flex-1 overflow-y-auto p-2 space-y-1">
<a href="/" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded text-gray-300 hover:bg-discord-hover hover:text-white transition-colors {{if eq .ActivePage "dashboard"}}sidebar-link-active{{end}}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
Dashboard
</a>
<a href="/panels" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded text-gray-300 hover:bg-discord-hover hover:text-white transition-colors {{if eq .ActivePage "panels"}}sidebar-link-active{{end}}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
Panels
</a>
<a href="/convocations" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded text-gray-300 hover:bg-discord-hover hover:text-white transition-colors {{if eq .ActivePage "convocations"}}sidebar-link-active{{end}}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3"/></svg>
Convocations
</a>
<a href="/transcripts" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded text-gray-300 hover:bg-discord-hover hover:text-white transition-colors {{if eq .ActivePage "transcripts"}}sidebar-link-active{{end}}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Transcripts
</a>
<a href="/audit" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded text-gray-300 hover:bg-discord-hover hover:text-white transition-colors {{if eq .ActivePage "audit"}}sidebar-link-active{{end}}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
Audit Log
</a>
{{if .Admin}}{{if .Admin.IsSuperadmin}}
<a href="/sessions" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded text-gray-300 hover:bg-discord-hover hover:text-white transition-colors {{if eq .ActivePage "sessions"}}sidebar-link-active{{end}}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
Sessions
</a>
{{end}}{{end}}
</nav>
<!-- Admin + logout -->
<div class="p-3 border-t border-discord-border">
{{if .Admin}}
<div class="flex items-center gap-2 mb-2">
{{if .Admin.DiscordAvatar}}
<img src="https://cdn.discordapp.com/avatars/{{.Admin.DiscordID}}/{{.Admin.DiscordAvatar}}.png?size=32"
class="w-8 h-8 rounded-full" alt="" onerror="this.style.display='none'">
{{end}}
<div class="min-w-0">
<p class="text-sm font-medium text-white truncate">{{.Admin.DiscordUsername}}</p>
{{if .Admin.IsSuperadmin}}<p class="text-xs text-discord-accent">Superadmin</p>{{end}}
</div>
</div>
{{end}}
<form method="POST" action="/auth/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="w-full text-left flex items-center gap-2 px-2 py-1.5 rounded text-gray-400 hover:text-red-400 hover:bg-discord-hover transition-colors text-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
Déconnexion
</button>
</form>
</div>
</aside>
<!-- Main content -->
<div class="flex-1 flex flex-col min-w-0 h-screen overflow-hidden">
<!-- Header -->
<header class="h-14 flex items-center justify-between px-6 border-b border-discord-border bg-discord-card flex-shrink-0">
<h2 class="font-semibold text-white">{{block "header" .}}Dashboard{{end}}</h2>
<div class="flex items-center gap-3">
{{if .BotOnline}}
<span class="flex items-center gap-1.5 text-sm text-green-400">
<span class="w-2 h-2 bg-green-400 rounded-full"></span>Bot en ligne
</span>
{{else}}
<span class="flex items-center gap-1.5 text-sm text-red-400">
<span class="w-2 h-2 bg-red-400 rounded-full"></span>Bot hors ligne
</span>
{{end}}
</div>
</header>
<!-- Page content -->
<main class="flex-1 overflow-y-auto p-6">
{{if .Flash}}
<div id="flash" class="mb-4 px-4 py-3 rounded bg-green-900/50 border border-green-700 text-green-300 text-sm toast-enter">
{{.Flash}}
</div>
{{end}}
{{if .Error}}
<div id="error-msg" class="mb-4 px-4 py-3 rounded bg-red-900/50 border border-red-700 text-red-300 text-sm toast-enter">
{{.Error}}
</div>
{{end}}
{{block "content" .}}{{end}}
</main>
</div>
<!-- Toast container for HTMX OOB swaps -->
<div id="toast-container" class="fixed bottom-4 right-4 space-y-2 z-50"></div>
</body>
</html>
{{end}}
+20
View File
@@ -0,0 +1,20 @@
{{define "content"}}
<div class="text-center">
<h2 class="text-xl font-semibold text-white mb-2">Connexion</h2>
<p class="text-gray-400 text-sm mb-8">Connecte-toi avec ton compte Discord pour accéder au panel.</p>
{{if .Error}}
<div class="mb-4 px-4 py-3 rounded bg-red-900/50 border border-red-700 text-red-300 text-sm text-left">
{{.Error}}
</div>
{{end}}
<a href="{{.OAuthURL}}"
class="flex items-center justify-center gap-3 w-full px-4 py-3 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors">
<svg class="w-5 h-5" viewBox="0 0 127.14 96.36" fill="currentColor">
<path d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/>
</svg>
Se connecter avec Discord
</a>
</div>
{{end}}
+431
View File
@@ -0,0 +1,431 @@
{{define "title"}}{{if .IsNew}}Nouveau panel{{else}}Éditer — {{.Panel.Name}}{{end}}{{end}}
{{define "header"}}{{if .IsNew}}Créer un panel{{else}}Éditer {{.Panel.Name}}{{end}}{{end}}
{{define "content"}}
<form id="panelForm" method="POST" action="{{if .IsNew}}/panels/new{{else}}/panels/{{.Panel.ID}}/edit{{end}}">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{if and .IsNew .Guilds}}
<div class="bg-discord-card rounded-lg border border-discord-border p-4 mb-4 flex items-center gap-4">
<label class="text-sm text-gray-300 flex-shrink-0">Serveur Discord :</label>
<select id="guildSelector" class="px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white text-sm focus:outline-none focus:border-indigo-500"
onchange="location.href='/panels/new?guild_id='+this.value">
{{range .Guilds}}
<option value="{{.ID}}" {{if eq .ID $.Panel.GuildID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<p class="text-xs text-gray-500">Les rôles et channels chargés correspondent au serveur sélectionné.</p>
</div>
{{end}}
<input type="hidden" name="guild_id" value="{{.Panel.GuildID}}">
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Left: form -->
<div class="xl:col-span-2 space-y-6">
<!-- Section 1: Infos panel -->
<div class="bg-discord-card rounded-lg border border-discord-border p-6">
<h2 class="font-semibold text-white mb-4">Informations du panel</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="sm:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Nom interne <span class="text-red-400">*</span></label>
<input type="text" name="name" value="{{.Panel.Name}}" required {{if not .IsNew}}readonly{{end}}
class="w-full px-3 py-2 bg-gray-800 border {{if index .Errors "name"}}border-red-600{{else}}border-gray-600{{end}} rounded-lg text-white focus:outline-none focus:border-indigo-500 {{if not .IsNew}}opacity-60 cursor-not-allowed{{end}}">
{{if index .Errors "name"}}<p class="text-red-400 text-xs mt-1">{{index .Errors "name"}}</p>{{end}}
</div>
<div class="sm:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Titre embed <span class="text-red-400">*</span></label>
<input type="text" name="embed_title" value="{{.Panel.EmbedTitle}}" required
hx-post="/panels/preview" hx-trigger="input changed delay:300ms" hx-include="#panelForm" hx-target="#preview"
class="w-full px-3 py-2 bg-gray-800 border {{if index .Errors "embed_title"}}border-red-600{{else}}border-gray-600{{end}} rounded-lg text-white focus:outline-none focus:border-indigo-500">
{{if index .Errors "embed_title"}}<p class="text-red-400 text-xs mt-1">{{index .Errors "embed_title"}}</p>{{end}}
</div>
<div class="sm:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Description embed</label>
<textarea name="embed_description" rows="3"
hx-post="/panels/preview" hx-trigger="input changed delay:300ms" hx-include="#panelForm" hx-target="#preview"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500 resize-none">{{.Panel.EmbedDescription.String}}</textarea>
</div>
<div>
<label class="block text-sm text-gray-300 mb-1">Couleur embed</label>
<div class="flex items-center gap-3">
<input type="color" name="embed_color" value="{{.Panel.EmbedColor}}" id="embedColor"
hx-post="/panels/preview" hx-trigger="input changed delay:200ms" hx-include="#panelForm" hx-target="#preview"
class="h-9 w-16 rounded cursor-pointer bg-gray-800 border border-gray-600">
<span id="embedColorText" class="text-sm text-gray-400">{{.Panel.EmbedColor}}</span>
</div>
</div>
<div class="sm:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Image URL (grande image en bas de l'embed)</label>
<input type="url" name="embed_image" value="{{.Panel.EmbedImage}}"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500"
placeholder="https://...">
</div>
<div>
<label class="block text-sm text-gray-300 mb-1">Channel Discord</label>
{{if .TextChans}}
<select name="channel_id" class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500">
<option value="">— Sélectionner —</option>
{{range .TextChans}}
<option value="{{.ID}}" {{if eq .ID $.Panel.ChannelID.String}}selected{{end}}>#{{.Name}}</option>
{{end}}
</select>
{{else}}
<input type="text" name="channel_id" value="{{.Panel.ChannelID.String}}"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500"
placeholder="ID du channel cible">
{{end}}
</div>
</div>
</div>
<!-- Section 2: Types de tickets -->
<div class="bg-discord-card rounded-lg border border-discord-border p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-white">Types de tickets</h2>
<button type="button" onclick="addType()"
class="px-3 py-1.5 text-sm bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg transition-colors">
+ Ajouter un type
</button>
</div>
<div id="typesList" class="space-y-4">
{{range $i, $t := .Panel.Types}}
{{template "type_block" dict "Type" $t "Index" $i "CSRFToken" $.CSRFToken "Roles" $.Roles "TextChans" $.TextChans "Categories" $.Categories}}
{{end}}
</div>
{{if eq (len .Panel.Types) 0}}
<p id="noTypesMsg" class="text-gray-500 text-sm text-center py-4">Aucun type. Cliquez sur "+ Ajouter un type".</p>
{{end}}
</div>
</div>
<!-- Right: preview + actions -->
<div class="space-y-6">
<div class="bg-discord-card rounded-lg border border-discord-border p-5">
<h3 class="font-semibold text-white mb-3">Aperçu</h3>
<div id="preview">
<div class="rounded-lg overflow-hidden border-l-4 bg-[#2b2d31] p-4" style="border-left-color:{{.Panel.EmbedColor}}">
<p class="font-semibold text-white mb-1">{{if .Panel.EmbedTitle}}{{.Panel.EmbedTitle}}{{else}}Titre du panel{{end}}</p>
{{if .Panel.EmbedDescription.Valid}}
<p class="text-gray-300 text-sm">{{.Panel.EmbedDescription.String}}</p>
{{end}}
{{if .Panel.Types}}
<div class="flex flex-wrap gap-2 mt-3">
{{range .Panel.Types}}
<span class="{{buttonClass .ButtonColor}} text-white text-sm px-3 py-1.5 rounded">{{.ButtonEmoji}} {{.ButtonLabel}}</span>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
<div class="bg-discord-card rounded-lg border border-discord-border p-5 space-y-3">
<button type="submit" name="action" value="save"
class="w-full px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors">
Sauvegarder
</button>
<button type="submit" name="action" value="save_send"
class="w-full px-4 py-2.5 bg-green-700 hover:bg-green-600 text-white font-medium rounded-lg transition-colors">
Sauvegarder et envoyer sur Discord
</button>
<a href="/panels" class="block text-center px-4 py-2.5 text-gray-400 hover:text-white border border-gray-700 hover:border-gray-500 rounded-lg transition-colors text-sm">
Annuler
</a>
</div>
</div>
</div>
</form>
<!-- Emoji picker -->
<div id="emojiPicker" class="hidden fixed z-50 bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-72">
<div class="flex gap-1 p-2 border-b border-gray-700 overflow-x-auto">
<button type="button" onclick="showEmojiCat('smileys')" class="emoji-cat-btn px-2 py-1 text-sm rounded hover:bg-gray-700" data-cat="smileys">😀</button>
<button type="button" onclick="showEmojiCat('gestures')" class="emoji-cat-btn px-2 py-1 text-sm rounded hover:bg-gray-700" data-cat="gestures">👍</button>
<button type="button" onclick="showEmojiCat('activities')" class="emoji-cat-btn px-2 py-1 text-sm rounded hover:bg-gray-700" data-cat="activities">🎮</button>
<button type="button" onclick="showEmojiCat('objects')" class="emoji-cat-btn px-2 py-1 text-sm rounded hover:bg-gray-700" data-cat="objects">📝</button>
<button type="button" onclick="showEmojiCat('symbols')" class="emoji-cat-btn px-2 py-1 text-sm rounded hover:bg-gray-700" data-cat="symbols"></button>
</div>
<div id="emojiGrid" class="p-2 grid grid-cols-8 gap-0.5 max-h-48 overflow-y-auto"></div>
</div>
<!-- Type block template for JS -->
<template id="typeTpl">
<div class="type-block border border-gray-700 rounded-lg p-4 space-y-3 cursor-move" data-index="__IDX__">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-300 flex items-center gap-2">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"/></svg>
Type <span class="type-num"></span>
</span>
<button type="button" onclick="removeType(this)" class="text-red-400 hover:text-red-300 text-sm">Supprimer</button>
</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-xs text-gray-400 mb-1">Nom interne</label>
<input type="text" name="types[__IDX__][name]" required class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500" placeholder="ex: support"></div>
<div><label class="block text-xs text-gray-400 mb-1">Label bouton</label>
<input type="text" name="types[__IDX__][button_label]"
hx-post="/panels/preview" hx-trigger="input changed delay:300ms" hx-include="#panelForm" hx-target="#preview"
class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div><label class="block text-xs text-gray-400 mb-1">Couleur bouton</label>
<select name="types[__IDX__][button_color]"
hx-post="/panels/preview" hx-trigger="change" hx-include="#panelForm" hx-target="#preview"
class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="primary">🔵 Bleu (primary)</option>
<option value="secondary">⚪ Gris (secondary)</option>
<option value="success">🟢 Vert (success)</option>
<option value="danger">🔴 Rouge (danger)</option>
</select></div>
<div><label class="block text-xs text-gray-400 mb-1">Emoji bouton</label>
<div class="flex gap-1">
<input type="text" name="types[__IDX__][button_emoji]" readonly
hx-post="/panels/preview" hx-trigger="input changed delay:200ms" hx-include="#panelForm" hx-target="#preview"
class="flex-1 px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none cursor-pointer" placeholder="—" onclick="openEmojiPicker(this)">
<button type="button" onclick="openEmojiPicker(this.previousElementSibling)" class="px-2 py-1.5 bg-gray-700 hover:bg-gray-600 border border-gray-600 rounded text-sm">😀</button>
</div></div>
<div><label class="block text-xs text-gray-400 mb-1">Titre embed ticket</label>
<input type="text" name="types[__IDX__][embed_title]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div><label class="block text-xs text-gray-400 mb-1">Couleur embed</label>
<input type="color" name="types[__IDX__][embed_color]" value="#5865f2" class="h-9 w-full rounded cursor-pointer bg-gray-800 border border-gray-600"></div>
<div class="col-span-2"><label class="block text-xs text-gray-400 mb-1">Texte embed bienvenue</label>
<textarea name="types[__IDX__][embed_text]" rows="2" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500 resize-none"></textarea></div>
<div class="col-span-2"><label class="block text-xs text-gray-400 mb-1">Image URL (optionnel)</label>
<input type="url" name="types[__IDX__][image_url]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500" placeholder="https://..."></div>
__ROLES_HTML__
__CATEGORIES_HTML__
__TEXTCHANS_HTML__
<div><label class="block text-xs text-gray-400 mb-1">Règle de fermeture</label>
<select name="types[__IDX__][close_rule]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="staff_only">Staff uniquement</option>
<option value="author_if_unclaimed">Auteur si non claimé</option>
</select></div>
<div class="col-span-2"><label class="block text-xs text-gray-400 mb-1">Reup claim (minutes, 0 = global)</label>
<input type="number" name="types[__IDX__][claim_reup_minutes]" value="0" min="0" max="1440"
class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div class="flex items-center gap-2">
<input type="hidden" name="types[__IDX__][claim_mode]" value="0">
<input type="checkbox" name="types[__IDX__][claim_mode]" value="1" class="rounded" id="cm__IDX__">
<label for="cm__IDX__" class="text-xs text-gray-400">Système de claim activé</label></div>
<div class="flex items-center gap-2">
<input type="hidden" name="types[__IDX__][modal_enabled]" value="1">
<input type="checkbox" name="types[__IDX__][modal_enabled]" value="1" checked class="rounded" id="me__IDX__">
<label for="me__IDX__" class="text-xs text-gray-400">Modal activé</label></div>
</div>
</div>
</template>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<script>
let typeCount = {{len .Panel.Types}};
// Emoji data
const emojiData = {
smileys: ['😀','😃','😄','😁','😆','😅','😂','🤣','😊','😇','🙂','😉','😌','😍','🥰','😘','😗','😙','😚','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','🤐','🤨','😐','😑','😶','😏','😒','🙄','😬','🤥','😌','😔','😪','🤤','😴','😷','🤒','🤕','🤢','🤧','🥵','🥶','🥴','😵','🤯','🤠','🥳','😎','🤓','🧐','😕','😟','🙁','☹️','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠','🤬','😈','👿','💀','☠️','💩','🤡','👹','👺','👻','👽','👾','🤖'],
gestures: ['👋','🤚','🖐','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','🖕','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','👐','🤲','🤝','🙏','✍️','💅','🤳','💪','🦾','🦿','🦵','🦶','👂','🦻','👃','🫀','🫁','🧠','🦷','🦴','👀','👁','👅','👄'],
activities: ['⚽','🏀','🏈','⚾','🥎','🎾','🏐','🏉','🥏','🎱','🪀','🏓','🏸','🏒','🥍','🏏','🪃','🥅','⛳','🪁','🏹','🎣','🤿','🥊','🥋','🎽','🛹','🛷','⛸','🥌','🎿','⛷','🏂','🏋','🤼','🤸','⛹','🤺','🤾','🏌','🏇','🧘','🏄','🏊','🤽','🚣','🧗','🚵','🚴','🎖','🏆','🥇','🥈','🥉','🏅','🎪','🤹','🎭','🎨','🎬','🎤','🎧','🎼','🎵','🎶','🎷','🪗','🎸','🎹','🎺','🎻','🥁','🪘','🎙','🎚','🎛','📻','🎮','🕹','🎲','🎯','🎳','🎰','🎴','🀄','🎭','🎠','🎡','🎢','🎪'],
objects: ['📱','💻','🖥','🖨','⌨️','🖱','🖲','💽','💾','💿','📀','📷','📸','📹','🎥','📽','🎞','📞','☎️','📟','📠','📺','📻','🧭','⏱','⏲','⏰','🕰','⌚','⏳','📡','🔋','🔌','💡','🔦','🕯','🗑','🛢','💰','💴','💵','💶','💷','💸','💳','🪙','💹','📈','📉','📊','📋','📌','📍','✂️','🗃','🗂','🗄','🗑','🔧','🔨','⚒','🛠','⛏','🔩','🪛','🔑','🗝','🔐','🔏','🔒','🔓','🚪','🪑','🛋','🚽','🪠','🚿','🛁','🛒','🚬','⚰️','🪦','⚱️','🧿','💎','🔮','🧸','🪆','🖼','🪅','🎊','🎉','🎁','🎀','🎗','🎟','🎫','🎖','📝','📓','📔','📒','📕','📗','📘','📙','📚','📖','🔖','🏷','💉','🩸','💊','🩹','🩺','🔬','🔭','🩻','🧬','🦠','🧫','🧪','🌡','🧹','🧺','🧻','🪣','🧼','🫧','🪥','🧽','🧯','🛒'],
symbols: ['✅','❌','❎','🔰','⛔','🚫','📵','🔕','🚷','🚯','🚳','🚱','🔞','📵','🔇','🔈','🔉','🔊','📢','📣','🔔','🔕','🔋','🪫','🔌','💡','🔦','🕯','🪔','🧯','💰','💱','💲','♾','🔁','🔂','▶️','⏩','⏭','⏯','◀️','⏪','⏮','🔀','🔃','🎵','🎶','','','✖️','➗','🟰','♾','💲','💱','🏧','🔱','⚜️','🔰','♻️','✅','🆗','✔️','❎','🆘','❓','❔','❕','❗','⁉️','‼️','🔅','🔆','🔱','⚜️','💠','🌀','⭕','✖️','❌','✖','➰','➿','〽️','✳️','✴️','❇️','💯','☑️','🔘','🔲','🔳','▪️','▫️','◾','◽','◼️','◻️','🟥','🟧','🟨','🟩','🟦','🟪','⬛','⬜','🟫','🔶','🔷','🔸','🔹','🔺','🔻','💠','🔘','🔳','🔲']
};
let currentEmojiInput = null;
let currentCat = 'smileys';
function openEmojiPicker(input) {
currentEmojiInput = input;
const picker = document.getElementById('emojiPicker');
const rect = input.getBoundingClientRect();
picker.style.top = (window.scrollY + rect.bottom + 4) + 'px';
picker.style.left = rect.left + 'px';
picker.classList.remove('hidden');
showEmojiCat(currentCat);
}
function showEmojiCat(cat) {
currentCat = cat;
const grid = document.getElementById('emojiGrid');
grid.innerHTML = emojiData[cat].map(e =>
`<button type="button" onclick="pickEmoji('${e}')" class="text-xl p-1 hover:bg-gray-700 rounded transition-colors" title="${e}">${e}</button>`
).join('');
}
function pickEmoji(e) {
if (currentEmojiInput) {
currentEmojiInput.value = e;
currentEmojiInput.dispatchEvent(new Event('input', {bubbles: true}));
}
document.getElementById('emojiPicker').classList.add('hidden');
}
document.addEventListener('click', function(ev) {
const picker = document.getElementById('emojiPicker');
if (!picker.classList.contains('hidden') && !picker.contains(ev.target) &&
!ev.target.closest('[onclick*="openEmojiPicker"]') && !ev.target.closest('[onclick*="EmojiPicker"]')) {
picker.classList.add('hidden');
}
});
// Sync color picker
document.getElementById('embedColor').addEventListener('input', function() {
document.getElementById('embedColorText').textContent = this.value;
});
// Roles HTML for JS-added type blocks
const rolesHtml = `{{range .Roles}}<option value="{{.ID}}">{{.Name}}</option>{{end}}`;
const categoriesHtml = `{{range .Categories}}<option value="{{.ID}}">📁 {{.Name}}</option>{{end}}`;
const textChansHtml = `{{range .TextChans}}<option value="{{.ID}}"># {{.Name}}</option>{{end}}`;
function makeSelect(name, label, options, value = '') {
if (!options) return `<div><label class="block text-xs text-gray-400 mb-1">${label}</label><input type="text" name="${name}" value="${value}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>`;
return `<div><label class="block text-xs text-gray-400 mb-1">${label}</label><select name="${name}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"><option value="">— Sélectionner —</option>${options}</select></div>`;
}
// SortableJS
new Sortable(document.getElementById('typesList'), {
animation: 150, handle: '.type-block',
onEnd: function() {
renumberTypes();
{{if not .IsNew}}
const ids = [...document.querySelectorAll('.type-block')].map(el => el.dataset.typeId).filter(Boolean);
if (ids.length) {
fetch('/panels/{{.Panel.ID}}/types/reorder', {
method: 'POST',
headers: {'X-CSRF-Token': '{{.CSRFToken}}', 'Content-Type': 'application/x-www-form-urlencoded'},
body: ids.map(id => 'ids[]=' + id).join('&')
});
}
{{end}}
}
});
function addType() {
document.getElementById('noTypesMsg')?.remove();
let html = document.getElementById('typeTpl').innerHTML.replaceAll('__IDX__', typeCount);
html = html.replace('__ROLES_HTML__', makeSelect(`types[${typeCount}][staff_role_id]`, 'Staff Role', rolesHtml));
html = html.replace('__CATEGORIES_HTML__', makeSelect(`types[${typeCount}][category_id]`, 'Catégorie', categoriesHtml));
html = html.replace('__TEXTCHANS_HTML__',
makeSelect(`types[${typeCount}][claim_channel_id]`, 'Claim Channel', textChansHtml) +
makeSelect(`types[${typeCount}][log_channel_id]`, 'Log Channel', textChansHtml));
const div = document.createElement('div');
div.innerHTML = html;
document.getElementById('typesList').appendChild(div.firstElementChild);
typeCount++;
renumberTypes();
htmx.process(document.getElementById('typesList'));
}
function removeType(btn) {
btn.closest('.type-block').remove();
renumberTypes();
}
function renumberTypes() {
document.querySelectorAll('.type-num').forEach((el, i) => el.textContent = i + 1);
}
</script>
{{end}}
{{define "type_block"}}
<div class="type-block border border-gray-700 rounded-lg p-4 space-y-3 cursor-move" data-index="{{.Index}}" data-type-id="{{.Type.ID}}">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-300 flex items-center gap-2">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"/></svg>
Type <span class="type-num">{{add .Index 1}}</span>
</span>
<button type="button" onclick="removeType(this)" class="text-red-400 hover:text-red-300 text-sm">Supprimer</button>
</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-xs text-gray-400 mb-1">Nom interne</label>
<input type="text" name="types[{{.Index}}][name]" value="{{.Type.Name}}" required class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div><label class="block text-xs text-gray-400 mb-1">Label bouton</label>
<input type="text" name="types[{{.Index}}][button_label]" value="{{.Type.ButtonLabel}}"
hx-post="/panels/preview" hx-trigger="input changed delay:300ms" hx-include="#panelForm" hx-target="#preview"
class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div><label class="block text-xs text-gray-400 mb-1">Couleur bouton</label>
<select name="types[{{.Index}}][button_color]"
hx-post="/panels/preview" hx-trigger="change" hx-include="#panelForm" hx-target="#preview"
class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="primary" {{if eq .Type.ButtonColor "primary"}}selected{{end}}>🔵 Bleu (primary)</option>
<option value="secondary" {{if eq .Type.ButtonColor "secondary"}}selected{{end}}>⚪ Gris (secondary)</option>
<option value="success" {{if eq .Type.ButtonColor "success"}}selected{{end}}>🟢 Vert (success)</option>
<option value="danger" {{if eq .Type.ButtonColor "danger"}}selected{{end}}>🔴 Rouge (danger)</option>
</select></div>
<div><label class="block text-xs text-gray-400 mb-1">Emoji bouton</label>
<div class="flex gap-1">
<input type="text" name="types[{{.Index}}][button_emoji]" value="{{.Type.ButtonEmoji}}" readonly
hx-post="/panels/preview" hx-trigger="input changed delay:200ms" hx-include="#panelForm" hx-target="#preview"
class="flex-1 px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm cursor-pointer" placeholder="—" onclick="openEmojiPicker(this)">
<button type="button" onclick="openEmojiPicker(this.previousElementSibling)" class="px-2 py-1.5 bg-gray-700 hover:bg-gray-600 border border-gray-600 rounded text-sm">😀</button>
</div></div>
<div><label class="block text-xs text-gray-400 mb-1">Titre embed ticket</label>
<input type="text" name="types[{{.Index}}][embed_title]" value="{{.Type.EmbedTitle}}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div><label class="block text-xs text-gray-400 mb-1">Couleur embed</label>
<input type="color" name="types[{{.Index}}][embed_color]" value="{{.Type.EmbedColor}}" class="h-9 w-full rounded cursor-pointer bg-gray-800 border border-gray-600"></div>
<div class="col-span-2"><label class="block text-xs text-gray-400 mb-1">Texte embed bienvenue</label>
<textarea name="types[{{.Index}}][embed_text]" rows="2" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500 resize-none">{{.Type.EmbedText}}</textarea></div>
<div class="col-span-2"><label class="block text-xs text-gray-400 mb-1">Image URL (optionnel)</label>
<input type="url" name="types[{{.Index}}][image_url]" value="{{.Type.ImageURL}}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500" placeholder="https://..."></div>
<div>
<label class="block text-xs text-gray-400 mb-1">Staff Role</label>
{{if .Roles}}
<select name="types[{{.Index}}][staff_role_id]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="">— Sélectionner —</option>
{{range .Roles}}<option value="{{.ID}}" {{if eq .ID $.Type.StaffRoleID}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
{{else}}
<input type="text" name="types[{{.Index}}][staff_role_id]" value="{{.Type.StaffRoleID}}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
{{end}}
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Catégorie</label>
{{if .Categories}}
<select name="types[{{.Index}}][category_id]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="">— Sélectionner —</option>
{{range .Categories}}<option value="{{.ID}}" {{if eq .ID $.Type.CategoryID}}selected{{end}}>📁 {{.Name}}</option>{{end}}
</select>
{{else}}
<input type="text" name="types[{{.Index}}][category_id]" value="{{.Type.CategoryID}}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
{{end}}
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Claim Channel</label>
{{if .TextChans}}
<select name="types[{{.Index}}][claim_channel_id]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="">— Sélectionner —</option>
{{range .TextChans}}<option value="{{.ID}}" {{if eq .ID $.Type.ClaimChannelID}}selected{{end}}># {{.Name}}</option>{{end}}
</select>
{{else}}
<input type="text" name="types[{{.Index}}][claim_channel_id]" value="{{.Type.ClaimChannelID}}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
{{end}}
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Log Channel</label>
{{if .TextChans}}
<select name="types[{{.Index}}][log_channel_id]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="">— Sélectionner —</option>
{{range .TextChans}}<option value="{{.ID}}" {{if eq .ID $.Type.LogChannelID}}selected{{end}}># {{.Name}}</option>{{end}}
</select>
{{else}}
<input type="text" name="types[{{.Index}}][log_channel_id]" value="{{.Type.LogChannelID}}" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
{{end}}
</div>
<div><label class="block text-xs text-gray-400 mb-1">Max tickets / user</label>
<input type="number" name="types[{{.Index}}][max_per_user]" value="{{.Type.MaxPerUser}}" min="1" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div><label class="block text-xs text-gray-400 mb-1">Règle de close</label>
<select name="types[{{.Index}}][close_rule]" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500">
<option value="staff_only" {{if eq .Type.CloseRule "staff_only"}}selected{{end}}>Staff uniquement</option>
<option value="author_if_unclaimed" {{if eq .Type.CloseRule "author_if_unclaimed"}}selected{{end}}>Auteur si non claimé</option>
</select></div>
<div class="flex items-center gap-2 pt-1">
<input type="hidden" name="types[{{.Index}}][claim_mode]" value="0">
<input type="checkbox" name="types[{{.Index}}][claim_mode]" value="1" {{if .Type.ClaimMode}}checked{{end}} class="rounded" id="cm{{.Index}}">
<label for="cm{{.Index}}" class="text-xs text-gray-400">Système de claim activé</label></div>
<div class="col-span-2"><label class="block text-xs text-gray-400 mb-1">Reup claim (minutes, 0 = global)</label>
<input type="number" name="types[{{.Index}}][claim_reup_minutes]" value="{{.Type.ClaimReupMinutes}}" min="0" max="1440"
class="w-full px-2 py-1.5 bg-gray-800 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-indigo-500"></div>
<div class="flex items-center gap-2 pt-1">
<input type="hidden" name="types[{{.Index}}][modal_enabled]" value="0">
<input type="checkbox" name="types[{{.Index}}][modal_enabled]" value="1" {{if .Type.ModalEnabled}}checked{{end}} class="rounded" id="me{{.Index}}">
<label for="me{{.Index}}" class="text-xs text-gray-400">Modal activé</label></div>
</div>
</div>
{{end}}
+91
View File
@@ -0,0 +1,91 @@
{{define "title"}}Panels{{end}}
{{define "header"}}Panels{{end}}
{{define "content"}}
<div class="flex items-center justify-between mb-6">
<p class="text-gray-400 text-sm">{{len .Panels}} panel{{if gt (len .Panels) 1}}s{{end}} configuré{{if gt (len .Panels) 1}}s{{end}}</p>
<a href="/panels/new" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors">
+ Créer un panel
</a>
</div>
{{if .Panels}}
<div class="grid gap-4">
{{range .Panels}}
<div class="bg-discord-card rounded-lg border border-discord-border p-5 flex items-center gap-4">
<!-- Color swatch -->
<div class="w-1 self-stretch rounded-full flex-shrink-0" style="background-color:{{.EmbedColor}}"></div>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-white">{{.Name}}</span>
<span class="text-xs text-gray-500 bg-gray-800 px-2 py-0.5 rounded">{{len .Types}} type{{if gt (len .Types) 1}}s{{end}}</span>
{{if .MessageID.Valid}}<span class="text-xs text-green-400 bg-green-900/30 px-2 py-0.5 rounded">Envoyé</span>{{end}}
{{if .GuildID}}
{{$gname := index $.GuildNames .GuildID}}
<span class="text-xs text-indigo-300 bg-indigo-900/30 px-2 py-0.5 rounded">{{if $gname}}{{$gname}}{{else}}{{.GuildID}}{{end}}</span>
{{end}}
</div>
<p class="text-sm text-gray-400 truncate">{{.EmbedTitle}}</p>
{{if .ChannelID.Valid}}<p class="text-xs text-gray-600 mt-0.5">Channel : {{.ChannelID.String}}</p>{{end}}
</div>
<!-- Actions -->
<div class="flex items-center gap-2 flex-shrink-0">
<a href="/panels/{{.ID}}/edit" class="px-3 py-1.5 text-sm text-indigo-400 hover:text-indigo-300 border border-indigo-700/50 hover:border-indigo-500 rounded-lg transition-colors">
Éditer
</a>
<form method="POST" action="/panels/{{.ID}}/duplicate">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200 border border-gray-700 hover:border-gray-500 rounded-lg transition-colors">
Dupliquer
</button>
</form>
<button onclick="confirmDelete({{.ID}}, '{{.Name}}')"
class="px-3 py-1.5 text-sm text-red-400 hover:text-red-300 border border-red-900/50 hover:border-red-700 rounded-lg transition-colors">
Supprimer
</button>
</div>
</div>
{{end}}
</div>
{{else}}
<div class="text-center py-20">
<p class="text-gray-500 mb-4">Aucun panel configuré.</p>
<a href="/panels/new" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors">
Créer votre premier panel
</a>
</div>
{{end}}
<!-- Delete confirmation modal -->
<div id="deleteModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-discord-card border border-discord-border rounded-xl p-6 w-full max-w-sm mx-4">
<h3 class="text-white font-semibold mb-2">Supprimer le panel</h3>
<p class="text-gray-400 text-sm mb-6">
Supprimer <strong id="deleteModalName" class="text-white"></strong> ? Cette action est irréversible.
</p>
<div class="flex gap-3 justify-end">
<button onclick="document.getElementById('deleteModal').classList.add('hidden')"
class="px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700 rounded-lg transition-colors">
Annuler
</button>
<form id="deleteForm" method="POST">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="px-4 py-2 text-sm bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors">
Supprimer
</button>
</form>
</div>
</div>
</div>
<script>
function confirmDelete(id, name) {
document.getElementById('deleteModalName').textContent = name;
document.getElementById('deleteForm').action = '/panels/' + id + '/delete';
document.getElementById('deleteModal').classList.remove('hidden');
}
document.getElementById('deleteModal').addEventListener('click', function(e) {
if (e.target === this) this.classList.add('hidden');
});
</script>
{{end}}
@@ -0,0 +1,30 @@
{{define "content"}}
<h2 class="text-xl font-semibold text-white mb-1">Créer un mot de passe</h2>
<p class="text-gray-400 text-sm mb-6">
Choisissez un mot de passe sécurisé (12 caractères minimum). Il vous sera demandé à chaque connexion.
</p>
{{if .Error}}
<div class="mb-4 px-4 py-3 rounded bg-red-900/50 border border-red-700 text-red-300 text-sm">
{{.Error}}
</div>
{{end}}
<form method="POST" action="/auth/password-setup">
<div class="mb-4">
<label class="block text-sm text-gray-300 mb-1">Mot de passe</label>
<input type="password" name="password" minlength="12" required autofocus
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500"
placeholder="12 caractères minimum">
</div>
<div class="mb-6">
<label class="block text-sm text-gray-300 mb-1">Confirmer le mot de passe</label>
<input type="password" name="confirm" required
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500">
</div>
<button type="submit"
class="w-full px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors">
Définir le mot de passe
</button>
</form>
{{end}}
+90
View File
@@ -0,0 +1,90 @@
{{define "title"}}Sessions{{end}}
{{define "header"}}Sessions actives{{end}}
{{define "content"}}
<div class="flex items-center justify-between mb-6">
<p class="text-gray-400 text-sm">{{len .Sessions}} session{{if gt (len .Sessions) 1}}s{{end}} active{{if gt (len .Sessions) 1}}s{{end}}</p>
<button onclick="document.getElementById('revokeAllModal').classList.remove('hidden')"
class="px-4 py-2 bg-red-700 hover:bg-red-600 text-white text-sm font-medium rounded-lg transition-colors">
Révoquer toutes les autres
</button>
</div>
<div class="bg-discord-card rounded-lg border border-discord-border overflow-hidden">
{{if .Sessions}}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase border-b border-discord-border">
<th class="px-4 py-2 text-left">Admin</th>
<th class="px-4 py-2 text-left">IP</th>
<th class="px-4 py-2 text-left">User Agent</th>
<th class="px-4 py-2 text-left">Dernière activité</th>
<th class="px-4 py-2 text-left">Créée le</th>
<th class="px-4 py-2 text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-discord-border">
{{range .Sessions}}
<tr class="hover:bg-white/5 transition-colors {{if .IsCurrent}}bg-indigo-900/20{{end}}">
<td class="px-4 py-2 text-white">
{{.DiscordUsername}}
{{if .IsCurrent}}<span class="ml-1 text-xs text-indigo-400">(vous)</span>{{end}}
</td>
<td class="px-4 py-2 text-gray-400 font-mono text-xs">{{.IPAddress}}</td>
<td class="px-4 py-2 text-gray-500 text-xs max-w-[200px] truncate" title="{{.UserAgent}}">{{.UserAgent}}</td>
<td class="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">{{.LastActivity.Format "02/01/2006 15:04:05"}}</td>
<td class="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">{{.CreatedAt.Format "02/01/2006 15:04:05"}}</td>
<td class="px-4 py-2 text-right">
{{if not .IsCurrent}}
<form method="POST" action="/sessions/{{.ID}}/revoke">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit"
class="px-3 py-1 text-xs text-red-400 hover:text-red-300 border border-red-900/50 hover:border-red-700 rounded transition-colors">
Révoquer
</button>
</form>
{{else}}
<span class="text-xs text-gray-600">Session courante</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="px-5 py-12 text-center text-gray-500 text-sm">
Aucune session active.
</div>
{{end}}
</div>
<!-- Revoke All Modal -->
<div id="revokeAllModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-discord-card border border-discord-border rounded-xl p-6 w-full max-w-sm mx-4">
<h3 class="text-white font-semibold mb-2">Révoquer toutes les autres sessions</h3>
<p class="text-gray-400 text-sm mb-6">
Cette action déconnectera toutes les sessions actives sauf la vôtre. Cette action est irréversible.
</p>
<div class="flex gap-3 justify-end">
<button onclick="document.getElementById('revokeAllModal').classList.add('hidden')"
class="px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700 rounded-lg transition-colors">
Annuler
</button>
<form method="POST" action="/sessions/revoke-all">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="px-4 py-2 text-sm bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors">
Révoquer tout
</button>
</form>
</div>
</div>
</div>
<script>
document.getElementById('revokeAllModal').addEventListener('click', function(e) {
if (e.target === this) this.classList.add('hidden');
});
</script>
{{end}}
+35
View File
@@ -0,0 +1,35 @@
{{define "content"}}
<h2 class="text-xl font-semibold text-white mb-1">Configuration TOTP</h2>
<p class="text-gray-400 text-sm mb-6">
Scanne le QR code avec Google Authenticator ou une app compatible, puis saisis le code pour valider.
</p>
{{if .Error}}
<div class="mb-4 px-4 py-3 rounded bg-red-900/50 border border-red-700 text-red-300 text-sm">
{{.Error}}
</div>
{{end}}
<div class="flex justify-center mb-4">
<img src="{{.QRCodeBase64}}" alt="QR Code TOTP" class="w-48 h-48 rounded-lg border border-gray-600">
</div>
<div class="mb-6">
<p class="text-xs text-gray-400 mb-1">Secret manuel :</p>
<code class="block bg-gray-800 px-3 py-2 rounded text-sm text-gray-200 font-mono break-all select-all">{{.Secret}}</code>
</div>
<form method="POST" action="/auth/totp-setup">
<input type="hidden" name="secret" value="{{.Secret}}">
<div class="mb-4">
<label class="block text-sm text-gray-300 mb-1">Code de vérification</label>
<input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white text-center text-xl tracking-widest focus:outline-none focus:border-indigo-500"
placeholder="000000" required autofocus>
</div>
<button type="submit"
class="w-full px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors">
Valider et continuer
</button>
</form>
{{end}}
+41
View File
@@ -0,0 +1,41 @@
{{define "content"}}
<h2 class="text-xl font-semibold text-white mb-1">Vérification</h2>
{{if .Admin}}
<div class="flex items-center gap-3 mb-6 p-3 bg-gray-800 rounded-lg">
{{if .Admin.DiscordAvatar}}
<img src="https://cdn.discordapp.com/avatars/{{.Admin.DiscordID}}/{{.Admin.DiscordAvatar}}.png?size=64"
class="w-10 h-10 rounded-full" alt="">
{{end}}
<div>
<p class="text-sm text-gray-400">Connecté en tant que</p>
<p class="font-medium text-white">{{.Admin.DiscordUsername}}</p>
</div>
</div>
{{end}}
{{if .Error}}
<div class="mb-4 px-4 py-3 rounded bg-red-900/50 border border-red-700 text-red-300 text-sm">
{{.Error}}
</div>
{{end}}
<form method="POST" action="/auth/verify">
<div class="mb-4">
<label class="block text-sm text-gray-300 mb-1">Mot de passe</label>
<input type="password" name="password" autocomplete="current-password"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-indigo-500"
required autofocus>
</div>
<div class="mb-6">
<label class="block text-sm text-gray-300 mb-1">Code TOTP</label>
<input type="text" name="totp" inputmode="numeric" autocomplete="one-time-code" maxlength="6"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white text-center text-xl tracking-widest focus:outline-none focus:border-indigo-500"
placeholder="000000" required>
</div>
<button type="submit"
class="w-full px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors">
Se connecter
</button>
</form>
{{end}}
+170
View File
@@ -0,0 +1,170 @@
{{define "title"}}Transcripts{{end}}
{{define "header"}}Transcripts{{end}}
{{define "content"}}
<!-- Filters -->
<form method="GET" action="/transcripts" class="bg-discord-card rounded-lg border border-discord-border p-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 mb-3">
<div>
<label class="block text-xs text-gray-400 mb-1">Recherche</label>
<input type="text" name="search" value="{{.FSearch}}" placeholder="Titre, user ID…"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-discord-accent">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Statut</label>
<select name="status" class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
<option value="">Tous</option>
<option value="open" {{if eq .FStatus "open"}}selected{{end}}>Ouvert</option>
<option value="claimed" {{if eq .FStatus "claimed"}}selected{{end}}>Claimé</option>
<option value="closed" {{if eq .FStatus "closed"}}selected{{end}}>Fermé</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Type</label>
<select name="type" class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
<option value="">Tous les types</option>
{{range .Types}}
<option value="{{.}}" {{if eq . $.FType}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Staff</label>
<select name="staff" class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
<option value="">Tous les staffs</option>
{{range .StaffList}}
<option value="{{.}}" {{if eq . $.FStaff}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
{{if .Guilds}}
<div>
<label class="block text-xs text-gray-400 mb-1">Serveur</label>
<select name="guild" class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
<option value="">Tous les serveurs</option>
{{range .Guilds}}
<option value="{{.ID}}" {{if eq .ID $.FGuild}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
{{end}}
<div>
<label class="block text-xs text-gray-400 mb-1">Du</label>
<input type="date" name="from" value="{{.FFrom}}"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Au</label>
<input type="date" name="to" value="{{.FTo}}"
class="w-full bg-discord-hover border border-discord-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-discord-accent">
</div>
</div>
<div class="flex items-center gap-2">
<button type="submit"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors">
Filtrer
</button>
<a href="/transcripts" class="px-4 py-2 text-sm text-gray-400 hover:text-white border border-discord-border rounded-lg transition-colors">
Réinitialiser
</a>
<span class="text-xs text-gray-500 ml-auto">{{.Total}} résultat{{if gt .Total 1}}s{{end}}</span>
</div>
</form>
<!-- Tickets table -->
<div class="bg-discord-card rounded-lg border border-discord-border overflow-hidden">
{{if .Tickets}}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase border-b border-discord-border">
<th class="px-4 py-2 text-left">#</th>
<th class="px-4 py-2 text-left">Serveur</th>
<th class="px-4 py-2 text-left">Type</th>
<th class="px-4 py-2 text-left">Utilisateur</th>
<th class="px-4 py-2 text-left">Staff</th>
<th class="px-4 py-2 text-left">Titre</th>
<th class="px-4 py-2 text-left">Statut</th>
<th class="px-4 py-2 text-left">Ouvert le</th>
<th class="px-4 py-2 text-left">Fermé le</th>
<th class="px-4 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-discord-border">
{{range .Tickets}}
<tr class="hover:bg-white/5 transition-colors">
<td class="px-4 py-2 text-indigo-400 font-mono">{{.TicketNumber}}</td>
<td class="px-4 py-2 text-xs text-indigo-300">
{{if .GuildID}}{{$gn := index $.GuildNames .GuildID}}{{if $gn}}{{$gn}}{{else}}{{.GuildID}}{{end}}{{else}}<span class="text-gray-600"></span>{{end}}
</td>
<td class="px-4 py-2 text-gray-300">{{.Type}}</td>
<td class="px-4 py-2 text-gray-400 font-mono text-xs">{{.UserID}}</td>
<td class="px-4 py-2 text-gray-400 font-mono text-xs">
{{if .ClaimedBy.Valid}}{{.ClaimedBy.String}}{{else}}—{{end}}
</td>
<td class="px-4 py-2 text-white max-w-[200px] truncate">
{{if .TicketTitle.Valid}}{{.TicketTitle.String}}{{else}}<span class="text-gray-500"></span>{{end}}
</td>
<td class="px-4 py-2">
{{if eq .Status "open"}}
<span class="px-2 py-0.5 rounded text-xs bg-green-900/40 text-green-400 border border-green-800">Ouvert</span>
{{else if eq .Status "claimed"}}
<span class="px-2 py-0.5 rounded text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-800">Claimé</span>
{{else}}
<span class="px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400">Fermé</span>
{{end}}
</td>
<td class="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">{{.OpenedAt.Format "02/01/06 15:04"}}</td>
<td class="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">
{{if .ClosedAt.Valid}}{{.ClosedAt.Time.Format "02/01/06 15:04"}}{{else}}—{{end}}
</td>
<td class="px-4 py-2 text-right">
<div class="flex items-center gap-1 justify-end">
{{if .TranscriptPath.Valid}}
<a href="/transcripts/view/{{.ID}}" target="_blank"
class="px-2 py-1 text-xs text-indigo-400 hover:text-indigo-300 border border-indigo-700/50 hover:border-indigo-500 rounded transition-colors">
Voir
</a>
<a href="/transcripts/download/{{.ID}}"
class="px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-700 hover:border-gray-500 rounded transition-colors">
</a>
{{else}}
<span class="text-xs text-gray-600"></span>
{{end}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if gt .Pages 1}}
<div class="px-5 py-3 border-t border-discord-border flex items-center gap-2 text-sm">
{{if gt .Page 1}}
<a href="?page={{add .Page -1}}&status={{.FStatus}}&type={{.FType}}&staff={{.FStaff}}&guild={{.FGuild}}&from={{.FFrom}}&to={{.FTo}}&search={{.FSearch}}"
class="px-3 py-1 rounded border border-discord-border text-gray-300 hover:text-white hover:border-gray-500 transition-colors">← Précédent</a>
{{end}}
<span class="text-gray-400">Page {{.Page}} / {{.Pages}}</span>
{{if lt .Page .Pages}}
<a href="?page={{add .Page 1}}&status={{.FStatus}}&type={{.FType}}&staff={{.FStaff}}&guild={{.FGuild}}&from={{.FFrom}}&to={{.FTo}}&search={{.FSearch}}"
class="px-3 py-1 rounded border border-discord-border text-gray-300 hover:text-white hover:border-gray-500 transition-colors">Suivant →</a>
{{end}}
</div>
{{end}}
{{else}}
<div class="px-5 py-12 text-center text-gray-500 text-sm">
Aucun ticket trouvé pour ces critères.
</div>
{{end}}
</div>
{{end}}
+4
View File
@@ -60,6 +60,10 @@ func (a *AuthService) Can(memberRoles []string, action Action, ticket *db.Ticket
if ticket == nil { if ticket == nil {
return "" return ""
} }
// Prefer role stored on the ticket itself (populated at open time for DB panels)
if ticket.StaffRoleID != "" {
return ticket.StaffRoleID
}
if p, ok := cfg.Panels[ticket.Panel]; ok { if p, ok := cfg.Panels[ticket.Panel]; ok {
if t, ok := p.Types[ticket.Type]; ok { if t, ok := p.Types[ticket.Type]; ok {
return t.StaffRole return t.StaffRole
+39 -8
View File
@@ -16,14 +16,15 @@ import (
type Service struct { type Service struct {
db *db.TicketRepo db *db.TicketRepo
panelRepo *db.PanelConfigRepo
auth *AuthService auth *AuthService
session *discordgo.Session session *discordgo.Session
cfg *config.Provider cfg *config.Provider
opening sync.Map // key: userID+":"+ticketType prevents double-click opening sync.Map // key: userID+":"+ticketType prevents double-click
} }
func NewService(repo *db.TicketRepo, auth *AuthService, s *discordgo.Session, cfg *config.Provider) *Service { func NewService(repo *db.TicketRepo, panelRepo *db.PanelConfigRepo, auth *AuthService, s *discordgo.Session, cfg *config.Provider) *Service {
return &Service{db: repo, auth: auth, session: s, cfg: cfg} return &Service{db: repo, panelRepo: panelRepo, auth: auth, session: s, cfg: cfg}
} }
var ErrAlreadyOpen = fmt.Errorf("already_open") var ErrAlreadyOpen = fmt.Errorf("already_open")
@@ -45,15 +46,40 @@ func (svc *Service) Open(ctx context.Context, guildID, userID, panelName, ticket
return existing, fmt.Errorf("%w:%s", ErrAlreadyOpen, existing.ChannelID) return existing, fmt.Errorf("%w:%s", ErrAlreadyOpen, existing.ChannelID)
} }
cfg := svc.cfg.Get() var categoryID, claimChannelID, logChannelID, staffRoleID string
panel, ok := cfg.Panels[panelName] var claimReupMinutes int
if !ok { yamlCfg := svc.cfg.Get()
return nil, fmt.Errorf("panel %q not found", panelName) if panel, ok := yamlCfg.Panels[panelName]; ok {
}
typeCfg, ok := panel.Types[ticketType] typeCfg, ok := panel.Types[ticketType]
if !ok { if !ok {
return nil, fmt.Errorf("type %q not found in panel %q", ticketType, panelName) return nil, fmt.Errorf("type %q not found in panel %q", ticketType, panelName)
} }
categoryID = typeCfg.Category
staffRoleID = typeCfg.StaffRole
// YAML panels have no per-type claim/log routing; fall back to global config at claim/log time
} else if svc.panelRepo != nil {
dbPanel, err := svc.panelRepo.GetByName(ctx, panelName)
if err != nil || dbPanel == nil {
return nil, fmt.Errorf("panel %q not found", panelName)
}
var dbType *db.PanelType
for _, t := range dbPanel.Types {
if t.Name == ticketType {
dbType = t
break
}
}
if dbType == nil {
return nil, fmt.Errorf("type %q not found in panel %q", ticketType, panelName)
}
categoryID = dbType.CategoryID
claimChannelID = dbType.ClaimChannelID
logChannelID = dbType.LogChannelID
staffRoleID = dbType.StaffRoleID
claimReupMinutes = dbType.ClaimReupMinutes
} else {
return nil, fmt.Errorf("panel %q not found", panelName)
}
// Reserve a placeholder to get the ticket number atomically // Reserve a placeholder to get the ticket number atomically
ticket := &db.Ticket{ ticket := &db.Ticket{
@@ -61,6 +87,11 @@ func (svc *Service) Open(ctx context.Context, guildID, userID, panelName, ticket
Panel: panelName, Panel: panelName,
Type: ticketType, Type: ticketType,
ChannelID: fmt.Sprintf("pending-%d", time.Now().UnixNano()), ChannelID: fmt.Sprintf("pending-%d", time.Now().UnixNano()),
GuildID: guildID,
ClaimChannelID: claimChannelID,
LogChannelID: logChannelID,
StaffRoleID: staffRoleID,
ClaimReupMinutes: claimReupMinutes,
OpenedAt: time.Now(), OpenedAt: time.Now(),
} }
if err := svc.db.Insert(ctx, ticket); err != nil { if err := svc.db.Insert(ctx, ticket); err != nil {
@@ -71,7 +102,7 @@ func (svc *Service) Open(ctx context.Context, guildID, userID, panelName, ticket
ch, err := svc.session.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{ ch, err := svc.session.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
Name: channelName, Name: channelName,
Type: discordgo.ChannelTypeGuildText, Type: discordgo.ChannelTypeGuildText,
ParentID: typeCfg.Category, ParentID: categoryID,
PermissionOverwrites: []*discordgo.PermissionOverwrite{ PermissionOverwrites: []*discordgo.PermissionOverwrite{
{ {
ID: guildID, ID: guildID,