feat(admin): search users by email + erase a bound email
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s

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.
This commit is contained in:
Ilia Denisov
2026-07-03 05:30:30 +02:00
parent 1dca6741f1
commit 356bf1a5ba
7 changed files with 175 additions and 2 deletions
+47
View File
@@ -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
+7 -1
View File
@@ -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
}
@@ -101,6 +101,11 @@
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
</tbody>
</table>
{{if .HasEmail}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/remove-email" onsubmit="return confirm('Erase the email identity from this account? The address will be freed.')">
<button type="submit">Erase email</button>
</form>
{{end}}
</section>
<section class="panel"><h2>Friends</h2>
<table class="list">
@@ -9,6 +9,7 @@
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{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">
<button type="submit">Filter</button>
</form>
<table class="list">
+4 -1
View File
@@ -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).
@@ -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")
}
}
}
@@ -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.