feat(admin): deleted-account dossier + operator delete-user action
The user-detail console gains a Deletion & retention panel: last login (time + IP), the tombstone (deleted-at + retained real name), and the retention journal (the legal dossier of detached credentials). A Delete-user action runs the same deletion orchestration as the in-app flow (mirrors the email-erase pattern). Store readers RetainedIdentities + DeletionInfo back the view; integration test covers them.
This commit is contained in:
@@ -62,6 +62,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
||||
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
||||
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
|
||||
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
||||
gm.GET("/reasons", s.consoleReasons)
|
||||
gm.POST("/reasons", s.consoleCreateReason)
|
||||
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
|
||||
@@ -368,6 +369,25 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)})
|
||||
}
|
||||
}
|
||||
if info, err := s.accounts.DeletionInfo(ctx, id); err == nil {
|
||||
if info.LastLoginAt != nil {
|
||||
view.LastLoginAt = fmtTime(*info.LastLoginAt)
|
||||
}
|
||||
view.LastLoginIP = info.LastLoginIP
|
||||
if info.DeletedAt != nil {
|
||||
view.Deleted = true
|
||||
view.DeletedAt = fmtTime(*info.DeletedAt)
|
||||
view.DeletedName = info.DeletedDisplayName
|
||||
}
|
||||
}
|
||||
if rets, err := s.accounts.RetainedIdentities(ctx, id); err == nil {
|
||||
for _, r := range rets {
|
||||
view.Retained = append(view.Retained, adminconsole.RetainedRow{
|
||||
Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
|
||||
Confirmed: r.Confirmed, LinkedAt: fmtTime(r.LinkedAt), DetachedAt: fmtTime(r.DetachedAt),
|
||||
})
|
||||
}
|
||||
}
|
||||
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
|
||||
view.TelegramID = tg
|
||||
}
|
||||
@@ -979,6 +999,22 @@ func (s *Server) consoleRemoveEmail(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// consoleDeleteUser deletes an account from the console — the operator-initiated equivalent
|
||||
// of the in-app deletion (legal retention, not erasure): the account is tombstoned, its
|
||||
// credentials journalled + freed, its live surfaces anonymised, and its sessions revoked.
|
||||
func (s *Server) consoleDeleteUser(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + id.String()
|
||||
if err := s.deleteAccount(c.Request.Context(), id); err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Deleted", "the account was deleted: its credentials were journalled and freed, its data anonymised, and its sessions revoked", back)
|
||||
}
|
||||
|
||||
// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a
|
||||
// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active
|
||||
// games, removing them from matchmaking. The block takes effect on the player's next request.
|
||||
|
||||
Reference in New Issue
Block a user