mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
impeccable:harden on all admin panel
This commit is contained in:
+135
-21
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 + `">`)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("", " ")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user