Help us shape the future of Go! Take the 2026 Go Developer Survey

admin

package
v0.34.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 30, 2026 License: MIT Imports: 34 Imported by: 0

Documentation

Overview

Package admin is an admin panel generated from ORM models: paginated lists with search, sort and filters, detail pages, create/edit forms built from z schemas, deletes with confirmation, and an audit log of every change. The UI is server-rendered html/template with htmx, all embedded (no CDN, no inline scripts or styles, so it runs under a strict CSP).

Access fails closed: every route requires an authenticated user (the guard in Config), the panel ability (default "admin") and the policy ability for the action (viewAny, view, create, update, delete) on the model type. A missing ability or policy denies.

panel, err := admin.New(admin.Config{DB: db, Guard: acc.Web, Authorizer: gate})
if err != nil { return err }
if err := panel.Register(admin.NewResource[models.Post](requests.PostSchema).Search("title")); err != nil {
	return err
}
return panel.Mount(r)

See https://github.com/AlexGalhardo/vukem/blob/main/docs/admin.md.

Index

Examples

Constants

View Source
const (
	// MaxBulk is the most records one bulk action may run on.
	MaxBulk = 100
	// MaxReason is the longest reason, in characters.
	MaxReason = 1000
)

Limits of custom actions.

View Source
const (
	// AbilityAdmin is the default gate ability required for every page.
	AbilityAdmin = "admin"
	// AbilityAudit is the gate ability required to read the audit log.
	AbilityAudit = "viewAuditLogs"
	// AbilityFlags is the gate ability required to see and change feature
	// flags (Config.Flags).
	AbilityFlags = "manageFlags"

	AbilityViewAny = "viewAny"
	AbilityView    = "view"
	AbilityCreate  = "create"
	AbilityUpdate  = "update"
	AbilityDelete  = "delete"
)

Abilities checked by the panel. Policy abilities are checked on the model type ((*T)(nil)) or on the record (*T).

View Source
const (
	ActionCreate = "create"
	ActionUpdate = "update"
	ActionDelete = "delete"
)

Audit actions.

View Source
const (
	InputText     = "text"
	InputTextarea = "textarea"
	InputNumber   = "number"
	InputEmail    = "email"
	InputURL      = "url"
	InputPassword = "password"
	InputCheckbox = "checkbox"
	InputSelect   = "select"
	InputDate     = "date"
	InputDateTime = "datetime-local"
)

Input types a form field can be rendered with (Resource.Input).

View Source
const AuditTable = "admin_audit_logs"

AuditTable is the table holding the admin audit log.

View Source
const FlagsResource = "_flags"

FlagsResource is the audit-log resource of feature flag changes.

View Source
const FlashKey = "admin.success"

FlashKey is the session flash key of the panel's success messages.

View Source
const ReasonKey = "_reason"

ReasonKey is the audit-log change key holding the reason given for an Action with Reason set. Column names never start with "_", so it cannot collide with a column.

Variables

This section is empty.

Functions

func Migration

func Migration(id string) schema.Migration

Migration creates the audit log table; register it with a timestamped id (vukem install:admin does):

admin.Migration("20261016120000_create_admin_audit_logs_table")

func Templates

func Templates() fs.FS

Templates returns the embedded templates, to copy one into Config.Views and change it.

Types

type Action added in v0.33.0

type Action[T any] struct {
	// Name is the policy ability, the URL segment and the audit-log action:
	// a lower-camel identifier of at most 16 characters ([a-z][a-zA-Z0-9]*),
	// not one of viewAny, view, create, update, delete or edit.
	Name string
	// Label is the button text (default: Name humanized).
	Label string
	// Reason asks for a reason on the confirmation page (required, at most
	// MaxReason characters); Run receives it and the audit log records it
	// under ReasonKey.
	Reason bool
	// Bulk also offers the action on the rows selected in the list (at
	// most MaxBulk at once); every record must pass the policy or none
	// runs.
	Bulk bool
	// Run performs the action. Changes it makes to rec's fields are saved
	// after it returns (orm.Update) and recorded in the audit log; other
	// writes go through tx so they roll back with the rest.
	Run func(ctx context.Context, tx *database.Tx, rec *T, reason string) error
}

Action is a custom operation on one record ("approve", "reject"), offered as a button on the record page and, with Bulk, on the rows selected in the list. Every run goes through a confirmation page, needs the policy ability named Name on the record (method Approve for "approve"; a missing method denies), runs in one transaction with its audit-log entries and fails as a whole.

type AnyResource

type AnyResource interface {
	// contains filtered or unexported methods
}

AnyResource is a Resource of any model, as accepted by Panel.Register. Only Resource implements it.

type AuditLog

type AuditLog struct {
	ID int64 `db:"id"`
	// ActorID is the AuthID of the user who made the change.
	ActorID string `db:"actor_id"`
	// Resource is the resource slug ("posts").
	Resource string `db:"resource"`
	// RecordID is the primary key of the changed record.
	RecordID string `db:"record_id"`
	// Action is ActionCreate, ActionUpdate, ActionDelete or the Name of a
	// custom Action.
	Action string `db:"action"`
	// Changes maps changed columns to their values before and after.
	// Sensitive columns only record that they changed.
	Changes   orm.JSON[map[string]Change] `db:"changes"`
	CreatedAt time.Time                   `db:"created_at"`
}

AuditLog is one change made through the admin panel.

func (AuditLog) Table

func (AuditLog) Table() string

Table implements orm.Tabler.

type Change

type Change struct {
	Before   any  `json:"before,omitempty"`
	After    any  `json:"after,omitempty"`
	Redacted bool `json:"redacted,omitempty"`
}

Change is the before/after value of one column. Redacted changes (to sensitive columns) carry no values.

type Config

type Config struct {
	// DB stores the resources and the audit log (required).
	DB *database.DB
	// Guard authenticates admins (required), e.g. the "web" session guard.
	// Guests are redirected to the route named "login" (401 for JSON).
	Guard auth.Checker
	// Authorizer decides the panel and policy abilities, usually an
	// authz.Gate. Nil uses the router's Services.Authorizer; with neither,
	// everything is denied.
	Authorizer web.Authorizer
	// Prefix is the URL prefix (default "/admin").
	Prefix string
	// Ability is the gate ability required for every page (default
	// "admin").
	Ability string
	// Title is shown in the header (default "Admin").
	Title string
	// Views overrides embedded templates by path: layouts/admin.html,
	// index.html, show.html, form.html, delete.html, dashboard.html,
	// audit.html, flags.html, action.html, partials/pager.html.
	Views fs.FS
	// Flags adds a page at <Prefix>/_flags to change the global value of
	// the registered feature flags (app.Flags()); it needs the
	// AbilityFlags gate ability. Without registered flags the page and its
	// routes do not exist. Locked flags are shown read-only. Every change
	// is a "flags.changed" audit event and an audit-log entry; a save that
	// leaves the value as it was records nothing.
	Flags *flags.Flags

	// TwoFactor makes two-factor authentication mandatory for the panel:
	// it reports whether the admin authenticated on ctx (auth.UserFrom)
	// has 2FA enabled. The 2FA state lives in the app (the install:auth
	// scaffold's users.two_factor_enabled), so the panel asks. false is 403
	// with an "admin.2fa_required" security event; an error is 403 too
	// (fail closed). Nil does not require 2FA.
	TwoFactor func(ctx context.Context) (bool, error)
	// MaxSessionAge, when positive, is the oldest a session may be to use
	// the panel, counted from its creation (session.CreatedAt), a stricter
	// limit than the app's SESSION_LIFETIME. An older session is
	// invalidated (signed out of every guard, an "admin.session_expired"
	// security event) and the browser is sent to log in again. A
	// remember-me cookie logs the user back in with a new session: turn
	// remember-me off for admins when the cap must force a password.
	MaxSessionAge time.Duration
	// IdleTimeout, when positive, ends a session that has not touched a
	// guarded admin route for that long. The clock is the session value
	// "admin.idle_at", updated only by Guard (and the routes Mount
	// registers), not by the rest of the app. An idle session is
	// invalidated (an "admin.session_idle" security event) and the browser
	// is sent to log in again. Activity elsewhere does not refresh it.
	IdleTimeout time.Duration
	// AllowCIDRs, when not empty, limits the panel to client IPs
	// (web.Ctx.IP, trusted-proxy aware) inside one of the prefixes. Others
	// get 403 with an "admin.ip_denied" security event, before any login
	// redirect. The embedded CSS and JS stay public.
	AllowCIDRs []netip.Prefix
	// Now is the clock for MaxSessionAge and IdleTimeout (tests); defaults
	// to time.Now.
	Now func() time.Time
}

Config configures a Panel.

type Panel

type Panel struct {
	// contains filtered or unexported fields
}

Panel is an admin panel; build it with New, add resources with Register and serve it with Mount.

func New

func New(cfg Config) (*Panel, error)

New validates cfg and returns an empty panel.

Example

The panel needs a database, a guard and an authorizer granting the "admin" ability plus a policy per model.

mount := func(r *router.Router, db *database.DB, guard *auth.SessionGuard[User]) error {
	gate := authz.New(func(ctx context.Context) (*User, error) {
		u, err := auth.UserFrom[User](ctx, guard.Name())
		return &u, err
	})
	gate.Define(admin.AbilityAdmin, func(_ context.Context, u *User) bool { return u.Role == "admin" })

	if err := gate.Register(authz.For[*Post](adminPolicy[*Post]{})); err != nil {
		return err
	}

	panel, err := admin.New(admin.Config{DB: db, Guard: guard, Authorizer: gate})
	if err != nil {
		return err
	}

	posts := admin.NewResource[Post](postSchema).
		Search("title").
		Filter("status", "draft", "live").
		Column("Excerpt", func(p Post) string { return strings.SplitN(p.Body, "\n", 2)[0] })

	if err := panel.Register(posts); err != nil {
		return err
	}

	return panel.Mount(r) // GET /admin, /admin/posts, ...
}

_ = mount

func (*Panel) Guard added in v0.34.0

func (p *Panel) Guard() func(http.Handler) http.Handler

Guard is the middleware Mount installs on every panel route: the IP allow-list, MaxSessionAge, IdleTimeout, the auth guard, CSRF, the no-store headers, two-factor and the panel ability, in that order. Put it on routes registered beside the panel (a ban or resolve action under the same prefix) so they fail closed the same way. Sessions must already be on the router. It does not check a resource policy; the handler does that itself.

func (*Panel) Mount

func (p *Panel) Mount(r *router.Router) error

Mount registers the panel's routes on r. Every route requires the guard, the panel ability and same-origin (CSRF-checked) unsafe requests.

func (*Panel) Register

func (p *Panel) Register(resources ...AnyResource) error

Register validates and adds resources; configuration mistakes are returned here, at boot.

type Resource

type Resource[T any] struct {
	// contains filtered or unexported fields
}

Resource exposes model T in the admin panel: a paginated list with search, sort and filters, a detail page, and create/edit forms generated from a z schema. Options chain and return the resource; mistakes (unknown columns, sensitive columns in lists) are reported by Panel.Register, at boot.

func NewResource

func NewResource[T, I any](schema z.StructSchema[I]) *Resource[T]

NewResource returns a resource for model T whose forms are generated from schema: each schema key is a form field written to the model column with the same name (or the snake_case of the input field name). Only schema keys are ever written (mass-assignment protection); the primary key and timestamps cannot be schema keys. A composite primary key (orm.CompositeKeyer) is addressed as a-b: each column's value, in PrimaryKeys order, joined with "-". A "-" or "~" inside a value is escaped so the URL matches every key column, not the first one alone. T is given explicitly and I is inferred:

admin.NewResource[models.Post](requests.PostSchema)

func (*Resource[T]) Actions added in v0.33.0

func (r *Resource[T]) Actions(actions ...Action[T]) *Resource[T]

Actions adds custom actions; mistakes are reported by Panel.Register.

func (*Resource[T]) AfterSave added in v0.33.0

func (r *Resource[T]) AfterSave(fn func(ctx context.Context, s Saved[T]) error) *Resource[T]

AfterSave runs fn after every create and update, inside the panel transaction and before the audit entry is written, with the transaction, the previous record and the Virtual field values (see Saved). Returning an error rolls the whole save back. Changes fn makes to Saved.Record and writes through Saved.Tx appear in the audit diff.

func (*Resource[T]) BeforeSave

func (r *Resource[T]) BeforeSave(fn func(ctx context.Context, rec *T) error) *Resource[T]

BeforeSave runs fn on the record before every create and update (after validation, inside the transaction), e.g. to hash a password or set an owner. Returning an error aborts the write. Use AfterSave for side effects that need the transaction.

func (*Resource[T]) Column

func (r *Resource[T]) Column(label string, fn func(T) string) *Resource[T]

Column adds a computed column to the list; fn's result is HTML-escaped.

func (*Resource[T]) Filter

func (r *Resource[T]) Filter(col string, options ...string) *Resource[T]

Filter adds an exact-match filter on col; with options it renders a select, otherwise a text box. Boolean columns get yes/no options.

func (*Resource[T]) Hidden

func (r *Resource[T]) Hidden(cols ...string) *Resource[T]

Hidden hides columns from the list, the detail page and the forms; hidden schema keys are never written.

func (*Resource[T]) Input

func (r *Resource[T]) Input(key, kind string) *Resource[T]

Input overrides the form control of a schema key (one of the Input* constants).

func (*Resource[T]) Label

func (r *Resource[T]) Label(col, label string) *Resource[T]

Label sets the label of a column.

func (*Resource[T]) Labels

func (r *Resource[T]) Labels(singular, plural string) *Resource[T]

Labels sets the singular and plural names shown in the UI.

func (*Resource[T]) List

func (r *Resource[T]) List(cols ...string) *Resource[T]

List sets the columns of the list, in order (default: every column that is not hidden or sensitive).

func (*Resource[T]) PerPage

func (r *Resource[T]) PerPage(n int) *Resource[T]

PerPage sets the page size of the list (default 20, at most 200).

func (*Resource[T]) ReadOnly

func (r *Resource[T]) ReadOnly(cols ...string) *Resource[T]

ReadOnly shows columns in the edit form without letting the admin change them: read-only schema keys keep the stored value and are never written.

func (*Resource[T]) Search

func (r *Resource[T]) Search(cols ...string) *Resource[T]

Search enables the search box over the given string columns (case-insensitive LIKE with bound parameters).

func (*Resource[T]) Sensitive

func (r *Resource[T]) Sensitive(cols ...string) *Resource[T]

Sensitive marks columns holding secrets (password hashes, tokens) beyond the ones detected by name or json:"-": they are never displayed, listed, searched, sorted, filtered or written to the audit log. A sensitive schema key renders as an empty password field and keeps the stored value when left blank on edit.

func (*Resource[T]) Slug

func (r *Resource[T]) Slug(slug string) *Resource[T]

Slug sets the URL segment (default: the table name in kebab-case).

func (*Resource[T]) Sortable

func (r *Resource[T]) Sortable(cols ...string) *Resource[T]

Sortable sets the columns the list may be sorted by (default: the listed columns). Any other sort parameter is ignored.

func (*Resource[T]) Virtual added in v0.33.0

func (r *Resource[T]) Virtual(key string, value func(rec *T) any) *Resource[T]

Virtual declares a schema key that maps to no model column: the form shows it, its parsed value reaches AfterSave hooks in Saved.Virtual, and nothing writes it to the record. value renders the current state on the edit form (nil: empty), e.g. a "banned" checkbox from a banned_at column. A schema key without a column that Virtual does not declare is a Register error.

type Saved added in v0.33.0

type Saved[T any] struct {
	// Tx is the panel transaction the record was written in: write related
	// rows through it, and use Tx.AfterCommit for work that must only
	// happen once the save is committed (events, mail, cache).
	Tx *database.Tx
	// Action is ActionCreate or ActionUpdate.
	Action string
	// Record is the record as written.
	Record *T
	// Before is a copy of the record before the form was applied; nil on
	// create.
	Before *T
	// Virtual holds the parsed values of the submitted Virtual fields, by
	// schema key.
	Virtual map[string]any
}

Saved describes a create or update to AfterSave hooks.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL