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

jetadmin

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 16, 2026 License: MIT Imports: 32 Imported by: 0

README

jet-admin

A Django-admin-style web admin for Go applications on PostgreSQL whose schema is described by go-jet/jet generated code. Register a page per table with the builder and model Jet generated, mount one http.Handler, and your team can list, search, filter, sort, add, change and delete rows, with an audit log of who did what.

  • Every statement is built with Jet's PostgreSQL DSL and run through pgx; values are always parameters.
  • Pages are rendered with templ and work without JavaScript: plain links, and forms answered with 303 redirects. Datastar (embedded, served by the admin) makes the search live, the filters, sorting and paging patch in place, form errors appear without a reload, and confirmations open as dialogs.
  • Database constraints are the validation: a unique, foreign key, check or not-null violation comes back on the form, on the field it is about, with the constraint's name.
  • A database-backed sign-in (package auth) with bcrypt passwords, hashed session tokens and a login backoff, or your own Authenticator.
  • One hand-written stylesheet, light and dark, usable on a phone.

Install

go get github.com/garden-studio/jet-admin

It needs Go 1.26 and PostgreSQL 13 or later.

Usage

1. Migrate

The admin needs three tables of its own: admin_users and admin_sessions for the auth package, admin_log for the audit log. Their DDL is jetadmin.Schema (the file schema.sql); paste it into one of your migrations, for example with goose:

-- +goose Up
-- (the contents of schema.sql)

-- +goose Down
DROP TABLE admin_log;
DROP TABLE admin_sessions;
DROP TABLE admin_users;

The tables are referenced without a schema, so they are found on your search_path.

2. Register your tables and mount the admin

table and model are your packages generated by Jet. The model can be Jet's default one or one with db:"column" tags: a column matches the field of its db tag, else the field whose name is the column in CamelCase (user_id → UserID).

package main

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

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

	jetadmin "github.com/garden-studio/jet-admin"
	"github.com/garden-studio/jet-admin/auth"

	"example.com/app/internal/db/jet/model"
	"example.com/app/internal/db/jet/table"
)

func main() {
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}

	signIn := auth.New(pool, auth.Options{Secure: true})
	site, err := jetadmin.New(jetadmin.Config{
		DB:       pool,
		Title:    "Sumi admin",
		BasePath: "/admin",
		Auth:     signIn,
		AuditLog: true,
	})
	if err != nil {
		log.Fatal(err)
	}

	d, a := table.Devices, table.MailAccounts
	must(jetadmin.Register(site, jetadmin.Model[model.Devices]{
		Table:        d,
		Group:        "Push",
		ListDisplay:  []jetadmin.Column{d.ID, d.Platform, d.Environment, d.CreatedAt},
		ListFilter:   []jetadmin.Column{d.Platform, d.Environment, d.CreatedAt},
		SearchFields: []jetadmin.Column{d.ID, d.Token},
		Ordering:     []jetadmin.Order{jetadmin.Desc(d.CreatedAt)},
		ReadOnly:     []jetadmin.Column{d.CreatedAt},
		Fields: []jetadmin.Field{
			{Column: d.Platform, Choices: jetadmin.Choices("ios", "macos")},
		},
		Inlines: []jetadmin.Inline{{Model: "registrations", Column: table.Registrations.DeviceID}},
		Display: func(m model.Devices) string { return m.Platform + " " + m.ID.String()[:8] },
	}))
	must(jetadmin.Register(site, jetadmin.Model[model.MailAccounts]{
		Table:  a,
		Group:  "Push",
		Hidden: []jetadmin.Column{a.SealedCredentials}, // never selected for a page, shown or written
	}))
	must(jetadmin.Register(site, jetadmin.Model[model.PushLogs]{
		Table:       table.PushLogs,
		Group:       "Push",
		Permissions: jetadmin.ViewOnly, // a log is read, not edited
	}))
	must(auth.Register(site)) // the admin users, managed in the admin itself

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

func must(err error) {
	if err != nil {
		log.Fatal(err)
	}
}
3. Create the first admin user

From your own command line (a createadmin subcommand, say):

user, err := auth.CreateUser(ctx, pool, "ivan", password)
// later: auth.SetPassword(ctx, pool, "ivan", newPassword)
//        auth.Deactivate(ctx, pool, "ivan")  // also ends their sessions

Then sign in at /admin/. Further admin users can be added from the "Admin users" page.

Try the demo

example/ is a runnable site over three related tables (a uuid key, an identity key, jsonb, a text array, a nullable timestamp, choices, a foreign key and a hidden secret):

DATABASE_URL=postgres://user:pass@localhost:5432/demo go run ./example

It creates its tables, seeds a few rows, creates the user admin with a fresh password (printed; set ADMIN_PASSWORD to choose it) and serves http://localhost:8080/admin/.

Pages

Under BasePath:

Path Page
/ the models by group, and the recent actions
/{slug}/ the list: search, filters, sortable headers, pages with the total, bulk delete
/{slug}/add a new row
/{slug}/{pk}/ the change form, with the inlines
/{slug}/{pk}/delete the delete confirmation
/{slug}/{pk}/history the row's audit log entries
/log every action
/login, /logout when the Authenticator is a LoginProvider (auth is)
/static/… the stylesheet and Datastar

A list's whole state is in its address, so a filtered list reloads and can be shared: ?q=ann&f_plan=pro&f_created_at__since=7d&f_notes__isnull=true&o=-created_at&p=2. f_<column>=<value> matches the column's text form exactly (any column, not only the ones in ListFilter), __since takes today, 7d, 30d or year, __isnull takes true or false.

Options

jetadmin.Config:

Field
DB what statements run on; a *pgxpool.Pool
Title the header and page titles
BasePath where Handler is mounted, without the trailing slash
Auth required; auth.New(…) or your own Authenticator
Logger for the failures a page cannot show; slog.Default() otherwise
AuditLog record adds, changes and deletes in admin_log

jetadmin.Model[M], every field but Table optional:

Field
Table the generated builder, table.Devices
Slug, Label, Plural, Group the URL segment and names; derived from the table name
ListDisplay the list's columns; the primary key and the next few by default
ListFilter sidebar filters: bools by yes/no, choices by each, timestamps by today/7 days/30 days/this year, others by their distinct values (up to 50)
SearchFields matched case-insensitively, as text, against every word of the search
Ordering the default order (jetadmin.Asc, jetadmin.Desc)
PageSize rows per page, 50 by default
ReadOnly shown, never written
Hidden never selected for a page, shown nor written
Fields per column: Label, Help, Choices, Widget (Textarea, JSON, Password)
ForeignKeys {Column, Model}: the value links to the related row and shows its name
Inlines {Model, Column}: rows of another model pointing at this one, on its change page, with an "add" link that fills the key in
Extra, Computed, BeforeSave form inputs that are not columns, and the hook that turns them into columns (how auth.Register hashes a password)
Display a row's name, like __str__; "<Label> <pk>" by default
Permissions DisableAdd, DisableChange, DisableDelete; jetadmin.ViewOnly for all three
Validate runs before every insert and update; return jetadmin.FieldErrors to mark fields

Forms follow the model field's Go type: string is a text input (a Textarea widget on request), bool a checkbox, integers a number input, floats a number with any step, time.Time a datetime-local input in UTC (a date input for a date column), uuid.UUID a validated text input, json.RawMessage or a jsonb column read as string a textarea that must hold valid JSON, []string a textarea of one value per line, []byte a read-only "N bytes". A pointer field is nullable: an empty input is NULL. Choices make a select. On add, a column with a database default (or identity) left blank is left out of the INSERT, so the default applies; GENERATED ALWAYS columns are not on the form at all.

auth.Options: CookieName (jetadmin_session), Secure (set it behind HTTPS), SessionTTL (14 days), ClientIP (the address failed sign-ins are counted against; return the client's behind a proxy) and Logger.

Security

  • Handler wraps everything in http.CrossOriginProtection: a form posted from another origin is refused.
  • Session cookies are random 32-byte tokens, HttpOnly, SameSite=Lax, Secure when configured; the database keeps only their SHA-256.
  • A refused sign-in always gives the same message, an unknown username takes as long as a wrong password (a dummy bcrypt comparison), and after five failures from one address or against one username sign-ins there wait, doubling up to 15 minutes. The counters are in memory, per process.
  • The audit log names the fields a change touched, never their values.

Limits

  • PostgreSQL only.
  • Single-column primary keys of a text, integer or uuid type; a table without one cannot be registered, and composite keys are refused.
  • Foreign keys and inlines are declared, not read from the database.
  • One filter value per column; no custom list actions besides delete yet.
  • Times are shown and entered in UTC.

License

MIT, see LICENSE. Datastar is embedded under its MIT license, see NOTICE.

Documentation

Overview

Package jetadmin is a Django-admin-style web admin for PostgreSQL tables described by go-jet/jet generated code. Register a page per table, mount Site.Handler, and the admin lists, searches, filters, adds, changes and deletes rows with Jet statements run through pgx.

Pages are rendered with templ and work without JavaScript; Datastar, embedded and served by the Site, makes the search live, the filters and forms answer without a full reload, and confirmations open as dialogs.

Index

Constants

This section is empty.

Variables

View Source
var Schema string

Schema is the DDL of the tables jet-admin uses: admin_users and admin_sessions for the auth package, admin_log for Config.AuditLog. Paste it (or schema.sql, the same text) into one of your migrations.

View Source
var ViewOnly = Permissions{DisableAdd: true, DisableChange: true, DisableDelete: true}

ViewOnly is the Permissions of a table the admin only shows (a log).

Functions

func Register

func Register[M any](s *Site, m Model[M]) error

Register adds the pages of a model to the site. It fails when the table or the model cannot be read, when the table has no single-column primary key, or when an option names a column the table does not have.

Types

type Authenticator

type Authenticator = core.Authenticator

Authenticator tells who is signed in on a request. The auth package has a database-backed one; a host with its own sign-in implements it over that.

type Choice

type Choice struct {
	Value, Label string
}

Choice is one allowed value of a column, in its text form.

func Choices

func Choices(values ...string) []Choice

Choices makes Choices whose labels are their values.

type Column

type Column = postgres.Column

Column is a column of a generated table builder: table.Devices.Name.

type Config

type Config struct {
	// DB runs every statement; a *pgxpool.Pool.
	DB DB
	// Title is the name in the header and the page titles: "Sumi admin".
	Title string
	// BasePath is where the Handler is mounted, without the trailing
	// slash: "/admin". The admin's pages live under BasePath + "/".
	BasePath string
	// Auth tells who is signed in. Required: the admin shows nothing to
	// anyone it does not authenticate.
	Auth Authenticator
	// Logger receives the failures the pages cannot show; slog.Default()
	// when nil.
	Logger *slog.Logger
	// AuditLog records every add, change and delete in admin_log (see
	// Schema) and shows them on the index, history and log pages.
	AuditLog bool
}

Config is the Site's setup.

type DB

type DB interface {
	Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
	Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
	QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
	Begin(ctx context.Context) (pgx.Tx, error)
}

DB is what the admin runs statements on; *pgxpool.Pool satisfies it.

type ExtraField

type ExtraField struct {
	Name, Label, Help string
	Widget            Widget
	// RequiredOnAdd refuses a blank value when adding a row.
	RequiredOnAdd bool
}

ExtraField is a form input that is not a column.

type Field

type Field struct {
	Column postgres.Column
	// Label is the field's name on the form and the list's header; the
	// column name in words by default.
	Label string
	// Help is shown under the input.
	Help string
	// Choices restrict the value to a list: a select on the form, a
	// filter of each choice, the choice's label in the list.
	Choices []Choice
	// Widget overrides the input chosen from the field's Go type.
	Widget Widget
}

Field holds the options of one column's form field and list cell.

type FieldErrors

type FieldErrors map[string]string

FieldErrors is a validation failure: a message per field, keyed by column or Extra name. The key "" is a message for the whole form.

func (FieldErrors) Error

func (e FieldErrors) Error() string

type ForeignKey

type ForeignKey struct {
	Column postgres.Column
	Model  string
}

ForeignKey links Column to the row of the model registered as Model (its slug) whose primary key it holds.

type Inline

type Inline struct {
	Model  string
	Column postgres.Column
	// Limit is the number of rows shown; 20 by default.
	Limit int
}

Inline lists, on a change page, the rows of the model registered as Model (its slug) whose Column holds this row's primary key, with an "add" link that fills Column in.

type LoginProvider

type LoginProvider = core.LoginProvider

LoginProvider is an Authenticator that also serves the sign-in and sign-out pages, mounted at BasePath/login and BasePath/logout.

type Model

type Model[M any] struct {
	// Table is the generated builder. Its AllColumns, MutableColumns and
	// DefaultColumns are read by reflection.
	Table postgres.Table
	// Slug is the URL segment of the pages; the table's name by default.
	Slug string
	// Label and Plural name one row and the table: "Device", "Devices".
	// They default to the table's name, singularised and capitalised.
	Label, Plural string
	// Group is the section of the index page the model is listed under.
	Group string

	// ListDisplay are the columns of the list; by default the primary key
	// and the next few columns.
	ListDisplay []postgres.Column
	// ListFilter are the columns of the filter sidebar: a bool filters by
	// yes and no, a column with Choices by each choice, a timestamp or
	// date by today, the past 7 and 30 days and this year, other columns
	// by their distinct values when there are few enough of them.
	ListFilter []postgres.Column
	// SearchFields are matched, case-insensitively and as text, against
	// the search box: a row matches when one of them contains it.
	SearchFields []postgres.Column
	// Ordering is the list's default order; the primary key by default.
	// The column headers sort too.
	Ordering []Order
	// PageSize is the number of rows per list page; 50 by default.
	PageSize int

	// ReadOnly columns are shown on the form but never written by it.
	ReadOnly []postgres.Column
	// Hidden columns are never selected for display, shown nor written by
	// a form (a sealed credential).
	Hidden []postgres.Column
	// Fields are per-column options: label, help text, choices, widget.
	Fields []Field
	// ForeignKeys link a column to the row of another registered model.
	ForeignKeys []ForeignKey
	// Inlines list, on the change page, the rows of other models that
	// point at this one.
	Inlines []Inline
	// Extra are form inputs that are not columns (a password). Their
	// values reach BeforeSave.
	Extra []ExtraField
	// Computed are columns BeforeSave sets: written on every save even
	// when Hidden or ReadOnly (a password hash).
	Computed []postgres.Column

	// Display names a row in titles, links and the log, like Django's
	// __str__. By default "<Label> <primary key>".
	Display func(M) string
	// Permissions turns off adding, changing or deleting; everything is
	// allowed by default.
	Permissions Permissions
	// BeforeSave runs before Validate on every add and change, with the
	// values of Extra. Return FieldErrors to show the problem on the form.
	BeforeSave func(ctx context.Context, m *M, save Save) error
	// Validate runs before the row is inserted or updated. Return
	// FieldErrors to mark fields, any other error to show it above the
	// form.
	Validate func(ctx context.Context, m *M) error
}

Model describes the admin pages of one table. M is the table's Jet model struct (model.Devices); Table is its generated builder (table.Devices). Every option but Table has a working default.

type Order

type Order struct {
	Column postgres.Column
	Desc   bool
}

Order is one column of a default ordering.

func Asc

func Asc(c postgres.Column) Order

Asc orders by c, smallest first.

func Desc

func Desc(c postgres.Column) Order

Desc orders by c, largest first.

type Permissions

type Permissions struct {
	DisableAdd, DisableChange, DisableDelete bool
}

Permissions turns off the actions a model does not allow. The zero value allows them all.

type Save

type Save struct {
	// Adding is true for a new row, false for a change.
	Adding bool
	// Extra holds the submitted values of Model.Extra, by name.
	Extra map[string]string
}

Save is what BeforeSave learns about the save in progress.

type Site

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

Site is an admin: its registered models and the Handler serving them.

func New

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

New makes a Site. It fails without a DB or an Authenticator.

func (*Site) BasePath

func (s *Site) BasePath() string

BasePath is the path the Site is mounted at, without the trailing slash.

func (*Site) DB

func (s *Site) DB() DB

DB is the database the Site runs its statements on.

func (*Site) Handler

func (s *Site) Handler() http.Handler

Handler serves every page under BasePath. Mount it at BasePath + "/":

mux.Handle("/admin/", site.Handler())

It refuses cross-origin form posts (http.CrossOriginProtection), sends whoever is not signed in to the login page (or answers 403 when the Authenticator serves none) and serves the embedded stylesheet and Datastar under BasePath/static/.

type User

type User = core.User

User is the admin signed in on a request.

type Widget

type Widget int

Widget is the input of a form field.

const (
	// WidgetDefault picks the input from the field's Go type.
	WidgetDefault Widget = iota
	// Textarea is a multi-line text input.
	Textarea
	// JSON is a textarea whose value must be valid JSON; jsonb columns get
	// it without asking.
	JSON
	// Password is a masked input that never shows its value.
	Password
)

Directories

Path Synopsis
Package auth is a database-backed sign-in for jet-admin, like Django's superusers: admin_users holds who may sign in (bcrypt password hashes), admin_sessions the open sessions, keyed by the SHA-256 of the random token in the cookie.
Package auth is a database-backed sign-in for jet-admin, like Django's superusers: admin_users holds who may sign in (bcrypt password hashes), admin_sessions the open sessions, keyed by the SHA-256 of the random token in the cookie.
Command example runs a jet-admin site over a small demo schema: customers, their orders and their API tokens.
Command example runs a jet-admin site over a small demo schema: customers, their orders and their API tokens.
db
Package db is the demo's schema: schema.sql, and the Jet builders and models generated from it (go run ./scripts/jetgen -schema example/db/schema.sql -dest example/db/jet -db-tags).
Package db is the demo's schema: schema.sql, and the Jet builders and models generated from it (go run ./scripts/jetgen -schema example/db/schema.sql -dest example/db/jet -db-tags).
internal
core
Package core holds what both the admin and the auth package need, so neither imports the other for it: auth registers its pages on a Site, and the Site takes an Authenticator.
Package core holds what both the admin and the auth package need, so neither imports the other for it: auth registers its pages on a Site, and the Site takes an Authenticator.
testdb
Package testdb gives each test its own PostgreSQL database, built from the DDL the test passes and dropped when the test ends.
Package testdb gives each test its own PostgreSQL database, built from the DDL the test passes and dropped when the test ends.
ui
templ: version: v0.3.1070
templ: version: v0.3.1070
scripts
jetgen command
Command jetgen writes Jet table builders and models for a schema file.
Command jetgen writes Jet table builders and models for a schema file.

Jump to

Keyboard shortcuts

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