From 5245c9484b448c004c8aec7ef50b94d84d16a73e Mon Sep 17 00:00:00 2001
From: mikestefanello <552328+mikestefanello@users.noreply.github.com>
Date: Sun, 20 Apr 2025 10:44:14 -0400
Subject: [PATCH 01/10] Add make command to create an admin user.
---
Makefile | 6 +++-
cmd/admin/main.go | 62 ++++++++++++++++++++++++++++++++++++++++++
pkg/handlers/admin.go | 2 +-
pkg/ui/pages/entity.go | 6 ++--
4 files changed, 71 insertions(+), 5 deletions(-)
create mode 100644 cmd/admin/main.go
diff --git a/Makefile b/Makefile
index a40014c..ffecf14 100644
--- a/Makefile
+++ b/Makefile
@@ -16,7 +16,11 @@ ent-gen: ## Generate Ent code
.PHONY: ent-new
ent-new: ## Create a new Ent entity (ie, make ent-new NAME=MyEntity)
- go run entgo.io/ent/cmd/ent new $(name)
+ go run entgo.io/ent/cmd/ent new $(NAME)
+
+.PHONY: admin
+admin: ## Create a new admin (ie, make admin EMAIL=myemail@web.com)
+ go run cmd/admin/main.go --email=$(EMAIL)
.PHONY: run
run: ## Run the application
diff --git a/cmd/admin/main.go b/cmd/admin/main.go
new file mode 100644
index 0000000..b025d76
--- /dev/null
+++ b/cmd/admin/main.go
@@ -0,0 +1,62 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+
+ "github.com/mikestefanello/pagoda/pkg/log"
+ "github.com/mikestefanello/pagoda/pkg/services"
+)
+
+// main creates a new admin user with the email passed in via the flag.
+func main() {
+ // Start a new container.
+ c := services.NewContainer()
+ defer func() {
+ // Gracefully shutdown all services.
+ if err := c.Shutdown(); err != nil {
+ log.Default().Error("shutdown failed", "error", err)
+ }
+ }()
+
+ var email string
+ flag.StringVar(&email, "email", "", "email address for the admin user")
+ flag.Parse()
+
+ if len(email) == 0 {
+ invalid("email is required")
+ }
+
+ // Generate a password.
+ pw, err := c.Auth.RandomToken(10)
+ if err != nil {
+ invalid("failed to generate a random password")
+ }
+
+ err = c.ORM.User.
+ Create().
+ SetEmail(email).
+ SetName("Admin").
+ SetAdmin(true).
+ SetVerified(true).
+ SetPassword(pw).
+ Exec(context.Background())
+
+ if err != nil {
+ invalid(err.Error())
+ }
+
+ fmt.Println("")
+ fmt.Println("-- ADMIN USER CREATED --")
+ fmt.Printf("Email: %s\n", email)
+ fmt.Printf("Password: %s\n", pw)
+ fmt.Println("----")
+ fmt.Println("")
+}
+
+func invalid(msg string) {
+ fmt.Printf("[ERROR] %s\n", msg)
+ os.Exit(1)
+}
diff --git a/pkg/handlers/admin.go b/pkg/handlers/admin.go
index c01ab9b..a828f27 100644
--- a/pkg/handlers/admin.go
+++ b/pkg/handlers/admin.go
@@ -47,7 +47,7 @@ func (h *Admin) Init(c *services.Container) error {
}
func (h *Admin) Routes(g *echo.Group) {
- entities := g.Group("/admin/content", middleware.RequireAdmin)
+ entities := g.Group("/admin/entity", middleware.RequireAdmin)
for _, n := range h.graph.Nodes {
ng := entities.Group(fmt.Sprintf("/%s", strings.ToLower(n.Name)))
diff --git a/pkg/ui/pages/entity.go b/pkg/ui/pages/entity.go
index bbe8867..021fce8 100644
--- a/pkg/ui/pages/entity.go
+++ b/pkg/ui/pages/entity.go
@@ -10,7 +10,7 @@ import (
"entgo.io/ent/schema/field"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/ent/admin"
- "github.com/mikestefanello/pagoda/pkg/pager" // todo make this easier
+ "github.com/mikestefanello/pagoda/pkg/pager"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
@@ -26,7 +26,7 @@ func AdminEntityDelete(ctx echo.Context, entityTypeName string) error {
form := Form(
Method(http.MethodPost),
- H2(Textf("Are you sure you want to delete this %s?", entityTypeName)),
+ P(Class("subtitle"), Textf("Are you sure you want to delete this %s?", entityTypeName)),
ControlGroup(
FormButton("is-link", "Delete"),
ButtonLink(
@@ -176,7 +176,7 @@ func AdminEntityList(ctx echo.Context, params AdminEntityListParams) error {
r.Path(routenames.AdminEntityEdit(params.EntityType.Name), row.ID),
"is-link",
"Edit",
- ), // todo make this easier
+ ),
),
Td(
ButtonLink(r.Path(routenames.AdminEntityDelete(params.EntityType.Name), row.ID),
From 47ed381b64c698717ab1af492f3f277b6a47af88 Mon Sep 17 00:00:00 2001
From: mikestefanello <552328+mikestefanello@users.noreply.github.com>
Date: Sun, 20 Apr 2025 11:16:18 -0400
Subject: [PATCH 02/10] Code cleanup and testing.
---
cmd/admin/main.go | 1 +
ent/admin/extension.go | 5 ++++
pkg/context/context.go | 6 +++++
pkg/handlers/admin.go | 18 ++++++---------
pkg/middleware/auth.go | 15 ++++++------
pkg/middleware/auth_test.go | 42 ++++++++++++++++++++++++++++++----
pkg/routenames/names.go | 4 ----
pkg/services/auth_test.go | 2 +-
pkg/services/container_test.go | 4 ----
9 files changed, 65 insertions(+), 32 deletions(-)
diff --git a/cmd/admin/main.go b/cmd/admin/main.go
index b025d76..449e4a8 100644
--- a/cmd/admin/main.go
+++ b/cmd/admin/main.go
@@ -35,6 +35,7 @@ func main() {
invalid("failed to generate a random password")
}
+ // Create the admin user.
err = c.ORM.User.
Create().
SetEmail(email).
diff --git a/ent/admin/extension.go b/ent/admin/extension.go
index e857450..308e2eb 100644
--- a/ent/admin/extension.go
+++ b/ent/admin/extension.go
@@ -16,6 +16,7 @@ var (
templateDir embed.FS
)
+// Extension is the Ent extension that generates code to support the entity admin panel.
type Extension struct {
entc.DefaultExtension
}
@@ -34,6 +35,7 @@ func (*Extension) Templates() []*gen.Template {
}
}
+// fieldName provides a struct field name from an entity field name (ie, user_id -> UserID).
func fieldName(name string) string {
if len(name) == 0 {
return name
@@ -51,6 +53,7 @@ func fieldName(name string) string {
return strings.Join(parts, "")
}
+// FieldLabel provides a label for an entity field name (ie, user_id -> User ID).
func FieldLabel(name string) string {
if len(name) == 0 {
return name
@@ -69,6 +72,7 @@ func FieldLabel(name string) string {
return strings.Join(parts, " ")
}
+// fieldIsPointer determines if a given entity field should be a pointer on the struct.
func fieldIsPointer(f *gen.Field) bool {
switch {
case f.Type.Type == field.TypeBool:
@@ -82,6 +86,7 @@ func fieldIsPointer(f *gen.Field) bool {
return false
}
+// upperFirst uppercases the first character of a given string.
func upperFirst(s string) string {
if len(s) == 0 {
return s
diff --git a/pkg/context/context.go b/pkg/context/context.go
index d433949..05ad549 100644
--- a/pkg/context/context.go
+++ b/pkg/context/context.go
@@ -34,6 +34,12 @@ const (
// ConfigKey is the key used to store the configuration in context.
ConfigKey = "config"
+
+ // AdminEntityKey is the key used to store the entity being operated on in the admin panel.
+ AdminEntityKey = "admin:entity"
+
+ // AdminEntityIDKey is the key used to store the ID of the entity being operated on in the admin panel.
+ AdminEntityIDKey = "admin:entity_id"
)
// IsCanceledError determines if an error is due to a context cancellation.
diff --git a/pkg/handlers/admin.go b/pkg/handlers/admin.go
index a828f27..b61bbc5 100644
--- a/pkg/handlers/admin.go
+++ b/pkg/handlers/admin.go
@@ -12,6 +12,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/ent/admin"
+ "github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/middleware"
"github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/pkg/pager"
@@ -21,10 +22,6 @@ import (
"github.com/mikestefanello/pagoda/pkg/ui/pages"
)
-// TODO plugins should create keys dynamically
-const entityContextKey = "admin:entity"
-const entityIDContextKey = "admin:entity_id"
-
type Admin struct {
orm *ent.Client
graph *gen.Graph
@@ -53,8 +50,6 @@ func (h *Admin) Routes(g *echo.Group) {
ng := entities.Group(fmt.Sprintf("/%s", strings.ToLower(n.Name)))
ng.GET("", h.EntityList(n)).
Name = routenames.AdminEntityList(n.Name)
- ng.POST("", h.EntityList(n)).
- Name = routenames.AdminEntityListSubmit(n.Name)
ng.GET("/add", h.EntityAdd(n)).
Name = routenames.AdminEntityAdd(n.Name)
ng.POST("/add", h.EntityAddSubmit(n)).
@@ -70,6 +65,7 @@ func (h *Admin) Routes(g *echo.Group) {
}
}
+// middlewareEntityLoad is middleware to extract the entity ID and attempt to load the given entity.
func (h *Admin) middlewareEntityLoad(n *gen.Type) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
@@ -81,8 +77,8 @@ func (h *Admin) middlewareEntityLoad(n *gen.Type) echo.MiddlewareFunc {
entity, err := h.admin.Get(ctx, n.Name, id)
switch {
case err == nil:
- ctx.Set(entityIDContextKey, id)
- ctx.Set(entityContextKey, map[string][]string(entity))
+ ctx.Set(context.AdminEntityIDKey, id)
+ ctx.Set(context.AdminEntityKey, map[string][]string(entity))
return next(ctx)
case ent.IsNotFound(err):
return echo.NewHTTPError(http.StatusNotFound, "entity not found")
@@ -134,14 +130,14 @@ func (h *Admin) EntityAddSubmit(n *gen.Type) echo.HandlerFunc {
func (h *Admin) EntityEdit(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
- v := ctx.Get(entityContextKey).(map[string][]string)
+ v := ctx.Get(context.AdminEntityKey).(map[string][]string)
return pages.AdminEntityForm(ctx, false, h.getEntitySchema(n), v)
}
}
func (h *Admin) EntityEditSubmit(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
- id := ctx.Get(entityIDContextKey).(int)
+ id := ctx.Get(context.AdminEntityIDKey).(int)
err := h.admin.Update(ctx, n.Name, id)
if err != nil {
msg.Danger(ctx, err.Error())
@@ -166,7 +162,7 @@ func (h *Admin) EntityDelete(n *gen.Type) echo.HandlerFunc {
func (h *Admin) EntityDeleteSubmit(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
- id := ctx.Get(entityIDContextKey).(int)
+ id := ctx.Get(context.AdminEntityIDKey).(int)
if err := h.admin.Delete(ctx, n.Name, id); err != nil {
msg.Danger(ctx, err.Error())
return h.EntityDelete(n)(ctx)
diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go
index d06391f..bd58912 100644
--- a/pkg/middleware/auth.go
+++ b/pkg/middleware/auth.go
@@ -15,7 +15,7 @@ import (
"github.com/labstack/echo/v4"
)
-// LoadAuthenticatedUser loads the authenticated user, if one, and stores in context
+// LoadAuthenticatedUser loads the authenticated user, if one, and stores in context.
func LoadAuthenticatedUser(authClient *services.AuthClient) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -41,7 +41,7 @@ func LoadAuthenticatedUser(authClient *services.AuthClient) echo.MiddlewareFunc
// LoadValidPasswordToken loads a valid password token entity that matches the user and token
// provided in path parameters
// If the token is invalid, the user will be redirected to the forgot password route
-// This requires that the user owning the token is loaded in to context
+// This requires that the user owning the token is loaded in to context.
func LoadValidPasswordToken(authClient *services.AuthClient) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -51,13 +51,13 @@ func LoadValidPasswordToken(authClient *services.AuthClient) echo.MiddlewareFunc
}
usr := c.Get(context.UserKey).(*ent.User)
- // Extract the token ID
+ // Extract the token ID.
tokenID, err := strconv.Atoi(c.Param("password_token"))
if err != nil {
return echo.NewHTTPError(http.StatusNotFound)
}
- // Attempt to load a valid password token
+ // Attempt to load a valid password token.
token, err := authClient.GetValidPasswordToken(
c,
usr.ID,
@@ -82,7 +82,7 @@ func LoadValidPasswordToken(authClient *services.AuthClient) echo.MiddlewareFunc
}
}
-// RequireAuthentication requires that the user be authenticated in order to proceed
+// RequireAuthentication requires that the user be authenticated in order to proceed.
func RequireAuthentication(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if u := c.Get(context.AuthenticatedUserKey); u == nil {
@@ -93,7 +93,7 @@ func RequireAuthentication(next echo.HandlerFunc) echo.HandlerFunc {
}
}
-// RequireNoAuthentication requires that the user not be authenticated in order to proceed
+// RequireNoAuthentication requires that the user not be authenticated in order to proceed.
func RequireNoAuthentication(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if u := c.Get(context.AuthenticatedUserKey); u != nil {
@@ -104,13 +104,12 @@ func RequireNoAuthentication(next echo.HandlerFunc) echo.HandlerFunc {
}
}
-// RequireAdmin requires that the user be an admin in order to proceed.
+// RequireAdmin requires that the authenticated user be an admin in order to proceed.
func RequireAdmin(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if u := c.Get(context.AuthenticatedUserKey); u != nil {
if user, ok := u.(*ent.User); ok {
if user.Admin {
- // TODO tests
return next(c)
}
}
diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go
index eebd910..01e9161 100644
--- a/pkg/middleware/auth_test.go
+++ b/pkg/middleware/auth_test.go
@@ -1,6 +1,7 @@
package middleware
import (
+ goctx "context"
"fmt"
"net/http"
"testing"
@@ -40,7 +41,7 @@ func TestRequireAuthentication(t *testing.T) {
tests.InitSession(ctx)
// Not logged in
- err := tests.ExecuteMiddleware(ctx, RequireAuthentication())
+ err := tests.ExecuteMiddleware(ctx, RequireAuthentication)
tests.AssertHTTPErrorCode(t, err, http.StatusUnauthorized)
// Login
@@ -49,7 +50,7 @@ func TestRequireAuthentication(t *testing.T) {
_ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth))
// Logged in
- err = tests.ExecuteMiddleware(ctx, RequireAuthentication())
+ err = tests.ExecuteMiddleware(ctx, RequireAuthentication)
assert.Nil(t, err)
}
@@ -58,7 +59,7 @@ func TestRequireNoAuthentication(t *testing.T) {
tests.InitSession(ctx)
// Not logged in
- err := tests.ExecuteMiddleware(ctx, RequireNoAuthentication())
+ err := tests.ExecuteMiddleware(ctx, RequireNoAuthentication)
assert.Nil(t, err)
// Login
@@ -67,7 +68,7 @@ func TestRequireNoAuthentication(t *testing.T) {
_ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth))
// Logged in
- err = tests.ExecuteMiddleware(ctx, RequireNoAuthentication())
+ err = tests.ExecuteMiddleware(ctx, RequireNoAuthentication)
tests.AssertHTTPErrorCode(t, err, http.StatusForbidden)
}
@@ -109,3 +110,36 @@ func TestLoadValidPasswordToken(t *testing.T) {
require.True(t, ok)
assert.Equal(t, pt.ID, ctxPt.ID)
}
+
+func TestRequireAdmin(t *testing.T) {
+ ctx, _ := tests.NewContext(c.Web, "/")
+ tests.InitSession(ctx)
+
+ // Not logged in
+ err := tests.ExecuteMiddleware(ctx, RequireAdmin)
+ tests.AssertHTTPErrorCode(t, err, http.StatusUnauthorized)
+
+ // Login as a non-admin
+ err = c.Auth.Login(ctx, usr.ID)
+ require.NoError(t, err)
+ _ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth))
+
+ // Logged in as a non-admin
+ err = tests.ExecuteMiddleware(ctx, RequireAdmin)
+ tests.AssertHTTPErrorCode(t, err, http.StatusUnauthorized)
+
+ // Create an admin and login
+ adm, err := tests.CreateUser(c.ORM)
+ require.NoError(t, err)
+ err = c.ORM.User.Update().
+ SetAdmin(true).
+ Exec(goctx.Background())
+ require.NoError(t, err)
+ err = c.Auth.Login(ctx, adm.ID)
+ require.NoError(t, err)
+ _ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth))
+
+ // Logged in as an admin
+ err = tests.ExecuteMiddleware(ctx, RequireAdmin)
+ assert.Nil(t, err)
+}
diff --git a/pkg/routenames/names.go b/pkg/routenames/names.go
index c7cc90a..007c6e2 100644
--- a/pkg/routenames/names.go
+++ b/pkg/routenames/names.go
@@ -32,10 +32,6 @@ func AdminEntityList(entityTypeName string) string {
return fmt.Sprintf("admin:%s_list", entityTypeName)
}
-func AdminEntityListSubmit(entityTypeName string) string {
- return fmt.Sprintf("admin:%s_list.submit", entityTypeName)
-}
-
func AdminEntityAdd(entityTypeName string) string {
return fmt.Sprintf("admin:%s_add", entityTypeName)
}
diff --git a/pkg/services/auth_test.go b/pkg/services/auth_test.go
index b654296..459446e 100644
--- a/pkg/services/auth_test.go
+++ b/pkg/services/auth_test.go
@@ -55,7 +55,7 @@ func TestAuthClient_GeneratePasswordResetToken(t *testing.T) {
token, pt, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID)
require.NoError(t, err)
assert.Len(t, token, c.Config.App.PasswordToken.Length)
- assert.NoError(t, c.Auth.CheckPassword(token, pt.Hash))
+ assert.NoError(t, c.Auth.CheckPassword(token, pt.Token))
}
func TestAuthClient_GetValidPasswordToken(t *testing.T) {
diff --git a/pkg/services/container_test.go b/pkg/services/container_test.go
index 1e0bd24..7f4a5d4 100644
--- a/pkg/services/container_test.go
+++ b/pkg/services/container_test.go
@@ -17,8 +17,4 @@ func TestNewContainer(t *testing.T) {
assert.NotNil(t, c.Mail)
assert.NotNil(t, c.Auth)
assert.NotNil(t, c.Tasks)
- g := c.Graph
- if g == nil {
-
- }
}
From 38b65878f893b6d9504478d41ae6984c422b8426 Mon Sep 17 00:00:00 2001
From: mikestefanello <552328+mikestefanello@users.noreply.github.com>
Date: Sun, 20 Apr 2025 13:24:05 -0400
Subject: [PATCH 03/10] Organized admin files.
---
pkg/handlers/admin.go | 12 +-
pkg/ui/forms/admin_entity.go | 112 +++++++++++++
pkg/ui/forms/admin_entity_delete.go | 27 ++++
pkg/ui/pages/admin_entity.go | 137 ++++++++++++++++
pkg/ui/pages/entity.go | 240 ----------------------------
5 files changed, 281 insertions(+), 247 deletions(-)
create mode 100644 pkg/ui/forms/admin_entity.go
create mode 100644 pkg/ui/forms/admin_entity_delete.go
create mode 100644 pkg/ui/pages/admin_entity.go
delete mode 100644 pkg/ui/pages/entity.go
diff --git a/pkg/handlers/admin.go b/pkg/handlers/admin.go
index b61bbc5..7368a30 100644
--- a/pkg/handlers/admin.go
+++ b/pkg/handlers/admin.go
@@ -96,17 +96,15 @@ func (h *Admin) EntityList(n *gen.Type) echo.HandlerFunc {
return echo.NewHTTPError(http.StatusInternalServerError, err)
}
- return pages.AdminEntityList(ctx, pages.AdminEntityListParams{
- EntityType: n,
- EntityList: list,
- Pager: pager.NewPager(ctx, h.admin.Config.ItemsPerPage),
- })
+ pgr := pager.NewPager(ctx, h.admin.Config.ItemsPerPage)
+
+ return pages.AdminEntityList(ctx, n.Name, list, pgr)
}
}
func (h *Admin) EntityAdd(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
- return pages.AdminEntityForm(ctx, true, h.getEntitySchema(n), nil)
+ return pages.AdminEntityInput(ctx, true, h.getEntitySchema(n), nil)
}
}
@@ -131,7 +129,7 @@ func (h *Admin) EntityAddSubmit(n *gen.Type) echo.HandlerFunc {
func (h *Admin) EntityEdit(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
v := ctx.Get(context.AdminEntityKey).(map[string][]string)
- return pages.AdminEntityForm(ctx, false, h.getEntitySchema(n), v)
+ return pages.AdminEntityInput(ctx, false, h.getEntitySchema(n), v)
}
}
diff --git a/pkg/ui/forms/admin_entity.go b/pkg/ui/forms/admin_entity.go
new file mode 100644
index 0000000..d436e98
--- /dev/null
+++ b/pkg/ui/forms/admin_entity.go
@@ -0,0 +1,112 @@
+package forms
+
+import (
+ "net/http"
+ "net/url"
+
+ "entgo.io/ent/entc/load"
+ "entgo.io/ent/schema/field"
+ "github.com/mikestefanello/pagoda/ent/admin"
+ "github.com/mikestefanello/pagoda/pkg/routenames"
+ "github.com/mikestefanello/pagoda/pkg/ui"
+ . "github.com/mikestefanello/pagoda/pkg/ui/components"
+ . "maragu.dev/gomponents"
+ . "maragu.dev/gomponents/html"
+)
+
+func AdminEntity(r *ui.Request, isNew bool, schema *load.Schema, values url.Values) Node {
+ // TODO inline validation?
+ nodes := make(Group, 0)
+
+ getValue := func(name string) string {
+ if value := r.Context.FormValue(name); value != "" {
+ return value
+ }
+
+ if values != nil && len(values[name]) > 0 {
+ return values[name][0] // TODO cardinality
+ }
+
+ return ""
+ }
+
+ for _, f := range schema.Fields {
+ // TODO cardinality?
+ if !isNew && f.Immutable {
+ continue
+ }
+
+ switch f.Info.Type {
+ case field.TypeString:
+ p := InputFieldParams{
+ Name: f.Name,
+ InputType: "text",
+ Label: admin.FieldLabel(f.Name),
+ Value: getValue(f.Name),
+ }
+
+ if f.Sensitive {
+ p.InputType = "password"
+ if !isNew {
+ p.Placeholder = "*****"
+ p.Help = "SENSITIVE: This field will only be updated if a value is provided."
+ }
+ }
+ nodes = append(nodes, InputField(p))
+ case field.TypeTime:
+ nodes = append(nodes, InputField(InputFieldParams{
+ Name: f.Name,
+ InputType: "datetime-local",
+ Label: admin.FieldLabel(f.Name),
+ Value: getValue(f.Name),
+ }))
+ case field.TypeInt, field.TypeInt8, field.TypeInt16, field.TypeInt32, field.TypeInt64,
+ field.TypeUint, field.TypeUint8, field.TypeUint16, field.TypeUint32, field.TypeUint64,
+ field.TypeFloat32, field.TypeFloat64:
+ nodes = append(nodes, InputField(InputFieldParams{
+ Name: f.Name,
+ InputType: "number",
+ Label: admin.FieldLabel(f.Name),
+ Value: getValue(f.Name),
+ }))
+ case field.TypeBool:
+ nodes = append(nodes, Checkbox(CheckboxParams{
+ Name: f.Name,
+ Label: admin.FieldLabel(f.Name),
+ Checked: getValue(f.Name) == "true",
+ }))
+ case field.TypeEnum:
+ options := make([]Choice, 0, len(f.Enums)+1)
+ if f.Optional {
+ options = append(options, Choice{
+ Label: "-",
+ Value: "",
+ })
+ }
+ for _, enum := range f.Enums {
+ options = append(options, Choice{
+ Label: enum.V,
+ Value: enum.V,
+ })
+ }
+ nodes = append(nodes, SelectList(OptionsParams{
+ Name: f.Name,
+ Label: admin.FieldLabel(f.Name),
+ Value: getValue(f.Name),
+ Options: options,
+ }))
+ default:
+ nodes = append(nodes, P(Textf("%s not supported", f.Name)))
+ }
+ }
+
+ nodes = append(nodes, ControlGroup(
+ FormButton("is-primary", "Submit"),
+ ButtonLink(r.Path(routenames.AdminEntityList(schema.Name)), "is-secondary", "Cancel"),
+ ), CSRF(r))
+
+ return Form(
+ Method(http.MethodPost),
+ nodes,
+ )
+}
diff --git a/pkg/ui/forms/admin_entity_delete.go b/pkg/ui/forms/admin_entity_delete.go
new file mode 100644
index 0000000..1d0a3fe
--- /dev/null
+++ b/pkg/ui/forms/admin_entity_delete.go
@@ -0,0 +1,27 @@
+package forms
+
+import (
+ "net/http"
+
+ "github.com/mikestefanello/pagoda/pkg/routenames"
+ "github.com/mikestefanello/pagoda/pkg/ui"
+ . "github.com/mikestefanello/pagoda/pkg/ui/components"
+ . "maragu.dev/gomponents"
+ . "maragu.dev/gomponents/html"
+)
+
+func AdminEntityDelete(r *ui.Request, entityTypeName string) Node {
+ return Form(
+ Method(http.MethodPost),
+ P(Class("subtitle"), Textf("Are you sure you want to delete this %s?", entityTypeName)),
+ ControlGroup(
+ FormButton("is-link", "Delete"),
+ ButtonLink(
+ r.Path(routenames.AdminEntityList(entityTypeName)),
+ "is-secondary",
+ "Cancel",
+ ),
+ ),
+ CSRF(r),
+ )
+}
diff --git a/pkg/ui/pages/admin_entity.go b/pkg/ui/pages/admin_entity.go
new file mode 100644
index 0000000..4180303
--- /dev/null
+++ b/pkg/ui/pages/admin_entity.go
@@ -0,0 +1,137 @@
+package pages
+
+import (
+ "fmt"
+ "net/url"
+
+ "entgo.io/ent/entc/load"
+ "github.com/labstack/echo/v4"
+ "github.com/mikestefanello/pagoda/ent/admin"
+ "github.com/mikestefanello/pagoda/pkg/pager"
+ "github.com/mikestefanello/pagoda/pkg/routenames"
+ "github.com/mikestefanello/pagoda/pkg/ui"
+ . "github.com/mikestefanello/pagoda/pkg/ui/components"
+ "github.com/mikestefanello/pagoda/pkg/ui/forms"
+ "github.com/mikestefanello/pagoda/pkg/ui/layouts"
+ . "maragu.dev/gomponents"
+ . "maragu.dev/gomponents/components"
+ . "maragu.dev/gomponents/html"
+)
+
+func AdminEntityDelete(ctx echo.Context, entityTypeName string) error {
+ r := ui.NewRequest(ctx)
+ r.Title = fmt.Sprintf("Delete %s", entityTypeName)
+
+ return r.Render(
+ layouts.Primary,
+ forms.AdminEntityDelete(r, entityTypeName),
+ )
+}
+
+func AdminEntityInput(ctx echo.Context, isNew bool, schema *load.Schema, values url.Values) error {
+ r := ui.NewRequest(ctx)
+ if isNew {
+ r.Title = fmt.Sprintf("Add %s", schema.Name)
+ } else {
+ r.Title = fmt.Sprintf("Edit %s", schema.Name)
+ }
+
+ return r.Render(
+ layouts.Primary,
+ forms.AdminEntity(r, isNew, schema, values),
+ )
+}
+
+func AdminEntityList(
+ ctx echo.Context,
+ entityTypeName string,
+ entityList *admin.EntityList,
+ pgr pager.Pager,
+) error {
+ r := ui.NewRequest(ctx)
+ r.Title = entityTypeName
+
+ genHeader := func() Node {
+ g := make(Group, 0, len(entityList.Columns)+3)
+ g = append(g, Th(Text("ID")))
+ for _, h := range entityList.Columns {
+ g = append(g, Th(Text(h)))
+ }
+ g = append(g, Th(), Th())
+ return g
+ }
+
+ genRow := func(row admin.EntityValues) Node {
+ g := make(Group, 0, len(row.Values)+3)
+ g = append(g, Th(Text(fmt.Sprint(row.ID))))
+ for _, h := range row.Values {
+ g = append(g, Td(Text(h)))
+ }
+ g = append(g,
+ Td(
+ ButtonLink(
+ r.Path(routenames.AdminEntityEdit(entityTypeName), row.ID),
+ "is-link",
+ "Edit",
+ ),
+ ),
+ Td(
+ ButtonLink(r.Path(routenames.AdminEntityDelete(entityTypeName), row.ID),
+ "is-danger",
+ "Delete",
+ ),
+ ),
+ )
+ return g
+ }
+
+ genRows := func() Node {
+ g := make(Group, 0, len(entityList.Entities))
+ for _, row := range entityList.Entities {
+ g = append(g, Tr(genRow(row)))
+ }
+ return g
+ }
+
+ pagedHref := func(page int) string {
+ return fmt.Sprintf("%s?%s=%d",
+ r.Path(routenames.AdminEntityList(entityTypeName)),
+ pager.QueryKey,
+ page,
+ )
+ }
+
+ return r.Render(layouts.Primary, Group{
+ ButtonLink(
+ r.Path(routenames.AdminEntityAdd(entityTypeName)),
+ "is-primary",
+ fmt.Sprintf("Add %s", entityTypeName),
+ ),
+ Table(
+ Class("table"),
+ THead(
+ Tr(genHeader()),
+ ),
+ TBody(genRows()),
+ ),
+ Nav(
+ Class("pagination"),
+ A(
+ Classes{
+ "pagination-previous": true,
+ "is-disabled": entityList.Page == 1,
+ },
+ If(entityList.Page != 1, Href(pagedHref(entityList.Page-1))),
+ Text("Previous page"),
+ ),
+ A(
+ Classes{
+ "pagination-previous": true,
+ "is-disabled": !entityList.HasNextPage,
+ },
+ If(entityList.HasNextPage, Href(pagedHref(entityList.Page+1))),
+ Text("Next page"),
+ ),
+ ),
+ })
+}
diff --git a/pkg/ui/pages/entity.go b/pkg/ui/pages/entity.go
deleted file mode 100644
index 021fce8..0000000
--- a/pkg/ui/pages/entity.go
+++ /dev/null
@@ -1,240 +0,0 @@
-package pages
-
-import (
- "fmt"
- "net/http"
- "net/url"
-
- "entgo.io/ent/entc/gen"
- "entgo.io/ent/entc/load"
- "entgo.io/ent/schema/field"
- "github.com/labstack/echo/v4"
- "github.com/mikestefanello/pagoda/ent/admin"
- "github.com/mikestefanello/pagoda/pkg/pager"
- "github.com/mikestefanello/pagoda/pkg/routenames"
- "github.com/mikestefanello/pagoda/pkg/ui"
- . "github.com/mikestefanello/pagoda/pkg/ui/components"
- "github.com/mikestefanello/pagoda/pkg/ui/layouts"
- . "maragu.dev/gomponents"
- . "maragu.dev/gomponents/components"
- . "maragu.dev/gomponents/html"
-)
-
-func AdminEntityDelete(ctx echo.Context, entityTypeName string) error {
- r := ui.NewRequest(ctx)
- r.Title = fmt.Sprintf("Delete %s", entityTypeName)
-
- form := Form(
- Method(http.MethodPost),
- P(Class("subtitle"), Textf("Are you sure you want to delete this %s?", entityTypeName)),
- ControlGroup(
- FormButton("is-link", "Delete"),
- ButtonLink(
- r.Path(routenames.AdminEntityList(entityTypeName)),
- "is-secondary",
- "Cancel",
- ),
- ),
- CSRF(r),
- )
-
- return r.Render(layouts.Primary, form)
-}
-
-func AdminEntityForm(ctx echo.Context, isNew bool, schema *load.Schema, values url.Values) error {
- r := ui.NewRequest(ctx)
- if isNew {
- r.Title = fmt.Sprintf("Add %s", schema.Name)
- } else {
- r.Title = fmt.Sprintf("Edit %s", schema.Name)
- }
- // TODO inline validation?
- nodes := make(Group, 0)
-
- getValue := func(name string) string {
- if value := ctx.FormValue(name); value != "" {
- return value
- }
-
- if values != nil && len(values[name]) > 0 {
- return values[name][0] // TODO cardinality
- }
-
- return ""
- }
-
- for _, f := range schema.Fields {
- // TODO cardinality?
- if !isNew && f.Immutable {
- continue
- }
-
- switch f.Info.Type {
- case field.TypeString:
- p := InputFieldParams{
- Name: f.Name,
- InputType: "text",
- Label: admin.FieldLabel(f.Name),
- Value: getValue(f.Name),
- }
-
- if f.Sensitive {
- p.InputType = "password"
- if !isNew {
- p.Placeholder = "*****"
- p.Help = "SENSITIVE: This field will only be updated if a value is provided."
- }
- }
- nodes = append(nodes, InputField(p))
- case field.TypeTime:
- nodes = append(nodes, InputField(InputFieldParams{
- Name: f.Name,
- InputType: "datetime-local",
- Label: admin.FieldLabel(f.Name),
- Value: getValue(f.Name),
- }))
- case field.TypeInt, field.TypeInt8, field.TypeInt16, field.TypeInt32, field.TypeInt64,
- field.TypeUint, field.TypeUint8, field.TypeUint16, field.TypeUint32, field.TypeUint64,
- field.TypeFloat32, field.TypeFloat64:
- nodes = append(nodes, InputField(InputFieldParams{
- Name: f.Name,
- InputType: "number",
- Label: admin.FieldLabel(f.Name),
- Value: getValue(f.Name),
- }))
- case field.TypeBool:
- nodes = append(nodes, Checkbox(CheckboxParams{
- Name: f.Name,
- Label: admin.FieldLabel(f.Name),
- Checked: getValue(f.Name) == "true",
- }))
- case field.TypeEnum:
- options := make([]Choice, 0, len(f.Enums)+1)
- if f.Optional {
- options = append(options, Choice{
- Label: "-",
- Value: "",
- })
- }
- for _, enum := range f.Enums {
- options = append(options, Choice{
- Label: enum.V,
- Value: enum.V,
- })
- }
- nodes = append(nodes, SelectList(OptionsParams{
- Name: f.Name,
- Label: admin.FieldLabel(f.Name),
- Value: getValue(f.Name),
- Options: options,
- }))
- default:
- nodes = append(nodes, P(Textf("%s not supported", f.Name)))
- }
- }
-
- nodes = append(nodes, ControlGroup(
- FormButton("is-primary", "Submit"),
- ButtonLink(r.Path(routenames.AdminEntityList(schema.Name)), "is-secondary", "Cancel"),
- ), CSRF(r))
-
- return r.Render(layouts.Primary, Form(
- Method(http.MethodPost),
- nodes,
- ))
-}
-
-type AdminEntityListParams struct {
- EntityType *gen.Type
- EntityList *admin.EntityList
- Pager pager.Pager
-}
-
-func AdminEntityList(ctx echo.Context, params AdminEntityListParams) error {
- r := ui.NewRequest(ctx)
- r.Title = params.EntityType.Name
-
- genHeader := func() Node {
- g := make(Group, 0, len(params.EntityList.Columns)+3)
- g = append(g, Th(Text("ID")))
- for _, h := range params.EntityList.Columns {
- g = append(g, Th(Text(h)))
- }
- g = append(g, Th(), Th())
- return g
- }
-
- genRow := func(row admin.EntityValues) Node {
- g := make(Group, 0, len(row.Values)+3)
- g = append(g, Th(Text(fmt.Sprint(row.ID))))
- for _, h := range row.Values {
- g = append(g, Td(Text(h)))
- }
- g = append(g,
- Td(
- ButtonLink(
- r.Path(routenames.AdminEntityEdit(params.EntityType.Name), row.ID),
- "is-link",
- "Edit",
- ),
- ),
- Td(
- ButtonLink(r.Path(routenames.AdminEntityDelete(params.EntityType.Name), row.ID),
- "is-danger",
- "Delete",
- ),
- ),
- )
- return g
- }
-
- genRows := func() Node {
- g := make(Group, 0, len(params.EntityList.Entities))
- for _, row := range params.EntityList.Entities {
- g = append(g, Tr(genRow(row)))
- }
- return g
- }
-
- pagedHref := func(page int) string {
- return fmt.Sprintf("%s?%s=%d",
- r.Path(routenames.AdminEntityList(params.EntityType.Name)),
- pager.QueryKey,
- page,
- )
- }
-
- return r.Render(layouts.Primary, Group{
- ButtonLink(
- r.Path(routenames.AdminEntityAdd(params.EntityType.Name)),
- "is-primary",
- fmt.Sprintf("Add %s", params.EntityType.Name),
- ),
- Table(
- Class("table"),
- THead(
- Tr(genHeader()),
- ),
- TBody(genRows()),
- ),
- Nav(
- Class("pagination"),
- A(
- Classes{
- "pagination-previous": true,
- "is-disabled": params.EntityList.Page == 1,
- },
- If(params.EntityList.Page != 1, Href(pagedHref(params.EntityList.Page-1))),
- Text("Previous page"),
- ),
- A(
- Classes{
- "pagination-previous": true,
- "is-disabled": !params.EntityList.HasNextPage,
- },
- If(params.EntityList.HasNextPage, Href(pagedHref(params.EntityList.Page+1))),
- Text("Next page"),
- ),
- ),
- })
-}
From d672e8cb609093e61f1ced39050ce8d8ed788513 Mon Sep 17 00:00:00 2001
From: mikestefanello <552328+mikestefanello@users.noreply.github.com>
Date: Sun, 20 Apr 2025 13:32:23 -0400
Subject: [PATCH 04/10] Code cleanup.
---
pkg/handlers/admin.go | 4 ++--
pkg/ui/forms/admin_entity.go | 27 ++++++++++++++++++++-------
pkg/ui/forms/admin_entity_delete.go | 5 ++++-
pkg/ui/pages/admin_entity.go | 6 +++---
4 files changed, 29 insertions(+), 13 deletions(-)
diff --git a/pkg/handlers/admin.go b/pkg/handlers/admin.go
index 7368a30..fd79c63 100644
--- a/pkg/handlers/admin.go
+++ b/pkg/handlers/admin.go
@@ -104,7 +104,7 @@ func (h *Admin) EntityList(n *gen.Type) echo.HandlerFunc {
func (h *Admin) EntityAdd(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
- return pages.AdminEntityInput(ctx, true, h.getEntitySchema(n), nil)
+ return pages.AdminEntityInput(ctx, h.getEntitySchema(n), nil)
}
}
@@ -129,7 +129,7 @@ func (h *Admin) EntityAddSubmit(n *gen.Type) echo.HandlerFunc {
func (h *Admin) EntityEdit(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
v := ctx.Get(context.AdminEntityKey).(map[string][]string)
- return pages.AdminEntityInput(ctx, false, h.getEntitySchema(n), v)
+ return pages.AdminEntityInput(ctx, h.getEntitySchema(n), v)
}
}
diff --git a/pkg/ui/forms/admin_entity.go b/pkg/ui/forms/admin_entity.go
index d436e98..0f81a4d 100644
--- a/pkg/ui/forms/admin_entity.go
+++ b/pkg/ui/forms/admin_entity.go
@@ -14,22 +14,26 @@ import (
. "maragu.dev/gomponents/html"
)
-func AdminEntity(r *ui.Request, isNew bool, schema *load.Schema, values url.Values) Node {
+func AdminEntity(r *ui.Request, schema *load.Schema, values url.Values) Node {
// TODO inline validation?
+ isNew := values == nil
nodes := make(Group, 0)
getValue := func(name string) string {
+ // Values in the submitted form take precedence.
if value := r.Context.FormValue(name); value != "" {
return value
}
+ // Fallback to the entity's values, if being edited.
if values != nil && len(values[name]) > 0 {
- return values[name][0] // TODO cardinality
+ return values[name][0]
}
return ""
}
+ // Attempt to add form elements for all editable entity fields.
for _, f := range schema.Fields {
// TODO cardinality?
if !isNew && f.Immutable {
@@ -53,6 +57,7 @@ func AdminEntity(r *ui.Request, isNew bool, schema *load.Schema, values url.Valu
}
}
nodes = append(nodes, InputField(p))
+
case field.TypeTime:
nodes = append(nodes, InputField(InputFieldParams{
Name: f.Name,
@@ -60,6 +65,7 @@ func AdminEntity(r *ui.Request, isNew bool, schema *load.Schema, values url.Valu
Label: admin.FieldLabel(f.Name),
Value: getValue(f.Name),
}))
+
case field.TypeInt, field.TypeInt8, field.TypeInt16, field.TypeInt32, field.TypeInt64,
field.TypeUint, field.TypeUint8, field.TypeUint16, field.TypeUint32, field.TypeUint64,
field.TypeFloat32, field.TypeFloat64:
@@ -69,12 +75,14 @@ func AdminEntity(r *ui.Request, isNew bool, schema *load.Schema, values url.Valu
Label: admin.FieldLabel(f.Name),
Value: getValue(f.Name),
}))
+
case field.TypeBool:
nodes = append(nodes, Checkbox(CheckboxParams{
Name: f.Name,
Label: admin.FieldLabel(f.Name),
Checked: getValue(f.Name) == "true",
}))
+
case field.TypeEnum:
options := make([]Choice, 0, len(f.Enums)+1)
if f.Optional {
@@ -95,18 +103,23 @@ func AdminEntity(r *ui.Request, isNew bool, schema *load.Schema, values url.Valu
Value: getValue(f.Name),
Options: options,
}))
+
default:
nodes = append(nodes, P(Textf("%s not supported", f.Name)))
}
}
- nodes = append(nodes, ControlGroup(
- FormButton("is-primary", "Submit"),
- ButtonLink(r.Path(routenames.AdminEntityList(schema.Name)), "is-secondary", "Cancel"),
- ), CSRF(r))
-
return Form(
Method(http.MethodPost),
nodes,
+ ControlGroup(
+ FormButton("is-primary", "Submit"),
+ ButtonLink(
+ r.Path(routenames.AdminEntityList(schema.Name)),
+ "is-secondary",
+ "Cancel",
+ ),
+ ),
+ CSRF(r),
)
}
diff --git a/pkg/ui/forms/admin_entity_delete.go b/pkg/ui/forms/admin_entity_delete.go
index 1d0a3fe..ec52af3 100644
--- a/pkg/ui/forms/admin_entity_delete.go
+++ b/pkg/ui/forms/admin_entity_delete.go
@@ -13,7 +13,10 @@ import (
func AdminEntityDelete(r *ui.Request, entityTypeName string) Node {
return Form(
Method(http.MethodPost),
- P(Class("subtitle"), Textf("Are you sure you want to delete this %s?", entityTypeName)),
+ P(
+ Class("subtitle"),
+ Textf("Are you sure you want to delete this %s?", entityTypeName),
+ ),
ControlGroup(
FormButton("is-link", "Delete"),
ButtonLink(
diff --git a/pkg/ui/pages/admin_entity.go b/pkg/ui/pages/admin_entity.go
index 4180303..8e0bafa 100644
--- a/pkg/ui/pages/admin_entity.go
+++ b/pkg/ui/pages/admin_entity.go
@@ -28,9 +28,9 @@ func AdminEntityDelete(ctx echo.Context, entityTypeName string) error {
)
}
-func AdminEntityInput(ctx echo.Context, isNew bool, schema *load.Schema, values url.Values) error {
+func AdminEntityInput(ctx echo.Context, schema *load.Schema, values url.Values) error {
r := ui.NewRequest(ctx)
- if isNew {
+ if values == nil {
r.Title = fmt.Sprintf("Add %s", schema.Name)
} else {
r.Title = fmt.Sprintf("Edit %s", schema.Name)
@@ -38,7 +38,7 @@ func AdminEntityInput(ctx echo.Context, isNew bool, schema *load.Schema, values
return r.Render(
layouts.Primary,
- forms.AdminEntity(r, isNew, schema, values),
+ forms.AdminEntity(r, schema, values),
)
}
From 57d6de62f28258dfc040dcb977aa846e4fc65b63 Mon Sep 17 00:00:00 2001
From: mikestefanello <552328+mikestefanello@users.noreply.github.com>
Date: Sun, 20 Apr 2025 13:56:00 -0400
Subject: [PATCH 05/10] Updated readme.
---
README.md | 31 +++++++++++++++++++++++++++----
1 file changed, 27 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 94362e4..d2f62e8 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,8 @@
* [Screenshots](#screenshots)
* [Getting started](#getting-started)
* [Dependencies](#dependencies)
+ * [Getting the code](#getting-the-code)
+ * [Create an admin account](#create-an-admin-account)
* [Start the application](#start-the-application)
* [Live reloading](#live-reloading)
* [Service container](#service-container)
@@ -39,6 +41,7 @@
* [Login / Logout](#login--logout)
* [Forgot password](#forgot-password)
* [Registration](#registration)
+ * [Admins](#admins)
* [Authenticated user](#authenticated-user)
* [Middleware](#middleware)
* [Email verification](#email-verification)
@@ -72,6 +75,7 @@
* [Node caching](#node-caching)
* [Flash messaging](#flash-messaging)
* [Pager](#pager)
+* [Admin panel](#admin-panel)
* [Cache](#cache)
* [Set data](#set-data)
* [Get data](#get-data)
@@ -145,17 +149,24 @@ Originally, Postgres and Redis were chosen as defaults but since the aim of this
Ensure that [Go](https://go.dev/) is installed on your system.
-### Start the application
+### Getting the code
-After checking out the repository, from within the root, simply run `make run`:
+Start by checking out the repository. Since this repository is a _template_ and not a Go _library_, you **do not** use `go get`.
```
git clone git@github.com:mikestefanello/pagoda.git
cd pagoda
-make run
```
-Since this repository is a _template_ and not a Go _library_, you **do not** use `go get`.
+### Create an admin account
+
+In order to access the [admin panel](#admin-panel), you must log in with an admin user and in order to create your first admin user account, you must use the command-line. Execute `make admin EMAIL=your@email.com` from the root of the codebase, and an admin account will be generated using that email address. The console will print the randomly-generated password for the account.
+
+Once you have one admin account, you can use that account to manage other users and admins from within the UI.
+
+### Start the application
+
+From within the root of the codebase, simply run `make run`.
By default, you should be able to access the application in your browser at `localhost:8000`. Your data will be stored within the `dbs` directory. If you ever want to quickly delete all data, just remove this directory.
@@ -174,6 +185,7 @@ The container is located at `pkg/services/container.go` and is meant to house al
- Configuration
- Database
- Files
+- Graph
- Mail
- ORM
- Tasks
@@ -343,6 +355,11 @@ The actual registration of a user is not handled within the `AuthClient` but rat
A route is provided for the user to register at `user/register`.
+
+### Admins
+
+A checkbox field has been added to the `User` entity type to indicate if the user has admin access. If your app requires a more robust authorization system, such as roles and permissions, you could easily replace this field and adjust all usage of it accordingly. If a user has this field checked, they will be able to access the [admin panel](#admin-panel). [Middleware](#middleware) is provided to easily restrict access to routes based on admin status.
+
### Authenticated user
The `AuthClient` has two methods available to get either the `User` entity or the ID of the user currently logged in for a given request. Those methods are `GetAuthenticatedUser()` and `GetAuthenticatedUserID()`.
@@ -353,6 +370,8 @@ Registered for all routes is middleware that will load the currently logged in u
If you wish to require either authentication or non-authentication for a given route, you can use either `middleware.RequireAuthentication()` or `middleware.RequireNoAuthentication()`.
+If you wish to restrict a route to admins only, you can use `middleware.RequireAdmin`.
+
### Email verification
Most web applications require the user to verify their email address (or other form of contact information). The `User` entity has a field `Verified` to indicate if they have verified themself. When a user successfully registers, an email is sent to them containing a link with a token that will verify their account when visited. This route is currently accessible at `/email/verify/:token` and handled by `pkg/handlers/auth.go`.
@@ -815,6 +834,10 @@ Methods include:
There is currently no generic component to easily render a pager, but the homepage does have an example.
+## Admin panel
+
+// TODO
+
## Cache
As previously mentioned, the default cache implementation is a simple in-memory store, backed by [otter](https://github.com/maypok86/otter), a lockless cache that uses [S3-FIFO](https://s3fifo.com/) eviction. The `Container` houses a `CacheClient` which is a useful wrapper to interact with the cache (see examples below). Within the `CacheClient` is the underlying store interface `CacheStore`. If you wish to use a different store, such as Redis, and want to keep using the `CacheClient`, simply implement the `CacheStore` interface with a Redis library and adjust the `Container` initialization to use that.
From a7948a9f69d1429ed2caa08d7ea94e4937aed543 Mon Sep 17 00:00:00 2001
From: mikestefanello <552328+mikestefanello@users.noreply.github.com>
Date: Sun, 20 Apr 2025 14:22:42 -0400
Subject: [PATCH 06/10] Add new readme images.
---
README.md | 30 +++++++++++++++++++++++++-----
1 file changed, 25 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index d2f62e8..bf32f09 100644
--- a/README.md
+++ b/README.md
@@ -133,15 +133,23 @@ Originally, Postgres and Redis were chosen as defaults but since the aim of this
#### Inline form validation
-
+
#### Switch layout templates, user registration
-
+
#### Alpine.js modal, HTMX AJAX request
-
+
+
+#### User entity list (admin panel)
+
+
+
+#### User entity edit (admin panel)
+
+
## Getting started
@@ -196,7 +204,7 @@ A new container can be created and initialized via `services.NewContainer()`. It
### Dependency injection
-The container exists to faciliate easy dependency-injection both for services within the container as well as areas of your application that require any of these dependencies. For example, the container is automatically passed to the `Init()` method of your route [handlers](#handlers) so that the handlers have full, easy access to all services.
+The container exists to facilitate easy dependency-injection both for services within the container and areas of your application that require any of these dependencies. For example, the container is automatically passed to the `Init()` method of your route [handlers](#handlers) so that the handlers have full, easy access to all services.
### Test dependencies
@@ -267,7 +275,7 @@ When this project was using Postgres, it would automatically drop and recreate t
## ORM
-As previously mentioned, [Ent](https://entgo.io/) is the supplied ORM. It can swapped out, but I highly recommend it. I don't think there is anything comparable for Go, at the current time. If you're not familiar with Ent, take a look through their top-notch [documentation](https://entgo.io/docs/getting-started).
+As previously mentioned, [Ent](https://entgo.io/) is the supplied ORM. It can be swapped out, but I highly recommend it. I don't think there is anything comparable for Go, at the current time. If you're not familiar with Ent, take a look through their top-notch [documentation](https://entgo.io/docs/getting-started).
An Ent client is included in the `Container` to provide easy access to the ORM throughout the application.
@@ -838,6 +846,18 @@ There is currently no generic component to easily render a pager, but the homepa
// TODO
+### Code generation
+
+// TODO
+
+### Access
+
+// TODO
+
+### Considerations
+
+// TODO
+
## Cache
As previously mentioned, the default cache implementation is a simple in-memory store, backed by [otter](https://github.com/maypok86/otter), a lockless cache that uses [S3-FIFO](https://s3fifo.com/) eviction. The `Container` houses a `CacheClient` which is a useful wrapper to interact with the cache (see examples below). Within the `CacheClient` is the underlying store interface `CacheStore`. If you wish to use a different store, such as Redis, and want to keep using the `CacheClient`, simply implement the `CacheStore` interface with a Redis library and adjust the `Container` initialization to use that.
From bbf51211cdb630e63438b15512d7fd294ae7f9cf Mon Sep 17 00:00:00 2001
From: mikestefanello <552328+mikestefanello@users.noreply.github.com>
Date: Mon, 21 Apr 2025 20:42:35 -0400
Subject: [PATCH 07/10] Updated readme.
---
Makefile | 2 +-
README.md | 63 ++++++++++++++++++--------------
ent/admin/templates/handler.tmpl | 18 ++++-----
pkg/handlers/admin.go | 4 +-
pkg/ui/pages/admin_entity.go | 1 -
5 files changed, 46 insertions(+), 42 deletions(-)
diff --git a/Makefile b/Makefile
index ffecf14..87ca5cc 100644
--- a/Makefile
+++ b/Makefile
@@ -19,7 +19,7 @@ ent-new: ## Create a new Ent entity (ie, make ent-new NAME=MyEntity)
go run entgo.io/ent/cmd/ent new $(NAME)
.PHONY: admin
-admin: ## Create a new admin (ie, make admin EMAIL=myemail@web.com)
+admin: ## Create a new admin user (ie, make admin EMAIL=myemail@web.com)
go run cmd/admin/main.go --email=$(EMAIL)
.PHONY: run
diff --git a/README.md b/README.md
index bf32f09..847e8ba 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@
* [Authenticated user](#authenticated-user)
* [Middleware](#middleware)
* [Email verification](#email-verification)
+* [Admin panel](#admin-panel)
* [Routes](#routes)
* [Custom middleware](#custom-middleware)
* [Handlers](#handlers)
@@ -75,7 +76,6 @@
* [Node caching](#node-caching)
* [Flash messaging](#flash-messaging)
* [Pager](#pager)
-* [Admin panel](#admin-panel)
* [Cache](#cache)
* [Set data](#set-data)
* [Get data](#get-data)
@@ -92,7 +92,6 @@
* [Email](#email)
* [HTTPS](#https)
* [Logging](#logging)
-* [Roadmap](#roadmap)
* [Credits](#credits)
## Introduction
@@ -275,11 +274,11 @@ When this project was using Postgres, it would automatically drop and recreate t
## ORM
-As previously mentioned, [Ent](https://entgo.io/) is the supplied ORM. It can be swapped out, but I highly recommend it. I don't think there is anything comparable for Go, at the current time. If you're not familiar with Ent, take a look through their top-notch [documentation](https://entgo.io/docs/getting-started).
+As previously mentioned, [Ent](https://entgo.io/) is the supplied ORM. It can be swapped out, but I highly recommend it. I don't think there is anything comparable for Go, at the current time. If you decide to remove Ent, you will lose the dynamic [admin panel](#admin-panel) which allows you to administer all entity types from within the UI. If you're not familiar with Ent, take a look through their top-notch [documentation](https://entgo.io/docs/getting-started).
An Ent client is included in the `Container` to provide easy access to the ORM throughout the application.
-Ent relies on code-generation for the entities you create to provide robust, type-safe data operations. Everything within the `ent` package in this repository is generated code for the two entity types listed below with the exception of the schema declaration.
+Ent relies on code-generation for the entities you create to provide robust, type-safe data operations. Everything within the `ent` directory in this repository is generated code for the two entity types listed below except the [schema declaration](#https://github.com/mikestefanello/pagoda/tree/main/ent/schema) and [custom extension](https://github.com/mikestefanello/pagoda/tree/main/ent/admin) to generate code for the [admin panel](#admin-panel).
### Entity types
@@ -394,6 +393,38 @@ Be sure to review the [email](#email) section since actual email sending is not
To generate a new verification token, the `AuthClient` has a method `GenerateEmailVerificationToken()` which creates a token for a given email address. To verify the token, pass it in to `ValidateEmailVerificationToken()` which will return the email address associated with the token and an error if the token is invalid.
+## Admin panel
+
+The admin panel functionality is considered in _beta_ and remains under active development, though all features described here are expected to be fully-functional. Please use caution when using these features and be sure to report any issues you encounter.
+
+What is currently included in the _admin panel_ is a completely dynamic UI to manage all entities defined by _Ent_ (see [screenshots](#screenshots)). There are no separate templates or interfaces for the admin section.
+
+Users with admin [access](#access) will see additional links on the default sidebar for each defined entity type. As with all default UI components, you can easily move these pages and links to a dedicated section, layout, etc. Clicking on the link for any given entity type will provide a pageable table of entities and the ability to add/edit/delete.
+
+### Code generation
+
+In order to automatically and dynamically provide admin functionality for entities, code generation is used by means of leveraging Ent's [extension API](https://entgo.io/docs/extensions) which makes generating code using the Ent graph schema very easy. A [custom extension](https://github.com/mikestefanello/pagoda/blob/master/ent/admin/extension.go) is provided to generate code that provides flat entity type structs and handler code that work directly with Echo. So, both of those are required in order for any of this to work. Whenever you modify one of your entity types or generate a new one, the admin code will also automatically generate.
+
+Without going in to too much detail here, the generated code provides a [handler](https://github.com/mikestefanello/pagoda/blob/master/ent/admin/handler.go) that is then used by a provided [web handler](https://github.com/mikestefanello/pagoda/blob/master/pkg/handlers/admin.go) to power all the routes used in the admin UI. While the rest of the related code should be simple enough to follow, it's worth calling attention to the highly-dynamic [entity form](https://github.com/mikestefanello/pagoda/blob/master/pkg/ui/forms/admin_entity.go) that is constructed using the _Ent_ graph data structure.
+
+### Access
+
+Only admin users can access the admin panel. The details are outlined in the [admins](#admins) and [middleware](#middleware) sections. If you haven't yet generated an admin user, follow [these instructions](#create-an-admin-account).
+
+### Considerations
+
+Since the generated code is completely dynamic, all entity functionality related to creating and editing must be defined within your _Ent_ schema. Refer to the [User](https://github.com/mikestefanello/pagoda/blob/master/ent/schema/user.go) entity schema as an example.
+- Field validation must be defined within each entity field (ie, validating an email address in a _string_ field).
+- Pre-processing must be defined within entity hooks (ie, hashing the user's password).
+- _Sensitive_ fields will be omitted from the UI, and only modified if a value is provided during creation or editing.
+
+### Roadmap
+
+* Either exposed sorting, or allow the _handler_ to be configured with sort criteria for each type.
+* Exposed filters.
+* Support all field types (types such as _JSON_ as currently not supported).
+* More features than just entity management.
+
## Routes
The router functionality is provided by [Echo](https://echo.labstack.com/guide/routing/) and constructed within via the `BuildRouter()` function inside `pkg/handlers/router.go`. Since the _Echo_ instance is a _Service_ on the `Container` which is passed in to `BuildRouter()`, middleware and routes can be added directly to it.
@@ -842,22 +873,6 @@ Methods include:
There is currently no generic component to easily render a pager, but the homepage does have an example.
-## Admin panel
-
-// TODO
-
-### Code generation
-
-// TODO
-
-### Access
-
-// TODO
-
-### Considerations
-
-// TODO
-
## Cache
As previously mentioned, the default cache implementation is a simple in-memory store, backed by [otter](https://github.com/maypok86/otter), a lockless cache that uses [S3-FIFO](https://s3fifo.com/) eviction. The `Container` houses a `CacheClient` which is a useful wrapper to interact with the cache (see examples below). Within the `CacheClient` is the underlying store interface `CacheStore`. If you wish to use a different store, such as Redis, and want to keep using the `CacheClient`, simply implement the `CacheStore` interface with a Redis library and adjust the `Container` initialization to use that.
@@ -1094,14 +1109,6 @@ The `LogRequest()` middleware is a replacement for Echo's `Logger()` middleware
2024/06/15 09:07:11 INFO GET /contact request_id=gNblvugTKcyLnBYPMPTwMPEqDOioVLKp ip=::1 host=localhost:8000 referer="" status=200 bytes_in=0 bytes_out=5925 latency=107.527803ms
```
-## Roadmap
-
-Future work includes but is not limited to:
-
-- Admin section
-- OAuth
-- Flexible pager templates
-
## Credits
Thank you to all the following amazing projects for making this possible.
diff --git a/ent/admin/templates/handler.tmpl b/ent/admin/templates/handler.tmpl
index 4318219..6f3f8da 100644
--- a/ent/admin/templates/handler.tmpl
+++ b/ent/admin/templates/handler.tmpl
@@ -242,21 +242,21 @@
for k, v := range ctx.Request().Form {
// Remove empty field values so Echo's bind does not fail when trying to parse things like
// times, etc.
- if len(v) == 1 && len(v[0]) == 0 {
- delete(ctx.Request().Form, k)
- continue
- }
+ if len(v) == 1 && len(v[0]) == 0 {
+ delete(ctx.Request().Form, k)
+ continue
+ }
- // Echo expects datetime values to be in a certain format but that does not align with the datetime-local
- // HTML form element format, so we will attempt to convert it here.
- for _, format := range []string{dateTimeFormatNoSeconds, dateTimeFormat} {
+ // Echo expects datetime values to be in a certain format but that does not align with the datetime-local
+ // HTML form element format, so we will attempt to convert it here.
+ for _, format := range []string{dateTimeFormatNoSeconds, dateTimeFormat} {
if t, err := time.Parse(format, v[0]); err == nil {
ctx.Request().Form[k][0] = t.Format(time.RFC3339)
break
}
}
- }
- return ctx.Bind(entity)
+ }
+ return ctx.Bind(entity)
}
{{ end }}
\ No newline at end of file
diff --git a/pkg/handlers/admin.go b/pkg/handlers/admin.go
index fd79c63..c999a8c 100644
--- a/pkg/handlers/admin.go
+++ b/pkg/handlers/admin.go
@@ -96,9 +96,7 @@ func (h *Admin) EntityList(n *gen.Type) echo.HandlerFunc {
return echo.NewHTTPError(http.StatusInternalServerError, err)
}
- pgr := pager.NewPager(ctx, h.admin.Config.ItemsPerPage)
-
- return pages.AdminEntityList(ctx, n.Name, list, pgr)
+ return pages.AdminEntityList(ctx, n.Name, list)
}
}
diff --git a/pkg/ui/pages/admin_entity.go b/pkg/ui/pages/admin_entity.go
index 8e0bafa..44ccae4 100644
--- a/pkg/ui/pages/admin_entity.go
+++ b/pkg/ui/pages/admin_entity.go
@@ -46,7 +46,6 @@ func AdminEntityList(
ctx echo.Context,
entityTypeName string,
entityList *admin.EntityList,
- pgr pager.Pager,
) error {
r := ui.NewRequest(ctx)
r.Title = entityTypeName
From a418003eaffd548d9dbe998c81045017437a15bb Mon Sep 17 00:00:00 2001
From: mikestefanello <552328+mikestefanello@users.noreply.github.com>
Date: Mon, 21 Apr 2025 20:58:18 -0400
Subject: [PATCH 08/10] Updated readme.
---
README.md | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 847e8ba..db59c9a 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,10 @@
* [Middleware](#middleware)
* [Email verification](#email-verification)
* [Admin panel](#admin-panel)
+ * [Code generation](#code-generation)
+ * [Access](#access)
+ * [Considerations](#considerations)
+ * [Roadmap](#roadmap)
* [Routes](#routes)
* [Custom middleware](#custom-middleware)
* [Handlers](#handlers)
@@ -395,7 +399,7 @@ To generate a new verification token, the `AuthClient` has a method `GenerateEma
## Admin panel
-The admin panel functionality is considered in _beta_ and remains under active development, though all features described here are expected to be fully-functional. Please use caution when using these features and be sure to report any issues you encounter.
+The admin panel functionality is considered to be in _beta_ and remains under active development, though all features described here are expected to be fully-functional. Please use caution when using these features and be sure to report any issues you encounter.
What is currently included in the _admin panel_ is a completely dynamic UI to manage all entities defined by _Ent_ (see [screenshots](#screenshots)). There are no separate templates or interfaces for the admin section.
From b6d2b01c9b7b899cfe512332fd238b861e1cc05f Mon Sep 17 00:00:00 2001
From: mikestefanello <552328+mikestefanello@users.noreply.github.com>
Date: Mon, 21 Apr 2025 21:47:51 -0400
Subject: [PATCH 09/10] Updated readme and makefile.
---
Makefile | 8 ++++----
README.md | 3 ++-
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/Makefile b/Makefile
index 87ca5cc..7d046af 100644
--- a/Makefile
+++ b/Makefile
@@ -15,12 +15,12 @@ ent-gen: ## Generate Ent code
go generate ./ent
.PHONY: ent-new
-ent-new: ## Create a new Ent entity (ie, make ent-new NAME=MyEntity)
- go run entgo.io/ent/cmd/ent new $(NAME)
+ent-new: ## Create a new Ent entity (ie, make ent-new name=MyEntity)
+ go run entgo.io/ent/cmd/ent new $(name)
.PHONY: admin
-admin: ## Create a new admin user (ie, make admin EMAIL=myemail@web.com)
- go run cmd/admin/main.go --email=$(EMAIL)
+admin: ## Create a new admin user (ie, make admin email=myemail@web.com)
+ go run cmd/admin/main.go --email=$(email)
.PHONY: run
run: ## Run the application
diff --git a/README.md b/README.md
index db59c9a..e46bf69 100644
--- a/README.md
+++ b/README.md
@@ -171,7 +171,7 @@ cd pagoda
### Create an admin account
-In order to access the [admin panel](#admin-panel), you must log in with an admin user and in order to create your first admin user account, you must use the command-line. Execute `make admin EMAIL=your@email.com` from the root of the codebase, and an admin account will be generated using that email address. The console will print the randomly-generated password for the account.
+In order to access the [admin panel](#admin-panel), you must log in with an admin user and in order to create your first admin user account, you must use the command-line. Execute `make admin email=your@email.com` from the root of the codebase, and an admin account will be generated using that email address. The console will print the randomly-generated password for the account.
Once you have one admin account, you can use that account to manage other users and admins from within the UI.
@@ -421,6 +421,7 @@ Since the generated code is completely dynamic, all entity functionality related
- Field validation must be defined within each entity field (ie, validating an email address in a _string_ field).
- Pre-processing must be defined within entity hooks (ie, hashing the user's password).
- _Sensitive_ fields will be omitted from the UI, and only modified if a value is provided during creation or editing.
+- _Edges_ must be bound to an [edge field](https://entgo.io/docs/schema-edges#edge-field) if you want them visible and editable.
### Roadmap
From 2d4961bd208fcb72cdac55d7ce9cdad555c9fcbf Mon Sep 17 00:00:00 2001
From: mikestefanello <552328+mikestefanello@users.noreply.github.com>
Date: Tue, 22 Apr 2025 08:25:29 -0400
Subject: [PATCH 10/10] Updated readme.
---
README.md | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index e46bf69..c17a2b0 100644
--- a/README.md
+++ b/README.md
@@ -425,10 +425,13 @@ Since the generated code is completely dynamic, all entity functionality related
### Roadmap
+* Determine which tests should be included and provide them.
+* Inline validation.
* Either exposed sorting, or allow the _handler_ to be configured with sort criteria for each type.
* Exposed filters.
* Support all field types (types such as _JSON_ as currently not supported).
-* More features than just entity management.
+* Control which fields appear in the entity list table.
+* More features than just entity management (ie, including the [Backlite](https://github.com/mikestefanello/backlite#screenshots) UI).
## Routes