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:
Lucas RayanandClaude Opus 5 committed 2026-08-24 09:36:35 -03:00
1 parent 52bf43b74f
commit 2b92abd185
16 files changed
+1431 -153

No files matched your search

+69
View File
@@ -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(