admin: add admin panel for user management

This commit is contained in:
2026-04-22 21:16:26 +02:00
parent 7e15380638
commit 77f0daca26
11 changed files with 530 additions and 9 deletions

175
api/admin/handler.go Normal file
View File

@@ -0,0 +1,175 @@
package admin
import (
"database/sql"
"html"
"log"
"net/http"
"golang.org/x/crypto/bcrypt"
"mal/api/auth"
"mal/internal/db"
"mal/internal/middleware"
"mal/web/templates"
)
type Handler struct {
db database.Querier
authService *auth.Service
}
func NewHandler(db database.Querier, authService *auth.Service) *Handler {
return &Handler{db: db, authService: authService}
}
func (h *Handler) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
users, err := h.db.ListUsers(r.Context())
if err != nil {
log.Printf("list users error: %v", err)
http.Error(w, "Failed to load users", http.StatusInternalServerError)
return
}
if err := templates.AdminPage(users).Render(r.Context(), w); err != nil {
log.Printf("render error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (h *Handler) HandleImpersonateUser(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Path[len("/admin/users/"):]
if userID == "" {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
targetUser, err := h.db.GetUser(r.Context(), userID)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "User not found", http.StatusNotFound)
return
}
log.Printf("get user error: %v", err)
http.Error(w, "Failed to load user", http.StatusInternalServerError)
return
}
if err := templates.AdminImpersonatePage(targetUser).Render(r.Context(), w); err != nil {
log.Printf("render error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (h *Handler) HandleUserWatchlist(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Path[len("/admin/users/"):]
userID = userID[:len(userID)-len("/watchlist")]
entries, err := h.db.GetUserWatchList(r.Context(), userID)
if err != nil {
log.Printf("get user watchlist error: %v", err)
http.Error(w, "Failed to load watchlist", http.StatusInternalServerError)
return
}
if err := templates.AdminUserWatchlist(entries).Render(r.Context(), w); err != nil {
log.Printf("render error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (h *Handler) HandleUserContinueWatching(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Path[len("/admin/users/"):]
userID = userID[:len(userID)-len("/continue-watching")]
entries, err := h.db.GetContinueWatchingEntries(r.Context(), userID)
if err != nil {
log.Printf("get continue watching error: %v", err)
http.Error(w, "Failed to load continue watching", http.StatusInternalServerError)
return
}
if err := templates.AdminUserContinueWatching(entries).Render(r.Context(), w); err != nil {
log.Printf("render error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (h *Handler) HandleAddUserForm(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" || password == "" {
writeInlineError(w, "Username and password are required")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
log.Printf("bcrypt error: %v", err)
writeInlineError(w, "Failed to create user")
return
}
_, err = h.db.CreateUser(r.Context(), database.CreateUserParams{
ID: generateUserID(),
Username: username,
PasswordHash: string(hash),
})
if err != nil {
log.Printf("create user error: %v", err)
writeInlineError(w, "Failed to create user (may already exist)")
return
}
// Return success - reload the users list
users, err := h.db.ListUsers(r.Context())
if err != nil {
log.Printf("list users error: %v", err)
writeInlineError(w, "User created but failed to refresh list")
return
}
if err := templates.AdminUsersList(users).Render(r.Context(), w); err != nil {
log.Printf("render error: %v", err)
writeInlineError(w, "User created but failed to render list")
}
}
func writeInlineError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`<p style="color: var(--danger); font-size: var(--text-sm);">` + html.EscapeString(message) + `</p>`))
}
func generateUserID() string {
// Simple UUID-like generation - in production use proper UUID
b := make([]byte, 16)
for i := range b {
b[i] = byte('a' + (i % 26))
}
return string(b)
}
const bcryptCost = 12
func GetImpersonatedUserID(r *http.Request) string {
// Check for impersonation parameter
impersonateID := r.URL.Query().Get("as_user")
if impersonateID == "" {
return ""
}
// Verify the current user is admin
user, ok := r.Context().Value(middleware.UserContextKey).(*database.User)
if !ok || user == nil || !middleware.IsAdmin(user) {
return ""
}
return impersonateID
}

View File

@@ -10,10 +10,10 @@ import (
)
type DBTX interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {

View File

@@ -33,6 +33,7 @@ type Querier interface {
GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error)
GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error)
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
ListUsers(ctx context.Context) ([]User, error)
MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error
MarkRelationsSynced(ctx context.Context, id int64) error
SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error

View File

@@ -4,6 +4,9 @@ SELECT * FROM user WHERE id = ? LIMIT 1;
-- name: GetUserByUsername :one
SELECT * FROM user WHERE username = ? LIMIT 1;
-- name: ListUsers :many
SELECT * FROM user ORDER BY created_at DESC;
-- name: CreateUser :one
INSERT INTO user (id, username, password_hash)
VALUES (?, ?, ?)

View File

@@ -688,6 +688,38 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat
return items, nil
}
const listUsers = `-- name: ListUsers :many
SELECT id, username, password_hash, created_at FROM user ORDER BY created_at DESC
`
func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
rows, err := q.db.QueryContext(ctx, listUsers)
if err != nil {
return nil, err
}
defer rows.Close()
var items []User
for rows.Next() {
var i User
if err := rows.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markAnimeFetchRetryFailed = `-- name: MarkAnimeFetchRetryFailed :exec
UPDATE anime_fetch_retry
SET attempts = attempts + 1,
@@ -698,9 +730,9 @@ WHERE anime_id = ?
`
type MarkAnimeFetchRetryFailedParams struct {
Datetime string `json:"datetime"`
LastError string `json:"last_error"`
AnimeID int64 `json:"anime_id"`
Datetime interface{} `json:"datetime"`
LastError string `json:"last_error"`
AnimeID int64 `json:"anime_id"`
}
func (q *Queries) MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error {

View File

@@ -0,0 +1,29 @@
package middleware
import (
"net/http"
"mal/internal/db"
"mal/web/shared/admin"
)
func IsAdmin(user *database.User) bool {
return admin.IsAdmin(user)
}
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserContextKey).(*database.User)
if !ok || user == nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if !admin.IsAdmin(user) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"mal/api/admin"
"mal/api/anime"
"mal/api/auth"
"mal/api/playback"
@@ -52,6 +53,7 @@ func NewRouter(cfg Config) http.Handler {
animeHandler := anime.NewHandler(cfg.JikanClient, cfg.DB)
playbackSvc := playback.NewService(cfg.DB, cfg.SQLDB, playback.Config{ProxyTokenSecret: cfg.PlaybackProxySecret})
playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient)
adminHandler := admin.NewHandler(cfg.DB, cfg.AuthService)
// Serve static files
fs := http.FileServer(http.Dir("./static"))
@@ -103,6 +105,21 @@ func NewRouter(cfg Config) http.Handler {
mux.HandleFunc("/api/continue-watching/", watchlistHandler.HandleDeleteContinueWatching)
mux.HandleFunc("/watchlist", watchlistHandler.HandleGetWatchlist)
// Admin Endpoints (protected by admin middleware in route handlers)
mux.Handle("/admin", middleware.RequireAdmin(http.HandlerFunc(adminHandler.HandleAdminPage)))
mux.Handle("/admin/users", middleware.RequireAdmin(http.HandlerFunc(adminHandler.HandleAddUserForm)))
mux.Handle("/admin/users/", middleware.RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case strings.HasSuffix(path, "/watchlist"):
adminHandler.HandleUserWatchlist(w, r)
case strings.HasSuffix(path, "/continue-watching"):
adminHandler.HandleUserContinueWatching(w, r)
default:
adminHandler.HandleImpersonateUser(w, r)
}
})))
// Wrap mux with global CSRF origin verification and auth checking,
// THEN auth context parsing.
protectedHandler := middleware.RequireGlobalAuthWithPolicy(middleware.NewAccessPolicy())(pkgmiddleware.VerifyOrigin(mux))

View File

@@ -1,12 +1,12 @@
version: "2"
sql:
- engine: "sqlite"
queries: "internal/database/queries.sql"
queries: "internal/db/queries.sql"
schema: "migrations/"
gen:
go:
package: "database"
out: "internal/database"
out: "internal/db"
emit_json_tags: true
emit_prepared_queries: false
emit_interface: true

32
web/shared/admin/admin.go Normal file
View File

@@ -0,0 +1,32 @@
package admin
import (
"context"
"mal/internal/db"
)
type contextKey string
const userContextKey contextKey = "user"
const AdminEmail = "mikkelelvers@outlook.com"
func IsAdmin(user *database.User) bool {
if user == nil {
return false
}
return user.Username == AdminEmail
}
func GetUser(ctx context.Context) *database.User {
user, ok := ctx.Value(userContextKey).(*database.User)
if !ok {
return nil
}
return user
}
func IsAdminFromContext(ctx context.Context) bool {
return IsAdmin(GetUser(ctx))
}

View File

@@ -1,6 +1,9 @@
package layout
import "mal/web/components/icons"
import (
"mal/web/components/icons"
"mal/web/shared/admin"
)
templ Layout(title string, showHeader bool) {
<!DOCTYPE html>
@@ -60,6 +63,14 @@ templ Layout(title string, showHeader bool) {
>
Watchlist
</a>
if admin.IsAdminFromContext(ctx) {
<a
class="text-(--accent) no-underline hover:text-(--accent) hover:no-underline"
href="/admin"
>
Admin
</a>
}
</div>
</div>
<div

221
web/templates/admin.templ Normal file
View File

@@ -0,0 +1,221 @@
package templates
import (
"fmt"
"mal/internal/db"
"mal/web/shared/layout"
)
templ AdminPage(users []database.User) {
@layout.Layout("mal - admin", true) {
<div class="grid gap-6">
<h1 class="text-xl font-semibold">Admin Panel</h1>
<!-- Add User Section -->
<div class="rounded bg-(--surface-search) p-4">
<h2 class="mb-4 text-lg">Add New User</h2>
<form
hx-post="/admin/users"
hx-target="#users-list"
hx-swap="outerHTML"
class="flex flex-wrap gap-3"
>
<input
type="email"
name="username"
placeholder="Email"
required
class="h-9 rounded bg-(--bg) px-3 text-(--text) placeholder:text-(--text-faint) focus:outline-none"
/>
<input
type="password"
name="password"
placeholder="Password"
required
class="h-9 rounded bg-(--bg) px-3 text-(--text) placeholder:text-(--text-faint) focus:outline-none"
/>
<button
type="submit"
class="h-9 cursor-pointer rounded bg-(--surface-button) px-4 text-sm text-(--text) transition-opacity duration-150 hover:opacity-80"
>
Add User
</button>
</form>
</div>
<!-- Users List -->
<div>
<h2 class="mb-4 text-lg">Users</h2>
@AdminUsersList(users)
</div>
</div>
}
}
templ AdminUsersList(users []database.User) {
<div id="users-list" class="grid gap-2">
for _, user := range users {
<div class="flex items-center justify-between rounded bg-(--surface-search) p-3">
<div>
<div class="text-sm font-medium">{ user.Username }</div>
<div class="text-xs text-(--text-muted)">ID: { user.ID }</div>
<div class="text-xs text-(--text-faint)">Created: { user.CreatedAt.Format("2006-01-02") }</div>
</div>
<div class="flex gap-2">
<a
href={ templ.URL(fmt.Sprintf("/admin/users/%s", user.ID)) }
class="rounded bg-(--panel-soft) px-3 py-1 text-xs text-(--text) hover:bg-(--panel)"
>
View
</a>
</div>
</div>
}
</div>
}
templ AdminImpersonatePage(user database.User) {
@layout.Layout("mal - admin - user view", true) {
<div class="grid gap-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold">Viewing User</h1>
<p class="text-sm text-(--text-muted)">{ user.Username }</p>
</div>
<a
href="/admin"
class="rounded bg-(--surface-search) px-4 py-2 text-sm text-(--text) hover:bg-(--panel-soft)"
>
← Back to Admin
</a>
</div>
<div class="grid gap-4 md:grid-cols-2">
<!-- Watchlist Card -->
<a
href={ templ.URL(fmt.Sprintf("/admin/users/%s/watchlist", user.ID)) }
class="block rounded bg-(--surface-search) p-4 hover:bg-(--panel-soft)"
>
<h2 class="mb-2 text-lg">Watchlist</h2>
<p class="text-sm text-(--text-muted)">View user's anime watchlist</p>
</a>
<!-- Continue Watching Card -->
<a
href={ templ.URL(fmt.Sprintf("/admin/users/%s/continue-watching", user.ID)) }
class="block rounded bg-(--surface-search) p-4 hover:bg-(--panel-soft)"
>
<h2 class="mb-2 text-lg">Continue Watching</h2>
<p class="text-sm text-(--text-muted)">View user's watch progress</p>
</a>
</div>
<div class="mt-4 rounded border border-(--danger) p-4 text-sm text-(--danger)">
<strong>Read-only mode:</strong> You are viewing this user's data. Changes cannot be made from this view.
</div>
</div>
}
}
templ AdminUserWatchlist(entries []database.GetUserWatchListRow) {
@layout.Layout("mal - admin - watchlist", true) {
<div class="grid gap-6">
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold">User Watchlist</h1>
<a
href="/admin"
class="rounded bg-(--surface-search) px-4 py-2 text-sm text-(--text) hover:bg-(--panel-soft)"
>
← Back to Admin
</a>
</div>
if len(entries) == 0 {
<div class="rounded bg-(--surface-search) p-8 text-center text-(--text-muted)">
No watchlist entries
</div>
} else {
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
for _, entry := range entries {
<div class="rounded bg-(--surface-search) p-3">
<div class="mb-2 aspect-2/3 overflow-hidden rounded bg-(--bg)">
if entry.ImageUrl != "" {
<img
src={ entry.ImageUrl }
alt={ entry.TitleOriginal }
class="h-full w-full object-cover"
loading="lazy"
/>
} else {
<div class="flex h-full items-center justify-center text-xs text-(--text-faint)">No image</div>
}
</div>
<div class="text-sm line-clamp-2">{ entry.TitleOriginal }</div>
<div class="mt-1 text-xs text-(--text-muted)">Status: { entry.Status }</div>
if entry.CurrentEpisode.Valid {
<div class="text-xs text-(--text-faint)">Episode: { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) }</div>
}
</div>
}
</div>
}
<div class="mt-4 rounded border border-(--danger) p-4 text-sm text-(--danger)">
<strong>Read-only mode:</strong> You are viewing this user's data. Changes cannot be made from this view.
</div>
</div>
}
}
templ AdminUserContinueWatching(entries []database.GetContinueWatchingEntriesRow) {
@layout.Layout("mal - admin - continue watching", true) {
<div class="grid gap-6">
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold">Continue Watching</h1>
<a
href="/admin"
class="rounded bg-(--surface-search) px-4 py-2 text-sm text-(--text) hover:bg-(--panel-soft)"
>
← Back to Admin
</a>
</div>
if len(entries) == 0 {
<div class="rounded bg-(--surface-search) p-8 text-center text-(--text-muted)">
No continue watching entries
</div>
} else {
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
for _, entry := range entries {
<div class="rounded bg-(--surface-search) p-3">
<div class="mb-2 aspect-2/3 overflow-hidden rounded bg-(--bg)">
if entry.ImageUrl != "" {
<img
src={ entry.ImageUrl }
alt={ entry.TitleOriginal }
class="h-full w-full object-cover"
loading="lazy"
/>
} else {
<div class="flex h-full items-center justify-center text-xs text-(--text-faint)">No image</div>
}
</div>
<div class="text-sm line-clamp-2">{ entry.TitleOriginal }</div>
if entry.CurrentEpisode.Valid {
<div class="mt-1 text-xs text-(--text-muted)">Episode: { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) }</div>
}
if entry.CurrentTimeSeconds > 0 {
<div class="text-xs text-(--text-faint)">Time: { fmt.Sprintf("%.0fs", entry.CurrentTimeSeconds) }</div>
}
</div>
}
</div>
}
<div class="mt-4 rounded border border-(--danger) p-4 text-sm text-(--danger)">
<strong>Read-only mode:</strong> You are viewing this user's data. Changes cannot be made from this view.
</div>
</div>
}
}