oups
This commit is contained in:
@@ -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
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/leolionad58/ticketbot/internal/discord/components"
|
||||
"github.com/leolionad58/ticketbot/internal/discord/events"
|
||||
"github.com/leolionad58/ticketbot/internal/logger"
|
||||
"github.com/leolionad58/ticketbot/internal/panel"
|
||||
"github.com/leolionad58/ticketbot/internal/tickets"
|
||||
"github.com/leolionad58/ticketbot/internal/transcript"
|
||||
)
|
||||
@@ -34,8 +36,23 @@ func main() {
|
||||
must(err, "open db")
|
||||
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)
|
||||
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")
|
||||
if token == "" {
|
||||
@@ -45,12 +62,27 @@ func main() {
|
||||
appID := os.Getenv("DISCORD_APP_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")
|
||||
|
||||
// Services
|
||||
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)
|
||||
logSvc := logger.NewDiscordLogger(bot.Session, cfgProvider)
|
||||
transcriptGen := transcript.NewGenerator("transcripts", bot.Session)
|
||||
@@ -76,6 +108,7 @@ func main() {
|
||||
panelComp := &components.PanelComponent{
|
||||
TicketSvc: ticketSvc,
|
||||
TicketRepo: ticketRepo,
|
||||
PanelRepo: panelRepo,
|
||||
ClaimMgr: claimMgr,
|
||||
LogSvc: logSvc,
|
||||
Config: cfgProvider,
|
||||
@@ -88,6 +121,12 @@ func main() {
|
||||
Transcript: transcriptGen,
|
||||
Auth: auth,
|
||||
}
|
||||
convocComp := &components.ConvocationComponent{
|
||||
ConvocRepo: convocRepo,
|
||||
TicketRepo: ticketRepo,
|
||||
Auth: auth,
|
||||
LogSvc: logSvc,
|
||||
}
|
||||
|
||||
// Router
|
||||
router := &discordbot.Router{
|
||||
@@ -96,6 +135,8 @@ func main() {
|
||||
ConvocCmd: convocCmd,
|
||||
PanelComp: panelComp,
|
||||
TicketComp: ticketComp,
|
||||
ConvocComp: convocComp,
|
||||
IsGuildAllowed: bot.IsGuildAllowed,
|
||||
}
|
||||
bot.Session.AddHandler(router.Handle)
|
||||
|
||||
@@ -123,6 +164,30 @@ func main() {
|
||||
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")
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
+8
-16
@@ -3,20 +3,12 @@ bot:
|
||||
logs_channel: "1499924634655658004" # channel de logs (open/claim/close)
|
||||
claim_channel: "1499924671884431380" # channel où défilent les tickets à claim
|
||||
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:
|
||||
support_panel:
|
||||
embed_title: "Ouvrir un ticket"
|
||||
embed_description: "Choisis le type de ticket ci-dessous."
|
||||
embed_color: "#2b2d31"
|
||||
types:
|
||||
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"
|
||||
panel:
|
||||
port: 8080
|
||||
base_url: "http://localhost:8080" # ton domaine en prod (ex: https://panel.tonsite.com)
|
||||
oauth_client_id: "1415152714559782922" # Discord app → OAuth2 → Client ID
|
||||
oauth_client_secret: "H5HvZ1AYvhT3VirKFLQmGvgRYuxzvy4m" # Discord app → OAuth2 → Client Secret
|
||||
session_timeout_minutes: 30
|
||||
@@ -3,16 +3,21 @@ module github.com/leolionad58/ticketbot
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bwmarrin/discordgo v0.29.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // 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/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // 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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // 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
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
|
||||
@@ -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/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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
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/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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
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/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/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
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/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -63,8 +63,12 @@ func (m *Manager) DeleteClaimMessage(ctx context.Context, s *discordgo.Session,
|
||||
if err != nil || cm == nil {
|
||||
return
|
||||
}
|
||||
cfg := m.cfg.Get()
|
||||
if err := s.ChannelMessageDelete(cfg.Bot.ClaimChannel, cm.MessageID); err != nil {
|
||||
// Resolve claim channel: look up ticket for per-ticket channel, fall back to config.
|
||||
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)
|
||||
}
|
||||
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()
|
||||
claimChannelID := cfg.Bot.ClaimChannel
|
||||
if ticket.ClaimChannelID != "" {
|
||||
claimChannelID = ticket.ClaimChannelID
|
||||
}
|
||||
if claimChannelID == "" {
|
||||
m.claims.Delete(ctx, ticket.ID) //nolint
|
||||
return
|
||||
@@ -131,7 +138,11 @@ func (m *Manager) startReupLoop(s *discordgo.Session, ticket *db.Ticket, lastReu
|
||||
}()
|
||||
|
||||
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)
|
||||
next := interval - elapsed
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
cfg := m.cfg.Get()
|
||||
claimChannelID := cfg.Bot.ClaimChannel
|
||||
if ticket.ClaimChannelID != "" {
|
||||
claimChannelID = ticket.ClaimChannelID
|
||||
}
|
||||
if claimChannelID == "" {
|
||||
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
|
||||
var staffRoleID string
|
||||
staffRoleID := ticket.StaffRoleID
|
||||
if panel, ok := cfg.Panels[ticket.Panel]; ok {
|
||||
if t, ok := panel.Types[ticket.Type]; ok {
|
||||
if c := parseHexColor(t.EmbedColor); c != 0 {
|
||||
embedColor = c
|
||||
}
|
||||
if staffRoleID == "" {
|
||||
staffRoleID = t.StaffRole
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fields := []*discordgo.MessageEmbedField{
|
||||
{Name: "Auteur", Value: fmt.Sprintf("<@%s>", ticket.UserID), Inline: true},
|
||||
|
||||
@@ -41,10 +41,20 @@ type Bot struct {
|
||||
ClaimChannel string `yaml:"claim_channel"`
|
||||
ConvocationCategory string `yaml:"convocation_category"`
|
||||
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 {
|
||||
Bot Bot `yaml:"bot"`
|
||||
Panel PanelWebConfig `yaml:"panel"`
|
||||
Panels map[string]Panel `yaml:"panels"`
|
||||
}
|
||||
|
||||
@@ -55,6 +65,12 @@ func (c *Config) Validate() error {
|
||||
if c.Bot.ClaimReupMinutes <= 0 {
|
||||
c.Bot.ClaimReupMinutes = 30
|
||||
}
|
||||
if c.Panel.Port <= 0 {
|
||||
c.Panel.Port = 8080
|
||||
}
|
||||
if c.Panel.SessionTimeoutMinutes <= 0 {
|
||||
c.Panel.SessionTimeoutMinutes = 30
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -63,8 +63,130 @@ func migrate(db *sql.DB) error {
|
||||
// v2: user-supplied ticket title and description (from modal)
|
||||
`ALTER TABLE tickets ADD COLUMN ticket_title 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 {
|
||||
v := i + 1
|
||||
if v <= version {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -14,6 +14,11 @@ type Ticket struct {
|
||||
Panel string
|
||||
Type string
|
||||
ChannelID string
|
||||
GuildID string
|
||||
ClaimChannelID string
|
||||
LogChannelID string
|
||||
StaffRoleID string
|
||||
ClaimReupMinutes int
|
||||
OpenedAt time.Time
|
||||
ClaimedBy sql.NullString
|
||||
ClaimedAt sql.NullTime
|
||||
@@ -53,10 +58,10 @@ func (r *TicketRepo) Insert(ctx context.Context, t *Ticket) error {
|
||||
t.TicketNumber = n
|
||||
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status,ticket_title,ticket_description)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||
n, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open",
|
||||
t.TicketTitle, t.TicketDescription,
|
||||
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(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
n, t.UserID, t.Panel, t.Type, t.ChannelID, t.GuildID, t.ClaimChannelID, t.LogChannelID,
|
||||
t.StaffRoleID, t.ClaimReupMinutes, t.OpenedAt.UTC(), "open", t.TicketTitle, t.TicketDescription,
|
||||
)
|
||||
if err != nil {
|
||||
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).
|
||||
func (r *TicketRepo) InsertWithNumber(ctx context.Context, t *Ticket) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status,ticket_title,ticket_description)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||
t.TicketNumber, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open",
|
||||
t.TicketTitle, t.TicketDescription,
|
||||
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(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.TicketNumber, t.UserID, t.Panel, t.Type, t.ChannelID, t.GuildID, t.ClaimChannelID, t.LogChannelID,
|
||||
t.StaffRoleID, t.ClaimReupMinutes, t.OpenedAt.UTC(), "open", t.TicketTitle, t.TicketDescription,
|
||||
)
|
||||
if err != nil {
|
||||
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) {
|
||||
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
|
||||
FROM tickets WHERE channel_id=?`, channelID)
|
||||
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) {
|
||||
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
|
||||
FROM tickets WHERE id=?`, id)
|
||||
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) {
|
||||
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
|
||||
FROM tickets WHERE user_id=? AND type=? AND status IN('open','claimed')
|
||||
LIMIT 1`, userID, ticketType)
|
||||
@@ -111,6 +116,44 @@ func (r *TicketRepo) HasOpenTicket(ctx context.Context, userID, ticketType strin
|
||||
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 {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`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) {
|
||||
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
|
||||
FROM tickets WHERE status='open'`)
|
||||
if err != nil {
|
||||
@@ -164,6 +207,188 @@ func (r *TicketRepo) NextConvocationNumber(ctx context.Context) (int, error) {
|
||||
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.
|
||||
func NullStr(s string) sql.NullString {
|
||||
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) {
|
||||
var t Ticket
|
||||
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.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription)
|
||||
if err != nil {
|
||||
@@ -193,6 +419,7 @@ func scanTickets(rows *sql.Rows) ([]*Ticket, error) {
|
||||
for rows.Next() {
|
||||
var t Ticket
|
||||
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.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -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
@@ -15,9 +15,10 @@ type Bot struct {
|
||||
Tickets *db.TicketRepo
|
||||
Claims *db.ClaimMessageRepo
|
||||
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)
|
||||
if err != nil {
|
||||
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.IntentsMessageContent
|
||||
|
||||
allowed := make(map[string]bool, len(allowedGuilds))
|
||||
for _, id := range allowedGuilds {
|
||||
if id != "" {
|
||||
allowed[id] = true
|
||||
}
|
||||
}
|
||||
return &Bot{
|
||||
Session: s,
|
||||
Config: cfg,
|
||||
Tickets: tickets,
|
||||
Claims: claims,
|
||||
GuildID: guildID,
|
||||
AllowedGuilds: allowed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Bot) IsGuildAllowed(guildID string) bool {
|
||||
if len(b.AllowedGuilds) == 0 {
|
||||
return true
|
||||
}
|
||||
return b.AllowedGuilds[guildID]
|
||||
}
|
||||
|
||||
func (b *Bot) Open() error {
|
||||
return b.Session.Open()
|
||||
}
|
||||
@@ -46,6 +61,17 @@ func (b *Bot) Close() {
|
||||
|
||||
func (b *Bot) RegisterCommands(appID string) error {
|
||||
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 {
|
||||
_, err := b.Session.ApplicationCommandCreate(appID, b.GuildID, cmd)
|
||||
if err != nil {
|
||||
@@ -53,5 +79,6 @@ func (b *Bot) RegisterCommands(appID string) error {
|
||||
}
|
||||
slog.Info("registered command", "name", cmd.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
type PanelComponent struct {
|
||||
TicketSvc *tickets.Service
|
||||
TicketRepo *db.TicketRepo
|
||||
PanelRepo *db.PanelConfigRepo
|
||||
ClaimMgr *claim.Manager
|
||||
LogSvc *logger.DiscordLogger
|
||||
Config *config.Provider
|
||||
@@ -123,26 +124,48 @@ func (c *PanelComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.In
|
||||
ticket.TicketTitle = db.NullStr(ticketTitle)
|
||||
ticket.TicketDescription = db.NullStr(ticketDescription)
|
||||
|
||||
cfg := c.Config.Get()
|
||||
var embedTitle, embedText, embedColor string
|
||||
if panel, ok := cfg.Panels[panelName]; ok {
|
||||
var embedTitle, embedText, embedColor, thumbnailURL, imageURL string
|
||||
yamlCfg := c.Config.Get()
|
||||
if panel, ok := yamlCfg.Panels[panelName]; ok {
|
||||
if typeCfg, ok := panel.Types[ticketType]; ok {
|
||||
embedTitle = typeCfg.EmbedTitle
|
||||
embedText = typeCfg.EmbedText
|
||||
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)
|
||||
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
|
||||
if _, err := s.ChannelMessageSendComplex(ticket.ChannelID, &discordgo.MessageSend{
|
||||
Embeds: []*discordgo.MessageEmbed{
|
||||
{Title: embedTitle, Description: embedText, Color: parseColor(embedColor)},
|
||||
},
|
||||
Embeds: []*discordgo.MessageEmbed{welcomeEmbed},
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||
discordgo.Button{
|
||||
Label: "Supprimer le ticket",
|
||||
Label: "Fermer le ticket",
|
||||
Style: discordgo.DangerButton,
|
||||
CustomID: deleteCustomID,
|
||||
},
|
||||
@@ -155,8 +178,21 @@ func (c *PanelComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.In
|
||||
// b) Webhook impersonation message
|
||||
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.LogSvc.LogTicketOpened(ticket, 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
|
||||
avatarURL := user.AvatarURL("128")
|
||||
displayName := user.Username
|
||||
if user.GlobalName != "" {
|
||||
displayName = user.GlobalName
|
||||
}
|
||||
if member.Nick != "" {
|
||||
displayName = member.Nick
|
||||
}
|
||||
|
||||
@@ -60,11 +60,11 @@ func (c *TicketComponent) HandleDeleteConfirm(s *discordgo.Session, i *discordgo
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
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,
|
||||
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},
|
||||
}},
|
||||
},
|
||||
|
||||
@@ -16,9 +16,15 @@ type Router struct {
|
||||
ConvocCmd *commands.ConvocationCommand
|
||||
PanelComp *components.PanelComponent
|
||||
TicketComp *components.TicketComponent
|
||||
ConvocComp *components.ConvocationComponent
|
||||
IsGuildAllowed func(guildID string) bool
|
||||
}
|
||||
|
||||
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 {
|
||||
case discordgo.InteractionApplicationCommand:
|
||||
r.routeCommand(s, i)
|
||||
@@ -61,6 +67,10 @@ func (r *Router) routeComponent(s *discordgo.Session, i *discordgo.InteractionCr
|
||||
r.TicketComp.HandleDeleteCancel(s, i)
|
||||
case strings.HasPrefix(customID, "convoc:delete:"):
|
||||
r.TicketComp.HandleConvocationDelete(s, i)
|
||||
case strings.HasPrefix(customID, "convoc:panel:"):
|
||||
if r.ConvocComp != nil {
|
||||
r.ConvocComp.HandlePanel(s, i)
|
||||
}
|
||||
default:
|
||||
slog.Warn("unknown component", "custom_id", customID)
|
||||
}
|
||||
@@ -72,6 +82,10 @@ func (r *Router) routeModal(s *discordgo.Session, i *discordgo.InteractionCreate
|
||||
switch {
|
||||
case strings.HasPrefix(customID, "modal:ticket:"):
|
||||
r.PanelComp.HandleModalSubmit(s, i)
|
||||
case strings.HasPrefix(customID, "modal:convoc:"):
|
||||
if r.ConvocComp != nil {
|
||||
r.ConvocComp.HandleModalSubmit(s, i)
|
||||
}
|
||||
default:
|
||||
slog.Warn("unknown modal", "custom_id", customID)
|
||||
}
|
||||
|
||||
@@ -23,13 +23,27 @@ func (l *DiscordLogger) send(embed *discordgo.MessageEmbed) {
|
||||
if cfg.Bot.LogsChannel == "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
l.send(&discordgo.MessageEmbed{
|
||||
l.sendTo(l.logChannel(ticket), &discordgo.MessageEmbed{
|
||||
Title: "Ticket ouvert",
|
||||
Color: 0x57f287,
|
||||
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) {
|
||||
l.send(&discordgo.MessageEmbed{
|
||||
l.sendTo(l.logChannel(ticket), &discordgo.MessageEmbed{
|
||||
Title: "Ticket claim",
|
||||
Color: 0xfee75c,
|
||||
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) {
|
||||
embed := &discordgo.MessageEmbed{
|
||||
l.sendTo(l.logChannel(ticket), &discordgo.MessageEmbed{
|
||||
Title: "Ticket fermé",
|
||||
Color: 0xed4245,
|
||||
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: "Raison", Value: reason},
|
||||
},
|
||||
}
|
||||
l.send(embed)
|
||||
})
|
||||
}
|
||||
|
||||
func (l *DiscordLogger) LogConvocationOpened(staffID, targetID, reason, channelID string) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -60,6 +60,10 @@ func (a *AuthService) Can(memberRoles []string, action Action, ticket *db.Ticket
|
||||
if ticket == nil {
|
||||
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 t, ok := p.Types[ticket.Type]; ok {
|
||||
return t.StaffRole
|
||||
|
||||
@@ -16,14 +16,15 @@ import (
|
||||
|
||||
type Service struct {
|
||||
db *db.TicketRepo
|
||||
panelRepo *db.PanelConfigRepo
|
||||
auth *AuthService
|
||||
session *discordgo.Session
|
||||
cfg *config.Provider
|
||||
opening sync.Map // key: userID+":"+ticketType prevents double-click
|
||||
}
|
||||
|
||||
func NewService(repo *db.TicketRepo, auth *AuthService, s *discordgo.Session, cfg *config.Provider) *Service {
|
||||
return &Service{db: repo, auth: auth, session: s, cfg: cfg}
|
||||
func NewService(repo *db.TicketRepo, panelRepo *db.PanelConfigRepo, auth *AuthService, s *discordgo.Session, cfg *config.Provider) *Service {
|
||||
return &Service{db: repo, panelRepo: panelRepo, auth: auth, session: s, cfg: cfg}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
cfg := svc.cfg.Get()
|
||||
panel, ok := cfg.Panels[panelName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("panel %q not found", panelName)
|
||||
}
|
||||
var categoryID, claimChannelID, logChannelID, staffRoleID string
|
||||
var claimReupMinutes int
|
||||
yamlCfg := svc.cfg.Get()
|
||||
if panel, ok := yamlCfg.Panels[panelName]; ok {
|
||||
typeCfg, ok := panel.Types[ticketType]
|
||||
if !ok {
|
||||
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
|
||||
ticket := &db.Ticket{
|
||||
@@ -61,6 +87,11 @@ func (svc *Service) Open(ctx context.Context, guildID, userID, panelName, ticket
|
||||
Panel: panelName,
|
||||
Type: ticketType,
|
||||
ChannelID: fmt.Sprintf("pending-%d", time.Now().UnixNano()),
|
||||
GuildID: guildID,
|
||||
ClaimChannelID: claimChannelID,
|
||||
LogChannelID: logChannelID,
|
||||
StaffRoleID: staffRoleID,
|
||||
ClaimReupMinutes: claimReupMinutes,
|
||||
OpenedAt: time.Now(),
|
||||
}
|
||||
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{
|
||||
Name: channelName,
|
||||
Type: discordgo.ChannelTypeGuildText,
|
||||
ParentID: typeCfg.Category,
|
||||
ParentID: categoryID,
|
||||
PermissionOverwrites: []*discordgo.PermissionOverwrite{
|
||||
{
|
||||
ID: guildID,
|
||||
|
||||
Reference in New Issue
Block a user