Release v1.8.0: promote development → master #173
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user