From 356bf1a5baf25d65b1d1e6e2baf05e88ac84c3fa Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 05:30:30 +0200 Subject: [PATCH] feat(admin): search users by email + erase a bound email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an exact (strict) email filter to the /users list (UserFilter.EmailExact → a kind='email' identity match) with a search input, and an 'Erase email' action on the user card that deletes the bound email identity and its pending confirmations, freeing the address. It refuses to remove the account's only identity (ErrLastIdentity), which would leave it unreachable. Integration tests for both. --- backend/internal/account/link.go | 47 +++++++++++ backend/internal/account/userlist.go | 8 +- .../templates/pages/user_detail.gohtml | 5 ++ .../adminconsole/templates/pages/users.gohtml | 1 + backend/internal/adminconsole/views.go | 5 +- backend/internal/inttest/adminemail_test.go | 83 +++++++++++++++++++ .../internal/server/handlers_admin_console.go | 28 +++++++ 7 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 backend/internal/inttest/adminemail_test.go diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index 1a597ed..08f3b32 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -2,6 +2,7 @@ package account import ( "context" + "database/sql" "errors" "fmt" "time" @@ -16,6 +17,52 @@ import ( // belongs to another account; the caller turns it into a merge. var ErrIdentityTaken = errors.New("account: identity already linked to another account") +// ErrLastIdentity is returned when removing an identity would leave the account with +// none, making it unreachable after logout. The admin email-erase refuses it. +var ErrLastIdentity = errors.New("account: cannot remove the last identity") + +// RemoveEmailIdentity deletes the account's email identity and any pending confirmations +// for it, freeing the address for reuse. It refuses when the email is the account's only +// identity (ErrLastIdentity) — that would leave the account unreachable — and returns +// ErrNotFound when the account has no email identity. It backs the admin console's +// "erase email" action. +func (s *Store) RemoveEmailIdentity(ctx context.Context, accountID uuid.UUID) error { + ids, err := s.Identities(ctx, accountID) + if err != nil { + return err + } + hasEmail, others := false, 0 + for _, id := range ids { + if id.Kind == KindEmail { + hasEmail = true + } else { + others++ + } + } + if !hasEmail { + return ErrNotFound + } + if others == 0 { + return ErrLastIdentity + } + return withTx(ctx, s.db, func(tx *sql.Tx) error { + delID := table.Identities.DELETE().WHERE( + table.Identities.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Identities.Kind.EQ(postgres.String(KindEmail))), + ) + if _, err := delID.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("account: delete email identity %s: %w", accountID, err) + } + delConf := table.EmailConfirmations.DELETE().WHERE( + table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)), + ) + if _, err := delConf.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("account: delete email confirmations %s: %w", accountID, err) + } + return nil + }) +} + // RequestLinkCode issues and mails a confirm-code for email to accountID, // replacing any prior pending code. Unlike RequestCode it never refuses up front // (taken or already-confirmed): possession of the address is the authorization for diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go index f2c39a3..9103c9f 100644 --- a/backend/internal/account/userlist.go +++ b/backend/internal/account/userlist.go @@ -28,11 +28,13 @@ 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. An empty mask means no filter on that field. +// 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 NameMask string ExternalIDMask string + EmailExact string } // robotExists is the correlated subquery testing whether account a is a robot. @@ -63,6 +65,10 @@ func userListWhere(f UserFilter) (string, []any) { args = append(args, ext) where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args)) } + if email := strings.ToLower(strings.TrimSpace(f.EmailExact)); email != "" { + args = append(args, email) + where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d)`, len(args)) + } return where, args } diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 9be7009..58194f6 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -101,6 +101,11 @@ {{else}}no identities (guest){{end}} +{{if .HasEmail}} +
+ +
+{{end}}

Friends

diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml index 9a116a0..9503578 100644 --- a/backend/internal/adminconsole/templates/pages/users.gohtml +++ b/backend/internal/adminconsole/templates/pages/users.gohtml @@ -9,6 +9,7 @@ {{if .Robots}}{{end}} +
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index a4db45b..ffded10 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -62,6 +62,7 @@ type UsersView struct { Robots bool NameMask string ExternalIDMask string + EmailExact string FilterQuery template.URL } @@ -161,7 +162,9 @@ type UserDetailView struct { HasStats bool Stats StatsRow Identities []IdentityRow - Games []GameRow + // HasEmail gates the "Erase email" action; set when the account carries an email identity. + HasEmail bool + Games []GameRow // TelegramID and VKID are the account's platform external ids (empty when absent). // TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK // user id with a link to the VK profile (there is no VK messaging to drive). diff --git a/backend/internal/inttest/adminemail_test.go b/backend/internal/inttest/adminemail_test.go new file mode 100644 index 0000000..7f4b532 --- /dev/null +++ b/backend/internal/inttest/adminemail_test.go @@ -0,0 +1,83 @@ +//go:build integration + +package inttest + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" +) + +// TestRemoveEmailIdentity erases an account's email identity but refuses when the +// email is the account's only identity (which would leave it unreachable). +func TestRemoveEmailIdentity(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + + // Email is the only identity → refuse. + solo, err := store.ProvisionEmail(ctx, "solo-"+uuid.NewString()+"@example.com", "", "en") + if err != nil { + t.Fatalf("provision email: %v", err) + } + if err := store.RemoveEmailIdentity(ctx, solo.ID); !errors.Is(err, account.ErrLastIdentity) { + t.Fatalf("remove last identity = %v, want ErrLastIdentity", err) + } + + // Telegram + email → erase the email, keep Telegram. + tg, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision telegram: %v", err) + } + if err := store.AttachIdentity(ctx, tg.ID, account.KindEmail, "dual-"+uuid.NewString()+"@example.com", true); err != nil { + t.Fatalf("attach email: %v", err) + } + if err := store.RemoveEmailIdentity(ctx, tg.ID); err != nil { + t.Fatalf("remove email: %v", err) + } + ids, err := store.Identities(ctx, tg.ID) + if err != nil { + t.Fatalf("identities: %v", err) + } + if len(ids) != 1 || ids[0].Kind != account.KindTelegram { + t.Errorf("identities after erase = %+v, want only telegram", ids) + } +} + +// TestListUsersEmailExact matches accounts strictly (exactly) by their email identity. +func TestListUsersEmailExact(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + email := "find-" + uuid.NewString() + "@example.com" + acc, err := store.ProvisionEmail(ctx, email, "", "en") + if err != nil { + t.Fatalf("provision: %v", err) + } + + items, err := store.ListUsers(ctx, account.UserFilter{EmailExact: email}, 50, 0) + if err != nil { + t.Fatalf("list: %v", err) + } + found := false + for _, it := range items { + if it.ID == acc.ID { + found = true + } + } + if !found { + t.Error("the exact email filter did not find the account") + } + + other, err := store.ListUsers(ctx, account.UserFilter{EmailExact: "nope-" + uuid.NewString() + "@example.com"}, 50, 0) + if err != nil { + t.Fatalf("list (no match): %v", err) + } + for _, it := range other { + if it.ID == acc.ID { + t.Error("a non-matching email filter must not return the account") + } + } +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 2ed1902..affda86 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -61,6 +61,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.POST("/users/:id/unblock", s.consoleUnblockUser) 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.GET("/reasons", s.consoleReasons) gm.POST("/reasons", s.consoleCreateReason) gm.POST("/reasons/:id/update", s.consoleUpdateReason) @@ -134,6 +135,7 @@ func (s *Server) consoleUsers(c *gin.Context) { Robots: c.Query("kind") == "robots", NameMask: c.Query("name"), ExternalIDMask: c.Query("ext"), + EmailExact: c.Query("email"), } total, _ := s.accounts.CountUsers(ctx, filter) items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize) @@ -151,9 +153,13 @@ func (s *Server) consoleUsers(c *gin.Context) { if strings.TrimSpace(filter.ExternalIDMask) != "" { q.Set("ext", filter.ExternalIDMask) } + if strings.TrimSpace(filter.EmailExact) != "" { + q.Set("email", filter.EmailExact) + } view := adminconsole.UsersView{ Pager: adminconsole.NewPager(page, adminPageSize, total), Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask, + EmailExact: filter.EmailExact, FilterQuery: template.URL(q.Encode()), } ids := make([]uuid.UUID, 0, len(items)) @@ -356,6 +362,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) { } if ids, err := s.accounts.Identities(ctx, id); err == nil { for _, idn := range ids { + if idn.Kind == account.KindEmail { + view.HasEmail = true + } view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)}) } } @@ -951,6 +960,25 @@ func (s *Server) consoleGrantHints(c *gin.Context) { s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back) } +// consoleRemoveEmail deletes the account's bound email identity (and any pending +// confirmations), freeing the address. It refuses to remove the account's only +// identity, which would leave it unreachable. +func (s *Server) consoleRemoveEmail(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + switch err := s.accounts.RemoveEmailIdentity(c.Request.Context(), id); { + case errors.Is(err, account.ErrLastIdentity): + s.renderConsoleMessage(c, "Can't remove", "the email is this account's only identity — removing it would leave the account unreachable", back) + case err != nil: + s.consoleError(c, err) + default: + s.renderConsoleMessage(c, "Removed", "the email identity was erased and the address freed", 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.