Files
at-container-registry/docs/ADMIN_PANEL.md

46 KiB

Hold Admin Panel Implementation Plan

This document describes the implementation plan for adding an owner-only admin web UI to the ATCR hold service. The admin panel will be embedded directly in the hold service binary for simplified deployment.

Table of Contents

  1. Overview
  2. Requirements
  3. Architecture
  4. File Structure
  5. Authentication
  6. Session Management
  7. Route Structure
  8. Feature Implementations
  9. Templates
  10. Environment Variables
  11. Security Considerations
  12. Implementation Phases
  13. Testing Strategy

Overview

The hold admin panel provides a web-based interface for hold owners to:

  • Manage crew members: Add, remove, edit permissions and quota tiers
  • Configure hold settings: Toggle public access, open registration, Bluesky posting
  • View usage metrics: Storage usage per user, top users, repository statistics
  • Monitor quota utilization: Track tier distribution and usage percentages

The admin panel is owner-only - only the DID that matches captain.Owner can access it.


Requirements

Functional Requirements

  1. Crew Management

    • List all crew members with their DID, role, permissions, tier, and storage usage
    • Add new crew members with specified permissions and tier
    • Edit existing crew member permissions and tier
    • Remove crew members (with confirmation)
    • Display each crew member's quota utilization percentage
  2. Quota/Tier Management

    • Display available tiers from quotas.yaml
    • Show tier limits and descriptions
    • Allow changing crew member tiers
    • Display current vs limit usage for each user
  3. Usage Metrics

    • Total storage used across all users
    • Total unique blobs (deduplicated)
    • Number of crew members
    • Top 10/50/100 users by storage consumption
    • Per-repository statistics (pulls, pushes)
  4. Hold Settings

    • Toggle public (allow anonymous blob reads)
    • Toggle allowAllCrew (allow any authenticated user to join)
    • Toggle enableBlueskyPosts (post to Bluesky on image push)

Non-Functional Requirements

  • Single binary: Embedded in hold service, no separate deployment
  • Responsive UI: Works on desktop and mobile browsers
  • Low latency: Dashboard loads in <500ms for typical data volumes
  • Minimal dependencies: Uses Go templates, HTMX for interactivity

Architecture

High-Level Design

┌─────────────────────────────────────────────────────────┐
│                    Hold Service                          │
├─────────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────┐  │
│  │  XRPC/PDS   │  │  OCI XRPC   │  │   Admin Panel   │  │
│  │  Handlers   │  │  Handlers   │  │    Handlers     │  │
│  └──────┬──────┘  └──────┬──────┘  └────────┬────────┘  │
│         │                │                   │           │
│  ┌──────┴────────────────┴───────────────────┴────────┐ │
│  │                    Chi Router                       │ │
│  └─────────────────────────────────────────────────────┘ │
│                           │                              │
│  ┌────────────────────────┴─────────────────────────┐   │
│  │                   Embedded PDS                    │   │
│  │  ┌──────────┐ ┌──────────┐ ┌──────────┐          │   │
│  │  │ Captain  │ │  Crew    │ │  Layer   │          │   │
│  │  │ Records  │ │ Records  │ │ Records  │          │   │
│  │  └──────────┘ └──────────┘ └──────────┘          │   │
│  └───────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

Components

  1. AdminUI - Main struct containing all admin dependencies
  2. Session Store - SQLite-backed session management (separate from carstore)
  3. OAuth Client - Reuses pkg/auth/oauth/ for browser-based login
  4. Auth Middleware - Validates owner-only access
  5. Handlers - HTTP handlers for each admin page
  6. Templates - Go html/template with embed.FS

File Structure

pkg/hold/admin/
├── admin.go              # Main struct, initialization, route registration
├── auth.go               # requireOwner middleware, session validation
├── handlers.go           # HTTP handlers for all admin pages
├── session.go            # SQLite session store implementation
├── metrics.go            # Metrics collection and aggregation
├── templates/
│   ├── base.html         # Base layout (html, head, body wrapper)
│   ├── components/
│   │   ├── head.html     # CSS/JS includes (HTMX, Lucide icons)
│   │   ├── nav.html      # Admin navigation bar
│   │   └── flash.html    # Flash message component
│   ├── pages/
│   │   ├── login.html    # OAuth login page
│   │   ├── dashboard.html # Metrics overview
│   │   ├── crew.html     # Crew list with management actions
│   │   ├── crew_add.html # Add crew member form
│   │   ├── crew_edit.html # Edit crew member form
│   │   └── settings.html # Hold settings page
│   └── partials/
│       ├── crew_row.html      # Single crew row (for HTMX updates)
│       ├── usage_stats.html   # Usage stats partial
│       └── top_users.html     # Top users table partial
└── public/
    ├── css/
    │   └── admin.css     # Admin-specific styles
    └── js/
        └── admin.js      # Admin-specific JavaScript (if needed)

Files to Modify

File Changes
cmd/hold/main.go Add admin UI initialization and route registration
pkg/hold/config.go Add Admin.Enabled and Admin.SessionDuration fields
.env.hold.example Document HOLD_ADMIN_ENABLED, HOLD_ADMIN_SESSION_DURATION

Authentication

OAuth Flow for Admin Login

The admin panel uses ATProto OAuth with DPoP for browser-based authentication:

┌──────────┐     ┌──────────┐     ┌──────────┐     ┌──────────┐
│  Browser │     │   Hold   │     │   PDS    │     │  Owner   │
│          │     │  Admin   │     │          │     │          │
└────┬─────┘     └────┬─────┘     └────┬─────┘     └────┬─────┘
     │                │                │                │
     │ GET /admin     │                │                │
     │───────────────>│                │                │
     │                │                │                │
     │ 302 /admin/auth/login           │                │
     │<───────────────│                │                │
     │                │                │                │
     │ GET /admin/auth/login           │                │
     │───────────────>│                │                │
     │                │                │                │
     │ Login page (enter handle)       │                │
     │<───────────────│                │                │
     │                │                │                │
     │ POST handle    │                │                │
     │───────────────>│                │                │
     │                │                │                │
     │                │ StartAuthFlow  │                │
     │                │───────────────>│                │
     │                │                │                │
     │ 302 to PDS auth URL             │                │
     │<───────────────│                │                │
     │                │                │                │
     │ Authorize in browser            │                │
     │────────────────────────────────>│                │
     │                │                │   Approve?     │
     │                │                │───────────────>│
     │                │                │                │
     │                │                │   Yes          │
     │                │                │<───────────────│
     │                │                │                │
     │ 302 callback with code          │                │
     │<────────────────────────────────│                │
     │                │                │                │
     │ GET /admin/auth/oauth/callback  │                │
     │───────────────>│                │                │
     │                │                │                │
     │                │ ProcessCallback│                │
     │                │───────────────>│                │
     │                │                │                │
     │                │ OAuth tokens   │                │
     │                │<───────────────│                │
     │                │                │                │
     │                │ Check: DID == captain.Owner?    │
     │                │─────────────────────────────────│
     │                │                │                │
     │                │ YES: Create session             │
     │                │                │                │
     │ 302 /admin + session cookie     │                │
     │<───────────────│                │                │
     │                │                │                │
     │ GET /admin (with cookie)        │                │
     │───────────────>│                │                │
     │                │                │                │
     │ Dashboard      │                │                │
     │<───────────────│                │                │

Owner Validation

The callback handler performs owner validation:

func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) {
    // Process OAuth callback
    sessionData, err := ui.clientApp.ProcessCallback(r.Context(), r.URL.Query())
    if err != nil {
        ui.renderError(w, "OAuth failed: " + err.Error())
        return
    }

    did := sessionData.AccountDID.String()

    // Get captain record to check owner
    _, captain, err := ui.pds.GetCaptainRecord(r.Context())
    if err != nil {
        ui.renderError(w, "Failed to verify ownership")
        return
    }

    // CRITICAL: Only allow the hold owner
    if did != captain.Owner {
        slog.Warn("Non-owner attempted admin access", "did", did, "owner", captain.Owner)
        ui.renderError(w, "Access denied: Only the hold owner can access the admin panel")
        return
    }

    // Create admin session
    sessionID, err := ui.sessionStore.Create(did, sessionData.Handle, 24*time.Hour)
    if err != nil {
        ui.renderError(w, "Failed to create session")
        return
    }

    // Set session cookie
    http.SetCookie(w, &http.Cookie{
        Name:     "hold_admin_session",
        Value:    sessionID,
        Path:     "/admin",
        MaxAge:   86400, // 24 hours
        HttpOnly: true,
        Secure:   r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
        SameSite: http.SameSiteLaxMode,
    })

    http.Redirect(w, r, "/admin", http.StatusFound)
}

Auth Middleware

// requireOwner ensures the request is from the hold owner
func (ui *AdminUI) requireOwner(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Get session cookie
        cookie, err := r.Cookie("hold_admin_session")
        if err != nil {
            http.Redirect(w, r, "/admin/auth/login?return_to="+r.URL.Path, http.StatusFound)
            return
        }

        // Validate session
        session, err := ui.sessionStore.Get(cookie.Value)
        if err != nil || session == nil || session.ExpiresAt.Before(time.Now()) {
            // Clear invalid cookie
            http.SetCookie(w, &http.Cookie{
                Name:   "hold_admin_session",
                Value:  "",
                Path:   "/admin",
                MaxAge: -1,
            })
            http.Redirect(w, r, "/admin/auth/login", http.StatusFound)
            return
        }

        // Double-check DID still matches captain.Owner
        // (in case ownership transferred while session active)
        _, captain, err := ui.pds.GetCaptainRecord(r.Context())
        if err != nil || session.DID != captain.Owner {
            ui.sessionStore.Delete(cookie.Value)
            http.Error(w, "Access denied: ownership verification failed", http.StatusForbidden)
            return
        }

        // Add session to context for handlers
        ctx := context.WithValue(r.Context(), adminSessionKey, session)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Session Management

Session Store Schema

-- Admin sessions (browser login state)
CREATE TABLE IF NOT EXISTS admin_sessions (
    id TEXT PRIMARY KEY,
    did TEXT NOT NULL,
    handle TEXT,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expires_at DATETIME NOT NULL,
    last_accessed DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Index for cleanup queries
CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires ON admin_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_admin_sessions_did ON admin_sessions(did);

-- OAuth sessions (indigo library storage)
CREATE TABLE IF NOT EXISTS admin_oauth_sessions (
    session_id TEXT PRIMARY KEY,
    did TEXT NOT NULL,
    data BLOB NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Session Store Interface

// AdminSession represents an authenticated admin session
type AdminSession struct {
    ID           string
    DID          string
    Handle       string
    CreatedAt    time.Time
    ExpiresAt    time.Time
    LastAccessed time.Time
}

// AdminSessionStore manages admin sessions
type AdminSessionStore struct {
    db *sql.DB
}

func NewAdminSessionStore(dbPath string) (*AdminSessionStore, error)

func (s *AdminSessionStore) Create(did, handle string, duration time.Duration) (string, error)
func (s *AdminSessionStore) Get(sessionID string) (*AdminSession, error)
func (s *AdminSessionStore) Delete(sessionID string) error
func (s *AdminSessionStore) DeleteForDID(did string) error
func (s *AdminSessionStore) Cleanup() error // Remove expired sessions
func (s *AdminSessionStore) Touch(sessionID string) error // Update last_accessed

Database Location

The admin database should be in the same directory as the carstore database:

adminDBPath := filepath.Join(cfg.Database.Path, "admin.db")

This keeps all hold data together while maintaining separation between the carstore (ATProto records) and admin sessions.


Route Structure

Complete Route Table

Route Method Auth Handler Description
/admin GET Owner DashboardHandler Main dashboard with metrics
/admin/crew GET Owner CrewListHandler List all crew members
/admin/crew/add GET Owner CrewAddFormHandler Add crew form
/admin/crew/add POST Owner CrewAddHandler Process add crew
/admin/crew/{rkey} GET Owner CrewEditFormHandler Edit crew form
/admin/crew/{rkey}/update POST Owner CrewUpdateHandler Process crew update
/admin/crew/{rkey}/delete POST Owner CrewDeleteHandler Delete crew member
/admin/settings GET Owner SettingsHandler Hold settings page
/admin/settings/update POST Owner SettingsUpdateHandler Update settings
/admin/api/stats GET Owner StatsAPIHandler JSON stats endpoint
/admin/api/top-users GET Owner TopUsersAPIHandler JSON top users
/admin/auth/login GET Public LoginHandler Login page
/admin/auth/oauth/authorize GET Public OAuth authorize Start OAuth flow
/admin/auth/oauth/callback GET Public CallbackHandler OAuth callback
/admin/auth/logout GET Owner LogoutHandler Logout and clear session
/admin/public/* GET Public Static files CSS, JS assets

Route Registration

func (ui *AdminUI) RegisterRoutes(r chi.Router) {
    // Public routes (login flow)
    r.Get("/admin/auth/login", ui.handleLogin)
    r.Get("/admin/auth/oauth/authorize", ui.handleAuthorize)
    r.Get("/admin/auth/oauth/callback", ui.handleCallback)

    // Static files (public)
    r.Handle("/admin/public/*", http.StripPrefix("/admin/public/", ui.staticHandler()))

    // Protected routes (require owner)
    r.Group(func(r chi.Router) {
        r.Use(ui.requireOwner)

        // Dashboard
        r.Get("/admin", ui.handleDashboard)

        // Crew management
        r.Get("/admin/crew", ui.handleCrewList)
        r.Get("/admin/crew/add", ui.handleCrewAddForm)
        r.Post("/admin/crew/add", ui.handleCrewAdd)
        r.Get("/admin/crew/{rkey}", ui.handleCrewEditForm)
        r.Post("/admin/crew/{rkey}/update", ui.handleCrewUpdate)
        r.Post("/admin/crew/{rkey}/delete", ui.handleCrewDelete)

        // Settings
        r.Get("/admin/settings", ui.handleSettings)
        r.Post("/admin/settings/update", ui.handleSettingsUpdate)

        // API endpoints (for HTMX)
        r.Get("/admin/api/stats", ui.handleStatsAPI)
        r.Get("/admin/api/top-users", ui.handleTopUsersAPI)

        // Logout
        r.Get("/admin/auth/logout", ui.handleLogout)
    })
}

Feature Implementations

Dashboard Handler

type DashboardStats struct {
    TotalCrewMembers int
    TotalBlobs       int64
    TotalStorageBytes int64
    TotalStorageHuman string
    TierDistribution map[string]int // tier -> count
    RecentActivity   []ActivityEntry
}

func (ui *AdminUI) handleDashboard(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // Collect basic stats
    crew, _ := ui.pds.ListCrewMembers(ctx)

    stats := DashboardStats{
        TotalCrewMembers: len(crew),
        TierDistribution: make(map[string]int),
    }

    // Count tier distribution
    for _, member := range crew {
        tier := member.Tier
        if tier == "" {
            tier = ui.quotaMgr.GetDefaultTier()
        }
        stats.TierDistribution[tier]++
    }

    // Storage stats (loaded via HTMX to avoid slow initial load)
    // The actual calculation happens in handleStatsAPI

    data := struct {
        AdminPageData
        Stats DashboardStats
    }{
        AdminPageData: ui.newPageData(r),
        Stats:         stats,
    }

    ui.templates.ExecuteTemplate(w, "dashboard", data)
}

Crew List Handler

type CrewMemberView struct {
    RKey         string
    DID          string
    Handle       string // Resolved from DID
    Role         string
    Permissions  []string
    Tier         string
    TierLimit    string // Human-readable
    CurrentUsage int64
    UsageHuman   string
    UsagePercent int
    Plankowner   bool
    AddedAt      time.Time
}

func (ui *AdminUI) handleCrewList(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    crew, err := ui.pds.ListCrewMembers(ctx)
    if err != nil {
        ui.renderError(w, "Failed to list crew: "+err.Error())
        return
    }

    // Enrich with usage data
    var crewViews []CrewMemberView
    for _, member := range crew {
        view := CrewMemberView{
            RKey:        member.RKey,
            DID:         member.Member,
            Role:        member.Role,
            Permissions: member.Permissions,
            Tier:        member.Tier,
            Plankowner:  member.Plankowner,
            AddedAt:     member.AddedAt,
        }

        // Get tier limit
        if limit := ui.quotaMgr.GetTierLimit(member.Tier); limit != nil {
            view.TierLimit = quota.FormatHumanBytes(*limit)
        } else {
            view.TierLimit = "Unlimited"
        }

        // Get usage (expensive - consider caching)
        usage, _, tier, limit, _ := ui.pds.GetQuotaForUserWithTier(ctx, member.Member, ui.quotaMgr)
        view.CurrentUsage = usage
        view.UsageHuman = quota.FormatHumanBytes(usage)
        if limit != nil && *limit > 0 {
            view.UsagePercent = int(float64(usage) / float64(*limit) * 100)
        }

        crewViews = append(crewViews, view)
    }

    // Sort by usage (highest first)
    sort.Slice(crewViews, func(i, j int) bool {
        return crewViews[i].CurrentUsage > crewViews[j].CurrentUsage
    })

    data := struct {
        AdminPageData
        Crew  []CrewMemberView
        Tiers []TierOption
    }{
        AdminPageData: ui.newPageData(r),
        Crew:          crewViews,
        Tiers:         ui.getTierOptions(),
    }

    ui.templates.ExecuteTemplate(w, "crew", data)
}

Add Crew Handler

func (ui *AdminUI) handleCrewAdd(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    if err := r.ParseForm(); err != nil {
        ui.setFlash(w, "error", "Invalid form data")
        http.Redirect(w, r, "/admin/crew/add", http.StatusFound)
        return
    }

    did := strings.TrimSpace(r.FormValue("did"))
    role := r.FormValue("role")
    tier := r.FormValue("tier")

    // Parse permissions checkboxes
    var permissions []string
    if r.FormValue("perm_read") == "on" {
        permissions = append(permissions, "blob:read")
    }
    if r.FormValue("perm_write") == "on" {
        permissions = append(permissions, "blob:write")
    }
    if r.FormValue("perm_admin") == "on" {
        permissions = append(permissions, "crew:admin")
    }

    // Validate DID format
    if !strings.HasPrefix(did, "did:") {
        ui.setFlash(w, "error", "Invalid DID format")
        http.Redirect(w, r, "/admin/crew/add", http.StatusFound)
        return
    }

    // Add crew member
    _, err := ui.pds.AddCrewMember(ctx, did, role, permissions)
    if err != nil {
        ui.setFlash(w, "error", "Failed to add crew member: "+err.Error())
        http.Redirect(w, r, "/admin/crew/add", http.StatusFound)
        return
    }

    // Set tier if specified
    if tier != "" && tier != ui.quotaMgr.GetDefaultTier() {
        if err := ui.pds.UpdateCrewMemberTier(ctx, did, tier); err != nil {
            slog.Warn("Failed to set tier for new crew member", "did", did, "tier", tier, "error", err)
        }
    }

    ui.setFlash(w, "success", "Crew member added successfully")
    http.Redirect(w, r, "/admin/crew", http.StatusFound)
}

Update Crew Handler

func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    rkey := chi.URLParam(r, "rkey")

    if err := r.ParseForm(); err != nil {
        ui.setFlash(w, "error", "Invalid form data")
        http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound)
        return
    }

    // Get current crew member
    current, err := ui.pds.GetCrewMemberByRKey(ctx, rkey)
    if err != nil {
        ui.setFlash(w, "error", "Crew member not found")
        http.Redirect(w, r, "/admin/crew", http.StatusFound)
        return
    }

    // Parse new values
    role := r.FormValue("role")
    tier := r.FormValue("tier")

    var permissions []string
    if r.FormValue("perm_read") == "on" {
        permissions = append(permissions, "blob:read")
    }
    if r.FormValue("perm_write") == "on" {
        permissions = append(permissions, "blob:write")
    }
    if r.FormValue("perm_admin") == "on" {
        permissions = append(permissions, "crew:admin")
    }

    // Update tier if changed
    if tier != current.Tier {
        if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil {
            ui.setFlash(w, "error", "Failed to update tier: "+err.Error())
            http.Redirect(w, r, "/admin/crew/"+rkey, 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 {
            ui.setFlash(w, "error", "Failed to update: "+err.Error())
            http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound)
            return
        }

        // Create new record with updated values
        if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions); err != nil {
            ui.setFlash(w, "error", "Failed to recreate crew record: "+err.Error())
            http.Redirect(w, r, "/admin/crew", http.StatusFound)
            return
        }

        // Re-apply tier to new record
        if tier != "" {
            ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier)
        }
    }

    ui.setFlash(w, "success", "Crew member updated successfully")
    http.Redirect(w, r, "/admin/crew", http.StatusFound)
}

Delete Crew Handler

func (ui *AdminUI) handleCrewDelete(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    rkey := chi.URLParam(r, "rkey")

    // Get crew member to log who was deleted
    member, err := ui.pds.GetCrewMemberByRKey(ctx, rkey)
    if err != nil {
        ui.setFlash(w, "error", "Crew member not found")
        http.Redirect(w, r, "/admin/crew", http.StatusFound)
        return
    }

    // Prevent deleting self (captain)
    session := getAdminSession(ctx)
    if member.Member == session.DID {
        ui.setFlash(w, "error", "Cannot remove yourself from crew")
        http.Redirect(w, r, "/admin/crew", http.StatusFound)
        return
    }

    // Delete
    if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil {
        ui.setFlash(w, "error", "Failed to remove crew member: "+err.Error())
        http.Redirect(w, r, "/admin/crew", http.StatusFound)
        return
    }

    slog.Info("Crew member removed via admin panel", "did", member.Member, "by", session.DID)

    // For HTMX requests, return empty response (row will be removed)
    if r.Header.Get("HX-Request") == "true" {
        w.WriteHeader(http.StatusOK)
        return
    }

    ui.setFlash(w, "success", "Crew member removed")
    http.Redirect(w, r, "/admin/crew", http.StatusFound)
}

Settings Handler

func (ui *AdminUI) handleSettings(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    _, captain, err := ui.pds.GetCaptainRecord(ctx)
    if err != nil {
        ui.renderError(w, "Failed to load settings: "+err.Error())
        return
    }

    data := struct {
        AdminPageData
        Settings struct {
            Public             bool
            AllowAllCrew       bool
            EnableBlueskyPosts bool
            OwnerDID           string
            HoldDID            string
        }
    }{
        AdminPageData: ui.newPageData(r),
    }
    data.Settings.Public = captain.Public
    data.Settings.AllowAllCrew = captain.AllowAllCrew
    data.Settings.EnableBlueskyPosts = captain.EnableBlueskyPosts
    data.Settings.OwnerDID = captain.Owner
    data.Settings.HoldDID = ui.pds.DID()

    ui.templates.ExecuteTemplate(w, "settings", data)
}

func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    if err := r.ParseForm(); err != nil {
        ui.setFlash(w, "error", "Invalid form data")
        http.Redirect(w, r, "/admin/settings", http.StatusFound)
        return
    }

    public := r.FormValue("public") == "on"
    allowAllCrew := r.FormValue("allow_all_crew") == "on"
    enablePosts := r.FormValue("enable_bluesky_posts") == "on"

    _, captain, _ := ui.pds.GetCaptainRecord(ctx)
    captain.Public = public
    captain.AllowAllCrew = allowAllCrew
    captain.EnableBlueskyPosts = enablePosts
    _, err := ui.pds.UpdateCaptainRecord(ctx, captain)
    if err != nil {
        ui.setFlash(w, "error", "Failed to update settings: "+err.Error())
        http.Redirect(w, r, "/admin/settings", http.StatusFound)
        return
    }

    ui.setFlash(w, "success", "Settings updated successfully")
    http.Redirect(w, r, "/admin/settings", http.StatusFound)
}

Metrics Handler (for HTMX lazy loading)

func (ui *AdminUI) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // Calculate total storage (expensive operation)
    // Iterate through all layer records
    records, _, err := ui.pds.RecordsIndex().ListRecords(atproto.LayerCollection, 100000, "", true)
    if err != nil {
        http.Error(w, "Failed to load stats", http.StatusInternalServerError)
        return
    }

    var totalSize int64
    uniqueDigests := make(map[string]bool)
    userUsage := make(map[string]int64)

    for _, record := range records {
        var layer atproto.LayerRecord
        if err := json.Unmarshal(record.Value, &layer); err != nil {
            continue
        }

        if !uniqueDigests[layer.Digest] {
            uniqueDigests[layer.Digest] = true
            totalSize += layer.Size
        }

        userUsage[layer.UserDID] += layer.Size
    }

    stats := struct {
        TotalBlobs   int    `json:"totalBlobs"`
        TotalSize    int64  `json:"totalSize"`
        TotalHuman   string `json:"totalHuman"`
    }{
        TotalBlobs: len(uniqueDigests),
        TotalSize:  totalSize,
        TotalHuman: quota.FormatHumanBytes(totalSize),
    }

    // If HTMX request, return HTML partial
    if r.Header.Get("HX-Request") == "true" {
        data := struct {
            Stats interface{}
        }{Stats: stats}
        ui.templates.ExecuteTemplate(w, "usage_stats", data)
        return
    }

    // Otherwise return JSON
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(stats)
}

Templates

Base Layout (templates/base.html)

{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ .Title }} - Hold Admin</title>
    {{ template "head" . }}
</head>
<body>
    {{ template "nav" . }}

    <main class="admin-container">
        {{ template "flash" . }}
        {{ template "content" . }}
    </main>

    <footer class="admin-footer">
        <p>Hold: {{ .HoldDID }}</p>
    </footer>
</body>
</html>
{{ end }}

Head Component (templates/components/head.html)

{{ define "head" }}
<link rel="stylesheet" href="/admin/public/css/admin.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/lucide@latest"></script>
{{ end }}

Navigation (templates/components/nav.html)

{{ define "nav" }}
<nav class="admin-nav">
    <div class="nav-brand">
        <a href="/admin">Hold Admin</a>
    </div>
    <ul class="nav-links">
        <li><a href="/admin" class="{{ if eq .ActivePage "dashboard" }}active{{ end }}">Dashboard</a></li>
        <li><a href="/admin/crew" class="{{ if eq .ActivePage "crew" }}active{{ end }}">Crew</a></li>
        <li><a href="/admin/settings" class="{{ if eq .ActivePage "settings" }}active{{ end }}">Settings</a></li>
    </ul>
    <div class="nav-user">
        <span>{{ .User.Handle }}</span>
        <a href="/admin/auth/logout">Logout</a>
    </div>
</nav>
{{ end }}

Dashboard Page (templates/pages/dashboard.html)

{{ define "dashboard" }}
{{ template "base" . }}
{{ define "content" }}
<h1>Dashboard</h1>

<div class="stats-grid">
    <div class="stat-card">
        <h3>Crew Members</h3>
        <p class="stat-value">{{ .Stats.TotalCrewMembers }}</p>
    </div>

    <div class="stat-card" hx-get="/admin/api/stats" hx-trigger="load" hx-swap="innerHTML">
        <p>Loading storage stats...</p>
    </div>
</div>

<section class="dashboard-section">
    <h2>Tier Distribution</h2>
    <div class="tier-chart">
        {{ range $tier, $count := .Stats.TierDistribution }}
        <div class="tier-bar">
            <span class="tier-name">{{ $tier }}</span>
            <span class="tier-count">{{ $count }}</span>
        </div>
        {{ end }}
    </div>
</section>

<section class="dashboard-section">
    <h2>Top Users by Storage</h2>
    <div hx-get="/admin/api/top-users?limit=10" hx-trigger="load" hx-swap="innerHTML">
        <p>Loading top users...</p>
    </div>
</section>
{{ end }}
{{ end }}

Crew List Page (templates/pages/crew.html)

{{ define "crew" }}
{{ template "base" . }}
{{ define "content" }}
<div class="page-header">
    <h1>Crew Members</h1>
    <a href="/admin/crew/add" class="btn btn-primary">Add Crew Member</a>
</div>

<table class="data-table">
    <thead>
        <tr>
            <th>DID</th>
            <th>Role</th>
            <th>Permissions</th>
            <th>Tier</th>
            <th>Usage</th>
            <th>Actions</th>
        </tr>
    </thead>
    <tbody id="crew-list">
        {{ range .Crew }}
        {{ template "crew_row" . }}
        {{ end }}
    </tbody>
</table>
{{ end }}
{{ end }}

Crew Row Partial (templates/partials/crew_row.html)

{{ define "crew_row" }}
<tr id="crew-{{ .RKey }}">
    <td>
        <code title="{{ .DID }}">{{ .DID | truncate 20 }}</code>
        {{ if .Plankowner }}<span class="badge badge-gold">Plankowner</span>{{ end }}
    </td>
    <td>{{ .Role }}</td>
    <td>
        {{ range .Permissions }}
        <span class="badge badge-perm">{{ . }}</span>
        {{ end }}
    </td>
    <td>
        <span class="badge badge-tier tier-{{ .Tier }}">{{ .Tier }}</span>
        <small>({{ .TierLimit }})</small>
    </td>
    <td>
        <div class="usage-cell">
            <span>{{ .UsageHuman }}</span>
            <div class="progress-bar">
                <div class="progress-fill {{ if gt .UsagePercent 90 }}danger{{ else if gt .UsagePercent 75 }}warning{{ end }}"
                     style="width: {{ .UsagePercent }}%"></div>
            </div>
            <small>{{ .UsagePercent }}%</small>
        </div>
    </td>
    <td>
        <a href="/admin/crew/{{ .RKey }}" class="btn btn-sm">Edit</a>
        <button class="btn btn-sm btn-danger"
                hx-post="/admin/crew/{{ .RKey }}/delete"
                hx-confirm="Are you sure you want to remove this crew member?"
                hx-target="#crew-{{ .RKey }}"
                hx-swap="outerHTML">
            Delete
        </button>
    </td>
</tr>
{{ end }}

Settings Page (templates/pages/settings.html)

{{ define "settings" }}
{{ template "base" . }}
{{ define "content" }}
<h1>Hold Settings</h1>

<form action="/admin/settings/update" method="POST" class="settings-form">
    <div class="setting-group">
        <h2>Access Control</h2>

        <label class="toggle-setting">
            <input type="checkbox" name="public" {{ if .Settings.Public }}checked{{ end }}>
            <span class="toggle-label">
                <strong>Public Hold</strong>
                <small>Allow anonymous users to read blobs (no auth required for pulls)</small>
            </span>
        </label>

        <label class="toggle-setting">
            <input type="checkbox" name="allow_all_crew" {{ if .Settings.AllowAllCrew }}checked{{ end }}>
            <span class="toggle-label">
                <strong>Open Registration</strong>
                <small>Allow any authenticated user to join as crew via requestCrew</small>
            </span>
        </label>
    </div>

    <div class="setting-group">
        <h2>Integrations</h2>

        <label class="toggle-setting">
            <input type="checkbox" name="enable_bluesky_posts" {{ if .Settings.EnableBlueskyPosts }}checked{{ end }}>
            <span class="toggle-label">
                <strong>Bluesky Posts</strong>
                <small>Post to Bluesky when images are pushed to this hold</small>
            </span>
        </label>
    </div>

    <div class="setting-group">
        <h2>Hold Information</h2>
        <dl>
            <dt>Hold DID</dt>
            <dd><code>{{ .Settings.HoldDID }}</code></dd>
            <dt>Owner DID</dt>
            <dd><code>{{ .Settings.OwnerDID }}</code></dd>
        </dl>
    </div>

    <button type="submit" class="btn btn-primary">Save Settings</button>
</form>
{{ end }}
{{ end }}

Environment Variables

Add to .env.hold.example:

# =============================================================================
# ADMIN PANEL CONFIGURATION
# =============================================================================

# Enable the admin web UI (default: false)
# When enabled, accessible at /admin
HOLD_ADMIN_ENABLED=false

# Admin session duration (default: 24h)
# How long admin sessions remain valid before requiring re-authentication
# Format: Go duration string (e.g., 24h, 168h for 1 week)
HOLD_ADMIN_SESSION_DURATION=24h

Config Struct Updates

// In pkg/hold/config.go

type Config struct {
    // ... existing fields ...

    Admin AdminConfig
}

type AdminConfig struct {
    Enabled         bool          `env:"HOLD_ADMIN_ENABLED" envDefault:"false"`
    SessionDuration time.Duration `env:"HOLD_ADMIN_SESSION_DURATION" envDefault:"24h"`
}

Security Considerations

1. Owner-Only Access

All admin routes validate that the authenticated user's DID matches captain.Owner. This check happens:

  • In the OAuth callback (primary gate)
  • In the requireOwner middleware (defense in depth)
  • Before destructive operations (extra validation)
http.SetCookie(w, &http.Cookie{
    Name:     "hold_admin_session",
    Value:    sessionID,
    Path:     "/admin",           // Scoped to admin paths only
    MaxAge:   86400,              // 24 hours
    HttpOnly: true,               // No JavaScript access
    Secure:   isHTTPS(r),         // HTTPS only in production
    SameSite: http.SameSiteLaxMode, // CSRF protection
})

3. CSRF Protection

For state-changing operations:

  • Forms include hidden CSRF token
  • HTMX requests include token in header
  • Server validates token before processing
<form action="/admin/crew/add" method="POST">
    <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
    ...
</form>

4. Input Validation

  • DID format validation before database operations
  • Tier names validated against quotas.yaml
  • Permission values validated against known set
  • All user input sanitized before display

5. Rate Limiting

Consider adding rate limiting for:

  • Login attempts (prevent brute force)
  • OAuth flow starts (prevent abuse)
  • API endpoints (prevent DoS)

6. Audit Logging

Log all administrative actions:

slog.Info("Admin action",
    "action", "crew_add",
    "admin_did", session.DID,
    "target_did", newMemberDID,
    "permissions", permissions)

Implementation Phases

Phase 1: Foundation (Est. 4-6 hours)

  1. Create pkg/hold/admin/ package structure
  2. Implement AdminSessionStore with SQLite
  3. Implement OAuth client setup (reuse pkg/auth/oauth/)
  4. Implement requireOwner middleware
  5. Create basic template loading with embed.FS
  6. Add env var configuration to pkg/hold/config.go

Deliverables:

  • Admin package compiles
  • Can start OAuth flow
  • Session store creates/validates sessions

Phase 2: Authentication (Est. 3-4 hours)

  1. Implement login page handler
  2. Implement OAuth authorize redirect
  3. Implement callback with owner validation
  4. Implement logout handler
  5. Wire up routes in cmd/hold/main.go

Deliverables:

  • Can login as hold owner
  • Non-owners rejected at callback
  • Sessions persist across requests

Phase 3: Dashboard (Est. 3-4 hours)

  1. Create base template and navigation
  2. Implement dashboard handler with basic stats
  3. Implement stats API for HTMX lazy loading
  4. Implement top users API
  5. Create dashboard template

Deliverables:

  • Dashboard shows crew count, tier distribution
  • Storage stats load asynchronously
  • Top users table displays

Phase 4: Crew Management (Est. 4-6 hours)

  1. Implement crew list handler
  2. Create crew list template with HTMX delete
  3. Implement add crew form and handler
  4. Implement edit crew form and handler
  5. Implement delete crew handler

Deliverables:

  • Full CRUD for crew members
  • Tier and permission editing works
  • HTMX updates without page reload

Phase 5: Settings (Est. 2-3 hours)

  1. Implement settings handler
  2. Create settings template
  3. Implement settings update handler

Deliverables:

  • Can toggle public/allowAllCrew/enableBlueskyPosts
  • Settings persist correctly

Phase 6: Polish (Est. 2-4 hours)

  1. Add CSS styling
  2. Add flash messages
  3. Add CSRF protection
  4. Add input validation
  5. Add audit logging
  6. Update documentation

Deliverables:

  • Professional-looking UI
  • Security hardening complete
  • Documentation updated

Total Estimated Time: 18-27 hours


Testing Strategy

Unit Tests

// pkg/hold/admin/session_test.go
func TestSessionStore_Create(t *testing.T) {
    store := newTestSessionStore(t)

    sessionID, err := store.Create("did:plc:test", "test.handle", 24*time.Hour)
    require.NoError(t, err)
    require.NotEmpty(t, sessionID)

    session, err := store.Get(sessionID)
    require.NoError(t, err)
    assert.Equal(t, "did:plc:test", session.DID)
}

// pkg/hold/admin/auth_test.go
func TestRequireOwner_RejectsNonOwner(t *testing.T) {
    pds := setupTestPDSWithOwner(t, "did:plc:owner")
    store := newTestSessionStore(t)

    // Create session for non-owner
    sessionID, _ := store.Create("did:plc:notowner", "notowner", 24*time.Hour)

    middleware := requireOwner(pds, store)
    handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    }))

    req := httptest.NewRequest("GET", "/admin", nil)
    req.AddCookie(&http.Cookie{Name: "hold_admin_session", Value: sessionID})
    w := httptest.NewRecorder()

    handler.ServeHTTP(w, req)

    assert.Equal(t, http.StatusForbidden, w.Code)
}

Integration Tests

// pkg/hold/admin/integration_test.go
func TestAdminLoginFlow(t *testing.T) {
    // Start test hold server
    server := startTestHoldWithAdmin(t)
    defer server.Close()

    // Verify login page accessible
    resp, _ := http.Get(server.URL + "/admin/auth/login")
    assert.Equal(t, http.StatusOK, resp.StatusCode)

    // Verify dashboard redirects to login
    client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
        return http.ErrUseLastResponse
    }}
    resp, _ = client.Get(server.URL + "/admin")
    assert.Equal(t, http.StatusFound, resp.StatusCode)
    assert.Contains(t, resp.Header.Get("Location"), "/admin/auth/login")
}

Manual Testing Checklist

  • Login as owner succeeds
  • Login as non-owner fails with clear error
  • Dashboard loads with correct stats
  • Add crew member with all permission combinations
  • Edit crew member permissions
  • Change crew member tier
  • Delete crew member
  • Toggle public setting
  • Toggle allowAllCrew setting
  • Toggle enableBlueskyPosts setting
  • Logout clears session
  • Session expires after configured duration
  • Expired session redirects to login

Future Enhancements

Potential Future Features

  1. Crew Invite Links - Generate one-time invite URLs for adding crew
  2. Usage Alerts - Email/webhook when users approach quota
  3. Bulk Operations - Add/remove multiple crew members at once
  4. Export Data - Download crew list, usage reports as CSV
  5. Activity Log - View recent admin actions
  6. API Keys - Generate programmatic access keys for admin API
  7. Backup/Restore - Backup crew records, restore from backup
  8. Multi-Hold Management - Manage multiple holds from one UI (separate feature)

Performance Optimizations

  1. Cache usage stats - Don't recalculate on every request
  2. Paginate crew list - Handle holds with 1000+ crew members
  3. Background stat refresh - Update stats periodically in background
  4. Batch DID resolution - Resolve multiple DIDs in parallel

References