#22: fix the admin lockout, and the regressions the UI rewrite brought back

The console demanded a second factor and refused to open without one, but
enrolment lived on the other surface - so an operator whose only account
was the administrator had no way in. The password was already verified at
that point, so the refusal now carries a short-lived grant that permits
enrolling a factor and nothing else, and the admin login screen walks
through it: secret, confirmation, recovery codes, sign in. Two tests cover
it, including that the grant opens no other endpoint.

The rewrite also undid work that was already in place:

  - The postcss override was dropped and the advisory came back, along
    with a critical one in vitest. postcss is a direct dependency now, so
    the fix is the version plus an override scoped to Next's own copy;
    vitest moves to 3.2.7. The lockfile needed regenerating for either to
    take effect. Zero advisories now.

  - 100 language-lint violations: the admin console had been rewritten
    with its strings in Portuguese, hardcoded. That fails CI, and it also
    puts those strings beyond the reach of the translation bundles - an
    English or Spanish user would have read Portuguese. They are English
    in the source again.

  - The mail screen put a fixed 320px list beside the reader with no
    breakpoint, so on a phone the viewport overflowed and the message was
    a few pixels wide. One pane at a time below md, with a way back.

  - The icon-only navigation toggles had nothing for a screen reader to
    announce.

The layouts' own mobile navigation and every previously fixed behaviour
survived the rewrite - checked rather than assumed.

Also fixes a marker collision I introduced: the E2E helper locates the
repository by a migrations directory, and services/migrations satisfied
that one directory too early. It now requires the SQL to actually be
there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 16:13:14 -03:00
1 parent 2282dc96e9
commit 3a82d82428
26 files changed
+3554 -1402

No files matched your search

+31 -2
View File
@@ -6,6 +6,9 @@ import { createJwt, verifyJwt, isSurfaceAuthorized, type SessionTokenPayload } f
import * as repo from "./repository.js";
const CHALLENGE_TTL_SECONDS = 300;
// Long enough to scan a code and type one back, short enough that a token
// granting enrolment is not left lying around.
const ENROLMENT_TTL_SECONDS = 900;
const SESSION_TTL_SECONDS = 28800;
/** Cookie scoped to the whole origin: the API lives under /api, so a cookie
@@ -94,7 +97,19 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
if (url.startsWith("/api/v1/admin/") && !isSurfaceAuthorized(session, "admin")) {
return sendJson(res, 401, { error: "UNAUTHORIZED", message: "Admin session required" });
}
if (url.startsWith("/api/v1/user/") && !isSurfaceAuthorized(session, "user")) {
// The two enrolment endpoints also accept the short-lived token handed out
// when a password was correct but no second factor exists. It grants
// nothing else, so an operator can enrol from the admin login screen
// instead of having to discover the webmail settings.
const isEnrolmentRoute =
url === "/api/v1/user/mfa/totp/enroll" || url === "/api/v1/user/mfa/totp/confirm";
const hasEnrolmentGrant = session?.purpose === "mfa_enrollment";
if (
url.startsWith("/api/v1/user/") &&
!isSurfaceAuthorized(session, "user") &&
!(isEnrolmentRoute && hasEnrolmentGrant)
) {
return sendJson(res, 401, { error: "UNAUTHORIZED", message: "User session required" });
}
@@ -217,9 +232,23 @@ async function login(req: IncomingMessage, res: ServerResponse, surface: "user"
// An admin whose second factor is not enrolled must not fall through to a
// full session: the admin surface requires MFA unconditionally.
if (surface === "admin" && !(await repo.hasConfirmedTotp(mailbox.id))) {
const enrolmentToken = createJwt(
{
sub: mailbox.id,
email: mailbox.email_address,
role: mailbox.role,
domainId: mailbox.domain_id,
surface: "user",
aud: "lambdamail:user",
mfaSatisfied: false,
purpose: "mfa_enrollment",
},
ENROLMENT_TTL_SECONDS,
);
return sendJson(res, 403, {
error: "MFA_ENROLLMENT_REQUIRED",
message: "Enroll a second factor from the webmail settings before using the admin console",
message: "The admin console requires a second factor. Enrol one to continue.",
enrollment_token: enrolmentToken,
});
}