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")}

- )} -