The webmail was indigo over glass and the console was green over glass, so the
two halves of one product did not look related, and neither followed a system
anything could be checked against. This replaces the interface with the
Nocturne design system from Claude Design: a near-neutral blue-grey ground,
Inter at weight 500, solid surfaces with a 1px inset edge instead of blur, and
a single blurple accent used as a line, a mark and a glow rather than a flood.
The palette is remapped at the source rather than renamed at every call site.
tailwind.config.js points slate, indigo, emerald, teal and the rest at the
Nocturne ramps, and globals.css carries the same values as variables, so a
utility class and a token cannot drift apart and no colour can enter the
interface from outside the system. The console's green is gone because it
resolves to the one accent; that was the point.
Structurally, both surfaces now share one rail - the same brand mark, the same
3px accent mark on the active item, the same account tile - with a segmented
control for crossing between them. Crossing into the console still goes to the
step-up rather than the admin sign-in, and leaving it is still a plain link.
Settings moves from eight stacked panels to a tab strip, so enabling a second
factor and writing a filter are no longer separated by everything else.
Nothing is sized by the length of its text any more. Labels wrap, tab strips
wrap instead of scrolling, subjects and queue diagnostics wrap instead of
truncating, and the message list's snippet is clamped to two lines rather than
one. That is what broke when the interface was read in Portuguese or Spanish.
Defects found while doing it:
- The queue and DMARC screens styled their status pills with "badge-danger",
"badge-warning" and "badge-verified" - three class names no stylesheet in
this project defines - so every verdict rendered as identical unstyled
text. They are Badge variants now, which exist.
- The DMARC screen printed the literal string "quarantine" as the
enforcement level whatever the domains publish. The report endpoint returns
no policy at all, so it was asserting a configuration nobody had made. It
shows the DMARC pass ratio it genuinely has.
- Half the mailboxes screen and two lines of the audit trace were hardcoded
strings, some Portuguese and some English, so they stayed in one language
whatever the interface was set to. They come from the bundle now, with the
new keys added to all three locales.
- Avatars printed email[0], so every account at one domain collapsed onto the
same letter. initialsFor takes two letters from the local part's own word
boundaries, behind tests.
Trivy reported one HIGH: nanoid 3.3.16, pulled in transitively by postcss,
vulnerable to a denial of service via an infinite loop in ID generation
(CVE-2026-67213). Pinned to ^3.3.18 through the overrides block the package
already uses for sharp and postcss.
Verified: 98 webmail tests green, tsc clean, production build clean, and the
compiled stylesheet serves the Nocturne values with no stock Tailwind indigo,
emerald or slate hex left in it. Trivy fs with the CI scanners, severities and
skip-dirs exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
7.5 KiB
TypeScript
179 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import Link from "next/link";
|
|
import { KeyRound, ShieldCheck, AlertCircle, Sliders } from "lucide-react";
|
|
import { useTranslations } from "../../../../i18n/provider";
|
|
import { useAccount } from "../../../../lib/useAccount";
|
|
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
|
import { Button } from "../../../../components/ui/Button";
|
|
|
|
/**
|
|
* Crossing from the webmail into the console.
|
|
*
|
|
* The password is not asked for again - the session that got here proved it -
|
|
* so this collects the one thing the console adds over the webmail: the second
|
|
* factor. Sending an operator who was already signed in to a full sign-in form
|
|
* was the app forgetting what it had already been told.
|
|
*
|
|
* An account with no factor yet is walked through enrolling one here rather
|
|
* than being sent off to find the settings screen.
|
|
*/
|
|
export default function AdminStepUpPage() {
|
|
const t = useTranslations();
|
|
const account = useAccount("user");
|
|
|
|
const [code, setCode] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [enrolmentToken, setEnrolmentToken] = useState<string | null>(null);
|
|
const [enrolSecret, setEnrolSecret] = useState<string | null>(null);
|
|
const [enrolUri, setEnrolUri] = useState<string | null>(null);
|
|
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
|
|
|
const submit = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
setError(null);
|
|
setLoading(true);
|
|
|
|
try {
|
|
// Confirming a fresh enrolment, using the grant the step-up handed back.
|
|
if (enrolmentToken) {
|
|
const res = await fetch("/api/v1/user/mfa/totp/confirm", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${enrolmentToken}` },
|
|
body: JSON.stringify({ code }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.message || t("auth.verificationFailed"));
|
|
setRecoveryCodes(data.recovery_codes ?? []);
|
|
setEnrolmentToken(null);
|
|
setEnrolSecret(null);
|
|
setCode("");
|
|
return;
|
|
}
|
|
|
|
const res = await fetch("/api/v1/auth/admin/step-up", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ code }),
|
|
});
|
|
const data = await res.json();
|
|
|
|
// No factor enrolled. The grant that comes back permits enrolling one and
|
|
// nothing else, so the operator can do it without leaving this screen.
|
|
if (res.status === 403 && data.error === "MFA_ENROLLMENT_REQUIRED" && data.enrollment_token) {
|
|
const enroll = await fetch("/api/v1/user/mfa/totp/enroll", {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${data.enrollment_token}` },
|
|
});
|
|
const enrolData = await enroll.json();
|
|
if (!enroll.ok) throw new Error(enrolData.message || t("auth.enrolmentFailed"));
|
|
setEnrolmentToken(data.enrollment_token);
|
|
setEnrolSecret(enrolData.secret);
|
|
setEnrolUri(enrolData.uri);
|
|
setCode("");
|
|
return;
|
|
}
|
|
|
|
// A session with no webmail session behind it cannot step up; the sign-in
|
|
// is where that starts.
|
|
if (res.status === 401) {
|
|
window.location.href = "/user/login?next=/admin/step-up";
|
|
return;
|
|
}
|
|
if (!res.ok) throw new Error(data.message || t("auth.verificationFailed"));
|
|
|
|
window.location.href = "/admin/dashboard";
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : t("auth.genericError"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center bg-dark-bg px-4">
|
|
<div className="w-full max-w-[400px] rounded-2xl bg-dark-panel p-7 shadow-edge">
|
|
<div className="mb-6 flex items-center gap-3">
|
|
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-dark-card text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
|
<Sliders className="h-5 w-5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h1 className="text-lg font-medium leading-tight text-slate-100">{t("auth.stepUpTitle")}</h1>
|
|
<p className="mt-0.5 break-words text-[12.5px] leading-relaxed text-slate-400">
|
|
{account?.email ? t("auth.stepUpSignedInAs", { email: account.email }) : t("common.loading")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4 flex items-start gap-2.5 rounded-xl bg-rose-900/60 p-3.5 text-xs leading-relaxed text-rose-200 shadow-edge">
|
|
<AlertCircle className="mt-px h-4 w-4 flex-none" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{recoveryCodes ? (
|
|
<RecoveryCodes
|
|
codes={recoveryCodes}
|
|
onContinue={() => setRecoveryCodes(null)}
|
|
continueLabel={t("common.continue")}
|
|
/>
|
|
) : (
|
|
<form onSubmit={submit} className="flex flex-col gap-4">
|
|
{enrolSecret ? (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="rounded-xl bg-dark-card p-3.5 text-xs leading-relaxed text-slate-300 shadow-edge">
|
|
{t("auth.enrolmentIntro")}
|
|
</div>
|
|
<TotpEnrolment secret={enrolSecret} uri={enrolUri} />
|
|
</div>
|
|
) : (
|
|
<div className="flex items-start gap-2.5 rounded-xl bg-dark-card p-3.5 text-xs leading-relaxed text-slate-300 shadow-edge">
|
|
<ShieldCheck className="mt-px h-4 w-4 flex-none text-indigo-500" />
|
|
<span>{t("auth.stepUpIntro")}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label htmlFor="step-up-code" className="mb-1.5 block text-xs text-slate-400">
|
|
{t("auth.totpCodeLabel")}
|
|
</label>
|
|
<div className="relative">
|
|
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
<input
|
|
id="step-up-code"
|
|
type="text"
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
autoFocus
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value.replace(/\s/g, ""))}
|
|
placeholder="123456"
|
|
required
|
|
className="w-full rounded-xl bg-dark-card py-3 pl-[38px] pr-3 text-center font-mono text-lg tracking-[0.3em] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent"
|
|
/>
|
|
</div>
|
|
{/* A recovery code is the way in when the phone is gone, and
|
|
nothing on the old screens said so. */}
|
|
<p className="mt-1.5 text-[11.5px] leading-relaxed text-slate-500">{t("auth.orRecoveryCode")}</p>
|
|
</div>
|
|
|
|
<Button type="submit" variant="primary" size="lg" className="w-full" disabled={loading}>
|
|
{loading ? t("common.loading") : t("auth.verifyCodeButton")}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
|
|
<div className="mt-6 pt-4 text-center">
|
|
<div className="lm-rule mb-4" />
|
|
<Link href="/user/mail/inbox" className="text-xs text-slate-400 transition-colors hover:text-slate-200">
|
|
{t("admin.backToWebmail")}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|