refactor: remove admin panel and multi-user features
This commit is contained in:
@@ -1,229 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"mal/api/auth"
|
||||
"mal/internal/db"
|
||||
"mal/internal/middleware"
|
||||
webcontext "mal/web/context"
|
||||
"mal/web/templates"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db database.Querier
|
||||
authService *auth.Service
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
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) 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
|
||||
}
|
||||
|
||||
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 (h *Handler) HandleDeleteUserRouter(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
parts := strings.Split(strings.TrimSuffix(path, "/delete"), "/")
|
||||
userID := parts[len(parts)-1]
|
||||
|
||||
if userID == "" {
|
||||
writeInlineError(w, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, ok := r.Context().Value(webcontext.UserKey).(*database.User)
|
||||
if !ok || currentUser == nil {
|
||||
writeInlineError(w, "Not authenticated")
|
||||
return
|
||||
}
|
||||
if userID == currentUser.ID {
|
||||
writeInlineError(w, "Cannot delete your own account")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.db.DeleteUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Printf("delete user error: %v", err)
|
||||
writeInlineError(w, "Failed to delete user")
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.db.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("list users error: %v", err)
|
||||
writeInlineError(w, "User deleted 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 deleted but failed to render list")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) HandleUserRouter(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/delete"):
|
||||
h.HandleDeleteUserRouter(w, r)
|
||||
case strings.HasSuffix(path, "/watchlist"):
|
||||
h.HandleUserWatchlist(w, r)
|
||||
case strings.HasSuffix(path, "/continue-watching"):
|
||||
h.HandleUserContinueWatching(w, r)
|
||||
default:
|
||||
h.HandleImpersonateUser(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
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 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 {
|
||||
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 {
|
||||
impersonateID := r.URL.Query().Get("as_user")
|
||||
if impersonateID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
user, ok := r.Context().Value(webcontext.UserKey).(*database.User)
|
||||
if !ok || user == nil || !middleware.IsAdmin(user) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return impersonateID
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mal/internal/db"
|
||||
webcontext "mal/web/context"
|
||||
"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(webcontext.UserKey).(*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)
|
||||
})
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"mal/internal/db"
|
||||
)
|
||||
|
||||
const AdminEmail = "mikkelelvers@outlook.com"
|
||||
|
||||
func IsAdmin(user *database.User) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
return user.Username == AdminEmail
|
||||
}
|
||||
|
||||
func IsAdminFromContext(ctx interface {
|
||||
Value(key interface{}) interface{}
|
||||
}) bool {
|
||||
const userKey = "mal:user"
|
||||
user, _ := ctx.Value(userKey).(*database.User)
|
||||
return IsAdmin(user)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package layout
|
||||
|
||||
import (
|
||||
"mal/web/components/icons"
|
||||
"mal/web/shared/admin"
|
||||
)
|
||||
|
||||
templ Layout(title string, showHeader bool) {
|
||||
@@ -63,14 +62,6 @@ 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
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
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>
|
||||
<button
|
||||
hx-delete={ templ.URL(fmt.Sprintf("/admin/users/%s/delete", user.ID)) }
|
||||
hx-target="#users-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm={ fmt.Sprintf("Are you sure you want to delete %s? This cannot be undone.", user.Username) }
|
||||
class="rounded bg-(--surface-button) px-3 py-1 text-xs text-(--danger) opacity-80 transition-opacity duration-150 hover:opacity-100"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</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>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user