Initial implementation of inventree

A small IT asset inventory system built around a nested folder tree,
matching the project brief: Go + html/template + Bootstrap 5 + vanilla JS,
SQLite storage (WAL, FKs, migrations), embedded templates/static assets,
single compiled binary.

- AJAX-driven inventory tree: lazy-loaded folder expansion with per-user
  persisted expand state, drag-and-drop move for folders/assets, safe
  delete-with-relocate-or-cascade confirmation, search with breadcrumbs.
- Assets: CRUD, duplicate, move, auto-generated IT-000001-style IDs,
  CSV import (with optional folder auto-create) and export.
- Admin-defined item types with custom fields (text/long_text/number/date/
  boolean/dropdown/url/ip_address).
- Configurable statuses, session-based auth (bcrypt, secure cookies),
  admin/user roles, user management.
- CLI: serve, version, database migrate, database backup; /healthz;
  graceful shutdown; Dockerfile/docker-compose/systemd unit; Go test suite.
This commit is contained in:
Owen
2026-08-17 13:34:40 -05:00
commit 218687a1ee
71 changed files with 8102 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
// Package users manages application accounts.
package users
import (
"context"
"database/sql"
"errors"
"time"
"golang.org/x/crypto/bcrypt"
"inventree/internal/models"
)
var (
ErrNotFound = errors.New("user not found")
ErrUsernameRequired = errors.New("username is required")
ErrUsernameTaken = errors.New("username is already taken")
ErrInvalidRole = errors.New("role must be admin or user")
ErrLastAdmin = errors.New("cannot remove the last enabled admin")
)
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
const timeLayout = "2006-01-02T15:04:05.000Z"
func parseTime(s string) time.Time {
t, _ := time.Parse(timeLayout, s)
return t
}
func scan(row interface{ Scan(...any) error }) (*models.User, error) {
var u models.User
var created, updated string
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Enabled, &created, &updated); err != nil {
return nil, err
}
u.CreatedAt = parseTime(created)
u.UpdatedAt = parseTime(updated)
return &u, nil
}
func (s *Store) List(ctx context.Context) ([]models.User, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, username, password_hash, role, enabled, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.User
for rows.Next() {
u, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
func (s *Store) Get(ctx context.Context, id int64) (*models.User, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash, role, enabled, created_at, updated_at FROM users WHERE id = ?`, id)
u, err := scan(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return u, err
}
func (s *Store) GetByUsername(ctx context.Context, username string) (*models.User, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash, role, enabled, created_at, updated_at FROM users WHERE username = ?`, username)
u, err := scan(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return u, err
}
// Create adds a user with the given plaintext password (hashed with bcrypt).
func (s *Store) Create(ctx context.Context, u *models.User, plaintextPassword string) error {
if u.Username == "" {
return ErrUsernameRequired
}
if u.Role != models.RoleAdmin && u.Role != models.RoleUser {
return ErrInvalidRole
}
if _, err := s.GetByUsername(ctx, u.Username); err == nil {
return ErrUsernameTaken
} else if !errors.Is(err, ErrNotFound) {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO users (username, password_hash, role, enabled) VALUES (?, ?, ?, ?)`,
u.Username, string(hash), u.Role, u.Enabled)
if err != nil {
return err
}
id, err := res.LastInsertId()
if err != nil {
return err
}
u.ID = id
return nil
}
// Update changes username, role, and enabled state (not the password).
func (s *Store) Update(ctx context.Context, u *models.User) error {
if u.Username == "" {
return ErrUsernameRequired
}
if u.Role != models.RoleAdmin && u.Role != models.RoleUser {
return ErrInvalidRole
}
if existing, err := s.GetByUsername(ctx, u.Username); err == nil && existing.ID != u.ID {
return ErrUsernameTaken
}
if u.Role != models.RoleAdmin || !u.Enabled {
if err := s.assertNotLastAdmin(ctx, u.ID); err != nil {
return err
}
}
_, err := s.db.ExecContext(ctx,
`UPDATE users SET username = ?, role = ?, enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
u.Username, u.Role, u.Enabled, u.ID)
return err
}
func (s *Store) SetPassword(ctx context.Context, id int64, plaintextPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
`UPDATE users SET password_hash = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
string(hash), id)
return err
}
func (s *Store) Delete(ctx context.Context, id int64) error {
if err := s.assertNotLastAdmin(ctx, id); err != nil {
return err
}
_, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
return err
}
// assertNotLastAdmin returns ErrLastAdmin if id is currently an enabled
// admin and is the only one remaining.
func (s *Store) assertNotLastAdmin(ctx context.Context, id int64) error {
u, err := s.Get(ctx, id)
if err != nil {
return err
}
if u.Role != models.RoleAdmin || !u.Enabled {
return nil
}
var count int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND enabled = 1`).Scan(&count); err != nil {
return err
}
if count <= 1 {
return ErrLastAdmin
}
return nil
}
// Authenticate verifies a username/password pair and returns the user if
// valid, enabled, and the password matches.
func (s *Store) Authenticate(ctx context.Context, username, password string) (*models.User, error) {
u, err := s.GetByUsername(ctx, username)
if err != nil {
return nil, err
}
if !u.Enabled {
return nil, errors.New("account is disabled")
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid credentials")
}
return u, nil
}
+96
View File
@@ -0,0 +1,96 @@
package users_test
import (
"context"
"database/sql"
"path/filepath"
"testing"
"inventree/internal/database"
"inventree/internal/models"
"inventree/internal/users"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
dir := t.TempDir()
db, err := database.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestDefaultAdminSeeded(t *testing.T) {
ctx := context.Background()
s := users.NewStore(testDB(t))
u, err := s.Authenticate(ctx, "admin", "admin")
if err != nil {
t.Fatalf("authenticate default admin: %v", err)
}
if !u.IsAdmin() {
t.Fatal("expected default admin to have admin role")
}
}
func TestAuthenticateRejectsBadPassword(t *testing.T) {
s := users.NewStore(testDB(t))
if _, err := s.Authenticate(context.Background(), "admin", "wrong"); err == nil {
t.Fatal("expected authentication to fail")
}
}
func TestCreateUserRejectsDuplicateUsername(t *testing.T) {
ctx := context.Background()
s := users.NewStore(testDB(t))
u := &models.User{Username: "admin", Role: models.RoleUser, Enabled: true}
if err := s.Create(ctx, u, "whatever"); err != users.ErrUsernameTaken {
t.Fatalf("expected ErrUsernameTaken, got %v", err)
}
}
func TestCannotRemoveLastAdmin(t *testing.T) {
ctx := context.Background()
s := users.NewStore(testDB(t))
admin, err := s.GetByUsername(ctx, "admin")
if err != nil {
t.Fatalf("get admin: %v", err)
}
if err := s.Delete(ctx, admin.ID); err != users.ErrLastAdmin {
t.Fatalf("expected ErrLastAdmin on delete, got %v", err)
}
admin.Enabled = false
if err := s.Update(ctx, admin); err != users.ErrLastAdmin {
t.Fatalf("expected ErrLastAdmin on disable, got %v", err)
}
admin.Role = models.RoleUser
admin.Enabled = true
if err := s.Update(ctx, admin); err != users.ErrLastAdmin {
t.Fatalf("expected ErrLastAdmin on demote, got %v", err)
}
}
func TestSecondAdminAllowsRemovingFirst(t *testing.T) {
ctx := context.Background()
s := users.NewStore(testDB(t))
second := &models.User{Username: "second-admin", Role: models.RoleAdmin, Enabled: true}
if err := s.Create(ctx, second, "password123"); err != nil {
t.Fatalf("create second admin: %v", err)
}
admin, err := s.GetByUsername(ctx, "admin")
if err != nil {
t.Fatalf("get admin: %v", err)
}
if err := s.Delete(ctx, admin.ID); err != nil {
t.Fatalf("expected delete to succeed with a second admin present: %v", err)
}
}