fix(delete): review follow-ups — admin Deleted filter, guest gate, dialog spacing
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s

- Admin /users gains a Deleted scope (between People and Robots); every other scope now
  hides tombstoned accounts (deleted_at filter in UserFilter).
- Admin delete-user is offered for any non-deleted account (drop the not-guest gate, so a
  stale is_guest account can still be tombstoned).
- Harden EmailService.ConfirmCode to ClearGuest — defence-in-depth so no confirmed-email
  path leaves is_guest set (the currently-live paths already do).
- Space the delete/change dialog's action row from its input field.
Integration tests: the Deleted filter scoping + ConfirmCode guest promotion.
This commit is contained in:
Ilia Denisov
2026-07-03 13:59:43 +02:00
parent c2a8426b74
commit 3faca690dd
8 changed files with 113 additions and 9 deletions
+5
View File
@@ -206,6 +206,11 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return Account{}, err
}
// Binding the first confirmed email promotes a guest to a durable account, matching the
// link and deeplink flows (defence-in-depth: no confirmed-email path leaves is_guest set).
if err := s.store.ClearGuest(ctx, accountID); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID)
}
+12 -4
View File
@@ -26,12 +26,14 @@ type UserListItem struct {
}
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
// one char) matched case-insensitively against the display name / any identity's external
// id; EmailExact is a strict (exact) match against an account's email identity. An empty
// value means no filter on that field.
// non-robot "people"); Deleted selects tombstoned accounts (every other scope hides them);
// NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = one char) matched
// case-insensitively against the display name / any identity's external id; EmailExact is a
// strict (exact) match against an account's email identity. An empty value means no filter
// on that field.
type UserFilter struct {
Robots bool
Deleted bool
NameMask string
ExternalIDMask string
EmailExact string
@@ -57,6 +59,12 @@ func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error)
func userListWhere(f UserFilter) (string, []any) {
args := []any{f.Robots}
where := robotExists + ` = $1`
// The Deleted scope shows tombstoned accounts; every other scope hides them.
if f.Deleted {
where += ` AND a.deleted_at IS NOT NULL`
} else {
where += ` AND a.deleted_at IS NULL`
}
if name := LikePattern(f.NameMask); name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
@@ -119,11 +119,11 @@
</tbody>
</table>
{{end}}
{{if not .Deleted}}{{if not .Guest}}
{{if not .Deleted}}
<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}}
{{end}}
</section>
<section class="panel"><h2>Friends</h2>
<table class="list">
@@ -2,11 +2,12 @@
<h1>Users</h1>
{{with .Data}}
<nav class="subnav">
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
<a href="/_gm/users"{{if and (not .Robots) (not .Deleted)}} class="active"{{end}}>People</a> ·
<a href="/_gm/users?kind=deleted"{{if .Deleted}} class="active"{{end}}>Deleted</a> ·
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
</nav>
<form class="form" method="get" action="/_gm/users">
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}{{if .Deleted}}<input type="hidden" name="kind" value="deleted">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
<input name="email" value="{{.EmailExact}}" placeholder="email (exact)" type="search">
+1
View File
@@ -60,6 +60,7 @@ type UsersView struct {
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
// percent-encoded again by the contextual escaper.
Robots bool
Deleted bool
NameMask string
ExternalIDMask string
EmailExact string
+79
View File
@@ -134,6 +134,85 @@ func TestDeletionDossierReaders(t *testing.T) {
}
}
// listHasID reports whether the user list contains accountID.
func listHasID(items []account.UserListItem, id uuid.UUID) bool {
for _, it := range items {
if it.ID == id {
return true
}
}
return false
}
// TestUserListDeletedFilter: a tombstoned account is hidden from the default People list and
// shown only under the Deleted scope.
func TestUserListDeletedFilter(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
deleter := accountdelete.NewDeleter(testDB)
live, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Live", "")
if err != nil {
t.Fatalf("provision live: %v", err)
}
gone, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Gone", "")
if err != nil {
t.Fatalf("provision gone: %v", err)
}
if err := deleter.AnonymizeAndTombstone(ctx, gone.ID); err != nil {
t.Fatalf("delete: %v", err)
}
people, err := store.ListUsers(ctx, account.UserFilter{}, 5000, 0)
if err != nil {
t.Fatalf("list people: %v", err)
}
if listHasID(people, gone.ID) {
t.Error("a deleted account must not appear in the default People list")
}
if !listHasID(people, live.ID) {
t.Error("a live account must appear in the default People list")
}
deleted, err := store.ListUsers(ctx, account.UserFilter{Deleted: true}, 5000, 0)
if err != nil {
t.Fatalf("list deleted: %v", err)
}
if !listHasID(deleted, gone.ID) {
t.Error("a deleted account must appear in the Deleted list")
}
if listHasID(deleted, live.ID) {
t.Error("a live account must not appear in the Deleted list")
}
}
// TestConfirmCodeClearsGuest: confirming an email on a guest via ConfirmCode promotes it to
// a durable account (defence-in-depth — no confirmed-email path leaves is_guest set).
func TestConfirmCodeClearsGuest(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
email := "cc-" + uuid.NewString() + "@example.com"
if err := svc.RequestCode(ctx, guest.ID, email); err != nil {
t.Fatalf("request code: %v", err)
}
if _, err := svc.ConfirmCode(ctx, guest.ID, email, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("confirm code: %v", err)
}
after, err := store.GetByID(ctx, guest.ID)
if err != nil {
t.Fatalf("load: %v", err)
}
if after.IsGuest {
t.Error("confirming an email must clear the guest flag")
}
}
// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human
// opponent.
func TestDropAllRobotGames(t *testing.T) {
@@ -134,6 +134,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
page := consolePage(c)
filter := account.UserFilter{
Robots: c.Query("kind") == "robots",
Deleted: c.Query("kind") == "deleted",
NameMask: c.Query("name"),
ExternalIDMask: c.Query("ext"),
EmailExact: c.Query("email"),
@@ -148,6 +149,9 @@ func (s *Server) consoleUsers(c *gin.Context) {
if filter.Robots {
q.Set("kind", "robots")
}
if filter.Deleted {
q.Set("kind", "deleted")
}
if strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
@@ -159,7 +163,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
}
view := adminconsole.UsersView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
Robots: filter.Robots, Deleted: filter.Deleted, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
EmailExact: filter.EmailExact,
FilterQuery: template.URL(q.Encode()),
}
+6
View File
@@ -644,6 +644,12 @@
display: flex;
gap: 8px;
}
/* When a button row sits below its own field (the change-email and delete dialogs),
separate them and keep the actions right-aligned. */
.addrow.end {
margin-top: 14px;
justify-content: flex-end;
}
.addrow input {
flex: 1;
min-width: 0;