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/avatar", requireLogin(http.HandlerFunc(h.handleAvatarPost)))
mux.Handle("POST /settings/password", requireLogin(http.HandlerFunc(h.handleSettingsPasswordPost))) 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 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" "errors"
"fmt" "fmt"
"strings" "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 { type SignupRequestStore struct {
db *sql.DB db *sql.DB
@ -39,6 +45,113 @@ func (s *SignupRequestStore) Create(username, password string) error {
return nil 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. // PendingCount returns the number of pending signup requests.
func (s *SignupRequestStore) PendingCount() (int, error) { func (s *SignupRequestStore) PendingCount() (int, error) {
var n int var n int

View file

@ -157,6 +157,21 @@ func (s *UserStore) UpdateProfile(userID int64, displayName, bio, profileVisibil
return err 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. // UpdateAvatar updates a user's avatar path.
func (s *UserStore) UpdateAvatar(userID int64, avatarPath string) error { func (s *UserStore) UpdateAvatar(userID int64, avatarPath string) error {
_, err := s.db.Exec( _, err := s.db.Exec(

View file

@ -169,6 +169,25 @@
border-radius: var(--pico-border-radius); 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 */ /* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { *, *::before, *::after {

View file

@ -0,0 +1,41 @@
{{define "head"}}
<meta name="robots" content="noindex">
{{end}}
{{define "content"}}
<h1>Administrasjon</h1>
{{with .Data}}
<div class="grid">
<article>
<header><strong>Brukere</strong></header>
<p class="stat">{{.UserCount}}</p>
<footer><a href="{{basePath}}/admin/users">Administrer brukere</a></footer>
</article>
<article>
<header><strong>Favoritter</strong></header>
<p class="stat">{{.FaveCount}}</p>
</article>
<article>
<header><strong>Ventende forespørsler</strong></header>
<p class="stat">{{.PendingCount}}</p>
{{if gt .PendingCount 0}}
<footer><a href="{{basePath}}/admin/signup-requests">Se forespørsler</a></footer>
{{end}}
</article>
</div>
<nav>
<ul>
<li><a href="{{basePath}}/admin/users" role="button" class="outline">Brukere</a></li>
<li><a href="{{basePath}}/admin/tags" role="button" class="outline">Merkelapper</a></li>
<li><a href="{{basePath}}/admin/signup-requests" role="button" class="outline">Forespørsler</a></li>
<li><a href="{{basePath}}/admin/settings" role="button" class="outline">Innstillinger</a></li>
</ul>
</nav>
{{with .Settings}}
<p><small>Registreringsmodus: <strong>{{.SignupMode}}</strong></small></p>
{{end}}
{{end}}
{{end}}

View file

@ -0,0 +1,44 @@
{{define "head"}}
<meta name="robots" content="noindex">
{{end}}
{{define "content"}}
<h1>Registreringsforespørsler</h1>
{{with .Data}}
{{if .Requests}}
<table role="grid">
<thead>
<tr>
<th scope="col">Brukernavn</th>
<th scope="col">Sendt</th>
<th scope="col">Handlinger</th>
</tr>
</thead>
<tbody>
{{range .Requests}}
<tr>
<td>{{.Username}}</td>
<td>{{.CreatedAt.Format "02.01.2006 15:04"}}</td>
<td>
<form method="POST" action="{{basePath}}/admin/signup-requests/{{.ID}}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="approve">
<button type="submit" class="outline primary nav-button">Godkjenn</button>
</form>
<form method="POST" action="{{basePath}}/admin/signup-requests/{{.ID}}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="reject">
<button type="submit" class="outline secondary nav-button"
onclick="return confirm('Avvis forespørselen fra «{{.Username}}»?')">Avvis</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>Ingen ventende forespørsler.</p>
{{end}}
{{end}}
{{end}}

View file

@ -0,0 +1,48 @@
{{define "head"}}
<meta name="robots" content="noindex">
{{end}}
{{define "content"}}
<h1>Nettstedsinnstillinger</h1>
{{with .Data}}{{with .Settings}}
<article>
<form method="POST" action="{{basePath}}/admin/settings">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<label for="site_name">
Nettstedsnavn
<input type="text" id="site_name" name="site_name"
value="{{.SiteName}}" required>
</label>
<label for="site_description">
Beskrivelse
<textarea id="site_description" name="site_description"
rows="3">{{.SiteDescription}}</textarea>
</label>
<fieldset>
<legend>Registreringsmodus</legend>
<label>
<input type="radio" name="signup_mode" value="open"
{{if eq .SignupMode "open"}}checked{{end}}>
Åpen — alle kan registrere seg
</label>
<label>
<input type="radio" name="signup_mode" value="requests"
{{if eq .SignupMode "requests"}}checked{{end}}>
Forespørsler — nye brukere må godkjennes
</label>
<label>
<input type="radio" name="signup_mode" value="closed"
{{if eq .SignupMode "closed"}}checked{{end}}>
Stengt — ingen nye registreringer
</label>
</fieldset>
<button type="submit">Lagre innstillinger</button>
</form>
</article>
{{end}}{{end}}
{{end}}

View file

@ -0,0 +1,42 @@
{{define "head"}}
<meta name="robots" content="noindex">
{{end}}
{{define "content"}}
<h1>Merkelapper</h1>
{{with .Data}}
{{if .Tags}}
<table role="grid">
<thead>
<tr>
<th scope="col">Navn</th>
<th scope="col">Handlinger</th>
</tr>
</thead>
<tbody>
{{range .Tags}}
<tr>
<td><a href="{{basePath}}/tags/{{.Name}}">{{.Name}}</a></td>
<td>
<form method="POST" action="{{basePath}}/admin/tags/{{.ID}}/rename" class="inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="text" name="name" value="{{.Name}}" required
class="inline-input" aria-label="Nytt navn for merkelapp">
<button type="submit" class="outline nav-button">Omdøp</button>
</form>
<form method="POST" action="{{basePath}}/admin/tags/{{.ID}}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="outline secondary nav-button"
onclick="return confirm('Slett merkelappen «{{.Name}}»?')">Slett</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>Ingen merkelapper ennå.</p>
{{end}}
{{end}}
{{end}}

View file

@ -0,0 +1,72 @@
{{define "head"}}
<meta name="robots" content="noindex">
{{end}}
{{define "content"}}
<h1>Brukere</h1>
<article>
<h2>Opprett ny bruker</h2>
<form method="POST" action="{{basePath}}/admin/users">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="grid">
<label for="username">
Brukernavn
<input type="text" id="username" name="username" required
pattern="[a-zA-Z0-9_-]+" minlength="2" maxlength="30">
</label>
<label for="role">
Rolle
<select id="role" name="role">
<option value="user">Bruker</option>
<option value="admin">Administrator</option>
</select>
</label>
</div>
<button type="submit">Opprett bruker</button>
<small>Brukeren vil få et midlertidig passord og må endre det ved første innlogging.</small>
</form>
</article>
{{with .Data}}
<table role="grid">
<thead>
<tr>
<th scope="col">Brukernavn</th>
<th scope="col">Visningsnavn</th>
<th scope="col">Rolle</th>
<th scope="col">Status</th>
<th scope="col">Opprettet</th>
<th scope="col">Handlinger</th>
</tr>
</thead>
<tbody>
{{range .Users}}
<tr {{if .Disabled}}class="disabled-row"{{end}}>
<td><a href="{{basePath}}/u/{{.Username}}">{{.Username}}</a></td>
<td>{{.DisplayName}}</td>
<td>{{.Role}}</td>
<td>
{{if .Disabled}}Deaktivert
{{else if .MustResetPassword}}Må endre passord
{{else}}Aktiv{{end}}
</td>
<td>{{.CreatedAt.Format "02.01.2006"}}</td>
<td>
<form method="POST" action="{{basePath}}/admin/users/{{.ID}}/reset-password" class="inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="outline secondary nav-button">Tilbakestill passord</button>
</form>
<form method="POST" action="{{basePath}}/admin/users/{{.ID}}/toggle-disabled" class="inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="outline {{if .Disabled}}primary{{else}}secondary{{end}} nav-button">
{{if .Disabled}}Aktiver{{else}}Deaktiver{{end}}
</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{end}}