Admin (Painel de Administração)
Um único painel (/admin/) para todos os apps — mas nenhum model aparece nele
por padrão. Diferente do admin do Django, o Kyrux não expõe nada
automaticamente: sem admin.Register, /admin/ nem é montado. O layout segue
o mesmo estilo visual da página de boas-vindas do framework (mesma paleta,
header e rodapé) — os templates são embutidos, mas o código é seu: copie e
adapte se quiser uma cara própria.
Ativar
Dois portões precisam estar abertos — o .env (kill-switch global) e o
código (opt-in por model):
ADMIN_ENABLED=true
ADMIN_PATH=/admin/ # opcional — renomear dificulta descoberta por scanners
// Em models.go, logo abaixo do struct — não em routes.go. admin.Register
// não depende de r/fw (só faz reflection sobre o struct e guarda metadados
// num registry interno), então roda de dentro de um func init() comum,
// junto do próprio model:
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"), // opcional — campos de busca (LIKE)
admin.ListFields("Nome", "Preco"), // opcional — colunas da listagem
admin.Conn("analytics"), // opcional — outra conexão nomeada
)
}
Go garante que todo init() do programa roda antes de main() começar —
muito antes de admin.Mount, que só monta depois que todos os apps já
registraram os seus (via bootstrap.RegisterApp). Não há risco de ordem.
Exceção: um model que já vive dentro de um pacote que
core/adminimporta (caso deauth.User, emcore/security/auth—core/adminjá importa esse pacote para login/sessão) não pode se auto-registrar assim: seria import cycle. Nesse caso, registre noRegister(r, fw)do app, como antes.
Register[T] exige que o model tenha um campo kyrux:"pk" — falha no boot
(panic) se não tiver, junto com slug inválido/duplicado/reservado (login,
logout). Sem ListFields, a listagem mostra todos os campos exceto os
marcados kyrux:"hash" (nunca exibidos, em lugar nenhum). Sem SearchFields,
a busca fica desativada para aquele model.
Navegação lateral
A barra lateral é organizada em quatro tópicos fixos, sempre nesta ordem, cada um como uma caixa própria (fundo, borda e cantos arredondados) com ícone, separada das demais por espaçamento — não uma lista de texto corrida:
- Dashboard — caixa inteira é o link, leva para
/admin/(os cards de todos os models registrados). - Framework — caixa com cabeçalho de seção (não clicável) seguido dos
models declarados em algum pacote do próprio framework (
kyrux/core/...), comoauth.User. Útil quando você registra o model de usuário embutido para administrar contas pelo painel:admin.Register[auth.User]("usuarios", "Usuários", admin.SuperuserOnly()) - Aplicações — caixa com cabeçalho de seção seguido dos models
declarados nos seus próprios apps (
apps/<nome>/models). - Logs — mesmo padrão de Dashboard: a caixa inteira é o link, leva
direto para
/admin/historico/(ver Histórico de alterações abaixo).
A separação entre Framework e Aplicações é automática: o admin identifica a
origem de T pelo pacote Go onde ele foi declarado
(reflect.Type.PkgPath()), sem exigir nenhuma Option adicional em
Register. Diferente das seções anteriores, os quatro tópicos aparecem
sempre — um tópico sem nenhum model registrado mostra "Nenhum model" em vez
de sumir, mantendo a navegação previsível.
Upload de imagem (kyrux:"image")
Um campo string marcado kyrux:"image" vira um input de upload no
formulário do admin em vez de um campo de texto — o dev não precisa hospedar
a imagem em outro lugar nem colar URL manualmente:
type Produto struct {
ID int64 `kyrux:"pk"`
Nome string `kyrux:"size:255"`
Capa string `kyrux:"size:500,image"` // vira <input type="file"> no admin
}
func init() {
admin.Register[Produto]("produtos", "Produtos",
admin.App("catalogo"), // obrigatório para todo model com campo image
)
}
- O arquivo é salvo em
medias/<app>/<tabela>/<nome-único>(na raiz do projeto, ao lado destatics/) e servido de volta em/medias/<app>/<tabela>/<nome-único>— o valor gravado no campo já é esse caminho pronto para usar em<img src="...">, sem helper de template adicional. admin.App("nome")é obrigatório em qualquer model com campokyrux:"image"— é o que define a pasta de destino do upload; falha no boot (panic) se faltar.- Só aceita
jpeg,png,gifewebp— validado pelos bytes reais do arquivo (http.DetectContentType), não pela extensão nem pelo Content-Type que o navegador declarou (ambos falsificáveis). - Nome do arquivo em disco é gerado (prefixo aleatório + nome original
saneado) — nunca sobrescreve um upload anterior e nunca confia no nome
enviado pelo navegador para montar o caminho (bloqueia path traversal do
tipo
../../etc/passwd). - Na edição, deixar o campo em branco mantém a imagem atual (mesmo padrão de
kyrux:"hash"para senha) — só troca se um novo arquivo for selecionado. /medias/nunca lista diretório (mesma proteção de/statics/) e recebeCache-Control: immutableem produção — seguro porque cada upload tem nome único, nunca é sobrescrito no lugar.
Sem banco configurado
Se ADMIN_ENABLED=true e nenhum banco estiver configurado no .env, o
comportamento depende do ambiente:
APP_ENV=development: o Kyrux cria sozinho um SQLite local emdatabase/kyrux.sqlite3(pasta já ignorada pelo git), registra-o como conexão"default"e gera automaticamente a tabela deauth.Usere a de cada model registrado viaadmin.Register— o admin funciona de imediato, semmakemigrations/migrate. É a única situação em que o Kyrux abre um banco por conta própria; para qualquer banco real, o driver continua sendo escolha e responsabilidade sua.APP_ENV=production: nada disso acontece. O Kyrux nunca cria um SQLite automaticamente em produção — escrever silenciosamente num arquivo local efêmero em vez do banco pretendido seria pior que simplesmente recusar. O log explica o motivo e o admin não é montado (404).
O fallback só faz
CREATE TABLE IF NOT EXISTS— nuncaALTER. Se o model evoluir (novo campo), configure um banco de verdade e usemakemigrations/migrate; o fallback é para começar rápido, não para acompanhar mudanças de schema ao longo do tempo.
Superusuário inicial
Duas formas de criar o primeiro usuário com acesso ao admin:
- Interativa —
go run main.go createsuperuser(ver seção 4). - Via
.env— defina as duas variáveis abaixo; o Kyrux cria a conta sozinho no próximo boot, somente se ainda não existir ninguém com esse login:
ADMIN_SUPERUSER_USERNAME=admin
ADMIN_SUPERUSER_PASSWORD=troque-esta-senha-provisoria
- O valor de
ADMIN_SUPERUSER_USERNAMEé gravado no campo marcado comkyrux:"login"no modelauth.User—UsernameouEmail, conforme a tag (ver seção 15). O nome da variável não muda mesmo quando o campo de login éEmail. - Nunca redefine a senha de uma conta já existente — mesmo que você
edite
ADMIN_SUPERUSER_PASSWORDdepois e reinicie o servidor (Air recompila e reinicia a cada save; sem essa proteção, um.envesquecido resetaria a senha a cada hot reload). Para trocar a senha depois de criada, use o próprio/admin/ougo run main.go createsuperuser/createuser. - Senha com menos de 8 caracteres é rejeitada (a criação falha e o log explica o motivo — nenhuma conta é criada).
- Funciona com qualquer banco, inclusive o SQLite de fallback acima —
nesse caso a tabela de
auth.Userjá existe antes desta checagem rodar. - Deixe as duas variáveis vazias (padrão) para desativar — nada acontece no
boot, use
createsuperusernormalmente.
Segurança
- Acesso exige
IsStaffouIsAdminno modelauth.User, não apenas no login. O resultado positivo fica cacheado na sessão por até 5s (evita umSELECTa cada requisição — medido ~27x mais rápido com cache quente); revogarIsStaffde um usuário encerra o acesso em até 5s, não mais instantaneamente. Pra revogação imediata de verdade, encerre a sessão do usuário diretamente (ex:store.Delete(sessionID)) em vez de só editar o campo no banco. - Login válido não implica acesso. Um usuário sem
IsStaff/IsAdminque autentica corretamente no/admin/login/recebe "sua conta não tem permissão" e a sessão recém-criada é imediatamente revogada. - Brute-force: o freio embutido em
auth.Login(10 falhas/minuto por conta+IP) já protege o login do admin — nenhuma configuração adicional. - CSRF roda pelo middleware global de sempre — todo formulário do admin inclui o token automaticamente.
- Campos
kyrux:"hash"nunca são exibidos — nem mascarados a partir do valor real, o valor simplesmente não sai do banco para o template. Na edição, o campo de senha fica em branco: preencher define uma nova senha (hash automático), deixar em branco mantém a atual. - Sem banco disponível, sem admin: se mesmo após a tentativa de fallback
(ou em produção, onde não há fallback) a conexão
"default"não existir, o admin recusa montar — não há como protegê-lo sem autenticação, então ele simplesmente não sobe (log explica o motivo). - Toda entrada do usuário (busca, ordenação, paginação) é validada contra os
metadados reais do model antes de chegar ao SQL —
sort=coluna_falsaé silenciosamente ignorado, nunca vira uma coluna arbitrária na query.
O que o admin faz e o que não faz
| Recurso | Suporte |
|---|---|
| CRUD completo (criar, listar, editar, excluir) | ✅ |
Busca textual (LIKE) |
✅ — opt-in via SearchFields |
| Ordenação por coluna (clique no cabeçalho) | ✅ |
| Paginação | ✅ — Anterior/Próxima, sem total exato (ver nota abaixo) |
| hash/encrypt automático na escrita | ✅ — reaproveita orm.Create/Query.Update |
| Upload de imagem | ✅ — kyrux:"image" + admin.App("nome") |
| Múltiplas conexões | ✅ — admin.Conn("nome") |
FK como <select> (só ids existentes) |
✅ — automático em qualquer kyrux:"fk:tabela"; rótulo via kyrux:"fklabel:coluna", combinável com + e colunas de outra tabela; aparece também na listagem |
| Filtros avançados na listagem | ✅ — opt-in via FilterFields |
| Ações em lote | ✅ — excluir selecionados embutido; extensível via BulkAction |
| Histórico de alterações | ✅ — automático em todo model registrado, sem Option |
| Inlines (editar relação dentro do model pai) | ❌ |
Inlines ficam de fora por design nesta primeira versão — o objetivo é um CRUD sólido e seguro sobre um model por vez, não replicar toda a superfície do Django admin.
FK como <select> (kyrux:"fklabel:coluna")
Todo campo com kyrux:"fk:tabela" vira automaticamente um <select> no
admin, populado com as linhas que realmente existem em tabela — evita
salvar um id que não existe (o que antes só falhava depois, na constraint
do banco). Sem fklabel, cada opção mostra o próprio id; com fklabel,
mostra a coluna indicada — e a listagem do model (não só o formulário)
também passa a mostrar esse rótulo em vez do id bruto na coluna da FK:
type Pedido struct {
ID int64 `kyrux:"pk"`
ClienteID int64 `kyrux:"column:cliente_id,fk:clientes,fklabel:nome"`
}
O rótulo sempre sai em maiúsculas (ex: "JOÃO SILVA").
Rótulo combinando colunas de outra tabela (tabela.coluna+coluna)
fklabel aceita partes separadas por +. Cada parte é coluna (do
próprio model referenciado) ou tabela.coluna — segue o FK do model
referenciado até essa tabela, resolvido automaticamente a partir dos
models já registrados no admin (precisa existir, na tabela referenciada,
um campo com kyrux:"fk:tabela" apontando pra ela). Múltiplas partes
saem concatenadas com " - ":
type Projeto struct {
ID int64 `kyrux:"pk"`
ClienteID int64 `kyrux:"column:cliente_id,fk:clientes,fklabel:nome"`
Titulo string `kyrux:"size:255"`
}
type Galeria struct {
ID int64 `kyrux:"pk"`
ProjetoID int64 `kyrux:"column:projeto_id,fk:projetos,fklabel:clientes.nome+titulo"`
}
O <select> de ProjetoID (e a coluna da listagem de Galeria) mostram
"NOME DO CLIENTE - TÍTULO DO PROJETO" em vez do id: segue
projetos.cliente_id até clientes, pega nome de lá, concatena com
projetos.titulo.
- A tabela referenciada precisa ter uma coluna
id(mesma suposição já feita pela migration ao gerarREFERENCES tabela(id)). - O
<select>e a resolução da listagem carregam até 1000 linhas (ORDER BYpelo próprio rótulo) — tabelas maiores continuam funcionais, só não listam/resolvem tudo (o id bruto aparece como fallback além do limite). - Editando um registro cujo valor atual não está mais entre as opções carregadas (linha órfã/excluída), o valor continua visível como uma opção extra em vez de sumir silenciosamente do formulário.
- Sintaxe inválida em
fklabel(ex: caracteres fora de identificador SQL) causa panic no boot, mesmo nível de validação de qualquer tagkyruxmal configurada. Já uma partetabela.colunasintaticamente válida mas semkyrux:"fk:tabela"correspondente na tabela referenciada só falha em runtime, ao abrir o formulário/listagem daquele model.
A listagem usa PaginateNoCount internamente (não Paginate) — a página
mostra "Página N" e Anterior/Próxima, mas não "N de M" nem o total de
registros. Medido: evitar o SELECT COUNT(*) a cada carregamento da lista
é ~40% mais rápido no nível do ORM (benchmark isolado, sem HTTP/sessão no
meio). Se seu caso de uso precisa do total exato, troque para Paginate em
core/admin/crud.go — é a única linha que muda.
Filtros avançados (FilterFields)
FilterFields adiciona um filtro por campo na listagem, além da busca
textual — o tipo de filtro é derivado automaticamente do widget do campo,
sem nenhuma configuração extra:
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"),
)
| Widget do campo | Filtro gerado |
|---|---|
checkbox (bool) |
Exato — <select> com Todos/Sim/Não |
select (kyrux:"fk:...") |
Exato — mesmas opções do <select> do formulário |
number / number-float |
Faixa — dois campos, mínimo e máximo |
datetime |
Faixa de datas — dois campos, de/até (o limite superior é exclusivo no dia seguinte, cobrindo o dia inteiro independente da hora armazenada) |
Campos com outros widgets (text, textarea, password, file) não têm
filtro coerente e fazem FilterFields panicar no boot, com o nome do campo
e o motivo — mesmo espírito de ListFields/SearchFields com nome
desconhecido. Os filtros ativos são preservados nos links de ordenação e
paginação da própria listagem; "Limpar" reseta busca e filtros de uma vez.
Ações em lote (BulkAction)
A listagem sempre tem uma coluna de checkbox por linha (mais "selecionar
todos" no cabeçalho) e a ação embutida Excluir selecionados — um único
DELETE ... WHERE id IN (...), não N exclusões individuais. BulkAction
registra ações adicionais, aplicadas aos ids marcados:
admin.Register[models.Produto]("produtos", "Produtos",
admin.BulkAction("ativar", "Ativar selecionados", func(db *database.DB, pks []any) error {
return orm.FromDB[models.Produto](db).WhereIn("id", pks...).Update(map[string]any{"ativo": true})
}),
)
pksjá vem convertido para o tipo Go real da coluna de PK (mesma conversão usada por uma exclusão/edição individual) — pronto paraWhereIn("id", pks...).- O nome
"delete"é reservado pela ação embutida —BulkAction("delete", ...)panica no boot. DuasBulkActioncom o mesmo nome também panicam. - Sem nenhum id selecionado, a ação não roda e a listagem volta com o flash "Nenhum registro selecionado." Uma ação com nome desconhecido (POST manual/adulterado) devolve 400.
- No navegador, o botão "Aplicar" pede confirmação antes de submeter — igual à exclusão individual, para evitar clique acidental numa ação irreversível.
Histórico de alterações
Toda ação feita através do admin — criar, editar, excluir (individual ou em
lote, embutida ou via BulkAction) — gera automaticamente uma entrada de
auditoria: quem fez, quando, em qual model/registro. Não é opt-in nem
precisa de nenhuma Option — vale para todo model registrado via
admin.Register, desde a primeira versão que inclui este recurso.
Acesse em Histórico, fixo no topo da barra lateral (/admin/historico/),
com filtro por model. Cada entrada mostra:
| Coluna | Conteúdo |
|---|---|
| Data | Quando a ação aconteceu |
| Model | O rótulo do model (admin.Register("slug", "Rótulo", ...)) |
| Registro | O valor da PK afetada |
| Ação | create, update, delete, ou o name de uma BulkAction |
| Alterações | Para create/update: os campos submetidos (campo: valor). Vazio para delete e ações em lote — nenhuma dessas grava um diff de valores nesta versão |
| Usuário | Quem estava autenticado quando a ação rodou |
- Campos sensíveis nunca aparecem em Alterações:
kyrux:"hash"ekyrux:"encrypt"são sempre gravados como***, mesma proteção que já vale pro resto do admin — o histórico é uma cópia secundária dos dados que fica indefinidamente, não pode ser um jeito indireto de vazar senha ou o plaintext de um campo cifrado. - Modelos
SuperuserOnly()também ficam escondidos no histórico para quem só temIsStaff— a mesma restrição que já vale pro model em si. - Cada entrada mora na mesma conexão do registro que ela descreve (a
conexão do model —
admin.Conn("nome")— não sempre"default"). A página Histórico sem filtro mostra a conexão"default"; para ver o histórico de um model configurado noutra conexão nomeada, filtre por ele explicitamente (o filtro resolve a conexão certa automaticamente). - A tabela (
history_logs) é criada automaticamente junto com a deauth.User— no SQLite de fallback (development, sem banco configurado) e viamakemigrations, que agora também escaneiacore/admin(mesmo tratamento já dado acore/security/auth).
O render de cada página do admin (core/admin/templates.go) reaproveita o
buffer de saída via sync.Pool entre requisições, em vez de alocar um
bytes.Buffer novo a cada render — mesmo padrão já usado pelo motor de
templates da aplicação (core/render). Medido (microbenchmark isolado):
~26% menos memória alocada por render e ~7% menos tempo, sem nenhuma
mudança de comportamento observável.
admin.Mount,admin.EnsureAllTableseadmin.Countsão exportadas mas de uso interno —bootstrap.Init()já as chama sozinho na ordem certa (depois que os apps registraram seus models, antes de montar as rotas). Código de app não precisa (nem deve) chamá-las diretamente; a única API voltada para o desenvolvedor éadmin.Register[T]e suas opções.