diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..224038f --- /dev/null +++ b/internal/handler/admin.go @@ -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) +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 7514231..b81e20e 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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 } diff --git a/internal/model/signup_request.go b/internal/model/signup_request.go new file mode 100644 index 0000000..2a5977e --- /dev/null +++ b/internal/model/signup_request.go @@ -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 +} diff --git a/internal/store/signup_request.go b/internal/store/signup_request.go index 15fcdd5..58e8de4 100644 --- a/internal/store/signup_request.go +++ b/internal/store/signup_request.go @@ -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 diff --git a/internal/store/user.go b/internal/store/user.go index 74e41b2..22b766a 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -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( diff --git a/web/static/css/style.css b/web/static/css/style.css index 763e273..ec1901c 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -169,6 +169,25 @@ border-radius: var(--pico-border-radius); } +/* Admin */ +.stat { + font-size: 2rem; + font-weight: bold; + margin: 0; +} + +.disabled-row { + opacity: 0.5; +} + +.inline-input { + display: inline-block; + width: auto; + margin: 0; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + /* Respect reduced motion preference */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { diff --git a/web/templates/pages/admin_dashboard.html b/web/templates/pages/admin_dashboard.html new file mode 100644 index 0000000..3acba2c --- /dev/null +++ b/web/templates/pages/admin_dashboard.html @@ -0,0 +1,41 @@ +{{define "head"}} + +{{end}} + +{{define "content"}} +

Administrasjon

+ +{{with .Data}} +
+
+
Brukere
+

{{.UserCount}}

+ +
+
+
Favoritter
+

{{.FaveCount}}

+
+
+
Ventende forespørsler
+

{{.PendingCount}}

+ {{if gt .PendingCount 0}} + + {{end}} +
+
+ + + +{{with .Settings}} +

Registreringsmodus: {{.SignupMode}}

+{{end}} +{{end}} +{{end}} diff --git a/web/templates/pages/admin_requests.html b/web/templates/pages/admin_requests.html new file mode 100644 index 0000000..ad2eafe --- /dev/null +++ b/web/templates/pages/admin_requests.html @@ -0,0 +1,44 @@ +{{define "head"}} + +{{end}} + +{{define "content"}} +

Registreringsforespørsler

+ +{{with .Data}} + {{if .Requests}} + + + + + + + + + + {{range .Requests}} + + + + + + {{end}} + +
BrukernavnSendtHandlinger
{{.Username}}{{.CreatedAt.Format "02.01.2006 15:04"}} +
+ + + +
+
+ + + +
+
+ {{else}} +

Ingen ventende forespørsler.

+ {{end}} +{{end}} +{{end}} diff --git a/web/templates/pages/admin_settings.html b/web/templates/pages/admin_settings.html new file mode 100644 index 0000000..daff9e3 --- /dev/null +++ b/web/templates/pages/admin_settings.html @@ -0,0 +1,48 @@ +{{define "head"}} + +{{end}} + +{{define "content"}} +

Nettstedsinnstillinger

+ +{{with .Data}}{{with .Settings}} +
+
+ + + + + + +
+ Registreringsmodus + + + +
+ + +
+
+{{end}}{{end}} +{{end}} diff --git a/web/templates/pages/admin_tags.html b/web/templates/pages/admin_tags.html new file mode 100644 index 0000000..b885166 --- /dev/null +++ b/web/templates/pages/admin_tags.html @@ -0,0 +1,42 @@ +{{define "head"}} + +{{end}} + +{{define "content"}} +

Merkelapper

+ +{{with .Data}} + {{if .Tags}} + + + + + + + + + {{range .Tags}} + + + + + {{end}} + +
NavnHandlinger
{{.Name}} +
+ + + +
+
+ + +
+
+ {{else}} +

Ingen merkelapper ennå.

+ {{end}} +{{end}} +{{end}} diff --git a/web/templates/pages/admin_users.html b/web/templates/pages/admin_users.html new file mode 100644 index 0000000..c2817c4 --- /dev/null +++ b/web/templates/pages/admin_users.html @@ -0,0 +1,72 @@ +{{define "head"}} + +{{end}} + +{{define "content"}} +

Brukere

+ +
+

Opprett ny bruker

+
+ +
+ + +
+ + Brukeren vil få et midlertidig passord og må endre det ved første innlogging. +
+
+ +{{with .Data}} + + + + + + + + + + + + + {{range .Users}} + + + + + + + + + {{end}} + +
BrukernavnVisningsnavnRolleStatusOpprettetHandlinger
{{.Username}}{{.DisplayName}}{{.Role}} + {{if .Disabled}}Deaktivert + {{else if .MustResetPassword}}Må endre passord + {{else}}Aktiv{{end}} + {{.CreatedAt.Format "02.01.2006"}} +
+ + +
+
+ + +
+
+{{end}} +{{end}}