diff --git a/README.md b/README.md index 1065817..50003df 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ Packages: - `FireRegisterEvent(ctx, app, user, fields)` fires `RegisterEvent` with `Payload` set to a copy of `fields` without `password` and `password_confirmation`, and returns the listeners' error (`/register` ignores it). Fire it after the user row is committed. - `FireCreatedEvent(ctx, app, user)` fires `CreatedEvent` (`user.Created`). `/register` ignores that error. - `FireDeletingEvent(ctx, app, user, tx)` fires `DeletingEvent` (`user.Deleting`) inside the delete transaction. + - `FireDeactivatingEvent(ctx, app, user, tx)` fires `DeactivatingEvent` (`user.Deactivating`) inside the deactivation transaction. The admin deactivate bulk action fires it after `DeactivateUsers` for every selected user that was not deactivated yet; a listener error rolls the action back. - `IssueToken(app, user, r)` mints the session JWT as `/register` and `/login` do. - `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners. - `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `HashAPIToken` and `RevokeAPIToken`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has: @@ -173,7 +174,7 @@ Packages: - `TouchLastSeen(ctx, db, userID)`. - `RequestUser(ctx, app, r)` and `EndRequestSession(ctx, app, w, r)`, for handlers that serve signed-in and anonymous browsers on one route (an OAuth provider's authorize page). `RequestUser` reads and checks the session JWT exactly as the `jwt` guard does (bearer header, then the `token` and `auth_token` cookies, the blacklist, `tokens_valid_after`) and returns the user, or `nil` for an anonymous, invalid, expired or blacklisted request and for a soft-deleted user; only an infrastructure failure is an error. `EndRequestSession` blacklists the request's JWT until it expires (as `POST /logout` does) and writes the forget cookie of `bouncer.LoadCookieConfig`, best effort: a blacklist failure is logged, never returned. - `AvatarURL(ctx, app, user, size)`, the display picture rule of the user payload at any size: the avatar thumbnail (size by size, mode auto) when the user has an avatar and a file bucket is published, the file path when the thumbnail fails, otherwise the gravatar URL. The URL may be protocol-relative or relative. The payload's `avatar_url` comes from it at 128. - - `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`. + - `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`. - `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`. - `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). - `updates`: the gormigrate migrations, returned by `updates.All()`. diff --git a/classes/events.go b/classes/events.go index ac9d211..bb94064 100644 --- a/classes/events.go +++ b/classes/events.go @@ -11,6 +11,9 @@ const CreatedEventName = "user.Created" // DeletingEventName is the festival name of DeletingEvent (D-13 / RESEARCH A6). const DeletingEventName = "user.Deleting" +// DeactivatingEventName is the festival name of DeactivatingEvent. +const DeactivatingEventName = "user.Deactivating" + // GetApiArrayEvent is the fire-and-collect payload other plugins extend. type GetApiArrayEvent struct { User *models.User @@ -52,3 +55,13 @@ type DeletingEvent struct { User *models.User Tx *gorm.DB } + +// DeactivatingEvent fires after a user is soft-deleted (deactivated, the +// PHP $user->delete()) by the admin deactivate action, inside the same +// transaction, so listeners can revoke what a deactivated account must not +// keep (OAuth grants, for example) while leaving what a restore needs. A +// listener error rolls the deactivation back. +type DeactivatingEvent struct { + User *models.User + Tx *gorm.DB +} diff --git a/controllers/registration.go b/controllers/registration.go index 8acbec6..55cc2cf 100644 --- a/controllers/registration.go +++ b/controllers/registration.go @@ -120,6 +120,16 @@ func FireDeletingEvent(ctx context.Context, app *backpack.App, user *models.User return app.Events.Fire(ctx, &classes.DeletingEvent{User: user, Tx: tx}) } +// FireDeactivatingEvent fires classes.DeactivatingEvent (user.Deactivating) +// for user inside the deactivation transaction and returns the listeners' +// error, so a failed listener rolls the deactivation back. +func FireDeactivatingEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB) error { + if app == nil || app.Events == nil { + return nil + } + return app.Events.Fire(ctx, &classes.DeactivatingEvent{User: user, Tx: tx}) +} + // IssueToken mints the session JWT for user exactly as /register and // /login do: the plugin's JWT secret, golem15.user.jwt.ttl and the request // URL as the issuer. diff --git a/controllers/users_admin_controller.go b/controllers/users_admin_controller.go index 743db5f..7026133 100644 --- a/controllers/users_admin_controller.go +++ b/controllers/users_admin_controller.go @@ -594,13 +594,35 @@ func (c usersAdminController) AdminBulkActions() []pact.AdminBulkAction { } return []pact.AdminBulkAction{ bulk("activate", classes.ActivateUsers), - bulk("deactivate", classes.DeactivateUsers), + bulk("deactivate", c.deactivateUsers), bulk("restore", classes.RestoreUsers), bulk("ban", classes.BanUsers), bulk("unban", classes.UnbanUsers), } } +// deactivateUsers runs classes.DeactivateUsers unchanged and then fires +// user.Deactivating, in the action's transaction, for every selected user +// that was not deactivated before. A listener error rolls the action back. +func (c usersAdminController) deactivateUsers(ctx context.Context, tx *gorm.DB, users []*models.User) (int, error) { + pending := make([]*models.User, 0, len(users)) + for _, user := range users { + if user != nil && user.ID != 0 && !user.DeletedAt.Valid { + pending = append(pending, user) + } + } + changed, err := classes.DeactivateUsers(ctx, tx, users) + if err != nil { + return 0, err + } + for _, user := range pending { + if err := FireDeactivatingEvent(ctx, c.application(), user, tx); err != nil { + return 0, err + } + } + return changed, nil +} + // throttleState reads whether one user is banned and whether they are // suspended, through the write's transaction when there is one. func (c usersAdminController) throttleState(ctx context.Context, userID uint) (banned, suspended bool, err error) {