From b33fc12f08996f186218b3ac8acc078b2d792341 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Tue, 18 Aug 2026 20:25:24 -0300 Subject: [PATCH] #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 --- services/webmail/messages/en.json | 18 + services/webmail/messages/es.json | 18 + services/webmail/messages/pt-BR.json | 18 + services/webmail/package-lock.json | 6 +- services/webmail/package.json | 3 +- .../src/app/(admin)/admin/dashboard/page.tsx | 105 +- .../src/app/(admin)/admin/dmarc/page.tsx | 100 +- .../src/app/(admin)/admin/domains/page.tsx | 240 ++--- .../webmail/src/app/(admin)/admin/layout.tsx | 166 ++-- .../src/app/(admin)/admin/mailboxes/page.tsx | 593 ++++++------ .../src/app/(admin)/admin/queue/page.tsx | 122 ++- .../src/app/(admin)/admin/security/page.tsx | 384 ++++---- .../src/app/(auth)/admin/login/page.tsx | 192 ++-- .../src/app/(auth)/admin/step-up/page.tsx | 50 +- .../src/app/(auth)/user/login/page.tsx | 52 +- .../src/app/(user)/user/compose/page.tsx | 333 ++++--- .../webmail/src/app/(user)/user/layout.tsx | 291 +++--- .../app/(user)/user/mail/[folder]/page.tsx | 337 ++++--- .../src/app/(user)/user/settings/page.tsx | 897 +++++++++++------- services/webmail/src/app/globals.css | 288 +++++- services/webmail/src/app/layout.tsx | 2 +- .../webmail/src/components/TotpEnrolment.tsx | 18 +- services/webmail/src/components/ui/Badge.tsx | 22 +- services/webmail/src/components/ui/Button.tsx | 26 +- services/webmail/src/components/ui/Card.tsx | 24 +- .../webmail/src/i18n/LanguageSwitcher.tsx | 4 +- services/webmail/src/initials.test.ts | 26 + services/webmail/src/lib/initials.ts | 20 + services/webmail/tailwind.config.js | 134 ++- 29 files changed, 2596 insertions(+), 1893 deletions(-) create mode 100644 services/webmail/src/initials.test.ts create mode 100644 services/webmail/src/lib/initials.ts diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 60a82ef..0a4b87f 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -1,6 +1,7 @@ { "admin": { "addAlias": "Add alias", + "aliasTarget": "Destination address", "aliasesTitle": "Aliases", "auditLog": "Audit log", "backToWebmail": "Back to webmail", @@ -9,7 +10,12 @@ "checksPassed": "{passed} of {total} passed", "createAccount": "Create account", "createAlias": "Create a routing alias", + "csvImportFailed": "The CSV import failed", + "csvImported": "Imported {count} mailboxes.", + "csvIntro": "Paste CSV to provision several accounts with their RFC 6154 folders. Columns: email, role, quota_mb.", "csvPreview": "Preview", + "csvRun": "Run import", + "csvTitle": "Bulk mailbox import (CSV)", "dashboardSubtitle": "Live figures read from the database.", "dashboardTitle": "Overview", "dkimAlgorithm": "Key type", @@ -34,6 +40,7 @@ "jobRetried": "Job {id} was queued for another attempt.", "lastChecked": "Last checked: {when}", "lastReload": "Last reload", + "lockAccount": "Lock", "mailHost": "Mail host", "mailboxCount": "{count} mailboxes", "mailboxesSubtitle": "Accounts, aliases, CSV import and the second-factor policy.", @@ -55,6 +62,7 @@ "queueEmpty": "The outbound queue is empty.", "queueSubtitle": "Inspect the outbound queue, retry a job, or cancel one.", "queueTitle": "Outbound queue", + "quotaMb": "Quota (MB)", "readinessIntro": "Checks the server reports about itself before it carries production mail.", "reconcileDns": "Reconcile DNS", "reconcileNotImplemented": "Per-record DNS verification is not implemented yet: this records the request and re-reads the stored status, it does not query a resolver.", @@ -65,7 +73,10 @@ "saveThresholds": "Save thresholds", "securitySubtitle": "Readiness checks, TLS state, spam thresholds and the audit trail.", "securityTitle": "Security", + "tabAccounts": "Accounts", + "tabAliases": "Aliases", "tabAudit": "Audit trail", + "tabCsv": "Bulk import", "tabReadiness": "Readiness", "tabRspamd": "Spam thresholds", "tabTls": "TLS", @@ -73,8 +84,11 @@ "tlsTitle": "Certificate and transport", "traceNoResults": "No trace found for that queue ID.", "tracePlaceholder": "Queue ID", + "traceResultFor": "Trace for {id}", + "traceStep": "Step {n}", "traceTitle": "Trace a message", "unknownSni": "Unknown SNI requests", + "unlockAccount": "Unlock", "verifyIntro": "Resolves every expected record against public resolvers and reports each one.", "watcherHealthy": "Certificate watcher" }, @@ -236,6 +250,7 @@ "valuePlaceholder": "Value to match..." }, "ui": { + "accountLocked": "Locked", "accountStatus": "Account status", "actions": "Actions", "active": "Active", @@ -275,6 +290,8 @@ "mailboxesActive": "Active mailboxes", "messageBody": "Message", "messageVolume": "Messages", + "mfaActive": "TOTP active", + "mfaNone": "No second factor", "newPassword": "New password", "noAppPasswords": "No app passwords yet.", "noData": "No data yet", @@ -288,6 +305,7 @@ "recipient": "Recipient", "recordName": "Record", "reportingOrg": "Reporting organisation", + "role": "Role", "scanners": "Spam and virus scanning", "sender": "Sender", "signOutOthers": "Sign out other devices", diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index a4bd6dc..14b2071 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -1,6 +1,7 @@ { "admin": { "addAlias": "Añadir apelido", + "aliasTarget": "Dirección de destino", "aliasesTitle": "Alias", "auditLog": "Registro de auditoría", "backToWebmail": "Volver al webmail", @@ -9,7 +10,12 @@ "checksPassed": "{passed} de {total} aprobadas", "createAccount": "Crear cuenta", "createAlias": "Crear un alias de enrutamiento", + "csvImportFailed": "La importación del CSV falló", + "csvImported": "{count} buzones importados.", + "csvIntro": "Pega el CSV para crear varias cuentas con sus carpetas de la RFC 6154. Columnas: email, rol, cuota_mb.", "csvPreview": "Vista previa", + "csvRun": "Ejecutar la importación", + "csvTitle": "Importar buzones en masa (CSV)", "dashboardSubtitle": "Cifras en vivo leídas de la base de datos.", "dashboardTitle": "Resumen", "dkimAlgorithm": "Tipo de clave", @@ -34,6 +40,7 @@ "jobRetried": "El trabajo {id} se volvió a poner en cola para otro intento.", "lastChecked": "Última comprobación: {when}", "lastReload": "Última recarga", + "lockAccount": "Bloquear", "mailHost": "Host de correo", "mailboxCount": "{count} buzones", "mailboxesSubtitle": "Cuentas, alias, importación CSV y la política de segundo factor.", @@ -55,6 +62,7 @@ "queueEmpty": "La cola de salida está vacía.", "queueSubtitle": "Inspeccione la cola de salida, reintente un trabajo o cancele uno.", "queueTitle": "Cola de salida", + "quotaMb": "Cuota (MB)", "readinessIntro": "Verificaciones que el servidor informa sobre sí mismo antes de transportar correo en producción.", "reconcileDns": "Reconciliar DNS", "reconcileNotImplemented": "La verificación de registros DNS individuales aún no está implementada: esto registra la solicitud y relee el estado almacenado, no consulta un resolutor.", @@ -65,7 +73,10 @@ "saveThresholds": "Guardar umbrales", "securitySubtitle": "Verificaciones de preparación, estado TLS, umbrales de spam y el registro de auditoría.", "securityTitle": "Seguridad", + "tabAccounts": "Cuentas", + "tabAliases": "Alias", "tabAudit": "Registro de auditoría", + "tabCsv": "Importación masiva", "tabReadiness": "Preparación", "tabRspamd": "Umbrales de spam", "tabTls": "TLS", @@ -73,8 +84,11 @@ "tlsTitle": "Certificado y transporte", "traceNoResults": "No se encontró rastro para ese ID de cola.", "tracePlaceholder": "ID de la cola", + "traceResultFor": "Rastreo de {id}", + "traceStep": "Paso {n}", "traceTitle": "Rastrear un mensaje", "unknownSni": "Solicitudes SNI desconocidas", + "unlockAccount": "Desbloquear", "verifyIntro": "Resuelve cada registro esperado en resolutores públicos e informa uno por uno.", "watcherHealthy": "Monitor de certificado" }, @@ -236,6 +250,7 @@ "valuePlaceholder": "Valor a coincidir..." }, "ui": { + "accountLocked": "Bloqueada", "accountStatus": "Estado de la cuenta", "actions": "Acciones", "active": "Activo", @@ -275,6 +290,8 @@ "mailboxesActive": "Buzones activos", "messageBody": "Mensaje", "messageVolume": "Mensajes", + "mfaActive": "TOTP activo", + "mfaNone": "Sin segundo factor", "newPassword": "Nueva contraseña", "noAppPasswords": "Aún no hay contraseñas de aplicación.", "noData": "Aún sin datos", @@ -288,6 +305,7 @@ "recipient": "Destinatario", "recordName": "Registro", "reportingOrg": "Organización informante", + "role": "Rol", "scanners": "Análisis de spam y virus", "sender": "Remitente", "signOutOthers": "Cerrar sesión en otros dispositivos", diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 1ae1bd0..a73ca10 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -1,6 +1,7 @@ { "admin": { "addAlias": "Adicionar apelido", + "aliasTarget": "Endereço de destino", "aliasesTitle": "Apelidos", "auditLog": "Trilha de auditoria", "backToWebmail": "Voltar ao webmail", @@ -9,7 +10,12 @@ "checksPassed": "{passed} de {total} aprovadas", "createAccount": "Criar conta", "createAlias": "Criar um apelido de roteamento", + "csvImportFailed": "A importação do CSV falhou", + "csvImported": "{count} caixas importadas.", + "csvIntro": "Cole o CSV para criar várias contas com as pastas da RFC 6154. Colunas: email, papel, cota_mb.", "csvPreview": "Pré-visualizar", + "csvRun": "Executar importação", + "csvTitle": "Importar caixas em massa (CSV)", "dashboardSubtitle": "Números ao vivo lidos do banco de dados.", "dashboardTitle": "Visão geral", "dkimAlgorithm": "Tipo de chave", @@ -34,6 +40,7 @@ "jobRetried": "O trabalho {id} foi recolocado na fila para nova tentativa.", "lastChecked": "Última verificação: {when}", "lastReload": "Última recarga", + "lockAccount": "Bloquear", "mailHost": "Host de e-mail", "mailboxCount": "{count} caixas de e-mail", "mailboxesSubtitle": "Contas, apelidos, importação CSV e a política de segundo fator.", @@ -55,6 +62,7 @@ "queueEmpty": "A fila de saída está vazia.", "queueSubtitle": "Inspecione a fila de saída, tente reenviar um trabalho ou cancele um.", "queueTitle": "Fila de saída", + "quotaMb": "Cota (MB)", "readinessIntro": "Verificações que o servidor relata sobre si mesmo antes de transportar mensagens em produção.", "reconcileDns": "Reconciliar DNS", "reconcileNotImplemented": "A verificação de registros DNS individuais ainda não foi implementada: isto registra a solicitação e relê o status armazenado, não consulta um resolvedor.", @@ -65,7 +73,10 @@ "saveThresholds": "Salvar limites", "securitySubtitle": "Verificações de prontidão, estado do TLS, limites de spam e a trilha de auditoria.", "securityTitle": "Segurança", + "tabAccounts": "Contas", + "tabAliases": "Apelidos", "tabAudit": "Trilha de auditoria", + "tabCsv": "Importação em massa", "tabReadiness": "Prontidão", "tabRspamd": "Limites de spam", "tabTls": "TLS", @@ -73,8 +84,11 @@ "tlsTitle": "Certificado e transporte", "traceNoResults": "Nenhum rastro encontrado para esse ID de fila.", "tracePlaceholder": "ID da fila", + "traceResultFor": "Rastreamento de {id}", + "traceStep": "Etapa {n}", "traceTitle": "Rastrear uma mensagem", "unknownSni": "Requisições SNI desconhecidas", + "unlockAccount": "Desbloquear", "verifyIntro": "Resolve cada registro esperado em servidores DNS públicos e relata um a um.", "watcherHealthy": "Monitor de certificado" }, @@ -236,6 +250,7 @@ "valuePlaceholder": "Valor a corresponder..." }, "ui": { + "accountLocked": "Bloqueada", "accountStatus": "Status da conta", "actions": "Ações", "active": "Ativo", @@ -275,6 +290,8 @@ "mailboxesActive": "Caixas ativas", "messageBody": "Mensagem", "messageVolume": "Mensagens", + "mfaActive": "TOTP ativo", + "mfaNone": "Sem segundo fator", "newPassword": "Nova senha", "noAppPasswords": "Nenhuma senha de aplicativo ainda.", "noData": "Ainda sem dados", @@ -288,6 +305,7 @@ "recipient": "Destinatário", "recordName": "Registro", "reportingOrg": "Organização que reportou", + "role": "Papel", "scanners": "Análise de spam e vírus", "sender": "Remetente", "signOutOthers": "Sair dos outros dispositivos", diff --git a/services/webmail/package-lock.json b/services/webmail/package-lock.json index 73087ea..498a804 100644 --- a/services/webmail/package-lock.json +++ b/services/webmail/package-lock.json @@ -2921,9 +2921,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", diff --git a/services/webmail/package.json b/services/webmail/package.json index c046885..0bece89 100644 --- a/services/webmail/package.json +++ b/services/webmail/package.json @@ -35,6 +35,7 @@ "sharp": "^0.35.0", "next": { "postcss": "^8.5.25" - } + }, + "nanoid": "^3.3.18" } } diff --git a/services/webmail/src/app/(admin)/admin/dashboard/page.tsx b/services/webmail/src/app/(admin)/admin/dashboard/page.tsx index c15f8dc..9b6a786 100644 --- a/services/webmail/src/app/(admin)/admin/dashboard/page.tsx +++ b/services/webmail/src/app/(admin)/admin/dashboard/page.tsx @@ -7,7 +7,6 @@ import { Send, Layers, HardDrive, - CheckCircle2, Server, ShieldCheck, Globe, @@ -15,7 +14,6 @@ import { Zap, } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; -import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card"; import { Badge } from "../../../../components/ui/Badge"; import { Button } from "../../../../components/ui/Button"; @@ -70,30 +68,27 @@ export default function AdminDashboardPage() { const shown = (value: number | undefined, format?: (n: number) => string) => value === undefined || value === null ? t("ui.noData") : format ? format(value) : String(value); + /* The KPI tiles no longer each carry their own hue. Four colours across four + numbers implied four kinds of thing; they are all just counts, and the one + accent marks the icon. */ const kpis = [ { title: t("ui.inboundVolume"), value: shown(metrics?.inbound_24h), subtext: t("ui.messageVolume"), icon: Inbox, - color: "text-indigo-400", - bg: "bg-indigo-500/10", }, { title: t("ui.outboundDelivered"), value: shown(metrics?.outbound_24h), subtext: t("ui.daneVerified"), icon: Send, - color: "text-emerald-400", - bg: "bg-emerald-500/10", }, { title: t("ui.queueDepth"), value: shown(metrics?.queue_depth), subtext: t("ui.postgresRunner"), icon: Layers, - color: "text-cyan-400", - bg: "bg-cyan-500/10", }, { title: t("ui.diskUsed"), @@ -102,42 +97,42 @@ export default function AdminDashboardPage() { ? `${t("ui.storageQuota")}: ${formatBytes(metrics.storage_quota_bytes)}` : t("ui.localDisk"), icon: HardDrive, - color: "text-amber-400", - bg: "bg-amber-500/10", }, ]; + const panel = "flex flex-col gap-3.5 rounded-2xl bg-dark-panel p-[18px] shadow-edge"; + return ( -
+
{/* Header Bar */} -
-
-

+
+
+

{t("admin.dashboardTitle")}

-

{t("admin.dashboardSubtitle")}

+

{t("admin.dashboardSubtitle")}

-
+
{/* Reports the count it can actually see. The old badge asserted "PREFLIGHT: HEALTHY" from a field the endpoint never sends, so it said healthy unconditionally. The real checks live on the security screen. */} {metrics && ( - + {t("ui.domainsVerified")}: {metrics.domains_verified}/{metrics.domains_total} )} -
{/* KPI Grid */} -
+
{kpis.map((kpi, idx) => { const Icon = kpi.icon; return ( @@ -146,22 +141,16 @@ export default function AdminDashboardPage() { initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3, delay: idx * 0.05 }} + className="flex flex-col gap-2.5 rounded-2xl bg-dark-panel p-4 shadow-edge" > - -
- {kpi.title} -
- -
-
-
- {kpi.value} -
-
- - {kpi.subtext} -
-
+
+ + {kpi.title} +
+
+ {kpi.value} +
+
{kpi.subtext}
); })} @@ -179,46 +168,48 @@ export default function AdminDashboardPage() { compose healthchecks, and the per-domain DNS state lives on the domains screen, which reads it from the database. */} - - - - +
+
+ +

{t("ui.domainVerification")} - - +

+
{metrics ? ( -
-
-
- +
+
+
+ {t("ui.domainsVerified")}
-
+
{metrics.domains_verified} - / {metrics.domains_total} + / {metrics.domains_total}
-
-
- +
+
+ {t("ui.mailboxesActive")}
-
{metrics.mailboxes_active}
+
+ {metrics.mailboxes_active} +
-
-
- +
+
+ {t("ui.bounceRate")}
-
+
{(metrics.bounce_rate * 100).toFixed(1)}%
) : ( -
{t("ui.noData")}
+
{t("ui.noData")}
)} - +
); } diff --git a/services/webmail/src/app/(admin)/admin/dmarc/page.tsx b/services/webmail/src/app/(admin)/admin/dmarc/page.tsx index 2ba09f6..a9a2b22 100644 --- a/services/webmail/src/app/(admin)/admin/dmarc/page.tsx +++ b/services/webmail/src/app/(admin)/admin/dmarc/page.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import { useTranslations } from "../../../../i18n/provider"; +import { Badge } from "../../../../components/ui/Badge"; interface DmarcSource { ip: string; @@ -30,78 +31,75 @@ export default function AdminDmarcPage() { .catch(() => {}); }, []); + const pct = (part: number) => + data && data.total_messages > 0 ? `${Math.round((part / data.total_messages) * 100)}%` : t("ui.noData"); + + const tiles = [ + { label: t("ui.totalEvaluated"), value: data ? String(data.total_messages) : t("ui.noData") }, + { label: t("ui.spfRatio"), value: data ? pct(data.spf_pass_count) : t("ui.noData") }, + { label: t("ui.dkimAlignment"), value: data ? pct(data.dkim_pass_count) : t("ui.noData") }, + /* This tile printed the literal string "quarantine" whatever the domains + actually publish - the report endpoint returns no policy at all, so it + was asserting an enforcement level nobody had configured. It reports the + DMARC pass ratio it genuinely has instead. */ + { label: t("ui.dmarcEnforcement"), value: data ? pct(data.dmarc_pass_count) : t("ui.noData") }, + ]; + return ( -
+
-

{t("admin.dmarcTitle")}

-

{t("admin.dmarcSubtitle")}

+

{t("admin.dmarcTitle")}

+

{t("admin.dmarcSubtitle")}

{/* DMARC Stats Summary Grid */} -
-
-
{t("ui.totalEvaluated")}
-
{data ? data.total_messages : t("ui.noData")}
-
- -
-
{t("ui.spfRatio")}
-
- {data && data.total_messages > 0 ? `${Math.round((data.spf_pass_count / data.total_messages) * 100)}%` : t("ui.noData")} +
+ {tiles.map((tile) => ( +
+
{tile.label}
+
+ {tile.value} +
-
- -
-
{t("ui.dkimAlignment")}
-
- {data && data.total_messages > 0 ? `${Math.round((data.dkim_pass_count / data.total_messages) * 100)}%` : t("ui.noData")} -
-
- -
-
{t("ui.dmarcEnforcement")}
-
quarantine
-
+ ))}
{/* Sending Sources Table */} -
-
- {t("ui.sourceIp")} -
- +
- +
- - - - - - + + + + + + - + {(data?.sources || []).map((src, idx) => ( - - - - - + + + + - ))}
{t("ui.sourceIp")}{t("ui.reportingOrg")}{t("ui.messageVolume")}{t("ui.spfResult")}{t("ui.dkimResult")}
{t("ui.sourceIp")}{t("ui.reportingOrg")}{t("ui.messageVolume")}{t("ui.spfResult")}{t("ui.dkimResult")}
{src.ip}{src.org}{src.count} - - {src.spf.toUpperCase()} - +
{src.ip}{src.org}{src.count} + {/* These were "badge-verified" and "badge-danger", class + names no stylesheet defines, so both verdicts rendered + as identical unstyled text. */} + {src.spf.toUpperCase()} - - {src.dkim.toUpperCase()} - + + {src.dkim.toUpperCase()}
+ {(data?.sources ?? []).length === 0 && ( +
{t("ui.noData")}
+ )}
); diff --git a/services/webmail/src/app/(admin)/admin/domains/page.tsx b/services/webmail/src/app/(admin)/admin/domains/page.tsx index 919d616..586a177 100644 --- a/services/webmail/src/app/(admin)/admin/domains/page.tsx +++ b/services/webmail/src/app/(admin)/admin/domains/page.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useState } from "react"; import { Globe, RefreshCw, Plus, KeyRound, CheckCircle2, AlertTriangle } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; -import { Card } from "../../../../components/ui/Card"; import { Badge } from "../../../../components/ui/Badge"; import { Button } from "../../../../components/ui/Button"; @@ -197,144 +196,147 @@ export default function AdminDomainsPage() { { id: "dkim" as const, label: "DKIM", icon: KeyRound }, ]; - return ( -
-
-
-

- {t("admin.domainsTitle")} -

-
+ const panel = "flex flex-col gap-4 rounded-2xl bg-dark-panel p-[18px] shadow-edge"; + const input = + "w-full min-h-[36px] rounded-[10px] bg-dark-card px-3 py-2 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent"; + const fieldLabel = "mb-1.5 block text-xs text-slate-400"; -
- {tabs.map((tab) => { - const Icon = tab.icon; - return ( - - ); - })} -
+ return ( +
+
+

{t("admin.domainsTitle")}

+

{t("admin.verifyIntro")}

+
+ +
+ {tabs.map((tab) => { + const Icon = tab.icon; + return ( + + ); + })}
{notice && ( -
- - {notice} +
+ + {notice}
)} {error && ( -
+
{error}
)} {activeTab === "domains" && ( -
+
{domains.length === 0 && !loading ? ( - {t("admin.noDomains")} +
+ {t("admin.noDomains")} +
) : ( domains.map((d) => ( - -
-
-
- -
-
-
{d.name}
-
- {t("admin.mailboxCount", { count: d.mailbox_count })} - {d.dmarc_policy ? ` | DMARC: ${d.dmarc_policy}` : ""} - {d.mta_sts_mode ? ` | MTA-STS: ${d.mta_sts_mode}` : ""} - {d.dane_enabled ? " | DANE" : ""} -
+
+
+
+
{d.name}
+
+ {t("admin.mailboxCount", { count: d.mailbox_count })} + {d.dmarc_policy ? ` · DMARC: ${d.dmarc_policy}` : ""} + {d.mta_sts_mode ? ` · MTA-STS: ${d.mta_sts_mode}` : ""} + {d.dane_enabled ? " · DANE" : ""}
-
+
{d.dns_status}
-
+
{t("admin.lastChecked", { when: d.dns_last_checked_at ? new Date(d.dns_last_checked_at).toLocaleString() : t("common.never"), })}
- +
)) )} - -
-

{t("admin.verifyIntro")}

-
{verification && ( <> -
- +
+ {verification.verified} / {verification.total} {verification.status}
- +
- - - - + + + + - + {verification.records.map((rec, i) => ( - - + - - +
{t("ui.recordName")}{t("ui.expectedValue")}{t("common.status")}
{t("ui.recordName")}{t("ui.expectedValue")}{t("common.status")}
- {rec.type} {rec.name} +
+ + {rec.type} + + {rec.name} {rec.expected} + + {rec.expected} + {rec.verified ? ( - - + + {rec.proxied ? t("admin.presentProxied") : t("admin.dnsStatusVerified")} ) : ( - + {t("admin.notPublished")} )} @@ -346,89 +348,105 @@ export default function AdminDomainsPage() { )} - + )} {activeTab === "onboarding" && ( - +
-

{t("admin.onboardTitle")}

-

{t("admin.onboardIntro")}

+

{t("admin.onboardTitle")}

+

{t("admin.onboardIntro")}

-
+
- + setOnboardDomain(e.target.value)} placeholder="example.org" required - className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" + className={input} />
-
- +
)} {activeTab === "dkim" && ( - +
-

- +

+ {t("admin.dkimTitle")}

-

- {selected ? selected.name : t("admin.noDomains")} -

+

{selected ? selected.name : t("admin.noDomains")}

-
-
- +
+
+
-
- +
+ setDkimSelector(e.target.value)} pattern="[A-Za-z0-9-]{1,63}" - className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white font-mono focus:outline-none focus:border-emerald-500" + className={`${input} font-mono`} />
- {dkimRecord && ( -
-
{t("admin.dkimPublishRecord")}
-
+
+
{t("admin.dkimPublishRecord")}
+
{dkimRecord.name} IN TXT "{dkimRecord.value}"
-
- {t("admin.dkimOverlap", { days: dkimRecord.overlapDays })} +
+ + + {t("admin.dkimOverlap", { days: dkimRecord.overlapDays })} +
)} - +
)} ); diff --git a/services/webmail/src/app/(admin)/admin/layout.tsx b/services/webmail/src/app/(admin)/admin/layout.tsx index 223c322..99641aa 100644 --- a/services/webmail/src/app/(admin)/admin/layout.tsx +++ b/services/webmail/src/app/(admin)/admin/layout.tsx @@ -5,14 +5,13 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { motion, AnimatePresence } from "framer-motion"; import { - LayoutDashboard, + Gauge, Globe, Users, ListOrdered, Activity, ShieldCheck, LogOut, - Sliders, Menu, X, Lock, @@ -21,6 +20,7 @@ import { import { useTranslations } from "../../../i18n/provider"; import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher"; import { useAccount } from "../../../lib/useAccount"; +import { initialsFor } from "../../../lib/initials"; export default function AdminLayout({ children }: { children: React.ReactNode }) { const t = useTranslations(); @@ -29,7 +29,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const account = useAccount("admin"); const navItems = [ - { href: "/admin/dashboard", label: t("admin.dashboardTitle"), icon: LayoutDashboard }, + { href: "/admin/dashboard", label: t("admin.dashboardTitle"), icon: Gauge }, { href: "/admin/domains", label: t("admin.domainsTitle"), icon: Globe }, { href: "/admin/mailboxes", label: t("admin.mailboxesTitle"), icon: Users }, { href: "/admin/queue", label: t("admin.queueTitle"), icon: ListOrdered }, @@ -43,90 +43,90 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) }); }; + /* The console's rail is the webmail's rail. It used to be green where the + other was indigo, which made one product look like two; the only thing + that now says which surface you are in is the line under the brand and + which half of the switch is lit. */ const SidebarContent = ( -
-
- {/* Admin Surface Branding */} -
-
- -
-
-
LambdaMail
-
- {t("common.adminPortal")} -
+
+
+
+ λ +
+
+
LambdaMail
+
+ {t("common.adminPortal")}
+
+ + +
{/* Leaving the console needs no ceremony - the webmail session was never given up to get here, so this is a plain link back. Coming the other way is what costs a second factor. */} - - - {t("admin.backToWebmail")} - +
+ + + {t("admin.backToWebmail")} + + + + {t("common.adminPortal")} + +
- {/* Admin Navigation */} - -
- - {/* Language and session controls */} -
-
+
- - {/* Operator Profile Footer */} -
-
-
-
- {account?.email?.[0] ?? "?"} -
-
-
- {account?.email ?? t("common.loading")} -
-
- - {account?.role ?? ""} -
-
-
-
-
); @@ -134,25 +134,27 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) return (
{/* Mobile Top Header */} -
-
-
- +
+
+
+ λ
- {t("common.appName")} {t("common.adminPortal")} + + {t("common.appName")} {t("common.adminPortal")} +
- {/* Desktop Executive Sidebar */} -
diff --git a/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx b/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx index 1364668..1e7c99a 100644 --- a/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx +++ b/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx @@ -16,8 +16,8 @@ import { CheckCircle2, } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; -import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card"; import { Badge } from "../../../../components/ui/Badge"; +import { initialsFor } from "../../../../lib/initials"; import { Button } from "../../../../components/ui/Button"; interface Mailbox { @@ -213,12 +213,12 @@ export default function AdminMailboxesPage() { }); const data = await res.json().catch(() => ({})); if (res.ok) { - setCsvSuccessMessage(`Imported ${data.imported ?? csvPreview.length} mailboxes.`); + setCsvSuccessMessage(t("admin.csvImported", { count: data.imported ?? csvPreview.length })); setCsvText(""); setCsvPreview([]); await load(); } else { - setError(data.message ?? "Falha ao importar CSV"); + setError(data.message ?? t("admin.csvImportFailed")); } } catch { setError(t("errors.serverError")); @@ -226,367 +226,392 @@ export default function AdminMailboxesPage() { setTimeout(() => setCsvSuccessMessage(null), 4000); }; + /* The tab labels, the lock buttons and the two-factor and lock states used to + be literal strings - some Portuguese, some English - so half this screen + stayed in one language whatever the interface was set to. They all come + from the bundle now. */ + const tabs = [ + { id: "mailboxes" as const, label: `${t("admin.tabAccounts")} (${mailboxes.length})`, icon: Users }, + { id: "aliases" as const, label: `${t("admin.tabAliases")} (${aliases.length})`, icon: Mail }, + { id: "csv" as const, label: t("admin.tabCsv"), icon: FileSpreadsheet }, + ]; + + const mfaPolicies = [ + { id: "optional" as const, label: t("admin.mfaOptional") }, + { id: "required_admins" as const, label: t("admin.mfaAdmins") }, + { id: "required_all" as const, label: t("admin.mfaEveryone") }, + ]; + + const panel = "flex flex-col gap-4 rounded-2xl bg-dark-panel p-[18px] shadow-edge"; + const input = + "w-full min-h-[36px] rounded-[10px] bg-dark-card px-3 py-2 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent"; + const fieldLabel = "mb-1.5 block text-xs text-slate-400"; + const iconButton = + "flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100"; + return ( -
+
{/* Header & Tabs */} -
-
-

- {t("admin.mailboxesTitle")} -

-

- {t("admin.mailboxesSubtitle")} -

-
+
+

{t("admin.mailboxesTitle")}

+

{t("admin.mailboxesSubtitle")}

+
- {/* Tab Selection Navigation */} -
- - - - - -
+
+ {tabs.map((tab) => { + const Icon = tab.icon; + return ( + + ); + })}
{error && ( -
- +
+ {error}
)} {/* Domain MFA Policy Panel */} - -
-
-

- - {t("admin.mfaPolicyTitle")} -

-

- {t("admin.mfaPolicyIntro")} -

-
- -
- - - +
+
+
+ + {t("admin.mfaPolicyTitle")}
+

{t("admin.mfaPolicyIntro")}

- + + {/* One segmented control instead of three buttons in three different + colours: these are three values of one setting, not three actions. */} +
+ {mfaPolicies.map((policy) => ( + + ))} +
+
{/* TAB 1: MAILBOXES */} {activeTab === "mailboxes" && ( -
- - - - - {t("ui.provisionMailbox")} - - -
- setNewEmail(e.target.value)} - placeholder="nova.conta@domain.com" - required - className="px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" - /> - setNewPassword(e.target.value)} - placeholder={t("ui.newPassword")} - minLength={12} - required - className="px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" - /> - - setNewQuota(parseInt(e.target.value, 10))} - placeholder={t("ui.storageQuota")} - className="px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-emerald-500" - /> -
-
- - -
- Contas Cadastradas ({mailboxes.length}) -
+ +
- +
- - - - - - - + + + + + + + - + {mailboxes.map((mb) => ( - - - + - + - - - ))}
{t("ui.emailAddress")}{t("ui.accountStatus")}{t("ui.storageQuota")}{t("ui.twoFactorSection")}{t("ui.accountStatus")}{t("ui.actions")}
{t("ui.emailAddress")}{t("ui.role")}{t("ui.storageQuota")}{t("ui.twoFactorSection")}{t("ui.accountStatus")}{t("ui.actions")}
{mb.email} - {mb.role} +
+
+ + {initialsFor(mb.email)} + + {mb.email} +
-
-
-
+ + {mb.role} + +
+
+ + -
- - {mb.storageUsedMb}MB / {mb.quotaMb}MB + + + {mb.storageUsedMb} / {mb.quotaMb} MB
- - {mb.mfaEnabled ? "TOTP ATIVO" : "SEM 2FA"} + + + {mb.mfaEnabled ? t("ui.mfaActive") : t("ui.mfaNone")} - - {mb.locked ? "BLOQUEADA" : t("ui.active")} + + + {mb.locked ? t("ui.accountLocked") : t("ui.active")} - - + + +
- +
)} {/* TAB 2: ALIASES */} {activeTab === "aliases" && ( -
- - - - - {t("admin.createAlias")} - - -
- setNewAliasAddr(e.target.value)} - placeholder="alias@domain.com" - required - className="flex-1 px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" - /> - setNewTargetAddr(e.target.value)} - placeholder="destino@domain.com" - required - className="flex-1 px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" - /> -
-
- - -
- Aliases de Roteamento ({aliases.length}) -
+ +
- +
- - - - - + + + + + - + {aliases.map((al) => ( - - - - - + + + ))}
{t("ui.emailAddress")}{t("ui.destination")}{t("admin.domainsTitle")}{t("ui.actions")}
{t("admin.aliasesTitle")}{t("ui.destination")}{t("admin.domainsTitle")}{t("ui.actions")}
{al.aliasAddress} - - {al.targetAddress} - {al.domain} - {al.aliasAddress}{al.targetAddress}{al.domain} + + +
- +
)} {/* TAB 3: CSV BULK IMPORT */} {activeTab === "csv" && ( - -
-

- - Bulk mailbox import (CSV) -

-

- Paste CSV to provision several accounts with their RFC 6154 folders. Format: email, role, quota_mb -

-
- - {csvSuccessMessage && ( -
- - {csvSuccessMessage} +
+
+
+

+ + {t("admin.csvTitle")} +

+

{t("admin.csvIntro")}

- )} -