Search titles and sections of every page. ↑↓ to move, ↵ to open

Building

Admin screens

4 min read Edit on GitHub

The admin package gives an app screens to manage its own data, generated from Go structs: for each kind of record, a list with search, filters, sorting and pages, and a form to create, edit and delete one. The forms check the same validate rules as form actions: in Go, and in the browser as the admin types.

#A resource

Describe a table with a struct. Each field is a column: its db tag, or else its name in snake_case (AddedAt is added_at):

type Link struct {
	ID      int64     `admin:"key"`
	Title   string    `validate:"required,max=200" admin:"list,search"`
	URL     string    `validate:"required,url" label:"Address" admin:"list,search"`
	Read    bool      `admin:"list,filter"`
	UserID  int64     `label:"Owner" admin:"list,ref=users"`
	AddedAt time.Time `admin:"list,readonly"`
}

The admin tag says how a field is shown:

Option Means
key The record's key. A field named ID is the key without it. A number is set by the database; text is entered when the record is created.
list A column in the list. Without any, the list shows the first five fields.
search The list's search box looks in it. Text only.
filter A filter above the list: for checkboxes, and for fields with oneof.
readonly Shown, never changed. When a record is created, the database fills it in, from the column's default.
textarea Several lines of text.
date A time.Time holds a day, not a moment.
unixms, unix An integer holds a time, in milliseconds or seconds since 1970. It's shown and edited as one.
ref=users It holds a key of another resource, and links to it.
- Left out.

Fields take their label from the label tag, or from their name, and help text from description. A pointer is a column that may be empty: an empty field saves NULL. A validate:"oneof=a|b" field is a menu, and a field whose name has "password" in it is a password field, which isn't shown in lists and stays as it is when left empty.

After the rules, a struct's Validate() error method runs. It can fail with kit.FieldError, as in a form action.

#The panel

Make a store for each table, and a panel with them:

links, err := admin.NewTable[Link](db, "links", "")
if err != nil {
	return err
}
users, err := admin.NewTable[User](db, "tern_users", "")
if err != nil {
	return err
}
panel := admin.New(admin.Options{
	Title: "Shelf admin",
	Authorize: func(c *kit.Ctx) error {
		u, err := acc.Require(c) // signed out: to the sign-in page
		if err != nil {
			return err
		}
		if !isAdmin(u) {
			return kit.Error(http.StatusForbidden, "")
		}
		return nil
	},
},
	admin.Resource("links", links),
	admin.Resource("users", users, admin.ReadOnly()),
)

NewTable's last argument is the list's order when nobody picked one, such as "added_at DESC"; "" is the newest key first. It works with SQLite and PostgreSQL.

Authorize is required. It runs before every screen and every save or delete. Decide there who's an admin: a role in your users table, an rbac check, or a list of user IDs in the environment. An email address in the environment is only safe if the app verifies addresses: otherwise anyone can sign up with the address before its owner does.

A resource's options:

  • admin.Label("People", "Person") names it in the menu and on its screens. The default comes from its name.
  • admin.ReadOnly() shows records without changing them. admin.NoCreate() and admin.NoDelete() leave out one of those.

Options.Path is where the route is, /admin by default, and Options.PerPage how many records a list shows, 25 by default.

#The route

Add the panel to the app's plugins:

ternApp(kit.WithPlugins(panel))

and give it a route that matches every path under it, such as src/routes/admin/path.rest:

// src/routes/admin/path.rest/page.server.go
package admin

import (
	"github.com/Unfield/Tern/admin"
	"github.com/Unfield/Tern/kit"
)

func Load(c *kit.Ctx) (admin.Page, error) { return admin.Load(c) }

var Actions = admin.Actions
<!-- src/routes/admin/path.rest/+page.svelte -->
<script lang="ts">
	import { Admin } from '@ternkit/tern/ui';
	import type { PageProps } from './$types';

	let { data, form }: PageProps = $props();
</script>

<Admin {data} {form} />

The screens are pages of the app, inside its layouts, so they're rendered on the server and work without JavaScript. With it, the forms check their rules as the admin types, and deleting asks first. /admin is an overview with how many records each resource has; /admin/links lists them; /admin/links/new and /admin/links/3 are the forms. A list's search, filters, sort and page are in its address, so they can be bookmarked and shared.

The <Admin> component takes the room it's given; a layout that keeps its pages narrow can widen them for it, as Shelf's does:

main:has(:global(.tern-admin)) {
	max-width: 72rem;
}

#Actions

A resource can have buttons on each record's page, which run a Go function with the record's key:

admin.Resource("orders", orders,
	admin.Action("resend", "Resend the receipt", func(ctx context.Context, key string) error {
		return sendReceipt(ctx, key)
	}),
	admin.DangerAction("refund", "Refund", refund), // asks first
)

An error made with kit.FormError shows on the page, such as "Only a paid order can be refunded."; others fail the request. After an action, the page says it's done.

#Operations

Tern's packages have screens ready for the panel, for running the app in production:

panel := admin.New(admin.Options{Authorize: onlyAdmins},
	links,                     // your own resources
	acc.UsersAdmin(),          // accounts: search, rename, and "Sign out everywhere"
	acc.AuditAdmin(),          // every account's audit log, linked to its user
	queue.Admin(),             // background jobs: by state, with Retry and Delete
	flags.Admin(),             // feature flags
	admin.Errors(reporter),    // error reports, grouped by cause
)
  • Users: accounts change their own email and password, through the flows that check them, so only the name is edited here. "Sign out everywhere" ends every session of an account that was taken over.
  • Jobs: filter by state, search by kind and error, retry a failed job with its attempts reset, or delete one.
  • Errors are the reporter's groups, kept in memory: each process has its own. Give the app the reporter with kit.WithReporter(reporter), as Shelf does, or use report.Default(). A tracker such as Sentry keeps them across processes and restarts.

#Data elsewhere

NewTable is one admin.Store. For records kept elsewhere, such as in another service, implement the interface:

type Store[T any] interface {
	List(ctx context.Context, q admin.Query) (records []T, total int, err error)
	Get(ctx context.Context, key string) (T, error) // admin.ErrNotFound if there's none
	Create(ctx context.Context, record T) (key string, err error)
	Update(ctx context.Context, key string, record T, columns []string) error
	Delete(ctx context.Context, key string) error
}

A Query has the search text, the filters by column, the column to sort by, and the page's offset and limit. Update gets the columns the form edited, so it can leave the others alone.

#Errors

  • A form that fails its rules or Validate shows each error next to its field, with what was typed.
  • Saving a value that a unique column already has fails the form with a message, rather than an error page. So does deleting a record that others refer to with a foreign key. Both are recognized for SQLite and PostgreSQL.
  • A record that doesn't exist, and a resource that isn't in the panel, are 404s.

The finished Shelf has admin screens for its links and accounts, in admin.go.