b33fc12f08996f186218b3ac8acc078b2d792341
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b33fc12f08 |
#35: replace both web surfaces with the Nocturne redesign
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>
|
||
|
|
8b69521c76 |
#34: fix the mail counters, the reader pane, and the state between surfaces
The counts were computed inline in JSX where nothing could test them. The
sidebar matched a folder by a special-use role folders do not always carry, its
badges were fetched once and then froze - new mail did not raise them and
reading a message did not lower it - and the list header printed the size of
the loaded page, capped at 50, as the size of the folder. The rules now live in
mailCounts.ts behind tests, useFolders keeps them current from the server's
push and from this tab's own changes, and each filter chip carries its number.
Inline images never left the server: RenderMessage dropped every part a body
references as cid:, so mail written by any normal client rendered with broken
icons. They now come back as bounded data URIs, refusing image/svg+xml, and the
reader resolves them, sizes its frame from the message instead of a fixed
500px, and renders on the light surface mail assumes.
Reading mail no longer asks for a second factor - the mailbox is behind the
account's own password - while the console still requires one unconditionally,
now collected by a step-up that does not ask again for the password the session
in hand just proved. An admin sign-in issues the webmail session too, so
leaving the console is a link rather than another login.
Two defects found while testing the above:
- The admin sign-in checked the password and the second factor and never the
role, so any account that had enrolled a factor from its own settings could
obtain an admin-audience session.
- Session tokens carried nothing that varied below one second, so signing in
twice within the same second minted the identical token and collided with
the unique index on web_sessions.refresh_token_hash. A correct password
answered 500, and a double-clicked sign-in was enough to do it. Tokens now
carry a jti.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
57198e0263 |
#28: stop enrolment from silently replacing a secret the authenticator already has
Enrolling a second factor could reach a state where the authenticator showed perfectly valid codes and every one of them was rejected, with the only feedback being "TOTP verification failed". The browser holds the enrolment secret in component state alone. Reloading the settings page - or leaving and coming back - brought the "enable" button back, and pressing it called the enrol endpoint again, which deleted the pending row and stored a brand new secret. The entry already saved in the authenticator then belonged to a secret the server no longer had. Nothing about that is visible from either side: the app generates codes correctly, the server compares them correctly, and they are for different keys. Enrolment now resumes. The endpoint returns the pending secret if there is one, so the QR shown after a reload is the same one already scanned and rescanning is harmless. Starting over is still possible - ?reset=1, exposed in the settings screen - because someone who deleted the entry from their app needs a secret they can actually generate codes for. A pending row that can no longer be decrypted is replaced rather than resumed, since nobody could ever confirm it. Also: the submitted code now has every non-digit stripped rather than just surrounding whitespace. Authenticators display the code grouped as "123 456", and a value typed or pasted with that space failed the six-character length check before it was ever compared - which, to someone reading the right number off their phone, looks like the server being wrong. The inputs sanitise as you type for the same reason. Ruled out first, rather than guessed at: the algorithm passes all six RFC 6238 reference vectors, base32 round-trips, and the deployment's clock is exactly in sync with real time, so neither the implementation nor drift explains it. Verified: the resume test fails without the change and passes with it; 47 auth tests green against a real Postgres. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ee240ae227 |
#23: show real data, unify the surfaces and make enrolment scannable
The interface was reporting things that were not true. Several screens were
mockups wired to nothing, and a reader with the console in Portuguese was
served a mixture of two languages.
Fabricated data, removed:
- The outbound queue was two invented jobs held in component state. Retry and
Freeze only edited that local array, so the screen showed the same two rows
on every deployment and reported success for actions that never happened.
"Freeze" named an operation the backend has never implemented. It now reads
/api/v1/admin/queue, which was already serving the real thing, and offers
retry and cancel, which exist.
- Every dashboard KPI fell back to an invented constant (1420 received, 890
sent, 3 queued, 18.4% disk), so an unreachable API produced a dashboard of
plausible traffic for a server that might have delivered nothing. Two of
those fields were never returned by the endpoint at all, so the fake disk
figure was what everyone always saw. DMARC did the same with 1250/98%/97%.
- The domains screen showed thirteen DNS records for example.com, all marked
VERIFIED, and posted {domain_id: "default-domain"} to endpoints expecting a
real id. DKIM rotation sends "domain", not "domain_id", so its 400 was
swallowed and a made-up TXT record displayed as though a key had been
issued: rotation had never once worked from this screen.
- The TLS panel asserted a Let's Encrypt RSA 4096 certificate and verified
DANE from static markup, on a deployment currently serving a self-signed
one. It now reads the cert watcher via /api/v1/admin/tls.
- The readiness scorecard printed "10 / 10 all checks passed" regardless of
what preflight reported, including failures.
- A message trace that found nothing invented a five-step delivery - TLS 1.3
accepted, SPF pass, spam score 0.8 - for a message that does not exist.
- Both sidebars showed a fixed admin@lambdamail.local with a hardcoded
"SUPER_ADMIN (2FA)", claiming a second factor whether or not one existed.
- Settings seeded a "[Boletim]" Sieve rule. Saving posts the whole list, so
the first edit wrote that invented filter into the account's real script.
The reconcile endpoint itself answered {records_verified: 13, status:
"VERIFIED"} for any domain id, including one that does not exist, without
resolving a name. It now reports what it does, which is record the request.
Surfaces:
The root was a chooser listing webmail and the console side by side, which
asked visitors a question they should not have to answer and advertised cookie
names and audiences to anonymous readers. It now redirects into the mail
client. Accounts that may administer the server get a link to the console from
inside the app - a link to /admin/login, so crossing over still costs the
second factor the admin audience requires - and the console gets a plain link
back, since the webmail session was never given up to get there.
Enrolment:
Both enrolment screens printed the base32 secret as text and left the operator
to type 32 characters into their phone. The enrol endpoint already returned an
otpauth:// URI; it simply was not drawn. Added a shared component that renders
it as a QR locally - the seed is already in the page, and sending it to an
image service would put it in another request log for nothing.
The recovery-code step rendered its own "sign in" button while the form's
submit button stayed visible below it: two identical buttons, and no way to
copy the codes it insisted you save. It is now terminal, with copy and
continue.
Language:
translate() had no interpolation, so "Undo Send ({seconds}s remaining)" showed
the placeholder and a second hardcoded countdown was appended after it. It now
substitutes, leaving an unmatched token visible rather than blanking it.
The pt-BR bundle had lost its diacritics - "Destinatario", "Ultimo erro",
"Acoes" - from the ASCII-source rule being applied to the translations, which
are data. Restored, and the three bundles are back in key parity at 270 each,
with roughly 120 strings that were hardcoded in the source now keyed.
Healthchecks:
web had none, despite being the only service users reach directly: a wedged
Next process kept its container running and Traefik kept routing to it. Added,
using node's own fetch since the image ships no curl. rspamd and clamav gained
the timeouts and start_periods whose absence would fail them during startup.
migrate stays without one and says why: it is a one-shot job, and a probe
would report unhealthy exactly when it has succeeded.
Verified: 36 webmail tests and 41 auth tests against a real Postgres, both new
admin/me cases confirmed to fail without the route (404). lang-lint clean,
production build clean, 0 vulnerabilities, compose validates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
3a82d82428 |
#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>
|
||
|
|
132cecd41c |
#21: guard the web surfaces and connect them to the auth service
Three separate gaps, all of which made the F4 interface non-functional or unprotected: The admin console rendered for anyone. There was no middleware and no guard in the layout, so /admin/dashboard returned 200 unauthenticated - only its data calls failed. middleware.ts now redirects both surfaces to their login when the session cookie is missing, expired, carries the wrong audience, or is a pre-MFA challenge token. It reads the claims without verifying the signature on purpose: the signing key belongs to the auth service and must not reach the browser bundle. That is sound because this is not the boundary - every /api/v1 request is verified for real upstream. Nothing served /api/v1/*. next.config.ts had no rewrites and there were no route handlers, so every call the pages made 404'd against the Next server, while AUTH_SERVICE_URL sat unused. A catch-all route now proxies to the auth service, forwarding the client address and preserving multiple Set-Cookie headers, which logout needs to clear both surfaces. Session cookies also move to Path=/, since a cookie pinned to /user or /admin was never sent with the /api/v1 requests that needed it. i18n existed only as files. The three bundles had perfect key parity and real translations, but no library, no imports and no switcher: the UI was hardcoded English and i18n.test.ts compared keys while the feature was absent. There is now a provider, a hook, a language switcher, and server-side Accept-Language negotiation that honours q-values and matches a bare "pt" to pt-BR. Logins, both layouts and the settings screen consume it; the admin pages still have English titles. The settings screen invented app passwords in the browser with Math.random and never stored them. It now calls the API, which returns the password once and keeps only its hash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
23da5f43e4 | #20: fix non-ASCII lint violations in webmail and router source files | ||
|
|
6e01fc7ffb | #20: implement F4 webmail, admin console, 2FA TOTP, surface isolation, and i18n |