Documentation
¶
Overview ¶
Package admin is an admin panel generated from ORM models: paginated lists with search, sort and filters, detail pages, create/edit forms built from z schemas, deletes with confirmation, and an audit log of every change. The UI is server-rendered html/template with htmx, all embedded (no CDN, no inline scripts or styles, so it runs under a strict CSP).
Access fails closed: every route requires an authenticated user (the guard in Config), the panel ability (default "admin") and the policy ability for the action (viewAny, view, create, update, delete) on the model type. A missing ability or policy denies.
panel, err := admin.New(admin.Config{DB: db, Guard: acc.Web, Authorizer: gate})
if err != nil { return err }
if err := panel.Register(admin.NewResource[models.Post](requests.PostSchema).Search("title")); err != nil {
return err
}
return panel.Mount(r)
See https://github.com/AlexGalhardo/vukem/blob/main/docs/admin.md.
Index ¶
- Constants
- func Migration(id string) schema.Migration
- func Templates() fs.FS
- type Action
- type AnyResource
- type AuditLog
- type Change
- type Config
- type Panel
- type Resource
- func (r *Resource[T]) Actions(actions ...Action[T]) *Resource[T]
- func (r *Resource[T]) AfterSave(fn func(ctx context.Context, s Saved[T]) error) *Resource[T]
- func (r *Resource[T]) BeforeSave(fn func(ctx context.Context, rec *T) error) *Resource[T]
- func (r *Resource[T]) Column(label string, fn func(T) string) *Resource[T]
- func (r *Resource[T]) Filter(col string, options ...string) *Resource[T]
- func (r *Resource[T]) Hidden(cols ...string) *Resource[T]
- func (r *Resource[T]) Input(key, kind string) *Resource[T]
- func (r *Resource[T]) Label(col, label string) *Resource[T]
- func (r *Resource[T]) Labels(singular, plural string) *Resource[T]
- func (r *Resource[T]) List(cols ...string) *Resource[T]
- func (r *Resource[T]) PerPage(n int) *Resource[T]
- func (r *Resource[T]) ReadOnly(cols ...string) *Resource[T]
- func (r *Resource[T]) Search(cols ...string) *Resource[T]
- func (r *Resource[T]) Sensitive(cols ...string) *Resource[T]
- func (r *Resource[T]) Slug(slug string) *Resource[T]
- func (r *Resource[T]) Sortable(cols ...string) *Resource[T]
- func (r *Resource[T]) Virtual(key string, value func(rec *T) any) *Resource[T]
- type Saved
Examples ¶
Constants ¶
const ( // MaxBulk is the most records one bulk action may run on. MaxBulk = 100 // MaxReason is the longest reason, in characters. MaxReason = 1000 )
Limits of custom actions.
const ( // AbilityAdmin is the default gate ability required for every page. AbilityAdmin = "admin" // AbilityAudit is the gate ability required to read the audit log. AbilityAudit = "viewAuditLogs" // AbilityFlags is the gate ability required to see and change feature // flags (Config.Flags). AbilityFlags = "manageFlags" AbilityViewAny = "viewAny" AbilityView = "view" AbilityCreate = "create" AbilityUpdate = "update" AbilityDelete = "delete" )
Abilities checked by the panel. Policy abilities are checked on the model type ((*T)(nil)) or on the record (*T).
const ( ActionCreate = "create" ActionUpdate = "update" ActionDelete = "delete" )
Audit actions.
const ( InputText = "text" InputTextarea = "textarea" InputNumber = "number" InputEmail = "email" InputURL = "url" InputPassword = "password" InputCheckbox = "checkbox" InputSelect = "select" InputDate = "date" InputDateTime = "datetime-local" )
Input types a form field can be rendered with (Resource.Input).
const AuditTable = "admin_audit_logs"
AuditTable is the table holding the admin audit log.
const FlagsResource = "_flags"
FlagsResource is the audit-log resource of feature flag changes.
const FlashKey = "admin.success"
FlashKey is the session flash key of the panel's success messages.
const ReasonKey = "_reason"
ReasonKey is the audit-log change key holding the reason given for an Action with Reason set. Column names never start with "_", so it cannot collide with a column.
Variables ¶
This section is empty.
Functions ¶
Types ¶
type Action ¶ added in v0.33.0
type Action[T any] struct { // Name is the policy ability, the URL segment and the audit-log action: // a lower-camel identifier of at most 16 characters ([a-z][a-zA-Z0-9]*), // not one of viewAny, view, create, update, delete or edit. Name string // Label is the button text (default: Name humanized). Label string // Reason asks for a reason on the confirmation page (required, at most // MaxReason characters); Run receives it and the audit log records it // under ReasonKey. Reason bool // Bulk also offers the action on the rows selected in the list (at // most MaxBulk at once); every record must pass the policy or none // runs. Bulk bool // Run performs the action. Changes it makes to rec's fields are saved // after it returns (orm.Update) and recorded in the audit log; other // writes go through tx so they roll back with the rest. Run func(ctx context.Context, tx *database.Tx, rec *T, reason string) error }
Action is a custom operation on one record ("approve", "reject"), offered as a button on the record page and, with Bulk, on the rows selected in the list. Every run goes through a confirmation page, needs the policy ability named Name on the record (method Approve for "approve"; a missing method denies), runs in one transaction with its audit-log entries and fails as a whole.
type AnyResource ¶
type AnyResource interface {
// contains filtered or unexported methods
}
AnyResource is a Resource of any model, as accepted by Panel.Register. Only Resource implements it.
type AuditLog ¶
type AuditLog struct {
ID int64 `db:"id"`
// ActorID is the AuthID of the user who made the change.
ActorID string `db:"actor_id"`
// Resource is the resource slug ("posts").
Resource string `db:"resource"`
// RecordID is the primary key of the changed record.
RecordID string `db:"record_id"`
// Action is ActionCreate, ActionUpdate, ActionDelete or the Name of a
// custom Action.
Action string `db:"action"`
// Changes maps changed columns to their values before and after.
// Sensitive columns only record that they changed.
Changes orm.JSON[map[string]Change] `db:"changes"`
CreatedAt time.Time `db:"created_at"`
}
AuditLog is one change made through the admin panel.
type Change ¶
type Change struct {
Before any `json:"before,omitempty"`
After any `json:"after,omitempty"`
Redacted bool `json:"redacted,omitempty"`
}
Change is the before/after value of one column. Redacted changes (to sensitive columns) carry no values.
type Config ¶
type Config struct {
// DB stores the resources and the audit log (required).
DB *database.DB
// Guard authenticates admins (required), e.g. the "web" session guard.
// Guests are redirected to the route named "login" (401 for JSON).
Guard auth.Checker
// Authorizer decides the panel and policy abilities, usually an
// authz.Gate. Nil uses the router's Services.Authorizer; with neither,
// everything is denied.
Authorizer web.Authorizer
// Prefix is the URL prefix (default "/admin").
Prefix string
// Ability is the gate ability required for every page (default
// "admin").
Ability string
// Title is shown in the header (default "Admin").
Title string
// Views overrides embedded templates by path: layouts/admin.html,
// index.html, show.html, form.html, delete.html, dashboard.html,
// audit.html, flags.html, action.html, page.html, partials/pager.html.
Views fs.FS
// Flags adds a page at <Prefix>/_flags to change the global value of
// the registered feature flags (app.Flags()); it needs the
// AbilityFlags gate ability. Without registered flags the page and its
// routes do not exist. Locked flags are shown read-only. Every change
// is a "flags.changed" audit event and an audit-log entry; a save that
// leaves the value as it was records nothing.
Flags *flags.Flags
// TwoFactor makes two-factor authentication mandatory for the panel:
// it reports whether the admin authenticated on ctx (auth.UserFrom)
// has 2FA enabled. The 2FA state lives in the app (the install:auth
// scaffold's users.two_factor_enabled), so the panel asks. false is 403
// with an "admin.2fa_required" security event; an error is 403 too
// (fail closed). Nil does not require 2FA.
TwoFactor func(ctx context.Context) (bool, error)
// MaxSessionAge, when positive, is the oldest a session may be to use
// the panel, counted from its creation (session.CreatedAt), a stricter
// limit than the app's SESSION_LIFETIME. An older session is
// invalidated (signed out of every guard, an "admin.session_expired"
// security event) and the browser is sent to log in again. A
// remember-me cookie logs the user back in with a new session: turn
// remember-me off for admins when the cap must force a password.
MaxSessionAge time.Duration
// IdleTimeout, when positive, ends a session that has not touched a
// guarded admin route for that long. The clock is the session value
// "admin.idle_at", updated only by Guard (and the routes Mount
// registers), not by the rest of the app. An idle session is
// invalidated (an "admin.session_idle" security event) and the browser
// is sent to log in again. Activity elsewhere does not refresh it.
IdleTimeout time.Duration
// AllowCIDRs, when not empty, limits the panel to client IPs
// (web.Ctx.IP, trusted-proxy aware) inside one of the prefixes. Others
// get 403 with an "admin.ip_denied" security event, before any login
// redirect. The embedded CSS and JS stay public.
AllowCIDRs []netip.Prefix
// Now is the clock for MaxSessionAge and IdleTimeout (tests); defaults
// to time.Now.
Now func() time.Time
}
Config configures a Panel.
type Panel ¶
type Panel struct {
// contains filtered or unexported fields
}
Panel is an admin panel; build it with New, add resources with Register and serve it with Mount.
func New ¶
New validates cfg and returns an empty panel.
Example ¶
The panel needs a database, a guard and an authorizer granting the "admin" ability plus a policy per model.
mount := func(r *router.Router, db *database.DB, guard *auth.SessionGuard[User]) error {
gate := authz.New(func(ctx context.Context) (*User, error) {
u, err := auth.UserFrom[User](ctx, guard.Name())
return &u, err
})
gate.Define(admin.AbilityAdmin, func(_ context.Context, u *User) bool { return u.Role == "admin" })
if err := gate.Register(authz.For[*Post](adminPolicy[*Post]{})); err != nil {
return err
}
panel, err := admin.New(admin.Config{DB: db, Guard: guard, Authorizer: gate})
if err != nil {
return err
}
posts := admin.NewResource[Post](postSchema).
Search("title").
Filter("status", "draft", "live").
Column("Excerpt", func(p Post) string { return strings.SplitN(p.Body, "\n", 2)[0] })
if err := panel.Register(posts); err != nil {
return err
}
return panel.Mount(r) // GET /admin, /admin/posts, ...
}
_ = mount
func (*Panel) AddPage ¶ added in v0.35.0
AddPage adds a custom page at <Prefix>/<slug>, rendered inside the panel layout with the same navigation, title and guard as the resources. The handler returns the page body, which is trusted HTML: escape request and user data yourself (a template.Must(...).Execute into a buffer does). label is the navigation entry and the page heading. The page answers GET only; register other methods on the router with Panel.Guard. Call it before Mount. Slugs share the namespace of the resources.
func (*Panel) Guard ¶ added in v0.34.0
Guard is the middleware Mount installs on every panel route: the IP allow-list, MaxSessionAge, IdleTimeout, the auth guard, CSRF, the no-store headers, two-factor and the panel ability, in that order. Put it on routes registered beside the panel (a ban or resolve action under the same prefix) so they fail closed the same way. Sessions must already be on the router. It does not check a resource policy; the handler does that itself.
func (*Panel) Mount ¶
Mount registers the panel's routes on r. Every route requires the guard, the panel ability and same-origin (CSRF-checked) unsafe requests.
func (*Panel) Register ¶
func (p *Panel) Register(resources ...AnyResource) error
Register validates and adds resources; configuration mistakes are returned here, at boot.
type Resource ¶
type Resource[T any] struct { // contains filtered or unexported fields }
Resource exposes model T in the admin panel: a paginated list with search, sort and filters, a detail page, and create/edit forms generated from a z schema. Options chain and return the resource; mistakes (unknown columns, sensitive columns in lists) are reported by Panel.Register, at boot.
func NewResource ¶
func NewResource[T, I any](schema z.StructSchema[I]) *Resource[T]
NewResource returns a resource for model T whose forms are generated from schema: each schema key is a form field written to the model column with the same name (or the snake_case of the input field name). Only schema keys are ever written (mass-assignment protection); the primary key and timestamps cannot be schema keys. A composite primary key (orm.CompositeKeyer) is addressed as a-b: each column's value, in PrimaryKeys order, joined with "-". A "-" or "~" inside a value is escaped so the URL matches every key column, not the first one alone. T is given explicitly and I is inferred:
admin.NewResource[models.Post](requests.PostSchema)
func (*Resource[T]) Actions ¶ added in v0.33.0
Actions adds custom actions; mistakes are reported by Panel.Register.
func (*Resource[T]) AfterSave ¶ added in v0.33.0
AfterSave runs fn after every create and update, inside the panel transaction and before the audit entry is written, with the transaction, the previous record and the Virtual field values (see Saved). Returning an error rolls the whole save back. Changes fn makes to Saved.Record and writes through Saved.Tx appear in the audit diff.
func (*Resource[T]) BeforeSave ¶
BeforeSave runs fn on the record before every create and update (after validation, inside the transaction), e.g. to hash a password or set an owner. Returning an error aborts the write. Use AfterSave for side effects that need the transaction.
func (*Resource[T]) Column ¶
Column adds a computed column to the list; fn's result is HTML-escaped.
func (*Resource[T]) Filter ¶
Filter adds an exact-match filter on col; with options it renders a select, otherwise a text box. Boolean columns get yes/no options.
func (*Resource[T]) Hidden ¶
Hidden hides columns from the list, the detail page and the forms; hidden schema keys are never written.
func (*Resource[T]) Input ¶
Input overrides the form control of a schema key (one of the Input* constants).
func (*Resource[T]) List ¶
List sets the columns of the list, in order (default: every column that is not hidden or sensitive).
func (*Resource[T]) ReadOnly ¶
ReadOnly shows columns in the edit form without letting the admin change them: read-only schema keys keep the stored value and are never written.
func (*Resource[T]) Search ¶
Search enables the search box over the given string columns (case-insensitive LIKE with bound parameters).
func (*Resource[T]) Sensitive ¶
Sensitive marks columns holding secrets (password hashes, tokens) beyond the ones detected by name or json:"-": they are never displayed, listed, searched, sorted, filtered or written to the audit log. A sensitive schema key renders as an empty password field and keeps the stored value when left blank on edit.
func (*Resource[T]) Sortable ¶
Sortable sets the columns the list may be sorted by (default: the listed columns). Any other sort parameter is ignored.
func (*Resource[T]) Virtual ¶ added in v0.33.0
Virtual declares a schema key that maps to no model column: the form shows it, its parsed value reaches AfterSave hooks in Saved.Virtual, and nothing writes it to the record. value renders the current state on the edit form (nil: empty), e.g. a "banned" checkbox from a banned_at column. A schema key without a column that Virtual does not declare is a Register error.
type Saved ¶ added in v0.33.0
type Saved[T any] struct { // Tx is the panel transaction the record was written in: write related // rows through it, and use Tx.AfterCommit for work that must only // happen once the save is committed (events, mail, cache). Tx *database.Tx // Action is ActionCreate or ActionUpdate. Action string // Record is the record as written. Record *T // Before is a copy of the record before the form was applied; nil on // create. Before *T // Virtual holds the parsed values of the submitted Virtual fields, by // schema key. Virtual map[string]any }
Saved describes a create or update to AfterSave hooks.