mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 10:03:43 +00:00
268 lines
8.2 KiB
Go
268 lines
8.2 KiB
Go
package core
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/MeshTender/MeshTender/internal/store"
|
|
"github.com/MeshTender/MeshTender/internal/web"
|
|
)
|
|
|
|
// requireCap is middleware that allows the request only if the current user
|
|
// satisfies pick; otherwise it 404s (without revealing the admin area exists).
|
|
func (s *Handlers) requireCap(pick func(*store.User) bool) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u, err := s.Store.GetUserByID(r.Context(), s.Auth.CurrentUserID(r.Context()))
|
|
if err != nil || !pick(u) {
|
|
s.NotFound(w, r)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
func capAny(u *store.User) bool { return u.CapManageUsers || u.CapManageCatalog }
|
|
func capCatalog(u *store.User) bool { return u.CapManageCatalog }
|
|
func capUsers(u *store.User) bool { return u.CapManageUsers }
|
|
|
|
// categoryGroup is a named list of per-command view models (the name is the
|
|
// feature area). The element type varies per page (raw command, share checkbox,
|
|
// per-tier checkbox); groupByFeature builds them all.
|
|
type categoryGroup[T any] struct {
|
|
Name string
|
|
Commands []T
|
|
}
|
|
|
|
// groupByFeature buckets the catalog by feature area (store.Command.Feature) —
|
|
// the same grouping the consent page uses — ordered by featureOrder, mapping
|
|
// each command via mk.
|
|
func groupByFeature[T any](catalog []*store.Command, mk func(*store.Command) T) []categoryGroup[T] {
|
|
byFeature := map[string][]T{}
|
|
var present []string
|
|
for _, c := range catalog {
|
|
if _, ok := byFeature[c.Feature]; !ok {
|
|
present = append(present, c.Feature)
|
|
}
|
|
byFeature[c.Feature] = append(byFeature[c.Feature], mk(c))
|
|
}
|
|
orderFeatures(present)
|
|
groups := make([]categoryGroup[T], 0, len(present))
|
|
for _, f := range present {
|
|
groups = append(groups, categoryGroup[T]{Name: f, Commands: byFeature[f]})
|
|
}
|
|
return groups
|
|
}
|
|
|
|
// catalogGroup buckets raw catalog commands for the admin catalog page.
|
|
type catalogGroup = categoryGroup[*store.Command]
|
|
|
|
func groupCatalog(catalog []*store.Command) []catalogGroup {
|
|
return groupByFeature(catalog, func(c *store.Command) *store.Command { return c })
|
|
}
|
|
|
|
func (s *Handlers) pageAdmin(w http.ResponseWriter, r *http.Request) {
|
|
u, err := s.Store.GetUserByID(r.Context(), s.Auth.CurrentUserID(r.Context()))
|
|
if err != nil {
|
|
s.ServerError(w, r, "could not load account", err)
|
|
return
|
|
}
|
|
s.Render(w, r, "admin.html", map[string]any{
|
|
"CanCatalog": u.CapManageCatalog,
|
|
"CanUsers": u.CapManageUsers,
|
|
})
|
|
}
|
|
|
|
func (s *Handlers) pageCatalog(w http.ResponseWriter, r *http.Request) {
|
|
catalog, err := s.Store.ListCommands(r.Context())
|
|
if err != nil {
|
|
s.ServerError(w, r, "could not load catalog", err)
|
|
return
|
|
}
|
|
s.Render(w, r, "admin_catalog.html", map[string]any{
|
|
"Groups": groupCatalog(catalog),
|
|
"Saved": r.URL.Query().Get("saved") != "",
|
|
})
|
|
}
|
|
|
|
func (s *Handlers) handleUpdateCommand(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
s.NotFound(w, r)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
on := func(name string) bool { return r.FormValue(name) != "" }
|
|
if err := s.Store.UpdateCommandFlags(r.Context(), id,
|
|
on("risky"), on("share"), on("org_member"), on("org_admin")); err != nil {
|
|
s.ServerError(w, r, "could not save", err)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/admin/catalog?saved=1", http.StatusSeeOther)
|
|
}
|
|
|
|
// userCursor carries the sort, search, capability filter, and keyset position of
|
|
// an admin user-list page, so "load more" stays consistent without re-sending
|
|
// the form; the sort/q/cap params only matter on the first (cursorless) page.
|
|
type userCursor struct {
|
|
Sort string `json:"s,omitempty"`
|
|
Query string `json:"q,omitempty"`
|
|
Cap string `json:"c,omitempty"`
|
|
Name string `json:"n,omitempty"` // username-sort cursor
|
|
Time string `json:"t,omitempty"` // RFC3339Nano, for last-login / newest sorts
|
|
ID int64 `json:"i"`
|
|
}
|
|
|
|
func nextUserCursor(p store.UserListParams, last *store.User) userCursor {
|
|
c := userCursor{Sort: string(p.Sort), Query: p.Query, Cap: string(p.Cap), ID: last.ID}
|
|
switch p.Sort {
|
|
case store.UserSortLastLogin:
|
|
c.Time = last.LastLoginKey().Format(time.RFC3339Nano)
|
|
case store.UserSortNewest:
|
|
c.Time = last.CreatedAt.Format(time.RFC3339Nano)
|
|
default: // UserSortName
|
|
c.Name = last.Username
|
|
}
|
|
return c
|
|
}
|
|
|
|
func (s *Handlers) pageUsers(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
sortKey := store.NormalizeUserSort(q.Get("sort"))
|
|
capFilter := store.NormalizeUserCapFilter(q.Get("cap"))
|
|
query := web.Clip(strings.TrimSpace(q.Get("q")), 100)
|
|
|
|
var p store.UserListParams
|
|
if c, ok := web.DecodeCursor[userCursor](q.Get("cursor")); ok {
|
|
sortKey = store.NormalizeUserSort(c.Sort)
|
|
capFilter = store.NormalizeUserCapFilter(c.Cap)
|
|
query = c.Query
|
|
p.HasCursor = true
|
|
p.AfterName = c.Name
|
|
p.AfterID = c.ID
|
|
if c.Time != "" {
|
|
p.AfterTime, _ = time.Parse(time.RFC3339Nano, c.Time)
|
|
}
|
|
}
|
|
p.Sort = sortKey
|
|
p.Cap = capFilter
|
|
p.Query = query
|
|
|
|
users, hasMore, err := s.Store.ListUsersPage(r.Context(), p)
|
|
if err != nil {
|
|
s.ServerError(w, r, "could not load users", err)
|
|
return
|
|
}
|
|
data := map[string]any{
|
|
"Users": users,
|
|
"Self": s.Auth.CurrentUserID(r.Context()),
|
|
"Error": q.Get("error"),
|
|
"Sort": string(sortKey),
|
|
"Cap": string(capFilter),
|
|
"Query": query,
|
|
}
|
|
if hasMore && len(users) > 0 {
|
|
data["NextCursor"] = web.EncodeCursor(nextUserCursor(p, users[len(users)-1]))
|
|
}
|
|
// htmx "load more": return just the rows + next control to append in place.
|
|
if r.Header.Get("HX-Request") != "" {
|
|
data["Layout"] = "users-frag"
|
|
}
|
|
s.Render(w, r, "admin_users.html", data)
|
|
}
|
|
|
|
// pageUserPermissions renders the per-user permissions modal fragment (the
|
|
// manage-users/manage-catalog form), loaded via htmx into the shared modal.
|
|
func (s *Handlers) pageUserPermissions(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
s.NotFound(w, r)
|
|
return
|
|
}
|
|
u, err := s.Store.GetUserByID(r.Context(), id)
|
|
if err != nil {
|
|
s.NotFound(w, r)
|
|
return
|
|
}
|
|
s.Render(w, r, "admin_user_permissions.html", map[string]any{
|
|
"Account": u,
|
|
"Self": s.Auth.CurrentUserID(r.Context()),
|
|
"Layout": "permissions-modal",
|
|
})
|
|
}
|
|
|
|
func (s *Handlers) handleSetUserCaps(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
s.NotFound(w, r)
|
|
return
|
|
}
|
|
manageUsers := r.FormValue("manage_users") != ""
|
|
manageCatalog := r.FormValue("manage_catalog") != ""
|
|
|
|
// Guard against removing the last user-manager.
|
|
if !manageUsers {
|
|
target, err := s.Store.GetUserByID(r.Context(), id)
|
|
if err != nil {
|
|
s.NotFound(w, r)
|
|
return
|
|
}
|
|
if target.CapManageUsers {
|
|
n, err := s.Store.CountManageUsers(r.Context())
|
|
if err != nil {
|
|
s.ServerError(w, r, "could not check administrators", err)
|
|
return
|
|
}
|
|
if n <= 1 {
|
|
web.RedirectErr(w, r, "/admin/users", "Can't remove the last user manager.")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if err := s.Store.SetCapabilities(r.Context(), id, manageUsers, manageCatalog); err != nil {
|
|
s.ServerError(w, r, "could not save", err)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
|
}
|
|
|
|
// usernameHistoryLimit bounds the rename history shown on the admin page.
|
|
const usernameHistoryLimit = 100
|
|
|
|
// pageUserHistory shows a user's username-change history. Admin-only (mounted
|
|
// under the manage-users capability): old usernames are never exposed elsewhere.
|
|
func (s *Handlers) pageUserHistory(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
s.NotFound(w, r)
|
|
return
|
|
}
|
|
u, err := s.Store.GetUserByID(r.Context(), id)
|
|
if err != nil {
|
|
s.NotFound(w, r)
|
|
return
|
|
}
|
|
changes, err := s.Store.ListUsernameChanges(r.Context(), id, usernameHistoryLimit)
|
|
if err != nil {
|
|
s.ServerError(w, r, "could not load history", err)
|
|
return
|
|
}
|
|
data := map[string]any{
|
|
"Account": u,
|
|
"Changes": changes,
|
|
}
|
|
// Loaded into the shared modal via htmx; render just the modal body then.
|
|
if r.Header.Get("HX-Request") != "" {
|
|
data["Layout"] = "history-modal"
|
|
}
|
|
s.Render(w, r, "admin_user_history.html", data)
|
|
}
|