howdy_admin

A development admin area for howdy apps: browse and edit the database, manage users and groups, and sign in as any user. It is mounted from the dev/ entry point beside howdy_dev, so it never reaches a deployment.

gleam add --dev howdy_dev@1 howdy_admin@1
// dev/my_app_dev.gleam
import gleam/erlang/process
import howdy/admin
import howdy/dev
import my_app

pub fn main() {
  let db = my_app.open_database()
  let identity = my_app.identity(db)
  let permissions = my_app.permissions(db)
  let dashboard =
    admin.new() |> admin.auth(identity) |> admin.authorization(permissions)

  let assert Ok(_) =
    dev.start(fn() {
      my_app.app(db, identity, permissions) |> admin.mount(dashboard)
    })
  process.sleep_forever()
}

Run gleam dev and open http://localhost:8787/_howdy (whatever port the app listens on). See examples/admin for a complete app.

What the app registers is what the admin shows

Gleam cannot discover at runtime which packages an app uses, and there is no registry of the Repo or auth.Auth the app built, so the app hands them over:

Every value in the grid is shown and edited as text, and the database casts what it is given: PostgreSQL through an explicit CAST to the column’s type, SQLite by column affinity. A refused write shows the driver’s message, which a real application never should. In an insert form, a column left empty takes its default and the NULL box stores a null; in an edit form every column is written.

Signing in as a user

The user’s page has Sign in as this user. It calls auth.impersonate, which issues a session with method Impersonation, records session.impersonated in the audit trail with the actor howdy_admin, and sets the app’s own session cookie before redirecting to /. Because the admin shares the app’s origin, the cookie is the real one: the next request to the app is authenticated as that user. Suspended users cannot be impersonated, and the session skips second factors and SSO enforcement, which is why it is never reachable by the user themselves.

howdy_auth migration 18 allows the new method name; run it before using this version.

Development only

There is no login. Anyone who can reach the pages can read and change every row and sign in as anyone. The defences are:

How the grid stays current

Each open grid is a Lustre server component that re-runs its query, compares the page with the previous one, and marks what differs for a few seconds. What prompts the query depends on the database.

On PostgreSQL the grid installs a statement-level trigger named howdy_admin_notify on the table it shows, which calls pg_notify with the table’s name on the howdy_admin channel after any insert, update, delete or truncate. The grid listens on one extra connection, opened with the same settings as the app’s pool through pgo’s own notification client, and reloads the moment its table is named. It still checks every 15 seconds in case a notification was missed while that connection reconnected. The trigger only notifies and never touches a row, and howdy/migration ignores triggers with this prefix when it checks a package’s schema, so the app starts as before. To put the database back exactly as it was, call admin.remove_notify_triggers(repo) or run DROP FUNCTION howdy_admin_notify() CASCADE.

On SQLite there is no such mechanism, and sqlight does not expose the update hook, so the grid asks once a second. That is fine for a development tool with a few tabs open.

Testing

gleam test drives every page through howdy/testing against an in-memory SQLite database, or against PostgreSQL when HOWDY_ADMIN_TEST_POSTGRES_URL names a server whose database the tests may empty, which also covers the notifications. node browser_test/live_grid.mjs opens the grid in headless Chromium against a running examples/admin, changes the table with sqlite3, and checks the grid follows. node browser_test/mail.mjs registers an address against the running example and checks the registration email appears in the outbox without a reload, renders in its sandboxed frame with its sign-in link, and that a preview can be sent.

✨ Search Document