Compare commits
2 Commits
aa3b63095c
...
812d38c8f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 812d38c8f1 | |||
| 2dfe0a48ea |
@ -15,6 +15,7 @@ func runMigrations(db *sql.DB) error {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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")
|
log.Println("Migrations completed")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -118,7 +132,7 @@ func seedData(db *sql.DB) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("hashing password: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("inserting admin user: %w", err)
|
return fmt.Errorf("inserting admin user: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ func (h *Handler) LoginSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
session, _ := h.Store.Get(r, "erp-session")
|
session, _ := h.Store.Get(r, "erp-session")
|
||||||
session.Values["user_id"] = user.ID
|
session.Values["user_id"] = user.ID
|
||||||
session.Values["username"] = user.Username
|
session.Values["username"] = user.Username
|
||||||
|
session.Values["role"] = user.Role
|
||||||
session.Save(r, w)
|
session.Save(r, w)
|
||||||
|
|
||||||
// HTMX redirect
|
// HTMX redirect
|
||||||
@ -52,6 +53,7 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
session, _ := h.Store.Get(r, "erp-session")
|
session, _ := h.Store.Get(r, "erp-session")
|
||||||
session.Values["user_id"] = nil
|
session.Values["user_id"] = nil
|
||||||
session.Values["username"] = nil
|
session.Values["username"] = nil
|
||||||
|
session.Values["role"] = nil
|
||||||
session.Options.MaxAge = -1
|
session.Options.MaxAge = -1
|
||||||
session.Save(r, w)
|
session.Save(r, w)
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@ -39,11 +40,18 @@ func (h *Handler) CustomerList(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// HTMX partial for search
|
// HTMX partial for search
|
||||||
if r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("partial") == "true" {
|
if r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("partial") == "true" {
|
||||||
|
// Construct clean URL for history
|
||||||
|
u := fmt.Sprintf("/customers?page=%d", page)
|
||||||
|
if search != "" {
|
||||||
|
u += "&search=" + search
|
||||||
|
}
|
||||||
|
w.Header().Set("HX-Push-Url", u)
|
||||||
|
|
||||||
h.renderPartial(w, "customers/list.html", "customer-table", data)
|
h.renderPartial(w, "customers/list.html", "customer-table", data)
|
||||||
return
|
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) {
|
func (h *Handler) CustomerNew(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -54,7 +62,7 @@ func (h *Handler) CustomerNew(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Customer": &models.Customer{},
|
"Customer": &models.Customer{},
|
||||||
"IsNew": true,
|
"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) {
|
func (h *Handler) CustomerCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -74,7 +82,7 @@ func (h *Handler) CustomerCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
"IsNew": true,
|
"IsNew": true,
|
||||||
"Error": "Name is required",
|
"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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,7 +126,7 @@ func (h *Handler) CustomerDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Orders": customerOrders,
|
"Orders": customerOrders,
|
||||||
"Invoices": invoices,
|
"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) {
|
func (h *Handler) CustomerEdit(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -136,7 +144,7 @@ func (h *Handler) CustomerEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Customer": customer,
|
"Customer": customer,
|
||||||
"IsNew": false,
|
"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) {
|
func (h *Handler) CustomerUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -158,7 +166,7 @@ func (h *Handler) CustomerUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
"IsNew": false,
|
"IsNew": false,
|
||||||
"Error": "Name is required",
|
"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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,5 +19,5 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
"FulfilledOrders": models.OrderCountByStatus(h.DB, "fulfilled"),
|
"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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,12 +21,24 @@ var funcMap = template.FuncMap{
|
|||||||
"formatMoney": func(amount float64) string {
|
"formatMoney": func(amount float64) string {
|
||||||
return fmt.Sprintf("$%.2f", amount)
|
return fmt.Sprintf("$%.2f", amount)
|
||||||
},
|
},
|
||||||
"formatDate": func(date string) string {
|
"formatDate": func(v interface{}) string {
|
||||||
t, err := time.Parse("2006-01-02", date)
|
if t, ok := v.(time.Time); ok {
|
||||||
if err != nil {
|
return t.Format("Jan 02, 2006")
|
||||||
return date
|
|
||||||
}
|
}
|
||||||
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 {
|
"statusBadge": func(status string) template.HTML {
|
||||||
colors := map[string]string{
|
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))
|
paths := make([]string, len(templates))
|
||||||
for i, t := range templates {
|
for i, t := range templates {
|
||||||
paths[i] = filepath.Join("templates", t)
|
paths[i] = filepath.Join("templates", t)
|
||||||
@ -106,3 +123,11 @@ func (h *Handler) getUsername(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
return ""
|
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 ""
|
||||||
|
}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ func (h *Handler) InvoiceList(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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) {
|
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",
|
"ActivePage": "invoices",
|
||||||
"Invoice": invoice,
|
"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) {
|
func (h *Handler) InvoicePay(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -21,7 +21,7 @@ func (h *Handler) ChartOfAccounts(w http.ResponseWriter, r *http.Request) {
|
|||||||
"ActivePage": "ledger",
|
"ActivePage": "ledger",
|
||||||
"Accounts": accounts,
|
"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) {
|
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,
|
"PrevPage": page - 1,
|
||||||
"NextPage": 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) {
|
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,
|
"Accounts": accounts,
|
||||||
"Today": time.Now().Format("2006-01-02"),
|
"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) {
|
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"),
|
"Today": time.Now().Format("2006-01-02"),
|
||||||
"Error": "At least two lines are required",
|
"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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,7 +121,7 @@ func (h *Handler) JournalEntryCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Today": time.Now().Format("2006-01-02"),
|
"Today": time.Now().Format("2006-01-02"),
|
||||||
"Error": err.Error(),
|
"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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,7 +146,7 @@ func (h *Handler) JournalEntryDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
"ActivePage": "ledger",
|
"ActivePage": "ledger",
|
||||||
"Entry": entry,
|
"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) {
|
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,
|
"TotalDebit": totalDebit,
|
||||||
"TotalCredit": totalCredit,
|
"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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,7 +44,7 @@ func (h *Handler) OrderList(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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) {
|
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,
|
"Customers": customers,
|
||||||
"IsNew": true,
|
"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) {
|
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,
|
"IsNew": true,
|
||||||
"Error": "Customer is required",
|
"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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,7 +115,7 @@ func (h *Handler) OrderDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
"ActivePage": "orders",
|
"ActivePage": "orders",
|
||||||
"Order": order,
|
"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) {
|
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,
|
"Customers": customers,
|
||||||
"IsNew": false,
|
"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) {
|
func (h *Handler) OrderUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
90
internal/handlers/users.go
Normal file
90
internal/handlers/users.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -12,15 +12,16 @@ type User struct {
|
|||||||
ID int
|
ID int
|
||||||
Username string
|
Username string
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
|
Role string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func Authenticate(db *sql.DB, username, password string) (*User, error) {
|
func Authenticate(db *sql.DB, username, password string) (*User, error) {
|
||||||
u := &User{}
|
u := &User{}
|
||||||
err := db.QueryRow(
|
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,
|
username,
|
||||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt)
|
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("user not found")
|
return nil, fmt.Errorf("user not found")
|
||||||
}
|
}
|
||||||
@ -31,3 +32,35 @@ func Authenticate(db *sql.DB, username, password string) (*User, error) {
|
|||||||
|
|
||||||
return u, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
7
main.go
7
main.go
@ -40,6 +40,7 @@ func main() {
|
|||||||
|
|
||||||
// Auth middleware
|
// Auth middleware
|
||||||
authMw := middleware.RequireAuth(store)
|
authMw := middleware.RequireAuth(store)
|
||||||
|
adminMw := middleware.RequireAdmin(store)
|
||||||
|
|
||||||
// Router
|
// Router
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@ -94,6 +95,12 @@ func main() {
|
|||||||
mux.Handle("GET /ledger/journal/{id}", authMw(http.HandlerFunc(h.JournalEntryDetail)))
|
mux.Handle("GET /ledger/journal/{id}", authMw(http.HandlerFunc(h.JournalEntryDetail)))
|
||||||
mux.Handle("GET /ledger/trial-balance", authMw(http.HandlerFunc(h.TrialBalance)))
|
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")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "1337"
|
port = "1337"
|
||||||
|
|||||||
@ -72,7 +72,8 @@
|
|||||||
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||||
{{if .HasPrev}}
|
{{if .HasPrev}}
|
||||||
<button
|
<button
|
||||||
hx-get="/customers?page={{.PrevPage}}&search={{.Search}}&partial=true"
|
hx-get="/customers?page={{.PrevPage}}{{if .Search}}&search={{.Search}}{{end}}&partial=true"
|
||||||
|
hx-push-url="/customers?page={{.PrevPage}}{{if .Search}}&search={{.Search}}{{end}}"
|
||||||
hx-target="#customer-table"
|
hx-target="#customer-table"
|
||||||
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
|
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
|
||||||
<span class="sr-only">Previous</span>
|
<span class="sr-only">Previous</span>
|
||||||
@ -83,7 +84,8 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{if .HasNext}}
|
{{if .HasNext}}
|
||||||
<button
|
<button
|
||||||
hx-get="/customers?page={{.NextPage}}&search={{.Search}}&partial=true"
|
hx-get="/customers?page={{.NextPage}}{{if .Search}}&search={{.Search}}{{end}}&partial=true"
|
||||||
|
hx-push-url="/customers?page={{.NextPage}}{{if .Search}}&search={{.Search}}{{end}}"
|
||||||
hx-target="#customer-table"
|
hx-target="#customer-table"
|
||||||
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
|
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
|
||||||
<span class="sr-only">Next</span>
|
<span class="sr-only">Next</span>
|
||||||
|
|||||||
@ -76,7 +76,8 @@
|
|||||||
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||||
{{if .HasPrev}}
|
{{if .HasPrev}}
|
||||||
<button
|
<button
|
||||||
hx-get="/invoices?page={{.PrevPage}}&status={{.FilterStatus}}&partial=true"
|
hx-get="/invoices?page={{.PrevPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}&partial=true"
|
||||||
|
hx-push-url="/invoices?page={{.PrevPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}"
|
||||||
hx-target="#invoice-table"
|
hx-target="#invoice-table"
|
||||||
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
|
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
|
||||||
<span class="sr-only">Previous</span>
|
<span class="sr-only">Previous</span>
|
||||||
@ -87,7 +88,8 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{if .HasNext}}
|
{{if .HasNext}}
|
||||||
<button
|
<button
|
||||||
hx-get="/invoices?page={{.NextPage}}&status={{.FilterStatus}}&partial=true"
|
hx-get="/invoices?page={{.NextPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}&partial=true"
|
||||||
|
hx-push-url="/invoices?page={{.NextPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}"
|
||||||
hx-target="#invoice-table"
|
hx-target="#invoice-table"
|
||||||
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
|
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
|
||||||
<span class="sr-only">Next</span>
|
<span class="sr-only">Next</span>
|
||||||
|
|||||||
@ -28,6 +28,9 @@
|
|||||||
<a href="/customers" class="{{if eq .ActivePage "customers"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Customers</a>
|
<a href="/customers" class="{{if eq .ActivePage "customers"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Customers</a>
|
||||||
<a href="/orders" class="{{if eq .ActivePage "orders"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Orders</a>
|
<a href="/orders" class="{{if eq .ActivePage "orders"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Orders</a>
|
||||||
<a href="/invoices" class="{{if eq .ActivePage "invoices"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Invoices</a>
|
<a href="/invoices" class="{{if eq .ActivePage "invoices"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Invoices</a>
|
||||||
|
{{if .IsAdmin}}
|
||||||
|
<a href="/users" class="{{if eq .ActivePage "users"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Users</a>
|
||||||
|
{{end}}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button type="button" onclick="toggleDropdown(event, 'ledger-dropdown')" class="{{if eq .ActivePage "ledger"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium inline-flex items-center">
|
<button type="button" onclick="toggleDropdown(event, 'ledger-dropdown')" class="{{if eq .ActivePage "ledger"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium inline-flex items-center">
|
||||||
General Ledger
|
General Ledger
|
||||||
@ -66,6 +69,9 @@
|
|||||||
<a href="/customers" class="{{if eq .ActivePage "customers"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Customers</a>
|
<a href="/customers" class="{{if eq .ActivePage "customers"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Customers</a>
|
||||||
<a href="/orders" class="{{if eq .ActivePage "orders"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Orders</a>
|
<a href="/orders" class="{{if eq .ActivePage "orders"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Orders</a>
|
||||||
<a href="/invoices" class="{{if eq .ActivePage "invoices"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Invoices</a>
|
<a href="/invoices" class="{{if eq .ActivePage "invoices"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Invoices</a>
|
||||||
|
{{if .IsAdmin}}
|
||||||
|
<a href="/users" class="{{if eq .ActivePage "users"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Users</a>
|
||||||
|
{{end}}
|
||||||
<div>
|
<div>
|
||||||
<button type="button" onclick="toggleDropdown(event, 'ledger-mobile-dropdown')" class="{{if eq .ActivePage "ledger"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} w-full text-left block rounded-md px-3 py-2 text-base font-medium flex justify-between items-center">
|
<button type="button" onclick="toggleDropdown(event, 'ledger-mobile-dropdown')" class="{{if eq .ActivePage "ledger"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} w-full text-left block rounded-md px-3 py-2 text-base font-medium flex justify-between items-center">
|
||||||
General Ledger
|
General Ledger
|
||||||
|
|||||||
@ -78,7 +78,8 @@
|
|||||||
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||||
{{if .HasPrev}}
|
{{if .HasPrev}}
|
||||||
<button
|
<button
|
||||||
hx-get="/orders?page={{.PrevPage}}&status={{.FilterStatus}}&partial=true"
|
hx-get="/orders?page={{.PrevPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}&partial=true"
|
||||||
|
hx-push-url="/orders?page={{.PrevPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}"
|
||||||
hx-target="#order-table"
|
hx-target="#order-table"
|
||||||
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
|
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
|
||||||
<span class="sr-only">Previous</span>
|
<span class="sr-only">Previous</span>
|
||||||
@ -89,7 +90,8 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{if .HasNext}}
|
{{if .HasNext}}
|
||||||
<button
|
<button
|
||||||
hx-get="/orders?page={{.NextPage}}&status={{.FilterStatus}}&partial=true"
|
hx-get="/orders?page={{.NextPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}&partial=true"
|
||||||
|
hx-push-url="/orders?page={{.NextPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}"
|
||||||
hx-target="#order-table"
|
hx-target="#order-table"
|
||||||
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
|
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
|
||||||
<span class="sr-only">Next</span>
|
<span class="sr-only">Next</span>
|
||||||
|
|||||||
54
templates/users/form.html
Normal file
54
templates/users/form.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="md:flex md:items-center md:justify-between mb-8">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||||
|
{{if .IsNew}}New User{{else}}Edit User{{end}}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden bg-white shadow sm:rounded-lg p-6 max-w-lg">
|
||||||
|
<form method="POST" action="/users{{if not .IsNew}}/{{.User.ID}}{{end}}">
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="rounded-md bg-red-50 p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-800">{{.Error}}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium leading-6 text-gray-900">Username</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input type="text" name="username" id="username" value="{{.User.Username}}" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">Password</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input type="password" name="password" id="password" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="role" class="block text-sm font-medium leading-6 text-gray-900">Role</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<select id="role" name="role" class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
|
||||||
|
<option value="user" {{if eq .User.Role "user"}}selected{{end}}>User</option>
|
||||||
|
<option value="admin" {{if eq .User.Role "admin"}}selected{{end}}>Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-center justify-end gap-x-6">
|
||||||
|
<a href="/users" class="text-sm font-semibold leading-6 text-gray-900">Cancel</a>
|
||||||
|
<button type="submit" class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
37
templates/users/list.html
Normal file
37
templates/users/list.html
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="md:flex md:items-center md:justify-between mb-8">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">Users</h2>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex md:ml-4 md:mt-0">
|
||||||
|
<a href="/users/new" class="ml-3 inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">New User</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden bg-white shadow sm:rounded-lg">
|
||||||
|
<table class="min-w-full divide-y divide-gray-300">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Username</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Role</th>
|
||||||
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Created At</th>
|
||||||
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||||
|
<span class="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
|
{{range .Users}}
|
||||||
|
<tr>
|
||||||
|
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{{.Username}}</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{{.Role}}</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{{.CreatedAt | formatDateTime}}</td>
|
||||||
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||||
|
<button hx-delete="/users/{{.ID}}" hx-confirm="Are you sure you want to delete this user?" class="text-red-600 hover:text-red-900">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Loading…
x
Reference in New Issue
Block a user