Kyrux
Back

Admin (Administration Panel)

A single panel (/admin/) for every app — but no model appears in it by default. Unlike Django's admin, Kyrux doesn't expose anything automatically: without admin.Register, /admin/ isn't even mounted. The layout follows the same visual style as the framework's welcome page (same palette, header, and footer) — the templates are embedded, but the code is yours: copy and adapt it if you want your own look.

Enabling it

Two gates need to be open — .env (a global kill switch) and the code (opt-in per model):

ADMIN_ENABLED=true
ADMIN_PATH=/admin/    # optional — renaming it makes discovery by scanners harder
// In models.go, right below the struct — not in routes.go. admin.Register
// doesn't depend on r/fw at all (it only reflects over the struct and
// stores metadata in an internal registry), so it runs fine from a plain
// func init() right next to the model itself:
package models

import "kyrux/core/admin"

type Produto struct {
    ID    int64   `kyrux:"pk"`
    Nome  string  `kyrux:"size:255"`
    Preco float64
}

func init() {
    admin.Register[Produto]("produtos", "Produtos",
        admin.SearchFields("Nome"),        // optional — search fields (LIKE)
        admin.ListFields("Nome", "Preco"), // optional — list columns
        admin.Conn("analytics"),           // optional — another named connection
    )
}

Go guarantees every init() in the program runs before main() even starts — long before admin.Mount, which only mounts after every app has already registered its own models (via bootstrap.RegisterApp). There's no ordering risk.

Exception: a model that already lives inside a package core/admin imports (the case of auth.User, in core/security/auth — core/admin already imports that package for login/session) can't self-register this way: it would be an import cycle. In that case, register it in the app's Register(r, fw), as before.

Register[T] requires the model to have a kyrux:"pk" field — it panics at boot if it doesn't, along with an invalid/duplicate/reserved slug (login, logout). Without ListFields, the listing shows every field except the ones marked kyrux:"hash" (never displayed, anywhere). Without SearchFields, search is disabled for that model.

Sidebar navigation

The sidebar is organized into four fixed topics, always in this order, each rendered as its own card (background, border and rounded corners) with an icon, separated from the others by spacing — not a plain running list of text:

  1. Dashboard — the whole card is the link, going to /admin/ (cards for every registered model).
  2. Framework — a card with a (non-clickable) section heading followed by models declared in a framework package (kyrux/core/...), such as auth.User. Useful when you register the built-in user model to manage accounts from the panel:
    admin.Register[auth.User]("usuarios", "Usuários", admin.SuperuserOnly())
    
  3. Aplicações — a card with a section heading followed by models declared in your own apps (apps/<name>/models).
  4. Logs — same pattern as Dashboard: the whole card is the link, going straight to /admin/historico/ (see Change history below).

The split between Framework and Aplicações is automatic: the admin detects where T was declared from its Go package (reflect.Type.PkgPath()), with no extra Option required on Register. Unlike the sections above, all four topics always appear — a topic with no registered models shows "Nenhum model" instead of disappearing, keeping the navigation predictable.

Image upload (kyrux:"image")

A string field marked kyrux:"image" becomes a file upload input in the admin form instead of a plain text field — you don't need to host the image elsewhere or paste a URL by hand:

type Produto struct {
    ID    int64  `kyrux:"pk"`
    Nome  string `kyrux:"size:255"`
    Capa  string `kyrux:"size:500,image"` // becomes <input type="file"> in the admin
}

func init() {
    admin.Register[Produto]("produtos", "Produtos",
        admin.App("catalogo"), // required for every model with an image field
    )
}
  • The file is saved to medias/<app>/<table>/<unique-name> (at the project root, alongside statics/) and served back at /medias/<app>/<table>/<unique-name> — the value written to the field is already that ready-to-use path for <img src="...">, no extra template helper needed.
  • admin.App("name") is required on any model with a kyrux:"image" field — it's what defines the upload's destination folder; missing it panics at boot.
  • Only accepts jpeg, png, gif, and webp — validated against the file's actual bytes (http.DetectContentType), not the extension or the Content-Type the browser declared (both are spoofable).
  • The on-disk filename is generated (random prefix + sanitized original name) — it never overwrites a previous upload and never trusts the filename sent by the browser to build the path (blocks path traversal like ../../etc/passwd).
  • On edit, leaving the field blank keeps the current image (same pattern as kyrux:"hash" for passwords) — it only swaps if a new file is selected.
  • /medias/ never lists a directory (same protection as /statics/) and gets Cache-Control: immutable in production — safe because every upload has a unique name and is never overwritten in place.

No database configured

If ADMIN_ENABLED=true and no database is configured in .env, the behavior depends on the environment:

  • APP_ENV=development: Kyrux creates a local SQLite database on its own at database/kyrux.sqlite3 (a folder already ignored by git), registers it as the "default" connection, and automatically generates the auth.User table plus the table for each model registered via admin.Register — the admin works immediately, without makemigrations/migrate. This is the only situation where Kyrux opens a database on its own; for any real database, the driver remains your choice and your responsibility.
  • APP_ENV=production: none of this happens. Kyrux never creates a SQLite database automatically in production — silently writing to an ephemeral local file instead of the intended database would be worse than simply refusing. The log explains why, and the admin isn't mounted (404).

The fallback only does CREATE TABLE IF NOT EXISTS — never ALTER. If the model evolves (new field), set up a real database and use makemigrations/migrate; the fallback is meant to get you started quickly, not to keep up with schema changes over time.

Initial superuser

Two ways to create the first user with admin access:

  1. Interactive — go run main.go createsuperuser (see section 4).
  2. Via .env — set the two variables below; Kyrux creates the account on its own at the next boot, only if no one with that login already exists:
ADMIN_SUPERUSER_USERNAME=admin
ADMIN_SUPERUSER_PASSWORD=change-this-temporary-password
  • The value of ADMIN_SUPERUSER_USERNAME is written to the field marked with kyrux:"login" on the auth.User model — Username or Email, depending on the tag (see section 15). The variable name doesn't change even when the login field is Email.
  • It never resets the password of an already-existing account — even if you edit ADMIN_SUPERUSER_PASSWORD afterward and restart the server (Air recompiles and restarts on every save; without this protection, a forgotten .env value would reset the password on every hot reload). To change the password after creation, use /admin/ itself or go run main.go createsuperuser/createuser.
  • A password shorter than 8 characters is rejected (creation fails and the log explains why — no account is created).
  • Works with any database, including the SQLite fallback above — in that case the auth.User table already exists before this check runs.
  • Leave both variables empty (the default) to disable this — nothing happens at boot, use createsuperuser as usual.

Security

  • Access requires IsStaff or IsAdmin on the auth.User model, not just at login. A positive result is cached in the session for up to 5s (avoids a SELECT on every request — measured ~27x faster with a warm cache); revoking IsStaff from a user ends their access within 5s, not instantly anymore. For true immediate revocation, end the user's session directly (e.g. store.Delete(sessionID)) instead of just editing the database field.
  • A valid login doesn't imply access. A user without IsStaff/IsAdmin who authenticates correctly at /admin/login/ gets "your account doesn't have permission," and the newly created session is immediately revoked.
  • Brute force: the built-in throttle in auth.Login (10 failures/minute per account+IP) already protects admin login — no extra configuration needed.
  • CSRF runs through the same global middleware as always — every admin form includes the token automatically.
  • kyrux:"hash" fields are never displayed — not even masked based on the real value; the value simply never leaves the database for the template. When editing, the password field stays blank: filling it in sets a new password (automatic hashing), leaving it blank keeps the current one.
  • No database available, no admin: if, even after the fallback attempt (or in production, where there's no fallback), the "default" connection doesn't exist, the admin refuses to mount — there's no way to protect it without authentication, so it simply doesn't come up (the log explains why).
  • Every piece of user input (search, sorting, pagination) is validated against the model's real metadata before reaching the SQL — sort=fake_column is silently ignored, it never turns into an arbitrary column in the query.

What the admin does and doesn't do

Feature Support
Full CRUD (create, list, edit, delete) ✅
Text search (LIKE) ✅ — opt-in via SearchFields
Column sorting (click the header) ✅
Pagination ✅ — Previous/Next, no exact total (see note below)
Automatic hash/encrypt on write ✅ — reuses orm.Create/Query.Update
Image upload ✅ — kyrux:"image" + admin.App("name")
Multiple connections ✅ — admin.Conn("name")
FK as <select> (only existing ids) ✅ — automatic on any kyrux:"fk:table"; label via kyrux:"fklabel:column", combinable with + and columns from another table; also shown on the listing
Advanced filters on the listing ✅ — opt-in via FilterFields
Bulk actions ✅ — built-in delete-selected; extensible via BulkAction
Change history ✅ — automatic on every registered model, no Option needed
Inlines (editing a relation inside the parent model) ❌

Inlines are left out by design in this first version — the goal is a solid, secure CRUD over one model at a time, not to replicate the entire surface of the Django admin.

FK as <select> (kyrux:"fklabel:column")

Every field with kyrux:"fk:table" automatically becomes a <select> in the admin, populated with the rows that actually exist in table — this prevents saving an id that doesn't exist (which used to only fail later, at the database constraint). Without fklabel, each option shows the id itself; with fklabel, it shows the given column instead — and the model's listing (not just the form) also shows that label instead of the raw id in the FK column:

type Pedido struct {
    ID         int64  `kyrux:"pk"`
    ClienteID  int64  `kyrux:"column:cliente_id,fk:clientes,fklabel:nome"`
}

The label always comes out in uppercase (e.g. "JOHN SMITH").

Combining columns from another table (table.column+column)

fklabel accepts parts separated by +. Each part is either column (on the referenced model itself) or table.column — follows the referenced model's own FK to that table, resolved automatically from the models already registered in the admin (the referenced table needs a field with kyrux:"fk:table" pointing to it). Multiple parts come out concatenated with " - ":

type Project struct {
    ID       int64  `kyrux:"pk"`
    ClientID int64  `kyrux:"column:client_id,fk:clients,fklabel:name"`
    Title    string `kyrux:"size:255"`
}

type Gallery struct {
    ID        int64  `kyrux:"pk"`
    ProjectID int64  `kyrux:"column:project_id,fk:projects,fklabel:clients.name+title"`
}

The ProjectID <select> (and the Gallery listing column) show "CLIENT NAME - PROJECT TITLE" instead of the id: it follows projects.client_id to clients, takes name from there, and concatenates it with projects.title.

  • The referenced table needs an id column (the same assumption already made by the migration when it generates REFERENCES table(id)).
  • The <select> and the listing resolution load up to 1000 rows (ORDER BY the label itself) — larger tables still work, they just won't list/resolve everything (the raw id shows as a fallback beyond the limit).
  • Editing a record whose current value is no longer among the loaded options (an orphaned/deleted row) keeps that value visible as an extra option instead of silently disappearing from the form.
  • Invalid fklabel syntax (e.g. characters outside a valid SQL identifier) panics at boot, same validation level as any misconfigured kyrux tag. A syntactically valid table.column part with no matching kyrux:"fk:table" on the referenced table only fails at runtime, when that model's form/listing is opened.

The listing uses PaginateNoCount internally (not Paginate) — the page shows "Page N" and Previous/Next, but not "N of M" nor the exact total record count. Measured: skipping the SELECT COUNT(*) on every list load is ~40% faster at the ORM level (isolated benchmark, no HTTP/session in the loop). If your use case needs the exact total, swap to Paginate in core/admin/crud.go — it's the only line that changes.

Advanced filters (FilterFields)

FilterFields adds a per-field filter to the listing, on top of text search — the filter type is derived automatically from the field's widget, with no extra configuration:

type Produto struct {
    ID          int64     `kyrux:"pk"`
    Nome        string    `kyrux:"size:200"`
    Ativo       bool
    CategoriaID int64     `kyrux:"column:categoria_id,fk:categorias,fklabel:nome"`
    Preco       float64
    CriadoEm    time.Time `kyrux:"column:criado_em"`
}

admin.Register[models.Produto]("produtos", "Produtos",
    admin.FilterFields("Ativo", "CategoriaID", "Preco", "CriadoEm"),
)
Field widget Generated filter
checkbox (bool) Exact — <select> with All/Yes/No
select (kyrux:"fk:...") Exact — same options as the form's <select>
number / number-float Range — two fields, min and max
datetime Date range — two fields, from/to (the upper bound is exclusive on the next day, covering the whole day regardless of the stored time)

Fields with other widgets (text, textarea, password, file) have no coherent filter and make FilterFields panic at boot, with the field name and the reason — same spirit as ListFields/SearchFields with an unknown name. Active filters are preserved in the listing's own sort/pagination links; "Clear" resets search and filters at once.

Bulk actions (BulkAction)

The listing always has a checkbox column per row (plus "select all" in the header) and the built-in Delete selected action — a single DELETE ... WHERE id IN (...), not N individual deletions. BulkAction registers additional actions, applied to the checked ids:

admin.Register[models.Produto]("produtos", "Produtos",
    admin.BulkAction("ativar", "Activate selected", func(db *database.DB, pks []any) error {
        return orm.FromDB[models.Produto](db).WhereIn("id", pks...).Update(map[string]any{"ativo": true})
    }),
)
  • pks already comes converted to the column's real Go PK type (the same conversion used by an individual delete/update) — ready for WhereIn("id", pks...).
  • The name "delete" is reserved by the built-in action — BulkAction("delete", ...) panics at boot. Two BulkAction calls with the same name also panic.
  • With no id selected, the action doesn't run and the listing comes back with the "No records selected" flash. An action with an unknown name (manual/tampered POST) returns 400.
  • In the browser, the "Apply" button asks for confirmation before submitting — same as an individual delete, to avoid an accidental click on an irreversible action.

Change history

Every action taken through the admin — create, edit, delete (individual or bulk, built-in or via BulkAction) — automatically generates an audit entry: who did it, when, on which model/record. It's not opt-in and needs no Option — it applies to every model registered via admin.Register, starting with the version that ships this feature.

Access it from Histórico, fixed at the top of the sidebar (/admin/historico/), with a filter by model. Each entry shows:

Column Content
Date When the action happened
Model The model's label (admin.Register("slug", "Label", ...))
Record The affected PK's value
Action create, update, delete, or a BulkAction's name
Changes For create/update: the submitted fields (field: value). Empty for delete and bulk actions — neither records a value diff in this version
User Who was authenticated when the action ran
  • Sensitive fields never appear in Changes: kyrux:"hash" and kyrux:"encrypt" are always recorded as ***, the same protection they already have everywhere else in the admin — the history log is a secondary copy of the data that persists indefinitely; it can't become an indirect way to leak a password or a cifered field's plaintext.
  • SuperuserOnly() models are also hidden from history for anyone with only IsStaff — the same restriction that already applies to the model itself.
  • Each entry lives in the same connection as the record it describes (the model's connection — admin.Conn("name") — not always "default"). The Histórico page with no filter shows the "default" connection; to see the history of a model configured on another named connection, filter by it explicitly (the filter resolves the right connection automatically).
  • The table (history_logs) is created automatically alongside auth.User's — in the SQLite fallback (development, no database configured) and via makemigrations, which now also scans core/admin (the same treatment already given to core/security/auth).

Each admin page render (core/admin/templates.go) reuses the output buffer via sync.Pool between requests, instead of allocating a new bytes.Buffer on every render — the same pattern already used by the application's template engine (core/render). Measured (isolated microbenchmark): ~26% less memory allocated per render and ~7% less time, with no observable change in behavior.

admin.Mount, admin.EnsureAllTables, and admin.Count are exported but for internal use — bootstrap.Init() already calls them on its own in the right order (after the apps have registered their models, before mounting the routes). App code doesn't need to (and shouldn't) call them directly; the only developer-facing API is admin.Register[T] and its options.