From f157a8e88e442de87330a98255fb574636363fb2 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 22 Jul 2024 00:38:35 +0800 Subject: [PATCH 01/33] activity: add error returns for currentUserFunc --- activity/builder.go | 6 +-- activity/builder_test.go | 4 +- activity/model_builder.go | 22 +++++---- activity/timeline.go | 49 ++++++++++--------- cmd/qor5/website-template/admin/config.go | 4 +- .../examples/examples_admin/activity.go | 4 +- .../page_builder_with_campaign.go | 4 +- .../docsrc/examples/examples_admin/publish.go | 4 +- 8 files changed, 52 insertions(+), 45 deletions(-) diff --git a/activity/builder.go b/activity/builder.go index c5a24bbd0..6229c7b85 100644 --- a/activity/builder.go +++ b/activity/builder.go @@ -18,8 +18,6 @@ type User struct { Avatar string } -type CurrentUserFunc func(ctx context.Context) *User - // @snippet_begin(ActivityBuilder) // Builder struct contains all necessary fields type Builder struct { @@ -28,7 +26,7 @@ type Builder struct { db *gorm.DB // global db logModelInstall presets.ModelInstallFunc // log model install permPolicy *perm.PolicyBuilder // permission policy - currentUserFunc CurrentUserFunc + currentUserFunc func(ctx context.Context) (*User, error) findUsersFunc func(ctx context.Context, ids []string) (map[string]*User, error) } @@ -55,7 +53,7 @@ func (ab *Builder) FindUsersFunc(v func(ctx context.Context, ids []string) (map[ } // New initializes a new Builder instance with a provided database connection and an optional activity log model. -func New(db *gorm.DB, currentUserFunc CurrentUserFunc) *Builder { +func New(db *gorm.DB, currentUserFunc func(ctx context.Context) (*User, error)) *Builder { ab := &Builder{ db: db, currentUserFunc: currentUserFunc, diff --git a/activity/builder_test.go b/activity/builder_test.go index 3d7d13ad1..f064992af 100644 --- a/activity/builder_test.go +++ b/activity/builder_test.go @@ -71,8 +71,8 @@ var currentUser = &User{ Avatar: "https://i.pravatar.cc/300", } -func testCurrentUser(ctx context.Context) *User { - return currentUser +func testCurrentUser(ctx context.Context) (*User, error) { + return currentUser, nil } func resetDB() { diff --git a/activity/model_builder.go b/activity/model_builder.go index f8e97963d..e101a94ac 100644 --- a/activity/model_builder.go +++ b/activity/model_builder.go @@ -238,6 +238,10 @@ func (amb *ModelBuilder) installPresetsModelBuilder(mb *presets.ModelBuilder) { if err != nil { return } + user, uerr := amb.ab.currentUserFunc(ctx.R.Context()) + if uerr != nil { + return + } var modelName string modelKeyses := []string{} reflectutils.ForEach(r, func(obj any) { @@ -247,7 +251,7 @@ func (amb *ModelBuilder) installPresetsModelBuilder(mb *presets.ModelBuilder) { modelKeyses = append(modelKeyses, amb.ParseModelKeys(obj)) }) if len(modelKeyses) > 0 { - counts, err := GetNotesCounts(amb.ab.db, amb.ab.currentUserFunc(ctx.R.Context()).ID, modelName, modelKeyses) + counts, err := GetNotesCounts(amb.ab.db, user.ID, modelName, modelKeyses) if err != nil { return r, totalCount, err } @@ -440,17 +444,17 @@ func (mb *ModelBuilder) create( modelName, modelKeys, modelLink string, detail any, ) (*ActivityLog, error) { - creator := mb.ab.currentUserFunc(ctx) - if creator == nil { - return nil, errors.New("current user is nil") + user, err := mb.ab.currentUserFunc(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to get current user") } if mb.ab.findUsersFunc == nil { user := &ActivityUser{ CreatedAt: mb.ab.db.NowFunc(), - ID: creator.ID, - Name: creator.Name, - Avatar: creator.Avatar, + ID: user.ID, + Name: user.Name, + Avatar: user.Avatar, } if mb.ab.db.Where("id = ?", user.ID).Select("*").Omit("created_at").Updates(user).RowsAffected == 0 { if err := mb.ab.db.Create(user).Error; err != nil { @@ -460,7 +464,7 @@ func (mb *ModelBuilder) create( } log := &ActivityLog{ - CreatorID: creator.ID, + CreatorID: user.ID, Action: action, ModelName: modelName, ModelKeys: modelKeys, @@ -482,7 +486,7 @@ func (mb *ModelBuilder) create( log.Hidden = true r := &ActivityLog{} if err := mb.ab.db. - Where("creator_id = ? AND model_name = ? AND model_keys = ? AND action = ?", creator.ID, modelName, modelKeys, action). + Where("creator_id = ? AND model_name = ? AND model_keys = ? AND action = ?", user.ID, modelName, modelKeys, action). Assign(log).FirstOrCreate(r).Error; err != nil { return nil, err } diff --git a/activity/timeline.go b/activity/timeline.go index 2e1be4d45..4b7b5dbf0 100644 --- a/activity/timeline.go +++ b/activity/timeline.go @@ -40,7 +40,7 @@ type TimelineCompo struct { } func (c *TimelineCompo) CompoID() string { - return fmt.Sprintf("Timeline:%s", c.ID) + return fmt.Sprintf("TimelineCompo:%s", c.ID) } func (c *TimelineCompo) MustGetEventContext(ctx context.Context) (*web.EventContext, *Messages) { @@ -116,6 +116,11 @@ func (c *TimelineCompo) MarshalHTML(ctx context.Context) ([]byte, error) { return h.Div().Attr("v-pre", true).Text(perm.PermissionDenied.Error()).MarshalHTML(ctx) } + user, err := c.ab.currentUserFunc(ctx) + if err != nil { + return nil, err + } + children := []h.HTMLComponent{ h.Div().Class("text-h6 mb-8").Text(msgr.Activities), web.Scope().VSlot("{locals: xlocals,form}").Init("{showEditBox:false}").Children( @@ -165,7 +170,7 @@ func (c *TimelineCompo) MarshalHTML(ctx context.Context) ([]byte, error) { h.Div().Class("bg-"+dotColor).Class("align-self-stretch").Style("width: 1px; margin: -6px 3.5px -2px 3.5px;"), h.Div().Class("flex-grow-1 d-flex flex-column pb-3").Children( h.Div().Class("d-flex flex-row align-center ga-2").Children( - v.VAvatar().Class("text-overline font-weight-medium text-primary bg-primary-lighten-2").Attr("size", "x-small").Attr("density", "compact").Attr("rounded", true).Text(avatarText).Children( + v.VAvatar().Class("text-overline font-weight-medium text-primary bg-primary-lighten-2").Size(v.SizeXSmall).Density(v.DensityCompact).Rounded(true).Text(avatarText).Children( h.Iff(log.Creator.Avatar != "", func() h.HTMLComponent { return v.VImg().Attr("alt", creatorName).Attr("src", log.Creator.Avatar) }), @@ -181,7 +186,7 @@ func (c *TimelineCompo) MarshalHTML(ctx context.Context) ([]byte, error) { ) if log.Action == ActionNote { child = web.Scope().VSlot("{locals: xlocals, form}").Init("{showEditBox:false}").Children( - v.VHover().Disabled(log.CreatorID != c.ab.currentUserFunc(ctx).ID).Children( + v.VHover().Disabled(log.CreatorID != user.ID).Children( web.Slot().Name("default").Scope("{ isHovering, props }").Children( h.Div().Class("d-flex flex-column").Style("position: relative").Attr("v-bind", "props").Children( h.Div().Attr("v-if", "isHovering && !xlocals.showEditBox").Class("d-flex flex-row ga-1"). @@ -258,7 +263,7 @@ func (c *TimelineCompo) CreateNote(ctx context.Context, req CreateNoteRequest) ( req.Note = strings.TrimSpace(req.Note) if req.Note == "" { - presets.ShowMessage(&r, msgr.NoteCannotBeEmpty, "error") + presets.ShowMessage(&r, msgr.NoteCannotBeEmpty, v.ColorError) return } @@ -266,11 +271,11 @@ func (c *TimelineCompo) CreateNote(ctx context.Context, req CreateNoteRequest) ( Note: req.Note, }) if err != nil { - presets.ShowMessage(&r, msgr.FailedToCreateNote, "error") + presets.ShowMessage(&r, msgr.FailedToCreateNote, v.ColorError) return } - presets.ShowMessage(&r, msgr.SuccessfullyCreatedNote, "") + presets.ShowMessage(&r, msgr.SuccessfullyCreatedNote, v.ColorSuccess) r.Emit(presets.NotifModelsCreated(&ActivityLog{}), presets.PayloadModelsCreated{ Models: []any{log}, }) @@ -294,23 +299,23 @@ func (c *TimelineCompo) UpdateNote(ctx context.Context, req UpdateNoteRequest) ( req.Note = strings.TrimSpace(req.Note) if req.Note == "" { - presets.ShowMessage(&r, msgr.NoteCannotBeEmpty, "error") + presets.ShowMessage(&r, msgr.NoteCannotBeEmpty, v.ColorError) return } - creator := c.ab.currentUserFunc(ctx) - if creator == nil { - presets.ShowMessage(&r, msgr.FailedToGetCurrentUser, "error") + user, err := c.ab.currentUserFunc(ctx) + if err != nil { + presets.ShowMessage(&r, msgr.FailedToGetCurrentUser, v.ColorError) return } log := &ActivityLog{} if err := c.ab.db.Where("id = ?", req.LogID).First(log).Error; err != nil { - presets.ShowMessage(&r, msgr.FailedToGetNote, "error") + presets.ShowMessage(&r, msgr.FailedToGetNote, v.ColorError) return } - if log.CreatorID != creator.ID { - presets.ShowMessage(&r, msgr.YouAreNotTheNoteCreator, "error") + if log.CreatorID != user.ID { + presets.ShowMessage(&r, msgr.YouAreNotTheNoteCreator, v.ColorError) return } @@ -328,11 +333,11 @@ func (c *TimelineCompo) UpdateNote(ctx context.Context, req UpdateNoteRequest) ( LastEditedAt: c.ab.db.NowFunc(), }) if err := c.ab.db.Save(log).Error; err != nil { - presets.ShowMessage(&r, msgr.FailedToUpdateNote, "error") + presets.ShowMessage(&r, msgr.FailedToUpdateNote, v.ColorError) return } - presets.ShowMessage(&r, msgr.SuccessfullyUpdatedNote, "") + presets.ShowMessage(&r, msgr.SuccessfullyUpdatedNote, v.ColorSuccess) r.Emit(presets.NotifModelsUpdated(&ActivityLog{}), presets.PayloadModelsUpdated{ Ids: []string{fmt.Sprint(log.ID)}, Models: []any{log}, @@ -354,22 +359,22 @@ func (c *TimelineCompo) DeleteNote(ctx context.Context, req DeleteNoteRequest) ( return r, perm.PermissionDenied } - creator := c.ab.currentUserFunc(ctx) - if creator == nil { - presets.ShowMessage(&r, msgr.FailedToGetCurrentUser, "error") + user, err := c.ab.currentUserFunc(ctx) + if err != nil { + presets.ShowMessage(&r, msgr.FailedToGetCurrentUser, v.ColorError) return } - result := c.ab.db.Where("id = ? AND creator_id = ?", req.LogID, creator.ID).Delete(&ActivityLog{}) + result := c.ab.db.Where("id = ? AND creator_id = ?", req.LogID, user.ID).Delete(&ActivityLog{}) if err := result.Error; err != nil { - presets.ShowMessage(&r, msgr.FailedToDeleteNote, "error") + presets.ShowMessage(&r, msgr.FailedToDeleteNote, v.ColorError) return } if result.RowsAffected == 0 { - presets.ShowMessage(&r, msgr.YouAreNotTheNoteCreator, "error") + presets.ShowMessage(&r, msgr.YouAreNotTheNoteCreator, v.ColorError) return } - presets.ShowMessage(&r, msgr.SuccessfullyDeletedNote, "") + presets.ShowMessage(&r, msgr.SuccessfullyDeletedNote, v.ColorSuccess) r.Emit(presets.NotifModelsDeleted(&ActivityLog{}), presets.PayloadModelsDeleted{ Ids: []string{fmt.Sprint(req.LogID)}, }) diff --git a/cmd/qor5/website-template/admin/config.go b/cmd/qor5/website-template/admin/config.go index 7a4f88baa..69ca379a1 100644 --- a/cmd/qor5/website-template/admin/config.go +++ b/cmd/qor5/website-template/admin/config.go @@ -68,11 +68,11 @@ func newConfig(db *gorm.DB) config { storage := filesystem.New(PublishDir) mediaBuilder := media.New(db) - ab := activity.New(db, func(ctx context.Context) *activity.User { + ab := activity.New(db, func(ctx context.Context) (*activity.User, error) { return &activity.User{ ID: "1", Name: "John", - } + }, nil }).AutoMigrate() publisher := publish.New(db, storage) seoBuilder := seo.New(db).AutoMigrate() diff --git a/docs/docsrc/examples/examples_admin/activity.go b/docs/docsrc/examples/examples_admin/activity.go index 08701dc30..0e1e42c78 100644 --- a/docs/docsrc/examples/examples_admin/activity.go +++ b/docs/docsrc/examples/examples_admin/activity.go @@ -16,12 +16,12 @@ func ActivityExample(b *presets.Builder, db *gorm.DB) http.Handler { // @snippet_begin(NewActivitySample) b.DataOperator(gorm2op.DataOperator(db)) - ab := activity.New(db, func(ctx context.Context) *activity.User { + ab := activity.New(db, func(ctx context.Context) (*activity.User, error) { return &activity.User{ ID: "1", Name: "John", Avatar: "https://i.pravatar.cc/300", - } + }, nil }).AutoMigrate() b.Use(ab) diff --git a/docs/docsrc/examples/examples_admin/page_builder_with_campaign.go b/docs/docsrc/examples/examples_admin/page_builder_with_campaign.go index d29f63456..e26a06f2a 100644 --- a/docs/docsrc/examples/examples_admin/page_builder_with_campaign.go +++ b/docs/docsrc/examples/examples_admin/page_builder_with_campaign.go @@ -149,12 +149,12 @@ func PageBuilderExample(b *presets.Builder, db *gorm.DB) http.Handler { panic(err) } storage := filesystem.New("/tmp/publish") - ab := activity.New(db, func(ctx context.Context) *activity.User { + ab := activity.New(db, func(ctx context.Context) (*activity.User, error) { return &activity.User{ ID: "1", Name: "John", Avatar: "https://i.pravatar.cc/300", - } + }, nil }).AutoMigrate() puBuilder := publish.New(db, storage) diff --git a/docs/docsrc/examples/examples_admin/publish.go b/docs/docsrc/examples/examples_admin/publish.go index f0b6f8b61..c4eead486 100644 --- a/docs/docsrc/examples/examples_admin/publish.go +++ b/docs/docsrc/examples/examples_admin/publish.go @@ -101,11 +101,11 @@ func PublishExample(b *presets.Builder, db *gorm.DB) http.Handler { }). Editing("Name", "Price") - ab := activity.New(db, func(ctx context.Context) *activity.User { + ab := activity.New(db, func(ctx context.Context) (*activity.User, error) { return &activity.User{ ID: "1", Name: "John", - } + }, nil }). AutoMigrate() publisher := publish.New(db, nil).Activity(ab) From fed64b81650b139aaa35d114720777f7303b5a73 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 22 Jul 2024 00:39:12 +0800 Subject: [PATCH 02/33] profile: without login sessions --- example/admin/config.go | 48 ++++++++- example/admin/profile.go | 2 +- go.mod | 1 + go.sum | 2 + profile/builder.go | 100 +++++++++++++++++++ profile/compo.go | 208 +++++++++++++++++++++++++++++++++++++++ profile/messages.go | 47 +++++++++ profile/util.go | 20 ++++ 8 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 profile/builder.go create mode 100644 profile/compo.go create mode 100644 profile/messages.go create mode 100644 profile/util.go diff --git a/example/admin/config.go b/example/admin/config.go index 9db4de600..0ff81fd20 100644 --- a/example/admin/config.go +++ b/example/admin/config.go @@ -12,6 +12,7 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3control" + "github.com/pkg/errors" "github.com/qor/oss" "github.com/qor/oss/filesystem" "github.com/qor/oss/s3" @@ -28,6 +29,7 @@ import ( "github.com/qor5/admin/v3/pagebuilder/example" "github.com/qor5/admin/v3/presets" "github.com/qor5/admin/v3/presets/gorm2op" + "github.com/qor5/admin/v3/profile" "github.com/qor5/admin/v3/publish" "github.com/qor5/admin/v3/richeditor" "github.com/qor5/admin/v3/role" @@ -171,13 +173,13 @@ func NewConfig(db *gorm.DB) Config { utils.Install(b) // @snippet_begin(ActivityExample) - ab := activity.New(db, func(ctx context.Context) *activity.User { + ab := activity.New(db, func(ctx context.Context) (*activity.User, error) { u := ctx.Value(login.UserKey).(*models.User) return &activity.User{ ID: fmt.Sprint(u.ID), Name: u.Name, Avatar: "", - } + }, nil }). AutoMigrate(). WrapLogModelInstall(func(in presets.ModelInstallFunc) presets.ModelInstallFunc { @@ -475,6 +477,45 @@ func configMenuOrder(b *presets.Builder) { } func configBrand(b *presets.Builder, db *gorm.DB) { + profileB := profile.New( + func(ctx context.Context) (*profile.User, error) { + evCtx := web.MustGetEventContext(ctx) + u := getCurrentUser(evCtx.R) + if u == nil { + return nil, perm.PermissionDenied + } + notifiCounts, err := activity.GetNotesCounts(db, fmt.Sprint(u.ID), "", nil) + if err != nil { + return nil, err + } + return &profile.User{ + ID: fmt.Sprint(u.ID), + Name: u.Name, + // Avatar: "", + Roles: u.GetRoles(), + Available: u.Status == "active", + Fields: []*profile.UserField{ + {Name: "Email", Value: u.Account}, + {Name: "Company", Value: u.Company}, + }, + NotifCounts: notifiCounts, + }, nil + }, + func(ctx context.Context, newName string) error { + evCtx := web.MustGetEventContext(ctx) + u := getCurrentUser(evCtx.R) + if u == nil { + return perm.PermissionDenied + } + u.Name = newName + if err := db.Save(u).Error; err != nil { + return errors.Wrap(err, "failed to update user name") + } + return nil + }, + ) + profileB.Install(b) + b.BrandFunc(func(ctx *web.EventContext) h.HTMLComponent { msgr := i18n.MustGetModuleMessages(ctx.R, I18nExampleKey, Messages_en_US).(*Messages) logo := "https://qor5.com/img/qor-logo.png" @@ -504,8 +545,7 @@ func configBrand(b *presets.Builder, db *gorm.DB) { h.Script("function updateCountdown(){const now=new Date();const nextEvenHour=new Date(now);nextEvenHour.setHours(nextEvenHour.getHours()+(nextEvenHour.getHours()%2===0?2:1),0,0,0);const timeLeft=nextEvenHour-now;const hours=Math.floor(timeLeft/(60*60*1000));const minutes=Math.floor((timeLeft%(60*60*1000))/(60*1000));const seconds=Math.floor((timeLeft%(60*1000))/1000);const countdownElem=document.getElementById(\"countdown\");countdownElem.innerText=`${hours.toString().padStart(2,\"0\")}:${minutes.toString().padStart(2,\"0\")}:${seconds.toString().padStart(2,\"0\")}`}updateCountdown();setInterval(updateCountdown,1000);"), ), ).Class("mb-n4 mt-n2") - }).ProfileFunc(profile(db)). - NotificationFunc(notifierComponent(db), notifierCount(db)). + }). DataOperator(gorm2op.DataOperator(db)). HomePageFunc(func(ctx *web.EventContext) (r web.PageResponse, err error) { r.PageTitle = "Home" diff --git a/example/admin/profile.go b/example/admin/profile.go index efa0b17d6..111e66367 100644 --- a/example/admin/profile.go +++ b/example/admin/profile.go @@ -27,7 +27,7 @@ const ( paramName = "name" ) -func profile(db *gorm.DB) presets.ComponentFunc { +func profileX(db *gorm.DB) presets.ComponentFunc { return func(ctx *web.EventContext) h.HTMLComponent { msgr := i18n.MustGetModuleMessages(ctx.R, I18nExampleKey, Messages_en_US).(*Messages) diff --git a/go.mod b/go.mod index 630238d0a..f34d1c75d 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,7 @@ require ( github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.27.0 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d golang.org/x/text v0.16.0 gorm.io/driver/postgres v1.5.9 gorm.io/driver/sqlite v1.5.6 diff --git a/go.sum b/go.sum index f0a50eb3d..d600f62ee 100644 --- a/go.sum +++ b/go.sum @@ -483,6 +483,8 @@ golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= diff --git a/profile/builder.go b/profile/builder.go new file mode 100644 index 000000000..0e04d05a3 --- /dev/null +++ b/profile/builder.go @@ -0,0 +1,100 @@ +package profile + +import ( + "context" + "sync" + + "github.com/pkg/errors" + "github.com/qor5/admin/v3/activity" + "github.com/qor5/admin/v3/presets" + "github.com/qor5/web/v3" + "github.com/qor5/x/v3/i18n" + h "github.com/theplant/htmlgo" + "golang.org/x/text/language" +) + +const ( + I18nProfileKey i18n.ModuleKey = "I18nProfileKey" +) + +func (b *Builder) Install(pb *presets.Builder) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.pb != nil { + return errors.Errorf("profile: already installed") + } + b.pb = pb + pb.GetI18n(). + RegisterForModule(language.English, I18nProfileKey, Messages_en_US). + RegisterForModule(language.SimplifiedChinese, I18nProfileKey, Messages_zh_CN). + RegisterForModule(language.Japanese, I18nProfileKey, Messages_ja_JP) + pb.ProfileFunc(func(evCtx *web.EventContext) h.HTMLComponent { + return b.NewCompo(evCtx, "") + }) + + dc := pb.GetDependencyCenter() + injectorName := b.injectorName() + dc.RegisterInjector(injectorName) + dc.MustProvide(injectorName, func() *Builder { + return b + }) + return nil +} + +type UserField struct { + Name string + Value string +} + +type User struct { + ID string + Name string + Avatar string + Roles []string + Available bool + Fields []*UserField + NotifCounts []*activity.NoteCount +} + +func (u *User) GetFirstRole() string { + role := "-" + if len(u.Roles) > 0 { + role = u.Roles[0] + } + return role +} + +type Builder struct { + mu sync.RWMutex + pb *presets.Builder + currentUserFunc func(ctx context.Context) (*User, error) + renameCallback func(ctx context.Context, newName string) error +} + +func New( + currentUserFunc func(ctx context.Context) (*User, error), + renameCallback func(ctx context.Context, newName string) error, +) *Builder { + return &Builder{ + currentUserFunc: currentUserFunc, + renameCallback: renameCallback, + } +} + +func (b *Builder) injectorName() string { + return "__profile__" +} + +func (b *Builder) NewCompo(evCtx *web.EventContext, idSuffix string) h.HTMLComponent { + b.mu.RLock() + pb := b.pb + b.mu.RUnlock() + if pb == nil { + panic("profile: not installed") + } + return h.Div().Class("d-flex flex-column align-stretch w-100").Children( + b.pb.GetDependencyCenter().MustInject(b.injectorName(), &ProfileCompo{ + ID: b.pb.GetURIPrefix() + idSuffix, + }), + ) +} diff --git a/profile/compo.go b/profile/compo.go new file mode 100644 index 000000000..cae35db3e --- /dev/null +++ b/profile/compo.go @@ -0,0 +1,208 @@ +package profile + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/jinzhu/inflection" + "github.com/qor5/admin/v3/activity" + "github.com/qor5/admin/v3/presets" + "github.com/qor5/web/v3" + "github.com/qor5/web/v3/stateful" + "github.com/qor5/x/v3/i18n" + v "github.com/qor5/x/v3/ui/vuetify" + "github.com/samber/lo" + h "github.com/theplant/htmlgo" + "golang.org/x/exp/maps" +) + +const logoutURL = "/auth/logout" + +func init() { + stateful.RegisterActionableCompoType(&ProfileCompo{}) +} + +type ProfileCompo struct { + b *Builder `inject:""` + + ID string `json:"id"` +} + +func (c *ProfileCompo) CompoID() string { + return fmt.Sprintf("ProfileCompo:%s", c.ID) +} + +func (c *ProfileCompo) MustGetEventContext(ctx context.Context) (*web.EventContext, *Messages) { + evCtx := web.MustGetEventContext(ctx) + return evCtx, i18n.MustGetModuleMessages(evCtx.R, I18nProfileKey, Messages_en_US).(*Messages) +} + +func (c *ProfileCompo) MarshalHTML(ctx context.Context) ([]byte, error) { + user, err := c.b.currentUserFunc(ctx) + if err != nil { + return nil, err + } + + prepend := v.VCard().Flat(true).Children( + web.Slot().Name(v.VSlotPrepend).Children( + v.VAvatar().Class("text-body-1 font-weight-medium text-primary bg-primary-lighten-2").Size(v.SizeLarge).Density(v.DensityCompact).Rounded(true). + Text(ShortName(user.Name)).Children( + h.Iff(user.Avatar != "", func() h.HTMLComponent { + return v.VImg().Attr("alt", user.Name).Attr("src", user.Avatar) + }), + ), + ), + web.Slot().Name(v.VSlotTitle).Children( + h.Div().Class("d-flex align-center ga-2 pt-1").Children( + h.Div(h.Text(user.Name)).Class("text-subtitle-2 text-secondary"), + c.userCompo(ctx, user), + ), + ), + web.Slot().Name(v.VSlotSubtitle).Children( + h.Div().Class("text-overline").Text(strings.ToUpper(user.GetFirstRole())), + ), + ) + return stateful.Actionable(ctx, c, h.Div().Class("d-flex align-center ga-0").Children( + prepend, + v.VSpacer(), + h.Iff(len(user.NotifCounts) > 0, func() h.HTMLComponent { + return h.Div().Class("d-flex align-center px-4 border-s-sm h-50").Children( + c.bellCompo(ctx, user.NotifCounts), + ) + }), + )).MarshalHTML(ctx) +} + +func (c *ProfileCompo) bellCompo(ctx context.Context, notifCounts []*activity.NoteCount) h.HTMLComponent { + _, msgr := c.MustGetEventContext(ctx) + + unreadBy := func(item *activity.NoteCount) int { return int(item.UnreadNotesCount) } + unreadCount := lo.SumBy(notifCounts, unreadBy) + + listItems := []h.HTMLComponent{} + groups := lo.GroupBy(notifCounts, func(item *activity.NoteCount) string { + return item.ModelName + }) + modelNames := maps.Keys(groups) + sort.Strings(modelNames) + for _, modelName := range modelNames { + counts := groups[modelName] + title := inflection.Plural(modelName) + // TODO: i18n + // TODO: href? + href := fmt.Sprintf( + "/%s?active_filter_tab=hasUnreadNotes&f_hasUnreadNotes=1", + strings.ToLower(title), + ) + listItems = append(listItems, v.VListItem().Href(href).Children( + v.VListItemTitle(h.Text(title)), + v.VListItemSubtitle(h.Text(msgr.UnreadMessages(lo.SumBy(counts, unreadBy)))), + )) + } + + icon := v.VIcon("mdi-bell-outline").Size(20).Color("grey-darken-1") + return v.VMenu().Children( + web.Slot().Name("activator").Scope(`{props}`).Children( + v.VBtn("").Attr("v-bind", "props").Size(36).Icon(true).Density(v.DensityCompact).Variant(v.VariantText).Children( + h.Iff(unreadCount > 0, func() h.HTMLComponent { + return v.VBadge(icon).Content(unreadCount).Dot(true).Color(v.ColorError) + }).Else(func() h.HTMLComponent { + return icon + }), + ), + ), + v.VCard(v.VList(listItems...)), + ) +} + +func (c *ProfileCompo) userCompo(ctx context.Context, user *User) h.HTMLComponent { + _, msgr := c.MustGetEventContext(ctx) + + children := []h.HTMLComponent{} + for _, field := range user.Fields { + children = append(children, h.Div().Class("d-flex flex-column ga-2").Children( + h.Div().Attr("v-pre", true).Class("text-body-2 text-grey-darken-2").Text(field.Name), + h.Div().Attr("v-pre", true).Class("text-subtitle-2 font-weight-medium text-grey-darken-4").Text(field.Value), + )) + } + children = append(children, h.Div().Class("d-flex flex-column ga-2").Children( + v.VBtn(msgr.ViewLoginSessions).Variant(v.VariantTonal).Color(v.ColorSecondary), + v.VBtn(msgr.Logout).Variant(v.VariantTonal).Color(v.ColorError).Attr("@click", web.Plaid().URL(logoutURL).Go()), + )) + availableText := msgr.Available + if !user.Available { + availableText = msgr.Unavailable + } + + renameAction := stateful.PostAction(ctx, c, + c.Rename, RenameRequest{}, + stateful.WithAppendFix(`v.request.name = xlocals.name`), + ).Go() + return v.VMenu().CloseOnContentClick(false).Children( + web.Slot().Name("activator").Scope(`{props}`).Children( + v.VBtn("").Attr("v-bind", "props").Size(16).Icon(true).Density(v.DensityCompact).Variant(v.VariantText).Children( + v.VIcon("mdi-chevron-right").Size(16), + ), + ), + v.VCard().Width(300).Children( + v.VCardText().Class("pa-0").Children( + h.Div().Class("d-flex align-center ga-6 pa-6 bg-grey-lighten-4").Children( + v.VAvatar().Class("text-h3 font-weight-medium text-primary bg-primary-lighten-2 rounded-lg").Size(80).Density(v.DensityCompact). + Text(ShortName(user.Name)).Children( + h.Iff(user.Avatar != "", func() h.HTMLComponent { + return v.VImg().Attr("alt", user.Name).Attr("src", user.Avatar) + }), + ), + h.Div().Class("flex-grow-1 d-flex flex-column ga-4").Children( + h.Div().Class("d-flex flex-column").Children( + web.Scope().VSlot(`{ locals: xlocals }`).Init(fmt.Sprintf(`{editShow:false, name: %q}`, user.Name)).Children( + h.Div().Attr("v-if", "!xlocals.editShow").Class("d-flex align-center ga-2").Children( + h.Div().Text(user.Name).Class("text-subtitle-1 font-weight-medium"), + v.VBtn("").Size(20).Variant(v.VariantText).Color(v.ColorGreyDarken1). + Attr("@click", "xlocals.editShow = true").Children( + v.VIcon("mdi-pencil-outline"), + ), + ), + h.Div().Attr("v-else", true).Style("height:24px").Class("d-flex align-center ga-2").Children( + v.VTextField().Class("text-subtitle-1 font-weight-medium mt-n2"). + HideDetails(true).Density(v.DensityCompact).Autofocus(true). + Color(v.ColorPrimary). + Variant(v.VariantPlain). + Attr("v-model", "xlocals.name"). + Attr("@keyup.enter", renameAction), + h.Div().Class("d-flex align-center ga-1").Children( + v.VBtn("").Size(20).Variant(v.VariantText).Color(v.ColorGreyDarken1). + Attr("@click", "xlocals.editShow = false").Children(v.VIcon("mdi-close")), + v.VBtn("").Size(20).Variant(v.VariantText).Color(v.ColorGreyDarken1). + Attr("@click", renameAction).Children(v.VIcon("mdi-check")), + ), + ), + ), + h.Div().Class("text-subtitle-2 font-weight-medium text-grey-darken-1").Text(user.GetFirstRole()), + ), + v.VChip().Attr("style", "height:20px").Class("align-self-start px-1 text-caption").Text(availableText).Label(true).Density(v.DensityCompact).Color(v.ColorSuccess), + ), + ), + h.Div().Class("d-flex flex-column ga-6 pa-6").Children(children...), + ), + ), + ) +} + +type RenameRequest struct { + Name string `json:"name"` +} + +func (c *ProfileCompo) Rename(ctx context.Context, req RenameRequest) (r web.EventResponse, _ error) { + err := c.b.renameCallback(ctx, req.Name) + if err != nil { + presets.ShowMessage(&r, err.Error(), v.ColorError) + return + } + _, msgr := c.MustGetEventContext(ctx) + presets.ShowMessage(&r, msgr.SuccessfullyRename, v.ColorSuccess) + r.Reload = true + return r, nil +} diff --git a/profile/messages.go b/profile/messages.go new file mode 100644 index 000000000..3fca7955f --- /dev/null +++ b/profile/messages.go @@ -0,0 +1,47 @@ +package profile + +import ( + "fmt" + "strings" +) + +type Messages struct { + UnreadMessagesTemplate string + ViewLoginSessions string + Logout string + Available string + Unavailable string + SuccessfullyRename string +} + +func (m *Messages) UnreadMessages(n int) string { + return strings.NewReplacer("{n}", fmt.Sprint(n)). + Replace(m.UnreadMessagesTemplate) +} + +var Messages_en_US = &Messages{ + UnreadMessagesTemplate: "{n} unread notes", + ViewLoginSessions: "View login sessions", + Logout: "Logout", + Available: "Available", + Unavailable: "Unavailable", + SuccessfullyRename: "Successfully renamed", +} + +var Messages_zh_CN = &Messages{ + UnreadMessagesTemplate: "未读 {n} 条", + ViewLoginSessions: "查看登录会话", + Logout: "登出", + Available: "可用", + Unavailable: "不可用", + SuccessfullyRename: "成功重命名", +} + +var Messages_ja_JP = &Messages{ + UnreadMessagesTemplate: "{n} 未読", + ViewLoginSessions: "ログインセッションを表示", + Logout: "ログアウト", + Available: "利用可能", + Unavailable: "利用不可", + SuccessfullyRename: "名前が変更されました", +} diff --git a/profile/util.go b/profile/util.go new file mode 100644 index 000000000..ae4d53e77 --- /dev/null +++ b/profile/util.go @@ -0,0 +1,20 @@ +package profile + +import "strings" + +func ShortName(name string) string { + if name == "" { + return "" + } + runes := []rune(name) + result := strings.ToUpper(string(runes[0:1])) + if len(runes) > 2 { + for i := 2; i < len(runes); i++ { + if runes[i-1] == ' ' && runes[i] != ' ' { + result += strings.ToUpper(string(runes[i : i+1])) + break + } + } + } + return result +} From b60a84806150a63fadf43b5c0439402924ecb0da Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 22 Jul 2024 00:47:42 +0800 Subject: [PATCH 03/33] profile: compo vpre --- profile/compo.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/profile/compo.go b/profile/compo.go index cae35db3e..1561b3fe7 100644 --- a/profile/compo.go +++ b/profile/compo.go @@ -56,7 +56,7 @@ func (c *ProfileCompo) MarshalHTML(ctx context.Context) ([]byte, error) { ), web.Slot().Name(v.VSlotTitle).Children( h.Div().Class("d-flex align-center ga-2 pt-1").Children( - h.Div(h.Text(user.Name)).Class("text-subtitle-2 text-secondary"), + h.Div().Attr("v-pre", true).Text(user.Name).Class("text-subtitle-2 text-secondary"), c.userCompo(ctx, user), ), ), @@ -159,7 +159,7 @@ func (c *ProfileCompo) userCompo(ctx context.Context, user *User) h.HTMLComponen h.Div().Class("d-flex flex-column").Children( web.Scope().VSlot(`{ locals: xlocals }`).Init(fmt.Sprintf(`{editShow:false, name: %q}`, user.Name)).Children( h.Div().Attr("v-if", "!xlocals.editShow").Class("d-flex align-center ga-2").Children( - h.Div().Text(user.Name).Class("text-subtitle-1 font-weight-medium"), + h.Div().Attr("v-pre", true).Text(user.Name).Class("text-subtitle-1 font-weight-medium"), v.VBtn("").Size(20).Variant(v.VariantText).Color(v.ColorGreyDarken1). Attr("@click", "xlocals.editShow = true").Children( v.VIcon("mdi-pencil-outline"), From 51896d8f6d93a1adc987dcc33591ebfdb8d2dbf5 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 22 Jul 2024 14:37:41 +0800 Subject: [PATCH 04/33] profile: customize Status text --- example/admin/config.go | 5 +++-- profile/builder.go | 2 +- profile/compo.go | 10 +++++----- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/example/admin/config.go b/example/admin/config.go index 0ff81fd20..82fadfe80 100644 --- a/example/admin/config.go +++ b/example/admin/config.go @@ -12,6 +12,7 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3control" + "github.com/iancoleman/strcase" "github.com/pkg/errors" "github.com/qor/oss" "github.com/qor/oss/filesystem" @@ -492,8 +493,8 @@ func configBrand(b *presets.Builder, db *gorm.DB) { ID: fmt.Sprint(u.ID), Name: u.Name, // Avatar: "", - Roles: u.GetRoles(), - Available: u.Status == "active", + Roles: u.GetRoles(), + Status: strcase.ToCamel(u.Status), Fields: []*profile.UserField{ {Name: "Email", Value: u.Account}, {Name: "Company", Value: u.Company}, diff --git a/profile/builder.go b/profile/builder.go index 0e04d05a3..fe917aa23 100644 --- a/profile/builder.go +++ b/profile/builder.go @@ -51,7 +51,7 @@ type User struct { Name string Avatar string Roles []string - Available bool + Status string Fields []*UserField NotifCounts []*activity.NoteCount } diff --git a/profile/compo.go b/profile/compo.go index 1561b3fe7..55199bf50 100644 --- a/profile/compo.go +++ b/profile/compo.go @@ -131,10 +131,6 @@ func (c *ProfileCompo) userCompo(ctx context.Context, user *User) h.HTMLComponen v.VBtn(msgr.ViewLoginSessions).Variant(v.VariantTonal).Color(v.ColorSecondary), v.VBtn(msgr.Logout).Variant(v.VariantTonal).Color(v.ColorError).Attr("@click", web.Plaid().URL(logoutURL).Go()), )) - availableText := msgr.Available - if !user.Available { - availableText = msgr.Unavailable - } renameAction := stateful.PostAction(ctx, c, c.Rename, RenameRequest{}, @@ -182,7 +178,11 @@ func (c *ProfileCompo) userCompo(ctx context.Context, user *User) h.HTMLComponen ), h.Div().Class("text-subtitle-2 font-weight-medium text-grey-darken-1").Text(user.GetFirstRole()), ), - v.VChip().Attr("style", "height:20px").Class("align-self-start px-1 text-caption").Text(availableText).Label(true).Density(v.DensityCompact).Color(v.ColorSuccess), + h.Iff(user.Status != "", func() h.HTMLComponent { + return v.VChip().Text(user.Status). + Attr("style", "height:20px").Class("align-self-start px-1 text-caption"). + Label(true).Density(v.DensityCompact).Color(v.ColorSuccess) + }), ), ), h.Div().Class("d-flex flex-column ga-6 pa-6").Children(children...), From 39ed699d8856eb254f25840329c3807b12e3e5cd Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 22 Jul 2024 18:21:53 +0800 Subject: [PATCH 05/33] login: move session_mgmt logic to login package --- example/admin/auth.go | 86 +------- example/admin/config.go | 30 +-- example/admin/middlewares.go | 29 --- example/admin/{profile.go => profile_go} | 0 example/admin/router.go | 5 +- example/admin/session_mgmt.go | 109 ---------- example/admin/user_config.go | 5 +- example/models/login_session.go | 20 -- example/models/user.go | 3 + login/session.go | 245 +++++++++++++++++++++++ login/util.go | 42 ++++ login/views.go | 4 +- profile/builder.go | 5 +- 13 files changed, 329 insertions(+), 254 deletions(-) rename example/admin/{profile.go => profile_go} (100%) delete mode 100644 example/admin/session_mgmt.go delete mode 100644 example/models/login_session.go create mode 100644 login/session.go create mode 100644 login/util.go diff --git a/example/admin/auth.go b/example/admin/auth.go index ce0092e22..e457c71bd 100644 --- a/example/admin/auth.go +++ b/example/admin/auth.go @@ -22,8 +22,6 @@ import ( ) var ( - loginBuilder *login.Builder - vh *login.ViewHelper loginSecret = osenv.Get("LOGIN_SECRET", "Login secret use to sign session", "") loginGoogleKey = osenv.Get("LOGIN_GOOGLE_KEY", "Google client key for Login with Google", "") loginGoogleSecret = osenv.Get("LOGIN_GOOGLE_SECRET", "Google client secret for Login with Google", "") @@ -47,9 +45,8 @@ func getCurrentUser(r *http.Request) (u *models.User) { return u } -func initLoginBuilder(db *gorm.DB, pb *presets.Builder, ab *activity.Builder) { - ab.RegisterModel(&models.User{}) - loginBuilder = plogin.New(pb). +func initLoginBuilder(db *gorm.DB, pb *presets.Builder, ab *activity.Builder) *plogin.SessionBuilder { + loginBuilder := plogin.New(pb). DB(db). UserModel(&models.User{}). Secret(loginSecret). @@ -99,18 +96,6 @@ func initLoginBuilder(db *gorm.DB, pb *presets.Builder, ab *activity.Builder) { } return nil }). - AfterLogin(func(r *http.Request, user interface{}, _ ...interface{}) error { - _, err := ab.Log(r.Context(), "log-in", user, nil) - if err != nil { - return err - } - - if err := addSessionLogByUserID(db, r, user.(*models.User).ID); err != nil { - return err - } - - return nil - }). AfterOAuthComplete(func(r *http.Request, user interface{}, _ ...interface{}) error { u := user.(goth.User) if u.Email == "" { @@ -128,6 +113,7 @@ func initLoginBuilder(db *gorm.DB, pb *presets.Builder, ab *activity.Builder) { user := &models.User{ Name: name, + Status: models.StatusActive, RegistrationDate: time.Now(), OAuthInfo: login.OAuthInfo{ OAuthProvider: u.Provider, @@ -146,68 +132,17 @@ func initLoginBuilder(db *gorm.DB, pb *presets.Builder, ab *activity.Builder) { } } - return nil - }). - AfterFailedToLogin(func(r *http.Request, user interface{}, _ ...interface{}) error { - if user != nil { - _, err := ab.Log(r.Context(), "login-failed", user, nil) - return err - } - return nil - }). - AfterUserLocked(func(r *http.Request, user interface{}, _ ...interface{}) error { - _, err := ab.Log(r.Context(), "locked", user, nil) - return err - }). - AfterLogout(func(r *http.Request, user interface{}, _ ...interface{}) error { - _, err := ab.Log(r.Context(), "log-out", user, nil) - if err != nil { - return err - } - - if err := expireCurrentSessionLog(db, r, user.(*models.User).ID); err != nil { - return err - } - - return nil - }). - AfterConfirmSendResetPasswordLink(func(r *http.Request, user interface{}, extraVals ...interface{}) error { - resetLink := extraVals[0] - _ = resetLink - _, err := ab.Log(r.Context(), "send-reset-password-link", user, nil) - return err - }). - AfterResetPassword(func(r *http.Request, user interface{}, _ ...interface{}) error { - if err := expireAllSessionLogs(db, user.(*models.User).ID); err != nil { - return err - } - _, err := ab.Log(r.Context(), "reset-password", user, nil) - return err - }). - AfterChangePassword(func(r *http.Request, user interface{}, _ ...interface{}) error { - if err := expireAllSessionLogs(db, user.(*models.User).ID); err != nil { - return err - } - - _, err := ab.Log(r.Context(), "change-password", user, nil) - return err - }). - AfterExtendSession(func(r *http.Request, user interface{}, extraVals ...interface{}) error { - oldToken := extraVals[0].(string) - if err := updateCurrentSessionLog(db, r, user.(*models.User).ID, oldToken); err != nil { - return err - } - - return nil - }). - AfterTOTPCodeReused(func(r *http.Request, user interface{}, _ ...interface{}) error { return nil }).TOTP(false).MaxRetryCount(0) - vh = loginBuilder.ViewHelper() - loginBuilder.LoginPageFunc(loginPage(vh, pb)) + loginBuilder.LoginPageFunc(loginPage(loginBuilder.ViewHelper(), pb)) genInitialUser(db) + + return plogin.NewSessionBuilder(loginBuilder, db). + ActivityModelBuilder(ab.RegisterModel(&models.User{})). + AutoMigrate(). + Setup() } func genInitialUser(db *gorm.DB) { @@ -230,7 +165,8 @@ func genInitialUser(db *gorm.DB) { } user := &models.User{ - Name: email, + Name: email, + Status: models.StatusActive, UserPass: login.UserPass{ Account: email, Password: password, diff --git a/example/admin/config.go b/example/admin/config.go index 82fadfe80..df5771755 100644 --- a/example/admin/config.go +++ b/example/admin/config.go @@ -20,6 +20,7 @@ import ( "github.com/qor5/admin/v3/activity" "github.com/qor5/admin/v3/example/models" "github.com/qor5/admin/v3/l10n" + plogin "github.com/qor5/admin/v3/login" "github.com/qor5/admin/v3/media" "github.com/qor5/admin/v3/media/base" "github.com/qor5/admin/v3/media/media_library" @@ -57,9 +58,10 @@ var assets embed.FS var PublishStorage oss.StorageInterface = filesystem.New("publish") type Config struct { - pb *presets.Builder - pageBuilder *pagebuilder.Builder - Publisher *publish.Builder + pb *presets.Builder + pageBuilder *pagebuilder.Builder + Publisher *publish.Builder + loginSessionBuilder *plogin.SessionBuilder } var ( @@ -79,7 +81,6 @@ func NewConfig(db *gorm.DB) Config { &models.Post{}, &models.InputDemo{}, &models.User{}, - &models.LoginSession{}, &models.ListModel{}, &role.Role{}, &perm.DefaultDBPolicy{}, @@ -335,15 +336,15 @@ func NewConfig(db *gorm.DB) Config { l10nM.Use(l10nBuilder) l10nVM.Use(l10nBuilder) - initLoginBuilder(db, b, ab) + loginSessionBuilder := initLoginBuilder(db, b, ab) configInputDemo(b, db) configOrder(b, db) configECDashboard(b, db) - configUser(b, ab, db, publisher) - configProfile(b, db) + configUser(b, ab, db, publisher, loginSessionBuilder) + // configProfile(b, db, loginSessionBuilder) b.Use( mediab, @@ -361,9 +362,10 @@ func NewConfig(db *gorm.DB) Config { } return Config{ - pb: b, - pageBuilder: pageBuilder, - Publisher: publisher, + pb: b, + pageBuilder: pageBuilder, + Publisher: publisher, + loginSessionBuilder: loginSessionBuilder, } } @@ -489,7 +491,7 @@ func configBrand(b *presets.Builder, db *gorm.DB) { if err != nil { return nil, err } - return &profile.User{ + user := &profile.User{ ID: fmt.Sprint(u.ID), Name: u.Name, // Avatar: "", @@ -500,7 +502,11 @@ func configBrand(b *presets.Builder, db *gorm.DB) { {Name: "Company", Value: u.Company}, }, NotifCounts: notifiCounts, - }, nil + } + if u.OAuthAvatar != "" { + user.Avatar = u.OAuthAvatar + } + return user, nil }, func(ctx context.Context, newName string) error { evCtx := web.MustGetEventContext(ctx) diff --git a/example/admin/middlewares.go b/example/admin/middlewares.go index b153c0ae7..9fb2c9474 100644 --- a/example/admin/middlewares.go +++ b/example/admin/middlewares.go @@ -4,7 +4,6 @@ import ( "net/http" "github.com/qor5/admin/v3/role" - "github.com/qor5/x/v3/login" "gorm.io/gorm" ) @@ -44,31 +43,3 @@ func securityMiddleware() func(next http.Handler) http.Handler { }) } } - -func validateSessionToken(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) { - user := getCurrentUser(r) - if user == nil { - next.ServeHTTP(w, r) - return - } - if login.IsLoginWIP(r) { - next.ServeHTTP(w, r) - return - } - - valid, err := checkIsTokenValidFromRequest(db, r, user.ID) - if err != nil || !valid { - if r.URL.Path == logoutURL { - next.ServeHTTP(w, r) - return - } - http.Redirect(w, r, logoutURL, http.StatusFound) - return - } - - next.ServeHTTP(w, r) - }) - } -} diff --git a/example/admin/profile.go b/example/admin/profile_go similarity index 100% rename from example/admin/profile.go rename to example/admin/profile_go diff --git a/example/admin/router.go b/example/admin/router.go index 5ab299580..a81476d80 100644 --- a/example/admin/router.go +++ b/example/admin/router.go @@ -45,7 +45,7 @@ func Router(db *gorm.DB) http.Handler { c := NewConfig(db) mux := http.NewServeMux() - loginBuilder.Mount(mux) + c.loginSessionBuilder.GetLoginBuilder().Mount(mux) // mux.Handle("/frontstyle.css", c.pb.GetWebBuilder().PacksHandler("text/css", web.ComponentsPack(` // :host { // all: initial; @@ -81,8 +81,7 @@ func Router(db *gorm.DB) http.Handler { cr := chi.NewRouter() cr.Use( - loginBuilder.Middleware(), - validateSessionToken(db), + c.loginSessionBuilder.Middleware(), withRoles(db), securityMiddleware(), ) diff --git a/example/admin/session_mgmt.go b/example/admin/session_mgmt.go deleted file mode 100644 index 5e37c4c4f..000000000 --- a/example/admin/session_mgmt.go +++ /dev/null @@ -1,109 +0,0 @@ -package admin - -import ( - "fmt" - "net/http" - "time" - - "github.com/qor5/admin/v3/example/models" - "github.com/qor5/x/v3/login" - "github.com/ua-parser/uap-go/uaparser" - "gorm.io/gorm" -) - -const ( - LoginTokenHashLen = 8 // The hash string length of the token stored in the DB. -) - -func addSessionLogByUserID(db *gorm.DB, r *http.Request, userID uint) (err error) { - token := login.GetSessionToken(loginBuilder, r) - client := uaparser.NewFromSaved().Parse(r.Header.Get("User-Agent")) - - if err = db.Model(&models.LoginSession{}).Create(&models.LoginSession{ - UserID: userID, - Device: fmt.Sprintf("%v - %v", client.UserAgent.Family, client.Os.Family), - IP: ip(r), - TokenHash: getStringHash(token, LoginTokenHashLen), - ExpiredAt: time.Now().Add(time.Duration(loginBuilder.GetSessionMaxAge()) * time.Second), - }).Error; err != nil { - return err - } - - return nil -} - -func updateCurrentSessionLog(db *gorm.DB, r *http.Request, userID uint, oldToken string) (err error) { - token := login.GetSessionToken(loginBuilder, r) - tokenHash := getStringHash(token, LoginTokenHashLen) - oldTokenHash := getStringHash(oldToken, LoginTokenHashLen) - if err = db.Model(&models.LoginSession{}). - Where("user_id = ? and token_hash = ?", userID, oldTokenHash). - Updates(map[string]interface{}{ - "token_hash": tokenHash, - "expired_at": time.Now().Add(time.Duration(loginBuilder.GetSessionMaxAge()) * time.Second), - }).Error; err != nil { - return err - } - - return nil -} - -func expireCurrentSessionLog(db *gorm.DB, r *http.Request, userID uint) (err error) { - token := login.GetSessionToken(loginBuilder, r) - tokenHash := getStringHash(token, LoginTokenHashLen) - if err = db.Model(&models.LoginSession{}). - Where("user_id = ? and token_hash = ?", userID, tokenHash). - Updates(map[string]interface{}{ - "expired_at": time.Now(), - }).Error; err != nil { - return err - } - - return nil -} - -func expireAllSessionLogs(db *gorm.DB, userID uint) (err error) { - return db.Model(&models.LoginSession{}). - Where("user_id = ?", userID). - Updates(map[string]interface{}{ - "expired_at": time.Now(), - }).Error -} - -func expireOtherSessionLogs(db *gorm.DB, r *http.Request, userID uint) (err error) { - token := login.GetSessionToken(loginBuilder, r) - - return db.Model(&models.LoginSession{}). - Where("user_id = ? AND token_hash != ?", userID, getStringHash(token, LoginTokenHashLen)). - Updates(map[string]interface{}{ - "expired_at": time.Now(), - }).Error -} - -func isTokenValid(v models.LoginSession) bool { - return time.Now().Sub(v.ExpiredAt) > 0 -} - -func checkIsTokenValidFromRequest(db *gorm.DB, r *http.Request, userID uint) (valid bool, err error) { - token := login.GetSessionToken(loginBuilder, r) - if token == "" { - return false, nil - } - sessionLog := models.LoginSession{} - if err = db.Where("user_id = ? and token_hash = ?", userID, getStringHash(token, LoginTokenHashLen)). - First(&sessionLog). - Error; err != nil { - if err != gorm.ErrRecordNotFound { - return false, err - } - return false, nil - } - // IP check - if sessionLog.IP != ip(r) { - return false, nil - } - if isTokenValid(sessionLog) { - return false, nil - } - return true, nil -} diff --git a/example/admin/user_config.go b/example/admin/user_config.go index beb95957a..2bf2cab43 100644 --- a/example/admin/user_config.go +++ b/example/admin/user_config.go @@ -8,6 +8,7 @@ import ( "strings" "time" + plogin "github.com/qor5/admin/v3/login" "github.com/qor5/admin/v3/presets/actions" "github.com/qor5/admin/v3/presets/gorm2op" "github.com/qor5/admin/v3/role" @@ -27,7 +28,7 @@ import ( "gorm.io/gorm" ) -func configUser(b *presets.Builder, ab *activity.Builder, db *gorm.DB, publisher *publish.Builder) { +func configUser(b *presets.Builder, ab *activity.Builder, db *gorm.DB, publisher *publish.Builder, loginSessionBuilder *plogin.SessionBuilder) { user := b.Model(&models.User{}) // MenuIcon("people") defer func() { ab.RegisterModel(user) }() @@ -111,7 +112,7 @@ func configUser(b *presets.Builder, ab *activity.Builder, db *gorm.DB, publisher if err != nil { return r, err } - err = expireAllSessionLogs(db, u.ID) + err = loginSessionBuilder.ExpireAllSessions(fmt.Sprint(u.ID)) if err != nil { return r, err } diff --git a/example/models/login_session.go b/example/models/login_session.go deleted file mode 100644 index c82330092..000000000 --- a/example/models/login_session.go +++ /dev/null @@ -1,20 +0,0 @@ -package models - -import ( - "time" - - "gorm.io/gorm" -) - -type LoginSession struct { - gorm.Model - - UserID uint `sql:"index"` - Device string - IP string - TokenHash string `sql:"index"` - ExpiredAt time.Time - - Time string `gorm:"-"` - Status string `gorm:"-"` -} diff --git a/example/models/user.go b/example/models/user.go index 723886b8f..fe0f3f757 100644 --- a/example/models/user.go +++ b/example/models/user.go @@ -17,6 +17,9 @@ const ( OAuthProviderGoogle = "google" OAuthProviderMicrosoftOnline = "microsoftonline" OAuthProviderGithub = "github" + + StatusActive = "active" + StatusInactive = "inactive" ) var DefaultRoles = []string{ diff --git a/login/session.go b/login/session.go new file mode 100644 index 000000000..da8b7a536 --- /dev/null +++ b/login/session.go @@ -0,0 +1,245 @@ +package login + +import ( + "cmp" + "fmt" + "net/http" + "sync" + "time" + + "github.com/qor5/admin/v3/activity" + "github.com/qor5/x/v3/login" + "github.com/ua-parser/uap-go/uaparser" + "gorm.io/gorm" +) + +const ( + LoginTokenHashLen = 8 // The hash string length of the token stored in the DB. +) + +type LoginSession struct { + gorm.Model + + UserID string `gorm:"index;not null"` + Device string `gorm:"not null"` + IP string `gorm:"not null"` + TokenHash string `gorm:"index;not null"` + ExpiredAt time.Time `gorm:"not null"` +} + +func (sess *LoginSession) IsExpired() bool { + return time.Now().After(sess.ExpiredAt) +} + +type SessionBuilder struct { + once sync.Once + lb *login.Builder + db *gorm.DB + amb *activity.ModelBuilder +} + +func NewSessionBuilder(lb *login.Builder, db *gorm.DB) *SessionBuilder { + return &SessionBuilder{lb: lb, db: db} +} + +func (b *SessionBuilder) GetLoginBuilder() *login.Builder { + return b.lb +} + +func (b *SessionBuilder) ActivityModelBuilder(amb *activity.ModelBuilder) *SessionBuilder { + b.amb = amb + return b +} + +func (b *SessionBuilder) CreateSession(r *http.Request, uid string) error { + token := login.GetSessionToken(b.lb, r) + client := uaparser.NewFromSaved().Parse(r.Header.Get("User-Agent")) + + if err := b.db.Create(&LoginSession{ + UserID: uid, + Device: fmt.Sprintf("%v - %v", client.UserAgent.Family, client.Os.Family), + IP: ip(r), + TokenHash: getStringHash(token, LoginTokenHashLen), + ExpiredAt: time.Now().Add(time.Duration(b.lb.GetSessionMaxAge()) * time.Second), + }).Error; err != nil { + return err + } + + return nil +} + +func (b *SessionBuilder) ExtendSession(r *http.Request, uid string, oldToken string) (err error) { + token := login.GetSessionToken(b.lb, r) + tokenHash := getStringHash(token, LoginTokenHashLen) + oldTokenHash := getStringHash(oldToken, LoginTokenHashLen) + if err = b.db.Model(&LoginSession{}). + Where("user_id = ? and token_hash = ?", uid, oldTokenHash). + Updates(map[string]any{ + "token_hash": tokenHash, + "expired_at": time.Now().Add(time.Duration(b.lb.GetSessionMaxAge()) * time.Second), + }).Error; err != nil { + return err + } + + return nil +} + +func (b *SessionBuilder) ExpireCurrentSession(r *http.Request, uid string) (err error) { + token := login.GetSessionToken(b.lb, r) + tokenHash := getStringHash(token, LoginTokenHashLen) + if err = b.db.Model(&LoginSession{}). + Where("user_id = ? and token_hash = ?", uid, tokenHash). + Updates(map[string]any{ + "expired_at": time.Now(), + }).Error; err != nil { + return err + } + + return nil +} + +func (b *SessionBuilder) ExpireAllSessions(uid string) (err error) { + return b.db.Model(&LoginSession{}). + Where("user_id = ?", uid). + Updates(map[string]any{ + "expired_at": time.Now(), + }).Error +} + +func (b *SessionBuilder) ExpireOtherSessions(r *http.Request, uid string) (err error) { + token := login.GetSessionToken(b.lb, r) + + return b.db.Model(&LoginSession{}). + Where("user_id = ? AND token_hash != ?", uid, getStringHash(token, LoginTokenHashLen)). + Updates(map[string]any{ + "expired_at": time.Now(), + }).Error +} + +func (b *SessionBuilder) IsSessionValid(r *http.Request, uid string) (valid bool, err error) { + token := login.GetSessionToken(b.lb, r) + if token == "" { + return false, nil + } + sess := LoginSession{} + if err = b.db.Where("user_id = ? and token_hash = ?", uid, getStringHash(token, LoginTokenHashLen)). + First(&sess). + Error; err != nil { + if err != gorm.ErrRecordNotFound { + return false, err + } + return false, nil + } + if sess.IsExpired() { + return false, nil + } + // IP check + if sess.IP != ip(r) { + return false, nil + } + return true, nil +} + +func (b *SessionBuilder) Middleware(cfgs ...login.MiddlewareConfig) func(next http.Handler) http.Handler { + middleware := b.lb.Middleware(cfgs...) + return func(next http.Handler) http.Handler { + return middleware(b.validateSessionToken()(next)) + } +} + +func (b *SessionBuilder) validateSessionToken() func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := login.GetCurrentUser(r) + if user == nil { + next.ServeHTTP(w, r) + return + } + if login.IsLoginWIP(r) { + next.ServeHTTP(w, r) + return + } + + valid, err := b.IsSessionValid(r, activity.ObjectID(user)) + if err != nil || !valid { + if r.URL.Path == b.lb.LogoutURL { + next.ServeHTTP(w, r) + return + } + http.Redirect(w, r, b.lb.LogoutURL, http.StatusFound) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func (b *SessionBuilder) AutoMigrate() (r *SessionBuilder) { + if b.db == nil { + panic("db is nil") + } + if err := b.db.AutoMigrate(&LoginSession{}); err != nil { + panic(err) + } + return b +} + +func (b *SessionBuilder) Setup() (r *SessionBuilder) { + if b.db == nil { + return b + } + b.once.Do(func() { + logAction := func(r *http.Request, user any, action string) error { + if b.amb != nil && user != nil { + _, err := b.amb.Log(r.Context(), action, user, nil) + return err + } + return nil + } + b.lb.AfterLogin(func(r *http.Request, user any, extraVals ...any) error { + return cmp.Or( + logAction(r, user, "login"), + b.CreateSession(r, activity.ObjectID(user)), + ) + }). + AfterFailedToLogin(func(r *http.Request, user interface{}, _ ...interface{}) error { + return logAction(r, user, "login-failed") + }). + AfterUserLocked(func(r *http.Request, user interface{}, _ ...interface{}) error { + return logAction(r, user, "locked") + }). + AfterLogout(func(r *http.Request, user interface{}, _ ...interface{}) error { + return cmp.Or( + logAction(r, user, "logout"), + b.ExpireCurrentSession(r, activity.ObjectID(user)), + ) + }). + AfterConfirmSendResetPasswordLink(func(r *http.Request, user interface{}, extraVals ...interface{}) error { + return logAction(r, user, "send-reset-password-link") + }). + AfterResetPassword(func(r *http.Request, user interface{}, _ ...interface{}) error { + return cmp.Or( + b.ExpireAllSessions(activity.ObjectID(user)), + logAction(r, user, "reset-password"), + ) + }). + AfterChangePassword(func(r *http.Request, user interface{}, _ ...interface{}) error { + return cmp.Or( + b.ExpireAllSessions(activity.ObjectID(user)), + logAction(r, user, "change-password"), + ) + }). + AfterExtendSession(func(r *http.Request, user interface{}, extraVals ...interface{}) error { + oldToken := extraVals[0].(string) + return cmp.Or( + b.ExtendSession(r, activity.ObjectID(user), oldToken), + logAction(r, user, "extend-session"), + ) + }). + AfterTOTPCodeReused(func(r *http.Request, user interface{}, _ ...interface{}) error { + return logAction(r, user, "totp-code-reused") + }) + }) + return b +} diff --git a/login/util.go b/login/util.go new file mode 100644 index 000000000..b36216357 --- /dev/null +++ b/login/util.go @@ -0,0 +1,42 @@ +package login + +import ( + "crypto/sha256" + "fmt" + "net" + "net/http" + "strings" +) + +func getStringHash(v string, len int) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(v)))[:len] +} + +func ip(r *http.Request) string { + if r == nil { + return "" + } + + ips := proxy(r) + if len(ips) > 0 && ips[0] != "" { + rip, _, err := net.SplitHostPort(ips[0]) + if err != nil { + rip = ips[0] + } + return rip + } + + if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return ip + } + + return r.RemoteAddr +} + +func proxy(r *http.Request) []string { + if ips := r.Header.Get("X-Forwarded-For"); ips != "" { + return strings.Split(ips, ",") + } + + return nil +} diff --git a/login/views.go b/login/views.go index a4067b7c9..c3ddb52da 100644 --- a/login/views.go +++ b/login/views.go @@ -367,7 +367,7 @@ func defaultChangePasswordPage(vh *login.ViewHelper, pb *presets.Builder) web.Pa }) } -func changePasswordDialog(vh *login.ViewHelper, ctx *web.EventContext, showVar string, content HTMLComponent) HTMLComponent { +func changePasswordDialog(_ *login.ViewHelper, ctx *web.EventContext, showVar string, content HTMLComponent) HTMLComponent { pmsgr := presets.MustGetMessages(ctx.R) return web.Scope(VDialog( VCard( @@ -391,7 +391,7 @@ func changePasswordDialog(vh *login.ViewHelper, ctx *web.EventContext, showVar s ).VSlot(" { locals : dialogLocals}").Init(fmt.Sprintf(`{%s: true}`, showVar)) } -func defaultChangePasswordDialogContent(vh *login.ViewHelper, pb *presets.Builder) func(ctx *web.EventContext) HTMLComponent { +func defaultChangePasswordDialogContent(vh *login.ViewHelper, _ *presets.Builder) func(ctx *web.EventContext) HTMLComponent { return func(ctx *web.EventContext) HTMLComponent { msgr := i18n.MustGetModuleMessages(ctx.R, login.I18nLoginKey, login.Messages_en_US).(*login.Messages) return Div( diff --git a/profile/builder.go b/profile/builder.go index fe917aa23..5e5b382eb 100644 --- a/profile/builder.go +++ b/profile/builder.go @@ -65,8 +65,9 @@ func (u *User) GetFirstRole() string { } type Builder struct { - mu sync.RWMutex - pb *presets.Builder + mu sync.RWMutex + pb *presets.Builder + currentUserFunc func(ctx context.Context) (*User, error) renameCallback func(ctx context.Context, newName string) error } From 0e797edf93f3644a70d65818899d8393997a9161 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:59:44 +0800 Subject: [PATCH 06/33] login session dialog --- activity/admin.go | 1 + activity/timeline.go | 1 + example/admin/auth.go | 10 +- example/admin/config.go | 6 +- example/admin/messages.go | 40 ----- example/admin/profile_go | 335 -------------------------------------- example/admin/router.go | 2 - login/messages.go | 57 +++++++ login/session.go | 270 +++++++++++++++++++++++++----- profile/builder.go | 4 + profile/compo.go | 8 +- 11 files changed, 312 insertions(+), 422 deletions(-) delete mode 100644 example/admin/profile_go create mode 100644 login/messages.go diff --git a/activity/admin.go b/activity/admin.go index 574ce0005..5405b608e 100644 --- a/activity/admin.go +++ b/activity/admin.go @@ -25,6 +25,7 @@ const ( paramHideDetailTop = "hideDetailTop" ) +// TODO: 这个 language 在 timeline 也用到,所以就造成 Install 是必须的,这不太对 func (ab *Builder) Install(b *presets.Builder) error { b.GetI18n(). RegisterForModule(language.English, I18nActivityKey, Messages_en_US). diff --git a/activity/timeline.go b/activity/timeline.go index 4b7b5dbf0..cac1e3981 100644 --- a/activity/timeline.go +++ b/activity/timeline.go @@ -93,6 +93,7 @@ func (c *TimelineCompo) humanContent(ctx context.Context, log *ActivityLog, forc } return h.Div().Class("d-flex flex-row align-center ga-2").Children( h.Div(h.Text(msgr.EditedNFields(len(diffs)))).ClassIf(forceTextColor, forceTextColor != ""), + // TODO: 需要判断是否启用了 presets 如果没启用就不显示这个 v.VBtn(msgr.MoreInfo).Class("text-none text-overline d-flex align-center"). Variant(v.VariantTonal).Color(v.ColorPrimary).Size(v.SizeXSmall).PrependIcon("mdi-open-in-new"). Attr("@click", web.POST(). diff --git a/example/admin/auth.go b/example/admin/auth.go index e457c71bd..ce27ff02a 100644 --- a/example/admin/auth.go +++ b/example/admin/auth.go @@ -140,7 +140,15 @@ func initLoginBuilder(db *gorm.DB, pb *presets.Builder, ab *activity.Builder) *p genInitialUser(db) return plogin.NewSessionBuilder(loginBuilder, db). - ActivityModelBuilder(ab.RegisterModel(&models.User{})). + Activity(ab.RegisterModel(&models.User{})). + Presets(pb). + IsPublicUser(func(u interface{}) bool { + user, ok := u.(*models.User) + if !ok { + return false + } + return user.GetAccountName() == loginInitialUserEmail + }). AutoMigrate(). Setup() } diff --git a/example/admin/config.go b/example/admin/config.go index df5771755..f9ee76ed4 100644 --- a/example/admin/config.go +++ b/example/admin/config.go @@ -118,7 +118,6 @@ func NewConfig(db *gorm.DB) Config { richeditor.PluginsJS = [][]byte{js} b.ExtraAsset("/redactor.js", "text/javascript", richeditor.JSComponentsPack()) b.ExtraAsset("/redactor.css", "text/css", richeditor.CSSComponentsPack()) - configBrand(b, db) initPermission(b, db) @@ -338,6 +337,8 @@ func NewConfig(db *gorm.DB) Config { loginSessionBuilder := initLoginBuilder(db, b, ab) + configBrand(b, db, loginSessionBuilder) + configInputDemo(b, db) configOrder(b, db) @@ -479,8 +480,9 @@ func configMenuOrder(b *presets.Builder) { ) } -func configBrand(b *presets.Builder, db *gorm.DB) { +func configBrand(b *presets.Builder, db *gorm.DB, lsb *plogin.SessionBuilder) { profileB := profile.New( + lsb, func(ctx context.Context) (*profile.User, error) { evCtx := web.MustGetEventContext(ctx) u := getCurrentUser(evCtx.R) diff --git a/example/admin/messages.go b/example/admin/messages.go index 3c8863dff..38ce71ec8 100644 --- a/example/admin/messages.go +++ b/example/admin/messages.go @@ -29,16 +29,6 @@ type Messages struct { Status string ChangePassword string LoginSessions string - LoginSessionsTips string - SignOutAllOtherSessions string - Expired string - Active string - CurrentSession string - Time string - Device string - IPAddress string - HideIPTips string - SignOutAllSuccessfullyTips string } var Messages_en_US = &Messages{ @@ -64,16 +54,6 @@ var Messages_en_US = &Messages{ Status: "Status", ChangePassword: "Change Password", LoginSessions: "Login Sessions", - LoginSessionsTips: "Places where you're logged into QOR5 admin.", - SignOutAllOtherSessions: "Sign out all other sessions", - Expired: "Expired", - Active: "Active", - CurrentSession: "Current Session", - Time: "Time", - Device: "Device", - IPAddress: "IP Address", - HideIPTips: "Invisible due to security concerns", - SignOutAllSuccessfullyTips: "All other sessions have successfully been signed out.", } var Messages_ja_JP = &Messages{ @@ -99,16 +79,6 @@ var Messages_ja_JP = &Messages{ Status: "ステータス", ChangePassword: "パスワードを変更する", LoginSessions: "ログインセッション", - LoginSessionsTips: "QOR5管理者にログインしている場所。", - SignOutAllOtherSessions: "他のすべてのセッションをサインアウトする", - Expired: "期限切れ", - Active: "アクティブ", - CurrentSession: "現在のセッション", - Time: "時間", - Device: "デバイス", - IPAddress: "IPアドレス", - HideIPTips: "セキュリティ上の理由から非表示", - SignOutAllSuccessfullyTips: "他のすべてのセッションは正常にサインアウトされました。", } var Messages_zh_CN = &Messages{ @@ -134,16 +104,6 @@ var Messages_zh_CN = &Messages{ Status: "状态", ChangePassword: "修改密码", LoginSessions: "登录会话", - LoginSessionsTips: "您在QOR5管理中登录的地方。", - SignOutAllOtherSessions: "退出所有其他会话", - Expired: "已过期", - Active: "活跃", - CurrentSession: "当前会话", - Time: "时间", - Device: "设备", - IPAddress: "IP地址", - HideIPTips: "由于安全原因,隐藏", - SignOutAllSuccessfullyTips: "所有其他会话已成功退出。", } type Messages_ModelsI18nModuleKey struct { diff --git a/example/admin/profile_go b/example/admin/profile_go deleted file mode 100644 index 111e66367..000000000 --- a/example/admin/profile_go +++ /dev/null @@ -1,335 +0,0 @@ -package admin - -import ( - "fmt" - "net/url" - "sort" - "strings" - - "github.com/dustin/go-humanize" - "github.com/qor5/admin/v3/example/models" - "github.com/qor5/admin/v3/presets" - "github.com/qor5/web/v3" - "github.com/qor5/x/v3/i18n" - "github.com/qor5/x/v3/login" - "github.com/qor5/x/v3/perm" - . "github.com/qor5/x/v3/ui/vuetify" - vx "github.com/qor5/x/v3/ui/vuetifyx" - h "github.com/theplant/htmlgo" - "gorm.io/gorm" -) - -const ( - signOutAllSessionEvent = "signOutAllSessionEvent" - loginSessionDialogEvent = "loginSessionDialogEvent" - accountRenameEvent = "accountRenameEvent" - - paramName = "name" -) - -func profileX(db *gorm.DB) presets.ComponentFunc { - return func(ctx *web.EventContext) h.HTMLComponent { - msgr := i18n.MustGetModuleMessages(ctx.R, I18nExampleKey, Messages_en_US).(*Messages) - - u := getCurrentUser(ctx.R) - if u == nil { - return VBtn("Login").Variant(VariantText).Href("/auth/login") - } - - var roles []string - for _, role := range u.Roles { - roles = append(roles, role.Name) - } - role := "-" - if len(roles) > 0 { - role = roles[0] - } - - total := notifierCount(db)(ctx) - content := notifierComponent(db)(ctx) - icon := VIcon("mdi-bell-outline").Size(20).Color("grey-darken-1") - notification := VMenu().Children( - h.Template().Attr("v-slot:activator", "{ props }").Children( - VBtn("").Icon(true).Children( - h.If(total > 0, - VBadge( - icon, - ).Content(total).Dot(true).Color("error"), - ).Else(icon), - ).Attr("v-bind", "props"). - Density(DensityCompact). - Variant(VariantText), - ), - VCard(content), - ) - - prependProfile := VCard( - web.Slot( - VAvatar().Text(getAvatarShortName(u)).Color(ColorPrimaryLighten2).Size(SizeLarge).Class(fmt.Sprintf("rounded-lg text-%s", ColorPrimary)), - ).Name(VSlotPrepend), - web.Slot( - h.Div( - h.Div(h.Text(u.Name)).Class(fmt.Sprintf(`text-subtitle-2 text-%s`, ColorSecondary)), - VBtn(""). - Icon(true).Density(DensityCompact).Variant(VariantText).Children( - VIcon("mdi-chevron-right").Size(SizeSmall), - ), - ).Class("d-flex justify-space-between align-center"), - ).Name(VSlotTitle), - web.Slot( - h.Div(h.Text(role)), - ).Name(VSlotSubtitle), - ).Class(W100).Flat(true) - - profileMenuCard := VMenu( - web.Slot().Name("activator").Scope(`{props}`).Children( - prependProfile.Attr("v-bind", "props"), - ), - VCard( - VCardText( - web.Scope( - VCard( - web.Slot( - VAvatar().Text(getAvatarShortName(u)).Color(ColorPrimaryLighten2). - Size(SizeXLarge).Class(fmt.Sprintf("rounded-lg text-%s", ColorPrimary), "mr-6"), - ).Name(VSlotPrepend), - web.Slot( - VTextField( - web.Slot( - VIcon("mdi-pencil-outline").Attr("@click", "locals.editShow=true"), - ).Name(VSlotAppend), - ).HideDetails(true). - Autofocus(true). - Color(ColorPrimary). - Attr(":variant", fmt.Sprintf(`locals.editShow?"%s":"%s"`, VariantOutlined, VariantPlain)). - Attr(":readonly", `!locals.editShow`). - Attr(web.VField(paramName, u.Name)...). - Attr("@blur", "locals.editShow=false"). - Attr("@keyup.enter", web.Plaid().EventFunc(accountRenameEvent). - URL("/profile").Query(presets.ParamID, u.ID).Go()), - ).Name(VSlotTitle), - web.Slot( - h.Div( - h.Text(role), - ), - h.Div( - VChip(h.Text(u.Status)).Label(true).Density(DensityCompact).Color(ColorSuccess).Class("px-1"), - ).Class("mt-2"), - ).Name(VSlotSubtitle), - ).Variant(VariantTonal).Rounded(false), - ).VSlot(`{ locals }`).Init(`{editShow:false}`), - ).Class("pa-0"), - VCardText( - VRow( - VCol( - vx.VXReadonlyField().Label(msgr.Email).Value(u.Account), - ), - ).Class("my-n6"), - VRow( - VCol( - vx.VXReadonlyField().Label(msgr.Company).Value(u.Company), - ), - ).Class("my-n6"), - VRow( - VBtn("View login sessions"). - Attr("@click", web.Plaid().URL("/profile").EventFunc(loginSessionDialogEvent).Query(presets.ParamID, u.ID).Go()). - Variant(VariantTonal).Color(ColorSecondary).Class(W100, "mt-6"), - ), - VRow( - VBtn("Logout").Attr("@click", web.Plaid().URL(logoutURL).Go()).Variant(VariantTonal).Color(ColorError).Class(W100, "mt-2"), - ), - ).Class("my-6 mx-2"), - )).Location(LocationEnd).CloseOnContentClick(false).Width(300) - - profileNewLook := VCard( - VCardTitle( - profileMenuCard, - VCardText( - h.Div( - notification, - ).Class("border-s-md", "pl-4", H75)), - ).Class("d-inline-flex align-center justify-space-between justify-center pa-0", W100), - ).Class("pa-0").Class(W100) - return profileNewLook - } -} - -type Profile struct{} - -func configProfile(b *presets.Builder, db *gorm.DB) { - m := b.Model(&Profile{}).URIName("profile"). - MenuIcon("mdi-account").Label("Profile").InMenu(false) - m.RegisterEventFunc(signOutAllSessionEvent, func(ctx *web.EventContext) (r web.EventResponse, err error) { - msgr := i18n.MustGetModuleMessages(ctx.R, I18nExampleKey, Messages_en_US).(*Messages) - - u := getCurrentUser(ctx.R) - - if u.GetAccountName() == loginInitialUserEmail { - return r, perm.PermissionDenied - } - - if err = expireOtherSessionLogs(db, ctx.R, u.ID); err != nil { - return r, err - } - - presets.ShowMessage(&r, msgr.SignOutAllSuccessfullyTips, "") - r.Reload = true - return - }) - m.RegisterEventFunc(loginSessionDialogEvent, loginSession(db)) - m.RegisterEventFunc(accountRenameEvent, accountRename(m, db)) -} - -func getAvatarShortName(u *models.User) string { - name := u.Name - if name == "" { - name = u.Account - } - if rs := []rune(name); len(rs) > 1 { - name = string(rs[:1]) - } - - return strings.ToUpper(name) -} - -func loginSession(db *gorm.DB) web.EventFunc { - return func(ctx *web.EventContext) (r web.EventResponse, err error) { - msgr := i18n.MustGetModuleMessages(ctx.R, I18nExampleKey, Messages_en_US).(*Messages) - presetsMsgr := presets.MustGetMessages(ctx.R) - uid := ctx.Param(presets.ParamID) - u := &models.User{} - if err = db.First(&u, uid).Error; err != nil { - return - } - var items []*models.LoginSession - if err = db.Where("user_id = ?", u.ID).Find(&items).Error; err != nil { - return - } - - isPublicUser := false - if u.GetAccountName() == loginInitialUserEmail { - isPublicUser = true - } - - currentTokenHash := getStringHash(login.GetSessionToken(loginBuilder, ctx.R), LoginTokenHashLen) - - var ( - expired = msgr.Expired - active = msgr.Active - currentSession = msgr.CurrentSession - ) - - activeDevices := make(map[string]struct{}) - for _, item := range items { - if isPublicUser { - item.IP = msgr.HideIPTips - } - - if isTokenValid(*item) { - item.Status = expired - } else { - item.Status = active - activeDevices[fmt.Sprintf("%s#%s", item.Device, item.IP)] = struct{}{} - } - if item.TokenHash == currentTokenHash { - item.Status = currentSession - } - - item.Time = humanize.Time(item.CreatedAt) - } - - { - newItems := make([]*models.LoginSession, 0, len(items)) - for _, item := range items { - if item.Status == expired { - _, ok := activeDevices[fmt.Sprintf("%s#%s", item.Device, item.IP)] - if ok { - continue - } - } - newItems = append(newItems, item) - } - items = newItems - } - - if isPublicUser { - if len(items) > 10 { - items = items[:10] - } - } - - sort.Slice(items, func(i, j int) bool { - if items[j].Status == currentSession { - return false - } - if items[i].Status == expired && - items[j].Status == active { - return false - } - if items[i].CreatedAt.Sub(items[j].CreatedAt) < 0 { - return false - } - return true - }) - - sessionTableHeaders := []DataTableHeader{ - {msgr.Time, "Time", "25%", false}, - {msgr.Device, "Device", "25%", false}, - {msgr.IPAddress, "IP", "25%", false}, - {"", "Status", "25%", true}, - } - - body := web.Scope().VSlot("{locals}").Init("{dialog:true}").Children( - VDialog( - VCard( - VCardTitle( - h.Text(msgr.LoginSessions), - VBtn("").Icon("mdi-close").Variant(VariantText).Attr("@click", "locals.dialog=false"), - ).Class("d-flex justify-space-between align-center", W100), - VRow( - VCol(VCardSubtitle(h.Text(msgr.LoginSessionsTips))), - VCol( - h.If(!isPublicUser, - VBtn("").Attr("@click", web.Plaid().EventFunc(signOutAllSessionEvent).Go()). - Variant(VariantOutlined).Color("primary"). - Children(VIcon("warning").Size(SizeSmall), h.Text(msgr.SignOutAllOtherSessions))), - ).Class("text-right mt-6 mr-4"), - ), - h.Div( - VDataTable().Headers(sessionTableHeaders). - Items(items). - ItemsPerPage(-1).HideDefaultFooter(true), - VCardActions(VSpacer(), VBtn(presetsMsgr.Cancel).Variant(VariantOutlined).Attr("@click", "locals.dialog=false")).Class("pa-0"), - ).Class("pa-6"), - ).Class("mx-2 mt-12 mb-4"), - ).Attr("v-model", "locals.dialog").Width(780), - ) - - r.UpdatePortals = append(r.UpdatePortals, &web.PortalUpdate{Name: presets.DialogPortalName, Body: body}) - - return - } -} - -func accountRename(mb *presets.ModelBuilder, db *gorm.DB) web.EventFunc { - return func(ctx *web.EventContext) (r web.EventResponse, err error) { - var ( - uid = ctx.Param(presets.ParamID) - name = ctx.Param(paramName) - u = &models.User{} - ) - if err = db.First(u, uid).Error; err != nil { - return - } - if mb.Info().Verifier().Do(presets.PermUpdate).ObjectOn(u).WithReq(ctx.R).IsAllowed() != nil { - presets.ShowMessage(&r, perm.PermissionDenied.Error(), ColorError) - return - } - u.Name = name - if err = db.Save(u).Error; err != nil { - return - } - r.PushState = web.Location(url.Values{}) - return - } -} diff --git a/example/admin/router.go b/example/admin/router.go index a81476d80..445dd06a9 100644 --- a/example/admin/router.go +++ b/example/admin/router.go @@ -17,8 +17,6 @@ import ( var favicon []byte const ( - logoutURL = "/auth/logout" - exportOrdersURL = "/export-orders" ) diff --git a/login/messages.go b/login/messages.go new file mode 100644 index 000000000..304565ba0 --- /dev/null +++ b/login/messages.go @@ -0,0 +1,57 @@ +package login + +type Messages struct { + SessionTableHeaderTime string + SessionTableHeaderDevice string + SessionTableHeaderIPAddress string + SessionTableHeaderStatus string + SessionsDialogTitle string + SessionStatusExpired string + SessionStatusActive string + SessionStatusCurrent string + HideIPAddressTips string + ExpireOtherSessions string + SuccessfullyExpiredOtherSessions string +} + +var Messages_en_US = &Messages{ + SessionTableHeaderTime: "Time", + SessionTableHeaderDevice: "Device", + SessionTableHeaderIPAddress: "IP Address", + SessionTableHeaderStatus: "Status", + SessionsDialogTitle: "Login Sessions", + SessionStatusExpired: "Expired", + SessionStatusActive: "Active", + SessionStatusCurrent: "Current Session", + HideIPAddressTips: "Invisible due to security concerns", + ExpireOtherSessions: "Sign out all other sessions", + SuccessfullyExpiredOtherSessions: "All other sessions have successfully been signed out.", +} + +var Messages_zh_CN = &Messages{ + SessionTableHeaderTime: "时间", + SessionTableHeaderDevice: "设备", + SessionTableHeaderIPAddress: "IP地址", + SessionTableHeaderStatus: "状态", + SessionsDialogTitle: "登录会话", + SessionStatusExpired: "已过期", + SessionStatusActive: "有效", + SessionStatusCurrent: "当前会话", + HideIPAddressTips: "由于安全原因,隐藏", + ExpireOtherSessions: "登出所有其他会话", + SuccessfullyExpiredOtherSessions: "所有其他会话已成功登出。", +} + +var Messages_ja_JP = &Messages{ + SessionTableHeaderTime: "時間", + SessionTableHeaderDevice: "デバイス", + SessionTableHeaderIPAddress: "IPアドレス", + SessionTableHeaderStatus: "ステータス", + SessionsDialogTitle: "ログインセッション", + SessionStatusExpired: "期限切れ", + SessionStatusActive: "有効", + SessionStatusCurrent: "現在のセッション", + HideIPAddressTips: "セキュリティ上の理由から非表示", + ExpireOtherSessions: "他のすべてのセッションをサインアウトする", + SuccessfullyExpiredOtherSessions: "他のすべてのセッションは正常にサインアウトされました。", +} diff --git a/login/session.go b/login/session.go index da8b7a536..3d3284b24 100644 --- a/login/session.go +++ b/login/session.go @@ -5,14 +5,28 @@ import ( "fmt" "net/http" "sync" + "sync/atomic" "time" + "github.com/dustin/go-humanize" + "github.com/pkg/errors" "github.com/qor5/admin/v3/activity" + "github.com/qor5/admin/v3/presets" + "github.com/qor5/web/v3" + "github.com/qor5/x/v3/i18n" "github.com/qor5/x/v3/login" + "github.com/qor5/x/v3/perm" + v "github.com/qor5/x/v3/ui/vuetify" + h "github.com/theplant/htmlgo" "github.com/ua-parser/uap-go/uaparser" + "golang.org/x/text/language" "gorm.io/gorm" ) +const ( + I18nLoginSessionKey i18n.ModuleKey = "I18nLoginSessionKey" +) + const ( LoginTokenHashLen = 8 // The hash string length of the token stored in the DB. ) @@ -27,15 +41,14 @@ type LoginSession struct { ExpiredAt time.Time `gorm:"not null"` } -func (sess *LoginSession) IsExpired() bool { - return time.Now().After(sess.ExpiredAt) -} - type SessionBuilder struct { - once sync.Once - lb *login.Builder - db *gorm.DB - amb *activity.ModelBuilder + once sync.Once + lb *login.Builder + db *gorm.DB + amb *activity.ModelBuilder + pb *presets.Builder + setup atomic.Bool + isPublicUser func(user any) bool } func NewSessionBuilder(lb *login.Builder, db *gorm.DB) *SessionBuilder { @@ -46,74 +59,85 @@ func (b *SessionBuilder) GetLoginBuilder() *login.Builder { return b.lb } -func (b *SessionBuilder) ActivityModelBuilder(amb *activity.ModelBuilder) *SessionBuilder { +func (b *SessionBuilder) Activity(amb *activity.ModelBuilder) *SessionBuilder { b.amb = amb return b } +func (b *SessionBuilder) Presets(pb *presets.Builder) *SessionBuilder { + b.pb = pb + return b +} + +func (b *SessionBuilder) IsPublicUser(f func(user any) bool) *SessionBuilder { + b.isPublicUser = f + return b +} + func (b *SessionBuilder) CreateSession(r *http.Request, uid string) error { token := login.GetSessionToken(b.lb, r) client := uaparser.NewFromSaved().Parse(r.Header.Get("User-Agent")) - if err := b.db.Create(&LoginSession{ UserID: uid, Device: fmt.Sprintf("%v - %v", client.UserAgent.Family, client.Os.Family), IP: ip(r), TokenHash: getStringHash(token, LoginTokenHashLen), - ExpiredAt: time.Now().Add(time.Duration(b.lb.GetSessionMaxAge()) * time.Second), + ExpiredAt: b.db.NowFunc().Add(time.Duration(b.lb.GetSessionMaxAge()) * time.Second), }).Error; err != nil { - return err + return errors.Wrap(err, "login: failed to create session") } - return nil } -func (b *SessionBuilder) ExtendSession(r *http.Request, uid string, oldToken string) (err error) { +func (b *SessionBuilder) ExtendSession(r *http.Request, uid string, oldToken string) error { token := login.GetSessionToken(b.lb, r) tokenHash := getStringHash(token, LoginTokenHashLen) oldTokenHash := getStringHash(oldToken, LoginTokenHashLen) - if err = b.db.Model(&LoginSession{}). + if err := b.db.Model(&LoginSession{}). Where("user_id = ? and token_hash = ?", uid, oldTokenHash). Updates(map[string]any{ "token_hash": tokenHash, - "expired_at": time.Now().Add(time.Duration(b.lb.GetSessionMaxAge()) * time.Second), + "expired_at": b.db.NowFunc().Add(time.Duration(b.lb.GetSessionMaxAge()) * time.Second), }).Error; err != nil { - return err + return errors.Wrap(err, "login: failed to extend session") } - return nil } -func (b *SessionBuilder) ExpireCurrentSession(r *http.Request, uid string) (err error) { +func (b *SessionBuilder) ExpireCurrentSession(r *http.Request, uid string) error { token := login.GetSessionToken(b.lb, r) tokenHash := getStringHash(token, LoginTokenHashLen) - if err = b.db.Model(&LoginSession{}). + if err := b.db.Model(&LoginSession{}). Where("user_id = ? and token_hash = ?", uid, tokenHash). Updates(map[string]any{ - "expired_at": time.Now(), + "expired_at": b.db.NowFunc(), }).Error; err != nil { - return err + return errors.Wrap(err, "login: failed to expire current session") } - return nil } -func (b *SessionBuilder) ExpireAllSessions(uid string) (err error) { - return b.db.Model(&LoginSession{}). +func (b *SessionBuilder) ExpireAllSessions(uid string) error { + if err := b.db.Model(&LoginSession{}). Where("user_id = ?", uid). Updates(map[string]any{ - "expired_at": time.Now(), - }).Error + "expired_at": b.db.NowFunc(), + }).Error; err != nil { + return errors.Wrap(err, "login: failed to expire all sessions") + } + return nil } -func (b *SessionBuilder) ExpireOtherSessions(r *http.Request, uid string) (err error) { +func (b *SessionBuilder) ExpireOtherSessions(r *http.Request, uid string) error { token := login.GetSessionToken(b.lb, r) - - return b.db.Model(&LoginSession{}). + if err := b.db.Model(&LoginSession{}). Where("user_id = ? AND token_hash != ?", uid, getStringHash(token, LoginTokenHashLen)). Updates(map[string]any{ - "expired_at": time.Now(), - }).Error + "expired_at": b.db.NowFunc(), + }).Error; err != nil { + return errors.Wrap(err, "login: failed to expire other sessions") + } + return nil } func (b *SessionBuilder) IsSessionValid(r *http.Request, uid string) (valid bool, err error) { @@ -126,11 +150,11 @@ func (b *SessionBuilder) IsSessionValid(r *http.Request, uid string) (valid bool First(&sess). Error; err != nil { if err != gorm.ErrRecordNotFound { - return false, err + return false, errors.Wrap(err, "login: failed to find session") } return false, nil } - if sess.IsExpired() { + if sess.ExpiredAt.Before(b.db.NowFunc()) { return false, nil } // IP check @@ -186,10 +210,10 @@ func (b *SessionBuilder) AutoMigrate() (r *SessionBuilder) { } func (b *SessionBuilder) Setup() (r *SessionBuilder) { - if b.db == nil { - return b - } b.once.Do(func() { + defer func() { + b.setup.Store(true) + }() logAction := func(r *http.Request, user any, action string) error { if b.amb != nil && user != nil { _, err := b.amb.Log(r.Context(), action, user, nil) @@ -240,6 +264,178 @@ func (b *SessionBuilder) Setup() (r *SessionBuilder) { AfterTOTPCodeReused(func(r *http.Request, user interface{}, _ ...interface{}) error { return logAction(r, user, "totp-code-reused") }) + + if b.pb != nil { + b.pb.GetI18n(). + RegisterForModule(language.English, I18nLoginSessionKey, Messages_en_US). + RegisterForModule(language.SimplifiedChinese, I18nLoginSessionKey, Messages_zh_CN). + RegisterForModule(language.Japanese, I18nLoginSessionKey, Messages_ja_JP) + + type LoginSessionsDialog struct{} + mb := b.pb.Model(&LoginSessionsDialog{}).URIName(uriNameLoginSessionsDialog).InMenu(false) + mb.RegisterEventFunc(eventLoginSessionsDialog, b.handleEventLoginSessionsDialog) + mb.RegisterEventFunc(eventExpireOtherSessions, b.handleEventExpireOtherSessions) + } }) return b } + +const ( + uriNameLoginSessionsDialog = "login-sessions-dialog" + eventLoginSessionsDialog = "loginSession_eventLoginSessionsDialog" + eventExpireOtherSessions = "loginSession_eventExpireOtherSessions" +) + +func (b *SessionBuilder) OpenSessionsDialog() string { + if b.setup.Load() == false { + panic("login: SessionBuilder is not setup") + } + if b.pb == nil { + panic("presets.Builder is nil") + } + return web.Plaid().URL("/" + uriNameLoginSessionsDialog).EventFunc(eventLoginSessionsDialog).Go() +} + +type dataTableHeader struct { + Title string `json:"title"` + Key string `json:"key"` + Width string `json:"width"` + Sortable bool `json:"sortable"` +} + +func (b *SessionBuilder) handleEventLoginSessionsDialog(ctx *web.EventContext) (r web.EventResponse, err error) { + msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginSessionKey, Messages_en_US).(*Messages) + // presetsMsgr := presets.MustGetMessages(ctx.R) + + user := login.GetCurrentUser(ctx.R) + if user == nil { + return r, errors.New("login: user not found") + } + uid := activity.ObjectID(user) + currentTokenHash := getStringHash(login.GetSessionToken(b.lb, ctx.R), LoginTokenHashLen) + + var sessions []*LoginSession + + // Only one record with the same `device+ip` is returned unless they are not expired. + // Order by `expired_at` in descending order. + // If the token is the current one, it should be the first one. + // Max 100 records returned. + raw := ` + WITH ranked_sessions AS ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY "device", "ip" ORDER BY "expired_at" DESC) AS rn + FROM "public"."login_sessions" + WHERE "user_id" = ? AND "deleted_at" IS NULL + ) + SELECT * + FROM ranked_sessions + WHERE rn = 1 OR "expired_at" >= NOW() + ORDER BY CASE WHEN "token_hash" = ? THEN 0 ELSE 1 END, "expired_at" DESC + LIMIT 100;` + if err := b.db.Raw(raw, uid, currentTokenHash).Scan(&sessions).Error; err != nil { + return r, errors.Wrap(err, "login: failed to find sessions") + } + + isPublicUser := false + if b.isPublicUser != nil { + isPublicUser = b.isPublicUser(user) + } + + if isPublicUser && len(sessions) > 10 { + sessions = sessions[:10] + } + + type sessionWrapper struct { + *LoginSession + Time string + Status string + } + now := b.db.NowFunc() + wrappers := make([]*sessionWrapper, 0, len(sessions)) + for _, v := range sessions { + w := &sessionWrapper{ + LoginSession: v, + Time: humanize.Time(v.CreatedAt), + Status: msgr.SessionStatusActive, + } + if isPublicUser { + w.IP = msgr.HideIPAddressTips + } + if v.ExpiredAt.Before(now) { + w.Status = msgr.SessionStatusExpired + } + if v.TokenHash == currentTokenHash { + w.Status = msgr.SessionStatusCurrent + } + wrappers = append(wrappers, w) + } + tableHeaders := []dataTableHeader{ + {msgr.SessionTableHeaderTime, "Time", "25%", false}, + {msgr.SessionTableHeaderDevice, "Device", "25%", false}, + {msgr.SessionTableHeaderIPAddress, "IP", "25%", false}, + {msgr.SessionTableHeaderStatus, "Status", "25%", false}, + } + table := v.VDataTable().Headers(tableHeaders).Items(wrappers).ItemsPerPage(-1).HideDefaultFooter(true) + + body := web.Scope().VSlot("{locals: xlocals}").Init("{dialog:true}").Children( + v.VDialog().Attr("v-model", "xlocals.dialog").Width("60%").MaxWidth(828).Scrollable(true).Children( + v.VCard().Children( + v.VCardTitle().Class("d-flex align-center pa-6 ga-2").Children( + h.Div().Class("text-h6").Text(msgr.SessionsDialogTitle), + v.VSpacer(), + v.VBtn("").Size(v.SizeXSmall).Icon("mdi-close").Variant(v.VariantText).Color(v.ColorGreyDarken1).Attr("@click", "xlocals.dialog=false"), + ), + v.VCardText().Class("px-6 pt-0 pb-6").Attr("style", "max-height: 46vh;").ClassIf("mb-6", isPublicUser).Children( + table, + ), + + h.Iff(!isPublicUser, func() h.HTMLComponent { + return v.VCardActions().Class("px-6 pt-0 pb-6").Children( + v.VSpacer(), + v.VBtn(msgr.ExpireOtherSessions).Variant(v.VariantOutlined).Size(v.SizeSmall).Color(v.ColorWarning).PrependIcon("mdi-alert-circle-outline"). + Class("text-none font-weight-regular"). + Attr("@click", web.Plaid().URL("/"+uriNameLoginSessionsDialog).EventFunc(eventExpireOtherSessions).Go()), + ) + }), + + // The old implementation doesn't make sense, so I removed it. + // v.VCardActions().Class("px-6 pt-0 pb-6").Children( + // v.VSpacer(), + // v.VBtn(presetsMsgr.Cancel).Variant(v.VariantOutlined).Size(v.SizeSmall).Color(v.ColorSecondary). + // Class("text-none text-caption font-weight-regular"). + // Attr("@click", "xlocals.dialog=false"), + // v.VBtn(presetsMsgr.OK).Variant(v.VariantTonal).Size(v.SizeSmall). + // Class("text-none text-caption font-weight-regular bg-secondary text-on-secondary"). + // Attr("@click", "xlocals.dialog=false"), + // ), + ), + ), + ) + + r.UpdatePortals = append(r.UpdatePortals, &web.PortalUpdate{Name: presets.DialogPortalName, Body: body}) + return +} + +func (b *SessionBuilder) handleEventExpireOtherSessions(ctx *web.EventContext) (r web.EventResponse, err error) { + msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginSessionKey, Messages_en_US).(*Messages) + + user := login.GetCurrentUser(ctx.R) + if user == nil { + return r, errors.New("login: user not found") + } + isPublicUser := false + if b.isPublicUser != nil { + isPublicUser = b.isPublicUser(user) + } + if isPublicUser { + return r, perm.PermissionDenied + } + uid := activity.ObjectID(user) + + if err = b.ExpireOtherSessions(ctx.R, uid); err != nil { + return r, err + } + + presets.ShowMessage(&r, msgr.SuccessfullyExpiredOtherSessions, "") + web.AppendRunScripts(&r, web.Plaid().MergeQuery(true).Go()) + return +} diff --git a/profile/builder.go b/profile/builder.go index 5e5b382eb..9c626374d 100644 --- a/profile/builder.go +++ b/profile/builder.go @@ -6,6 +6,7 @@ import ( "github.com/pkg/errors" "github.com/qor5/admin/v3/activity" + plogin "github.com/qor5/admin/v3/login" "github.com/qor5/admin/v3/presets" "github.com/qor5/web/v3" "github.com/qor5/x/v3/i18n" @@ -68,15 +69,18 @@ type Builder struct { mu sync.RWMutex pb *presets.Builder + lsb *plogin.SessionBuilder currentUserFunc func(ctx context.Context) (*User, error) renameCallback func(ctx context.Context, newName string) error } func New( + lsb *plogin.SessionBuilder, currentUserFunc func(ctx context.Context) (*User, error), renameCallback func(ctx context.Context, newName string) error, ) *Builder { return &Builder{ + lsb: lsb, currentUserFunc: currentUserFunc, renameCallback: renameCallback, } diff --git a/profile/compo.go b/profile/compo.go index 55199bf50..ddc68b161 100644 --- a/profile/compo.go +++ b/profile/compo.go @@ -18,8 +18,6 @@ import ( "golang.org/x/exp/maps" ) -const logoutURL = "/auth/logout" - func init() { stateful.RegisterActionableCompoType(&ProfileCompo{}) } @@ -128,8 +126,8 @@ func (c *ProfileCompo) userCompo(ctx context.Context, user *User) h.HTMLComponen )) } children = append(children, h.Div().Class("d-flex flex-column ga-2").Children( - v.VBtn(msgr.ViewLoginSessions).Variant(v.VariantTonal).Color(v.ColorSecondary), - v.VBtn(msgr.Logout).Variant(v.VariantTonal).Color(v.ColorError).Attr("@click", web.Plaid().URL(logoutURL).Go()), + v.VBtn(msgr.ViewLoginSessions).Variant(v.VariantTonal).Color(v.ColorSecondary).Attr("@click", c.b.lsb.OpenSessionsDialog()), + v.VBtn(msgr.Logout).Variant(v.VariantTonal).Color(v.ColorError).Attr("@click", web.Plaid().URL(c.b.lsb.GetLoginBuilder().LogoutURL).Go()), )) renameAction := stateful.PostAction(ctx, c, @@ -203,6 +201,6 @@ func (c *ProfileCompo) Rename(ctx context.Context, req RenameRequest) (r web.Eve } _, msgr := c.MustGetEventContext(ctx) presets.ShowMessage(&r, msgr.SuccessfullyRename, v.ColorSuccess) - r.Reload = true + web.AppendRunScripts(&r, web.Plaid().MergeQuery(true).Go()) return r, nil } From b26f6d8d81df450c0ab1938fc4ca352f667468ab Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Wed, 24 Jul 2024 10:38:49 +0800 Subject: [PATCH 07/33] go mod tidy --- go.mod | 6 +++--- go.sum | 12 ++++++------ utils/testflow/gentool/go.mod | 2 +- utils/testflow/gentool/go.sum | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index f34d1c75d..4ae3dbaa9 100644 --- a/go.mod +++ b/go.mod @@ -26,13 +26,13 @@ require ( github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.4.0 github.com/qor/oss v0.0.0-20230717083721-c04686f83630 - github.com/qor5/web/v3 v3.0.5-0.20240717040023-4b80a69c031d - github.com/qor5/x/v3 v3.0.6-0.20240716033136-45995deaaeb2 + github.com/qor5/web/v3 v3.0.5-0.20240723094007-d24081129126 + github.com/qor5/x/v3 v3.0.6-0.20240723060143-cae2d56f60a2 github.com/samber/lo v1.46.0 github.com/shurcooL/sanitized_anchor_name v1.0.0 github.com/spf13/cast v1.6.0 github.com/stretchr/testify v1.9.0 - github.com/sunfmin/reflectutils v1.0.5 + github.com/sunfmin/reflectutils v1.0.6-0.20240723093451-ac287aca03a9 github.com/sunfmin/snippetgo v0.0.3 github.com/theplant/bimg v1.1.1 github.com/theplant/docgo v0.0.16 diff --git a/go.sum b/go.sum index d600f62ee..e80399a53 100644 --- a/go.sum +++ b/go.sum @@ -317,10 +317,10 @@ github.com/qor/oss v0.0.0-20230717083721-c04686f83630 h1:CRi4xF7B8aGX/y48NCjarNd github.com/qor/oss v0.0.0-20230717083721-c04686f83630/go.mod h1:FDxJAVwmZ1j8ITcKJExFlzkTYuUor1dBKZgNVWqEqlM= 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.5-0.20240717040023-4b80a69c031d h1:IKW0l0FZAmczHynvBOH6MQjfqpL5v9pSf094uO7U9io= -github.com/qor5/web/v3 v3.0.5-0.20240717040023-4b80a69c031d/go.mod h1:RPLKS/poCC6yN+CKPQB1iS7g2V0iz7vIsJSY6v/8zY0= -github.com/qor5/x/v3 v3.0.6-0.20240716033136-45995deaaeb2 h1:+PKEd+OBDnJj6OVz4I+GubbiUmzMXsOiR/tOk7paidY= -github.com/qor5/x/v3 v3.0.6-0.20240716033136-45995deaaeb2/go.mod h1:85rLAySzyq7L0dWXzICMnBVULCv53AtYmx3x1qeCDx0= +github.com/qor5/web/v3 v3.0.5-0.20240723094007-d24081129126 h1:LfWbM+CBJbgDqKDyRk8GeYYOoZ8UgQG17t5DwoYUX9o= +github.com/qor5/web/v3 v3.0.5-0.20240723094007-d24081129126/go.mod h1:32vdHHcZb2JimlcaclW9hLUyimdXjrllZDHTh3rl6d0= +github.com/qor5/x/v3 v3.0.6-0.20240723060143-cae2d56f60a2 h1:Ed4WRJS9uNomilDEgd6MA/YqrdeGu/0rl2q627hkXmA= +github.com/qor5/x/v3 v3.0.6-0.20240723060143-cae2d56f60a2/go.mod h1:85rLAySzyq7L0dWXzICMnBVULCv53AtYmx3x1qeCDx0= 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.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= @@ -375,8 +375,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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/sunfmin/reflectutils v1.0.5 h1:izu+W1FasRz0KXUqFo/h4GKsXveDiUt3SwVvFs6w0wU= -github.com/sunfmin/reflectutils v1.0.5/go.mod h1:ao2bbF4RZrTe2PboJKdZoC3BA71gdU6rFkCuUjoeqMw= +github.com/sunfmin/reflectutils v1.0.6-0.20240723093451-ac287aca03a9 h1:uGwnYgklZ5WLaQO8GKg4vGP2kykhrQztoAZ5/huKnG0= +github.com/sunfmin/reflectutils v1.0.6-0.20240723093451-ac287aca03a9/go.mod h1:ao2bbF4RZrTe2PboJKdZoC3BA71gdU6rFkCuUjoeqMw= github.com/sunfmin/snippetgo v0.0.3 h1:pMCpFCyW2fYHhfLp4tb5ccvTCpIuSNFomtDCr9duUaE= github.com/sunfmin/snippetgo v0.0.3/go.mod h1:Ue+VuRdcJfuRkdxawPJOYYqKw1MgeJNwAz1qCc1pazQ= github.com/testcontainers/testcontainers-go v0.31.0 h1:W0VwIhcEVhRflwL9as3dhY6jXjVCA27AkmbnZ+UTh3U= diff --git a/utils/testflow/gentool/go.mod b/utils/testflow/gentool/go.mod index 205ab8f29..55534a581 100644 --- a/utils/testflow/gentool/go.mod +++ b/utils/testflow/gentool/go.mod @@ -6,7 +6,7 @@ require ( github.com/gobuffalo/flect v1.0.2 github.com/pkg/errors v0.9.1 github.com/qor5/admin/v3 v3.0.1-0.20240424102851-d75759576158 - github.com/qor5/web/v3 v3.0.5-0.20240717040023-4b80a69c031d + github.com/qor5/web/v3 v3.0.5-0.20240723094007-d24081129126 github.com/sergi/go-diff v1.3.1 mvdan.cc/gofumpt v0.6.0 ) diff --git a/utils/testflow/gentool/go.sum b/utils/testflow/gentool/go.sum index 4085a2aca..e642f07eb 100644 --- a/utils/testflow/gentool/go.sum +++ b/utils/testflow/gentool/go.sum @@ -20,8 +20,8 @@ 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/qor5/web/v3 v3.0.5-0.20240717040023-4b80a69c031d h1:IKW0l0FZAmczHynvBOH6MQjfqpL5v9pSf094uO7U9io= -github.com/qor5/web/v3 v3.0.5-0.20240717040023-4b80a69c031d/go.mod h1:RPLKS/poCC6yN+CKPQB1iS7g2V0iz7vIsJSY6v/8zY0= +github.com/qor5/web/v3 v3.0.5-0.20240723094007-d24081129126 h1:LfWbM+CBJbgDqKDyRk8GeYYOoZ8UgQG17t5DwoYUX9o= +github.com/qor5/web/v3 v3.0.5-0.20240723094007-d24081129126/go.mod h1:32vdHHcZb2JimlcaclW9hLUyimdXjrllZDHTh3rl6d0= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= From 4741dfe33a3a5254fe0f3eed96095ee67cf28af9 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Wed, 24 Jul 2024 11:18:51 +0800 Subject: [PATCH 08/33] fix DataOperator call timing --- example/admin/auth.go | 5 ++--- example/admin/config.go | 23 ++++++++++------------- example/admin/router.go | 2 +- login/session.go | 8 ++++++++ 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/example/admin/auth.go b/example/admin/auth.go index ce27ff02a..c5feff374 100644 --- a/example/admin/auth.go +++ b/example/admin/auth.go @@ -45,7 +45,7 @@ func getCurrentUser(r *http.Request) (u *models.User) { return u } -func initLoginBuilder(db *gorm.DB, pb *presets.Builder, ab *activity.Builder) *plogin.SessionBuilder { +func initLoginSessionBuilder(db *gorm.DB, pb *presets.Builder, ab *activity.Builder) *plogin.SessionBuilder { loginBuilder := plogin.New(pb). DB(db). UserModel(&models.User{}). @@ -149,8 +149,7 @@ func initLoginBuilder(db *gorm.DB, pb *presets.Builder, ab *activity.Builder) *p } return user.GetAccountName() == loginInitialUserEmail }). - AutoMigrate(). - Setup() + AutoMigrate() } func genInitialUser(db *gorm.DB) { diff --git a/example/admin/config.go b/example/admin/config.go index f9ee76ed4..a5b2b8641 100644 --- a/example/admin/config.go +++ b/example/admin/config.go @@ -110,7 +110,7 @@ func NewConfig(db *gorm.DB) Config { Session: sess, Endpoint: publishURL, })) - b := presets.New().RightDrawerWidth("700") + b := presets.New().DataOperator(gorm2op.DataOperator(db)).RightDrawerWidth("700") defer b.Build() js, _ := assets.ReadFile("assets/fontcolor.min.js") @@ -335,7 +335,8 @@ func NewConfig(db *gorm.DB) Config { l10nM.Use(l10nBuilder) l10nVM.Use(l10nBuilder) - loginSessionBuilder := initLoginBuilder(db, b, ab) + loginSessionBuilder := initLoginSessionBuilder(db, b, ab) + defer func() { loginSessionBuilder.Setup() }() configBrand(b, db, loginSessionBuilder) @@ -345,7 +346,6 @@ func NewConfig(db *gorm.DB) Config { configECDashboard(b, db) configUser(b, ab, db, publisher, loginSessionBuilder) - // configProfile(b, db, loginSessionBuilder) b.Use( mediab, @@ -554,16 +554,13 @@ func configBrand(b *presets.Builder, db *gorm.DB, lsb *plogin.SessionBuilder) { h.Script("function updateCountdown(){const now=new Date();const nextEvenHour=new Date(now);nextEvenHour.setHours(nextEvenHour.getHours()+(nextEvenHour.getHours()%2===0?2:1),0,0,0);const timeLeft=nextEvenHour-now;const hours=Math.floor(timeLeft/(60*60*1000));const minutes=Math.floor((timeLeft%(60*60*1000))/(60*1000));const seconds=Math.floor((timeLeft%(60*1000))/1000);const countdownElem=document.getElementById(\"countdown\");countdownElem.innerText=`${hours.toString().padStart(2,\"0\")}:${minutes.toString().padStart(2,\"0\")}:${seconds.toString().padStart(2,\"0\")}`}updateCountdown();setInterval(updateCountdown,1000);"), ), ).Class("mb-n4 mt-n2") - }). - DataOperator(gorm2op.DataOperator(db)). - HomePageFunc(func(ctx *web.EventContext) (r web.PageResponse, err error) { - r.PageTitle = "Home" - r.Body = Dashboard() - return - }). - NotFoundPageLayoutConfig(&presets.LayoutConfig{ - NotificationCenterInvisible: true, - }) + }).HomePageFunc(func(ctx *web.EventContext) (r web.PageResponse, err error) { + r.PageTitle = "Home" + r.Body = Dashboard() + return + }).NotFoundPageLayoutConfig(&presets.LayoutConfig{ + NotificationCenterInvisible: true, + }) } func configPost( diff --git a/example/admin/router.go b/example/admin/router.go index 445dd06a9..b74039a4b 100644 --- a/example/admin/router.go +++ b/example/admin/router.go @@ -43,7 +43,7 @@ func Router(db *gorm.DB) http.Handler { c := NewConfig(db) mux := http.NewServeMux() - c.loginSessionBuilder.GetLoginBuilder().Mount(mux) + c.loginSessionBuilder.Mount(mux) // mux.Handle("/frontstyle.css", c.pb.GetWebBuilder().PacksHandler("text/css", web.ComponentsPack(` // :host { // all: initial; diff --git a/login/session.go b/login/session.go index 3d3284b24..91a5bda75 100644 --- a/login/session.go +++ b/login/session.go @@ -74,6 +74,14 @@ func (b *SessionBuilder) IsPublicUser(f func(user any) bool) *SessionBuilder { return b } +func (b *SessionBuilder) Mount(mux *http.ServeMux) { + b.lb.Mount(mux) +} + +func (b *SessionBuilder) MountAPI(mux *http.ServeMux) { + b.lb.MountAPI(mux) +} + func (b *SessionBuilder) CreateSession(r *http.Request, uid string) error { token := login.GetSessionToken(b.lb, r) client := uaparser.NewFromSaved().Parse(r.Header.Get("User-Agent")) From 12b6da25ae7b8dfb34b1c0e1a8fe3b48958a342c Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Wed, 24 Jul 2024 17:15:34 +0800 Subject: [PATCH 09/33] activity.ObjectID => presets.ObjectID --- login/session.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/login/session.go b/login/session.go index 91a5bda75..b6a75092d 100644 --- a/login/session.go +++ b/login/session.go @@ -192,7 +192,7 @@ func (b *SessionBuilder) validateSessionToken() func(next http.Handler) http.Han return } - valid, err := b.IsSessionValid(r, activity.ObjectID(user)) + valid, err := b.IsSessionValid(r, presets.ObjectID(user)) if err != nil || !valid { if r.URL.Path == b.lb.LogoutURL { next.ServeHTTP(w, r) @@ -232,7 +232,7 @@ func (b *SessionBuilder) Setup() (r *SessionBuilder) { b.lb.AfterLogin(func(r *http.Request, user any, extraVals ...any) error { return cmp.Or( logAction(r, user, "login"), - b.CreateSession(r, activity.ObjectID(user)), + b.CreateSession(r, presets.ObjectID(user)), ) }). AfterFailedToLogin(func(r *http.Request, user interface{}, _ ...interface{}) error { @@ -244,7 +244,7 @@ func (b *SessionBuilder) Setup() (r *SessionBuilder) { AfterLogout(func(r *http.Request, user interface{}, _ ...interface{}) error { return cmp.Or( logAction(r, user, "logout"), - b.ExpireCurrentSession(r, activity.ObjectID(user)), + b.ExpireCurrentSession(r, presets.ObjectID(user)), ) }). AfterConfirmSendResetPasswordLink(func(r *http.Request, user interface{}, extraVals ...interface{}) error { @@ -252,20 +252,20 @@ func (b *SessionBuilder) Setup() (r *SessionBuilder) { }). AfterResetPassword(func(r *http.Request, user interface{}, _ ...interface{}) error { return cmp.Or( - b.ExpireAllSessions(activity.ObjectID(user)), + b.ExpireAllSessions(presets.ObjectID(user)), logAction(r, user, "reset-password"), ) }). AfterChangePassword(func(r *http.Request, user interface{}, _ ...interface{}) error { return cmp.Or( - b.ExpireAllSessions(activity.ObjectID(user)), + b.ExpireAllSessions(presets.ObjectID(user)), logAction(r, user, "change-password"), ) }). AfterExtendSession(func(r *http.Request, user interface{}, extraVals ...interface{}) error { oldToken := extraVals[0].(string) return cmp.Or( - b.ExtendSession(r, activity.ObjectID(user), oldToken), + b.ExtendSession(r, presets.ObjectID(user), oldToken), logAction(r, user, "extend-session"), ) }). @@ -319,7 +319,7 @@ func (b *SessionBuilder) handleEventLoginSessionsDialog(ctx *web.EventContext) ( if user == nil { return r, errors.New("login: user not found") } - uid := activity.ObjectID(user) + uid := presets.ObjectID(user) currentTokenHash := getStringHash(login.GetSessionToken(b.lb, ctx.R), LoginTokenHashLen) var sessions []*LoginSession @@ -437,7 +437,7 @@ func (b *SessionBuilder) handleEventExpireOtherSessions(ctx *web.EventContext) ( if isPublicUser { return r, perm.PermissionDenied } - uid := activity.ObjectID(user) + uid := presets.ObjectID(user) if err = b.ExpireOtherSessions(ctx.R, uid); err != nil { return r, err From eb4c73a1f9f243cc0da35b5b7417048ce516af86 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Wed, 24 Jul 2024 17:46:23 +0800 Subject: [PATCH 10/33] make creating reuse the editing logic, unless Wrap is not used --- presets/editing.go | 27 +++++++++++++++++++++------ publish/builder.go | 3 +-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/presets/editing.go b/presets/editing.go index c812aaf8f..3a36b8750 100644 --- a/presets/editing.go +++ b/presets/editing.go @@ -61,12 +61,27 @@ func (b *EditingBuilder) Creating(vs ...interface{}) (r *EditingBuilder) { if b.mb.creating == nil { b.mb.creating = &EditingBuilder{ - mb: b.mb, - Fetcher: b.Fetcher, - Setter: b.Setter, - Saver: b.Saver, - Deleter: b.Deleter, - Validator: b.Validator, + mb: b.mb, + Fetcher: func(obj interface{}, id string, ctx *web.EventContext) (interface{}, error) { + return b.Fetcher(obj, id, ctx) + }, + Setter: func(obj interface{}, ctx *web.EventContext) { + if b.Setter != nil { + b.Setter(obj, ctx) + } + }, + Saver: func(obj interface{}, id string, ctx *web.EventContext) error { + return b.Saver(obj, id, ctx) + }, + Deleter: func(obj interface{}, id string, ctx *web.EventContext) error { + return b.Deleter(obj, id, ctx) + }, + Validator: func(obj interface{}, ctx *web.EventContext) (r web.ValidationErrors) { + if b.Validator == nil { + return r + } + return b.Validator(obj, ctx) + }, } } diff --git a/publish/builder.go b/publish/builder.go index d837f275b..ee6026aab 100644 --- a/publish/builder.go +++ b/publish/builder.go @@ -103,7 +103,7 @@ func (b *Builder) ModelInstall(pb *presets.Builder, m *presets.ModelBuilder) err func (b *Builder) configVersionAndPublish(pb *presets.Builder, mb *presets.ModelBuilder, db *gorm.DB) { ed := mb.Editing() - creating := ed.Creating().Except(VersionsPublishBar) + ed.Creating().Except(VersionsPublishBar) // On demand, currently only supported detailing // var fb *presets.FieldBuilder // if !mb.HasDetailing() { @@ -131,7 +131,6 @@ func (b *Builder) configVersionAndPublish(pb *presets.Builder, mb *presets.Model setter := makeSetVersionSetterFunc(db) ed.WrapSetterFunc(setter) - creating.WrapSetterFunc(setter) mb.Listing().Field(ListingFieldDraftCount).ComponentFunc(draftCountFunc(mb, db)) mb.Listing().Field(ListingFieldLive).ComponentFunc(liveFunc(db)) From f70602ddd126994a4c5364531a65fd61d3e01e5c Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Wed, 24 Jul 2024 17:48:09 +0800 Subject: [PATCH 11/33] activity: rm ActionLastView select option in ActivityLogs menu --- activity/admin.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/activity/admin.go b/activity/admin.go index 5405b608e..25b069d27 100644 --- a/activity/admin.go +++ b/activity/admin.go @@ -130,6 +130,9 @@ func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelB panic(err) } for _, action := range actions { + if action == ActionLastView { + continue + } actionOptions = append(actionOptions, &vuetifyx.SelectItem{ Text: string(action), Value: string(action), From b8b838e1fcaed875a7947ddc12cdcb8be0a801ca Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Thu, 25 Jul 2024 16:57:01 +0800 Subject: [PATCH 12/33] activity: tableNamePrefix without admin --- activity/README.md | 23 ++++- activity/activity_log.go | 11 +-- activity/builder.go | 28 ++++-- activity/builder_test.go | 4 +- activity/model_builder.go | 34 +++---- activity/note.go | 49 ++++++++--- activity/tests/gorm_test.go | 171 ++++++++++++++++++++++++++++++++++++ activity/timeline.go | 5 ++ activity/util.go | 35 ++++++++ example/admin/config.go | 104 ++++++++++------------ 10 files changed, 359 insertions(+), 105 deletions(-) create mode 100644 activity/tests/gorm_test.go diff --git a/activity/README.md b/activity/README.md index b847c7905..2b8a8d257 100644 --- a/activity/README.md +++ b/activity/README.md @@ -33,7 +33,6 @@ - Configure more options for the `presets.ModelBuilder` to record more custom information ```go - activity.RegisterModel(presetModel).UseDefaultTab() //use activity tab on the admin model edit page activity.RegisterModel(presetModel).AddKeys("ID", "Version") // will record value of the ID and Version field as the keyword of a model table activity.RegisterModel(presetModel).AddIgnoredFields("UpdateAt") // will ignore the UpdateAt field when recording activity log for update operation activity.RegisterModel(presetModel).AddTypeHanders( @@ -66,3 +65,25 @@ activity.MustGetModelBuilder(presetModel1).OnEdit(ctx, old, new) activity.MustGetModelBuilder(presetModel2).OnCreate(ctx, obj) ``` + +- Add ListFieldNotes to Listing Builder + + ```go + mb.Listing("ID", "Title", "Body", activity.ListFieldNotes) + ``` + +- Add TimelineCompo to DetailingBuilder or EditingBuilder + + - Add to SidePanel + + ```go + dp.SidePanelFunc(func(obj interface{}, ctx *web.EventContext) h.HTMLComponent { + return ab.MustGetModelBuilder(mb).NewTimelineCompo(ctx, obj, "_side") + }) + ``` + + - Add to Field + + ```go + mb.Detailing("Title", "Body", activity.FieldTimeline) + ``` diff --git a/activity/activity_log.go b/activity/activity_log.go index 04036d937..4d6eab1dc 100644 --- a/activity/activity_log.go +++ b/activity/activity_log.go @@ -33,16 +33,13 @@ type ActivityLog struct { Detail string `gorm:"not null;"` } -func (*ActivityLog) AfterMigrate(tx *gorm.DB) error { - // just a forward compatible - if err := tx.Exec(`DROP INDEX IF EXISTS idx_model_name_keys_action_lastview`).Error; err != nil { - return errors.Wrap(err, "failed to drop index idx_model_name_keys_action_lastview") - } +func (v *ActivityLog) AfterMigrate(tx *gorm.DB, tablePrefix string) error { + tableName := tablePrefix + ParseTableNameWithDB(tx, v) if err := tx.Exec(fmt.Sprintf(` CREATE UNIQUE INDEX IF NOT EXISTS uix_creator_id_model_name_keys_action_lastview - ON activity_logs (creator_id, model_name, model_keys) + ON %s (creator_id, model_name, model_keys) WHERE action = '%s' AND deleted_at IS NULL - `, ActionLastView)).Error; err != nil { + `, tableName, ActionLastView)).Error; err != nil { return errors.Wrap(err, "failed to create index uix_creator_id_model_name_keys_action_lastview") } return nil diff --git a/activity/builder.go b/activity/builder.go index 6229c7b85..228abe243 100644 --- a/activity/builder.go +++ b/activity/builder.go @@ -23,7 +23,9 @@ type User struct { type Builder struct { models []*ModelBuilder // registered model builders - db *gorm.DB // global db + dbPrimitive *gorm.DB + db *gorm.DB // global db + tablePrefix string logModelInstall presets.ModelInstallFunc // log model install permPolicy *perm.PolicyBuilder // permission policy currentUserFunc func(ctx context.Context) (*User, error) @@ -55,6 +57,7 @@ func (ab *Builder) FindUsersFunc(v func(ctx context.Context, ids []string) (map[ // New initializes a new Builder instance with a provided database connection and an optional activity log model. func New(db *gorm.DB, currentUserFunc func(ctx context.Context) (*User, error)) *Builder { ab := &Builder{ + dbPrimitive: db, db: db, currentUserFunc: currentUserFunc, permPolicy: perm.PolicyFor(perm.Anybody).WhoAre(perm.Denied). @@ -65,6 +68,16 @@ func New(db *gorm.DB, currentUserFunc func(ctx context.Context) (*User, error)) return ab } +func (ab *Builder) TablePrefix(prefix string) *Builder { + ab.tablePrefix = prefix + if prefix == "" { + ab.db = ab.dbPrimitive + } else { + ab.db = ab.dbPrimitive.Scopes(ScopeDynamicTablePrefix(prefix)).Session(&gorm.Session{}) + } + return ab +} + // RegisterModels register mutiple models func (ab *Builder) RegisterModels(models ...any) *Builder { for _, model := range models { @@ -137,23 +150,26 @@ func (ab *Builder) GetModelBuilders() []*ModelBuilder { } func (b *Builder) AutoMigrate() (r *Builder) { - if err := AutoMigrate(b.db); err != nil { + if err := AutoMigrate(b.db, ""); err != nil { panic(err) } return b } -func AutoMigrate(db *gorm.DB) error { +func AutoMigrate(db *gorm.DB, tablePrefix string) error { + if tablePrefix != "" { + db = db.Scopes(ScopeDynamicTablePrefix(tablePrefix)).Session(&gorm.Session{}) + } dst := []any{&ActivityLog{}, &ActivityUser{}} for _, v := range dst { - err := db.AutoMigrate(v) + err := db.Model(v).AutoMigrate(v) if err != nil { return errors.Wrap(err, "auto migrate") } if vv, ok := v.(interface { - AfterMigrate(tx *gorm.DB) error + AfterMigrate(tx *gorm.DB, tablePrefix string) error }); ok { - err := vv.AfterMigrate(db) + err := vv.AfterMigrate(db, tablePrefix) if err != nil { return err } diff --git a/activity/builder_test.go b/activity/builder_test.go index f064992af..2fc5c96ed 100644 --- a/activity/builder_test.go +++ b/activity/builder_test.go @@ -55,7 +55,7 @@ func TestMain(m *testing.M) { db = env.DB db.Logger = db.Logger.LogMode(logger.Info) - if err = AutoMigrate(db); err != nil { + if err = AutoMigrate(db, ""); err != nil { panic(err) } if err = db.AutoMigrate(&TestActivityModel{}); err != nil { @@ -84,7 +84,7 @@ func resetDB() { func TestModelKeys(t *testing.T) { resetDB() - if err := AutoMigrate(db); err != nil { + if err := AutoMigrate(db, ""); err != nil { panic(err) } diff --git a/activity/model_builder.go b/activity/model_builder.go index 33b641260..e6e448f28 100644 --- a/activity/model_builder.go +++ b/activity/model_builder.go @@ -98,25 +98,25 @@ func (amb *ModelBuilder) NewTimelineCompo(evCtx *web.EventContext, obj any, idSu return mb }) }) - return h.ComponentFunc(func(ctx context.Context) (r []byte, err error) { - modelName := ParseModelName(obj) - log, err := amb.Log(ctx, ActionLastView, obj, nil) - if err != nil { - panic(err) - } - presets.WrapEventFuncAddon(evCtx, func(in presets.EventFuncAddon) presets.EventFuncAddon { - return func(ctx *web.EventContext, r *web.EventResponse) (err error) { - if err = in(ctx, r); err != nil { - return - } - // this action is special, so we use a separate notification - r.Emit(NotifiLastViewedAtUpdated(modelName), PayloadLastViewedAtUpdated{Log: log}) + modelName := ParseModelName(obj) + + log, err := amb.Log(evCtx.R.Context(), ActionLastView, obj, nil) + if err != nil { + panic(fmt.Errorf("failed to log last view: %w", err)) + } + presets.WrapEventFuncAddon(evCtx, func(in presets.EventFuncAddon) presets.EventFuncAddon { + return func(ctx *web.EventContext, r *web.EventResponse) (err error) { + if err = in(ctx, r); err != nil { return } - }) - - keys := amb.ParseModelKeys(obj) + // this action is special, so we use a separate notification + r.Emit(NotifiLastViewedAtUpdated(modelName), PayloadLastViewedAtUpdated{Log: log}) + return + } + }) + keys := amb.ParseModelKeys(obj) + return h.ComponentFunc(func(ctx context.Context) (r []byte, err error) { return dc.MustInject(injectorName, &TimelineCompo{ ID: mb.Info().URIName() + ":" + keys + idSuffix, ModelName: modelName, @@ -251,7 +251,7 @@ func (amb *ModelBuilder) installPresetsModelBuilder(mb *presets.ModelBuilder) { modelKeyses = append(modelKeyses, amb.ParseModelKeys(obj)) }) if len(modelKeyses) > 0 { - counts, err := GetNotesCounts(amb.ab.db, user.ID, modelName, modelKeyses) + counts, err := amb.ab.GetNotesCounts(ctx.R.Context(), modelName, modelKeyses) if err != nil { return r, totalCount, err } diff --git a/activity/note.go b/activity/note.go index b06010922..bf7160e84 100644 --- a/activity/note.go +++ b/activity/note.go @@ -1,6 +1,7 @@ package activity import ( + "context" "fmt" "strings" "time" @@ -17,11 +18,13 @@ type NoteCount struct { TotalNotesCount int64 } -func GetNotesCounts(db *gorm.DB, creatorID string, modelName string, modelKeyses []string) ([]*NoteCount, error) { +func getNotesCounts(db *gorm.DB, tablePrefix string, creatorID string, modelName string, modelKeyses []string) ([]*NoteCount, error) { if creatorID == "" { return nil, errors.New("creatorID is required") } + tableName := tablePrefix + ParseTableNameWithDB(db, &ActivityLog{}) + args := []any{ ActionNote, } @@ -50,13 +53,13 @@ func GetNotesCounts(db *gorm.DB, creatorID string, modelName string, modelKeyses raw := fmt.Sprintf(` WITH NoteRecords AS ( SELECT model_name, model_keys, created_at, creator_id - FROM activity_logs + FROM %s WHERE action = ? AND deleted_at IS NULL %s ), LastViewedAts AS ( SELECT model_name, model_keys, MAX(updated_at) AS last_viewed_at - FROM public.activity_logs + FROM %s WHERE action = ? AND creator_id = ? AND deleted_at IS NULL %s GROUP BY model_name, model_keys @@ -70,7 +73,7 @@ func GetNotesCounts(db *gorm.DB, creatorID string, modelName string, modelKeyses LEFT JOIN LastViewedAts lva ON n.model_name = lva.model_name AND n.model_keys = lva.model_keys - GROUP BY n.model_name, n.model_keys;`, explictWhere, explictWhere) + GROUP BY n.model_name, n.model_keys;`, tableName, explictWhere, tableName, explictWhere) counts := []*NoteCount{} if err := db.Raw(raw, args...).Scan(&counts).Error; err != nil { @@ -79,19 +82,21 @@ func GetNotesCounts(db *gorm.DB, creatorID string, modelName string, modelKeyses return counts, nil } -func MarkAllNotesAsRead(db *gorm.DB, creatorID string) error { +func markAllNotesAsRead(db *gorm.DB, tablePrefix string, creatorID string) error { + tableName := tablePrefix + ParseTableNameWithDB(db, &ActivityLog{}) + return db.Transaction(func(tx *gorm.DB) error { var results []struct { ModelName string ModelKeys string MaxCreatedAt time.Time } - if err := db.Raw(` + if err := db.Raw(fmt.Sprintf(` SELECT model_name, model_keys, MAX(created_at) AS max_created_at - FROM activity_logs + FROM %s WHERE action = ? AND deleted_at IS NULL GROUP BY model_name, model_keys; - `, ActionNote, + `, tableName), ActionNote, ).Scan(&results).Error; err != nil { return errors.Wrap(err, "") } @@ -126,7 +131,7 @@ func MarkAllNotesAsRead(db *gorm.DB, creatorID string) error { }) } -func SQLConditionHasUnreadNotes(creatorID string, modelName string, columns []string, sep string, columnPrefix string) string { +func sqlConditionHasUnreadNotes(db *gorm.DB, tablePrefix string, creatorID string, modelName string, columns []string, sep string, columnPrefix string) string { a := strings.Join(lo.Map(columns, func(v string, _ int) string { return fmt.Sprintf("%s%s::text", columnPrefix, v) }), ",") @@ -134,17 +139,19 @@ func SQLConditionHasUnreadNotes(creatorID string, modelName string, columns []st return fmt.Sprintf(`split_part(n.model_keys, '%s', %d) AS %s`, sep, i+1, v) }), ",\n") + tableName := tablePrefix + ParseTableNameWithDB(db, &ActivityLog{}) + return fmt.Sprintf(` (%s) IN ( WITH NoteRecords AS ( SELECT model_name, model_keys, created_at, creator_id - FROM activity_logs + FROM %s WHERE action = '%s' AND deleted_at IS NULL AND model_name = '%s' ), LastViewedAts AS ( SELECT model_name, model_keys, MAX(updated_at) AS last_viewed_at - FROM activity_logs + FROM %s WHERE action = '%s' AND creator_id = '%s' AND deleted_at IS NULL AND model_name = '%s' GROUP BY model_name, model_keys @@ -159,9 +166,25 @@ func SQLConditionHasUnreadNotes(creatorID string, modelName string, columns []st WHERE n.creator_id <> '%s' AND (lva.last_viewed_at IS NULL OR n.created_at > lva.last_viewed_at) GROUP BY n.model_keys - )`, a, ActionNote, modelName, ActionLastView, creatorID, modelName, b, creatorID) + )`, a, tableName, ActionNote, modelName, tableName, ActionLastView, creatorID, modelName, b, creatorID) +} + +func (ab *Builder) GetNotesCounts(ctx context.Context, modelName string, modelKeyses []string) ([]*NoteCount, error) { + user, err := ab.currentUserFunc(ctx) + if err != nil { + return nil, err + } + return getNotesCounts(ab.db, ab.tablePrefix, user.ID, modelName, modelKeyses) +} + +func (ab *Builder) MarkAllNotesAsRead(ctx context.Context) error { + user, err := ab.currentUserFunc(ctx) + if err != nil { + return err + } + return markAllNotesAsRead(ab.db, ab.tablePrefix, user.ID) } func (amb *ModelBuilder) SQLConditionHasUnreadNotes(creatorID string, columnPrefix string) string { - return SQLConditionHasUnreadNotes(creatorID, ParseModelName(amb.ref), amb.keyColumns, ModelKeysSeparator, columnPrefix) + return sqlConditionHasUnreadNotes(amb.ab.db, amb.ab.tablePrefix, creatorID, ParseModelName(amb.ref), amb.keyColumns, ModelKeysSeparator, columnPrefix) } diff --git a/activity/tests/gorm_test.go b/activity/tests/gorm_test.go new file mode 100644 index 000000000..c4c8511d9 --- /dev/null +++ b/activity/tests/gorm_test.go @@ -0,0 +1,171 @@ +package activity + +import ( + "cmp" + "testing" + + "github.com/stretchr/testify/require" + "github.com/theplant/testenv" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/gorm/schema" +) + +type Foo struct { + ID string + Name string +} + +type Bar struct { + ID string + Name string +} + +var db *gorm.DB + +func TestMain(m *testing.M) { + env, err := testenv.New().DBEnable(true).SetUp() + if err != nil { + panic(err) + } + defer env.TearDown() + + db = env.DB + db.Logger = db.Logger.LogMode(logger.Info) + + if err = db.AutoMigrate(&Foo{}); err != nil { + panic(err) + } + + m.Run() +} + +func TestTablePrefix(t *testing.T) { + require.NoError(t, db.Create(&Foo{ID: "1", Name: "foo"}).Error) + { + foo := &Foo{} + require.NoError(t, db.Where("id = ?", "1").First(foo).Error) + require.Equal(t, "foo", foo.Name) + } + + require.NoError(t, db.Exec(`CREATE SCHEMA IF NOT EXISTS copilot ;`).Error) + + db := db.Session(&gorm.Session{}) + db.Config.NamingStrategy = schema.NamingStrategy{ + TablePrefix: "copilot.", + IdentifierMaxLength: 64, + } + { + foo := &Foo{} + sql := db.ToSQL(func(tx *gorm.DB) *gorm.DB { + return tx.Where("id = ?", "1").First(foo) + }) + require.NotContains(t, sql, "copilot") // Because the db already has an internal cache + } + { + require.NoError(t, db.AutoMigrate(&Bar{})) + require.NoError(t, db.Create(&Bar{ID: "1", Name: "bar"}).Error) + + sql := db.ToSQL(func(tx *gorm.DB) *gorm.DB { + return tx.Create(&Bar{ID: "1", Name: "bar"}) + }) + require.Contains(t, sql, "copilot") // Because the db hasn't cached the Bar yet. + } + // So it is not a reliable solution. +} + +func TestTable(t *testing.T) { + foo := &Foo{} + require.Contains(t, db.ToSQL(func(tx *gorm.DB) *gorm.DB { + return tx.Table("copilotx.foos").Where("id = ?", "1").First(foo) + }), "copilotx") + require.Contains(t, db.ToSQL(func(tx *gorm.DB) *gorm.DB { + return tx.Table("copiloty.foos").Where("id = ?", "1").First(foo) + }), "copiloty") + + require.NoError(t, db.Exec(`CREATE SCHEMA IF NOT EXISTS copilot;`).Error) + db := db.Table("copilot.foos").Session(&gorm.Session{}) // Fixed TableName + require.Contains(t, db.ToSQL(func(tx *gorm.DB) *gorm.DB { + return tx.Where("id = ?", "1").First(foo) + }), "copilot") + require.Contains(t, db.ToSQL(func(tx *gorm.DB) *gorm.DB { + return tx.Where("id = ?", "1").First(foo) + }), "copilot") +} + +func TestScopes(t *testing.T) { + callCount := 0 + scopeTableName := func(tableName string) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + callCount++ + return db.Table(tableName) + } + } + { + db := db.Scopes(scopeTableName("foos")) + require.NoError(t, db.Create(&Foo{ID: "1", Name: "foo1"}).Error) + require.NoError(t, db.Create(&Foo{ID: "2", Name: "foo2"}).Error) + require.Equal(t, 1, callCount) // the Scopes method is disposable + } + { + db := db.Scopes(scopeTableName("foos")).Session(&gorm.Session{}) // fixed + require.NoError(t, db.Create(&Foo{ID: "3", Name: "foo1"}).Error) + require.NoError(t, db.Create(&Foo{ID: "4", Name: "foo2"}).Error) + require.Equal(t, 1+2, callCount) + } +} + +func TestDynamicTablePrefix(t *testing.T) { + getTableName := func(db *gorm.DB, tablePrefix string, model any) string { + stmt := &gorm.Statement{DB: db} + stmt.Parse(model) + return tablePrefix + stmt.Schema.Table + } + + dynamicTablePrefix := func(tablePrefix string) func(db *gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + stmt := db.Statement + if stmt.Table != "" { + return db + } + + model := cmp.Or(stmt.Model, stmt.Dest) + if model == nil { + return db + } + + return db.Table(getTableName(db, tablePrefix, model)) + } + } + + type Foox struct { + ID string + Name string + } + + type Barx struct { + ID string + Name string + } + + prefix := "some_" + + db := db.Scopes(dynamicTablePrefix(prefix)).Session(&gorm.Session{}) + require.NoError(t, db.AutoMigrate(&Foox{}, &Barx{})) + + foo := &Foox{} + require.Equal(t, "some_fooxes", getTableName(db, prefix, foo)) + require.Contains(t, db.ToSQL(func(tx *gorm.DB) *gorm.DB { + return tx.Where("id = ?", "1").First(foo) + }), "some_fooxes") + + bar := &Barx{} + require.Equal(t, "some_barxes", getTableName(db, prefix, bar)) + require.Contains(t, db.ToSQL(func(tx *gorm.DB) *gorm.DB { + return tx.Where("id = ?", "1").First(bar) + }), "some_barxes") + + require.Contains(t, db.ToSQL(func(tx *gorm.DB) *gorm.DB { + return tx.Table("x_barxes").Where("id = ?", "1").First(bar) + }), "x_barxes") +} diff --git a/activity/timeline.go b/activity/timeline.go index cac1e3981..bd060d643 100644 --- a/activity/timeline.go +++ b/activity/timeline.go @@ -112,6 +112,11 @@ func (c *TimelineCompo) humanContent(ctx context.Context, log *ActivityLog, forc } func (c *TimelineCompo) MarshalHTML(ctx context.Context) ([]byte, error) { + user, err := c.ab.currentUserFunc(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to get current user") + } + evCtx, msgr := c.MustGetEventContext(ctx) if c.mb.Info().Verifier().Do(presets.PermGet).WithReq(evCtx.R).IsAllowed() != nil { return h.Div().Attr("v-pre", true).Text(perm.PermissionDenied.Error()).MarshalHTML(ctx) diff --git a/activity/util.go b/activity/util.go index bad18a899..9f5c87c0a 100644 --- a/activity/util.go +++ b/activity/util.go @@ -1,6 +1,7 @@ package activity import ( + "cmp" "fmt" "reflect" "strings" @@ -109,6 +110,40 @@ func ParsePrimaryKeys(v any) []string { return parsePrimaryKeys(reflect.Indirect(reflect.ValueOf(v)).Type()) } +func ParseTableNameWithDB(db *gorm.DB, model any) string { + stmt := &gorm.Statement{DB: db} + stmt.Parse(model) + return stmt.Schema.Table +} + +const dbKeyTablePrefix = "__table_prefix__" + +// ScopeDynamicTablePrefix set dynamic table prefix +// Only scenarios where a Model is provided are supported +func ScopeDynamicTablePrefix(tablePrefix string) func(db *gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + if _, ok := db.Get(dbKeyTablePrefix); ok { + panic("db table prefix already set") + } + + if tablePrefix == "" { + return db + } + + stmt := db.Statement + if stmt.Table != "" { + return db + } + + model := cmp.Or(stmt.Model, stmt.Dest) + if model == nil { + return db + } + + return db.Set(dbKeyTablePrefix, tablePrefix).Table(tablePrefix + ParseTableNameWithDB(db, model)) + } +} + func FetchOldWithSlug(db *gorm.DB, ref any, slug string) (any, bool) { if slug == "" { return FetchOld(db, ref) diff --git a/example/admin/config.go b/example/admin/config.go index a5b2b8641..b19a3df56 100644 --- a/example/admin/config.go +++ b/example/admin/config.go @@ -95,6 +95,42 @@ func NewConfig(db *gorm.DB) Config { panic(err) } + // @snippet_begin(ActivityExample) + ab := activity.New(db, func(ctx context.Context) (*activity.User, error) { + u := ctx.Value(login.UserKey).(*models.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(model interface{}, params *presets.SearchParams, ctx *web.EventContext) (r interface{}, totalCount int, err error) { + u := getCurrentUser(ctx.R) + if rs := u.GetRoles(); !slices.Contains(rs, models.RoleAdmin) { + params.SQLConditions = append(params.SQLConditions, &presets.SQLCondition{ + Query: "creator_id = ?", + Args: []interface{}{fmt.Sprint(u.ID)}, + }) + } + return in(model, params, ctx) + } + }) + return + } + }). + TablePrefix("cms_"). + AutoMigrate() + + // ab.Model(l).SkipDelete().SkipCreate() + // @snippet_end + sess := session.Must(session.NewSession()) media_oss.Storage = s3.New(&s3.Config{ Bucket: s3Bucket, @@ -173,43 +209,6 @@ func NewConfig(db *gorm.DB) Config { utils.Install(b) - // @snippet_begin(ActivityExample) - ab := activity.New(db, func(ctx context.Context) (*activity.User, error) { - u := ctx.Value(login.UserKey).(*models.User) - return &activity.User{ - ID: fmt.Sprint(u.ID), - Name: u.Name, - Avatar: "", - }, nil - }). - AutoMigrate(). - 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(model interface{}, params *presets.SearchParams, ctx *web.EventContext) (r interface{}, totalCount int, err error) { - u := getCurrentUser(ctx.R) - if rs := u.GetRoles(); !slices.Contains(rs, models.RoleAdmin) { - params.SQLConditions = append(params.SQLConditions, &presets.SQLCondition{ - Query: "creator_id = ?", - Args: []interface{}{fmt.Sprint(u.ID)}, - }) - } - return in(model, params, ctx) - } - }) - return - } - }) - - // ab.Model(m).EnableActivityInfoTab() - // ab.Model(pm).EnableActivityInfoTab() - // ab.Model(l).SkipDelete().SkipCreate() - // @snippet_end - publisher.Activity(ab) // media_view.MediaLibraryPerPage = 3 @@ -326,7 +325,7 @@ func NewConfig(db *gorm.DB) Config { configListModel(b, ab) - b.GetWebBuilder().RegisterEventFunc(noteMarkAllAsRead, markAllAsRead(db)) + b.GetWebBuilder().RegisterEventFunc(noteMarkAllAsRead, markAllAsRead(ab)) microb := microsite.New(db).Publisher(publisher) @@ -480,7 +479,7 @@ func configMenuOrder(b *presets.Builder) { ) } -func configBrand(b *presets.Builder, db *gorm.DB, lsb *plogin.SessionBuilder) { +func configBrand(b *presets.Builder, db *gorm.DB, ab *activity.Builder, lsb *plogin.SessionBuilder) { profileB := profile.New( lsb, func(ctx context.Context) (*profile.User, error) { @@ -489,7 +488,7 @@ func configBrand(b *presets.Builder, db *gorm.DB, lsb *plogin.SessionBuilder) { if u == nil { return nil, perm.PermissionDenied } - notifiCounts, err := activity.GetNotesCounts(db, fmt.Sprint(u.ID), "", nil) + notifiCounts, err := ab.GetNotesCounts(ctx, "", nil) if err != nil { return nil, err } @@ -683,13 +682,9 @@ func configPost( return m } -func notifierCount(db *gorm.DB) func(ctx *web.EventContext) int { +func notifierCount(ab *activity.Builder) func(ctx *web.EventContext) int { return func(ctx *web.EventContext) int { - user := getCurrentUser(ctx.R) - if user == nil { - return 0 - } - counts, err := activity.GetNotesCounts(db, fmt.Sprint(user.ID), "", nil) + counts, err := ab.GetNotesCounts(ctx.R.Context(), "", nil) if err != nil { panic(err) } @@ -701,13 +696,9 @@ func notifierCount(db *gorm.DB) func(ctx *web.EventContext) int { } } -func notifierComponent(db *gorm.DB) func(ctx *web.EventContext) h.HTMLComponent { +func notifierComponent(ab *activity.Builder) func(ctx *web.EventContext) h.HTMLComponent { return func(ctx *web.EventContext) h.HTMLComponent { - user := getCurrentUser(ctx.R) - if user == nil { - return nil - } - counts, err := activity.GetNotesCounts(db, fmt.Sprint(user.ID), "", nil) + counts, err := ab.GetNotesCounts(ctx.R.Context(), "", nil) if err != nil { panic(err) } @@ -747,14 +738,9 @@ func notifierComponent(db *gorm.DB) func(ctx *web.EventContext) h.HTMLComponent var noteMarkAllAsRead = "note_mark_all_as_read" -func markAllAsRead(db *gorm.DB) web.EventFunc { +func markAllAsRead(ab *activity.Builder) web.EventFunc { return func(ctx *web.EventContext) (r web.EventResponse, err error) { - u := getCurrentUser(ctx.R) - if u == nil { - return - } - - if err = activity.MarkAllNotesAsRead(db, fmt.Sprint(u.ID)); err != nil { + if err = ab.MarkAllNotesAsRead(ctx.R.Context()); err != nil { return r, err } From 2c543b3aa841dd25b5c89eb7235d3f52ee336d05 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Fri, 26 Jul 2024 00:27:46 +0800 Subject: [PATCH 13/33] activity: TablePrefix complete --- activity/activity_log.go | 14 ++++++--- activity/admin.go | 61 +++++++++++++++++++++--------------- activity/builder.go | 6 ++-- activity/note.go | 32 ++++++++++++++----- activity/util.go | 44 +++++++++++++------------- activity/util_test.go | 6 ++-- example/admin/config.go | 17 ++++++---- example/admin/user_config.go | 8 +++-- 8 files changed, 113 insertions(+), 75 deletions(-) diff --git a/activity/activity_log.go b/activity/activity_log.go index 4d6eab1dc..a4d146268 100644 --- a/activity/activity_log.go +++ b/activity/activity_log.go @@ -3,6 +3,7 @@ package activity import ( "fmt" + "github.com/iancoleman/strcase" "github.com/pkg/errors" "gorm.io/gorm" ) @@ -34,13 +35,18 @@ type ActivityLog struct { } func (v *ActivityLog) AfterMigrate(tx *gorm.DB, tablePrefix string) error { - tableName := tablePrefix + ParseTableNameWithDB(tx, v) + s, err := ParseSchemaWithDB(tx, v) + if err != nil { + return err + } + tableName := tablePrefix + s.Table + uix := fmt.Sprintf(`uix_%s_creator_id_model_name_keys_action_lastview`, strcase.ToSnake(tableName)) if err := tx.Exec(fmt.Sprintf(` - CREATE UNIQUE INDEX IF NOT EXISTS uix_creator_id_model_name_keys_action_lastview + CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s (creator_id, model_name, model_keys) WHERE action = '%s' AND deleted_at IS NULL - `, tableName, ActionLastView)).Error; err != nil { - return errors.Wrap(err, "failed to create index uix_creator_id_model_name_keys_action_lastview") + `, uix, tableName, ActionLastView)).Error; err != nil { + return errors.Wrapf(err, "failed to create index %s", uix) } return nil } diff --git a/activity/admin.go b/activity/admin.go index 25b069d27..410b377c5 100644 --- a/activity/admin.go +++ b/activity/admin.go @@ -3,12 +3,14 @@ package activity import ( "cmp" "encoding/json" + "errors" "net/http" "net/url" "github.com/dustin/go-humanize" "github.com/qor5/admin/v3/presets" "github.com/qor5/admin/v3/presets/actions" + "github.com/qor5/admin/v3/presets/gorm2op" "github.com/qor5/web/v3" "github.com/qor5/x/v3/i18n" . "github.com/qor5/x/v3/ui/vuetify" @@ -55,38 +57,45 @@ func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelB var ( lb = mb.Listing("CreatedAt", "Creator", "Action", "ModelKeys", "ModelLabel", "ModelName") dp = mb.Detailing("Detail").Drawer(true) + eb = mb.Editing() ) - dp.WrapFetchFunc(func(in presets.FetchFunc) presets.FetchFunc { - return func(model any, id string, ctx *web.EventContext) (r any, err error) { - r, err = in(model, id, ctx) - if err != nil { - return - } - log := r.(*ActivityLog) - if err := ab.supplyCreators(ctx.R.Context(), []*ActivityLog{log}); err != nil { - return nil, err - } - return log, nil + // should use own DataOperator + + op := gorm2op.DataOperator(ab.db) + dp.FetchFunc(func(obj any, id string, ctx *web.EventContext) (r any, err error) { + r, err = op.Fetch(obj, id, ctx) + if err != nil { + return r, err } + log := r.(*ActivityLog) + if err := ab.supplyCreators(ctx.R.Context(), []*ActivityLog{log}); err != nil { + return nil, err + } + return log, nil }) - lb.WrapSearchFunc(func(in presets.SearchFunc) presets.SearchFunc { - return func(model any, params *presets.SearchParams, ctx *web.EventContext) (r any, totalCount int, err error) { - params.SQLConditions = append(params.SQLConditions, &presets.SQLCondition{ - Query: "hidden = ?", - Args: []any{false}, - }) - r, totalCount, err = in(model, params, ctx) - if totalCount <= 0 { - return - } - logs := r.([]*ActivityLog) - if err := ab.supplyCreators(ctx.R.Context(), logs); err != nil { - return nil, 0, err - } - return logs, totalCount, nil + eb.SaveFunc(func(obj any, id string, ctx *web.EventContext) error { + return errors.New("should not be used") + }) + eb.DeleteFunc(func(obj any, id string, ctx *web.EventContext) error { + return errors.New("should not be used") + }) + + lb.SearchFunc(func(model any, params *presets.SearchParams, ctx *web.EventContext) (r any, totalCount int, err error) { + params.SQLConditions = append(params.SQLConditions, &presets.SQLCondition{ + Query: "hidden = ?", + Args: []any{false}, + }) + r, totalCount, err = op.Search(model, params, ctx) + if totalCount <= 0 { + return + } + logs := r.([]*ActivityLog) + if err := ab.supplyCreators(ctx.R.Context(), logs); err != nil { + return nil, 0, err } + return logs, totalCount, nil }) lb.NewButtonFunc(func(ctx *web.EventContext) h.HTMLComponent { return nil }) diff --git a/activity/builder.go b/activity/builder.go index 228abe243..c6baa4725 100644 --- a/activity/builder.go +++ b/activity/builder.go @@ -73,7 +73,7 @@ func (ab *Builder) TablePrefix(prefix string) *Builder { if prefix == "" { ab.db = ab.dbPrimitive } else { - ab.db = ab.dbPrimitive.Scopes(ScopeDynamicTablePrefix(prefix)).Session(&gorm.Session{}) + ab.db = ab.dbPrimitive.Scopes(scopeDynamicTablePrefix(prefix)).Session(&gorm.Session{}) } return ab } @@ -150,7 +150,7 @@ func (ab *Builder) GetModelBuilders() []*ModelBuilder { } func (b *Builder) AutoMigrate() (r *Builder) { - if err := AutoMigrate(b.db, ""); err != nil { + if err := AutoMigrate(b.dbPrimitive, b.tablePrefix); err != nil { panic(err) } return b @@ -158,7 +158,7 @@ func (b *Builder) AutoMigrate() (r *Builder) { func AutoMigrate(db *gorm.DB, tablePrefix string) error { if tablePrefix != "" { - db = db.Scopes(ScopeDynamicTablePrefix(tablePrefix)).Session(&gorm.Session{}) + db = db.Scopes(scopeDynamicTablePrefix(tablePrefix)).Session(&gorm.Session{}) } dst := []any{&ActivityLog{}, &ActivityUser{}} for _, v := range dst { diff --git a/activity/note.go b/activity/note.go index bf7160e84..dbd58ae7d 100644 --- a/activity/note.go +++ b/activity/note.go @@ -23,7 +23,11 @@ func getNotesCounts(db *gorm.DB, tablePrefix string, creatorID string, modelName return nil, errors.New("creatorID is required") } - tableName := tablePrefix + ParseTableNameWithDB(db, &ActivityLog{}) + s, err := ParseSchemaWithDB(db, &ActivityLog{}) + if err != nil { + return nil, err + } + tableName := tablePrefix + s.Table args := []any{ ActionNote, @@ -83,7 +87,11 @@ func getNotesCounts(db *gorm.DB, tablePrefix string, creatorID string, modelName } func markAllNotesAsRead(db *gorm.DB, tablePrefix string, creatorID string) error { - tableName := tablePrefix + ParseTableNameWithDB(db, &ActivityLog{}) + s, err := ParseSchemaWithDB(db, &ActivityLog{}) + if err != nil { + return err + } + tableName := tablePrefix + s.Table return db.Transaction(func(tx *gorm.DB) error { var results []struct { @@ -131,7 +139,7 @@ func markAllNotesAsRead(db *gorm.DB, tablePrefix string, creatorID string) error }) } -func sqlConditionHasUnreadNotes(db *gorm.DB, tablePrefix string, creatorID string, modelName string, columns []string, sep string, columnPrefix string) string { +func sqlConditionHasUnreadNotes(db *gorm.DB, tablePrefix string, creatorID string, modelName string, columns []string, sep string, columnPrefix string) (string, error) { a := strings.Join(lo.Map(columns, func(v string, _ int) string { return fmt.Sprintf("%s%s::text", columnPrefix, v) }), ",") @@ -139,7 +147,11 @@ func sqlConditionHasUnreadNotes(db *gorm.DB, tablePrefix string, creatorID strin return fmt.Sprintf(`split_part(n.model_keys, '%s', %d) AS %s`, sep, i+1, v) }), ",\n") - tableName := tablePrefix + ParseTableNameWithDB(db, &ActivityLog{}) + s, err := ParseSchemaWithDB(db, &ActivityLog{}) + if err != nil { + return "", err + } + tableName := tablePrefix + s.Table return fmt.Sprintf(` (%s) IN ( @@ -166,7 +178,7 @@ func sqlConditionHasUnreadNotes(db *gorm.DB, tablePrefix string, creatorID strin WHERE n.creator_id <> '%s' AND (lva.last_viewed_at IS NULL OR n.created_at > lva.last_viewed_at) GROUP BY n.model_keys - )`, a, tableName, ActionNote, modelName, tableName, ActionLastView, creatorID, modelName, b, creatorID) + )`, a, tableName, ActionNote, modelName, tableName, ActionLastView, creatorID, modelName, b, creatorID), nil } func (ab *Builder) GetNotesCounts(ctx context.Context, modelName string, modelKeyses []string) ([]*NoteCount, error) { @@ -185,6 +197,12 @@ func (ab *Builder) MarkAllNotesAsRead(ctx context.Context) error { return markAllNotesAsRead(ab.db, ab.tablePrefix, user.ID) } -func (amb *ModelBuilder) SQLConditionHasUnreadNotes(creatorID string, columnPrefix string) string { - return sqlConditionHasUnreadNotes(amb.ab.db, amb.ab.tablePrefix, creatorID, ParseModelName(amb.ref), amb.keyColumns, ModelKeysSeparator, columnPrefix) +// SQLConditionHasUnreadNotes returns a SQL condition that can be used in a WHERE clause to filter records that have unread notes. +// Note that this method requires the applied db to be amb.ab.db, not any other db +func (amb *ModelBuilder) SQLConditionHasUnreadNotes(ctx context.Context, columnPrefix string) (string, error) { + user, err := amb.ab.currentUserFunc(ctx) + if err != nil { + return "", err + } + return sqlConditionHasUnreadNotes(amb.ab.db, amb.ab.tablePrefix, user.ID, ParseModelName(amb.ref), amb.keyColumns, ModelKeysSeparator, columnPrefix) } diff --git a/activity/util.go b/activity/util.go index 9f5c87c0a..e63dc9a99 100644 --- a/activity/util.go +++ b/activity/util.go @@ -71,12 +71,12 @@ func ParseSchema(v any) (*schema.Schema, error) { return s, nil } -func ParsePrimaryFields(v any) ([]*schema.Field, error) { - s, err := ParseSchema(v) - if err != nil { - return nil, err +func ParseSchemaWithDB(db *gorm.DB, v any) (*schema.Schema, error) { + stmt := &gorm.Statement{DB: db} + if err := stmt.Parse(v); err != nil { + return nil, errors.Wrap(err, "parse schema with db") } - return s.PrimaryFields, nil + return stmt.Schema, nil } func parsePrimaryKeys(t reflect.Type) (keys []string) { @@ -102,28 +102,25 @@ func parsePrimaryKeys(t reflect.Type) (keys []string) { } func ParsePrimaryKeys(v any) []string { - fields, err := ParsePrimaryFields(v) + s, err := ParseSchema(v) if err == nil { - return lo.Map(fields, func(f *schema.Field, _ int) string { return f.Name }) + return lo.Map(s.PrimaryFields, func(f *schema.Field, _ int) string { return f.Name }) } // parsePrimaryKeys is more compatible if some of the model's fields do not obey sql.Driver very well return parsePrimaryKeys(reflect.Indirect(reflect.ValueOf(v)).Type()) } -func ParseTableNameWithDB(db *gorm.DB, model any) string { - stmt := &gorm.Statement{DB: db} - stmt.Parse(model) - return stmt.Schema.Table -} - const dbKeyTablePrefix = "__table_prefix__" -// ScopeDynamicTablePrefix set dynamic table prefix -// Only scenarios where a Model is provided are supported -func ScopeDynamicTablePrefix(tablePrefix string) func(db *gorm.DB) *gorm.DB { +// scopeDynamicTablePrefix set dynamic table prefix +// 1. Only scenarios where a Model is provided are supported +// 2. Previously Table(...) will be overwritten +func scopeDynamicTablePrefix(tablePrefix string) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { - if _, ok := db.Get(dbKeyTablePrefix); ok { - panic("db table prefix already set") + if v, ok := db.Get(dbKeyTablePrefix); ok { + if v.(string) != tablePrefix { + panic(fmt.Sprintf("table prefix is already set to %s", v)) + } } if tablePrefix == "" { @@ -131,16 +128,17 @@ func ScopeDynamicTablePrefix(tablePrefix string) func(db *gorm.DB) *gorm.DB { } stmt := db.Statement - if stmt.Table != "" { - return db - } - model := cmp.Or(stmt.Model, stmt.Dest) if model == nil { return db } - return db.Set(dbKeyTablePrefix, tablePrefix).Table(tablePrefix + ParseTableNameWithDB(db, model)) + s, err := ParseSchemaWithDB(db, model) + if err != nil { + db.AddError(err) + return db + } + return db.Set(dbKeyTablePrefix, tablePrefix).Table(tablePrefix + s.Table) } } diff --git a/activity/util_test.go b/activity/util_test.go index f2e36730e..62a094f80 100644 --- a/activity/util_test.go +++ b/activity/util_test.go @@ -219,15 +219,15 @@ func TestParsePrimaryFields(t *testing.T) { for _, test := range tests { t.Run(test.Name, func(t *testing.T) { - fields, err := activity.ParsePrimaryFields(test.Model) + s, err := activity.ParseSchema(test.Model) if err != nil { t.Errorf("Error occurred: %v", err) } - keys := lo.Map(fields, func(f *schema.Field, _ int) string { + keys := lo.Map(s.PrimaryFields, func(f *schema.Field, _ int) string { return f.Name }) if !reflect.DeepEqual(keys, test.Expected) { - t.Errorf("Expected primary fields %v, but got %v", test.Expected, fields) + t.Errorf("Expected primary fields %v, but got %v", test.Expected, s.PrimaryFields) } }) } diff --git a/example/admin/config.go b/example/admin/config.go index b19a3df56..18615c776 100644 --- a/example/admin/config.go +++ b/example/admin/config.go @@ -125,7 +125,7 @@ func NewConfig(db *gorm.DB) Config { return } }). - TablePrefix("cms_"). + TablePrefix("cs_portal_"). AutoMigrate() // ab.Model(l).SkipDelete().SkipCreate() @@ -291,12 +291,15 @@ func NewConfig(db *gorm.DB) Config { } pmListing := pm.Listing() pmListing.FilterDataFunc(func(ctx *web.EventContext) vx.FilterData { - u := getCurrentUser(ctx.R) + hasUnreadNotesCondition, err := ab.MustGetModelBuilder(pm).SQLConditionHasUnreadNotes(ctx.R.Context(), "") + if err != nil { + panic(err) + } return []*vx.FilterItem{ { Key: "hasUnreadNotes", Invisible: true, - SQLCondition: ab.MustGetModelBuilder(pm).SQLConditionHasUnreadNotes(fmt.Sprint(u.ID), ""), + SQLCondition: hasUnreadNotesCondition, }, } }) @@ -582,13 +585,15 @@ func configPost( PerPage(10) mListing.FilterDataFunc(func(ctx *web.EventContext) vx.FilterData { - u := getCurrentUser(ctx.R) - + hasUnreadNotesCondition, err := ab.MustGetModelBuilder(m).SQLConditionHasUnreadNotes(ctx.R.Context(), "") + if err != nil { + panic(err) + } return []*vx.FilterItem{ { Key: "hasUnreadNotes", Invisible: true, - SQLCondition: ab.MustGetModelBuilder(m).SQLConditionHasUnreadNotes(fmt.Sprint(u.ID), ""), + SQLCondition: hasUnreadNotesCondition, }, { Key: "created", diff --git a/example/admin/user_config.go b/example/admin/user_config.go index 2bf2cab43..a7bdad74c 100644 --- a/example/admin/user_config.go +++ b/example/admin/user_config.go @@ -307,8 +307,10 @@ func configUser(b *presets.Builder, ab *activity.Builder, db *gorm.DB, publisher cl.SearchColumns("users.Name", "Account") cl.FilterDataFunc(func(ctx *web.EventContext) vx.FilterData { - u := getCurrentUser(ctx.R) - + hasUnreadNotesCondition, err := ab.MustGetModelBuilder(user).SQLConditionHasUnreadNotes(ctx.R.Context(), "users.") + if err != nil { + panic(err) + } return []*vx.FilterItem{ { Key: "created", @@ -335,7 +337,7 @@ func configUser(b *presets.Builder, ab *activity.Builder, db *gorm.DB, publisher { Key: "hasUnreadNotes", Invisible: true, - SQLCondition: ab.MustGetModelBuilder(user).SQLConditionHasUnreadNotes(fmt.Sprint(u.ID), "users."), + SQLCondition: hasUnreadNotesCondition, }, { Key: "registration_date", From 195b69b16818ce24ece4e71f1ad6149707b549f0 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Fri, 26 Jul 2024 00:46:22 +0800 Subject: [PATCH 14/33] activity: rm ActionView from DefaultActions --- activity/activity_log.go | 12 +++++++++++- activity/admin.go | 38 +++++++++++++++----------------------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/activity/activity_log.go b/activity/activity_log.go index a4d146268..70f9da912 100644 --- a/activity/activity_log.go +++ b/activity/activity_log.go @@ -17,7 +17,17 @@ const ( ActionLastView = "LastView" // hidden and only for internal use ) -var DefaultActions = []string{ActionView, ActionEdit, ActionCreate, ActionDelete, ActionNote} +var DefaultActions = []string{ActionCreate /* ActionView,*/, ActionEdit, ActionDelete, ActionNote} + +func defaultActionLabels(msgr *Messages) map[string]string { + return map[string]string{ + ActionCreate: msgr.ActionCreate, + ActionView: msgr.ActionView, + ActionEdit: msgr.ActionEdit, + ActionDelete: msgr.ActionDelete, + ActionNote: msgr.ActionNote, + } +} type ActivityLog struct { gorm.Model diff --git a/activity/admin.go b/activity/admin.go index 410b377c5..f3c3834d8 100644 --- a/activity/admin.go +++ b/activity/admin.go @@ -42,17 +42,6 @@ func (ab *Builder) Install(b *presets.Builder) error { return ab.logModelInstall(b, mb) } -func defaultActionLabels(msgr *Messages) map[string]string { - return map[string]string{ - "": msgr.ActionAll, - ActionView: msgr.ActionView, - ActionEdit: msgr.ActionEdit, - ActionCreate: msgr.ActionCreate, - ActionDelete: msgr.ActionDelete, - ActionNote: msgr.ActionNote, - } -} - func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelBuilder) error { var ( lb = mb.Listing("CreatedAt", "Creator", "Action", "ModelKeys", "ModelLabel", "ModelName") @@ -129,7 +118,7 @@ func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelB var actionOptions []*vuetifyx.SelectItem for _, action := range DefaultActions { actionOptions = append(actionOptions, &vuetifyx.SelectItem{ - Text: actionLabels[action], + Text: cmp.Or(actionLabels[action], action), Value: action, }) } @@ -143,8 +132,8 @@ func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelB continue } actionOptions = append(actionOptions, &vuetifyx.SelectItem{ - Text: string(action), - Value: string(action), + Text: cmp.Or(actionLabels[action], action), + Value: action, }) } actionOptions = lo.UniqBy(actionOptions, func(item *vuetifyx.SelectItem) string { return item.Value }) @@ -212,17 +201,20 @@ func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelB lb.FilterTabsFunc(func(ctx *web.EventContext) []*presets.FilterTab { msgr := i18n.MustGetModuleMessages(ctx.R, I18nActivityKey, Messages_en_US).(*Messages) + filterTabs := []*presets.FilterTab{ + { + Label: msgr.ActionAll, + Query: url.Values{}, + }, + } actionLabels := defaultActionLabels(msgr) - return lo.Map(append([]string{""}, DefaultActions...), func(action string, _ int) *presets.FilterTab { - filterTab := &presets.FilterTab{ - Label: actionLabels[action], + for _, action := range DefaultActions { + filterTabs = append(filterTabs, &presets.FilterTab{ + Label: cmp.Or(actionLabels[action], action), Query: url.Values{"action": []string{action}}, - } - if action == "" { - filterTab.Query.Del("action") - } - return filterTab - }) + }) + } + return filterTabs }) dp.Field("Detail").ComponentFunc( From 12949640f1d315b304f4af5895b613bcb2714e03 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Fri, 26 Jul 2024 00:58:28 +0800 Subject: [PATCH 15/33] activity: uix name use table name without schema --- activity/activity_log.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/activity/activity_log.go b/activity/activity_log.go index 70f9da912..ce6131e2a 100644 --- a/activity/activity_log.go +++ b/activity/activity_log.go @@ -2,8 +2,8 @@ package activity import ( "fmt" + "strings" - "github.com/iancoleman/strcase" "github.com/pkg/errors" "gorm.io/gorm" ) @@ -50,7 +50,12 @@ func (v *ActivityLog) AfterMigrate(tx *gorm.DB, tablePrefix string) error { return err } tableName := tablePrefix + s.Table - uix := fmt.Sprintf(`uix_%s_creator_id_model_name_keys_action_lastview`, strcase.ToSnake(tableName)) + + tableBare := tableName + if tables := strings.Split(tableName, "."); len(tables) == 2 { + tableBare = tables[1] + } + uix := fmt.Sprintf(`uix_%s_creator_id_model_name_keys_action_lastview`, tableBare) if err := tx.Exec(fmt.Sprintf(` CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s (creator_id, model_name, model_keys) From e41773f939febe210f3fd95dc25b1fdd7fa0b2dc Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Fri, 26 Jul 2024 10:13:43 +0800 Subject: [PATCH 16/33] activity: fix markAllNotesAsRead --- activity/builder.go | 4 ++-- activity/note.go | 43 ++++++++++++++++++++----------------------- activity/util.go | 6 ++++-- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/activity/builder.go b/activity/builder.go index c6baa4725..e0702ac05 100644 --- a/activity/builder.go +++ b/activity/builder.go @@ -73,7 +73,7 @@ func (ab *Builder) TablePrefix(prefix string) *Builder { if prefix == "" { ab.db = ab.dbPrimitive } else { - ab.db = ab.dbPrimitive.Scopes(scopeDynamicTablePrefix(prefix)).Session(&gorm.Session{}) + ab.db = ab.dbPrimitive.Scopes(scopeWithTablePrefix(prefix)).Session(&gorm.Session{}) } return ab } @@ -158,7 +158,7 @@ func (b *Builder) AutoMigrate() (r *Builder) { func AutoMigrate(db *gorm.DB, tablePrefix string) error { if tablePrefix != "" { - db = db.Scopes(scopeDynamicTablePrefix(tablePrefix)).Session(&gorm.Session{}) + db = db.Scopes(scopeWithTablePrefix(tablePrefix)).Session(&gorm.Session{}) } dst := []any{&ActivityLog{}, &ActivityUser{}} for _, v := range dst { diff --git a/activity/note.go b/activity/note.go index dbd58ae7d..71f5d5e00 100644 --- a/activity/note.go +++ b/activity/note.go @@ -87,26 +87,21 @@ func getNotesCounts(db *gorm.DB, tablePrefix string, creatorID string, modelName } func markAllNotesAsRead(db *gorm.DB, tablePrefix string, creatorID string) error { - s, err := ParseSchemaWithDB(db, &ActivityLog{}) - if err != nil { - return err - } - tableName := tablePrefix + s.Table - return db.Transaction(func(tx *gorm.DB) error { + tx = tx.Scopes(scopeWithTablePrefix(tablePrefix)).Session(&gorm.Session{}) + var results []struct { ModelName string ModelKeys string + ModelLabel string + ModelLink string MaxCreatedAt time.Time } - if err := db.Raw(fmt.Sprintf(` - SELECT model_name, model_keys, MAX(created_at) AS max_created_at - FROM %s - WHERE action = ? AND deleted_at IS NULL - GROUP BY model_name, model_keys; - `, tableName), ActionNote, - ).Scan(&results).Error; err != nil { - return errors.Wrap(err, "") + if err := tx.Model(&ActivityLog{}). + Select("model_name, model_keys, MAX(model_label) AS model_label, MAX(model_link) AS model_link, MAX(created_at) AS max_created_at"). + Where("action = ?", ActionNote). + Group("model_name, model_keys").Scan(&results).Error; err != nil { + return errors.Wrap(err, "find created_at of last notes") } if len(results) <= 0 { @@ -114,25 +109,27 @@ func markAllNotesAsRead(db *gorm.DB, tablePrefix string, creatorID string) error } if err := tx.Unscoped().Where("creator_id = ? AND action = ?", creatorID, ActionLastView).Delete(&ActivityLog{}).Error; err != nil { - return errors.Wrap(err, "") + return errors.Wrap(err, "delete last views") } var logs []ActivityLog for _, v := range results { log := ActivityLog{ - CreatorID: creatorID, - Action: ActionLastView, - Hidden: true, - ModelName: v.ModelName, - ModelKeys: v.ModelKeys, + CreatorID: creatorID, + Action: ActionLastView, + Hidden: true, + ModelName: v.ModelName, + ModelKeys: v.ModelKeys, + ModelLabel: v.ModelLabel, + ModelLink: v.ModelLink, } log.CreatedAt = v.MaxCreatedAt log.UpdatedAt = v.MaxCreatedAt logs = append(logs, log) } - if err := tx.Create(&logs).Error; err != nil { - return errors.Wrap(err, "") + if err := tx.Create(logs).Error; err != nil { + return errors.Wrap(err, "create new last views") } return nil @@ -194,7 +191,7 @@ func (ab *Builder) MarkAllNotesAsRead(ctx context.Context) error { if err != nil { return err } - return markAllNotesAsRead(ab.db, ab.tablePrefix, user.ID) + return markAllNotesAsRead(ab.dbPrimitive, ab.tablePrefix, user.ID) } // SQLConditionHasUnreadNotes returns a SQL condition that can be used in a WHERE clause to filter records that have unread notes. diff --git a/activity/util.go b/activity/util.go index e63dc9a99..f570ad833 100644 --- a/activity/util.go +++ b/activity/util.go @@ -112,14 +112,16 @@ func ParsePrimaryKeys(v any) []string { const dbKeyTablePrefix = "__table_prefix__" -// scopeDynamicTablePrefix set dynamic table prefix +// scopeWithTablePrefix set table prefix // 1. Only scenarios where a Model is provided are supported // 2. Previously Table(...) will be overwritten -func scopeDynamicTablePrefix(tablePrefix string) func(db *gorm.DB) *gorm.DB { +func scopeWithTablePrefix(tablePrefix string) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { if v, ok := db.Get(dbKeyTablePrefix); ok { if v.(string) != tablePrefix { panic(fmt.Sprintf("table prefix is already set to %s", v)) + } else { + return db } } From 31e3052d929fcf919f166d4b9ce2593dc0bf6c85 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Fri, 26 Jul 2024 10:29:51 +0800 Subject: [PATCH 17/33] activity: change the usage of markAllNotesAsRead --- activity/note.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/activity/note.go b/activity/note.go index 71f5d5e00..034590ea4 100644 --- a/activity/note.go +++ b/activity/note.go @@ -86,10 +86,8 @@ func getNotesCounts(db *gorm.DB, tablePrefix string, creatorID string, modelName return counts, nil } -func markAllNotesAsRead(db *gorm.DB, tablePrefix string, creatorID string) error { +func markAllNotesAsRead(db *gorm.DB, creatorID string) error { return db.Transaction(func(tx *gorm.DB) error { - tx = tx.Scopes(scopeWithTablePrefix(tablePrefix)).Session(&gorm.Session{}) - var results []struct { ModelName string ModelKeys string @@ -122,6 +120,7 @@ func markAllNotesAsRead(db *gorm.DB, tablePrefix string, creatorID string) error ModelKeys: v.ModelKeys, ModelLabel: v.ModelLabel, ModelLink: v.ModelLink, + Detail: "null", } log.CreatedAt = v.MaxCreatedAt log.UpdatedAt = v.MaxCreatedAt @@ -191,7 +190,7 @@ func (ab *Builder) MarkAllNotesAsRead(ctx context.Context) error { if err != nil { return err } - return markAllNotesAsRead(ab.dbPrimitive, ab.tablePrefix, user.ID) + return markAllNotesAsRead(ab.db, user.ID) } // SQLConditionHasUnreadNotes returns a SQL condition that can be used in a WHERE clause to filter records that have unread notes. From 94a1327f5713550722fcf12b87df7f63c45df117 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:30:30 +0800 Subject: [PATCH 18/33] activity: tidy comments --- activity/README.md | 2 +- activity/builder.go | 13 +++---------- activity/model_builder.go | 4 +--- example/admin/config.go | 3 ++- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/activity/README.md b/activity/README.md index 2b8a8d257..e1dc7c7b2 100644 --- a/activity/README.md +++ b/activity/README.md @@ -77,7 +77,7 @@ - Add to SidePanel ```go - dp.SidePanelFunc(func(obj interface{}, ctx *web.EventContext) h.HTMLComponent { + dp.SidePanelFunc(func(obj any, ctx *web.EventContext) h.HTMLComponent { return ab.MustGetModelBuilder(mb).NewTimelineCompo(ctx, obj, "_side") }) ``` diff --git a/activity/builder.go b/activity/builder.go index e0702ac05..deba40897 100644 --- a/activity/builder.go +++ b/activity/builder.go @@ -19,14 +19,13 @@ type User struct { } // @snippet_begin(ActivityBuilder) -// Builder struct contains all necessary fields type Builder struct { models []*ModelBuilder // registered model builders - dbPrimitive *gorm.DB - db *gorm.DB // global db + dbPrimitive *gorm.DB // primitive db + db *gorm.DB // global db with table prefix scope tablePrefix string - logModelInstall presets.ModelInstallFunc // log model install + logModelInstall presets.ModelInstallFunc // admin preset install permPolicy *perm.PolicyBuilder // permission policy currentUserFunc func(ctx context.Context) (*User, error) findUsersFunc func(ctx context.Context, ids []string) (map[string]*User, error) @@ -54,7 +53,6 @@ func (ab *Builder) FindUsersFunc(v func(ctx context.Context, ids []string) (map[ return ab } -// New initializes a new Builder instance with a provided database connection and an optional activity log model. func New(db *gorm.DB, currentUserFunc func(ctx context.Context) (*User, error)) *Builder { ab := &Builder{ dbPrimitive: db, @@ -78,7 +76,6 @@ func (ab *Builder) TablePrefix(prefix string) *Builder { return ab } -// RegisterModels register mutiple models func (ab *Builder) RegisterModels(models ...any) *Builder { for _, model := range models { ab.RegisterModel(model) @@ -86,7 +83,6 @@ func (ab *Builder) RegisterModels(models ...any) *Builder { return ab } -// RegisterModel Model register a model and return model builder func (ab *Builder) RegisterModel(m any) (amb *ModelBuilder) { if amb, exist := ab.GetModelBuilder(m); exist { return amb @@ -122,7 +118,6 @@ func (ab *Builder) RegisterModel(m any) (amb *ModelBuilder) { return amb } -// GetModelBuilder get model builder func (ab *Builder) GetModelBuilder(v any) (*ModelBuilder, bool) { if _, ok := v.(*presets.ModelBuilder); ok { return lo.Find(ab.models, func(amb *ModelBuilder) bool { @@ -135,7 +130,6 @@ func (ab *Builder) GetModelBuilder(v any) (*ModelBuilder, bool) { }) } -// MustGetModelBuilder get model builder func (ab *Builder) MustGetModelBuilder(v any) *ModelBuilder { amb, ok := ab.GetModelBuilder(v) if !ok { @@ -144,7 +138,6 @@ func (ab *Builder) MustGetModelBuilder(v any) *ModelBuilder { return amb } -// GetModelBuilders get all model builders func (ab *Builder) GetModelBuilders() []*ModelBuilder { return ab.models } diff --git a/activity/model_builder.go b/activity/model_builder.go index e6e448f28..232b7edce 100644 --- a/activity/model_builder.go +++ b/activity/model_builder.go @@ -33,7 +33,6 @@ const ( ) // @snippet_begin(ActivityModelBuilder) -// a unique model builder is consist of typ and presetModel type ModelBuilder struct { once sync.Once @@ -280,7 +279,6 @@ func (amb *ModelBuilder) installPresetsModelBuilder(mb *presets.ModelBuilder) { totalNotesCountText = fmt.Sprintf("%d", count.TotalNotesCount) } - // TODO: Because of the filter of hasUnreadNotes, it seems that you need to reload the list directly. total := h.Div().Class("text-caption bg-grey-lighten-3 rounded px-1").Text(totalNotesCountText) return h.Td( web.Scope().VSlot("{locals}").Init(fmt.Sprintf("{ unreadNotesCount: %d }", count.UnreadNotesCount)).Children( @@ -492,7 +490,7 @@ func (mb *ModelBuilder) create( } return r, nil - // Why not use this ? Because id will be empty when the record is already created + // Why not use this ? Because log.id is empty although the record is already created, there is no advance fetch of the original id here . // if mb.ab.db.Where("creator_id = ? AND model_name = ? AND model_keys = ? AND action = ?", log.CreatorID, log.ModelName, log.ModelKeys, log.Action). // Select("*").Updates(log).RowsAffected == 0 { // if err := mb.ab.db.Create(log).Error; err != nil { diff --git a/example/admin/config.go b/example/admin/config.go index 18615c776..3da246afd 100644 --- a/example/admin/config.go +++ b/example/admin/config.go @@ -96,6 +96,7 @@ func NewConfig(db *gorm.DB) Config { } // @snippet_begin(ActivityExample) + db.Exec(`CREATE SCHEMA IF NOT EXISTS cs_portal;`) ab := activity.New(db, func(ctx context.Context) (*activity.User, error) { u := ctx.Value(login.UserKey).(*models.User) return &activity.User{ @@ -125,7 +126,7 @@ func NewConfig(db *gorm.DB) Config { return } }). - TablePrefix("cs_portal_"). + TablePrefix("cs_portal."). AutoMigrate() // ab.Model(l).SkipDelete().SkipCreate() From cd30b0e4bac0adee7889f0eb9b01d29408a43fe9 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:39:02 +0800 Subject: [PATCH 19/33] activity: optional installation of admin ui --- activity/admin.go | 15 +++++++++++++- activity/builder.go | 6 ++++-- activity/model_builder.go | 43 +++++++++++++++++++-------------------- activity/timeline.go | 27 +++++++++++------------- example/admin/config.go | 2 +- 5 files changed, 52 insertions(+), 41 deletions(-) diff --git a/activity/admin.go b/activity/admin.go index f3c3834d8..abed0b721 100644 --- a/activity/admin.go +++ b/activity/admin.go @@ -3,11 +3,11 @@ package activity import ( "cmp" "encoding/json" - "errors" "net/http" "net/url" "github.com/dustin/go-humanize" + "github.com/pkg/errors" "github.com/qor5/admin/v3/presets" "github.com/qor5/admin/v3/presets/actions" "github.com/qor5/admin/v3/presets/gorm2op" @@ -29,6 +29,10 @@ const ( // TODO: 这个 language 在 timeline 也用到,所以就造成 Install 是必须的,这不太对 func (ab *Builder) Install(b *presets.Builder) error { + if actual, loaded := ab.installedPresets.LoadOrStore(b, true); loaded && actual.(bool) { + return errors.Errorf("activity: preset %q already installed", b.GetURIPrefix()) + } + b.GetI18n(). RegisterForModule(language.English, I18nActivityKey, Messages_en_US). RegisterForModule(language.SimplifiedChinese, I18nActivityKey, Messages_zh_CN). @@ -42,6 +46,15 @@ func (ab *Builder) Install(b *presets.Builder) error { return ab.logModelInstall(b, mb) } +func (ab *Builder) IsPresetInstalled(pb *presets.Builder) bool { + installed := false + valInstalled, ok := ab.installedPresets.Load(pb) + if ok { + installed = valInstalled.(bool) + } + return installed +} + func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelBuilder) error { var ( lb = mb.Listing("CreatedAt", "Creator", "Action", "ModelKeys", "ModelLabel", "ModelName") diff --git a/activity/builder.go b/activity/builder.go index deba40897..3d57dd06c 100644 --- a/activity/builder.go +++ b/activity/builder.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "reflect" + "sync" "github.com/pkg/errors" "github.com/qor5/admin/v3/presets" @@ -20,7 +21,8 @@ type User struct { // @snippet_begin(ActivityBuilder) type Builder struct { - models []*ModelBuilder // registered model builders + models []*ModelBuilder // registered model builders + installedPresets sync.Map // installed presets builders for admin dbPrimitive *gorm.DB // primitive db db *gorm.DB // global db with table prefix scope @@ -111,7 +113,7 @@ func (ab *Builder) RegisterModel(m any) (amb *ModelBuilder) { amb.IgnoredFields(primaryKeys...) if mb, ok := m.(*presets.ModelBuilder); ok { - amb.installPresetsModelBuilder(mb) + amb.installPresetModelBuilder(mb) } ab.models = append(ab.models, amb) diff --git a/activity/model_builder.go b/activity/model_builder.go index 232b7edce..127b5aaf8 100644 --- a/activity/model_builder.go +++ b/activity/model_builder.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "reflect" - "sync" "github.com/iancoleman/strcase" "github.com/pkg/errors" @@ -16,6 +15,7 @@ import ( "github.com/samber/lo" "github.com/sunfmin/reflectutils" h "github.com/theplant/htmlgo" + "golang.org/x/text/language" "gorm.io/gorm" "gorm.io/gorm/schema" ) @@ -34,8 +34,6 @@ const ( // @snippet_begin(ActivityModelBuilder) type ModelBuilder struct { - once sync.Once - ref any // model ref typ reflect.Type // model type ab *Builder // activity builder @@ -77,27 +75,18 @@ func emitLogCreated(evCtx *web.EventContext, log *ActivityLog) { }) } +func injectorName(mb *presets.ModelBuilder) string { + return fmt.Sprintf("__activity:%s__", mb.Info().URIName()) +} + func (amb *ModelBuilder) NewTimelineCompo(evCtx *web.EventContext, obj any, idSuffix string) h.HTMLComponent { if amb.presetModel == nil { panic("NewTimelineCompo method only supports presets.ModelBuilder") } mb := amb.presetModel - injectorName := fmt.Sprintf("__activity:%s__", mb.Info().URIName()) + injectorName := injectorName(mb) dc := mb.GetPresetsBuilder().GetDependencyCenter() - amb.once.Do(func() { - dc.RegisterInjector(injectorName) - dc.MustProvide(injectorName, func() *Builder { - return amb.ab - }) - dc.MustProvide(injectorName, func() *ModelBuilder { - return amb - }) - dc.MustProvide(injectorName, func() *presets.ModelBuilder { - return mb - }) - }) - modelName := ParseModelName(obj) log, err := amb.Log(evCtx.R.Context(), ActionLastView, obj, nil) @@ -125,7 +114,7 @@ func (amb *ModelBuilder) NewTimelineCompo(evCtx *web.EventContext, obj any, idSu }) } -func (amb *ModelBuilder) installPresetsModelBuilder(mb *presets.ModelBuilder) { +func (amb *ModelBuilder) installPresetModelBuilder(mb *presets.ModelBuilder) { amb.presetModel = mb amb.LinkFunc(func(a any) string { id := presets.ObjectID(a) @@ -138,6 +127,20 @@ func (amb *ModelBuilder) installPresetsModelBuilder(mb *presets.ModelBuilder) { return "" }) + pb := mb.GetPresetsBuilder() + if !amb.ab.IsPresetInstalled(pb) { + pb.GetI18n(). + RegisterForModule(language.English, I18nActivityKey, Messages_en_US). + RegisterForModule(language.SimplifiedChinese, I18nActivityKey, Messages_zh_CN). + RegisterForModule(language.Japanese, I18nActivityKey, Messages_ja_JP) + } + dc := mb.GetPresetsBuilder().GetDependencyCenter() + injectorName := injectorName(mb) + dc.RegisterInjector(injectorName) + dc.MustProvide(injectorName, func() (*Builder, *presets.ModelBuilder, *ModelBuilder) { + return amb.ab, mb, amb + }) + eb := mb.Editing() eb.WrapSaveFunc(func(in presets.SaveFunc) presets.SaveFunc { return func(obj any, id string, ctx *web.EventContext) (err error) { @@ -237,10 +240,6 @@ func (amb *ModelBuilder) installPresetsModelBuilder(mb *presets.ModelBuilder) { if err != nil { return } - user, uerr := amb.ab.currentUserFunc(ctx.R.Context()) - if uerr != nil { - return - } var modelName string modelKeyses := []string{} reflectutils.ForEach(r, func(obj any) { diff --git a/activity/timeline.go b/activity/timeline.go index bd060d643..fb542f593 100644 --- a/activity/timeline.go +++ b/activity/timeline.go @@ -91,18 +91,20 @@ func (c *TimelineCompo) humanContent(ctx context.Context, log *ActivityLog, forc if err := json.Unmarshal([]byte(log.Detail), &diffs); err != nil { return h.Text(fmt.Sprintf("Failed to unmarshal detail: %v", err)) } + presetInstalled := c.ab.IsPresetInstalled(c.mb.GetPresetsBuilder()) return h.Div().Class("d-flex flex-row align-center ga-2").Children( h.Div(h.Text(msgr.EditedNFields(len(diffs)))).ClassIf(forceTextColor, forceTextColor != ""), - // TODO: 需要判断是否启用了 presets 如果没启用就不显示这个 - v.VBtn(msgr.MoreInfo).Class("text-none text-overline d-flex align-center"). - Variant(v.VariantTonal).Color(v.ColorPrimary).Size(v.SizeXSmall).PrependIcon("mdi-open-in-new"). - Attr("@click", web.POST(). - EventFunc(actions.DetailingDrawer). - Query(presets.ParamOverlay, actions.Dialog). - URL(fmt.Sprintf("%s/activity-logs/%d", c.mb.GetPresetsBuilder().GetURIPrefix(), log.ID)). - Query(paramHideDetailTop, true). - Go(), - ), + h.Iff(presetInstalled, func() h.HTMLComponent { + return v.VBtn(msgr.MoreInfo).Class("text-none text-overline d-flex align-center"). + Variant(v.VariantTonal).Color(v.ColorPrimary).Size(v.SizeXSmall).PrependIcon("mdi-open-in-new"). + Attr("@click", web.POST(). + EventFunc(actions.DetailingDrawer). + Query(presets.ParamOverlay, actions.Dialog). + URL(fmt.Sprintf("%s/activity-logs/%d", c.mb.GetPresetsBuilder().GetURIPrefix(), log.ID)). + Query(paramHideDetailTop, true). + Go(), + ) + }), ) case ActionDelete: return h.Div(h.Text(msgr.Deleted)).ClassIf(forceTextColor, forceTextColor != "") @@ -122,11 +124,6 @@ func (c *TimelineCompo) MarshalHTML(ctx context.Context) ([]byte, error) { return h.Div().Attr("v-pre", true).Text(perm.PermissionDenied.Error()).MarshalHTML(ctx) } - user, err := c.ab.currentUserFunc(ctx) - if err != nil { - return nil, err - } - children := []h.HTMLComponent{ h.Div().Class("text-h6 mb-8").Text(msgr.Activities), web.Scope().VSlot("{locals: xlocals,form}").Init("{showEditBox:false}").Children( diff --git a/example/admin/config.go b/example/admin/config.go index 3da246afd..91bc0c7eb 100644 --- a/example/admin/config.go +++ b/example/admin/config.go @@ -341,7 +341,7 @@ func NewConfig(db *gorm.DB) Config { loginSessionBuilder := initLoginSessionBuilder(db, b, ab) defer func() { loginSessionBuilder.Setup() }() - configBrand(b, db, loginSessionBuilder) + configBrand(b, db, ab, loginSessionBuilder) configInputDemo(b, db) From e6099a21a3c7888265ef4ce6fee1cff99e394bca Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:52:23 +0800 Subject: [PATCH 20/33] activity: ShowMessage use const var --- activity/timeline.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/activity/timeline.go b/activity/timeline.go index fb542f593..b70a4696c 100644 --- a/activity/timeline.go +++ b/activity/timeline.go @@ -114,11 +114,6 @@ func (c *TimelineCompo) humanContent(ctx context.Context, log *ActivityLog, forc } func (c *TimelineCompo) MarshalHTML(ctx context.Context) ([]byte, error) { - user, err := c.ab.currentUserFunc(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to get current user") - } - evCtx, msgr := c.MustGetEventContext(ctx) if c.mb.Info().Verifier().Do(presets.PermGet).WithReq(evCtx.R).IsAllowed() != nil { return h.Div().Attr("v-pre", true).Text(perm.PermissionDenied.Error()).MarshalHTML(ctx) @@ -151,6 +146,10 @@ func (c *TimelineCompo) MarshalHTML(ctx context.Context) ([]byte, error) { return nil, err } + user, err := c.ab.currentUserFunc(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to get current user") + } for i, log := range logs { creatorName := log.Creator.Name if creatorName == "" { From 7c2b9e147703c8ab1722ecc3ca39a2c54d85aaf5 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Fri, 26 Jul 2024 16:06:29 +0800 Subject: [PATCH 21/33] activity: creator => user --- activity/activity_log.go | 8 ++-- activity/admin.go | 40 +++++++++---------- activity/builder.go | 14 +++---- activity/builder_test.go | 20 +++++----- activity/messages.go | 32 +++++++-------- activity/model_builder.go | 6 +-- activity/note.go | 32 +++++++-------- activity/timeline.go | 26 ++++++------ .../examples/examples_admin/activity_test.go | 2 +- example/admin/config.go | 2 +- example/admin/perm.go | 2 +- 11 files changed, 92 insertions(+), 92 deletions(-) diff --git a/activity/activity_log.go b/activity/activity_log.go index ce6131e2a..185d97348 100644 --- a/activity/activity_log.go +++ b/activity/activity_log.go @@ -32,8 +32,8 @@ func defaultActionLabels(msgr *Messages) map[string]string { type ActivityLog struct { gorm.Model - CreatorID string `gorm:"index;not null;"` - Creator User `gorm:"-"` + UserID string `gorm:"index;not null;"` + User User `gorm:"-"` Action string `gorm:"index;not null;"` Hidden bool `gorm:"index;default:false;not null;"` @@ -55,10 +55,10 @@ func (v *ActivityLog) AfterMigrate(tx *gorm.DB, tablePrefix string) error { if tables := strings.Split(tableName, "."); len(tables) == 2 { tableBare = tables[1] } - uix := fmt.Sprintf(`uix_%s_creator_id_model_name_keys_action_lastview`, tableBare) + uix := fmt.Sprintf(`uix_%s_user_id_model_name_keys_action_lastview`, tableBare) if err := tx.Exec(fmt.Sprintf(` CREATE UNIQUE INDEX IF NOT EXISTS %s - ON %s (creator_id, model_name, model_keys) + ON %s (user_id, model_name, model_keys) WHERE action = '%s' AND deleted_at IS NULL `, uix, tableName, ActionLastView)).Error; err != nil { return errors.Wrapf(err, "failed to create index %s", uix) diff --git a/activity/admin.go b/activity/admin.go index abed0b721..4b4c0e72d 100644 --- a/activity/admin.go +++ b/activity/admin.go @@ -57,7 +57,7 @@ func (ab *Builder) IsPresetInstalled(pb *presets.Builder) bool { func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelBuilder) error { var ( - lb = mb.Listing("CreatedAt", "Creator", "Action", "ModelKeys", "ModelLabel", "ModelName") + lb = mb.Listing("CreatedAt", "User", "Action", "ModelKeys", "ModelLabel", "ModelName") dp = mb.Detailing("Detail").Drawer(true) eb = mb.Editing() ) @@ -71,7 +71,7 @@ func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelB return r, err } log := r.(*ActivityLog) - if err := ab.supplyCreators(ctx.R.Context(), []*ActivityLog{log}); err != nil { + if err := ab.supplyUsers(ctx.R.Context(), []*ActivityLog{log}); err != nil { return nil, err } return log, nil @@ -94,7 +94,7 @@ func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelB return } logs := r.([]*ActivityLog) - if err := ab.supplyCreators(ctx.R.Context(), logs); err != nil { + if err := ab.supplyUsers(ctx.R.Context(), logs); err != nil { return nil, 0, err } return logs, totalCount, nil @@ -108,9 +108,9 @@ func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelB return h.Td(h.Text(obj.(*ActivityLog).CreatedAt.Format(timeFormat))) }, ) - lb.Field("Creator").Label(Messages_en_US.ModelCreator).ComponentFunc( + lb.Field("User").Label(Messages_en_US.ModelUser).ComponentFunc( func(obj any, field *presets.FieldContext, ctx *web.EventContext) h.HTMLComponent { - return h.Td(h.Div().Attr("v-pre", true).Text(obj.(*ActivityLog).Creator.Name)) + return h.Td(h.Div().Attr("v-pre", true).Text(obj.(*ActivityLog).User.Name)) }, ) lb.Field("ModelKeys").Label(Messages_en_US.ModelKeys) @@ -151,20 +151,20 @@ func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelB } actionOptions = lo.UniqBy(actionOptions, func(item *vuetifyx.SelectItem) string { return item.Value }) - creatorIDs := []string{} - err = ab.db.Model(&ActivityLog{}).Select("DISTINCT creator_id AS id").Pluck("id", &creatorIDs).Error + userIDs := []string{} + err = ab.db.Model(&ActivityLog{}).Select("DISTINCT user_id AS id").Pluck("id", &userIDs).Error if err != nil { panic(err) } - creators, err := ab.findUsers(ctx.R.Context(), creatorIDs) + users, err := ab.findUsers(ctx.R.Context(), userIDs) if err != nil { panic(err) } - var creatorOptions []*vuetifyx.SelectItem - for _, creator := range creators { - creatorOptions = append(creatorOptions, &vuetifyx.SelectItem{ - Text: creator.Name, - Value: creator.ID, + var userOptions []*vuetifyx.SelectItem + for _, user := range users { + userOptions = append(userOptions, &vuetifyx.SelectItem{ + Text: user.Name, + Value: user.ID, }) } @@ -191,13 +191,13 @@ func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelB SQLCondition: `created_at %s ?`, }, } - if len(creatorOptions) > 0 { + if len(userOptions) > 0 { filterData = append(filterData, &vuetifyx.FilterItem{ - Key: "creator_id", - Label: msgr.FilterCreator, + Key: "user_id", + Label: msgr.FilterUser, ItemType: vuetifyx.ItemTypeSelect, - SQLCondition: `creator_id %s ?`, - Options: creatorOptions, + SQLCondition: `user_id %s ?`, + Options: userOptions, }) } if len(modelOptions) > 0 { @@ -246,8 +246,8 @@ func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelB VCardText().Class("pa-0 pt-3").Children( VTable( h.Tbody( - h.Tr(h.Td(h.Text(msgr.ModelCreator)), h.Td().Attr("v-pre", true).Text(log.Creator.Name)), - h.Tr(h.Td(h.Text(msgr.ModelUserID)), h.Td().Attr("v-pre", true).Text(log.CreatorID)), + h.Tr(h.Td(h.Text(msgr.ModelUser)), h.Td().Attr("v-pre", true).Text(log.User.Name)), + h.Tr(h.Td(h.Text(msgr.ModelUserID)), h.Td().Attr("v-pre", true).Text(log.UserID)), h.Tr(h.Td(h.Text(msgr.ModelAction)), h.Td().Attr("v-pre", true).Text(log.Action)), h.Tr(h.Td(h.Text(msgr.ModelName)), h.Td().Attr("v-pre", true).Text(log.ModelName)), h.Tr(h.Td(h.Text(msgr.ModelLabel)), h.Td().Attr("v-pre", true).Text(cmp.Or(log.ModelLabel, "-"))), diff --git a/activity/builder.go b/activity/builder.go index 3d57dd06c..d7e6f4e79 100644 --- a/activity/builder.go +++ b/activity/builder.go @@ -192,17 +192,17 @@ func (ab *Builder) findUsers(ctx context.Context, ids []string) (map[string]*Use }), nil } -func (ab *Builder) supplyCreators(ctx context.Context, logs []*ActivityLog) error { - creatorIDs := lo.Uniq(lo.Map(logs, func(log *ActivityLog, _ int) string { - return log.CreatorID +func (ab *Builder) supplyUsers(ctx context.Context, logs []*ActivityLog) error { + userIDs := lo.Uniq(lo.Map(logs, func(log *ActivityLog, _ int) string { + return log.UserID })) - creators, err := ab.findUsers(ctx, creatorIDs) + users, err := ab.findUsers(ctx, userIDs) if err != nil { return err } for _, log := range logs { - if creator, ok := creators[log.CreatorID]; ok { - log.Creator = *creator + if user, ok := users[log.UserID]; ok { + log.User = *user } } return nil @@ -214,7 +214,7 @@ func (ab *Builder) getActivityLogs(ctx context.Context, modelName, modelKeys str if err != nil { return nil, err } - if err := ab.supplyCreators(ctx, logs); err != nil { + if err := ab.supplyUsers(ctx, logs); err != nil { return nil, err } return logs, nil diff --git a/activity/builder_test.go b/activity/builder_test.go index 2fc5c96ed..e30359c27 100644 --- a/activity/builder_test.go +++ b/activity/builder_test.go @@ -226,7 +226,7 @@ func TestModelTypeHanders(t *testing.T) { } } -func TestCreatorInferface(t *testing.T) { +func TestUser(t *testing.T) { builder := New(db, testCurrentUser) builder.Install(pb) @@ -239,8 +239,8 @@ func TestCreatorInferface(t *testing.T) { if err := db.First(record).Error; err != nil { t.Fatal(err) } - if record.CreatorID != "1" { - t.Errorf("want the user id %v, but got %v", "1", record.CreatorID) + if record.UserID != "1" { + t.Errorf("want the user id %v, but got %v", "1", record.UserID) } } @@ -286,7 +286,7 @@ func TestGetActivityLogs(t *testing.T) { if log.ModelKeys != "1:v1" { t.Errorf("expected model keys '1:v1', but got %s", log.ModelKeys) } - require.Equal(t, log.CreatorID, currentUser.ID) + require.Equal(t, log.UserID, currentUser.ID) } } @@ -312,8 +312,8 @@ func TestMutliModelBuilder(t *testing.T) { // add create record db.Create(data1) builder.OnCreate(ctx, data1) - pageModel2.Editing().Saver(data2, "", &web.EventContext{R: httptest.NewRequest("POST", "/admin/page-01/2", nil).WithContext(context.WithValue(context.Background(), "creator", "Test User"))}) - pageModel3.Editing().Saver(data3, "", &web.EventContext{R: httptest.NewRequest("POST", "/admin/page-02/3", nil).WithContext(context.WithValue(context.Background(), "creator", "Test User"))}) + pageModel2.Editing().Saver(data2, "", &web.EventContext{R: httptest.NewRequest("POST", "/admin/page-01/2", nil).WithContext(context.WithValue(context.Background(), "user", "Test User"))}) + pageModel3.Editing().Saver(data3, "", &web.EventContext{R: httptest.NewRequest("POST", "/admin/page-02/3", nil).WithContext(context.WithValue(context.Background(), "user", "Test User"))}) { for _, id := range []string{"1", "2"} { var log ActivityLog @@ -338,11 +338,11 @@ func TestMutliModelBuilder(t *testing.T) { data2.Title = "test2-1" data2.Description = "Description2-1" - pageModel2.Editing().Saver(data2, "2", &web.EventContext{R: httptest.NewRequest("POST", "/admin/page-01/2", nil).WithContext(context.WithValue(context.Background(), "creator", "Test User"))}) + pageModel2.Editing().Saver(data2, "2", &web.EventContext{R: httptest.NewRequest("POST", "/admin/page-01/2", nil).WithContext(context.WithValue(context.Background(), "user", "Test User"))}) data3.Title = "test3-1" data3.Description = "Description3-1" - pageModel3.Editing().Saver(data3, "3", &web.EventContext{R: httptest.NewRequest("POST", "/admin/page-02/3", nil).WithContext(context.WithValue(context.Background(), "creator", "Test User"))}) + pageModel3.Editing().Saver(data3, "3", &web.EventContext{R: httptest.NewRequest("POST", "/admin/page-02/3", nil).WithContext(context.WithValue(context.Background(), "user", "Test User"))}) { var log1 ActivityLog @@ -381,8 +381,8 @@ func TestMutliModelBuilder(t *testing.T) { // add delete record db.Delete(data1) builder.OnDelete(ctx, data1) - pageModel2.Editing().Deleter(data2, "2", &web.EventContext{R: httptest.NewRequest("POST", "/admin/page-01/2", nil).WithContext(context.WithValue(context.Background(), "creator", "Test User"))}) - pageModel3.Editing().Deleter(data3, "3", &web.EventContext{R: httptest.NewRequest("POST", "/admin/page-02/3", nil).WithContext(context.WithValue(context.Background(), "creator", "Test User"))}) + pageModel2.Editing().Deleter(data2, "2", &web.EventContext{R: httptest.NewRequest("POST", "/admin/page-01/2", nil).WithContext(context.WithValue(context.Background(), "user", "Test User"))}) + pageModel3.Editing().Deleter(data3, "3", &web.EventContext{R: httptest.NewRequest("POST", "/admin/page-02/3", nil).WithContext(context.WithValue(context.Background(), "user", "Test User"))}) { for _, id := range []string{"1", "3"} { var log ActivityLog diff --git a/activity/messages.go b/activity/messages.go index 79f810f0b..33b453719 100644 --- a/activity/messages.go +++ b/activity/messages.go @@ -17,7 +17,7 @@ type Messages struct { ModelUserID string ModelCreatedAt string ModelAction string - ModelCreator string + ModelUser string ModelKeys string ModelName string ModelLabel string @@ -26,7 +26,7 @@ type Messages struct { FilterAction string FilterCreatedAt string - FilterCreator string + FilterUser string FilterModel string DiffDetail string @@ -48,13 +48,13 @@ type Messages struct { PerformActionNoDetailTemplate string PerformActionTemplate string AddNote string - UnknownCreator string + UnknownUser string NoteCannotBeEmpty string FailedToCreateNote string SuccessfullyCreatedNote string FailedToGetCurrentUser string FailedToGetNote string - YouAreNotTheNoteCreator string + YouAreNotTheNoteUser string FailedToUpdateNote string SuccessfullyUpdatedNote string FailedToDeleteNote string @@ -100,7 +100,7 @@ var Messages_en_US = &Messages{ ModelUserID: "Creator ID", ModelCreatedAt: "Date Time", ModelAction: "Action", - ModelCreator: "Creator", + ModelUser: "Creator", ModelKeys: "Keys", ModelName: "Table Name", ModelLabel: "Menu Name", @@ -109,7 +109,7 @@ var Messages_en_US = &Messages{ FilterAction: "Action", FilterCreatedAt: "Create Time", - FilterCreator: "Creator", + FilterUser: "Creator", FilterModel: "Model Name", DiffDetail: "Detail", @@ -131,13 +131,13 @@ var Messages_en_US = &Messages{ PerformActionNoDetailTemplate: "Perform {action}", PerformActionTemplate: "Perform {action} with {detail}", AddNote: "Add Note", - UnknownCreator: "Unknown", + UnknownUser: "Unknown", NoteCannotBeEmpty: "Note cannot be empty", FailedToCreateNote: "Failed to create note", SuccessfullyCreatedNote: "Successfully created note", FailedToGetCurrentUser: "Failed to get current user", FailedToGetNote: "Failed to get note", - YouAreNotTheNoteCreator: "You are not the creator of this note", + YouAreNotTheNoteUser: "You are not the creator of this note", FailedToUpdateNote: "Failed to update note", SuccessfullyUpdatedNote: "Successfully updated note", FailedToDeleteNote: "Failed to delete note", @@ -161,7 +161,7 @@ var Messages_zh_CN = &Messages{ ModelUserID: "操作者ID", ModelCreatedAt: "日期时间", ModelAction: "操作", - ModelCreator: "操作者", + ModelUser: "操作者", ModelKeys: "表的主键值", ModelName: "表名", ModelLabel: "菜单名", @@ -170,7 +170,7 @@ var Messages_zh_CN = &Messages{ FilterAction: "操作类型", FilterCreatedAt: "操作时间", - FilterCreator: "操作人", + FilterUser: "操作人", FilterModel: "操作对象", DiffDetail: "详情", DiffAdd: "新加", @@ -191,13 +191,13 @@ var Messages_zh_CN = &Messages{ PerformActionNoDetailTemplate: "执行 {action}", PerformActionTemplate: "执行 {action} 操作,详情为 {detail}", AddNote: "添加备注", - UnknownCreator: "未知", + UnknownUser: "未知", NoteCannotBeEmpty: "备注不能为空", FailedToCreateNote: "创建备注失败", SuccessfullyCreatedNote: "成功创建备注", FailedToGetCurrentUser: "获取当前用户失败", FailedToGetNote: "获取备注失败", - YouAreNotTheNoteCreator: "您不是备注的创建者", + YouAreNotTheNoteUser: "您不是备注的创建者", FailedToUpdateNote: "更新备注失败", SuccessfullyUpdatedNote: "成功更新备注", FailedToDeleteNote: "删除备注失败", @@ -221,7 +221,7 @@ var Messages_ja_JP = &Messages{ ModelUserID: "作成者ID", ModelCreatedAt: "日時", ModelAction: "アクション", - ModelCreator: "作成者", + ModelUser: "作成者", ModelKeys: "キー", ModelName: "テーブル名", ModelLabel: "メニュー名", @@ -230,7 +230,7 @@ var Messages_ja_JP = &Messages{ FilterAction: "アクション", FilterCreatedAt: "作成日時", - FilterCreator: "作成者", + FilterUser: "作成者", FilterModel: "モデル名", DiffDetail: "詳細", @@ -252,13 +252,13 @@ var Messages_ja_JP = &Messages{ PerformActionNoDetailTemplate: "{action} を実行", PerformActionTemplate: "{action} を実行し、{detail} を使用", AddNote: "ノートを追加", - UnknownCreator: "不明", + UnknownUser: "不明", NoteCannotBeEmpty: "ノートは空にできません", FailedToCreateNote: "ノートの作成に失敗しました", SuccessfullyCreatedNote: "ノートの作成に成功しました", FailedToGetCurrentUser: "現在のユーザーの取得に失敗しました", FailedToGetNote: "ノートの取得に失敗しました", - YouAreNotTheNoteCreator: "このノートの作成者ではありません", + YouAreNotTheNoteUser: "このノートの作成者ではありません", FailedToUpdateNote: "ノートの更新に失敗しました", SuccessfullyUpdatedNote: "ノートの更新に成功しました", FailedToDeleteNote: "ノートの削除に失敗しました", diff --git a/activity/model_builder.go b/activity/model_builder.go index 127b5aaf8..2e9e57944 100644 --- a/activity/model_builder.go +++ b/activity/model_builder.go @@ -461,7 +461,7 @@ func (mb *ModelBuilder) create( } log := &ActivityLog{ - CreatorID: user.ID, + UserID: user.ID, Action: action, ModelName: modelName, ModelKeys: modelKeys, @@ -483,14 +483,14 @@ func (mb *ModelBuilder) create( log.Hidden = true r := &ActivityLog{} if err := mb.ab.db. - Where("creator_id = ? AND model_name = ? AND model_keys = ? AND action = ?", user.ID, modelName, modelKeys, action). + Where("user_id = ? AND model_name = ? AND model_keys = ? AND action = ?", user.ID, modelName, modelKeys, action). Assign(log).FirstOrCreate(r).Error; err != nil { return nil, err } return r, nil // Why not use this ? Because log.id is empty although the record is already created, there is no advance fetch of the original id here . - // if mb.ab.db.Where("creator_id = ? AND model_name = ? AND model_keys = ? AND action = ?", log.CreatorID, log.ModelName, log.ModelKeys, log.Action). + // if mb.ab.db.Where("user_id = ? AND model_name = ? AND model_keys = ? AND action = ?", log.UserID, log.ModelName, log.ModelKeys, log.Action). // Select("*").Updates(log).RowsAffected == 0 { // if err := mb.ab.db.Create(log).Error; err != nil { // return nil, errors.Wrap(err, "failed to create log") diff --git a/activity/note.go b/activity/note.go index 034590ea4..06a3a6a4f 100644 --- a/activity/note.go +++ b/activity/note.go @@ -18,9 +18,9 @@ type NoteCount struct { TotalNotesCount int64 } -func getNotesCounts(db *gorm.DB, tablePrefix string, creatorID string, modelName string, modelKeyses []string) ([]*NoteCount, error) { - if creatorID == "" { - return nil, errors.New("creatorID is required") +func getNotesCounts(db *gorm.DB, tablePrefix string, uid string, modelName string, modelKeyses []string) ([]*NoteCount, error) { + if uid == "" { + return nil, errors.New("uid is required") } s, err := ParseSchemaWithDB(db, &ActivityLog{}) @@ -43,7 +43,7 @@ func getNotesCounts(db *gorm.DB, tablePrefix string, creatorID string, modelName args = append(args, modelKeyses) } - args = append(args, ActionLastView, creatorID) + args = append(args, ActionLastView, uid) if modelName != "" { args = append(args, modelName) @@ -52,11 +52,11 @@ func getNotesCounts(db *gorm.DB, tablePrefix string, creatorID string, modelName args = append(args, modelKeyses) } - args = append(args, creatorID) + args = append(args, uid) raw := fmt.Sprintf(` WITH NoteRecords AS ( - SELECT model_name, model_keys, created_at, creator_id + SELECT model_name, model_keys, created_at, user_id FROM %s WHERE action = ? AND deleted_at IS NULL %s @@ -64,14 +64,14 @@ func getNotesCounts(db *gorm.DB, tablePrefix string, creatorID string, modelName LastViewedAts AS ( SELECT model_name, model_keys, MAX(updated_at) AS last_viewed_at FROM %s - WHERE action = ? AND creator_id = ? AND deleted_at IS NULL + WHERE action = ? AND user_id = ? AND deleted_at IS NULL %s GROUP BY model_name, model_keys ) SELECT n.model_name, n.model_keys, - COUNT(CASE WHEN n.creator_id <> ? AND (lva.last_viewed_at IS NULL OR n.created_at > lva.last_viewed_at) THEN 1 END) AS unread_notes_count, + COUNT(CASE WHEN n.user_id <> ? AND (lva.last_viewed_at IS NULL OR n.created_at > lva.last_viewed_at) THEN 1 END) AS unread_notes_count, COUNT(*) AS total_notes_count FROM NoteRecords n LEFT JOIN LastViewedAts lva @@ -86,7 +86,7 @@ func getNotesCounts(db *gorm.DB, tablePrefix string, creatorID string, modelName return counts, nil } -func markAllNotesAsRead(db *gorm.DB, creatorID string) error { +func markAllNotesAsRead(db *gorm.DB, uid string) error { return db.Transaction(func(tx *gorm.DB) error { var results []struct { ModelName string @@ -106,14 +106,14 @@ func markAllNotesAsRead(db *gorm.DB, creatorID string) error { return nil } - if err := tx.Unscoped().Where("creator_id = ? AND action = ?", creatorID, ActionLastView).Delete(&ActivityLog{}).Error; err != nil { + if err := tx.Unscoped().Where("user_id = ? AND action = ?", uid, ActionLastView).Delete(&ActivityLog{}).Error; err != nil { return errors.Wrap(err, "delete last views") } var logs []ActivityLog for _, v := range results { log := ActivityLog{ - CreatorID: creatorID, + UserID: uid, Action: ActionLastView, Hidden: true, ModelName: v.ModelName, @@ -135,7 +135,7 @@ func markAllNotesAsRead(db *gorm.DB, creatorID string) error { }) } -func sqlConditionHasUnreadNotes(db *gorm.DB, tablePrefix string, creatorID string, modelName string, columns []string, sep string, columnPrefix string) (string, error) { +func sqlConditionHasUnreadNotes(db *gorm.DB, tablePrefix string, uid string, modelName string, columns []string, sep string, columnPrefix string) (string, error) { a := strings.Join(lo.Map(columns, func(v string, _ int) string { return fmt.Sprintf("%s%s::text", columnPrefix, v) }), ",") @@ -152,7 +152,7 @@ func sqlConditionHasUnreadNotes(db *gorm.DB, tablePrefix string, creatorID strin return fmt.Sprintf(` (%s) IN ( WITH NoteRecords AS ( - SELECT model_name, model_keys, created_at, creator_id + SELECT model_name, model_keys, created_at, user_id FROM %s WHERE action = '%s' AND deleted_at IS NULL AND model_name = '%s' @@ -160,7 +160,7 @@ func sqlConditionHasUnreadNotes(db *gorm.DB, tablePrefix string, creatorID strin LastViewedAts AS ( SELECT model_name, model_keys, MAX(updated_at) AS last_viewed_at FROM %s - WHERE action = '%s' AND creator_id = '%s' AND deleted_at IS NULL + WHERE action = '%s' AND user_id = '%s' AND deleted_at IS NULL AND model_name = '%s' GROUP BY model_name, model_keys ) @@ -171,10 +171,10 @@ func sqlConditionHasUnreadNotes(db *gorm.DB, tablePrefix string, creatorID strin LEFT JOIN LastViewedAts lva ON n.model_name = lva.model_name AND n.model_keys = lva.model_keys - WHERE n.creator_id <> '%s' + WHERE n.user_id <> '%s' AND (lva.last_viewed_at IS NULL OR n.created_at > lva.last_viewed_at) GROUP BY n.model_keys - )`, a, tableName, ActionNote, modelName, tableName, ActionLastView, creatorID, modelName, b, creatorID), nil + )`, a, tableName, ActionNote, modelName, tableName, ActionLastView, uid, modelName, b, uid), nil } func (ab *Builder) GetNotesCounts(ctx context.Context, modelName string, modelKeyses []string) ([]*NoteCount, error) { diff --git a/activity/timeline.go b/activity/timeline.go index b70a4696c..1f9167407 100644 --- a/activity/timeline.go +++ b/activity/timeline.go @@ -151,13 +151,13 @@ func (c *TimelineCompo) MarshalHTML(ctx context.Context) ([]byte, error) { return nil, errors.Wrap(err, "failed to get current user") } for i, log := range logs { - creatorName := log.Creator.Name - if creatorName == "" { - creatorName = msgr.UnknownCreator + userName := log.User.Name + if userName == "" { + userName = msgr.UnknownUser } avatarText := "" - if log.Creator.Avatar == "" { - avatarText = strings.ToUpper(string([]rune(creatorName)[0:1])) + if log.User.Avatar == "" { + avatarText = strings.ToUpper(string([]rune(userName)[0:1])) } dotColor := v.ColorSuccess if i != 0 { @@ -173,11 +173,11 @@ func (c *TimelineCompo) MarshalHTML(ctx context.Context) ([]byte, error) { h.Div().Class("flex-grow-1 d-flex flex-column pb-3").Children( h.Div().Class("d-flex flex-row align-center ga-2").Children( v.VAvatar().Class("text-overline font-weight-medium text-primary bg-primary-lighten-2").Size(v.SizeXSmall).Density(v.DensityCompact).Rounded(true).Text(avatarText).Children( - h.Iff(log.Creator.Avatar != "", func() h.HTMLComponent { - return v.VImg().Attr("alt", creatorName).Attr("src", log.Creator.Avatar) + h.Iff(log.User.Avatar != "", func() h.HTMLComponent { + return v.VImg().Attr("alt", userName).Attr("src", log.User.Avatar) }), ), - h.Div().Attr("v-pre", true).Text(creatorName).Class("font-weight-medium").ClassIf("text-grey", i != 0), + h.Div().Attr("v-pre", true).Text(userName).Class("font-weight-medium").ClassIf("text-grey", i != 0), ), h.Div().Class("d-flex flex-row align-center ga-2").Children( h.Div().Style("width: 16px"), @@ -188,7 +188,7 @@ func (c *TimelineCompo) MarshalHTML(ctx context.Context) ([]byte, error) { ) if log.Action == ActionNote { child = web.Scope().VSlot("{locals: xlocals, form}").Init("{showEditBox:false}").Children( - v.VHover().Disabled(log.CreatorID != user.ID).Children( + v.VHover().Disabled(log.UserID != user.ID).Children( web.Slot().Name("default").Scope("{ isHovering, props }").Children( h.Div().Class("d-flex flex-column").Style("position: relative").Attr("v-bind", "props").Children( h.Div().Attr("v-if", "isHovering && !xlocals.showEditBox").Class("d-flex flex-row ga-1"). @@ -316,8 +316,8 @@ func (c *TimelineCompo) UpdateNote(ctx context.Context, req UpdateNoteRequest) ( presets.ShowMessage(&r, msgr.FailedToGetNote, v.ColorError) return } - if log.CreatorID != user.ID { - presets.ShowMessage(&r, msgr.YouAreNotTheNoteCreator, v.ColorError) + if log.UserID != user.ID { + presets.ShowMessage(&r, msgr.YouAreNotTheNoteUser, v.ColorError) return } @@ -367,13 +367,13 @@ func (c *TimelineCompo) DeleteNote(ctx context.Context, req DeleteNoteRequest) ( return } - result := c.ab.db.Where("id = ? AND creator_id = ?", req.LogID, user.ID).Delete(&ActivityLog{}) + result := c.ab.db.Where("id = ? AND user_id = ?", req.LogID, user.ID).Delete(&ActivityLog{}) if err := result.Error; err != nil { presets.ShowMessage(&r, msgr.FailedToDeleteNote, v.ColorError) return } if result.RowsAffected == 0 { - presets.ShowMessage(&r, msgr.YouAreNotTheNoteCreator, v.ColorError) + presets.ShowMessage(&r, msgr.YouAreNotTheNoteUser, v.ColorError) return } presets.ShowMessage(&r, msgr.SuccessfullyDeletedNote, v.ColorSuccess) diff --git a/docs/docsrc/examples/examples_admin/activity_test.go b/docs/docsrc/examples/examples_admin/activity_test.go index 98c4f6512..4affc9f14 100644 --- a/docs/docsrc/examples/examples_admin/activity_test.go +++ b/docs/docsrc/examples/examples_admin/activity_test.go @@ -24,7 +24,7 @@ INSERT INTO "public"."with_activity_products" ("id", "created_at", "updated_at", ('22', '2024-07-16 03:35:10.242888+00', '2024-07-16 03:35:10.242888+00', NULL, 'Jordan 1 Retro High', '10010', '250'), ('23', '2024-07-16 03:40:10.242888+00', '2024-07-16 03:40:10.242888+00', NULL, 'Under Armour Curry 7', '10011', '140'); -INSERT INTO "public"."activity_logs" ("id", "created_at", "updated_at", "deleted_at", "creator_id", "action", "hidden", "model_name", "model_keys", "model_label", "model_link", "detail") VALUES ('29', '2024-07-16 02:50:10.250739+00', '2024-07-16 02:50:10.251259+00', NULL, '1', 'Create', 'f', 'WithActivityProduct', '13', 'with-activity-products', '/examples/activity-example/with-activity-products/13', 'null'), +INSERT INTO "public"."activity_logs" ("id", "created_at", "updated_at", "deleted_at", "user_id", "action", "hidden", "model_name", "model_keys", "model_label", "model_link", "detail") VALUES ('29', '2024-07-16 02:50:10.250739+00', '2024-07-16 02:50:10.251259+00', NULL, '1', 'Create', 'f', 'WithActivityProduct', '13', 'with-activity-products', '/examples/activity-example/with-activity-products/13', 'null'), ('45', '2024-07-16 02:56:45.176698+00', '2024-07-16 02:56:45.177268+00', NULL, '1', 'Note', 'f', 'WithActivityProduct', '13', 'with-activity-products', '/examples/activity-example/with-activity-products/13', '{"note":"The newest model of the off-legacy Air Jordans is ready to burst onto to the scene.","last_edited_at":"0001-01-01T00:00:00Z"}'), ('44', '2024-07-16 02:56:42.273117+00', '2024-07-16 02:56:42.275043+00', NULL, '1', 'LastView', 't', 'WithActivityProduct', '13', 'with-activity-products', '/examples/activity-example/with-activity-products/13', 'null'), ('30', '2024-07-16 02:55:10.250739+00', '2024-07-16 02:55:10.251259+00', NULL, '1', 'Create', 'f', 'WithActivityProduct', '14', 'with-activity-products', '/examples/activity-example/with-activity-products/14', 'null'), diff --git a/example/admin/config.go b/example/admin/config.go index 91bc0c7eb..6bea7cbf0 100644 --- a/example/admin/config.go +++ b/example/admin/config.go @@ -116,7 +116,7 @@ func NewConfig(db *gorm.DB) Config { u := getCurrentUser(ctx.R) if rs := u.GetRoles(); !slices.Contains(rs, models.RoleAdmin) { params.SQLConditions = append(params.SQLConditions, &presets.SQLCondition{ - Query: "creator_id = ?", + Query: "user_id = ?", Args: []interface{}{fmt.Sprint(u.ID)}, }) } diff --git a/example/admin/perm.go b/example/admin/perm.go index 8c9d5e25f..9526e762f 100644 --- a/example/admin/perm.go +++ b/example/admin/perm.go @@ -43,7 +43,7 @@ func initPermission(b *presets.Builder, db *gorm.DB) { switch v := obj.(type) { case *activity.ActivityLog: u := getCurrentUser(r) - if fmt.Sprint(u.GetID()) == v.CreatorID { + if fmt.Sprint(u.GetID()) == v.UserID { c["is_authorized"] = true } else { c["is_authorized"] = false From f7fc1dc8438c96f87bd985800c786dd1df6b5e6a Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Fri, 26 Jul 2024 16:35:19 +0800 Subject: [PATCH 22/33] fix tests --- activity/builder_test.go | 24 +++++++++++++++----- activity/tests/gorm_test.go | 44 +++++++++++++++++++++++-------------- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/activity/builder_test.go b/activity/builder_test.go index e30359c27..61cf80a6d 100644 --- a/activity/builder_test.go +++ b/activity/builder_test.go @@ -16,12 +16,7 @@ import ( "gorm.io/gorm/logger" ) -var ( - db *gorm.DB - pb = presets.New() - pageModel = pb.Model(&Page{}) - widgetModel = pb.Model(&Widget{}) -) +var db *gorm.DB type ( Page struct { @@ -82,6 +77,10 @@ func resetDB() { } func TestModelKeys(t *testing.T) { + pb := presets.New() + pageModel := pb.Model(&Page{}) + widgetModel := pb.Model(&Widget{}) + resetDB() if err := AutoMigrate(db, ""); err != nil { @@ -124,6 +123,9 @@ func TestModelKeys(t *testing.T) { } func TestModelLink(t *testing.T) { + pb := presets.New() + pageModel := pb.Model(&Page{}) + builder := New(db, testCurrentUser) builder.Install(pb) builder.RegisterModel(pageModel).LinkFunc(func(v any) string { @@ -145,6 +147,9 @@ func TestModelLink(t *testing.T) { } func TestModelTypeHanders(t *testing.T) { + pb := presets.New() + pageModel := pb.Model(&Page{}) + builder := New(db, testCurrentUser) builder.Install(pb) builder.RegisterModel(pageModel).AddTypeHanders(Widgets{}, func(old, new any, prefixField string) (diffs []Diff) { @@ -227,6 +232,9 @@ func TestModelTypeHanders(t *testing.T) { } func TestUser(t *testing.T) { + pb := presets.New() + pageModel := pb.Model(&Page{}) + builder := New(db, testCurrentUser) builder.Install(pb) @@ -245,6 +253,8 @@ func TestUser(t *testing.T) { } func TestGetActivityLogs(t *testing.T) { + pb := presets.New() + builder := New(db, testCurrentUser) pb.Use(builder) @@ -291,6 +301,8 @@ func TestGetActivityLogs(t *testing.T) { } func TestMutliModelBuilder(t *testing.T) { + pb := presets.New() + builder := New(db, testCurrentUser) builder.Install(pb) pb.DataOperator(gorm2op.DataOperator(db)) diff --git a/activity/tests/gorm_test.go b/activity/tests/gorm_test.go index c4c8511d9..0475fdfee 100644 --- a/activity/tests/gorm_test.go +++ b/activity/tests/gorm_test.go @@ -33,14 +33,18 @@ func TestMain(m *testing.M) { db = env.DB db.Logger = db.Logger.LogMode(logger.Info) - if err = db.AutoMigrate(&Foo{}); err != nil { - panic(err) - } - m.Run() } +func resetDB() { + db.Exec("DROP TABLE IF EXISTS foos") + db.Exec("DROP TABLE IF EXISTS bars") +} + func TestTablePrefix(t *testing.T) { + resetDB() + require.NoError(t, db.AutoMigrate(&Foo{})) + require.NoError(t, db.Create(&Foo{ID: "1", Name: "foo"}).Error) { foo := &Foo{} @@ -48,11 +52,11 @@ func TestTablePrefix(t *testing.T) { require.Equal(t, "foo", foo.Name) } - require.NoError(t, db.Exec(`CREATE SCHEMA IF NOT EXISTS copilot ;`).Error) + require.NoError(t, db.Exec(`CREATE SCHEMA IF NOT EXISTS plant;`).Error) db := db.Session(&gorm.Session{}) db.Config.NamingStrategy = schema.NamingStrategy{ - TablePrefix: "copilot.", + TablePrefix: "plant.", IdentifierMaxLength: 64, } { @@ -60,7 +64,7 @@ func TestTablePrefix(t *testing.T) { sql := db.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Where("id = ?", "1").First(foo) }) - require.NotContains(t, sql, "copilot") // Because the db already has an internal cache + require.NotContains(t, sql, "plant") // Because the db already has an internal cache } { require.NoError(t, db.AutoMigrate(&Bar{})) @@ -69,31 +73,37 @@ func TestTablePrefix(t *testing.T) { sql := db.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Create(&Bar{ID: "1", Name: "bar"}) }) - require.Contains(t, sql, "copilot") // Because the db hasn't cached the Bar yet. + require.Contains(t, sql, "plant") // Because the db hasn't cached the Bar yet. } // So it is not a reliable solution. } func TestTable(t *testing.T) { + resetDB() + require.NoError(t, db.AutoMigrate(&Foo{})) + foo := &Foo{} require.Contains(t, db.ToSQL(func(tx *gorm.DB) *gorm.DB { - return tx.Table("copilotx.foos").Where("id = ?", "1").First(foo) - }), "copilotx") + return tx.Table("plantx.foos").Where("id = ?", "1").First(foo) + }), "plantx") require.Contains(t, db.ToSQL(func(tx *gorm.DB) *gorm.DB { - return tx.Table("copiloty.foos").Where("id = ?", "1").First(foo) - }), "copiloty") + return tx.Table("planty.foos").Where("id = ?", "1").First(foo) + }), "planty") - require.NoError(t, db.Exec(`CREATE SCHEMA IF NOT EXISTS copilot;`).Error) - db := db.Table("copilot.foos").Session(&gorm.Session{}) // Fixed TableName + require.NoError(t, db.Exec(`CREATE SCHEMA IF NOT EXISTS plant;`).Error) + db := db.Table("plant.foos").Session(&gorm.Session{}) // Fixed TableName require.Contains(t, db.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Where("id = ?", "1").First(foo) - }), "copilot") + }), "plant") require.Contains(t, db.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Where("id = ?", "1").First(foo) - }), "copilot") + }), "plant") } func TestScopes(t *testing.T) { + resetDB() + require.NoError(t, db.AutoMigrate(&Foo{})) + callCount := 0 scopeTableName := func(tableName string) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { @@ -116,6 +126,8 @@ func TestScopes(t *testing.T) { } func TestDynamicTablePrefix(t *testing.T) { + resetDB() + getTableName := func(db *gorm.DB, tablePrefix string, model any) string { stmt := &gorm.Statement{DB: db} stmt.Parse(model) From a072935a921e94994a3ca6dccb9cc3d032912eb9 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Fri, 26 Jul 2024 16:45:32 +0800 Subject: [PATCH 23/33] activity: ensure call TablePrefix before AutoMigrate --- activity/builder.go | 12 ++++++++++-- docs/docsrc/examples/examples_admin/activity.go | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/activity/builder.go b/activity/builder.go index d7e6f4e79..0d8c3b4a9 100644 --- a/activity/builder.go +++ b/activity/builder.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" "sync" + "sync/atomic" "github.com/pkg/errors" "github.com/qor5/admin/v3/presets" @@ -21,8 +22,9 @@ type User struct { // @snippet_begin(ActivityBuilder) type Builder struct { - models []*ModelBuilder // registered model builders - installedPresets sync.Map // installed presets builders for admin + models []*ModelBuilder // registered model builders + installedPresets sync.Map // installed presets builders for admin + calledAutoMigrate atomic.Bool // auto migrate flag dbPrimitive *gorm.DB // primitive db db *gorm.DB // global db with table prefix scope @@ -69,6 +71,9 @@ func New(db *gorm.DB, currentUserFunc func(ctx context.Context) (*User, error)) } func (ab *Builder) TablePrefix(prefix string) *Builder { + if ab.calledAutoMigrate.Load() { + panic("please set table prefix before auto migrate") + } ab.tablePrefix = prefix if prefix == "" { ab.db = ab.dbPrimitive @@ -145,6 +150,9 @@ func (ab *Builder) GetModelBuilders() []*ModelBuilder { } func (b *Builder) AutoMigrate() (r *Builder) { + if !b.calledAutoMigrate.CompareAndSwap(false, true) { + panic("already migrated") + } if err := AutoMigrate(b.dbPrimitive, b.tablePrefix); err != nil { panic(err) } diff --git a/docs/docsrc/examples/examples_admin/activity.go b/docs/docsrc/examples/examples_admin/activity.go index 0e1e42c78..6833f247c 100644 --- a/docs/docsrc/examples/examples_admin/activity.go +++ b/docs/docsrc/examples/examples_admin/activity.go @@ -22,7 +22,9 @@ func ActivityExample(b *presets.Builder, db *gorm.DB) http.Handler { Name: "John", Avatar: "https://i.pravatar.cc/300", }, nil - }).AutoMigrate() + }). + // TablePrefix("cms_"). // multitentant if needed + AutoMigrate() b.Use(ab) // @snippet_end From 732544001b67a95c28002d41caa96029ac8ccbc8 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Sun, 28 Jul 2024 23:22:35 +0800 Subject: [PATCH 24/33] activity: util.Fetch func returns error --- activity/builder.go | 2 +- activity/builder_test.go | 11 ++++++----- activity/note.go | 2 +- activity/util.go | 29 ++++++++++++++--------------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/activity/builder.go b/activity/builder.go index 0d8c3b4a9..73b2bf3f8 100644 --- a/activity/builder.go +++ b/activity/builder.go @@ -241,7 +241,7 @@ func (ab *Builder) onlyModelBuilder(v any) (*ModelBuilder, error) { if ok { return bare, nil } - return nil, errors.Errorf("multiple model builders found for %v", v) + return nil, errors.Errorf("multiple preset model builders found for %v", v) } return ambs[0], nil } diff --git a/activity/builder_test.go b/activity/builder_test.go index 61cf80a6d..50607000c 100644 --- a/activity/builder_test.go +++ b/activity/builder_test.go @@ -9,7 +9,6 @@ import ( "github.com/qor5/admin/v3/presets" "github.com/qor5/admin/v3/presets/gorm2op" "github.com/qor5/web/v3" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/theplant/testenv" "gorm.io/gorm" @@ -343,10 +342,12 @@ func TestMutliModelBuilder(t *testing.T) { // add edit record data1.Title = "test1-1" data1.Description = "Description1-1" - old, _ := FetchOld(db, data1) - db.Save(data1) - _, err := builder.OnEdit(ctx, old, data1) - assert.NoError(t, err) + + old, err := FetchOld(db, data1) + require.NoError(t, err) + require.NoError(t, db.Save(data1).Error) + _, err = builder.OnEdit(ctx, old, data1) + require.NoError(t, err) data2.Title = "test2-1" data2.Description = "Description2-1" diff --git a/activity/note.go b/activity/note.go index 06a3a6a4f..49f922195 100644 --- a/activity/note.go +++ b/activity/note.go @@ -166,7 +166,7 @@ func sqlConditionHasUnreadNotes(db *gorm.DB, tablePrefix string, uid string, mod ) SELECT -%s + %s FROM NoteRecords n LEFT JOIN LastViewedAts lva ON n.model_name = lva.model_name diff --git a/activity/util.go b/activity/util.go index f570ad833..519ef904f 100644 --- a/activity/util.go +++ b/activity/util.go @@ -144,7 +144,7 @@ func scopeWithTablePrefix(tablePrefix string) func(db *gorm.DB) *gorm.DB { } } -func FetchOldWithSlug(db *gorm.DB, ref any, slug string) (any, bool) { +func FetchOldWithSlug(db *gorm.DB, ref any, slug string) (any, error) { if slug == "" { return FetchOld(db, ref) } @@ -163,14 +163,14 @@ func FetchOldWithSlug(db *gorm.DB, ref any, slug string) (any, bool) { db = db.Where("id = ?", slug) } - if db.First(old).Error != nil { - return nil, false + if err := db.First(old).Error; err != nil { + return nil, errors.Wrap(err, "fetch old with slug") } - return old, true + return old, nil } -func FetchOld(db *gorm.DB, ref any) (any, bool) { +func FetchOld(db *gorm.DB, ref any) (any, error) { var ( rtRef = reflect.Indirect(reflect.ValueOf(ref)) old = reflect.New(rtRef.Type()).Interface() @@ -178,13 +178,12 @@ func FetchOld(db *gorm.DB, ref any) (any, bool) { vars []any ) - stmt := &gorm.Statement{DB: db} - if err := stmt.Parse(ref); err != nil { - return nil, false + s, err := ParseSchemaWithDB(db, ref) + if err != nil { + return nil, err } - - for _, dbName := range stmt.Schema.DBNames { - if field := stmt.Schema.LookUpField(dbName); field != nil && field.PrimaryKey { + for _, dbName := range s.DBNames { + if field := s.LookUpField(dbName); field != nil && field.PrimaryKey { if value, isZero := field.ValueOf(db.Statement.Context, rtRef); !isZero { sqls = append(sqls, fmt.Sprintf("%v = ?", dbName)) vars = append(vars, value) @@ -193,12 +192,12 @@ func FetchOld(db *gorm.DB, ref any) (any, bool) { } if len(sqls) == 0 || len(vars) == 0 || len(sqls) != len(vars) { - return nil, false + return nil, errors.New("no primary key found") } - if db.Where(strings.Join(sqls, " AND "), vars...).First(old).Error != nil { - return nil, false + if err := db.Where(strings.Join(sqls, " AND "), vars...).First(old).Error; err != nil { + return nil, errors.Wrap(err, "fetch old") } - return old, true + return old, nil } From e42b97a97bab9fdbaaef8c0f38bf6040641124b4 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Sun, 28 Jul 2024 23:37:13 +0800 Subject: [PATCH 25/33] activity: README --- activity/README.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/activity/README.md b/activity/README.md index e1dc7c7b2..f3a9afe40 100644 --- a/activity/README.md +++ b/activity/README.md @@ -5,7 +5,9 @@ - Firstly, you should create an activity instance in your project. ```go - activity := activity.New(db, currentUserFunc) + ab := activity.New(db, currentUserFunc). + TablePrefix("cms_"). // if needed + AutoMigrate() // if needed ``` - db (Required): The database where activity_logs is stored. @@ -14,28 +16,28 @@ - Register activity into presets ```go - activityBuilder.Install(presetsBuilder) + ab.Install(presetsBuilder) ``` - Register normal model or a `presets.ModelBuilder` into activity ```go - activity.RegisterModel(normalModel) // It need you to record the activity log manually - activity.RegisterModel(presetModel) // It will record the activity log automatically when you create, update or delete the model data via preset admin + ab.RegisterModel(normalModel) // It need you to record the activity log manually + ab.RegisterModel(presetModel) // It will record the activity log automatically when you create, update or delete the model data via preset admin ``` - Skip recording activity log for preset model if you don't want to record the activity log automatically ```go - activity.RegisterModel(presetModel).SkipCreate().SkipUpdate().SkipDelete() + ab.RegisterModel(presetModel).SkipCreate().SkipUpdate().SkipDelete() ``` - Configure more options for the `presets.ModelBuilder` to record more custom information ```go - activity.RegisterModel(presetModel).AddKeys("ID", "Version") // will record value of the ID and Version field as the keyword of a model table - activity.RegisterModel(presetModel).AddIgnoredFields("UpdateAt") // will ignore the UpdateAt field when recording activity log for update operation - activity.RegisterModel(presetModel).AddTypeHanders( + ab.RegisterModel(presetModel).AddKeys("ID", "Version") // will record value of the ID and Version field as the keyword of a model table + ab.RegisterModel(presetModel).AddIgnoredFields("UpdateAt") // will ignore the UpdateAt field when recording activity log for update operation + ab.RegisterModel(presetModel).AddTypeHanders( time.Time{}, func(old, new any, prefixField string) []Diff { oldString := old.(time.Time).Format(time.RFC3339) @@ -55,15 +57,15 @@ - When a struct type only have one `activity.ModelBuilder`, you can use `activity` to record the log directly. ```go - activity.OnEdit(ctx, old, new) - activity.OnCreate(ctx, obj) + ab.OnEdit(ctx, old, new) + ab.OnCreate(ctx, obj) ``` - When a struct type have multiple `activity.ModelBuilder`, you need to get the corresponding `activity.ModelBuilder` and then use it to record the log. ```go - activity.MustGetModelBuilder(presetModel1).OnEdit(ctx, old, new) - activity.MustGetModelBuilder(presetModel2).OnCreate(ctx, obj) + ab.MustGetModelBuilder(presetModel1).OnEdit(ctx, old, new) + ab.MustGetModelBuilder(presetModel2).OnCreate(ctx, obj) ``` - Add ListFieldNotes to Listing Builder From 434e1fa3a1018cc010fcbd987416761a0dfeb4e9 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 29 Jul 2024 00:32:27 +0800 Subject: [PATCH 26/33] profile: package => login --- activity/admin.go | 1 - example/admin/auth.go | 1 - example/admin/config.go | 21 +++--- login/messages.go | 34 +++++++++ profile/compo.go => login/profile.go | 103 ++++++++++++++++++++++++-- login/session.go | 64 ++++++++-------- login/util.go | 23 ++++++ profile/builder.go | 105 --------------------------- profile/messages.go | 47 ------------ profile/util.go | 20 ----- 10 files changed, 195 insertions(+), 224 deletions(-) rename profile/compo.go => login/profile.go (74%) delete mode 100644 profile/builder.go delete mode 100644 profile/messages.go delete mode 100644 profile/util.go diff --git a/activity/admin.go b/activity/admin.go index 4b4c0e72d..de3d9b5af 100644 --- a/activity/admin.go +++ b/activity/admin.go @@ -27,7 +27,6 @@ const ( paramHideDetailTop = "hideDetailTop" ) -// TODO: 这个 language 在 timeline 也用到,所以就造成 Install 是必须的,这不太对 func (ab *Builder) Install(b *presets.Builder) error { if actual, loaded := ab.installedPresets.LoadOrStore(b, true); loaded && actual.(bool) { return errors.Errorf("activity: preset %q already installed", b.GetURIPrefix()) diff --git a/example/admin/auth.go b/example/admin/auth.go index c5feff374..6c5aaa95c 100644 --- a/example/admin/auth.go +++ b/example/admin/auth.go @@ -141,7 +141,6 @@ func initLoginSessionBuilder(db *gorm.DB, pb *presets.Builder, ab *activity.Buil return plogin.NewSessionBuilder(loginBuilder, db). Activity(ab.RegisterModel(&models.User{})). - Presets(pb). IsPublicUser(func(u interface{}) bool { user, ok := u.(*models.User) if !ok { diff --git a/example/admin/config.go b/example/admin/config.go index 6bea7cbf0..2c2f57fb3 100644 --- a/example/admin/config.go +++ b/example/admin/config.go @@ -31,7 +31,6 @@ import ( "github.com/qor5/admin/v3/pagebuilder/example" "github.com/qor5/admin/v3/presets" "github.com/qor5/admin/v3/presets/gorm2op" - "github.com/qor5/admin/v3/profile" "github.com/qor5/admin/v3/publish" "github.com/qor5/admin/v3/richeditor" "github.com/qor5/admin/v3/role" @@ -339,9 +338,10 @@ func NewConfig(db *gorm.DB) Config { l10nVM.Use(l10nBuilder) loginSessionBuilder := initLoginSessionBuilder(db, b, ab) - defer func() { loginSessionBuilder.Setup() }() - configBrand(b, db, ab, loginSessionBuilder) + configBrand(b) + + profielBuilder := configProfile(db, ab, loginSessionBuilder) configInputDemo(b, db) @@ -357,6 +357,8 @@ func NewConfig(db *gorm.DB) Config { publisher, l10nBuilder, roleBuilder, + loginSessionBuilder, + profielBuilder, ) if resetAndImportInitialData { @@ -483,10 +485,10 @@ func configMenuOrder(b *presets.Builder) { ) } -func configBrand(b *presets.Builder, db *gorm.DB, ab *activity.Builder, lsb *plogin.SessionBuilder) { - profileB := profile.New( +func configProfile(db *gorm.DB, ab *activity.Builder, lsb *plogin.SessionBuilder) *plogin.ProfileBuilder { + return plogin.NewProfileBuilder( lsb, - func(ctx context.Context) (*profile.User, error) { + func(ctx context.Context) (*plogin.Profile, error) { evCtx := web.MustGetEventContext(ctx) u := getCurrentUser(evCtx.R) if u == nil { @@ -496,13 +498,13 @@ func configBrand(b *presets.Builder, db *gorm.DB, ab *activity.Builder, lsb *plo if err != nil { return nil, err } - user := &profile.User{ + user := &plogin.Profile{ ID: fmt.Sprint(u.ID), Name: u.Name, // Avatar: "", Roles: u.GetRoles(), Status: strcase.ToCamel(u.Status), - Fields: []*profile.UserField{ + Fields: []*plogin.ProfileField{ {Name: "Email", Value: u.Account}, {Name: "Company", Value: u.Company}, }, @@ -526,8 +528,9 @@ func configBrand(b *presets.Builder, db *gorm.DB, ab *activity.Builder, lsb *plo return nil }, ) - profileB.Install(b) +} +func configBrand(b *presets.Builder) { b.BrandFunc(func(ctx *web.EventContext) h.HTMLComponent { msgr := i18n.MustGetModuleMessages(ctx.R, I18nExampleKey, Messages_en_US).(*Messages) logo := "https://qor5.com/img/qor-logo.png" diff --git a/login/messages.go b/login/messages.go index 304565ba0..8e7ccf83a 100644 --- a/login/messages.go +++ b/login/messages.go @@ -1,5 +1,10 @@ package login +import ( + "fmt" + "strings" +) + type Messages struct { SessionTableHeaderTime string SessionTableHeaderDevice string @@ -12,6 +17,17 @@ type Messages struct { HideIPAddressTips string ExpireOtherSessions string SuccessfullyExpiredOtherSessions string + UnreadMessagesTemplate string + ViewLoginSessions string + Logout string + Available string + Unavailable string + SuccessfullyRename string +} + +func (m *Messages) UnreadMessages(n int) string { + return strings.NewReplacer("{n}", fmt.Sprint(n)). + Replace(m.UnreadMessagesTemplate) } var Messages_en_US = &Messages{ @@ -26,6 +42,12 @@ var Messages_en_US = &Messages{ HideIPAddressTips: "Invisible due to security concerns", ExpireOtherSessions: "Sign out all other sessions", SuccessfullyExpiredOtherSessions: "All other sessions have successfully been signed out.", + UnreadMessagesTemplate: "{n} unread notes", + ViewLoginSessions: "View login sessions", + Logout: "Logout", + Available: "Available", + Unavailable: "Unavailable", + SuccessfullyRename: "Successfully renamed", } var Messages_zh_CN = &Messages{ @@ -40,6 +62,12 @@ var Messages_zh_CN = &Messages{ HideIPAddressTips: "由于安全原因,隐藏", ExpireOtherSessions: "登出所有其他会话", SuccessfullyExpiredOtherSessions: "所有其他会话已成功登出。", + UnreadMessagesTemplate: "未读 {n} 条", + ViewLoginSessions: "查看登录会话", + Logout: "登出", + Available: "可用", + Unavailable: "不可用", + SuccessfullyRename: "成功重命名", } var Messages_ja_JP = &Messages{ @@ -54,4 +82,10 @@ var Messages_ja_JP = &Messages{ HideIPAddressTips: "セキュリティ上の理由から非表示", ExpireOtherSessions: "他のすべてのセッションをサインアウトする", SuccessfullyExpiredOtherSessions: "他のすべてのセッションは正常にサインアウトされました。", + UnreadMessagesTemplate: "{n} 未読", + ViewLoginSessions: "ログインセッションを表示", + Logout: "ログアウト", + Available: "利用可能", + Unavailable: "利用不可", + SuccessfullyRename: "名前が変更されました", } diff --git a/profile/compo.go b/login/profile.go similarity index 74% rename from profile/compo.go rename to login/profile.go index ddc68b161..b84717f37 100644 --- a/profile/compo.go +++ b/login/profile.go @@ -1,12 +1,14 @@ -package profile +package login import ( "context" "fmt" "sort" "strings" + "sync" "github.com/jinzhu/inflection" + "github.com/pkg/errors" "github.com/qor5/admin/v3/activity" "github.com/qor5/admin/v3/presets" "github.com/qor5/web/v3" @@ -16,14 +18,101 @@ import ( "github.com/samber/lo" h "github.com/theplant/htmlgo" "golang.org/x/exp/maps" + "golang.org/x/text/language" ) +func (b *ProfileBuilder) Install(pb *presets.Builder) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.pb != nil { + return errors.Errorf("profile: already installed") + } + b.pb = pb + pb.GetI18n(). + RegisterForModule(language.English, I18nAdminLoginKey, Messages_en_US). + RegisterForModule(language.SimplifiedChinese, I18nAdminLoginKey, Messages_zh_CN). + RegisterForModule(language.Japanese, I18nAdminLoginKey, Messages_ja_JP) + pb.ProfileFunc(func(evCtx *web.EventContext) h.HTMLComponent { + return b.NewCompo(evCtx, "") + }) + + dc := pb.GetDependencyCenter() + injectorName := b.injectorName() + dc.RegisterInjector(injectorName) + dc.MustProvide(injectorName, func() *ProfileBuilder { + return b + }) + return nil +} + +type ProfileField struct { + Name string + Value string +} + +type Profile struct { + ID string + Name string + Avatar string + Roles []string + Status string + Fields []*ProfileField + NotifCounts []*activity.NoteCount +} + +func (u *Profile) GetFirstRole() string { + role := "-" + if len(u.Roles) > 0 { + role = u.Roles[0] + } + return role +} + +type ProfileBuilder struct { + mu sync.RWMutex + pb *presets.Builder + + lsb *SessionBuilder + currentProfileFunc func(ctx context.Context) (*Profile, error) + renameCallback func(ctx context.Context, newName string) error +} + +func NewProfileBuilder( + lsb *SessionBuilder, + currentProfileFunc func(ctx context.Context) (*Profile, error), + renameCallback func(ctx context.Context, newName string) error, +) *ProfileBuilder { + return &ProfileBuilder{ + lsb: lsb, + currentProfileFunc: currentProfileFunc, + renameCallback: renameCallback, + } +} + +func (b *ProfileBuilder) injectorName() string { + return "__profile__" +} + +func (b *ProfileBuilder) NewCompo(evCtx *web.EventContext, idSuffix string) h.HTMLComponent { + b.mu.RLock() + pb := b.pb + b.mu.RUnlock() + if pb == nil { + panic("profile: not installed") + } + return h.Div().Class("d-flex flex-column align-stretch w-100").Children( + b.pb.GetDependencyCenter().MustInject(b.injectorName(), &ProfileCompo{ + ID: b.pb.GetURIPrefix() + idSuffix, + }), + ) +} + func init() { stateful.RegisterActionableCompoType(&ProfileCompo{}) } type ProfileCompo struct { - b *Builder `inject:""` + b *ProfileBuilder `inject:""` ID string `json:"id"` } @@ -34,11 +123,11 @@ func (c *ProfileCompo) CompoID() string { func (c *ProfileCompo) MustGetEventContext(ctx context.Context) (*web.EventContext, *Messages) { evCtx := web.MustGetEventContext(ctx) - return evCtx, i18n.MustGetModuleMessages(evCtx.R, I18nProfileKey, Messages_en_US).(*Messages) + return evCtx, i18n.MustGetModuleMessages(evCtx.R, I18nAdminLoginKey, Messages_en_US).(*Messages) } func (c *ProfileCompo) MarshalHTML(ctx context.Context) ([]byte, error) { - user, err := c.b.currentUserFunc(ctx) + user, err := c.b.currentProfileFunc(ctx) if err != nil { return nil, err } @@ -46,7 +135,7 @@ func (c *ProfileCompo) MarshalHTML(ctx context.Context) ([]byte, error) { prepend := v.VCard().Flat(true).Children( web.Slot().Name(v.VSlotPrepend).Children( v.VAvatar().Class("text-body-1 font-weight-medium text-primary bg-primary-lighten-2").Size(v.SizeLarge).Density(v.DensityCompact).Rounded(true). - Text(ShortName(user.Name)).Children( + Text(shortName(user.Name)).Children( h.Iff(user.Avatar != "", func() h.HTMLComponent { return v.VImg().Attr("alt", user.Name).Attr("src", user.Avatar) }), @@ -115,7 +204,7 @@ func (c *ProfileCompo) bellCompo(ctx context.Context, notifCounts []*activity.No ) } -func (c *ProfileCompo) userCompo(ctx context.Context, user *User) h.HTMLComponent { +func (c *ProfileCompo) userCompo(ctx context.Context, user *Profile) h.HTMLComponent { _, msgr := c.MustGetEventContext(ctx) children := []h.HTMLComponent{} @@ -144,7 +233,7 @@ func (c *ProfileCompo) userCompo(ctx context.Context, user *User) h.HTMLComponen v.VCardText().Class("pa-0").Children( h.Div().Class("d-flex align-center ga-6 pa-6 bg-grey-lighten-4").Children( v.VAvatar().Class("text-h3 font-weight-medium text-primary bg-primary-lighten-2 rounded-lg").Size(80).Density(v.DensityCompact). - Text(ShortName(user.Name)).Children( + Text(shortName(user.Name)).Children( h.Iff(user.Avatar != "", func() h.HTMLComponent { return v.VImg().Attr("alt", user.Name).Attr("src", user.Avatar) }), diff --git a/login/session.go b/login/session.go index b6a75092d..aba733537 100644 --- a/login/session.go +++ b/login/session.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "sync" - "sync/atomic" "time" "github.com/dustin/go-humanize" @@ -23,14 +22,35 @@ import ( "gorm.io/gorm" ) -const ( - I18nLoginSessionKey i18n.ModuleKey = "I18nLoginSessionKey" -) - const ( LoginTokenHashLen = 8 // The hash string length of the token stored in the DB. ) +func (b *SessionBuilder) Install(pb *presets.Builder) error { + if b.pb != nil { + return errors.Errorf("profile: already installed") + } + return b.installPreset(pb) +} + +func (b *SessionBuilder) installPreset(pb *presets.Builder) error { + if pb == nil { + return errors.Errorf("profile: presets.Builder is nil") + } + + b.pb = pb + b.pb.GetI18n(). + RegisterForModule(language.English, I18nAdminLoginKey, Messages_en_US). + RegisterForModule(language.SimplifiedChinese, I18nAdminLoginKey, Messages_zh_CN). + RegisterForModule(language.Japanese, I18nAdminLoginKey, Messages_ja_JP) + + type LoginSessionsDialog struct{} + mb := b.pb.Model(&LoginSessionsDialog{}).URIName(uriNameLoginSessionsDialog).InMenu(false) + mb.RegisterEventFunc(eventLoginSessionsDialog, b.handleEventLoginSessionsDialog) + mb.RegisterEventFunc(eventExpireOtherSessions, b.handleEventExpireOtherSessions) + return nil +} + type LoginSession struct { gorm.Model @@ -47,12 +67,11 @@ type SessionBuilder struct { db *gorm.DB amb *activity.ModelBuilder pb *presets.Builder - setup atomic.Bool isPublicUser func(user any) bool } func NewSessionBuilder(lb *login.Builder, db *gorm.DB) *SessionBuilder { - return &SessionBuilder{lb: lb, db: db} + return (&SessionBuilder{lb: lb, db: db}).setup() } func (b *SessionBuilder) GetLoginBuilder() *login.Builder { @@ -64,11 +83,6 @@ func (b *SessionBuilder) Activity(amb *activity.ModelBuilder) *SessionBuilder { return b } -func (b *SessionBuilder) Presets(pb *presets.Builder) *SessionBuilder { - b.pb = pb - return b -} - func (b *SessionBuilder) IsPublicUser(f func(user any) bool) *SessionBuilder { b.isPublicUser = f return b @@ -217,11 +231,8 @@ func (b *SessionBuilder) AutoMigrate() (r *SessionBuilder) { return b } -func (b *SessionBuilder) Setup() (r *SessionBuilder) { +func (b *SessionBuilder) setup() (r *SessionBuilder) { b.once.Do(func() { - defer func() { - b.setup.Store(true) - }() logAction := func(r *http.Request, user any, action string) error { if b.amb != nil && user != nil { _, err := b.amb.Log(r.Context(), action, user, nil) @@ -272,18 +283,6 @@ func (b *SessionBuilder) Setup() (r *SessionBuilder) { AfterTOTPCodeReused(func(r *http.Request, user interface{}, _ ...interface{}) error { return logAction(r, user, "totp-code-reused") }) - - if b.pb != nil { - b.pb.GetI18n(). - RegisterForModule(language.English, I18nLoginSessionKey, Messages_en_US). - RegisterForModule(language.SimplifiedChinese, I18nLoginSessionKey, Messages_zh_CN). - RegisterForModule(language.Japanese, I18nLoginSessionKey, Messages_ja_JP) - - type LoginSessionsDialog struct{} - mb := b.pb.Model(&LoginSessionsDialog{}).URIName(uriNameLoginSessionsDialog).InMenu(false) - mb.RegisterEventFunc(eventLoginSessionsDialog, b.handleEventLoginSessionsDialog) - mb.RegisterEventFunc(eventExpireOtherSessions, b.handleEventExpireOtherSessions) - } }) return b } @@ -295,9 +294,6 @@ const ( ) func (b *SessionBuilder) OpenSessionsDialog() string { - if b.setup.Load() == false { - panic("login: SessionBuilder is not setup") - } if b.pb == nil { panic("presets.Builder is nil") } @@ -312,7 +308,7 @@ type dataTableHeader struct { } func (b *SessionBuilder) handleEventLoginSessionsDialog(ctx *web.EventContext) (r web.EventResponse, err error) { - msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginSessionKey, Messages_en_US).(*Messages) + msgr := i18n.MustGetModuleMessages(ctx.R, I18nAdminLoginKey, Messages_en_US).(*Messages) // presetsMsgr := presets.MustGetMessages(ctx.R) user := login.GetCurrentUser(ctx.R) @@ -331,7 +327,7 @@ func (b *SessionBuilder) handleEventLoginSessionsDialog(ctx *web.EventContext) ( raw := ` WITH ranked_sessions AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY "device", "ip" ORDER BY "expired_at" DESC) AS rn - FROM "public"."login_sessions" + FROM login_sessions WHERE "user_id" = ? AND "deleted_at" IS NULL ) SELECT * @@ -424,7 +420,7 @@ func (b *SessionBuilder) handleEventLoginSessionsDialog(ctx *web.EventContext) ( } func (b *SessionBuilder) handleEventExpireOtherSessions(ctx *web.EventContext) (r web.EventResponse, err error) { - msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginSessionKey, Messages_en_US).(*Messages) + msgr := i18n.MustGetModuleMessages(ctx.R, I18nAdminLoginKey, Messages_en_US).(*Messages) user := login.GetCurrentUser(ctx.R) if user == nil { diff --git a/login/util.go b/login/util.go index b36216357..9ee98f1dd 100644 --- a/login/util.go +++ b/login/util.go @@ -6,6 +6,12 @@ import ( "net" "net/http" "strings" + + "github.com/qor5/x/v3/i18n" +) + +const ( + I18nAdminLoginKey i18n.ModuleKey = "I18nAdminLoginKey" ) func getStringHash(v string, len int) string { @@ -40,3 +46,20 @@ func proxy(r *http.Request) []string { return nil } + +func shortName(name string) string { + if name == "" { + return "" + } + runes := []rune(name) + result := strings.ToUpper(string(runes[0:1])) + if len(runes) > 2 { + for i := 2; i < len(runes); i++ { + if runes[i-1] == ' ' && runes[i] != ' ' { + result += strings.ToUpper(string(runes[i : i+1])) + break + } + } + } + return result +} diff --git a/profile/builder.go b/profile/builder.go deleted file mode 100644 index 9c626374d..000000000 --- a/profile/builder.go +++ /dev/null @@ -1,105 +0,0 @@ -package profile - -import ( - "context" - "sync" - - "github.com/pkg/errors" - "github.com/qor5/admin/v3/activity" - plogin "github.com/qor5/admin/v3/login" - "github.com/qor5/admin/v3/presets" - "github.com/qor5/web/v3" - "github.com/qor5/x/v3/i18n" - h "github.com/theplant/htmlgo" - "golang.org/x/text/language" -) - -const ( - I18nProfileKey i18n.ModuleKey = "I18nProfileKey" -) - -func (b *Builder) Install(pb *presets.Builder) error { - b.mu.Lock() - defer b.mu.Unlock() - if b.pb != nil { - return errors.Errorf("profile: already installed") - } - b.pb = pb - pb.GetI18n(). - RegisterForModule(language.English, I18nProfileKey, Messages_en_US). - RegisterForModule(language.SimplifiedChinese, I18nProfileKey, Messages_zh_CN). - RegisterForModule(language.Japanese, I18nProfileKey, Messages_ja_JP) - pb.ProfileFunc(func(evCtx *web.EventContext) h.HTMLComponent { - return b.NewCompo(evCtx, "") - }) - - dc := pb.GetDependencyCenter() - injectorName := b.injectorName() - dc.RegisterInjector(injectorName) - dc.MustProvide(injectorName, func() *Builder { - return b - }) - return nil -} - -type UserField struct { - Name string - Value string -} - -type User struct { - ID string - Name string - Avatar string - Roles []string - Status string - Fields []*UserField - NotifCounts []*activity.NoteCount -} - -func (u *User) GetFirstRole() string { - role := "-" - if len(u.Roles) > 0 { - role = u.Roles[0] - } - return role -} - -type Builder struct { - mu sync.RWMutex - pb *presets.Builder - - lsb *plogin.SessionBuilder - currentUserFunc func(ctx context.Context) (*User, error) - renameCallback func(ctx context.Context, newName string) error -} - -func New( - lsb *plogin.SessionBuilder, - currentUserFunc func(ctx context.Context) (*User, error), - renameCallback func(ctx context.Context, newName string) error, -) *Builder { - return &Builder{ - lsb: lsb, - currentUserFunc: currentUserFunc, - renameCallback: renameCallback, - } -} - -func (b *Builder) injectorName() string { - return "__profile__" -} - -func (b *Builder) NewCompo(evCtx *web.EventContext, idSuffix string) h.HTMLComponent { - b.mu.RLock() - pb := b.pb - b.mu.RUnlock() - if pb == nil { - panic("profile: not installed") - } - return h.Div().Class("d-flex flex-column align-stretch w-100").Children( - b.pb.GetDependencyCenter().MustInject(b.injectorName(), &ProfileCompo{ - ID: b.pb.GetURIPrefix() + idSuffix, - }), - ) -} diff --git a/profile/messages.go b/profile/messages.go deleted file mode 100644 index 3fca7955f..000000000 --- a/profile/messages.go +++ /dev/null @@ -1,47 +0,0 @@ -package profile - -import ( - "fmt" - "strings" -) - -type Messages struct { - UnreadMessagesTemplate string - ViewLoginSessions string - Logout string - Available string - Unavailable string - SuccessfullyRename string -} - -func (m *Messages) UnreadMessages(n int) string { - return strings.NewReplacer("{n}", fmt.Sprint(n)). - Replace(m.UnreadMessagesTemplate) -} - -var Messages_en_US = &Messages{ - UnreadMessagesTemplate: "{n} unread notes", - ViewLoginSessions: "View login sessions", - Logout: "Logout", - Available: "Available", - Unavailable: "Unavailable", - SuccessfullyRename: "Successfully renamed", -} - -var Messages_zh_CN = &Messages{ - UnreadMessagesTemplate: "未读 {n} 条", - ViewLoginSessions: "查看登录会话", - Logout: "登出", - Available: "可用", - Unavailable: "不可用", - SuccessfullyRename: "成功重命名", -} - -var Messages_ja_JP = &Messages{ - UnreadMessagesTemplate: "{n} 未読", - ViewLoginSessions: "ログインセッションを表示", - Logout: "ログアウト", - Available: "利用可能", - Unavailable: "利用不可", - SuccessfullyRename: "名前が変更されました", -} diff --git a/profile/util.go b/profile/util.go deleted file mode 100644 index ae4d53e77..000000000 --- a/profile/util.go +++ /dev/null @@ -1,20 +0,0 @@ -package profile - -import "strings" - -func ShortName(name string) string { - if name == "" { - return "" - } - runes := []rune(name) - result := strings.ToUpper(string(runes[0:1])) - if len(runes) > 2 { - for i := 2; i < len(runes); i++ { - if runes[i-1] == ' ' && runes[i] != ' ' { - result += strings.ToUpper(string(runes[i : i+1])) - break - } - } - } - return result -} From f0d69f873e9085e0492a77362198d14b7392a3db Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 29 Jul 2024 12:38:53 +0800 Subject: [PATCH 27/33] login: use presets.MustObjectID --- login/session.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/login/session.go b/login/session.go index aba733537..7238b0246 100644 --- a/login/session.go +++ b/login/session.go @@ -206,7 +206,7 @@ func (b *SessionBuilder) validateSessionToken() func(next http.Handler) http.Han return } - valid, err := b.IsSessionValid(r, presets.ObjectID(user)) + valid, err := b.IsSessionValid(r, presets.MustObjectID(user)) if err != nil || !valid { if r.URL.Path == b.lb.LogoutURL { next.ServeHTTP(w, r) @@ -243,7 +243,7 @@ func (b *SessionBuilder) setup() (r *SessionBuilder) { b.lb.AfterLogin(func(r *http.Request, user any, extraVals ...any) error { return cmp.Or( logAction(r, user, "login"), - b.CreateSession(r, presets.ObjectID(user)), + b.CreateSession(r, presets.MustObjectID(user)), ) }). AfterFailedToLogin(func(r *http.Request, user interface{}, _ ...interface{}) error { @@ -255,7 +255,7 @@ func (b *SessionBuilder) setup() (r *SessionBuilder) { AfterLogout(func(r *http.Request, user interface{}, _ ...interface{}) error { return cmp.Or( logAction(r, user, "logout"), - b.ExpireCurrentSession(r, presets.ObjectID(user)), + b.ExpireCurrentSession(r, presets.MustObjectID(user)), ) }). AfterConfirmSendResetPasswordLink(func(r *http.Request, user interface{}, extraVals ...interface{}) error { @@ -263,20 +263,20 @@ func (b *SessionBuilder) setup() (r *SessionBuilder) { }). AfterResetPassword(func(r *http.Request, user interface{}, _ ...interface{}) error { return cmp.Or( - b.ExpireAllSessions(presets.ObjectID(user)), + b.ExpireAllSessions(presets.MustObjectID(user)), logAction(r, user, "reset-password"), ) }). AfterChangePassword(func(r *http.Request, user interface{}, _ ...interface{}) error { return cmp.Or( - b.ExpireAllSessions(presets.ObjectID(user)), + b.ExpireAllSessions(presets.MustObjectID(user)), logAction(r, user, "change-password"), ) }). AfterExtendSession(func(r *http.Request, user interface{}, extraVals ...interface{}) error { oldToken := extraVals[0].(string) return cmp.Or( - b.ExtendSession(r, presets.ObjectID(user), oldToken), + b.ExtendSession(r, presets.MustObjectID(user), oldToken), logAction(r, user, "extend-session"), ) }). @@ -315,7 +315,7 @@ func (b *SessionBuilder) handleEventLoginSessionsDialog(ctx *web.EventContext) ( if user == nil { return r, errors.New("login: user not found") } - uid := presets.ObjectID(user) + uid := presets.MustObjectID(user) currentTokenHash := getStringHash(login.GetSessionToken(b.lb, ctx.R), LoginTokenHashLen) var sessions []*LoginSession @@ -433,7 +433,7 @@ func (b *SessionBuilder) handleEventExpireOtherSessions(ctx *web.EventContext) ( if isPublicUser { return r, perm.PermissionDenied } - uid := presets.ObjectID(user) + uid := presets.MustObjectID(user) if err = b.ExpireOtherSessions(ctx.R, uid); err != nil { return r, err From 0db688dc223cf365eac2e9d75a4b81450b00c458 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Thu, 1 Aug 2024 15:39:24 +0800 Subject: [PATCH 28/33] profile: fix ui --- example/main.go | 3 ++- login/profile.go | 43 +++++++++++++++++-------------------------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/example/main.go b/example/main.go index 342af9362..efc06c81c 100644 --- a/example/main.go +++ b/example/main.go @@ -12,6 +12,7 @@ import ( func main() { h := admin.Router(admin.ConnectDB()) + host := osenv.Get("HOST", "The host to serve the admin on", "") port := osenv.Get("PORT", "The port to serve the admin on", "9000") fmt.Println("Served at http://localhost:" + port) @@ -24,7 +25,7 @@ func main() { ), ), ) - err := http.ListenAndServe(":"+port, mux) + err := http.ListenAndServe(host+":"+port, mux) if err != nil { panic(err) } diff --git a/login/profile.go b/login/profile.go index b84717f37..ba6c0a121 100644 --- a/login/profile.go +++ b/login/profile.go @@ -100,7 +100,7 @@ func (b *ProfileBuilder) NewCompo(evCtx *web.EventContext, idSuffix string) h.HT if pb == nil { panic("profile: not installed") } - return h.Div().Class("d-flex flex-column align-stretch w-100").Children( + return h.Div().Class("w-100").Children( b.pb.GetDependencyCenter().MustInject(b.injectorName(), &ProfileCompo{ ID: b.pb.GetURIPrefix() + idSuffix, }), @@ -132,30 +132,22 @@ func (c *ProfileCompo) MarshalHTML(ctx context.Context) ([]byte, error) { return nil, err } - prepend := v.VCard().Flat(true).Children( - web.Slot().Name(v.VSlotPrepend).Children( - v.VAvatar().Class("text-body-1 font-weight-medium text-primary bg-primary-lighten-2").Size(v.SizeLarge).Density(v.DensityCompact).Rounded(true). - Text(shortName(user.Name)).Children( - h.Iff(user.Avatar != "", func() h.HTMLComponent { - return v.VImg().Attr("alt", user.Name).Attr("src", user.Avatar) - }), - ), + return stateful.Actionable(ctx, c, h.Div().Class("d-flex align-center ga-2 pa-3").Children( + v.VAvatar().Class("text-body-1 font-weight-medium text-primary bg-primary-lighten-2").Size(v.SizeLarge).Density(v.DensityCompact).Rounded(true). + Text(shortName(user.Name)).Children( + h.Iff(user.Avatar != "", func() h.HTMLComponent { + return v.VImg().Attr("alt", user.Name).Attr("src", user.Avatar) + }), ), - web.Slot().Name(v.VSlotTitle).Children( + h.Div().Class("d-flex flex-column flex-1-1").Style("max-width: 119px").Children( h.Div().Class("d-flex align-center ga-2 pt-1").Children( - h.Div().Attr("v-pre", true).Text(user.Name).Class("text-subtitle-2 text-secondary"), + h.Div().Attr("v-pre", true).Text(user.Name).Class("text-subtitle-2 text-secondary text-truncate"), c.userCompo(ctx, user), ), + h.Div().Class("text-overline text-grey-darken-1").Text(strings.ToUpper(user.GetFirstRole())), ), - web.Slot().Name(v.VSlotSubtitle).Children( - h.Div().Class("text-overline").Text(strings.ToUpper(user.GetFirstRole())), - ), - ) - return stateful.Actionable(ctx, c, h.Div().Class("d-flex align-center ga-0").Children( - prepend, - v.VSpacer(), h.Iff(len(user.NotifCounts) > 0, func() h.HTMLComponent { - return h.Div().Class("d-flex align-center px-4 border-s-sm h-50").Children( + return h.Div().Class("d-flex align-center px-4 me-n3 border-s-sm h-50").Children( c.bellCompo(ctx, user.NotifCounts), ) }), @@ -163,7 +155,7 @@ func (c *ProfileCompo) MarshalHTML(ctx context.Context) ([]byte, error) { } func (c *ProfileCompo) bellCompo(ctx context.Context, notifCounts []*activity.NoteCount) h.HTMLComponent { - _, msgr := c.MustGetEventContext(ctx) + evCtx, msgr := c.MustGetEventContext(ctx) unreadBy := func(item *activity.NoteCount) int { return int(item.UnreadNotesCount) } unreadCount := lo.SumBy(notifCounts, unreadBy) @@ -176,12 +168,11 @@ func (c *ProfileCompo) bellCompo(ctx context.Context, notifCounts []*activity.No sort.Strings(modelNames) for _, modelName := range modelNames { counts := groups[modelName] - title := inflection.Plural(modelName) - // TODO: i18n - // TODO: href? + title := i18n.T(evCtx.R, presets.ModelsI18nModuleKey, modelName) + // TODO: href? model label? href := fmt.Sprintf( "/%s?active_filter_tab=hasUnreadNotes&f_hasUnreadNotes=1", - strings.ToLower(title), + strings.ToLower(inflection.Plural(modelName)), ) listItems = append(listItems, v.VListItem().Href(href).Children( v.VListItemTitle(h.Text(title)), @@ -238,11 +229,11 @@ func (c *ProfileCompo) userCompo(ctx context.Context, user *Profile) h.HTMLCompo return v.VImg().Attr("alt", user.Name).Attr("src", user.Avatar) }), ), - h.Div().Class("flex-grow-1 d-flex flex-column ga-4").Children( + h.Div().Class("flex-1-1 d-flex flex-column ga-4").Style("max-width:148px").Children( h.Div().Class("d-flex flex-column").Children( web.Scope().VSlot(`{ locals: xlocals }`).Init(fmt.Sprintf(`{editShow:false, name: %q}`, user.Name)).Children( h.Div().Attr("v-if", "!xlocals.editShow").Class("d-flex align-center ga-2").Children( - h.Div().Attr("v-pre", true).Text(user.Name).Class("text-subtitle-1 font-weight-medium"), + h.Div().Attr("v-pre", true).Text(user.Name).Class("text-subtitle-1 font-weight-medium text-truncate"), v.VBtn("").Size(20).Variant(v.VariantText).Color(v.ColorGreyDarken1). Attr("@click", "xlocals.editShow = true").Children( v.VIcon("mdi-pencil-outline"), From e50933c5e70df9b2942520c559f56f1d1895b49c Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Thu, 1 Aug 2024 16:30:29 +0800 Subject: [PATCH 29/33] login session: support multitenant --- activity/builder.go | 4 +-- activity/util.go | 4 +-- example/admin/auth.go | 1 + login/session.go | 82 ++++++++++++++++++++++++++++++++++--------- 4 files changed, 71 insertions(+), 20 deletions(-) diff --git a/activity/builder.go b/activity/builder.go index 73b2bf3f8..8bb0a2cab 100644 --- a/activity/builder.go +++ b/activity/builder.go @@ -78,7 +78,7 @@ func (ab *Builder) TablePrefix(prefix string) *Builder { if prefix == "" { ab.db = ab.dbPrimitive } else { - ab.db = ab.dbPrimitive.Scopes(scopeWithTablePrefix(prefix)).Session(&gorm.Session{}) + ab.db = ab.dbPrimitive.Scopes(ScopeWithTablePrefix(prefix)).Session(&gorm.Session{}) } return ab } @@ -161,7 +161,7 @@ func (b *Builder) AutoMigrate() (r *Builder) { func AutoMigrate(db *gorm.DB, tablePrefix string) error { if tablePrefix != "" { - db = db.Scopes(scopeWithTablePrefix(tablePrefix)).Session(&gorm.Session{}) + db = db.Scopes(ScopeWithTablePrefix(tablePrefix)).Session(&gorm.Session{}) } dst := []any{&ActivityLog{}, &ActivityUser{}} for _, v := range dst { diff --git a/activity/util.go b/activity/util.go index b35ed279f..5aaa515b6 100644 --- a/activity/util.go +++ b/activity/util.go @@ -114,10 +114,10 @@ func ParsePrimaryKeys(v any) []string { const dbKeyTablePrefix = "__table_prefix__" -// scopeWithTablePrefix set table prefix +// ScopeWithTablePrefix set table prefix // 1. Only scenarios where a Model is provided are supported // 2. Previously Table(...) will be overwritten -func scopeWithTablePrefix(tablePrefix string) func(db *gorm.DB) *gorm.DB { +func ScopeWithTablePrefix(tablePrefix string) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { if v, ok := db.Get(dbKeyTablePrefix); ok { if v.(string) != tablePrefix { diff --git a/example/admin/auth.go b/example/admin/auth.go index 6c5aaa95c..f67bc60a5 100644 --- a/example/admin/auth.go +++ b/example/admin/auth.go @@ -148,6 +148,7 @@ func initLoginSessionBuilder(db *gorm.DB, pb *presets.Builder, ab *activity.Buil } return user.GetAccountName() == loginInitialUserEmail }). + TablePrefix("cms_"). AutoMigrate() } diff --git a/login/session.go b/login/session.go index 7238b0246..e7d3ff2b8 100644 --- a/login/session.go +++ b/login/session.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "sync" + "sync/atomic" "time" "github.com/dustin/go-humanize" @@ -62,16 +63,69 @@ type LoginSession struct { } type SessionBuilder struct { - once sync.Once + once sync.Once + calledAutoMigrate atomic.Bool // auto migrate flag + lb *login.Builder - db *gorm.DB + dbPrimitive *gorm.DB // primitive db + db *gorm.DB // global db with table prefix scope + tablePrefix string amb *activity.ModelBuilder pb *presets.Builder isPublicUser func(user any) bool } func NewSessionBuilder(lb *login.Builder, db *gorm.DB) *SessionBuilder { - return (&SessionBuilder{lb: lb, db: db}).setup() + return (&SessionBuilder{ + lb: lb, + db: db, + dbPrimitive: db, + }).setup() +} + +func (b *SessionBuilder) TablePrefix(prefix string) *SessionBuilder { + if b.calledAutoMigrate.Load() { + panic("please set table prefix before auto migrate") + } + b.tablePrefix = prefix + if prefix == "" { + b.db = b.dbPrimitive + } else { + b.db = b.dbPrimitive.Scopes(activity.ScopeWithTablePrefix(prefix)).Session(&gorm.Session{}) + } + return b +} + +func (b *SessionBuilder) AutoMigrate() (r *SessionBuilder) { + if !b.calledAutoMigrate.CompareAndSwap(false, true) { + panic("already migrated") + } + if err := AutoMigrateSession(b.dbPrimitive, b.tablePrefix); err != nil { + panic(err) + } + return b +} + +func AutoMigrateSession(db *gorm.DB, tablePrefix string) error { + if tablePrefix != "" { + db = db.Scopes(activity.ScopeWithTablePrefix(tablePrefix)).Session(&gorm.Session{}) + } + dst := []any{&LoginSession{}} + for _, v := range dst { + err := db.Model(v).AutoMigrate(v) + if err != nil { + return errors.Wrap(err, "auto migrate") + } + if vv, ok := v.(interface { + AfterMigrate(tx *gorm.DB, tablePrefix string) error + }); ok { + err := vv.AfterMigrate(db, tablePrefix) + if err != nil { + return err + } + } + } + return nil } func (b *SessionBuilder) GetLoginBuilder() *login.Builder { @@ -221,16 +275,6 @@ func (b *SessionBuilder) validateSessionToken() func(next http.Handler) http.Han } } -func (b *SessionBuilder) AutoMigrate() (r *SessionBuilder) { - if b.db == nil { - panic("db is nil") - } - if err := b.db.AutoMigrate(&LoginSession{}); err != nil { - panic(err) - } - return b -} - func (b *SessionBuilder) setup() (r *SessionBuilder) { b.once.Do(func() { logAction := func(r *http.Request, user any, action string) error { @@ -320,21 +364,27 @@ func (b *SessionBuilder) handleEventLoginSessionsDialog(ctx *web.EventContext) ( var sessions []*LoginSession + s, err := activity.ParseSchemaWithDB(b.db, &LoginSession{}) + if err != nil { + return r, err + } + tableName := b.tablePrefix + s.Table + // Only one record with the same `device+ip` is returned unless they are not expired. // Order by `expired_at` in descending order. // If the token is the current one, it should be the first one. // Max 100 records returned. - raw := ` + raw := fmt.Sprintf(` WITH ranked_sessions AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY "device", "ip" ORDER BY "expired_at" DESC) AS rn - FROM login_sessions + FROM %s WHERE "user_id" = ? AND "deleted_at" IS NULL ) SELECT * FROM ranked_sessions WHERE rn = 1 OR "expired_at" >= NOW() ORDER BY CASE WHEN "token_hash" = ? THEN 0 ELSE 1 END, "expired_at" DESC - LIMIT 100;` + LIMIT 100;`, tableName) if err := b.db.Raw(raw, uid, currentTokenHash).Scan(&sessions).Error; err != nil { return r, errors.Wrap(err, "login: failed to find sessions") } From 827f22b8ee8112f1eaa9b1a6a3d47dd1d5eb8198 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Thu, 1 Aug 2024 16:59:38 +0800 Subject: [PATCH 30/33] profile: sync unread notes count --- activity/model_builder.go | 2 +- example/admin/config.go | 3 +-- login/profile.go | 8 ++++++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/activity/model_builder.go b/activity/model_builder.go index 8ac7eeb2f..50eb6e354 100644 --- a/activity/model_builder.go +++ b/activity/model_builder.go @@ -52,7 +52,7 @@ type ModelBuilder struct { type ctxKeyUnreadCounts struct{} func NotifiLastViewedAtUpdated(modelName string) string { - return fmt.Sprintf("activity_NotifModelsCreated_%s", modelName) + return fmt.Sprintf("activity_NotifiLastViewedAtUpdated_%s", modelName) } type PayloadLastViewedAtUpdated struct { diff --git a/example/admin/config.go b/example/admin/config.go index 2c2f57fb3..c6b68ece9 100644 --- a/example/admin/config.go +++ b/example/admin/config.go @@ -95,7 +95,6 @@ func NewConfig(db *gorm.DB) Config { } // @snippet_begin(ActivityExample) - db.Exec(`CREATE SCHEMA IF NOT EXISTS cs_portal;`) ab := activity.New(db, func(ctx context.Context) (*activity.User, error) { u := ctx.Value(login.UserKey).(*models.User) return &activity.User{ @@ -125,7 +124,7 @@ func NewConfig(db *gorm.DB) Config { return } }). - TablePrefix("cs_portal."). + TablePrefix("cms_"). AutoMigrate() // ab.Model(l).SkipDelete().SkipCreate() diff --git a/login/profile.go b/login/profile.go index ba6c0a121..9320ffda3 100644 --- a/login/profile.go +++ b/login/profile.go @@ -190,6 +190,14 @@ func (c *ProfileCompo) bellCompo(ctx context.Context, notifCounts []*activity.No return icon }), ), + h.Components( + lo.Map(modelNames, func(modelName string, _ int) h.HTMLComponent { + return web.Listen( + activity.NotifiLastViewedAtUpdated(modelName), + stateful.ReloadAction(ctx, c, nil).Go(), + ) + })..., + ), ), v.VCard(v.VList(listItems...)), ) From 52306cf8af76a7479009a84a0a7e197ba3e651ca Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Thu, 1 Aug 2024 17:26:09 +0800 Subject: [PATCH 31/33] activity: HasUnreadCount Filter methods --- activity/admin.go | 4 +- activity/messages.go | 8 +++ activity/model_builder.go | 4 +- activity/note.go | 37 +++++++++++- example/admin/config.go | 108 +++++------------------------------ example/admin/user_config.go | 18 +++--- login/profile.go | 24 +++++--- 7 files changed, 86 insertions(+), 117 deletions(-) diff --git a/activity/admin.go b/activity/admin.go index 69bb96471..54e365285 100644 --- a/activity/admin.go +++ b/activity/admin.go @@ -147,7 +147,7 @@ func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelB lb.Field("ModelLabel").Label(Messages_en_US.ModelLabel).ComponentFunc( func(obj any, field *presets.FieldContext, ctx *web.EventContext) h.HTMLComponent { if obj.(*ActivityLog).ModelLabel == "" { - return h.Td(h.Text("-")) + return h.Td(h.Text(NopModelLabel)) } return h.Td(h.Div().Attr("v-pre", true).Text(obj.(*ActivityLog).ModelLabel)) }, @@ -301,7 +301,7 @@ func (ab *Builder) defaultLogModelInstall(b *presets.Builder, mb *presets.ModelB h.Tr(h.Td(h.Text(msgr.ModelName)), h.Td().Attr("v-pre", true).Text( i18n.T(ctx.R, presets.ModelsI18nModuleKey, obj.(*ActivityLog).ModelName), )), - h.Tr(h.Td(h.Text(msgr.ModelLabel)), h.Td().Attr("v-pre", true).Text(cmp.Or(log.ModelLabel, "-"))), + h.Tr(h.Td(h.Text(msgr.ModelLabel)), h.Td().Attr("v-pre", true).Text(cmp.Or(log.ModelLabel, NopModelLabel))), h.Tr(h.Td(h.Text(msgr.ModelKeys)), h.Td().Attr("v-pre", true).Text(log.ModelKeys)), h.Iff(log.ModelLink != "", func() h.HTMLComponent { return h.Tr(h.Td(h.Text(msgr.ModelLink)), h.Td( diff --git a/activity/messages.go b/activity/messages.go index b2a3bf55d..0694d50bd 100644 --- a/activity/messages.go +++ b/activity/messages.go @@ -69,6 +69,8 @@ type Messages struct { ActivityLogs string ActivityLog string + + FilterTabsHasUnreadNotes string } func (msgr *Messages) LastEditedAt(desc string) string { @@ -157,6 +159,8 @@ var Messages_en_US = &Messages{ ActivityLogs: "Activity Logs", ActivityLog: "Activity Log", + + FilterTabsHasUnreadNotes: "Has Unread Notes", } var Messages_zh_CN = &Messages{ @@ -223,6 +227,8 @@ var Messages_zh_CN = &Messages{ ActivityLogs: "操作日志", ActivityLog: "操作日志", + + FilterTabsHasUnreadNotes: "未读备注", } var Messages_ja_JP = &Messages{ @@ -289,4 +295,6 @@ var Messages_ja_JP = &Messages{ ActivityLogs: "アクティビティ履歴", ActivityLog: "アクティビティ履歴", + + FilterTabsHasUnreadNotes: "未読ノート", } diff --git a/activity/model_builder.go b/activity/model_builder.go index 50eb6e354..6e598a967 100644 --- a/activity/model_builder.go +++ b/activity/model_builder.go @@ -25,6 +25,8 @@ const ( FieldTimeline string = "__ActivityTimeline__" ListFieldNotes string = "__ActivityNotes__" ListFieldLabelNotes string = "Notes" + + NopModelLabel = "-" ) const ( @@ -472,7 +474,7 @@ func (mb *ModelBuilder) create( Action: action, ModelName: modelName, ModelKeys: modelKeys, - ModelLabel: "-", + ModelLabel: "", ModelLink: modelLink, } if mb.presetModel != nil { diff --git a/activity/note.go b/activity/note.go index 49f922195..95f3ae21a 100644 --- a/activity/note.go +++ b/activity/note.go @@ -3,10 +3,15 @@ package activity import ( "context" "fmt" + "net/url" "strings" "time" "github.com/pkg/errors" + "github.com/qor5/admin/v3/presets" + "github.com/qor5/web/v3" + "github.com/qor5/x/v3/i18n" + vx "github.com/qor5/x/v3/ui/vuetifyx" "github.com/samber/lo" "gorm.io/gorm" ) @@ -14,6 +19,7 @@ import ( type NoteCount struct { ModelName string ModelKeys string + ModelLabel string UnreadNotesCount int64 TotalNotesCount int64 } @@ -56,7 +62,7 @@ func getNotesCounts(db *gorm.DB, tablePrefix string, uid string, modelName strin raw := fmt.Sprintf(` WITH NoteRecords AS ( - SELECT model_name, model_keys, created_at, user_id + SELECT model_name, model_keys, model_label, created_at, user_id FROM %s WHERE action = ? AND deleted_at IS NULL %s @@ -71,6 +77,7 @@ func getNotesCounts(db *gorm.DB, tablePrefix string, uid string, modelName strin SELECT n.model_name, n.model_keys, + MAX(n.model_label) AS model_label, COUNT(CASE WHEN n.user_id <> ? AND (lva.last_viewed_at IS NULL OR n.created_at > lva.last_viewed_at) THEN 1 END) AS unread_notes_count, COUNT(*) AS total_notes_count FROM NoteRecords n @@ -202,3 +209,31 @@ func (amb *ModelBuilder) SQLConditionHasUnreadNotes(ctx context.Context, columnP } return sqlConditionHasUnreadNotes(amb.ab.db, amb.ab.tablePrefix, user.ID, ParseModelName(amb.ref), amb.keyColumns, ModelKeysSeparator, columnPrefix) } + +const KeyHasUnreadNotes = "hasUnreadNotes" + +func (amb *ModelBuilder) NewHasUnreadNotesFilterItem(ctx context.Context, columnPrefix string) (*vx.FilterItem, error) { + hasUnreadNotesCondition, err := amb.SQLConditionHasUnreadNotes(ctx, columnPrefix) + if err != nil { + return nil, err + } + return &vx.FilterItem{ + Key: KeyHasUnreadNotes, + Invisible: true, + SQLCondition: hasUnreadNotesCondition, + }, nil +} + +func (amb *ModelBuilder) NewHasUnreadNotesFilterTab(ctx context.Context) (*presets.FilterTab, error) { + evCtx := web.MustGetEventContext(ctx) + msgr := i18n.MustGetModuleMessages(evCtx.R, I18nActivityKey, Messages_en_US).(*Messages) + return &presets.FilterTab{ + Label: msgr.FilterTabsHasUnreadNotes, + ID: KeyHasUnreadNotes, + Query: url.Values{KeyHasUnreadNotes: []string{"1"}}, + }, nil +} + +func GetHasUnreadNotesHref(listingHref string) string { + return fmt.Sprintf("/%s?active_filter_tab=%s&f_%s=1", listingHref, KeyHasUnreadNotes, KeyHasUnreadNotes) +} diff --git a/example/admin/config.go b/example/admin/config.go index c6b68ece9..3b22531eb 100644 --- a/example/admin/config.go +++ b/example/admin/config.go @@ -43,7 +43,6 @@ import ( "github.com/qor5/x/v3/perm" v "github.com/qor5/x/v3/ui/vuetify" vx "github.com/qor5/x/v3/ui/vuetifyx" - "github.com/samber/lo" h "github.com/theplant/htmlgo" "github.com/theplant/osenv" "golang.org/x/text/language" @@ -290,33 +289,27 @@ func NewConfig(db *gorm.DB) Config { } pmListing := pm.Listing() pmListing.FilterDataFunc(func(ctx *web.EventContext) vx.FilterData { - hasUnreadNotesCondition, err := ab.MustGetModelBuilder(pm).SQLConditionHasUnreadNotes(ctx.R.Context(), "") + item, err := ab.MustGetModelBuilder(pm).NewHasUnreadNotesFilterItem(ctx.R.Context(), "") if err != nil { panic(err) } - return []*vx.FilterItem{ - { - Key: "hasUnreadNotes", - Invisible: true, - SQLCondition: hasUnreadNotesCondition, - }, - } + return []*vx.FilterItem{item} }) pmListing.FilterTabsFunc(func(ctx *web.EventContext) []*presets.FilterTab { msgr := i18n.MustGetModuleMessages(ctx.R, I18nExampleKey, Messages_en_US).(*Messages) + tab, err := ab.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"}}, }, - { - Label: msgr.FilterTabsHasUnreadNotes, - ID: "hasUnreadNotes", - Query: url.Values{"hasUnreadNotes": []string{"1"}}, - }, + tab, } }) return nil @@ -327,8 +320,6 @@ func NewConfig(db *gorm.DB) Config { configListModel(b, ab) - b.GetWebBuilder().RegisterEventFunc(noteMarkAllAsRead, markAllAsRead(ab)) - microb := microsite.New(db).Publisher(publisher) l10nBuilder.Activity(ab) @@ -588,16 +579,12 @@ func configPost( PerPage(10) mListing.FilterDataFunc(func(ctx *web.EventContext) vx.FilterData { - hasUnreadNotesCondition, err := ab.MustGetModelBuilder(m).SQLConditionHasUnreadNotes(ctx.R.Context(), "") + item, err := ab.MustGetModelBuilder(m).NewHasUnreadNotesFilterItem(ctx.R.Context(), "") if err != nil { panic(err) } return []*vx.FilterItem{ - { - Key: "hasUnreadNotes", - Invisible: true, - SQLCondition: hasUnreadNotesCondition, - }, + item, { Key: "created", Label: "Create Time", @@ -646,17 +633,17 @@ func configPost( mListing.FilterTabsFunc(func(ctx *web.EventContext) []*presets.FilterTab { msgr := i18n.MustGetModuleMessages(ctx.R, I18nExampleKey, Messages_en_US).(*Messages) + tab, err := ab.MustGetModelBuilder(m).NewHasUnreadNotesFilterTab(ctx.R.Context()) + if err != nil { + panic(err) + } return []*presets.FilterTab{ { Label: msgr.FilterTabsAll, ID: "all", Query: url.Values{"all": []string{"1"}}, }, - { - Label: msgr.FilterTabsHasUnreadNotes, - ID: "hasUnreadNotes", - Query: url.Values{"hasUnreadNotes": []string{"1"}}, - }, + tab, } }) @@ -689,70 +676,3 @@ func configPost( }) return m } - -func notifierCount(ab *activity.Builder) func(ctx *web.EventContext) int { - return func(ctx *web.EventContext) int { - counts, err := ab.GetNotesCounts(ctx.R.Context(), "", nil) - if err != nil { - panic(err) - } - total := lo.SumBy(counts, func(item *activity.NoteCount) int { - return int(item.UnreadNotesCount) - }) - // TODO: listen to auto update ? - return total - } -} - -func notifierComponent(ab *activity.Builder) func(ctx *web.EventContext) h.HTMLComponent { - return func(ctx *web.EventContext) h.HTMLComponent { - counts, err := ab.GetNotesCounts(ctx.R.Context(), "", nil) - if err != nil { - panic(err) - } - - groups := lo.GroupBy(counts, func(item *activity.NoteCount) string { - return item.ModelName - }) - - by := func(item *activity.NoteCount) int { return int(item.UnreadNotesCount) } - - a, b, c := lo.SumBy(groups["Page"], by), lo.SumBy(groups["Post"], by), lo.SumBy(groups["User"], by) - - // TODO: listen to auto update ? - return v.VList( - v.VListItem( - v.VListItemTitle(h.Text("Pages")), - v.VListItemSubtitle(h.Text(fmt.Sprintf("%d unread notes", a))), - ).Lines(2).Href("/pages?active_filter_tab=hasUnreadNotes&f_hasUnreadNotes=1"), - v.VListItem( - v.VListItemTitle(h.Text("Posts")), - v.VListItemSubtitle(h.Text(fmt.Sprintf("%d unread notes", b))), - ).Lines(2).Href("/posts?active_filter_tab=hasUnreadNotes&f_hasUnreadNotes=1"), - v.VListItem( - v.VListItemTitle(h.Text("Users")), - v.VListItemSubtitle(h.Text(fmt.Sprintf("%d unread notes", c))), - ).Lines(2).Href("/users?active_filter_tab=hasUnreadNotes&f_hasUnreadNotes=1"), - h.If(a+b+c > 0, - v.VListItem( - v.VListItemSubtitle(h.Text("Mark all as read")), - ).Attr("@click", web.Plaid().EventFunc(noteMarkAllAsRead).Go()), - ), - ) - // .Class("mx-auto") - // .Attr("max-width", "140") - } -} - -var noteMarkAllAsRead = "note_mark_all_as_read" - -func markAllAsRead(ab *activity.Builder) web.EventFunc { - return func(ctx *web.EventContext) (r web.EventResponse, err error) { - if err = ab.MarkAllNotesAsRead(ctx.R.Context()); err != nil { - return r, err - } - - r.Reload = true - return - } -} diff --git a/example/admin/user_config.go b/example/admin/user_config.go index a7bdad74c..cf1b824f8 100644 --- a/example/admin/user_config.go +++ b/example/admin/user_config.go @@ -307,11 +307,12 @@ func configUser(b *presets.Builder, ab *activity.Builder, db *gorm.DB, publisher cl.SearchColumns("users.Name", "Account") cl.FilterDataFunc(func(ctx *web.EventContext) vx.FilterData { - hasUnreadNotesCondition, err := ab.MustGetModelBuilder(user).SQLConditionHasUnreadNotes(ctx.R.Context(), "users.") + item, err := ab.MustGetModelBuilder(user).NewHasUnreadNotesFilterItem(ctx.R.Context(), "users.") if err != nil { panic(err) } return []*vx.FilterItem{ + item, { Key: "created", Label: "Create Time", @@ -334,11 +335,6 @@ func configUser(b *presets.Builder, ab *activity.Builder, db *gorm.DB, publisher {Text: "Inactive", Value: "inactive"}, }, }, - { - Key: "hasUnreadNotes", - Invisible: true, - SQLCondition: hasUnreadNotesCondition, - }, { Key: "registration_date", Label: "Registration Date", @@ -359,6 +355,10 @@ func configUser(b *presets.Builder, ab *activity.Builder, db *gorm.DB, publisher cl.FilterTabsFunc(func(ctx *web.EventContext) []*presets.FilterTab { msgr := i18n.MustGetModuleMessages(ctx.R, I18nExampleKey, Messages_en_US).(*Messages) + tab, err := ab.MustGetModelBuilder(user).NewHasUnreadNotesFilterTab(ctx.R.Context()) + if err != nil { + panic(err) + } return []*presets.FilterTab{ { Label: msgr.FilterTabsAll, @@ -368,11 +368,7 @@ func configUser(b *presets.Builder, ab *activity.Builder, db *gorm.DB, publisher Label: msgr.FilterTabsActive, Query: url.Values{"status": []string{"active"}}, }, - { - Label: msgr.FilterTabsHasUnreadNotes, - ID: "hasUnreadNotes", - Query: url.Values{"hasUnreadNotes": []string{"1"}}, - }, + tab, } }) } diff --git a/login/profile.go b/login/profile.go index 9320ffda3..34fc9d35d 100644 --- a/login/profile.go +++ b/login/profile.go @@ -7,7 +7,6 @@ import ( "strings" "sync" - "github.com/jinzhu/inflection" "github.com/pkg/errors" "github.com/qor5/admin/v3/activity" "github.com/qor5/admin/v3/presets" @@ -169,15 +168,24 @@ func (c *ProfileCompo) bellCompo(ctx context.Context, notifCounts []*activity.No for _, modelName := range modelNames { counts := groups[modelName] title := i18n.T(evCtx.R, presets.ModelsI18nModuleKey, modelName) - // TODO: href? model label? - href := fmt.Sprintf( - "/%s?active_filter_tab=hasUnreadNotes&f_hasUnreadNotes=1", - strings.ToLower(inflection.Plural(modelName)), - ) - listItems = append(listItems, v.VListItem().Href(href).Children( + + listItem := v.VListItem().Children( v.VListItemTitle(h.Text(title)), v.VListItemSubtitle(h.Text(msgr.UnreadMessages(lo.SumBy(counts, unreadBy)))), - )) + ) + + var href string + hasModelLabel, ok := lo.Find(counts, func(item *activity.NoteCount) bool { + return item.ModelLabel != "" && item.ModelLabel != activity.NopModelLabel + }) + if ok { + href = activity.GetHasUnreadNotesHref(hasModelLabel.ModelLabel) + } + if href != "" { + listItem.Href(href) + } + + listItems = append(listItems, listItem) } icon := v.VIcon("mdi-bell-outline").Size(20).Color("grey-darken-1") From 8346fc317c23b04fb289aac2d1acc9db451548f6 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Thu, 1 Aug 2024 18:30:39 +0800 Subject: [PATCH 32/33] fix TestProfile --- example/integration/profile_test.go | 51 ++++++++++++++++------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/example/integration/profile_test.go b/example/integration/profile_test.go index 954461b7a..5bb8533b7 100644 --- a/example/integration/profile_test.go +++ b/example/integration/profile_test.go @@ -4,11 +4,12 @@ import ( "net/http" "testing" - "github.com/qor5/admin/v3/example/models" - "github.com/theplant/gofixtures" + "gorm.io/gorm" "github.com/qor5/admin/v3/example/admin" + "github.com/qor5/admin/v3/example/models" + "github.com/qor5/admin/v3/role" "github.com/qor5/web/v3/multipartestutils" ) @@ -17,32 +18,39 @@ INSERT INTO public.users (id, created_at, updated_at, deleted_at, name, company, `, []string{"users"})) func TestProfile(t *testing.T) { - h := admin.TestHandler(TestDB, nil) + h := admin.TestHandler(TestDB, &models.User{ + Model: gorm.Model{ID: 1}, + Roles: []role.Role{ + { + Name: models.RoleAdmin, + }, + }, + }) + dbr, _ := TestDB.DB() + profileData.TruncatePut(dbr) cases := []multipartestutils.TestCase{ - { - Name: "login", - Debug: true, - ReqFunc: func() *http.Request { - profileData.TruncatePut(dbr) - req := multipartestutils.NewMultipartBuilder(). - PageURL("/auth/userpass/login"). - AddField("account", "qor@theplant.jp"). - AddField("password", "123"). - BuildEventFuncRequest() - return req - }, - ExpectPageBodyNotContains: []string{`/auth/userpass/login`}, - }, { Name: "rename", Debug: true, ReqFunc: func() *http.Request { - profileData.TruncatePut(dbr) req := multipartestutils.NewMultipartBuilder(). - PageURL("/profile?__execute_event__=accountRenameEvent&id=1"). - AddField("name", "123@theplant.jp"). + PageURL("/?__execute_event__=__dispatch_stateful_action__"). + AddField("__action__", ` + { + "compo_type": "*login.ProfileCompo", + "compo": { + "id": "" + }, + "injector": "__profile__", + "sync_query": false, + "method": "Rename", + "request": { + "name": "123@theplant.jp" + } + } + `). BuildEventFuncRequest() return req }, @@ -58,9 +66,8 @@ func TestProfile(t *testing.T) { Name: "login Sessions", Debug: true, ReqFunc: func() *http.Request { - profileData.TruncatePut(dbr) req := multipartestutils.NewMultipartBuilder(). - PageURL("/profile?__execute_event__=loginSessionDialogEvent&id=1"). + PageURL("/login-sessions-dialog?__execute_event__=loginSession_eventLoginSessionsDialog"). BuildEventFuncRequest() return req }, From dbba76d0132db9bfee42b7a4d9a2fd79869c85db Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Fri, 2 Aug 2024 09:44:57 +0800 Subject: [PATCH 33/33] profile: test profile compo ui --- example/integration/profile_test.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/example/integration/profile_test.go b/example/integration/profile_test.go index 5bb8533b7..5f3d11457 100644 --- a/example/integration/profile_test.go +++ b/example/integration/profile_test.go @@ -20,6 +20,7 @@ INSERT INTO public.users (id, created_at, updated_at, deleted_at, name, company, func TestProfile(t *testing.T) { h := admin.TestHandler(TestDB, &models.User{ Model: gorm.Model{ID: 1}, + Name: "qor@theplant.jp", Roles: []role.Role{ { Name: models.RoleAdmin, @@ -31,6 +32,17 @@ func TestProfile(t *testing.T) { profileData.TruncatePut(dbr) cases := []multipartestutils.TestCase{ + { + Name: "index", + Debug: true, + ReqFunc: func() *http.Request { + req := multipartestutils.NewMultipartBuilder(). + PageURL("/"). + BuildEventFuncRequest() + return req + }, + ExpectPageBodyContainsInOrder: []string{`portal-name='ProfileCompo:`, ``, `qor@theplant.jp`, `ADMIN`}, + }, { Name: "rename", Debug: true, @@ -71,7 +83,7 @@ func TestProfile(t *testing.T) { BuildEventFuncRequest() return req }, - ExpectPortalUpdate0ContainsInOrder: []string{`Login Sessions`}, + ExpectPortalUpdate0ContainsInOrder: []string{`Login Sessions`, `Time`, `Device`, `IP`, `Status`}, }, }