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:
@@ -3,13 +3,16 @@ package account
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
@@ -110,6 +113,71 @@ func (s *Store) ReapExpiredRetention(ctx context.Context, cutoff time.Time) (ide
|
||||
return identities, feedback, nil
|
||||
}
|
||||
|
||||
// RetainedIdentity is one row of the retention journal, for the admin dossier.
|
||||
type RetainedIdentity struct {
|
||||
Kind string
|
||||
ExternalID string
|
||||
Reason string
|
||||
Confirmed bool
|
||||
LinkedAt time.Time
|
||||
DetachedAt time.Time
|
||||
}
|
||||
|
||||
// RetainedIdentities returns the account's retention-journal rows (the legal dossier of
|
||||
// detached credentials), newest detach first, for the admin console.
|
||||
func (s *Store) RetainedIdentities(ctx context.Context, accountID uuid.UUID) ([]RetainedIdentity, error) {
|
||||
var rows []model.RetainedIdentities
|
||||
err := postgres.SELECT(table.RetainedIdentities.AllColumns).
|
||||
FROM(table.RetainedIdentities).
|
||||
WHERE(table.RetainedIdentities.AccountID.EQ(postgres.UUID(accountID))).
|
||||
ORDER_BY(table.RetainedIdentities.DetachedAt.DESC()).
|
||||
QueryContext(ctx, s.db, &rows)
|
||||
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil, fmt.Errorf("account: retained identities %s: %w", accountID, err)
|
||||
}
|
||||
out := make([]RetainedIdentity, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, RetainedIdentity{
|
||||
Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
|
||||
Confirmed: r.Confirmed, LinkedAt: r.LinkedAt, DetachedAt: r.DetachedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeletionInfo is a tombstoned account's dossier header, for the admin console.
|
||||
type DeletionInfo struct {
|
||||
DeletedAt *time.Time
|
||||
DeletedDisplayName string
|
||||
LastLoginAt *time.Time
|
||||
LastLoginIP string
|
||||
}
|
||||
|
||||
// DeletionInfo reads the account's deletion tombstone + last-login dossier fields.
|
||||
func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (DeletionInfo, error) {
|
||||
var row model.Accounts
|
||||
err := postgres.SELECT(
|
||||
table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName,
|
||||
table.Accounts.LastLoginAt, table.Accounts.LastLoginIP,
|
||||
).FROM(table.Accounts).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))).
|
||||
QueryContext(ctx, s.db, &row)
|
||||
if err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return DeletionInfo{}, ErrNotFound
|
||||
}
|
||||
return DeletionInfo{}, fmt.Errorf("account: deletion info %s: %w", accountID, err)
|
||||
}
|
||||
info := DeletionInfo{DeletedAt: row.DeletedAt, LastLoginAt: row.LastLoginAt}
|
||||
if row.DeletedDisplayName != nil {
|
||||
info.DeletedDisplayName = *row.DeletedDisplayName
|
||||
}
|
||||
if row.LastLoginIP != nil {
|
||||
info.LastLoginIP = *row.LastLoginIP
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// RetentionReaper periodically purges expired account-deletion retention data via
|
||||
// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once
|
||||
// from main.
|
||||
|
||||
@@ -107,6 +107,24 @@
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Deletion & retention</h2>
|
||||
{{if .LastLoginAt}}<p class="note">Last login: {{.LastLoginAt}}{{if .LastLoginIP}} — <code>{{.LastLoginIP}}</code>{{end}}</p>{{end}}
|
||||
{{if .Deleted}}<p><span class="warn">Deleted</span> at {{.DeletedAt}}{{if .DeletedName}} — was <code>{{.DeletedName}}</code>{{end}}</p>{{end}}
|
||||
{{if .Retained}}
|
||||
<h3>Retention journal (legal dossier of detached credentials)</h3>
|
||||
<table class="list">
|
||||
<thead><tr><th>Kind</th><th>Credential</th><th>Reason</th><th>Detached</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Retained}}<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{.Reason}}</td><td>{{.DetachedAt}}</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
{{if not .Deleted}}{{if not .Guest}}
|
||||
<form class="form" method="post" action="/_gm/users/{{.ID}}/delete" onsubmit="return confirm('Delete this account? Its credentials are journalled and freed, its data anonymised, and its sessions revoked. This cannot be undone.')">
|
||||
<button type="submit">Delete user</button>
|
||||
</form>
|
||||
{{end}}{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Friends</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
|
||||
|
||||
@@ -151,6 +151,15 @@ type UserDetailView struct {
|
||||
// MergedInto is the primary account id when this account has been retired by a
|
||||
// merge, or empty for a live account.
|
||||
MergedInto string
|
||||
// The account-deletion dossier. Deleted marks a tombstoned account; DeletedAt and
|
||||
// DeletedName are its deletion time and retained real name; LastLoginAt/IP are the
|
||||
// last cold-load stamp (shown for any account); Retained is the credential journal.
|
||||
Deleted bool
|
||||
DeletedAt string
|
||||
DeletedName string
|
||||
LastLoginAt string
|
||||
LastLoginIP string
|
||||
Retained []RetainedRow
|
||||
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
|
||||
// empty for an unflagged account; the card shows it with the Clear action.
|
||||
FlaggedHighRateAt string
|
||||
@@ -237,6 +246,17 @@ type IdentityRow struct {
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// RetainedRow is one credential in the account-deletion retention journal (the legal
|
||||
// dossier of detached credentials): what was detached, when, and why.
|
||||
type RetainedRow struct {
|
||||
Kind string
|
||||
ExternalID string
|
||||
Reason string
|
||||
Confirmed bool
|
||||
LinkedAt string
|
||||
DetachedAt string
|
||||
}
|
||||
|
||||
// GameRow is one game row in a list.
|
||||
type GameRow struct {
|
||||
ID string
|
||||
|
||||
@@ -95,6 +95,45 @@ func TestAnonymizeAndTombstone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeletionDossierReaders: after deletion the admin readers expose the credential
|
||||
// journal and the tombstone dossier.
|
||||
func TestDeletionDossierReaders(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
deleter := accountdelete.NewDeleter(testDB)
|
||||
|
||||
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00")
|
||||
if err != nil {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "dos-"+uuid.NewString()+"@example.com", true); err != nil {
|
||||
t.Fatalf("attach email: %v", err)
|
||||
}
|
||||
if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
rets, err := store.RetainedIdentities(ctx, acc.ID)
|
||||
if err != nil || len(rets) != 2 {
|
||||
t.Fatalf("RetainedIdentities = (%+v, %v), want 2 rows", rets, err)
|
||||
}
|
||||
for _, r := range rets {
|
||||
if r.Reason != "delete" {
|
||||
t.Errorf("retained reason = %q, want delete", r.Reason)
|
||||
}
|
||||
}
|
||||
info, err := store.DeletionInfo(ctx, acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeletionInfo: %v", err)
|
||||
}
|
||||
if info.DeletedAt == nil {
|
||||
t.Error("DeletionInfo.DeletedAt should be set")
|
||||
}
|
||||
if info.DeletedDisplayName != "Иван" {
|
||||
t.Errorf("DeletionInfo.DeletedDisplayName = %q, want Иван", info.DeletedDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human
|
||||
// opponent.
|
||||
func TestDropAllRobotGames(t *testing.T) {
|
||||
|
||||
@@ -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