Release v1.8.0: promote development → master #173

Merged
developer merged 98 commits from development into master 2026-07-03 21:31:25 +00:00
7 changed files with 175 additions and 2 deletions
Showing only changes of commit 356bf1a5ba - Show all commits
+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.