Skip to content
byteminusPublic

About

Resource-driven admin panel for Go: define resources in code and get server-rendered lists, forms, filters and actions, with htmx and a strict CSP.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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"`
	Version   int64     `admin:"version"`
}

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.WithContextTx(), pgxrepo.WithVersionColumn("version"))
	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", 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},
		VersionField: "version",
		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; they are never bound from forms, whatever field type they are declared with.

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, FieldReadOnly and FieldRelation (a select of another resource's records, named by Relation). Required, MaxLength, Help, Placeholder and Sortable (for fields shown on the list) complete the definition.

What a form can write. Only fields declared in Fields, shown on the form (OnForm), of an editable type and not tagged readonly are bound from a submission; every other submitted value is ignored, so a crafted POST cannot set store-managed, computed or undeclared columns. A field kept off the form gets its value from Resource.New on create and keeps its stored value on edit. The ID can be entered when creating a record but never changed afterwards, and the version column is never bound.

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, on the server, for every request: hiding a button is never the only check. Every request passes a resource-level check first; record-level checks follow for viewing (including each list row, which is replaced by a placeholder when denied), editing (against both the stored and the submitted record), deleting, running actions, creating once values are bound, and every record selected for a bulk action (one denied record refuses the whole action). Edit, delete and action pages also require PermView on the record, so a write permission never shows more than the detail page would. Relation selects, relation titles and relation filters only list target records the user may view.

Row-level scope. A per-record PermView denial hides a list row's data but not its existence: without more, the row still matches search and filters, still counts in the total and still takes its place in the sort order, so a user can recover the searchable, filterable and sortable values of records they may not view, one guess at a time. SearchFields may also name columns the list does not display. When some users may list a resource but not every record in it, set Resource.Scope: it returns conditions for the current user that the panel adds to every query for that resource (lists, search, filters, sorting, totals, relation options and titles, bulk selections and the lookup behind detail, edit, delete and action pages, where an out-of-scope record is not found). The Authorizer's per-record checks stay as the final guard. A repository already scoped to the user works too.

admin.Resource[Customer]{
	// ...
	Scope: func(ctx context.Context) ([]admin.Condition, error) {
		u, _ := admin.UserFrom(ctx)
		return []admin.Condition{{Field: "account_manager", Op: admin.OpEq, Value: u.DisplayName()}}, nil
	},
}

Count widgets query their repository directly; give them the same conditions.

The current user. Config.CurrentUser resolves the user from the request; the panel then stores it with admin.WithUser, so the Authorizer, hooks, actions, widgets and repositories read it with admin.UserFrom(ctx). When your middleware already keeps the user in the context, adapt that lookup once at startup:

panel, err := admin.New(admin.Config{
	CurrentUser: func(r *http.Request) (admin.User, bool) {
		u, ok := myapp.UserFrom(r.Context())
		return Staff{Email: u.Email}, ok && u.IsStaff
	},
	Authorizer: admin.AllowAll(),
})

Concurrent edits. Without a version, the second of two admins editing the same record silently overwrites the first, and an edit also writes back the columns it did not show as they were when the form was submitted. Set Resource.VersionField to an integer column and enable it on the repository (memrepo.WithVersionColumn, pgxrepo.WithVersionColumn): the edit form carries the version the user saw, and a save after someone else's is refused with a message asking the user to reload, instead of overwriting. pgxrepo checks and increments the version in the same UPDATE, so the check holds under concurrency; other writers of the table must increment the column too.

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 (it checks Sec-Fetch-Site, then Origin against Host); Config.TrustedOrigins must be written exactly as browsers send them, and New rejects anything else. Forms therefore need no CSRF tokens, and no GET request changes state: deletes and actions show a confirmation page on GET and run only on POST, with no method override. Request bodies, URL-encoded or multipart, are capped by Config.MaxFormBytes; file uploads are refused. Redirects after a write go only to panel paths, and the login next parameter is always a same-site path. All output goes through html/template escaping; the only trusted HTML is what custom widgets return. Stored URL and email values are linked only when they are valid http(s) URLs and plain addresses; javascript: and data: URLs, and addresses containing ?, &, % or = (which would add mailto: headers such as bcc), show as plain text. 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 of a named resource. A widget with a Resource is shown only to users who may view that resource and links to its list; a widget without one is shown to everyone who can open the panel. Custom widgets return trusted template.HTML, so build it with html/template.

Lists and paging. Lists are paged by number with OFFSET, because people navigate admin lists by page. Page sizes are capped at MaxPageSize (500) and page numbers at 100000. On very large tables a deep page still makes the database read and discard every skipped row, and each page counts all matching rows; index the sort, filter and search columns and narrow lists with filters or tenant scoping.

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(),
    version    bigint NOT NULL DEFAULT 0
);
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. Sort and condition fields from a request are checked against the resource definition by the panel and against the struct's columns by the repository, so a crafted sort parameter cannot reach the SQL.
  • 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, with PostgreSQL's microsecond precision (memrepo truncates the same way).
  • WithVersionColumn("version") makes Update conditional on the version and increments it; a stale update fails with admin.ErrVersionMismatch.

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.WithContextTx())
// ...
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. When your application carries its own transaction in the context, pass its lookup to pgxrepo.WithConn instead:

repo, err := pgxrepo.New[Customer](pool, "customers",
	pgxrepo.WithConn(func(ctx context.Context) pgxrepo.Querier {
		if tx, ok := myapp.TxFrom(ctx); ok {
			return tx
		}
		return nil // use the pool
	}))

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, ErrInvalid, ErrConflict Family sentinels, checked with errors.Is.
ErrInvalidRecord, ErrInvalidQuery, ErrVersionMismatch Repository errors wrapping ErrInvalid or ErrConflict.
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.
DefaultPageSize, MaxPageSize, DefaultRelationLimit, DefaultMaxFormBytes, MaxBulkSelection Defaults and limits.
memrepo.New, WithIDColumn, WithUnique, WithVersionColumn In-memory repository (not transactional).
pgxrepo.New, WithIDColumn, WithConn, WithContextTx, WithVersionColumn, Transactor, WithTx, TxFrom PostgreSQL repository.
repotest.Run, repotest.Record, repotest.RunVersioned, repotest.VersionedRecord Conformance suites 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 suites against it (RunVersioned only if it supports version columns):

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
	})
	repotest.RunVersioned(t, func(t *testing.T) admin.Repository[repotest.VersionedRecord] {
		return newMyVersionedRepository(t)
	})
}

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).
  • An audit trail of panel writes.
  • Configurable page sizes per request and saved list views.
  • Localization of built-in messages.

Stability

This package follows semantic versioning. Before v1.0.0, a minor release may contain breaking changes; each one is listed in CHANGELOG.md with upgrade steps, including SQL for schema changes. From v1.0.0, the exported API and the PostgreSQL schema change only in backwards-compatible ways until the next major version.

License

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

About

Resource-driven admin panel for Go: define resources in code and get server-rendered lists, forms, filters and actions, with htmx and a strict CSP.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages