diff --git a/docs/docsrc/examples/examples_presets/detailing.go b/docs/docsrc/examples/examples_presets/detailing.go
index be4ea351d..d7dfbdb46 100644
--- a/docs/docsrc/examples/examples_presets/detailing.go
+++ b/docs/docsrc/examples/examples_presets/detailing.go
@@ -436,3 +436,28 @@ func PresetsDetailDisableSave(b *presets.Builder, db *gorm.DB) (
dpc.Section(sec)
return
}
+
+func PresetsDetailSaverValidation(b *presets.Builder, db *gorm.DB) (
+ cust *presets.ModelBuilder,
+ cl *presets.ListingBuilder,
+ ce *presets.EditingBuilder,
+ dp *presets.DetailingBuilder,
+) {
+ cust, cl, ce, dp = PresetsHelloWorld(b, db)
+ dp = cust.Detailing("Customer")
+ section := presets.NewSectionBuilder(cust, "Customer").Editing("Name", "Email")
+ section.WrapSaveFunc(func(in presets.SaveFunc) presets.SaveFunc {
+ return func(obj interface{}, id string, ctx *web.EventContext) (err error) {
+ ve := web.ValidationErrors{}
+ if obj.(*Customer).Name == "system" {
+ ve.GlobalError("You can not use system as name")
+ }
+ if ve.HaveErrors() {
+ return &ve
+ }
+ return in(obj, id, ctx)
+ }
+ })
+ dp.Section(section)
+ return
+}
diff --git a/docs/docsrc/examples/examples_presets/detailing_test.go b/docs/docsrc/examples/examples_presets/detailing_test.go
index 62b5ecf64..fc2c4b0cd 100644
--- a/docs/docsrc/examples/examples_presets/detailing_test.go
+++ b/docs/docsrc/examples/examples_presets/detailing_test.go
@@ -908,3 +908,28 @@ func TestPresetsDetailDisableSave(t *testing.T) {
})
}
}
+
+func TestPresetsDetailSaverValidation(t *testing.T) {
+ pb := presets.New().DataOperator(gorm2op.DataOperator(TestDB))
+ PresetsDetailSaverValidation(pb, TestDB)
+
+ cases := []multipartestutils.TestCase{
+ {
+ Name: "detail saver validation",
+ ReqFunc: func() *http.Request {
+ customPageData.TruncatePut(SqlDB)
+ return multipartestutils.NewMultipartBuilder().
+ PageURL("/customers").
+ EventFunc("section_save_Customer").
+ AddField("Name", "system").
+ BuildEventFuncRequest()
+ },
+ ExpectRunScriptContainsInOrder: []string{"You can not use system as name"},
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.Name, func(t *testing.T) {
+ multipartestutils.RunCase(t, c, pb)
+ })
+ }
+}
diff --git a/docs/docsrc/examples/examples_presets/editing.go b/docs/docsrc/examples/examples_presets/editing.go
index 078b22642..f66ec17e1 100644
--- a/docs/docsrc/examples/examples_presets/editing.go
+++ b/docs/docsrc/examples/examples_presets/editing.go
@@ -376,3 +376,24 @@ func PresetsEditingSection(b *presets.Builder, db *gorm.DB) (
edit.Section(section.Clone())
return
}
+
+func PresetsEditingSaverValidation(b *presets.Builder, db *gorm.DB) (mb *presets.ModelBuilder,
+ cl *presets.ListingBuilder,
+ ce *presets.EditingBuilder,
+ dp *presets.DetailingBuilder,
+) {
+ b.DataOperator(gorm2op.DataOperator(db))
+ db.AutoMigrate(&Company{})
+ mb = b.Model(&Company{})
+ mb.Editing().SaveFunc(func(obj interface{}, id string, ctx *web.EventContext) (err error) {
+ ve := web.ValidationErrors{}
+ if obj.(*Company).Name == "system" {
+ ve.FieldError("Name", "You can not use system as name")
+ }
+ if ve.HaveErrors() {
+ return &ve
+ }
+ return nil
+ })
+ return
+}
diff --git a/docs/docsrc/examples/examples_presets/editing_test.go b/docs/docsrc/examples/examples_presets/editing_test.go
index a321341ae..bd5d199ce 100644
--- a/docs/docsrc/examples/examples_presets/editing_test.go
+++ b/docs/docsrc/examples/examples_presets/editing_test.go
@@ -522,3 +522,27 @@ func TestCreateHTMLSanitizerSetterFunc(t *testing.T) {
t.Errorf("Expected %q, got %q", expected, customer.HTMLSanitizerPolicyTiptapInput)
}
}
+
+func TestPresetsEditingSaver(t *testing.T) {
+ pb := presets.New().DataOperator(gorm2op.DataOperator(TestDB))
+ PresetsEditingSaverValidation(pb, TestDB)
+
+ cases := []multipartestutils.TestCase{
+ {
+ Name: "saver return error",
+ ReqFunc: func() *http.Request {
+ return multipartestutils.NewMultipartBuilder().
+ PageURL("/companies?__execute_event__=presets_Update").
+ AddField("Name", "system").
+ BuildEventFuncRequest()
+ },
+ ExpectPortalUpdate0ContainsInOrder: []string{`You can not use system as name`},
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.Name, func(t *testing.T) {
+ multipartestutils.RunCase(t, c, pb)
+ })
+ }
+}
diff --git a/docs/docsrc/examples/examples_presets/listing.go b/docs/docsrc/examples/examples_presets/listing.go
index 9907007d6..f9f653cb1 100644
--- a/docs/docsrc/examples/examples_presets/listing.go
+++ b/docs/docsrc/examples/examples_presets/listing.go
@@ -14,6 +14,7 @@ import (
"gorm.io/gorm"
"github.com/qor5/x/v3/i18n"
+ "github.com/qor5/x/v3/statusx"
v "github.com/qor5/x/v3/ui/vuetify"
"github.com/qor5/x/v3/ui/vuetifyx"
@@ -480,3 +481,71 @@ func PresetsListingDatatableFunc(b *presets.Builder, db *gorm.DB) (
}
// @snippet_end
+
+// @snippet_begin(PresetsListingFilterNotificationFuncSample)
+
+func PresetsListingFilterNotificationFunc(b *presets.Builder, db *gorm.DB) (
+ mb *presets.ModelBuilder,
+ cl *presets.ListingBuilder,
+ ce *presets.EditingBuilder,
+ dp *presets.DetailingBuilder,
+) {
+ b.DataOperator(gorm2op.DataOperator(db))
+ err := db.AutoMigrate(&Customer{})
+ if err != nil {
+ panic(err)
+ }
+ mb = b.Model(&Customer{})
+ mb.Listing().FilterNotificationFunc(func(_ *web.EventContext) h.HTMLComponent {
+ return h.Div().Text("Filter Notification").Class(fmt.Sprintf("text-%s", v.ColorWarning))
+ })
+ return
+}
+
+// @snippet_end
+type mockGRPCDataOperatorWrapper struct {
+ next presets.DataOperator
+}
+
+func mockGRPCDataOperator(next presets.DataOperator) presets.DataOperator {
+ return &mockGRPCDataOperatorWrapper{next: next}
+}
+
+func (w *mockGRPCDataOperatorWrapper) Search(eventCtx *web.EventContext, params *presets.SearchParams) (*presets.SearchResult, error) {
+ return w.next.Search(eventCtx, params)
+}
+
+func (w *mockGRPCDataOperatorWrapper) Fetch(obj any, id string, eventCtx *web.EventContext) (any, error) {
+ return w.next.Fetch(obj, id, eventCtx)
+}
+
+func (w *mockGRPCDataOperatorWrapper) Save(obj any, id string, eventCtx *web.EventContext) error {
+ var fvs statusx.FieldViolations
+ p := obj.(*Customer)
+ if p.Name == "system" {
+ fvs = append(fvs, statusx.NewFieldViolation("Name", "name can`t set system", "name can`t set system"))
+ return statusx.BadRequest(fvs).Err()
+ }
+ return nil
+}
+
+func (w *mockGRPCDataOperatorWrapper) Delete(obj any, id string, eventCtx *web.EventContext) error {
+ return w.next.Delete(obj, id, eventCtx)
+}
+
+func PresetsDataOperatorWithGRPC(b *presets.Builder, db *gorm.DB) (
+ mb *presets.ModelBuilder,
+ cl *presets.ListingBuilder,
+ ce *presets.EditingBuilder,
+ dp *presets.DetailingBuilder,
+) {
+ b.DataOperator(presets.DataOperatorWithGRPC(mockGRPCDataOperator(gorm2op.DataOperator(db))))
+ err := db.AutoMigrate(&Customer{})
+ if err != nil {
+ panic(err)
+ }
+ mb = b.Model(&Customer{})
+ cl = mb.Listing()
+ ce = mb.Editing()
+ return
+}
diff --git a/docs/docsrc/examples/examples_presets/listing_test.go b/docs/docsrc/examples/examples_presets/listing_test.go
index fbe897ea0..dbf76841f 100644
--- a/docs/docsrc/examples/examples_presets/listing_test.go
+++ b/docs/docsrc/examples/examples_presets/listing_test.go
@@ -9,6 +9,7 @@ import (
"github.com/theplant/gofixtures"
"github.com/qor5/admin/v3/presets"
+ "github.com/qor5/admin/v3/presets/actions"
"github.com/qor5/admin/v3/presets/gorm2op"
)
@@ -171,3 +172,78 @@ func TestPresetsListingDatatableFunc(t *testing.T) {
})
}
}
+
+func TestPresetsListingFilterNotificationFunc(t *testing.T) {
+ pb := presets.New().DataOperator(gorm2op.DataOperator(TestDB))
+ PresetsListingFilterNotificationFunc(pb, TestDB)
+ cases := []multipartestutils.TestCase{
+ {
+ Name: "Filter Notification",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ listingDatatableData.TruncatePut(SqlDB)
+ return multipartestutils.NewMultipartBuilder().
+ PageURL("/customers").
+ BuildEventFuncRequest()
+ },
+ ExpectPageBodyContainsInOrder: []string{`Filter Notification`},
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.Name, func(t *testing.T) {
+ multipartestutils.RunCase(t, c, pb)
+ })
+ }
+}
+
+func TestPresetsDataOperatorWithGRPC(t *testing.T) {
+ pb := presets.New().DataOperator(gorm2op.DataOperator(TestDB))
+ PresetsDataOperatorWithGRPC(pb, TestDB)
+ cases := []multipartestutils.TestCase{
+ {
+ Name: "Index Customer",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ listingDatatableData.TruncatePut(SqlDB)
+ return multipartestutils.NewMultipartBuilder().
+ PageURL("/customers").
+ BuildEventFuncRequest()
+ },
+ ExpectPageBodyContainsInOrder: []string{`v-card`, `Felix 1`, `abc@example.com`},
+ },
+ {
+ Name: "Update Customer",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ listingDatatableData.TruncatePut(SqlDB)
+ return multipartestutils.NewMultipartBuilder().
+ PageURL("/customers").
+ Query(presets.ParamID, "12").
+ EventFunc(actions.Update).
+ AddField("Name", "system").
+ BuildEventFuncRequest()
+ },
+ ExpectPortalUpdate0ContainsInOrder: []string{"invalid argument"},
+ },
+ {
+ Name: "Delete Customer",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ listingDatatableData.TruncatePut(SqlDB)
+ return multipartestutils.NewMultipartBuilder().
+ PageURL("/customers").
+ Query(presets.ParamID, "12").
+ EventFunc(actions.DoDelete).
+ BuildEventFuncRequest()
+ },
+ ExpectRunScriptContainsInOrder: []string{"PresetsNotifModelsDeletedexamplesPresetsCustomer"},
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.Name, func(t *testing.T) {
+ multipartestutils.RunCase(t, c, pb)
+ })
+ }
+}
diff --git a/docs/docsrc/examples/examples_presets/menu_test.go b/docs/docsrc/examples/examples_presets/menu_test.go
index a40581b34..c78c9f115 100644
--- a/docs/docsrc/examples/examples_presets/menu_test.go
+++ b/docs/docsrc/examples/examples_presets/menu_test.go
@@ -67,7 +67,7 @@ func TestPresetsPresetsMenuComponent(t *testing.T) {
cases := []multipartestutils.TestCase{
{
Name: "multiple openStrategy",
- Debug: false,
+ Debug: true,
ReqFunc: func() *http.Request {
return multipartestutils.NewMultipartBuilder().
PageURL("/presets-menu-component/books").
diff --git a/docs/docsrc/examples/examples_presets/mux.go b/docs/docsrc/examples/examples_presets/mux.go
index b3b49d70b..e74f26927 100644
--- a/docs/docsrc/examples/examples_presets/mux.go
+++ b/docs/docsrc/examples/examples_presets/mux.go
@@ -47,8 +47,10 @@ func SamplesHandler(mux examples.Muxer) {
addExample(mux, db, PresetsEditingValidate)
addExample(mux, db, PresetsEditingSetter)
addExample(mux, db, PresetsEditingSection)
+ addExample(mux, db, PresetsEditingSaverValidation)
addExample(mux, db, PresetsListingCustomizationSearcher)
addExample(mux, db, PresetsListingDatatableFunc)
+ addExample(mux, db, PresetsListingFilterNotificationFunc)
addExample(mux, db, PresetsDetailInlineEditDetails)
addExample(mux, db, PresetsDetailSectionView)
addExample(mux, db, PresetsDetailTabsSection)
@@ -65,6 +67,8 @@ func SamplesHandler(mux examples.Muxer) {
addExample(mux, db, PresetsCustomPage)
addExample(mux, db, PresetsPlainNestedField)
addExample(mux, db, PresetsDetailDisableSave)
+ addExample(mux, db, PresetsDetailSaverValidation)
+ addExample(mux, db, PresetsDataOperatorWithGRPC)
return
}
diff --git a/example/integration/login_test.go b/example/integration/login_test.go
index 820ba3901..3e8df7c78 100644
--- a/example/integration/login_test.go
+++ b/example/integration/login_test.go
@@ -24,7 +24,7 @@ func TestLogin(t *testing.T) {
cases := []multipartestutils.TestCase{
{
Name: "view by en",
- Debug: false,
+ Debug: true,
ReqFunc: func() *http.Request {
req := multipartestutils.NewMultipartBuilder().
PageURL("/auth/login").
@@ -36,7 +36,7 @@ func TestLogin(t *testing.T) {
},
{
Name: "view by zh",
- Debug: false,
+ Debug: true,
ReqFunc: func() *http.Request {
req := multipartestutils.NewMultipartBuilder().
PageURL("/auth/login").
@@ -48,7 +48,7 @@ func TestLogin(t *testing.T) {
},
{
Name: "view by ja",
- Debug: false,
+ Debug: true,
ReqFunc: func() *http.Request {
req := multipartestutils.NewMultipartBuilder().
PageURL("/auth/login").
@@ -60,7 +60,7 @@ func TestLogin(t *testing.T) {
},
{
Name: "view by en (customized)",
- Debug: false,
+ Debug: true,
HandlerMaker: func() http.Handler {
mux := http.NewServeMux()
c := admin.NewConfig(TestDB, false)
diff --git a/go.mod b/go.mod
index 828f6b2d4..ae853936d 100644
--- a/go.mod
+++ b/go.mod
@@ -10,6 +10,7 @@ require (
github.com/fatih/color v1.17.0
github.com/go-chi/chi/v5 v5.2.2
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1
+ github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/gosimple/slug v1.14.0
@@ -21,17 +22,18 @@ require (
github.com/lib/pq v1.10.9
github.com/manifoldco/promptui v0.9.0
github.com/markbates/goth v1.80.0
- github.com/mholt/archiver/v4 v4.0.0-alpha.8
+ github.com/mholt/archiver/v4 v4.0.0-alpha.9
github.com/microcosm-cc/bluemonday v1.0.27
github.com/orcaman/concurrent-map/v2 v2.0.1
github.com/ory/ladon v1.3.0
github.com/oschwald/geoip2-golang v1.11.0
github.com/pkg/errors v0.9.1
github.com/pquerna/otp v1.4.0
+ github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54
github.com/qor5/imaging v1.6.4
github.com/qor5/web v1.3.2
github.com/qor5/web/v3 v3.0.12-0.20250618085230-3764d0e521a8
- github.com/qor5/x/v3 v3.1.3-0.20251013064705-2b80bfbbe517
+ github.com/qor5/x/v3 v3.1.3-0.20251021020244-c33f2868c60b
github.com/samber/lo v1.50.0
github.com/shurcooL/sanitized_anchor_name v1.0.0
github.com/spf13/cast v1.7.1
@@ -42,6 +44,7 @@ require (
github.com/theplant/docgo v0.0.16
github.com/theplant/gofixtures v1.1.3
github.com/theplant/htmlgo v1.0.3
+ github.com/theplant/inject v1.1.0
github.com/theplant/osenv v0.0.2
github.com/theplant/relay v0.4.3-0.20250424075128-61850ded6ace
github.com/theplant/sliceutils v0.0.0-20200406042209-89153d988eb1
@@ -55,22 +58,28 @@ require (
go.uber.org/zap v1.27.0
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
golang.org/x/text v0.28.0
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c
+ google.golang.org/grpc v1.75.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.30.1
)
require (
- cloud.google.com/go/compute/metadata v0.7.0 // indirect
+ cloud.google.com/go/compute/metadata v0.8.0 // indirect
+ connectrpc.com/connect v1.18.1 // indirect
+ connectrpc.com/cors v0.1.0 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/NYTimes/gziphandler v1.1.1 // indirect
- github.com/andybalholm/brotli v1.1.0 // indirect
+ github.com/STARRY-S/zip v0.1.0 // indirect
+ github.com/andybalholm/brotli v1.1.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.6 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.59 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
@@ -85,7 +94,7 @@ require (
github.com/aws/smithy-go v1.22.5 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
- github.com/bodgit/sevenzip v1.5.1 // indirect
+ github.com/bodgit/sevenzip v1.5.2 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/boombuler/barcode v1.0.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
@@ -103,34 +112,49 @@ require (
github.com/docker/docker v28.3.3+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
- github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
+ github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
+ github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/fsnotify/fsnotify v1.8.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+ github.com/go-kit/kit v0.12.1-0.20220826005032-a7ba4fa4e289 // indirect
+ github.com/go-kit/log v0.2.0 // indirect
+ github.com/go-logfmt/logfmt v0.5.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/form v3.1.4+incompatible // indirect
github.com/go-playground/form/v4 v4.2.1 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.25.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
- github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
+ github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.3.0 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
- github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect
+ github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
+ github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.21.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+ github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.5 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/now v1.1.5 // indirect
+ github.com/jjeffery/errors v1.0.3 // indirect
+ github.com/jjeffery/kv v0.8.1 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/markbates/going v1.0.3 // indirect
@@ -138,6 +162,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/mdelapenya/tlscert v0.2.0 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
@@ -153,11 +178,15 @@ require (
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/ory/pagination v0.0.1 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
- github.com/pierrec/lz4/v4 v4.1.21 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.3 // indirect
+ github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/redis/go-redis/v9 v9.11.0 // indirect
+ github.com/rs/cors v1.11.1 // indirect
github.com/russross/blackfriday v1.6.0 // indirect
+ github.com/sagikazarmark/locafero v0.6.0 // indirect
+ github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sergi/go-diff v1.3.1 // indirect
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 // indirect
@@ -165,12 +194,19 @@ require (
github.com/shurcooL/highlight_go v0.0.0-20230708025100-33e05792540a // indirect
github.com/shurcooL/octicon v0.0.0-20230705024016-66bff059edb8 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
+ github.com/sorairolake/lzip-go v0.3.5 // indirect
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect
+ github.com/sourcegraph/conc v0.3.0 // indirect
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
+ github.com/spf13/afero v1.11.0 // indirect
+ github.com/spf13/pflag v1.0.6 // indirect
+ github.com/spf13/viper v1.19.0 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
github.com/testcontainers/testcontainers-go v0.38.0 // indirect
github.com/testcontainers/testcontainers-go/modules/redis v0.38.0 // indirect
- github.com/theplant/inject v1.0.2 // indirect
+ github.com/theplant/appkit v0.0.0-20250528023215-3d0d299dc4c6 // indirect
+ github.com/theplant/validator v0.0.0-20210202101755-357a9daa8f5f // indirect
github.com/therootcompany/xz v1.0.1 // indirect
github.com/tidwall/gjson v1.17.3 // indirect
github.com/tidwall/match v1.1.1 // indirect
@@ -194,8 +230,9 @@ require (
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/time v0.12.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect
+ google.golang.org/protobuf v1.36.8 // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 2d01e5dff..a0e504db3 100644
--- a/go.sum
+++ b/go.sum
@@ -9,13 +9,17 @@ cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6T
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
-cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
-cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
+cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
+cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
+connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw=
+connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8=
+connectrpc.com/cors v0.1.0 h1:f3gTXJyDZPrDIZCQ567jxfD9PAIpopHiRDnJRt3QuOQ=
+connectrpc.com/cors v0.1.0/go.mod h1:v8SJZCPfHtGH1zsm+Ttajpozd4cYIUryl4dFB6QEpfg=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
@@ -31,10 +35,12 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
+github.com/STARRY-S/zip v0.1.0 h1:eUER3jKmHKXjv+iy3BekLa+QnNSo1Lqz4eTzYBcGDqo=
+github.com/STARRY-S/zip v0.1.0/go.mod h1:qj/mTZkvb3AvfGQ2e775/3AODRvB4peSw8KNMvrM8/I=
github.com/ahmetb/go-linq/v3 v3.2.0 h1:BEuMfp+b59io8g5wYzNoFe9pWPalRklhlhbiU3hYZDE=
github.com/ahmetb/go-linq/v3 v3.2.0/go.mod h1:haQ3JfOeWK8HpVxMtHHEMPVgBKiYyQ+f1/kLZh/cj9U=
-github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
-github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
+github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/aws/aws-sdk-go-v2 v1.38.1 h1:j7sc33amE74Rz0M/PoCpsZQ6OunLqys/m5antM0J+Z8=
@@ -47,6 +53,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.17.59 h1:9btwmrt//Q6JcSdgJOLI98sdr5p
github.com/aws/aws-sdk-go-v2/credentials v1.17.59/go.mod h1:NM8fM6ovI3zak23UISdWidyZuI1ghNe2xjzUZAyT+08=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 h1:KwsodFKVQTlI5EyhRSugALzsV6mG/SGrdjlMXSZSdso=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28/go.mod h1:EY3APf9MzygVhKuPXAc5H+MkGb8k/DOSQjWS0LgkKqI=
+github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.4 h1:2RIi889b7VHUULrQXbB5RcNvN9JZ1VJZPAOG2FJJ6YU=
+github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.4/go.mod h1:2oLW5huI9B5XV6ycns7nRLeRJtue48ZB5kZ5ZRL1HSU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
@@ -75,10 +83,11 @@ github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
-github.com/bodgit/sevenzip v1.5.1 h1:rVj0baZsooZFy64DJN0zQogPzhPrT8BQ8TTRd1H4WHw=
-github.com/bodgit/sevenzip v1.5.1/go.mod h1:Q3YMySuVWq6pyGEolyIE98828lOfEoeWg5zeH6x22rc=
+github.com/bodgit/sevenzip v1.5.2 h1:acMIYRaqoHAdeu9LhEGGjL9UzBD4RNf9z7+kWDNignI=
+github.com/bodgit/sevenzip v1.5.2/go.mod h1:gTGzXA67Yko6/HLSD0iK4kWaWzPlPmLfDO73jTjSRqc=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -103,6 +112,7 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -131,15 +141,21 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
-github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
-github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
+github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
+github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
+github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
+github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
+github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -147,10 +163,22 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
+github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
+github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-kit/kit v0.12.1-0.20220826005032-a7ba4fa4e289 h1:468Nv6YtYO38Z+pFL6fRSVILGfdTFPei9ksVneiUHUc=
+github.com/go-kit/kit v0.12.1-0.20220826005032-a7ba4fa4e289/go.mod h1:phqEHMMUbyrCFCTgH48JueqrM3md2HcAZ8N3XE4FKDg=
+github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
+github.com/go-kit/log v0.2.0 h1:7i2K3eKTos3Vc0enKCfnVcgHh2olr/MyfboYq7cAcFw=
+github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
+github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
+github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
+github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -159,12 +187,20 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
-github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/form v3.1.4+incompatible h1:lvKiHVxE2WvzDIoyMnWcjyiBxKt2+uFJyZcPYWsLnjI=
github.com/go-playground/form v3.1.4+incompatible/go.mod h1:lhcKXfTuhRtIZCIKUeJ0b5F207aeQCPbZU09ScKjwWg=
github.com/go-playground/form/v4 v4.2.1 h1:HjdRDKO0fftVMU5epjPW2SOREcZ6/wLUzEobqUGJuPw=
github.com/go-playground/form/v4 v4.2.1/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
+github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ=
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@@ -188,6 +224,8 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
@@ -224,8 +262,12 @@ github.com/gosimple/slug v1.14.0 h1:RtTL/71mJNDfpUbCOmnf/XFkzKRtD6wL6Uy+3akm4Es=
github.com/gosimple/slug v1.14.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
+github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
+github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
+github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0=
+github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.21.0 h1:CWyXh/jylQWp2dtiV33mY4iSSp6yf4lmn+c7/tN+ObI=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.21.0/go.mod h1:nCLIt0w3Ept2NwF8ThLmrppXsfT07oC8k0XNDxd8sVU=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -238,6 +280,8 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c=
github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U=
@@ -260,6 +304,10 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/jjeffery/errors v1.0.3 h1:oM8cLCCNP8ccKKpn7tbii4AW5hqZsr2pvEXxvwVoBUs=
+github.com/jjeffery/errors v1.0.3/go.mod h1:K2Ea6XpV2ki89b7/nPp5DVHqMuTvcbv6q95/XkzvXx8=
+github.com/jjeffery/kv v0.8.1 h1:S4/KbfPVNTGM/YCt5F+Za93o09119VNtOT+rLL4Gls0=
+github.com/jjeffery/kv v0.8.1/go.mod h1:iHA3uy+umBqxcJFr+e+gaGAv1OcyHlU6rSo3TcR61yQ=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
@@ -272,6 +320,7 @@ github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYW
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -279,6 +328,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
@@ -301,10 +352,12 @@ github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJK
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
-github.com/mholt/archiver/v4 v4.0.0-alpha.8 h1:tRGQuDVPh66WCOelqe6LIGh0gwmfwxUrSSDunscGsRM=
-github.com/mholt/archiver/v4 v4.0.0-alpha.8/go.mod h1:5f7FUYGXdJWUjESffJaYR4R60VhnHxb2X3T1teMyv5A=
+github.com/mholt/archiver/v4 v4.0.0-alpha.9 h1:EZgAsW6DsuawxDgTtIdjCUBa2TQ6AOe9pnCidofSRtE=
+github.com/mholt/archiver/v4 v4.0.0-alpha.9/go.mod h1:5D3uct315OMkMRXKwEuMB+wQi/2m5NQngKDmApqwVlo=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
@@ -337,6 +390,7 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c=
github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM=
github.com/ory/ladon v1.3.0 h1:35Rc3O8d+mhFWxzmKs6Qj/ETQEHGEI5BmWQf8wtqFHk=
@@ -349,8 +403,11 @@ github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnY
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw=
github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
-github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
-github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
+github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
+github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
+github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -361,24 +418,34 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:Om
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54 h1:sO/saPkFgwfLaiCVTg+e9x6lAYBrqY91h1REu4NLMm0=
+github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54/go.mod h1:03dPo1SHYn9sU57mH67Y1p9FIcglWaHr4i/xkeYmX4o=
github.com/qor5/imaging v1.6.4 h1:6VNy1EnFKAns8e73uzsZa6UQDw0oekvUHui1oeydy78=
github.com/qor5/imaging v1.6.4/go.mod h1:i1E3GRDbTFbXuzZtm1/8zWu+lDbDw6O1hCmjtyQFnR4=
github.com/qor5/web v1.3.2 h1:zw796YJeDLe8vRwGR1cM+uS1ZuSkPutchBEXv2GgOhI=
github.com/qor5/web v1.3.2/go.mod h1:LszskQJbFQDJwOeZC6j6afOiHxxyjrzz8B3zuBwfgKQ=
github.com/qor5/web/v3 v3.0.12-0.20250618085230-3764d0e521a8 h1:s3jBS5bq6VX56GicRPCeXvb0TRLSz5w1xJHymPnhTuo=
github.com/qor5/web/v3 v3.0.12-0.20250618085230-3764d0e521a8/go.mod h1:hrhZ4nc1U+AOBrGmnUoRUPpA9fymxlAbNfGvn9TJLns=
-github.com/qor5/x/v3 v3.1.3-0.20251013064705-2b80bfbbe517 h1:YuZ/ttBTtGf6W4kDQ8fujWY+kCdAB8A5mShbBTWOQOg=
-github.com/qor5/x/v3 v3.1.3-0.20251013064705-2b80bfbbe517/go.mod h1:UugTZhkbyUdlYaGAvSUsRXrCgt/j3qoTWxbJV11tK3E=
+github.com/qor5/x/v3 v3.1.3-0.20251016020953-4d227d199456 h1:BTwsRW2eb/lHBC31xhzbC48hVTSVgUShLs3f1YtPiVQ=
+github.com/qor5/x/v3 v3.1.3-0.20251016020953-4d227d199456/go.mod h1:ZtfomAwdcec3E0CNM7oSLNLJRRkTa2OsND7bmJl5Kzg=
+github.com/qor5/x/v3 v3.1.3-0.20251021020244-c33f2868c60b h1:apfqd+AQQHNzUx34jZQ2zxhV05/z+q/ftqB1DivKok0=
+github.com/qor5/x/v3 v3.1.3-0.20251021020244-c33f2868c60b/go.mod h1:63Xf2S/u3kCH/ByS8Q+XCJOAAv8CvFO2GtMOO7kHITk=
github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
+github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
+github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk=
+github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0=
+github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
+github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
github.com/serenize/snaker v0.0.0-20171204205717-a683aaf2d516/go.mod h1:Yow6lPLSAXx2ifx470yD/nUe22Dv5vBvxK/UK9UUTVs=
@@ -400,21 +467,34 @@ github.com/shurcooL/octicon v0.0.0-20230705024016-66bff059edb8 h1:W5meM/5DP0Igf+
github.com/shurcooL/octicon v0.0.0-20230705024016-66bff059edb8/go.mod h1:hWBWTvIJ918VxbNOk2hxQg1/5j1M9yQI1Kp8d9qrOq8=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/sorairolake/lzip-go v0.3.5 h1:ms5Xri9o1JBIWvOFAorYtUNik6HI3HgBTkISiqu0Cwg=
+github.com/sorairolake/lzip-go v0.3.5/go.mod h1:N0KYq5iWrMXI0ZEXKXaS9hCyOjZUQdBDEIbXfoUwbdk=
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d h1:yKm7XZV6j9Ev6lojP2XaIshpT4ymkqhMeSghO5Ps00E=
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e h1:qpG93cPwA5f7s/ZPBJnGOYQNK/vKsaDaseuKT5Asee8=
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
+github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
+github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -423,6 +503,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/sunfmin/reflectutils v1.0.6 h1:tX1ecTgYLsv2F8iBO2JL3BY2AdMtyFz/ir3ip+njFNs=
github.com/sunfmin/reflectutils v1.0.6/go.mod h1:ao2bbF4RZrTe2PboJKdZoC3BA71gdU6rFkCuUjoeqMw=
github.com/sunfmin/snippetgo v0.0.3 h1:pMCpFCyW2fYHhfLp4tb5ccvTCpIuSNFomtDCr9duUaE=
@@ -431,6 +513,8 @@ github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxd
github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w=
github.com/testcontainers/testcontainers-go/modules/redis v0.38.0 h1:289pn0BFmGqDrd6BrImZAprFef9aaPZacx07YOQaPV4=
github.com/testcontainers/testcontainers-go/modules/redis v0.38.0/go.mod h1:EcKPWRzOglnQfYe+ekA8RPEIWSNJTGwaC5oE5bQV+D0=
+github.com/theplant/appkit v0.0.0-20250528023215-3d0d299dc4c6 h1:LQEFJ4e9i7Yyv0Drk2/rZOakxzFWATFwpcjwW8E2wuU=
+github.com/theplant/appkit v0.0.0-20250528023215-3d0d299dc4c6/go.mod h1:Eg9VHkTQzjBVksZIMq9SkmbOcrCiOm4objkq0LhH+Io=
github.com/theplant/bimg v1.1.1 h1:97KW0oDbGt8d3K7vu2rgM88gT/+beWzTu0ZwtlqcxwE=
github.com/theplant/bimg v1.1.1/go.mod h1:H0qlp9lKZoOO4akI0VxEtxO8lgLBLNuhF/+2U00LSPg=
github.com/theplant/docgo v0.0.16 h1:6K6IHfeQ0sx9k7b1h9L6RV2pD0SiN8TAQm1RNLYezgU=
@@ -441,8 +525,8 @@ github.com/theplant/htmlgo v1.0.3 h1:G7/YSf8OrOIRHVQ13avd78T/GV1kDl/jMwpQURrXB0o
github.com/theplant/htmlgo v1.0.3/go.mod h1:pCKSFJsoVNkyW+yN2i1Mst+8130NSQzIU7L2IbnuyKg=
github.com/theplant/htmltestingutils v0.0.0-20190423050759-0e06de7b6967 h1:yPrgtU8bj7Q/XbXgjjmngZtOhsUufBAraruNwxv/eXM=
github.com/theplant/htmltestingutils v0.0.0-20190423050759-0e06de7b6967/go.mod h1:86iN4EAYaQbx1VTW5uPslTIviRkYH8CzslMC//g+BgY=
-github.com/theplant/inject v1.0.2 h1:JXN2C/bAWJ9COG53WGoM9npJyYpwufYTHop+x2abDaQ=
-github.com/theplant/inject v1.0.2/go.mod h1:vuuYp7lKifex8HfI4izobKSqulfhpWIvGvW2/pP4Rqo=
+github.com/theplant/inject v1.1.0 h1:Bxiu4rXQN0BJSwNHAMPszkiySmozPTYZvbhaOKJr+lg=
+github.com/theplant/inject v1.1.0/go.mod h1:vuuYp7lKifex8HfI4izobKSqulfhpWIvGvW2/pP4Rqo=
github.com/theplant/osenv v0.0.2 h1:SI2I/gLQQj5pQgpBQ8YKx/u4i7KE7yG2Gmr/ZORuxn8=
github.com/theplant/osenv v0.0.2/go.mod h1:gUdlLzvmJb/dyBmXk+qEWiIhAN1tmVhYktzK1HHEz3c=
github.com/theplant/relay v0.4.3-0.20250424075128-61850ded6ace h1:2Vxx6Wh7d8G4S2gY6Z+kaqL/YirmLGX6p4g/2ziaVAg=
@@ -453,6 +537,8 @@ github.com/theplant/testenv v0.2.1 h1:GL80bSN7GHs4tl98W1ufdd2YcQ0BkHncWSgsOZsNY6
github.com/theplant/testenv v0.2.1/go.mod h1:/Eq/353mtHC7t1VzGpZ/dzGI/YQ5QN1kZMB0+5GqCn4=
github.com/theplant/testingutils v0.0.2 h1:ryFb7J8NPnyMA4mdgBEf5ha3QUqWA9WVulWGyUbH2u4=
github.com/theplant/testingutils v0.0.2/go.mod h1:nh7wj3YTJehg0PBHnPXtvqIqdnBUn0Gqb79JHnblFuc=
+github.com/theplant/validator v0.0.0-20210202101755-357a9daa8f5f h1:VQbZHMv9xuUihaRESflntL8VIi/cI6nVaP6a+F78BkU=
+github.com/theplant/validator v0.0.0-20210202101755-357a9daa8f5f/go.mod h1:Gy7nm6qbfZJXKLRga/Gbb+zca34+j4eKVgPvK+TeRHU=
github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw=
github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -480,6 +566,8 @@ github.com/wI2L/jsondiff v0.6.0 h1:zrsH3FbfVa3JO9llxrcDy/XLkYPLgoMX6Mz3T2PP2AI=
github.com/wI2L/jsondiff v0.6.0/go.mod h1:D6aQ5gKgPF9g17j+E9N7aasmU1O+XvfmWm1y8UMmNpw=
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts=
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -511,10 +599,14 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
@@ -618,9 +710,11 @@ golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -635,6 +729,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -693,6 +788,7 @@ golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgw
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -712,6 +808,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
@@ -738,17 +836,20 @@ google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvx
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a h1:DMCgtIAIQGZqJXMVzJF4MV8BlWoJh2ZuFiRdAleyr58=
-google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a/go.mod h1:y2yVLIE/CSMCPXaHnSKXxu1spLPnglFLegmgdY23uuE=
+google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ=
+google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
@@ -762,13 +863,17 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
diff --git a/microsite/microsite.go b/microsite/microsite.go
index e55895b74..89660a9de 100644
--- a/microsite/microsite.go
+++ b/microsite/microsite.go
@@ -3,6 +3,7 @@ package microsite
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"path"
@@ -176,9 +177,9 @@ func (this *MicroSite) GetUnPublishActions(ctx context.Context, db *gorm.DB, sto
}
func (*MicroSite) UnArchiveAndPublish(getPath func(string) string, fileName string, f io.Reader, storage oss.StorageInterface) (filesList []string, err error) {
- format, reader, err := archiver.Identify(fileName, f)
+ format, reader, err := archiver.Identify(context.Background(), fileName, f)
if err != nil {
- if err == archiver.ErrNoMatch {
+ if errors.Is(err, archiver.NoMatch) {
err = utils.Upload(storage, getPath(fileName), f)
return
}
@@ -189,19 +190,21 @@ func (*MicroSite) UnArchiveAndPublish(getPath func(string) string, fileName stri
var putError error
var mutex sync.Mutex
- err = format.(archiver.Extractor).Extract(context.Background(), reader, nil, func(ctx context.Context, f archiver.File) (err error) {
- if f.IsDir() || strings.Contains(f.NameInArchive, "__MACOSX") || strings.Contains(f.NameInArchive, "DS_Store") {
+ err = format.(archiver.Extractor).Extract(context.Background(), reader, func(ctx context.Context, info archiver.FileInfo) (err error) {
+ if info.IsDir() || strings.Contains(info.NameInArchive, "__MACOSX") || strings.Contains(info.NameInArchive, "DS_Store") {
return
}
- rc, err := f.Open()
+ rc, err := info.Open()
if err != nil {
return
}
- filesList = append(filesList, f.NameInArchive)
+ mutex.Lock()
+ filesList = append(filesList, info.NameInArchive)
+ mutex.Unlock()
- publishedPath := getPath(f.NameInArchive)
+ publishedPath := getPath(info.NameInArchive)
wg.Add(1)
putSemaphore <- struct{}{}
go func() {
diff --git a/presets/api.go b/presets/api.go
index c17464cf4..2f2ea36e7 100644
--- a/presets/api.go
+++ b/presets/api.go
@@ -98,6 +98,8 @@ type SlugEncoder interface {
type FilterDataFunc func(ctx *web.EventContext) vuetifyx.FilterData
+type FilterNotificationFunc func(ctx *web.EventContext) h.HTMLComponent
+
type FilterTab struct {
ID string
Label string
diff --git a/presets/data_operator.go b/presets/data_operator.go
new file mode 100644
index 000000000..a2758b6e6
--- /dev/null
+++ b/presets/data_operator.go
@@ -0,0 +1,105 @@
+package presets
+
+import (
+ "cmp"
+
+ "github.com/pkg/errors"
+ "github.com/qor5/web/v3"
+ "github.com/qor5/x/v3/i18n"
+ "github.com/qor5/x/v3/i18nx"
+ "github.com/qor5/x/v3/statusx"
+ "github.com/samber/lo"
+ "golang.org/x/text/language"
+ "google.golang.org/genproto/googleapis/rpc/errdetails"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+)
+
+// DataOperatorWithGRPC returns a hook that handles gRPC-related concerns for
+// DataOperator: outgoing metadata injection and error conversion.
+type grpcWrapper struct {
+ next DataOperator
+}
+
+func DataOperatorWithGRPC(next DataOperator) DataOperator {
+ return &grpcWrapper{next: next}
+}
+
+func (w *grpcWrapper) Search(eventCtx *web.EventContext, params *SearchParams) (*SearchResult, error) {
+ w.inject(eventCtx)
+ res, err := w.next.Search(eventCtx, params)
+ if err != nil {
+ return nil, w.convert(err)
+ }
+ return res, nil
+}
+
+func (w *grpcWrapper) Fetch(obj any, id string, eventCtx *web.EventContext) (any, error) {
+ w.inject(eventCtx)
+ res, err := w.next.Fetch(obj, id, eventCtx)
+ if err != nil {
+ return nil, w.convert(err)
+ }
+ return res, nil
+}
+
+func (w *grpcWrapper) Save(obj any, id string, eventCtx *web.EventContext) error {
+ w.inject(eventCtx)
+ if err := w.next.Save(obj, id, eventCtx); err != nil {
+ return w.convert(err)
+ }
+ return nil
+}
+
+func (w *grpcWrapper) Delete(obj any, id string, eventCtx *web.EventContext) error {
+ w.inject(eventCtx)
+ if err := w.next.Delete(obj, id, eventCtx); err != nil {
+ return w.convert(err)
+ }
+ return nil
+}
+
+func (w *grpcWrapper) inject(eventCtx *web.EventContext) {
+ ctx := eventCtx.R.Context()
+ ctx = metadata.AppendToOutgoingContext(
+ ctx,
+ i18nx.HeaderSelectedLanguage,
+ i18n.LanguageTagFromContext(ctx, language.English).String(),
+ )
+ eventCtx.R = eventCtx.R.WithContext(ctx)
+}
+
+func (w *grpcWrapper) convert(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ st, ok := status.FromError(err)
+ if !ok {
+ return err
+ }
+
+ var vErr web.ValidationErrors
+
+ details := st.Details()
+
+ badRequest := statusx.ExtractDetail[*errdetails.BadRequest](details)
+ if badRequest != nil {
+ for _, violation := range badRequest.GetFieldViolations() {
+ vErr.FieldError(
+ statusx.FormatField(violation.GetField(), lo.PascalCase),
+ cmp.Or(violation.GetLocalizedMessage().GetMessage(), violation.GetDescription()),
+ )
+ }
+ return errors.WithStack(&vErr)
+ }
+
+ localized := statusx.ExtractDetail[*errdetails.LocalizedMessage](details)
+ if localized != nil {
+ vErr.GlobalError(cmp.Or(localized.GetMessage(), st.Message()))
+ } else {
+ vErr.GlobalError(st.Message())
+ }
+
+ return errors.WithStack(&vErr)
+}
diff --git a/presets/editing.go b/presets/editing.go
index 3b977aa9e..7e9817b8c 100644
--- a/presets/editing.go
+++ b/presets/editing.go
@@ -1,6 +1,7 @@
package presets
import (
+ "errors"
"fmt"
"strings"
"time"
@@ -567,7 +568,12 @@ func (b *EditingBuilder) doUpdate(
err1 := usingB.Saver(obj, id, ctx)
if err1 != nil {
- usingB.UpdateOverlayContent(ctx, r, obj, "", err1)
+ var ve *web.ValidationErrors
+ if errors.As(err1, &ve) {
+ usingB.UpdateOverlayContent(ctx, r, obj, "", ve)
+ } else {
+ usingB.UpdateOverlayContent(ctx, r, obj, "", err1)
+ }
return created, err1
}
diff --git a/presets/listener.go b/presets/listener.go
index c9cd64a14..c82db6579 100644
--- a/presets/listener.go
+++ b/presets/listener.go
@@ -42,7 +42,7 @@ func NotifModelsDeleted(v any) string {
}
func (*ModelBuilder) NotifRowUpdated() string {
- return fmt.Sprintf("presets_NotifRowUpdated")
+ return "presets_NotifRowUpdated"
}
type PayloadRowUpdated struct {
diff --git a/presets/listing_builder.go b/presets/listing_builder.go
index 05ab2ed04..7103bf253 100644
--- a/presets/listing_builder.go
+++ b/presets/listing_builder.go
@@ -40,23 +40,24 @@ type OrderableField struct {
}
type ListingBuilder struct {
- mb *ModelBuilder
- bulkActions []*BulkActionBuilder
- footerActions []*FooterActionBuilder
- actions []*ActionBuilder
- actionsAsMenu bool
- rowMenu *RowMenuBuilder
- filterDataFunc FilterDataFunc
- filterTabsFunc FilterTabsFunc
- newBtnFunc ComponentFunc
- pageFunc web.PageFunc
- cellWrapperFunc vx.CellWrapperFunc
- cellProcessor CellProcessor
- rowProcessor RowProcessor
- tableProcessor TableProcessor
- Searcher SearchFunc
- searchColumns []string
- titleFunc func(evCtx *web.EventContext, style ListingStyle, defaultTitle string) (title string, titleCompo h.HTMLComponent, err error)
+ mb *ModelBuilder
+ bulkActions []*BulkActionBuilder
+ footerActions []*FooterActionBuilder
+ actions []*ActionBuilder
+ actionsAsMenu bool
+ rowMenu *RowMenuBuilder
+ filterDataFunc FilterDataFunc
+ filterTabsFunc FilterTabsFunc
+ filterNotificationFunc FilterNotificationFunc
+ newBtnFunc ComponentFunc
+ pageFunc web.PageFunc
+ cellWrapperFunc vx.CellWrapperFunc
+ cellProcessor CellProcessor
+ rowProcessor RowProcessor
+ tableProcessor TableProcessor
+ Searcher SearchFunc
+ searchColumns []string
+ titleFunc func(evCtx *web.EventContext, style ListingStyle, defaultTitle string) (title string, titleCompo h.HTMLComponent, err error)
// perPage is the number of records per page.
// if request query param "per_page" is set, it will be set to that value.
@@ -223,6 +224,11 @@ func (b *ListingBuilder) RelayPagination(v RelayPagination) (r *ListingBuilder)
return b
}
+func (b *ListingBuilder) FilterNotificationFunc(v FilterNotificationFunc) (r *ListingBuilder) {
+ b.filterNotificationFunc = v
+ return b
+}
+
func (b *ListingBuilder) NewButtonFunc(v ComponentFunc) (r *ListingBuilder) {
b.newBtnFunc = v
return b
diff --git a/presets/listing_compo.go b/presets/listing_compo.go
index a5266832f..43909901a 100644
--- a/presets/listing_compo.go
+++ b/presets/listing_compo.go
@@ -187,6 +187,14 @@ func (c *ListingCompo) MarshalHTML(ctx context.Context) (r []byte, err error) {
).MarshalHTML(ctx)
}
+func (c *ListingCompo) filterNotification(ctx context.Context) h.HTMLComponent {
+ if c.lb.filterNotificationFunc == nil {
+ return nil
+ }
+ evCtx, _ := c.MustGetEventContext(ctx)
+ return c.lb.filterNotificationFunc(evCtx)
+}
+
func (c *ListingCompo) tabsFilter(ctx context.Context) h.HTMLComponent {
if c.lb.filterTabsFunc == nil {
return nil
@@ -710,6 +718,7 @@ func (c *ListingCompo) dataTable(ctx context.Context) h.HTMLComponent {
return h.Components(
filterScript,
+ c.filterNotification(ctx),
dataBody,
)
}
@@ -817,7 +826,7 @@ func CardDataTableFunc(lb *ListingBuilder, config *CardDataTableConfig) func(ctx
).Class("pa-0"),
)
var card h.HTMLComponent
- if selectedActions != nil && len(selectedActions) > 0 {
+ if len(selectedActions) > 0 {
card = VHover(
web.Slot(
VCard(
diff --git a/presets/presets.go b/presets/presets.go
index cb852b271..f2b3adb06 100644
--- a/presets/presets.go
+++ b/presets/presets.go
@@ -7,6 +7,7 @@ import (
"net/http"
"regexp"
"strings"
+ "sync"
"github.com/iancoleman/strcase"
"github.com/jinzhu/inflection"
@@ -30,6 +31,7 @@ type Builder struct {
prefix string
models []*ModelBuilder
handler http.Handler
+ warmupOnce sync.Once
builder *web.Builder
dc *stateful.DependencyCenter
i18nBuilder *i18n.Builder
@@ -1393,8 +1395,15 @@ func (b *Builder) LookUpModelBuilder(uriName string) *ModelBuilder {
}
func (b *Builder) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ b.warmupOnce.Do(func() {
+ if b.handler == nil {
+ b.Build()
+ }
+ })
if b.handler == nil {
- b.Build()
+ log.Printf("presets: Builder.handler is nil after Build; cannot serve request for %s", r.URL.Path)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
}
redirectSlashes(b.handler).ServeHTTP(w, r)
}
diff --git a/presets/presets_test.go b/presets/presets_test.go
index 73c1105c4..3fe4fc97e 100644
--- a/presets/presets_test.go
+++ b/presets/presets_test.go
@@ -1,6 +1,8 @@
package presets
import (
+ "bytes"
+ "log"
"net/http"
"net/http/httptest"
"net/url"
@@ -183,3 +185,26 @@ func TestNewMuxHook(t *testing.T) {
assert.Equal(t, http.StatusOK, w2.Code)
assert.Equal(t, "next handler", w2.Body.String())
}
+
+func TestServeHTTP_HandlerNilAfterOnce_Returns500(t *testing.T) {
+ b := New()
+
+ // Mark warmupOnce as done to skip Build() so that handler stays nil.
+ b.warmupOnce.Do(func() {})
+
+ // Capture log output.
+ var buf bytes.Buffer
+ orig := log.Writer()
+ log.SetOutput(&buf)
+ defer log.SetOutput(orig)
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/foo", nil)
+
+ b.ServeHTTP(w, r)
+
+ require.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Contains(t, w.Body.String(), "Internal Server Error")
+ assert.Contains(t, buf.String(), "Builder.handler is nil after Build")
+ assert.Contains(t, buf.String(), "/foo")
+}
diff --git a/presets/section.go b/presets/section.go
index 1bcad1dab..91a7f6a2a 100644
--- a/presets/section.go
+++ b/presets/section.go
@@ -1097,10 +1097,18 @@ func (b *SectionBuilder) SaveDetailField(ctx *web.EventContext) (r web.EventResp
}
if needSave {
- err = b.saver(obj, id, ctx)
+ err := b.saver(obj, id, ctx)
if err != nil {
- ShowMessage(&r, err.Error(), "warning")
- return r, nil
+ var ve *web.ValidationErrors
+ if errors.As(err, &ve) {
+ ctx.Flash = ve
+ if ve.GetGlobalError() != "" {
+ ShowMessage(&r, ve.GetGlobalError(), "warning")
+ }
+ } else {
+ ShowMessage(&r, err.Error(), "warning")
+ return r, nil
+ }
}
}
diff --git a/starter/auth.go b/starter/auth.go
new file mode 100644
index 000000000..ae724767b
--- /dev/null
+++ b/starter/auth.go
@@ -0,0 +1,398 @@
+package starter
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/iancoleman/strcase"
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/providers/github"
+ "github.com/markbates/goth/providers/google"
+ "github.com/markbates/goth/providers/microsoftonline"
+ "github.com/pkg/errors"
+ "github.com/qor5/admin/v3/activity"
+ "github.com/qor5/admin/v3/presets"
+ "github.com/qor5/admin/v3/presets/gorm2op"
+ "github.com/qor5/admin/v3/role"
+ "github.com/qor5/web/v3"
+ "github.com/qor5/x/v3/i18n"
+ "github.com/qor5/x/v3/login"
+ "github.com/qor5/x/v3/perm"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+
+ . "github.com/theplant/htmlgo"
+
+ plogin "github.com/qor5/admin/v3/login"
+)
+
+// AuthConfig contains all authentication-related configuration
+type AuthConfig struct {
+ Secret string `confx:"secret" validate:"required"`
+ BaseURL string `confx:"baseURL" validate:"required,url"`
+ InitialUserEmail string `confx:"initialUserEmail" validate:"required,email"`
+ InitialUserPassword string `confx:"initialUserPassword" validate:"required,min=12"`
+ InitialUserRole string `confx:"initialUserRole" validate:"required"`
+ GoogleClientKey string `confx:"googleClientKey"`
+ GoogleClientSecret string `confx:"googleClientSecret"`
+ MicrosoftClientKey string `confx:"microsoftClientKey"`
+ MicrosoftClientSecret string `confx:"microsoftClientSecret"`
+ GitHubClientKey string `confx:"githubClientKey"`
+ GitHubClientSecret string `confx:"githubClientSecret"`
+ EnableRecaptcha bool `confx:"enableRecaptcha"`
+ RecaptchaSiteKey string `confx:"recaptchaSiteKey"`
+ RecaptchaSecretKey string `confx:"recaptchaSecretKey"`
+ MaxRetryCount int `confx:"maxRetryCount" validate:"min=1"`
+ EnableTOTP bool `confx:"enableTOTP"`
+}
+
+// createRoleBuilder creates and configures the role builder
+func (a *Handler) createRoleBuilder() *role.Builder {
+ roleBuilder := role.New(a.DB).
+ AfterInstall(func(_ *presets.Builder, mb *presets.ModelBuilder) error {
+ mb.Listing().SearchFunc(func(ctx *web.EventContext, params *presets.SearchParams) (*presets.SearchResult, error) {
+ u := GetCurrentUser(ctx.R)
+ qdb := a.DB.WithContext(ctx.R.Context())
+ // If the current user doesn't have 'admin' role, do not allow them to view admin and manager roles
+ if currentRoles := u.GetRoles(); !slices.Contains(currentRoles, RoleAdmin) {
+ qdb = qdb.Where("name NOT IN (?)", []string{RoleAdmin, RoleManager})
+ }
+ result, err := gorm2op.DataOperator(qdb).Search(ctx, params)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to search roles")
+ }
+ return result, nil
+ })
+ return nil
+ })
+
+ a.Use(roleBuilder)
+ return roleBuilder
+}
+
+func (a *Handler) createLoginBuilder(presetsBuilder *presets.Builder) *login.Builder {
+ loginBuilder := plogin.New(presetsBuilder).
+ DB(a.DB).
+ UserModel(&User{}).
+ Secret(a.Auth.Secret).
+ OAuthProviders(buildOAuthProviders(&a.Auth)...).
+ HomeURLFunc(func(_ *http.Request, _ any) string {
+ return strings.TrimSuffix(a.Prefix, "/") + "/"
+ }).
+ Recaptcha(a.Auth.EnableRecaptcha, login.RecaptchaConfig{
+ SiteKey: a.Auth.RecaptchaSiteKey,
+ SecretKey: a.Auth.RecaptchaSecretKey,
+ }).
+ WrapBeforeSetPassword(func(in login.HookFunc) login.HookFunc {
+ return func(r *http.Request, user any, extraVals ...any) error {
+ if err := in(r, user, extraVals...); err != nil {
+ return err
+ }
+
+ u := user.(*User)
+ if u.GetAccountName() == a.Auth.InitialUserEmail {
+ return errors.WithStack(
+ &login.NoticeError{
+ Level: login.NoticeLevel_Error,
+ Message: "Cannot change password for public user",
+ },
+ )
+ }
+
+ password := extraVals[0].(string)
+ if len(password) < 12 {
+ return errors.WithStack(
+ &login.NoticeError{
+ Level: login.NoticeLevel_Error,
+ Message: "Password cannot be less than 12 characters",
+ },
+ )
+ }
+
+ return nil
+ }
+ }).
+ WrapAfterOAuthComplete(func(in login.HookFunc) login.HookFunc {
+ return func(r *http.Request, user any, extraVals ...any) error {
+ if err := in(r, user, extraVals...); err != nil {
+ return err
+ }
+
+ gothUser := user.(goth.User)
+ if gothUser.Email == "" {
+ return nil
+ }
+
+ db := a.DB.WithContext(r.Context())
+
+ // Check if user already exists
+ if err := db.Where("o_auth_provider = ? AND o_auth_identifier = ?", gothUser.Provider, gothUser.Email).
+ First(&User{}).Error; errors.Is(err, gorm.ErrRecordNotFound) {
+ // Create new user from OAuth data
+ var name string
+ if at := strings.LastIndex(gothUser.Email, "@"); at > 0 {
+ name = gothUser.Email[:at]
+ } else {
+ name = gothUser.Email
+ }
+
+ newUser := &User{
+ Name: name,
+ Status: StatusActive,
+ RegistrationDate: time.Now(),
+ OAuthInfo: login.OAuthInfo{
+ OAuthProvider: gothUser.Provider,
+ OAuthUserID: gothUser.UserID,
+ OAuthIdentifier: gothUser.Email,
+ OAuthAvatar: gothUser.AvatarURL,
+ },
+ }
+ if err := db.Create(newUser).Error; err != nil {
+ return errors.Wrap(err, "failed to create user from OAuth")
+ }
+
+ // Grant manager role to new OAuth user
+ if err := grantUserRole(r.Context(), db, newUser.ID, RoleManager); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ }
+ }).
+ TOTP(a.Auth.EnableTOTP).
+ MaxRetryCount(a.Auth.MaxRetryCount)
+
+ loginBuilder.LoginPageFunc(plogin.NewAdvancedLoginPage(func(ctx *web.EventContext, config *plogin.AdvancedLoginPageConfig) (*plogin.AdvancedLoginPageConfig, error) {
+ msgr := i18n.MustGetModuleMessages(ctx.R, I18nDemoKey, Messages_en_US).(*Messages)
+ config.TitleLabel = msgr.SystemTitleLabel
+ return config, nil
+ })(loginBuilder.ViewHelper(), presetsBuilder))
+
+ return loginBuilder
+}
+
+func (a *Handler) createLoginSessionBuilder(loginBuilder *login.Builder, activityBuilder *activity.Builder) *plogin.SessionBuilder {
+ loginSessionBuilder := plogin.NewSessionBuilder(loginBuilder, a.DB).
+ Activity(activityBuilder.RegisterModel(&User{})).
+ IsPublicUser(func(u any) bool {
+ user, ok := u.(*User)
+ return ok && user.GetAccountName() == a.Auth.InitialUserEmail
+ }).
+ AutoMigrate()
+ a.Use(loginSessionBuilder)
+ return loginSessionBuilder
+}
+
+// createProfileBuilder creates and configures the profile builder
+func (a *Handler) createProfileBuilder(activityBuilder *activity.Builder, loginSessionBuilder *plogin.SessionBuilder) *plogin.ProfileBuilder {
+ profileBuilder := plogin.NewProfileBuilder(
+ func(ctx context.Context) (*plogin.Profile, error) {
+ evCtx := web.MustGetEventContext(ctx)
+ u := GetCurrentUser(evCtx.R)
+ if u == nil {
+ return nil, perm.PermissionDenied //nolint:errhandle
+ }
+ notifiCounts, err := activityBuilder.GetNotesCounts(ctx, "", nil)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to get activity notes counts")
+ }
+ user := &plogin.Profile{
+ ID: fmt.Sprint(u.ID),
+ Name: u.Name,
+ Roles: u.GetRoles(),
+ Status: strcase.ToCamel(u.Status),
+ Fields: []*plogin.ProfileField{
+ {Name: "Email", Value: u.Account},
+ {Name: "Company", Value: u.Company},
+ },
+ NotifCounts: notifiCounts,
+ }
+ if u.OAuthAvatar != "" {
+ user.Avatar = u.OAuthAvatar
+ }
+ return user, nil
+ },
+ func(ctx context.Context, newName string) error {
+ evCtx := web.MustGetEventContext(ctx)
+ u := GetCurrentUser(evCtx.R)
+ if u == nil {
+ return perm.PermissionDenied //nolint:errhandle
+ }
+ u.Name = newName
+ if err := a.DB.Save(u).Error; err != nil {
+ return errors.Wrap(err, "failed to update user name")
+ }
+ return nil
+ },
+ ).SessionBuilder(loginSessionBuilder)
+
+ a.Use(profileBuilder)
+ return profileBuilder
+}
+
+// buildOAuthProviders creates OAuth provider configurations
+func buildOAuthProviders(authConfig *AuthConfig) []*login.Provider {
+ var providers []*login.Provider
+
+ // Google OAuth provider
+ if authConfig.GoogleClientKey != "" && authConfig.GoogleClientSecret != "" {
+ providers = append(providers, &login.Provider{
+ Goth: google.New(
+ authConfig.GoogleClientKey,
+ authConfig.GoogleClientSecret,
+ authConfig.BaseURL+"/auth/callback?provider="+OAuthProviderGoogle,
+ ),
+ Key: OAuthProviderGoogle,
+ Text: "LoginProviderGoogleText",
+ Logo: RawHTML(``),
+ })
+ }
+
+ // Microsoft OAuth provider
+ if authConfig.MicrosoftClientKey != "" && authConfig.MicrosoftClientSecret != "" {
+ providers = append(providers, &login.Provider{
+ Goth: microsoftonline.New(
+ authConfig.MicrosoftClientKey,
+ authConfig.MicrosoftClientSecret,
+ // TODO: @molon 为什么这里偏偏不用给到 OAuthProviderMicrosoftOnline provider 参数呢?
+ authConfig.BaseURL+"/auth/callback",
+ ),
+ Key: OAuthProviderMicrosoftOnline,
+ Text: "LoginProviderMicrosoftText",
+ Logo: RawHTML(``),
+ })
+ }
+
+ // GitHub OAuth provider
+ if authConfig.GitHubClientKey != "" && authConfig.GitHubClientSecret != "" {
+ providers = append(providers, &login.Provider{
+ Goth: github.New(
+ authConfig.GitHubClientKey,
+ authConfig.GitHubClientSecret,
+ authConfig.BaseURL+"/auth/callback?provider="+OAuthProviderGithub,
+ ),
+ Key: OAuthProviderGithub,
+ Text: "LoginProviderGithubText",
+ Logo: RawHTML(``),
+ })
+ }
+
+ return providers
+}
+
+func createDefaultRolesIfEmpty(ctx context.Context, db *gorm.DB) error {
+ db = db.WithContext(ctx)
+
+ var count int64
+ if err := db.Model(&role.Role{}).Count(&count).Error; err != nil {
+ return errors.Wrap(err, "failed to count roles")
+ }
+
+ if count > 0 {
+ return nil
+ }
+
+ var roles []*role.Role
+ for _, roleName := range DefaultRoles {
+ roles = append(roles, &role.Role{
+ Name: roleName,
+ })
+ }
+
+ if err := db.Create(roles).Error; err != nil {
+ return errors.Wrap(err, "failed to create default roles")
+ }
+
+ return nil
+}
+
+func createInitialUserIfEmpty(ctx context.Context, db *gorm.DB, opts *UpsertUserOptions) (*User, error) {
+ db = db.WithContext(ctx)
+
+ var count int64
+ if err := db.Model(&User{}).Where("account = ?", opts.Email).Count(&count).Error; err != nil {
+ return nil, errors.Wrap(err, "failed to count users")
+ }
+ if count > 0 {
+ return nil, nil
+ }
+
+ return UpsertUser(ctx, db, opts)
+}
+
+func grantUserRole(ctx context.Context, db *gorm.DB, userID uint, roleName string) error {
+ db = db.WithContext(ctx)
+
+ var roleID int
+ if err := db.Model(&role.Role{}).Where("name = ?", roleName).Pluck("id", &roleID).Error; err != nil {
+ return errors.Wrapf(err, "failed to get role id for role %s", roleName)
+ }
+
+ if err := db.Table("user_role_join").
+ Clauses(clause.OnConflict{DoNothing: true}).
+ Create(map[string]any{"user_id": userID, "role_id": roleID}).Error; err != nil {
+ return errors.Wrapf(err, "failed to grant role %s to user %d", roleName, userID)
+ }
+
+ return nil
+}
+
+type UpsertUserOptions struct {
+ Email string
+ Password string
+ Role []string
+}
+
+func UpsertUser(ctx context.Context, db *gorm.DB, opts *UpsertUserOptions) (*User, error) {
+ user := &User{
+ Name: opts.Email,
+ Status: StatusActive,
+ UserPass: login.UserPass{
+ Account: opts.Email,
+ Password: opts.Password,
+ },
+ }
+ user.EncryptPassword()
+
+ db = db.WithContext(ctx)
+
+ err := db.Clauses(
+ clause.OnConflict{
+ Columns: []clause.Column{{Name: "account"}},
+ TargetWhere: clause.Where{Exprs: []clause.Expression{
+ gorm.Expr("account <> ''"),
+ gorm.Expr("deleted_at IS NULL"),
+ }},
+ UpdateAll: true,
+ },
+ clause.Returning{Columns: []clause.Column{{Name: "id"}}},
+ ).Create(user).Error
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to upsert user")
+ }
+
+ for _, roleName := range opts.Role {
+ if err := grantUserRole(ctx, db, user.ID, roleName); err != nil {
+ return nil, errors.Wrapf(err, "failed to grant role %s to user", roleName)
+ }
+ }
+
+ if err := db.Preload("Roles").Where("id = ?", user.ID).First(user).Error; err != nil {
+ return nil, errors.Wrap(err, "failed to reload user with roles")
+ }
+
+ return user, nil
+}
+
+func GetCurrentUser(r *http.Request) *User {
+ u, ok := login.GetCurrentUser(r).(*User)
+ if !ok {
+ return nil
+ }
+ return u
+}
diff --git a/starter/auth_test.go b/starter/auth_test.go
new file mode 100644
index 000000000..633577ab8
--- /dev/null
+++ b/starter/auth_test.go
@@ -0,0 +1,358 @@
+package starter_test
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+
+ . "github.com/qor5/web/v3/multipartestutils"
+
+ "github.com/qor5/admin/v3/starter"
+ "github.com/qor5/x/v3/gormx"
+ "github.com/stretchr/testify/require"
+ "github.com/theplant/inject"
+)
+
+// Coverage focus: auth builder pages and role listing behavior for different roles
+func TestAuthChangePasswordAndProfileRename(t *testing.T) {
+ env := newTestEnv(t, starter.SetupPageBuilderForHandler)
+ suite := inject.MustResolve[*gormx.TestSuite](env.lc)
+ db := suite.DB()
+
+ cases := []TestCase{
+ {
+ Name: "Change Password Page",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ return httptest.NewRequest("GET", "/auth/change-password", http.NoBody)
+ },
+ ExpectPageBodyContainsInOrder: []string{"Change your password", "Old password", "New password"},
+ },
+ {
+ Name: "Profile Rename",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ // Send a stateful action to rename current user
+ return NewMultipartBuilder().
+ PageURL("/?__execute_event__=__dispatch_stateful_action__").
+ AddField("__action__", `
+{
+ "compo_type": "*login.ProfileCompo",
+ "compo": {"id": ""},
+ "injector": "__profile__",
+ "sync_query": false,
+ "method": "Rename",
+ "request": {"name": "renamed@example.com"}
+}
+ `).
+ BuildEventFuncRequest()
+ },
+ EventResponseMatch: func(t *testing.T, er *TestEventResponse) {
+ var u starter.User
+ require.NoError(t, db.Where("account = ?", "test@example.com").First(&u).Error)
+ require.Equal(t, "renamed@example.com", u.Name)
+ },
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.Name, func(t *testing.T) {
+ RunCase(t, c, env.handler)
+ })
+ }
+}
+
+func TestRolesIndex_AdminAndEditorVisibility(t *testing.T) {
+ env := newTestEnv(t, starter.SetupPageBuilderForHandler)
+ suite := inject.MustResolve[*gormx.TestSuite](env.lc)
+ db := suite.DB()
+ // Admin should see Admin/Manager roles
+ {
+ c := TestCase{
+ Name: "Roles Visible For Admin",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ return httptest.NewRequest("GET", "/roles", http.NoBody)
+ },
+ ExpectPageBodyContainsInOrder: []string{"Viewer", "Editor", "Manager", ">Admin<"},
+ }
+ t.Run(c.Name, func(t *testing.T) {
+ RunCase(t, c, env.handler)
+ })
+ }
+
+ // Switch current user to Editor by modifying join table, withRoles middleware reloads from DB
+ {
+ var cur starter.User
+ require.NoError(t, db.Where("account = ?", "test@example.com").First(&cur).Error)
+ // Find Editor role id
+ var editorRoleID uint
+ require.NoError(t, db.Table("roles").Select("id").Where("name = ?", starter.RoleEditor).Scan(&editorRoleID).Error)
+ require.NotZero(t, editorRoleID)
+ // Reset joins
+ require.NoError(t, db.Exec("DELETE FROM user_role_join WHERE user_id = ?", cur.ID).Error)
+ require.NoError(t, db.Exec("INSERT INTO user_role_join(user_id, role_id) VALUES(?, ?)", cur.ID, editorRoleID).Error)
+
+ c := TestCase{
+ Name: "Roles Hidden For Editor",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ return httptest.NewRequest("GET", "/roles", http.NoBody)
+ },
+ ExpectPageBodyContainsInOrder: []string{"Viewer", "Editor"},
+ ExpectPageBodyNotContains: []string{"Manager", ">Admin<"},
+ }
+ t.Run(c.Name, func(t *testing.T) {
+ RunCase(t, c, env.handler)
+ })
+ }
+}
+
+func TestUsersListing_FilterAdminManagerForNonAdmin(t *testing.T) {
+ env := newTestEnv(t, starter.SetupPageBuilderForHandler)
+ suite := inject.MustResolve[*gormx.TestSuite](env.lc)
+ db := suite.DB()
+
+ // Prepare one Manager user to ensure listing filter can exclude it for non-admin
+ mgr, err := starter.UpsertUser(context.Background(), db, &starter.UpsertUserOptions{
+ Email: "manager@example.com",
+ Password: "1234567890abcd",
+ Role: []string{starter.RoleManager},
+ })
+ require.NoError(t, err)
+ require.NotNil(t, mgr)
+
+ // Switch current user to Editor
+ var cur starter.User
+ require.NoError(t, db.Where("account = ?", "test@example.com").First(&cur).Error)
+ var editorRoleID uint
+ require.NoError(t, db.Table("roles").Select("id").Where("name = ?", starter.RoleEditor).Scan(&editorRoleID).Error)
+ require.NoError(t, db.Exec("DELETE FROM user_role_join WHERE user_id = ?", cur.ID).Error)
+ require.NoError(t, db.Exec("INSERT INTO user_role_join(user_id, role_id) VALUES(?, ?)", cur.ID, editorRoleID).Error)
+
+ // As Editor, Manager users should be filtered out in listing
+ c := TestCase{
+ Name: "Users listing hides Admin/Manager for non-admin",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ return httptest.NewRequest("GET", "/users", http.NoBody)
+ },
+ ExpectPageBodyNotContains: []string{"manager@example.com"},
+ }
+ t.Run(c.Name, func(t *testing.T) {
+ RunCase(t, c, env.handler)
+ })
+}
+
+type unloginKey struct{}
+
+func TestAuthLoginPage(t *testing.T) {
+ env := newTestEnv(t, starter.SetupPageBuilderForHandler, func(handler *starter.Handler) *unloginKey {
+ handler.WithHandlerHook(func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ r.Header.Set("Cookie", "")
+ next.ServeHTTP(w, r)
+ })
+ })
+ return &unloginKey{}
+ })
+ c := TestCase{
+ Name: "Login Page",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ return httptest.NewRequest("GET", "/auth/login", http.NoBody)
+ },
+ ExpectPageBodyContainsInOrder: []string{"Sign in with Google", "Sign in with Microsoft", "Sign in with Github"},
+ }
+ t.Run(c.Name, func(t *testing.T) {
+ RunCase(t, c, env.handler)
+ })
+}
+
+func TestAuthLoigin(t *testing.T) {
+ env := newTestEnv(t, starter.SetupPageBuilderForHandler, func(handler *starter.Handler) *unloginKey {
+ handler.WithHandlerHook(func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ r.Header.Set("Cookie", "")
+ next.ServeHTTP(w, r)
+ })
+ })
+ return &unloginKey{}
+ })
+ form := url.Values{}
+ form.Set("account", "qor@theplant.jp")
+ form.Set("password", "admin123456789")
+ req := httptest.NewRequest("POST", "/auth/userpass/login", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ rr := httptest.NewRecorder()
+ env.handler.ServeHTTP(rr, req)
+
+ res := rr.Result()
+ defer res.Body.Close()
+
+ require.Equal(t, http.StatusFound, res.StatusCode)
+ require.Equal(t, "/", res.Header.Get("Location"))
+
+ var hasAuthCookie bool
+ for _, c := range res.Cookies() {
+ if c.Name == "auth" && c.Value != "" {
+ hasAuthCookie = true
+ break
+ }
+ }
+ require.True(t, hasAuthCookie)
+}
+
+func TestDoResetPassword_Success(t *testing.T) {
+ env := newTestEnv(t, starter.SetupPageBuilderForHandler)
+ suite := inject.MustResolve[*gormx.TestSuite](env.lc)
+ db := suite.DB()
+
+ // Prepare a dedicated user for reset flow to avoid affecting other tests
+ ctx := context.Background()
+ usr, err := starter.UpsertUser(ctx, db, &starter.UpsertUserOptions{
+ Email: "resetuser@example.com",
+ Password: "origPassword1234",
+ Role: []string{starter.RoleViewer},
+ })
+ require.NoError(t, err)
+ require.NotNil(t, usr)
+
+ // Generate reset token
+ token, err := usr.GenerateResetPasswordToken(db, &starter.User{})
+ require.NoError(t, err)
+ userID := strconv.Itoa(int(usr.ID))
+
+ // Call do-reset-password
+ form := url.Values{}
+ form.Set("user_id", userID)
+ form.Set("token", token)
+ form.Set("password", "newPassword1234")
+ form.Set("confirm_password", "newPassword1234")
+ req := httptest.NewRequest("POST", "/auth/do-reset-password", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr := httptest.NewRecorder()
+ env.handler.ServeHTTP(rr, req)
+ res := rr.Result()
+ defer res.Body.Close()
+
+ require.Equal(t, http.StatusFound, res.StatusCode)
+ require.Equal(t, "/auth/login", res.Header.Get("Location"))
+
+ // Verify new password works by logging in
+ loginForm := url.Values{}
+ loginForm.Set("account", "resetuser@example.com")
+ loginForm.Set("password", "newPassword1234")
+ loginReq := httptest.NewRequest("POST", "/auth/userpass/login", strings.NewReader(loginForm.Encode()))
+ loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ loginRR := httptest.NewRecorder()
+ env.handler.ServeHTTP(loginRR, loginReq)
+ loginRes := loginRR.Result()
+ defer loginRes.Body.Close()
+
+ require.Equal(t, http.StatusFound, loginRes.StatusCode)
+ require.Equal(t, "/", loginRes.Header.Get("Location"))
+
+ var hasAuthCookie bool
+ for _, c := range loginRes.Cookies() {
+ if c.Name == "auth" && c.Value != "" {
+ hasAuthCookie = true
+ break
+ }
+ }
+ require.True(t, hasAuthCookie)
+
+ // Follow redirect to home with cookies to ensure authenticated access works
+ homeReq := httptest.NewRequest("GET", "/", http.NoBody)
+ for _, c := range loginRes.Cookies() {
+ homeReq.AddCookie(c)
+ }
+ homeRR := httptest.NewRecorder()
+ env.handler.ServeHTTP(homeRR, homeReq)
+ homeRes := homeRR.Result()
+ defer homeRes.Body.Close()
+ require.Equal(t, http.StatusOK, homeRes.StatusCode)
+}
+
+func TestDoResetPassword_FailedByInitialUser(t *testing.T) {
+ env := newTestEnv(t, starter.SetupPageBuilderForHandler)
+ suite := inject.MustResolve[*gormx.TestSuite](env.lc)
+ db := suite.DB()
+
+ ctx := context.Background()
+ usr, err := starter.UpsertUser(ctx, db, &starter.UpsertUserOptions{
+ Email: "qor@theplant.jp",
+ Password: "admin123456789",
+ Role: []string{starter.RoleViewer},
+ })
+ require.NoError(t, err)
+ require.NotNil(t, usr)
+
+ // Generate reset token
+ token, err := usr.GenerateResetPasswordToken(db, &starter.User{})
+ require.NoError(t, err)
+ userID := strconv.Itoa(int(usr.ID))
+
+ // Call do-reset-password and expect redirect back to reset page
+ form := url.Values{}
+ form.Set("user_id", userID)
+ form.Set("token", token)
+ form.Set("password", "newPassword1234")
+ form.Set("confirm_password", "newPassword1234")
+ req := httptest.NewRequest("POST", "/auth/do-reset-password", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr := httptest.NewRecorder()
+ env.handler.ServeHTTP(rr, req)
+ res := rr.Result()
+ defer res.Body.Close()
+
+ require.Equal(t, http.StatusFound, res.StatusCode)
+ loc := res.Header.Get("Location")
+ require.True(t, strings.HasPrefix(loc, "/auth/reset-password?"))
+ require.True(t, strings.Contains(loc, "id="))
+ require.True(t, strings.Contains(loc, "token="))
+}
+
+func TestDoResetPassword_FailedByLessPassword(t *testing.T) {
+ env := newTestEnv(t, starter.SetupPageBuilderForHandler)
+ suite := inject.MustResolve[*gormx.TestSuite](env.lc)
+ db := suite.DB()
+
+ ctx := context.Background()
+ usr, err := starter.UpsertUser(ctx, db, &starter.UpsertUserOptions{
+ Email: "resetuser@theplant.jp",
+ Password: "admin123456789",
+ Role: []string{starter.RoleViewer},
+ })
+ require.NoError(t, err)
+ require.NotNil(t, usr)
+
+ // Generate reset token
+ token, err := usr.GenerateResetPasswordToken(db, &starter.User{})
+ require.NoError(t, err)
+ userID := strconv.Itoa(int(usr.ID))
+
+ // Call do-reset-password
+ form := url.Values{}
+ form.Set("user_id", userID)
+ form.Set("token", token)
+ form.Set("password", "newPassword")
+ form.Set("confirm_password", "newPassword")
+ req := httptest.NewRequest("POST", "/auth/do-reset-password", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr := httptest.NewRecorder()
+ env.handler.ServeHTTP(rr, req)
+ res := rr.Result()
+ defer res.Body.Close()
+
+ require.Equal(t, http.StatusFound, res.StatusCode)
+ loc := res.Header.Get("Location")
+ require.True(t, strings.HasPrefix(loc, "/auth/reset-password?"))
+ require.True(t, strings.Contains(loc, "id="))
+ require.True(t, strings.Contains(loc, "token="))
+}
diff --git a/starter/embed/default-conf.yaml b/starter/embed/default-conf.yaml
new file mode 100644
index 000000000..d2ea0b91f
--- /dev/null
+++ b/starter/embed/default-conf.yaml
@@ -0,0 +1,43 @@
+
+database:
+ dsn: ""
+ debug: false
+ tracing:
+ excludeQuery: false
+ excludeQueryVars: false
+ maxIdleConns: 20
+ maxOpenConns: 200
+ connMaxLifetime: "30m"
+ connMaxIdleTime: "10m"
+ authMethod: "password" # password, iam
+
+prefix: ""
+s3:
+ accessID: "" # AWS access ID, if not provided, will use AWS default config
+ accessKey: "" # AWS access key, if not provided, will use AWS default config
+ bucket: ""
+ region: ""
+ endpoint: ""
+s3Publish:
+ accessID: "" # AWS access ID, if not provided, will use AWS default config
+ accessKey: "" # AWS access key, if not provided, will use AWS default config
+ bucket: ""
+ region: ""
+ endpoint: ""
+auth:
+ secret: "test"
+ baseURL: "http://localhost:10001"
+ initialUserEmail: "qor@theplant.jp"
+ initialUserPassword: "admin123456789"
+ initialUserRole: "Admin"
+ googleClientKey: ""
+ googleClientSecret: ""
+ microsoftClientKey: ""
+ microsoftClientSecret: "ts"
+ githubClientKey: ""
+ githubClientSecret: "ts"
+ enableRecaptcha: false
+ recaptchaSiteKey: ""
+ recaptchaSecretKey: ""
+ maxRetryCount: 5
+ enableTOTP: false
diff --git a/starter/embed/favicon.ico b/starter/embed/favicon.ico
new file mode 100644
index 000000000..a9840a5b4
Binary files /dev/null and b/starter/embed/favicon.ico differ
diff --git a/starter/handler.go b/starter/handler.go
new file mode 100644
index 000000000..4e4a74dbf
--- /dev/null
+++ b/starter/handler.go
@@ -0,0 +1,316 @@
+package starter
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "slices"
+ "strings"
+ "sync"
+
+ "github.com/pkg/errors"
+ "github.com/qor5/admin/v3/activity"
+ "github.com/qor5/admin/v3/l10n"
+ "github.com/qor5/admin/v3/media"
+ "github.com/qor5/admin/v3/presets"
+ "github.com/qor5/admin/v3/presets/gorm2op"
+ "github.com/qor5/admin/v3/role"
+ "github.com/qor5/admin/v3/utils"
+ "github.com/qor5/confx"
+ "github.com/qor5/web/v3"
+ "github.com/qor5/x/v3/hook"
+ "github.com/qor5/x/v3/login"
+ "github.com/qor5/x/v3/perm"
+ "github.com/qor5/x/v3/s3x"
+ "github.com/theplant/inject"
+ "golang.org/x/text/language"
+ "gorm.io/gorm"
+
+ _ "embed"
+
+ plogin "github.com/qor5/admin/v3/login"
+ media_oss "github.com/qor5/admin/v3/media/oss"
+ v "github.com/qor5/x/v3/ui/vuetify"
+ h "github.com/theplant/htmlgo"
+)
+
+// Config contains all dependencies needed for Handler
+type Config struct {
+ DB *gorm.DB `inject:"" confx:"-"`
+
+ Prefix string `confx:"prefix" validate:"omitempty"`
+ S3 s3x.Config `confx:"s3"`
+ S3Publish s3x.Config `confx:"s3Publish"`
+ Auth AuthConfig `confx:"auth"`
+}
+
+// Handler handles admin with embedded configuration
+type Handler struct {
+ *Config
+ *inject.Injector
+
+ plugins []presets.Plugin
+ handlerHook hook.Hook[http.Handler]
+ warmupOnce sync.Once
+ handler http.Handler
+}
+
+// NewHandler creates a new Handler with the provided configuration
+func NewHandler(cfg *Config) *Handler {
+ cfg.Prefix = strings.TrimRight(cfg.Prefix, "/")
+ handler := &Handler{
+ Config: cfg,
+ Injector: inject.New(),
+ }
+ _ = handler.Provide(func() *Handler { return handler })
+ return handler
+}
+
+// Build initializes all components and sets up the admin interface
+func (a *Handler) Build(ctx context.Context, ctors ...any) error {
+ if err := a.Provide(
+ a.createPresetsBuilder,
+ a.createActivityBuilder,
+ a.createMediaBuilder,
+ a.createL10nBuilder,
+ a.createRoleBuilder,
+ a.createLoginBuilder,
+ a.createLoginSessionBuilder,
+ a.createProfileBuilder,
+ a.createUserModelBuilder,
+ a.createMux,
+ ); err != nil {
+ return err
+ }
+
+ if len(ctors) > 0 {
+ if err := a.Provide(ctors...); err != nil {
+ return err
+ }
+ }
+
+ if err := a.ApplyContext(ctx, a.Config); err != nil {
+ return err
+ }
+
+ if err := a.autoMigrate(ctx); err != nil {
+ return err
+ }
+
+ a.configureMediaStorage()
+
+ if err := a.BuildContext(ctx); err != nil {
+ return err
+ }
+
+ if a.Auth.InitialUserEmail != "" && a.Auth.InitialUserPassword != "" && a.Auth.InitialUserRole != "" {
+ if _, err := createInitialUserIfEmpty(ctx, a.DB, &UpsertUserOptions{
+ Email: a.Auth.InitialUserEmail,
+ Password: a.Auth.InitialUserPassword,
+ Role: []string{a.Auth.InitialUserRole},
+ }); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (a *Handler) WithHandlerHook(hooks ...hook.Hook[http.Handler]) *Handler {
+ a.handlerHook = hook.Prepend(a.handlerHook, hooks...)
+ return a
+}
+
+func (a *Handler) Use(plugins ...presets.Plugin) {
+ a.plugins = append(a.plugins, plugins...)
+}
+
+// autoMigrate performs database migrations
+func (a *Handler) autoMigrate(ctx context.Context) error {
+ db := a.DB.WithContext(ctx)
+ if err := db.AutoMigrate(
+ &role.Role{},
+ &User{},
+ &perm.DefaultDBPolicy{},
+ ); err != nil {
+ return errors.Wrap(err, "failed to auto migrate database")
+ }
+
+ if err := createDefaultRolesIfEmpty(ctx, db); err != nil {
+ return errors.Wrap(err, "failed to initialize default roles")
+ }
+ return nil
+}
+
+// configureMediaStorage configures S3 storage for media
+func (a *Handler) configureMediaStorage() {
+ media_oss.Storage = s3x.SetupClient(&a.S3, nil)
+}
+
+// createActivityBuilder creates and configures the activity builder
+func (a *Handler) createActivityBuilder() *activity.Builder {
+ activityBuilder := activity.New(a.DB, func(ctx context.Context) (*activity.User, error) {
+ u := ctx.Value(login.UserKey).(*User)
+ return &activity.User{
+ ID: fmt.Sprint(u.ID),
+ Name: u.Name,
+ Avatar: "",
+ }, nil
+ }).WrapLogModelInstall(func(in presets.ModelInstallFunc) presets.ModelInstallFunc {
+ return func(pb *presets.Builder, mb *presets.ModelBuilder) (err error) {
+ err = in(pb, mb)
+ if err != nil {
+ return
+ }
+ mb.Listing().WrapSearchFunc(func(in presets.SearchFunc) presets.SearchFunc {
+ return func(ctx *web.EventContext, params *presets.SearchParams) (result *presets.SearchResult, err error) {
+ u := GetCurrentUser(ctx.R)
+ if rs := u.GetRoles(); !slices.Contains(rs, RoleAdmin) {
+ params.SQLConditions = append(params.SQLConditions, &presets.SQLCondition{
+ Query: "user_id = ?",
+ Args: []any{fmt.Sprint(u.ID)},
+ })
+ }
+ return in(ctx, params)
+ }
+ })
+ return
+ }
+ }).AutoMigrate()
+
+ a.Use(activityBuilder)
+ return activityBuilder
+}
+
+// createPresetsBuilder creates and configures the main presets builder
+func (a *Handler) createPresetsBuilder() *presets.Builder {
+ presetsBuilder := presets.New().
+ URIPrefix(a.Prefix).
+ DataOperator(gorm2op.DataOperator(a.DB))
+
+ // Configure basic UI
+ presetsBuilder.BrandFunc(func(_ *web.EventContext) h.HTMLComponent {
+ logo := "https://qor5.com/img/qor-logo.png"
+ return h.Div(
+ v.VRow(
+ v.VCol(h.A(h.Img(logo).Attr("width", "80")).Href("/")),
+ ),
+ ).Class("mb-n4 mt-n2")
+ }).HomePageFunc(func(_ *web.EventContext) (r web.PageResponse, err error) {
+ r.PageTitle = "Home"
+ r.Body = h.H1("Home")
+ return
+ }).NotFoundPageLayoutConfig(&presets.LayoutConfig{
+ NotificationCenterInvisible: true,
+ }).RightDrawerWidth("700")
+
+ a.configurePermission(presetsBuilder)
+ a.configureI18n(presetsBuilder)
+ return presetsBuilder
+}
+
+// configureI18n configures i18n support
+func (a *Handler) configureI18n(presetsBuilder *presets.Builder) {
+ utils.Install(presetsBuilder)
+ presetsBuilder.GetI18n().
+ SupportLanguages(language.English, language.SimplifiedChinese, language.Japanese).
+ RegisterForModule(language.English, presets.ModelsI18nModuleKey, Messages_en_US_ModelsI18nModuleKey).
+ RegisterForModule(language.SimplifiedChinese, presets.ModelsI18nModuleKey, Messages_zh_CN_ModelsI18nModuleKey).
+ RegisterForModule(language.Japanese, presets.ModelsI18nModuleKey, Messages_ja_JP_ModelsI18nModuleKey).
+ RegisterForModule(language.English, I18nDemoKey, Messages_en_US).
+ RegisterForModule(language.Japanese, I18nDemoKey, Messages_ja_JP).
+ RegisterForModule(language.SimplifiedChinese, I18nDemoKey, Messages_zh_CN).
+ GetSupportLanguagesFromRequestFunc(func(_ *http.Request) []language.Tag {
+ return presetsBuilder.GetI18n().GetSupportLanguages()
+ })
+}
+
+// createMediaBuilder creates and configures the media builder
+func (a *Handler) createMediaBuilder() *media.Builder {
+ mediaBuilder := media.New(a.DB).CurrentUserID(func(ctx *web.EventContext) (id uint) {
+ u := GetCurrentUser(ctx.R)
+ if u == nil {
+ return
+ }
+ return u.ID
+ }).Searcher(func(db *gorm.DB, ctx *web.EventContext) *gorm.DB {
+ u := GetCurrentUser(ctx.R)
+ if u == nil {
+ return db
+ }
+ if rs := u.GetRoles(); !slices.Contains(rs, RoleAdmin) && !slices.Contains(rs, RoleManager) {
+ return db.Where("user_id = ?", u.ID)
+ }
+ return db
+ }).AutoMigrate()
+
+ a.Use(mediaBuilder)
+ return mediaBuilder
+}
+
+// createL10nBuilder creates and configures the localization builder
+func (a *Handler) createL10nBuilder(activityBuilder *activity.Builder) *l10n.Builder {
+ l10nBuilder := l10n.New(a.DB).
+ Activity(activityBuilder).
+ RegisterLocales("China", "cn", "China", l10n.ChinaSvg).
+ RegisterLocales("Japan", "jp", "Japan", l10n.JapanSvg)
+
+ l10nBuilder.SupportLocalesFunc(func(_ *http.Request) []string {
+ return l10nBuilder.GetSupportLocaleCodes()
+ })
+
+ a.Use(l10nBuilder)
+ return l10nBuilder
+}
+
+//go:embed embed/favicon.ico
+var favicon []byte
+
+func (a *Handler) createMux(presetsBuilder *presets.Builder, loginSessionBuilder *plogin.SessionBuilder) *http.ServeMux {
+ mux := http.NewServeMux()
+ loginSessionBuilder.Mount(mux)
+ mux.Handle("/", presetsBuilder)
+ mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write(favicon)
+ })
+ return mux
+}
+
+// ServeHTTP implements http.Handler interface
+func (a *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ a.warmupOnce.Do(func() {
+ if _, err := a.Invoke(func(mux *http.ServeMux, presetsBuilder *presets.Builder, loginSessionBuilder *plogin.SessionBuilder) {
+ presetsBuilder.Use(a.plugins...)
+ presetsBuilder.Build()
+
+ handlerHook := hook.Chain(
+ loginSessionBuilder.Middleware(),
+ withRoles(a.DB),
+ securityMiddleware(),
+ )
+ if a.handlerHook != nil {
+ handlerHook = hook.Prepend(handlerHook, a.handlerHook)
+ }
+ a.handler = handlerHook(mux)
+ }); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ })
+ if a.handler == nil {
+ http.Error(w, "handler not initialized", http.StatusInternalServerError)
+ return
+ }
+ a.handler.ServeHTTP(w, r)
+}
+
+//go:embed embed/default-conf.yaml
+var defaultConfigYAML string
+
+func InitializeConfig(opts ...confx.Option) (confx.Loader[*Config], error) {
+ def, err := confx.Read[*Config]("yaml", strings.NewReader(defaultConfigYAML))
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to load default config from embedded YAML")
+ }
+ return confx.Initialize(def, opts...)
+}
diff --git a/starter/messages.go b/starter/messages.go
new file mode 100644
index 000000000..6e0bc34d8
--- /dev/null
+++ b/starter/messages.go
@@ -0,0 +1,732 @@
+package starter
+
+import (
+ "github.com/qor5/x/v3/i18n"
+)
+
+const I18nDemoKey i18n.ModuleKey = "I18nDemoKey"
+
+type Messages struct {
+ FilterTabsAll string
+ FilterTabsHasUnreadNotes string
+ FilterTabsActive string
+ DemoTips string
+ DemoUsernameLabel string
+ DemoPasswordLabel string
+ LoginProviderGoogleText string
+ LoginProviderMicrosoftText string
+ LoginProviderGithubText string
+ OAuthCompleteInfoTitle string
+ OAuthCompleteInfoPositionLabel string
+ OAuthCompleteInfoAgreeLabel string
+ OAuthCompleteInfoBackLabel string
+ Demo string
+ DBResetTipLabel string
+ Name string
+ Email string
+ Company string
+ Role string
+ Status string
+ ChangePassword string
+ LoginSessions string
+ SystemTitleLabel string
+}
+
+var Messages_en_US = &Messages{
+ FilterTabsAll: "All",
+ FilterTabsHasUnreadNotes: "Has Unread Notes",
+ FilterTabsActive: "Active",
+ DemoTips: "Please note that the database would be reset every even hour.",
+ DemoUsernameLabel: "Demo Username: ",
+ DemoPasswordLabel: "Demo Password: ",
+ LoginProviderGoogleText: "Login with Google",
+ LoginProviderMicrosoftText: "Login with Microsoft",
+ LoginProviderGithubText: "Login with Github",
+ OAuthCompleteInfoTitle: "Complete your information",
+ OAuthCompleteInfoPositionLabel: "Position(Optional)",
+ OAuthCompleteInfoAgreeLabel: "Subscribe to QOR5 newsletter(Optional)",
+ OAuthCompleteInfoBackLabel: "Back to login",
+ Demo: "DEMO",
+ DBResetTipLabel: "Database reset countdown",
+ Name: "Name",
+ Email: "Email",
+ Company: "Company",
+ Role: "Role",
+ Status: "Status",
+ ChangePassword: "Change Password",
+ LoginSessions: "Login Sessions",
+ SystemTitleLabel: "Adex System",
+}
+
+var Messages_ja_JP = &Messages{
+ FilterTabsAll: "すべて",
+ FilterTabsHasUnreadNotes: "未読のノートがあります",
+ FilterTabsActive: "有効",
+ DemoTips: "データベースは偶数時間ごとにリセットされることに注意してください。",
+ DemoUsernameLabel: "デモのユーザー名: ",
+ DemoPasswordLabel: "デモパスワード: ",
+ LoginProviderGoogleText: "Googleでログイン",
+ LoginProviderMicrosoftText: "Microsoftでログイン",
+ LoginProviderGithubText: "Githubでログイン",
+ OAuthCompleteInfoTitle: "情報を入力してください",
+ OAuthCompleteInfoPositionLabel: "役職(任意)",
+ OAuthCompleteInfoAgreeLabel: "QOR5ニュースレターを購読する(任意)",
+ OAuthCompleteInfoBackLabel: "ログインに戻る",
+ Demo: "デモ",
+ DBResetTipLabel: "データベースリセットのカウントダウン",
+ Name: "名前",
+ Email: "メール",
+ Company: "会社",
+ Role: "役割",
+ Status: "ステータス",
+ ChangePassword: "パスワードを変更する",
+ LoginSessions: "ログインセッション",
+ SystemTitleLabel: "Adex システム",
+}
+
+var Messages_zh_CN = &Messages{
+ FilterTabsAll: "全部",
+ FilterTabsHasUnreadNotes: "未读备注",
+ FilterTabsActive: "有效",
+ DemoTips: "请注意,数据库将每隔偶数小时重置一次。",
+ DemoUsernameLabel: "演示账户:",
+ DemoPasswordLabel: "演示密码:",
+ LoginProviderGoogleText: "使用Google登录",
+ LoginProviderMicrosoftText: "使用Microsoft登录",
+ LoginProviderGithubText: "使用Github登录",
+ OAuthCompleteInfoTitle: "请填写您的信息",
+ OAuthCompleteInfoPositionLabel: "职位(可选)",
+ OAuthCompleteInfoAgreeLabel: "订阅QOR5新闻(可选)",
+ OAuthCompleteInfoBackLabel: "返回登录",
+ Demo: "演示",
+ DBResetTipLabel: "数据库重置倒计时",
+ Name: "姓名",
+ Email: "邮箱",
+ Company: "公司",
+ Role: "角色",
+ Status: "状态",
+ ChangePassword: "修改密码",
+ LoginSessions: "登录会话",
+ SystemTitleLabel: "Adex 系统",
+}
+
+type Messages_ModelsI18nModuleKey struct {
+ Admin string
+ QOR5Example string
+ Roles string
+ Users string
+
+ // TODO: @iBakuman 有很多是不需要的
+ Posts string
+ PostsID string
+ PostsTitle string
+ PostsHeroImage string
+ PostsBody string
+ Example string
+ Settings string
+ Post string
+ PostsBodyImage string
+
+ SeoPost string
+ SeoVariableTitle string
+ SeoVariableSiteName string
+
+ PageBuilder string
+ Pages string
+ SharedContainers string
+ DemoContainers string
+ Templates string
+ PageCategories string
+ ECManagement string
+ ECDashboard string
+ Orders string
+ InputDemos string
+ Products string
+ NestedFieldDemos string
+ SiteManagement string
+ SEO string
+ UserManagement string
+ Profile string
+ FeaturedModelsManagement string
+ Customers string
+ ListModels string
+ MicrositeModels string
+ Workers string
+ MediaLibrary string
+
+ PagesID string
+ PagesTitle string
+ PagesSlug string
+ PagesLocale string
+ PagesNotes string
+ PagesDraftCount string
+ PagesPath string
+ PagesOnline string
+ PagesVersion string
+ PagesVersions string
+ PagesStartAt string
+ PagesEndAt string
+ PagesOption string
+ PagesLive string
+
+ Page string
+ PagesStatus string
+ PagesSchedule string
+ PagesCategoryID string
+ PagesTemplateSelection string
+ PagesEditContainer string
+
+ WebHeader string
+ WebHeadersColor string
+ Header string
+
+ WebFooter string
+ WebFootersEnglishUrl string
+ WebFootersJapaneseUrl string
+ Footer string
+
+ VideoBanner string
+ VideoBannersAddTopSpace string
+ VideoBannersAddBottomSpace string
+ VideoBannersAnchorID string
+ VideoBannersVideo string
+ VideoBannersBackgroundVideo string
+ VideoBannersMobileBackgroundVideo string
+ VideoBannersVideoCover string
+ VideoBannersMobileVideoCover string
+ VideoBannersHeading string
+ VideoBannersPopupText string
+ VideoBannersText string
+ VideoBannersLinkText string
+ VideoBannersLink string
+
+ Heading string
+ HeadingsAddTopSpace string
+ HeadingsAddBottomSpace string
+ HeadingsAnchorID string
+ HeadingsHeading string
+ HeadingsFontColor string
+ HeadingsBackgroundColor string
+ HeadingsLink string
+ HeadingsLinkText string
+ HeadingsLinkDisplayOption string
+ HeadingsText string
+
+ BrandGrid string
+ BrandGridsAddTopSpace string
+ BrandGridsAddBottomSpace string
+ BrandGridsAnchorID string
+ BrandGridsBrands string
+
+ ListContent string
+ ListContentsAddTopSpace string
+ ListContentsAddBottomSpace string
+ ListContentsAnchorID string
+ ListContentsBackgroundColor string
+ ListContentsItems string
+ ListContentsLink string
+ ListContentsLinkText string
+ ListContentsLinkDisplayOption string
+
+ ImageContainer string
+ ImageContainersAddTopSpace string
+ ImageContainersAddBottomSpace string
+ ImageContainersAnchorID string
+ ImageContainersBackgroundColor string
+ ImageContainersTransitionBackgroundColor string
+ ImageContainersImage string
+ Image string
+
+ InNumber string
+ InNumbersAddTopSpace string
+ InNumbersAddBottomSpace string
+ InNumbersAnchorID string
+ InNumbersHeading string
+ InNumbersItems string
+ InNumbers string
+
+ ContactForm string
+ ContactFormsAddTopSpace string
+ ContactFormsAddBottomSpace string
+ ContactFormsAnchorID string
+ ContactFormsHeading string
+ ContactFormsText string
+ ContactFormsSendButtonText string
+ ContactFormsFormButtonText string
+ ContactFormsMessagePlaceholder string
+ ContactFormsNamePlaceholder string
+ ContactFormsEmailPlaceholder string
+ ContactFormsThankyouMessage string
+ ContactFormsActionUrl string
+ ContactFormsPrivacyPolicy string
+
+ ActivityActionLogIn string
+ ActivityActionExtendSession string
+
+ PagesPage string
+}
+
+var Messages_en_US_ModelsI18nModuleKey = &Messages_ModelsI18nModuleKey{
+ Posts: "Posts",
+ PostsID: "ID",
+ PostsTitle: "Title",
+ PostsHeroImage: "Hero Image",
+ PostsBody: "Content",
+ Example: "QOR5 Demo",
+ Settings: "SEO Settings",
+ Post: "Post",
+ PostsBodyImage: "Content Image",
+
+ SeoPost: "Post",
+ SeoVariableTitle: "Title",
+ SeoVariableSiteName: "Site Name",
+
+ Admin: "Admin",
+ QOR5Example: "QOR5 Example",
+ Roles: "Role Management",
+ Users: "User Management",
+
+ PageBuilder: "Page Builder",
+ Pages: "Pages",
+ SharedContainers: "Shared Containers",
+ DemoContainers: "Demo Containers",
+ Templates: "Templates",
+ PageCategories: "Page Categories",
+ ECManagement: "E-commerce Management",
+ ECDashboard: "E-commerce Dashboard",
+ Orders: "Orders",
+ InputDemos: "Input Demos",
+ Products: "Products",
+ NestedFieldDemos: "Nested Field Demos",
+ SiteManagement: "Site Management",
+ SEO: "SEO",
+ UserManagement: "User Management",
+ Profile: "Profile",
+ FeaturedModelsManagement: "Featured Models Management",
+ Customers: "Customers",
+ ListModels: "List Models",
+ MicrositeModels: "Microsite Models",
+ Workers: "Workers",
+ MediaLibrary: "Media Library",
+
+ PagesID: "ID",
+ PagesTitle: "Title",
+ PagesSlug: "Slug",
+ PagesLocale: "Locale",
+ PagesNotes: "Notes",
+ PagesDraftCount: "Draft Count",
+ PagesPath: "Path",
+ PagesOnline: "Online",
+ PagesVersion: "Version",
+ PagesVersions: "Versions",
+ PagesStartAt: "Start At",
+ PagesEndAt: "End At",
+ PagesOption: "Option",
+ PagesLive: "Live Status",
+
+ Page: "Page",
+ PagesStatus: "Status",
+ PagesSchedule: "Schedule",
+ PagesCategoryID: "Category ID",
+ PagesTemplateSelection: "Template Selection",
+ PagesEditContainer: "Edit Container",
+
+ WebHeader: "Web Header",
+ WebHeadersColor: "Color",
+ Header: "Header",
+
+ WebFooter: "Web Footer",
+ WebFootersEnglishUrl: "English URL",
+ WebFootersJapaneseUrl: "Japanese URL",
+ Footer: "Footer",
+
+ VideoBanner: "Video Banner",
+ VideoBannersAddTopSpace: "Add Top Space",
+ VideoBannersAddBottomSpace: "Add Bottom Space",
+ VideoBannersAnchorID: "Anchor ID",
+ VideoBannersVideo: "Video",
+ VideoBannersBackgroundVideo: "Background Video",
+ VideoBannersMobileBackgroundVideo: "Mobile Background Video",
+ VideoBannersVideoCover: "Video Cover",
+ VideoBannersMobileVideoCover: "Mobile Video Cover",
+ VideoBannersHeading: "Heading",
+ VideoBannersPopupText: "Popup Text",
+ VideoBannersText: "Text",
+ VideoBannersLinkText: "Link Text",
+ VideoBannersLink: "Link",
+
+ Heading: "Heading",
+ HeadingsAddTopSpace: "Add Top Space",
+ HeadingsAddBottomSpace: "Add Bottom Space",
+ HeadingsAnchorID: "Anchor ID",
+ HeadingsHeading: "Heading",
+ HeadingsFontColor: "Font Color",
+ HeadingsBackgroundColor: "Background Color",
+ HeadingsLink: "Link",
+ HeadingsLinkText: "Link Text",
+ HeadingsLinkDisplayOption: "Link Display Option",
+ HeadingsText: "Text",
+
+ BrandGrid: "Brand Grid",
+ BrandGridsAddTopSpace: "Add Top Space",
+ BrandGridsAddBottomSpace: "Add Bottom Space",
+ BrandGridsAnchorID: "Anchor ID",
+ BrandGridsBrands: "Brands",
+
+ ListContent: "List Content",
+ ListContentsAddTopSpace: "Add Top Space",
+ ListContentsAddBottomSpace: "Add Bottom Space",
+ ListContentsAnchorID: "Anchor ID",
+ ListContentsBackgroundColor: "Background Color",
+ ListContentsItems: "Items",
+ ListContentsLink: "Link",
+ ListContentsLinkText: "Link Text",
+ ListContentsLinkDisplayOption: "Link Display Option",
+
+ ImageContainer: "Image Container",
+ ImageContainersAddTopSpace: "Add Top Space",
+ ImageContainersAddBottomSpace: "Add Bottom Space",
+ ImageContainersAnchorID: "Anchor ID",
+ ImageContainersBackgroundColor: "Background Color",
+ ImageContainersTransitionBackgroundColor: "Transition Background Color",
+ ImageContainersImage: "Image",
+ Image: "Image",
+
+ InNumber: "In Number",
+ InNumbersAddTopSpace: "Add Top Space",
+ InNumbersAddBottomSpace: "Add Bottom Space",
+ InNumbersAnchorID: "Anchor ID",
+ InNumbersHeading: "Heading",
+ InNumbersItems: "Items",
+ InNumbers: "In Numbers",
+
+ ContactForm: "Contact Form",
+ ContactFormsAddTopSpace: "Add Top Space",
+ ContactFormsAddBottomSpace: "Add Bottom Space",
+ ContactFormsAnchorID: "Anchor ID",
+ ContactFormsHeading: "Heading",
+ ContactFormsText: "Text",
+ ContactFormsSendButtonText: "Send Button Text",
+ ContactFormsFormButtonText: "Form Button Text",
+ ContactFormsMessagePlaceholder: "Message Placeholder",
+ ContactFormsNamePlaceholder: "Name Placeholder",
+ ContactFormsEmailPlaceholder: "Email Placeholder",
+ ContactFormsThankyouMessage: "Thank You Message",
+ ContactFormsActionUrl: "Action URL",
+ ContactFormsPrivacyPolicy: "Privacy Policy",
+
+ ActivityActionLogIn: "Log In",
+ ActivityActionExtendSession: "Extend Session",
+
+ PagesPage: "Page",
+}
+
+var Messages_zh_CN_ModelsI18nModuleKey = &Messages_ModelsI18nModuleKey{
+ Posts: "帖子 示例",
+ PostsID: "ID",
+ PostsTitle: "标题",
+ PostsHeroImage: "主图",
+ PostsBody: "内容",
+ Example: "QOR5演示",
+ Settings: "SEO 设置",
+ Post: "帖子",
+ PostsBodyImage: "内容图片",
+
+ SeoPost: "帖子",
+ SeoVariableTitle: "标题",
+ SeoVariableSiteName: "站点名称",
+
+ Admin: "管理员",
+ QOR5Example: "QOR5 示例",
+ Roles: "权限管理",
+ Users: "用户管理",
+
+ PageBuilder: "页面管理菜单",
+ Pages: "页面管理",
+ SharedContainers: "公用组件",
+ DemoContainers: "示例组件",
+ Templates: "模板页面",
+ PageCategories: "目录管理",
+ ECManagement: "电子商务管理",
+ ECDashboard: "电子商务仪表盘",
+ Orders: "订单管理",
+ InputDemos: "表单 示例",
+ Products: "产品管理",
+ NestedFieldDemos: "嵌套表单 示例",
+ SiteManagement: "站点管理菜单",
+ SEO: "SEO 管理",
+ UserManagement: "用户管理菜单",
+ Profile: "个人页面",
+ FeaturedModelsManagement: "特色模块管理菜单",
+ Customers: "Customers 示例",
+ ListModels: "发布带排序及分页模块 示例",
+ MicrositeModels: "Microsite 示例",
+ Workers: "后台工作进程管理",
+ MediaLibrary: "媒体库",
+
+ PagesID: "ID",
+ PagesTitle: "标题",
+ PagesSlug: "Slug",
+ PagesLocale: "地区",
+ PagesNotes: "备注",
+ PagesDraftCount: "草稿数",
+ PagesPath: "路径",
+ PagesOnline: "在线",
+ PagesVersion: "版本",
+ PagesVersions: "版本",
+ PagesStartAt: "开始时间",
+ PagesEndAt: "结束时间",
+ PagesOption: "选项",
+ PagesLive: "发布状态",
+
+ Page: "Page",
+ PagesStatus: "状态",
+ PagesSchedule: "PagesSchedule",
+ PagesCategoryID: "PagesCategoryID",
+ PagesTemplateSelection: "PagesTemplateSelection",
+ PagesEditContainer: "PagesEditContainer",
+
+ WebHeader: "WebHeader",
+ WebHeadersColor: "WebHeadersColor",
+ Header: "Header",
+
+ WebFooter: "WebFooter",
+ WebFootersEnglishUrl: "WebFootersEnglishUrl",
+ WebFootersJapaneseUrl: "WebFootersJapaneseUrl",
+ Footer: "Footer",
+
+ VideoBanner: "VideoBanner",
+ VideoBannersAddTopSpace: "VideoBannersAddTopSpace",
+ VideoBannersAddBottomSpace: "VideoBannersAddBottomSpace",
+ VideoBannersAnchorID: "VideoBannersAnchorID",
+ VideoBannersVideo: "VideoBannersVideo",
+ VideoBannersBackgroundVideo: "VideoBannersBackgroundVideo",
+ VideoBannersMobileBackgroundVideo: "VideoBannersMobileBackgroundVideo",
+ VideoBannersVideoCover: "VideoBannersVideoCover",
+ VideoBannersMobileVideoCover: "VideoBannersMobileVideoCover",
+ VideoBannersHeading: "VideoBannersHeading",
+ VideoBannersPopupText: "VideoBannersPopupText",
+ VideoBannersText: "VideoBannersText",
+ VideoBannersLinkText: "VideoBannersLinkText",
+ VideoBannersLink: "VideoBannersLink",
+
+ Heading: "Heading",
+ HeadingsAddTopSpace: "HeadingsAddTopSpace",
+ HeadingsAddBottomSpace: "HeadingsAddBottomSpace",
+ HeadingsAnchorID: "HeadingsAnchorID",
+ HeadingsHeading: "HeadingsHeading",
+ HeadingsFontColor: "HeadingsFontColor",
+ HeadingsBackgroundColor: "HeadingsBackgroundColor",
+ HeadingsLink: "HeadingsLink",
+ HeadingsLinkText: "HeadingsLinkText",
+ HeadingsLinkDisplayOption: "HeadingsLinkDisplayOption",
+ HeadingsText: "HeadingsText",
+
+ BrandGrid: "BrandGrid",
+ BrandGridsAddTopSpace: "BrandGridsAddTopSpace",
+ BrandGridsAddBottomSpace: "BrandGridsAddBottomSpace",
+ BrandGridsAnchorID: "BrandGridsAnchorID",
+ BrandGridsBrands: "BrandGridsBrands",
+
+ ListContent: "ListContent",
+ ListContentsAddTopSpace: "ListContentsAddTopSpace",
+ ListContentsAddBottomSpace: "ListContentsAddBottomSpace",
+ ListContentsAnchorID: "ListContentsAnchorID",
+ ListContentsBackgroundColor: "ListContentsBackgroundColor",
+ ListContentsItems: "ListContentsItems",
+ ListContentsLink: "ListContentsLink",
+ ListContentsLinkText: "ListContentsLinkText",
+ ListContentsLinkDisplayOption: "ListContentsLinkDisplayOption",
+
+ ImageContainer: "ImageContainer",
+ ImageContainersAddTopSpace: "ImageContainersAddTopSpace",
+ ImageContainersAddBottomSpace: "ImageContainersAddBottomSpace",
+ ImageContainersAnchorID: "ImageContainersAnchorID",
+ ImageContainersBackgroundColor: "ImageContainersBackgroundColor",
+ ImageContainersTransitionBackgroundColor: "ImageContainersTransitionBackgroundColor",
+ ImageContainersImage: "ImageContainersImage",
+ Image: "Image",
+
+ InNumber: "InNumber",
+ InNumbersAddTopSpace: "InNumbersAddTopSpace",
+ InNumbersAddBottomSpace: "InNumbersAddBottomSpace",
+ InNumbersAnchorID: "InNumbersAnchorID",
+ InNumbersHeading: "InNumbersHeading",
+ InNumbersItems: "InNumbersItems",
+ InNumbers: "InNumbers",
+
+ ContactForm: "ContactForm",
+ ContactFormsAddTopSpace: "ContactFormsAddTopSpace",
+ ContactFormsAddBottomSpace: "ContactFormsAddBottomSpace",
+ ContactFormsAnchorID: "ContactFormsAnchorID",
+ ContactFormsHeading: "ContactFormsHeading",
+ ContactFormsText: "ContactFormsText",
+ ContactFormsSendButtonText: "ContactFormsSendButtonText",
+ ContactFormsFormButtonText: "ContactFormsFormButtonText",
+ ContactFormsMessagePlaceholder: "ContactFormsMessagePlaceholder",
+ ContactFormsNamePlaceholder: "ContactFormsNamePlaceholder",
+ ContactFormsEmailPlaceholder: "ContactFormsEmailPlaceholder",
+ ContactFormsThankyouMessage: "ContactFormsThankyouMessage",
+ ContactFormsActionUrl: "ContactFormsActionUrl",
+ ContactFormsPrivacyPolicy: "ContactFormsPrivacyPolicy",
+
+ ActivityActionLogIn: "登录",
+ ActivityActionExtendSession: "延长会话",
+
+ PagesPage: "Page",
+}
+
+var Messages_ja_JP_ModelsI18nModuleKey = &Messages_ModelsI18nModuleKey{
+ Posts: "投稿",
+ PostsID: "投稿ID",
+ PostsTitle: "投稿タイトル",
+ PostsHeroImage: "メイン画像",
+ PostsBody: "コンテンツ",
+ Example: "QOR5サンプル",
+ Settings: "設定",
+ Post: "投稿",
+ PostsBodyImage: "内容イメージ",
+
+ SeoPost: "SEO 投稿",
+ SeoVariableTitle: "SEO タイトル",
+ SeoVariableSiteName: "SEO サイト名",
+
+ Admin: "管理員",
+ QOR5Example: "QOR5サンプル",
+ Roles: "ユーザー権限",
+ Users: "ユーザー",
+
+ PageBuilder: "ページビルダー",
+ Pages: "ページ",
+ SharedContainers: "共有コンテナ",
+ DemoContainers: "デモ用コン店た",
+ Templates: "テンプレート",
+ PageCategories: "カテゴリー",
+ ECManagement: "ECマネジメント",
+ ECDashboard: "ECダッシュボード",
+ Orders: "注文",
+ InputDemos: "入力デモ",
+ Products: "製品",
+ SiteManagement: "サイト管理",
+ NestedFieldDemos: "ネストフィールドデモ",
+ SEO: "SEO",
+ UserManagement: "ユーザー管理",
+ Profile: "プロフィール",
+ FeaturedModelsManagement: "モデル管理",
+ Customers: "お客さま",
+ ListModels: "リストモデル",
+ MicrositeModels: "マイクロサイトモデル",
+ Workers: "ワーカーズ",
+ MediaLibrary: "メディアライブラリ",
+
+ PagesID: "ID",
+ PagesTitle: "タイトル",
+ PagesSlug: "スラッグ",
+ PagesLocale: "ローカル",
+ PagesNotes: "ノート",
+ PagesDraftCount: "カウント下書き",
+ PagesPath: "パス",
+ PagesOnline: "オンライン",
+ PagesVersion: "バージョン",
+ PagesVersions: "バージョン",
+ PagesStartAt: "開始日時",
+ PagesEndAt: "終了日時",
+ PagesOption: "オプション",
+ PagesLive: "ライブ",
+
+ Page: "ページ",
+ PagesStatus: "状態",
+ PagesSchedule: "スケジュール",
+ PagesCategoryID: "カテゴリーID",
+ PagesTemplateSelection: "テンプレート選択",
+ PagesEditContainer: "コンテナ編集",
+
+ WebHeader: "ウェブヘッダー",
+ WebHeadersColor: "カラー",
+ Header: "ヘッダー",
+
+ WebFooter: "ウェブ用フッター",
+ WebFootersEnglishUrl: "英語用URL",
+ WebFootersJapaneseUrl: "日本語用URL",
+ Footer: "フッター",
+
+ VideoBanner: "動画バナー",
+ VideoBannersAddTopSpace: "上方に空白を追加",
+ VideoBannersAddBottomSpace: "下方に空白を追加",
+ VideoBannersAnchorID: "アンカーID",
+ VideoBannersVideo: "動画",
+ VideoBannersBackgroundVideo: "背景動画",
+ VideoBannersMobileBackgroundVideo: "モバイル用背景動画",
+ VideoBannersVideoCover: "動画カバー",
+ VideoBannersMobileVideoCover: "モバイル用動画カバー",
+ VideoBannersHeading: "ヘディング",
+ VideoBannersPopupText: "ポップアップ用テキスト",
+ VideoBannersText: "テキスト",
+ VideoBannersLinkText: "リンクテキスト",
+ VideoBannersLink: "リンク",
+
+ Heading: "ヘディング",
+ HeadingsAddTopSpace: "上方に空白を追加",
+ HeadingsAddBottomSpace: "下方に空白を追加",
+ HeadingsAnchorID: "アンカーID",
+ HeadingsHeading: "ヘディング",
+ HeadingsFontColor: "フォント色",
+ HeadingsBackgroundColor: "背景色",
+ HeadingsLink: "リンク",
+ HeadingsLinkText: "リンクテキスト",
+ HeadingsLinkDisplayOption: "リンク表示オプション",
+ HeadingsText: "テキスト",
+
+ BrandGrid: "ブランドグリッド",
+ BrandGridsAddTopSpace: "上方に空白を追加",
+ BrandGridsAddBottomSpace: "下方に空白を追加",
+ BrandGridsAnchorID: "アンカーID",
+ BrandGridsBrands: "ブランド",
+
+ ListContent: "リストコンテンツ",
+ ListContentsAddTopSpace: "上方に空白を追加",
+ ListContentsAddBottomSpace: "下方に空白を追加",
+ ListContentsAnchorID: "アンカーID",
+ ListContentsBackgroundColor: "背景色",
+ ListContentsItems: "アイテム",
+ ListContentsLink: "リンク",
+ ListContentsLinkText: "リンクテキスト",
+ ListContentsLinkDisplayOption: "リンク表示オプション",
+
+ ImageContainer: "画像コンテナ",
+ ImageContainersAddTopSpace: "上方に空白を追加",
+ ImageContainersAddBottomSpace: "ボタン用空白追加",
+ ImageContainersAnchorID: "アンカーID",
+ ImageContainersBackgroundColor: "背景色",
+ ImageContainersTransitionBackgroundColor: "背景色変更",
+ ImageContainersImage: "画像",
+ Image: "画像",
+
+ InNumber: "数字",
+ InNumbersAddTopSpace: "上方に空白を追加",
+ InNumbersAddBottomSpace: "下方に空白を追加",
+ InNumbersAnchorID: "アンカーID",
+ InNumbersHeading: "ヘディング",
+ InNumbersItems: "アイテム",
+ InNumbers: "数字",
+
+ ContactForm: "お問合せフォーム",
+ ContactFormsAddTopSpace: "上方に空白を追加",
+ ContactFormsAddBottomSpace: "下方に空白を追加",
+ ContactFormsAnchorID: "アンカーID",
+ ContactFormsHeading: "ヘディング",
+ ContactFormsText: "テキスト",
+ ContactFormsSendButtonText: "送信ボタン用テキスト",
+ ContactFormsFormButtonText: "ウェブフォームボタン用テキスト",
+ ContactFormsMessagePlaceholder: "メッセージ",
+ ContactFormsNamePlaceholder: "名前",
+ ContactFormsEmailPlaceholder: "メールアドレス",
+ ContactFormsThankyouMessage: "サンキューメッセージ",
+ ContactFormsActionUrl: "アクションURL",
+ ContactFormsPrivacyPolicy: "プライバシーポリシー",
+
+ ActivityActionLogIn: "ログイン",
+ ActivityActionExtendSession: "セッション延長",
+
+ PagesPage: "ページ",
+}
diff --git a/starter/middlewares.go b/starter/middlewares.go
new file mode 100644
index 000000000..c79edcdf8
--- /dev/null
+++ b/starter/middlewares.go
@@ -0,0 +1,45 @@
+package starter
+
+import (
+ "net/http"
+
+ "github.com/qor5/admin/v3/role"
+ "gorm.io/gorm"
+)
+
+func withRoles(db *gorm.DB) func(next http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ u := GetCurrentUser(r)
+ if u == nil {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ var roleIDs []uint
+ if err := db.Table("user_role_join").Select("role_id").Where("user_id=?", u.ID).Scan(&roleIDs).Error; err != nil {
+ panic(err)
+ }
+ if len(roleIDs) > 0 {
+ var roles []role.Role
+ if err := db.Where("id in (?)", roleIDs).Find(&roles).Error; err != nil {
+ panic(err)
+ }
+ u.Roles = roles
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+func securityMiddleware() func(next http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ w.Header().Add("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
+ w.Header().Add("Cache-control", "no-cache, no-store, max-age=0, must-revalidate")
+ w.Header().Add("Pragma", "no-cache")
+
+ next.ServeHTTP(w, req)
+ })
+ }
+}
diff --git a/starter/model.go b/starter/model.go
new file mode 100644
index 000000000..7108a216d
--- /dev/null
+++ b/starter/model.go
@@ -0,0 +1,74 @@
+package starter
+
+import (
+ "time"
+
+ "github.com/qor5/admin/v3/role"
+ "github.com/qor5/x/v3/login"
+ "gorm.io/gorm"
+)
+
+const (
+ RoleAdmin = "Admin"
+ RoleManager = "Manager"
+ RoleEditor = "Editor"
+ RoleViewer = "Viewer"
+
+ OAuthProviderGoogle = "google"
+ OAuthProviderMicrosoftOnline = "microsoftonline"
+ OAuthProviderGithub = "github"
+
+ StatusActive = "active"
+ StatusInactive = "inactive"
+)
+
+var DefaultRoles = []string{
+ RoleAdmin,
+ RoleManager,
+ RoleEditor,
+ RoleViewer,
+}
+
+var OAuthProviders = []string{
+ OAuthProviderGoogle,
+ OAuthProviderMicrosoftOnline,
+ OAuthProviderGithub,
+}
+
+type User struct {
+ gorm.Model
+
+ Name string
+ Company string
+ Roles []role.Role `gorm:"many2many:user_role_join;"`
+ Status string
+ UpdatedAt time.Time
+ CreatedAt time.Time
+ RegistrationDate time.Time `gorm:"type:date"`
+
+ login.UserPass
+ login.OAuthInfo
+ login.SessionSecure
+}
+
+func (u User) GetName() string {
+ return u.Name
+}
+
+func (u User) GetID() uint {
+ return u.ID
+}
+
+func (u User) GetRoles() (rs []string) {
+ for _, r := range u.Roles {
+ rs = append(rs, r.Name)
+ }
+ if len(rs) == 0 {
+ rs = []string{RoleViewer}
+ }
+ return
+}
+
+func (u User) IsOAuthUser() bool {
+ return u.OAuthProvider != "" && u.OAuthIdentifier != ""
+}
diff --git a/starter/pagebuilder.go b/starter/pagebuilder.go
new file mode 100644
index 000000000..80f6df98e
--- /dev/null
+++ b/starter/pagebuilder.go
@@ -0,0 +1,99 @@
+package starter
+
+import (
+ "net/http"
+ "net/url"
+
+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "github.com/qor5/admin/v3/activity"
+ "github.com/qor5/admin/v3/l10n"
+ "github.com/qor5/admin/v3/media"
+ "github.com/qor5/admin/v3/pagebuilder"
+ "github.com/qor5/admin/v3/presets"
+ "github.com/qor5/admin/v3/publish"
+ "github.com/qor5/admin/v3/seo"
+ "github.com/qor5/web/v3"
+ "github.com/qor5/x/v3/i18n"
+ "github.com/qor5/x/v3/oss"
+ "github.com/qor5/x/v3/s3x"
+
+ vx "github.com/qor5/x/v3/ui/vuetifyx"
+)
+
+var SetupPageBuilderForHandler = []any{
+ CreateSEOBuilder,
+ CreatePublishStorage,
+ CreatePublisher,
+ CreatePageBuilder,
+}
+
+// CreateSEOBuilder creates and configures the SEO builder
+func CreateSEOBuilder(a *Handler, l10nBuilder *l10n.Builder) *seo.Builder {
+ return seo.New(a.DB, seo.WithLocales(l10nBuilder.GetSupportLocaleCodes()...)).AutoMigrate()
+}
+
+// CreatePublishStorage configures S3 storage for publishing
+func CreatePublishStorage(a *Handler) oss.StorageInterface {
+ a.S3Publish.ACL = string(types.ObjectCannedACLBucketOwnerFullControl)
+ return s3x.SetupClient(&a.S3Publish, nil)
+}
+
+// CreatePublisher creates and configures the publisher
+func CreatePublisher(a *Handler, publishStorage oss.StorageInterface, l10nBuilder *l10n.Builder, activityBuilder *activity.Builder) *publish.Builder {
+ publisher := publish.New(a.DB, publishStorage).
+ ContextValueFuncs(l10nBuilder.ContextValueProvider).
+ Activity(activityBuilder)
+
+ a.Use(publisher)
+ return publisher
+}
+
+// CreatePageBuilder creates and configures the page builder
+func CreatePageBuilder(a *Handler, presetsBuilder *presets.Builder, mediaBuilder *media.Builder, l10nBuilder *l10n.Builder, activityBuilder *activity.Builder, publisher *publish.Builder, seoBuilder *seo.Builder, mux *http.ServeMux) *pagebuilder.Builder {
+ pageBuilder := pagebuilder.New("/page_builder", a.DB, presetsBuilder).
+ Media(mediaBuilder).
+ L10n(l10nBuilder).
+ Activity(activityBuilder).
+ Publisher(publisher).
+ SEO(seoBuilder).
+ PreviewContainer(false).
+ WrapPageInstall(func(in presets.ModelInstallFunc) presets.ModelInstallFunc {
+ return func(pb *presets.Builder, pm *presets.ModelBuilder) (err error) {
+ err = in(pb, pm)
+ if err != nil {
+ return
+ }
+ pmListing := pm.Listing()
+ pmListing.FilterDataFunc(func(ctx *web.EventContext) vx.FilterData {
+ item, err := activityBuilder.MustGetModelBuilder(pm).NewHasUnreadNotesFilterItem(ctx.R.Context(), "")
+ if err != nil {
+ panic(err)
+ }
+ return []*vx.FilterItem{item}
+ })
+
+ pmListing.FilterTabsFunc(func(ctx *web.EventContext) []*presets.FilterTab {
+ msgr := i18n.MustGetModuleMessages(ctx.R, I18nDemoKey, Messages_en_US).(*Messages)
+
+ tab, err := activityBuilder.MustGetModelBuilder(pm).NewHasUnreadNotesFilterTab(ctx.R.Context())
+ if err != nil {
+ panic(err)
+ }
+ return []*presets.FilterTab{
+ {
+ Label: msgr.FilterTabsAll,
+ ID: "all",
+ Query: url.Values{"all": []string{"1"}},
+ },
+ tab,
+ }
+ })
+ return nil
+ }
+ }).AutoMigrate()
+
+ mux.Handle("/page_builder/", pageBuilder)
+
+ a.Use(pageBuilder)
+ return pageBuilder
+}
diff --git a/starter/pagebuilder_test.go b/starter/pagebuilder_test.go
new file mode 100644
index 000000000..3a1524474
--- /dev/null
+++ b/starter/pagebuilder_test.go
@@ -0,0 +1,43 @@
+package starter_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/qor5/admin/v3/starter"
+ "github.com/qor5/web/v3/multipartestutils"
+ "github.com/qor5/x/v3/gormx"
+ "github.com/theplant/gofixtures"
+ "github.com/theplant/inject"
+)
+
+var pageBuilderTestData = gofixtures.Data(gofixtures.Sql(`
+INSERT INTO public.page_builder_pages (id, created_at, updated_at, deleted_at, title, slug, category_id, seo, status, online_url, scheduled_start_at, scheduled_end_at, actual_start_at, actual_end_at, version, version_name, parent_version, locale_code) VALUES
+ (10, '2024-05-21 01:54:45.280106 +00:00', '2024-05-21 01:54:57.983233 +00:00', null, '1234567', '/12313', 0, '{"Title":"{{Title}}default","EnabledCustomize":true}', 'draft', '', null, null, null, null, '2024-05-21-v01', '2024-05-21-v01', '', 'China');
+SELECT setval('page_builder_pages_id_seq', 10, true);
+`, []string{"page_builder_pages"}))
+
+func TestPageBuilder(t *testing.T) {
+ env := newTestEnv(t, starter.SetupPageBuilderForHandler)
+ suite := inject.MustResolve[*gormx.TestSuite](env.lc)
+ db := suite.DB()
+ dbr, _ := db.DB()
+ cases := []multipartestutils.TestCase{
+ {
+ Name: "Index Page",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ pageBuilderTestData.TruncatePut(dbr)
+ return httptest.NewRequest("GET", "/pages", nil)
+ },
+ ExpectPageBodyContainsInOrder: []string{"1234567"},
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.Name, func(t *testing.T) {
+ multipartestutils.RunCase(t, c, env.handler)
+ })
+ }
+}
diff --git a/starter/perm.go b/starter/perm.go
new file mode 100644
index 000000000..843ef80b8
--- /dev/null
+++ b/starter/perm.go
@@ -0,0 +1,54 @@
+package starter
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/ory/ladon"
+ "github.com/qor5/admin/v3/activity"
+ "github.com/qor5/admin/v3/presets"
+ "github.com/qor5/x/v3/perm"
+)
+
+func (a *Handler) configurePermission(b *presets.Builder) {
+ perm.Verbose = true
+
+ b.Permission(
+ perm.New().Policies(
+ perm.PolicyFor(perm.Anybody).WhoAre(perm.Allowed).ToDo(perm.Anything).On(perm.Anything),
+ perm.PolicyFor(
+ RoleViewer,
+ RoleEditor,
+ RoleManager,
+ ).WhoAre(perm.Denied).ToDo(presets.PermCreate, presets.PermUpdate, presets.PermDelete).On("*:roles:*", "*:users:*"),
+ perm.PolicyFor(RoleViewer).WhoAre(perm.Denied).ToDo(presets.PermCreate, presets.PermUpdate, presets.PermDelete).On(perm.Anything),
+
+ perm.PolicyFor(RoleManager).WhoAre(perm.Denied).ToDo(perm.Anything).
+ On("*:activity_logs").On("*:activity_logs:*").
+ Given(perm.Conditions{
+ "is_authorized": &ladon.BooleanCondition{},
+ }),
+ ).SubjectsFunc(func(r *http.Request) []string {
+ u := GetCurrentUser(r)
+ if u == nil {
+ return nil
+ }
+ return u.GetRoles()
+ }).ContextFunc(func(r *http.Request, objs []any) perm.Context {
+ c := make(perm.Context)
+ for _, obj := range objs {
+ // nolint:gocritic
+ switch v := obj.(type) {
+ case *activity.ActivityLog:
+ u := GetCurrentUser(r)
+ if fmt.Sprint(u.GetID()) == v.UserID {
+ c["is_authorized"] = true
+ } else {
+ c["is_authorized"] = false
+ }
+ }
+ }
+ return c
+ }).DBPolicy(perm.NewDBPolicy(a.DB)),
+ )
+}
diff --git a/starter/provider.go b/starter/provider.go
new file mode 100644
index 000000000..2989a3fc3
--- /dev/null
+++ b/starter/provider.go
@@ -0,0 +1,34 @@
+package starter
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/theplant/inject"
+)
+
+func setupHandlerFactory(build func(ctx context.Context, handler *Handler, ctors ...any) error, ctors ...any) func(ctx context.Context, inj *inject.Injector, conf *Config, mux *http.ServeMux) (*Handler, error) {
+ return func(ctx context.Context, inj *inject.Injector, conf *Config, mux *http.ServeMux) (*Handler, error) {
+ handler := NewHandler(conf)
+ if err := handler.SetParent(inj); err != nil {
+ return nil, err
+ }
+ if err := build(ctx, handler, ctors...); err != nil {
+ return nil, err
+ }
+ mux.Handle("/", handler)
+ return handler, nil
+ }
+}
+
+func SetupHandlerFactory(ctors ...any) func(ctx context.Context, inj *inject.Injector, conf *Config, mux *http.ServeMux) (*Handler, error) {
+ return setupHandlerFactory(func(ctx context.Context, handler *Handler, ctors ...any) error {
+ return handler.Build(ctx, ctors...)
+ }, ctors...)
+}
+
+func SetupTestHandlerFactory(ctors ...any) func(ctx context.Context, inj *inject.Injector, conf *Config, mux *http.ServeMux) (*Handler, error) {
+ return setupHandlerFactory(func(ctx context.Context, handler *Handler, ctors ...any) error {
+ return handler.BuildForTest(ctx, ctors...)
+ }, ctors...)
+}
diff --git a/starter/setup_test.go b/starter/setup_test.go
new file mode 100644
index 000000000..fee1c572f
--- /dev/null
+++ b/starter/setup_test.go
@@ -0,0 +1,79 @@
+package starter_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/qor5/admin/v3/starter"
+ "github.com/qor5/x/v3/gormx"
+ "github.com/stretchr/testify/require"
+ "github.com/theplant/inject"
+ "github.com/theplant/inject/lifecycle"
+ "gorm.io/gorm"
+
+ _ "embed"
+)
+
+func TestMain(m *testing.M) {
+ m.Run()
+}
+
+func setupDummyUserOptions() *starter.UpsertUserOptions {
+ return &starter.UpsertUserOptions{
+ Email: "test@example.com",
+ Password: "test123456789",
+ Role: []string{starter.RoleAdmin},
+ }
+}
+
+func setupTestConfig(ctx context.Context) (*starter.Config, error) {
+ loader, err := starter.InitializeConfig()
+ if err != nil {
+ return nil, err
+ }
+ conf, err := loader(ctx, "testdata/config.yaml")
+ if err != nil {
+ return nil, err
+ }
+ return conf, nil
+}
+
+func setupTestHandlerFactory(ctors ...any) []any {
+ ctors = append(ctors,
+ setupDummyUserOptions,
+ )
+ return []any{
+ starter.SetupTestHandlerFactory(ctors...),
+ }
+}
+
+type env struct {
+ lc *lifecycle.Lifecycle
+ handler *starter.Handler
+}
+
+func newTestEnv(t *testing.T, ctors ...any) *env {
+ lc, err := lifecycle.Start(context.Background(),
+ lifecycle.SetupSignal,
+ gormx.SetupTestSuiteFactory(),
+ func(testSuite *gormx.TestSuite) *gorm.DB {
+ return testSuite.DB()
+ },
+ setupTestConfig,
+ http.NewServeMux,
+ setupTestHandlerFactory(ctors...),
+ )
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ err := lc.Stop(context.Background())
+ require.NoError(t, err)
+ })
+
+ handler := inject.MustResolve[*starter.Handler](lc)
+
+ return &env{
+ lc: lc,
+ handler: handler,
+ }
+}
diff --git a/starter/testdata/config.yaml b/starter/testdata/config.yaml
new file mode 100644
index 000000000..fea010ec2
--- /dev/null
+++ b/starter/testdata/config.yaml
@@ -0,0 +1,20 @@
+database:
+ dsn: "placeholder"
+
+s3:
+ bucket: "qor5-test-example"
+ region: "ap-northeast-1"
+ endpoint: "https://cdn.qor5.theplant-dev.com"
+s3Publish:
+ bucket: "qor5-test-example"
+ region: "ap-northeast-1"
+ endpoint: "https://cdn.qor5.theplant-dev.com"
+auth:
+ secret: "test"
+ googleClientKey: "test"
+ googleClientSecret: "test"
+ microsoftClientKey: "test"
+ microsoftClientSecret: "test"
+ githubClientKey: "test"
+ githubClientSecret: "test"
+
diff --git a/starter/testhelper.go b/starter/testhelper.go
new file mode 100644
index 000000000..408a7534b
--- /dev/null
+++ b/starter/testhelper.go
@@ -0,0 +1,134 @@
+package starter
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "time"
+
+ "github.com/golang-jwt/jwt/v4"
+ "github.com/google/uuid"
+ "github.com/pkg/errors"
+ "github.com/qor5/x/v3/login"
+ "github.com/theplant/inject"
+
+ plogin "github.com/qor5/admin/v3/login"
+)
+
+// signClaims signs JWT claims with the provided secret
+func signClaims(claims jwt.Claims, secret string) (string, error) {
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ signed, err := token.SignedString([]byte(secret))
+ if err != nil {
+ return "", errors.New("failed to sign claims")
+ }
+ return signed, nil
+}
+
+func createDummyAuthCookies(ctx context.Context, user *User, secret string, sessionBuilder *plogin.SessionBuilder) ([]*http.Cookie, error) {
+ if user.GetSecure() == "" {
+ return nil, errors.New("secure salt is empty")
+ }
+
+ cookies := make([]*http.Cookie, 0, 2)
+
+ userID := fmt.Sprint(user.GetID())
+ now := time.Now()
+ maxAge := 3600
+ claims := login.UserClaims{
+ UserID: userID,
+ PassUpdatedAt: user.GetPasswordUpdatedAt(),
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(maxAge) * time.Second)),
+ IssuedAt: jwt.NewNumericDate(now),
+ NotBefore: jwt.NewNumericDate(now),
+ Subject: userID,
+ ID: uuid.New().String(),
+ },
+ }
+
+ tokenValue, err := signClaims(claims, secret)
+ if err != nil {
+ return nil, err
+ }
+ cookies = append(cookies, &http.Cookie{
+ Name: "auth",
+ Value: tokenValue,
+ Path: "/",
+ MaxAge: 3600,
+ HttpOnly: true,
+ })
+
+ tokenValue, err = signClaims(claims.RegisteredClaims, secret+user.GetSecure())
+ if err != nil {
+ return nil, err
+ }
+ cookies = append(cookies, &http.Cookie{
+ Name: "qor5_auth_secure",
+ Value: tokenValue,
+ Path: "/",
+ MaxAge: 3600,
+ HttpOnly: true,
+ })
+
+ if sessionBuilder != nil {
+ r := httptest.NewRequest("GET", "/", nil).WithContext(ctx)
+ for _, cookie := range cookies {
+ r.AddCookie(cookie)
+ }
+ if err := sessionBuilder.CreateSession(r, userID); err != nil {
+ return nil, errors.Wrap(err, "failed to create session")
+ }
+ }
+
+ return cookies, nil
+}
+
+func (h *Handler) BuildForTest(ctx context.Context, ctors ...any) error {
+ if err := h.Build(ctx, ctors...); err != nil {
+ return err
+ }
+
+ var upsertUserOpts *UpsertUserOptions
+ if err := h.ResolveContext(ctx, &upsertUserOpts); err != nil && !errors.Is(err, inject.ErrTypeNotProvided) {
+ return err
+ }
+ if upsertUserOpts == nil {
+ return nil
+ }
+
+ var sessionBuilder *plogin.SessionBuilder
+ if err := h.ResolveContext(ctx, &sessionBuilder); err != nil {
+ return err
+ }
+
+ user, err := UpsertUser(ctx, h.DB, upsertUserOpts)
+ if err != nil {
+ return err
+ }
+
+ uid := fmt.Sprint(user.GetID())
+ if user.GetSecure() == "" {
+ err := user.UpdateSecure(h.DB, user, uid)
+ if err != nil {
+ return err
+ }
+ }
+
+ cookies, err := createDummyAuthCookies(ctx, user, h.Auth.Secret, sessionBuilder)
+ if err != nil {
+ return err
+ }
+
+ h.WithHandlerHook(func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ for _, cookie := range cookies {
+ r.AddCookie(cookie)
+ }
+ next.ServeHTTP(w, r)
+ })
+ })
+
+ return nil
+}
diff --git a/starter/user.go b/starter/user.go
new file mode 100644
index 000000000..62ab1f77d
--- /dev/null
+++ b/starter/user.go
@@ -0,0 +1,424 @@
+package starter
+
+import (
+ "fmt"
+ "net/url"
+ "slices"
+ "strconv"
+ "time"
+
+ "github.com/pkg/errors"
+ "github.com/qor5/admin/v3/activity"
+ "github.com/qor5/admin/v3/presets"
+ "github.com/qor5/admin/v3/presets/gorm2op"
+ "github.com/qor5/admin/v3/role"
+ "github.com/qor5/web/v3"
+ "github.com/qor5/x/v3/i18n"
+ "github.com/qor5/x/v3/login"
+ "github.com/qor5/x/v3/perm"
+ "gorm.io/gorm"
+
+ plogin "github.com/qor5/admin/v3/login"
+ v "github.com/qor5/x/v3/ui/vuetify"
+ vx "github.com/qor5/x/v3/ui/vuetifyx"
+ h "github.com/theplant/htmlgo"
+)
+
+type UserModelBuilder *presets.ModelBuilder
+
+// createUserModelBuilder creates and configures the user model builder
+func (a *Handler) createUserModelBuilder(presetsBuilder *presets.Builder, activityBuilder *activity.Builder, loginSessionBuilder *plogin.SessionBuilder) UserModelBuilder {
+ umb := presetsBuilder.Model(&User{})
+ defer func() { activityBuilder.RegisterModel(umb) }()
+
+ // ========== Search Configuration ==========
+ umb.Listing().SearchFunc(func(ctx *web.EventContext, params *presets.SearchParams) (*presets.SearchResult, error) {
+ u := GetCurrentUser(ctx.R)
+ qdb := a.DB.WithContext(ctx.R.Context())
+
+ // If the current user doesn't have 'admin' role, do not allow them to view admin and manager users
+ // We didn't do this on permission because we are not supporting the permission on listing page
+ if currentRoles := u.GetRoles(); !slices.Contains(currentRoles, RoleAdmin) {
+ qdb = qdb.Joins("inner join user_role_join urj on users.id = urj.user_id inner join roles r on r.id = urj.role_id").
+ Group("users.id").
+ Having("COUNT(CASE WHEN r.name in (?) THEN 1 END) = 0", []string{RoleAdmin, RoleManager})
+ }
+
+ result, err := gorm2op.DataOperator(qdb).Search(ctx, params)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to search users")
+ }
+ return result, nil
+ })
+
+ // ========== Editing Configuration ==========
+ editing := umb.Editing(
+ "Type",
+ "Actions",
+ "Name",
+ "OAuthProvider",
+ "OAuthIdentifier",
+ "Account",
+ "Company",
+ "Roles",
+ "Status",
+ )
+
+ // Configure side panel for activity timeline
+ editing.SidePanelFunc(func(obj any, ctx *web.EventContext) h.HTMLComponent {
+ if ctx.R.FormValue(presets.ParamID) == "" {
+ return nil
+ }
+ return activityBuilder.MustGetModelBuilder(umb).NewTimelineCompo(ctx, obj, "_side")
+ })
+
+ // Configure validation
+ editing.ValidateFunc(func(obj any, _ *web.EventContext) (err web.ValidationErrors) {
+ u := obj.(*User)
+ if u.OAuthProvider == "" && u.Account == "" {
+ err.FieldError("Account", "Email is required")
+ }
+ return
+ })
+
+ // ========== Event Handlers ==========
+ // Event: Unlock user
+ umb.RegisterEventFunc("eventUnlockUser", func(ctx *web.EventContext) (r web.EventResponse, err error) {
+ uid := ctx.R.FormValue("id")
+ var u User
+ if err := a.DB.WithContext(ctx.R.Context()).Where("id = ?", uid).First(&u).Error; err != nil {
+ return r, errors.Wrap(err, "failed to find user")
+ }
+ if err = u.UnlockUser(a.DB, &User{}); err != nil {
+ return r, err
+ }
+ presets.ShowMessage(&r, "success", "")
+ editing.UpdateOverlayContent(ctx, &r, &u, "", nil)
+ return r, nil
+ })
+
+ // Event: Send reset password email
+ umb.RegisterEventFunc("eventSendResetPasswordEmail", func(ctx *web.EventContext) (r web.EventResponse, err error) {
+ uid := ctx.R.FormValue("id")
+ var u User
+ if err := a.DB.WithContext(ctx.R.Context()).Where("id = ?", uid).First(&u).Error; err != nil {
+ return r, errors.Wrap(err, "failed to find user")
+ }
+ token, err := u.GenerateResetPasswordToken(a.DB, &User{})
+ if err != nil {
+ return r, err
+ }
+ r.RunScript = fmt.Sprintf(`alert("http://localhost:9500/auth/reset-password?id=%s&token=%s")`, uid, token)
+ return r, nil
+ })
+
+ // Event: Revoke TOTP
+ umb.RegisterEventFunc("eventRevokeTOTP", func(ctx *web.EventContext) (r web.EventResponse, err error) {
+ uid := ctx.R.FormValue("id")
+ var u *User
+ if err := a.DB.WithContext(ctx.R.Context()).Where("id = ?", uid).First(&u).Error; err != nil {
+ return r, errors.Wrap(err, "failed to find user")
+ }
+ err = login.RevokeTOTP(u, a.DB, &User{}, fmt.Sprint(u.ID))
+ if err != nil {
+ return r, errors.WithStack(err)
+ }
+ err = loginSessionBuilder.ExpireAllSessions(fmt.Sprint(u.ID))
+ if err != nil {
+ return r, errors.Wrap(err, "failed to expire all sessions")
+ }
+ presets.ShowMessage(&r, "success", "")
+ editing.UpdateOverlayContent(ctx, &r, u, "", nil)
+ return r, nil
+ })
+
+ // ========== Field Configurations ==========
+ editing.Field("Type").ComponentFunc(func(obj any, _ *presets.FieldContext, _ *web.EventContext) h.HTMLComponent {
+ u := obj.(*User)
+ if u.ID == 0 {
+ return nil
+ }
+
+ var accountType string
+ if u.IsOAuthUser() {
+ accountType = "OAuth Account"
+ } else {
+ accountType = "Main Account"
+ }
+
+ return h.Div(
+ v.VRow(
+ v.VCol(
+ h.Text(accountType),
+ ).Class("text-left deep-orange--text"),
+ ),
+ ).Class("mb-2")
+ })
+
+ editing.Field("Actions").ComponentFunc(func(obj any, _ *presets.FieldContext, _ *web.EventContext) h.HTMLComponent {
+ var actionBtns h.HTMLComponents
+ u := obj.(*User)
+
+ if !u.IsOAuthUser() && u.Account != "" {
+ actionBtns = append(actionBtns,
+ v.VBtn("Send Reset Password Email").
+ Color("primary").
+ Attr("@click", web.Plaid().EventFunc("eventSendResetPasswordEmail").
+ Query("id", u.ID).Go()),
+ )
+ }
+
+ if u.GetLocked() {
+ actionBtns = append(actionBtns,
+ v.VBtn("Unlock").Color("primary").
+ Attr("@click", web.Plaid().EventFunc("eventUnlockUser").
+ Query("id", u.ID).Go(),
+ ),
+ )
+ }
+
+ if u.GetIsTOTPSetup() {
+ actionBtns = append(actionBtns,
+ v.VBtn("Revoke TOTP").
+ Color("primary").
+ Attr("@click", web.Plaid().EventFunc("eventRevokeTOTP").
+ Query("id", u.ID).Go()),
+ )
+ }
+
+ if len(actionBtns) == 0 {
+ return nil
+ }
+ return h.Div(
+ actionBtns...,
+ ).Class("mb-5 text-right")
+ })
+
+ editing.Field("Account").Label("Email").ComponentFunc(func(obj any, field *presets.FieldContext, _ *web.EventContext) h.HTMLComponent {
+ return vx.VXField().Attr(web.VField(field.Name, field.Value(obj))...).Label(field.Label).ErrorMessages(field.Errors...)
+ }).SetterFunc(func(obj any, field *presets.FieldContext, ctx *web.EventContext) (err error) {
+ u := obj.(*User)
+ email := ctx.R.FormValue(field.Name)
+ if email == "" {
+ return
+ }
+ u.Account = email
+ u.OAuthIdentifier = email
+ return nil
+ })
+
+ editing.Field("OAuthProvider").Label("OAuth Provider").ComponentFunc(func(obj any, field *presets.FieldContext, _ *web.EventContext) h.HTMLComponent {
+ u := obj.(*User)
+ if !u.IsOAuthUser() && u.ID != 0 {
+ return nil
+ } else {
+ return v.VSelect().Attr(web.VField(field.Name, field.Value(obj))...).
+ Label(field.Label).
+ Items(OAuthProviders)
+ }
+ })
+
+ editing.Field("OAuthIdentifier").Label("OAuth Identifier").ComponentFunc(func(obj any, field *presets.FieldContext, _ *web.EventContext) h.HTMLComponent {
+ u := obj.(*User)
+ if !u.IsOAuthUser() {
+ return nil
+ } else {
+ return v.VTextField().Attr(web.VField(field.Name, field.Value(obj))...).Label(field.Label).ErrorMessages(field.Errors...).Disabled(true)
+ }
+ })
+
+ editing.Field("Roles").
+ ComponentFunc(func(obj any, field *presets.FieldContext, ctx *web.EventContext) h.HTMLComponent {
+ var selectedItems []v.DefaultOptionItem
+ var values []string
+ u, ok := obj.(*User)
+ if ok && u.ID != 0 {
+ var userWithRoles User
+ if err := a.DB.WithContext(ctx.R.Context()).Preload("Roles").Where("id = ?", u.ID).First(&userWithRoles).Error; err != nil {
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ panic(err)
+ }
+ }
+ for _, r := range userWithRoles.Roles {
+ values = append(values, fmt.Sprint(r.ID))
+ selectedItems = append(selectedItems, v.DefaultOptionItem{
+ Text: r.Name,
+ Value: fmt.Sprint(r.ID),
+ })
+ }
+ }
+
+ var roles []role.Role
+ a.DB.Find(&roles)
+ var allRoleItems []v.DefaultOptionItem
+ for _, r := range roles {
+ allRoleItems = append(allRoleItems, v.DefaultOptionItem{
+ Text: r.Name,
+ Value: fmt.Sprint(r.ID),
+ })
+ }
+
+ return vx.VXSelect().Label(field.Label).Chips(true).
+ Items(allRoleItems).ItemTitle("text").ItemValue("value").
+ Multiple(true).Attr(presets.VFieldError(field.Name, values, field.Errors)...).
+ Disabled(field.Disabled)
+ }).
+ SetterFunc(func(obj any, field *presets.FieldContext, ctx *web.EventContext) (err error) {
+ u, ok := obj.(*User)
+ if !ok {
+ return
+ }
+ if u.GetAccountName() == a.Auth.InitialUserEmail {
+ return perm.PermissionDenied //nolint:errhandle
+ }
+ rids := ctx.R.Form[field.Name]
+ var roles []role.Role
+ for _, id := range rids {
+ uid, err1 := strconv.Atoi(id)
+ if err1 != nil {
+ continue
+ }
+ roles = append(roles, role.Role{
+ Model: gorm.Model{ID: uint(uid)},
+ })
+ }
+ u.Roles = roles
+ return
+ })
+
+ // Field: Status
+ editing.Field("Status").
+ ComponentFunc(func(obj any, field *presets.FieldContext, _ *web.EventContext) h.HTMLComponent {
+ return vx.VXSelect().Attr(presets.VFieldError(field.Name, field.Value(obj), field.Errors)...).
+ Label(field.Label).
+ Items([]string{"active", "inactive"})
+ })
+
+ // ========== Save Logic Configuration ==========
+ editing.WrapSaveFunc(func(in presets.SaveFunc) presets.SaveFunc {
+ return func(obj any, id string, ctx *web.EventContext) error {
+ u := obj.(*User)
+ if u.GetAccountName() == a.Auth.InitialUserEmail {
+ return perm.PermissionDenied //nolint:errhandle
+ }
+ if u.RegistrationDate.IsZero() {
+ u.RegistrationDate = time.Now()
+ }
+ if err := a.DB.Transaction(func(tx *gorm.DB) error {
+ ctx.WithContextValue(gorm2op.CtxKeyDB{}, tx)
+ defer ctx.WithContextValue(gorm2op.CtxKeyDB{}, nil)
+ // First save the user to ensure we have a valid ID (covers both create and update)
+ if err := in(obj, id, ctx); err != nil {
+ return err
+ }
+ // Explicitly replace user roles in the join table
+ var roleIDs []uint
+ for _, r := range u.Roles {
+ if r.ID != 0 {
+ roleIDs = append(roleIDs, r.ID)
+ }
+ }
+ if err := replaceUserRoles(tx, u.ID, roleIDs); err != nil {
+ return err
+ }
+ return nil
+ }); err != nil {
+ return errors.Wrap(err, "failed to save user")
+ }
+ return nil
+ }
+ })
+
+ // ========== Listing Configuration ==========
+ listing := umb.Listing("ID", "Name", "Account", "Status", activity.ListFieldNotes).PerPage(10)
+ listing.Field("Account").Label("Email")
+ listing.SearchColumns("users.Name", "Account")
+
+ // Configure filter data
+ listing.FilterDataFunc(func(ctx *web.EventContext) vx.FilterData {
+ item, err := activityBuilder.MustGetModelBuilder(umb).NewHasUnreadNotesFilterItem(ctx.R.Context(), "users.")
+ if err != nil {
+ panic(err)
+ }
+ return []*vx.FilterItem{
+ item,
+ {
+ Key: "created",
+ Label: "Create Time",
+ ItemType: vx.ItemTypeDatetimeRange,
+ SQLCondition: `users.created_at %s ?`,
+ },
+ {
+ Key: "name",
+ Label: "Name",
+ ItemType: vx.ItemTypeString,
+ SQLCondition: `users.name %s ?`,
+ },
+ {
+ Key: "status",
+ Label: "Status",
+ ItemType: vx.ItemTypeSelect,
+ SQLCondition: `users.status %s ?`,
+ Options: []*vx.SelectItem{
+ {Text: "Active", Value: "active"},
+ {Text: "Inactive", Value: "inactive"},
+ },
+ },
+ {
+ Key: "registration_date",
+ Label: "Registration Date",
+ ItemType: vx.ItemTypeDate,
+ SQLCondition: `users.registration_date %s ?`,
+ Folded: true,
+ },
+ {
+ Key: "registration_date_range",
+ Label: "Registration Date Range",
+ ItemType: vx.ItemTypeDateRange,
+ SQLCondition: `users.registration_date %s ?`,
+ Folded: true,
+ },
+ }
+ })
+
+ // Configure filter tabs
+ listing.FilterTabsFunc(func(ctx *web.EventContext) []*presets.FilterTab {
+ msgr := i18n.MustGetModuleMessages(ctx.R, I18nDemoKey, Messages_en_US).(*Messages)
+
+ tab, err := activityBuilder.MustGetModelBuilder(umb).NewHasUnreadNotesFilterTab(ctx.R.Context())
+ if err != nil {
+ panic(err)
+ }
+ return []*presets.FilterTab{
+ {
+ Label: msgr.FilterTabsAll,
+ Query: url.Values{"all": []string{"1"}},
+ },
+ {
+ Label: msgr.FilterTabsActive,
+ Query: url.Values{"status": []string{"active"}},
+ },
+ tab,
+ }
+ })
+
+ return umb
+}
+
+// replaceUserRoles replaces all roles of a user by role IDs via the join table explicitly.
+// This does not rely on GORM association auto-save behavior.
+func replaceUserRoles(db *gorm.DB, userID uint, roleIDs []uint) error {
+ // Clear existing relations
+ if err := db.Table("user_role_join").Where("user_id = ?", userID).Delete(nil).Error; err != nil {
+ return errors.Wrap(err, "failed to delete user role")
+ }
+ if len(roleIDs) == 0 {
+ return nil
+ }
+ // Bulk insert new relations
+ rows := make([]map[string]any, 0, len(roleIDs))
+ for _, rid := range roleIDs {
+ rows = append(rows, map[string]any{"user_id": userID, "role_id": rid})
+ }
+ return errors.WithStack(db.Table("user_role_join").Create(&rows).Error)
+}
diff --git a/starter/uset_test.go b/starter/uset_test.go
new file mode 100644
index 000000000..0f8f8fa01
--- /dev/null
+++ b/starter/uset_test.go
@@ -0,0 +1,153 @@
+package starter_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ . "github.com/qor5/web/v3/multipartestutils"
+ "github.com/qor5/x/v3/gormx"
+ "github.com/theplant/inject"
+
+ "github.com/qor5/admin/v3/presets"
+ "github.com/qor5/admin/v3/presets/actions"
+ "github.com/qor5/admin/v3/starter"
+)
+
+func TestUsers(t *testing.T) {
+ env := newTestEnv(t, starter.SetupPageBuilderForHandler)
+ suite := inject.MustResolve[*gormx.TestSuite](env.lc)
+ db := suite.DB()
+ cases := []TestCase{
+ {
+ Name: "Index Users",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ req := NewMultipartBuilder().
+ PageURL("/users").
+ BuildEventFuncRequest()
+ return req
+ },
+ ExpectPageBodyContainsInOrder: []string{`qor@theplant.jp`},
+ },
+ {
+ Name: "Lock Users",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ req := NewMultipartBuilder().
+ PageURL("/users").
+ EventFunc("eventUnlockUser").
+ Query(presets.ParamID, "1").
+ BuildEventFuncRequest()
+ return req
+ },
+ ExpectPageBodyContainsInOrder: []string{`success`},
+ },
+ {
+ Name: "Send Reset Password Email Users",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ req := NewMultipartBuilder().
+ PageURL("/users").
+ EventFunc("eventSendResetPasswordEmail").
+ Query(presets.ParamID, "1").
+ BuildEventFuncRequest()
+ return req
+ },
+ ExpectPageBodyContainsInOrder: []string{`auth/reset-password`},
+ },
+ {
+ Name: "Revoke TOTP Users",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ req := NewMultipartBuilder().
+ PageURL("/users").
+ EventFunc("eventRevokeTOTP").
+ Query(presets.ParamID, "1").
+ BuildEventFuncRequest()
+ return req
+ },
+ ExpectPageBodyContainsInOrder: []string{`success`},
+ },
+ {
+ Name: "Edit User",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ req := NewMultipartBuilder().
+ PageURL("/users").
+ EventFunc(actions.Edit).
+ Query(presets.ParamID, "1").
+ BuildEventFuncRequest()
+ return req
+ },
+ ExpectPortalUpdate0ContainsInOrder: []string{`Type`, "Actions"},
+ },
+ {
+ Name: "Update User",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ req := NewMultipartBuilder().
+ PageURL("/users").
+ EventFunc(actions.Update).
+ Query(presets.ParamID, "1").
+ BuildEventFuncRequest()
+ return req
+ },
+ ExpectPortalUpdate0ContainsInOrder: []string{`Type`, "Actions"},
+ },
+ {
+ Name: "User InValidate",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ req := NewMultipartBuilder().
+ PageURL("/users").
+ EventFunc(actions.Update).
+ Query(presets.ParamID, "1").
+ AddField("Name", "test@theplant.jp").
+ AddField("OAuthIdentifier", "test@theplant.jp").
+ AddField("status", "active").
+ BuildEventFuncRequest()
+ return req
+ },
+ ExpectPortalUpdate0ContainsInOrder: []string{`test@theplant.jp`, `Email`, `Email is required`, `Company`, "Roles"},
+ },
+ {
+ Name: "User Update With Google Provider",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ req := NewMultipartBuilder().
+ PageURL("/users").
+ EventFunc(actions.Update).
+ Query(presets.ParamID, "1").
+ AddField("Name", "viwer@theplant.jp").
+ AddField("Account", "viwer@theplant.jp").
+ AddField("OAuthProvider", "google").
+ AddField("OAuthIdentifier", "viwer@theplant.jp").
+ AddField("status", "active").
+ AddField("Roles", "1").
+ AddField("Roles", "2").
+ AddField("Roles", "3").
+ BuildEventFuncRequest()
+ return req
+ },
+ ResponseMatch: func(t *testing.T, w *httptest.ResponseRecorder) {
+ user := &starter.User{}
+ db.Preload("Roles").First(user, 1)
+ if user.Account != "viwer@theplant.jp" {
+ t.Fatalf(`expected "viwer@theplant.jp" but got "%s"`, user.Account)
+ return
+ }
+ if len(user.Roles) != 3 {
+ t.Fatalf(`expected 3 roles but got %d`, len(user.Roles))
+ return
+ }
+ },
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.Name, func(t *testing.T) {
+ RunCase(t, c, env.handler)
+ })
+ }
+}