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. The user is then stored with WithUser, so the Authorizer, hooks, actions, widgets and repositories read it with UserFrom. Every page and write asks the Authorizer, first for the resource and then for each record concerned, including list rows, bulk selections and relation options; every page that shows a record requires PermView on it. Per-record denials do not stop search and filters from matching hidden rows, so set Resource.Scope to keep a user's queries to the records they may reach.
Only fields declared on the form with an editable type are bound from a submission; columns tagged `readonly`, fields kept off the form, the version column and, after creation, the ID cannot be written by a crafted request.
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, and no GET request changes state. Every page works without JavaScript; htmx only makes list searching, filtering, sorting and paging update in place.
Concurrent edits ¶
Set Resource.VersionField, and enable the same column on the repository, to refuse an edit saved after someone else's instead of silently overwriting it.
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.WithContextTx 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 ¶
- Constants
- 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 ¶
const ( // DefaultPageSize is the list page size when Resource.PageSize is zero. DefaultPageSize = 25 // MaxPageSize caps Resource.PageSize and Resource.RelationLimit. MaxPageSize = 500 // DefaultRelationLimit is the number of relation select options when // Resource.RelationLimit is zero. DefaultRelationLimit = 200 )
Limits and defaults applied to resource definitions.
const DefaultMaxFormBytes = 1 << 20
DefaultMaxFormBytes is the request body limit when Config.MaxFormBytes is zero.
const MaxBulkSelection = 1000
MaxBulkSelection is the most rows one bulk action may be applied to; a larger selection is refused with 413 Request Entity Too Large.
Variables ¶
var ( // ErrNotFound reports that no record has the requested ID. ErrNotFound = errors.New("admin: record not found") // ErrInvalid reports input that failed validation. ErrInvalidRecord, // ErrInvalidQuery and FieldErrors all satisfy errors.Is(err, ErrInvalid). ErrInvalid = errors.New("admin: invalid input") // ErrConflict reports a write that violates a uniqueness or reference // constraint, a create with an ID that already exists, or a record whose // state no longer allows the operation. 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 = fmt.Errorf("admin: invalid record: %w", ErrInvalid) // ErrInvalidQuery reports a list query naming an unknown field or // carrying a malformed value. ErrInvalidQuery = fmt.Errorf("admin: invalid query: %w", ErrInvalid) // ErrVersionMismatch reports an update whose record carries a version // other than the stored one: someone else saved the record after it was // read. See Resource.VersionField. ErrVersionMismatch = fmt.Errorf("admin: record was changed by someone else: %w", ErrConflict) )
Errors returned by repositories and the panel. Check them with errors.Is. ErrNotFound, ErrInvalid and ErrConflict are the sentinels shared by every Byte Minus package; the more specific errors wrap one of them.
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, and every record the panel shows or changes
// then passes a record-level one:
//
// - viewing a record, and each row of a list: a row the user may not
// view is replaced by a placeholder that shows none of its data;
// - editing (against the stored record and again against the submitted
// values), deleting, and running a row action;
// - creating, once the submitted values are bound;
// - every record selected for a bulk action, which is refused as a
// whole when any one of them is denied;
// - relation options, relation titles and relation filters, which only
// list target records the user may view.
//
// Hiding which records exist at all, and how many, 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 is the button text. It defaults to Name humanized. 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 reports whether user may perform access.
Authorize(ctx context.Context, user User, access Access) (bool, error)
}
Authorizer decides whether a user may perform an operation. The application implements it. Returning an error aborts the request with a server error; returning false denies it with 403 Forbidden, or leaves the denied record out of the page. The user is never nil. Implementations must be safe for concurrent use and should be fast: a list page asks about every row it shows.
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 is the option text. It defaults to Name humanized. 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. At most MaxBulkSelection records may be selected. 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 is the submitted and stored value.
Value string
// Label is shown to the user.
Label string
}
Choice is one option of a select field or filter.
type Condition ¶
type Condition struct {
// Field is a column name.
Field string
// Op is the comparison.
Op Op
// Value is the operand of OpEq, OpGte and OpLt.
Value string
// Values are the operands of OpIn.
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 resolves the authenticated user from the request,
// reporting false (or a nil User) when there is none. It is how the
// application supplies its own session lookup: the panel calls it once
// per request, then stores the user with WithUser so that the
// Authorizer, hooks, actions, widgets and repositories can read it back
// with UserFrom. When your authentication middleware already keeps the
// user in the request context, adapt that lookup here.
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 panel
// path in the "next" query parameter. The panel only ever puts a
// same-site path there (it starts with a single "/"); the login handler
// should still check next the same way before redirecting to it.
LoginURL string
// TrustedOrigins are extra origins allowed to submit forms across
// origins, each written exactly as a browser sends it in the Origin
// header: a lower-case http or https scheme and host, an optional
// non-default port, and nothing else ("https://admin.example.com").
// Same-origin requests are always allowed. New rejects any other form,
// because an origin that can never match would silently do nothing.
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, URL-encoded and multipart alike. It
// defaults to DefaultMaxFormBytes; larger bodies are refused with 413
// Request Entity Too Large.
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 selects the input and display; empty infers it from the Go type.
Type FieldType
// Show restricts the pages the field appears on. A field not shown on
// the form is never bound from submissions: its value comes from
// Resource.New when creating and stays as stored when editing.
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. It takes effect only
// when the field is shown on the list.
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. It satisfies errors.Is(err, ErrInvalid).
func (FieldErrors) Error ¶
func (fe FieldErrors) Error() string
Error lists each field with its message, in field name order.
func (FieldErrors) Is ¶ added in v0.2.0
func (fe FieldErrors) Is(target error) bool
Is reports whether target is ErrInvalid, so validation failures are recognised the same way across packages.
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 and links only to them, // so stored javascript: or data: URLs are shown as plain text. FieldURL FieldType = "url" // FieldReadOnly is displayed on forms but never bound from them. Columns // tagged `readonly` are never bound either, whatever their field type. 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 selects the control.
Type FilterType
// Label defaults to the field's label.
Label string
// Options are the choices of a FilterSelect; values not listed are
// ignored.
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 may change the bound record before it is stored. BeforeCreate func(ctx context.Context, record *T) error // AfterCreate receives the record as stored, with generated values. AfterCreate func(ctx context.Context, record T) error // BeforeUpdate receives the stored record and may change the updated // one before it is stored. BeforeUpdate func(ctx context.Context, old T, record *T) error // AfterUpdate receives the record before and after the update. AfterUpdate func(ctx context.Context, old, record T) error // BeforeDelete receives the record about to be deleted. BeforeDelete func(ctx context.Context, record T) error // AfterDelete receives the deleted record. 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.
When Config.Transact retries its function, the hooks run again with fresh copies of the records.
type ListQuery ¶
type ListQuery struct {
// Search is matched case-insensitively as a literal substring (LIKE
// wildcards in it have no special meaning) of any of SearchFields. It is
// ignored when empty or when SearchFields is empty. SearchFields come
// from the resource definition, never from the request.
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. The panel always sets
// a positive Limit no larger than MaxBulkSelection.
Offset int
Limit int
}
ListQuery selects a page of records. The zero value lists every record in ID order.
The panel pages lists by number, so Offset grows with the page: on a very large table the database still reads and discards every skipped row, and counting the matches for Total scans them too. Index the columns used for sorting, filtering and search, and keep the matching set small with filters or a repository scoped to the user; the panel caps the page size at MaxPageSize and the page number at 100000.
type ListResult ¶
type ListResult[T any] struct { // Items are the records on the page, in order. 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 ¶
type PublicError struct {
// Message is shown to the user as is (HTML-escaped).
Message string
// Err is the underlying cause, reachable with errors.Is and errors.As
// but never shown.
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. When the resource sets VersionField, Update must // write only if the stored version equals the record's, increment it, // and otherwise return an error wrapping ErrVersionMismatch. 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. The application implements it, or uses memrepo or pgxrepo. 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 and honour context cancellation. The repotest package checks these rules.
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. They // may name columns the list does not display; a user can then probe // their contents through search, so name only columns every user who // may open the list may read. 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 // DefaultPageSize and is capped at MaxPageSize. Lists page with OFFSET so // that people can jump to a page number; see ListQuery for the cost on // very large tables. PageSize int // RelationLimit caps the options a relation select offers when this // resource is the target of a FieldRelation. It defaults to // DefaultRelationLimit and is capped at MaxPageSize. RelationLimit int // VersionField, when set, names a non-pointer integer column used for // optimistic concurrency control. The edit form carries the version the // user saw; saving fails with a conflict message instead of overwriting // when the stored version differs. The Repository must reject an Update // whose record version differs from the stored one with an error // wrapping ErrVersionMismatch, and increment the version on success: // memrepo.WithVersionColumn and pgxrepo.WithVersionColumn do this. // Without that support the panel still compares versions before the // update, which narrows but does not close the race. The version is // never bound from forms. VersionField string // 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] // Scope, when set, returns conditions that limit the records the // current user may reach, such as {Field: "tenant_id", Op: OpEq, Value: // tenant}. The panel adds them to every query it makes for this // resource: list pages (and so search, filters, sorting, paging and the // total), relation options and titles, bulk selections, and the lookup // behind detail, edit, delete and action pages, where a record outside // the scope is not found. Returning an error fails the request. The // Authorizer's per-record checks still apply as the final guard. // // Without Scope, a per-record PermView denial hides a list row's data // but not its existence: the row still matches search and filters and // still counts, so a user can learn the searchable, filterable and // sortable values of records they may not view. Use Scope (or a // repository already scoped to the user) whenever some users may list // a resource but not view all of its records. // // Count widgets query their repository directly; give them the same // conditions yourself. Scope func(ctx context.Context) ([]Condition, error) }
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.
Example (VersionField) ¶
A version field turns a second, stale save into a conflict instead of a silent overwrite.
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 Staff struct{ Name string }
func (s Staff) DisplayName() string { return s.Name }
type Article struct {
ID int64 `admin:"id"`
Title string `admin:"title"`
Version int64 `admin:"version"`
}
func main() {
articles, err := memrepo.New[Article](memrepo.WithVersionColumn("version"))
if err != nil {
panic(err)
}
panel, err := admin.New(admin.Config{
CurrentUser: func(*http.Request) (admin.User, bool) { return Staff{Name: "Grace"}, true },
Authorizer: admin.AllowAll(),
})
if err != nil {
panic(err)
}
admin.MustRegister(panel, admin.Resource[Article]{
Name: "articles",
Repository: articles,
VersionField: "version",
Fields: []admin.Field{{Name: "title", Required: true}},
})
if err := articles.Create(context.Background(), &Article{Title: "Draft"}); err != nil {
panic(err)
}
save := func(title, version string) int {
form := url.Values{"title": {title}, "admin.version": {version}}
req := httptest.NewRequest(http.MethodPost, "/admin/articles/1/edit", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
panel.ServeHTTP(rec, req)
return rec.Code
}
// Two editors opened the form at version 0.
fmt.Println("first save:", save("Published", "0"))
fmt.Println("second save:", save("Overwritten", "0"))
a, _ := articles.Get(context.Background(), "1")
fmt.Println("stored:", a.Title, a.Version)
}
Output: first save: 303 second save: 409 stored: Published 1
type Sort ¶
type Sort struct {
// Field is a column name; empty means the ID.
Field string
// Desc reverses the order. Nulls sort last ascending and first
// descending, as in PostgreSQL.
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 application implements it. The panel never authenticates anyone itself; Config.CurrentUser extracts the user that the host application's own authentication middleware established.
type UserFunc ¶
UserFunc resolves the authenticated user of a request, reporting false when the request is unauthenticated. It is the type of Config.CurrentUser.
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. A widget without a
// Resource is shown to every user who can open the panel, so give every
// widget that reveals data from a resource that resource's name.
Resource string
// Render produces the widget body and is called only for users who may
// see the widget. The returned HTML is inserted verbatim: it is the one
// place the panel trusts HTML from the application, 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, resource string, repo Repository[T], conditions ...Condition) Widget
Count returns a widget showing how many records of repo match conditions. The widget belongs to the named resource: only users allowed to view it see the count, and the heading links to its list.
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", 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. |