Add dynamic admin panel for managing entities (#108)

This commit is contained in:
Mike Stefanello 2025-04-22 08:26:35 -04:00 committed by GitHub
parent 60009df0bf
commit 1a6874fd82
47 changed files with 2173 additions and 320 deletions

View file

@ -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.

184
pkg/handlers/admin.go Normal file
View file

@ -0,0 +1,184 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"entgo.io/ent/entc/gen"
"entgo.io/ent/entc/load"
"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"
"github.com/mikestefanello/pagoda/pkg/redirect"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/pkg/ui/pages"
)
type Admin struct {
orm *ent.Client
graph *gen.Graph
admin *admin.Handler
}
func init() {
Register(new(Admin))
}
func (h *Admin) Init(c *services.Container) error {
h.graph = c.Graph
h.orm = c.ORM
h.admin = admin.NewHandler(h.orm, admin.HandlerConfig{
ItemsPerPage: 25,
PageQueryKey: pager.QueryKey,
TimeFormat: time.DateTime,
})
return nil
}
func (h *Admin) Routes(g *echo.Group) {
entities := g.Group("/admin/entity", middleware.RequireAdmin)
for _, n := range h.graph.Nodes {
ng := entities.Group(fmt.Sprintf("/%s", strings.ToLower(n.Name)))
ng.GET("", h.EntityList(n)).
Name = routenames.AdminEntityList(n.Name)
ng.GET("/add", h.EntityAdd(n)).
Name = routenames.AdminEntityAdd(n.Name)
ng.POST("/add", h.EntityAddSubmit(n)).
Name = routenames.AdminEntityAddSubmit(n.Name)
ng.GET("/:id/edit", h.EntityEdit(n), h.middlewareEntityLoad(n)).
Name = routenames.AdminEntityEdit(n.Name)
ng.POST("/:id/edit", h.EntityEditSubmit(n), h.middlewareEntityLoad(n)).
Name = routenames.AdminEntityEditSubmit(n.Name)
ng.GET("/:id/delete", h.EntityDelete(n), h.middlewareEntityLoad(n)).
Name = routenames.AdminEntityDelete(n.Name)
ng.POST("/:id/delete", h.EntityDeleteSubmit(n), h.middlewareEntityLoad(n)).
Name = routenames.AdminEntityDeleteSubmit(n.Name)
}
}
// 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 {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid entity ID")
}
entity, err := h.admin.Get(ctx, n.Name, id)
switch {
case err == nil:
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")
default:
return echo.NewHTTPError(http.StatusInternalServerError, err)
}
}
}
}
func (h *Admin) EntityList(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
list, err := h.admin.List(ctx, n.Name)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err)
}
return pages.AdminEntityList(ctx, n.Name, list)
}
}
func (h *Admin) EntityAdd(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
return pages.AdminEntityInput(ctx, h.getEntitySchema(n), nil)
}
}
func (h *Admin) EntityAddSubmit(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
err := h.admin.Create(ctx, n.Name)
if err != nil {
msg.Danger(ctx, err.Error())
return h.EntityAdd(n)(ctx)
}
msg.Success(ctx, fmt.Sprintf("Successfully added %s.", n.Name))
return redirect.
New(ctx).
Route(routenames.AdminEntityList(n.Name)).
StatusCode(http.StatusFound).
Go()
}
}
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, h.getEntitySchema(n), v)
}
}
func (h *Admin) EntityEditSubmit(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
id := ctx.Get(context.AdminEntityIDKey).(int)
err := h.admin.Update(ctx, n.Name, id)
if err != nil {
msg.Danger(ctx, err.Error())
return h.EntityEdit(n)(ctx)
}
msg.Success(ctx, fmt.Sprintf("Updated %s.", n.Name))
return redirect.
New(ctx).
Route(routenames.AdminEntityList(n.Name)).
StatusCode(http.StatusFound).
Go()
}
}
func (h *Admin) EntityDelete(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
return pages.AdminEntityDelete(ctx, n.Name)
}
}
func (h *Admin) EntityDeleteSubmit(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
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)
}
msg.Success(ctx, fmt.Sprintf("Successfully deleted %s (ID %d).", n.Name, id))
return redirect.
New(ctx).
Route(routenames.AdminEntityList(n.Name)).
StatusCode(http.StatusFound).
Go()
}
}
func (h *Admin) getEntitySchema(n *gen.Type) *load.Schema {
for _, s := range h.graph.Schemas {
if s.Name == n.Name {
return s
}
}
return nil
}

View file

@ -39,10 +39,10 @@ func (h *Auth) Init(c *services.Container) error {
}
func (h *Auth) Routes(g *echo.Group) {
g.GET("/logout", h.Logout, middleware.RequireAuthentication()).Name = routenames.Logout
g.GET("/logout", h.Logout, middleware.RequireAuthentication).Name = routenames.Logout
g.GET("/email/verify/:token", h.VerifyEmail).Name = routenames.VerifyEmail
noAuth := g.Group("/user", middleware.RequireNoAuthentication())
noAuth := g.Group("/user", middleware.RequireNoAuthentication)
noAuth.GET("/login", h.LoginPage).Name = routenames.Login
noAuth.POST("/login", h.LoginSubmit).Name = routenames.LoginSubmit
noAuth.GET("/register", h.RegisterPage).Name = routenames.Register
@ -206,18 +206,12 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error {
return err
}
// Hash the password.
pwHash, err := h.auth.HashPassword(input.Password)
if err != nil {
return fail(err, "unable to hash password")
}
// Attempt creating the user.
u, err := h.orm.User.
Create().
SetName(input.Name).
SetEmail(input.Email).
SetPassword(pwHash).
SetPassword(input.Password).
Save(ctx.Request().Context())
switch err.(type) {
@ -305,19 +299,13 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error {
return err
}
// Hash the new password.
hash, err := h.auth.HashPassword(input.Password)
if err != nil {
return fail(err, "unable to hash password")
}
// Get the requesting user.
usr := ctx.Get(context.UserKey).(*ent.User)
// Update the user.
_, err = usr.
Update().
SetPassword(hash).
SetPassword(input.Password).
Save(ctx.Request().Context())
if err != nil {

View file

@ -9,12 +9,13 @@ 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"
)
// 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 {
@ -40,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 {
@ -50,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,
@ -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,
@ -82,28 +82,39 @@ func LoadValidPasswordToken(authClient *services.AuthClient) echo.MiddlewareFunc
}
}
// RequireAuthentication requires that the user be authenticated in order to proceed
func RequireAuthentication() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if u := c.Get(context.AuthenticatedUserKey); u == nil {
return echo.NewHTTPError(http.StatusUnauthorized)
}
return next(c)
// 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 {
return echo.NewHTTPError(http.StatusUnauthorized)
}
return next(c)
}
}
// RequireNoAuthentication requires that the user not be authenticated in order to proceed
func RequireNoAuthentication() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if u := c.Get(context.AuthenticatedUserKey); u != nil {
return echo.NewHTTPError(http.StatusForbidden)
}
return next(c)
// 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 {
return echo.NewHTTPError(http.StatusForbidden)
}
return next(c)
}
}
// 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 {
return next(c)
}
}
}
return echo.NewHTTPError(http.StatusUnauthorized)
}
}

View file

@ -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)
}

View file

@ -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

View file

@ -1,5 +1,9 @@
package routenames
import (
"fmt"
)
const (
Home = "home"
About = "about"
@ -23,3 +27,31 @@ const (
Files = "files"
FilesSubmit = "files.submit"
)
func AdminEntityList(entityTypeName string) string {
return fmt.Sprintf("admin:%s_list", entityTypeName)
}
func AdminEntityAdd(entityTypeName string) string {
return fmt.Sprintf("admin:%s_add", entityTypeName)
}
func AdminEntityEdit(entityTypeName string) string {
return fmt.Sprintf("admin:%s_edit", entityTypeName)
}
func AdminEntityDelete(entityTypeName string) string {
return fmt.Sprintf("admin:%s_delete", entityTypeName)
}
func AdminEntityAddSubmit(entityTypeName string) string {
return fmt.Sprintf("admin:%s_add.submit", entityTypeName)
}
func AdminEntityEditSubmit(entityTypeName string) string {
return fmt.Sprintf("admin:%s_edit.submit", entityTypeName)
}
func AdminEntityDeleteSubmit(entityTypeName string) string {
return fmt.Sprintf("admin:%s_delete.submit", entityTypeName)
}

View file

@ -106,15 +106,6 @@ func (c *AuthClient) GetAuthenticatedUser(ctx echo.Context) (*ent.User, error) {
return nil, NotAuthenticatedError{}
}
// HashPassword returns a hash of a given password
func (c *AuthClient) HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// CheckPassword check if a given password matches a given hash
func (c *AuthClient) CheckPassword(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
@ -123,7 +114,7 @@ func (c *AuthClient) CheckPassword(password, hash string) error {
// GeneratePasswordResetToken generates a password reset token for a given user.
// For security purposes, the token itself is not stored in the database but rather
// a hash of the token, exactly how passwords are handled. This method returns both
// the generated token as well as the token entity which only contains the hash.
// the generated token and the token entity which only contains the hash.
func (c *AuthClient) GeneratePasswordResetToken(ctx echo.Context, userID int) (string, *ent.PasswordToken, error) {
// Generate the token, which is what will go in the URL, but not the database
token, err := c.RandomToken(c.config.App.PasswordToken.Length)
@ -131,16 +122,10 @@ func (c *AuthClient) GeneratePasswordResetToken(ctx echo.Context, userID int) (s
return "", nil, err
}
// Hash the token, which is what will be stored in the database
hash, err := c.HashPassword(token)
if err != nil {
return "", nil, err
}
// Create and save the password reset token
pt, err := c.orm.PasswordToken.
Create().
SetHash(hash).
SetToken(token).
SetUserID(userID).
Save(ctx.Request().Context())
@ -166,7 +151,7 @@ func (c *AuthClient) GetValidPasswordToken(ctx echo.Context, userID, tokenID int
case *ent.NotFoundError:
case nil:
// Check the token for a hash match
if err := c.CheckPassword(token, pt.Hash); err == nil {
if err := c.CheckPassword(token, pt.Token); err == nil {
return pt, nil
}
default:

View file

@ -8,6 +8,7 @@ import (
"github.com/mikestefanello/pagoda/ent/passwordtoken"
"github.com/mikestefanello/pagoda/ent/user"
"golang.org/x/crypto/bcrypt"
"github.com/stretchr/testify/require"
@ -41,12 +42,12 @@ func TestAuthClient_Auth(t *testing.T) {
assertNoAuth()
}
func TestAuthClient_PasswordHashing(t *testing.T) {
func TestAuthClient_CheckPassword(t *testing.T) {
pw := "testcheckpassword"
hash, err := c.Auth.HashPassword(pw)
assert.NoError(t, err)
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
require.NoError(t, err)
assert.NotEqual(t, hash, pw)
err = c.Auth.CheckPassword(pw, hash)
err = c.Auth.CheckPassword(pw, string(hash))
assert.NoError(t, err)
}
@ -54,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) {

View file

@ -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.

View file

@ -19,16 +19,16 @@ type (
Help string
}
RadiosParams struct {
OptionsParams struct {
Form form.Form
FormField string
Name string
Label string
Value string
Options []Radio
Options []Choice
}
Radio struct {
Choice struct {
Value string
Label string
}
@ -41,6 +41,14 @@ type (
Value string
Help string
}
CheckboxParams struct {
Form form.Form
FormField string
Name string
Label string
Checked bool
}
)
func ControlGroup(controls ...Node) Node {
@ -80,7 +88,7 @@ func TextareaField(el TextareaFieldParams) Node {
)
}
func Radios(el RadiosParams) Node {
func Radios(el OptionsParams) Node {
buttons := make(Group, len(el.Options))
for i, opt := range el.Options {
buttons[i] = Label(
@ -106,6 +114,50 @@ func Radios(el RadiosParams) Node {
)
}
func SelectList(el OptionsParams) Node {
buttons := make(Group, len(el.Options))
for i, opt := range el.Options {
buttons[i] = Option(
Text(opt.Label),
Value(opt.Value),
If(opt.Value == el.Value, Attr("selected")),
)
}
return Div(
Class("control field"),
Label(Class("label"), Text(el.Label)),
Div(
Class("select"),
Select(
Name(el.Name),
buttons,
),
),
formFieldErrors(el.Form, el.FormField),
)
}
func Checkbox(el CheckboxParams) Node {
return Div(
Class("field"),
Div(
Class("control"),
Label(
Class("checkbox"),
Input(
Type("checkbox"),
Name(el.Name),
If(el.Checked, Checked()),
Value("true"),
),
Text(" "+el.Label),
),
),
formFieldErrors(el.Form, el.FormField),
)
}
func InputField(el InputFieldParams) Node {
return Div(
Class("field"),
@ -153,6 +205,8 @@ func FileField(name, label string) Node {
func formFieldStatusClass(fm form.Form, formField string) string {
switch {
case fm == nil:
return ""
case !fm.IsSubmitted():
return ""
case fm.FieldHasErrors(formField):
@ -163,6 +217,10 @@ func formFieldStatusClass(fm form.Form, formField string) string {
}
func formFieldErrors(fm form.Form, field string) Node {
if fm == nil {
return nil
}
errs := fm.GetFieldErrors(field)
if len(errs) == 0 {
return nil

View file

@ -0,0 +1,125 @@
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, 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]
}
return ""
}
// Attempt to add form elements for all editable entity fields.
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)))
}
}
return Form(
Method(http.MethodPost),
nodes,
ControlGroup(
FormButton("is-primary", "Submit"),
ButtonLink(
r.Path(routenames.AdminEntityList(schema.Name)),
"is-secondary",
"Cancel",
),
),
CSRF(r),
)
}

View file

@ -0,0 +1,30 @@
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),
)
}

View file

@ -31,13 +31,13 @@ func (f *Contact) Render(r *ui.Request) Node {
Label: "Email address",
Value: f.Email,
}),
Radios(RadiosParams{
Radios(OptionsParams{
Form: f,
FormField: "Department",
Name: "department",
Label: "Department",
Value: f.Department,
Options: []Radio{
Options: []Choice{
{Value: "sales", Label: "Sales"},
{Value: "marketing", Label: "Marketing"},
{Value: "hr", Label: "HR"},

View file

@ -13,6 +13,7 @@ func Auth(r *ui.Request, content Node) Node {
return Doctype(
HTML(
Lang("en"),
Data("theme", "light"),
Head(
Metatags(r),
CSS(),

View file

@ -1,6 +1,7 @@
package layouts
import (
"github.com/mikestefanello/pagoda/ent/admin"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/cache"
@ -13,6 +14,7 @@ func Primary(r *ui.Request, content Node) Node {
return Doctype(
HTML(
Lang("en"),
Data("theme", "light"),
Head(
Metatags(r),
CSS(),
@ -30,9 +32,12 @@ func Primary(r *ui.Request, content Node) Node {
),
Div(
Class("column is-10"),
If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))),
FlashMessages(r),
content,
Div(
Class("box"),
If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))),
FlashMessages(r),
content,
),
),
),
),
@ -128,6 +133,25 @@ func search(r *ui.Request) Node {
}
func sidebarMenu(r *ui.Request) Node {
adminSubMenu := func() Node {
entityTypeNames := admin.GetEntityTypeNames()
entityTypeLinks := make(Group, len(entityTypeNames))
for _, n := range entityTypeNames {
entityTypeLinks = append(entityTypeLinks, MenuLink(r, n, routenames.AdminEntityList(n)))
}
return Group{
P(
Class("menu-label"),
Text("Entities"),
),
Ul(
Class("menu-list"),
entityTypeLinks,
),
}
}
return Aside(
Class("menu"),
HxBoost(),
@ -155,5 +179,6 @@ func sidebarMenu(r *ui.Request) Node {
If(!r.IsAuth, MenuLink(r, "Register", routenames.Register)),
If(!r.IsAuth, MenuLink(r, "Forgot password", routenames.ForgotPasswordSubmit)),
),
Iff(r.IsAdmin, adminSubMenu),
)
}

View file

@ -0,0 +1,136 @@
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, schema *load.Schema, values url.Values) error {
r := ui.NewRequest(ctx)
if values == nil {
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, schema, values),
)
}
func AdminEntityList(
ctx echo.Context,
entityTypeName string,
entityList *admin.EntityList,
) 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"),
),
),
})
}

View file

@ -28,6 +28,9 @@ type (
// IsAuth stores whether the user is authenticated.
IsAuth bool
// IsAdmin stores whether the user is an admin.
IsAdmin bool
// AuthUser stores the authenticated user.
AuthUser *ent.User
@ -77,6 +80,7 @@ func NewRequest(ctx echo.Context) *Request {
if u := ctx.Get(context.AuthenticatedUserKey); u != nil {
p.IsAuth = true
p.AuthUser = u.(*ent.User)
p.IsAdmin = p.AuthUser.Admin
}
if cfg := ctx.Get(context.ConfigKey); cfg != nil {