feat: add admin panel with user, tag, and signup management

Phase 4 — Admin Panel:
- Admin dashboard with user/fave/pending-request counts
- User management: create with temp password, reset password,
  enable/disable accounts (prevents self-disable)
- Tag management: rename and delete tags
- Signup request management: approve (creates user with
  must-reset-password) and reject pending requests
- Site settings: site name, description, signup mode
  (open/requests/closed)
- All admin routes require both login and admin role
- SignupRequest model and full store (create, list pending,
  approve with user creation, reject)
- SetMustResetPassword method on UserStore for admin password resets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-29 16:09:30 +02:00
commit 13aec5be6e
11 changed files with 778 additions and 1 deletions

351
internal/handler/admin.go Normal file
View file

@ -0,0 +1,351 @@
// 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/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
}
// Force password reset on next login by setting the flag back.
h.deps.Users.SetMustResetPassword(id, true)
// 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")
}
// 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: "+err.Error(), "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)
}

View file

@ -112,5 +112,22 @@ func (h *Handler) Routes() *http.ServeMux {
mux.Handle("POST /settings/avatar", requireLogin(http.HandlerFunc(h.handleAvatarPost)))
mux.Handle("POST /settings/password", requireLogin(http.HandlerFunc(h.handleSettingsPasswordPost)))
// Admin panel (requires admin role).
admin := func(hf http.HandlerFunc) http.Handler {
return requireLogin(middleware.RequireAdmin(http.HandlerFunc(hf)))
}
mux.Handle("GET /admin", admin(h.handleAdminDashboard))
mux.Handle("GET /admin/users", admin(h.handleAdminUsers))
mux.Handle("POST /admin/users", admin(h.handleAdminCreateUser))
mux.Handle("POST /admin/users/{id}/reset-password", admin(h.handleAdminResetPassword))
mux.Handle("POST /admin/users/{id}/toggle-disabled", admin(h.handleAdminToggleDisabled))
mux.Handle("GET /admin/tags", admin(h.handleAdminTags))
mux.Handle("POST /admin/tags/{id}/rename", admin(h.handleAdminRenameTag))
mux.Handle("POST /admin/tags/{id}/delete", admin(h.handleAdminDeleteTag))
mux.Handle("GET /admin/signup-requests", admin(h.handleAdminSignupRequests))
mux.Handle("POST /admin/signup-requests/{id}", admin(h.handleAdminSignupRequestAction))
mux.Handle("GET /admin/settings", admin(h.handleAdminSettingsGet))
mux.Handle("POST /admin/settings", admin(h.handleAdminSettingsPost))
return mux
}

View file

@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package model
import "time"
type SignupRequest struct {
ID int64
Username string
PasswordHash string
Status string
CreatedAt time.Time
ReviewedAt *time.Time
ReviewedBy int64
}

View file

@ -7,9 +7,15 @@ import (
"errors"
"fmt"
"strings"
"time"
"kode.naiv.no/olemd/favoritter/internal/model"
)
var ErrSignupRequestExists = errors.New("signup request already exists")
var (
ErrSignupRequestExists = errors.New("signup request already exists")
ErrSignupRequestNotFound = errors.New("signup request not found")
)
type SignupRequestStore struct {
db *sql.DB
@ -39,6 +45,113 @@ func (s *SignupRequestStore) Create(username, password string) error {
return nil
}
// GetByID returns a signup request by ID.
func (s *SignupRequestStore) GetByID(id int64) (*model.SignupRequest, error) {
var sr model.SignupRequest
var createdAt string
var reviewedAt sql.NullString
var reviewedBy sql.NullInt64
err := s.db.QueryRow(
`SELECT id, username, password_hash, status, created_at, reviewed_at, reviewed_by
FROM signup_requests WHERE id = ?`, id,
).Scan(&sr.ID, &sr.Username, &sr.PasswordHash, &sr.Status, &createdAt, &reviewedAt, &reviewedBy)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSignupRequestNotFound
}
if err != nil {
return nil, err
}
sr.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
if reviewedAt.Valid {
t, _ := time.Parse(time.RFC3339, reviewedAt.String)
sr.ReviewedAt = &t
}
if reviewedBy.Valid {
sr.ReviewedBy = reviewedBy.Int64
}
return &sr, nil
}
// ListPending returns all pending signup requests, newest first.
func (s *SignupRequestStore) ListPending() ([]*model.SignupRequest, error) {
rows, err := s.db.Query(
`SELECT id, username, password_hash, status, created_at, reviewed_at, reviewed_by
FROM signup_requests WHERE status = 'pending'
ORDER BY created_at DESC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var requests []*model.SignupRequest
for rows.Next() {
var sr model.SignupRequest
var createdAt string
var reviewedAt sql.NullString
var reviewedBy sql.NullInt64
if err := rows.Scan(&sr.ID, &sr.Username, &sr.PasswordHash, &sr.Status, &createdAt, &reviewedAt, &reviewedBy); err != nil {
return nil, err
}
sr.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
requests = append(requests, &sr)
}
return requests, rows.Err()
}
// Approve marks a request as approved and creates the user account.
// The new user will have must_reset_password=1.
func (s *SignupRequestStore) Approve(id int64, adminID int64) error {
sr, err := s.GetByID(id)
if err != nil {
return err
}
if sr.Status != "pending" {
return fmt.Errorf("request is not pending (status: %s)", sr.Status)
}
// Create the user with the already-hashed password.
_, err = s.db.Exec(
`INSERT INTO users (username, password_hash, must_reset_password) VALUES (?, ?, 1)`,
sr.Username, sr.PasswordHash,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
return ErrUserExists
}
return fmt.Errorf("create user from request: %w", err)
}
// Mark the request as approved.
_, err = s.db.Exec(
`UPDATE signup_requests SET status = 'approved',
reviewed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
reviewed_by = ?
WHERE id = ?`,
adminID, id,
)
return err
}
// Reject marks a request as rejected.
func (s *SignupRequestStore) Reject(id int64, adminID int64) error {
result, err := s.db.Exec(
`UPDATE signup_requests SET status = 'rejected',
reviewed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
reviewed_by = ?
WHERE id = ? AND status = 'pending'`,
adminID, id,
)
if err != nil {
return err
}
n, _ := result.RowsAffected()
if n == 0 {
return ErrSignupRequestNotFound
}
return nil
}
// PendingCount returns the number of pending signup requests.
func (s *SignupRequestStore) PendingCount() (int, error) {
var n int

View file

@ -157,6 +157,21 @@ func (s *UserStore) UpdateProfile(userID int64, displayName, bio, profileVisibil
return err
}
// SetMustResetPassword sets or clears the must_reset_password flag.
func (s *UserStore) SetMustResetPassword(userID int64, must bool) error {
val := 0
if must {
val = 1
}
_, err := s.db.Exec(
`UPDATE users SET must_reset_password = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?`,
val, userID,
)
return err
}
// UpdateAvatar updates a user's avatar path.
func (s *UserStore) UpdateAvatar(userID int64, avatarPath string) error {
_, err := s.db.Exec(