admin

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 26 Imported by: 0

README

go-admin

tests Go Reference

go-admin turns resource definitions written in Go into a server-rendered admin panel for SaaS back offices: lists with search, filters, sorting, pagination and bulk actions, detail pages, validated create and edit forms, confirmed deletes and a dashboard. It is a plain http.Handler with embedded templates and no code generation, it never authenticates users itself, it asks your Authorizer before every page and write, and it ships with a strict Content-Security-Policy, cross-origin request protection and accessible markup. Records are read and written through a small generic repository interface, with an in-memory implementation and a PostgreSQL one on pgx v5.

Install

go get github.com/byteminus/go-admin

Requires Go 1.26 or later.

Quick start

package main

import (
	"context"
	"log"
	"net/http"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/byteminus/go-admin"
	"github.com/byteminus/go-admin/pgxrepo"
)

type Customer struct {
	ID        int64     `admin:"id"`
	Name      string    `admin:"name"`
	Email     string    `admin:"email"`
	Plan      string    `admin:"plan"`
	Active    bool      `admin:"active"`
	CreatedAt time.Time `admin:"created_at,readonly"`
}

type Staff struct{ Email string }

func (s Staff) DisplayName() string { return s.Email }

// staffFromRequest stands in for your own session lookup.
func staffFromRequest(r *http.Request) (admin.User, bool) {
	email := r.Header.Get("X-Staff-Email") // set by your auth proxy or middleware
	if email == "" {
		return nil, false
	}
	return Staff{Email: email}, true
}

func main() {
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, "postgres://localhost:5432/app")
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()

	customers, err := pgxrepo.New[Customer](pool, "customers", pgxrepo.WithConn(pgxrepo.TxFrom))
	if err != nil {
		log.Fatal(err)
	}

	panel, err := admin.New(admin.Config{
		Prefix:      "/admin",
		Title:       "Acme back office",
		CurrentUser: staffFromRequest,
		Authorizer:  admin.AllowAll(),
		LoginURL:    "/login",
		Transact:    pgxrepo.Transactor(pool),
		Widgets: []admin.Widget{
			admin.Count("Active customers", customers, admin.Condition{Field: "active", Op: admin.OpEq, Value: "true"}),
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	admin.MustRegister(panel, admin.Resource[Customer]{
		Name:         "customers",
		Group:        "Accounts",
		Repository:   customers,
		SearchFields: []string{"name", "email"},
		DefaultSort:  admin.Sort{Field: "created_at", Desc: true},
		Title:        func(c Customer) string { return c.Name },
		Fields: []admin.Field{
			{Name: "name", Required: true, Sortable: true},
			{Name: "email", Type: admin.FieldEmail, Required: true},
			{Name: "plan", Type: admin.FieldSelect, Options: []admin.Choice{
				{Value: "free", Label: "Free"},
				{Value: "pro", Label: "Pro"},
			}},
			{Name: "active"},
			{Name: "created_at", Type: admin.FieldReadOnly, Show: admin.OnList | admin.OnDetail, Sortable: true},
		},
		Filters: []admin.Filter{
			{Field: "plan", Type: admin.FilterSelect},
			{Field: "active", Type: admin.FilterBool},
			{Field: "created_at", Type: admin.FilterDateRange},
		},
		BulkActions: []admin.BulkAction[Customer]{{
			Name:    "deactivate",
			Label:   "Deactivate",
			Confirm: "Deactivate the selected customers?",
			Run: func(ctx context.Context, cs []Customer) error {
				for _, c := range cs {
					c.Active = false
					if err := customers.Update(ctx, &c); err != nil {
						return err
					}
				}
				return nil
			},
		}},
	})

	mux := http.NewServeMux()
	mux.Handle("/admin/", panel)
	log.Fatal(http.ListenAndServe(":8080", mux))
}

Concepts

Panel. admin.New(Config) returns an http.Handler. Mount it with mux.Handle(prefix+"/", panel). Config.CurrentUser and Config.Authorizer are required; everything else has a working zero value.

Resources. A Resource[T] describes one record type: Name (the URL segment), labels, IDField (default "id"), DefaultSort, SearchFields, Fields, Filters, actions, hooks and the Repository. Register it with admin.Register (or MustRegister); resources referenced by relation fields are registered first. Registration validates the whole definition against T and reports every mistake as an error.

Records and tags. T is a struct whose persisted fields carry admin:"column" tags. Supported field types are strings, booleans, integers, floats, time.Time, types implementing encoding.TextMarshaler and TextUnmarshaler (such as uuid.UUID), named types based on these, and pointers to any of them for nullable columns. The tag option readonly (admin:"created_at,readonly") marks columns the store manages.

Fields. Each Field has a Label (derived from the column name when empty), a Type (inferred from the Go type when empty) and a Show visibility set (OnList, OnDetail, OnForm; zero means all three). Types: FieldText, FieldTextarea, FieldNumber, FieldBool, FieldSelect (with Options), FieldDate, FieldDateTime (edited in Config.Location), FieldEmail, FieldURL, FieldHidden, FieldReadOnly and FieldRelation (a select of another resource's records, named by Relation). Required, MaxLength, Help, Placeholder and Sortable complete the definition. The ID can be entered when creating a record but never changed afterwards.

Filters. FilterSelect (options from the filter, the field or the related resource), FilterBool and FilterDateRange (inclusive from and to dates).

Actions. Action[T] runs on one record from its detail page and list row; BulkAction[T] runs on the rows selected in a list. Both may require a confirmation page (Confirm) and a permission (default PermUpdate), and run inside Config.Transact.

Validation and hooks. Submitted values are checked by type (numbers, dates, emails, absolute http(s) URLs, select options, existing relation targets), Required and MaxLength, then by Resource.Validate. Errors are shown next to the inputs and in a summary, with the submitted values kept. Hooks run before and after create, update and delete; returning FieldErrors or a PublicError (see admin.Errorf) redisplays the form with that message, and any other error is logged and answered with a generic 500. Repository errors wrapping ErrConflict or ErrInvalidRecord are explained on the form.

Authorization. The Authorizer answers Access{Resource, Permission, Record} questions for PermView, PermCreate, PermUpdate and PermDelete. Every request passes a resource-level check first; record-level checks follow for viewing, editing (against both the stored and the submitted record), deleting, running actions, and creating once values are bound. Which rows a user may see in a list is the repository's responsibility, for example by scoping queries to the user's tenant. admin.UserFrom(ctx) gives hooks, actions, widgets and repositories the current user.

Unauthenticated requests are served by Config.Unauthenticated, or redirected to Config.LoginURL with the requested path in next (htmx requests get an HX-Redirect header), or answered with 401.

Security. Responses carry Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none' plus nosniff, DENY framing and a same-origin referrer policy. Templates contain no inline scripts, styles or event handlers; htmx is configured through a meta tag with allowEval, indicator styles and history snapshots turned off. Non-safe requests from other origins are rejected by http.CrossOriginProtection (add origins with Config.TrustedOrigins), so forms need no CSRF tokens. Notices after redirects are fixed codes, never text taken from the URL. Every page works without JavaScript.

Dashboard. Config.Widgets are rendered in order. admin.Stat shows a computed value and admin.Count the number of matching records; set Widget.Resource to link a widget to a resource and hide it from users who cannot view that resource. Custom widgets return trusted template.HTML.

PostgreSQL

pgxrepo maps a struct onto a table you own; go-admin creates no tables of its own, so there is no schema to install. A table for the quick start could be:

CREATE TABLE IF NOT EXISTS customers (
    id         bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name       text NOT NULL,
    email      text NOT NULL UNIQUE,
    plan       text NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'pro')),
    active     boolean NOT NULL DEFAULT true,
    created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS customers_created_at_idx ON customers (created_at);
  • pgxrepo.New[T](db, "schema.table", opts...) accepts a *pgxpool.Pool, *pgx.Conn or pgx.Tx. Identifiers come only from the struct tags and the table name, are validated against the struct's columns and quoted with pgx.Identifier; all values are bind parameters.
  • Zero IDs get a UUID v7 for string and uuid.UUID keys; integer keys are left to the column's identity or serial default. readonly columns are never written and are read back with RETURNING after every write.
  • Search uses ILIKE with % and _ escaped. Add a pg_trgm index on searched columns for large tables.
  • Unique and foreign key violations map to admin.ErrConflict; NOT NULL, CHECK and data errors to admin.ErrInvalidRecord.
  • Times are returned in UTC.

To run each write and its hooks in one transaction, give the panel a Transact function and let the repositories join the transaction carried in the context:

repo, err := pgxrepo.New[Customer](pool, "customers", pgxrepo.WithConn(pgxrepo.TxFrom))
// ...
panel, err := admin.New(admin.Config{
	// ...
	Transact: pgxrepo.Transactor(pool),
})

Inside hooks and actions, other pgx code joins the same transaction through pgxrepo.TxFrom(ctx). A nested Transactor call runs in a savepoint.

API overview

Identifier Purpose
New(Config) (*Panel, error) Create a panel; Panel implements http.Handler.
Register, MustRegister Add a Resource[T] to a panel.
Panel.URL(resource, id) Path of the dashboard, a list or a record.
Resource[T], Field, FieldType, Visibility, Choice Resource definitions.
Filter, FilterType List filters.
Action[T], BulkAction[T], Hooks[T] Operations and write hooks.
Repository[T], ListQuery, Condition, Op, Sort, ListResult[T] Data access.
ErrNotFound, ErrConflict, ErrInvalidRecord, ErrInvalidQuery Repository errors.
FieldErrors, PublicError, Errorf Errors shown to users.
User, UserFunc, Authorizer, AuthorizerFunc, AllowAll, Access, Permission Authentication hand-off and authorization.
WithUser, UserFrom The current user in a context.
Widget, Stat, Count Dashboard widgets.
memrepo.New, WithIDColumn, WithUnique In-memory repository (not transactional).
pgxrepo.New, WithIDColumn, WithConn, Transactor, WithTx, TxFrom PostgreSQL repository.
repotest.Run, repotest.Record Conformance suite for repositories.

Testing

go test -race ./...
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable go test -race -count=1 ./...

Handler tests cover rendering, the CSP header, the validation round trip, authorization denials and cross-origin rejection with httptest. PostgreSQL tests create a random schema per test and drop it afterwards, so they run in parallel against one database; under CI a missing TEST_DATABASE_URL is a failure, not a skip.

To check your own repository, run the conformance suite against it:

func TestMyRepository(t *testing.T) {
	repotest.Run(t, func(t *testing.T) admin.Repository[repotest.Record] {
		return newMyRepository(t) // an empty repository of repotest.Record
	})
}

Roadmap

  • File and image upload fields.
  • Inline editing of related records (has-many relations on the parent form).
  • CSV and JSON export of lists.
  • A searchable relation picker for large target tables (relation selects currently list the first RelationLimit records).
  • Optimistic concurrency checks on edit (version columns).
  • An audit trail of panel writes.
  • Configurable page sizes per request and saved list views.
  • Localization of built-in messages.

License

MIT; see LICENSE. htmx 2.0.11 is vendored under the Zero-Clause BSD license; see THIRD_PARTY_NOTICES.

Documentation

Overview

Package admin generates a server-rendered admin panel for SaaS back offices from resource definitions written in Go.

A Panel is an http.Handler. Each Resource[T] describes how records of a struct type T are listed, shown, created, edited and deleted: the fields and their input types, list search, filters and sorting, row and bulk actions, a validation hook and hooks around every write. Records are read and written through a Repository[T]; the memrepo subpackage keeps them in memory and the pgxrepo subpackage maps them to PostgreSQL tables through `admin:"column"` struct tags.

panel, err := admin.New(admin.Config{
	Prefix:      "/admin",
	CurrentUser: currentUser,        // from your own session middleware
	Authorizer:  admin.AllowAll(),   // or your role checks
})
if err != nil { ... }
err = admin.Register(panel, admin.Resource[Customer]{
	Name:         "customers",
	Repository:   customers,
	SearchFields: []string{"name", "email"},
	Fields: []admin.Field{
		{Name: "name", Required: true, Sortable: true},
		{Name: "email", Type: admin.FieldEmail, Required: true},
	},
})
mux.Handle("/admin/", panel)

Security

The panel does not authenticate anyone: Config.CurrentUser reads the user established by the host application, and unauthenticated requests go to Config.LoginURL or Config.Unauthenticated. Every page and write asks the Authorizer, first for the resource and then for the record concerned.

Pages are rendered with html/template from embedded templates and served with a strict Content-Security-Policy that allows only the panel's own scripts and styles; there is no inline script or style anywhere. htmx 2 is vendored and embedded, configured through a meta tag so that it needs neither eval nor inline styles. State-changing requests from other origins are rejected with net/http's CrossOriginProtection, so forms need no CSRF tokens. Every page works without JavaScript; htmx only makes list searching, filtering, sorting and paging update in place.

Transactions

Set Config.Transact to run each write together with its Before and After hooks in one transaction. pgxrepo.Transactor provides such a function, and pgxrepo.WithConn(pgxrepo.TxFrom) makes repositories join it.

Example

This example mounts a panel with one resource and drives it with httptest the way a browser would.

package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"

	"github.com/byteminus/go-admin"
	"github.com/byteminus/go-admin/memrepo"
)

type Customer struct {
	ID    int64  `admin:"id"`
	Name  string `admin:"name"`
	Email string `admin:"email"`
	Plan  string `admin:"plan"`
}

type Staff struct{ Name string }

func (s Staff) DisplayName() string { return s.Name }

func main() {
	customers, err := memrepo.New[Customer]()
	if err != nil {
		panic(err)
	}
	panel, err := admin.New(admin.Config{
		Prefix: "/admin",
		CurrentUser: func(r *http.Request) (admin.User, bool) {
			// A real application reads its own session here.
			return Staff{Name: "Grace"}, true
		},
		Authorizer: admin.AllowAll(),
	})
	if err != nil {
		panic(err)
	}
	err = admin.Register(panel, admin.Resource[Customer]{
		Name:         "customers",
		Repository:   customers,
		SearchFields: []string{"name", "email"},
		Title:        func(c Customer) string { return c.Name },
		Fields: []admin.Field{
			{Name: "name", Required: true, Sortable: true},
			{Name: "email", Type: admin.FieldEmail, Required: true},
			{Name: "plan", Type: admin.FieldSelect, Options: []admin.Choice{
				{Value: "free", Label: "Free"},
				{Value: "pro", Label: "Pro"},
			}},
		},
	})
	if err != nil {
		panic(err)
	}
	mux := http.NewServeMux()
	mux.Handle("/admin/", panel)

	submit := func(form url.Values) *http.Response {
		req := httptest.NewRequest(http.MethodPost, "/admin/customers/new", strings.NewReader(form.Encode()))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, req)
		return rec.Result()
	}

	res := submit(url.Values{"name": {"Ada"}, "email": {"not an email"}, "plan": {"pro"}})
	fmt.Println("invalid:", res.StatusCode)

	res = submit(url.Values{"name": {"Ada"}, "email": {"ada@example.com"}, "plan": {"pro"}})
	fmt.Println("valid:", res.StatusCode, res.Header.Get("Location"))

	c, err := customers.Get(context.Background(), "1")
	if err != nil {
		panic(err)
	}
	fmt.Println("stored:", c.Name, c.Email, c.Plan)
}
Output:
invalid: 422
valid: 303 /admin/customers/1/?notice=created
stored: Ada ada@example.com pro

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrNotFound reports that no record has the requested ID.
	ErrNotFound = errors.New("admin: record not found")
	// ErrConflict reports a write that violates a uniqueness or reference
	// constraint, or a create with an ID that already exists.
	ErrConflict = errors.New("admin: conflicting record")
	// ErrInvalidRecord reports a write the store rejected because a value
	// breaks one of its constraints (NOT NULL, CHECK, length or range).
	ErrInvalidRecord = errors.New("admin: invalid record")
	// ErrInvalidQuery reports a list query naming an unknown field or
	// carrying a malformed value.
	ErrInvalidQuery = errors.New("admin: invalid query")
)

Errors returned by repositories and the panel. Check them with errors.Is.

Functions

func Errorf

func Errorf(format string, args ...any) error

Errorf returns a PublicError whose message is formatted from format and args. It does not wrap; set PublicError.Err directly to keep a cause.

func MustRegister

func MustRegister[T any](p *Panel, r Resource[T])

MustRegister is like Register but panics on error. Definitions are written by programmers, so a failure is a bug to fix rather than a condition to handle at run time.

func Register

func Register[T any](p *Panel, r Resource[T]) error

Register adds a resource to the panel. Resources referenced by relation fields must be registered before the resources that reference them.

func WithUser

func WithUser(ctx context.Context, user User) context.Context

WithUser returns a context carrying user. The panel stores the current user this way before calling hooks, actions, widgets and repositories.

Types

type Access

type Access struct {
	// Resource is the resource name.
	Resource string
	// Permission is the operation requested.
	Permission Permission
	// Record is the record concerned, of the resource's record type T (not a
	// pointer), or nil for resource-level checks. Every request first passes
	// a resource-level check. Record-level checks follow for viewing a
	// record, editing it (against the stored record and again against the
	// submitted values), deleting it, running an action on it, and creating
	// it once the submitted values are bound. List pages run record-level
	// checks only to decide which row links and actions to show: which rows
	// a user may see at all is the repository's job (for example by scoping
	// queries to the user's tenant).
	Record any
}

Access describes one authorization question.

type Action

type Action[T any] struct {
	// Name identifies the action in URLs; it must match ^[a-z][a-z0-9_-]*$.
	Name  string
	Label string
	// Confirm, when set, is shown on a confirmation page before running.
	Confirm string
	// Permission is required on the record. It defaults to PermUpdate.
	Permission Permission
	// Destructive styles the button as dangerous.
	Destructive bool
	// Run performs the action, inside Config.Transact when it is set.
	Run func(ctx context.Context, record T) error
}

Action is an operation on a single record, offered on its detail page and list row.

type Authorizer

type Authorizer interface {
	Authorize(ctx context.Context, user User, access Access) (bool, error)
}

Authorizer decides whether a user may perform an operation. Returning an error aborts the request with a server error; returning false denies it with 403 Forbidden. Implementations must be safe for concurrent use.

func AllowAll

func AllowAll() Authorizer

AllowAll returns an Authorizer that permits every operation to every authenticated user. It suits panels whose host already restricts access to administrators before the request reaches the panel.

type AuthorizerFunc

type AuthorizerFunc func(ctx context.Context, user User, access Access) (bool, error)

AuthorizerFunc adapts a function to the Authorizer interface.

Example

Authorizers decide per resource and, where a record is involved, per record.

package main

import (
	"context"
	"fmt"

	"github.com/byteminus/go-admin"
)

type Customer struct {
	ID    int64  `admin:"id"`
	Name  string `admin:"name"`
	Email string `admin:"email"`
	Plan  string `admin:"plan"`
}

type Staff struct{ Name string }

func (s Staff) DisplayName() string { return s.Name }

func main() {
	readOnlyBilling := admin.AuthorizerFunc(func(ctx context.Context, u admin.User, a admin.Access) (bool, error) {
		if a.Resource == "invoices" {
			return a.Permission == admin.PermView, nil
		}
		if c, ok := a.Record.(Customer); ok && a.Permission == admin.PermDelete {
			return c.Plan != "enterprise", nil
		}
		return true, nil
	})
	staff := Staff{Name: "Grace"}
	for _, a := range []admin.Access{
		{Resource: "invoices", Permission: admin.PermView},
		{Resource: "invoices", Permission: admin.PermUpdate},
		{Resource: "customers", Permission: admin.PermDelete, Record: Customer{Plan: "enterprise"}},
		{Resource: "customers", Permission: admin.PermDelete, Record: Customer{Plan: "free"}},
	} {
		ok, _ := readOnlyBilling.Authorize(context.Background(), staff, a)
		fmt.Println(a.Resource, a.Permission, ok)
	}
}
Output:
invoices view true
invoices update false
customers delete false
customers delete true

func (AuthorizerFunc) Authorize

func (f AuthorizerFunc) Authorize(ctx context.Context, user User, access Access) (bool, error)

Authorize calls f.

type BulkAction

type BulkAction[T any] struct {
	// Name identifies the action in forms; it must match ^[a-z][a-z0-9_-]*$.
	Name  string
	Label string
	// Confirm, when set, is shown on a confirmation page before running.
	Confirm string
	// Permission is required on every selected record. It defaults to
	// PermUpdate.
	Permission Permission
	// Run performs the action, inside Config.Transact when it is set.
	Run func(ctx context.Context, records []T) error
}

BulkAction is an operation on the records selected in a list.

type Choice

type Choice struct {
	Value string
	Label string
}

Choice is one option of a select field or filter.

type Condition

type Condition struct {
	Field  string
	Op     Op
	Value  string
	Values []string
}

Condition restricts a list to records whose Field satisfies Op. Values use the textual form of the field's Go type: decimal numbers, "true"/"false", RFC 3339 timestamps or YYYY-MM-DD dates (UTC), and the MarshalText form for types such as uuid.UUID.

type Config

type Config struct {
	// Prefix is the path the panel is mounted under, such as "/admin". It
	// defaults to "/admin"; use "/" to mount the panel at the root. Mount the
	// panel with mux.Handle(prefix+"/", panel).
	Prefix string
	// Title is shown in the header and page titles. It defaults to "Admin".
	Title string
	// CurrentUser extracts the authenticated user from the request.
	CurrentUser UserFunc
	// Authorizer decides what each user may do.
	Authorizer Authorizer
	// Unauthenticated serves requests without a user. When nil, the panel
	// redirects to LoginURL, or responds 401 Unauthorized when LoginURL is
	// empty.
	Unauthenticated http.Handler
	// LoginURL receives unauthenticated requests, with the requested path in
	// the "next" query parameter.
	LoginURL string
	// TrustedOrigins are extra origins (such as "https://admin.example.com")
	// allowed to submit forms across origins. Same-origin requests are
	// always allowed.
	TrustedOrigins []string
	// Location is the time zone for editing and displaying date-times. It
	// defaults to UTC.
	Location *time.Location
	// Transact, when set, runs every write together with its hooks so they
	// commit or roll back together. pgxrepo.Transactor provides one for
	// PostgreSQL.
	Transact func(ctx context.Context, fn func(ctx context.Context) error) error
	// Widgets are shown on the dashboard in order.
	Widgets []Widget
	// MaxFormBytes caps request bodies. It defaults to 1 MiB.
	MaxFormBytes int64
	// Logger receives server errors. It defaults to slog.Default().
	Logger *slog.Logger
}

Config configures a Panel. CurrentUser and Authorizer are required; every other field has a working zero value.

type Field

type Field struct {
	// Name is the column name from the record type's `admin` tag.
	Name string
	// Label defaults to Name humanized ("created_at" becomes "Created at").
	Label string
	Type  FieldType
	// Show restricts the pages the field appears on. Hidden fields default
	// to OnForm.
	Show Visibility
	// Help is shown under the form input.
	Help string
	// Required rejects empty submissions. For FieldBool it requires the box
	// to be ticked.
	Required bool
	// MaxLength caps the number of characters of text inputs.
	MaxLength int
	// Placeholder is shown in empty text inputs.
	Placeholder string
	// Options lists the choices of a FieldSelect.
	Options []Choice
	// Relation names the target resource of a FieldRelation. The target must
	// be registered first, or be this resource.
	Relation string
	// Sortable lets users order lists by this field.
	Sortable bool
}

Field describes one column of a resource.

type FieldErrors

type FieldErrors map[string]string

FieldErrors maps field names to messages shown next to the form inputs. The empty key holds a message for the form as a whole. Validation hooks return it directly; Before and After hooks may return it as an error to redisplay the form.

func (FieldErrors) Error

func (fe FieldErrors) Error() string

Error lists the fields with errors.

type FieldType

type FieldType string

FieldType selects how a field is edited and displayed.

const (
	FieldText     FieldType = "text"
	FieldTextarea FieldType = "textarea"
	FieldNumber   FieldType = "number"
	FieldBool     FieldType = "bool"
	// FieldSelect offers Field.Options and rejects any other value.
	FieldSelect FieldType = "select"
	// FieldDate stores midnight UTC of the chosen day.
	FieldDate FieldType = "date"
	// FieldDateTime is edited and displayed in Config.Location.
	FieldDateTime FieldType = "datetime"
	FieldEmail    FieldType = "email"
	// FieldURL accepts absolute http and https URLs.
	FieldURL FieldType = "url"
	// FieldHidden is submitted as a hidden input. Like any form value it can
	// be altered by the client, so validate it.
	FieldHidden FieldType = "hidden"
	// FieldReadOnly is displayed on forms but never bound from them.
	FieldReadOnly FieldType = "readonly"
	// FieldRelation stores the ID of a record of the resource named by
	// Field.Relation and is edited with a select of that resource's records.
	FieldRelation FieldType = "relation"
)

Field types. When Field.Type is empty it is inferred from the Go type: strings and text types become FieldText, booleans FieldBool, numbers FieldNumber and time.Time FieldDateTime.

type Filter

type Filter struct {
	// Field names a field declared in Resource.Fields.
	Field string
	Type  FilterType
	// Label defaults to the field's label.
	Label   string
	Options []Choice
}

Filter narrows a list by one field.

type FilterType

type FilterType string

FilterType selects the control a filter uses.

const (
	// FilterSelect offers Filter.Options, falling back to the field's options
	// or, for relation fields, the target resource's records.
	FilterSelect FilterType = "select"
	// FilterBool offers yes and no.
	FilterBool FilterType = "bool"
	// FilterDateRange offers inclusive from and to dates.
	FilterDateRange FilterType = "daterange"
)

Filter types.

type Hooks

type Hooks[T any] struct {
	BeforeCreate func(ctx context.Context, record *T) error
	AfterCreate  func(ctx context.Context, record T) error
	BeforeUpdate func(ctx context.Context, old T, record *T) error
	AfterUpdate  func(ctx context.Context, old, record T) error
	BeforeDelete func(ctx context.Context, record T) error
	AfterDelete  func(ctx context.Context, record T) error
}

Hooks run around repository writes. Returning an error aborts the operation (and rolls back the transaction when Config.Transact is set). FieldErrors and PublicError values are shown to the user; other errors are logged and reported as a server error.

type ListQuery

type ListQuery struct {
	// Search is matched case-insensitively as a substring of any of
	// SearchFields. It is ignored when empty or when SearchFields is empty.
	Search       string
	SearchFields []string
	// Conditions must all hold.
	Conditions []Condition
	// Sort orders the results; ties are broken by ID ascending. An empty
	// Sort.Field orders by ID ascending.
	Sort Sort
	// Offset skips that many records. Limit caps the page size; zero or a
	// negative value returns every matching record.
	Offset int
	Limit  int
}

ListQuery selects a page of records. The zero value lists every record in ID order.

func (ListQuery) Validate

func (q ListQuery) Validate() error

Validate reports structural problems that do not depend on the record type.

type ListResult

type ListResult[T any] struct {
	Items []T
	// Total counts every record matching the query, ignoring Offset and Limit.
	Total int
}

ListResult is one page of records.

type Op

type Op string

Op is a comparison operator in a Condition.

const (
	// OpEq matches records whose field equals Value. On a nullable field an
	// empty Value matches null.
	OpEq Op = "eq"
	// OpGte matches records whose field is greater than or equal to Value.
	OpGte Op = "gte"
	// OpLt matches records whose field is less than Value.
	OpLt Op = "lt"
	// OpIn matches records whose field equals any of Values.
	OpIn Op = "in"
)

Operators supported by every repository.

type Panel

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

Panel is an http.Handler serving the admin panel. Register resources before serving requests; a Panel is safe for concurrent use.

func New

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

New returns a panel configured by cfg.

func (*Panel) ServeHTTP

func (p *Panel) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP serves the panel.

func (*Panel) URL

func (p *Panel) URL(name, id string) string

URL returns the panel path for the given resource and record ID, or the dashboard when name is empty and the resource list when id is empty.

type Permission

type Permission string

Permission is an operation a user may be allowed to perform on a resource.

const (
	PermView   Permission = "view"
	PermCreate Permission = "create"
	PermUpdate Permission = "update"
	PermDelete Permission = "delete"
)

Permissions checked by the panel.

type PublicError

type PublicError struct {
	Message string
	Err     error
}

PublicError carries a message that is safe to show to panel users. Hooks and actions return it to explain why an operation was refused; any other error is logged and replaced with a generic message.

func (*PublicError) Error

func (e *PublicError) Error() string

Error returns the message prefixed with the package name.

func (*PublicError) Unwrap

func (e *PublicError) Unwrap() error

Unwrap returns the underlying cause, if any.

type Repository

type Repository[T any] interface {
	// List returns one page of records matching q and the total number of
	// matching records.
	List(ctx context.Context, q ListQuery) (ListResult[T], error)
	// Get returns the record with the given ID.
	Get(ctx context.Context, id string) (T, error)
	// Create stores a new record and updates *record with the stored values,
	// including generated IDs and store-managed columns.
	Create(ctx context.Context, record *T) error
	// Update replaces the record with the same ID and updates *record with
	// the stored values.
	Update(ctx context.Context, record *T) error
	// Delete removes the record with the given ID.
	Delete(ctx context.Context, id string) error
}

Repository is the data access the panel needs for one resource. Field names in a ListQuery are the column names from the record type's `admin` tags, and values are strings in the formats described on Condition.

Implementations return errors wrapping ErrNotFound for missing records (including IDs that cannot be parsed), ErrInvalidQuery for unknown fields or malformed values, and ErrConflict or ErrInvalidRecord when the store rejects a write. Implementations must be safe for concurrent use.

type Resource

type Resource[T any] struct {
	// Name identifies the resource in URLs and authorization checks. It must
	// match ^[a-z][a-z0-9_-]*$ and be unique within a panel.
	Name string
	// Singular and Plural are display labels. Plural defaults to Name
	// humanized ("line_items" becomes "Line items") and Singular to Plural
	// without a trailing "s"; set them when that rule does not fit.
	Singular string
	Plural   string
	// Group places the resource under a heading in the navigation.
	Group string
	// IDField names the primary key column. It defaults to "id".
	IDField string
	// DefaultSort orders lists when the request does not choose a column. It
	// defaults to the ID ascending.
	DefaultSort Sort
	// SearchFields are string columns matched by the list search box.
	SearchFields []string
	// Fields are shown in declaration order.
	Fields []Field
	// Filters appear beside the list.
	Filters []Filter
	// RowActions run on one record; BulkActions on the records selected in
	// a list.
	RowActions  []Action[T]
	BulkActions []BulkAction[T]
	// Repository stores the records. It is required.
	Repository Repository[T]
	// PageSize is the number of rows per list page. It defaults to 25 and is
	// capped at 500.
	PageSize int
	// RelationLimit caps the options a relation select offers when this
	// resource is the target of a FieldRelation. It defaults to 200.
	RelationLimit int
	// Title labels a record in headings and relation selects. It defaults to
	// the singular label followed by the ID.
	Title func(record T) string
	// New returns the initial values for the create form. It defaults to the
	// zero value of T.
	New func(ctx context.Context) T
	// Validate checks a record after the submitted form values are bound and
	// the built-in checks pass. Returning a non-empty FieldErrors redisplays
	// the form.
	Validate func(ctx context.Context, record T) FieldErrors
	// Hooks run around writes, inside Config.Transact when it is set.
	Hooks Hooks[T]
}

Resource defines how the panel lists, shows and edits records of type T. T must be a struct whose persisted fields carry `admin:"column"` tags; the tag option `readonly` (as in `admin:"created_at,readonly"`) marks columns the store manages, which the bundled repositories never write.

Supported field types are strings, booleans, integers, floats, time.Time, types implementing both encoding.TextMarshaler and encoding.TextUnmarshaler (such as uuid.UUID), named types based on these, and pointers to any of them for nullable columns.

type Sort

type Sort struct {
	Field string
	Desc  bool
}

Sort names the field to order by.

type User

type User interface {
	// DisplayName is shown in the panel header.
	DisplayName() string
}

User is the authenticated person using the panel. The panel never authenticates anyone itself; Config.CurrentUser extracts the user that the host application's own authentication middleware established.

func UserFrom

func UserFrom(ctx context.Context) (User, bool)

UserFrom returns the user stored by WithUser.

type UserFunc

type UserFunc func(r *http.Request) (User, bool)

UserFunc extracts the authenticated user from a request, reporting false when the request is unauthenticated.

type Visibility

type Visibility uint8

Visibility is a set of pages a field appears on. The zero value means every page.

const (
	OnList Visibility = 1 << iota
	OnDetail
	OnForm
)

Pages a field can appear on.

type Widget

type Widget struct {
	// Title is the widget heading.
	Title string
	// Resource, when set, shows the widget only to users allowed to view
	// that resource and links the heading to its list.
	Resource string
	// Render produces the widget body. The returned HTML is inserted
	// verbatim, so build it with html/template or escape every dynamic
	// value. Inline scripts and styles are blocked by the panel's
	// Content-Security-Policy. The current user is available through
	// UserFrom. An error is logged and the widget shows as unavailable.
	Render func(ctx context.Context) (template.HTML, error)
}

Widget is a dashboard panel.

func Count

func Count[T any](title string, repo Repository[T], conditions ...Condition) Widget

Count returns a widget showing how many records of repo match conditions. Set the returned widget's Resource to link it to the resource list and hide it from users who cannot view that resource.

Example

Count widgets show live record counts on the dashboard.

package main

import (
	"context"
	"fmt"

	"github.com/byteminus/go-admin"
	"github.com/byteminus/go-admin/memrepo"
)

type Customer struct {
	ID    int64  `admin:"id"`
	Name  string `admin:"name"`
	Email string `admin:"email"`
	Plan  string `admin:"plan"`
}

func main() {
	customers, err := memrepo.New[Customer]()
	if err != nil {
		panic(err)
	}
	for _, plan := range []string{"pro", "free", "pro"} {
		if err := customers.Create(context.Background(), &Customer{Name: "c", Email: "c@example.com", Plan: plan}); err != nil {
			panic(err)
		}
	}
	w := admin.Count("Pro customers", customers, admin.Condition{Field: "plan", Op: admin.OpEq, Value: "pro"})
	html, err := w.Render(context.Background())
	fmt.Println(html, err)
}
Output:
<p class="stat">2</p> <nil>

func Stat

func Stat(title string, value func(ctx context.Context) (string, error)) Widget

Stat returns a widget showing the single value that value computes, such as a revenue figure.

Directories

Path Synopsis
internal
structmap
Package structmap maps the `admin:"column"` struct tags of a record type to typed columns.
Package structmap maps the `admin:"column"` struct tags of a record type to typed columns.
Package memrepo is an in-memory admin.Repository for tests, examples and prototypes.
Package memrepo is an in-memory admin.Repository for tests, examples and prototypes.
Package pgxrepo is a generic PostgreSQL admin.Repository built on github.com/jackc/pgx/v5.
Package pgxrepo is a generic PostgreSQL admin.Repository built on github.com/jackc/pgx/v5.
Package repotest is a conformance suite for admin.Repository implementations.
Package repotest is a conformance suite for admin.Repository implementations.

Jump to

Keyboard shortcuts

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