Admins can now change user roles and permanently delete user accounts.
- New SetRole store method with validation (user/admin only)
- New Delete store method — cascades via foreign keys to sessions,
faves, and fave_tags
- handleAdminSetRole: change role with self-modification prevention
- handleAdminDeleteUser: permanent deletion with image cleanup from
disk before cascade delete, self-deletion prevention
- admin_users.html: role dropdown with save button per user row,
delete button with hx-confirm for safety
- Routes: POST /admin/users/{id}/role, POST /admin/users/{id}/delete
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
435 lines
13 KiB
Go
435 lines
13 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package handler
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"kode.naiv.no/olemd/favoritter/internal/image"
|
|
"kode.naiv.no/olemd/favoritter/internal/middleware"
|
|
"kode.naiv.no/olemd/favoritter/internal/render"
|
|
"kode.naiv.no/olemd/favoritter/internal/store"
|
|
)
|
|
|
|
// handleAdminDashboard shows admin overview stats.
|
|
func (h *Handler) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
|
|
userCount, _ := h.deps.Users.Count()
|
|
faveCount, _ := h.deps.Faves.Count()
|
|
pendingCount, _ := h.deps.SignupRequests.PendingCount()
|
|
settings, _ := h.deps.Settings.Get()
|
|
|
|
h.deps.Renderer.Page(w, r, "admin_dashboard", render.PageData{
|
|
Title: "Administrasjon",
|
|
Data: map[string]any{
|
|
"UserCount": userCount,
|
|
"FaveCount": faveCount,
|
|
"PendingCount": pendingCount,
|
|
"Settings": settings,
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleAdminUsers lists all users.
|
|
func (h *Handler) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
|
users, err := h.deps.Users.ListAll()
|
|
if err != nil {
|
|
slog.Error("list users error", "error", err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.deps.Renderer.Page(w, r, "admin_users", render.PageData{
|
|
Title: "Brukere",
|
|
Data: map[string]any{"Users": users},
|
|
})
|
|
}
|
|
|
|
// handleAdminCreateUser creates a new user with a temporary password.
|
|
func (h *Handler) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
username := strings.TrimSpace(r.FormValue("username"))
|
|
role := r.FormValue("role")
|
|
|
|
if !usernameRegexp.MatchString(username) {
|
|
h.adminUsersFlash(w, r, "Ugyldig brukernavn.", "error")
|
|
return
|
|
}
|
|
if role != "user" && role != "admin" {
|
|
role = "user"
|
|
}
|
|
|
|
// Generate a temporary password the admin can share with the user.
|
|
tempPassword := generateTempPassword()
|
|
|
|
_, err := h.deps.Users.CreateWithReset(username, tempPassword, role)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrUserExists) {
|
|
h.adminUsersFlash(w, r, "Brukernavnet er allerede i bruk.", "error")
|
|
return
|
|
}
|
|
slog.Error("admin create user error", "error", err)
|
|
h.adminUsersFlash(w, r, "Noe gikk galt.", "error")
|
|
return
|
|
}
|
|
|
|
h.adminUsersFlash(w, r,
|
|
"Bruker opprettet. Midlertidig passord: "+tempPassword+" — brukeren må endre det ved første innlogging.",
|
|
"success")
|
|
}
|
|
|
|
// handleAdminResetPassword resets a user's password to a temporary one.
|
|
func (h *Handler) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
tempPassword := generateTempPassword()
|
|
if err := h.deps.Users.UpdatePassword(id, tempPassword); err != nil {
|
|
slog.Error("admin reset password error", "error", err)
|
|
h.adminUsersFlash(w, r, "Noe gikk galt.", "error")
|
|
return
|
|
}
|
|
|
|
if err := h.deps.Users.SetMustResetPassword(id, true); err != nil {
|
|
slog.Error("set must-reset-password error", "error", err)
|
|
}
|
|
|
|
// Invalidate all sessions for this user.
|
|
if delErr := h.deps.Sessions.DeleteAllForUser(id); delErr != nil {
|
|
slog.Error("session cleanup error", "error", delErr)
|
|
}
|
|
|
|
h.adminUsersFlash(w, r,
|
|
"Passord tilbakestilt. Midlertidig passord: "+tempPassword,
|
|
"success")
|
|
}
|
|
|
|
// handleAdminToggleDisabled enables or disables a user.
|
|
func (h *Handler) handleAdminToggleDisabled(w http.ResponseWriter, r *http.Request) {
|
|
admin := middleware.UserFromContext(r.Context())
|
|
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Don't allow disabling yourself.
|
|
if id == admin.ID {
|
|
h.adminUsersFlash(w, r, "Du kan ikke deaktivere din egen konto.", "error")
|
|
return
|
|
}
|
|
|
|
user, err := h.deps.Users.GetByID(id)
|
|
if err != nil {
|
|
slog.Error("get user error", "error", err)
|
|
h.adminUsersFlash(w, r, "Noe gikk galt.", "error")
|
|
return
|
|
}
|
|
|
|
if err := h.deps.Users.SetDisabled(id, !user.Disabled); err != nil {
|
|
slog.Error("toggle disabled error", "error", err)
|
|
h.adminUsersFlash(w, r, "Noe gikk galt.", "error")
|
|
return
|
|
}
|
|
|
|
if !user.Disabled {
|
|
// Was enabled, now disabled — invalidate sessions.
|
|
if delErr := h.deps.Sessions.DeleteAllForUser(id); delErr != nil {
|
|
slog.Error("session cleanup error", "error", delErr)
|
|
}
|
|
}
|
|
|
|
action := "aktivert"
|
|
if !user.Disabled {
|
|
action = "deaktivert"
|
|
}
|
|
h.adminUsersFlash(w, r, "Bruker "+user.Username+" er "+action+".", "success")
|
|
}
|
|
|
|
// handleAdminSetRole changes a user's role.
|
|
func (h *Handler) handleAdminSetRole(w http.ResponseWriter, r *http.Request) {
|
|
admin := middleware.UserFromContext(r.Context())
|
|
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
if id == admin.ID {
|
|
h.adminUsersFlash(w, r, "Du kan ikke endre din egen rolle.", "error")
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
role := r.FormValue("role")
|
|
if role != "user" && role != "admin" {
|
|
h.adminUsersFlash(w, r, "Ugyldig rolle.", "error")
|
|
return
|
|
}
|
|
|
|
if err := h.deps.Users.SetRole(id, role); err != nil {
|
|
slog.Error("set role error", "error", err)
|
|
h.adminUsersFlash(w, r, "Noe gikk galt.", "error")
|
|
return
|
|
}
|
|
|
|
user, _ := h.deps.Users.GetByID(id)
|
|
h.adminUsersFlash(w, r, "Rollen til "+user.Username+" er endret til "+role+".", "success")
|
|
}
|
|
|
|
// handleAdminDeleteUser permanently deletes a user and all their data.
|
|
func (h *Handler) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|
admin := middleware.UserFromContext(r.Context())
|
|
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
if id == admin.ID {
|
|
h.adminUsersFlash(w, r, "Du kan ikke slette din egen konto.", "error")
|
|
return
|
|
}
|
|
|
|
user, err := h.deps.Users.GetByID(id)
|
|
if err != nil {
|
|
slog.Error("get user error", "error", err)
|
|
h.adminUsersFlash(w, r, "Noe gikk galt.", "error")
|
|
return
|
|
}
|
|
|
|
// Delete user's images from disk before database deletion.
|
|
faves, _, _ := h.deps.Faves.ListByUser(id, 100000, 0)
|
|
for _, f := range faves {
|
|
if f.ImagePath != "" {
|
|
if delErr := image.Delete(h.deps.Config.UploadDir, f.ImagePath); delErr != nil {
|
|
slog.Error("image delete error", "fave_id", f.ID, "error", delErr)
|
|
}
|
|
}
|
|
}
|
|
if user.AvatarPath != "" {
|
|
if delErr := image.Delete(h.deps.Config.UploadDir, user.AvatarPath); delErr != nil {
|
|
slog.Error("avatar delete error", "user_id", id, "error", delErr)
|
|
}
|
|
}
|
|
|
|
if err := h.deps.Users.Delete(id); err != nil {
|
|
slog.Error("delete user error", "error", err)
|
|
h.adminUsersFlash(w, r, "Noe gikk galt.", "error")
|
|
return
|
|
}
|
|
|
|
h.adminUsersFlash(w, r, "Bruker "+user.Username+" er permanent slettet.", "success")
|
|
}
|
|
|
|
// handleAdminTags lists all tags.
|
|
func (h *Handler) handleAdminTags(w http.ResponseWriter, r *http.Request) {
|
|
tags, err := h.deps.Tags.ListAll()
|
|
if err != nil {
|
|
slog.Error("list tags error", "error", err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.deps.Renderer.Page(w, r, "admin_tags", render.PageData{
|
|
Title: "Merkelapper",
|
|
Data: map[string]any{"Tags": tags},
|
|
})
|
|
}
|
|
|
|
// handleAdminRenameTag renames a tag.
|
|
func (h *Handler) handleAdminRenameTag(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
newName := strings.TrimSpace(r.FormValue("name"))
|
|
if newName == "" {
|
|
h.adminTagsFlash(w, r, "Navn kan ikke være tomt.", "error")
|
|
return
|
|
}
|
|
|
|
if err := h.deps.Tags.Rename(id, newName); err != nil {
|
|
slog.Error("rename tag error", "error", err)
|
|
h.adminTagsFlash(w, r, "Noe gikk galt. Kanskje navnet allerede finnes?", "error")
|
|
return
|
|
}
|
|
|
|
h.adminTagsFlash(w, r, "Merkelapp omdøpt.", "success")
|
|
}
|
|
|
|
// handleAdminDeleteTag deletes a tag.
|
|
func (h *Handler) handleAdminDeleteTag(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
if err := h.deps.Tags.Delete(id); err != nil {
|
|
slog.Error("delete tag error", "error", err)
|
|
h.adminTagsFlash(w, r, "Noe gikk galt.", "error")
|
|
return
|
|
}
|
|
|
|
h.adminTagsFlash(w, r, "Merkelapp slettet.", "success")
|
|
}
|
|
|
|
// handleAdminSignupRequests lists pending signup requests.
|
|
func (h *Handler) handleAdminSignupRequests(w http.ResponseWriter, r *http.Request) {
|
|
requests, err := h.deps.SignupRequests.ListPending()
|
|
if err != nil {
|
|
slog.Error("list signup requests error", "error", err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.deps.Renderer.Page(w, r, "admin_requests", render.PageData{
|
|
Title: "Registreringsforespørsler",
|
|
Data: map[string]any{"Requests": requests},
|
|
})
|
|
}
|
|
|
|
// handleAdminSignupRequestAction approves or rejects a signup request.
|
|
func (h *Handler) handleAdminSignupRequestAction(w http.ResponseWriter, r *http.Request) {
|
|
admin := middleware.UserFromContext(r.Context())
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
action := r.FormValue("action")
|
|
switch action {
|
|
case "approve":
|
|
if err := h.deps.SignupRequests.Approve(id, admin.ID); err != nil {
|
|
slog.Error("approve signup request error", "error", err)
|
|
h.adminRequestsFlash(w, r, "Noe gikk galt ved godkjenning.", "error")
|
|
return
|
|
}
|
|
h.adminRequestsFlash(w, r, "Forespørsel godkjent. Brukeren må endre passord ved første innlogging.", "success")
|
|
case "reject":
|
|
if err := h.deps.SignupRequests.Reject(id, admin.ID); err != nil {
|
|
slog.Error("reject signup request error", "error", err)
|
|
h.adminRequestsFlash(w, r, "Noe gikk galt.", "error")
|
|
return
|
|
}
|
|
h.adminRequestsFlash(w, r, "Forespørsel avvist.", "success")
|
|
default:
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// handleAdminSettingsGet shows the site settings page.
|
|
func (h *Handler) handleAdminSettingsGet(w http.ResponseWriter, r *http.Request) {
|
|
settings, err := h.deps.Settings.Get()
|
|
if err != nil {
|
|
slog.Error("get settings error", "error", err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.deps.Renderer.Page(w, r, "admin_settings", render.PageData{
|
|
Title: "Nettstedsinnstillinger",
|
|
Data: map[string]any{"Settings": settings},
|
|
})
|
|
}
|
|
|
|
// handleAdminSettingsPost updates site settings.
|
|
func (h *Handler) handleAdminSettingsPost(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
siteName := strings.TrimSpace(r.FormValue("site_name"))
|
|
siteDescription := strings.TrimSpace(r.FormValue("site_description"))
|
|
signupMode := r.FormValue("signup_mode")
|
|
|
|
if siteName == "" {
|
|
siteName = "Favoritter"
|
|
}
|
|
if signupMode != "open" && signupMode != "requests" && signupMode != "closed" {
|
|
signupMode = "open"
|
|
}
|
|
|
|
if err := h.deps.Settings.Update(siteName, siteDescription, signupMode); err != nil {
|
|
slog.Error("update settings error", "error", err)
|
|
h.adminSettingsFlash(w, r, "Noe gikk galt.", "error")
|
|
return
|
|
}
|
|
|
|
h.adminSettingsFlash(w, r, "Innstillingene er lagret.", "success")
|
|
}
|
|
|
|
// Flash helpers that redirect back to the relevant admin page.
|
|
func (h *Handler) adminUsersFlash(w http.ResponseWriter, r *http.Request, msg, flashType string) {
|
|
// Re-fetch and re-render the users page with flash.
|
|
users, _ := h.deps.Users.ListAll()
|
|
h.deps.Renderer.Page(w, r, "admin_users", render.PageData{
|
|
Title: "Brukere", Flash: msg, FlashType: flashType,
|
|
Data: map[string]any{"Users": users},
|
|
})
|
|
}
|
|
|
|
func (h *Handler) adminTagsFlash(w http.ResponseWriter, r *http.Request, msg, flashType string) {
|
|
tags, _ := h.deps.Tags.ListAll()
|
|
h.deps.Renderer.Page(w, r, "admin_tags", render.PageData{
|
|
Title: "Merkelapper", Flash: msg, FlashType: flashType,
|
|
Data: map[string]any{"Tags": tags},
|
|
})
|
|
}
|
|
|
|
func (h *Handler) adminRequestsFlash(w http.ResponseWriter, r *http.Request, msg, flashType string) {
|
|
requests, _ := h.deps.SignupRequests.ListPending()
|
|
h.deps.Renderer.Page(w, r, "admin_requests", render.PageData{
|
|
Title: "Registreringsforespørsler", Flash: msg, FlashType: flashType,
|
|
Data: map[string]any{"Requests": requests},
|
|
})
|
|
}
|
|
|
|
func (h *Handler) adminSettingsFlash(w http.ResponseWriter, r *http.Request, msg, flashType string) {
|
|
settings, _ := h.deps.Settings.Get()
|
|
h.deps.Renderer.Page(w, r, "admin_settings", render.PageData{
|
|
Title: "Nettstedsinnstillinger", Flash: msg, FlashType: flashType,
|
|
Data: map[string]any{"Settings": settings},
|
|
})
|
|
}
|
|
|
|
func generateTempPassword() string {
|
|
b := make([]byte, 12)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|