diff --git a/admin/src/app/dashboard/MailboxesPage.test.tsx b/admin/src/app/dashboard/MailboxesPage.test.tsx new file mode 100644 index 000000000..0cd062445 --- /dev/null +++ b/admin/src/app/dashboard/MailboxesPage.test.tsx @@ -0,0 +1,35 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, expect, it, vi } from "vitest"; +import MailboxesPage from "./MailboxesPage"; + +const calls = vi.hoisted(() => ({ search: vi.fn(), queries: vi.fn() })); +vi.mock("@/lib/api/client/admin/mailboxes", () => ({ searchMailboxes: calls.search })); +vi.mock("@/lib/api/client/admin/fleetNodes", () => ({ listFleetNodes: vi.fn() })); +vi.mock("@/components/data/DataTable", () => ({ DataTable: () =>
Mailbox results
})); +vi.mock("@tanstack/react-query", () => ({ keepPreviousData: vi.fn(), useQuery: (options: { queryKey: string[]; queryFn: () => unknown }) => { + calls.queries(options); + if (options.queryKey[1] === "mailboxes") options.queryFn(); + return { data: { data: [], pagination: {} }, isLoading: false }; +} })); +afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); }); + +it("looks up an exact mailbox across inactive/moved assignments without old filters or placeholder rows", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const mailbox = "33333333-3333-4333-8333-333333333333"; + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + try { + await act(async () => root.render()); + expect(calls.search).toHaveBeenCalledWith({ mailbox_id: mailbox, status: "all", limit: 50 }); + expect(calls.queries.mock.calls.filter(([options]) => options.queryKey[1] === "mailboxes").every(([options]) => options.placeholderData === undefined)).toBe(true); + expect(host.textContent).toContain("Exact identity lookup"); + expect(host.textContent).toContain(mailbox); + expect(host.querySelector("input")).toBeNull(); + } finally { + await act(async () => root.unmount()); + host.remove(); + } +}); diff --git a/admin/src/app/dashboard/MailboxesPage.tsx b/admin/src/app/dashboard/MailboxesPage.tsx index 8febd31b3..c25f96770 100644 --- a/admin/src/app/dashboard/MailboxesPage.tsx +++ b/admin/src/app/dashboard/MailboxesPage.tsx @@ -177,6 +177,7 @@ export default function MailboxesPage() { const orgId = params.get("org") || undefined; const userId = params.get("user") || undefined; const workerParam = params.get("worker") || ""; + const mailboxId = params.get("mailbox_id") || undefined; // `?q=` seeds the search box so the command palette can land here on a // mailbox; a status of "all" keeps a disabled mailbox findable that way. @@ -225,7 +226,7 @@ export default function MailboxesPage() { ]; const filterKey = JSON.stringify({ - query, status, provider, warmup, workerId, riskBand, pool, synced, orgId, userId, + query, status, provider, warmup, workerId, riskBand, pool, synced, orgId, userId, mailboxId, warmupPaused, trackingVerified, hasTrackingDomain, hasOrg, signatureSync, hasOAuth, hasSmtp, capMin, capMax, gapMin, gapMax, connected, lastSynced, sort, }); @@ -237,7 +238,7 @@ export default function MailboxesPage() { const { data, isLoading, error, refetch } = useQuery({ queryKey: ["admin", "mailboxes", filterKey, pager.cursor], queryFn: () => - searchMailboxes({ + searchMailboxes(mailboxId ? { mailbox_id: mailboxId, status: "all", limit: 50 } : { q: query.trim() || undefined, status, provider: provider || undefined, @@ -270,7 +271,7 @@ export default function MailboxesPage() { sort_desc: sort.by ? sort.desc : undefined, }), staleTime: 30_000, - placeholderData: keepPreviousData, + placeholderData: mailboxId ? undefined : keepPreviousData, }); const rows = data?.data ?? []; @@ -330,6 +331,15 @@ export default function MailboxesPage() { setParams(next, { replace: true }); } + if (mailboxId) return
+ +

{mailboxId}

+ + m.id} loading={isLoading} error={error} + onRetry={() => refetch()} errorTitle="Mailbox lookup failed" noun="mailboxes" + emptyTitle="Mailbox not found" emptyHint="This mailbox is no longer registered on this instance." /> +
; + return (
)} - +
; } -export function NodeDiagnostics({ node, mailboxes }: { node: FleetNode; mailboxes: AdminWorkerEmail[] }) { +export function NodeDiagnostics({ node }: { node: FleetNode }) { const { data: me } = useMe(); const canViewBroker = !!me && hasAdminPerm(me.admin_permissions, AdminPerm.ViewAnalytics); const canViewMailboxes = !!me && hasAdminPerm(me.admin_permissions, AdminPerm.ViewUsers); @@ -121,7 +120,6 @@ export function NodeDiagnostics({ node, mailboxes }: { node: FleetNode; mailboxe
{events.map((event) => { - const mailbox = mailboxes.find((m) => m.id === event.mailbox_id); const mailboxID = isUUID(event.mailbox_id ?? "") ? event.mailbox_id : null; return @@ -129,7 +127,7 @@ export function NodeDiagnostics({ node, mailboxes }: { node: FleetNode; mailboxe - + ; })}
ObservedLevelEvidenceMailbox
{eventLabels[event.event].title}
{event.category.replaceAll("_", " ")} {event.event === "sync_control_plane_held" && safeCount(event.http_status) && event.http_status >= 100 && event.http_status <= 599 && ` · HTTP ${event.http_status}`} {safeCount(event.count) && event.count <= 1000000 && ` · count ${event.count}`}
{mailboxID ? mailbox && canViewMailboxes ? {mailboxID} : {mailboxID} : "Not provided"}{mailboxID ? canViewMailboxes ? {mailboxID} : {mailboxID} : "Not provided"}
} diff --git a/admin/src/lib/api/models/admin.ts b/admin/src/lib/api/models/admin.ts index 2c6648904..e1912ef89 100644 --- a/admin/src/lib/api/models/admin.ts +++ b/admin/src/lib/api/models/admin.ts @@ -190,6 +190,7 @@ export interface AdminMailboxesResult { } export interface AdminMailboxSearch { + mailbox_id?: string; q?: string; status?: string; provider?: string; diff --git a/docs/content/docs/api/authentication.mdx b/docs/content/docs/api/authentication.mdx index f01f8d8ac..76b5201cf 100644 --- a/docs/content/docs/api/authentication.mdx +++ b/docs/content/docs/api/authentication.mdx @@ -232,9 +232,10 @@ Returned when: This backend flow is separate from workspace API-key authorization. Platform admin access uses a normal bearer session, never an organization API key. -The admin UI and CLI implement this contract in a separate rollout stage; -these endpoints alone do not provide a consent page or CLI command. The CLI -never listens on an HTTP port or receives a browser redirect. +Use [remote `warmblyctl` login](/development/warmblyctl/#remote-administrator-commands) +and the administrator website's `/device` consent page. Deploy the backend +before upgrading the admin UI and CLI. The CLI never listens on an HTTP port +or receives a browser redirect. ### Endpoint contract @@ -298,8 +299,8 @@ and request. A different session cannot spend it. If decide returns again; never auto-approve when login/reauth returns. Poll no faster than every five seconds. Pending/denied responses contain -`status: "pending"` or `"denied"`, no `token`; the existing UUID serializer may -include a zero `user_id`, which is not an identity. An approving poll returns: +`status: "pending"` or `"denied"`, with neither `token` nor `user_id`. +An approving poll returns: ```json { diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index fd6a3ddb6..ec7696a5b 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -623,6 +623,7 @@ Alongside them, `api_url` is this API's own public base (for a copyable example - `GET /admin/warmup-content/overview` includes effective generation controls and requires `view_warmup_pool`; `PUT /admin/warmup-content/settings` writes effective controls with `manage_settings`. These are platform-admin routes, not organization API-key scopes. See [effective generation policy and stopping](/development/warmup-generation/). - `POST /admin/organizations/:id/sample-data`: `manage_testers` and an MFA-verified admin session. Adds the [reviewer sample dataset](/development/accounts-and-access/#adding-sample-data) only to an active dedicated Test workspace. No request body. Returns `200` with `organization_id`, `created` (false when already seeded) and `seeded_at`; retries preserve edits and deletions. An ordinary workspace, revoked or expired tester, or missing/expired managed Test grant returns `400`. Inserts and the admin audit marker commit together. No mailbox connection, email delivery, external request or credit spend occurs. - `GET /admin/testers`: `view_users` and an MFA-verified admin session. Each tester in `data` may include `test_workspace_id` for its owned dedicated Test workspace and `sample_data_seeded_at` when populated. These fields are absent for a tester merely attached to an ordinary workspace. + - `GET /admin/mailboxes`: `view_users` and an MFA-verified admin session. Optional `mailbox_id` filters by exact UUID; `status=all` includes inactive mailboxes. Node evidence links use `/mailboxes?mailbox_id=UUID`, regardless of loaded worker assignments or earlier browse filters. Deleted identities return an empty result, not a substitute mailbox. `POST /subscription/checkout`, `POST /subscription/portal`, `POST /subscription/cancel`, `POST /subscription/change-plan` and `GET /subscription/preview-change` require the `MANAGE_BILLING` organization permission; reading the subscription, its limits, trial and features needs only membership. `POST /subscription/portal` requires an existing Stripe billing customer. A workspace without one receives `400` with code `bad_request` and a message to complete checkout first; free and operator-granted plans alone do not create a billing customer. diff --git a/internal/models/admin.go b/internal/models/admin.go index 09045a903..a7a494bfd 100644 --- a/internal/models/admin.go +++ b/internal/models/admin.go @@ -435,6 +435,7 @@ type AdminMailboxesResult struct { // All fields optional; empty status = active, "all" returns every // status. provider lets ops filter to "all gmail mailboxes" quickly. type AdminMailboxSearch struct { + MailboxID *ParamUUID `form:"mailbox_id"` Query string `form:"q"` Status string `form:"status"` Provider string `form:"provider"` diff --git a/internal/models/param_uuid_test.go b/internal/models/param_uuid_test.go index 5699fa319..1607b291b 100644 --- a/internal/models/param_uuid_test.go +++ b/internal/models/param_uuid_test.go @@ -38,6 +38,20 @@ func TestParamUUIDRejectsGarbage(t *testing.T) { } } +func TestAdminMailboxExactIdentityBindsAndRejectsInvalidUUID(t *testing.T) { + id := uuid.New() + var search AdminMailboxSearch + if err := bindQuery(t, "mailbox_id="+id.String()+"&status=all", &search); err != nil { + t.Fatal(err) + } + if search.MailboxID == nil || search.MailboxID.UUID != id || search.Status != "all" || search.WorkerID != nil { + t.Fatalf("exact mailbox lookup changed identity or placement scope: %+v", search) + } + if err := bindQuery(t, "mailbox_id=not-a-uuid", &AdminMailboxSearch{}); err == nil { + t.Fatal("invalid exact mailbox identity accepted") + } +} + // Every admin search struct must bind its ids and cursor together, so a field // typed as a bare uuid.UUID cannot 400 an explorer again. func TestAdminSearchStructsBindIDsAndCursor(t *testing.T) { diff --git a/internal/repository/pg_admin.go b/internal/repository/pg_admin.go index abdb8b66f..ef4833a1e 100644 --- a/internal/repository/pg_admin.go +++ b/internal/repository/pg_admin.go @@ -1913,6 +1913,11 @@ func (r *adminRepository) SearchMailboxesForAdmin(ctx context.Context, search *m args := []interface{}{} argNum := 1 where := "WHERE 1=1" + if search.MailboxID != nil { + where += " AND ea.id = $" + itoa(argNum) + args = append(args, search.MailboxID.UUID) + argNum++ + } if search.Query != "" { where += ` AND (ea.email ILIKE $` + itoa(argNum) + diff --git a/qa/flows/admin-operations.flow.ts b/qa/flows/admin-operations.flow.ts index b14c2ff61..0b1024121 100644 --- a/qa/flows/admin-operations.flow.ts +++ b/qa/flows/admin-operations.flow.ts @@ -122,7 +122,7 @@ test("admin CLI consent and redacted node diagnostics with disclosed local respo await expect(logs.getByRole("table", { name: "Redacted node events", exact: true })).toBeVisible(); await expect(logs.getByText("Control-plane sync held", { exact: true })).toBeVisible(); await expect(logs).toContainText("HTTP 503"); await expect(logs).toContainText("Partial coverage"); await expect(logs).toContainText("per process run"); - await expect(logs.getByRole("link", { name: mailbox, exact: true })).toHaveAttribute("href", new RegExp("/mailboxes\\?q=fixture-mailbox%40example.test")); + await expect(logs.getByRole("link", { name: mailbox, exact: true })).toHaveAttribute("href", `/mailboxes?mailbox_id=${mailbox}`); await proof.chapter("Redacted operational evidence", "All node capture/log/offset responses are synthetic localhost fixtures. They do not establish production sampling, provider health, delivery or the cause of an arrival 503."); await logs.getByRole("table", { name: "Redacted node events", exact: true }).scrollIntoViewIfNeeded(); await proof.shot("admin-node-redacted-evidence", { caption: "Synthetic capture and allowlisted events: timestamps, partial coverage, retention bounds, three known per-run drops, numeric control-plane HTTP 503 and native mailbox link." });