fix(delete): review follow-ups — admin Deleted filter, guest gate, dialog spacing
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
- Admin /users gains a Deleted scope (between People and Robots); every other scope now hides tombstoned accounts (deleted_at filter in UserFilter). - Admin delete-user is offered for any non-deleted account (drop the not-guest gate, so a stale is_guest account can still be tombstoned). - Harden EmailService.ConfirmCode to ClearGuest — defence-in-depth so no confirmed-email path leaves is_guest set (the currently-live paths already do). - Space the delete/change dialog's action row from its input field. Integration tests: the Deleted filter scoping + ConfirmCode guest promotion.
This commit is contained in:
@@ -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 {
|
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
|
||||||
return Account{}, err
|
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)
|
return s.store.GetByID(ctx, accountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ type UserListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
|
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
|
||||||
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
|
// non-robot "people"); Deleted selects tombstoned accounts (every other scope hides them);
|
||||||
// one char) matched case-insensitively against the display name / any identity's external
|
// NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = one char) matched
|
||||||
// id; EmailExact is a strict (exact) match against an account's email identity. An empty
|
// case-insensitively against the display name / any identity's external id; EmailExact is a
|
||||||
// value means no filter on that field.
|
// strict (exact) match against an account's email identity. An empty value means no filter
|
||||||
|
// on that field.
|
||||||
type UserFilter struct {
|
type UserFilter struct {
|
||||||
Robots bool
|
Robots bool
|
||||||
|
Deleted bool
|
||||||
NameMask string
|
NameMask string
|
||||||
ExternalIDMask string
|
ExternalIDMask string
|
||||||
EmailExact 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) {
|
func userListWhere(f UserFilter) (string, []any) {
|
||||||
args := []any{f.Robots}
|
args := []any{f.Robots}
|
||||||
where := robotExists + ` = $1`
|
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 != "" {
|
if name := LikePattern(f.NameMask); name != "" {
|
||||||
args = append(args, name)
|
args = append(args, name)
|
||||||
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
|
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
|
||||||
|
|||||||
@@ -119,11 +119,11 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if not .Deleted}}{{if not .Guest}}
|
{{if not .Deleted}}
|
||||||
<form class="form" method="post" action="/_gm/users/{{.ID}}/delete" onsubmit="return confirm('Delete this account? Its credentials are journalled and freed, its data anonymised, and its sessions revoked. This cannot be undone.')">
|
<form class="form" method="post" action="/_gm/users/{{.ID}}/delete" onsubmit="return confirm('Delete this account? Its credentials are journalled and freed, its data anonymised, and its sessions revoked. This cannot be undone.')">
|
||||||
<button type="submit">Delete user</button>
|
<button type="submit">Delete user</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}{{end}}
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Friends</h2>
|
<section class="panel"><h2>Friends</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
<nav class="subnav">
|
<nav class="subnav">
|
||||||
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
|
<a href="/_gm/users"{{if and (not .Robots) (not .Deleted)}} class="active"{{end}}>People</a> ·
|
||||||
|
<a href="/_gm/users?kind=deleted"{{if .Deleted}} class="active"{{end}}>Deleted</a> ·
|
||||||
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
|
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
|
||||||
</nav>
|
</nav>
|
||||||
<form class="form" method="get" action="/_gm/users">
|
<form class="form" method="get" action="/_gm/users">
|
||||||
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
|
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}{{if .Deleted}}<input type="hidden" name="kind" value="deleted">{{end}}
|
||||||
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
||||||
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
|
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
|
||||||
<input name="email" value="{{.EmailExact}}" placeholder="email (exact)" type="search">
|
<input name="email" value="{{.EmailExact}}" placeholder="email (exact)" type="search">
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type UsersView struct {
|
|||||||
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
|
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
|
||||||
// percent-encoded again by the contextual escaper.
|
// percent-encoded again by the contextual escaper.
|
||||||
Robots bool
|
Robots bool
|
||||||
|
Deleted bool
|
||||||
NameMask string
|
NameMask string
|
||||||
ExternalIDMask string
|
ExternalIDMask string
|
||||||
EmailExact string
|
EmailExact string
|
||||||
|
|||||||
@@ -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
|
// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human
|
||||||
// opponent.
|
// opponent.
|
||||||
func TestDropAllRobotGames(t *testing.T) {
|
func TestDropAllRobotGames(t *testing.T) {
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
|||||||
page := consolePage(c)
|
page := consolePage(c)
|
||||||
filter := account.UserFilter{
|
filter := account.UserFilter{
|
||||||
Robots: c.Query("kind") == "robots",
|
Robots: c.Query("kind") == "robots",
|
||||||
|
Deleted: c.Query("kind") == "deleted",
|
||||||
NameMask: c.Query("name"),
|
NameMask: c.Query("name"),
|
||||||
ExternalIDMask: c.Query("ext"),
|
ExternalIDMask: c.Query("ext"),
|
||||||
EmailExact: c.Query("email"),
|
EmailExact: c.Query("email"),
|
||||||
@@ -148,6 +149,9 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
|||||||
if filter.Robots {
|
if filter.Robots {
|
||||||
q.Set("kind", "robots")
|
q.Set("kind", "robots")
|
||||||
}
|
}
|
||||||
|
if filter.Deleted {
|
||||||
|
q.Set("kind", "deleted")
|
||||||
|
}
|
||||||
if strings.TrimSpace(filter.NameMask) != "" {
|
if strings.TrimSpace(filter.NameMask) != "" {
|
||||||
q.Set("name", filter.NameMask)
|
q.Set("name", filter.NameMask)
|
||||||
}
|
}
|
||||||
@@ -159,7 +163,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
view := adminconsole.UsersView{
|
view := adminconsole.UsersView{
|
||||||
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
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,
|
EmailExact: filter.EmailExact,
|
||||||
FilterQuery: template.URL(q.Encode()),
|
FilterQuery: template.URL(q.Encode()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -644,6 +644,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
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 {
|
.addrow input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user