407 lines
12 KiB
Go
407 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
|
|
)
|
|
|
|
// OperatorRole is the OIDC role required to access operator pages
|
|
const OperatorRole = "operator-member"
|
|
|
|
// OperatorPartialsHandler handles HTMX partial requests for operator pages
|
|
type OperatorPartialsHandler struct {
|
|
DB db.Querier
|
|
Logger *slog.Logger
|
|
AuthConfig *auth.Config
|
|
Templates *template.Template
|
|
}
|
|
|
|
// OperatorPartialsConfig holds configuration for the operator partials handler
|
|
type OperatorPartialsConfig struct {
|
|
DB db.Querier
|
|
Logger *slog.Logger
|
|
AuthConfig *auth.Config
|
|
}
|
|
|
|
// NewOperatorPartialsHandler creates a new OperatorPartialsHandler
|
|
func NewOperatorPartialsHandler(cfg OperatorPartialsConfig) (*OperatorPartialsHandler, error) {
|
|
// Parse partial templates
|
|
templateSubFS, err := fs.Sub(embeds.Templates, "templates/partials")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tmpl, err := template.ParseFS(templateSubFS, "operator_*.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &OperatorPartialsHandler{
|
|
DB: cfg.DB,
|
|
Logger: cfg.Logger,
|
|
AuthConfig: cfg.AuthConfig,
|
|
Templates: tmpl,
|
|
}, nil
|
|
}
|
|
|
|
// RegisterRoutes registers all operator HTMX partial routes
|
|
func (h *OperatorPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /partials/operator/users", h.requireOperatorRole(h.GetUsers))
|
|
mux.HandleFunc("GET /partials/operator/sites", h.requireOperatorRole(h.GetSites))
|
|
mux.HandleFunc("GET /partials/operator/payments", h.requireOperatorRole(h.GetPayments))
|
|
mux.HandleFunc("GET /partials/operator/user/{id}/edit", h.requireOperatorRole(h.GetUserEditForm))
|
|
mux.HandleFunc("PUT /partials/operator/user/{id}", h.requireOperatorRole(h.UpdateUser))
|
|
}
|
|
|
|
// requireOperatorRole is middleware that checks for the operator role
|
|
func (h *OperatorPartialsHandler) requireOperatorRole(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !h.AuthConfig.HasRole(r, OperatorRole) {
|
|
h.Logger.Warn("operator partials access denied",
|
|
slog.String("path", r.URL.Path),
|
|
slog.String("reason", "missing operator-member role"))
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// UserViewModel represents a user for operator template rendering
|
|
type UserViewModel struct {
|
|
ID int64
|
|
Username string
|
|
Email string
|
|
IsMember bool
|
|
SitesQuota int64
|
|
SitesCount int64
|
|
CreatedAt string
|
|
}
|
|
|
|
// UsersData holds data for the users list partial
|
|
type UsersData struct {
|
|
Users []UserViewModel
|
|
Error string
|
|
}
|
|
|
|
// GetUsers handles GET /partials/operator/users
|
|
func (h *OperatorPartialsHandler) GetUsers(w http.ResponseWriter, r *http.Request) {
|
|
users, err := h.DB.ListUsers(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list users", slog.Any("error", err))
|
|
h.renderError(w, "operator_users.html", "Failed to retrieve users")
|
|
return
|
|
}
|
|
|
|
// Build view models with site counts
|
|
userVMs := make([]UserViewModel, len(users))
|
|
for i, user := range users {
|
|
siteCount, err := h.DB.GetSiteCountByUserID(r.Context(), user.ID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to get site count", slog.Any("error", err), slog.Int64("userID", user.ID))
|
|
siteCount = 0
|
|
}
|
|
|
|
userVMs[i] = UserViewModel{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
Email: user.Email,
|
|
IsMember: user.IsMember != 0,
|
|
SitesQuota: user.SitesQuota,
|
|
SitesCount: siteCount,
|
|
CreatedAt: user.CreatedAt.Format("Jan 2, 2006"),
|
|
}
|
|
}
|
|
|
|
data := UsersData{Users: userVMs}
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
if err := h.Templates.ExecuteTemplate(w, "operator_users.html", data); err != nil {
|
|
h.Logger.Error("template error", slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
// OperatorSiteViewModel represents a site for operator template rendering
|
|
type OperatorSiteViewModel struct {
|
|
ID int64
|
|
Domain string
|
|
OwnerUsername string
|
|
OwnerID int64
|
|
IsCustomDomain bool
|
|
CreatedAt string
|
|
}
|
|
|
|
// SitesData holds data for the sites list partial
|
|
type OperatorSitesData struct {
|
|
Sites []OperatorSiteViewModel
|
|
Error string
|
|
}
|
|
|
|
// GetSites handles GET /partials/operator/sites
|
|
func (h *OperatorPartialsHandler) GetSites(w http.ResponseWriter, r *http.Request) {
|
|
sites, err := h.DB.GetAllSites(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to get all sites", slog.Any("error", err))
|
|
h.renderError(w, "operator_sites.html", "Failed to retrieve sites")
|
|
return
|
|
}
|
|
|
|
// Get all users for owner lookup
|
|
users, err := h.DB.ListUsers(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list users for owner lookup", slog.Any("error", err))
|
|
}
|
|
userMap := make(map[int64]string)
|
|
for _, u := range users {
|
|
userMap[u.ID] = u.Username
|
|
}
|
|
|
|
siteVMs := make([]OperatorSiteViewModel, len(sites))
|
|
for i, site := range sites {
|
|
ownerUsername := userMap[site.UserID]
|
|
if ownerUsername == "" {
|
|
ownerUsername = "(unknown)"
|
|
}
|
|
|
|
siteVMs[i] = OperatorSiteViewModel{
|
|
ID: site.ID,
|
|
Domain: site.Domain,
|
|
OwnerUsername: ownerUsername,
|
|
OwnerID: site.UserID,
|
|
IsCustomDomain: site.IsCustomDomain != 0,
|
|
CreatedAt: site.CreatedAt.Format("Jan 2, 2006"),
|
|
}
|
|
}
|
|
|
|
data := OperatorSitesData{Sites: siteVMs}
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
if err := h.Templates.ExecuteTemplate(w, "operator_sites.html", data); err != nil {
|
|
h.Logger.Error("template error", slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
// PaymentViewModel represents a payment for operator template rendering
|
|
type PaymentViewModel struct {
|
|
ID int64
|
|
UserID int64
|
|
Username string
|
|
Amount string // Formatted with currency
|
|
Currency string
|
|
Status string
|
|
PaidAt string
|
|
CreatedAt string
|
|
}
|
|
|
|
// PaymentsData holds data for the payments list partial
|
|
type PaymentsData struct {
|
|
Payments []PaymentViewModel
|
|
Error string
|
|
}
|
|
|
|
// GetPayments handles GET /partials/operator/payments
|
|
func (h *OperatorPartialsHandler) GetPayments(w http.ResponseWriter, r *http.Request) {
|
|
payments, err := h.DB.ListPayments(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list payments", slog.Any("error", err))
|
|
h.renderError(w, "operator_payments.html", "Failed to retrieve payments")
|
|
return
|
|
}
|
|
|
|
// Get all users for username lookup
|
|
users, err := h.DB.ListUsers(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list users for payment lookup", slog.Any("error", err))
|
|
}
|
|
userMap := make(map[int64]string)
|
|
for _, u := range users {
|
|
userMap[u.ID] = u.Username
|
|
}
|
|
|
|
paymentVMs := make([]PaymentViewModel, len(payments))
|
|
for i, payment := range payments {
|
|
username := userMap[payment.UserID]
|
|
if username == "" {
|
|
username = "(unknown)"
|
|
}
|
|
|
|
paidAt := "-"
|
|
if payment.PaidAt.Valid {
|
|
paidAt = payment.PaidAt.Time.Format("Jan 2, 2006")
|
|
}
|
|
|
|
// Format amount (assuming minor units, e.g., cents)
|
|
amountStr := formatAmount(payment.Amount, payment.Currency)
|
|
|
|
paymentVMs[i] = PaymentViewModel{
|
|
ID: payment.ID,
|
|
UserID: payment.UserID,
|
|
Username: username,
|
|
Amount: amountStr,
|
|
Currency: payment.Currency,
|
|
Status: payment.Status,
|
|
PaidAt: paidAt,
|
|
CreatedAt: payment.CreatedAt.Format("Jan 2, 2006"),
|
|
}
|
|
}
|
|
|
|
data := PaymentsData{Payments: paymentVMs}
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
if err := h.Templates.ExecuteTemplate(w, "operator_payments.html", data); err != nil {
|
|
h.Logger.Error("template error", slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
// UserEditData holds data for the user edit form partial
|
|
type UserEditData struct {
|
|
User UserViewModel
|
|
Error string
|
|
}
|
|
|
|
// GetUserEditForm handles GET /partials/operator/user/{id}/edit
|
|
func (h *OperatorPartialsHandler) GetUserEditForm(w http.ResponseWriter, r *http.Request) {
|
|
idStr := r.PathValue("id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
h.Logger.Error("invalid user ID", slog.String("id", idStr))
|
|
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
user, err := h.DB.GetUserByID(r.Context(), id)
|
|
if err != nil {
|
|
h.Logger.Error("failed to get user", slog.Any("error", err), slog.Int64("id", id))
|
|
h.renderEditError(w, id, "User not found")
|
|
return
|
|
}
|
|
|
|
siteCount, _ := h.DB.GetSiteCountByUserID(r.Context(), id)
|
|
|
|
data := UserEditData{
|
|
User: UserViewModel{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
Email: user.Email,
|
|
IsMember: user.IsMember != 0,
|
|
SitesQuota: user.SitesQuota,
|
|
SitesCount: siteCount,
|
|
CreatedAt: user.CreatedAt.Format("Jan 2, 2006"),
|
|
},
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
if err := h.Templates.ExecuteTemplate(w, "operator_user_edit.html", data); err != nil {
|
|
h.Logger.Error("template error", slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
// UpdateUser handles PUT /partials/operator/user/{id}
|
|
func (h *OperatorPartialsHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
|
idStr := r.PathValue("id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
h.Logger.Error("invalid user ID", slog.String("id", idStr))
|
|
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderEditError(w, id, "Invalid form data")
|
|
return
|
|
}
|
|
|
|
// Parse is_member checkbox (present = true, absent = false)
|
|
isMember := r.FormValue("is_member") == "on" || r.FormValue("is_member") == "true" || r.FormValue("is_member") == "1"
|
|
|
|
sitesQuotaStr := r.FormValue("sites_quota")
|
|
sitesQuota, err := strconv.ParseInt(sitesQuotaStr, 10, 64)
|
|
if err != nil || sitesQuota < 0 {
|
|
h.renderEditError(w, id, "Invalid sites quota value")
|
|
return
|
|
}
|
|
|
|
var isMemberInt int64 = 0
|
|
if isMember {
|
|
isMemberInt = 1
|
|
}
|
|
|
|
_, err = h.DB.UpdateUserMembership(r.Context(), db.UpdateUserMembershipParams{
|
|
ID: id,
|
|
IsMember: isMemberInt,
|
|
SitesQuota: sitesQuota,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to update user membership", slog.Any("error", err), slog.Int64("id", id))
|
|
h.renderEditError(w, id, "Failed to update user")
|
|
return
|
|
}
|
|
|
|
h.Logger.Info("user membership updated",
|
|
slog.Int64("id", id),
|
|
slog.Bool("is_member", isMember),
|
|
slog.Int64("sites_quota", sitesQuota))
|
|
|
|
// Return success and trigger refresh
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Header().Set("HX-Trigger", `{"refreshUsers": true, "closeModal": true}`)
|
|
w.Write([]byte(`<div class="alert alert-success">User updated successfully</div>`))
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func (h *OperatorPartialsHandler) renderError(w http.ResponseWriter, template string, message string) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
data := struct{ Error string }{Error: message}
|
|
h.Templates.ExecuteTemplate(w, template, data)
|
|
}
|
|
|
|
func (h *OperatorPartialsHandler) renderEditError(w http.ResponseWriter, userID int64, message string) {
|
|
// Re-fetch user data and render with error
|
|
user, err := h.DB.GetUserByID(context.TODO(), userID)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(`<div class="alert alert-danger">` + message + `</div>`))
|
|
return
|
|
}
|
|
|
|
siteCount, _ := h.DB.GetSiteCountByUserID(context.TODO(), userID)
|
|
|
|
data := UserEditData{
|
|
User: UserViewModel{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
Email: user.Email,
|
|
IsMember: user.IsMember != 0,
|
|
SitesQuota: user.SitesQuota,
|
|
SitesCount: siteCount,
|
|
},
|
|
Error: message,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
h.Templates.ExecuteTemplate(w, "operator_user_edit.html", data)
|
|
}
|
|
|
|
func formatAmount(amount int64, currency string) string {
|
|
// Simple formatting - assumes minor units (cents)
|
|
major := amount / 100
|
|
minor := amount % 100
|
|
return currency + " " + strconv.FormatInt(major, 10) + "." + padLeft(strconv.FormatInt(minor, 10), 2, '0')
|
|
}
|
|
|
|
func padLeft(s string, length int, pad rune) string {
|
|
for len(s) < length {
|
|
s = string(pad) + s
|
|
}
|
|
return s
|
|
}
|