diff --git a/backend/internal/account/retention.go b/backend/internal/account/retention.go index 4c00386..14c6bab 100644 --- a/backend/internal/account/retention.go +++ b/backend/internal/account/retention.go @@ -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. diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 58194f6..f6efb5d 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -107,6 +107,24 @@ {{end}} +

Deletion & retention

+{{if .LastLoginAt}}

Last login: {{.LastLoginAt}}{{if .LastLoginIP}} — {{.LastLoginIP}}{{end}}

{{end}} +{{if .Deleted}}

Deleted at {{.DeletedAt}}{{if .DeletedName}} — was {{.DeletedName}}{{end}}

{{end}} +{{if .Retained}} +

Retention journal (legal dossier of detached credentials)

+ + + +{{range .Retained}}{{end}} + +
KindCredentialReasonDetached
{{.Kind}}{{.ExternalID}}{{.Reason}}{{.DetachedAt}}
+{{end}} +{{if not .Deleted}}{{if not .Guest}} +
+ +
+{{end}}{{end}} +

Friends

diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index ffded10..22db467 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -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 diff --git a/backend/internal/inttest/delete_test.go b/backend/internal/inttest/delete_test.go index 7dcabcd..0f60df1 100644 --- a/backend/internal/inttest/delete_test.go +++ b/backend/internal/inttest/delete_test.go @@ -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) { diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index affda86..51c37e8 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -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.
AccountFriends since