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 <noreply@anthropic.com>
This commit is contained in:
1 parent
52bf43b74f
commit
2b92abd185
16 files changed
+1431
-153
No files matched your search
@@ -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> = {}): 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> = {}): 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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>;
|
||||
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";
|
||||
}
|
||||
@@ -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<Record<string, unknown> | null> {
|
||||
const { sql, params } = scopeClause(scope, "m.domain_id");
|
||||
const row = await queryOne<Record<string, unknown>>(
|
||||
`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<Record<string, unknown>> {
|
||||
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(
|
||||
|
||||
+163
-138
@@ -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");
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
sendJson(res, 200, await repo.getRspamdThresholds());
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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<string | null>(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<Mailbox | null>(null);
|
||||
const [editRole, setEditRole] = useState("USER");
|
||||
const [editQuotaMb, setEditQuotaMb] = useState(0);
|
||||
const [editLocale, setEditLocale] = useState("");
|
||||
const [editAliases, setEditAliases] = useState<Alias[] | null>(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() {
|
||||
</section>
|
||||
|
||||
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||
{/* Filtering and paging are done by the server. */}
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<div className="relative min-w-[220px] flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
aria-label={t("ui.role")}
|
||||
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.all")}</option>
|
||||
<option value="USER">USER</option>
|
||||
<option value="DOMAIN_ADMIN">DOMAIN_ADMIN</option>
|
||||
<option value="SUPER_ADMIN">SUPER_ADMIN</option>
|
||||
</select>
|
||||
<select
|
||||
value={activeFilter}
|
||||
onChange={(e) => setActiveFilter(e.target.value)}
|
||||
aria-label={t("ui.accountStatus")}
|
||||
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.all")}</option>
|
||||
<option value="true">{t("ui.active")}</option>
|
||||
<option value="false">{t("ui.accountLocked")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="lm-table">
|
||||
<thead>
|
||||
@@ -439,6 +585,15 @@ export default function AdminMailboxesPage() {
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="whitespace-nowrap pr-0 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openUser(mb)}
|
||||
title={t("common.edit")}
|
||||
aria-label={t("common.edit")}
|
||||
className={`${iconButton} inline-flex`}
|
||||
>
|
||||
<Pencil className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleToggleLock(mb.id, mb.locked)}
|
||||
@@ -463,6 +618,105 @@ export default function AdminMailboxesPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={pageInfo.page}
|
||||
pageSize={pageInfo.pageSize}
|
||||
total={pageInfo.total}
|
||||
totalPages={pageInfo.totalPages}
|
||||
onPage={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The user being edited, with the aliases that land in their mailbox. */}
|
||||
{editing && (
|
||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/60 p-4 pt-16">
|
||||
<div className="w-full max-w-[560px] rounded-2xl bg-dark-panel p-5 shadow-edge">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="break-words text-[17px] font-medium leading-tight text-slate-100">
|
||||
{editing.email}
|
||||
</h2>
|
||||
<p className="mt-1 text-[12px] text-slate-400">{t("ui.editUser")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
aria-label={t("common.cancel")}
|
||||
className={`${iconButton} inline-flex`}
|
||||
>
|
||||
<X className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<label htmlFor="edit-role" className={fieldLabel}>{t("ui.role")}</label>
|
||||
<select
|
||||
id="edit-role"
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value)}
|
||||
className={input}
|
||||
>
|
||||
<option value="USER">USER</option>
|
||||
<option value="DOMAIN_ADMIN">DOMAIN_ADMIN</option>
|
||||
<option value="SUPER_ADMIN">SUPER_ADMIN</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="edit-quota" className={fieldLabel}>{t("ui.quotaMb")}</label>
|
||||
<input
|
||||
id="edit-quota"
|
||||
type="number"
|
||||
min={0}
|
||||
value={editQuotaMb}
|
||||
onChange={(e) => setEditQuotaMb(Number(e.target.value))}
|
||||
className={input}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="edit-locale" className={fieldLabel}>{t("settings.language")}</label>
|
||||
<select
|
||||
id="edit-locale"
|
||||
value={editLocale}
|
||||
onChange={(e) => setEditLocale(e.target.value)}
|
||||
className={input}
|
||||
>
|
||||
<option value="">{t("common.none")}</option>
|
||||
<option value="pt-BR">Portugues (BR)</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Espanol</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||
<div className="text-xs text-slate-400">{t("admin.aliasesForUser")}</div>
|
||||
{editAliases === null ? (
|
||||
<div className="mt-2 text-[12.5px] text-slate-500">{t("common.loading")}</div>
|
||||
) : editAliases.length === 0 ? (
|
||||
<div className="mt-2 text-[12.5px] text-slate-500">{t("admin.noAliasesForUser")}</div>
|
||||
) : (
|
||||
<ul className="mt-2 flex flex-col gap-1.5">
|
||||
{editAliases.map((a) => (
|
||||
<li key={a.id} className="break-all font-mono text-[12px] text-slate-200">
|
||||
{a.aliasAddress}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditing(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={() => void saveUser()} disabled={saving}>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<string, number>;
|
||||
recent: QueueJob[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,10 +61,28 @@ export default function AdminQueuePage() {
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] text-rose-200 shadow-edge">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<div className="relative min-w-[220px] flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
aria-label={t("common.status")}
|
||||
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.all")}</option>
|
||||
{/* Taken from the aggregate, so the choices are the states this
|
||||
queue actually contains rather than a list invented here. */}
|
||||
{Object.keys(summary?.by_status ?? {}).sort().map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||
{jobs.length === 0 && !loading ? (
|
||||
<div className="p-8 text-center text-[13px] text-slate-400">{t("admin.queueEmpty")}</div>
|
||||
@@ -186,6 +237,15 @@ export default function AdminQueuePage() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
page={summary?.page ?? 1}
|
||||
pageSize={summary?.page_size ?? 25}
|
||||
total={summary?.total ?? 0}
|
||||
totalPages={summary?.total_pages ?? 1}
|
||||
onPage={setPage}
|
||||
busy={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<Report | null>(null);
|
||||
const [userReport, setUserReport] = useState<Report | null>(null);
|
||||
const [runningServer, setRunningServer] = useState(false);
|
||||
const [runningUser, setRunningUser] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [users, setUsers] = useState<UserRow[]>([]);
|
||||
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 : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Badge variant={variantFor(report.status)}>{report.status}</Badge>
|
||||
<span className="text-[12px] text-slate-400">
|
||||
{t("admin.checksRun", { count: report.checks.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{report.checks.map((c, i) => (
|
||||
<li
|
||||
key={`${c.key}-${c.subject ?? i}`}
|
||||
className="flex flex-wrap items-center gap-2.5 rounded-xl bg-dark-card px-3.5 py-2.5 shadow-edge"
|
||||
>
|
||||
<Badge variant={variantFor(c.status)} className="whitespace-nowrap">
|
||||
{c.status}
|
||||
</Badge>
|
||||
{/* The key is translated here rather than sent as a finished
|
||||
sentence, so the page reads in the interface's language. */}
|
||||
<span className="min-w-0 flex-1 break-words text-[13px] text-slate-200">
|
||||
{t(`checks.${c.key}`)}
|
||||
{c.subject ? <span className="text-slate-400"> — {c.subject}</span> : null}
|
||||
</span>
|
||||
{c.detail ? (
|
||||
<span className="whitespace-nowrap font-mono text-[11.5px] text-slate-400">{c.detail}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.testsTitle")}</h1>
|
||||
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.testsSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] text-rose-200 shadow-edge">{error}</div>
|
||||
)}
|
||||
|
||||
<section className={panel}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||
<Server className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||
{t("admin.serverChecks")}
|
||||
</h2>
|
||||
<Button variant="primary" size="sm" onClick={() => void runServer()} disabled={runningServer}>
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
<span>{runningServer ? t("common.loading") : t("admin.runChecks")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{renderReport(serverReport)}
|
||||
</section>
|
||||
|
||||
<section className={panel}>
|
||||
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||
<UserIcon className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||
{t("admin.userChecks")}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<div className="relative min-w-[200px] flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
aria-label={t("admin.userChecks")}
|
||||
className="min-w-[220px] rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.selectUser")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.email_address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void runUser()}
|
||||
disabled={runningUser || !selected}
|
||||
>
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
<span>{runningUser ? t("common.loading") : t("admin.runChecks")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{userReport?.subject ? (
|
||||
<div className="break-all text-[12.5px] text-slate-400">{userReport.subject}</div>
|
||||
) : null}
|
||||
{renderReport(userReport)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-1">
|
||||
<span className="text-[12px] text-slate-400">
|
||||
{total > 0 ? t("ui.showingRange", { from, to, total }) : t("ui.noResults")}
|
||||
</span>
|
||||
|
||||
{showControls && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPage(page - 1)}
|
||||
disabled={busy || page <= 1}
|
||||
aria-label={t("ui.previousPage")}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{pageWindow(page, totalPages).map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={p === page ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => onPage(p)}
|
||||
disabled={busy}
|
||||
aria-current={p === page ? "page" : undefined}
|
||||
>
|
||||
<span className="tabular-nums">{p}</span>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPage(page + 1)}
|
||||
disabled={busy || page >= totalPages}
|
||||
aria-label={t("ui.nextPage")}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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<T>(value: T, delayMs = 300): T {
|
||||
const [settled, setSettled] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSettled(value), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
|
||||
return settled;
|
||||
}
|
||||
Reference in new issue
Block a user