From 2b92abd185acf8f326638cab3281011a7f855d2b Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Mon, 24 Aug 2026 09:36:35 -0300 Subject: [PATCH] feat(admin): paginacao na interface, edicao de utilizadores e pagina de testes - fila e utilizadores passam a pedir uma pagina de cada vez, com pesquisa e filtros; nada e cortado no browser, porque o que faltava nunca chegou a ser buscado. A pesquisa espera que se pare de escrever: cada tecla seria mais uma consulta, e as respostas podem chegar fora de ordem - pageWindow e pageRange tem testes proprios: uma pagina fora do intervalo vem de um URL velho ou de uma lista que encolheu, e nao pode dar um paginador vazio ou negativo, nem dizer "76 a 100 de 82" - editar utilizador: papel, quota, idioma, com os apelidos que entregam naquela caixa a mostra no mesmo painel - "Caixas de e-mail" passa a chamar-se Utilizadores, que e o que sao Pagina nova /admin/tests: verificacoes que o operador corre a mao, sobre o servidor ou sobre um utilizador. Todas leem estado; nenhuma envia correio nem altera definicoes -- uma pagina de diagnostico que mexe naquilo que diagnostica e pior do que nao existir. As regras de cada verificacao sao funcoes puras com testes, separadas da recolha dos factos. Alguns casos que estes testes fixam: uma quota zero significa sem limite, e um racio marcaria todas as caixas ilimitadas como cheias; um bloqueio ja expirado nao bloqueia ninguem e nao deve mandar ninguem investigar; e uma resposta de ausencia ligada com o periodo ja passado esta ligada sem fazer nada, que e o que parece avariado a quem a pos. Co-Authored-By: Claude Opus 5 --- services/auth/src/diagnostics.test.ts | 117 +++++++ services/auth/src/diagnostics.ts | 168 ++++++++++ services/auth/src/repository.ts | 69 ++++ services/auth/src/router.test.ts | 301 ++++++++++-------- services/auth/src/router.ts | 51 +++ services/webmail/messages/en.json | 54 +++- services/webmail/messages/es.json | 54 +++- services/webmail/messages/pt-BR.json | 54 +++- .../webmail/src/app/(admin)/admin/layout.tsx | 2 + .../src/app/(admin)/admin/mailboxes/page.tsx | 260 ++++++++++++++- .../src/app/(admin)/admin/queue/page.tsx | 66 +++- .../src/app/(admin)/admin/tests/page.tsx | 217 +++++++++++++ .../webmail/src/components/ui/Pagination.tsx | 76 +++++ services/webmail/src/lib/paging.test.ts | 50 +++ services/webmail/src/lib/paging.ts | 24 ++ services/webmail/src/lib/useDebounced.ts | 21 ++ 16 files changed, 1431 insertions(+), 153 deletions(-) create mode 100644 services/auth/src/diagnostics.test.ts create mode 100644 services/auth/src/diagnostics.ts create mode 100644 services/webmail/src/app/(admin)/admin/tests/page.tsx create mode 100644 services/webmail/src/components/ui/Pagination.tsx create mode 100644 services/webmail/src/lib/paging.test.ts create mode 100644 services/webmail/src/lib/paging.ts create mode 100644 services/webmail/src/lib/useDebounced.ts diff --git a/services/auth/src/diagnostics.test.ts b/services/auth/src/diagnostics.test.ts new file mode 100644 index 0000000..9494a23 --- /dev/null +++ b/services/auth/src/diagnostics.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { checkUser, checkServer, overallStatus, type UserFacts, type ServerFacts } from "./diagnostics.js"; + +const NOW = new Date("2026-06-15T12:00:00Z"); + +const user = (over: Partial = {}): UserFacts => ({ + email: "ana@example.test", + isActive: true, + lockedUntil: null, + quotaBytes: 1000, + usedBytes: 100, + mfaEnrolled: true, + aliasCount: 0, + sieveScriptBytes: null, + vacationEnabled: false, + vacationStart: null, + vacationEnd: null, + passwordHashPresent: true, + ...over, +}); + +const keys = (checks: { key: string }[]) => checks.map((c) => c.key); +const statusOf = (checks: { key: string; status: string }[], key: string) => + checks.find((c) => c.key === key)?.status; + +describe("user checks", () => { + it("passes a healthy account", () => { + const checks = checkUser(user(), NOW); + expect(overallStatus(checks)).toBe("PASS"); + }); + + it("fails a disabled account", () => { + expect(statusOf(checkUser(user({ isActive: false }), NOW), "userDisabled")).toBe("FAIL"); + }); + + // A lockout that has already elapsed no longer blocks anyone; reporting it + // as a failure sends an operator chasing a problem that resolved itself. + it("distinguishes a spent lockout from a live one", () => { + const spent = checkUser(user({ lockedUntil: "2026-06-15T11:00:00Z" }), NOW); + expect(keys(spent)).toContain("userNotLockedOut"); + + const live = checkUser(user({ lockedUntil: "2026-06-15T13:00:00Z" }), NOW); + expect(statusOf(live, "userLockedOut")).toBe("FAIL"); + }); + + // Zero means unlimited. A ratio would divide by zero and mark every + // unlimited mailbox as full. + it("does not call an unlimited mailbox full", () => { + const checks = checkUser(user({ quotaBytes: 0, usedBytes: 5_000_000 }), NOW); + expect(keys(checks)).toContain("quotaUnlimited"); + expect(keys(checks)).not.toContain("quotaFull"); + }); + + it("warns before the quota is reached and fails once it is", () => { + expect(statusOf(checkUser(user({ quotaBytes: 100, usedBytes: 95 }), NOW), "quotaNearlyFull")).toBe("WARN"); + expect(statusOf(checkUser(user({ quotaBytes: 100, usedBytes: 100 }), NOW), "quotaFull")).toBe("FAIL"); + }); + + // Switched on with a window that has closed is the state that reads as + // broken to whoever set it, so it is called out rather than shown as active. + it("notices an autoresponder whose window has passed", () => { + const checks = checkUser( + user({ vacationEnabled: true, vacationEnd: "2026-01-01T00:00:00Z" }), + NOW, + ); + expect(statusOf(checks, "vacationWindowPast")).toBe("WARN"); + }); + + it("does not report an autoresponder that is switched off", () => { + expect(keys(checkUser(user(), NOW)).some((k) => k.startsWith("vacation"))).toBe(false); + }); +}); + +const server = (over: Partial = {}): ServerFacts => ({ + domains: [{ name: "example.test", dnsStatus: "VERIFIED", daneEnabled: false }], + queueByStatus: {}, + usersOverQuota: 0, + usersTotal: 3, + domainsWithoutDkim: [], + ...over, +}); + +describe("server checks", () => { + it("passes a healthy server", () => { + expect(overallStatus(checkServer(server()))).toBe("PASS"); + }); + + it("fails a domain whose DNS is not verified", () => { + const checks = checkServer(server({ domains: [{ name: "bad.test", dnsStatus: "ERROR", daneEnabled: false }] })); + expect(statusOf(checks, "dnsNotVerified")).toBe("FAIL"); + }); + + // Unsigned mail from a domain that publishes DMARC is treated as a failure + // by most large receivers, so a missing key is not a warning. + it("fails a domain with no DKIM key", () => { + expect(statusOf(checkServer(server({ domainsWithoutDkim: ["example.test"] })), "dkimMissing")).toBe("FAIL"); + }); + + it("reports frozen jobs as a failure and deferred ones as a warning", () => { + const checks = checkServer(server({ queueByStatus: { FROZEN: 2, DEFERRED: 5 } })); + expect(statusOf(checks, "queueHasFrozen")).toBe("FAIL"); + expect(statusOf(checks, "queueHasDeferred")).toBe("WARN"); + }); + + it("says so when there are no domains at all", () => { + expect(statusOf(checkServer(server({ domains: [] })), "noDomains")).toBe("WARN"); + }); +}); + +describe("overall status", () => { + it("reports the worst thing present", () => { + expect(overallStatus([{ key: "a", status: "PASS" }, { key: "b", status: "WARN" }])).toBe("WARN"); + expect(overallStatus([{ key: "a", status: "WARN" }, { key: "b", status: "FAIL" }])).toBe("FAIL"); + expect(overallStatus([{ key: "a", status: "INFO" }])).toBe("INFO"); + expect(overallStatus([])).toBe("INFO"); + }); +}); diff --git a/services/auth/src/diagnostics.ts b/services/auth/src/diagnostics.ts new file mode 100644 index 0000000..7dbb4a9 --- /dev/null +++ b/services/auth/src/diagnostics.ts @@ -0,0 +1,168 @@ +/** + * Checks an operator can run by hand, against the whole server or one user. + * + * Each check is a pure function over facts already gathered, so the rules can + * be tested without a database and without sending mail. Gathering the facts + * is the repository's job; deciding what they mean is here. + */ + +export type CheckStatus = "PASS" | "WARN" | "FAIL" | "INFO"; + +export interface Check { + /** Stable key: the console translates it, so no English is built here. */ + key: string; + status: CheckStatus; + /** What the check looked at - an address, a domain, a count. */ + subject?: string; + detail?: string; +} + +export interface UserFacts { + email: string; + isActive: boolean; + lockedUntil: string | null; + quotaBytes: number; + usedBytes: number; + mfaEnrolled: boolean; + aliasCount: number; + sieveScriptBytes: number | null; + vacationEnabled: boolean; + vacationStart: string | null; + vacationEnd: string | null; + passwordHashPresent: boolean; +} + +/** Quota headroom below this is worth saying out loud before mail bounces. */ +export const QUOTA_WARN_RATIO = 0.9; + +export function checkUser(facts: UserFacts, now: Date = new Date()): Check[] { + const checks: Check[] = []; + + checks.push({ + key: facts.isActive ? "userActive" : "userDisabled", + status: facts.isActive ? "PASS" : "FAIL", + subject: facts.email, + }); + + // A lockout in the past is spent; only a future one still blocks sign-in. + const locked = facts.lockedUntil !== null && new Date(facts.lockedUntil) > now; + checks.push({ + key: locked ? "userLockedOut" : "userNotLockedOut", + status: locked ? "FAIL" : "PASS", + detail: locked ? String(facts.lockedUntil) : undefined, + }); + + checks.push({ + key: facts.passwordHashPresent ? "userHasPassword" : "userHasNoPassword", + status: facts.passwordHashPresent ? "PASS" : "FAIL", + }); + + // A zero quota means unlimited here, so a ratio would divide by zero and + // report every unlimited mailbox as full. + if (facts.quotaBytes > 0) { + const ratio = facts.usedBytes / facts.quotaBytes; + checks.push({ + key: ratio >= 1 ? "quotaFull" : ratio >= QUOTA_WARN_RATIO ? "quotaNearlyFull" : "quotaHealthy", + status: ratio >= 1 ? "FAIL" : ratio >= QUOTA_WARN_RATIO ? "WARN" : "PASS", + detail: `${Math.round(ratio * 100)}%`, + }); + } else { + checks.push({ key: "quotaUnlimited", status: "INFO" }); + } + + checks.push({ + key: facts.mfaEnrolled ? "mfaEnrolled" : "mfaNotEnrolled", + status: facts.mfaEnrolled ? "PASS" : "INFO", + }); + + checks.push({ + key: "aliasesDelivering", + status: "INFO", + detail: String(facts.aliasCount), + }); + + if (facts.sieveScriptBytes !== null) { + checks.push({ + key: "sieveScriptPresent", + status: "INFO", + detail: `${facts.sieveScriptBytes} B`, + }); + } + + if (facts.vacationEnabled) { + // An autoresponder whose window has already closed is switched on and + // doing nothing, which reads as broken to whoever set it. + const ended = facts.vacationEnd !== null && new Date(facts.vacationEnd) < now; + const notYet = facts.vacationStart !== null && new Date(facts.vacationStart) > now; + checks.push({ + key: ended ? "vacationWindowPast" : notYet ? "vacationWindowFuture" : "vacationActive", + status: ended ? "WARN" : "INFO", + }); + } + + return checks; +} + +export interface ServerFacts { + domains: { name: string; dnsStatus: string; daneEnabled: boolean }[]; + queueByStatus: Record; + usersOverQuota: number; + usersTotal: number; + domainsWithoutDkim: string[]; +} + +export function checkServer(facts: ServerFacts): Check[] { + const checks: Check[] = []; + + for (const d of facts.domains) { + checks.push({ + key: d.dnsStatus === "VERIFIED" ? "dnsVerified" : "dnsNotVerified", + status: d.dnsStatus === "VERIFIED" ? "PASS" : d.dnsStatus === "PENDING" ? "WARN" : "FAIL", + subject: d.name, + detail: d.dnsStatus, + }); + } + if (facts.domains.length === 0) { + checks.push({ key: "noDomains", status: "WARN" }); + } + + for (const name of facts.domainsWithoutDkim) { + // Without a DKIM key the domain signs nothing, and most large receivers + // treat unsigned mail from a domain that publishes DMARC as a failure. + checks.push({ key: "dkimMissing", status: "FAIL", subject: name }); + } + + const bounced = facts.queueByStatus.BOUNCED ?? 0; + const deferred = facts.queueByStatus.DEFERRED ?? 0; + const frozen = facts.queueByStatus.FROZEN ?? 0; + + checks.push({ + key: bounced > 0 ? "queueHasBounces" : "queueNoBounces", + status: bounced > 0 ? "WARN" : "PASS", + detail: String(bounced), + }); + checks.push({ + key: deferred > 0 ? "queueHasDeferred" : "queueNoDeferred", + status: deferred > 0 ? "WARN" : "PASS", + detail: String(deferred), + }); + if (frozen > 0) { + checks.push({ key: "queueHasFrozen", status: "FAIL", detail: String(frozen) }); + } + + checks.push({ + key: facts.usersOverQuota > 0 ? "usersOverQuota" : "noUsersOverQuota", + status: facts.usersOverQuota > 0 ? "WARN" : "PASS", + detail: `${facts.usersOverQuota}/${facts.usersTotal}`, + }); + + return checks; +} + +/** The worst status present, which is what a summary badge should show. */ +export function overallStatus(checks: Check[]): CheckStatus { + if (checks.some((c) => c.status === "FAIL")) return "FAIL"; + if (checks.some((c) => c.status === "WARN")) return "WARN"; + if (checks.some((c) => c.status === "PASS")) return "PASS"; + return "INFO"; +} diff --git a/services/auth/src/repository.ts b/services/auth/src/repository.ts index 78c6d72..ffc3a20 100644 --- a/services/auth/src/repository.ts +++ b/services/auth/src/repository.ts @@ -917,6 +917,75 @@ export async function listAliasesForMailbox( ); } +/** Everything one user's checks need, in a single pass over the database. */ +export async function userDiagnosticFacts( + scope: AdminScope, + mailboxId: string, +): Promise | null> { + const { sql, params } = scopeClause(scope, "m.domain_id"); + const row = await queryOne>( + `SELECT m.email_address, m.is_active, m.locked_until, + m.quota_bytes::text AS quota_bytes, m.used_bytes::text AS used_bytes, + (m.password_hash IS NOT NULL AND m.password_hash <> '') AS password_hash_present, + EXISTS (SELECT 1 FROM mfa_totp t WHERE t.mailbox_id = m.id AND t.status = 'CONFIRMED') AS mfa_enrolled, + (SELECT count(*) FROM aliases a + WHERE a.domain_id = m.domain_id + AND EXISTS (SELECT 1 FROM unnest(a.destination_addresses) d + WHERE lower(d) = lower(m.email_address)))::int AS alias_count, + (SELECT length(sc.script) FROM sieve_scripts sc + WHERE sc.mailbox_id = m.id AND sc.is_active + AND sc.name <> 'vacation-autoresponder' LIMIT 1) AS sieve_bytes, + EXISTS (SELECT 1 FROM sieve_scripts sc + WHERE sc.mailbox_id = m.id AND sc.is_active + AND sc.name = 'vacation-autoresponder') AS vacation_enabled + FROM mailboxes m + WHERE m.id = $${params.length + 1} AND ${sql}`, + [...params, mailboxId], + ); + return row ?? null; +} + +/** Everything the server-wide checks need. */ +export async function serverDiagnosticFacts(scope: AdminScope): Promise> { + const { sql, params } = scopeClause(scope, "d.id"); + const domains = await query<{ name: string; dns_status: string; dane_enabled: boolean }>( + `SELECT d.name, d.dns_status, d.dane_enabled FROM domains d WHERE ${sql} ORDER BY d.name`, + params, + ); + + const withoutDkim = await query<{ name: string }>( + `SELECT d.name FROM domains d + WHERE ${sql} + AND NOT EXISTS (SELECT 1 FROM dkim_keys k WHERE k.domain_id = d.id) + ORDER BY d.name`, + params, + ); + + const queue = await query<{ status: string; count: string }>( + `SELECT status, count(*)::text AS count FROM outbound_jobs GROUP BY status`, + ); + + const { sql: mSql, params: mParams } = scopeClause(scope, "m.domain_id"); + const quota = await queryOne<{ over: string; total: string }>( + `SELECT count(*) FILTER (WHERE m.quota_bytes > 0 AND m.used_bytes >= m.quota_bytes)::text AS over, + count(*)::text AS total + FROM mailboxes m WHERE ${mSql}`, + mParams, + ); + + return { + domains: domains.map((d) => ({ + name: d.name, + dnsStatus: d.dns_status, + daneEnabled: d.dane_enabled, + })), + domainsWithoutDkim: withoutDkim.map((d) => d.name), + queueByStatus: Object.fromEntries(queue.map((r) => [r.status, Number(r.count)])), + usersOverQuota: Number(quota?.over ?? 0), + usersTotal: Number(quota?.total ?? 0), + }; +} + export type AliasResult = { status: "OK"; id: string } | { status: "FORBIDDEN" } | { status: "DUPLICATE" } | { status: "INVALID" }; export async function createAlias( diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts index f4dcebb..ed782eb 100644 --- a/services/auth/src/router.test.ts +++ b/services/auth/src/router.test.ts @@ -668,147 +668,172 @@ describeDb("auth API against a real database", () => { await query(`DELETE FROM domains WHERE name = $1`, [domainName]); }, 90000); + + describe("paging, filtering and editing users", () => { + // A domain of its own, so these row counts cannot disturb the tests above + // that assert on the seeded one. Signing in costs seconds of deliberate + // password hashing, so it is done once for the whole suite. + const PAGED_DOMAIN = "55555555-5555-5555-5555-555555555555"; + let admin: string; + + beforeAll(async () => { + admin = await adminSession(); + const hash = await hashPassword(PASSWORD); + const name = `pagedtest-${PAGED_DOMAIN}.invalid`; + await query(`DELETE FROM domains WHERE id = $1`, [PAGED_DOMAIN]); + await query(`INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`, [PAGED_DOMAIN, name, name]); + // Enough rows that one hardcoded page cannot hold them, which is the + // condition the console could not cope with at all. + for (let i = 0; i < 30; i++) { + const local = `paged${String(i).padStart(2, "0")}`; + await query( + `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, role, is_active) + VALUES (gen_random_uuid(), $1, $2, $3, $4, 'USER', $5)`, + [PAGED_DOMAIN, local, `${local}@${name}`, hash, i % 2 === 0], + ); + } + }, 90000); + + afterAll(async () => { + await query(`DELETE FROM domains WHERE id = $1`, [PAGED_DOMAIN]).catch(() => undefined); + }); + + + + + it("returns one page at a time, with the total of the whole set", async () => { + const first = await get("/api/v1/admin/mailboxes?page=1&page_size=10", admin); + + expect(first.status).toBe(200); + const body = first.body as unknown as { items: unknown[]; total: number; total_pages: number }; + expect(body.items).toHaveLength(10); + // The total describes every row, not the ten that came back; otherwise + // the page control cannot know there is anything after this page. + expect(body.total).toBeGreaterThanOrEqual(32); + expect(body.total_pages).toBeGreaterThan(1); + }); + + it("gives a different page for a different page number", async () => { + const one = await get("/api/v1/admin/mailboxes?page=1&page_size=5", admin); + const two = await get("/api/v1/admin/mailboxes?page=2&page_size=5", admin); + + const ids = (r: typeof one) => (r.body as unknown as { items: { id: string }[] }).items.map((i) => i.id); + expect(ids(one)).not.toEqual(ids(two)); + expect(ids(one).some((id) => ids(two).includes(id))).toBe(false); + }); + + it("filters in the database rather than returning everything", async () => { + const res = await get("/api/v1/admin/mailboxes?search=paged1", admin); + const body = res.body as unknown as { items: { email_address: string }[]; total: number }; + + expect(body.total).toBe(10); // paged10..paged19 + for (const item of body.items) { + expect(item.email_address).toContain("paged1"); + } + }); + + it("filters by whether the user is enabled", async () => { + const res = await get("/api/v1/admin/mailboxes?active=false&page_size=200", admin); + const body = res.body as unknown as { items: { is_active: boolean }[] }; + + expect(body.items.length).toBeGreaterThan(0); + for (const item of body.items) expect(item.is_active).toBe(false); + }); + + // A search term is bound as a parameter. If it were interpolated, this ends + // the statement and drops the table. + it("does not let a search term reach the SQL text", async () => { + const evil = encodeURIComponent("'; DROP TABLE mailboxes; --"); + const res = await get(`/api/v1/admin/mailboxes?search=${evil}`, admin); + + expect(res.status).toBe(200); + expect((res.body as unknown as { total: number }).total).toBe(0); + const still = await query(`SELECT count(*)::int AS c FROM mailboxes`); + expect((still[0] as { c: number }).c).toBeGreaterThan(0); + }); + + it("edits a user instead of only disabling them", async () => { + const res = await fetch(`${base}/api/v1/admin/mailboxes/${USER_ID}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin}` }, + body: JSON.stringify({ quota_bytes: 12345678, locale: "pt-BR", role: "DOMAIN_ADMIN" }), + }); + expect(res.status).toBe(200); + + const rows = await query<{ quota_bytes: string; locale: string; role: string }>( + `SELECT quota_bytes::text, locale, role FROM mailboxes WHERE id = $1`, + [USER_ID], + ); + expect(Number(rows[0].quota_bytes)).toBe(12345678); + expect(rows[0].locale).toBe("pt-BR"); + expect(rows[0].role).toBe("DOMAIN_ADMIN"); + + await query(`UPDATE mailboxes SET role = 'USER' WHERE id = $1`, [USER_ID]); + }); + + it("refuses a quota that is not a positive whole number", async () => { + const res = await fetch(`${base}/api/v1/admin/mailboxes/${USER_ID}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin}` }, + body: JSON.stringify({ quota_bytes: -1 }), + }); + expect(res.status).toBe(400); + }); + + it("lists the aliases that deliver to one user", async () => { + const mine = `alias-of-user@authtest-${DOMAIN_ID}.invalid`; + const other = `alias-of-nobody@authtest-${DOMAIN_ID}.invalid`; + await query( + `INSERT INTO aliases (domain_id, source_address, destination_addresses) + VALUES ($1, $2, ARRAY[$3]), ($1, $4, ARRAY['somebody-else@elsewhere.invalid']) + ON CONFLICT DO NOTHING`, + [DOMAIN_ID, mine, userEmail(), other], + ); + + const res = await get(`/api/v1/admin/mailboxes/${USER_ID}/aliases`, admin); + expect(res.status).toBe(200); + const items = res.body as unknown as { source_address: string }[]; + + expect(items.map((a) => a.source_address)).toContain(mine); + // An alias pointing somewhere else is not this user's, even though it + // belongs to the same domain. + expect(items.map((a) => a.source_address)).not.toContain(other); + }); + + it("runs the server checks and reports the worst thing it found", async () => { + const res = await get("/api/v1/admin/diagnostics/server", admin); + + expect(res.status).toBe(200); + const body = res.body as unknown as { status: string; checks: { key: string }[] }; + expect(body.checks.length).toBeGreaterThan(0); + expect(["PASS", "WARN", "FAIL", "INFO"]).toContain(body.status); + // The seeded domain has no DKIM key, which is a real finding rather than + // something the page should stay quiet about. + expect(body.checks.map((c) => c.key)).toContain("dkimMissing"); + }); + + it("runs the checks for one user", async () => { + const res = await get(`/api/v1/admin/diagnostics/user/${USER_ID}`, admin); + + expect(res.status).toBe(200); + const body = res.body as unknown as { subject: string; checks: { key: string }[] }; + expect(body.subject).toBe(userEmail()); + expect(body.checks.map((c) => c.key)).toContain("userActive"); + }); + + it("refuses diagnostics for a user that does not exist", async () => { + const res = await get("/api/v1/admin/diagnostics/user/44444444-4444-4444-4444-444444444444", admin); + expect(res.status).toBe(404); + }); + + it("answers 404 for a user that does not exist rather than leaking the difference", async () => { + const res = await get(`/api/v1/admin/mailboxes/44444444-4444-4444-4444-444444444444/aliases`, admin); + expect(res.status).toBe(404); + }); + }); }); // Guards that need no database, so they still run in a bare checkout. -describeDb("paging, filtering and editing users", () => { - beforeAll(async () => { - await startServer(); - await seed(); - // Enough rows that a single hardcoded page cannot hold them, which is the - // condition the console could not cope with at all. - const hash = await hashPassword(PASSWORD); - for (let i = 0; i < 30; i++) { - const local = `paged${String(i).padStart(2, "0")}`; - await query( - `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, role, is_active) - VALUES (gen_random_uuid(), $1, $2, $3, $4, 'USER', $5) - ON CONFLICT DO NOTHING`, - [DOMAIN_ID, local, `${local}@authtest-${DOMAIN_ID}.invalid`, hash, i % 2 === 0], - ); - } - }); - - afterAll(async () => { - server?.close(); - await query(`DELETE FROM domains WHERE id = $1`, [DOMAIN_ID]).catch(() => undefined); - await closePool(); - }); - - it("returns one page at a time, with the total of the whole set", async () => { - const admin = await adminSession(); - const first = await get("/api/v1/admin/mailboxes?page=1&page_size=10", admin); - - expect(first.status).toBe(200); - const body = first.body as unknown as { items: unknown[]; total: number; total_pages: number }; - expect(body.items).toHaveLength(10); - // The total describes every row, not the ten that came back; otherwise - // the page control cannot know there is anything after this page. - expect(body.total).toBeGreaterThanOrEqual(32); - expect(body.total_pages).toBeGreaterThan(1); - }); - - it("gives a different page for a different page number", async () => { - const admin = await adminSession(); - const one = await get("/api/v1/admin/mailboxes?page=1&page_size=5", admin); - const two = await get("/api/v1/admin/mailboxes?page=2&page_size=5", admin); - - const ids = (r: typeof one) => (r.body as unknown as { items: { id: string }[] }).items.map((i) => i.id); - expect(ids(one)).not.toEqual(ids(two)); - expect(ids(one).some((id) => ids(two).includes(id))).toBe(false); - }); - - it("filters in the database rather than returning everything", async () => { - const admin = await adminSession(); - const res = await get("/api/v1/admin/mailboxes?search=paged1", admin); - const body = res.body as unknown as { items: { email_address: string }[]; total: number }; - - expect(body.total).toBe(10); // paged10..paged19 - for (const item of body.items) { - expect(item.email_address).toContain("paged1"); - } - }); - - it("filters by whether the user is enabled", async () => { - const admin = await adminSession(); - const res = await get("/api/v1/admin/mailboxes?active=false&page_size=200", admin); - const body = res.body as unknown as { items: { is_active: boolean }[] }; - - expect(body.items.length).toBeGreaterThan(0); - for (const item of body.items) expect(item.is_active).toBe(false); - }); - - // A search term is bound as a parameter. If it were interpolated, this ends - // the statement and drops the table. - it("does not let a search term reach the SQL text", async () => { - const admin = await adminSession(); - const evil = encodeURIComponent("'; DROP TABLE mailboxes; --"); - const res = await get(`/api/v1/admin/mailboxes?search=${evil}`, admin); - - expect(res.status).toBe(200); - expect((res.body as unknown as { total: number }).total).toBe(0); - const still = await query(`SELECT count(*)::int AS c FROM mailboxes`); - expect((still[0] as { c: number }).c).toBeGreaterThan(0); - }); - - it("edits a user instead of only disabling them", async () => { - const admin = await adminSession(); - const res = await fetch(`${base}/api/v1/admin/mailboxes/${USER_ID}`, { - method: "PATCH", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin}` }, - body: JSON.stringify({ quota_bytes: 12345678, locale: "pt-BR", role: "DOMAIN_ADMIN" }), - }); - expect(res.status).toBe(200); - - const rows = await query<{ quota_bytes: string; locale: string; role: string }>( - `SELECT quota_bytes::text, locale, role FROM mailboxes WHERE id = $1`, - [USER_ID], - ); - expect(Number(rows[0].quota_bytes)).toBe(12345678); - expect(rows[0].locale).toBe("pt-BR"); - expect(rows[0].role).toBe("DOMAIN_ADMIN"); - - await query(`UPDATE mailboxes SET role = 'USER' WHERE id = $1`, [USER_ID]); - }); - - it("refuses a quota that is not a positive whole number", async () => { - const admin = await adminSession(); - const res = await fetch(`${base}/api/v1/admin/mailboxes/${USER_ID}`, { - method: "PATCH", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin}` }, - body: JSON.stringify({ quota_bytes: -1 }), - }); - expect(res.status).toBe(400); - }); - - it("lists the aliases that deliver to one user", async () => { - const admin = await adminSession(); - const mine = `alias-of-user@authtest-${DOMAIN_ID}.invalid`; - const other = `alias-of-nobody@authtest-${DOMAIN_ID}.invalid`; - await query( - `INSERT INTO aliases (domain_id, source_address, destination_addresses) - VALUES ($1, $2, ARRAY[$3]), ($1, $4, ARRAY['somebody-else@elsewhere.invalid']) - ON CONFLICT DO NOTHING`, - [DOMAIN_ID, mine, userEmail(), other], - ); - - const res = await get(`/api/v1/admin/mailboxes/${USER_ID}/aliases`, admin); - expect(res.status).toBe(200); - const items = res.body as unknown as { source_address: string }[]; - - expect(items.map((a) => a.source_address)).toContain(mine); - // An alias pointing somewhere else is not this user's, even though it - // belongs to the same domain. - expect(items.map((a) => a.source_address)).not.toContain(other); - }); - - it("answers 404 for a user that does not exist rather than leaking the difference", async () => { - const admin = await adminSession(); - const res = await get(`/api/v1/admin/mailboxes/44444444-4444-4444-4444-444444444444/aliases`, admin); - expect(res.status).toBe(404); - }); -}); - describe("surface routing", () => { it("ignores anything outside /api/v1/", async () => { const { handleApiRequest: handler } = await import("./router.js"); diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts index 576ad86..9dcf4f9 100644 --- a/services/auth/src/router.ts +++ b/services/auth/src/router.ts @@ -12,6 +12,7 @@ import { } from "./surfaceAccess.js"; import * as repo from "./repository.js"; import { parsePageParams, paged } from "./pagination.js"; +import { checkUser, checkServer, overallStatus } from "./diagnostics.js"; const CHALLENGE_TTL_SECONDS = 300; // Long enough to scan a code and type one back, short enough that a token @@ -194,6 +195,10 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr if (url.startsWith("/api/v1/admin/queue/") && url.endsWith("/cancel") && method === "POST") { return adminQueueAction(req, res, session!, url.slice("/api/v1/admin/queue/".length, -"/cancel".length), "cancel"); } + if (url === "/api/v1/admin/diagnostics/server" && method === "GET") return adminDiagnosticsServer(res, session!); + if (url.startsWith("/api/v1/admin/diagnostics/user/") && method === "GET") { + return adminDiagnosticsUser(res, session!, url.substring("/api/v1/admin/diagnostics/user/".length)); + } if (url === "/api/v1/admin/dashboard" && method === "GET") return adminDashboard(res); if (url === "/api/v1/admin/domains" && method === "GET") return adminDomains(res, session!); if (url.split("?")[0] === "/api/v1/admin/mailboxes" && method === "GET") return adminMailboxes(req, res, session!); @@ -946,6 +951,52 @@ async function adminReconcileDomain(req: IncomingMessage, res: ServerResponse, s } +/** + * The checks an operator runs by hand from /admin/tests. + * + * They read state rather than changing it: a diagnostic page that alters the + * thing it is diagnosing is worse than no page at all. + */ +async function adminDiagnosticsServer(res: ServerResponse, session: SessionTokenPayload): Promise { + const facts = await repo.serverDiagnosticFacts(scopeOf(session)); + const checks = checkServer(facts as never); + sendJson(res, 200, { scope: "server", status: overallStatus(checks), checks }); +} + +async function adminDiagnosticsUser( + res: ServerResponse, session: SessionTokenPayload, id: string, +): Promise { + const row = await repo.userDiagnosticFacts(scopeOf(session), id); + if (!row) { + return sendJson(res, 404, { error: "NOT_FOUND", message: "No such user" }); + } + + const checks = checkUser({ + email: String(row.email_address), + isActive: Boolean(row.is_active), + lockedUntil: row.locked_until ? String(row.locked_until) : null, + // Bytes come back as text so a bigint is not rounded through a double. + quotaBytes: Number(row.quota_bytes ?? 0), + usedBytes: Number(row.used_bytes ?? 0), + mfaEnrolled: Boolean(row.mfa_enrolled), + aliasCount: Number(row.alias_count ?? 0), + sieveScriptBytes: row.sieve_bytes === null || row.sieve_bytes === undefined ? null : Number(row.sieve_bytes), + vacationEnabled: Boolean(row.vacation_enabled), + // The autoresponder is a Sieve script with no date window of its own, so + // there is no window to judge and none is invented here. + vacationStart: null, + vacationEnd: null, + passwordHashPresent: Boolean(row.password_hash_present), + }); + + sendJson(res, 200, { + scope: "user", + subject: String(row.email_address), + status: overallStatus(checks), + checks, + }); +} + async function adminGetRspamdThresholds(res: ServerResponse): Promise { sendJson(res, 200, await repo.getRspamdThresholds()); } diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 742e00c..6374dec 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -44,7 +44,7 @@ "mailHost": "Mail host", "mailboxCount": "{count} mailboxes", "mailboxesSubtitle": "Accounts, aliases, CSV import and the second-factor policy.", - "mailboxesTitle": "Mailboxes", + "mailboxesTitle": "Users", "mfaAdmins": "Required for admins", "mfaEveryone": "Required for everyone", "mfaOptional": "Optional", @@ -96,7 +96,15 @@ "reconcileErrors": "The provider refused these:", "selectDomain": "Select", "domainSelected": "Selected", - "tlsSelfSigned": "This certificate is self-signed (issuer: {issuer}). Clients that verify certificates will refuse the connection on the mail ports. Set TRAEFIK_ACME_DIR or STATIC_CERT_PATH to serve an issued certificate." + "tlsSelfSigned": "This certificate is self-signed (issuer: {issuer}). Clients that verify certificates will refuse the connection on the mail ports. Set TRAEFIK_ACME_DIR or STATIC_CERT_PATH to serve an issued certificate.", + "aliasesForUser": "Aliases delivering to this mailbox", + "noAliasesForUser": "No alias delivers to this mailbox.", + "testsTitle": "Tests", + "testsSubtitle": "Checks you can run at any time. They all read state; none sends mail or changes a setting.", + "serverChecks": "Server", + "userChecks": "User", + "runChecks": "Run", + "checksRun": "{count} checks" }, "auth": { "adminLoginTitle": "Admin console", @@ -358,12 +366,52 @@ "italic": "Italic", "underline": "Underline", "insertLink": "Link", - "insertImage": "Image" + "insertImage": "Image", + "showingRange": "Showing {from} to {to} of {total}", + "noResults": "No results", + "previousPage": "Previous page", + "nextPage": "Next page", + "searchPlaceholder": "Search...", + "all": "All", + "filters": "Filters", + "editUser": "Edit user", + "quotaMb": "Quota (MB)", + "selectUser": "Select a user" }, "folders": { "new": "New folder", "newPlaceholder": "New folder…", "delete": "Delete {name}", "deleteConfirm": "Delete the folder \"{name}\" and every message in it? This cannot be undone from the interface." + }, + "checks": { + "userActive": "The account is enabled", + "userDisabled": "The account is disabled", + "userLockedOut": "Locked out after failed sign-ins", + "userNotLockedOut": "No active lockout", + "userHasPassword": "Has a password set", + "userHasNoPassword": "Has no password set", + "quotaFull": "The quota is full", + "quotaNearlyFull": "The quota is nearly full", + "quotaHealthy": "The quota has headroom", + "quotaUnlimited": "No quota limit", + "mfaEnrolled": "Second factor enrolled", + "mfaNotEnrolled": "No second factor", + "aliasesDelivering": "Aliases delivering to this mailbox", + "sieveScriptPresent": "Has filing rules", + "vacationActive": "Autoresponder is on", + "vacationWindowPast": "Autoresponder is on, but its window has passed", + "vacationWindowFuture": "Autoresponder is on, its window has not started", + "dnsVerified": "DNS records verified", + "dnsNotVerified": "DNS records not verified", + "noDomains": "No domains configured", + "dkimMissing": "No DKIM key: the domain signs nothing", + "queueHasBounces": "Bounced messages in the queue", + "queueNoBounces": "No bounces in the queue", + "queueHasDeferred": "Deferred messages in the queue", + "queueNoDeferred": "No deferred messages", + "queueHasFrozen": "Frozen messages in the queue", + "usersOverQuota": "Users over their quota", + "noUsersOverQuota": "No user is over quota" } } diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index bf7b35c..71ccab6 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -44,7 +44,7 @@ "mailHost": "Host de correo", "mailboxCount": "{count} buzones", "mailboxesSubtitle": "Cuentas, alias, importación CSV y la política de segundo factor.", - "mailboxesTitle": "Buzones", + "mailboxesTitle": "Usuarios", "mfaAdmins": "Obligatorio para administradores", "mfaEveryone": "Obligatorio para todos", "mfaOptional": "Opcional", @@ -96,7 +96,15 @@ "reconcileErrors": "El proveedor rechazó estos:", "selectDomain": "Seleccionar", "domainSelected": "Seleccionado", - "tlsSelfSigned": "Este certificado es autofirmado (emisor: {issuer}). Los clientes que verifican el certificado rechazaran la conexion en los puertos de correo. Defina TRAEFIK_ACME_DIR o STATIC_CERT_PATH para servir un certificado emitido." + "tlsSelfSigned": "Este certificado es autofirmado (emisor: {issuer}). Los clientes que verifican el certificado rechazaran la conexion en los puertos de correo. Defina TRAEFIK_ACME_DIR o STATIC_CERT_PATH para servir un certificado emitido.", + "aliasesForUser": "Alias que entregan en este buzon", + "noAliasesForUser": "Ningun alias entrega en este buzon.", + "testsTitle": "Pruebas", + "testsSubtitle": "Comprobaciones que puede ejecutar en cualquier momento. Todas leen el estado; ninguna envia correo ni cambia ajustes.", + "serverChecks": "Servidor", + "userChecks": "Usuario", + "runChecks": "Ejecutar", + "checksRun": "{count} comprobaciones" }, "auth": { "adminLoginTitle": "Consola de administración", @@ -358,12 +366,52 @@ "italic": "Cursiva", "underline": "Subrayado", "insertLink": "Enlace", - "insertImage": "Imagen" + "insertImage": "Imagen", + "showingRange": "Mostrando {from} a {to} de {total}", + "noResults": "Sin resultados", + "previousPage": "Pagina anterior", + "nextPage": "Pagina siguiente", + "searchPlaceholder": "Buscar...", + "all": "Todos", + "filters": "Filtros", + "editUser": "Editar usuario", + "quotaMb": "Cuota (MB)", + "selectUser": "Elegir usuario" }, "folders": { "new": "Nueva carpeta", "newPlaceholder": "Nueva carpeta…", "delete": "Eliminar {name}", "deleteConfirm": "¿Eliminar la carpeta \"{name}\" y todos sus mensajes? Esto no se puede deshacer desde la interfaz." + }, + "checks": { + "userActive": "La cuenta esta activa", + "userDisabled": "La cuenta esta desactivada", + "userLockedOut": "Bloqueada por intentos fallidos", + "userNotLockedOut": "Sin bloqueo activo", + "userHasPassword": "Tiene contrasena definida", + "userHasNoPassword": "No tiene contrasena definida", + "quotaFull": "La cuota esta llena", + "quotaNearlyFull": "La cuota esta casi llena", + "quotaHealthy": "La cuota tiene margen", + "quotaUnlimited": "Sin limite de cuota", + "mfaEnrolled": "Segundo factor activo", + "mfaNotEnrolled": "Sin segundo factor", + "aliasesDelivering": "Alias que entregan en este buzon", + "sieveScriptPresent": "Tiene reglas de filtrado", + "vacationActive": "Respuesta de ausencia activada", + "vacationWindowPast": "Respuesta de ausencia activada, pero el periodo ya paso", + "vacationWindowFuture": "Respuesta de ausencia activada, el periodo aun no empezo", + "dnsVerified": "Registros DNS verificados", + "dnsNotVerified": "Registros DNS sin verificar", + "noDomains": "Ningun dominio configurado", + "dkimMissing": "Sin clave DKIM: el dominio no firma nada", + "queueHasBounces": "Hay mensajes devueltos en la cola", + "queueNoBounces": "Sin devoluciones en la cola", + "queueHasDeferred": "Hay mensajes aplazados en la cola", + "queueNoDeferred": "Sin mensajes aplazados", + "queueHasFrozen": "Hay mensajes congelados en la cola", + "usersOverQuota": "Usuarios con la cuota excedida", + "noUsersOverQuota": "Ningun usuario con la cuota excedida" } } diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 80b5fb3..6a8139e 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -44,7 +44,7 @@ "mailHost": "Host de e-mail", "mailboxCount": "{count} caixas de e-mail", "mailboxesSubtitle": "Contas, apelidos, importação CSV e a política de segundo fator.", - "mailboxesTitle": "Caixas de e-mail", + "mailboxesTitle": "Usuarios", "mfaAdmins": "Obrigatório para administradores", "mfaEveryone": "Obrigatório para todos", "mfaOptional": "Opcional", @@ -96,7 +96,15 @@ "reconcileErrors": "O provedor recusou estes:", "selectDomain": "Selecionar", "domainSelected": "Selecionado", - "tlsSelfSigned": "Este certificado e autoassinado (emissor: {issuer}). Clientes que verificam o certificado vao recusar a ligacao nas portas de correio. Defina TRAEFIK_ACME_DIR ou STATIC_CERT_PATH para servir um certificado emitido." + "tlsSelfSigned": "Este certificado e autoassinado (emissor: {issuer}). Clientes que verificam o certificado vao recusar a ligacao nas portas de correio. Defina TRAEFIK_ACME_DIR ou STATIC_CERT_PATH para servir um certificado emitido.", + "aliasesForUser": "Apelidos que entregam nesta caixa", + "noAliasesForUser": "Nenhum apelido entrega nesta caixa.", + "testsTitle": "Testes", + "testsSubtitle": "Verificacoes que pode correr a qualquer momento. Todas leem o estado; nenhuma envia correio nem altera definicoes.", + "serverChecks": "Servidor", + "userChecks": "Utilizador", + "runChecks": "Correr", + "checksRun": "{count} verificacoes" }, "auth": { "adminLoginTitle": "Console de administração", @@ -358,12 +366,52 @@ "italic": "Itálico", "underline": "Sublinhado", "insertLink": "Link", - "insertImage": "Imagem" + "insertImage": "Imagem", + "showingRange": "A mostrar {from} a {to} de {total}", + "noResults": "Nenhum resultado", + "previousPage": "Pagina anterior", + "nextPage": "Pagina seguinte", + "searchPlaceholder": "Pesquisar...", + "all": "Todos", + "filters": "Filtros", + "editUser": "Editar utilizador", + "quotaMb": "Quota (MB)", + "selectUser": "Escolher utilizador" }, "folders": { "new": "Nova pasta", "newPlaceholder": "Nova pasta…", "delete": "Apagar {name}", "deleteConfirm": "Apagar a pasta \"{name}\" e todas as mensagens nela? Isto não pode ser desfeito pela interface." + }, + "checks": { + "userActive": "A conta esta activa", + "userDisabled": "A conta esta desactivada", + "userLockedOut": "A conta esta bloqueada por tentativas falhadas", + "userNotLockedOut": "Sem bloqueio activo", + "userHasPassword": "Tem palavra-passe definida", + "userHasNoPassword": "Nao tem palavra-passe definida", + "quotaFull": "A quota esta cheia", + "quotaNearlyFull": "A quota esta quase cheia", + "quotaHealthy": "A quota tem folga", + "quotaUnlimited": "Sem limite de quota", + "mfaEnrolled": "Segundo factor activo", + "mfaNotEnrolled": "Sem segundo factor", + "aliasesDelivering": "Apelidos que entregam nesta caixa", + "sieveScriptPresent": "Tem regras de filtragem", + "vacationActive": "Resposta de ausencia ligada", + "vacationWindowPast": "Resposta de ausencia ligada, mas o periodo ja passou", + "vacationWindowFuture": "Resposta de ausencia ligada, o periodo ainda nao comecou", + "dnsVerified": "Registos DNS verificados", + "dnsNotVerified": "Registos DNS por verificar", + "noDomains": "Nenhum dominio configurado", + "dkimMissing": "Sem chave DKIM: o dominio nao assina nada", + "queueHasBounces": "Ha mensagens devolvidas na fila", + "queueNoBounces": "Sem devolucoes na fila", + "queueHasDeferred": "Ha mensagens adiadas na fila", + "queueNoDeferred": "Sem mensagens adiadas", + "queueHasFrozen": "Ha mensagens congeladas na fila", + "usersOverQuota": "Utilizadores com a quota excedida", + "noUsersOverQuota": "Nenhum utilizador com a quota excedida" } } diff --git a/services/webmail/src/app/(admin)/admin/layout.tsx b/services/webmail/src/app/(admin)/admin/layout.tsx index aa9817a..fdb40bf 100644 --- a/services/webmail/src/app/(admin)/admin/layout.tsx +++ b/services/webmail/src/app/(admin)/admin/layout.tsx @@ -16,6 +16,7 @@ import { X, Lock, Mail, + FlaskConical, } from "lucide-react"; import { useTranslations } from "../../../i18n/provider"; import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher"; @@ -35,6 +36,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { href: "/admin/queue", label: t("admin.queueTitle"), icon: ListOrdered }, { href: "/admin/dmarc", label: t("admin.dmarcTitle"), icon: Activity }, { href: "/admin/security", label: t("admin.securityTitle"), icon: ShieldCheck }, + { href: "/admin/tests", label: t("admin.testsTitle"), icon: FlaskConical }, ]; const handleLogout = () => { diff --git a/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx b/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx index 1e7c99a..f8bf2a4 100644 --- a/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx +++ b/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx @@ -14,9 +14,14 @@ import { Upload, AlertCircle, CheckCircle2, + Pencil, + Search, + X, } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; import { Badge } from "../../../../components/ui/Badge"; +import { Pagination } from "../../../../components/ui/Pagination"; +import { useDebounced } from "../../../../lib/useDebounced"; import { initialsFor } from "../../../../lib/initials"; import { Button } from "../../../../components/ui/Button"; @@ -28,6 +33,7 @@ interface Mailbox { quotaMb: number; mfaEnabled: boolean; locked: boolean; + locale: string; } interface Alias { @@ -45,6 +51,16 @@ interface ApiMailbox { quota_bytes: number; mfa_enrolled: boolean; is_active: boolean; + locale: string | null; +} + +/** The envelope every server-paged list answers with. */ +interface PagedUsers { + items: ApiMailbox[]; + total: number; + page: number; + page_size: number; + total_pages: number; } interface ApiAlias { @@ -79,14 +95,45 @@ export default function AdminMailboxesPage() { const [error, setError] = useState(null); + // Paging and filtering are the server's job; nothing here slices a list that + // was never fully fetched in the first place. + const [page, setPage] = useState(1); + const [pageInfo, setPageInfo] = useState({ total: 0, page: 1, pageSize: 25, totalPages: 1 }); + const [search, setSearch] = useState(""); + const [roleFilter, setRoleFilter] = useState(""); + const [activeFilter, setActiveFilter] = useState(""); + const debouncedSearch = useDebounced(search, 300); + + // The user being edited, and the aliases that deliver to them. + const [editing, setEditing] = useState(null); + const [editRole, setEditRole] = useState("USER"); + const [editQuotaMb, setEditQuotaMb] = useState(0); + const [editLocale, setEditLocale] = useState(""); + const [editAliases, setEditAliases] = useState(null); + const [saving, setSaving] = useState(false); + const load = useCallback(async () => { try { + const params = new URLSearchParams({ page: String(page), page_size: "25" }); + if (debouncedSearch) params.set("search", debouncedSearch); + if (roleFilter) params.set("role", roleFilter); + if (activeFilter) params.set("active", activeFilter); + const [mb, al] = await Promise.all([ - fetch("/api/v1/admin/mailboxes").then((r) => (r.ok ? r.json() : [])), + fetch(`/api/v1/admin/mailboxes?${params.toString()}`).then((r) => + r.ok ? r.json() : { items: [], total: 0, page: 1, page_size: 25, total_pages: 1 }, + ), fetch("/api/v1/admin/aliases").then((r) => (r.ok ? r.json() : [])), ]); + const users = mb as PagedUsers; + setPageInfo({ + total: users.total ?? 0, + page: users.page ?? 1, + pageSize: users.page_size ?? 25, + totalPages: users.total_pages ?? 1, + }); setMailboxes( - (mb as ApiMailbox[]).map((m) => ({ + (users.items ?? []).map((m) => ({ id: m.id, email: m.email_address, role: m.role, @@ -94,6 +141,7 @@ export default function AdminMailboxesPage() { quotaMb: Math.round(Number(m.quota_bytes ?? 0) / 1048576), mfaEnabled: Boolean(m.mfa_enrolled), locked: !m.is_active, + locale: m.locale ?? "", })) ); setAliases( @@ -107,7 +155,13 @@ export default function AdminMailboxesPage() { } catch { setError(t("errors.loadFailed")); } - }, []); + }, [t, page, debouncedSearch, roleFilter, activeFilter]); + + // A changed filter belongs on page one; keeping the page number lands the + // reader on an empty page of a smaller result set. + useEffect(() => { + setPage(1); + }, [debouncedSearch, roleFilter, activeFilter]); useEffect(() => { void load(); @@ -155,6 +209,62 @@ export default function AdminMailboxesPage() { await load(); }; + /** + * Opens one user for editing, with the aliases that deliver to them. + * + * An alias belongs to a domain rather than to a person - it carries a list + * of destinations, which may point at other servers - so what is shown here + * is the set that lands in this mailbox, not a collection the user owns. + */ + const openUser = async (mb: Mailbox) => { + setEditing(mb); + setEditRole(mb.role); + setEditQuotaMb(mb.quotaMb); + setEditLocale(mb.locale); + setEditAliases(null); + try { + const res = await fetch(`/api/v1/admin/mailboxes/${encodeURIComponent(mb.id)}/aliases`); + const data = res.ok ? await res.json() : []; + setEditAliases( + (data as ApiAlias[]).map((a) => ({ + id: a.id, + aliasAddress: a.source_address, + targetAddress: (a.destination_addresses ?? []).join(", "), + domain: a.domain_name, + })), + ); + } catch { + setEditAliases([]); + } + }; + + const saveUser = async () => { + if (!editing) return; + setSaving(true); + setError(null); + try { + const res = await fetch(`/api/v1/admin/mailboxes/${encodeURIComponent(editing.id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + role: editRole, + // Stored in bytes; the field is in megabytes because that is what an + // operator thinks in. + quota_bytes: Math.max(0, Math.round(editQuotaMb * 1048576)), + locale: editLocale || undefined, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.message); + setEditing(null); + await load(); + } catch (err) { + setError(err instanceof Error && err.message ? err.message : t("errors.serverError")); + } finally { + setSaving(false); + } + }; + const handleCreateAlias = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -385,6 +495,42 @@ export default function AdminMailboxesPage() {
+ {/* Filtering and paging are done by the server. */} +
+
+ + setSearch(e.target.value)} + placeholder={t("ui.searchPlaceholder")} + aria-label={t("ui.searchPlaceholder")} + className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500" + /> +
+ + +
+
@@ -439,6 +585,15 @@ export default function AdminMailboxesPage() {
+
+ + +
+ + )} + + {/* The user being edited, with the aliases that land in their mailbox. */} + {editing && ( +
+
+
+
+

+ {editing.email} +

+

{t("ui.editUser")}

+
+ +
+ +
+
+ + +
+
+ + setEditQuotaMb(Number(e.target.value))} + className={input} + /> +
+
+ + +
+ +
+
{t("admin.aliasesForUser")}
+ {editAliases === null ? ( +
{t("common.loading")}
+ ) : editAliases.length === 0 ? ( +
{t("admin.noAliasesForUser")}
+ ) : ( +
    + {editAliases.map((a) => ( +
  • + {a.aliasAddress} +
  • + ))} +
+ )} +
+ +
+ + +
+
)} diff --git a/services/webmail/src/app/(admin)/admin/queue/page.tsx b/services/webmail/src/app/(admin)/admin/queue/page.tsx index 9b92a78..8014c9c 100644 --- a/services/webmail/src/app/(admin)/admin/queue/page.tsx +++ b/services/webmail/src/app/(admin)/admin/queue/page.tsx @@ -1,10 +1,12 @@ "use client"; import React, { useCallback, useEffect, useState } from "react"; -import { RefreshCw } from "lucide-react"; +import { RefreshCw, Search } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; import { Badge } from "../../../../components/ui/Badge"; import { Button } from "../../../../components/ui/Button"; +import { Pagination } from "../../../../components/ui/Pagination"; +import { useDebounced } from "../../../../lib/useDebounced"; /** * The outbound queue, read from the database. @@ -33,6 +35,10 @@ interface QueueJob { interface QueueSummary { by_status: Record; recent: QueueJob[]; + total: number; + page: number; + page_size: number; + total_pages: number; } /** @@ -55,10 +61,28 @@ export default function AdminQueuePage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + // Filtering and paging happen in the database. Holding them here and + // slicing in the browser would still have fetched only the first page, + // which is the whole reason a backed-up queue could not be inspected. + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [status, setStatus] = useState(""); + const debouncedSearch = useDebounced(search, 300); + + // A new search belongs on page one; keeping the old page number lands the + // reader on an empty page of a smaller result set. + useEffect(() => { + setPage(1); + }, [debouncedSearch, status]); + const load = useCallback(async () => { setLoading(true); try { - const res = await fetch("/api/v1/admin/queue"); + const params = new URLSearchParams({ page: String(page), page_size: "25" }); + if (debouncedSearch) params.set("search", debouncedSearch); + if (status) params.set("status", status); + + const res = await fetch(`/api/v1/admin/queue?${params.toString()}`); if (!res.ok) throw new Error(); setSummary(await res.json()); setError(null); @@ -70,7 +94,7 @@ export default function AdminQueuePage() { } finally { setLoading(false); } - }, [t]); + }, [t, page, debouncedSearch, status]); useEffect(() => { void load(); @@ -134,6 +158,33 @@ export default function AdminQueuePage() {
{error}
)} +
+
+ + setSearch(e.target.value)} + placeholder={t("ui.searchPlaceholder")} + aria-label={t("ui.searchPlaceholder")} + className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500" + /> +
+ +
+
{jobs.length === 0 && !loading ? (
{t("admin.queueEmpty")}
@@ -186,6 +237,15 @@ export default function AdminQueuePage() {
)} + + ); diff --git a/services/webmail/src/app/(admin)/admin/tests/page.tsx b/services/webmail/src/app/(admin)/admin/tests/page.tsx new file mode 100644 index 0000000..c8d1552 --- /dev/null +++ b/services/webmail/src/app/(admin)/admin/tests/page.tsx @@ -0,0 +1,217 @@ +"use client"; + +import React, { useCallback, useEffect, useState } from "react"; +import { PlayCircle, Server, User as UserIcon, Search } from "lucide-react"; +import { useTranslations } from "../../../../i18n/provider"; +import { Badge } from "../../../../components/ui/Badge"; +import { Button } from "../../../../components/ui/Button"; +import { useDebounced } from "../../../../lib/useDebounced"; + +/** + * Checks an operator runs by hand, against the server or one user. + * + * Everything here reads state; nothing sends mail or changes a setting. A + * diagnostic screen that alters what it is diagnosing is worse than no screen. + */ + +interface Check { + key: string; + status: "PASS" | "WARN" | "FAIL" | "INFO"; + subject?: string; + detail?: string; +} + +interface Report { + scope: string; + subject?: string; + status: Check["status"]; + checks: Check[]; +} + +interface UserRow { + id: string; + email_address: string; +} + +function variantFor(status: Check["status"]): "success" | "warning" | "danger" | "neutral" { + if (status === "PASS") return "success"; + if (status === "WARN") return "warning"; + if (status === "FAIL") return "danger"; + return "neutral"; +} + +export default function AdminTestsPage() { + const t = useTranslations(); + + const [serverReport, setServerReport] = useState(null); + const [userReport, setUserReport] = useState(null); + const [runningServer, setRunningServer] = useState(false); + const [runningUser, setRunningUser] = useState(false); + const [error, setError] = useState(null); + + const [search, setSearch] = useState(""); + const [users, setUsers] = useState([]); + const [selected, setSelected] = useState(""); + const debouncedSearch = useDebounced(search, 300); + + // The picker asks the server for a short page rather than pulling every + // user into the browser to filter them here. + const loadUsers = useCallback(async () => { + try { + const params = new URLSearchParams({ page: "1", page_size: "20" }); + if (debouncedSearch) params.set("search", debouncedSearch); + const res = await fetch(`/api/v1/admin/mailboxes?${params.toString()}`); + if (!res.ok) throw new Error(); + const data = await res.json(); + setUsers((data.items ?? []) as UserRow[]); + } catch { + setUsers([]); + } + }, [debouncedSearch]); + + useEffect(() => { + void loadUsers(); + }, [loadUsers]); + + const runServer = async () => { + setRunningServer(true); + setError(null); + try { + const res = await fetch("/api/v1/admin/diagnostics/server"); + if (!res.ok) throw new Error(); + setServerReport(await res.json()); + } catch { + setServerReport(null); + setError(t("errors.loadFailed")); + } finally { + setRunningServer(false); + } + }; + + const runUser = async () => { + if (!selected) return; + setRunningUser(true); + setError(null); + try { + const res = await fetch(`/api/v1/admin/diagnostics/user/${encodeURIComponent(selected)}`); + if (!res.ok) throw new Error(); + setUserReport(await res.json()); + } catch { + setUserReport(null); + setError(t("errors.loadFailed")); + } finally { + setRunningUser(false); + } + }; + + const panel = "flex flex-col gap-3.5 rounded-2xl bg-dark-panel p-[18px] shadow-edge"; + + const renderReport = (report: Report | null) => + report === null ? null : ( +
+
+ {report.status} + + {t("admin.checksRun", { count: report.checks.length })} + +
+ +
    + {report.checks.map((c, i) => ( +
  • + + {c.status} + + {/* The key is translated here rather than sent as a finished + sentence, so the page reads in the interface's language. */} + + {t(`checks.${c.key}`)} + {c.subject ? — {c.subject} : null} + + {c.detail ? ( + {c.detail} + ) : null} +
  • + ))} +
+
+ ); + + return ( +
+
+

{t("admin.testsTitle")}

+

{t("admin.testsSubtitle")}

+
+ + {error && ( +
{error}
+ )} + +
+
+

+ + {t("admin.serverChecks")} +

+ +
+ {renderReport(serverReport)} +
+ +
+

+ + {t("admin.userChecks")} +

+ +
+
+ + setSearch(e.target.value)} + placeholder={t("ui.searchPlaceholder")} + aria-label={t("ui.searchPlaceholder")} + className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500" + /> +
+ + +
+ + {userReport?.subject ? ( +
{userReport.subject}
+ ) : null} + {renderReport(userReport)} +
+
+ ); +} diff --git a/services/webmail/src/components/ui/Pagination.tsx b/services/webmail/src/components/ui/Pagination.tsx new file mode 100644 index 0000000..e319968 --- /dev/null +++ b/services/webmail/src/components/ui/Pagination.tsx @@ -0,0 +1,76 @@ +"use client"; + +import React from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "./Button"; +import { pageWindow, pageRange } from "../../lib/paging"; +import { useTranslations } from "../../i18n/provider"; + +interface PaginationProps { + page: number; + pageSize: number; + total: number; + totalPages: number; + onPage: (page: number) => void; + busy?: boolean; +} + +/** + * The pager under a server-paged list. + * + * The lists it sits under fetch one page at a time, so every control here + * moves the page the server is asked for - nothing is sliced in the browser. + */ +export function Pagination({ page, pageSize, total, totalPages, onPage, busy }: PaginationProps) { + const t = useTranslations(); + const { from, to } = pageRange(page, pageSize, total); + + // One page of results needs no controls, but the count is still worth + // stating so an empty list is distinguishable from a broken one. + const showControls = totalPages > 1; + + return ( +
+ + {total > 0 ? t("ui.showingRange", { from, to, total }) : t("ui.noResults")} + + + {showControls && ( +
+ + + {pageWindow(page, totalPages).map((p) => ( + + ))} + + +
+ )} +
+ ); +} diff --git a/services/webmail/src/lib/paging.test.ts b/services/webmail/src/lib/paging.test.ts new file mode 100644 index 0000000..d2bdd9f --- /dev/null +++ b/services/webmail/src/lib/paging.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { pageWindow, pageRange } from "./paging"; + +describe("pageWindow", () => { + it("shows every page when there are few", () => { + expect(pageWindow(1, 3)).toEqual([1, 2, 3]); + }); + + it("follows the current page without running past the ends", () => { + expect(pageWindow(10, 20)).toEqual([8, 9, 10, 11, 12]); + expect(pageWindow(1, 20)).toEqual([1, 2, 3]); + expect(pageWindow(20, 20)).toEqual([18, 19, 20]); + }); + + // A page number out of range comes back from a stale URL or a list that + // shrank under the reader; it must not produce an empty or negative pager. + it.each([ + [0, 5], + [-3, 5], + [99, 5], + [Number.NaN, 5], + ])("stays inside the range for page=%s of %s", (page, total) => { + const w = pageWindow(page, total); + expect(w.length).toBeGreaterThan(0); + for (const p of w) { + expect(p).toBeGreaterThanOrEqual(1); + expect(p).toBeLessThanOrEqual(total); + } + }); + + it("survives having no pages at all", () => { + expect(pageWindow(1, 0)).toEqual([1]); + }); +}); + +describe("pageRange", () => { + it("describes the rows on this page", () => { + expect(pageRange(1, 25, 100)).toEqual({ from: 1, to: 25 }); + expect(pageRange(4, 25, 100)).toEqual({ from: 76, to: 100 }); + }); + + // The last page is usually short; saying "76 to 100 of 82" would be wrong. + it("stops at the total on a partial last page", () => { + expect(pageRange(4, 25, 82)).toEqual({ from: 76, to: 82 }); + }); + + it("says nothing when there is nothing", () => { + expect(pageRange(1, 25, 0)).toEqual({ from: 0, to: 0 }); + }); +}); diff --git a/services/webmail/src/lib/paging.ts b/services/webmail/src/lib/paging.ts new file mode 100644 index 0000000..55c2a4d --- /dev/null +++ b/services/webmail/src/lib/paging.ts @@ -0,0 +1,24 @@ +/** + * The page numbers a pager should offer. + * + * Rendering every page is fine for three and unusable for four hundred, so the + * window follows the current page and the ends stay reachable. + */ +export function pageWindow(page: number, totalPages: number, span = 2): number[] { + if (!Number.isFinite(totalPages) || totalPages < 1) return [1]; + const current = Math.min(Math.max(Math.trunc(page) || 1, 1), totalPages); + + const first = Math.max(1, current - span); + const last = Math.min(totalPages, current + span); + + const pages: number[] = []; + for (let p = first; p <= last; p++) pages.push(p); + return pages; +} + +/** The human sentence under a list: which rows these are, out of how many. */ +export function pageRange(page: number, pageSize: number, total: number): { from: number; to: number } { + if (total <= 0) return { from: 0, to: 0 }; + const from = (page - 1) * pageSize + 1; + return { from, to: Math.min(total, page * pageSize) }; +} diff --git a/services/webmail/src/lib/useDebounced.ts b/services/webmail/src/lib/useDebounced.ts new file mode 100644 index 0000000..e4aaa52 --- /dev/null +++ b/services/webmail/src/lib/useDebounced.ts @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Holds a value still until typing stops. + * + * Every keystroke in a search box would otherwise be one more query against + * the database, and the answers can arrive out of order - so the list flickers + * between results for "an", "ana" and "a". + */ +export function useDebounced(value: T, delayMs = 300): T { + const [settled, setSettled] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setSettled(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return settled; +}