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 ¶
- Variables
- func Errorf(format string, args ...any) error
- func MustRegister[T any](p *Panel, r Resource[T])
- func Register[T any](p *Panel, r Resource[T]) error
- func WithUser(ctx context.Context, user User) context.Context
- type Access
- type Action
- type Authorizer
- type AuthorizerFunc
- type BulkAction
- type Choice
- type Condition
- type Config
- type Field
- type FieldErrors
- type FieldType
- type Filter
- type FilterType
- type Hooks
- type ListQuery
- type ListResult
- type Op
- type Panel
- type Permission
- type PublicError
- type Repository
- type Resource
- type Sort
- type User
- type UserFunc
- type Visibility
- type Widget
Examples ¶
Constants ¶
This section is empty.
Variables ¶
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 ¶
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 ¶
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.
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 ¶
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
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 Condition ¶
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 ¶
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.
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.
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.
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 ¶
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 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.
type UserFunc ¶
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>
Source Files
¶
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. |