feat: fire user.Deactivating from the admin deactivate action

- DeactivatingEvent and DeactivatingEventName in classes
- FireDeactivatingEvent modelled on FireDeletingEvent
- the deactivate bulk action runs DeactivateUsers unchanged, then fires
  the event per newly deactivated user in the action's transaction
This commit is contained in:
2026-10-19 19:06:20 +02:00
parent ee474401cb
commit 80b436d3d9
4 changed files with 48 additions and 2 deletions

View File

@@ -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()`.

View File

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

View File

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

View File

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