From 812d38c8f17b0b6dc4da68a48d9da776dcc545f2 Mon Sep 17 00:00:00 2001 From: Victor Broman Date: Sun, 8 Feb 2026 14:20:18 +0100 Subject: [PATCH] adds --- internal/database/migrations.go | 16 +++++- internal/handlers/auth.go | 2 + internal/handlers/customers.go | 12 ++--- internal/handlers/dashboard.go | 2 +- internal/handlers/handler.go | 37 +++++++++++--- internal/handlers/invoices.go | 4 +- internal/handlers/ledger.go | 14 ++--- internal/handlers/orders.go | 10 ++-- internal/handlers/users.go | 90 +++++++++++++++++++++++++++++++++ internal/middleware/auth.go | 14 +++++ internal/models/user.go | 37 +++++++++++++- main.go | 7 +++ templates/layout.html | 6 +++ templates/users/form.html | 54 ++++++++++++++++++++ templates/users/list.html | 37 ++++++++++++++ 15 files changed, 312 insertions(+), 30 deletions(-) create mode 100644 internal/handlers/users.go create mode 100644 templates/users/form.html create mode 100644 templates/users/list.html diff --git a/internal/database/migrations.go b/internal/database/migrations.go index 18d0759..e2e06c2 100644 --- a/internal/database/migrations.go +++ b/internal/database/migrations.go @@ -15,6 +15,7 @@ func runMigrations(db *sql.DB) error { id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, @@ -105,6 +106,19 @@ func runMigrations(db *sql.DB) error { } } + // Manual migration for existing users table + var roleExists int + db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='role'").Scan(&roleExists) + if roleExists == 0 { + if _, err := db.Exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'"); err != nil { + log.Printf("Failed to add role column: %v", err) + } else { + log.Println("Added role column to users table") + // Update existing admin user if exists + db.Exec("UPDATE users SET role = 'admin' WHERE username = 'admin'") + } + } + log.Println("Migrations completed") return nil } @@ -118,7 +132,7 @@ func seedData(db *sql.DB) error { if err != nil { return fmt.Errorf("hashing password: %w", err) } - _, err = db.Exec("INSERT INTO users (username, password_hash) VALUES (?, ?)", "admin", string(hash)) + _, err = db.Exec("INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", "admin", string(hash), "admin") if err != nil { return fmt.Errorf("inserting admin user: %w", err) } diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 1720318..099649c 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -38,6 +38,7 @@ func (h *Handler) LoginSubmit(w http.ResponseWriter, r *http.Request) { session, _ := h.Store.Get(r, "erp-session") session.Values["user_id"] = user.ID session.Values["username"] = user.Username + session.Values["role"] = user.Role session.Save(r, w) // HTMX redirect @@ -52,6 +53,7 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { session, _ := h.Store.Get(r, "erp-session") session.Values["user_id"] = nil session.Values["username"] = nil + session.Values["role"] = nil session.Options.MaxAge = -1 session.Save(r, w) diff --git a/internal/handlers/customers.go b/internal/handlers/customers.go index 98e890a..c717160 100644 --- a/internal/handlers/customers.go +++ b/internal/handlers/customers.go @@ -51,7 +51,7 @@ func (h *Handler) CustomerList(w http.ResponseWriter, r *http.Request) { return } - h.render(w, []string{"layout.html", "customers/list.html"}, data) + h.render(w, r, []string{"layout.html", "customers/list.html"}, data) } func (h *Handler) CustomerNew(w http.ResponseWriter, r *http.Request) { @@ -62,7 +62,7 @@ func (h *Handler) CustomerNew(w http.ResponseWriter, r *http.Request) { "Customer": &models.Customer{}, "IsNew": true, } - h.render(w, []string{"layout.html", "customers/form.html"}, data) + h.render(w, r, []string{"layout.html", "customers/form.html"}, data) } func (h *Handler) CustomerCreate(w http.ResponseWriter, r *http.Request) { @@ -82,7 +82,7 @@ func (h *Handler) CustomerCreate(w http.ResponseWriter, r *http.Request) { "IsNew": true, "Error": "Name is required", } - h.render(w, []string{"layout.html", "customers/form.html"}, data) + h.render(w, r, []string{"layout.html", "customers/form.html"}, data) return } @@ -126,7 +126,7 @@ func (h *Handler) CustomerDetail(w http.ResponseWriter, r *http.Request) { "Orders": customerOrders, "Invoices": invoices, } - h.render(w, []string{"layout.html", "customers/detail.html"}, data) + h.render(w, r, []string{"layout.html", "customers/detail.html"}, data) } func (h *Handler) CustomerEdit(w http.ResponseWriter, r *http.Request) { @@ -144,7 +144,7 @@ func (h *Handler) CustomerEdit(w http.ResponseWriter, r *http.Request) { "Customer": customer, "IsNew": false, } - h.render(w, []string{"layout.html", "customers/form.html"}, data) + h.render(w, r, []string{"layout.html", "customers/form.html"}, data) } func (h *Handler) CustomerUpdate(w http.ResponseWriter, r *http.Request) { @@ -166,7 +166,7 @@ func (h *Handler) CustomerUpdate(w http.ResponseWriter, r *http.Request) { "IsNew": false, "Error": "Name is required", } - h.render(w, []string{"layout.html", "customers/form.html"}, data) + h.render(w, r, []string{"layout.html", "customers/form.html"}, data) return } diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 27d1811..336c416 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -19,5 +19,5 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { "FulfilledOrders": models.OrderCountByStatus(h.DB, "fulfilled"), } - h.render(w, []string{"layout.html", "dashboard.html"}, data) + h.render(w, r, []string{"layout.html", "dashboard.html"}, data) } diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go index e7bb906..cd89400 100644 --- a/internal/handlers/handler.go +++ b/internal/handlers/handler.go @@ -21,12 +21,24 @@ var funcMap = template.FuncMap{ "formatMoney": func(amount float64) string { return fmt.Sprintf("$%.2f", amount) }, - "formatDate": func(date string) string { - t, err := time.Parse("2006-01-02", date) - if err != nil { - return date + "formatDate": func(v interface{}) string { + if t, ok := v.(time.Time); ok { + return t.Format("Jan 02, 2006") } - return t.Format("Jan 02, 2006") + if date, ok := v.(string); ok { + t, err := time.Parse("2006-01-02", date) + if err != nil { + return date + } + return t.Format("Jan 02, 2006") + } + return "" + }, + "formatDateTime": func(v interface{}) string { + if t, ok := v.(time.Time); ok { + return t.Format("Jan 02, 2006 15:04") + } + return "" }, "statusBadge": func(status string) template.HTML { colors := map[string]string{ @@ -69,7 +81,12 @@ var funcMap = template.FuncMap{ }, } -func (h *Handler) render(w http.ResponseWriter, templates []string, data interface{}) { +func (h *Handler) render(w http.ResponseWriter, r *http.Request, templates []string, data interface{}) { + if d, ok := data.(map[string]interface{}); ok { + d["Username"] = h.getUsername(r) + d["IsAdmin"] = h.getRole(r) == "admin" + } + paths := make([]string, len(templates)) for i, t := range templates { paths[i] = filepath.Join("templates", t) @@ -106,3 +123,11 @@ func (h *Handler) getUsername(r *http.Request) string { } return "" } + +func (h *Handler) getRole(r *http.Request) string { + session, _ := h.Store.Get(r, "erp-session") + if role, ok := session.Values["role"].(string); ok { + return role + } + return "" +} diff --git a/internal/handlers/invoices.go b/internal/handlers/invoices.go index f61a5ac..e0a9d9c 100644 --- a/internal/handlers/invoices.go +++ b/internal/handlers/invoices.go @@ -43,7 +43,7 @@ func (h *Handler) InvoiceList(w http.ResponseWriter, r *http.Request) { return } - h.render(w, []string{"layout.html", "invoices/list.html"}, data) + h.render(w, r, []string{"layout.html", "invoices/list.html"}, data) } func (h *Handler) InvoiceDetail(w http.ResponseWriter, r *http.Request) { @@ -60,7 +60,7 @@ func (h *Handler) InvoiceDetail(w http.ResponseWriter, r *http.Request) { "ActivePage": "invoices", "Invoice": invoice, } - h.render(w, []string{"layout.html", "invoices/detail.html"}, data) + h.render(w, r, []string{"layout.html", "invoices/detail.html"}, data) } func (h *Handler) InvoicePay(w http.ResponseWriter, r *http.Request) { diff --git a/internal/handlers/ledger.go b/internal/handlers/ledger.go index d3a1561..35e49e4 100644 --- a/internal/handlers/ledger.go +++ b/internal/handlers/ledger.go @@ -21,7 +21,7 @@ func (h *Handler) ChartOfAccounts(w http.ResponseWriter, r *http.Request) { "ActivePage": "ledger", "Accounts": accounts, } - h.render(w, []string{"layout.html", "ledger/chart_of_accounts.html"}, data) + h.render(w, r, []string{"layout.html", "ledger/chart_of_accounts.html"}, data) } func (h *Handler) JournalEntries(w http.ResponseWriter, r *http.Request) { @@ -51,7 +51,7 @@ func (h *Handler) JournalEntries(w http.ResponseWriter, r *http.Request) { "PrevPage": page - 1, "NextPage": page + 1, } - h.render(w, []string{"layout.html", "ledger/journal_entries.html"}, data) + h.render(w, r, []string{"layout.html", "ledger/journal_entries.html"}, data) } func (h *Handler) JournalEntryNew(w http.ResponseWriter, r *http.Request) { @@ -64,7 +64,7 @@ func (h *Handler) JournalEntryNew(w http.ResponseWriter, r *http.Request) { "Accounts": accounts, "Today": time.Now().Format("2006-01-02"), } - h.render(w, []string{"layout.html", "ledger/journal_entry_form.html"}, data) + h.render(w, r, []string{"layout.html", "ledger/journal_entry_form.html"}, data) } func (h *Handler) JournalEntryCreate(w http.ResponseWriter, r *http.Request) { @@ -107,7 +107,7 @@ func (h *Handler) JournalEntryCreate(w http.ResponseWriter, r *http.Request) { "Today": time.Now().Format("2006-01-02"), "Error": "At least two lines are required", } - h.render(w, []string{"layout.html", "ledger/journal_entry_form.html"}, data) + h.render(w, r, []string{"layout.html", "ledger/journal_entry_form.html"}, data) return } @@ -121,7 +121,7 @@ func (h *Handler) JournalEntryCreate(w http.ResponseWriter, r *http.Request) { "Today": time.Now().Format("2006-01-02"), "Error": err.Error(), } - h.render(w, []string{"layout.html", "ledger/journal_entry_form.html"}, data) + h.render(w, r, []string{"layout.html", "ledger/journal_entry_form.html"}, data) return } @@ -146,7 +146,7 @@ func (h *Handler) JournalEntryDetail(w http.ResponseWriter, r *http.Request) { "ActivePage": "ledger", "Entry": entry, } - h.render(w, []string{"layout.html", "ledger/journal_entry_detail.html"}, data) + h.render(w, r, []string{"layout.html", "ledger/journal_entry_detail.html"}, data) } func (h *Handler) TrialBalance(w http.ResponseWriter, r *http.Request) { @@ -170,5 +170,5 @@ func (h *Handler) TrialBalance(w http.ResponseWriter, r *http.Request) { "TotalDebit": totalDebit, "TotalCredit": totalCredit, } - h.render(w, []string{"layout.html", "ledger/trial_balance.html"}, data) + h.render(w, r, []string{"layout.html", "ledger/trial_balance.html"}, data) } diff --git a/internal/handlers/orders.go b/internal/handlers/orders.go index 9ecef78..7569a2b 100644 --- a/internal/handlers/orders.go +++ b/internal/handlers/orders.go @@ -44,7 +44,7 @@ func (h *Handler) OrderList(w http.ResponseWriter, r *http.Request) { return } - h.render(w, []string{"layout.html", "orders/list.html"}, data) + h.render(w, r, []string{"layout.html", "orders/list.html"}, data) } func (h *Handler) OrderNew(w http.ResponseWriter, r *http.Request) { @@ -58,7 +58,7 @@ func (h *Handler) OrderNew(w http.ResponseWriter, r *http.Request) { "Customers": customers, "IsNew": true, } - h.render(w, []string{"layout.html", "orders/form.html"}, data) + h.render(w, r, []string{"layout.html", "orders/form.html"}, data) } func (h *Handler) OrderCreate(w http.ResponseWriter, r *http.Request) { @@ -81,7 +81,7 @@ func (h *Handler) OrderCreate(w http.ResponseWriter, r *http.Request) { "IsNew": true, "Error": "Customer is required", } - h.render(w, []string{"layout.html", "orders/form.html"}, data) + h.render(w, r, []string{"layout.html", "orders/form.html"}, data) return } @@ -115,7 +115,7 @@ func (h *Handler) OrderDetail(w http.ResponseWriter, r *http.Request) { "ActivePage": "orders", "Order": order, } - h.render(w, []string{"layout.html", "orders/detail.html"}, data) + h.render(w, r, []string{"layout.html", "orders/detail.html"}, data) } func (h *Handler) OrderEdit(w http.ResponseWriter, r *http.Request) { @@ -141,7 +141,7 @@ func (h *Handler) OrderEdit(w http.ResponseWriter, r *http.Request) { "Customers": customers, "IsNew": false, } - h.render(w, []string{"layout.html", "orders/form.html"}, data) + h.render(w, r, []string{"layout.html", "orders/form.html"}, data) } func (h *Handler) OrderUpdate(w http.ResponseWriter, r *http.Request) { diff --git a/internal/handlers/users.go b/internal/handlers/users.go new file mode 100644 index 0000000..620c25d --- /dev/null +++ b/internal/handlers/users.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "fmt" + "net/http" + "strconv" + + "erp_system/internal/models" +) + +func (h *Handler) UserList(w http.ResponseWriter, r *http.Request) { + users, err := models.UserGetAll(h.DB) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + data := map[string]interface{}{ + "Title": "Users", + "ActivePage": "users", + "Users": users, + } + h.render(w, r, []string{"layout.html", "users/list.html"}, data) +} + +func (h *Handler) UserNew(w http.ResponseWriter, r *http.Request) { + data := map[string]interface{}{ + "Title": "New User", + "ActivePage": "users", + "User": &models.User{}, + "IsNew": true, + } + h.render(w, r, []string{"layout.html", "users/form.html"}, data) +} + +func (h *Handler) UserCreate(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") + password := r.FormValue("password") + role := r.FormValue("role") + + if username == "" || password == "" { + data := map[string]interface{}{ + "Title": "New User", + "ActivePage": "users", + "User": &models.User{Username: username, Role: role}, + "IsNew": true, + "Error": "Username and Password are required", + } + h.render(w, r, []string{"layout.html", "users/form.html"}, data) + return + } + + if err := models.UserCreate(h.DB, username, password, role); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", "/users") + return + } + http.Redirect(w, r, "/users", http.StatusSeeOther) +} + +func (h *Handler) UserDelete(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.Atoi(r.PathValue("id")) + + // Prevent deleting yourself + session, _ := h.Store.Get(r, "erp-session") + currentUserID := session.Values["user_id"].(int) + if id == currentUserID { + w.Header().Set("HX-Trigger", `{"showMessage": "Cannot delete yourself"}`) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "Cannot delete yourself") + return + } + + if err := models.UserDelete(h.DB, id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if r.Header.Get("HX-Request") == "true" { + // Just remove the row from the table or reload + // Since it's a delete action, usually redirect or reload list + w.Header().Set("HX-Refresh", "true") + return + } + http.Redirect(w, r, "/users", http.StatusSeeOther) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 46f4173..0c33427 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -25,3 +25,17 @@ func RequireAuth(store *sessions.CookieStore) func(http.Handler) http.Handler { }) } } + +func RequireAdmin(store *sessions.CookieStore) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, _ := store.Get(r, "erp-session") + role, ok := session.Values["role"].(string) + if !ok || role != "admin" { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/models/user.go b/internal/models/user.go index 8b164fb..9e0752f 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -12,15 +12,16 @@ type User struct { ID int Username string PasswordHash string + Role string CreatedAt time.Time } func Authenticate(db *sql.DB, username, password string) (*User, error) { u := &User{} err := db.QueryRow( - "SELECT id, username, password_hash, created_at FROM users WHERE username = ?", + "SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?", username, - ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt) + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) if err != nil { return nil, fmt.Errorf("user not found") } @@ -31,3 +32,35 @@ func Authenticate(db *sql.DB, username, password string) (*User, error) { return u, nil } + +func UserGetAll(db *sql.DB) ([]User, error) { + rows, err := db.Query("SELECT id, username, role, created_at FROM users ORDER BY username") + if err != nil { + return nil, err + } + defer rows.Close() + + var users []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil { + return nil, err + } + users = append(users, u) + } + return users, nil +} + +func UserCreate(db *sql.DB, username, password, role string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + _, err = db.Exec("INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", username, string(hash), role) + return err +} + +func UserDelete(db *sql.DB, id int) error { + _, err := db.Exec("DELETE FROM users WHERE id = ?", id) + return err +} diff --git a/main.go b/main.go index d927538..63851c7 100644 --- a/main.go +++ b/main.go @@ -40,6 +40,7 @@ func main() { // Auth middleware authMw := middleware.RequireAuth(store) + adminMw := middleware.RequireAdmin(store) // Router mux := http.NewServeMux() @@ -94,6 +95,12 @@ func main() { mux.Handle("GET /ledger/journal/{id}", authMw(http.HandlerFunc(h.JournalEntryDetail))) mux.Handle("GET /ledger/trial-balance", authMw(http.HandlerFunc(h.TrialBalance))) + // User Management (Admin only) + mux.Handle("GET /users", authMw(adminMw(http.HandlerFunc(h.UserList)))) + mux.Handle("GET /users/new", authMw(adminMw(http.HandlerFunc(h.UserNew)))) + mux.Handle("POST /users", authMw(adminMw(http.HandlerFunc(h.UserCreate)))) + mux.Handle("DELETE /users/{id}", authMw(adminMw(http.HandlerFunc(h.UserDelete)))) + port := os.Getenv("PORT") if port == "" { port = "1337" diff --git a/templates/layout.html b/templates/layout.html index f3e2c36..f3a5b9c 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -28,6 +28,9 @@ Customers Orders Invoices + {{if .IsAdmin}} + Users + {{end}}
+
+ + +{{end}} diff --git a/templates/users/list.html b/templates/users/list.html new file mode 100644 index 0000000..16ca510 --- /dev/null +++ b/templates/users/list.html @@ -0,0 +1,37 @@ +{{define "content"}} +
+
+

Users

+
+
+ New User +
+
+ +
+ + + + + + + + + + + {{range .Users}} + + + + + + + {{end}} + +
UsernameRoleCreated At + Actions +
{{.Username}}{{.Role}}{{.CreatedAt | formatDateTime}} + +
+
+{{end}}