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 { 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) PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...any) (*sql.Rows, error) QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row QueryRowContext(context.Context, string, ...interface{}) *sql.Row
} }
func New(db DBTX) *Queries { func New(db DBTX) *Queries {

View File

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

View File

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

View File

@@ -688,6 +688,38 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat
return items, nil 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 const markAnimeFetchRetryFailed = `-- name: MarkAnimeFetchRetryFailed :exec
UPDATE anime_fetch_retry UPDATE anime_fetch_retry
SET attempts = attempts + 1, SET attempts = attempts + 1,
@@ -698,9 +730,9 @@ WHERE anime_id = ?
` `
type MarkAnimeFetchRetryFailedParams struct { type MarkAnimeFetchRetryFailedParams struct {
Datetime string `json:"datetime"` Datetime interface{} `json:"datetime"`
LastError string `json:"last_error"` LastError string `json:"last_error"`
AnimeID int64 `json:"anime_id"` AnimeID int64 `json:"anime_id"`
} }
func (q *Queries) MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error { 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" "path/filepath"
"strings" "strings"
"mal/api/admin"
"mal/api/anime" "mal/api/anime"
"mal/api/auth" "mal/api/auth"
"mal/api/playback" "mal/api/playback"
@@ -52,6 +53,7 @@ func NewRouter(cfg Config) http.Handler {
animeHandler := anime.NewHandler(cfg.JikanClient, cfg.DB) animeHandler := anime.NewHandler(cfg.JikanClient, cfg.DB)
playbackSvc := playback.NewService(cfg.DB, cfg.SQLDB, playback.Config{ProxyTokenSecret: cfg.PlaybackProxySecret}) playbackSvc := playback.NewService(cfg.DB, cfg.SQLDB, playback.Config{ProxyTokenSecret: cfg.PlaybackProxySecret})
playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient) playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient)
adminHandler := admin.NewHandler(cfg.DB, cfg.AuthService)
// Serve static files // Serve static files
fs := http.FileServer(http.Dir("./static")) 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("/api/continue-watching/", watchlistHandler.HandleDeleteContinueWatching)
mux.HandleFunc("/watchlist", watchlistHandler.HandleGetWatchlist) 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, // Wrap mux with global CSRF origin verification and auth checking,
// THEN auth context parsing. // THEN auth context parsing.
protectedHandler := middleware.RequireGlobalAuthWithPolicy(middleware.NewAccessPolicy())(pkgmiddleware.VerifyOrigin(mux)) protectedHandler := middleware.RequireGlobalAuthWithPolicy(middleware.NewAccessPolicy())(pkgmiddleware.VerifyOrigin(mux))

View File

@@ -1,12 +1,12 @@
version: "2" version: "2"
sql: sql:
- engine: "sqlite" - engine: "sqlite"
queries: "internal/database/queries.sql" queries: "internal/db/queries.sql"
schema: "migrations/" schema: "migrations/"
gen: gen:
go: go:
package: "database" package: "database"
out: "internal/database" out: "internal/db"
emit_json_tags: true emit_json_tags: true
emit_prepared_queries: false emit_prepared_queries: false
emit_interface: true 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 package layout
import "mal/web/components/icons" import (
"mal/web/components/icons"
"mal/web/shared/admin"
)
templ Layout(title string, showHeader bool) { templ Layout(title string, showHeader bool) {
<!DOCTYPE html> <!DOCTYPE html>
@@ -60,6 +63,14 @@ templ Layout(title string, showHeader bool) {
> >
Watchlist Watchlist
</a> </a>
if admin.IsAdminFromContext(ctx) {
<a
class="text-(--accent) no-underline hover:text-(--accent) hover:no-underline"
href="/admin"
>
Admin
</a>
}
</div> </div>
</div> </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>
}
}