From 33e98f9a9eb2105e2eeef17cc183735cbff908f0 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sun, 30 Mar 2025 14:46:46 -0400 Subject: [PATCH] Initial commit of admin panel. --- go.mod | 2 +- pkg/handlers/admin.go | 284 +++++++++++++++++++++++++++++++++ pkg/middleware/auth.go | 4 +- pkg/redirect/redirect.go | 2 +- pkg/services/container.go | 23 ++- pkg/services/container_test.go | 5 + pkg/ui/layouts/admin.go | 65 ++++++++ pkg/ui/pages/entity.go | 88 ++++++++++ 8 files changed, 466 insertions(+), 7 deletions(-) create mode 100644 pkg/handlers/admin.go create mode 100644 pkg/ui/layouts/admin.go create mode 100644 pkg/ui/pages/entity.go diff --git a/go.mod b/go.mod index 2979637..3b703a3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/mikestefanello/pagoda -go 1.23.0 +go 1.24.0 require ( entgo.io/ent v0.14.3 diff --git a/pkg/handlers/admin.go b/pkg/handlers/admin.go new file mode 100644 index 0000000..d1d045d --- /dev/null +++ b/pkg/handlers/admin.go @@ -0,0 +1,284 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/entc/gen" + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/ent" + "github.com/mikestefanello/pagoda/ent/passwordtoken" + "github.com/mikestefanello/pagoda/ent/user" + "github.com/mikestefanello/pagoda/pkg/msg" + "github.com/mikestefanello/pagoda/pkg/pager" + "github.com/mikestefanello/pagoda/pkg/redirect" + "github.com/mikestefanello/pagoda/pkg/services" + "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 +} + +func init() { + Register(new(Admin)) +} + +func (h *Admin) Init(c *services.Container) error { + h.graph = c.Graph + h.orm = c.ORM + return nil +} + +func (h *Admin) Routes(g *echo.Group) { + // TODO admin user status middleware + entities := g.Group("/admin/content") + + for _, p := range h.getEntityPlugins() { + pg := entities.Group(fmt.Sprintf("/%s", p.ID)) + pg.GET("", h.EntityList(p)).Name = p.RouteNameList() + pg.POST("", h.EntityList(p)).Name = p.RouteNameListSubmit() + pg.GET("/add", h.EntityAdd(p)).Name = p.RouteNameAdd() + pg.POST("/add", h.EntityAddSubmit(p)).Name = p.RouteNameAddSubmit() + pg.GET("/:id/edit", h.EntityEdit(p), h.entityPluginMiddleware(p)).Name = p.RouteNameEdit() + pg.POST("/:id/edit", h.EntityEditSubmit(p), h.entityPluginMiddleware(p)).Name = p.RouteNameEditSubmit() + pg.GET("/:id/delete", h.EntityDelete(p), h.entityPluginMiddleware(p)).Name = p.RouteNameDelete() + pg.POST("/:id/delete", h.EntityDeleteSubmit(p), h.entityPluginMiddleware(p)).Name = p.RouteNameDeleteSubmit() + } +} + +// TODO, maybe this can be used outside of admin stuff as well? +func (h *Admin) entityPluginMiddleware(plugin AdminEntityPlugin) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(ctx echo.Context) error { + id, err := strconv.Atoi(ctx.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid entity ID") + } + + entity, err := plugin.Load(ctx, h.orm, id) + switch { + case err == nil: + ctx.Set(entityIDContextKey, id) + ctx.Set(entityContextKey, entity) + return next(ctx) + case errors.Is(err, new(ent.NotFoundError)): + return echo.NewHTTPError(http.StatusNotFound, "entity not found") + default: + return echo.NewHTTPError(http.StatusInternalServerError, err) + } + } + } +} + +func (h *Admin) EntityList(p AdminEntityPlugin) echo.HandlerFunc { + return func(ctx echo.Context) error { + var err error + pgr := pager.NewPager(ctx, 25) + params := pages.AdminEntityListParams{ + Title: p.LabelPlural, + Headers: p.Heading, + EditRoute: p.RouteNameEdit(), // todo remove, pass in plugin + DeleteRoute: p.RouteNameDelete(), // todo remove, pass in plugin + Pager: pgr, + } + params.Rows, err = p.List(ctx, h.orm, pgr) + if err != nil { + return fail(err, fmt.Sprintf("failed to query %s", p.ID)) + } + + return pages.AdminEntityList(ctx, params) + } +} + +func (h *Admin) EntityAdd(p AdminEntityPlugin) echo.HandlerFunc { + return func(ctx echo.Context) error { + return nil + } +} + +func (h *Admin) EntityAddSubmit(p AdminEntityPlugin) echo.HandlerFunc { + return func(ctx echo.Context) error { + return nil + } +} + +func (h *Admin) EntityEdit(p AdminEntityPlugin) echo.HandlerFunc { + return func(ctx echo.Context) error { + return nil + } +} + +func (h *Admin) EntityEditSubmit(p AdminEntityPlugin) echo.HandlerFunc { + return func(ctx echo.Context) error { + return nil + } +} + +func (h *Admin) EntityDelete(p AdminEntityPlugin) echo.HandlerFunc { + return func(ctx echo.Context) error { + return pages.AdminEntityDelete(ctx) + } +} + +func (h *Admin) EntityDeleteSubmit(p AdminEntityPlugin) echo.HandlerFunc { + return func(ctx echo.Context) error { + id := ctx.Get(entityIDContextKey).(int) + if err := p.Delete(ctx, h.orm, id); err != nil { + return fail(err, fmt.Sprintf("failed to delete %s (ID: %d)", p.ID, id)) + } + + msg.Success(ctx, fmt.Sprintf("Successfully deleted %s.", strings.ToLower(p.Label))) + + return redirect. + New(ctx). + Route(p.RouteNameList()). + Go() + } +} + +// TODO inject orm? move to separate package? +type AdminEntityPlugin struct { + ID string + Label string + LabelPlural string + Heading []string + List func(ctx echo.Context, orm *ent.Client, pgr pager.Pager) ([]pages.AdminEntityListRow, error) + Load func(ctx echo.Context, orm *ent.Client, id int) (any, error) + Delete func(ctx echo.Context, orm *ent.Client, id int) error +} + +func (p *AdminEntityPlugin) RouteNameList() string { + return fmt.Sprintf("admin:%s_list", p.ID) +} + +func (p *AdminEntityPlugin) RouteNameListSubmit() string { + return fmt.Sprintf("admin:%s_list.submit", p.ID) +} + +func (p *AdminEntityPlugin) RouteNameAdd() string { + return fmt.Sprintf("admin:%s_add", p.ID) +} + +func (p *AdminEntityPlugin) RouteNameEdit() string { + return fmt.Sprintf("admin:%s_edit", p.ID) +} + +func (p *AdminEntityPlugin) RouteNameDelete() string { + return fmt.Sprintf("admin:%s_delete", p.ID) +} + +func (p *AdminEntityPlugin) RouteNameAddSubmit() string { + return fmt.Sprintf("admin:%s_add.submit", p.ID) +} + +func (p *AdminEntityPlugin) RouteNameEditSubmit() string { + return fmt.Sprintf("admin:%s_edit.submit", p.ID) +} + +func (p *AdminEntityPlugin) RouteNameDeleteSubmit() string { + return fmt.Sprintf("admin:%s_delete.submit", p.ID) +} + +func (h *Admin) getEntityPlugins() []AdminEntityPlugin { + return []AdminEntityPlugin{ + { + ID: "user", + Label: "User", + LabelPlural: "Users", + Heading: []string{ + "ID", + "Name", + "Email", + "Created at", + }, + List: func(ctx echo.Context, client *ent.Client, pgr pager.Pager) ([]pages.AdminEntityListRow, error) { + users, err := client.User. + Query(). + Limit(pgr.ItemsPerPage). + Offset(pgr.GetOffset()). + Order(user.ByCreatedAt(sql.OrderDesc())). + All(ctx.Request().Context()) + + if err != nil { + return nil, err + } + + rows := make([]pages.AdminEntityListRow, 0, len(users)) + + for _, u := range users { + rows = append(rows, pages.AdminEntityListRow{ + ID: u.ID, + Columns: []string{ + fmt.Sprint(u.ID), + u.Name, + u.Email, + u.CreatedAt.Format(time.RFC822), + }, + }) + } + + return rows, nil + }, + Load: func(ctx echo.Context, orm *ent.Client, id int) (any, error) { + return orm.User.Get(ctx.Request().Context(), id) + }, + Delete: func(ctx echo.Context, orm *ent.Client, id int) error { + return orm.User.DeleteOneID(id).Exec(ctx.Request().Context()) + }, + }, + { + ID: "passwordtoken", + Label: "Password token", + LabelPlural: "Password tokens", + Heading: []string{ + "ID", + "Hash", + "Created at", + }, + List: func(ctx echo.Context, client *ent.Client, pgr pager.Pager) ([]pages.AdminEntityListRow, error) { + tokens, err := client.PasswordToken. + Query(). + Limit(pgr.ItemsPerPage). + Offset(pgr.GetOffset()). + Order(passwordtoken.ByCreatedAt(sql.OrderDesc())). + All(ctx.Request().Context()) + + if err != nil { + return nil, err + } + + rows := make([]pages.AdminEntityListRow, 0, len(tokens)) + + for _, t := range tokens { + rows = append(rows, pages.AdminEntityListRow{ + ID: t.ID, + Columns: []string{ + fmt.Sprint(t.ID), + t.Hash, + t.CreatedAt.Format(time.RFC822), + }, + }) + } + + return rows, nil + }, + Load: func(ctx echo.Context, orm *ent.Client, id int) (any, error) { + return orm.PasswordToken.Get(ctx.Request().Context(), id) + }, + Delete: func(ctx echo.Context, orm *ent.Client, id int) error { + return orm.PasswordToken.DeleteOneID(id).Exec(ctx.Request().Context()) + }, + }, + } +} diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 6194b60..54a8346 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -9,6 +9,7 @@ import ( "github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/msg" + "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" "github.com/labstack/echo/v4" @@ -70,8 +71,7 @@ func LoadValidPasswordToken(authClient *services.AuthClient) echo.MiddlewareFunc return next(c) case services.InvalidPasswordTokenError: msg.Warning(c, "The link is either invalid or has expired. Please request a new one.") - // TODO use the const for route name - return c.Redirect(http.StatusFound, c.Echo().Reverse("forgot_password")) + return c.Redirect(http.StatusFound, c.Echo().Reverse(routenames.ForgotPassword)) default: return echo.NewHTTPError( http.StatusInternalServerError, diff --git a/pkg/redirect/redirect.go b/pkg/redirect/redirect.go index 3f08b43..73da260 100644 --- a/pkg/redirect/redirect.go +++ b/pkg/redirect/redirect.go @@ -41,7 +41,7 @@ func (r *Redirect) Params(params ...any) *Redirect { return r } -// StatusCode sets the HTTP status code which defaults to http.StatusFound. +// StatusCode sets the HTTP status code which defaults to http.StatusTemporaryRedirect. // Does not apply to HTMX redirects. func (r *Redirect) StatusCode(code int) *Redirect { r.status = code diff --git a/pkg/services/container.go b/pkg/services/container.go index a3f3ce8..625acb8 100644 --- a/pkg/services/container.go +++ b/pkg/services/container.go @@ -6,17 +6,21 @@ import ( "fmt" "log/slog" "os" + "path" + "path/filepath" + "runtime" "strings" - "github.com/mikestefanello/backlite" - "github.com/spf13/afero" - entsql "entgo.io/ent/dialect/sql" + "entgo.io/ent/entc" + "entgo.io/ent/entc/gen" "github.com/labstack/echo/v4" _ "github.com/mattn/go-sqlite3" + "github.com/mikestefanello/backlite" "github.com/mikestefanello/pagoda/config" "github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/pkg/log" + "github.com/spf13/afero" // Required by ent. _ "github.com/mikestefanello/pagoda/ent/runtime" @@ -46,6 +50,9 @@ type Container struct { // ORM stores a client to the ORM. ORM *ent.Client + // Graph is the entity graph defined by your Ent schema. + Graph *gen.Graph + // Mail stores an email sending client. Mail *MailClient @@ -184,6 +191,16 @@ func (c *Container) initORM() { if err := c.ORM.Schema.Create(context.Background()); err != nil { panic(err) } + + // Load the graph. + _, b, _, _ := runtime.Caller(0) + d := path.Join(path.Dir(b)) + p := filepath.Join(filepath.Dir(d), "../ent/schema") + g, err := entc.LoadGraph(p, &gen.Config{}) + if err != nil { + panic(err) + } + c.Graph = g } // initAuth initializes the authentication client. diff --git a/pkg/services/container_test.go b/pkg/services/container_test.go index 7f4a5d4..5d2c3d7 100644 --- a/pkg/services/container_test.go +++ b/pkg/services/container_test.go @@ -17,4 +17,9 @@ 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 { + //c.ORM.User.Create(). + } } diff --git a/pkg/ui/layouts/admin.go b/pkg/ui/layouts/admin.go new file mode 100644 index 0000000..fe18623 --- /dev/null +++ b/pkg/ui/layouts/admin.go @@ -0,0 +1,65 @@ +package layouts + +import ( + "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 Admin(r *ui.Request, content Node) Node { + return Doctype( + HTML( + Lang("en"), + Head( + Metatags(r), + CSS(), + JS(r), + ), + Body( + Div( + Class("box"), + Div( + Class("columns"), + Div( + Class("column is-2"), + adminMenu(r), + ), + Div( + Class("column is-10"), + If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))), + FlashMessages(r), + content, + ), + ), + ), + ), + ), + ) +} + +func adminMenu(r *ui.Request) Node { + return Aside( + Class("menu"), + HxBoost(), + P( + Class("menu-label"), + Text("Content"), + ), + Ul( + Class("menu-list"), + MenuLink(r, "Users", "admin:user_list"), + MenuLink(r, "Tokens", "admin:passwordtoken_list"), + ), + P( + Class("menu-label"), + Text("Account"), + ), + Ul( + Class("menu-list"), + If(r.IsAuth, MenuLink(r, "Logout", routenames.Logout)), + ), + ) +} diff --git a/pkg/ui/pages/entity.go b/pkg/ui/pages/entity.go new file mode 100644 index 0000000..30d4fe6 --- /dev/null +++ b/pkg/ui/pages/entity.go @@ -0,0 +1,88 @@ +package pages + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/pager" + "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/html" +) + +func Entity(ctx echo.Context) error { + return ui.NewRequest(ctx).Render(layouts.Admin, Div(Text("abc"))) +} + +func AdminEntityDelete(ctx echo.Context) error { + r := ui.NewRequest(ctx) + form := Form( + Method(http.MethodPost), + H2(Text("Are you sure you want to delete this entity?")), + ControlGroup( + FormButton("is-link", "Delete"), + ButtonLink("/", "is-secondary", "Cancel"), + ), + CSRF(r), + ) + + return r.Render(layouts.Admin, form) +} + +type AdminEntityListParams struct { + Title string + Headers []string + Rows []AdminEntityListRow + EditRoute string + DeleteRoute string + Pager pager.Pager +} + +type AdminEntityListRow struct { + ID int + Columns []string +} + +func AdminEntityList(ctx echo.Context, params AdminEntityListParams) error { + r := ui.NewRequest(ctx) + r.Title = params.Title + + genHeader := func() Node { + g := make(Group, 0, len(params.Headers)+2) + for _, h := range params.Headers { + g = append(g, Th(Text(h))) + } + g = append(g, Th(), Th()) + return g + } + + genRow := func(row AdminEntityListRow) Node { + g := make(Group, 0, len(row.Columns)+2) + for _, h := range row.Columns { + g = append(g, Td(Text(h))) + } + g = append(g, + Td(A(Href(r.Path(params.EditRoute, row.ID)), Text("Edit"))), + Td(A(Href(r.Path(params.DeleteRoute, row.ID)), Text("Delete"))), + ) + return g + } + + genRows := func() Node { + g := make(Group, 0, len(params.Rows)) + for _, row := range params.Rows { + g = append(g, Tr(genRow(row))) + } + return g + } + + return r.Render(layouts.Admin, Table( + Class("table"), + THead( + Tr(genHeader()), + ), + TBody(genRows()), + )) +}