@@ -497,7 +529,14 @@ export function AdminMembers() {
>
-
+
@@ -574,6 +613,7 @@ export function AdminMembers() {
setExpanded(expanded === row.slug ? null : row.slug);
}}
onChanged={refresh}
+ onSearchDomain={(domain) => setParam('q', `@${domain}`)}
/>
))}
diff --git a/plans/roster-email-search.md b/plans/roster-email-search.md
new file mode 100644
index 0000000..a8e215b
--- /dev/null
+++ b/plans/roster-email-search.md
@@ -0,0 +1,43 @@
+---
+status: done
+depends: [roster-signals]
+specs:
+ - specs/api/moderation.md
+ - specs/screens/admin-members.md
+issues: []
+pr: 191
+---
+
+# Plan: roster search covers email; domain pivot
+
+## Scope
+
+Two things found on the first day of triage: searching for an email
+substring did nothing unless the query contained an `@` (an accidental gate
+in `listMembers`), and once one throwaway domain turns up staff want every
+other account from it in one click.
+
+In: search email for every query; the email's domain on each row is a button
+that searches `@domain` across the whole roster. Out: a domain histogram.
+
+## Implements
+
+- [api/moderation.md](../specs/api/moderation.md) — `q` matches email for
+ any query and applies to the whole roster before pagination.
+- [screens/admin-members.md](../specs/screens/admin-members.md) — the domain
+ button.
+
+## Approach
+
+Drop the `@` gate in `ModerationService.listMembers`; `EmailWithDomainSearch`
+in the page sets `q=@domain`; the search input remounts on `q` so the URL
+drives it.
+
+## Validation
+
+- API test: `q=example.org` returns both seeded members with that domain.
+- `type-check` + `lint`; moderation suite green.
+
+## Follow-ups
+
+None.
diff --git a/specs/api/moderation.md b/specs/api/moderation.md
index 43c44eb..780792e 100644
--- a/specs/api/moderation.md
+++ b/specs/api/moderation.md
@@ -21,7 +21,7 @@ a signal.
| Param | Type | Notes |
| ----- | ---- | ----- |
-| `q` | string | Full-text on `fullName`, `slug`, `bio`, and (staff-visible) `email`. |
+| `q` | string | Substring match on `fullName`, `slug`, `bio`, and (staff-visible) `email` — searched for every query, so `.com` finds every member on that domain. Applies to the whole roster, then paginates. |
| `vote` | enum | `none` (no human vote yet) \| `spam` \| `legit`. Filters on the **latest** human vote. |
| `origin` | enum | `imported` (has a laddr `legacyId`) \| `signed-up` (created on this site through GitHub). |
| `joinedAfter`, `joinedBefore` | ISO date | Inclusive bounds on `createdAt`. |
diff --git a/specs/screens/admin-members.md b/specs/screens/admin-members.md
index 8e06c2a..b1c4bd4 100644
--- a/specs/screens/admin-members.md
+++ b/specs/screens/admin-members.md
@@ -36,7 +36,9 @@ See [api/moderation.md](../api/moderation.md).
what it hid).
- Each row: avatar, `fullName` (link to the public profile, opens in a new tab),
`@slug`, then the **badges**, then "joined {createdAt relative}", sign-ins
- (`signed in 4× · last 2h ago` or `never signed in`), email (staff-visible),
+ (`signed in 4× · last 2h ago` or `never signed in`), email (staff-visible;
+ the domain is a button that searches `@domain` across the whole roster, so
+ one throwaway address leads to every account from that domain),
bio excerpt, compact footprint counts (`2 projects · 1 update · 3 tags`), and
the vote state:
- no vote → two buttons **Spam** / **Not spam**