diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index d65015d..102bd73 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -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) } diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go index 9103c9f..c9b7acd 100644 --- a/backend/internal/account/userlist.go +++ b/backend/internal/account/userlist.go @@ -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)) diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index f6efb5d..2d300d1 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -119,11 +119,11 @@ {{end}} -{{if not .Deleted}}{{if not .Guest}} +{{if not .Deleted}}
-{{end}}{{end}} +{{end}}

Friends

diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml index 9503578..b99bee4 100644 --- a/backend/internal/adminconsole/templates/pages/users.gohtml +++ b/backend/internal/adminconsole/templates/pages/users.gohtml @@ -2,11 +2,12 @@

Users

{{with .Data}} -{{if .Robots}}{{end}} +{{if .Robots}}{{end}}{{if .Deleted}}{{end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 22db467..1825d48 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -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 diff --git a/backend/internal/inttest/delete_test.go b/backend/internal/inttest/delete_test.go index 0f60df1..94760f6 100644 --- a/backend/internal/inttest/delete_test.go +++ b/backend/internal/inttest/delete_test.go @@ -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) { diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 51c37e8..6e72a08 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -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()), } diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index 3ae6d94..9cc9a3f 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -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;