goadmin

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 10, 2026 License: MIT Imports: 28 Imported by: 0

README

go-admin-bootstrap

Bootstrap library for building Go admin panels with Echo v4, PostgreSQL (uptrace/bun), and Jet templates.

Features

  • User management (CRUD) with role-based access control (owner, root, user)
  • JWT access tokens + refresh tokens in PostgreSQL
  • Structured logging via log/slog
  • CSRF protection on all state-changing routes (POST only; GET requests to destructive endpoints return 405)
  • Rate limiting on login endpoint
  • Secure cookies (HttpOnly, SameSite=Strict, Secure in production)
  • Embedded assets (JS, CSS, views) served from embed.FS in production
  • Goose migrations with embedded SQL
  • CLI adapters for cobra and urfave/cli v3
  • Graceful shutdown, health check endpoint
  • Password hashing with argon2id (bcrypt backward-compatible)

Quick Start

package main

import (
    "context"
    "database/sql"
    "log/slog"
    "os"
    "os/signal"

    "github.com/uptrace/bun"
    "github.com/uptrace/bun/dialect/pgdialect"
    "github.com/uptrace/bun/driver/pgdriver"

    goadmin "github.com/partyzanex/go-admin-bootstrap"
    "github.com/partyzanex/go-admin-bootstrap/repository/postgres"
    "github.com/partyzanex/go-admin-bootstrap/usecase"
)

func main() {
    sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(os.Getenv("PG_DSN"))))
    db := bun.NewDB(sqldb, pgdialect.New())
    defer db.Close()

    userRepo := postgres.NewUserRepository(db)
    tokenRepo := postgres.NewTokenRepository(db)

    admin, _ := goadmin.New(&goadmin.Config{
        Host:      "localhost",
        Port:      9900,
        BaseURL:   "http://localhost:9900/admin",
        JWTSecret: []byte(os.Getenv("JWT_SECRET")),
        DBConfig:  goadmin.DBConfig{DB: db},
        UserCase:  usecase.NewUserCase(userRepo, tokenRepo),
    })

    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
    defer stop()

    admin.Serve(ctx)
}

See example/main.go for a complete example with middleware and logging.

Project Structure

├── app.go, config.go, auth.go, ...   Core library
├── usecase/                           Business logic
├── repository/postgres/               Bun-based data layer
├── db/migrations/postgres/            Goose SQL migrations
├── widgets/                           Pagination, breadcrumbs
├── views/                             Embedded Jet templates
├── assets/                            Embedded JS, CSS, favicon
├── pkg/commands/                      Framework-agnostic CLI commands
├── adapters/
│   ├── cobracli/                      Cobra adapter
│   └── urfavecli/                     urfave/cli v3 adapter
├── cmd/
│   ├── goadmin-users/                 User management CLI (cobra)
│   ├── admin-cobra/                   Example CLI (cobra)
│   └── admin-cli/                     Example CLI (urfave/cli v3)
└── example/                           Example web application

CLI Tools

Create User (cobra)

Password is resolved in priority order: --password flag → GOADMIN_PASSWORD env → interactive prompt → piped stdin. Use the flag only in dev/CI environments; prefer the env var or prompt in production.

# Interactive prompt (production-safe — password is not visible in process list or shell history)
go run ./cmd/goadmin-users create-user \
    --dsn="postgres://user:pass@localhost:5432/db?sslmode=disable" \
    --login="admin@example.com" \
    --name="Admin" \
    --role="owner"

# Environment variable (CI/CD)
GOADMIN_PASSWORD="Admin123" go run ./cmd/goadmin-users create-user \
    --dsn="postgres://user:pass@localhost:5432/db?sslmode=disable" \
    --login="admin@example.com" \
    --name="Admin" \
    --role="owner"

# --password flag (dev shortcut only)
go run ./cmd/goadmin-users create-user \
    --dsn="postgres://user:pass@localhost:5432/db?sslmode=disable" \
    --login="admin@example.com" \
    --password="Admin123" \
    --name="Admin" \
    --role="owner"
Run Migrations
go run ./cmd/goadmin-users migrate --dsn="..." --direction=up

Running the example application

Prerequisites
  • Go 1.22+
  • Docker and Docker Compose
Step-by-step

1. Start PostgreSQL:

make local-db-up

This starts a PostgreSQL container on port 5432 with credentials goadmin:goadmin (see docker-compose.yml).

2. Run migrations:

make migration-up

Creates the goadmin schema and required tables (user, auth_token, audit_log).

3. Create an admin user:

make create-default-user

Creates admin@example.com / Admin123 with owner role. For production use GOADMIN_PASSWORD env var or the interactive prompt instead of --password.

4. Start the example application:

make run-example

The admin panel is available at http://localhost:9900/admin.

Log in with admin@example.com / Admin123.

Custom DSN or JWT secret
PG_DSN="postgres://user:pass@host:5432/db?sslmode=disable" JWT_SECRET="my-secret" make run-example

Makefile defaults: PG_DSN=postgres://goadmin:goadmin@localhost:5432/goadmin?sslmode=disable, JWT_SECRET=dev-secret-change-me.

Stop the database
make local-db-down

Development

make tools           # Install goose CLI
make local-db-up     # Start PostgreSQL via docker compose
make migration-up    # Run migrations
make create-default-user  # Create admin user
make run-example     # Start example application
make test            # Run unit tests
make cover           # Run all tests (requires PostgreSQL) with coverage report
make lint            # Run golangci-lint v2

Configuration

Field Type Required Default Description
Host string no "" Listen host
Port uint16 yes - Listen port
BaseURL string yes - Full base URL
JWTSecret []byte yes - JWT signing secret
AccessCookieName string no "auth_token" Cookie name prefix
AccessTokenTTL Duration no 15m JWT access token TTL
RefreshTokenTTL Duration no 30d Refresh token TTL
DevMode bool no false Development mode
DBConfig.DB *bun.DB yes - Database connection
DBConfig.MigrationsTable string no "goadmin_migrations" Goose table name
UserCase UserUseCase yes - User use case implementation
Logger *slog.Logger no slog.Default() Structured logger

Options via goadmin.WithMiddleware(...) and goadmin.WithAssets(...).

Security

  • CSRF: All admin routes use Echo's CSRF middleware. State-changing operations (logout, delete) are POST-only — GET requests return 405. Tokens are validated via cookie + header/form double-submit pattern.
  • Authentication: JWT access tokens in HttpOnly cookies. Refresh tokens stored in PostgreSQL and invalidated on logout.
  • Rate limiting: Login endpoint is rate-limited to prevent brute-force attacks.
  • Roles: owner and root users can manage other users. user role has read-only dashboard access. Users cannot delete themselves.

Documentation

Index

Constants

View Source
const (
	DefaultAssetsPath    = "./assets"
	DefaultViewsPath     = "./views"
	DefaultLimit         = 20
	LoginRateLimitPerSec = 5
	SecureTokenLength    = 32

	// MinJWTSecretLen is the minimum byte length of JWTSecret for non-dev deployments.
	// HS256 provides its full 256-bit security only when the key is at least 32 bytes.
	MinJWTSecretLen = 32

	DefaultAccessTokenTTL  = 15 * time.Minute
	DefaultRefreshTokenTTL = 30 * 24 * time.Hour

	UserContextKey   = "goadmin_user"
	DataContextKey   = "goadmin_data"
	LoggerContextKey = "goadmin_logger"

	AuthToken TokenType = "auth"

	UserNew     UserStatus = "new"
	UserActive  UserStatus = "active"
	UserBlocked UserStatus = "blocked"

	RoleOwner UserRole = "owner"
	RoleRoot  UserRole = "root"
	RoleUser  UserRole = "user"
)
View Source
const (
	DashboardURL = "/"
	LoginURL     = "/login"
	LogoutURL    = "/logout"

	UserListURL   = "/users"
	UserCreateURL = "/users/create"
	UserUpdateURL = "/users/:id/update"
	UserDeleteURL = "/users/:id/delete"

	AuditLogURL = "/audit"

	DefaultAccessCookieName = "auth_token"
	DefaultMigrationsTable  = "goadmin_migrations"

	FaviconPrefix = "/favicon/:id"
)

Variables

View Source
var (
	JS = []*Asset{
		{"plugins/bootstrap/js/bootstrap.bundle.min.js", -800, JavaScript},
		{"js/admin.js", 0, JavaScript},
	}
	CSS = []*Asset{
		{"plugins/bootstrap/css/bootstrap.min.css", -1000, Stylesheet},
		{"css/style.css", -900, Stylesheet},
	}
	Views = []*Asset{
		{"layouts/nav.jet", 0, View},
		{"layouts/main.jet", 0, View},
		{"widgets/breadcrumbs.jet", 0, View},
		{"widgets/pagination.jet", 0, View},
		{"index/dashboard.jet", 0, View},
		{"errors/error.jet", 0, View},
		{"auth/login.jet", 0, View},
		{"user/form.jet", 0, View},
		{"user/index.jet", 0, View},
		{"audit/index.jet", 0, View},
	}
)
View Source
var (
	ErrInvalidPort          = errors.New("invalid http port")
	ErrContextNotConfigured = errors.New("admin context not configured")
	ErrRequiredUserName     = errors.New("required user name")
	ErrRequiredUserLogin    = errors.New("required user login")
	ErrInvalidUserLogin     = errors.New("invalid user login")
	ErrInvalidUserStatus    = errors.New("invalid user status")
	ErrRequiredUserID       = errors.New("required user id")
	ErrRequiredUserPassword = errors.New("required user password")
	ErrWrongPassword        = errors.New("wrong password")
	ErrInvalidUserRole      = errors.New("invalid user role")
	ErrRequiredConfig       = errors.New("required config")
	ErrRequiredJWTSecret    = errors.New("required jwt secret")
	ErrJWTSecretTooShort    = fmt.Errorf("jwt secret must be at least %d bytes for HS256", MinJWTSecretLen)
	ErrUserBlocked          = errors.New("user account is not active")
	ErrRequiredUserCase     = errors.New("required user use case")
)

Functions

func AuditLogList added in v0.4.0

func AuditLogList(ctx *AppContext) error

func AuthByCookie

func AuthByCookie(handlerFunc echo.HandlerFunc) echo.HandlerFunc

func Dashboard

func Dashboard(ctx *AppContext) error

func Favicon added in v0.2.0

func Favicon(ctx echo.Context) error

func HTMLError

func HTMLError(e error, ctx echo.Context)

func HTTPError added in v0.2.0

func HTTPError(e error, ctx echo.Context)

func IsExpired added in v0.4.0

func IsExpired(err error) bool

func IsNotFound added in v0.4.0

func IsNotFound(err error) bool

func JSONError

func JSONError(e error, ctx echo.Context)

func Login

func Login(ctx *AppContext) error

func Logout

func Logout(ctx *AppContext) error

func Path

func Path(paths ...string) string

func RequireRole added in v0.4.0

func RequireRole(roles ...UserRole) echo.MiddlewareFunc

func UserCreate added in v0.0.2

func UserCreate(ctx *AppContext) error

func UserDelete added in v0.0.2

func UserDelete(ctx *AppContext) error

func UserList added in v0.0.2

func UserList(ctx *AppContext) error

func UserUpdate added in v0.0.2

func UserUpdate(ctx *AppContext) error

func WrapHandler

func WrapHandler(handleFunc AdminHandler) echo.HandlerFunc

Types

type AdminHandler

type AdminHandler func(ctx *AppContext) error

type App added in v0.2.0

type App struct {
	// contains filtered or unexported fields
}

func New

func New(config *Config, opts ...Option) (*App, error)

func (*App) Admin added in v0.2.0

func (app *App) Admin() *echo.Group

func (*App) Close added in v0.3.0

func (app *App) Close() error

func (*App) CreateAssets added in v0.2.0

func (app *App) CreateAssets() error

func (*App) Echo added in v0.2.0

func (app *App) Echo() *echo.Echo

func (*App) Serve added in v0.2.0

func (app *App) Serve(ctx context.Context) error

func (*App) Static added in v0.2.0

func (app *App) Static() *echo.Group

type AppContext added in v0.2.0

type AppContext struct {
	echo.Context
	// contains filtered or unexported fields
}

func (*AppContext) CookieName added in v0.4.0

func (c *AppContext) CookieName() string

func (*AppContext) Ctx added in v0.2.0

func (c *AppContext) Ctx() context.Context

func (*AppContext) Data added in v0.2.0

func (c *AppContext) Data() *Data

func (*AppContext) Log added in v0.4.0

func (c *AppContext) Log() *slog.Logger

func (*AppContext) URL added in v0.2.0

func (c *AppContext) URL(path string, args ...any) string

func (*AppContext) User added in v0.2.0

func (c *AppContext) User() *User

func (*AppContext) UserCase added in v0.2.0

func (c *AppContext) UserCase() UserUseCase

type Asset added in v0.0.2

type Asset struct {
	Path      string
	SortOrder int
	Kind      AssetKind
}

type AssetKind added in v0.2.0

type AssetKind uint8
const (
	JavaScript AssetKind = iota
	Stylesheet
	View
)

type AuditAction added in v0.4.0

type AuditAction string

AuditAction describes the type of event recorded in the audit log.

const (
	AuditLogin      AuditAction = "login"
	AuditLogout     AuditAction = "logout"
	AuditCreateUser AuditAction = "create_user"
	AuditUpdateUser AuditAction = "update_user"
	AuditDeleteUser AuditAction = "delete_user"
)

type AuditLog added in v0.4.0

type AuditLog struct {
	ID         int64
	ActorID    int64  // 0 when the original actor has been deleted
	ActorLogin string // snapshot of the actor's login at the time of the event
	Action     AuditAction
	EntityID   int64          // ID of the affected entity (user ID, etc.)
	Meta       map[string]any // optional context-specific fields
	DTCreated  time.Time
}

AuditLog is a single audit trail entry.

func (*AuditLog) GetDTCreated added in v0.4.0

func (a *AuditLog) GetDTCreated() string

type AuditLogFilter added in v0.4.0

type AuditLogFilter struct {
	Action AuditAction
	Limit  int
	Offset int
}

AuditLogFilter narrows the result set returned by AuditLogRepository.Search.

type AuditLogRepository added in v0.4.0

type AuditLogRepository interface {
	Create(ctx context.Context, entry *AuditLog) (*AuditLog, error)
	Search(ctx context.Context, filter *AuditLogFilter) ([]*AuditLog, error)
	Count(ctx context.Context, filter *AuditLogFilter) (int64, error)
}

AuditLogRepository persists and retrieves audit log entries. It is optional: set Config.AuditLog to nil to disable audit logging.

type Claims added in v0.4.0

type Claims struct {
	jwt.RegisteredClaims
	UserID int64  `json:"uid"`
	Role   string `json:"role"`
}

type Config

type Config struct {
	Host string
	Port uint16

	BaseURL          string
	ViewsPath        string
	AssetsPath       string
	AccessCookieName string

	JWTSecret       []byte
	AccessTokenTTL  time.Duration
	RefreshTokenTTL time.Duration

	DevMode bool

	DBConfig DBConfig
	UserCase UserUseCase
	AuditLog AuditLogRepository // optional: set to nil to disable audit logging
	Logger   *slog.Logger
	// contains filtered or unexported fields
}

func (*Config) Clone added in v0.2.0

func (config *Config) Clone() *Config

func (*Config) Validate

func (config *Config) Validate() error

type DBConfig

type DBConfig struct {
	DB          DBPinger
	MigrateFunc func() error
}

DBConfig holds optional database-related settings. DB is used only for the /health endpoint connectivity check; leave nil to skip the check. MigrateFunc, when set, is called once during New() before the server starts.

type DBPinger added in v0.4.0

type DBPinger interface {
	PingContext(ctx context.Context) error
}

DBPinger is satisfied by *sql.DB, *bun.DB, and any other driver that supports context-aware connectivity checks. It is used by the /health endpoint.

type Data

type Data struct {
	jet.VarMap

	Title       string
	User        *User
	Breadcrumbs widgets.Breadcrumbs
}

func (Data) Has added in v0.0.9

func (data Data) Has(key string) bool

func (*Data) JetData

func (data *Data) JetData() map[string]any

func (*Data) JetVars

func (data *Data) JetVars() jet.VarMap

func (*Data) Set

func (data *Data) Set(name string, value any)

type ExpiredError added in v0.4.0

type ExpiredError struct {
	Entity string
	ID     any
}

ExpiredError indicates that a resource has expired.

func NewTokenExpiredError added in v0.4.0

func NewTokenExpiredError(token string) *ExpiredError

func (*ExpiredError) Error added in v0.4.0

func (e *ExpiredError) Error() string

type FSLoader added in v0.3.0

type FSLoader struct {
	// contains filtered or unexported fields
}

func NewFSLoader added in v0.3.0

func NewFSLoader(fs *embed.FS) *FSLoader

func (*FSLoader) Exists added in v0.3.0

func (l *FSLoader) Exists(templatePath string) bool

func (*FSLoader) Open added in v0.3.0

func (l *FSLoader) Open(templatePath string) (io.ReadCloser, error)

type NotFoundError added in v0.4.0

type NotFoundError struct {
	Entity string
	ID     any
}

NotFoundError indicates that a requested entity was not found.

func NewTokenNotFoundError added in v0.4.0

func NewTokenNotFoundError(token string) *NotFoundError

func NewUserNotFoundError added in v0.4.0

func NewUserNotFoundError(id any) *NotFoundError

func (*NotFoundError) Error added in v0.4.0

func (e *NotFoundError) Error() string

type Option added in v0.4.0

type Option func(*App) error

func WithAssets added in v0.4.0

func WithAssets(a ...*Asset) Option

func WithMiddleware added in v0.4.0

func WithMiddleware(mw ...echo.MiddlewareFunc) Option

type Renderer

type Renderer struct {
	Views *jet.Set
}

func (*Renderer) Render

func (r *Renderer) Render(w io.Writer, name string, data any, _ echo.Context) error

type Response

type Response struct {
	Success bool   `json:"success"`
	Error   string `json:"error,omitempty"`
	Data    any    `json:"data,omitempty"`
}

type Token

type Token struct {
	ID        int64     `json:"-"`
	UserID    int64     `json:"user_id"`
	Token     string    `json:"token"`
	Type      TokenType `json:"type"`
	DTExpired time.Time `json:"dt_expired"`
	DTCreated time.Time `json:"dt_created"`

	User *User `json:"-"`
}

func (*Token) IsExpired

func (t *Token) IsExpired() bool

type TokenRepository

type TokenRepository interface {
	Search(ctx context.Context, token string) (*Token, error)
	Create(ctx context.Context, token *Token) (*Token, error)
	DeleteExpired(ctx context.Context) (int64, error)
	DeleteByUserID(ctx context.Context, userID int64) error
}

type TokenType

type TokenType string

func (TokenType) IsValid

func (t TokenType) IsValid() bool

type User

type User struct {
	ID       int64      `json:"id"`
	Login    string     `json:"login" validate:"required,email"`
	Password string     `json:"password"`
	Status   UserStatus `json:"status" validate:"required"`
	Name     string     `json:"name" validate:"required"`
	Role     UserRole   `json:"role" validate:"required"`

	DTCreated    time.Time `json:"dt_created"`
	DTUpdated    time.Time `json:"dt_updated"`
	DTLastLogged time.Time `json:"dt_last_logged"`

	PasswordIsEncoded bool `json:"-"`
	Current           bool `json:"-"`
}

func (*User) GetDTCreated added in v0.3.0

func (user *User) GetDTCreated() string

func (*User) GetDTLastLogged added in v0.3.0

func (user *User) GetDTLastLogged() string

func (*User) GetDTUpdated added in v0.3.0

func (user *User) GetDTUpdated() string

type UserFilter

type UserFilter struct {
	IDs    []int64
	Name   string
	Login  string
	Search string // ILIKE match against both login and name
	Status UserStatus
	Limit  int
	Offset int
}

type UserRepository

type UserRepository interface {
	Search(ctx context.Context, filter *UserFilter) ([]*User, error)
	Count(ctx context.Context, filter *UserFilter) (int64, error)
	Create(ctx context.Context, user *User) (*User, error)
	Update(ctx context.Context, user *User) (*User, error)
	SetLastLogged(ctx context.Context, user *User) error
	Delete(ctx context.Context, user *User) error
}

type UserRole added in v0.0.2

type UserRole string

func (UserRole) IsValid added in v0.0.2

func (role UserRole) IsValid() bool

type UserStatus

type UserStatus string

func (UserStatus) IsValid

func (status UserStatus) IsValid() bool

type UserUseCase

type UserUseCase interface {
	Validate(user *User, create bool) error

	SearchByLogin(ctx context.Context, login string) (*User, error)
	SearchByID(ctx context.Context, id int64) (*User, error)
	SetLastLogged(ctx context.Context, user *User) error
	Register(ctx context.Context, user *User) error
	UpdateUser(ctx context.Context, user *User) (*User, error)
	DeleteUser(ctx context.Context, id int64) error
	ListUsers(ctx context.Context, filter *UserFilter) ([]*User, int64, error)

	ComparePassword(user *User, password string) (bool, error)
	EncodePassword(user *User) error

	CreateAuthToken(ctx context.Context, user *User, cookieToken string, ttl time.Duration) (*Token, error)
	SearchToken(ctx context.Context, token string) (*Token, error)
	RevokeUserTokens(ctx context.Context, userID int64) error
	CleanupExpiredTokens(ctx context.Context) (int64, error)
}

type ViewData

type ViewData interface {
	JetVars() jet.VarMap
	JetData() map[string]any
}

Directories

Path Synopsis
adapters
cmd
admin-cli command
admin-cobra command
goadmin-users command
db
pkg
repository

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL