feat(admin): repor palavra-passe e remover segundo factor no modal

Duas operacoes que so existiam pela linha de comandos, e que sao precisamente
as que alguem pede ao telefone: esqueci-me da palavra-passe, perdi o
autenticador.

A reposicao termina todas as sessoes daquele utilizador, incluindo a que esta
aberta nesse momento. Um administrador a repor uma palavra-passe esta a fazer
uma passagem de conta ou a responder a um comprometimento, e nos dois casos as
sessoes abertas com a palavra-passe anterior nao podem sobreviver. Exige os
mesmos 12 caracteres que os outros caminhos, para a consola nao ser a maneira
de contornar a regra.

Remover o segundo factor apaga tambem os codigos de recuperacao: foram
gerados contra o segredo antigo, nao servem contra um novo, e deixa-los para
tras e um segundo conjunto de credenciais que ninguem esta a seguir.

A rota do /totp teve de ficar acima do DELETE generico de /mailboxes/. O
generico apanha qualquer caminho por baixo desse prefixo, por isso carregar em
"remover segundo factor" era encaminhado para apagar a caixa do utilizador --
so nao a apagou porque o id saia malformado e o Postgres recusou o uuid. Ha um
teste so para essa ordem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-24 11:43:53 -03:00
1 parent bb12e603a3
commit 1e7c6a118c
7 files changed
+309 -3

No files matched your search

+7 -1
View File
@@ -106,7 +106,13 @@
"runChecks": "Run",
"checksRun": "{count} checks",
"pickADomain": "Pick a domain.",
"aliasSource": "Alias address"
"aliasSource": "Alias address",
"setPassword": "Set password",
"atLeast12": "At least 12 characters",
"passwordReset": "Password changed. This user's sessions were signed out.",
"passwordResetWarning": "Signs out every session this user holds, including one open right now.",
"removeTotp": "Remove second factor",
"totpRemoved": "Second factor removed, with its recovery codes."
},
"auth": {
"adminLoginTitle": "Admin console",
+7 -1
View File
@@ -106,7 +106,13 @@
"runChecks": "Ejecutar",
"checksRun": "{count} comprobaciones",
"pickADomain": "Elija un dominio.",
"aliasSource": "Direccion del alias"
"aliasSource": "Direccion del alias",
"setPassword": "Definir contrasena",
"atLeast12": "Al menos 12 caracteres",
"passwordReset": "Contrasena cambiada. Las sesiones de este usuario se cerraron.",
"passwordResetWarning": "Cierra todas las sesiones de este usuario, incluida una abierta ahora.",
"removeTotp": "Quitar segundo factor",
"totpRemoved": "Segundo factor eliminado, con sus codigos de recuperacion."
},
"auth": {
"adminLoginTitle": "Consola de administración",
+7 -1
View File
@@ -106,7 +106,13 @@
"runChecks": "Correr",
"checksRun": "{count} verificacoes",
"pickADomain": "Escolha um dominio.",
"aliasSource": "Endereco do apelido"
"aliasSource": "Endereco do apelido",
"setPassword": "Definir palavra-passe",
"atLeast12": "Pelo menos 12 caracteres",
"passwordReset": "Palavra-passe alterada. As sessoes deste utilizador foram terminadas.",
"passwordResetWarning": "Termina todas as sessoes deste utilizador, incluindo a que esteja aberta agora.",
"removeTotp": "Remover segundo factor",
"totpRemoved": "Segundo factor removido, com os codigos de recuperacao."
},
"auth": {
"adminLoginTitle": "Console de administração",
@@ -117,6 +117,9 @@ export default function AdminMailboxesPage() {
const [saving, setSaving] = useState(false);
const [newAliasForUser, setNewAliasForUser] = useState("");
const [aliasBusy, setAliasBusy] = useState(false);
const [newUserPassword, setNewUserPassword] = useState("");
const [pwBusy, setPwBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const load = useCallback(async () => {
try {
@@ -247,6 +250,8 @@ export default function AdminMailboxesPage() {
setEditQuotaMb(Math.round(mb.quotaBytes / 1048576));
setEditLocale(mb.locale);
setEditAliases(null);
setNewUserPassword("");
setNotice(null);
try {
const res = await fetch(`/api/v1/admin/mailboxes/${encodeURIComponent(mb.id)}/aliases`);
const data = res.ok ? await res.json() : [];
@@ -311,6 +316,54 @@ export default function AdminMailboxesPage() {
}
};
/**
* Sets the user's password. Their sessions are signed out by the server,
* which is the point: this is either a handover or a response to a
* compromise.
*/
const resetUserPassword = async () => {
if (!editing || newUserPassword.length < 12) return;
setPwBusy(true);
setError(null);
setNotice(null);
try {
const res = await fetch(`/api/v1/admin/mailboxes/${encodeURIComponent(editing.id)}/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: newUserPassword }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message);
setNewUserPassword("");
setNotice(t("admin.passwordReset"));
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
} finally {
setPwBusy(false);
}
};
/** Removes the second factor, for somebody who lost their authenticator. */
const removeUserTotp = async () => {
if (!editing) return;
setPwBusy(true);
setError(null);
setNotice(null);
try {
const res = await fetch(`/api/v1/admin/mailboxes/${encodeURIComponent(editing.id)}/totp`, {
method: "DELETE",
});
if (!res.ok) throw new Error();
setNotice(t("admin.totpRemoved"));
setEditing({ ...editing, mfaEnabled: false });
await load();
} catch {
setError(t("errors.serverError"));
} finally {
setPwBusy(false);
}
};
const saveUser = async () => {
if (!editing) return;
setSaving(true);
@@ -782,6 +835,56 @@ export default function AdminMailboxesPage() {
</select>
</div>
<div className="rounded-xl bg-dark-card p-3.5 shadow-edge">
<div className="text-xs text-slate-400">{t("admin.setPassword")}</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<input
type="password"
value={newUserPassword}
onChange={(e) => setNewUserPassword(e.target.value)}
placeholder={t("admin.atLeast12")}
aria-label={t("admin.setPassword")}
autoComplete="new-password"
className={`${input} min-w-[200px] flex-1`}
/>
<Button
variant="secondary"
size="sm"
onClick={() => void resetUserPassword()}
disabled={pwBusy || newUserPassword.length < 12}
>
{t("common.save")}
</Button>
</div>
<p className="mt-1.5 text-[11.5px] leading-relaxed text-slate-500">
{t("admin.passwordResetWarning")}
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-2.5 rounded-xl bg-dark-card p-3.5 shadow-edge">
<div className="min-w-0">
<div className="text-xs text-slate-400">{t("ui.twoFactorSection")}</div>
<div className="mt-1 text-[13px] text-slate-200">
{editing.mfaEnabled ? t("ui.mfaActive") : t("ui.mfaNone")}
</div>
</div>
<Button
variant="danger"
size="sm"
onClick={() => void removeUserTotp()}
disabled={pwBusy || !editing.mfaEnabled}
>
<ShieldCheck className="h-3.5 w-3.5" />
<span>{t("admin.removeTotp")}</span>
</Button>
</div>
{notice && (
<div className="rounded-xl bg-dark-card px-3.5 py-2.5 text-[12.5px] text-slate-200 shadow-edge">
{notice}
</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 ? (