impeccable:harden on all admin panel

This commit is contained in:
Evan Jarrett
2026-04-21 23:14:05 -05:00
parent 6b6ce093d3
commit 267012b41e
40 changed files with 1134 additions and 318 deletions
+135 -21
View File
@@ -6,6 +6,7 @@ package admin
//go:generate sh -c "command -v npm >/dev/null 2>&1 && cd ../../.. && npm run build:hold || echo 'npm not found, skipping build'"
import (
"bytes"
"context"
"crypto/rand"
"embed"
@@ -54,12 +55,24 @@ func DefaultAdminConfig() AdminConfig {
}
}
// AdminSession represents an authenticated admin session
// AdminSession represents an authenticated admin session. UserAgent and
// IPPrefix are captured at login and rechecked on every request — a stolen
// token replayed from a different browser or network prefix is rejected and
// the session is torn down. Binding at /24 (IPv4) / /64 (IPv6) tolerates
// DHCP renewals within a prefix without inviting cross-network replay.
type AdminSession struct {
DID string
Handle string
DID string
Handle string
CSRFToken string
CreatedAt time.Time
UserAgent string
IPPrefix string
}
// sessionTTL is the server-side lifetime of an admin session. Sessions older
// than this are treated as expired regardless of cookie state.
const sessionTTL = 24 * time.Hour
// AdminUI manages the admin web interface
type AdminUI struct {
pds *pds.HoldPDS
@@ -69,6 +82,11 @@ type AdminUI struct {
templates map[string]*template.Template
config AdminConfig
// secureCookies indicates cookies should carry the Secure flag regardless
// of per-request proxy header state. Set at init from PublicURL scheme so
// a misconfigured reverse proxy can't silently drop Secure.
secureCookies bool
// In-memory session storage (single user, no persistence needed)
sessions map[string]*AdminSession
sessionsMu sync.RWMutex
@@ -139,13 +157,14 @@ func NewAdminUI(ctx context.Context, holdPDS *pds.HoldPDS, quotaMgr *quota.Manag
}
ui := &AdminUI{
pds: holdPDS,
quotaMgr: quotaMgr,
gc: garbageCollector,
clientApp: clientApp,
templates: templates,
config: cfg,
sessions: make(map[string]*AdminSession),
pds: holdPDS,
quotaMgr: quotaMgr,
gc: garbageCollector,
clientApp: clientApp,
templates: templates,
config: cfg,
secureCookies: strings.HasPrefix(cfg.PublicURL, "https://"),
sessions: make(map[string]*AdminSession),
}
slog.Info("Admin panel initialized", "publicURL", cfg.PublicURL)
@@ -155,24 +174,49 @@ func NewAdminUI(ctx context.Context, holdPDS *pds.HoldPDS, quotaMgr *quota.Manag
// Session management
func (ui *AdminUI) createSession(did, handle string) (string, error) {
func (ui *AdminUI) createSession(did, handle, userAgent, ipPrefix string) (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("failed to create session token: %w", err)
}
token := base64.URLEncoding.EncodeToString(b)
csrfToken, err := generateCSRFToken()
if err != nil {
return "", err
}
ui.sessionsMu.Lock()
ui.sessions[token] = &AdminSession{DID: did, Handle: handle}
ui.sessions[token] = &AdminSession{
DID: did,
Handle: handle,
CSRFToken: csrfToken,
CreatedAt: time.Now(),
UserAgent: userAgent,
IPPrefix: ipPrefix,
}
ui.sessionsMu.Unlock()
return token, nil
}
// getSession returns the session for the given token, or nil if missing or
// expired. Expired sessions are evicted on access to keep the in-memory map
// bounded even if the user never hits logout.
func (ui *AdminUI) getSession(token string) *AdminSession {
ui.sessionsMu.RLock()
defer ui.sessionsMu.RUnlock()
return ui.sessions[token]
session := ui.sessions[token]
ui.sessionsMu.RUnlock()
if session == nil {
return nil
}
if !session.CreatedAt.IsZero() && time.Since(session.CreatedAt) > sessionTTL {
ui.sessionsMu.Lock()
delete(ui.sessions, token)
ui.sessionsMu.Unlock()
return nil
}
return session
}
func (ui *AdminUI) deleteSession(token string) {
@@ -185,15 +229,31 @@ func (ui *AdminUI) deleteSession(token string) {
const sessionCookieName = "hold_admin_session"
// secureForRequest returns whether the Secure flag should be set on admin
// cookies for this request. True if the hold is served over HTTPS (derived
// from PublicURL at init) OR the request itself is TLS-terminated or came
// through a proxy that advertised https. Union rules out the case where a
// reverse proxy forgets to set X-Forwarded-Proto — PublicURL is the source
// of truth.
func (ui *AdminUI) secureForRequest(r *http.Request) bool {
return ui.secureCookies || r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
}
func (ui *AdminUI) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
// SameSite=Lax (not Strict). Strict would drop the session cookie on
// the redirect chain coming back from the OAuth provider: the browser
// classifies the post-callback navigation to /admin as not-same-site
// because the chain was initiated from the PDS, and the user lands
// back on the login page. Lax still blocks the dominant CSRF vectors
// (cross-site form POSTs, image/XHR requests) and the CSRF middleware
// covers what Lax doesn't.
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/admin",
MaxAge: 86400, // 24 hours
HttpOnly: true,
Secure: secure,
Secure: ui.secureForRequest(r),
SameSite: http.SameSiteLaxMode,
})
}
@@ -217,6 +277,39 @@ func getSessionCookie(r *http.Request) (string, bool) {
return cookie.Value, true
}
// clientIPPrefix returns a stable prefix key for the request's client IP.
// /24 for IPv4, /64 for IPv6. Returns empty string if the address is
// unparseable — callers treat "" as "don't bind" to avoid locking users out
// behind unusual proxies (Unix sockets, tests, etc.).
func clientIPPrefix(r *http.Request) string {
var host string
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
// Leftmost entry is the original client.
if comma := strings.IndexByte(fwd, ','); comma >= 0 {
host = strings.TrimSpace(fwd[:comma])
} else {
host = strings.TrimSpace(fwd)
}
} else {
h, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
host = h
} else {
host = r.RemoteAddr
}
}
ip := net.ParseIP(host)
if ip == nil {
return ""
}
if v4 := ip.To4(); v4 != nil {
return fmt.Sprintf("v4:%d.%d.%d", v4[0], v4[1], v4[2])
}
v6 := ip.To16()
return fmt.Sprintf("v6:%02x%02x%02x%02x%02x%02x%02x%02x",
v6[0], v6[1], v6[2], v6[3], v6[4], v6[5], v6[6], v6[7])
}
// parseTemplates loads and parses all HTML templates.
// Components (including layout) are parsed into a base template. Each page and
// partial gets its own clone of the base so that {{block}} overrides don't conflict.
@@ -258,6 +351,13 @@ func parseTemplates() (map[string]*template.Template, error) {
template.HTMLEscapeString(name),
))
},
// csrfInput emits a hidden input carrying the per-session CSRF token.
// Usage: {{ csrfInput .CSRFToken }}
"csrfInput": csrfInputHTML,
// loginError maps a slug from the login error query parameter to a
// user-friendly message. Unknown slugs produce a generic fallback so
// internal error details are never surfaced to the browser.
"loginError": loginErrorMessage,
}
// Collect template files by category
@@ -358,14 +458,19 @@ func (ui *AdminUI) RegisterRoutes(r chi.Router) {
// OAuth client metadata endpoint (required for production OAuth)
r.Get("/admin/oauth-client-metadata.json", ui.handleClientMetadata)
// Public auth routes
// Public auth routes. Authorize is POST-only — the handle is the user's
// identity and must not land in browser history, access logs, or
// Referer headers.
r.Get("/admin/auth/login", ui.handleLogin)
r.Get("/admin/auth/oauth/authorize", ui.handleAuthorize)
r.Post("/admin/auth/oauth/authorize", ui.handleAuthorize)
r.Get("/admin/auth/oauth/callback", ui.handleCallback)
// Protected routes (require owner)
r.Group(func(r chi.Router) {
r.Use(ui.requireOwner)
// CSRF check runs after requireOwner so the session (and thus the
// per-session token to compare against) is already on the context.
r.Use(ui.requireCSRF)
// Single admin page (client-side tab switching)
r.Get("/admin", ui.handleAdmin)
@@ -440,11 +545,20 @@ func (ui *AdminUI) handleClientMetadata(w http.ResponseWriter, r *http.Request)
metadata.ClientName = &clientName
metadata.ClientURI = &ui.config.PublicURL
// Encode into a buffer first so an encode failure can produce a clean
// 500 response. Writing directly to w commits the 200 header at the
// first byte, after which WriteHeader becomes a no-op.
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(metadata); err != nil {
slog.Error("failed to encode client metadata", "error", err, "path", r.URL.Path)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
if err := json.NewEncoder(w).Encode(metadata); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
if _, err := w.Write(buf.Bytes()); err != nil {
slog.Debug("client metadata write failed", "error", err, "path", r.URL.Path)
}
}
+51 -1
View File
@@ -7,7 +7,14 @@ import (
"strings"
)
// requireOwner middleware ensures the request is from the hold owner
// requireOwner middleware ensures the request is from the hold owner.
// Enforces three layers:
// 1. Session token exists and has not expired (24h absolute TTL).
// 2. Session's DID still matches captain.Owner in the PDS — if ownership
// changes mid-session, the old session is torn down on the next hit.
// 3. Browser User-Agent and network prefix match what was captured at
// login. A mismatch means the cookie is being replayed from a
// different browser or network and the session is invalidated.
func (ui *AdminUI) requireOwner(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get session cookie
@@ -43,6 +50,30 @@ func (ui *AdminUI) requireOwner(next http.Handler) http.Handler {
return
}
// User-Agent / IP-prefix binding. Empty bound values (e.g. from
// clients with no UA, requests over Unix sockets) are not
// compared — binding opts out rather than locking the user out.
if session.UserAgent != "" && session.UserAgent != r.UserAgent() {
slog.Warn("Admin session User-Agent mismatch — suspected token replay",
"did", session.DID)
ui.deleteSession(token)
clearSessionCookie(w)
http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound)
return
}
if session.IPPrefix != "" {
if now := clientIPPrefix(r); now != "" && now != session.IPPrefix {
slog.Warn("Admin session IP prefix mismatch — suspected token replay",
"did", session.DID,
"sessionPrefix", session.IPPrefix,
"requestPrefix", now)
ui.deleteSession(token)
clearSessionCookie(w)
http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound)
return
}
}
// Add session to context for handlers
ctx := context.WithValue(r.Context(), adminContextKey{}, session)
next.ServeHTTP(w, r.WithContext(ctx))
@@ -58,6 +89,15 @@ func getSessionFromContext(ctx context.Context) *AdminSession {
return session
}
// sessionDIDFromContext returns the authenticated DID for log annotation,
// empty if there is no session (e.g. an early-rejected request).
func sessionDIDFromContext(ctx context.Context) string {
if s := getSessionFromContext(ctx); s != nil {
return s.DID
}
return ""
}
// PageData contains common data for all admin pages
type PageData struct {
Title string
@@ -65,6 +105,10 @@ type PageData struct {
User *AdminSession
HoldDID string
Flash *Flash
// CSRFToken is the per-session token that forms and htmx must echo back
// on state-mutating requests. Threaded to <body hx-headers=...> at the
// layout level; plain forms emit it via {{ csrfInput .CSRFToken }}.
CSRFToken string
}
// Flash represents a flash message
@@ -78,12 +122,18 @@ func (ui *AdminUI) newPageData(r *http.Request, title, activePage string) PageDa
session := getSessionFromContext(r.Context())
flash := getFlash(r, ui)
var csrf string
if session != nil {
csrf = session.CSRFToken
}
return PageData{
Title: title,
ActivePage: activePage,
User: session,
HoldDID: ui.pds.DID(),
Flash: flash,
CSRFToken: csrf,
}
}
+112
View File
@@ -0,0 +1,112 @@
package admin
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"html/template"
"log/slog"
"net/http"
"strings"
)
const (
csrfHeaderName = "X-CSRF-Token"
csrfFormField = "csrf_token"
)
// generateCSRFToken returns a cryptographically random token.
// 32 bytes (256 bits) base64url-encoded, matching the session token format.
func generateCSRFToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate csrf token: %w", err)
}
return base64.URLEncoding.EncodeToString(b), nil
}
// requireCSRF validates a per-session CSRF token on state-mutating requests.
// Safe methods (GET/HEAD/OPTIONS) are unchecked; everything else must supply
// the token via the X-CSRF-Token header (htmx path) or the csrf_token form
// field on application/x-www-form-urlencoded bodies (plain-form path).
// Multipart bodies are rejected unless the header is present — this avoids
// consuming a multipart body in middleware and stepping on per-handler size
// limits such as http.MaxBytesReader.
//
// Must run after requireOwner so a session is present on the request context.
func (ui *AdminUI) requireCSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
session := getSessionFromContext(r.Context())
if session == nil || session.CSRFToken == "" {
slog.Warn("CSRF check failed: no session token",
"path", r.URL.Path, "method", r.Method)
csrfReject(w, r, "missing session")
return
}
got := r.Header.Get(csrfHeaderName)
if got == "" {
contentType := r.Header.Get("Content-Type")
// Split off any ;boundary=... suffix before comparing.
if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
contentType = contentType[:idx]
}
contentType = strings.TrimSpace(strings.ToLower(contentType))
switch contentType {
case "application/x-www-form-urlencoded":
if err := r.ParseForm(); err == nil {
got = r.PostFormValue(csrfFormField)
}
case "multipart/form-data":
// Parse multipart with a small limit just to read the CSRF
// field. 32 KB is enough for the token + file metadata without
// loading uploaded file data into memory — Go stores the file
// portion beyond maxMemory on disk.
const csrfMultipartMaxMem = 32 << 10 // 32 KB
if err := r.ParseMultipartForm(csrfMultipartMaxMem); err == nil {
got = r.FormValue(csrfFormField)
}
}
}
if subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFToken)) != 1 {
slog.Warn("CSRF token mismatch",
"path", r.URL.Path,
"method", r.Method,
"did", session.DID,
"provided", got != "")
csrfReject(w, r, "token mismatch")
return
}
next.ServeHTTP(w, r)
})
}
// csrfReject returns a 403. For htmx requests it surfaces a toast via the
// standard HX-Trigger channel so the page-level error handler can announce
// the failure; for plain browsers it's a text response.
func csrfReject(w http.ResponseWriter, r *http.Request, reason string) {
const userMsg = "Session expired or CSRF token invalid — reload the page and try again."
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Trigger",
`{"toast":{"message":"`+userMsg+`","type":"error"}}`)
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusForbidden)
return
}
http.Error(w, "Forbidden: "+userMsg, http.StatusForbidden)
}
// csrfInputHTML returns a hidden form input carrying the CSRF token, safely
// escaped for attribute context.
func csrfInputHTML(token string) template.HTML {
escaped := template.HTMLEscapeString(token)
return template.HTML(`<input type="hidden" name="` + csrfFormField + `" value="` + escaped + `">`)
}
+37
View File
@@ -0,0 +1,37 @@
package admin
import (
"encoding/json"
"log/slog"
"net/http"
)
// renderHTMXError sends an error response suitable for htmx. For htmx
// requests it sets HX-Trigger so the client fires a toast; the global
// htmx:responseError listener in main.js is the fallback for non-triggering
// handlers. For plain browsers it degrades to http.Error. serverErr is
// logged but never exposed — pass userMsg for anything user-visible.
func renderHTMXError(w http.ResponseWriter, r *http.Request, status int, userMsg string, serverErr error) {
if serverErr != nil {
slog.Error("admin htmx handler error",
"path", r.URL.Path,
"status", status,
"err", serverErr,
)
}
if userMsg == "" {
userMsg = http.StatusText(status)
}
if r.Header.Get("HX-Request") == "true" {
trigger := map[string]map[string]string{
"toast": {"message": userMsg, "type": "error"},
}
if b, err := json.Marshal(trigger); err == nil {
w.Header().Set("HX-Trigger", string(b))
}
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(status)
return
}
http.Error(w, userMsg, status)
}
+26 -4
View File
@@ -8,8 +8,22 @@ import (
const flashCookieName = "hold_admin_flash"
// setFlash sets a flash message cookie
// validFlashCategories bounds what can appear in alert class interpolation.
// Anything outside this set is coerced to "info" before the cookie is set.
var validFlashCategories = map[string]bool{
"success": true,
"error": true,
"warning": true,
"info": true,
}
// setFlash sets a flash message cookie. Category is coerced to "info" if
// not on the known allowlist — the value flows into an HTML class attribute
// in layout.html and must not carry arbitrary text.
func setFlash(w http.ResponseWriter, r *http.Request, category, message string) {
if !validFlashCategories[category] {
category = "info"
}
flash := Flash{
Category: category,
Message: message,
@@ -30,11 +44,15 @@ func setFlash(w http.ResponseWriter, r *http.Request, category, message string)
MaxAge: 60, // 1 minute - should be consumed on next page load
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
SameSite: http.SameSiteStrictMode,
})
}
// getFlash retrieves and clears the flash message
// getFlash retrieves the flash message. Callers that render the flash into
// the page layout should arrange for the cookie to be cleared after display
// via clearFlash — handlers typically defer clearFlash(w) at entry.
// getFlash also rejects flashes whose category has drifted off the
// allowlist (e.g. a forged or pre-upgrade cookie).
func getFlash(r *http.Request, ui *AdminUI) *Flash {
cookie, err := r.Cookie(flashCookieName)
if err != nil {
@@ -51,6 +69,10 @@ func getFlash(r *http.Request, ui *AdminUI) *Flash {
return nil
}
if !validFlashCategories[flash.Category] {
flash.Category = "info"
}
return &flash
}
@@ -62,6 +84,6 @@ func clearFlash(w http.ResponseWriter) {
Path: "/admin",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
SameSite: http.SameSiteStrictMode,
})
}
+2 -6
View File
@@ -20,12 +20,7 @@ type DashboardStats struct {
// handleAdmin renders the single admin page with client-side tab switching
func (ui *AdminUI) handleAdmin(w http.ResponseWriter, r *http.Request) {
defer clearFlash(w)
data := struct {
PageData
}{
PageData: ui.newPageData(r, "Hold Admin", ""),
}
ui.renderTemplate(w, "pages/admin.html", data)
ui.renderTemplate(w, "pages/admin.html", ui.newPageData(r, "Hold Admin", ""))
}
// getDashboardStats returns dashboard statistics
@@ -58,6 +53,7 @@ func (ui *AdminUI) getDashboardStats(ctx context.Context) DashboardStats {
// handleDashboardTab returns the dashboard tab content (HTMX partial)
func (ui *AdminUI) handleDashboardTab(w http.ResponseWriter, r *http.Request) {
defer clearFlash(w)
data := struct {
Stats DashboardStats
}{
+34 -10
View File
@@ -8,6 +8,27 @@ import (
"atcr.io/pkg/atproto"
)
// loginErrorMessages maps slug-based error codes (emitted as query params by the
// auth handlers) to user-friendly sentences. Any slug not in this map falls back
// to a generic message so internal details are never exposed in the browser.
var loginErrorMessages = map[string]string{
"handle_required": "Please enter a handle or DID to sign in.",
"handle_invalid": "That handle or DID could not be found. Check the spelling and try again.",
"oauth_failed": "Couldn't start the sign-in flow. Please try again.",
"oauth_callback_failed": "Sign-in was not completed. Please try again.",
"ownership_check_failed": "Couldn't verify hold ownership. Please try again.",
"access_denied": "Only the hold owner can access the admin panel.",
}
// loginErrorMessage translates a slug into a human-readable login error.
// It is registered as the "loginError" template function.
func loginErrorMessage(slug string) string {
if msg, ok := loginErrorMessages[slug]; ok {
return msg
}
return "Couldn't sign you in. Please try again."
}
// handleLogin renders the login page
func (ui *AdminUI) handleLogin(w http.ResponseWriter, r *http.Request) {
// If already logged in, redirect to dashboard
@@ -36,11 +57,12 @@ func (ui *AdminUI) handleLogin(w http.ResponseWriter, r *http.Request) {
ui.renderTemplate(w, "pages/login.html", data)
}
// handleAuthorize starts the OAuth flow
// handleAuthorize starts the OAuth flow.
// Accepts both GET (direct URL) and POST (login form submission).
func (ui *AdminUI) handleAuthorize(w http.ResponseWriter, r *http.Request) {
handle := strings.TrimSpace(r.URL.Query().Get("handle"))
handle := strings.TrimSpace(r.FormValue("handle"))
if handle == "" {
http.Redirect(w, r, "/admin/auth/login?error=Handle+is+required", http.StatusFound)
http.Redirect(w, r, "/admin/auth/login?error=handle_required", http.StatusFound)
return
}
@@ -51,7 +73,7 @@ func (ui *AdminUI) handleAuthorize(w http.ResponseWriter, r *http.Request) {
did, _, _, err := atproto.ResolveIdentity(r.Context(), handle)
if err != nil {
slog.Warn("Failed to resolve handle for admin login", "handle", handle, "error", err)
http.Redirect(w, r, "/admin/auth/login?error=Could+not+resolve+handle", http.StatusFound)
http.Redirect(w, r, "/admin/auth/login?error=handle_invalid", http.StatusFound)
return
}
@@ -61,7 +83,7 @@ func (ui *AdminUI) handleAuthorize(w http.ResponseWriter, r *http.Request) {
authURL, err := ui.clientApp.StartAuthFlow(r.Context(), did)
if err != nil {
slog.Error("Failed to start OAuth flow", "error", err)
http.Redirect(w, r, "/admin/auth/login?error=OAuth+initialization+failed", http.StatusFound)
http.Redirect(w, r, "/admin/auth/login?error=oauth_failed", http.StatusFound)
return
}
@@ -76,7 +98,7 @@ func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) {
sessionData, err := ui.clientApp.ProcessCallback(ctx, r.URL.Query())
if err != nil {
slog.Error("OAuth callback failed", "error", err)
http.Redirect(w, r, "/admin/auth/login?error=OAuth+authentication+failed", http.StatusFound)
http.Redirect(w, r, "/admin/auth/login?error=oauth_callback_failed", http.StatusFound)
return
}
@@ -95,7 +117,7 @@ func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) {
_, captain, err := ui.pds.GetCaptainRecord(ctx)
if err != nil {
slog.Error("Failed to get captain record during OAuth callback", "error", err)
http.Redirect(w, r, "/admin/auth/login?error=Failed+to+verify+ownership", http.StatusFound)
http.Redirect(w, r, "/admin/auth/login?error=ownership_check_failed", http.StatusFound)
return
}
@@ -105,12 +127,14 @@ func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) {
"did", did,
"handle", handle,
"owner", captain.Owner)
http.Redirect(w, r, "/admin/auth/login?error=Access+denied:+Only+the+hold+owner+can+access+the+admin+panel", http.StatusFound)
http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound)
return
}
// Create session and set cookie
token, err := ui.createSession(did, handle)
// Create session and set cookie. Bind to the browser's User-Agent and
// network prefix at login so a stolen cookie replayed from a different
// browser or /24 can't be used.
token, err := ui.createSession(did, handle, r.UserAgent(), clientIPPrefix(r))
if err != nil {
slog.Error("failed to create session token", "error", err, "path", r.URL.Path)
http.Error(w, "Failed to create session", http.StatusInternalServerError)
+52 -21
View File
@@ -4,6 +4,7 @@ import (
"context"
"log/slog"
"net/http"
"net/url"
"sort"
"strings"
"time"
@@ -54,9 +55,10 @@ type TierOption struct {
// Includes usage data (fast bulk SQL query) for correct sort order.
// Handles are lazy-loaded per-row via handleCrewMemberInfo.
func (ui *AdminUI) handleCrewTab(w http.ResponseWriter, r *http.Request) {
defer clearFlash(w)
crew, err := ui.pds.ListCrewMembers(r.Context())
if err != nil {
http.Error(w, "Failed to list crew: "+err.Error(), http.StatusInternalServerError)
renderHTMXError(w, r, http.StatusInternalServerError, "Couldn't load crew", err)
return
}
@@ -307,9 +309,11 @@ func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rkey := chi.URLParam(r, "rkey")
editURL, _ := url.JoinPath("/admin/crew/", rkey)
if err := r.ParseForm(); err != nil {
setFlash(w, r, "error", "Invalid form data")
http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound)
http.Redirect(w, r, editURL, http.StatusFound)
return
}
@@ -321,6 +325,16 @@ func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) {
return
}
// Owner's crew record is immutable via the admin UI — the template
// disables the inputs but defense-in-depth requires a server-side gate
// in case the form is bypassed.
_, captain, capErr := ui.pds.GetCaptainRecord(ctx)
if capErr == nil && captain != nil && current.Member == captain.Owner {
setFlash(w, r, "error", "Owner permissions cannot be modified")
http.Redirect(w, r, "/admin#crew", http.StatusFound)
return
}
// Parse new values
role := r.FormValue("role")
tier := r.FormValue("tier")
@@ -340,24 +354,28 @@ func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) {
if tier != current.Tier {
if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil {
setFlash(w, r, "error", "Failed to update tier: "+err.Error())
http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound)
http.Redirect(w, r, editURL, http.StatusFound)
return
}
}
// For role/permissions changes, need to delete and recreate
// (ATProto records are immutable, updates require delete+create)
if role != current.Role || !slicesEqual(permissions, current.Permissions) {
// Delete old record
if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil {
setFlash(w, r, "error", "Failed to update: "+err.Error())
http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound)
// For role/permissions changes, need to delete and recreate — ATProto
// records are immutable. Create-then-delete (instead of delete-then-
// create): if creation fails the old record is still there, so the
// worst outcome is a transient duplicate rather than a silently-
// deleted member.
if role != current.Role || !permissionsEqual(permissions, current.Permissions) {
if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions, tier); err != nil {
setFlash(w, r, "error", "Failed to update crew record: "+err.Error())
http.Redirect(w, r, editURL, http.StatusFound)
return
}
// Create new record with updated values (including tier)
if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions, tier); err != nil {
setFlash(w, r, "error", "Failed to recreate crew record: "+err.Error())
if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil {
slog.Error("Failed to remove old crew record after replacement",
"rkey", rkey, "member", current.Member, "error", err)
// The new record is already live; surface a non-fatal warning
// and return to the crew list.
setFlash(w, r, "warning", "Update succeeded but old record may linger: "+err.Error())
http.Redirect(w, r, "/admin#crew", http.StatusFound)
return
}
@@ -404,9 +422,12 @@ func (ui *AdminUI) handleCrewDelete(w http.ResponseWriter, r *http.Request) {
slog.Info("Crew member removed via admin panel", "did", member.Member, "by", session.DID)
// For HTMX requests, return empty response (row will be removed)
// For HTMX requests, return 204 No Content. The row uses
// hx-swap="outerHTML" so htmx replaces it with the empty response body
// and the row disappears. Explicit 204 is the stable idiom (plain 200
// with empty body works today but is implementation-defined).
if r.Header.Get("HX-Request") == "true" {
w.WriteHeader(http.StatusOK)
w.WriteHeader(http.StatusNoContent)
return
}
@@ -438,12 +459,13 @@ func (ui *AdminUI) getTierOptions() []TierOption {
return options
}
// slicesEqual checks if two string slices contain the same elements
func slicesEqual(a, b []string) bool {
// permissionsEqual checks if two permission slices contain the same set of
// entries. Order-independent — permissions are an unordered set, not a list.
func permissionsEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
aMap := make(map[string]bool)
aMap := make(map[string]bool, len(a))
for _, v := range a {
aMap[v] = true
}
@@ -455,8 +477,17 @@ func slicesEqual(a, b []string) bool {
return true
}
// parseTime parses an RFC3339 timestamp
// parseTime parses an RFC3339 timestamp. Malformed values return the zero
// time so callers can use t.IsZero() as a guard; the debug log lets an
// operator trace data corruption that the UI otherwise hides.
func parseTime(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
if s == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
slog.Debug("parseTime: malformed RFC3339 timestamp", "value", s, "error", err)
return time.Time{}
}
return t
}
+3 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"mime"
"net/http"
"strings"
"time"
@@ -62,8 +63,9 @@ func (ui *AdminUI) handleCrewExport(w http.ResponseWriter, r *http.Request) {
}
filename := "crew-export-" + time.Now().Format("2006-01-02") + ".json"
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
w.Header().Set("Content-Disposition", disposition)
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
+1
View File
@@ -30,6 +30,7 @@ type gcProgressData struct {
// handleGCTab returns the storage/GC tab content (HTMX partial)
func (ui *AdminUI) handleGCTab(w http.ResponseWriter, r *http.Request) {
defer clearFlash(w)
if ui.gc == nil {
ui.renderTemplate(w, "partials/tab_storage.html", gcTabData{})
return
+30 -2
View File
@@ -41,8 +41,22 @@ func getRelayViews() []RelayView {
return relays
}
// knownRelayByURL returns the matching KnownRelay (exact URL match) or nil
// if the URL is not on the allowlist. Used to gate outbound HTTP from the
// status/crawl handlers — the `url` query parameter is attacker-controllable
// in principle, so we restrict it to vetted relay endpoints.
func knownRelayByURL(relayURL string) *atproto.KnownRelay {
for i := range atproto.KnownRelays {
if atproto.KnownRelays[i].URL == relayURL {
return &atproto.KnownRelays[i]
}
}
return nil
}
// handleRelaysTab returns the relays tab content (HTMX partial)
func (ui *AdminUI) handleRelaysTab(w http.ResponseWriter, r *http.Request) {
defer clearFlash(w)
data := struct {
Relays []RelayView
}{
@@ -54,11 +68,18 @@ func (ui *AdminUI) handleRelaysTab(w http.ResponseWriter, r *http.Request) {
// handleRelayStatus returns an HTMX partial with a relay's full status row.
func (ui *AdminUI) handleRelayStatus(w http.ResponseWriter, r *http.Request) {
relayURL := r.URL.Query().Get("url")
relayName := r.URL.Query().Get("name")
if relayURL == "" {
http.Error(w, "Missing url parameter", http.StatusBadRequest)
return
}
known := knownRelayByURL(relayURL)
if known == nil {
slog.Warn("Admin relay status request for non-allowlisted URL",
"url", relayURL, "did", sessionDIDFromContext(r.Context()))
http.Error(w, "Unknown relay", http.StatusBadRequest)
return
}
relayName := known.Name
parsed, err := url.Parse(ui.config.PublicURL)
if err != nil {
@@ -99,11 +120,18 @@ type RelayCrawlResultView struct {
// handleRelayCrawl requests crawl from a single relay and returns an HTMX partial.
func (ui *AdminUI) handleRelayCrawl(w http.ResponseWriter, r *http.Request) {
relayURL := r.URL.Query().Get("url")
relayName := r.URL.Query().Get("name")
if relayURL == "" {
http.Error(w, "Missing relay URL", http.StatusBadRequest)
return
}
known := knownRelayByURL(relayURL)
if known == nil {
slog.Warn("Admin relay crawl request for non-allowlisted URL",
"url", relayURL, "did", sessionDIDFromContext(r.Context()))
http.Error(w, "Unknown relay", http.StatusBadRequest)
return
}
relayName := known.Name
err := atproto.RequestCrawl(relayURL, ui.config.PublicURL)
+58 -15
View File
@@ -57,9 +57,10 @@ func (ui *AdminUI) getSettingsData(ctx context.Context) (*settingsData, error) {
// handleSettingsTab returns the settings tab content (HTMX partial)
func (ui *AdminUI) handleSettingsTab(w http.ResponseWriter, r *http.Request) {
defer clearFlash(w)
settings, err := ui.getSettingsData(r.Context())
if err != nil {
http.Error(w, "Failed to load settings: "+err.Error(), http.StatusInternalServerError)
renderHTMXError(w, r, http.StatusInternalServerError, "Couldn't load settings", err)
return
}
@@ -71,13 +72,30 @@ func (ui *AdminUI) handleSettingsTab(w http.ResponseWriter, r *http.Request) {
ui.renderTemplate(w, "partials/tab_settings.html", data)
}
// handleSettingsUpdate processes settings updates
// handleSettingsUpdate processes settings updates. Supports both plain-form
// (flash-then-redirect) and htmx (HX-Trigger toast) paths so the settings
// panel can stay in the SPA shell.
func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
isHTMX := r.Header.Get("HX-Request") == "true"
respond := func(category, msg string, status int) {
if isHTMX {
trigger := `{"toast":{"message":` + jsonString(msg) + `,"type":"` + category + `"}}`
w.Header().Set("HX-Trigger", trigger)
w.Header().Set("HX-Reswap", "none")
if status == 0 {
status = http.StatusNoContent
}
w.WriteHeader(status)
return
}
setFlash(w, r, category, msg)
http.Redirect(w, r, "/admin#settings", http.StatusFound)
}
if err := r.ParseForm(); err != nil {
setFlash(w, r, "error", "Invalid form data")
http.Redirect(w, r, "/admin#settings", http.StatusFound)
respond("error", "Invalid form data", http.StatusBadRequest)
return
}
@@ -89,8 +107,7 @@ func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request)
// Validate successor DID format if provided
if successor != "" {
if !atproto.IsDID(successor) || (!strings.HasPrefix(successor, "did:web:") && !strings.HasPrefix(successor, "did:plc:")) {
setFlash(w, r, "error", "Successor must be a valid did:web: or did:plc: DID")
http.Redirect(w, r, "/admin#settings", http.StatusFound)
respond("error", "Successor must be a valid did:web: or did:plc: DID", http.StatusBadRequest)
return
}
}
@@ -99,8 +116,7 @@ func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request)
_, captain, getErr := ui.pds.GetCaptainRecord(ctx)
if getErr != nil {
slog.Error("Failed to get captain record", "error", getErr)
setFlash(w, r, "error", "Failed to read settings: "+getErr.Error())
http.Redirect(w, r, "/admin#settings", http.StatusFound)
respond("error", "Couldn't read settings", http.StatusInternalServerError)
return
}
@@ -112,8 +128,7 @@ func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request)
_, err := ui.pds.UpdateCaptainRecord(ctx, captain)
if err != nil {
slog.Error("Failed to update captain record", "error", err)
setFlash(w, r, "error", "Failed to update settings: "+err.Error())
http.Redirect(w, r, "/admin#settings", http.StatusFound)
respond("error", "Couldn't update settings", http.StatusInternalServerError)
return
}
@@ -133,16 +148,44 @@ func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request)
// Show warning toast once, then suppress for 2 minutes
if _, err := r.Cookie("config_write_warned"); err != nil {
setFlash(w, r, "warning", "Settings saved but config file is not writable — changes won't persist across restarts")
setConfigWriteWarnedCookie(w, r)
http.Redirect(w, r, "/admin#settings", http.StatusFound)
respond("warning", "Saved, but config file isn't writable — changes won't persist across restarts", 0)
return
}
}
}
setFlash(w, r, "success", "Settings updated successfully")
http.Redirect(w, r, "/admin#settings", http.StatusFound)
respond("success", "Settings updated", 0)
}
// jsonString wraps a string in double quotes with JSON-safe escaping.
// Used to build HX-Trigger header values without pulling in encoding/json.
func jsonString(s string) string {
b := make([]byte, 0, len(s)+2)
b = append(b, '"')
for i := 0; i < len(s); i++ {
c := s[i]
switch c {
case '"', '\\':
b = append(b, '\\', c)
case '\n':
b = append(b, '\\', 'n')
case '\r':
b = append(b, '\\', 'r')
case '\t':
b = append(b, '\\', 't')
default:
if c < 0x20 {
b = append(b, '\\', 'u', '0', '0',
"0123456789abcdef"[c>>4],
"0123456789abcdef"[c&0xf])
} else {
b = append(b, c)
}
}
}
b = append(b, '"')
return string(b)
}
// writeConfigSettings updates the toggleable settings in the YAML config file.
@@ -173,6 +216,6 @@ func setConfigWriteWarnedCookie(w http.ResponseWriter, r *http.Request) {
MaxAge: 120, // 2 minutes
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
SameSite: http.SameSiteStrictMode,
})
}
File diff suppressed because one or more lines are too long
+96 -45
View File
@@ -94,24 +94,24 @@
/* ========================================
ADDITIONAL CSS VARIABLES
======================================== */
/* Card elevation. In dark mode, shadows disappear against a dark surface —
depth is communicated primarily by the base-100 → base-200 surface ramp.
Tinted or glow shadows read as highlights, not elevation; use neutral-black. */
:root {
--shadow-card-hover:
0 8px 25px oklch(67.1% 0.05 145 / 0.25), 0 4px 12px oklch(0% 0 0 / 0.1);
--color-helm-light: oklch(31% 0.181 267.5);
--color-helm-dark: oklch(64.6% 0.19 273.2);
}
[data-theme="dark"] {
--shadow-card-hover:
0 8px 25px oklch(67.1% 0.05 145 / 0.2), 0 4px 12px oklch(0% 0 0 / 0.2);
0 2px 4px -1px oklch(0% 0 0 / 0.55),
0 12px 24px -6px oklch(0% 0 0 / 0.45);
}
[data-theme="light"] {
--shadow-card-hover:
0 8px 25px oklch(53.1% 0.1 144.8 / 0.25), 0 4px 12px oklch(0% 0 0 / 0.1);
}
[data-theme="dark"] {
--shadow-card-hover:
0 8px 25px oklch(63.1% 0.07 144.7 / 0.2), 0 4px 12px oklch(0% 0 0 / 0.2);
0 1px 2px oklch(0% 0 0 / 0.06),
0 8px 24px -6px oklch(0% 0 0 / 0.12);
}
/* ========================================
@@ -260,21 +260,23 @@
/* ----------------------------------------
HELM BRAND COLOR (official Helm blue #0F1689)
Tokens live on :root (--color-helm-{light,dark}) so the value is
declared once and any future brand shift updates every consumer.
---------------------------------------- */
.text-helm {
@apply text-[oklch(31%_0.181_267.5)];
color: var(--color-helm-light);
}
[data-theme="dark"] .text-helm {
@apply text-[oklch(64.6%_0.19_273.2)];
color: var(--color-helm-dark);
}
.badge-helm {
--badge-color: oklch(31% 0.181 267.5);
--badge-color: var(--color-helm-light);
}
[data-theme="dark"] .badge-helm {
--badge-color: oklch(64.6% 0.19 273.2);
--badge-color: var(--color-helm-dark);
}
/* ----------------------------------------
@@ -296,37 +298,9 @@
CARD EXTENSIONS
---------------------------------------- */
.card-interactive {
@apply cursor-pointer duration-500;
transition-property: box-shadow, transform;
}
.card-interactive:hover {
box-shadow: var(--shadow-card-hover);
transform: translateY(-2px);
}
/* ----------------------------------------
ACTOR-TYPEAHEAD COMPONENT STYLING
---------------------------------------- */
actor-typeahead {
/* Use DaisyUI CSS variables - they auto-switch with theme */
--color-background: var(--color-base-100);
--color-border: var(--color-base-300);
--color-shadow: var(--color-base-content);
--color-hover: var(--color-base-200);
--color-avatar-fallback: var(--color-base-300);
--radius: 0.5rem;
--padding-menu: 0.25rem;
z-index: 50;
}
actor-typeahead::part(handle) {
@apply text-base-content;
}
actor-typeahead::part(menu) {
@apply shadow-lg;
margin-top: 0.25rem;
@apply cursor-pointer;
transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 250ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* ----------------------------------------
@@ -351,7 +325,6 @@
@apply text-base-content;
}
.recent-accounts-item:hover,
.recent-accounts-item.focused {
@apply bg-base-200;
}
@@ -369,3 +342,81 @@
@apply block w-full;
}
}
/* ========================================
KEYBOARD FOCUS RING
Applied only on :focus-visible so mouse users don't see it.
Uses the primary hue so it reads as part of the Deep Ocean palette.
======================================== */
:where(a, button, [role="button"], [role="tab"], input, select, textarea, summary, [tabindex]):focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* ========================================
HOVER-ONLY CARD AFFORDANCE
Guard behind pointer: fine to prevent sticky hover state on touch.
======================================== */
@media (hover: hover) and (pointer: fine) {
.card-interactive:hover {
box-shadow: var(--shadow-card-hover);
transform: translateY(-2px);
}
.recent-accounts-item:hover {
@apply bg-base-200;
}
}
/* ========================================
TOUCH TARGET SIZING
Small buttons and compact form controls meet the keyboard minimum on
desktop but fall below the 44×44 recommended touch target on touch
devices (WCAG 2.5.5). Grow them on any device that can't reliably
produce hover — covers pure touch as well as hybrid touchscreen
laptops where `pointer: coarse` alone misses.
======================================== */
@media (pointer: coarse), (hover: none) {
/* Icon-only buttons grow both axes — daisyUI's circle/square variants
are the marker for these. */
:is(.btn-circle, .btn-square):is(.btn-xs, .btn-sm) {
min-width: 2.75rem;
min-height: 2.75rem;
}
/* Text buttons only need vertical clearance — padding handles width. */
.btn-xs, .btn-sm {
min-height: 2.75rem;
}
/* Small checkbox/radio: expand the clickable region without distorting
the control itself. */
:is(.checkbox, .radio):is(.checkbox-xs, .radio-xs, .checkbox-sm, .radio-sm) {
min-width: 1.5rem;
min-height: 1.5rem;
}
/* daisyUI menu items (used in dropdowns) are small link-like elements.
Ensure they meet the tap threshold. */
.menu li > a,
.menu li > button {
min-height: 2.75rem;
}
}
/* ========================================
REDUCED MOTION
Honor users who opt out of animation. Collapse all durations to a
near-instant value rather than removing transitions entirely, so
state changes still fire (transitionend listeners, etc.).
======================================== */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+134 -8
View File
@@ -2,11 +2,21 @@
import htmx from 'htmx.org';
window.htmx = htmx;
// Safe localStorage wrappers. Safari private mode, disabled storage, and
// quota-exceeded all throw from getItem/setItem — absorb those failures so
// individual features degrade silently instead of crashing the page.
function lsGet(key) {
try { return localStorage.getItem(key); } catch (_) { return null; }
}
function lsSet(key, value) {
try { localStorage.setItem(key, value); } catch (_) { /* quota/disabled */ }
}
// ========================================
// Theme management (system / light / dark)
// ========================================
function getThemePreference() {
return localStorage.getItem('hold-admin-theme') || 'system';
return lsGet('hold-admin-theme') || 'system';
}
function getEffectiveTheme(pref) {
@@ -26,7 +36,7 @@ function applyTheme() {
}
function setTheme(theme) {
localStorage.setItem('hold-admin-theme', theme);
lsSet('hold-admin-theme', theme);
applyTheme();
closeThemeDropdown();
}
@@ -43,6 +53,7 @@ function updateThemeUI(pref) {
if (check) {
check.style.visibility = isSelected ? 'visible' : 'hidden';
}
option.setAttribute('aria-checked', isSelected ? 'true' : 'false');
});
}
@@ -70,14 +81,39 @@ function initDIDLookup() {
if (!didInput || !lookupBtn || !handleResult) return;
// Build a simple text node inside a wrapper span — used to avoid innerHTML
// with externally-controlled strings.
function setHandleResult(className, text, iconHref) {
if (!document.contains(handleResult)) return;
handleResult.textContent = '';
const span = document.createElement('span');
span.className = className;
if (iconHref) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'icon size-4');
svg.setAttribute('aria-hidden', 'true');
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttribute('href', iconHref);
svg.appendChild(use);
span.appendChild(svg);
span.appendChild(document.createTextNode(' '));
const strong = document.createElement('strong');
strong.textContent = text;
span.appendChild(strong);
} else {
span.textContent = text;
}
handleResult.appendChild(span);
}
async function lookupHandle() {
const did = didInput.value.trim();
if (!did.startsWith('did:')) {
handleResult.innerHTML = '<span class="text-error">Invalid DID format</span>';
setHandleResult('text-error', 'Invalid DID format');
return;
}
handleResult.innerHTML = '<span class="text-base-content/50 italic">Looking up...</span>';
setHandleResult('text-base-content/50 italic', 'Looking up...');
try {
let url;
@@ -87,7 +123,8 @@ function initDIDLookup() {
const host = did.replace('did:web:', '').replace(/%3A/g, ':');
url = `https://${host}/.well-known/did.json`;
} else {
handleResult.innerHTML = '<span class="text-error">Unsupported DID method</span>';
if (!document.contains(handleResult)) return;
setHandleResult('text-error', 'Unsupported DID method');
return;
}
@@ -95,16 +132,22 @@ function initDIDLookup() {
if (!resp.ok) throw new Error('DID not found');
const doc = await resp.json();
if (!document.contains(handleResult)) return;
const aka = doc.alsoKnownAs || [];
const handleUri = aka.find(u => u.startsWith('at://'));
if (handleUri) {
const handle = handleUri.replace('at://', '');
handleResult.innerHTML = `<span class="text-success flex items-center gap-1"><svg class="icon size-4" aria-hidden="true"><use href="/admin/public/icons.svg#check-circle"></use></svg> <strong>${handle}</strong></span>`;
setHandleResult(
'text-success flex items-center gap-1',
handle,
'/admin/public/icons.svg#check-circle'
);
} else {
handleResult.innerHTML = '<span class="text-warning">No handle found</span>';
setHandleResult('text-warning', 'No handle found');
}
} catch (err) {
handleResult.innerHTML = `<span class="text-error">Lookup failed: ${err.message}</span>`;
if (!document.contains(handleResult)) return;
setHandleResult('text-error', `Lookup failed: ${err.message}`);
}
}
@@ -117,10 +160,82 @@ function initDIDLookup() {
});
}
// ========================================
// Toast notifications + htmx error handling
// ========================================
// Pre-create the toast container on DOMContentLoaded so the aria-live region
// exists before any announcement. If the very first toast fires earlier
// (e.g. an htmx:responseError during initial boot) ensureToastContainer()
// constructs it lazily.
function ensureToastContainer() {
let container = document.getElementById('toast-container');
if (container) return container;
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast toast-end toast-bottom z-50';
container.setAttribute('aria-live', 'polite');
container.setAttribute('aria-atomic', 'false');
if (document.body) document.body.appendChild(container);
return container;
}
function showToast(message, type) {
const container = ensureToastContainer();
const isError = type === 'error';
const alertClass = isError ? 'alert-error' : (type === 'warning' ? 'alert-warning' : 'alert-success');
const toast = document.createElement('div');
toast.className = `alert ${alertClass} shadow-lg transition-opacity duration-300`;
toast.setAttribute('role', isError ? 'alert' : 'status');
const span = document.createElement('span');
span.textContent = message;
toast.appendChild(span);
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Global htmx error handlers. Opt-out: any ancestor with
// data-suppress-htmx-toast skips the toast (for components that render their
// own inline error state). If the server already triggered a toast via
// HX-Trigger, skip to avoid double-firing.
document.body.addEventListener('htmx:responseError', (evt) => {
const elt = evt.detail && evt.detail.elt;
if (elt && elt.closest && elt.closest('[data-suppress-htmx-toast]')) return;
const xhr = evt.detail && evt.detail.xhr;
const trigger = xhr && xhr.getResponseHeader && xhr.getResponseHeader('HX-Trigger');
if (trigger && trigger.indexOf('toast') !== -1) return;
const status = xhr ? xhr.status : 0;
const msg = status === 401 ? 'Session expired — please sign in again'
: status === 403 ? 'Not authorized'
: status === 404 ? 'Not found'
: status === 429 ? 'Too many requests — please slow down'
: status >= 500 ? 'Server error — please try again'
: 'Something went wrong';
showToast(msg, 'error');
});
document.body.addEventListener('htmx:sendError', (evt) => {
const elt = evt.detail && evt.detail.elt;
if (elt && elt.closest && elt.closest('[data-suppress-htmx-toast]')) return;
showToast('Network error — check your connection', 'error');
});
// Server-triggered toast via HX-Trigger JSON header.
// Accepts { "toast": { "message": "...", "type": "success|error|warning" } }.
document.body.addEventListener('toast', (evt) => {
const d = (evt && evt.detail) || {};
const message = d.message || d.msg || '';
if (!message) return;
showToast(message, d.type || 'info');
});
// ========================================
// Init
// ========================================
document.addEventListener('DOMContentLoaded', () => {
ensureToastContainer();
applyTheme();
// Theme dropdown setup
@@ -132,9 +247,20 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// Sync aria-expanded on theme-toggle <summary> with the native <details>
// open state so SR announcements match actual disclosure state.
document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
const details = btn.closest('details');
if (!details) return;
const sync = () => btn.setAttribute('aria-expanded', details.open ? 'true' : 'false');
sync();
details.addEventListener('toggle', sync);
});
// DID lookup on crew add page
initDIDLookup();
});
// Export for template onclick handlers
window.setTheme = setTheme;
window.showToast = showToast;
@@ -1,8 +1,14 @@
{{define "admin-head"}}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark light">
<meta name="referrer" content="strict-origin-when-cross-origin">
<meta name="robots" content="noindex, nofollow">
<!-- Theme: apply early to prevent flash -->
<!-- Theme: apply early to prevent flash.
Wrapped in try/catch — Safari Private and quota-exceeded both throw
synchronously from localStorage.getItem, and an unhandled throw here
would block HTML parsing and leave the page on the browser default. -->
<script>
(function() {
function getEffectiveTheme(pref) {
@@ -10,7 +16,8 @@
if (pref === 'light') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
var pref = localStorage.getItem('hold-admin-theme') || 'system';
var pref = 'system';
try { pref = localStorage.getItem('hold-admin-theme') || 'system'; } catch (_) {}
var effective = getEffectiveTheme(pref);
document.documentElement.classList.toggle('dark', effective === 'dark');
document.documentElement.setAttribute('data-theme', effective);
@@ -5,16 +5,20 @@
{{template "admin-head"}}
<title>{{.Title}} - Hold Admin</title>
</head>
<body class="min-h-screen flex flex-col bg-base-200">
<body class="min-h-screen flex flex-col bg-base-200" hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'>
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:bg-primary focus:text-primary-content focus:px-3 focus:py-2 focus:rounded">Skip to main content</a>
{{template "nav" .}}
<div class="flex-1 flex">
{{template "admin-sidebar" .}}
<main class="flex-1 min-w-0">
<main id="main-content" tabindex="-1" class="flex-1 min-w-0">
<div class="max-w-480 mx-auto px-6 pt-6 pb-6">
{{if .Flash}}
<div role="alert" class="alert alert-{{.Flash.Category}} mb-4">
<div role="{{if eq .Flash.Category "error"}}alert{{else}}status{{end}}"
aria-live="{{if eq .Flash.Category "error"}}assertive{{else}}polite{{end}}"
class="alert alert-{{.Flash.Category}} mb-4">
<span>{{.Flash.Message}}</span>
</div>
{{end}}
@@ -28,25 +32,6 @@
</main>
</div>
<footer class="text-center p-6 text-base-content/50 text-sm">
<p>Hold: <code class="font-mono">{{.HoldDID}}</code></p>
</footer>
{{if .ActivePage}}
<script>
(function() {
var active = "{{.ActivePage}}";
var li = document.querySelector('.menu li[data-tab="' + active + '"]');
if (li) li.classList.add('menu-active');
document.querySelectorAll('.admin-tab-mobile').forEach(function(a) {
if (a.dataset.tab === active) {
a.classList.remove('btn-ghost');
a.classList.add('btn-secondary');
}
});
})();
</script>
{{end}}
</body>
</html>
{{end}}
+5 -4
View File
@@ -1,5 +1,5 @@
{{define "nav"}}
<div class="navbar bg-neutral text-neutral-content px-4 shadow-md">
<nav class="navbar bg-neutral text-neutral-content px-4 shadow-md" aria-label="Primary navigation">
<div class="flex-none">
<a href="/admin" class="text-lg font-semibold hover:opacity-80 transition-opacity">Hold Admin</a>
</div>
@@ -7,11 +7,12 @@
{{if .User}}
<div class="flex items-center gap-3">
{{template "admin-theme-toggle"}}
<span class="text-sm opacity-80">{{.User.Handle}}</span>
<span class="text-sm opacity-80 truncate max-w-[16ch]" title="{{.User.Handle}}">{{.User.Handle}}</span>
<form method="POST" action="/admin/auth/logout" class="inline">
<button type="submit" class="btn btn-sm btn-ghost">Logout</button>
{{ csrfInput .CSRFToken }}
<button type="submit" class="btn btn-sm btn-ghost" aria-label="Sign out of Hold Admin">Logout</button>
</form>
</div>
{{end}}
</div>
</nav>
{{end}}
@@ -1,19 +1,49 @@
{{define "admin-sidebar-mobile"}}
<!-- Mobile tab bar (below lg) -->
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6">
<a href="/admin#dashboard" class="btn btn-sm btn-ghost admin-tab-mobile" data-tab="dashboard">
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6" role="tablist" aria-label="Admin sections">
<a id="tab-label-dashboard-mobile" href="/admin#dashboard"
class="btn btn-sm btn-ghost admin-tab-mobile"
role="tab"
data-tab="dashboard"
aria-controls="tab-dashboard"
aria-selected="false"
tabindex="-1">
{{ icon "compass" "size-4" }} Dashboard
</a>
<a href="/admin#crew" class="btn btn-sm btn-ghost admin-tab-mobile" data-tab="crew">
<a id="tab-label-crew-mobile" href="/admin#crew"
class="btn btn-sm btn-ghost admin-tab-mobile"
role="tab"
data-tab="crew"
aria-controls="tab-crew"
aria-selected="false"
tabindex="-1">
{{ icon "anchor" "size-4" }} Crew
</a>
<a href="/admin#settings" class="btn btn-sm btn-ghost admin-tab-mobile" data-tab="settings">
<a id="tab-label-settings-mobile" href="/admin#settings"
class="btn btn-sm btn-ghost admin-tab-mobile"
role="tab"
data-tab="settings"
aria-controls="tab-settings"
aria-selected="false"
tabindex="-1">
{{ icon "settings" "size-4" }} Settings
</a>
<a href="/admin#relays" class="btn btn-sm btn-ghost admin-tab-mobile" data-tab="relays">
<a id="tab-label-relays-mobile" href="/admin#relays"
class="btn btn-sm btn-ghost admin-tab-mobile"
role="tab"
data-tab="relays"
aria-controls="tab-relays"
aria-selected="false"
tabindex="-1">
{{ icon "radio-tower" "size-4" }} Relays
</a>
<a href="/admin#storage" class="btn btn-sm btn-ghost admin-tab-mobile" data-tab="storage">
<a id="tab-label-storage-mobile" href="/admin#storage"
class="btn btn-sm btn-ghost admin-tab-mobile"
role="tab"
data-tab="storage"
aria-controls="tab-storage"
aria-selected="false"
tabindex="-1">
{{ icon "hard-drive" "size-4" }} Storage
</a>
</div>
@@ -21,13 +51,18 @@
{{define "admin-sidebar"}}
<!-- Sidebar (lg and above) — pinned to left edge -->
<aside class="hidden lg:block w-64 shrink-0 sticky top-0 h-screen overflow-y-auto bg-base-200 pt-6 px-4">
<ul class="menu menu-lg rounded-box w-full">
<li data-tab="dashboard"><a href="/admin#dashboard">{{ icon "compass" "size-5" }} Dashboard</a></li>
<li data-tab="crew"><a href="/admin#crew">{{ icon "anchor" "size-5" }} Crew</a></li>
<li data-tab="settings"><a href="/admin#settings">{{ icon "settings" "size-5" }} Settings</a></li>
<li data-tab="relays"><a href="/admin#relays">{{ icon "radio-tower" "size-5" }} Relays</a></li>
<li data-tab="storage"><a href="/admin#storage">{{ icon "hard-drive" "size-5" }} Storage</a></li>
<aside class="hidden lg:block w-64 shrink-0 sticky top-0 h-screen overflow-y-auto bg-base-200 pt-6 px-4"
aria-label="Admin navigation">
<ul class="menu menu-lg rounded-box w-full" role="tablist" aria-label="Admin sections">
<li data-tab="dashboard"><a id="tab-label-dashboard" href="/admin#dashboard" role="tab" aria-controls="tab-dashboard" aria-selected="false" tabindex="-1">{{ icon "compass" "size-5" }} Dashboard</a></li>
<li data-tab="crew"><a id="tab-label-crew" href="/admin#crew" role="tab" aria-controls="tab-crew" aria-selected="false" tabindex="-1">{{ icon "anchor" "size-5" }} Crew</a></li>
<li data-tab="settings"><a id="tab-label-settings" href="/admin#settings" role="tab" aria-controls="tab-settings" aria-selected="false" tabindex="-1">{{ icon "settings" "size-5" }} Settings</a></li>
<li data-tab="relays"><a id="tab-label-relays" href="/admin#relays" role="tab" aria-controls="tab-relays" aria-selected="false" tabindex="-1">{{ icon "radio-tower" "size-5" }} Relays</a></li>
<li data-tab="storage"><a id="tab-label-storage" href="/admin#storage" role="tab" aria-controls="tab-storage" aria-selected="false" tabindex="-1">{{ icon "hard-drive" "size-5" }} Storage</a></li>
</ul>
<div class="mt-4 px-3 text-xs text-base-content/50">
<p class="uppercase tracking-wide mb-1">Hold</p>
<code class="font-mono break-all block" title="{{.HoldDID}}">{{.HoldDID}}</code>
</div>
</aside>
{{end}}
@@ -3,23 +3,23 @@
<summary data-theme-toggle class="btn btn-ghost btn-circle list-none" aria-label="Theme settings">
<svg class="icon size-5" data-theme-icon aria-hidden="true"><use href="/admin/public/icons.svg#sun"></use></svg>
</summary>
<ul data-theme-menu class="dropdown-content menu bg-base-100 text-base-content rounded-box z-50 w-40 p-2 shadow-lg">
<ul data-theme-menu role="menu" class="dropdown-content menu bg-base-100 text-base-content rounded-box z-50 w-40 p-2 shadow-lg">
<li>
<button type="button" class="theme-option" data-value="system">
<button type="button" role="menuitem" class="theme-option" data-value="system">
{{ icon "sun-moon" "size-4" }}
<span>System</span>
{{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }}
</button>
</li>
<li>
<button type="button" class="theme-option" data-value="light">
<button type="button" role="menuitem" class="theme-option" data-value="light">
{{ icon "sun" "size-4" }}
<span>Light</span>
{{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }}
</button>
</li>
<li>
<button type="button" class="theme-option" data-value="dark">
<button type="button" role="menuitem" class="theme-option" data-value="dark">
{{ icon "moon" "size-4" }}
<span>Dark</span>
{{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }}
+115 -23
View File
@@ -3,100 +3,184 @@
{{define "page-content"}}
<!-- Dashboard (loads immediately) -->
<div id="tab-dashboard" class="admin-panel"
role="tabpanel"
aria-labelledby="tab-label-dashboard"
tabindex="0"
hx-get="/admin/api/tab/dashboard"
hx-trigger="load"
hx-swap="innerHTML">
hx-swap="innerHTML"
aria-live="polite"
aria-busy="true">
<p class="text-base-content/50 italic">Loading...</p>
</div>
<!-- Crew (loads on first activation) -->
<div id="tab-crew" class="admin-panel hidden"
role="tabpanel"
aria-labelledby="tab-label-crew"
tabindex="0"
hx-get="/admin/api/tab/crew"
hx-trigger="tab:crew from:body once"
hx-swap="innerHTML">
hx-swap="innerHTML"
aria-live="polite">
</div>
<!-- Settings (loads on first activation) -->
<div id="tab-settings" class="admin-panel hidden"
role="tabpanel"
aria-labelledby="tab-label-settings"
tabindex="0"
hx-get="/admin/api/tab/settings"
hx-trigger="tab:settings from:body once"
hx-swap="innerHTML">
hx-swap="innerHTML"
aria-live="polite">
</div>
<!-- Relays (loads on first activation) -->
<div id="tab-relays" class="admin-panel hidden"
role="tabpanel"
aria-labelledby="tab-label-relays"
tabindex="0"
hx-get="/admin/api/tab/relays"
hx-trigger="tab:relays from:body once"
hx-swap="innerHTML">
hx-swap="innerHTML"
aria-live="polite">
</div>
<!-- Storage / GC (loads on first activation) -->
<div id="tab-storage" class="admin-panel hidden"
role="tabpanel"
aria-labelledby="tab-label-storage"
tabindex="0"
hx-get="/admin/api/tab/storage"
hx-trigger="tab:storage from:body once"
hx-swap="innerHTML">
hx-swap="innerHTML"
aria-live="polite">
</div>
<script>
(function() {
var validTabs = ['dashboard', 'crew', 'settings', 'relays', 'storage'];
var tabTitles = {
dashboard: 'Dashboard',
crew: 'Crew',
settings: 'Settings',
relays: 'Relays',
storage: 'Storage'
};
function switchAdminTab(tabId) {
if (validTabs.indexOf(tabId) === -1) tabId = 'dashboard';
// Toggle panel visibility
document.querySelectorAll('.admin-panel').forEach(function(p) {
p.classList.add('hidden');
var isActive = p.id === 'tab-' + tabId;
p.classList.toggle('hidden', !isActive);
});
var panel = document.getElementById('tab-' + tabId);
if (panel) panel.classList.remove('hidden');
// Desktop sidebar: toggle menu-active
// Desktop sidebar: toggle menu-active + aria-selected + aria-current
document.querySelectorAll('.menu li[data-tab]').forEach(function(li) {
if (li.dataset.tab === tabId) {
li.classList.add('menu-active');
} else {
li.classList.remove('menu-active');
var isActive = li.dataset.tab === tabId;
li.classList.toggle('menu-active', isActive);
var link = li.querySelector('a, button');
if (link) {
link.setAttribute('aria-selected', isActive ? 'true' : 'false');
if (isActive) {
link.setAttribute('aria-current', 'page');
} else {
link.removeAttribute('aria-current');
}
link.setAttribute('tabindex', isActive ? '0' : '-1');
}
});
// Mobile: toggle btn-secondary / btn-ghost
// Mobile: toggle btn-secondary / btn-ghost + aria-selected
document.querySelectorAll('.admin-tab-mobile').forEach(function(a) {
if (a.dataset.tab === tabId) {
a.classList.remove('btn-ghost');
a.classList.add('btn-secondary');
var isActive = a.dataset.tab === tabId;
a.classList.toggle('btn-secondary', isActive);
a.classList.toggle('btn-ghost', !isActive);
a.setAttribute('aria-selected', isActive ? 'true' : 'false');
if (isActive) {
a.setAttribute('aria-current', 'page');
} else {
a.classList.remove('btn-secondary');
a.classList.add('btn-ghost');
a.removeAttribute('aria-current');
}
a.setAttribute('tabindex', isActive ? '0' : '-1');
});
// Update document.title so AT users navigating by page title see the
// active tab (WCAG 2.4.2). The server already renders "{{.Title}} -
// Hold Admin" so we keep that suffix.
document.title = tabTitles[tabId] + ' — Hold Admin';
history.replaceState(null, '', '#' + tabId);
document.body.dispatchEvent(new CustomEvent('tab:' + tabId));
}
// Arrow-key navigation inside a tablist. Left/Right move focus and
// activate the next tab; Home/End jump to the first/last tab. Only
// enabled when the event target is actually a tab.
function handleTabKey(e, tabs) {
var idx = tabs.indexOf(e.target);
if (idx === -1) return;
var next = -1;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowUp':
next = (idx - 1 + tabs.length) % tabs.length;
break;
case 'ArrowRight':
case 'ArrowDown':
next = (idx + 1) % tabs.length;
break;
case 'Home':
next = 0;
break;
case 'End':
next = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
var target = tabs[next];
switchAdminTab(target.dataset.tab);
target.focus();
}
document.addEventListener('DOMContentLoaded', function() {
// Mobile tab click handlers
document.querySelectorAll('.admin-tab-mobile').forEach(function(a) {
var mobileTabs = Array.from(document.querySelectorAll('.admin-tab-mobile'));
mobileTabs.forEach(function(a) {
a.addEventListener('click', function(e) {
e.preventDefault();
switchAdminTab(this.dataset.tab);
});
a.addEventListener('keydown', function(e) { handleTabKey(e, mobileTabs); });
});
// Desktop sidebar click handlers
document.querySelectorAll('.menu li[data-tab] a').forEach(function(link) {
var desktopTabs = Array.from(document.querySelectorAll('.menu li[data-tab] a'));
desktopTabs.forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
switchAdminTab(this.parentElement.dataset.tab);
});
link.addEventListener('keydown', function(e) { handleTabKey(e, desktopTabs); });
});
// Activate tab from hash (default: dashboard)
requestAnimationFrame(function() {
// Activate tab from hash (default: dashboard). rAF deferral keeps
// the initial state update outside the first paint.
var rafId = requestAnimationFrame(function() {
rafId = 0;
var hash = window.location.hash.replace('#', '') || 'dashboard';
switchAdminTab(hash);
});
// Cancel on bfcache/pageshow restore so the callback doesn't run
// against a stale DOM.
window.addEventListener('pagehide', function() {
if (rafId) cancelAnimationFrame(rafId);
}, { once: true });
});
// Handle browser back/forward
@@ -104,6 +188,14 @@
var hash = window.location.hash.replace('#', '') || 'dashboard';
switchAdminTab(hash);
});
// Clear aria-busy when the dashboard panel finishes loading.
document.body.addEventListener('htmx:afterSettle', function(evt) {
var elt = evt.detail && evt.detail.elt;
if (elt && elt.classList && elt.classList.contains('admin-panel')) {
elt.removeAttribute('aria-busy');
}
});
})();
</script>
{{end}}
+7 -2
View File
@@ -12,12 +12,16 @@
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<form action="/admin/crew/add" method="POST" class="max-w-lg">
{{ csrfInput .CSRFToken }}
<fieldset class="fieldset mb-6">
<label class="fieldset-label font-medium" for="did">DID</label>
<div class="join w-full">
<input type="text" id="did" name="did"
class="input input-bordered join-item flex-1"
placeholder="did:plc:..." required>
placeholder="did:plc:..."
autocomplete="off"
inputmode="url"
required>
<button type="button" id="lookup-btn" class="btn btn-ghost join-item" title="Lookup handle" aria-label="Lookup handle from DID">
{{ icon "search" "size-4" }}
</button>
@@ -30,7 +34,8 @@
<label class="fieldset-label font-medium" for="role">Role</label>
<input type="text" id="role" name="role"
class="input input-bordered w-full"
placeholder="member" value="member">
placeholder="member" value="member"
autocomplete="off">
<span class="fieldset-label text-base-content/50">Optional role name (e.g., member, admin)</span>
</fieldset>
@@ -22,6 +22,7 @@
<div class="card-body">
<form action="/admin/crew/{{.RKey}}/update" method="POST" class="max-w-lg">
{{ csrfInput .CSRFToken }}
<fieldset class="fieldset mb-6">
<label class="fieldset-label font-medium" for="role">Role</label>
<input type="text" id="role" name="role"
@@ -12,6 +12,7 @@
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<form action="/admin/crew/import" method="POST" enctype="multipart/form-data" class="max-w-lg">
{{ csrfInput .CSRFToken }}
<fieldset class="fieldset mb-6">
<label class="fieldset-label font-medium" for="crew_file">Crew Export File</label>
<input type="file" id="crew_file" name="crew_file"
@@ -27,17 +27,18 @@
<div class="card bg-base-100 shadow-sm">
<div class="overflow-x-auto">
<table class="table table-zebra">
<caption class="sr-only">Crew import results</caption>
<thead>
<tr>
<th>DID</th>
<th>Status</th>
<th>Detail</th>
<th scope="col">DID</th>
<th scope="col">Status</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody>
{{range .Results}}
<tr>
<td>
<td scope="row">
<div>
{{if .Handle}}<strong class="text-base-content">{{.Handle}}</strong><br>{{end}}
<code class="text-xs text-base-content/50 break-all font-mono">{{.DID}}</code>
+5 -1
View File
@@ -8,10 +8,14 @@
<body class="min-h-screen flex flex-col bg-base-200">
{{template "nav" .}}
<main class="flex-1 max-w-7xl w-full mx-auto p-6">
<main id="main-content" tabindex="-1" class="flex-1 max-w-7xl w-full mx-auto p-6">
<div class="text-center py-16">
<h1 class="text-2xl font-bold mb-4">Error</h1>
{{if .Error}}
<p class="text-error text-lg mb-6">{{.Error}}</p>
{{else}}
<p class="text-error text-lg mb-6">An unexpected error occurred.</p>
{{end}}
<a href="/admin" class="btn btn-primary">Back to Dashboard</a>
</div>
</main>
+4 -4
View File
@@ -6,7 +6,7 @@
<title>Login - Hold Admin</title>
</head>
<body class="min-h-screen flex items-center justify-center bg-base-200">
<div class="w-full max-w-sm p-4">
<main id="main-content" tabindex="-1" class="w-full max-w-sm p-4">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h1 class="card-title justify-center text-2xl">Hold Admin</h1>
@@ -14,11 +14,11 @@
{{if .Error}}
<div role="alert" class="alert alert-error mb-4">
<span>{{.Error}}</span>
<span>{{loginError .Error}}</span>
</div>
{{end}}
<form action="/admin/auth/oauth/authorize" method="GET">
<form action="/admin/auth/oauth/authorize" method="POST">
<fieldset class="fieldset mb-4">
<label class="fieldset-label" for="handle">Handle or DID</label>
<input type="text" id="handle" name="handle"
@@ -41,7 +41,7 @@
<footer class="text-center mt-8 text-base-content/50 text-xs">
<p>Hold: <code class="font-mono">{{.HoldDID}}</code></p>
</footer>
</div>
</main>
</body>
</html>
{{end}}
@@ -1,9 +1,9 @@
{{define "partials/crew_member_row.html"}}
<tr id="crew-{{.RKey}}">
<td>
<div>
{{if .Handle}}<strong class="text-base-content">{{.Handle}}</strong><br>{{end}}
<code class="text-xs text-base-content/50 break-all font-mono">{{.DID}}</code>
<div class="flex flex-col gap-0.5">
{{if .Handle}}<strong class="text-base-content">{{.Handle}}</strong>{{end}}
<code class="text-xs text-base-content/50 font-mono truncate max-w-[24ch]" title="{{.DID}}">{{.DID}}</code>
</div>
</td>
<td>{{.Role}}</td>
@@ -19,11 +19,11 @@
<td>
<div class="flex flex-col gap-1 min-w-24">
<span class="text-sm">{{.UsageHuman}}</span>
<progress class="progress {{if gt .UsagePercent 90}}progress-error{{else if gt .UsagePercent 75}}progress-warning{{else}}progress-primary{{end}} w-full" value="{{.UsagePercent}}" max="100"></progress>
<progress class="progress {{if gt .UsagePercent 90}}progress-error{{else if gt .UsagePercent 75}}progress-warning{{else}}progress-primary{{end}} w-full" value="{{.UsagePercent}}" max="100" aria-label="Storage usage for {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}: {{.UsagePercent}}%"></progress>
<small class="text-base-content/50">{{.UsagePercent}}%</small>
</div>
</td>
<td class="text-sm text-base-content/70">{{formatTime .AddedAt}}</td>
<td class="text-sm text-base-content/70">{{if .AddedAt.IsZero}}<span class="text-base-content/30"></span>{{else}}{{formatTime .AddedAt}}{{end}}</td>
<td>
<div class="flex gap-1 justify-end">
<a href="/admin/crew/{{.RKey}}" class="btn btn-ghost btn-sm btn-square" title="Edit" aria-label="Edit crew member {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}">
@@ -1,5 +1,5 @@
{{define "partials/gc_error.html"}}
<div class="alert alert-error">
<div class="alert alert-error" role="alert">
{{ icon "alert-triangle" "size-5" }}
<span>{{.Error}}</span>
</div>
@@ -5,21 +5,21 @@
<div class="stats shadow bg-base-100">
<div class="stat">
<div class="stat-title">Orphaned Records</div>
<div class="stat-value {{if .Preview.OrphanedRecords}}text-error{{end}}">{{len .Preview.OrphanedRecords}}</div>
<div class="stat-value {{if .Preview.OrphanedRecords}}text-error{{end}}">{{len .Preview.OrphanedRecords}}{{if .Preview.OrphanedRecords}}<span class="sr-only"> — needs attention</span>{{end}}</div>
<div class="stat-desc">Layer records with no manifest</div>
</div>
</div>
<div class="stats shadow bg-base-100">
<div class="stat">
<div class="stat-title">Orphaned Blobs</div>
<div class="stat-value {{if .Preview.OrphanedBlobs}}text-error{{end}}">{{len .Preview.OrphanedBlobs}}</div>
<div class="stat-value {{if .Preview.OrphanedBlobs}}text-error{{end}}">{{len .Preview.OrphanedBlobs}}{{if .Preview.OrphanedBlobs}}<span class="sr-only"> — needs attention</span>{{end}}</div>
<div class="stat-desc">S3 blobs with no layer record</div>
</div>
</div>
<div class="stats shadow bg-base-100">
<div class="stat">
<div class="stat-title">Missing Records</div>
<div class="stat-value {{if .Preview.MissingRecords}}text-warning{{end}}">{{len .Preview.MissingRecords}}</div>
<div class="stat-value {{if .Preview.MissingRecords}}text-warning{{end}}">{{len .Preview.MissingRecords}}{{if .Preview.MissingRecords}}<span class="sr-only"> — needs attention</span>{{end}}</div>
<div class="stat-desc">Would be reconciled</div>
</div>
</div>
@@ -40,27 +40,34 @@
<!-- Orphaned Records table -->
{{if .Preview.OrphanedRecords}}
<div class="collapse collapse-arrow bg-base-100 shadow-sm">
<input type="checkbox" />
<input type="checkbox" aria-label="Show orphaned layer records" />
<div class="collapse-title font-medium">
{{ icon "file-x" "size-4 inline" }} Orphaned Layer Records ({{len .Preview.OrphanedRecords}})
{{ icon "file-x" "size-4" }} Orphaned Layer Records ({{len .Preview.OrphanedRecords}})
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-sm">
<table class="table table-sm table-fixed">
<caption class="sr-only">Orphaned layer records</caption>
<colgroup>
<col style="width: 15%">
<col style="width: 25%">
<col style="width: 45%">
<col style="width: 15%">
</colgroup>
<thead>
<tr>
<th>RKey</th>
<th>Digest</th>
<th>Manifest</th>
<th>Size</th>
<th scope="col">RKey</th>
<th scope="col">Digest</th>
<th scope="col">Manifest</th>
<th scope="col">Size</th>
</tr>
</thead>
<tbody>
{{range .Preview.OrphanedRecords}}
<tr>
<td><code class="text-xs font-mono">{{.Rkey}}</code></td>
<td><code class="text-xs font-mono">{{truncate .Digest 24}}</code></td>
<td><code class="text-xs font-mono break-all">{{truncate .ManifestURI 50}}</code></td>
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.Rkey}}">{{.Rkey}}</code></td>
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.Digest}}">{{truncate .Digest 24}}</code></td>
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.ManifestURI}}">{{truncate .ManifestURI 50}}</code></td>
<td class="whitespace-nowrap">{{formatBytes .Size}}</td>
</tr>
{{end}}
@@ -74,23 +81,28 @@
<!-- Orphaned Blobs table -->
{{if .Preview.OrphanedBlobs}}
<div class="collapse collapse-arrow bg-base-100 shadow-sm">
<input type="checkbox" />
<input type="checkbox" aria-label="Show orphaned blobs" />
<div class="collapse-title font-medium">
{{ icon "trash-2" "size-4 inline" }} Orphaned Blobs ({{len .Preview.OrphanedBlobs}})
{{ icon "trash-2" "size-4" }} Orphaned Blobs ({{len .Preview.OrphanedBlobs}})
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-sm">
<table class="table table-sm table-fixed">
<caption class="sr-only">Orphaned blobs</caption>
<colgroup>
<col style="width: 80%">
<col style="width: 20%">
</colgroup>
<thead>
<tr>
<th>Digest</th>
<th>Size</th>
<th scope="col">Digest</th>
<th scope="col">Size</th>
</tr>
</thead>
<tbody>
{{range .Preview.OrphanedBlobs}}
<tr>
<td><code class="text-xs font-mono">{{truncate .Digest 30}}</code></td>
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.Digest}}">{{truncate .Digest 30}}</code></td>
<td class="whitespace-nowrap">{{formatBytes .Size}}</td>
</tr>
{{end}}
@@ -104,27 +116,34 @@
<!-- Missing Records table (reconcile mode only) -->
{{if .Preview.MissingRecords}}
<div class="collapse collapse-arrow bg-base-100 shadow-sm">
<input type="checkbox" />
<input type="checkbox" aria-label="Show missing layer records" />
<div class="collapse-title font-medium">
{{ icon "file-plus" "size-4 inline" }} Missing Layer Records ({{len .Preview.MissingRecords}})
{{ icon "file-plus" "size-4" }} Missing Layer Records ({{len .Preview.MissingRecords}})
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-sm">
<table class="table table-sm table-fixed">
<caption class="sr-only">Missing layer records</caption>
<colgroup>
<col style="width: 20%">
<col style="width: 40%">
<col style="width: 25%">
<col style="width: 15%">
</colgroup>
<thead>
<tr>
<th>Digest</th>
<th>Manifest</th>
<th>User</th>
<th>Size</th>
<th scope="col">Digest</th>
<th scope="col">Manifest</th>
<th scope="col">User</th>
<th scope="col">Size</th>
</tr>
</thead>
<tbody>
{{range .Preview.MissingRecords}}
<tr>
<td><code class="text-xs font-mono">{{truncate .Digest 24}}</code></td>
<td><code class="text-xs font-mono break-all">{{truncate .ManifestURI 50}}</code></td>
<td><code class="text-xs font-mono">{{truncate .UserDID 24}}</code></td>
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.Digest}}">{{truncate .Digest 24}}</code></td>
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.ManifestURI}}">{{truncate .ManifestURI 50}}</code></td>
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.UserDID}}">{{truncate .UserDID 24}}</code></td>
<td class="whitespace-nowrap">{{formatBytes .Size}}</td>
</tr>
{{end}}
@@ -3,8 +3,8 @@
hx-trigger="load delay:2s"
hx-target="#gc-results"
hx-swap="innerHTML">
<div class="flex items-center gap-3 p-4 bg-base-100 rounded-lg shadow-sm">
<span class="loading loading-spinner loading-md text-primary"></span>
<div class="flex items-center gap-3 p-4 bg-base-100 rounded-lg shadow-sm" role="status">
<span class="loading loading-spinner loading-md text-primary" aria-hidden="true"></span>
<div>
<p class="font-medium">{{.Message}}</p>
<p class="text-sm text-base-content/50">
@@ -1,24 +1,25 @@
{{define "partials/relay_crawl_result.html"}}
<tr hx-get="/admin/api/relay/status?url={{.URL}}&name={{.Name}}"
{{/* Auto-refreshes relay status 10s after a crawl request is sent. htmx will not fire if this <tr> has been detached. */}}
<tr hx-get="/admin/api/relay/status?url={{.URL | urlquery}}&name={{.Name | urlquery}}"
hx-trigger="load delay:10s"
hx-swap="outerHTML">
<td>
{{if .Success}}
<span class="badge badge-success badge-sm gap-1">
<span class="badge badge-success badge-sm gap-1" role="status">
{{ icon "check-circle" "size-3" }}
Sent
</span>
{{else}}
<span class="badge badge-error badge-sm gap-1">
<span class="badge badge-error badge-sm gap-1" role="status">
{{ icon "alert-circle" "size-3" }}
Failed
</span>
{{end}}
</td>
<td>
<div>
<strong>{{.Name}}</strong><br>
<code class="text-xs text-base-content/50 font-mono">{{.URL}}</code>
<div class="flex flex-col gap-0.5">
<strong class="truncate max-w-[20ch]" title="{{.Name}}">{{.Name}}</strong>
<code class="text-xs text-base-content/50 font-mono truncate max-w-[24ch]" title="{{.URL}}">{{.URL}}</code>
</div>
</td>
<td>
@@ -27,7 +28,7 @@
<td>
{{if .Success}}
<span class="text-sm text-info flex items-center gap-1">
<span class="loading loading-spinner loading-xs"></span>
<span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Crawl requested, refreshing...
</span>
{{else}}
@@ -36,10 +37,10 @@
</td>
<td class="text-right">
<button class="btn btn-ghost btn-sm gap-1"
hx-get="/admin/api/relay/status?url={{.URL}}&name={{.Name}}"
hx-get="/admin/api/relay/status?url={{.URL | urlquery}}&name={{.Name | urlquery}}"
hx-target="closest tr"
hx-swap="outerHTML"
title="Refresh Status">
aria-label="Refresh status for {{.Name}}">
{{ icon "refresh-ccw" "size-4" }}
Refresh
</button>
@@ -1,25 +1,26 @@
{{define "partials/relay_crawl_results.html"}}
{{range .Results}}
<tr hx-get="/admin/api/relay/status?url={{.URL}}&name={{.Name}}"
{{/* Auto-refreshes relay status 10s after crawl-all completes. htmx will not fire if this <tr> has been detached. */}}
<tr hx-get="/admin/api/relay/status?url={{.URL | urlquery}}&name={{.Name | urlquery}}"
hx-trigger="load delay:10s"
hx-swap="outerHTML">
<td>
{{if .Success}}
<span class="badge badge-success badge-sm gap-1">
<span class="badge badge-success badge-sm gap-1" role="status">
{{ icon "check-circle" "size-3" }}
Sent
</span>
{{else}}
<span class="badge badge-error badge-sm gap-1">
<span class="badge badge-error badge-sm gap-1" role="status">
{{ icon "alert-circle" "size-3" }}
Failed
</span>
{{end}}
</td>
<td>
<div>
<strong>{{.Name}}</strong><br>
<code class="text-xs text-base-content/50 font-mono">{{.URL}}</code>
<div class="flex flex-col gap-0.5">
<strong class="truncate max-w-[20ch]" title="{{.Name}}">{{.Name}}</strong>
<code class="text-xs text-base-content/50 font-mono truncate max-w-[24ch]" title="{{.URL}}">{{.URL}}</code>
</div>
</td>
<td>
@@ -28,7 +29,7 @@
<td>
{{if .Success}}
<span class="text-sm text-info flex items-center gap-1">
<span class="loading loading-spinner loading-xs"></span>
<span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Crawl requested, refreshing...
</span>
{{else}}
@@ -37,10 +38,10 @@
</td>
<td class="text-right">
<button class="btn btn-ghost btn-sm gap-1"
hx-get="/admin/api/relay/status?url={{.URL}}&name={{.Name}}"
hx-get="/admin/api/relay/status?url={{.URL | urlquery}}&name={{.Name | urlquery}}"
hx-target="closest tr"
hx-swap="outerHTML"
title="Refresh Status">
aria-label="Refresh status for {{.Name}}">
{{ icon "refresh-ccw" "size-4" }}
Refresh
</button>
@@ -2,21 +2,21 @@
<tr>
<td>
{{if .Online}}
<span class="badge badge-success badge-sm gap-1">
<span class="badge badge-success badge-sm gap-1" role="status">
{{ icon "check-circle" "size-3" }}
Online
</span>
{{else}}
<span class="badge badge-error badge-sm gap-1">
<span class="badge badge-error badge-sm gap-1" role="status">
{{ icon "alert-circle" "size-3" }}
Offline
</span>
{{end}}
</td>
<td>
<div>
<strong>{{.Name}}</strong><br>
<code class="text-xs text-base-content/50 font-mono">{{.URL}}</code>
<div class="flex flex-col gap-0.5">
<strong class="truncate max-w-[20ch]" title="{{.Name}}">{{.Name}}</strong>
<code class="text-xs text-base-content/50 font-mono truncate max-w-[24ch]" title="{{.URL}}">{{.URL}}</code>
</div>
</td>
<td>
@@ -55,10 +55,10 @@
<td class="text-right">
{{if and .Online .HasRequestCrawl}}
<button class="btn btn-ghost btn-sm gap-1"
hx-post="/admin/relays/crawl?url={{.URL}}&name={{.Name}}"
hx-post="/admin/relays/crawl?url={{.URL | urlquery}}&name={{.Name | urlquery}}"
hx-target="closest tr"
hx-swap="outerHTML"
title="Request Crawl">
aria-label="Request crawl from {{.Name}}">
{{ icon "refresh-ccw" "size-4" }}
Request Crawl
</button>
+26 -16
View File
@@ -22,16 +22,26 @@
{{if .Crew}}
<div class="card bg-base-100 shadow-sm">
<div class="overflow-x-auto">
<table class="table table-zebra">
<table class="table table-zebra table-fixed">
<caption class="sr-only">Crew members</caption>
<colgroup>
<col style="width: 22%">
<col style="width: 10%">
<col style="width: 18%">
<col style="width: 14%">
<col style="width: 16%">
<col style="width: 12%">
<col style="width: 8%">
</colgroup>
<thead>
<tr>
<th>Member</th>
<th>Role</th>
<th>Permissions</th>
<th>Tier</th>
<th>Usage</th>
<th>Added</th>
<th class="text-right">Actions</th>
<th scope="col">Member</th>
<th scope="col">Role</th>
<th scope="col">Permissions</th>
<th scope="col">Tier</th>
<th scope="col">Usage</th>
<th scope="col">Added</th>
<th scope="col" class="text-right">Actions</th>
</tr>
</thead>
<tbody id="crew-list">
@@ -39,12 +49,12 @@
<tr id="crew-{{.RKey}}"
hx-get="/admin/api/crew/member?rkey={{.RKey}}"
hx-trigger="load"
hx-swap="outerHTML">
hx-swap="outerHTML"
aria-busy="true">
<td>
<div>
<span class="loading loading-spinner loading-xs"></span>
<br>
<code class="text-xs text-base-content/50 break-all font-mono">{{truncate .DID 32}}</code>
<div class="flex flex-col gap-0.5">
<span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
<code class="text-xs text-base-content/50 font-mono truncate max-w-full" title="{{.DID}}">{{truncate .DID 32}}</code>
</div>
</td>
<td>{{.Role}}</td>
@@ -60,11 +70,11 @@
<td>
<div class="flex flex-col gap-1 min-w-24">
<span class="text-sm">{{.UsageHuman}}</span>
<progress class="progress {{if gt .UsagePercent 90}}progress-error{{else if gt .UsagePercent 75}}progress-warning{{else}}progress-primary{{end}} w-full" value="{{.UsagePercent}}" max="100"></progress>
<progress class="progress {{if gt .UsagePercent 90}}progress-error{{else if gt .UsagePercent 75}}progress-warning{{else}}progress-primary{{end}} w-full" value="{{.UsagePercent}}" max="100" aria-label="Storage usage for {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}: {{.UsagePercent}}%"></progress>
<small class="text-base-content/50">{{.UsagePercent}}%</small>
</div>
</td>
<td class="text-sm text-base-content/70">{{formatTime .AddedAt}}</td>
<td class="text-sm text-base-content/70">{{if .AddedAt.IsZero}}<span class="text-base-content/30"></span>{{else}}{{formatTime .AddedAt}}{{end}}</td>
<td></td>
</tr>
{{end}}
@@ -73,7 +83,7 @@
</div>
</div>
{{else}}
<div class="text-center py-12 text-base-content/60">
<div role="status" aria-live="polite" class="text-center py-12 text-base-content/60">
<p>No crew members yet. <a href="/admin/crew/add" class="link link-primary">Add your first crew member</a>.</p>
</div>
{{end}}
@@ -11,7 +11,7 @@
<div class="stat-value">{{.Stats.TotalCrewMembers}}</div>
</div>
</div>
<div class="stats shadow bg-base-100" hx-get="/admin/api/stats" hx-trigger="load" hx-swap="innerHTML">
<div class="stats shadow bg-base-100" hx-get="/admin/api/stats" hx-trigger="load" hx-swap="innerHTML" aria-live="polite" aria-busy="true">
<div class="stat">
<div class="stat-title">Storage</div>
<div class="stat-value text-base-content/30 italic text-lg">Loading...</div>
@@ -24,6 +24,7 @@
<h2 class="card-title text-lg">Tier Distribution</h2>
{{if .Stats.TierDistribution}}
<div class="flex flex-col gap-2">
{{/* TODO: sort tiers in handler (getDashboardStats) — Go map iteration is non-deterministic */}}
{{range $tier, $count := .Stats.TierDistribution}}
<div class="flex justify-between items-center p-3 bg-base-200 rounded-lg">
<span class="font-medium">{{$tier}}</span>
@@ -40,7 +41,7 @@
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Top Users by Storage</h2>
<div hx-get="/admin/api/top-users?limit=10" hx-trigger="load" hx-swap="innerHTML">
<div hx-get="/admin/api/top-users?limit=10" hx-trigger="load" hx-swap="innerHTML" aria-live="polite" aria-busy="true">
<p class="text-base-content/50 italic">Loading top users...</p>
</div>
</div>
@@ -13,7 +13,7 @@
<div id="crawl-loading" class="htmx-indicator mb-4">
<div class="flex items-center gap-3 p-4 bg-base-200 rounded-lg">
<span class="loading loading-spinner loading-md text-primary"></span>
<span class="loading loading-spinner loading-md text-primary" aria-hidden="true"></span>
<div>
<p class="font-medium">Requesting crawl from all relays...</p>
<p class="text-sm text-base-content/50">This may take a few seconds.</p>
@@ -23,28 +23,37 @@
<div class="card bg-base-100 shadow-sm">
<div class="overflow-x-auto">
<table class="table">
<table class="table table-fixed">
<caption class="sr-only">Relay status</caption>
<colgroup>
<col style="width: 10%">
<col style="width: 25%">
<col style="width: 30%">
<col style="width: 25%">
<col style="width: 10%">
</colgroup>
<thead>
<tr>
<th class="w-16"></th>
<th>Relay</th>
<th>Capabilities</th>
<th>Hold Status</th>
<th class="text-right">Actions</th>
<th scope="col" class="w-16"><span class="sr-only">Status</span></th>
<th scope="col">Relay</th>
<th scope="col">Capabilities</th>
<th scope="col">Hold Status</th>
<th scope="col" class="text-right">Actions</th>
</tr>
</thead>
<tbody id="relay-tbody">
<tbody id="relay-tbody" aria-live="polite">
{{range .Relays}}
<tr hx-get="/admin/api/relay/status?url={{.URL}}&name={{.Name}}"
<tr hx-get="/admin/api/relay/status?url={{.URL | urlquery}}&name={{.Name | urlquery}}"
hx-trigger="load"
hx-swap="outerHTML">
hx-swap="outerHTML"
aria-busy="true">
<td>
<span class="loading loading-spinner loading-xs"></span>
<span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
</td>
<td>
<div>
<strong>{{.Name}}</strong><br>
<code class="text-xs text-base-content/50 font-mono">{{.URL}}</code>
<div class="flex flex-col gap-0.5">
<strong class="truncate max-w-[20ch]" title="{{.Name}}">{{.Name}}</strong>
<code class="text-xs text-base-content/50 font-mono truncate max-w-[24ch]" title="{{.URL}}">{{.URL}}</code>
</div>
</td>
<td class="text-base-content/30 text-sm">...</td>
@@ -3,7 +3,7 @@
<h1 class="text-2xl font-bold">Hold Settings</h1>
</div>
<form action="/admin/settings/update" method="POST" class="space-y-6">
<form hx-post="/admin/settings/update" hx-swap="none" class="space-y-6">
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Access Control</h2>
@@ -16,7 +16,7 @@
{{if .Running}}
<span class="badge badge-warning gap-1">
<span class="loading loading-spinner loading-xs"></span>
<span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Running
</span>
{{end}}
@@ -52,7 +52,7 @@
</button>
</div>
<div id="gc-results">
<div id="gc-results" aria-live="polite">
{{if .Running}}
<div hx-get="/admin/api/gc/status"
hx-trigger="load delay:2s"
@@ -1,21 +1,27 @@
{{define "partials/top_users.html"}}
{{if .Users}}
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<table class="table table-zebra table-sm table-fixed">
<caption class="sr-only">Top users by storage</caption>
<colgroup>
<col style="width: 55%">
<col style="width: 25%">
<col style="width: 20%">
</colgroup>
<thead>
<tr>
<th>Member</th>
<th>Usage</th>
<th>Blobs</th>
<th scope="col">Member</th>
<th scope="col">Usage</th>
<th scope="col">Blobs</th>
</tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td>
<div>
{{if .Handle}}<strong class="text-base-content">{{.Handle}}</strong><br>{{end}}
<code class="text-xs text-base-content/50 break-all font-mono">{{.DID}}</code>
<div class="flex flex-col gap-0.5">
{{if .Handle}}<strong class="text-base-content truncate max-w-full" title="{{.Handle}}">{{.Handle}}</strong>{{end}}
<code class="text-xs text-base-content/50 font-mono truncate max-w-[20ch] block" title="{{.DID}}">{{.DID}}</code>
</div>
</td>
<td>{{.UsageHuman}}</td>