← repoman internals

internal/server/handlers

import "git.griefed.de/griefed/repoman/internal/server/handlers"
package handlers // import "git.griefed.de/griefed/repoman/internal/server/handlers"

Package handlers contains repoman's HTTP handlers. They are thin glue between
the auth/migrate/inventory packages and the templated views.

Templates: this skeleton uses html/template directly so the project builds
today. Final implementation switches to `templ` (which compiles templates to
Go code at build time) for type-safe templates with less runtime overhead.
The handler signatures stay the same; only the internal rendering changes.

FUNCTIONS

func MustChangePasswordMiddleware(next http.Handler) http.Handler
    MustChangePasswordMiddleware forces a user whose MustChangePassword
    flag is set to the password-change page before they can reach any other
    authenticated route. The change-password page itself, logout, and the 2FA
    step are allowed through so the user can comply or escape.

    It must be wired AFTER the Auth middleware so UserFromContext is populated;
    anonymous requests (user == nil) pass straight through.

func NotificationCountMiddleware(count func(context.Context) int) func(http.Handler) http.Handler
    NotificationCountMiddleware injects a lazy unread-count provider, resolved
    only when an authenticated page renders (NewBaseData). Static/SSE requests
    pay nothing. The provider reads the user from context itself.

func RenderPage(w http.ResponseWriter, r *http.Request, name string, data any)
    RenderPage renders a full page by name (e.g. "configs/form.gohtml").
    The named template is looked up in pageTemplates — the set parsed once
    at startup from the embedded files (see embed.go), combining the page's
    "title"/"content" define blocks with the shared partials and the base layout
    — and executed as "page". An unknown name is a programmer error (a typo'd
    call site or a missing file) and yields a 500.

func RestorePendingMiddleware(dataDir string) func(http.Handler) http.Handler
    RestorePendingMiddleware injects a lazy checker reporting whether a database
    restore is staged under dataDir (the restore-pending dir). It is lazy —
    the filesystem is stat'd only when a page actually reads it (NewBaseData,
    for an admin) — so static-asset and SSE requests pay nothing. The check is
    cheap (one os.Stat) and always reflects the current on-disk state, which
    clears itself on the next boot when ApplyPendingRestore consumes the dir.

func StaticFS() fs.FS
    StaticFS returns the embedded static-asset filesystem. The router serves it
    under /static/ via http.FileServerFS.

func TokenExpiryNoticesMiddleware(provider func(context.Context) []TokenExpiryNotice) func(http.Handler) http.Handler
    TokenExpiryNoticesMiddleware injects a lazy provider of expiring-token
    notices (unfiltered by viewer). It is lazy like RestorePendingMiddleware —
    the provider queries the DB only when an authenticated page actually reads
    it (NewBaseData), so static-asset and SSE requests pay nothing. Per-viewer
    filtering happens in NewBaseData, which keeps only the notices the viewer is
    permitted to edit.


TYPES

type AuthHandler struct {
	Authenticator *auth.Authenticator
	Sessions      *auth.SessionManager
	Users         auth.UserRepository
	ResetService  *auth.PasswordResetService

	// EmailSender may be a no-op (IsConfigured()=false) when SMTP is
	// disabled. Handlers must check IsConfigured before assuming a
	// reset link can be delivered.
	EmailSender EmailSender

	// BaseURL is the externally-visible URL of the repoman instance,
	// used to construct absolute reset links in emails. Without a
	// trailing slash.
	BaseURL string

	// SecureCookies controls the Secure cookie flag. False is only
	// permitted for localhost dev.
	SecureCookies bool

	// PostLoginPath is where successful logins are sent when no `next`
	// query parameter is provided. Default "/".
	PostLoginPath string

	// Settings provides current auth settings (token validity, etc.)
	// at request time. Function indirection lets reconfiguration take
	// effect without restarts.
	Settings func() auth.Settings

	// Audit records privileged actions; nil disables audit logging.
	Audit *audit.Logger
}
    AuthHandler bundles the dependencies for /auth/* endpoints.

    The fields are exported so the cmd/repoman wiring can build the
    handler with concrete dependencies; all the methods on it are
    http.HandlerFunc-compatible.

func (h *AuthHandler) ForgotPasswordGET(w http.ResponseWriter, r *http.Request)
    ForgotPasswordGET renders the email-entry form. Available only when SMTP is
    configured; the route is omitted from the router otherwise so this handler
    should not normally be reached in disabled mode.

func (h *AuthHandler) ForgotPasswordPOST(w http.ResponseWriter, r *http.Request)
    ForgotPasswordPOST issues a reset token (silently — no confirmation that the
    email was found), sends the email, and renders a generic acknowledgement.
    The same acknowledgement is shown for unknown emails to prevent enumeration.

func (h *AuthHandler) KeepAlivePOST(w http.ResponseWriter, r *http.Request)
    KeepAlivePOST records in-page user activity on the current session,
    resetting the server-side idle clock (POST /auth/keepalive). The client
    posts it on throttled genuine interaction (mousemove/keydown/scroll/…) so
    a user actively working on a single page — e.g. watching a run monitor — is
    not idled out, while a backgrounded/idle tab sends nothing and still times
    out. It sits behind the auth middleware, so an already-expired session gets
    the normal 303-to-login instead of reaching here. Responds 204 with no body.

func (h *AuthHandler) LoginGET(w http.ResponseWriter, r *http.Request)
    LoginGET renders the login form for unauthenticated GET requests.

func (h *AuthHandler) LoginPOST(w http.ResponseWriter, r *http.Request)
    LoginPOST processes a submitted login form.

    Flow:
     1. Parse form (username, password, next-URL)
     2. Authenticator.Authenticate (handles all the lockout magic)
     3. On success: set session cookie, redirect to /auth/2fa or post-login
     4. On failure: re-render login with generic "Invalid credentials"

func (h *AuthHandler) LogoutPOST(w http.ResponseWriter, r *http.Request)
    LogoutPOST destroys the current session and redirects to the login page with
    a "Logged out" flash.

func (h *AuthHandler) ResetGET(w http.ResponseWriter, r *http.Request)
    ResetGET renders the new-password form for a reset link. Both the token and
    the token-id are read from query parameters per the project decision to use
    the query-variant URL.

func (h *AuthHandler) ResetPOST(w http.ResponseWriter, r *http.Request)
    ResetPOST consumes a reset token and sets the new password.

    Failure paths surface different messages:
      - Bad token: "Invalid or expired reset link."
      - Weak password: the actual policy violation
      - Internal error: 500 with no detail

type BaseData struct {
	// Nav/layout
	Username string
	// DisplayName is the user's chosen display name shown in the nav; empty
	// falls back to Username (which stays the immutable login identifier).
	DisplayName string
	Role        auth.Role

	// Auto-logout countdown. The JS timer counts down from this value
	// in seconds and posts to /auth/logout on expiry. Resets on every
	// page load (JS re-initialises from this value each time).
	AutoLogoutSeconds int

	// Themes populates the nav theme dropdown: "auto", the built-in
	// themes, then the instance's custom themes. The selected entry is
	// client-side state (localStorage), so no option is marked here.
	Themes []ThemeOption

	// Timezone is the signed-in user's IANA timezone ("" = UTC), passed to
	// the localTime template func so timestamps render as "local (UTC)".
	Timezone string

	// RestorePending is true (admins only) when a database restore is staged and
	// awaiting a restart, so the nav can show a persistent reminder. Set only for
	// admins, since only they can act on it; clears on the next boot when the
	// restore is applied.
	RestorePending bool

	// UnreadNotifications is the viewer's unread in-app notification count, shown
	// as a nav badge. 0 hides the badge.
	UnreadNotifications int

	// TokenExpiryNotices are the expiring-access-token reminders this viewer is
	// permitted to see — only tokens whose editing permission the viewer holds
	// (global → settings admins, config → config editors), so a warning is never
	// shown to someone who cannot act on it. Empty hides the banner.
	TokenExpiryNotices []TokenExpiryNotice

	// Flash message for one-time user feedback.
	Flash     string
	FlashKind string // "error" | "success" | "info"
}
    BaseData is the common data available to every authenticated page template.
    It is embedded in page-specific data structs or passed directly when no
    extra fields are needed.

func NewBaseData(r *http.Request, settings auth.Settings) BaseData
    NewBaseData builds a BaseData from the current request context. settings
    is read to obtain the auto-logout timeout. For authenticated users it
    also resolves the theme dropdown options, invoking the lazily injected
    custom-theme lister (see ThemeHandler.OptionsMiddleware) — the DB is only
    queried when a page actually renders.

func (bd BaseData) WithError(msg string) BaseData
    WithError is shorthand for WithFlash("error", msg).

func (bd BaseData) WithFlash(kind, msg string) BaseData
    WithFlash returns a copy of bd with a flash message attached.

func (bd BaseData) WithSuccess(msg string) BaseData
    WithSuccess is shorthand for WithFlash("success", msg).

type ChangeHistoryStore interface {
	Record(ctx context.Context, entityType string, entityID int64, snapshotJSON, comment string, userID *int64, username string) error
	List(ctx context.Context, entityType string, entityID int64, limit int) ([]*db.ChangeHistoryEntry, error)
}
    ChangeHistoryStore records and lists change-history snapshots for an entity.
    db.ChangeHistoryRepo satisfies it; kept handler-local so the profile/theme
    handlers depend on the capability, not the repo.

type ConfigHandler struct {
	Service  *migrationconfig.Service
	Versions migrationconfig.VersionRepository
	Settings func() auth.Settings
	Audit    *audit.Logger // optional; nil disables audit logging

	// PlatformTokens is the config owner's account token store. On save, a token
	// entered for a source is written here (keyed by platform+instance) and blanked
	// on the source, so the config persists no token value — the reference model's
	// single store of token values. It also feeds the source-row token dropdown.
	// Nil leaves tokens on the source as a legacy fallback.
	PlatformTokens PlatformTokenStore

	// OwnerAssigner reassigns a config's owning user — the admin claim path for an
	// orphaned (ownerless) config. Nil hides the owner panel on the edit form and
	// disables POST /configs/{id}/owner.
	OwnerAssigner ConfigOwnerAssigner

	// UserLister populates the admin owner-assignment dropdown. Nil hides the panel.
	UserLister ConfigUserLister
}
    ConfigHandler handles /configs/* routes. Operators with PermManageConfigs
    can create, edit and delete. Operators with PermViewConfigs can list and
    view history.

func (h *ConfigHandler) AssignOwnerPOST(w http.ResponseWriter, r *http.Request)
    AssignOwnerPOST reassigns a config's owner from the admin owner panel (POST
    /configs/{id}/owner, admin-only via PermManageUsers). It is deliberately
    separate from the main save so it never enters the conflict-resolution flow:
    claiming an orphaned config is a metadata change, not a content edit.
    A 404 is returned for an unknown config so existence never leaks beyond the
    route gate.

func (h *ConfigHandler) CreatePOST(w http.ResponseWriter, r *http.Request)
    CreatePOST validates the submitted form and creates a new config,
    re-rendering the form with conflicts or errors when the save is blocked
    (POST /configs).

func (h *ConfigHandler) DeletePOST(w http.ResponseWriter, r *http.Request)
    DeletePOST removes a config and redirects to the list (POST
    /configs/{id}/delete).

func (h *ConfigHandler) ListGET(w http.ResponseWriter, r *http.Request)
    ListGET renders the list of all migration configs (GET /configs).

func (h *ConfigHandler) NewGET(w http.ResponseWriter, r *http.Request)
    NewGET renders the empty config creation form (GET /configs/new).

func (h *ConfigHandler) ShowGET(w http.ResponseWriter, r *http.Request)
    ShowGET renders the edit form for an existing config (GET /configs/{id}).

func (h *ConfigHandler) UpdatePOST(w http.ResponseWriter, r *http.Request)
    UpdatePOST validates the submitted form and saves changes to an existing
    config, re-rendering with conflicts when the save is blocked (POST
    /configs/{id}).

func (h *ConfigHandler) VersionsGET(w http.ResponseWriter, r *http.Request)
    VersionsGET renders the saved-version history for a config (GET
    /configs/{id}/versions).

type ConfigLogsHandler struct {
	Configs migrationconfig.Repository
	// RunLogDir is the directory the worker writes per-config run logs into
	// (<log-dir>/runs). Empty disables the download.
	RunLogDir string
	Audit     *audit.Logger
}
    ConfigLogsHandler serves a config's per-run log files as one .zip, so a
    config that is misbehaving can be diagnosed from a single download instead
    of by shell access to the log directory.

    POST-only, matching the repo and DB-snapshot downloads: the same-origin CSRF
    check then guards the contents, which name every repository a config touches
    and carry whatever a source API said when it failed.

func (h *ConfigLogsHandler) DownloadPOST(w http.ResponseWriter, r *http.Request)
    DownloadPOST streams every run-log file belonging to one config as a .zip
    (POST /configs/{id}/logs/download). The archive is written straight to the
    response — these are plain text files already on local disk, so there is
    nothing to build first and nothing worth buffering.

type ConfigOwnerAssigner interface {
	AssignOwner(ctx context.Context, configID, ownerUserID int64) error
}
    ConfigOwnerAssigner reassigns a config's owning user. It is the claim
    mechanism for an orphaned config: the owner is the account whose saved
    source tokens a run authenticates with, so an unclaimed config can't resolve
    any token. Concrete implementation: *db.MigrationConfigRepo.

type ConfigUserLister interface {
	ListUsers(ctx context.Context) ([]*auth.User, error)
}
    ConfigUserLister lists users to populate the admin owner-assignment
    dropdown. Concrete implementation: *db.UserRepo (ListUsers).

type DashboardHandler struct {
	Configs  migrationconfig.Repository
	Runs     dashboardRunLister
	Queue    run.QueueRepository
	Worker   *migrate.Worker
	Repos    *db.RepositoryRepo
	Settings func() auth.Settings
	Now      func() time.Time
}
    DashboardHandler renders the main "/" page.

func (h *DashboardHandler) DashboardGET(w http.ResponseWriter, r *http.Request)
    DashboardGET renders the real dashboard.

type DiffSection struct {
	Title   string
	Changes []FieldChange
}
    DiffSection groups field changes under a heading matching the section
    in the originating view (e.g. the settings form's "Email / SMTP" card).
    Only sections with at least one change are kept.

type DryRunQueuer interface {
	Enqueue(ctx context.Context, entry *run.QueueEntry) error
}
    DryRunQueuer is the slice of the worker this wizard needs: queue one run.
    A handler-local interface (like PlatformTokenStore) so the wizard depends on
    the capability rather than on *migrate.Worker — which is also what makes the
    queueing half of step 2 testable without standing up a whole worker.

type EmailSender interface {
	IsConfigured() bool
	SendPasswordReset(ctx context.Context, toAddress, username, resetURL string, validity time.Duration) error
}
    EmailSender is the subset of the email package's Sender interface that
    handlers need. Defined locally so handlers can be tested with a stub that
    does not import the real SMTP plumbing.

type FieldChange struct {
	Label     string
	Old       string
	New       string
	Sensitive bool
}
    FieldChange is one changed field within a history entry: a human-readable
    label and the old/new display values. For sensitive fields (tokens,
    passwords) Old/New are non-revealing placeholders ("set"/"not set") and
    Sensitive is true, so the actual secret is never rendered.

type FirstRunHandler struct {
	Service  *migrationconfig.Service
	Settings func() auth.Settings
	Audit    *audit.Logger // optional; nil disables audit logging

	// Runs and Worker back step 2's dry run. Both optional: with either unwired
	// the step still renders and can be skipped, so the wizard never becomes
	// impassable because run plumbing is missing.
	Runs   run.Repository
	Worker DryRunQueuer
	// Queue reports runs that are waiting for the worker. Without it a run that
	// has been queued but not yet started is indistinguishable from no run at
	// all, so the step invites a second click.
	Queue run.QueueRepository
}
    FirstRunHandler owns the namespace-confirmation wizard. The wizard must be
    completed before a real (non-dry) run may fire.

    Flow:

        GET  /configs/{id}/confirm          → Step 1: preview & explain
        GET  /configs/{id}/confirm/dry-run  → Step 2: run a real dry run and watch it
        POST /configs/{id}/confirm/dry-run  → start that dry run
        GET  /configs/{id}/confirm/lock     → Step 3: typed-name lock
        POST /configs/{id}/confirm          → complete the wizard
        (redirect) → /configs/{id}?flash=…  → done

    The wizard is only reachable for configs that are NOT yet confirmed
    (IsConfirmed() == false). Arriving at Step 1 for an already-confirmed config
    redirects back to the config page with an info message.

func (h *FirstRunHandler) ConfirmGET(w http.ResponseWriter, r *http.Request)
    ConfirmGET renders the first step of the wizard: a summary of what the
    config will do and what "confirming" locks in.

func (h *FirstRunHandler) ConfirmPOST(w http.ResponseWriter, r *http.Request)
    ConfirmPOST completes the wizard: the operator must type the config
    name exactly (mirroring GitHub's "type the repo name to delete" UX)
    before the namespace is confirmed and real runs are unlocked (POST
    /configs/{id}/confirm).

func (h *FirstRunHandler) DryRunGET(w http.ResponseWriter, r *http.Request)
    DryRunGET renders step 2: a real dry run over the new config, so the
    operator sees what it would actually do before locking the namespace in.

    The wizard's first step analyses the config; this one EXERCISES it.
    A namespace typo or a token that resolves to nothing shows up here as a
    failed listing rather than as a surprise on the first real run.

func (h *FirstRunHandler) DryRunPOST(w http.ResponseWriter, r *http.Request)
    DryRunPOST queues the wizard's dry run and returns to step 2 to watch it.

    Always a dry run, whatever the config's own DryRun setting says: the point
    is to preview an unconfirmed config, and a real run on one is refused
    elsewhere anyway.

    The form field "only_if_missing" marks the automatic start that carries the
    operator from step 1 into step 2: a config that has never been run gets its
    dry run without having to ask for it, while one that already has a run (or
    one queued) is carried straight through instead of queueing a duplicate.
    That path also stays silent when runs are unavailable — the step is
    skippable, so merely continuing must not look like a failure.

func (h *FirstRunHandler) DryRunStatusJSON(w http.ResponseWriter, r *http.Request)
    DryRunStatusJSON reports the wizard dry run's progress as JSON (GET
    /configs/{id}/confirm/dry-run/status), so the step can refresh itself
    without reloading the page.

    It exists specifically so the page does NOT reload on a timer. A reload is a
    top-level navigation, and the auth middleware counts navigations as genuine
    user activity — so a self-reloading page keeps its session alive forever,
    reinstating exactly the idle-logout hole that background traffic was made
    non-navigational to close. A fetch is not a navigation, so polling here
    cannot slide the session.

func (h *FirstRunHandler) LockGET(w http.ResponseWriter, r *http.Request)
    LockGET renders step 3 of the wizard: the typed-name confirmation form.
    Reachable via GET /configs/{id}/confirm/lock from the forward button on step
    2 (the dry run), and re-rendered by ConfirmPOST on a name mismatch.

func (h *FirstRunHandler) RequireConfirmed(next http.Handler) http.Handler
    RequireConfirmed is middleware for future run-trigger routes. If the
    requested config is not yet confirmed it redirects the operator to the
    confirmation wizard instead of allowing the run. The configID is read from
    the chi URL param "id".

type ForgejoVersioner interface {
	ServerVersion(ctx context.Context) (string, error)
}
    ForgejoVersioner is the slice of *forgejo.Client the Forgejo probe uses.

type GlobalSettingsHandler struct {
	Service  *globalconfig.Service
	Settings func() auth.Settings

	// DB is the live database connection, used by BackupPOST to run
	// VACUUM INTO for the downloadable snapshot. Nil disables the backup
	// endpoint (BackupPOST 500s), which is acceptable in tests that do
	// not exercise it.
	DB *sql.DB
	// DataDir is the directory the transient snapshot file is written to
	// before streaming — the service-owned data directory, same
	// filesystem as the live DB, so the copy never lands in a
	// world-readable temp dir. Empty falls back to the system temp dir.
	DataDir string
	// BackupDir is the directory holding scheduled/stored backups, listed and
	// served inline in the settings page's backups section (backups.go).
	BackupDir string
	// Audit records the backup_created event. Nil-safe (no-op).
	Audit *audit.Logger

	// Themes reads the custom + pending themes for the settings page's themes
	// section. Optional; nil hides the section.
	Themes themeSectionStore
}
    GlobalSettingsHandler handles /settings routes. Requires PermManageSettings
    (admin only).

func (h *GlobalSettingsHandler) BackupDeletePOST(w http.ResponseWriter, r *http.Request)
    BackupDeletePOST removes a stored backup (POST /settings/backups/delete,
    form field "name").

func (h *GlobalSettingsHandler) BackupDownloadPOST(w http.ResponseWriter, r *http.Request)
    BackupDownloadPOST streams a stored backup as an attachment (POST
    /settings/backups/download, form field "name"). POST so the same-origin CSRF
    check guards the (whole-database) download, mirroring BackupPOST.

func (h *GlobalSettingsHandler) BackupNowPOST(w http.ResponseWriter, r *http.Request)
    BackupNowPOST creates a stored backup immediately and prunes under the
    current retention policy (POST /settings/backups/run).

func (h *GlobalSettingsHandler) BackupPOST(w http.ResponseWriter, r *http.Request)
    BackupPOST streams a consistent snapshot of the SQLite database to the
    client as a file download (POST /settings/backup). It snapshots to a
    transient file in DataDir via internal/backup, streams that file with an
    attachment disposition, then removes it.

    POST (not GET) so the same-origin CSRF check applies: the snapshot is the
    entire database, so it must not be fetchable cross-site. All fallible work
    happens before any response body is written, so an early failure can still
    redirect back to /settings with a flash; once streaming starts the status
    line is committed and errors are only logged.

func (h *GlobalSettingsHandler) BackupSchedulePOST(w http.ResponseWriter, r *http.Request)
    BackupSchedulePOST saves the backup schedule/retention config, preserving
    the rest of global settings (POST /settings/backups). On validation failure
    it redirects back with the error flash.

func (h *GlobalSettingsHandler) BackupsGET(w http.ResponseWriter, r *http.Request)
    BackupsGET redirects to the consolidated settings page's backups section
    (GET /settings/backups) — scheduled-backup management moved there. Kept as a
    redirect so existing bookmarks/links still resolve.

func (h *GlobalSettingsHandler) RestoreCancelPOST(w http.ResponseWriter, r *http.Request)
    RestoreCancelPOST discards a staged or armed restore (POST
    /settings/restore/cancel).

func (h *GlobalSettingsHandler) RestoreConfirmPOST(w http.ResponseWriter, r *http.Request)
    RestoreConfirmPOST arms the staged restore after a typed confirmation:
    it atomically renames the staging dir to the pending dir, which the daemon
    applies on its next start (POST /settings/restore/confirm).

func (h *GlobalSettingsHandler) RestoreGET(w http.ResponseWriter, r *http.Request)
    RestoreGET renders the restore page: the upload form, or — when a restore is
    already armed — instructions to restart (GET /settings/restore).

func (h *GlobalSettingsHandler) RestoreUploadPOST(w http.ResponseWriter, r *http.Request)
    RestoreUploadPOST receives an uploaded backup (and optional pepper),
    stages it, validates it, and renders the preview/confirm page. Nothing
    is applied here — staging is inert until RestoreConfirmPOST arms it (POST
    /settings/restore/upload).

func (h *GlobalSettingsHandler) SettingsGET(w http.ResponseWriter, r *http.Request)
    SettingsGET renders the settings form with the current configuration and the
    ten most recent saves (GET /settings).

func (h *GlobalSettingsHandler) SettingsPOST(w http.ResponseWriter, r *http.Request)
    SettingsPOST validates and persists the submitted settings form, preserving
    existing secrets when their fields are left blank (POST /settings).

type HelpHandler struct {
	Manual   *help.Manual
	Settings func() auth.Settings
}
    HelpHandler serves repoman's in-app user manual: a grouped index and
    one page per topic. The content is the pre-rendered Markdown held in the
    help.Manual (loaded once at startup), so these handlers only assemble view
    models — they never render Markdown per request. Help text is not secret,
    so every topic is readable by any signed-in user; admin-only topics carry a
    badge, not a gate.

func (h *HelpHandler) IndexGET(w http.ResponseWriter, r *http.Request)
    IndexGET renders the help landing page: every topic grouped by area,
    in the canonical display order (GET /help).

func (h *HelpHandler) TopicGET(w http.ResponseWriter, r *http.Request)
    TopicGET renders one help topic by slug, with the grouped index alongside as
    a sidebar (GET /help/{slug}). An unknown slug is a 404 — the slug space is
    the fixed topic registry, so a miss is a bad link, not a server error.

type InventoryHandler struct {
	Repos    *db.RepositoryRepo
	Configs  migrationconfig.Repository // for owner-scoping rows to the viewer
	Settings func() auth.Settings
}
    InventoryHandler renders /inventory.

func (h *InventoryHandler) InventoryGET(w http.ResponseWriter, r *http.Request)
    InventoryGET renders the /inventory page listing all tracked repos.

type IssueWebhookHandler struct {
	// Deriver recomputes the per-config webhook secret the hook was provisioned
	// with, so deliveries can be verified without storing a secret.
	Deriver *crypto.WebhookSecretDeriver
	// Configs loads the migration config (its sources + tokens) addressed by the
	// {configID} path segment.
	Configs migrationconfig.Repository
	// Links is the issue/comment mapping store.
	Links *db.IssueSyncRepo
	// Global returns the live global settings (Forgejo connection). The sync
	// engine is built per request from these so it always targets the currently
	// configured Forgejo instance.
	Global func(ctx context.Context) (*globalconfig.Settings, error)
	// OwnerTokens loads a config owner's personal source tokens, the fallback in
	// source-token resolution (per-source → config override → owner token). Nil or
	// an ownerless config means no owner fallback.
	OwnerTokens func(ctx context.Context, userID int64) (map[string]string, error)
}
    IssueWebhookHandler ingests issue-hub webhooks. It is anonymous to the
    auth middleware and exempt from the same-origin CSRF check (the senders
    are machines, not browsers) — every request is instead gated by a signature
    verified against the per-config secret derived from the pepper. The Forgejo
    endpoint propagates hub edits OUT to each linked source via the sync engine.
    See docs/issue-hub.md.

func (h *IssueWebhookHandler) ForgejoPOST(w http.ResponseWriter, r *http.Request)
    ForgejoPOST handles POST /webhooks/forgejo/{configID}. It verifies the
    Forgejo HMAC signature, parses the issue/issue_comment event, and pushes the
    hub edit out to every source the issue is linked to. Always acks with 200
    once the signature is valid (logging per-link failures) so Forgejo does not
    retry-storm; the reconcile poll is the correctness backstop. A bad signature
    is 401, a malformed request 400.

func (h *IssueWebhookHandler) SourcePOST(w http.ResponseWriter, r *http.Request)
    SourcePOST handles POST /webhooks/source/{configID}/{platform}. It verifies
    the platform's signature (GitHub/Gitea HMAC, GitLab token), parses the
    issue/comment event, resolves the single link for that source issue,
    and pulls the edit into the Forgejo hub. Like ForgejoPOST it acks 200 once
    verified; bad signature 401, malformed 400. The repoman-write echo (our own
    API write to the source firing a webhook back) is broken by the baseline
    check inside the engine.

type NotificationStore interface {
	ListForUser(ctx context.Context, userID int64, limit int) ([]*db.Notification, error)
	UnreadCount(ctx context.Context, userID int64) (int, error)
	MarkRead(ctx context.Context, userID, id int64) error
	MarkAllRead(ctx context.Context, userID int64) error
}
    NotificationStore is the subset of db.NotificationRepo the handler needs.

type NotificationsHandler struct {
	Store    NotificationStore
	Settings func() auth.Settings
}
    NotificationsHandler serves the per-user in-app notification feed.

func (h *NotificationsHandler) ListGET(w http.ResponseWriter, r *http.Request)
    ListGET renders the signed-in user's notifications, newest first (GET
    /notifications).

func (h *NotificationsHandler) MarkAllReadPOST(w http.ResponseWriter, r *http.Request)
    MarkAllReadPOST marks all of the user's notifications read (POST
    /notifications/read-all).

func (h *NotificationsHandler) MarkReadPOST(w http.ResponseWriter, r *http.Request)
    MarkReadPOST marks one of the user's notifications read (POST
    /notifications/{id}/read).

type PersonalThemeLister interface {
	ListPersonalThemes(ctx context.Context, ownerUserID int64) ([]theme.Theme, error)
}
    PersonalThemeLister reads a user's personal themes for the profile page's
    "My themes" section. db.ThemeRepo satisfies it; kept handler-local so the
    profile page depends on the capability, not the whole theme store.

type PlatformTokenStore interface {
	List(ctx context.Context, userID int64) ([]db.UserPlatformToken, error)
	Set(ctx context.Context, userID int64, platform, instanceURL, namespace, ownerType, token string) error
	Delete(ctx context.Context, userID int64, platform, instanceURL, namespace string) error
}
    PlatformTokenStore is the subset of db.UserPlatformTokenRepo the profile
    page needs: list a user's saved (platform, instance, namespace, token)
    records and add/update or delete one by (platform, instance, namespace).
    Kept as a handler-local interface so handlers depends on the capability,
    not the repo. This is the reference model's single store of token values — a
    config source resolves its token from here.

type ProfileHandler struct {
	Users    auth.UserAdminRepository // GetByID, UpdatePasswordHash, ClearTOTP
	TOTPRepo auth.TOTPUserRepository  // SetTOTP (via CompleteEnrollment)
	Sessions *auth.SessionManager     // DeleteAllForUserExcept after pw change
	Pepper   auth.Pepper              // VerifyPassword / HashPassword
	SecLog   *security.Logger         // optional; nil disables security logging
	Audit    *audit.Logger            // optional; nil disables audit logging
	Settings func() auth.Settings

	// PlatformTokens stores this user's personal source access tokens (the
	// multi-tenant per-user credentials). Optional; nil hides the tokens section.
	PlatformTokens PlatformTokenStore

	// PersonalThemes lists the signed-in user's personal themes for the
	// profile page's "My themes" section. Optional; nil hides the section.
	// (Theme create/edit/import/submit still live under /profile/themes,
	// served by ThemeHandler — this is only the read for the profile page.)
	PersonalThemes PersonalThemeLister

	// History records and lists profile change history. Optional; nil disables
	// both recording and the "Change history" section.
	History ChangeHistoryStore
}
    ProfileHandler serves the authenticated user's self-service profile —
    a single consolidated page (GET /profile) carrying two-factor status,
    password change, timezone, source access tokens, and personal themes.
    Every action operates on the user from the request context (set by the Auth
    middleware) — there is no cross-user access here.

func (h *ProfileHandler) PasswordChangeGET(w http.ResponseWriter, r *http.Request)
    PasswordChangeGET renders the password-change form (GET /profile/password).

func (h *ProfileHandler) PasswordChangePOST(w http.ResponseWriter, r *http.Request)
    PasswordChangePOST validates and applies a password change, then revokes
    every other session while keeping the current device logged in (POST
    /profile/password).

func (h *ProfileHandler) PlatformTokenDeletePOST(w http.ResponseWriter, r *http.Request)
    PlatformTokenDeletePOST removes one saved (platform, instance) token (POST
    /profile/tokens/delete). Operates only on the signed-in user.

func (h *ProfileHandler) PlatformTokensPOST(w http.ResponseWriter, r *http.Request)
    PlatformTokensPOST adds or updates one of the user's source access tokens
    (POST /profile/tokens) from the account page's add form: a (platform,
    instance URL, token) triple. A blank URL means the platform's default
    (cloud) instance. Operates only on the signed-in user. This is the single
    input path that grows the account store (the config form's per-source token
    field also routes here).

func (h *ProfileHandler) ProfileDetailsPOST(w http.ResponseWriter, r *http.Request)
    ProfileDetailsPOST updates the signed-in user's display name and email
    (POST /profile/details). The display name is free-form; changing the email
    requires re-entering the current password (a credential change), and a blank
    email clears it. An email already used by another account is rejected.

func (h *ProfileHandler) ProfileGET(w http.ResponseWriter, r *http.Request)
    ProfileGET renders the consolidated self-service profile page: two-factor
    status, password change, timezone, source access tokens, and personal themes
    (GET /profile).

func (h *ProfileHandler) TimezonePOST(w http.ResponseWriter, r *http.Request)
    TimezonePOST saves the user's preferred IANA timezone (POST
    /profile/timezone). An empty value or "UTC" stores UTC; any other value must
    be a resolvable IANA name.

func (h *ProfileHandler) TwoFADisablePOST(w http.ResponseWriter, r *http.Request)
    TwoFADisablePOST disables 2FA after re-verifying the user's password (POST
    /profile/2fa/disable).

func (h *ProfileHandler) TwoFAEnrolGET(w http.ResponseWriter, r *http.Request)
    TwoFAEnrolGET starts 2FA enrolment by generating a fresh secret and
    rendering the QR-code wizard (GET /profile/2fa/enrol).

func (h *ProfileHandler) TwoFAEnrolPOST(w http.ResponseWriter, r *http.Request)
    TwoFAEnrolPOST verifies the submitted code against the candidate secret and,
    on success, completes enrolment and shows the recovery codes (POST
    /profile/2fa/enrol).

type RepoDownloadHandler struct {
	Repos    repoInventory
	Global   settingsLoader
	Archiver repoMirrorWriter
	Audit    *audit.Logger

	// DataDir is the directory transient per-repo zips are written to before
	// streaming — the service-owned data directory, so the copy never lands in
	// a world-readable temp dir. Empty falls back to the system temp dir.
	DataDir string
}
    RepoDownloadHandler streams synced repositories as restorable .zip mirrors
    (every branch/tag/commit). Admin-only — the routes are gated behind
    PermBackupRestore at the router. Each download clones the all-refs mirror
    from Forgejo on demand via the Archiver, so it reflects current state.

func (h *RepoDownloadHandler) DownloadAllPOST(w http.ResponseWriter, r *http.Request)
    DownloadAllPOST streams every synced repository as one container .zip whose
    entries are each repository's own .zip mirror (POST /inventory/download).
    A repo that fails to clone gets a ".FAILED.txt" marker entry and the rest
    continue, so one unreachable repository does not abort the whole backup.

func (h *RepoDownloadHandler) DownloadOnePOST(w http.ResponseWriter, r *http.Request)
    DownloadOnePOST streams a single repository (by inventory ID) as a .zip of
    its full Git mirror (POST /inventory/{id}/download). The mirror is built
    into a transient file first, so a clone failure redirects cleanly instead of
    handing the client a truncated archive.

type RunHandler struct {
	Runs     run.Repository
	Events   run.EventRepository
	Configs  migrationconfig.Repository
	Queue    run.QueueRepository // lists queued-but-unstarted entries for the history view
	Worker   *migrate.Worker
	Settings func() auth.Settings
	Audit    *audit.Logger // optional; nil disables audit logging
}
    RunHandler handles all run-related routes:

        POST /configs/{id}/runs           — trigger a new run
        GET  /configs/{id}/runs           — run history for one config
        GET  /configs/{id}/runs/{runID}   — live monitor page
        GET  /configs/{id}/runs/{runID}/events — SSE stream
        POST /configs/{id}/runs/{runID}/cancel — cancel an in-progress run

func (h *RunHandler) CancelPOST(w http.ResponseWriter, r *http.Request)
    CancelPOST requests cancellation of the currently active run,
    rejecting the request when that run is not the active one (POST
    /configs/{id}/runs/{runID}/cancel).

func (h *RunHandler) EventsSSE(w http.ResponseWriter, r *http.Request)
    EventsSSE streams a run's log as Server-Sent Events: historical events for
    a finished run, or a live subscription to the worker broadcaster for an
    in-progress one (GET /configs/{id}/runs/{runID}/events).

func (h *RunHandler) HistoryGET(w http.ResponseWriter, r *http.Request)
    HistoryGET renders the recent run history for a config (GET
    /configs/{id}/runs).

func (h *RunHandler) LogPageJSON(w http.ResponseWriter, r *http.Request)
    LogPageJSON returns one page of a run's log as JSON for the lazy monitor
    view (GET /configs/{id}/runs/{runID}/log?before=<id>&limit=<n>): the newest
    `limit` lines older than `before` (before omitted/0 → newest overall),
    oldest-first. The client loads just enough to fill the viewport on open and
    pages older lines in on scroll-up, so a multi-thousand-line run never ships
    its whole log at once.

func (h *RunHandler) MonitorGET(w http.ResponseWriter, r *http.Request)
    MonitorGET renders the monitor page shell for a single run (GET
    /configs/{id}/runs/{runID}). It deliberately ships NO log events: the
    client fetches the log lazily via LogPageJSON (a viewport-worth at a time,
    older lines on scroll-up), so opening a long run never blocks the browser.

func (h *RunHandler) TriggerPOST(w http.ResponseWriter, r *http.Request)
    TriggerPOST enqueues a new run for a config, refusing a real run on an
    unconfirmed config (POST /configs/{id}/runs).

type SMTPTester interface {
	TestConnection(ctx context.Context) error
}
    SMTPTester is the slice of email.Sender the SMTP probe uses.

type SourceLister interface {
	ListRepos(ctx context.Context) ([]*source.Repo, error)
}
    SourceLister is the slice of source.Client the source probe uses.

type TestConnectionHandler struct {
	Settings *globalconfig.Service

	NewForgejo func(baseURL, token string, skipTLS bool) ForgejoVersioner
	NewSMTP    func(cfg email.Config) (SMTPTester, error)
	NewSource  func(platform, namespace, token string, ownerType migrationconfig.OwnerType, instURL string) (SourceLister, error)

	// Now supplies the clock the expiry countdown is measured against; nil means
	// time.Now. Injected so a test can assert a stable "in N days".
	Now func() time.Time

	// OwnerTokens loads a user's personal source tokens, used as the blank-token
	// fallback when probing a source so a user can test their own saved token.
	// Optional; nil means no personal-token fallback.
	OwnerTokens func(ctx context.Context, userID int64) (map[string]string, error)
}
    TestConnectionHandler backs the "test connection" buttons on the settings
    and config-editor pages. Each handler builds a client from the submitted
    form values — so operators can verify credentials *before* saving — and
    reports the verdict as JSON.

    Secret fields left blank fall back to the saved value, mirroring the
    settings form's "leave blank to keep" behaviour: the operator can re-test a
    saved Forgejo token or SMTP password without retyping it.

    Status codes:
      - 400 — the request to the probe itself was malformed or missing a
        required field (URL, platform, namespace). Nothing was attempted.
      - 200 — the probe ran; the verdict is in the body. ok=true on success,
        ok=false with a human-readable error otherwise.

    The client constructors are injected so tests can supply fakes;
    NewTestConnectionHandler wires the real forgejo/email/source ones.

func NewTestConnectionHandler(settings *globalconfig.Service) *TestConnectionHandler
    NewTestConnectionHandler returns a handler wired with the production client
    constructors.

func (h *TestConnectionHandler) TestForgejoPOST(w http.ResponseWriter, r *http.Request)
    TestForgejoPOST probes the Forgejo connection using the submitted form
    values (falling back to saved URL/token), reporting the server version on
    success (POST /settings/test-forgejo).

func (h *TestConnectionHandler) TestOwnerTokenPOST(w http.ResponseWriter, r *http.Request)
    TestOwnerTokenPOST probes one of the signed-in user's SAVED account tokens
    (POST /profile/tokens/test), identified by the (platform, instance URL,
    namespace) triple that keys the account store — the value itself is never
    sent to the browser and never comes back from it.

    It answers the question the Account page could not: a token listed there
    is just a row, and "is it still good, and whose is it" needed a migration
    run to find out. Shares the probe with the config editor's source test,
    so both screens report the same facts in the same shape.

func (h *TestConnectionHandler) TestSMTPPOST(w http.ResponseWriter, r *http.Request)
    TestSMTPPOST probes the SMTP connection using the submitted form values
    (falling back to the saved password), mapping the single TLS toggle to SMTPS
    or STARTTLS (POST /settings/test-smtp).

func (h *TestConnectionHandler) TestSourcePOST(w http.ResponseWriter, r *http.Request)
    TestSourcePOST probes a source platform with the submitted credentials
    (falling back to the signed-in user's saved token), reporting the repo
    count plus what the platform says about the token itself — the account it
    authenticates as, its scopes, its expiry (POST /configs/test-source).

    With no namespace it reports the token's identity alone. That is the
    outbound token-override case: such a row names a platform and a credential
    and has nothing to enumerate, and refusing to probe it left the only tokens
    repoman uses for WRITING as the only ones that could not be tested.

type ThemeHandler struct {
	Store    themeStore
	Settings func() auth.Settings
	// Audit records theme_created/updated/deleted events. Nil-safe.
	Audit *audit.Logger

	// Notifier raises the theme-submission notifications (admin alerted on
	// submit; submitter told the result). Nil disables notifications.
	Notifier ThemeNotifier
	// Users lists accounts to resolve admin recipients for submission alerts.
	// Nil disables the admin alert.
	Users UserLister

	// History records and lists theme change history. Optional; nil disables
	// both recording and the editor's "Change history" section. Keyed by theme
	// id, so a theme's history travels with it across personal→pending→global.
	History ChangeHistoryStore
}
    ThemeHandler serves the generated /themes.css stylesheet (all users,
    pre-auth included so the login page is themed) and the admin-only theme
    management under /settings/themes: create, edit, delete, import a shared
    .css theme file, and export a theme back to that format.

func (h *ThemeHandler) AcceptPOST(w http.ResponseWriter, r *http.Request)
    AcceptPOST promotes a pending theme to global with the admin's final name
    and notifies the submitter (POST /settings/themes/{id}/accept).

func (h *ThemeHandler) BuilderGET(w http.ResponseWriter, r *http.Request)
    BuilderGET renders the standalone theme builder available to ANY signed-in
    user (GET /theme-builder): color pickers for every theme variable,
    a live preview, and client-side export/import of the .css theme format.
    Users can build a theme and export it to send to an admin, who imports
    it under Settings → Themes — so this page needs no admin rights and saves
    nothing server-side.

func (h *ThemeHandler) CreatePOST(w http.ResponseWriter, r *http.Request)
    CreatePOST validates the submitted form and creates the theme (POST
    /settings/themes).

func (h *ThemeHandler) DeletePOST(w http.ResponseWriter, r *http.Request)
    DeletePOST removes a custom theme (POST /settings/themes/{id}/delete).
    Users who had it selected fall back to the default theme client-side.

func (h *ThemeHandler) DenyPOST(w http.ResponseWriter, r *http.Request)
    DenyPOST returns a pending theme to its submitter as a personal theme and
    notifies them (POST /settings/themes/{id}/deny).

func (h *ThemeHandler) EditGET(w http.ResponseWriter, r *http.Request)
    EditGET renders the form pre-filled with an existing theme (GET
    /settings/themes/{id}).

func (h *ThemeHandler) ExportGET(w http.ResponseWriter, r *http.Request)
    ExportGET streams a theme as a downloadable .css file in the same format the
    import accepts (GET /settings/themes/{id}/export). Themes are not secrets —
    every user receives them via /themes.css — so a GET is fine.

func (h *ThemeHandler) ImportPOST(w http.ResponseWriter, r *http.Request)
    ImportPOST creates a theme from an uploaded .css theme file (POST
    /settings/themes/import). The name comes from the optional form field,
    falling back to the file's base name.

func (h *ThemeHandler) ListGET(w http.ResponseWriter, r *http.Request)
    ListGET redirects to the consolidated settings page's themes section (GET
    /settings/themes) — theme management moved there. Kept as a redirect so
    existing bookmarks/links still resolve.

func (h *ThemeHandler) MyStylesheetGET(w http.ResponseWriter, r *http.Request)
    MyStylesheetGET serves the signed-in user's PERSONAL themes as a per-user
    stylesheet (GET /themes/me.css, authenticated, private-cached) — kept
    separate from the anonymous global /themes.css so one user's themes never
    leak to others. An admin additionally gets every pending theme so the
    management page's live preview can render them.

func (h *ThemeHandler) NewGET(w http.ResponseWriter, r *http.Request)
    NewGET renders an empty theme form pre-filled with the light theme's values
    as a starting point (GET /settings/themes/new).

func (h *ThemeHandler) OptionsMiddleware(next http.Handler) http.Handler
    OptionsMiddleware injects a lazy custom-theme lister into the request
    context so NewBaseData can populate the nav dropdown on whichever page
    renders — without every handler taking a theme dependency, and without
    querying the DB for requests that never render a page (SSE, downloads).

func (h *ThemeHandler) PersonalCreatePOST(w http.ResponseWriter, r *http.Request)
    PersonalCreatePOST saves a new personal theme owned by the signed-in user
    (POST /profile/themes).

func (h *ThemeHandler) PersonalDeletePOST(w http.ResponseWriter, r *http.Request)
    PersonalDeletePOST removes one of the user's personal themes (POST
    /profile/themes/{id}/delete).

func (h *ThemeHandler) PersonalEditGET(w http.ResponseWriter, r *http.Request)
    PersonalEditGET renders the editor for one of the user's personal themes
    (GET /profile/themes/{id}).

func (h *ThemeHandler) PersonalExportGET(w http.ResponseWriter, r *http.Request)
    PersonalExportGET streams one of the user's personal themes as a .css file
    (GET /profile/themes/{id}/export).

func (h *ThemeHandler) PersonalImportPOST(w http.ResponseWriter, r *http.Request)
    PersonalImportPOST creates a personal theme from an uploaded .css file (POST
    /profile/themes/import) — the same validation gate as the admin import.

func (h *ThemeHandler) PersonalListGET(w http.ResponseWriter, r *http.Request)
    PersonalListGET redirects to the consolidated profile page's "My themes"
    section (GET /profile/themes) — the personal-theme list moved there. Kept as
    a redirect so existing bookmarks/links still resolve.

func (h *ThemeHandler) PersonalNewGET(w http.ResponseWriter, r *http.Request)
    PersonalNewGET renders the personal-theme editor pre-filled with the light
    theme as a starting point (GET /profile/themes/new).

func (h *ThemeHandler) PersonalUpdatePOST(w http.ResponseWriter, r *http.Request)
    PersonalUpdatePOST saves edits to one of the user's personal themes (POST
    /profile/themes/{id}).

func (h *ThemeHandler) StylesheetGET(w http.ResponseWriter, r *http.Request)
    StylesheetGET serves the generated stylesheet containing every theme (GET
    /themes.css, anonymous — the login page links it). The content is ETag'd
    with its SHA-256 so unchanged themes answer 304 to revalidations.

func (h *ThemeHandler) SubmitPOST(w http.ResponseWriter, r *http.Request)
    SubmitPOST submits one of the user's personal themes for admin evaluation
    with an optional suggested name (POST /profile/themes/{id}/submit).
    The admin alert notification is raised by the Notifier when wired.

func (h *ThemeHandler) UpdatePOST(w http.ResponseWriter, r *http.Request)
    UpdatePOST validates the submitted form and updates an existing theme (POST
    /settings/themes/{id}).

type ThemeNotifier interface {
	NotifyUsers(ctx context.Context, userIDs []int64, kind, title, body, link string)
	Notify(ctx context.Context, userID int64, kind, title, body, link string) error
}
    ThemeNotifier raises a notification to a set of users. notify.Service
    satisfies it (NotifyUsers); kept as an interface so handlers don't import
    the notify implementation directly and tests can record calls.

type ThemeOption struct {
	Value string
	Label string
}
    ThemeOption is one nav-dropdown entry: Value is the data-theme attribute
    value the client sets ("auto", a built-in slug, or "custom-<id>"), Label the
    display name.

type TokenExpiryNotice struct {
	Platform    string
	Location    string
	ExpiresAt   time.Time
	Expired     bool
	OwnerUserID int64
}
    TokenExpiryNotice is one expiring-token reminder for the nav banner.
    Location is a human description of where the token lives; OwnerUserID is the
    user who may edit it — the banner's security boundary, so a token is shown
    only to its owner (and to admins, who may edit any token).

type TwoFactorHandler struct {
	Sessions *auth.SessionManager
	Users    auth.UserRepository
	TOTPRepo auth.TOTPUserRepository
	SecLog   *security.Logger
}
    TwoFactorHandler handles the second-factor step of login. It is invoked
    after LoginPOST set a session cookie with pending_2fa=1. The session
    middleware redirects every other URL to /auth/2fa until the user provides a
    valid code.

func (h *TwoFactorHandler) TwoFactorGET(w http.ResponseWriter, r *http.Request)
    TwoFactorGET renders the 2FA-input form.

func (h *TwoFactorHandler) TwoFactorPOST(w http.ResponseWriter, r *http.Request)
    TwoFactorPOST validates the submitted code (TOTP or recovery) and promotes
    the session out of pending_2fa state.

type UserAdminHandler struct {
	Repo     auth.UserAdminRepository
	Pepper   auth.Pepper
	Sessions *auth.SessionManager
	Settings func() auth.Settings
	SecLog   *security.Logger // optional; nil disables security logging
	Audit    *audit.Logger    // optional; nil disables audit logging

	// History lists any user's profile change history so an admin can oversee
	// changes across the instance. Optional; nil hides the per-user History view.
	History ChangeHistoryStore
}
    UserAdminHandler handles the /admin/users/* routes. All routes require
    PermManageUsers (enforced by the router middleware).

func (h *UserAdminHandler) CreatePOST(w http.ResponseWriter, r *http.Request)
    CreatePOST validates and creates a new user, re-rendering the form with an
    error on validation failure or a taken username (POST /admin/users).

func (h *UserAdminHandler) ListGET(w http.ResponseWriter, r *http.Request)
    ListGET renders the table of all users (GET /admin/users).

func (h *UserAdminHandler) LockPOST(w http.ResponseWriter, r *http.Request)
    LockPOST hard-locks a user and invalidates their sessions, refusing to lock
    the last active admin (POST /admin/users/{id}/lock).

func (h *UserAdminHandler) NewGET(w http.ResponseWriter, r *http.Request)
    NewGET renders the empty new-user form (GET /admin/users/new).

func (h *UserAdminHandler) Reset2FAPOST(w http.ResponseWriter, r *http.Request)
    Reset2FAPOST clears a user's TOTP enrolment and invalidates their sessions,
    forcing re-login (POST /admin/users/{id}/reset-2fa).

func (h *UserAdminHandler) ResetPasswordPOST(w http.ResponseWriter, r *http.Request)
    ResetPasswordPOST sets a one-time temporary password (forcing a change on
    next login) and shows it once (POST /admin/users/{id}/reset-password).

func (h *UserAdminHandler) SetForgejoOwnerPOST(w http.ResponseWriter, r *http.Request)
    SetForgejoOwnerPOST pins (or clears) a user's Forgejo target owner (POST
    /admin/users/{id}/forgejo-owner). Non-admins may only create configs
    targeting this owner; admins are unrestricted.

func (h *UserAdminHandler) UnlockPOST(w http.ResponseWriter, r *http.Request)
    UnlockPOST clears a lock on a user account (POST /admin/users/{id}/unlock).

func (h *UserAdminHandler) UpdateRolePOST(w http.ResponseWriter, r *http.Request)
    UpdateRolePOST changes a user's role, refusing to demote the last active
    admin (POST /admin/users/{id}/role).

func (h *UserAdminHandler) UserHistoryGET(w http.ResponseWriter, r *http.Request)
    UserHistoryGET renders the profile change history for one user so an admin
    can audit it (GET /admin/users/{id}/history). The history reuses the same
    snapshot diff as the user's own profile page.

type UserLister interface {
	ListUsers(ctx context.Context) ([]*auth.User, error)
}
    UserLister lists user accounts (for resolving notification recipients).