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/adminimports (the case ofauth.User, incore/security/auth—core/adminalready 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'sRegister(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:
- Dashboard — the whole card is the link, going to
/admin/(cards for every registered model). - Framework — a card with a (non-clickable) section heading followed
by models declared in a framework package (
kyrux/core/...), such asauth.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()) - Aplicações — a card with a section heading followed by models
declared in your own apps (
apps/<name>/models). - 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, alongsidestatics/) 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 akyrux:"image"field — it's what defines the upload's destination folder; missing it panics at boot.- Only accepts
jpeg,png,gif, andwebp— 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 getsCache-Control: immutablein 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 atdatabase/kyrux.sqlite3(a folder already ignored by git), registers it as the"default"connection, and automatically generates theauth.Usertable plus the table for each model registered viaadmin.Register— the admin works immediately, withoutmakemigrations/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— neverALTER. If the model evolves (new field), set up a real database and usemakemigrations/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:
- Interactive —
go run main.go createsuperuser(see section 4). - 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_USERNAMEis written to the field marked withkyrux:"login"on theauth.Usermodel —UsernameorEmail, depending on the tag (see section 15). The variable name doesn't change even when the login field isEmail. - It never resets the password of an already-existing account — even if
you edit
ADMIN_SUPERUSER_PASSWORDafterward and restart the server (Air recompiles and restarts on every save; without this protection, a forgotten.envvalue would reset the password on every hot reload). To change the password after creation, use/admin/itself orgo 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.Usertable already exists before this check runs. - Leave both variables empty (the default) to disable this — nothing happens
at boot, use
createsuperuseras usual.
Security
- Access requires
IsStafforIsAdminon theauth.Usermodel, not just at login. A positive result is cached in the session for up to 5s (avoids aSELECTon every request — measured ~27x faster with a warm cache); revokingIsStafffrom 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/IsAdminwho 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_columnis 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
idcolumn (the same assumption already made by the migration when it generatesREFERENCES table(id)). - The
<select>and the listing resolution load up to 1000 rows (ORDER BYthe 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
fklabelsyntax (e.g. characters outside a valid SQL identifier) panics at boot, same validation level as any misconfiguredkyruxtag. A syntactically validtable.columnpart with no matchingkyrux:"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})
}),
)
pksalready comes converted to the column's real Go PK type (the same conversion used by an individual delete/update) — ready forWhereIn("id", pks...).- The name
"delete"is reserved by the built-in action —BulkAction("delete", ...)panics at boot. TwoBulkActioncalls 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"andkyrux:"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 onlyIsStaff— 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 alongsideauth.User's — in the SQLite fallback (development, no database configured) and viamakemigrations, which now also scanscore/admin(the same treatment already given tocore/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, andadmin.Countare 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 isadmin.Register[T]and its options.