#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>
This commit is contained in:
1 parent
8b69521c76
commit
b33fc12f08
29 files changed
+2596
-1893
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"admin": {
|
"admin": {
|
||||||
"addAlias": "Add alias",
|
"addAlias": "Add alias",
|
||||||
|
"aliasTarget": "Destination address",
|
||||||
"aliasesTitle": "Aliases",
|
"aliasesTitle": "Aliases",
|
||||||
"auditLog": "Audit log",
|
"auditLog": "Audit log",
|
||||||
"backToWebmail": "Back to webmail",
|
"backToWebmail": "Back to webmail",
|
||||||
@@ -9,7 +10,12 @@
|
|||||||
"checksPassed": "{passed} of {total} passed",
|
"checksPassed": "{passed} of {total} passed",
|
||||||
"createAccount": "Create account",
|
"createAccount": "Create account",
|
||||||
"createAlias": "Create a routing alias",
|
"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",
|
"csvPreview": "Preview",
|
||||||
|
"csvRun": "Run import",
|
||||||
|
"csvTitle": "Bulk mailbox import (CSV)",
|
||||||
"dashboardSubtitle": "Live figures read from the database.",
|
"dashboardSubtitle": "Live figures read from the database.",
|
||||||
"dashboardTitle": "Overview",
|
"dashboardTitle": "Overview",
|
||||||
"dkimAlgorithm": "Key type",
|
"dkimAlgorithm": "Key type",
|
||||||
@@ -34,6 +40,7 @@
|
|||||||
"jobRetried": "Job {id} was queued for another attempt.",
|
"jobRetried": "Job {id} was queued for another attempt.",
|
||||||
"lastChecked": "Last checked: {when}",
|
"lastChecked": "Last checked: {when}",
|
||||||
"lastReload": "Last reload",
|
"lastReload": "Last reload",
|
||||||
|
"lockAccount": "Lock",
|
||||||
"mailHost": "Mail host",
|
"mailHost": "Mail host",
|
||||||
"mailboxCount": "{count} mailboxes",
|
"mailboxCount": "{count} mailboxes",
|
||||||
"mailboxesSubtitle": "Accounts, aliases, CSV import and the second-factor policy.",
|
"mailboxesSubtitle": "Accounts, aliases, CSV import and the second-factor policy.",
|
||||||
@@ -55,6 +62,7 @@
|
|||||||
"queueEmpty": "The outbound queue is empty.",
|
"queueEmpty": "The outbound queue is empty.",
|
||||||
"queueSubtitle": "Inspect the outbound queue, retry a job, or cancel one.",
|
"queueSubtitle": "Inspect the outbound queue, retry a job, or cancel one.",
|
||||||
"queueTitle": "Outbound queue",
|
"queueTitle": "Outbound queue",
|
||||||
|
"quotaMb": "Quota (MB)",
|
||||||
"readinessIntro": "Checks the server reports about itself before it carries production mail.",
|
"readinessIntro": "Checks the server reports about itself before it carries production mail.",
|
||||||
"reconcileDns": "Reconcile DNS",
|
"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.",
|
"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",
|
"saveThresholds": "Save thresholds",
|
||||||
"securitySubtitle": "Readiness checks, TLS state, spam thresholds and the audit trail.",
|
"securitySubtitle": "Readiness checks, TLS state, spam thresholds and the audit trail.",
|
||||||
"securityTitle": "Security",
|
"securityTitle": "Security",
|
||||||
|
"tabAccounts": "Accounts",
|
||||||
|
"tabAliases": "Aliases",
|
||||||
"tabAudit": "Audit trail",
|
"tabAudit": "Audit trail",
|
||||||
|
"tabCsv": "Bulk import",
|
||||||
"tabReadiness": "Readiness",
|
"tabReadiness": "Readiness",
|
||||||
"tabRspamd": "Spam thresholds",
|
"tabRspamd": "Spam thresholds",
|
||||||
"tabTls": "TLS",
|
"tabTls": "TLS",
|
||||||
@@ -73,8 +84,11 @@
|
|||||||
"tlsTitle": "Certificate and transport",
|
"tlsTitle": "Certificate and transport",
|
||||||
"traceNoResults": "No trace found for that queue ID.",
|
"traceNoResults": "No trace found for that queue ID.",
|
||||||
"tracePlaceholder": "Queue ID",
|
"tracePlaceholder": "Queue ID",
|
||||||
|
"traceResultFor": "Trace for {id}",
|
||||||
|
"traceStep": "Step {n}",
|
||||||
"traceTitle": "Trace a message",
|
"traceTitle": "Trace a message",
|
||||||
"unknownSni": "Unknown SNI requests",
|
"unknownSni": "Unknown SNI requests",
|
||||||
|
"unlockAccount": "Unlock",
|
||||||
"verifyIntro": "Resolves every expected record against public resolvers and reports each one.",
|
"verifyIntro": "Resolves every expected record against public resolvers and reports each one.",
|
||||||
"watcherHealthy": "Certificate watcher"
|
"watcherHealthy": "Certificate watcher"
|
||||||
},
|
},
|
||||||
@@ -236,6 +250,7 @@
|
|||||||
"valuePlaceholder": "Value to match..."
|
"valuePlaceholder": "Value to match..."
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
|
"accountLocked": "Locked",
|
||||||
"accountStatus": "Account status",
|
"accountStatus": "Account status",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
@@ -275,6 +290,8 @@
|
|||||||
"mailboxesActive": "Active mailboxes",
|
"mailboxesActive": "Active mailboxes",
|
||||||
"messageBody": "Message",
|
"messageBody": "Message",
|
||||||
"messageVolume": "Messages",
|
"messageVolume": "Messages",
|
||||||
|
"mfaActive": "TOTP active",
|
||||||
|
"mfaNone": "No second factor",
|
||||||
"newPassword": "New password",
|
"newPassword": "New password",
|
||||||
"noAppPasswords": "No app passwords yet.",
|
"noAppPasswords": "No app passwords yet.",
|
||||||
"noData": "No data yet",
|
"noData": "No data yet",
|
||||||
@@ -288,6 +305,7 @@
|
|||||||
"recipient": "Recipient",
|
"recipient": "Recipient",
|
||||||
"recordName": "Record",
|
"recordName": "Record",
|
||||||
"reportingOrg": "Reporting organisation",
|
"reportingOrg": "Reporting organisation",
|
||||||
|
"role": "Role",
|
||||||
"scanners": "Spam and virus scanning",
|
"scanners": "Spam and virus scanning",
|
||||||
"sender": "Sender",
|
"sender": "Sender",
|
||||||
"signOutOthers": "Sign out other devices",
|
"signOutOthers": "Sign out other devices",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"admin": {
|
"admin": {
|
||||||
"addAlias": "Añadir apelido",
|
"addAlias": "Añadir apelido",
|
||||||
|
"aliasTarget": "Dirección de destino",
|
||||||
"aliasesTitle": "Alias",
|
"aliasesTitle": "Alias",
|
||||||
"auditLog": "Registro de auditoría",
|
"auditLog": "Registro de auditoría",
|
||||||
"backToWebmail": "Volver al webmail",
|
"backToWebmail": "Volver al webmail",
|
||||||
@@ -9,7 +10,12 @@
|
|||||||
"checksPassed": "{passed} de {total} aprobadas",
|
"checksPassed": "{passed} de {total} aprobadas",
|
||||||
"createAccount": "Crear cuenta",
|
"createAccount": "Crear cuenta",
|
||||||
"createAlias": "Crear un alias de enrutamiento",
|
"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",
|
"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.",
|
"dashboardSubtitle": "Cifras en vivo leídas de la base de datos.",
|
||||||
"dashboardTitle": "Resumen",
|
"dashboardTitle": "Resumen",
|
||||||
"dkimAlgorithm": "Tipo de clave",
|
"dkimAlgorithm": "Tipo de clave",
|
||||||
@@ -34,6 +40,7 @@
|
|||||||
"jobRetried": "El trabajo {id} se volvió a poner en cola para otro intento.",
|
"jobRetried": "El trabajo {id} se volvió a poner en cola para otro intento.",
|
||||||
"lastChecked": "Última comprobación: {when}",
|
"lastChecked": "Última comprobación: {when}",
|
||||||
"lastReload": "Última recarga",
|
"lastReload": "Última recarga",
|
||||||
|
"lockAccount": "Bloquear",
|
||||||
"mailHost": "Host de correo",
|
"mailHost": "Host de correo",
|
||||||
"mailboxCount": "{count} buzones",
|
"mailboxCount": "{count} buzones",
|
||||||
"mailboxesSubtitle": "Cuentas, alias, importación CSV y la política de segundo factor.",
|
"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.",
|
"queueEmpty": "La cola de salida está vacía.",
|
||||||
"queueSubtitle": "Inspeccione la cola de salida, reintente un trabajo o cancele uno.",
|
"queueSubtitle": "Inspeccione la cola de salida, reintente un trabajo o cancele uno.",
|
||||||
"queueTitle": "Cola de salida",
|
"queueTitle": "Cola de salida",
|
||||||
|
"quotaMb": "Cuota (MB)",
|
||||||
"readinessIntro": "Verificaciones que el servidor informa sobre sí mismo antes de transportar correo en producción.",
|
"readinessIntro": "Verificaciones que el servidor informa sobre sí mismo antes de transportar correo en producción.",
|
||||||
"reconcileDns": "Reconciliar DNS",
|
"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.",
|
"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",
|
"saveThresholds": "Guardar umbrales",
|
||||||
"securitySubtitle": "Verificaciones de preparación, estado TLS, umbrales de spam y el registro de auditoría.",
|
"securitySubtitle": "Verificaciones de preparación, estado TLS, umbrales de spam y el registro de auditoría.",
|
||||||
"securityTitle": "Seguridad",
|
"securityTitle": "Seguridad",
|
||||||
|
"tabAccounts": "Cuentas",
|
||||||
|
"tabAliases": "Alias",
|
||||||
"tabAudit": "Registro de auditoría",
|
"tabAudit": "Registro de auditoría",
|
||||||
|
"tabCsv": "Importación masiva",
|
||||||
"tabReadiness": "Preparación",
|
"tabReadiness": "Preparación",
|
||||||
"tabRspamd": "Umbrales de spam",
|
"tabRspamd": "Umbrales de spam",
|
||||||
"tabTls": "TLS",
|
"tabTls": "TLS",
|
||||||
@@ -73,8 +84,11 @@
|
|||||||
"tlsTitle": "Certificado y transporte",
|
"tlsTitle": "Certificado y transporte",
|
||||||
"traceNoResults": "No se encontró rastro para ese ID de cola.",
|
"traceNoResults": "No se encontró rastro para ese ID de cola.",
|
||||||
"tracePlaceholder": "ID de la cola",
|
"tracePlaceholder": "ID de la cola",
|
||||||
|
"traceResultFor": "Rastreo de {id}",
|
||||||
|
"traceStep": "Paso {n}",
|
||||||
"traceTitle": "Rastrear un mensaje",
|
"traceTitle": "Rastrear un mensaje",
|
||||||
"unknownSni": "Solicitudes SNI desconocidas",
|
"unknownSni": "Solicitudes SNI desconocidas",
|
||||||
|
"unlockAccount": "Desbloquear",
|
||||||
"verifyIntro": "Resuelve cada registro esperado en resolutores públicos e informa uno por uno.",
|
"verifyIntro": "Resuelve cada registro esperado en resolutores públicos e informa uno por uno.",
|
||||||
"watcherHealthy": "Monitor de certificado"
|
"watcherHealthy": "Monitor de certificado"
|
||||||
},
|
},
|
||||||
@@ -236,6 +250,7 @@
|
|||||||
"valuePlaceholder": "Valor a coincidir..."
|
"valuePlaceholder": "Valor a coincidir..."
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
|
"accountLocked": "Bloqueada",
|
||||||
"accountStatus": "Estado de la cuenta",
|
"accountStatus": "Estado de la cuenta",
|
||||||
"actions": "Acciones",
|
"actions": "Acciones",
|
||||||
"active": "Activo",
|
"active": "Activo",
|
||||||
@@ -275,6 +290,8 @@
|
|||||||
"mailboxesActive": "Buzones activos",
|
"mailboxesActive": "Buzones activos",
|
||||||
"messageBody": "Mensaje",
|
"messageBody": "Mensaje",
|
||||||
"messageVolume": "Mensajes",
|
"messageVolume": "Mensajes",
|
||||||
|
"mfaActive": "TOTP activo",
|
||||||
|
"mfaNone": "Sin segundo factor",
|
||||||
"newPassword": "Nueva contraseña",
|
"newPassword": "Nueva contraseña",
|
||||||
"noAppPasswords": "Aún no hay contraseñas de aplicación.",
|
"noAppPasswords": "Aún no hay contraseñas de aplicación.",
|
||||||
"noData": "Aún sin datos",
|
"noData": "Aún sin datos",
|
||||||
@@ -288,6 +305,7 @@
|
|||||||
"recipient": "Destinatario",
|
"recipient": "Destinatario",
|
||||||
"recordName": "Registro",
|
"recordName": "Registro",
|
||||||
"reportingOrg": "Organización informante",
|
"reportingOrg": "Organización informante",
|
||||||
|
"role": "Rol",
|
||||||
"scanners": "Análisis de spam y virus",
|
"scanners": "Análisis de spam y virus",
|
||||||
"sender": "Remitente",
|
"sender": "Remitente",
|
||||||
"signOutOthers": "Cerrar sesión en otros dispositivos",
|
"signOutOthers": "Cerrar sesión en otros dispositivos",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"admin": {
|
"admin": {
|
||||||
"addAlias": "Adicionar apelido",
|
"addAlias": "Adicionar apelido",
|
||||||
|
"aliasTarget": "Endereço de destino",
|
||||||
"aliasesTitle": "Apelidos",
|
"aliasesTitle": "Apelidos",
|
||||||
"auditLog": "Trilha de auditoria",
|
"auditLog": "Trilha de auditoria",
|
||||||
"backToWebmail": "Voltar ao webmail",
|
"backToWebmail": "Voltar ao webmail",
|
||||||
@@ -9,7 +10,12 @@
|
|||||||
"checksPassed": "{passed} de {total} aprovadas",
|
"checksPassed": "{passed} de {total} aprovadas",
|
||||||
"createAccount": "Criar conta",
|
"createAccount": "Criar conta",
|
||||||
"createAlias": "Criar um apelido de roteamento",
|
"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",
|
"csvPreview": "Pré-visualizar",
|
||||||
|
"csvRun": "Executar importação",
|
||||||
|
"csvTitle": "Importar caixas em massa (CSV)",
|
||||||
"dashboardSubtitle": "Números ao vivo lidos do banco de dados.",
|
"dashboardSubtitle": "Números ao vivo lidos do banco de dados.",
|
||||||
"dashboardTitle": "Visão geral",
|
"dashboardTitle": "Visão geral",
|
||||||
"dkimAlgorithm": "Tipo de chave",
|
"dkimAlgorithm": "Tipo de chave",
|
||||||
@@ -34,6 +40,7 @@
|
|||||||
"jobRetried": "O trabalho {id} foi recolocado na fila para nova tentativa.",
|
"jobRetried": "O trabalho {id} foi recolocado na fila para nova tentativa.",
|
||||||
"lastChecked": "Última verificação: {when}",
|
"lastChecked": "Última verificação: {when}",
|
||||||
"lastReload": "Última recarga",
|
"lastReload": "Última recarga",
|
||||||
|
"lockAccount": "Bloquear",
|
||||||
"mailHost": "Host de e-mail",
|
"mailHost": "Host de e-mail",
|
||||||
"mailboxCount": "{count} caixas de e-mail",
|
"mailboxCount": "{count} caixas de e-mail",
|
||||||
"mailboxesSubtitle": "Contas, apelidos, importação CSV e a política de segundo fator.",
|
"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.",
|
"queueEmpty": "A fila de saída está vazia.",
|
||||||
"queueSubtitle": "Inspecione a fila de saída, tente reenviar um trabalho ou cancele um.",
|
"queueSubtitle": "Inspecione a fila de saída, tente reenviar um trabalho ou cancele um.",
|
||||||
"queueTitle": "Fila de saída",
|
"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.",
|
"readinessIntro": "Verificações que o servidor relata sobre si mesmo antes de transportar mensagens em produção.",
|
||||||
"reconcileDns": "Reconciliar DNS",
|
"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.",
|
"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",
|
"saveThresholds": "Salvar limites",
|
||||||
"securitySubtitle": "Verificações de prontidão, estado do TLS, limites de spam e a trilha de auditoria.",
|
"securitySubtitle": "Verificações de prontidão, estado do TLS, limites de spam e a trilha de auditoria.",
|
||||||
"securityTitle": "Segurança",
|
"securityTitle": "Segurança",
|
||||||
|
"tabAccounts": "Contas",
|
||||||
|
"tabAliases": "Apelidos",
|
||||||
"tabAudit": "Trilha de auditoria",
|
"tabAudit": "Trilha de auditoria",
|
||||||
|
"tabCsv": "Importação em massa",
|
||||||
"tabReadiness": "Prontidão",
|
"tabReadiness": "Prontidão",
|
||||||
"tabRspamd": "Limites de spam",
|
"tabRspamd": "Limites de spam",
|
||||||
"tabTls": "TLS",
|
"tabTls": "TLS",
|
||||||
@@ -73,8 +84,11 @@
|
|||||||
"tlsTitle": "Certificado e transporte",
|
"tlsTitle": "Certificado e transporte",
|
||||||
"traceNoResults": "Nenhum rastro encontrado para esse ID de fila.",
|
"traceNoResults": "Nenhum rastro encontrado para esse ID de fila.",
|
||||||
"tracePlaceholder": "ID da fila",
|
"tracePlaceholder": "ID da fila",
|
||||||
|
"traceResultFor": "Rastreamento de {id}",
|
||||||
|
"traceStep": "Etapa {n}",
|
||||||
"traceTitle": "Rastrear uma mensagem",
|
"traceTitle": "Rastrear uma mensagem",
|
||||||
"unknownSni": "Requisições SNI desconhecidas",
|
"unknownSni": "Requisições SNI desconhecidas",
|
||||||
|
"unlockAccount": "Desbloquear",
|
||||||
"verifyIntro": "Resolve cada registro esperado em servidores DNS públicos e relata um a um.",
|
"verifyIntro": "Resolve cada registro esperado em servidores DNS públicos e relata um a um.",
|
||||||
"watcherHealthy": "Monitor de certificado"
|
"watcherHealthy": "Monitor de certificado"
|
||||||
},
|
},
|
||||||
@@ -236,6 +250,7 @@
|
|||||||
"valuePlaceholder": "Valor a corresponder..."
|
"valuePlaceholder": "Valor a corresponder..."
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
|
"accountLocked": "Bloqueada",
|
||||||
"accountStatus": "Status da conta",
|
"accountStatus": "Status da conta",
|
||||||
"actions": "Ações",
|
"actions": "Ações",
|
||||||
"active": "Ativo",
|
"active": "Ativo",
|
||||||
@@ -275,6 +290,8 @@
|
|||||||
"mailboxesActive": "Caixas ativas",
|
"mailboxesActive": "Caixas ativas",
|
||||||
"messageBody": "Mensagem",
|
"messageBody": "Mensagem",
|
||||||
"messageVolume": "Mensagens",
|
"messageVolume": "Mensagens",
|
||||||
|
"mfaActive": "TOTP ativo",
|
||||||
|
"mfaNone": "Sem segundo fator",
|
||||||
"newPassword": "Nova senha",
|
"newPassword": "Nova senha",
|
||||||
"noAppPasswords": "Nenhuma senha de aplicativo ainda.",
|
"noAppPasswords": "Nenhuma senha de aplicativo ainda.",
|
||||||
"noData": "Ainda sem dados",
|
"noData": "Ainda sem dados",
|
||||||
@@ -288,6 +305,7 @@
|
|||||||
"recipient": "Destinatário",
|
"recipient": "Destinatário",
|
||||||
"recordName": "Registro",
|
"recordName": "Registro",
|
||||||
"reportingOrg": "Organização que reportou",
|
"reportingOrg": "Organização que reportou",
|
||||||
|
"role": "Papel",
|
||||||
"scanners": "Análise de spam e vírus",
|
"scanners": "Análise de spam e vírus",
|
||||||
"sender": "Remetente",
|
"sender": "Remetente",
|
||||||
"signOutOthers": "Sair dos outros dispositivos",
|
"signOutOthers": "Sair dos outros dispositivos",
|
||||||
|
|||||||
Generated
+3
-3
@@ -2921,9 +2921,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"sharp": "^0.35.0",
|
"sharp": "^0.35.0",
|
||||||
"next": {
|
"next": {
|
||||||
"postcss": "^8.5.25"
|
"postcss": "^8.5.25"
|
||||||
}
|
},
|
||||||
|
"nanoid": "^3.3.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
Layers,
|
Layers,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
CheckCircle2,
|
|
||||||
Server,
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Globe,
|
Globe,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
|
|
||||||
import { Badge } from "../../../../components/ui/Badge";
|
import { Badge } from "../../../../components/ui/Badge";
|
||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
|
|
||||||
@@ -70,30 +68,27 @@ export default function AdminDashboardPage() {
|
|||||||
const shown = (value: number | undefined, format?: (n: number) => string) =>
|
const shown = (value: number | undefined, format?: (n: number) => string) =>
|
||||||
value === undefined || value === null ? t("ui.noData") : format ? format(value) : String(value);
|
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 = [
|
const kpis = [
|
||||||
{
|
{
|
||||||
title: t("ui.inboundVolume"),
|
title: t("ui.inboundVolume"),
|
||||||
value: shown(metrics?.inbound_24h),
|
value: shown(metrics?.inbound_24h),
|
||||||
subtext: t("ui.messageVolume"),
|
subtext: t("ui.messageVolume"),
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
color: "text-indigo-400",
|
|
||||||
bg: "bg-indigo-500/10",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("ui.outboundDelivered"),
|
title: t("ui.outboundDelivered"),
|
||||||
value: shown(metrics?.outbound_24h),
|
value: shown(metrics?.outbound_24h),
|
||||||
subtext: t("ui.daneVerified"),
|
subtext: t("ui.daneVerified"),
|
||||||
icon: Send,
|
icon: Send,
|
||||||
color: "text-emerald-400",
|
|
||||||
bg: "bg-emerald-500/10",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("ui.queueDepth"),
|
title: t("ui.queueDepth"),
|
||||||
value: shown(metrics?.queue_depth),
|
value: shown(metrics?.queue_depth),
|
||||||
subtext: t("ui.postgresRunner"),
|
subtext: t("ui.postgresRunner"),
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
color: "text-cyan-400",
|
|
||||||
bg: "bg-cyan-500/10",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("ui.diskUsed"),
|
title: t("ui.diskUsed"),
|
||||||
@@ -102,42 +97,42 @@ export default function AdminDashboardPage() {
|
|||||||
? `${t("ui.storageQuota")}: ${formatBytes(metrics.storage_quota_bytes)}`
|
? `${t("ui.storageQuota")}: ${formatBytes(metrics.storage_quota_bytes)}`
|
||||||
: t("ui.localDisk"),
|
: t("ui.localDisk"),
|
||||||
icon: HardDrive,
|
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 (
|
return (
|
||||||
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto">
|
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-5 px-5 pb-11 pt-7 sm:px-8">
|
||||||
{/* Header Bar */}
|
{/* Header Bar */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
<h1 className="text-[25px] font-medium leading-tight text-slate-100">
|
||||||
{t("admin.dashboardTitle")}
|
{t("admin.dashboardTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-400 mt-1">{t("admin.dashboardSubtitle")}</p>
|
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.dashboardSubtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{/* Reports the count it can actually see. The old badge asserted
|
{/* Reports the count it can actually see. The old badge asserted
|
||||||
"PREFLIGHT: HEALTHY" from a field the endpoint never sends, so it
|
"PREFLIGHT: HEALTHY" from a field the endpoint never sends, so it
|
||||||
said healthy unconditionally. The real checks live on the
|
said healthy unconditionally. The real checks live on the
|
||||||
security screen. */}
|
security screen. */}
|
||||||
{metrics && (
|
{metrics && (
|
||||||
<Badge variant={metrics.domains_verified === metrics.domains_total ? "success" : "warning"}>
|
<Badge variant={metrics.domains_verified === metrics.domains_total ? "success" : "warning"}>
|
||||||
<Zap className="w-3.5 h-3.5 mr-1" />
|
<Zap className="h-3.5 w-3.5 flex-none" />
|
||||||
{t("ui.domainsVerified")}: {metrics.domains_verified}/{metrics.domains_total}
|
{t("ui.domainsVerified")}: {metrics.domains_verified}/{metrics.domains_total}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={fetchMetrics} disabled={loading}>
|
<Button variant="secondary" size="sm" onClick={fetchMetrics} disabled={loading}>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||||
<span>{t("common.refresh")}</span>
|
<span>{t("common.refresh")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Grid */}
|
{/* KPI Grid */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{kpis.map((kpi, idx) => {
|
{kpis.map((kpi, idx) => {
|
||||||
const Icon = kpi.icon;
|
const Icon = kpi.icon;
|
||||||
return (
|
return (
|
||||||
@@ -146,22 +141,16 @@ export default function AdminDashboardPage() {
|
|||||||
initial={{ opacity: 0, y: 15 }}
|
initial={{ opacity: 0, y: 15 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3, delay: idx * 0.05 }}
|
transition={{ duration: 0.3, delay: idx * 0.05 }}
|
||||||
|
className="flex flex-col gap-2.5 rounded-2xl bg-dark-panel p-4 shadow-edge"
|
||||||
>
|
>
|
||||||
<Card hoverable className="relative overflow-hidden">
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
<div className="flex items-center justify-between">
|
<Icon className="h-4 w-4 flex-none text-indigo-500" />
|
||||||
<span className="text-xs font-medium text-slate-400">{kpi.title}</span>
|
<span className="text-[12.5px] leading-snug">{kpi.title}</span>
|
||||||
<div className={`w-9 h-9 rounded-xl ${kpi.bg} flex items-center justify-center`}>
|
</div>
|
||||||
<Icon className={`w-5 h-5 ${kpi.color}`} />
|
<div className="text-[30px] font-medium leading-none tracking-[-0.02em] tabular-nums text-slate-100">
|
||||||
</div>
|
{kpi.value}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-black text-white mt-3 tracking-tight">
|
<div className="text-[11.5px] leading-snug text-slate-400">{kpi.subtext}</div>
|
||||||
{kpi.value}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-400 mt-2 flex items-center gap-1 font-mono">
|
|
||||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400 inline" />
|
|
||||||
{kpi.subtext}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -179,46 +168,48 @@ export default function AdminDashboardPage() {
|
|||||||
compose healthchecks, and the per-domain DNS state lives on the domains
|
compose healthchecks, and the per-domain DNS state lives on the domains
|
||||||
screen, which reads it from the database.
|
screen, which reads it from the database.
|
||||||
*/}
|
*/}
|
||||||
<Card>
|
<section className={panel}>
|
||||||
<CardHeader>
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<Globe className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||||
<Globe className="w-5 h-5 text-indigo-400" />
|
<h2 className="text-[17px] font-medium leading-tight text-slate-100">
|
||||||
{t("ui.domainVerification")}
|
{t("ui.domainVerification")}
|
||||||
</CardTitle>
|
</h2>
|
||||||
</CardHeader>
|
</div>
|
||||||
{metrics ? (
|
{metrics ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800">
|
<div className="flex flex-col gap-1.5 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||||
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
<ShieldCheck className="h-3.5 w-3.5 flex-none text-indigo-500" />
|
||||||
{t("ui.domainsVerified")}
|
{t("ui.domainsVerified")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-white">
|
<div className="text-2xl font-medium tabular-nums text-slate-100">
|
||||||
{metrics.domains_verified}
|
{metrics.domains_verified}
|
||||||
<span className="text-slate-500 text-base"> / {metrics.domains_total}</span>
|
<span className="text-base text-slate-500"> / {metrics.domains_total}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800">
|
<div className="flex flex-col gap-1.5 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||||
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
<Server className="w-3.5 h-3.5 text-indigo-400" />
|
<Server className="h-3.5 w-3.5 flex-none text-indigo-500" />
|
||||||
{t("ui.mailboxesActive")}
|
{t("ui.mailboxesActive")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-white">{metrics.mailboxes_active}</div>
|
<div className="text-2xl font-medium tabular-nums text-slate-100">
|
||||||
|
{metrics.mailboxes_active}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800">
|
<div className="flex flex-col gap-1.5 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||||
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
<Send className="w-3.5 h-3.5 text-amber-400" />
|
<Send className="h-3.5 w-3.5 flex-none text-indigo-500" />
|
||||||
{t("ui.bounceRate")}
|
{t("ui.bounceRate")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-white">
|
<div className="text-2xl font-medium tabular-nums text-slate-100">
|
||||||
{(metrics.bounce_rate * 100).toFixed(1)}%
|
{(metrics.bounce_rate * 100).toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-6 text-center text-xs text-slate-500">{t("ui.noData")}</div>
|
<div className="p-6 text-center text-[13px] text-slate-400">{t("ui.noData")}</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
|
import { Badge } from "../../../../components/ui/Badge";
|
||||||
|
|
||||||
interface DmarcSource {
|
interface DmarcSource {
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -30,78 +31,75 @@ export default function AdminDmarcPage() {
|
|||||||
.catch(() => {});
|
.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 (
|
return (
|
||||||
<div className="p-8 space-y-8">
|
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.dmarcTitle")}</h1>
|
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.dmarcTitle")}</h1>
|
||||||
<p className="text-xs text-slate-400">{t("admin.dmarcSubtitle")}</p>
|
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.dmarcSubtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DMARC Stats Summary Grid */}
|
{/* DMARC Stats Summary Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
{tiles.map((tile) => (
|
||||||
<div className="text-xs font-medium text-slate-400 mb-2">{t("ui.totalEvaluated")}</div>
|
<div key={tile.label} className="flex flex-col gap-2.5 rounded-2xl bg-dark-panel p-4 shadow-edge">
|
||||||
<div className="text-3xl font-extrabold text-white">{data ? data.total_messages : t("ui.noData")}</div>
|
<div className="text-[12.5px] leading-snug text-slate-400">{tile.label}</div>
|
||||||
</div>
|
<div className="text-[30px] font-medium leading-none tracking-[-0.02em] tabular-nums text-slate-100">
|
||||||
|
{tile.value}
|
||||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
</div>
|
||||||
<div className="text-xs font-medium text-slate-400 mb-2">{t("ui.spfRatio")}</div>
|
|
||||||
<div className="text-3xl font-extrabold text-emerald-400">
|
|
||||||
{data && data.total_messages > 0 ? `${Math.round((data.spf_pass_count / data.total_messages) * 100)}%` : t("ui.noData")}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
|
||||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
|
||||||
<div className="text-xs font-medium text-slate-400 mb-2">{t("ui.dkimAlignment")}</div>
|
|
||||||
<div className="text-3xl font-extrabold text-emerald-400">
|
|
||||||
{data && data.total_messages > 0 ? `${Math.round((data.dkim_pass_count / data.total_messages) * 100)}%` : t("ui.noData")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
|
||||||
<div className="text-xs font-medium text-slate-400 mb-2">{t("ui.dmarcEnforcement")}</div>
|
|
||||||
<div className="text-3xl font-extrabold text-indigo-400">quarantine</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sending Sources Table */}
|
{/* Sending Sources Table */}
|
||||||
<div className="glass-panel rounded-2xl border border-slate-800 overflow-hidden">
|
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||||
<div className="p-4 border-b border-slate-800 bg-slate-900/60 font-bold text-xs text-slate-300">
|
|
||||||
{t("ui.sourceIp")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse text-xs">
|
<table className="lm-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
<tr>
|
||||||
<th className="p-3">{t("ui.sourceIp")}</th>
|
<th className="pl-0">{t("ui.sourceIp")}</th>
|
||||||
<th className="p-3">{t("ui.reportingOrg")}</th>
|
<th>{t("ui.reportingOrg")}</th>
|
||||||
<th className="p-3">{t("ui.messageVolume")}</th>
|
<th>{t("ui.messageVolume")}</th>
|
||||||
<th className="p-3">{t("ui.spfResult")}</th>
|
<th>{t("ui.spfResult")}</th>
|
||||||
<th className="p-3">{t("ui.dkimResult")}</th>
|
<th className="pr-0 text-right">{t("ui.dkimResult")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
<tbody>
|
||||||
{(data?.sources || []).map((src, idx) => (
|
{(data?.sources || []).map((src, idx) => (
|
||||||
<tr key={idx} className="hover:bg-slate-900/30">
|
<tr key={idx}>
|
||||||
<td className="p-3 font-bold text-slate-200">{src.ip}</td>
|
<td className="pl-0 font-mono text-[12.5px] text-slate-100">{src.ip}</td>
|
||||||
<td className="p-3 text-slate-300">{src.org}</td>
|
<td className="break-words text-[13px] text-slate-300">{src.org}</td>
|
||||||
<td className="p-3 text-slate-400">{src.count}</td>
|
<td className="text-[13px] tabular-nums text-slate-400">{src.count}</td>
|
||||||
<td className="p-3">
|
<td>
|
||||||
<span className={src.spf === "pass" ? "badge-verified px-2 py-0.5 rounded text-[10px]" : "badge-danger px-2 py-0.5 rounded text-[10px]"}>
|
{/* These were "badge-verified" and "badge-danger", class
|
||||||
{src.spf.toUpperCase()}
|
names no stylesheet defines, so both verdicts rendered
|
||||||
</span>
|
as identical unstyled text. */}
|
||||||
|
<Badge variant={src.spf === "pass" ? "success" : "danger"}>{src.spf.toUpperCase()}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="pr-0 text-right">
|
||||||
<span className={src.dkim === "pass" ? "badge-verified px-2 py-0.5 rounded text-[10px]" : "badge-danger px-2 py-0.5 rounded text-[10px]"}>
|
<Badge variant={src.dkim === "pass" ? "success" : "danger"}>{src.dkim.toUpperCase()}</Badge>
|
||||||
{src.dkim.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{(data?.sources ?? []).length === 0 && (
|
||||||
|
<div className="p-8 text-center text-[13px] text-slate-400">{t("ui.noData")}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { Globe, RefreshCw, Plus, KeyRound, CheckCircle2, AlertTriangle } from "lucide-react";
|
import { Globe, RefreshCw, Plus, KeyRound, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { Card } from "../../../../components/ui/Card";
|
|
||||||
import { Badge } from "../../../../components/ui/Badge";
|
import { Badge } from "../../../../components/ui/Badge";
|
||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
|
|
||||||
@@ -197,144 +196,147 @@ export default function AdminDomainsPage() {
|
|||||||
{ id: "dkim" as const, label: "DKIM", icon: KeyRound },
|
{ id: "dkim" as const, label: "DKIM", icon: KeyRound },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
const panel = "flex flex-col gap-4 rounded-2xl bg-dark-panel p-[18px] shadow-edge";
|
||||||
<div className="p-6 md:p-8 space-y-6 max-w-7xl mx-auto">
|
const input =
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
"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";
|
||||||
<div>
|
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
|
||||||
{t("admin.domainsTitle")}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex bg-slate-900/90 p-1.5 rounded-xl border border-slate-800 text-xs font-medium self-start md:self-auto">
|
return (
|
||||||
{tabs.map((tab) => {
|
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
||||||
const Icon = tab.icon;
|
<div>
|
||||||
return (
|
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.domainsTitle")}</h1>
|
||||||
<button
|
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.verifyIntro")}</p>
|
||||||
key={tab.id}
|
</div>
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
<div className="lm-tabstrip self-start">
|
||||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
{tabs.map((tab) => {
|
||||||
activeTab === tab.id
|
const Icon = tab.icon;
|
||||||
? "bg-emerald-600 text-white shadow-sm"
|
return (
|
||||||
: "text-slate-400 hover:text-slate-200"
|
<button
|
||||||
}`}
|
key={tab.id}
|
||||||
>
|
type="button"
|
||||||
<Icon className="w-3.5 h-3.5" />
|
onClick={() => setActiveTab(tab.id)}
|
||||||
<span>{tab.label}</span>
|
data-active={activeTab === tab.id}
|
||||||
</button>
|
aria-pressed={activeTab === tab.id}
|
||||||
);
|
className="lm-tab"
|
||||||
})}
|
>
|
||||||
</div>
|
<Icon className="h-[15px] w-[15px] flex-none" />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notice && (
|
{notice && (
|
||||||
<div className="p-4 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-xs flex items-center gap-2">
|
<div className="flex items-center gap-2 rounded-xl bg-dark-card px-4 py-3 text-[12.5px] text-slate-200 shadow-edge">
|
||||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
|
<CheckCircle2 className="h-4 w-4 flex-none text-indigo-500" />
|
||||||
<span>{notice}</span>
|
<span className="break-words">{notice}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-xs">
|
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] leading-relaxed text-rose-200 shadow-edge">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "domains" && (
|
{activeTab === "domains" && (
|
||||||
<div className="space-y-4">
|
<div className="flex flex-col gap-3">
|
||||||
{domains.length === 0 && !loading ? (
|
{domains.length === 0 && !loading ? (
|
||||||
<Card className="p-8 text-center text-xs text-slate-500">{t("admin.noDomains")}</Card>
|
<div className="rounded-2xl bg-dark-panel p-8 text-center text-[13px] text-slate-400 shadow-edge">
|
||||||
|
{t("admin.noDomains")}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
domains.map((d) => (
|
domains.map((d) => (
|
||||||
<Card key={d.id} className="p-4">
|
<div
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
key={d.id}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
className="flex flex-col gap-3.5 rounded-2xl bg-dark-panel px-[18px] py-4 shadow-edge"
|
||||||
<div className="w-10 h-10 rounded-xl bg-indigo-500/15 border border-indigo-500/30 flex items-center justify-center text-indigo-400 flex-shrink-0">
|
>
|
||||||
<Globe className="w-5 h-5" />
|
<div className="flex flex-wrap items-center gap-3.5">
|
||||||
</div>
|
<div className="min-w-[200px] flex-1">
|
||||||
<div className="min-w-0">
|
<div className="break-words text-lg font-medium leading-tight text-slate-100">{d.name}</div>
|
||||||
<div className="text-base font-bold text-white truncate">{d.name}</div>
|
<div className="mt-1 text-[12.5px] leading-relaxed text-slate-400">
|
||||||
<div className="text-xs text-slate-400">
|
{t("admin.mailboxCount", { count: d.mailbox_count })}
|
||||||
{t("admin.mailboxCount", { count: d.mailbox_count })}
|
{d.dmarc_policy ? ` · DMARC: ${d.dmarc_policy}` : ""}
|
||||||
{d.dmarc_policy ? ` | DMARC: ${d.dmarc_policy}` : ""}
|
{d.mta_sts_mode ? ` · MTA-STS: ${d.mta_sts_mode}` : ""}
|
||||||
{d.mta_sts_mode ? ` | MTA-STS: ${d.mta_sts_mode}` : ""}
|
{d.dane_enabled ? " · DANE" : ""}
|
||||||
{d.dane_enabled ? " | DANE" : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex flex-none flex-wrap items-center gap-2.5">
|
||||||
<Badge variant={statusVariant(d.dns_status)}>{d.dns_status}</Badge>
|
<Badge variant={statusVariant(d.dns_status)}>{d.dns_status}</Badge>
|
||||||
<Button
|
<Button
|
||||||
variant={selectedId === d.id ? "primary" : "outline"}
|
variant={selectedId === d.id ? "primary" : "secondary"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setSelectedId(d.id)}
|
onClick={() => setSelectedId(d.id)}
|
||||||
>
|
>
|
||||||
{selectedId === d.id ? <CheckCircle2 className="w-3.5 h-3.5" /> : null}
|
{selectedId === d.id ? <CheckCircle2 className="h-3.5 w-3.5" /> : null}
|
||||||
<span>{selectedId === d.id ? t("common.status") : t("common.add")}</span>
|
<span>{selectedId === d.id ? t("common.status") : t("common.add")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 pt-3 border-t border-slate-800 text-[11px] text-slate-500 font-mono">
|
<div className="text-[11.5px] text-slate-500">
|
||||||
{t("admin.lastChecked", {
|
{t("admin.lastChecked", {
|
||||||
when: d.dns_last_checked_at
|
when: d.dns_last_checked_at
|
||||||
? new Date(d.dns_last_checked_at).toLocaleString()
|
? new Date(d.dns_last_checked_at).toLocaleString()
|
||||||
: t("common.never"),
|
: t("common.never"),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="space-y-4">
|
<section className={panel}>
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p className="text-xs text-slate-400">{t("admin.verifyIntro")}</p>
|
<h2 className="text-[17px] font-medium leading-tight text-slate-100">
|
||||||
<Button variant="outline" size="sm" onClick={handleReconcile} disabled={reconciling || !selected}>
|
{selected ? t("admin.dnsRecordsFor", { domain: selected.name }) : t("ui.domainVerification")}
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${reconciling ? "animate-spin" : ""}`} />
|
</h2>
|
||||||
|
<Button variant="secondary" size="sm" onClick={handleReconcile} disabled={reconciling || !selected}>
|
||||||
|
<RefreshCw className={`h-3.5 w-3.5 ${reconciling ? "animate-spin" : ""}`} />
|
||||||
<span>{t("admin.reconcileDns")}</span>
|
<span>{t("admin.reconcileDns")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{verification && (
|
{verification && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span
|
<span className="font-mono text-2xl font-medium tabular-nums text-slate-100">
|
||||||
className={`text-2xl font-black font-mono ${
|
|
||||||
verification.verified === verification.total ? "text-emerald-400" : "text-amber-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{verification.verified} / {verification.total}
|
{verification.verified} / {verification.total}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={statusVariant(verification.status)}>{verification.status}</Badge>
|
<Badge variant={statusVariant(verification.status)}>{verification.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse text-xs">
|
<table className="lm-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
<tr>
|
||||||
<th className="p-2.5">{t("ui.recordName")}</th>
|
<th className="pl-0">{t("ui.recordName")}</th>
|
||||||
<th className="p-2.5">{t("ui.expectedValue")}</th>
|
<th>{t("ui.expectedValue")}</th>
|
||||||
<th className="p-2.5">{t("common.status")}</th>
|
<th className="pr-0 text-right">{t("common.status")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
<tbody>
|
||||||
{verification.records.map((rec, i) => (
|
{verification.records.map((rec, i) => (
|
||||||
<tr key={i} className="hover:bg-slate-900/30 align-top">
|
<tr key={i} className="align-top">
|
||||||
<td className="p-2.5 text-slate-200 whitespace-nowrap">
|
<td className="whitespace-nowrap pl-0 text-[13px]">
|
||||||
<span className="text-emerald-400 font-bold">{rec.type}</span> {rec.name}
|
<Badge variant="neutral" className="mr-2 font-mono">
|
||||||
|
{rec.type}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-mono text-[12.5px] text-slate-200">{rec.name}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2.5 text-slate-400 break-all max-w-md">{rec.expected}</td>
|
<td className="max-w-[420px] break-all font-mono text-xs text-slate-400">
|
||||||
<td className="p-2.5 whitespace-nowrap">
|
{rec.expected}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap pr-0 text-right">
|
||||||
{rec.verified ? (
|
{rec.verified ? (
|
||||||
<span className="flex items-center gap-1 text-emerald-400">
|
<span className="inline-flex items-center gap-1.5 text-[12.5px] text-indigo-400">
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
{rec.proxied ? t("admin.presentProxied") : t("admin.dnsStatusVerified")}
|
{rec.proxied ? t("admin.presentProxied") : t("admin.dnsStatusVerified")}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className="flex items-center gap-1 text-amber-400"
|
className="inline-flex items-center gap-1.5 text-[12.5px] text-amber-400"
|
||||||
title={rec.detail}
|
title={rec.detail}
|
||||||
>
|
>
|
||||||
<AlertTriangle className="w-3.5 h-3.5" />
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
{t("admin.notPublished")}
|
{t("admin.notPublished")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -346,89 +348,105 @@ export default function AdminDomainsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "onboarding" && (
|
{activeTab === "onboarding" && (
|
||||||
<Card className="max-w-2xl space-y-4">
|
<section className={`${panel} max-w-[620px]`}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-white mb-1">{t("admin.onboardTitle")}</h2>
|
<h2 className="text-[17px] font-medium leading-tight text-slate-100">{t("admin.onboardTitle")}</h2>
|
||||||
<p className="text-xs text-slate-400">{t("admin.onboardIntro")}</p>
|
<p className="mt-1 text-[13px] leading-relaxed text-slate-400">{t("admin.onboardIntro")}</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleOnboard} className="space-y-3 text-xs">
|
<form onSubmit={handleOnboard} className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-slate-300 block mb-1">{t("admin.domainName")}</label>
|
<label htmlFor="onboard-domain" className={fieldLabel}>
|
||||||
|
{t("admin.domainName")}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="onboard-domain"
|
||||||
type="text"
|
type="text"
|
||||||
value={onboardDomain}
|
value={onboardDomain}
|
||||||
onChange={(e) => setOnboardDomain(e.target.value)}
|
onChange={(e) => setOnboardDomain(e.target.value)}
|
||||||
placeholder="example.org"
|
placeholder="example.org"
|
||||||
required
|
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}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" variant="primary" size="md" disabled={onboarding}>
|
<Button type="submit" variant="primary" size="md" className="self-start" disabled={onboarding}>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span>{onboarding ? t("common.loading") : t("common.add")}</span>
|
<span>{onboarding ? t("common.loading") : t("common.add")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "dkim" && (
|
{activeTab === "dkim" && (
|
||||||
<Card className="max-w-3xl space-y-5">
|
<section className={`${panel} max-w-[720px]`}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-white mb-1 flex items-center gap-2">
|
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||||
<KeyRound className="w-5 h-5 text-indigo-400" />
|
<KeyRound className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||||
{t("admin.dkimTitle")}
|
{t("admin.dkimTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="mt-1 text-[13px] text-slate-400">{selected ? selected.name : t("admin.noDomains")}</p>
|
||||||
{selected ? selected.name : t("admin.noDomains")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
|
<div className="flex flex-wrap gap-3">
|
||||||
<div>
|
<div className="min-w-[200px] flex-1">
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.dkimAlgorithm")}</label>
|
<label htmlFor="dkim-algorithm" className={fieldLabel}>
|
||||||
|
{t("admin.dkimAlgorithm")}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="dkim-algorithm"
|
||||||
value={dkimAlgorithm}
|
value={dkimAlgorithm}
|
||||||
onChange={(e) => setDkimAlgorithm(e.target.value as "ed25519" | "rsa2048")}
|
onChange={(e) => setDkimAlgorithm(e.target.value as "ed25519" | "rsa2048")}
|
||||||
className="w-full 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"
|
className={input}
|
||||||
>
|
>
|
||||||
<option value="ed25519">Ed25519</option>
|
<option value="ed25519">Ed25519</option>
|
||||||
<option value="rsa2048">RSA-2048</option>
|
<option value="rsa2048">RSA-2048</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-[200px] flex-1">
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.dkimSelector")}</label>
|
<label htmlFor="dkim-selector" className={fieldLabel}>
|
||||||
|
{t("admin.dkimSelector")}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="dkim-selector"
|
||||||
type="text"
|
type="text"
|
||||||
value={dkimSelector}
|
value={dkimSelector}
|
||||||
onChange={(e) => setDkimSelector(e.target.value)}
|
onChange={(e) => setDkimSelector(e.target.value)}
|
||||||
pattern="[A-Za-z0-9-]{1,63}"
|
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`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="primary" size="md" onClick={handleRotateDkim} disabled={rotating || !selected}>
|
<Button
|
||||||
<KeyRound className="w-4 h-4" />
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="self-start"
|
||||||
|
onClick={handleRotateDkim}
|
||||||
|
disabled={rotating || !selected}
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
<span>{rotating ? t("common.loading") : t("admin.dkimRotate")}</span>
|
<span>{rotating ? t("common.loading") : t("admin.dkimRotate")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{dkimRecord && (
|
{dkimRecord && (
|
||||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-3 text-xs">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="font-bold text-white">{t("admin.dkimPublishRecord")}</div>
|
<div className="text-xs text-slate-400">{t("admin.dkimPublishRecord")}</div>
|
||||||
<div className="p-3 bg-slate-950 rounded-lg border border-slate-800 font-mono text-[11px] text-emerald-400 break-all">
|
<div className="lm-code break-all px-3.5 py-3">
|
||||||
{dkimRecord.name} IN TXT "{dkimRecord.value}"
|
{dkimRecord.name} IN TXT "{dkimRecord.value}"
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3.5 rounded-xl bg-amber-500/15 border border-amber-500/25 text-amber-300">
|
<div className="flex items-start gap-2.5 rounded-xl bg-dark-card px-3.5 py-3 shadow-edge">
|
||||||
{t("admin.dkimOverlap", { days: dkimRecord.overlapDays })}
|
<AlertTriangle className="mt-px h-4 w-4 flex-none text-amber-400" />
|
||||||
|
<span className="text-[12.5px] leading-relaxed text-slate-300">
|
||||||
|
{t("admin.dkimOverlap", { days: dkimRecord.overlapDays })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
Gauge,
|
||||||
Globe,
|
Globe,
|
||||||
Users,
|
Users,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
Activity,
|
Activity,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
LogOut,
|
LogOut,
|
||||||
Sliders,
|
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
Lock,
|
Lock,
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
import { useTranslations } from "../../../i18n/provider";
|
import { useTranslations } from "../../../i18n/provider";
|
||||||
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
||||||
import { useAccount } from "../../../lib/useAccount";
|
import { useAccount } from "../../../lib/useAccount";
|
||||||
|
import { initialsFor } from "../../../lib/initials";
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -29,7 +29,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const account = useAccount("admin");
|
const account = useAccount("admin");
|
||||||
|
|
||||||
const navItems = [
|
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/domains", label: t("admin.domainsTitle"), icon: Globe },
|
||||||
{ href: "/admin/mailboxes", label: t("admin.mailboxesTitle"), icon: Users },
|
{ href: "/admin/mailboxes", label: t("admin.mailboxesTitle"), icon: Users },
|
||||||
{ href: "/admin/queue", label: t("admin.queueTitle"), icon: ListOrdered },
|
{ 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 = (
|
const SidebarContent = (
|
||||||
<div className="flex flex-col justify-between h-full p-4">
|
<div className="flex h-full flex-col gap-4 p-3">
|
||||||
<div>
|
<div className="flex items-center gap-2.5 px-2 py-1">
|
||||||
{/* Admin Surface Branding */}
|
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||||
<div className="flex items-center gap-3 px-2 mb-6">
|
λ
|
||||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-emerald-600 to-teal-500 border border-emerald-400/30 flex items-center justify-center text-white font-bold shadow-lg shadow-emerald-500/20">
|
</div>
|
||||||
<Sliders className="w-5 h-5 text-white" />
|
<div className="min-w-0">
|
||||||
</div>
|
<div className="text-[15px] font-medium leading-tight">LambdaMail</div>
|
||||||
<div>
|
<div className="text-[10.5px] uppercase tracking-[0.08em] text-indigo-500">
|
||||||
<div className="font-bold text-base text-white tracking-tight">LambdaMail</div>
|
{t("common.adminPortal")}
|
||||||
<div className="text-[10px] text-emerald-400 uppercase tracking-widest font-mono font-semibold">
|
|
||||||
{t("common.adminPortal")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-0.5">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
data-active={isActive}
|
||||||
|
className="lm-nav"
|
||||||
|
>
|
||||||
|
<span className="lm-nav-mark" />
|
||||||
|
<Icon className={`h-[17px] w-[17px] flex-none ${isActive ? "text-indigo-500" : "text-slate-400"}`} />
|
||||||
|
<span className="min-w-0 flex-1 text-[13.5px] leading-snug">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-auto flex flex-col gap-2.5">
|
||||||
{/* Leaving the console needs no ceremony - the webmail session was
|
{/* 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
|
never given up to get here, so this is a plain link back. Coming the
|
||||||
other way is what costs a second factor. */}
|
other way is what costs a second factor. */}
|
||||||
<Link
|
<div className="flex gap-[3px] rounded-[10px] bg-dark-panel p-[3px] shadow-edge">
|
||||||
href="/user/mail/inbox"
|
<Link
|
||||||
className="mb-6 flex items-center gap-2 p-2.5 rounded-xl bg-indigo-500/10 border border-indigo-500/25 text-xs font-semibold text-indigo-300 hover:border-indigo-500/50 hover:bg-indigo-500/15 transition-all"
|
href="/user/mail/inbox"
|
||||||
>
|
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-white/[0.05] hover:text-slate-100"
|
||||||
<Mail className="w-4 h-4 flex-shrink-0" />
|
>
|
||||||
<span className="truncate">{t("admin.backToWebmail")}</span>
|
<Mail className="h-3.5 w-3.5 flex-none" />
|
||||||
</Link>
|
<span className="min-w-0 truncate">{t("admin.backToWebmail")}</span>
|
||||||
|
</Link>
|
||||||
|
<span className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] bg-dark-card px-2 py-1.5 text-[12px] leading-snug text-indigo-500 shadow-edge-accent">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 flex-none" />
|
||||||
|
<span className="min-w-0 truncate">{t("common.adminPortal")}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Admin Navigation */}
|
<div className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge">
|
||||||
<nav className="space-y-1 text-sm font-medium">
|
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
|
||||||
{navItems.map((item) => {
|
{initialsFor(account?.email)}
|
||||||
const Icon = item.icon;
|
</span>
|
||||||
const isActive = pathname === item.href;
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block break-words text-[12.5px] leading-tight">
|
||||||
|
{account?.email ?? t("common.loading")}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 flex items-center gap-1 text-[10.5px] text-slate-400">
|
||||||
|
<Lock className="h-3 w-3 flex-none text-indigo-500" />
|
||||||
|
{account?.role ?? ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
<div className="flex items-center gap-1.5">
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={`flex items-center gap-3 px-3.5 py-2.5 rounded-xl transition-all duration-150 ${
|
|
||||||
isActive
|
|
||||||
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/30 shadow-sm"
|
|
||||||
: "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40 border border-transparent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className={`w-4 h-4 ${isActive ? "text-emerald-400" : "text-slate-400"}`} />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Language and session controls */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between gap-2 p-2 rounded-xl bg-slate-900/60 border border-slate-800/80">
|
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-slate-700/80 text-xs font-medium text-slate-300 hover:text-rose-400 hover:border-rose-500/40 hover:bg-rose-500/10 transition-all"
|
title={t("settings.signOut")}
|
||||||
|
aria-label={t("settings.signOut")}
|
||||||
|
className="flex h-8 w-8 flex-none items-center justify-center rounded-[9px] border border-white/[0.14] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
|
||||||
>
|
>
|
||||||
<LogOut className="w-3.5 h-3.5" />
|
<LogOut className="h-[15px] w-[15px]" />
|
||||||
<span>{t("settings.signOut")}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Operator Profile Footer */}
|
|
||||||
<div className="pt-3 border-t border-slate-800/80">
|
|
||||||
<div className="flex items-center justify-between p-2.5 rounded-xl bg-slate-900 border border-slate-800">
|
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 flex items-center justify-center font-bold text-xs flex-shrink-0 uppercase">
|
|
||||||
{account?.email?.[0] ?? "?"}
|
|
||||||
</div>
|
|
||||||
<div className="truncate">
|
|
||||||
<div className="text-xs font-semibold text-slate-200 truncate">
|
|
||||||
{account?.email ?? t("common.loading")}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-emerald-400 font-mono flex items-center gap-1">
|
|
||||||
<Lock className="w-3 h-3" />
|
|
||||||
{account?.role ?? ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -134,25 +134,27 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
|
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
|
||||||
{/* Mobile Top Header */}
|
{/* Mobile Top Header */}
|
||||||
<div className="md:hidden flex items-center justify-between p-4 border-b border-slate-800 bg-slate-900/90 z-20 w-full fixed top-0 left-0">
|
<div className="fixed left-0 top-0 z-20 flex w-full items-center justify-between bg-dark-rail px-4 py-3 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.09)] md:hidden">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
<div className="w-8 h-8 rounded-lg bg-emerald-600 flex items-center justify-center text-white font-bold">
|
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||||
<Sliders className="w-4 h-4" />
|
λ
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-slate-100">{t("common.appName")} {t("common.adminPortal")}</span>
|
<span className="min-w-0 truncate font-medium text-slate-100">
|
||||||
|
{t("common.appName")} {t("common.adminPortal")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileOpen(!mobileOpen)}
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
aria-label={t("ui.toggleMenu")}
|
aria-label={t("ui.toggleMenu")}
|
||||||
aria-expanded={mobileOpen}
|
aria-expanded={mobileOpen}
|
||||||
className="p-2 rounded-lg bg-slate-800 text-slate-300 hover:text-white"
|
className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100"
|
||||||
>
|
>
|
||||||
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Executive Sidebar */}
|
{/* Desktop rail */}
|
||||||
<aside className="hidden md:flex w-64 border-r border-emerald-950/60 bg-slate-900/70 backdrop-blur-xl flex-col flex-shrink-0">
|
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
|
||||||
{SidebarContent}
|
{SidebarContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -164,7 +166,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: "-100%" }}
|
exit={{ x: "-100%" }}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
className="fixed inset-0 z-30 bg-slate-950 w-72 border-r border-slate-800 pt-16 md:hidden"
|
className="fixed inset-y-0 left-0 z-30 w-[280px] overflow-y-auto bg-dark-rail pt-14 shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:hidden"
|
||||||
>
|
>
|
||||||
{SidebarContent}
|
{SidebarContent}
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
@@ -172,7 +174,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<main className="flex-1 flex flex-col overflow-y-auto pt-16 md:pt-0 bg-dark-bg">
|
<main className="flex flex-1 flex-col overflow-y-auto bg-dark-bg pt-14 md:pt-0">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
|
|
||||||
import { Badge } from "../../../../components/ui/Badge";
|
import { Badge } from "../../../../components/ui/Badge";
|
||||||
|
import { initialsFor } from "../../../../lib/initials";
|
||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
|
|
||||||
interface Mailbox {
|
interface Mailbox {
|
||||||
@@ -213,12 +213,12 @@ export default function AdminMailboxesPage() {
|
|||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setCsvSuccessMessage(`Imported ${data.imported ?? csvPreview.length} mailboxes.`);
|
setCsvSuccessMessage(t("admin.csvImported", { count: data.imported ?? csvPreview.length }));
|
||||||
setCsvText("");
|
setCsvText("");
|
||||||
setCsvPreview([]);
|
setCsvPreview([]);
|
||||||
await load();
|
await load();
|
||||||
} else {
|
} else {
|
||||||
setError(data.message ?? "Falha ao importar CSV");
|
setError(data.message ?? t("admin.csvImportFailed"));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("errors.serverError"));
|
setError(t("errors.serverError"));
|
||||||
@@ -226,367 +226,392 @@ export default function AdminMailboxesPage() {
|
|||||||
setTimeout(() => setCsvSuccessMessage(null), 4000);
|
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 (
|
return (
|
||||||
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto">
|
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
||||||
{/* Header & Tabs */}
|
{/* Header & Tabs */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div>
|
||||||
<div>
|
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.mailboxesTitle")}</h1>
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.mailboxesSubtitle")}</p>
|
||||||
{t("admin.mailboxesTitle")}
|
</div>
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-slate-400 mt-1">
|
|
||||||
{t("admin.mailboxesSubtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Selection Navigation */}
|
<div className="lm-tabstrip self-start">
|
||||||
<div className="flex bg-slate-900/90 p-1.5 rounded-xl border border-slate-800 text-xs font-medium self-start md:self-auto">
|
{tabs.map((tab) => {
|
||||||
<button
|
const Icon = tab.icon;
|
||||||
onClick={() => setActiveTab("mailboxes")}
|
return (
|
||||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
<button
|
||||||
activeTab === "mailboxes"
|
key={tab.id}
|
||||||
? "bg-emerald-600 text-white shadow-sm"
|
type="button"
|
||||||
: "text-slate-400 hover:text-slate-200"
|
onClick={() => setActiveTab(tab.id)}
|
||||||
}`}
|
data-active={activeTab === tab.id}
|
||||||
>
|
aria-pressed={activeTab === tab.id}
|
||||||
<Users className="w-3.5 h-3.5" />
|
className="lm-tab"
|
||||||
<span>Contas ({mailboxes.length})</span>
|
>
|
||||||
</button>
|
<Icon className="h-[15px] w-[15px] flex-none" />
|
||||||
|
<span>{tab.label}</span>
|
||||||
<button
|
</button>
|
||||||
onClick={() => setActiveTab("aliases")}
|
);
|
||||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
})}
|
||||||
activeTab === "aliases"
|
|
||||||
? "bg-emerald-600 text-white shadow-sm"
|
|
||||||
: "text-slate-400 hover:text-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Mail className="w-3.5 h-3.5" />
|
|
||||||
<span>Aliases ({aliases.length})</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("csv")}
|
|
||||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
|
||||||
activeTab === "csv"
|
|
||||||
? "bg-emerald-600 text-white shadow-sm"
|
|
||||||
: "text-slate-400 hover:text-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<FileSpreadsheet className="w-3.5 h-3.5" />
|
|
||||||
<span>CSV</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-sm flex items-center gap-3">
|
<div className="flex items-center gap-2.5 rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] leading-relaxed text-rose-200 shadow-edge">
|
||||||
<AlertCircle className="w-5 h-5 text-rose-400 flex-shrink-0" />
|
<AlertCircle className="h-4 w-4 flex-none" />
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Domain MFA Policy Panel */}
|
{/* Domain MFA Policy Panel */}
|
||||||
<Card>
|
<div className="flex flex-wrap items-center gap-3.5 rounded-2xl bg-dark-panel px-[18px] py-4 shadow-edge">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="min-w-[240px] flex-1">
|
||||||
<div>
|
<div className="flex items-center gap-2 text-[15px] font-medium leading-snug text-slate-100">
|
||||||
<h2 className="text-sm font-bold text-slate-100 flex items-center gap-2">
|
<ShieldCheck className="h-4 w-4 flex-none text-indigo-500" />
|
||||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
{t("admin.mfaPolicyTitle")}
|
||||||
{t("admin.mfaPolicyTitle")}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-slate-400 mt-0.5">
|
|
||||||
{t("admin.mfaPolicyIntro")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 text-xs">
|
|
||||||
<button
|
|
||||||
onClick={() => setMfaPolicy("optional")}
|
|
||||||
className={`px-3 py-1.5 rounded-lg border font-medium transition-all ${
|
|
||||||
mfaPolicy === "optional"
|
|
||||||
? "bg-slate-800 border-slate-600 text-white"
|
|
||||||
: "border-slate-800 text-slate-400 hover:text-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("admin.mfaOptional")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMfaPolicy("required_admins")}
|
|
||||||
className={`px-3 py-1.5 rounded-lg border font-medium transition-all ${
|
|
||||||
mfaPolicy === "required_admins"
|
|
||||||
? "bg-emerald-600/20 border-emerald-500 text-emerald-300"
|
|
||||||
: "border-slate-800 text-slate-400 hover:text-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("admin.mfaAdmins")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMfaPolicy("required_all")}
|
|
||||||
className={`px-3 py-1.5 rounded-lg border font-medium transition-all ${
|
|
||||||
mfaPolicy === "required_all"
|
|
||||||
? "bg-indigo-600/20 border-indigo-500 text-indigo-300"
|
|
||||||
: "border-slate-800 text-slate-400 hover:text-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("admin.mfaEveryone")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 text-[12.5px] leading-relaxed text-slate-400">{t("admin.mfaPolicyIntro")}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
{/* One segmented control instead of three buttons in three different
|
||||||
|
colours: these are three values of one setting, not three actions. */}
|
||||||
|
<div className="flex flex-none flex-wrap gap-[3px] rounded-[10px] bg-dark-card p-[3px] shadow-edge">
|
||||||
|
{mfaPolicies.map((policy) => (
|
||||||
|
<button
|
||||||
|
key={policy.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMfaPolicy(policy.id)}
|
||||||
|
aria-pressed={mfaPolicy === policy.id}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-[12.5px] leading-snug transition-colors ${
|
||||||
|
mfaPolicy === policy.id
|
||||||
|
? "text-indigo-500 shadow-edge-accent"
|
||||||
|
: "text-slate-400 hover:text-slate-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{policy.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* TAB 1: MAILBOXES */}
|
{/* TAB 1: MAILBOXES */}
|
||||||
{activeTab === "mailboxes" && (
|
{activeTab === "mailboxes" && (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-3.5">
|
||||||
<Card>
|
<section className={panel}>
|
||||||
<CardHeader>
|
<h2 className="text-[17px] font-medium leading-tight text-slate-100">
|
||||||
<CardTitle className="flex items-center gap-2 text-sm">
|
{t("ui.provisionMailbox")}
|
||||||
<Plus className="w-4 h-4 text-emerald-400" />
|
</h2>
|
||||||
{t("ui.provisionMailbox")}
|
<form onSubmit={handleCreateMailbox} className="flex flex-wrap items-end gap-2.5">
|
||||||
</CardTitle>
|
<div className="min-w-[220px] flex-[2]">
|
||||||
</CardHeader>
|
<label htmlFor="new-email" className={fieldLabel}>
|
||||||
<form onSubmit={handleCreateMailbox} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 text-xs">
|
{t("ui.emailAddress")}
|
||||||
<input
|
</label>
|
||||||
type="email"
|
<input
|
||||||
value={newEmail}
|
id="new-email"
|
||||||
onChange={(e) => setNewEmail(e.target.value)}
|
type="email"
|
||||||
placeholder="nova.conta@domain.com"
|
value={newEmail}
|
||||||
required
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
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"
|
placeholder="nova.conta@domain.com"
|
||||||
/>
|
required
|
||||||
<input
|
className={input}
|
||||||
type="password"
|
/>
|
||||||
value={newPassword}
|
</div>
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
<div className="min-w-[180px] flex-1">
|
||||||
placeholder={t("ui.newPassword")}
|
<label htmlFor="new-mailbox-password" className={fieldLabel}>
|
||||||
minLength={12}
|
{t("ui.newPassword")}
|
||||||
required
|
</label>
|
||||||
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"
|
<input
|
||||||
/>
|
id="new-mailbox-password"
|
||||||
<select
|
type="password"
|
||||||
value={newRole}
|
value={newPassword}
|
||||||
onChange={(e) => setNewRole(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
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"
|
minLength={12}
|
||||||
>
|
required
|
||||||
<option value="USER">USER</option>
|
className={input}
|
||||||
<option value="DOMAIN_ADMIN">DOMAIN_ADMIN</option>
|
/>
|
||||||
<option value="SUPER_ADMIN">SUPER_ADMIN</option>
|
</div>
|
||||||
</select>
|
<div className="min-w-[160px] flex-1">
|
||||||
<input
|
<label htmlFor="new-role" className={fieldLabel}>
|
||||||
type="number"
|
{t("ui.role")}
|
||||||
value={newQuota}
|
</label>
|
||||||
onChange={(e) => setNewQuota(parseInt(e.target.value, 10))}
|
<select
|
||||||
placeholder={t("ui.storageQuota")}
|
id="new-role"
|
||||||
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"
|
value={newRole}
|
||||||
/>
|
onChange={(e) => setNewRole(e.target.value)}
|
||||||
<Button type="submit" variant="primary" size="md">
|
className={input}
|
||||||
{t("admin.createAccount")}
|
>
|
||||||
|
<option value="USER">USER</option>
|
||||||
|
<option value="DOMAIN_ADMIN">DOMAIN_ADMIN</option>
|
||||||
|
<option value="SUPER_ADMIN">SUPER_ADMIN</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[130px] flex-1">
|
||||||
|
<label htmlFor="new-quota" className={fieldLabel}>
|
||||||
|
{t("admin.quotaMb")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-quota"
|
||||||
|
type="number"
|
||||||
|
value={newQuota}
|
||||||
|
onChange={(e) => setNewQuota(parseInt(e.target.value, 10))}
|
||||||
|
className={input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="primary" size="md" className="flex-none">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span>{t("admin.createAccount")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</section>
|
||||||
|
|
||||||
<Card className="p-0 overflow-hidden">
|
|
||||||
<div className="p-4 border-b border-slate-800 bg-slate-900/80 font-bold text-xs text-slate-300 flex items-center justify-between">
|
|
||||||
<span>Contas Cadastradas ({mailboxes.length})</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse text-xs">
|
<table className="lm-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
<tr>
|
||||||
<th className="p-3.5">{t("ui.emailAddress")}</th>
|
<th className="pl-0">{t("ui.emailAddress")}</th>
|
||||||
<th className="p-3.5">{t("ui.accountStatus")}</th>
|
<th>{t("ui.role")}</th>
|
||||||
<th className="p-3.5">{t("ui.storageQuota")}</th>
|
<th>{t("ui.storageQuota")}</th>
|
||||||
<th className="p-3.5">{t("ui.twoFactorSection")}</th>
|
<th>{t("ui.twoFactorSection")}</th>
|
||||||
<th className="p-3.5">{t("ui.accountStatus")}</th>
|
<th>{t("ui.accountStatus")}</th>
|
||||||
<th className="p-3.5">{t("ui.actions")}</th>
|
<th className="pr-0 text-right">{t("ui.actions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
<tbody>
|
||||||
{mailboxes.map((mb) => (
|
{mailboxes.map((mb) => (
|
||||||
<tr key={mb.id} className="hover:bg-slate-900/40 transition-colors">
|
<tr key={mb.id}>
|
||||||
<td className="p-3.5 font-bold text-slate-100">{mb.email}</td>
|
<td className="pl-0">
|
||||||
<td className="p-3.5 text-slate-300">
|
<div className="flex items-center gap-2.5">
|
||||||
<Badge variant="neutral">{mb.role}</Badge>
|
<span className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-indigo-900 text-[11px] uppercase text-indigo-300">
|
||||||
|
{initialsFor(mb.email)}
|
||||||
|
</span>
|
||||||
|
<span className="break-words text-[13.5px] text-slate-100">{mb.email}</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3.5">
|
<td>
|
||||||
<div className="flex items-center gap-2">
|
<Badge variant="neutral" className="whitespace-nowrap">
|
||||||
<div className="w-24 h-2 bg-slate-800 rounded-full overflow-hidden">
|
{mb.role}
|
||||||
<div
|
</Badge>
|
||||||
className="h-full bg-emerald-500 rounded-full"
|
</td>
|
||||||
style={{ width: `${Math.min((mb.storageUsedMb / (mb.quotaMb || 1)) * 100, 100)}%` }}
|
<td>
|
||||||
|
<div className="flex min-w-[150px] items-center gap-2.5">
|
||||||
|
<span className="block h-1.5 flex-1 overflow-hidden rounded-full bg-dark-card">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-indigo-500"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min((mb.storageUsedMb / (mb.quotaMb || 1)) * 100, 100)}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</span>
|
||||||
<span className="text-[10px] text-slate-400">
|
<span className="whitespace-nowrap text-[11.5px] tabular-nums text-slate-400">
|
||||||
{mb.storageUsedMb}MB / {mb.quotaMb}MB
|
{mb.storageUsedMb} / {mb.quotaMb} MB
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3.5">
|
<td>
|
||||||
<Badge variant={mb.mfaEnabled ? "success" : "warning"}>
|
<Badge variant={mb.mfaEnabled ? "success" : "warning"} className="whitespace-nowrap">
|
||||||
{mb.mfaEnabled ? "TOTP ATIVO" : "SEM 2FA"}
|
{mb.mfaEnabled ? t("ui.mfaActive") : t("ui.mfaNone")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3.5">
|
<td>
|
||||||
<Badge variant={mb.locked ? "danger" : "success"}>
|
<Badge variant={mb.locked ? "danger" : "info"} className="whitespace-nowrap">
|
||||||
{mb.locked ? "BLOQUEADA" : t("ui.active")}
|
{mb.locked ? t("ui.accountLocked") : t("ui.active")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3.5 flex items-center gap-2">
|
<td className="whitespace-nowrap pr-0 text-right">
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleToggleLock(mb.id, mb.locked)}
|
onClick={() => void handleToggleLock(mb.id, mb.locked)}
|
||||||
|
title={mb.locked ? t("admin.unlockAccount") : t("admin.lockAccount")}
|
||||||
|
aria-label={mb.locked ? t("admin.unlockAccount") : t("admin.lockAccount")}
|
||||||
|
className={`${iconButton} inline-flex`}
|
||||||
>
|
>
|
||||||
{mb.locked ? <Unlock className="w-3 h-3" /> : <Lock className="w-3 h-3" />}
|
{mb.locked ? <Unlock className="h-[15px] w-[15px]" /> : <Lock className="h-[15px] w-[15px]" />}
|
||||||
<span>{mb.locked ? "Desbloquear" : "Bloquear"}</span>
|
</button>
|
||||||
</Button>
|
<button
|
||||||
<Button
|
type="button"
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleDeleteMailbox(mb.id)}
|
onClick={() => void handleDeleteMailbox(mb.id)}
|
||||||
|
title={t("common.delete")}
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
className={`${iconButton} inline-flex hover:text-rose-400`}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="h-[15px] w-[15px]" />
|
||||||
</Button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAB 2: ALIASES */}
|
{/* TAB 2: ALIASES */}
|
||||||
{activeTab === "aliases" && (
|
{activeTab === "aliases" && (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-3.5">
|
||||||
<Card>
|
<section className={panel}>
|
||||||
<CardHeader>
|
<h2 className="text-[17px] font-medium leading-tight text-slate-100">{t("admin.createAlias")}</h2>
|
||||||
<CardTitle className="flex items-center gap-2 text-sm">
|
<form onSubmit={handleCreateAlias} className="flex flex-wrap items-end gap-3">
|
||||||
<Plus className="w-4 h-4 text-emerald-400" />
|
<div className="min-w-[220px] flex-1">
|
||||||
{t("admin.createAlias")}
|
<label htmlFor="new-alias" className={fieldLabel}>
|
||||||
</CardTitle>
|
{t("admin.aliasesTitle")}
|
||||||
</CardHeader>
|
</label>
|
||||||
<form onSubmit={handleCreateAlias} className="flex flex-col sm:flex-row gap-3 text-xs">
|
<input
|
||||||
<input
|
id="new-alias"
|
||||||
type="email"
|
type="email"
|
||||||
value={newAliasAddr}
|
value={newAliasAddr}
|
||||||
onChange={(e) => setNewAliasAddr(e.target.value)}
|
onChange={(e) => setNewAliasAddr(e.target.value)}
|
||||||
placeholder="alias@domain.com"
|
placeholder="alias@domain.com"
|
||||||
required
|
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"
|
className={input}
|
||||||
/>
|
/>
|
||||||
<input
|
</div>
|
||||||
type="email"
|
<ArrowRight className="mb-2.5 h-[18px] w-[18px] flex-none text-slate-500" />
|
||||||
value={newTargetAddr}
|
<div className="min-w-[220px] flex-1">
|
||||||
onChange={(e) => setNewTargetAddr(e.target.value)}
|
<label htmlFor="new-alias-target" className={fieldLabel}>
|
||||||
placeholder="destino@domain.com"
|
{t("admin.aliasTarget")}
|
||||||
required
|
</label>
|
||||||
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"
|
<input
|
||||||
/>
|
id="new-alias-target"
|
||||||
<Button type="submit" variant="primary" size="md">
|
type="email"
|
||||||
|
value={newTargetAddr}
|
||||||
|
onChange={(e) => setNewTargetAddr(e.target.value)}
|
||||||
|
placeholder="destino@domain.com"
|
||||||
|
required
|
||||||
|
className={input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="primary" size="md" className="flex-none">
|
||||||
{t("admin.addAlias")}
|
{t("admin.addAlias")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</section>
|
||||||
|
|
||||||
<Card className="p-0 overflow-hidden">
|
|
||||||
<div className="p-4 border-b border-slate-800 bg-slate-900/80 font-bold text-xs text-slate-300">
|
|
||||||
Aliases de Roteamento ({aliases.length})
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse text-xs">
|
<table className="lm-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
<tr>
|
||||||
<th className="p-3.5">{t("ui.emailAddress")}</th>
|
<th className="pl-0">{t("admin.aliasesTitle")}</th>
|
||||||
<th className="p-3.5">{t("ui.destination")}</th>
|
<th>{t("ui.destination")}</th>
|
||||||
<th className="p-3.5">{t("admin.domainsTitle")}</th>
|
<th>{t("admin.domainsTitle")}</th>
|
||||||
<th className="p-3.5">{t("ui.actions")}</th>
|
<th className="pr-0 text-right">{t("ui.actions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
<tbody>
|
||||||
{aliases.map((al) => (
|
{aliases.map((al) => (
|
||||||
<tr key={al.id} className="hover:bg-slate-900/40 transition-colors">
|
<tr key={al.id}>
|
||||||
<td className="p-3.5 font-bold text-emerald-400">{al.aliasAddress}</td>
|
<td className="break-words pl-0 text-[13.5px] text-slate-100">{al.aliasAddress}</td>
|
||||||
<td className="p-3.5 text-slate-200 flex items-center gap-2">
|
<td className="break-words text-[13px] text-slate-300">{al.targetAddress}</td>
|
||||||
<ArrowRight className="w-3.5 h-3.5 text-slate-500" />
|
<td className="text-[13px] text-slate-400">{al.domain}</td>
|
||||||
<span>{al.targetAddress}</span>
|
<td className="pr-0 text-right">
|
||||||
</td>
|
<button
|
||||||
<td className="p-3.5 text-slate-400">{al.domain}</td>
|
type="button"
|
||||||
<td className="p-3.5">
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleDeleteAlias(al.id)}
|
onClick={() => void handleDeleteAlias(al.id)}
|
||||||
|
title={t("common.delete")}
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
className={`${iconButton} inline-flex hover:text-rose-400`}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="h-[15px] w-[15px]" />
|
||||||
</Button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAB 3: CSV BULK IMPORT */}
|
{/* TAB 3: CSV BULK IMPORT */}
|
||||||
{activeTab === "csv" && (
|
{activeTab === "csv" && (
|
||||||
<Card className="space-y-4">
|
<div className="flex flex-wrap gap-3.5">
|
||||||
<div>
|
<section className={`${panel} min-w-[320px] flex-1`}>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-1">
|
<div>
|
||||||
<Upload className="w-5 h-5 text-indigo-400" />
|
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||||
Bulk mailbox import (CSV)
|
<Upload className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||||
</h2>
|
{t("admin.csvTitle")}
|
||||||
<p className="text-xs text-slate-400">
|
</h2>
|
||||||
Paste CSV to provision several accounts with their RFC 6154 folders. Format: <code>email, role, quota_mb</code>
|
<p className="mt-1 text-[13px] leading-relaxed text-slate-400">{t("admin.csvIntro")}</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{csvSuccessMessage && (
|
|
||||||
<div className="p-3.5 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-xs flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
|
||||||
<span>{csvSuccessMessage}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<textarea
|
{csvSuccessMessage && (
|
||||||
rows={6}
|
<div className="flex items-center gap-2 rounded-xl bg-dark-card px-3.5 py-3 text-[12.5px] text-slate-200 shadow-edge">
|
||||||
value={csvText}
|
<CheckCircle2 className="h-4 w-4 flex-none text-indigo-500" />
|
||||||
onChange={(e) => setCsvText(e.target.value)}
|
<span>{csvSuccessMessage}</span>
|
||||||
placeholder={`email,role,quota_mb\nalice@example.com,USER,5000\nbob@example.com,DOMAIN_ADMIN,2000`}
|
</div>
|
||||||
className="w-full px-4 py-3 rounded-xl bg-slate-900/90 border border-slate-800 font-mono text-xs text-slate-200 placeholder-slate-600 focus:outline-none focus:border-emerald-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button variant="secondary" size="md" onClick={handleParseCsv}>
|
|
||||||
{t("admin.csvPreview")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{csvPreview.length > 0 && (
|
|
||||||
<Button variant="primary" size="md" onClick={handleExecuteCsvImport}>
|
|
||||||
Run import ({csvPreview.length} Contas)
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
value={csvText}
|
||||||
|
onChange={(e) => setCsvText(e.target.value)}
|
||||||
|
aria-label={t("admin.csvTitle")}
|
||||||
|
placeholder={`email,role,quota_mb\nalice@example.com,USER,5000\nbob@example.com,DOMAIN_ADMIN,2000`}
|
||||||
|
className="lm-code w-full px-4 py-3 text-slate-200 placeholder-slate-600 focus:outline-none focus-visible:shadow-edge-accent"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="secondary" size="md" onClick={handleParseCsv}>
|
||||||
|
{t("admin.csvPreview")}
|
||||||
|
</Button>
|
||||||
|
{csvPreview.length > 0 && (
|
||||||
|
<Button variant="primary" size="md" onClick={handleExecuteCsvImport}>
|
||||||
|
{t("admin.csvRun")} ({csvPreview.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{csvPreview.length > 0 && (
|
{csvPreview.length > 0 && (
|
||||||
<div className="mt-4 border border-slate-800 rounded-xl overflow-hidden bg-slate-950 p-4 space-y-2">
|
<section className={`${panel} min-w-[300px] flex-1`}>
|
||||||
<h3 className="font-bold text-xs text-white">Preview ({csvPreview.length} linhas)</h3>
|
<div className="flex items-center justify-between gap-2.5">
|
||||||
<div className="divide-y divide-slate-800/80 font-mono text-[11px]">
|
<h3 className="text-[17px] font-medium leading-tight text-slate-100">
|
||||||
|
{t("admin.csvPreview")}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="neutral">{csvPreview.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
{csvPreview.map((row, idx) => (
|
{csvPreview.map((row, idx) => (
|
||||||
<div key={idx} className="py-2 flex justify-between items-center">
|
<div
|
||||||
<span className="text-emerald-400">{row.email}</span>
|
key={idx}
|
||||||
<Badge variant="neutral">{row.role}</Badge>
|
className="flex flex-wrap items-center gap-2.5 rounded-xl bg-dark-card px-3 py-2.5 shadow-edge"
|
||||||
<span className="text-slate-400">Cota: {row.quotaMb} MB</span>
|
>
|
||||||
|
<span className="min-w-0 flex-1 break-words text-[13px] text-slate-100">{row.email}</span>
|
||||||
|
<Badge variant="neutral" className="flex-none whitespace-nowrap">
|
||||||
|
{row.role}
|
||||||
|
</Badge>
|
||||||
|
<span className="flex-none whitespace-nowrap text-[11.5px] tabular-nums text-slate-400">
|
||||||
|
{row.quotaMb} MB
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { RefreshCw } from "lucide-react";
|
import { RefreshCw } from "lucide-react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
|
import { Badge } from "../../../../components/ui/Badge";
|
||||||
|
import { Button } from "../../../../components/ui/Button";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The outbound queue, read from the database.
|
* The outbound queue, read from the database.
|
||||||
@@ -33,11 +35,17 @@ interface QueueSummary {
|
|||||||
recent: QueueJob[];
|
recent: QueueJob[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only failure states earn a warning colour; the rest are ordinary progress. */
|
/**
|
||||||
function statusClass(status: string): string {
|
* Only failure states earn a warning colour; the rest are ordinary progress.
|
||||||
if (status === "BOUNCED" || status === "FROZEN") return "badge-danger";
|
*
|
||||||
if (status === "DEFERRED") return "badge-warning";
|
* These used to return "badge-danger", "badge-warning" and "badge-verified" -
|
||||||
return "badge-verified";
|
* three class names no stylesheet in this project ever defined, so every status
|
||||||
|
* rendered as unstyled text. They are Badge variants now, which exist.
|
||||||
|
*/
|
||||||
|
function statusVariant(status: string): "danger" | "warning" | "neutral" {
|
||||||
|
if (status === "BOUNCED" || status === "FROZEN") return "danger";
|
||||||
|
if (status === "DEFERRED") return "warning";
|
||||||
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminQueuePage() {
|
export default function AdminQueuePage() {
|
||||||
@@ -92,21 +100,16 @@ export default function AdminQueuePage() {
|
|||||||
const jobs = summary?.recent ?? [];
|
const jobs = summary?.recent ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 space-y-6 max-w-7xl mx-auto">
|
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.queueTitle")}</h1>
|
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.queueTitle")}</h1>
|
||||||
<p className="text-xs text-slate-400">{t("admin.queueSubtitle")}</p>
|
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.queueSubtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button variant="secondary" size="sm" onClick={() => void load()} disabled={loading}>
|
||||||
type="button"
|
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||||
onClick={() => void load()}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex items-center gap-2 self-start rounded-xl border border-slate-700 px-3.5 py-2 text-xs font-medium text-slate-300 transition-colors hover:border-emerald-500/50 hover:text-white disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
|
||||||
<span>{t("common.refresh")}</span>
|
<span>{t("common.refresh")}</span>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Counts per status, straight from the by_status aggregate. */}
|
{/* Counts per status, straight from the by_status aggregate. */}
|
||||||
@@ -115,80 +118,67 @@ export default function AdminQueuePage() {
|
|||||||
{Object.entries(summary.by_status).map(([status, count]) => (
|
{Object.entries(summary.by_status).map(([status, count]) => (
|
||||||
<span
|
<span
|
||||||
key={status}
|
key={status}
|
||||||
className="rounded-lg border border-slate-800 bg-slate-900/70 px-3 py-1.5 text-xs text-slate-300"
|
className="flex items-center gap-2.5 rounded-xl bg-dark-panel px-3.5 py-2.5 shadow-edge"
|
||||||
>
|
>
|
||||||
<span className="font-mono font-bold text-white">{count}</span> {status}
|
<span className="text-[19px] font-medium leading-none tabular-nums text-slate-100">{count}</span>
|
||||||
|
<span className="text-xs text-slate-400">{status}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs">
|
<div className="rounded-xl bg-dark-card px-4 py-3 text-[12.5px] text-slate-200 shadow-edge">{message}</div>
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs">
|
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] text-rose-200 shadow-edge">{error}</div>
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="glass-panel rounded-2xl border border-slate-800 overflow-hidden">
|
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||||
<div className="p-4 border-b border-slate-800 bg-slate-900/60 font-bold text-xs text-slate-300">
|
|
||||||
{t("admin.queueTitle")} ({jobs.length})
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{jobs.length === 0 && !loading ? (
|
{jobs.length === 0 && !loading ? (
|
||||||
<div className="p-8 text-center text-xs text-slate-500">{t("admin.queueEmpty")}</div>
|
<div className="p-8 text-center text-[13px] text-slate-400">{t("admin.queueEmpty")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse text-xs">
|
<table className="lm-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
<tr>
|
||||||
<th className="p-3">{t("ui.jobId")}</th>
|
<th className="pl-0">{t("ui.jobId")}</th>
|
||||||
<th className="p-3">{t("ui.sender")}</th>
|
<th>{t("ui.sender")}</th>
|
||||||
<th className="p-3">{t("ui.recipient")}</th>
|
<th>{t("ui.recipient")}</th>
|
||||||
<th className="p-3">{t("ui.destination")}</th>
|
<th>{t("ui.destination")}</th>
|
||||||
<th className="p-3">{t("ui.attempts")}</th>
|
<th>{t("ui.attempts")}</th>
|
||||||
<th className="p-3">{t("ui.lastError")}</th>
|
<th>{t("ui.lastError")}</th>
|
||||||
<th className="p-3">{t("common.status")}</th>
|
<th>{t("common.status")}</th>
|
||||||
<th className="p-3">{t("ui.actions")}</th>
|
<th className="pr-0 text-right">{t("ui.actions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
<tbody>
|
||||||
{jobs.map((j) => (
|
{jobs.map((j) => (
|
||||||
<tr key={j.id} className="hover:bg-slate-900/30">
|
<tr key={j.id}>
|
||||||
<td className="p-3 font-bold text-slate-200">{j.id.slice(0, 8)}</td>
|
<td className="pl-0 font-mono text-[12.5px] text-slate-100">{j.id.slice(0, 8)}</td>
|
||||||
<td className="p-3 text-slate-300">{j.envelope_from}</td>
|
<td className="max-w-[180px] break-words text-[12.5px] text-slate-300">{j.envelope_from}</td>
|
||||||
<td className="p-3 text-slate-300">{j.envelope_to}</td>
|
<td className="max-w-[180px] break-words text-[12.5px] text-slate-300">{j.envelope_to}</td>
|
||||||
<td className="p-3 text-emerald-400">{j.destination_domain}</td>
|
<td className="break-words text-[12.5px] text-slate-300">{j.destination_domain}</td>
|
||||||
<td className="p-3 text-slate-400">{j.attempt}</td>
|
<td className="whitespace-nowrap text-[12.5px] tabular-nums text-slate-300">{j.attempt}</td>
|
||||||
<td className="p-3 text-slate-400 truncate max-w-xs" title={j.last_error ?? ""}>
|
{/* The diagnostic wraps instead of being truncated: a queue
|
||||||
|
screen exists to show why a message did not leave. */}
|
||||||
|
<td className="max-w-[250px] text-xs leading-relaxed text-slate-400">
|
||||||
{j.last_error
|
{j.last_error
|
||||||
? `${j.last_smtp_code ? `${j.last_smtp_code} ` : ""}${j.last_error}`
|
? `${j.last_smtp_code ? `${j.last_smtp_code} ` : ""}${j.last_error}`
|
||||||
: t("common.none")}
|
: t("common.none")}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td>
|
||||||
<span className={`${statusClass(j.status)} px-2 py-0.5 rounded text-[10px]`}>
|
<Badge variant={statusVariant(j.status)} className="whitespace-nowrap">
|
||||||
{j.status}
|
{j.status}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 flex items-center gap-2">
|
<td className="whitespace-nowrap pr-0 text-right">
|
||||||
<button
|
<Button variant="secondary" size="sm" onClick={() => void act(j.id, "retry")}>
|
||||||
type="button"
|
|
||||||
onClick={() => void act(j.id, "retry")}
|
|
||||||
className="px-2 py-1 rounded bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 text-[10px] font-bold"
|
|
||||||
>
|
|
||||||
{t("admin.retryJob")}
|
{t("admin.retryJob")}
|
||||||
</button>
|
</Button>{" "}
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={() => void act(j.id, "cancel")}>
|
||||||
type="button"
|
|
||||||
onClick={() => void act(j.id, "cancel")}
|
|
||||||
className="px-2 py-1 rounded bg-red-600/20 hover:bg-red-600/30 text-red-300 text-[10px] font-bold"
|
|
||||||
>
|
|
||||||
{t("admin.cancelJob")}
|
{t("admin.cancelJob")}
|
||||||
</button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
|
|
||||||
import { Badge } from "../../../../components/ui/Badge";
|
import { Badge } from "../../../../components/ui/Badge";
|
||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
|
|
||||||
@@ -172,123 +171,97 @@ export default function AdminSecurityPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const securityTabs = [
|
||||||
|
{ id: "readiness" as const, label: t("admin.tabReadiness"), icon: ShieldCheck },
|
||||||
|
{ id: "tls" as const, label: t("admin.tabTls"), icon: Lock },
|
||||||
|
{ id: "rspamd" as const, label: t("admin.tabRspamd"), icon: Sliders },
|
||||||
|
{ id: "audit" as const, label: t("admin.tabAudit"), icon: ListOrdered },
|
||||||
|
];
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto">
|
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
||||||
{/* Header & Tabs */}
|
{/* Header & Tabs */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div>
|
||||||
<div>
|
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.securityTitle")}</h1>
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight flex items-center gap-3">
|
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.securitySubtitle")}</p>
|
||||||
{t("admin.securityTitle")}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-slate-400 mt-1">{t("admin.securitySubtitle")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Selector Controls */}
|
|
||||||
<div className="flex bg-slate-900/90 p-1.5 rounded-xl border border-slate-800 text-xs font-medium self-start md:self-auto overflow-x-auto">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("readiness")}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
|
||||||
activeTab === "readiness"
|
|
||||||
? "bg-emerald-600 text-white shadow-sm"
|
|
||||||
: "text-slate-400 hover:text-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5" />
|
|
||||||
<span>{t("admin.tabReadiness")}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("tls")}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
|
||||||
activeTab === "tls"
|
|
||||||
? "bg-emerald-600 text-white shadow-sm"
|
|
||||||
: "text-slate-400 hover:text-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Lock className="w-3.5 h-3.5" />
|
|
||||||
<span>{t("admin.tabTls")}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("rspamd")}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
|
||||||
activeTab === "rspamd"
|
|
||||||
? "bg-emerald-600 text-white shadow-sm"
|
|
||||||
: "text-slate-400 hover:text-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Sliders className="w-3.5 h-3.5" />
|
|
||||||
<span>{t("admin.tabRspamd")}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("audit")}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
|
||||||
activeTab === "audit"
|
|
||||||
? "bg-emerald-600 text-white shadow-sm"
|
|
||||||
: "text-slate-400 hover:text-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ListOrdered className="w-3.5 h-3.5" />
|
|
||||||
<span>{t("admin.tabAudit")}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TAB 1: 10/10 READINESS SCORECARD */}
|
{/* Tab Selector Controls */}
|
||||||
|
<div className="lm-tabstrip self-start">
|
||||||
|
{securityTabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
data-active={activeTab === tab.id}
|
||||||
|
aria-pressed={activeTab === tab.id}
|
||||||
|
className="lm-tab"
|
||||||
|
>
|
||||||
|
<Icon className="h-[15px] w-[15px] flex-none" />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TAB 1: READINESS SCORECARD */}
|
||||||
{activeTab === "readiness" && (
|
{activeTab === "readiness" && (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-3.5">
|
||||||
{/* The score is counted from the checks that came back. It used to
|
{/* The score is counted from the checks that came back. It used to
|
||||||
read a hardcoded "10 / 10" with an "all checks passed" badge
|
read a hardcoded "10 / 10" with an "all checks passed" badge
|
||||||
regardless of what the preflight endpoint reported - including
|
regardless of what the preflight endpoint reported - including
|
||||||
when it reported failures, or nothing at all. */}
|
when it reported failures, or nothing at all. */}
|
||||||
<Card className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-6">
|
<div className="flex flex-wrap items-center justify-between gap-4 rounded-2xl bg-dark-panel p-[18px] shadow-edge">
|
||||||
<div>
|
<div className="min-w-[260px] flex-1">
|
||||||
<h2 className="text-lg font-bold text-white flex items-center gap-2">
|
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||||
<Shield className="w-5 h-5 text-emerald-400" />
|
<Shield className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||||
{t("admin.preflightTitle")}
|
{t("admin.preflightTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400 mt-1">{t("admin.readinessIntro")}</p>
|
<p className="mt-1 text-[13px] leading-relaxed text-slate-400">{t("admin.readinessIntro")}</p>
|
||||||
</div>
|
</div>
|
||||||
{readiness && readiness.length > 0 ? (
|
{readiness && readiness.length > 0 ? (
|
||||||
(() => {
|
(() => {
|
||||||
const passed = readiness.filter((c) => c.status === "PASSED").length;
|
const passed = readiness.filter((c) => c.status === "PASSED").length;
|
||||||
const total = readiness.length;
|
const total = readiness.length;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-none items-center gap-3">
|
||||||
<span
|
<span className="font-mono text-[30px] font-medium leading-none tabular-nums text-slate-100">
|
||||||
className={`text-3xl font-black font-mono ${
|
|
||||||
passed === total ? "text-emerald-400" : "text-amber-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{passed} / {total}
|
{passed} / {total}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={passed === total ? "success" : "warning"}>
|
<Badge variant={passed === total ? "success" : "warning"}>
|
||||||
{passed === total
|
{passed === total ? t("ui.allChecksPassed") : t("admin.checksPassed", { passed, total })}
|
||||||
? t("ui.allChecksPassed")
|
|
||||||
: t("admin.checksPassed", { passed, total })}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-slate-500">{t("admin.noChecks")}</span>
|
<span className="text-[13px] text-slate-400">{t("admin.noChecks")}</span>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
{(readiness ?? []).map((chk) => (
|
{(readiness ?? []).map((chk) => (
|
||||||
<Card key={chk.id} hoverable className="space-y-2">
|
<div key={chk.id} className="flex flex-col gap-2 rounded-2xl bg-dark-panel p-4 shadow-edge">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="font-bold text-slate-100">
|
<span className="min-w-0 text-[13.5px] text-slate-100">
|
||||||
#{chk.id}: {chk.name}
|
#{chk.id}: {chk.name}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={chk.status === "PASSED" ? "success" : chk.status === "FAILED" ? "danger" : "warning"}>
|
<Badge
|
||||||
|
variant={chk.status === "PASSED" ? "success" : chk.status === "FAILED" ? "danger" : "warning"}
|
||||||
|
className="flex-none"
|
||||||
|
>
|
||||||
{chk.status}
|
{chk.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-400 leading-relaxed font-mono">{chk.details}</div>
|
<div className="break-words text-[12.5px] leading-relaxed text-slate-400">{chk.details}</div>
|
||||||
</Card>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,204 +269,223 @@ export default function AdminSecurityPage() {
|
|||||||
|
|
||||||
{/* TAB 2: TLS - read from the protocols service cert watcher */}
|
{/* TAB 2: TLS - read from the protocols service cert watcher */}
|
||||||
{activeTab === "tls" && (
|
{activeTab === "tls" && (
|
||||||
<div className="space-y-6 text-xs">
|
<section className={panel}>
|
||||||
<Card className="space-y-4">
|
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
<Lock className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||||
<Lock className="w-5 h-5 text-indigo-400" />
|
{t("admin.tlsTitle")}
|
||||||
{t("admin.tlsTitle")}
|
</h2>
|
||||||
</h2>
|
|
||||||
|
|
||||||
{tlsStatus ? (
|
{tlsStatus ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
|
<div className="flex flex-col gap-1 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||||
<div className="text-slate-400 font-medium">{t("admin.certificateState")}</div>
|
<div className="text-xs text-slate-400">{t("admin.certificateState")}</div>
|
||||||
<div
|
<div
|
||||||
className={`font-bold text-sm ${
|
className={`text-[15px] font-medium ${
|
||||||
tlsStatus.state === "OK" ? "text-emerald-400" : "text-amber-400"
|
tlsStatus.state === "OK" ? "text-indigo-400" : "text-amber-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{String(tlsStatus.state ?? "UNKNOWN")}
|
{String(tlsStatus.state ?? "UNKNOWN")}
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-slate-500 font-mono">
|
|
||||||
{typeof tlsStatus.expires_in_days === "number"
|
|
||||||
? t("admin.expiresInDays", { days: tlsStatus.expires_in_days })
|
|
||||||
: t("admin.noCertificate")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-[11.5px] text-slate-500">
|
||||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
|
{typeof tlsStatus.expires_in_days === "number"
|
||||||
<div className="text-slate-400 font-medium">{t("admin.mailHost")}</div>
|
? t("admin.expiresInDays", { days: tlsStatus.expires_in_days })
|
||||||
<div className="text-slate-100 font-bold text-sm break-all">
|
: t("admin.noCertificate")}
|
||||||
{String(tlsStatus.mail_host ?? "-")}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-slate-500 font-mono">
|
|
||||||
{t("admin.tlsMode")}: {String(tlsStatus.tls_mode ?? "-")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
|
|
||||||
<div className="text-slate-400 font-medium">{t("admin.watcherHealthy")}</div>
|
|
||||||
<div
|
|
||||||
className={`font-bold text-sm ${
|
|
||||||
tlsStatus.watcher_healthy ? "text-emerald-400" : "text-amber-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tlsStatus.watcher_healthy ? t("ui.healthy") : t("common.unavailable")}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-slate-500 font-mono">
|
|
||||||
{t("admin.lastReload")}:{" "}
|
|
||||||
{tlsStatus.last_reload
|
|
||||||
? new Date(String(tlsStatus.last_reload)).toLocaleString()
|
|
||||||
: t("common.never")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="p-6 text-center text-slate-500">{t("errors.loadFailed")}</div>
|
<div className="flex flex-col gap-1 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||||
)}
|
<div className="text-xs text-slate-400">{t("admin.mailHost")}</div>
|
||||||
</Card>
|
<div className="break-all text-[15px] font-medium text-slate-100">
|
||||||
</div>
|
{String(tlsStatus.mail_host ?? "-")}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11.5px] text-slate-500">
|
||||||
|
{t("admin.tlsMode")}: {String(tlsStatus.tls_mode ?? "-")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||||
|
<div className="text-xs text-slate-400">{t("admin.watcherHealthy")}</div>
|
||||||
|
<div
|
||||||
|
className={`text-[15px] font-medium ${
|
||||||
|
tlsStatus.watcher_healthy ? "text-indigo-400" : "text-amber-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tlsStatus.watcher_healthy ? t("ui.healthy") : t("common.unavailable")}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11.5px] text-slate-500">
|
||||||
|
{t("admin.lastReload")}:{" "}
|
||||||
|
{tlsStatus.last_reload
|
||||||
|
? new Date(String(tlsStatus.last_reload)).toLocaleString()
|
||||||
|
: t("common.never")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 text-center text-[13px] text-slate-400">{t("errors.loadFailed")}</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAB 3: RSPAMD ANTI-SPAM THRESHOLDS */}
|
{/* TAB 3: RSPAMD ANTI-SPAM THRESHOLDS */}
|
||||||
{activeTab === "rspamd" && (
|
{activeTab === "rspamd" && (
|
||||||
<Card className="space-y-6 text-xs max-w-3xl">
|
<section className={`${panel} max-w-[620px]`}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white mb-1 flex items-center gap-2">
|
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||||
<Sliders className="w-5 h-5 text-indigo-400" />
|
<Sliders className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||||
{t("admin.tabRspamd")}
|
{t("admin.tabRspamd")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-slate-400">{t("admin.rspamdIntro")}</p>
|
<p className="mt-1 text-[13px] leading-relaxed text-slate-400">{t("admin.rspamdIntro")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rspamdSaved && (
|
{rspamdSaved && (
|
||||||
<div className="p-3.5 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 flex items-center gap-2">
|
<div className="flex items-center gap-2 rounded-xl bg-dark-card px-3.5 py-3 text-[12.5px] text-slate-200 shadow-edge">
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
<CheckCircle2 className="h-4 w-4 flex-none text-indigo-500" />
|
||||||
<span>{t("admin.rspamdSaved")}</span>
|
<span>{t("admin.rspamdSaved")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSaveRspamd} className="space-y-4">
|
<form onSubmit={handleSaveRspamd} className="flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.greylistScore")}</label>
|
<label htmlFor="greylist-score" className={fieldLabel}>
|
||||||
|
{t("admin.greylistScore")}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="greylist-score"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
value={greylistScore}
|
value={greylistScore}
|
||||||
onChange={(e) => setGreylistScore(parseFloat(e.target.value))}
|
onChange={(e) => setGreylistScore(parseFloat(e.target.value))}
|
||||||
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`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.headerScore")}</label>
|
<label htmlFor="header-score" className={fieldLabel}>
|
||||||
|
{t("admin.headerScore")}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="header-score"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
value={addHeaderScore}
|
value={addHeaderScore}
|
||||||
onChange={(e) => setAddHeaderScore(parseFloat(e.target.value))}
|
onChange={(e) => setAddHeaderScore(parseFloat(e.target.value))}
|
||||||
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`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.rejectScore")}</label>
|
<label htmlFor="reject-score" className={fieldLabel}>
|
||||||
|
{t("admin.rejectScore")}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="reject-score"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
value={rejectScore}
|
value={rejectScore}
|
||||||
onChange={(e) => setRejectScore(parseFloat(e.target.value))}
|
onChange={(e) => setRejectScore(parseFloat(e.target.value))}
|
||||||
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`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" variant="primary" size="md">
|
<Button type="submit" variant="primary" size="md" className="self-start">
|
||||||
{t("admin.saveThresholds")}
|
{t("admin.saveThresholds")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAB 4: AUDIT LOG & QUEUE ID TRANSACTION TRACE */}
|
{/* TAB 4: AUDIT LOG & QUEUE ID TRANSACTION TRACE */}
|
||||||
{activeTab === "audit" && (
|
{activeTab === "audit" && (
|
||||||
<div className="space-y-6 text-xs">
|
<div className="flex flex-col gap-3.5">
|
||||||
{/* Queue ID Search Box */}
|
{/* Queue ID Search Box */}
|
||||||
<Card className="space-y-4">
|
<section className={panel}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white mb-1 flex items-center gap-2">
|
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||||
<Search className="w-5 h-5 text-indigo-400" />
|
<Search className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||||
{t("admin.traceTitle")}
|
{t("admin.traceTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-slate-400">{t("admin.tracePlaceholder")}</p>
|
<p className="mt-1 text-[13px] leading-relaxed text-slate-400">{t("admin.tracePlaceholder")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSearchQueueId} className="flex gap-3">
|
<form onSubmit={handleSearchQueueId} className="flex flex-wrap gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQueueId}
|
value={searchQueueId}
|
||||||
onChange={(e) => setSearchQueueId(e.target.value)}
|
onChange={(e) => setSearchQueueId(e.target.value)}
|
||||||
placeholder={t("admin.tracePlaceholder")}
|
placeholder={t("admin.tracePlaceholder")}
|
||||||
|
aria-label={t("admin.traceTitle")}
|
||||||
required
|
required
|
||||||
className="flex-1 px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white font-mono placeholder-slate-500 focus:outline-none focus:border-emerald-500"
|
className={`${input} min-w-[240px] flex-1 font-mono`}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variant="primary" size="md">
|
<Button type="submit" variant="primary" size="md" className="flex-none">
|
||||||
{t("admin.traceTitle")}
|
{t("admin.traceTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{tracedSteps && (
|
{tracedSteps && (
|
||||||
<div className="mt-4 border border-slate-800 rounded-xl overflow-hidden bg-slate-950 p-4 space-y-3">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="font-bold text-white">Resultado do Rastreamento para {searchQueueId}</h3>
|
{/* These two lines were literal Portuguese, so an interface
|
||||||
<div className="space-y-2">
|
running in English still said "Resultado do Rastreamento"
|
||||||
{tracedSteps.map((st) => (
|
and "Etapa". */}
|
||||||
<div key={st.step} className="p-3 rounded-lg bg-slate-900/90 border border-slate-800 flex items-center justify-between">
|
<h3 className="break-all text-[13.5px] text-slate-100">
|
||||||
<div>
|
{t("admin.traceResultFor", { id: searchQueueId })}
|
||||||
<div className="font-bold text-slate-200">Etapa {st.step}: {st.title}</div>
|
</h3>
|
||||||
<div className="text-[11px] text-slate-400 font-mono mt-0.5">{st.detail}</div>
|
{tracedSteps.map((st) => (
|
||||||
|
<div
|
||||||
|
key={st.step}
|
||||||
|
className="flex flex-wrap items-center gap-3 rounded-xl bg-dark-card px-3.5 py-3 shadow-edge"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="break-words text-[13.5px] text-slate-100">
|
||||||
|
{t("admin.traceStep", { n: st.step })}: {st.title}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="success">{st.status}</Badge>
|
<div className="mt-0.5 break-words text-[11.5px] text-slate-400">{st.detail}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<Badge variant="info" className="flex-none">
|
||||||
</div>
|
{st.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</section>
|
||||||
|
|
||||||
{/* Immutable System Audit Log Table */}
|
{/* Immutable System Audit Log Table */}
|
||||||
<Card className="p-0 overflow-hidden">
|
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||||
<div className="p-4 border-b border-slate-800 bg-slate-900/80 font-bold text-xs text-slate-300">
|
|
||||||
{t("admin.auditLog")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse text-xs">
|
<table className="lm-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
<tr>
|
||||||
<th className="p-3.5">{t("ui.timestamp")}</th>
|
<th className="pl-0">{t("ui.timestamp")}</th>
|
||||||
<th className="p-3.5">Queue ID</th>
|
<th>{t("ui.jobId")}</th>
|
||||||
<th className="p-3.5">{t("ui.actor")}</th>
|
<th>{t("ui.actor")}</th>
|
||||||
<th className="p-3.5">{t("ui.eventAction")}</th>
|
<th>{t("ui.eventAction")}</th>
|
||||||
<th className="p-3.5">{t("ui.targetObject")}</th>
|
<th>{t("ui.targetObject")}</th>
|
||||||
<th className="p-3.5">{t("ui.clientIp")}</th>
|
<th className="pr-0 text-right">{t("ui.clientIp")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
<tbody>
|
||||||
{auditEntries.map((aud) => (
|
{auditEntries.map((aud) => (
|
||||||
<tr key={aud.id} className="hover:bg-slate-900/40 transition-colors">
|
<tr key={aud.id}>
|
||||||
<td className="p-3.5 text-slate-400">{aud.timestamp}</td>
|
<td className="whitespace-nowrap pl-0 text-[12.5px] text-slate-400">{aud.timestamp}</td>
|
||||||
<td className="p-3.5 text-indigo-400 font-bold">{aud.queueId}</td>
|
<td className="font-mono text-[12.5px] text-slate-200">{aud.queueId}</td>
|
||||||
<td className="p-3.5 font-bold text-slate-200">{aud.actor}</td>
|
<td className="break-words text-[13px] text-slate-100">{aud.actor}</td>
|
||||||
<td className="p-3.5 font-bold">
|
<td>
|
||||||
<Badge variant="success">{aud.action}</Badge>
|
<Badge variant="info" className="whitespace-nowrap">
|
||||||
|
{aud.action}
|
||||||
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3.5 text-slate-300">{aud.target}</td>
|
<td className="break-words text-[13px] text-slate-300">{aud.target}</td>
|
||||||
<td className="p-3.5 text-slate-500">{aud.ip}</td>
|
<td className="pr-0 text-right font-mono text-[12.5px] text-slate-500">{aud.ip}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
{auditEntries.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-[13px] text-slate-400">{t("ui.noData")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
||||||
|
import { Button } from "../../../../components/ui/Button";
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -94,21 +95,29 @@ export default function AdminLoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const field =
|
||||||
|
"w-full min-h-[38px] rounded-xl bg-dark-card px-3 py-2.5 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent";
|
||||||
|
const codeField = `${field} text-center font-mono text-lg tracking-[0.3em]`;
|
||||||
|
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center px-4 bg-slate-950">
|
<div className="flex min-h-screen items-center justify-center bg-dark-bg px-4">
|
||||||
<div className="glass-panel p-8 rounded-2xl max-w-md w-full border border-emerald-900/40 shadow-2xl">
|
<div className="w-full max-w-[400px] rounded-2xl bg-dark-panel p-7 shadow-edge">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
{/* The console sign-in wears the same mark as the webmail. It used to be
|
||||||
<div className="w-10 h-10 rounded-xl bg-emerald-600/20 border border-emerald-500/30 flex items-center justify-center text-emerald-400 font-bold text-xl">
|
green over green, which made the second surface look like a
|
||||||
⚙
|
different product. */}
|
||||||
|
<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-xl leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||||
|
λ
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-xl font-bold text-white">{t("auth.adminLoginTitle")}</h1>
|
<h1 className="text-lg font-medium leading-tight text-slate-100">{t("auth.adminLoginTitle")}</h1>
|
||||||
<p className="text-xs text-slate-400">{t("auth.adminStepUpNote")}</p>
|
<p className="mt-0.5 text-[12.5px] leading-relaxed text-slate-400">{t("auth.adminStepUpNote")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
<div className="mb-4 rounded-xl bg-rose-900/60 p-3.5 text-xs leading-relaxed text-rose-200 shadow-edge">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -124,87 +133,102 @@ export default function AdminLoginPage() {
|
|||||||
continueLabel={t("auth.signInButton")}
|
continueLabel={t("auth.signInButton")}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||||
{enrolSecret ? (
|
{enrolSecret ? (
|
||||||
<div className="space-y-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="rounded-lg border border-indigo-500/30 bg-indigo-500/10 p-3 text-xs text-indigo-300">
|
<div className="rounded-xl bg-dark-card p-3.5 text-xs leading-relaxed text-slate-300 shadow-edge">
|
||||||
{t("auth.enrolmentIntro")}
|
{t("auth.enrolmentIntro")}
|
||||||
</div>
|
</div>
|
||||||
<TotpEnrolment secret={enrolSecret} uri={enrolUri} />
|
<TotpEnrolment secret={enrolSecret} uri={enrolUri} />
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-slate-300">{t("auth.totpCodeLabel")}</label>
|
<label htmlFor="admin-enrol-code" className={fieldLabel}>
|
||||||
<input
|
{t("auth.totpCodeLabel")}
|
||||||
type="text"
|
</label>
|
||||||
inputMode="numeric"
|
<input
|
||||||
autoComplete="one-time-code"
|
id="admin-enrol-code"
|
||||||
value={mfaCode}
|
type="text"
|
||||||
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
inputMode="numeric"
|
||||||
maxLength={7}
|
autoComplete="one-time-code"
|
||||||
required
|
value={mfaCode}
|
||||||
className="w-full rounded-lg border border-slate-800 bg-slate-900 px-4 py-2.5 text-center font-mono text-lg tracking-widest text-white focus:border-emerald-500 focus:outline-none"
|
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
/>
|
maxLength={7}
|
||||||
</div>
|
required
|
||||||
</div>
|
className={codeField}
|
||||||
) : !challengeToken ? (
|
/>
|
||||||
<>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-slate-300 mb-1">{t("auth.emailLabel")}</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="admin@domain.com"
|
|
||||||
required
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : !challengeToken ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="admin-email" className={fieldLabel}>
|
||||||
|
{t("auth.emailLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="admin-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="admin@domain.com"
|
||||||
|
required
|
||||||
|
className={field}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-300 mb-1">{t("auth.passwordLabel")}</label>
|
<label htmlFor="admin-password" className={fieldLabel}>
|
||||||
<input
|
{t("auth.passwordLabel")}
|
||||||
type="password"
|
</label>
|
||||||
value={password}
|
<input
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
id="admin-password"
|
||||||
placeholder="************"
|
type="password"
|
||||||
required
|
autoComplete="current-password"
|
||||||
className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500 transition-colors"
|
value={password}
|
||||||
/>
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="************"
|
||||||
|
required
|
||||||
|
className={field}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<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.mfaRequired")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="admin-mfa-code" className={fieldLabel}>
|
||||||
|
{t("auth.totpCodeLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="admin-mfa-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
value={mfaCode}
|
||||||
|
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
|
placeholder="123456"
|
||||||
|
maxLength={7}
|
||||||
|
required
|
||||||
|
className={codeField}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div className="p-3 mb-4 rounded-lg bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs">
|
|
||||||
{t("auth.mfaRequired")}
|
|
||||||
</div>
|
|
||||||
<label className="block text-xs font-medium text-slate-300 mb-1">{t("auth.totpCodeLabel")}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={mfaCode}
|
|
||||||
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
|
||||||
placeholder="123456"
|
|
||||||
maxLength={7}
|
|
||||||
required
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white text-center tracking-widest text-lg font-mono focus:outline-none focus:border-emerald-500 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<Button type="submit" variant="primary" size="lg" className="mt-1 w-full" disabled={loading}>
|
||||||
type="submit"
|
{loading
|
||||||
disabled={loading}
|
? t("common.loading")
|
||||||
className="w-full py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-medium transition-colors shadow-lg shadow-emerald-600/20 disabled:opacity-50"
|
: challengeToken || enrolSecret
|
||||||
>
|
? t("auth.verifyCodeButton")
|
||||||
{loading
|
: t("auth.signInButton")}
|
||||||
? t("common.loading")
|
</Button>
|
||||||
: challengeToken || enrolSecret
|
</form>
|
||||||
? t("auth.verifyCodeButton")
|
|
||||||
: t("auth.signInButton")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 pt-4 border-t border-slate-800 text-center">
|
<div className="mt-6 pt-4 text-center">
|
||||||
<Link href="/user/mail/inbox" className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
|
<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")}
|
{t("admin.backToWebmail")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { KeyRound, ShieldCheck, AlertCircle, Sliders } from "lucide-react";
|
|||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { useAccount } from "../../../../lib/useAccount";
|
import { useAccount } from "../../../../lib/useAccount";
|
||||||
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
||||||
|
import { Button } from "../../../../components/ui/Button";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crossing from the webmail into the console.
|
* Crossing from the webmail into the console.
|
||||||
@@ -92,23 +93,23 @@ export default function AdminStepUpPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center px-4 bg-slate-950">
|
<div className="flex min-h-screen items-center justify-center bg-dark-bg px-4">
|
||||||
<div className="glass-panel p-8 rounded-2xl max-w-md w-full border border-emerald-900/40 shadow-2xl">
|
<div className="w-full max-w-[400px] rounded-2xl bg-dark-panel p-7 shadow-edge">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="mb-6 flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-emerald-600/20 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
|
<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="w-5 h-5" />
|
<Sliders className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-lg font-bold text-white">{t("auth.stepUpTitle")}</h1>
|
<h1 className="text-lg font-medium leading-tight text-slate-100">{t("auth.stepUpTitle")}</h1>
|
||||||
<p className="text-xs text-slate-400 truncate">
|
<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")}
|
{account?.email ? t("auth.stepUpSignedInAs", { email: account.email }) : t("common.loading")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center gap-2">
|
<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="w-4 h-4 flex-shrink-0" />
|
<AlertCircle className="mt-px h-4 w-4 flex-none" />
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -120,27 +121,27 @@ export default function AdminStepUpPage() {
|
|||||||
continueLabel={t("common.continue")}
|
continueLabel={t("common.continue")}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit} className="space-y-4">
|
<form onSubmit={submit} className="flex flex-col gap-4">
|
||||||
{enrolSecret ? (
|
{enrolSecret ? (
|
||||||
<div className="space-y-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="rounded-lg border border-indigo-500/30 bg-indigo-500/10 p-3 text-xs text-indigo-300">
|
<div className="rounded-xl bg-dark-card p-3.5 text-xs leading-relaxed text-slate-300 shadow-edge">
|
||||||
{t("auth.enrolmentIntro")}
|
{t("auth.enrolmentIntro")}
|
||||||
</div>
|
</div>
|
||||||
<TotpEnrolment secret={enrolSecret} uri={enrolUri} />
|
<TotpEnrolment secret={enrolSecret} uri={enrolUri} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-300 flex items-start gap-2">
|
<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="w-4 h-4 flex-shrink-0 mt-0.5" />
|
<ShieldCheck className="mt-px h-4 w-4 flex-none text-indigo-500" />
|
||||||
<span>{t("auth.stepUpIntro")}</span>
|
<span>{t("auth.stepUpIntro")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="step-up-code" className="mb-1 block text-xs font-medium text-slate-300">
|
<label htmlFor="step-up-code" className="mb-1.5 block text-xs text-slate-400">
|
||||||
{t("auth.totpCodeLabel")}
|
{t("auth.totpCodeLabel")}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<KeyRound className="w-4 h-4 absolute left-3.5 top-3.5 text-slate-500" />
|
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
id="step-up-code"
|
id="step-up-code"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -151,26 +152,23 @@ export default function AdminStepUpPage() {
|
|||||||
onChange={(e) => setCode(e.target.value.replace(/\s/g, ""))}
|
onChange={(e) => setCode(e.target.value.replace(/\s/g, ""))}
|
||||||
placeholder="123456"
|
placeholder="123456"
|
||||||
required
|
required
|
||||||
className="w-full rounded-xl border border-slate-800 bg-slate-900 pl-10 pr-4 py-3 text-center font-mono text-lg tracking-widest text-white focus:border-emerald-500 focus:outline-none"
|
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>
|
</div>
|
||||||
{/* A recovery code is the way in when the phone is gone, and
|
{/* A recovery code is the way in when the phone is gone, and
|
||||||
nothing on the old screens said so. */}
|
nothing on the old screens said so. */}
|
||||||
<p className="mt-1.5 text-[11px] text-slate-500">{t("auth.orRecoveryCode")}</p>
|
<p className="mt-1.5 text-[11.5px] leading-relaxed text-slate-500">{t("auth.orRecoveryCode")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<Button type="submit" variant="primary" size="lg" className="w-full" disabled={loading}>
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-medium transition-colors shadow-lg shadow-emerald-600/20 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? t("common.loading") : t("auth.verifyCodeButton")}
|
{loading ? t("common.loading") : t("auth.verifyCodeButton")}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 pt-4 border-t border-slate-800 text-center">
|
<div className="mt-6 pt-4 text-center">
|
||||||
<Link href="/user/mail/inbox" className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
|
<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")}
|
{t("admin.backToWebmail")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Mail, Lock, AlertCircle } from "lucide-react";
|
import { Mail, Lock, AlertCircle } from "lucide-react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { Card } from "../../../../components/ui/Card";
|
|
||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
|
|
||||||
export default function UserLoginPage() {
|
export default function UserLoginPage() {
|
||||||
@@ -49,37 +48,41 @@ export default function UserLoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const field =
|
||||||
<div className="min-h-screen flex items-center justify-center px-4 bg-dark-bg relative overflow-hidden">
|
"w-full min-h-[38px] rounded-xl bg-dark-card py-2.5 pl-[38px] pr-3 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent";
|
||||||
{/* Background Ambient Glow */}
|
|
||||||
<div className="absolute -top-40 -left-40 w-96 h-96 bg-indigo-600/20 rounded-full blur-3xl pointer-events-none" />
|
|
||||||
<div className="absolute -bottom-40 -right-40 w-96 h-96 bg-cyan-600/20 rounded-full blur-3xl pointer-events-none" />
|
|
||||||
|
|
||||||
<Card className="max-w-md w-full border border-slate-800 shadow-2xl p-8 z-10">
|
return (
|
||||||
<div className="flex items-center gap-3.5 mb-6">
|
/* No ambient glow behind the card any more: two blurred coloured circles
|
||||||
<div className="w-11 h-11 rounded-2xl bg-gradient-to-tr from-indigo-600 to-cyan-500 flex items-center justify-center text-white font-bold shadow-lg shadow-indigo-500/20">
|
under a sign-in form are decoration that costs a full-viewport paint and
|
||||||
<Mail className="w-6 h-6" />
|
says nothing. Nocturne's depth is the surface and its edge. */
|
||||||
|
<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-xl leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||||
|
λ
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-xl font-extrabold text-white tracking-tight">{t("auth.userLoginTitle")}</h1>
|
<h1 className="text-lg font-medium leading-tight text-slate-100">{t("auth.userLoginTitle")}</h1>
|
||||||
<p className="text-xs text-slate-400">{t("common.appName")} · {t("common.userPortal")}</p>
|
<p className="mt-0.5 text-[12.5px] text-slate-400">
|
||||||
|
{t("common.appName")} · {t("common.userPortal")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-5 p-3.5 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center gap-2.5">
|
<div className="mb-5 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="w-4 h-4 text-rose-400 flex-shrink-0" />
|
<AlertCircle className="mt-px h-4 w-4 flex-none" />
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4 text-xs">
|
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="login-email" className="block font-semibold text-slate-300 mb-1.5">
|
<label htmlFor="login-email" className="mb-1.5 block text-xs text-slate-400">
|
||||||
{t("auth.emailLabel")}
|
{t("auth.emailLabel")}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
|
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
id="login-email"
|
id="login-email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -88,17 +91,17 @@ export default function UserLoginPage() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="user@domain.com"
|
placeholder="user@domain.com"
|
||||||
required
|
required
|
||||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
|
className={field}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="login-password" className="block font-semibold text-slate-300 mb-1.5">
|
<label htmlFor="login-password" className="mb-1.5 block text-xs text-slate-400">
|
||||||
{t("auth.passwordLabel")}
|
{t("auth.passwordLabel")}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
|
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
id="login-password"
|
id="login-password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -107,17 +110,16 @@ export default function UserLoginPage() {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="************"
|
placeholder="************"
|
||||||
required
|
required
|
||||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
|
className={field}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" variant="primary" size="lg" className="w-full mt-2" disabled={loading}>
|
<Button type="submit" variant="primary" size="lg" className="mt-1 w-full" disabled={loading}>
|
||||||
{loading ? t("common.loading") : t("auth.signInButton")}
|
{loading ? t("common.loading") : t("auth.signInButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
|
|
||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
|
|
||||||
/** Splits a comma or semicolon separated field, dropping empty entries. */
|
/** Splits a comma or semicolon separated field, dropping empty entries. */
|
||||||
@@ -178,32 +177,50 @@ export default function ComposePage({
|
|||||||
setSending(false);
|
setSending(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** One row of the compose sheet: a fixed-width label beside the control. */
|
||||||
|
const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]";
|
||||||
|
const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400";
|
||||||
|
const bareInput =
|
||||||
|
"min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none";
|
||||||
|
const toolButton =
|
||||||
|
"flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 p-6 md:p-8 bg-dark-bg flex flex-col items-center overflow-y-auto">
|
<div className="flex-1 overflow-y-auto bg-dark-bg px-5 pb-11 pt-7 sm:px-8">
|
||||||
<Card className="max-w-3xl w-full border border-slate-800 shadow-2xl space-y-5">
|
<form onSubmit={handleSend} className="mx-auto flex w-full max-w-[860px] flex-col gap-[18px]">
|
||||||
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<h1 className="text-xl font-bold text-white flex items-center gap-2 tracking-tight">
|
<div className="min-w-0">
|
||||||
<Send className="w-5 h-5 text-indigo-400" />
|
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("mail.compose")}</h1>
|
||||||
<span>{t("mail.compose")}</span>
|
{draftStatus && (
|
||||||
</h1>
|
<div className="mt-1 flex items-center gap-1.5 text-[12.5px] text-slate-400">
|
||||||
{draftStatus && (
|
<CheckCircle2 className="h-3.5 w-3.5 flex-none text-indigo-500" />
|
||||||
<span className="text-xs text-emerald-400 font-mono flex items-center gap-1">
|
{draftStatus}
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
</div>
|
||||||
{draftStatus}
|
)}
|
||||||
</span>
|
</div>
|
||||||
)}
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link href="/user/mail/inbox">
|
||||||
|
<Button variant="secondary" size="md">
|
||||||
|
{t("mail.discard")}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button type="submit" variant="primary" size="md" disabled={sending}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
<span>{sending ? t("mail.sending") : t("mail.sendMessage")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sendError && (
|
{sendError && (
|
||||||
<div className="p-4 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-xs">
|
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-xs leading-relaxed text-rose-200 shadow-edge">
|
||||||
{sendError}
|
{sendError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{undoSeconds !== null && (
|
{undoSeconds !== null && (
|
||||||
<div className="p-4 rounded-xl bg-indigo-600/20 border border-indigo-500/40 flex items-center justify-between text-indigo-300 text-xs">
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-dark-card px-4 py-3 text-xs text-slate-200 shadow-edge">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 text-indigo-400 animate-spin" />
|
<Sparkles className="h-4 w-4 flex-none animate-spin text-indigo-500" />
|
||||||
{/* The countdown is a placeholder inside the message, not text
|
{/* The countdown is a placeholder inside the message, not text
|
||||||
bolted on after it. It used to be both: the bundle's own
|
bolted on after it. It used to be both: the bundle's own
|
||||||
"{seconds}" was never substituted and a second, hardcoded
|
"{seconds}" was never substituted and a second, hardcoded
|
||||||
@@ -212,217 +229,191 @@ export default function ComposePage({
|
|||||||
<span>{t("mail.undoSend", { seconds: undoSeconds })}</span>
|
<span>{t("mail.undoSend", { seconds: undoSeconds })}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" size="sm" onClick={handleUndo}>
|
<Button variant="primary" size="sm" onClick={handleUndo}>
|
||||||
<Undo2 className="w-3.5 h-3.5" />
|
<Undo2 className="h-3.5 w-3.5" />
|
||||||
{t("mail.undoSendAction")}
|
{t("mail.undoSendAction")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSend} className="space-y-4 text-xs">
|
{/* One sheet, the fields stacked as rows inside it, rather than a stack
|
||||||
<div>
|
of separately outlined inputs: the message is one object. */}
|
||||||
<div className="flex justify-between items-center mb-1.5">
|
<div className="overflow-hidden rounded-2xl bg-dark-panel shadow-edge">
|
||||||
<label className="font-semibold text-slate-300">{t("ui.toRecipients")}</label>
|
<div className={fieldRow}>
|
||||||
<button
|
<label htmlFor="compose-to" className={fieldLabel}>
|
||||||
type="button"
|
{t("ui.toRecipients")}
|
||||||
onClick={() => setShowCcBcc(!showCcBcc)}
|
</label>
|
||||||
className="text-indigo-400 hover:text-indigo-300 font-medium hover:underline"
|
|
||||||
>
|
|
||||||
{showCcBcc ? t("mail.hideCcBcc") : t("mail.showCcBcc")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
|
id="compose-to"
|
||||||
type="text"
|
type="text"
|
||||||
value={to}
|
value={to}
|
||||||
onChange={(e) => setTo(e.target.value)}
|
onChange={(e) => setTo(e.target.value)}
|
||||||
placeholder="recipient@example.com"
|
placeholder="recipient@example.com"
|
||||||
required
|
required
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
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-indigo-500 transition-colors"
|
className={bareInput}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCcBcc(!showCcBcc)}
|
||||||
|
className="flex-none rounded-lg px-2 py-1 text-[12.5px] text-indigo-500 transition-colors hover:bg-indigo-500/10"
|
||||||
|
>
|
||||||
|
{showCcBcc ? t("mail.hideCcBcc") : t("mail.showCcBcc")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCcBcc && (
|
{showCcBcc && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<>
|
||||||
<div>
|
<div className={fieldRow}>
|
||||||
<label className="font-semibold text-slate-300 mb-1 block">Cc</label>
|
<label htmlFor="compose-cc" className={fieldLabel}>
|
||||||
|
Cc
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="compose-cc"
|
||||||
type="text"
|
type="text"
|
||||||
value={cc}
|
value={cc}
|
||||||
onChange={(e) => setCc(e.target.value)}
|
onChange={(e) => setCc(e.target.value)}
|
||||||
placeholder="cc@domain.com"
|
placeholder="cc@domain.com"
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
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-indigo-500"
|
className={bareInput}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className={fieldRow}>
|
||||||
<label className="font-semibold text-slate-300 mb-1 block">Bcc</label>
|
<label htmlFor="compose-bcc" className={fieldLabel}>
|
||||||
|
Bcc
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="compose-bcc"
|
||||||
type="text"
|
type="text"
|
||||||
value={bcc}
|
value={bcc}
|
||||||
onChange={(e) => setBcc(e.target.value)}
|
onChange={(e) => setBcc(e.target.value)}
|
||||||
placeholder="bcc@domain.com"
|
placeholder="bcc@domain.com"
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
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-indigo-500"
|
className={bareInput}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div className={fieldRow}>
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("ui.subject")}</label>
|
<label htmlFor="compose-subject" className={fieldLabel}>
|
||||||
|
{t("ui.subject")}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="compose-subject"
|
||||||
type="text"
|
type="text"
|
||||||
value={subject}
|
value={subject}
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
placeholder={t("ui.subject")}
|
placeholder={t("ui.subject")}
|
||||||
required
|
required
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
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-indigo-500 transition-colors"
|
className={bareInput}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Formatting Toolbar */}
|
{/* Formatting Toolbar */}
|
||||||
<div>
|
<div className="flex flex-wrap items-center gap-0.5 px-3 py-2 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]">
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("ui.messageBody")}</label>
|
<button type="button" onClick={() => executeFormat("bold")} className={toolButton} title={t("mail.bold")}>
|
||||||
<div className="border border-slate-800 rounded-xl overflow-hidden bg-slate-900/90">
|
<Bold className="h-4 w-4" />
|
||||||
<div className="flex items-center gap-1 p-2 bg-slate-950/80 border-b border-slate-800/80 flex-wrap text-slate-300">
|
</button>
|
||||||
<button
|
<button type="button" onClick={() => executeFormat("italic")} className={toolButton} title={t("mail.italic")}>
|
||||||
type="button"
|
<Italic className="h-4 w-4" />
|
||||||
onClick={() => executeFormat("bold")}
|
</button>
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
<button
|
||||||
title={t("mail.bold")}
|
type="button"
|
||||||
>
|
onClick={() => executeFormat("underline")}
|
||||||
<Bold className="w-4 h-4" />
|
className={toolButton}
|
||||||
</button>
|
title={t("mail.underline")}
|
||||||
<button
|
>
|
||||||
type="button"
|
<Underline className="h-4 w-4" />
|
||||||
onClick={() => executeFormat("italic")}
|
</button>
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
<button
|
||||||
title={t("mail.italic")}
|
type="button"
|
||||||
>
|
onClick={() => executeFormat("strikeThrough")}
|
||||||
<Italic className="w-4 h-4" />
|
className={toolButton}
|
||||||
</button>
|
title={t("mail.strikethrough")}
|
||||||
<button
|
>
|
||||||
type="button"
|
<Strikethrough className="h-4 w-4" />
|
||||||
onClick={() => executeFormat("underline")}
|
</button>
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
|
||||||
title={t("mail.underline")}
|
<button
|
||||||
>
|
type="button"
|
||||||
<Underline className="w-4 h-4" />
|
onClick={() => executeFormat("insertUnorderedList")}
|
||||||
</button>
|
className={toolButton}
|
||||||
<button
|
title={t("mail.bulletList")}
|
||||||
type="button"
|
>
|
||||||
onClick={() => executeFormat("strikeThrough")}
|
<List className="h-4 w-4" />
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
</button>
|
||||||
title={t("mail.strikethrough")}
|
<button
|
||||||
>
|
type="button"
|
||||||
<Strikethrough className="w-4 h-4" />
|
onClick={() => executeFormat("insertOrderedList")}
|
||||||
</button>
|
className={toolButton}
|
||||||
<span className="w-px h-4 bg-slate-800 mx-1" />
|
title={t("mail.numberedList")}
|
||||||
<button
|
>
|
||||||
type="button"
|
<ListOrdered className="h-4 w-4" />
|
||||||
onClick={() => executeFormat("insertUnorderedList")}
|
</button>
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
|
||||||
title={t("mail.bulletList")}
|
<button
|
||||||
>
|
type="button"
|
||||||
<List className="w-4 h-4" />
|
onClick={() => {
|
||||||
</button>
|
const url = prompt(t("mail.insertUrl"));
|
||||||
<button
|
if (url) executeFormat("createLink", url);
|
||||||
type="button"
|
}}
|
||||||
onClick={() => executeFormat("insertOrderedList")}
|
className={`${toolButton} text-indigo-500`}
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
title={t("mail.insertLink")}
|
||||||
title={t("mail.numberedList")}
|
>
|
||||||
>
|
<LinkIcon className="h-4 w-4" />
|
||||||
<ListOrdered className="w-4 h-4" />
|
</button>
|
||||||
</button>
|
|
||||||
<span className="w-px h-4 bg-slate-800 mx-1" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const url = prompt(t("mail.insertUrl"));
|
|
||||||
if (url) executeFormat("createLink", url);
|
|
||||||
}}
|
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-indigo-400 hover:text-indigo-300"
|
|
||||||
title={t("mail.insertLink")}
|
|
||||||
>
|
|
||||||
<LinkIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={editorRef}
|
|
||||||
contentEditable
|
|
||||||
suppressContentEditableWarning
|
|
||||||
className="p-4 min-h-[220px] focus:outline-none text-white font-sans text-xs leading-relaxed"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
aria-label={t("ui.messageBody")}
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
className="min-h-[280px] px-[22px] py-5 font-sans text-[14.5px] leading-relaxed text-slate-200 focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Attachment Selector & Display */}
|
{/* Attachment Selector & Display */}
|
||||||
<div className="pt-2">
|
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3.5 shadow-[inset_0_1px_0_0_rgba(233,233,237,0.07)]">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<label className="flex flex-none cursor-pointer items-center gap-2 rounded-[10px] border border-white/[0.14] px-3 py-2 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]">
|
||||||
<span className="font-semibold text-slate-300">{t("mail.attachments", { count: attachments.length })}</span>
|
<Paperclip className="h-[15px] w-[15px]" />
|
||||||
<label className="cursor-pointer text-indigo-400 hover:text-indigo-300 flex items-center gap-1.5 font-medium">
|
<span>{t("mail.addFiles")}</span>
|
||||||
<Paperclip className="w-3.5 h-3.5" />
|
<input type="file" multiple onChange={handleFileUpload} className="hidden" />
|
||||||
<span>{t("mail.addFiles")}</span>
|
</label>
|
||||||
<input
|
|
||||||
type="file"
|
{attachments.map((file) => (
|
||||||
multiple
|
<div
|
||||||
onChange={handleFileUpload}
|
key={file.id}
|
||||||
className="hidden"
|
className="flex items-center gap-2 rounded-[10px] bg-dark-card px-3 py-2 text-[12.5px] text-slate-200 shadow-edge"
|
||||||
/>
|
>
|
||||||
</label>
|
<Paperclip className="h-[15px] w-[15px] flex-none text-indigo-400" />
|
||||||
</div>
|
<span className="max-w-[160px] truncate">{file.name}</span>
|
||||||
|
<span className="text-[11px] text-slate-500">{(file.size / 1024).toFixed(1)} KB</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeAttachment(file.id)}
|
||||||
|
className="text-slate-400 transition-colors hover:text-rose-400"
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Says so plainly rather than letting someone attach a contract
|
{/* Says so plainly rather than letting someone attach a contract
|
||||||
and watch it silently not arrive: /mail/send is handed the file
|
and watch it silently not arrive: /mail/send is handed the file
|
||||||
names only, because there is no upload endpoint behind this
|
names only, because there is no upload endpoint behind this
|
||||||
picker yet. */}
|
picker yet. */}
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="mb-2 rounded-xl border border-amber-500/30 bg-amber-500/10 p-2.5 text-[11px] text-amber-300">
|
<span className="ml-auto text-[11.5px] leading-relaxed text-slate-400">
|
||||||
{t("mail.attachmentsUnsupported")}
|
{t("mail.attachmentsUnsupported")}
|
||||||
</div>
|
</span>
|
||||||
)}
|
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{attachments.map((file) => (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200"
|
|
||||||
>
|
|
||||||
<Paperclip className="w-3 h-3 text-indigo-400" />
|
|
||||||
<span className="font-medium max-w-[140px] truncate">{file.name}</span>
|
|
||||||
<span className="text-[10px] text-slate-500">
|
|
||||||
({(file.size / 1024).toFixed(1)} KB)
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeAttachment(file.id)}
|
|
||||||
className="text-slate-400 hover:text-rose-400 ml-1"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-slate-800">
|
</form>
|
||||||
<Link href="/user/mail/inbox">
|
|
||||||
<Button variant="ghost" size="md">
|
|
||||||
{t("mail.discard")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Button type="submit" variant="primary" size="md" disabled={sending}>
|
|
||||||
<Send className="w-4 h-4" />
|
|
||||||
<span>{sending ? t("mail.sending") : t("mail.sendMessage")}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -11,13 +11,12 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Trash2,
|
Trash2,
|
||||||
Settings,
|
|
||||||
PenSquare,
|
PenSquare,
|
||||||
LogOut,
|
LogOut,
|
||||||
Mail,
|
Mail,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Sliders,
|
SlidersHorizontal,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -26,6 +25,7 @@ import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
|||||||
import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
||||||
import { useFolders } from "../../../lib/useFolders";
|
import { useFolders } from "../../../lib/useFolders";
|
||||||
import { badgeCount, folderMetrics } from "../../../lib/mailCounts";
|
import { badgeCount, folderMetrics } from "../../../lib/mailCounts";
|
||||||
|
import { initialsFor } from "../../../lib/initials";
|
||||||
|
|
||||||
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -51,143 +51,160 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const settingsActive = pathname === "/user/settings";
|
||||||
|
|
||||||
const SidebarContent = (
|
const SidebarContent = (
|
||||||
<div className="flex flex-col justify-between h-full p-4">
|
<div className="flex h-full flex-col gap-4 p-3">
|
||||||
<div>
|
{/* The brand mark. One mark for both surfaces - the line underneath is
|
||||||
{/* Webmail Surface Branding */}
|
the only thing that says which one you are in. */}
|
||||||
<div className="flex items-center gap-3 px-2 mb-6">
|
<div className="flex items-center gap-2.5 px-2 py-1">
|
||||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-indigo-600 to-cyan-500 border border-indigo-400/30 flex items-center justify-center text-white font-bold shadow-lg shadow-indigo-500/20">
|
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||||
<Mail className="w-5 h-5 text-white" />
|
λ
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="font-bold text-base text-white tracking-tight">LambdaMail</div>
|
<div className="text-[15px] font-medium leading-tight">LambdaMail</div>
|
||||||
<div className="text-[10px] text-indigo-400 uppercase tracking-widest font-mono font-semibold">
|
<div className="text-[10.5px] uppercase tracking-[0.08em] text-indigo-500">
|
||||||
{t("common.userPortal")}
|
{t("common.userPortal")}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compose Floating Button */}
|
|
||||||
<Link
|
|
||||||
href="/user/compose"
|
|
||||||
className="group relative flex items-center justify-center gap-2 w-full py-3 px-4 mb-6 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-sm transition-all duration-200 shadow-lg shadow-indigo-600/30 active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
<PenSquare className="w-4 h-4 transition-transform group-hover:rotate-12" />
|
|
||||||
<span>{t("mail.compose")}</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Folder Navigation List */}
|
|
||||||
<nav className="space-y-1 text-sm font-medium">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const isActive = pathname === item.href || (item.role === "inbox" && pathname === "/user/mail");
|
|
||||||
// Drafts counts what is waiting rather than what is unread; see
|
|
||||||
// badgeCount, which is where that rule is tested.
|
|
||||||
const badge = badgeCount(folders, item.role);
|
|
||||||
const { total } = folderMetrics(folders, item.role);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
title={total > 0 ? t("mail.messagesInFolder", { count: total }) : item.label}
|
|
||||||
className={`flex items-center justify-between px-3.5 py-2.5 rounded-xl transition-all duration-150 ${
|
|
||||||
isActive
|
|
||||||
? "bg-indigo-600/20 text-indigo-300 border border-indigo-500/30 shadow-sm"
|
|
||||||
: "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40 border border-transparent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
|
||||||
<Icon className={`w-4 h-4 flex-shrink-0 ${isActive ? "text-indigo-400" : "text-slate-400"}`} />
|
|
||||||
<span className="truncate">{item.label}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
||||||
{/* The folder's size, shown quietly beside the badge: the
|
|
||||||
sidebar reported unread only, so there was nowhere in the
|
|
||||||
interface that said how much mail a folder holds. */}
|
|
||||||
{total > 0 && (
|
|
||||||
<span className="text-[10px] font-mono text-slate-500 tabular-nums">{total}</span>
|
|
||||||
)}
|
|
||||||
{badge > 0 && (
|
|
||||||
<span
|
|
||||||
className={`text-xs px-2 py-0.5 rounded-full font-semibold tabular-nums ${
|
|
||||||
isActive ? "bg-indigo-500/40 text-indigo-200" : "bg-indigo-500/20 text-indigo-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Controls & User Settings */}
|
<Link
|
||||||
<div className="space-y-3">
|
href="/user/compose"
|
||||||
<div className="flex items-center justify-between gap-2 p-2 rounded-xl bg-slate-900/60 border border-slate-800/80">
|
onClick={() => setMobileOpen(false)}
|
||||||
<LanguageSwitcher />
|
className="flex w-full items-center justify-center gap-2 rounded-[10px] border border-indigo-500 px-3 py-2.5 text-sm font-medium text-indigo-500 transition-colors hover:bg-indigo-500/[0.12] active:bg-indigo-500/[0.22]"
|
||||||
<button
|
>
|
||||||
type="button"
|
<PenSquare className="h-4 w-4 flex-none" />
|
||||||
onClick={handleLogout}
|
<span className="min-w-0">{t("mail.compose")}</span>
|
||||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-slate-700/80 text-xs font-medium text-slate-300 hover:text-rose-400 hover:border-rose-500/40 hover:bg-rose-500/10 transition-all"
|
</Link>
|
||||||
>
|
|
||||||
<LogOut className="w-3.5 h-3.5" />
|
|
||||||
<span>{t("settings.signOut")}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The console is reachable from inside the app for the accounts that
|
{/* Folder navigation. Every row is a mark, an icon, a label that may wrap,
|
||||||
may open it. It goes to the step-up rather than to the admin sign-in:
|
and its numbers - nothing is sized by the length of the label, which
|
||||||
the admin audience is a separate token, so crossing over costs a
|
is what clipped the Portuguese and Spanish names before. */}
|
||||||
second factor - but not the password, which the session in hand
|
<nav className="flex flex-col gap-0.5">
|
||||||
proved already. */}
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = pathname === item.href || (item.role === "inbox" && pathname === "/user/mail");
|
||||||
|
// Drafts counts what is waiting rather than what is unread; see
|
||||||
|
// badgeCount, which is where that rule is tested.
|
||||||
|
const badge = badgeCount(folders, item.role);
|
||||||
|
const { total } = folderMetrics(folders, item.role);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
data-active={isActive}
|
||||||
|
title={total > 0 ? t("mail.messagesInFolder", { count: total }) : item.label}
|
||||||
|
className="lm-nav"
|
||||||
|
>
|
||||||
|
<span className="lm-nav-mark" />
|
||||||
|
<Icon className={`h-[17px] w-[17px] flex-none ${isActive ? "text-indigo-500" : "text-slate-400"}`} />
|
||||||
|
<span className="min-w-0 flex-1 text-[13.5px] leading-snug">{item.label}</span>
|
||||||
|
<span className="flex flex-none items-center gap-1.5">
|
||||||
|
{/* The folder's size, shown quietly beside the badge: the
|
||||||
|
sidebar reported unread only, so there was nowhere in the
|
||||||
|
interface that said how much mail a folder holds. */}
|
||||||
|
{total > 0 && badge > 0 && (
|
||||||
|
<span className="text-[10.5px] tabular-nums text-slate-500">{total}</span>
|
||||||
|
)}
|
||||||
|
{badge > 0 ? (
|
||||||
|
<span
|
||||||
|
className={`text-[11.5px] tabular-nums ${isActive ? "text-indigo-300" : "text-slate-400"}`}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
total > 0 && (
|
||||||
|
<span className="text-[11.5px] tabular-nums text-slate-500">{total}</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="lm-rule my-2.5" />
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/user/settings"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
data-active={settingsActive}
|
||||||
|
className="lm-nav"
|
||||||
|
>
|
||||||
|
<span className="lm-nav-mark" />
|
||||||
|
<SlidersHorizontal
|
||||||
|
className={`h-[17px] w-[17px] flex-none ${settingsActive ? "text-indigo-500" : "text-slate-400"}`}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 text-[13.5px] leading-snug">{t("settings.title")}</span>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-auto flex flex-col gap-2.5">
|
||||||
|
{/* The two surfaces as one control, so where you are and where else you
|
||||||
|
could be are the same question. The console goes to the step-up
|
||||||
|
rather than to the admin sign-in: the admin audience is a separate
|
||||||
|
token, so crossing over costs a second factor - but not the
|
||||||
|
password, which the session in hand proved already. */}
|
||||||
{isAdminRole(account?.role) && (
|
{isAdminRole(account?.role) && (
|
||||||
<Link
|
<div className="flex gap-[3px] rounded-[10px] bg-dark-panel p-[3px] shadow-edge">
|
||||||
href="/admin/step-up"
|
<span className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] bg-dark-card px-2 py-1.5 text-[12px] leading-snug text-indigo-500 shadow-edge-accent">
|
||||||
className="flex items-center gap-2.5 p-2.5 rounded-xl border border-emerald-500/25 bg-emerald-500/10 text-emerald-300 hover:border-emerald-500/50 hover:bg-emerald-500/15 transition-all"
|
<Mail className="h-3.5 w-3.5 flex-none" />
|
||||||
>
|
<span className="min-w-0 truncate">{t("common.userPortal")}</span>
|
||||||
<Sliders className="w-4 h-4 flex-shrink-0" />
|
</span>
|
||||||
<span className="text-xs font-semibold truncate">{t("admin.openAdmin")}</span>
|
<Link
|
||||||
</Link>
|
href="/admin/step-up"
|
||||||
|
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-white/[0.05] hover:text-slate-100"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 flex-none" />
|
||||||
|
<span className="min-w-0 truncate">{t("admin.openAdmin")}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/user/settings"
|
href="/user/settings"
|
||||||
className={`flex items-center justify-between p-2.5 rounded-xl border transition-all ${
|
onClick={() => setMobileOpen(false)}
|
||||||
pathname === "/user/settings"
|
className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge transition-colors hover:bg-dark-card"
|
||||||
? "bg-indigo-600/15 border-indigo-500/30 text-slate-100"
|
|
||||||
: "bg-slate-900/80 border-slate-800 hover:border-slate-700 text-slate-300"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
|
||||||
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-indigo-500 to-cyan-500 text-white flex items-center justify-center font-bold text-xs flex-shrink-0 shadow-sm uppercase">
|
{initialsFor(account?.email)}
|
||||||
{account?.email?.[0] ?? "?"}
|
</span>
|
||||||
</div>
|
<span className="min-w-0 flex-1">
|
||||||
<div className="truncate">
|
<span className="block break-words text-[12.5px] leading-tight">
|
||||||
<div className="text-xs font-semibold text-slate-200 truncate">
|
{account?.email ?? t("common.loading")}
|
||||||
{account?.email ?? t("common.loading")}
|
</span>
|
||||||
</div>
|
{/* Reports what is actually configured. The old fixed "2FA
|
||||||
{/* Reports what is actually configured. The old fixed "2FA
|
active" reassured accounts that had no second factor at all. */}
|
||||||
active" reassured accounts that had no second factor at all. */}
|
<span className="mt-0.5 flex items-center gap-1 text-[10.5px] text-slate-400">
|
||||||
<div className="text-[10px] text-slate-400 flex items-center gap-1">
|
{account?.mfa_enrolled ? (
|
||||||
{account?.mfa_enrolled ? (
|
<>
|
||||||
<>
|
<ShieldCheck className="h-3 w-3 flex-none text-indigo-500" />
|
||||||
<ShieldCheck className="w-3 h-3 text-emerald-400 inline" />
|
{t("settings.mfaEnabled")}
|
||||||
{t("settings.mfaEnabled")}
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
<ShieldAlert className="h-3 w-3 flex-none text-amber-400" />
|
||||||
<ShieldAlert className="w-3 h-3 text-amber-400 inline" />
|
{t("settings.mfaDisabled")}
|
||||||
{t("settings.mfaDisabled")}
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</span>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Settings className="w-4 h-4 text-slate-400 hover:text-slate-200 transition-colors" />
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
title={t("settings.signOut")}
|
||||||
|
aria-label={t("settings.signOut")}
|
||||||
|
className="flex h-8 w-8 flex-none items-center justify-center rounded-[9px] border border-white/[0.14] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
|
||||||
|
>
|
||||||
|
<LogOut className="h-[15px] w-[15px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -195,25 +212,25 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
|
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
|
||||||
{/* Mobile Top Header */}
|
{/* Mobile Top Header */}
|
||||||
<div className="md:hidden flex items-center justify-between p-4 border-b border-slate-800 bg-slate-900/90 z-20 w-full fixed top-0 left-0">
|
<div className="fixed left-0 top-0 z-20 flex w-full items-center justify-between bg-dark-rail px-4 py-3 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.09)] md:hidden">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-white font-bold">
|
<div className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||||
<Mail className="w-4 h-4" />
|
λ
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-slate-100">LambdaMail</span>
|
<span className="font-medium text-slate-100">LambdaMail</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileOpen(!mobileOpen)}
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
aria-label={t("ui.toggleMenu")}
|
aria-label={t("ui.toggleMenu")}
|
||||||
aria-expanded={mobileOpen}
|
aria-expanded={mobileOpen}
|
||||||
className="p-2 rounded-lg bg-slate-800 text-slate-300 hover:text-white"
|
className="flex h-9 w-9 items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100"
|
||||||
>
|
>
|
||||||
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Sidebar */}
|
{/* Desktop rail */}
|
||||||
<aside className="hidden md:flex w-64 border-r border-slate-800/80 bg-slate-900/60 backdrop-blur-xl flex-col flex-shrink-0">
|
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
|
||||||
{SidebarContent}
|
{SidebarContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -225,7 +242,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: "-100%" }}
|
exit={{ x: "-100%" }}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
className="fixed inset-0 z-30 bg-slate-950 w-72 border-r border-slate-800 pt-16 md:hidden"
|
className="fixed inset-y-0 left-0 z-30 w-[280px] overflow-y-auto bg-dark-rail pt-14 shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:hidden"
|
||||||
>
|
>
|
||||||
{SidebarContent}
|
{SidebarContent}
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
@@ -233,7 +250,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<main className="flex-1 flex flex-col overflow-hidden pt-16 md:pt-0 bg-dark-bg relative">
|
<main className="relative flex flex-1 flex-col overflow-hidden bg-dark-bg pt-14 md:pt-0">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Download,
|
Download,
|
||||||
|
RefreshCw,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useI18n } from "../../../../../i18n/provider";
|
import { useI18n } from "../../../../../i18n/provider";
|
||||||
@@ -261,76 +262,97 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
{ key: "attachment", label: t("mail.attachmentsFilter"), count: counts.withAttachments },
|
{ key: "attachment", label: t("mail.attachmentsFilter"), count: counts.withAttachments },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
// The folder's own name, translated. The heading printed the URL segment
|
||||||
|
// capitalised, so an interface running in Portuguese still said "Inbox".
|
||||||
|
const FOLDER_LABELS: Record<string, string> = {
|
||||||
|
inbox: t("mail.inbox"),
|
||||||
|
sent: t("mail.sent"),
|
||||||
|
drafts: t("mail.drafts"),
|
||||||
|
archive: t("mail.archive"),
|
||||||
|
junk: t("mail.junk"),
|
||||||
|
trash: t("mail.trash"),
|
||||||
|
};
|
||||||
|
const folderTitle = FOLDER_LABELS[folder.toLowerCase()] ?? folder;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Messages List Column */}
|
{/* Messages List Column */}
|
||||||
<div
|
<div
|
||||||
className={`${selected ? "hidden md:flex" : "flex"} w-full md:w-[22rem] lg:w-96 border-r border-slate-800/80 flex-col bg-slate-900/40 backdrop-blur-md md:flex-shrink-0`}
|
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:w-[404px] md:flex-none`}
|
||||||
>
|
>
|
||||||
{/* Folder heading, with the counts that belong to the folder rather
|
{/* Folder heading, with the counts that belong to the folder rather
|
||||||
than to the page of it that happens to be loaded. */}
|
than to the page of it that happens to be loaded. */}
|
||||||
<div className="px-3.5 pt-3.5 flex items-baseline justify-between gap-2">
|
<div className="flex flex-col gap-3 px-4 pb-3 pt-4">
|
||||||
<h2 className="text-sm font-bold text-slate-100 capitalize truncate">{folder}</h2>
|
<div className="flex items-end justify-between gap-3">
|
||||||
<span className="text-[11px] text-slate-400 font-mono tabular-nums flex-shrink-0">
|
<div className="min-w-0">
|
||||||
{header.isFolderTotal
|
<h2 className="text-xl font-medium leading-tight text-slate-100">{folderTitle}</h2>
|
||||||
? t("mail.messagesInFolder", { count: header.count })
|
<div className="mt-1 text-xs tabular-nums text-slate-400">
|
||||||
: t("mail.searchMatches", { count: header.count })}
|
{header.isFolderTotal
|
||||||
{metrics.unread > 0 && (
|
? t("mail.messagesInFolder", { count: header.count })
|
||||||
<span className="text-indigo-300"> · {t("mail.unreadCount", { count: metrics.unread })}</span>
|
: t("mail.searchMatches", { count: header.count })}
|
||||||
)}
|
{metrics.unread > 0 && (
|
||||||
</span>
|
<span className="text-indigo-300"> · {t("mail.unreadCount", { count: metrics.unread })}</span>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void loadMessages(searchQuery)}
|
||||||
|
className="flex-none rounded-[9px]"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
<span>{t("common.refresh")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search Bar & Filter Chips */}
|
|
||||||
<div className="p-3.5 border-b border-slate-800 space-y-2.5">
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="w-4 h-4 absolute left-3 top-3 text-slate-400" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={t("mail.searchPlaceholder")}
|
placeholder={t("mail.searchPlaceholder")}
|
||||||
aria-label={t("common.search")}
|
aria-label={t("common.search")}
|
||||||
className="w-full pl-9 pr-3 py-2 text-xs rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
|
className="min-h-[38px] w-full rounded-xl bg-dark-panel py-2 pl-[34px] pr-3 text-[13px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 text-[11px] overflow-x-auto pb-0.5">
|
{/* Every chip carries its own number: two of the three used to filter
|
||||||
|
to a count nothing on screen disclosed. Chips wrap rather than
|
||||||
|
scroll, so a longer translation is never pushed out of reach. */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{chips.map((chip) => (
|
{chips.map((chip) => (
|
||||||
<button
|
<button
|
||||||
key={chip.label}
|
key={chip.label}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={activeFilter === chip.key}
|
aria-pressed={activeFilter === chip.key}
|
||||||
|
data-active={activeFilter === chip.key}
|
||||||
onClick={() => setActiveFilter(chip.key)}
|
onClick={() => setActiveFilter(chip.key)}
|
||||||
className={`px-2.5 py-1 rounded-lg font-medium transition-all whitespace-nowrap flex items-center gap-1.5 ${
|
className="lm-chip"
|
||||||
activeFilter === chip.key
|
|
||||||
? "bg-indigo-600 text-white shadow-sm"
|
|
||||||
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span>{chip.label}</span>
|
<span>{chip.label}</span>
|
||||||
{/* Every chip carries its own number: two of the three used to
|
<span className="tabular-nums opacity-70">{chip.count}</span>
|
||||||
filter to a count nothing on screen disclosed. */}
|
|
||||||
<span className="tabular-nums opacity-80">{chip.count}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message Items List */}
|
{/* Message Items List */}
|
||||||
<div className="flex-1 overflow-y-auto divide-y divide-slate-800/50">
|
<div className="flex flex-1 flex-col gap-[3px] overflow-y-auto px-2 pb-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-6 text-center text-xs text-slate-500 animate-pulse flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2 p-6 text-center text-xs text-slate-400">
|
||||||
<Clock className="w-4 h-4 text-indigo-400 animate-spin" />
|
<Clock className="h-4 w-4 animate-spin text-indigo-500" />
|
||||||
<span>{t("common.loading")}</span>
|
<span>{t("common.loading")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="p-4 text-xs text-rose-400 bg-rose-500/10 m-3 rounded-xl border border-rose-500/20">
|
<div className="m-2 rounded-xl bg-rose-900/60 p-3.5 text-xs leading-relaxed text-rose-200 shadow-edge">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : filteredMessages.length === 0 ? (
|
) : filteredMessages.length === 0 ? (
|
||||||
<div className="p-8 text-center text-xs text-slate-500 flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2 p-8 text-center text-xs text-slate-400">
|
||||||
<Inbox className="w-8 h-8 text-slate-600" />
|
<Inbox className="h-8 w-8 text-slate-600" strokeWidth={1.25} />
|
||||||
<span>{searchQuery ? t("mail.noMatches") : t("mail.noMessages")}</span>
|
<span>{searchQuery ? t("mail.noMatches") : t("mail.noMessages")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -340,53 +362,59 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
<button
|
<button
|
||||||
key={msg.uid}
|
key={msg.uid}
|
||||||
onClick={() => void openMessage(msg.uid)}
|
onClick={() => void openMessage(msg.uid)}
|
||||||
className={`w-full text-left p-3.5 transition-all duration-150 flex gap-3 relative ${
|
data-active={isSelected}
|
||||||
isSelected ? "bg-indigo-600/15" : "hover:bg-slate-800/30"
|
className="lm-row w-full text-left"
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isSelected && <span className="absolute left-0 top-0 bottom-0 w-[3px] bg-indigo-500" />}
|
|
||||||
{/* An unread row is marked by a dot in a reserved column, so
|
|
||||||
the subject does not shift sideways when it is read. */}
|
|
||||||
<span className="mt-1.5 w-2 flex-shrink-0">
|
|
||||||
{!msg.seen && <span className="block w-2 h-2 rounded-full bg-indigo-400" />}
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
className={`mt-0.5 w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-[10px] font-bold ${
|
className={`flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full text-xs ${
|
||||||
msg.seen
|
msg.seen
|
||||||
? "bg-slate-800 text-slate-400"
|
? "bg-dark-card text-slate-400"
|
||||||
: "bg-indigo-500/25 text-indigo-200 border border-indigo-500/30"
|
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||||
}`}
|
}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{initials(msg.from_display_name, msg.sender_address)}
|
{initials(msg.from_display_name, msg.sender_address)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="min-w-0 flex-1">
|
<span className="flex min-w-0 flex-1 flex-col gap-[3px]">
|
||||||
<span className="flex items-center justify-between gap-2 mb-0.5">
|
<span className="flex items-baseline gap-2">
|
||||||
<span
|
<span
|
||||||
className={`text-xs truncate ${
|
className={`min-w-0 truncate text-[13.5px] ${
|
||||||
msg.seen ? "text-slate-300 font-medium" : "text-white font-bold"
|
msg.seen ? "text-slate-300" : "text-slate-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{msg.from_display_name || msg.sender_address}
|
{msg.from_display_name || msg.sender_address}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-slate-500 font-mono flex-shrink-0 tabular-nums">
|
<span className="ml-auto flex-none text-[11px] tabular-nums text-slate-500">
|
||||||
{listTimestamp(msg.received_at, locale)}
|
{listTimestamp(msg.received_at, locale)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
{/* The subject wraps instead of being clipped - a truncated
|
||||||
|
subject is the one thing a mail list must not do. */}
|
||||||
<span
|
<span
|
||||||
className={`text-xs truncate flex items-center gap-1.5 mb-0.5 ${
|
className={`text-[13.5px] leading-snug ${
|
||||||
msg.seen ? "text-slate-300" : "font-semibold text-slate-100"
|
msg.seen ? "text-slate-300" : "text-slate-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate">{msg.subject || t("mail.noSubject")}</span>
|
{msg.subject || t("mail.noSubject")}
|
||||||
{msg.has_attachments && (
|
|
||||||
<Paperclip className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[11px] text-slate-500 truncate leading-relaxed">
|
<span className="line-clamp-2 text-xs leading-relaxed text-slate-400">
|
||||||
{msg.snippet}
|
{msg.snippet}
|
||||||
</span>
|
</span>
|
||||||
|
{msg.has_attachments && (
|
||||||
|
<span className="mt-px flex items-center gap-1.5">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-md bg-slate-800 px-2 py-0.5 text-[10.5px] text-slate-300">
|
||||||
|
<Paperclip className="h-2.5 w-2.5" />
|
||||||
|
{t("mail.attachments")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* An unread row is marked by a dot in a reserved column, so
|
||||||
|
the subject does not shift sideways when it is read. */}
|
||||||
|
<span className="mt-3 w-[7px] flex-none">
|
||||||
|
{!msg.seen && <span className="block h-[7px] w-[7px] rounded-full bg-indigo-500" />}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -396,9 +424,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message Reader Column */}
|
{/* Message Reader Column */}
|
||||||
<div
|
<div className={`${selected ? "flex" : "hidden md:flex"} flex-1 flex-col overflow-y-auto bg-dark-bg`}>
|
||||||
className={`${selected ? "flex" : "hidden md:flex"} flex-1 flex-col bg-dark-bg overflow-y-auto`}
|
|
||||||
>
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<motion.article
|
<motion.article
|
||||||
@@ -407,137 +433,140 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -8 }}
|
exit={{ opacity: 0, y: -8 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="w-full max-w-3xl mx-auto px-4 sm:px-6 py-5 space-y-5"
|
className="mx-auto flex w-full max-w-[760px] flex-col gap-5 px-5 pb-10 pt-6 sm:px-7"
|
||||||
>
|
>
|
||||||
{/* Toolbar: closing, replying and read state all in one row, so
|
{/* Toolbar: closing, replying and read state all in one row, so
|
||||||
the reader is not the only pane with no way out of itself. */}
|
the reader is not the only pane with no way out of itself. */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href={`/user/compose?replyTo=${encodeURIComponent(selected.from)}&subject=${encodeURIComponent(selected.subject)}`}
|
||||||
onClick={() => setSelected(null)}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-slate-800 bg-slate-900/70 px-2.5 py-1.5 text-xs text-slate-300 hover:text-white hover:border-slate-700 transition-colors"
|
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<Button variant="primary" size="sm">
|
||||||
<span>{t("mail.backToList")}</span>
|
<Reply className="h-3.5 w-3.5" />
|
||||||
</button>
|
<span>{t("mail.reply")}</span>
|
||||||
|
</Button>
|
||||||
<div className="flex items-center gap-2 text-xs">
|
</Link>
|
||||||
<button
|
{/* Forward carries the subject but no recipient: it used to
|
||||||
type="button"
|
prefill the original sender, which sends the message
|
||||||
onClick={() => {
|
straight back to whoever wrote it. */}
|
||||||
const nowSeen = !(selectedRow?.seen ?? true);
|
<Link href={`/user/compose?subject=${encodeURIComponent(selected.subject)}`}>
|
||||||
void setSeen(selected.uid, nowSeen, { persist: true });
|
<Button variant="secondary" size="sm">
|
||||||
}}
|
<Forward className="h-3.5 w-3.5" />
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-slate-800 bg-slate-900/70 px-2.5 py-1.5 text-slate-300 hover:text-white hover:border-slate-700 transition-colors"
|
<span>{t("mail.forward")}</span>
|
||||||
>
|
</Button>
|
||||||
<MailOpen className="w-3.5 h-3.5" />
|
</Link>
|
||||||
<span className="hidden sm:inline">
|
<Button
|
||||||
{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}
|
variant="secondary"
|
||||||
</span>
|
size="sm"
|
||||||
</button>
|
onClick={() => {
|
||||||
<Link
|
const nowSeen = !(selectedRow?.seen ?? true);
|
||||||
href={`/user/compose?replyTo=${encodeURIComponent(selected.from)}&subject=${encodeURIComponent(selected.subject)}`}
|
void setSeen(selected.uid, nowSeen, { persist: true });
|
||||||
>
|
}}
|
||||||
<Button variant="primary" size="sm">
|
>
|
||||||
<Reply className="w-3.5 h-3.5" />
|
<MailOpen className="h-3.5 w-3.5" />
|
||||||
<span>{t("mail.reply")}</span>
|
<span>{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
<Button
|
||||||
{/* Forward carries the subject but no recipient: it used to
|
variant="secondary"
|
||||||
prefill the original sender, which sends the message
|
size="sm"
|
||||||
straight back to whoever wrote it. */}
|
onClick={() => setSelected(null)}
|
||||||
<Link href={`/user/compose?subject=${encodeURIComponent(selected.subject)}`}>
|
className="ml-auto h-9 w-9 flex-none p-0"
|
||||||
<Button variant="secondary" size="sm">
|
title={t("mail.backToList")}
|
||||||
<Forward className="w-3.5 h-3.5" />
|
aria-label={t("mail.backToList")}
|
||||||
<span className="hidden sm:inline">{t("mail.forward")}</span>
|
>
|
||||||
</Button>
|
<X className="h-4 w-4" />
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header className="space-y-4">
|
<header className="flex flex-col gap-3.5">
|
||||||
<h1 className="text-lg md:text-xl font-bold text-white tracking-tight leading-snug break-words">
|
<h1 className="text-[25px] font-medium leading-tight tracking-[-0.015em] text-slate-100 [text-wrap:pretty]">
|
||||||
{selected.subject || t("mail.noSubject")}
|
{selected.subject || t("mail.noSubject")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="w-9 h-9 rounded-full bg-gradient-to-tr from-indigo-500/30 to-cyan-500/20 border border-indigo-500/30 text-indigo-200 flex items-center justify-center text-[11px] font-bold flex-shrink-0">
|
<div className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[13px] text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
|
||||||
{initials(selectedRow?.from_display_name ?? "", selected.from)}
|
{initials(selectedRow?.from_display_name ?? "", selected.from)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 text-xs">
|
<div className="flex min-w-0 flex-1 flex-col gap-[3px]">
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
||||||
<span className="font-semibold text-slate-100 break-all">{selected.from}</span>
|
<span className="break-all text-sm text-slate-100">{selected.from}</span>
|
||||||
<span className="text-[11px] text-slate-500 font-mono">
|
{selectedRow && selectedRow.size_bytes > 0 && (
|
||||||
{selected.date
|
<span className="text-[11px] tabular-nums text-slate-500">
|
||||||
? new Date(selected.date).toLocaleString(locale)
|
{formatBytes(selectedRow.size_bytes)}
|
||||||
: selectedRow
|
</span>
|
||||||
? new Date(selectedRow.received_at).toLocaleString(locale)
|
)}
|
||||||
: ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Recipients are labelled with translated headings; they
|
{/* Recipients are labelled with translated headings; they
|
||||||
used to be introduced by a Portuguese word regardless of
|
used to be introduced by a Portuguese word regardless of
|
||||||
the interface language. */}
|
the interface language. */}
|
||||||
{selected.to.length > 0 && (
|
{selected.to.length > 0 && (
|
||||||
<div className="text-[11px] text-slate-400 break-all">
|
<div className="break-words text-[12.5px] leading-relaxed text-slate-400">
|
||||||
<span className="text-slate-500">{t("ui.toRecipients")}: </span>
|
<span className="text-slate-500">{t("ui.toRecipients")} </span>
|
||||||
{selected.to.join(", ")}
|
{selected.to.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selected.cc.length > 0 && (
|
{selected.cc.length > 0 && (
|
||||||
<div className="text-[11px] text-slate-400 break-all">
|
<div className="break-words text-[12.5px] leading-relaxed text-slate-400">
|
||||||
<span className="text-slate-500">{t("ui.ccRecipients")}: </span>
|
<span className="text-slate-500">{t("ui.ccRecipients")} </span>
|
||||||
{selected.cc.join(", ")}
|
{selected.cc.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedRow && selectedRow.size_bytes > 0 && (
|
|
||||||
<div className="text-[11px] text-slate-500 font-mono">
|
|
||||||
{formatBytes(selectedRow.size_bytes)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="flex-none text-xs tabular-nums text-slate-400">
|
||||||
|
{selected.date
|
||||||
|
? new Date(selected.date).toLocaleString(locale)
|
||||||
|
: selectedRow
|
||||||
|
? new Date(selectedRow.received_at).toLocaleString(locale)
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{/* Attachment Download Chips */}
|
{/* Remote content banner, shown only when something was actually
|
||||||
{selected.attachments.length > 0 && (
|
held back rather than over every message. The wording is the
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
reassurance, not the colour: this is not an error. */}
|
||||||
|
{hasBlockedContent && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-xl bg-dark-card p-3 shadow-edge">
|
||||||
|
<ImageIcon className="h-4 w-4 flex-none text-indigo-400" />
|
||||||
|
<span className="min-w-[200px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
|
||||||
|
{t("mail.remoteContentBlocked")}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
|
||||||
|
>
|
||||||
|
{loadRemoteImages ? t("mail.imagesEnabled") : t("mail.loadImages")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attachment Download Chips */}
|
||||||
|
{selected.attachments.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.08em] text-slate-400">
|
||||||
|
{t("mail.attachments")}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
{selected.attachments.map((filename, idx) => (
|
{selected.attachments.map((filename, idx) => (
|
||||||
<a
|
<a
|
||||||
key={`${filename}-${idx}`}
|
key={`${filename}-${idx}`}
|
||||||
href={`/api/v1/mail/message/${selected.uid}/attachment/${encodeURIComponent(filename)}?folder=${encodeURIComponent(folder)}`}
|
href={`/api/v1/mail/message/${selected.uid}/attachment/${encodeURIComponent(filename)}?folder=${encodeURIComponent(folder)}`}
|
||||||
download={filename}
|
download={filename}
|
||||||
className="group max-w-full px-3 py-2 rounded-xl bg-slate-900/80 border border-slate-800 hover:border-indigo-500/60 hover:bg-slate-900 text-slate-200 flex items-center gap-2 transition-all"
|
className="group flex max-w-full items-center gap-2.5 rounded-xl bg-dark-card px-3 py-2.5 text-slate-200 shadow-edge transition-shadow hover:shadow-edge-accent"
|
||||||
>
|
>
|
||||||
<Paperclip className="w-3.5 h-3.5 text-indigo-400 flex-shrink-0" />
|
<Paperclip className="h-[18px] w-[18px] flex-none text-indigo-400" />
|
||||||
<span className="truncate font-mono text-[11px]">{filename}</span>
|
<span className="min-w-0 break-all text-[13px]">{filename}</span>
|
||||||
<Download className="w-3.5 h-3.5 text-slate-500 group-hover:text-indigo-300 flex-shrink-0" />
|
<Download className="h-4 w-4 flex-none text-slate-500 group-hover:text-indigo-300" />
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Remote content banner, shown only when something was actually
|
|
||||||
held back rather than over every message. */}
|
|
||||||
{hasBlockedContent && (
|
|
||||||
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/25 flex flex-wrap items-center justify-between gap-2 text-xs text-amber-200">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<ImageIcon className="w-4 h-4 text-amber-400 flex-shrink-0" />
|
|
||||||
<span>{t("mail.remoteContentBlocked")}</span>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
|
|
||||||
className="px-3 py-1 rounded-lg bg-amber-500/20 hover:bg-amber-500/30 text-amber-100 font-medium transition-colors"
|
|
||||||
>
|
|
||||||
{loadRemoteImages ? t("mail.imagesEnabled") : t("mail.loadImages")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Message Body */}
|
{/* Message Body */}
|
||||||
{readerDocument ? (
|
{readerDocument ? (
|
||||||
<div className="bg-white rounded-2xl overflow-hidden border border-slate-800 shadow-xl">
|
<div className="overflow-hidden rounded-2xl bg-white shadow-edge">
|
||||||
<iframe
|
<iframe
|
||||||
title={t("ui.messageBody")}
|
title={t("ui.messageBody")}
|
||||||
srcDoc={readerDocument}
|
srcDoc={readerDocument}
|
||||||
@@ -549,12 +578,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
style={{ height: `${readerHeight}px` }}
|
style={{ height: `${readerHeight}px` }}
|
||||||
className="w-full border-0 block"
|
className="block w-full border-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-5">
|
<div className="rounded-2xl bg-dark-panel px-6 py-6 shadow-edge">
|
||||||
<pre className="whitespace-pre-wrap break-words text-sm text-slate-200 font-sans leading-relaxed">
|
<pre className="whitespace-pre-wrap break-words font-sans text-[14.5px] leading-relaxed text-slate-200">
|
||||||
{selected.text || t("mail.emptyBody")}
|
{selected.text || t("mail.emptyBody")}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -564,9 +593,9 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
className="flex-1 flex flex-col items-center justify-center text-slate-500 text-sm gap-3 py-20 px-6 text-center"
|
className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-20 text-center text-sm text-slate-400"
|
||||||
>
|
>
|
||||||
<Mail className="w-12 h-12 text-slate-700 stroke-1" />
|
<Mail className="h-12 w-12 text-slate-600" strokeWidth={1} />
|
||||||
{/* This pane used to say "No messages" even with a full list
|
{/* This pane used to say "No messages" even with a full list
|
||||||
beside it: the empty reader is not an empty folder. */}
|
beside it: the empty reader is not an empty folder. */}
|
||||||
<span>{t("mail.selectAMessage")}</span>
|
<span>{t("mail.selectAMessage")}</span>
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Plus,
|
Plus,
|
||||||
Lock,
|
Lock,
|
||||||
|
Languages,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { Card } from "../../../../components/ui/Card";
|
|
||||||
import { Badge } from "../../../../components/ui/Badge";
|
import { Badge } from "../../../../components/ui/Badge";
|
||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
||||||
|
import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
|
||||||
|
|
||||||
interface SieveRule {
|
interface SieveRule {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,8 +45,11 @@ interface WebSession {
|
|||||||
current: boolean;
|
current: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SettingsTab = "security" | "filters" | "vacation" | "signature" | "language";
|
||||||
|
|
||||||
export default function UserSettingsPage() {
|
export default function UserSettingsPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsTab>("security");
|
||||||
|
|
||||||
const [mfaEnabled, setMfaEnabled] = useState(false);
|
const [mfaEnabled, setMfaEnabled] = useState(false);
|
||||||
const [qrSecret, setQrSecret] = useState<string | null>(null);
|
const [qrSecret, setQrSecret] = useState<string | null>(null);
|
||||||
@@ -241,396 +245,551 @@ export default function UserSettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* The screen was one long scroll of eight panels, so the two things people
|
||||||
|
come here to do - turn on a second factor, and write a filter - were
|
||||||
|
separated by everything else. The same panels now sit behind a tab strip,
|
||||||
|
in the order the redesign groups them. */
|
||||||
|
const tabs = [
|
||||||
|
{ id: "security" as const, label: t("settings.security"), icon: Shield },
|
||||||
|
{ id: "filters" as const, label: t("settings.sieveTitle"), icon: Filter },
|
||||||
|
{ id: "vacation" as const, label: t("settings.vacationTitle"), icon: Palmtree },
|
||||||
|
{ id: "signature" as const, label: t("settings.signatureTitle"), icon: FileText },
|
||||||
|
{ id: "language" as const, label: t("settings.language"), icon: Languages },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 panel = "flex flex-col gap-4 rounded-2xl bg-dark-panel p-5 shadow-edge";
|
||||||
|
const panelTitle = "text-[17px] font-medium leading-tight text-slate-100";
|
||||||
|
const panelIntro = "mt-1 text-[13px] leading-relaxed text-slate-400";
|
||||||
|
const tile = "flex flex-wrap items-center gap-3 rounded-xl bg-dark-card px-3.5 py-3 text-[13px] shadow-edge";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 p-6 md:p-8 bg-dark-bg overflow-y-auto">
|
<div className="flex-1 overflow-y-auto bg-dark-bg px-5 pb-11 pt-7 sm:px-8">
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="mx-auto flex w-full max-w-[900px] flex-col gap-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("settings.title")}</h1>
|
||||||
{t("settings.title")}
|
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("settings.subtitle")}</p>
|
||||||
</h1>
|
</div>
|
||||||
<p className="text-sm text-slate-400 mt-1">{t("settings.subtitle")}</p>
|
|
||||||
|
{/* Tabs wrap rather than scroll, so a longer translation never pushes
|
||||||
|
one out of reach. */}
|
||||||
|
<div className="lm-tabstrip self-start">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
data-active={activeTab === tab.id}
|
||||||
|
aria-pressed={activeTab === tab.id}
|
||||||
|
className="lm-tab"
|
||||||
|
>
|
||||||
|
<Icon className="h-[15px] w-[15px] flex-none" />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className="p-4 rounded-xl bg-indigo-500/15 border border-indigo-500/30 text-indigo-300 text-xs flex items-center gap-2">
|
<div className="flex items-center gap-2 rounded-xl bg-dark-card px-4 py-3 text-[12.5px] text-slate-200 shadow-edge">
|
||||||
<CheckCircle2 className="w-4 h-4 text-indigo-400" />
|
<CheckCircle2 className="h-4 w-4 flex-none text-indigo-500" />
|
||||||
<span>{message}</span>
|
<span>{message}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 2FA TOTP SECTION */}
|
{activeTab === "security" && (
|
||||||
<Card className="space-y-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
{/* 2FA TOTP SECTION */}
|
||||||
<div>
|
<section className={panel}>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<Shield className="w-5 h-5 text-indigo-400" />
|
<div className="min-w-[260px] flex-1">
|
||||||
{t("ui.twoFactorSection")}
|
<h2 className={panelTitle}>{t("ui.twoFactorSection")}</h2>
|
||||||
</h2>
|
<p className={panelIntro}>{t("settings.twoFactorIntro")}</p>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">{t("settings.twoFactorIntro")}</p>
|
</div>
|
||||||
</div>
|
<Badge variant={mfaEnabled ? "success" : "warning"} className="flex-none">
|
||||||
<Badge variant={mfaEnabled ? "success" : "warning"}>
|
{mfaEnabled ? t("settings.mfaEnabled") : t("settings.mfaDisabled")}
|
||||||
{mfaEnabled ? t("settings.mfaEnabled") : t("settings.mfaDisabled")}
|
</Badge>
|
||||||
</Badge>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{!mfaEnabled && !qrSecret && (
|
{!mfaEnabled && !qrSecret && (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={async () => {
|
className="self-start"
|
||||||
const res = await fetch("/api/v1/user/mfa/totp/enroll", { method: "POST" });
|
onClick={async () => {
|
||||||
const data = await res.json();
|
const res = await fetch("/api/v1/user/mfa/totp/enroll", { method: "POST" });
|
||||||
setQrSecret(data.secret);
|
const data = await res.json();
|
||||||
setQrUri(data.uri);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{t("settings.enableMfa")}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{qrSecret && (
|
|
||||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800 space-y-4 text-xs">
|
|
||||||
<TotpEnrolment secret={qrSecret} uri={qrUri} />
|
|
||||||
{/* Enrolment resumes rather than restarting, so this code is the
|
|
||||||
same one already scanned. Anyone who removed the entry from
|
|
||||||
their app needs a way to ask for a different secret. */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
const res = await fetch("/api/v1/user/mfa/totp/enroll?reset=1", { method: "POST" });
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (res.ok) {
|
|
||||||
setQrSecret(data.secret);
|
setQrSecret(data.secret);
|
||||||
setQrUri(data.uri);
|
setQrUri(data.uri);
|
||||||
setTotpCode("");
|
}}
|
||||||
}
|
>
|
||||||
}}
|
<span>{t("settings.enableMfa")}</span>
|
||||||
className="text-[11px] text-slate-400 underline hover:text-slate-200"
|
</Button>
|
||||||
>
|
)}
|
||||||
{t("settings.startOverMfa")}
|
|
||||||
</button>
|
{qrSecret && (
|
||||||
<form
|
<div className="flex flex-col gap-4 rounded-xl bg-dark-card p-4 text-xs shadow-edge">
|
||||||
onSubmit={async (e) => {
|
<TotpEnrolment secret={qrSecret} uri={qrUri} />
|
||||||
e.preventDefault();
|
{/* Enrolment resumes rather than restarting, so this code is the
|
||||||
const res = await fetch("/api/v1/user/mfa/totp/confirm", {
|
same one already scanned. Anyone who removed the entry from
|
||||||
method: "POST",
|
their app needs a way to ask for a different secret. */}
|
||||||
headers: { "Content-Type": "application/json" },
|
<button
|
||||||
body: JSON.stringify({ secret: qrSecret, code: totpCode }),
|
type="button"
|
||||||
});
|
onClick={async () => {
|
||||||
const data = await res.json().catch(() => ({}));
|
const res = await fetch("/api/v1/user/mfa/totp/enroll?reset=1", { method: "POST" });
|
||||||
if (res.ok) {
|
const data = await res.json().catch(() => ({}));
|
||||||
setMfaEnabled(true);
|
if (res.ok) {
|
||||||
setRecoveryCodes(data.recovery_codes ?? []);
|
setQrSecret(data.secret);
|
||||||
setQrSecret(null);
|
setQrUri(data.uri);
|
||||||
setTotpCode("");
|
setTotpCode("");
|
||||||
} else {
|
}
|
||||||
setMessage(data.message ?? t("auth.verificationFailed"));
|
}}
|
||||||
}
|
className="self-start text-[11.5px] text-slate-400 underline underline-offset-4 hover:text-slate-200"
|
||||||
}}
|
>
|
||||||
className="pt-2 flex items-center gap-3"
|
{t("settings.startOverMfa")}
|
||||||
>
|
</button>
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const res = await fetch("/api/v1/user/mfa/totp/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ secret: qrSecret, code: totpCode }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
setMfaEnabled(true);
|
||||||
|
setRecoveryCodes(data.recovery_codes ?? []);
|
||||||
|
setQrSecret(null);
|
||||||
|
setTotpCode("");
|
||||||
|
} else {
|
||||||
|
setMessage(data.message ?? t("auth.verificationFailed"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex flex-wrap items-center gap-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
|
placeholder={t("settings.sixDigitCode")}
|
||||||
|
maxLength={7}
|
||||||
|
required
|
||||||
|
className="min-h-[36px] w-[150px] rounded-[10px] bg-dark-rail px-3 py-2 text-center font-mono text-[13.5px] tracking-[0.2em] text-slate-100 shadow-edge focus:outline-none focus-visible:shadow-edge-accent"
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="primary" size="sm">
|
||||||
|
{t("common.confirm")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recoveryCodes && (
|
||||||
|
<RecoveryCodes
|
||||||
|
codes={recoveryCodes}
|
||||||
|
onContinue={() => setRecoveryCodes(null)}
|
||||||
|
continueLabel={t("common.continue")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* APP PASSWORDS */}
|
||||||
|
<section className={panel}>
|
||||||
|
<div>
|
||||||
|
<h2 className={panelTitle}>{t("ui.appPasswordsSection")}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generatedAppPass && (
|
||||||
|
<div className="flex flex-col gap-2 rounded-xl bg-dark-card p-4 text-xs shadow-edge">
|
||||||
|
<div className="text-slate-100">{t("ui.appPasswordGenerated")}</div>
|
||||||
|
<code className="lm-code block break-all p-2.5 text-sm">{generatedAppPass}</code>
|
||||||
|
<div className="text-[11px] text-amber-400">{t("ui.copyPasswordNow")}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={createAppPassword} className="flex flex-wrap gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
value={newAppPassLabel}
|
||||||
autoComplete="one-time-code"
|
onChange={(e) => setNewAppPassLabel(e.target.value)}
|
||||||
value={totpCode}
|
placeholder={t("settings.appPasswordPlaceholder")}
|
||||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
className={`${input} min-w-[220px] flex-1`}
|
||||||
placeholder={t("settings.sixDigitCode")}
|
|
||||||
maxLength={7}
|
|
||||||
required
|
|
||||||
className="px-4 py-2 rounded-xl bg-slate-950 border border-slate-800 text-white text-xs font-mono text-center focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variant="primary" size="sm">
|
<Button type="submit" variant="primary" size="md" className="flex-none">
|
||||||
{t("common.confirm")}
|
{t("settings.generateAppPassword")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{recoveryCodes && (
|
<div className="flex flex-col gap-2">
|
||||||
<RecoveryCodes
|
{appPasswords.length === 0 ? (
|
||||||
codes={recoveryCodes}
|
<div className="text-[13px] text-slate-400">{t("ui.noAppPasswords")}</div>
|
||||||
onContinue={() => setRecoveryCodes(null)}
|
|
||||||
continueLabel={t("common.continue")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* VISUAL SIEVE RULES BUILDER */}
|
|
||||||
<Card className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
|
||||||
<Filter className="w-5 h-5 text-indigo-400" />
|
|
||||||
{t("settings.sieveTitle")}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-slate-400 mt-0.5">{t("settings.sieveIntro")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={addSieveRule} className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-3 text-xs bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
|
||||||
<select
|
|
||||||
value={newField}
|
|
||||||
onChange={(e) => setNewField(e.target.value as "From" | "Subject" | "Header")}
|
|
||||||
className="px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
>
|
|
||||||
<option value="Subject">{t("settings.fieldSubject")}</option>
|
|
||||||
<option value="From">{t("settings.fieldFrom")}</option>
|
|
||||||
<option value="Header">{t("settings.fieldHeader")}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={newMatch}
|
|
||||||
onChange={(e) => setNewMatch(e.target.value as "contains" | "equals" | "matches")}
|
|
||||||
className="px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
>
|
|
||||||
<option value="contains">{t("settings.matchContains")}</option>
|
|
||||||
<option value="equals">{t("settings.matchEquals")}</option>
|
|
||||||
<option value="matches">{t("settings.matchRegex")}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newValue}
|
|
||||||
onChange={(e) => setNewValue(e.target.value)}
|
|
||||||
placeholder={t("settings.valuePlaceholder")}
|
|
||||||
required
|
|
||||||
className="px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={newAction}
|
|
||||||
onChange={(e) => setNewAction(e.target.value as "move" | "flag" | "discard" | "redirect")}
|
|
||||||
className="px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
>
|
|
||||||
<option value="move">{t("settings.actionMove")}</option>
|
|
||||||
<option value="flag">{t("settings.actionFlag")}</option>
|
|
||||||
<option value="discard">{t("settings.actionDiscard")}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<Button type="submit" variant="primary" size="sm" className="w-full">
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span>{t("settings.addRule")}</span>
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="space-y-2 text-xs">
|
|
||||||
{rules.map((r) => (
|
|
||||||
<div key={r.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-900/80 border border-slate-800">
|
|
||||||
<span className="font-mono text-slate-200">
|
|
||||||
{t("settings.ruleIf")} <strong>{r.field}</strong> {r.match} "{r.value}" →{" "}
|
|
||||||
{t("settings.ruleThen")} {r.action.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => removeSieveRule(r.id)}
|
|
||||||
className="text-rose-400 hover:text-rose-300 transition-colors p-1"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* VACATION AUTO-RESPONDER */}
|
|
||||||
<Card className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
|
||||||
<Palmtree className="w-5 h-5 text-indigo-400" />
|
|
||||||
{t("settings.vacationTitle")}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-slate-400 mt-0.5">{t("settings.vacationIntro")}</p>
|
|
||||||
</div>
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={vacationEnabled}
|
|
||||||
onChange={(e) => setVacationEnabled(e.target.checked)}
|
|
||||||
className="sr-only peer"
|
|
||||||
/>
|
|
||||||
<div className="w-11 h-6 bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{vacationSaved && (
|
|
||||||
<div className="p-3.5 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-xs flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
|
||||||
<span>{t("settings.settingsSaved")}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{vacationEnabled && (
|
|
||||||
<form onSubmit={saveVacationResponder} className="space-y-3 text-xs">
|
|
||||||
<div>
|
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("settings.vacationSubjectLabel")}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={vacationSubject}
|
|
||||||
onChange={(e) => setVacationSubject(e.target.value)}
|
|
||||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("settings.vacationBodyLabel")}</label>
|
|
||||||
<textarea
|
|
||||||
rows={4}
|
|
||||||
value={vacationBody}
|
|
||||||
onChange={(e) => setVacationBody(e.target.value)}
|
|
||||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" variant="primary" size="sm">
|
|
||||||
{t("settings.saveAutoReply")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* EMAIL SIGNATURE MANAGER */}
|
|
||||||
<Card className="space-y-4 text-xs">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
|
||||||
<FileText className="w-5 h-5 text-indigo-400" />
|
|
||||||
{t("settings.signatureTitle")}
|
|
||||||
</h2>
|
|
||||||
<p className="text-slate-400 mt-0.5">{t("settings.signatureIntro")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sigSaved && (
|
|
||||||
<div className="p-3.5 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
|
||||||
<span>{t("settings.signatureSaved")}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
rows={4}
|
|
||||||
value={signature}
|
|
||||||
onChange={(e) => setSignature(e.target.value)}
|
|
||||||
placeholder={t("settings.signaturePlaceholder")}
|
|
||||||
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-indigo-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button onClick={saveSignature} variant="primary" size="sm">
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* APP PASSWORDS */}
|
|
||||||
<Card className="space-y-4">
|
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
|
||||||
<KeyRound className="w-5 h-5 text-indigo-400" />
|
|
||||||
{t("ui.appPasswordsSection")}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{generatedAppPass && (
|
|
||||||
<div className="p-4 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-xs space-y-1">
|
|
||||||
<div className="font-bold">{t("ui.appPasswordGenerated")}</div>
|
|
||||||
<code className="block font-mono text-sm text-white break-all bg-slate-950 p-2 rounded-lg border border-slate-800">
|
|
||||||
{generatedAppPass}
|
|
||||||
</code>
|
|
||||||
<div className="text-[10px] text-emerald-400">{t("ui.copyPasswordNow")}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={createAppPassword} className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newAppPassLabel}
|
|
||||||
onChange={(e) => setNewAppPassLabel(e.target.value)}
|
|
||||||
placeholder={t("settings.appPasswordPlaceholder")}
|
|
||||||
className="flex-1 px-3.5 py-2 text-xs rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
<Button type="submit" variant="primary" size="sm">
|
|
||||||
{t("settings.generateAppPassword")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{appPasswords.length === 0 ? (
|
|
||||||
<div className="text-xs text-slate-500 italic">{t("ui.noAppPasswords")}</div>
|
|
||||||
) : (
|
|
||||||
appPasswords.map((ap) => (
|
|
||||||
<div key={ap.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-900/80 border border-slate-800 text-xs">
|
|
||||||
<div>
|
|
||||||
<span className="font-bold text-slate-200">{ap.label}</span>
|
|
||||||
<span className="text-[10px] text-slate-500 ml-2">
|
|
||||||
{ap.created_at ? new Date(ap.created_at).toLocaleDateString() : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={() => void revokeAppPassword(ap.id)} className="text-rose-400 hover:text-rose-300 p-1">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ACTIVE SESSIONS */}
|
|
||||||
<Card className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
|
||||||
<Monitor className="w-5 h-5 text-indigo-400" />
|
|
||||||
{t("ui.activeSessions")}
|
|
||||||
</h2>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => void revokeOtherSessions()}>
|
|
||||||
{t("ui.signOutOthers")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sessions.map((session) => (
|
|
||||||
<div key={session.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-900/80 border border-slate-800 text-xs">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-slate-200 truncate font-mono">
|
|
||||||
{session.ip_address ?? "-"}
|
|
||||||
<span className="text-[10px] text-slate-500 ml-2 uppercase font-sans font-bold">{session.surface}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-slate-500 truncate">{session.user_agent ?? ""}</div>
|
|
||||||
</div>
|
|
||||||
{session.current ? (
|
|
||||||
<Badge variant="success">{t("ui.currentSession")}</Badge>
|
|
||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={() => void revokeSession(session.id)} className="text-rose-400 hover:text-rose-300 p-1">
|
appPasswords.map((ap) => (
|
||||||
<Trash2 className="w-4 h-4" />
|
<div key={ap.id} className={tile}>
|
||||||
</button>
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="break-words text-[13.5px] text-slate-100">{ap.label}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-slate-400">
|
||||||
|
{ap.created_at ? new Date(ap.created_at).toLocaleDateString() : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void revokeAppPassword(ap.id)}
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
className="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-rose-400"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-[15px] w-[15px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</section>
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* PASSWORD CHANGE */}
|
{/* ACTIVE SESSIONS */}
|
||||||
<Card className="space-y-4">
|
<section className={panel}>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<Lock className="w-5 h-5 text-indigo-400" />
|
<h2 className={panelTitle}>{t("ui.activeSessions")}</h2>
|
||||||
{t("ui.changePassword")}
|
<Button variant="secondary" size="sm" onClick={() => void revokeOtherSessions()}>
|
||||||
</h2>
|
{t("ui.signOutOthers")}
|
||||||
<form onSubmit={changePassword} className="space-y-3">
|
</Button>
|
||||||
<input
|
</div>
|
||||||
type="password"
|
|
||||||
value={currentPassword}
|
<div className="flex flex-col gap-2">
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
{sessions.map((session) => (
|
||||||
placeholder={t("ui.currentPassword")}
|
<div key={session.id} className={tile}>
|
||||||
required
|
<Monitor className="h-5 w-5 flex-none text-slate-400" />
|
||||||
className="w-full px-3.5 py-2.5 text-xs rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
|
<div className="min-w-0 flex-1">
|
||||||
/>
|
<div className="break-all font-mono text-[13px] text-slate-100">
|
||||||
<input
|
{session.ip_address ?? "-"}
|
||||||
type="password"
|
<span className="ml-2 font-sans text-[10.5px] uppercase tracking-[0.08em] text-slate-400">
|
||||||
value={newPassword}
|
{session.surface}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
</span>
|
||||||
placeholder={t("ui.newPassword")}
|
</div>
|
||||||
minLength={12}
|
<div className="mt-0.5 break-all text-[11.5px] text-slate-400">{session.user_agent ?? ""}</div>
|
||||||
required
|
</div>
|
||||||
className="w-full px-3.5 py-2.5 text-xs rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
|
{session.current ? (
|
||||||
/>
|
<Badge variant="info" className="flex-none">
|
||||||
<Button type="submit" variant="primary" size="sm">
|
{t("ui.currentSession")}
|
||||||
{t("common.save")}
|
</Badge>
|
||||||
</Button>
|
) : (
|
||||||
</form>
|
<button
|
||||||
</Card>
|
type="button"
|
||||||
|
onClick={() => void revokeSession(session.id)}
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
className="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-rose-400"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-[15px] w-[15px]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* PASSWORD CHANGE */}
|
||||||
|
<section className={panel}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Lock className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||||
|
<h2 className={panelTitle}>{t("ui.changePassword")}</h2>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={changePassword} className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="current-password" className={fieldLabel}>
|
||||||
|
{t("ui.currentPassword")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="current-password"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className={input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="new-password" className={fieldLabel}>
|
||||||
|
{t("ui.newPassword")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
minLength={12}
|
||||||
|
required
|
||||||
|
className={input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="primary" size="md" className="self-start">
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* VISUAL SIEVE RULES BUILDER */}
|
||||||
|
{activeTab === "filters" && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<section className={panel}>
|
||||||
|
<div>
|
||||||
|
<h2 className={panelTitle}>{t("settings.sieveTitle")}</h2>
|
||||||
|
<p className={panelIntro}>{t("settings.sieveIntro")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The rule reads as a sentence: IF <field> <match> <value> THEN
|
||||||
|
<action>. Each control keeps its own label, so the row still
|
||||||
|
makes sense when it wraps on a narrow window. */}
|
||||||
|
<form onSubmit={addSieveRule} className="flex flex-wrap items-end gap-2.5">
|
||||||
|
<div className="pb-2.5 text-xs uppercase tracking-[0.08em] text-slate-400">
|
||||||
|
{t("settings.ruleIf")}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[150px] flex-1">
|
||||||
|
<label htmlFor="rule-field" className={fieldLabel}>
|
||||||
|
{t("ui.eventAction")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rule-field"
|
||||||
|
value={newField}
|
||||||
|
onChange={(e) => setNewField(e.target.value as "From" | "Subject" | "Header")}
|
||||||
|
className={input}
|
||||||
|
>
|
||||||
|
<option value="Subject">{t("settings.fieldSubject")}</option>
|
||||||
|
<option value="From">{t("settings.fieldFrom")}</option>
|
||||||
|
<option value="Header">{t("settings.fieldHeader")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[150px] flex-1">
|
||||||
|
<label htmlFor="rule-match" className={fieldLabel}>
|
||||||
|
{t("common.status")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rule-match"
|
||||||
|
value={newMatch}
|
||||||
|
onChange={(e) => setNewMatch(e.target.value as "contains" | "equals" | "matches")}
|
||||||
|
className={input}
|
||||||
|
>
|
||||||
|
<option value="contains">{t("settings.matchContains")}</option>
|
||||||
|
<option value="equals">{t("settings.matchEquals")}</option>
|
||||||
|
<option value="matches">{t("settings.matchRegex")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[200px] flex-[2]">
|
||||||
|
<label htmlFor="rule-value" className={fieldLabel}>
|
||||||
|
{t("settings.valuePlaceholder")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="rule-value"
|
||||||
|
type="text"
|
||||||
|
value={newValue}
|
||||||
|
onChange={(e) => setNewValue(e.target.value)}
|
||||||
|
placeholder={t("settings.valuePlaceholder")}
|
||||||
|
required
|
||||||
|
className={input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pb-2.5 text-xs uppercase tracking-[0.08em] text-slate-400">
|
||||||
|
{t("settings.ruleThen")}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[170px] flex-1">
|
||||||
|
<label htmlFor="rule-action" className={fieldLabel}>
|
||||||
|
{t("ui.actions")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rule-action"
|
||||||
|
value={newAction}
|
||||||
|
onChange={(e) => setNewAction(e.target.value as "move" | "flag" | "discard" | "redirect")}
|
||||||
|
className={input}
|
||||||
|
>
|
||||||
|
<option value="move">{t("settings.actionMove")}</option>
|
||||||
|
<option value="flag">{t("settings.actionFlag")}</option>
|
||||||
|
<option value="discard">{t("settings.actionDiscard")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" variant="primary" size="md" className="flex-none">
|
||||||
|
<Plus className="h-[15px] w-[15px]" />
|
||||||
|
<span>{t("settings.addRule")}</span>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{rules.length > 0 && (
|
||||||
|
<section className={panel}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{rules.map((r, index) => (
|
||||||
|
<div key={r.id} className={tile}>
|
||||||
|
<span className="flex h-[22px] w-[22px] flex-none items-center justify-center rounded-[7px] bg-indigo-900 text-[11px] text-indigo-300">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-[200px] flex-1 break-words font-mono text-[13px] leading-relaxed text-slate-200">
|
||||||
|
{t("settings.ruleIf")} <strong className="font-medium">{r.field}</strong> {r.match} "
|
||||||
|
{r.value}" → {t("settings.ruleThen")} {r.action.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeSieveRule(r.id)}
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
className="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-rose-400"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-[15px] w-[15px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* VACATION AUTO-RESPONDER */}
|
||||||
|
{activeTab === "vacation" && (
|
||||||
|
<section className={panel}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="min-w-[240px] flex-1">
|
||||||
|
<h2 className={panelTitle}>{t("settings.vacationTitle")}</h2>
|
||||||
|
<p className={panelIntro}>{t("settings.vacationIntro")}</p>
|
||||||
|
</div>
|
||||||
|
{/* A segmented pair rather than an unlabelled switch: a toggle
|
||||||
|
with nothing written on it says neither which state it is in
|
||||||
|
nor what turning it on would do. */}
|
||||||
|
<div className="flex flex-none gap-[3px] rounded-[10px] bg-dark-card p-[3px] shadow-edge">
|
||||||
|
{[
|
||||||
|
{ on: true, label: t("ui.active") },
|
||||||
|
{ on: false, label: t("settings.mfaDisabled") },
|
||||||
|
].map((option) => (
|
||||||
|
<button
|
||||||
|
key={String(option.on)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVacationEnabled(option.on)}
|
||||||
|
aria-pressed={vacationEnabled === option.on}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-[12.5px] leading-snug transition-colors ${
|
||||||
|
vacationEnabled === option.on
|
||||||
|
? "text-indigo-500 shadow-edge-accent"
|
||||||
|
: "text-slate-400 hover:text-slate-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vacationSaved && (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl bg-dark-card px-3.5 py-3 text-[12.5px] text-slate-200 shadow-edge">
|
||||||
|
<CheckCircle2 className="h-4 w-4 flex-none text-indigo-500" />
|
||||||
|
<span>{t("settings.settingsSaved")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vacationEnabled && (
|
||||||
|
<form onSubmit={saveVacationResponder} className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="vacation-subject" className={fieldLabel}>
|
||||||
|
{t("settings.vacationSubjectLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="vacation-subject"
|
||||||
|
type="text"
|
||||||
|
value={vacationSubject}
|
||||||
|
onChange={(e) => setVacationSubject(e.target.value)}
|
||||||
|
className={input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="vacation-body" className={fieldLabel}>
|
||||||
|
{t("settings.vacationBodyLabel")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="vacation-body"
|
||||||
|
rows={5}
|
||||||
|
value={vacationBody}
|
||||||
|
onChange={(e) => setVacationBody(e.target.value)}
|
||||||
|
className={`${input} leading-relaxed`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" variant="primary" size="md" className="self-start">
|
||||||
|
{t("settings.saveAutoReply")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* EMAIL SIGNATURE MANAGER */}
|
||||||
|
{activeTab === "signature" && (
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<section className={`${panel} min-w-[320px] flex-1`}>
|
||||||
|
<div>
|
||||||
|
<h2 className={panelTitle}>{t("settings.signatureTitle")}</h2>
|
||||||
|
<p className={panelIntro}>{t("settings.signatureIntro")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sigSaved && (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl bg-dark-card px-3.5 py-3 text-[12.5px] text-slate-200 shadow-edge">
|
||||||
|
<CheckCircle2 className="h-4 w-4 flex-none text-indigo-500" />
|
||||||
|
<span>{t("settings.signatureSaved")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="signature-text" className={fieldLabel}>
|
||||||
|
{t("ui.messageBody")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="signature-text"
|
||||||
|
rows={7}
|
||||||
|
value={signature}
|
||||||
|
onChange={(e) => setSignature(e.target.value)}
|
||||||
|
placeholder={t("settings.signaturePlaceholder")}
|
||||||
|
className={`${input} leading-relaxed`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={saveSignature} variant="primary" size="md" className="self-start">
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* What the signature will actually look like under a reply, so it
|
||||||
|
is not written blind into a textarea. */}
|
||||||
|
<section className={`${panel} min-w-[280px] flex-1`}>
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.08em] text-slate-400">
|
||||||
|
{t("admin.csvPreview")}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-dark-card p-4 text-[13.5px] leading-relaxed text-slate-300">
|
||||||
|
<div className="lm-rule my-2.5" />
|
||||||
|
<pre className="whitespace-pre-wrap break-words font-sans">
|
||||||
|
{signature || t("settings.signaturePlaceholder")}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "language" && (
|
||||||
|
<section className={panel}>
|
||||||
|
<div>
|
||||||
|
<h2 className={panelTitle}>{t("settings.language")}</h2>
|
||||||
|
<p className={panelIntro}>{t("settings.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-[280px]">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,60 +1,286 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nocturne — the one visual language for both surfaces.
|
||||||
|
*
|
||||||
|
* The webmail used to be indigo over glass and the console green over glass,
|
||||||
|
* so the two halves of the same product did not look related. Everything below
|
||||||
|
* comes from the Nocturne design system: a near-neutral blue-grey ground, Inter
|
||||||
|
* at weight 500 for headings, 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. Tailwind's slate/indigo/emerald scales are remapped onto these
|
||||||
|
* same values in tailwind.config.js, so a utility class and a variable here
|
||||||
|
* cannot drift apart.
|
||||||
|
*/
|
||||||
:root {
|
:root {
|
||||||
--bg-main: #090d16;
|
/* Grounds, darkest to lightest: the rail, the page, a panel, a card. */
|
||||||
--bg-panel: rgba(15, 23, 42, 0.85);
|
--lm-rail: #12131f;
|
||||||
--bg-card: rgba(30, 41, 59, 0.65);
|
--lm-bg: #161826;
|
||||||
--border-color: rgba(255, 255, 255, 0.08);
|
--lm-panel: #1b1c28;
|
||||||
|
--lm-surface: #232532;
|
||||||
|
|
||||||
|
--lm-text: #e9e9ed;
|
||||||
|
--lm-text-muted: rgba(233, 233, 237, 0.62);
|
||||||
|
|
||||||
|
/* One accent, in the product's own blurple. */
|
||||||
|
--lm-accent: #9184d9;
|
||||||
|
--lm-accent-pale: #d2cefd;
|
||||||
|
|
||||||
|
/* Edges are drawn as an inset ring, not a border, so nothing shifts by 1px
|
||||||
|
when a surface becomes active. */
|
||||||
|
--lm-edge: rgba(233, 233, 237, 0.09);
|
||||||
|
--lm-edge-strong: rgba(233, 233, 237, 0.16);
|
||||||
|
--lm-divider: rgba(233, 233, 237, 0.14);
|
||||||
|
|
||||||
|
--lm-radius-md: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg-main);
|
background-color: var(--lm-bg);
|
||||||
color: #f8fafc;
|
color: var(--lm-text);
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Glassmorphism custom classes */
|
/* Hierarchy here is size and space; headings never go past weight 500. */
|
||||||
.glass-panel {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
background: var(--bg-panel);
|
font-weight: 500;
|
||||||
backdrop-filter: blur(16px);
|
letter-spacing: -0.015em;
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card {
|
/* Keyboard focus is themed everywhere rather than left to the browser. */
|
||||||
background: var(--bg-card);
|
:focus-visible {
|
||||||
backdrop-filter: blur(12px);
|
outline: 2px solid var(--lm-accent);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
outline-offset: 2px;
|
||||||
border: 1px solid var(--border-color);
|
}
|
||||||
|
::selection {
|
||||||
|
background: rgba(145, 132, 217, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card-hover {
|
/* ────────────────────────────────────────────────────────────────────────
|
||||||
transition: all 0.2s ease-in-out;
|
Surfaces
|
||||||
}
|
──────────────────────────────────────────────────────────────────────── */
|
||||||
.glass-card-hover:hover {
|
|
||||||
background: rgba(30, 41, 59, 0.85);
|
/** A code or DNS-record block: the darkest ground, monospace, accent ink. */
|
||||||
border-color: rgba(99, 102, 241, 0.3);
|
.lm-code {
|
||||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
background: var(--lm-rail);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--lm-edge);
|
||||||
|
border-radius: var(--lm-radius-md);
|
||||||
|
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--lm-accent-pale);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar Styling */
|
/**
|
||||||
|
* A rule that fades to transparent at both ends rather than stopping cleanly —
|
||||||
|
* the one Nocturne signature that has to be CSS, because Tailwind has no
|
||||||
|
* gradient border.
|
||||||
|
*/
|
||||||
|
.lm-rule {
|
||||||
|
height: 1px;
|
||||||
|
border: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
var(--lm-divider) 24px,
|
||||||
|
var(--lm-divider) calc(100% - 24px),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────────────────────────────────
|
||||||
|
Navigation, rows and chips
|
||||||
|
──────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sidebar item. The 3px mark on the left is the accent's whole appearance
|
||||||
|
* here — an active item is not a filled pill. Nothing in this block is sized
|
||||||
|
* by its text: the label wraps to a second line instead of being clipped,
|
||||||
|
* which is what broke when the interface was read in Portuguese or Spanish.
|
||||||
|
*/
|
||||||
|
.lm-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 9px 8px 4px;
|
||||||
|
border-radius: var(--lm-radius-md);
|
||||||
|
color: var(--lm-text-muted);
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.lm-nav:hover {
|
||||||
|
background: rgba(233, 233, 237, 0.05);
|
||||||
|
color: var(--lm-text);
|
||||||
|
}
|
||||||
|
.lm-nav[data-active='true'] {
|
||||||
|
background: var(--lm-surface);
|
||||||
|
color: var(--lm-text);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.35);
|
||||||
|
}
|
||||||
|
.lm-nav-mark {
|
||||||
|
width: 3px;
|
||||||
|
flex: none;
|
||||||
|
align-self: stretch;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.lm-nav[data-active='true'] .lm-nav-mark {
|
||||||
|
background: var(--lm-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A message row in the list pane. */
|
||||||
|
.lm-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 11px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
.lm-row:hover {
|
||||||
|
background: rgba(233, 233, 237, 0.04);
|
||||||
|
}
|
||||||
|
.lm-row[data-active='true'] {
|
||||||
|
background: var(--lm-surface);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A filter chip: an outline that takes the accent when it is the one chosen. */
|
||||||
|
.lm-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 5px 11px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--lm-text-muted);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--lm-edge-strong);
|
||||||
|
transition: color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.lm-chip:hover {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--lm-accent);
|
||||||
|
}
|
||||||
|
.lm-chip[data-active='true'] {
|
||||||
|
color: var(--lm-accent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--lm-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tab inside the segmented strip the settings, domain and mailbox screens use. */
|
||||||
|
.lm-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--lm-text-muted);
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.lm-tab:hover {
|
||||||
|
color: var(--lm-text);
|
||||||
|
background: rgba(233, 233, 237, 0.05);
|
||||||
|
}
|
||||||
|
.lm-tab[data-active='true'] {
|
||||||
|
background: var(--lm-surface);
|
||||||
|
color: var(--lm-accent);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.4);
|
||||||
|
}
|
||||||
|
.lm-tabstrip {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--lm-panel);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--lm-edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────────────────────────────────
|
||||||
|
Tables — the header and the row rules fade at their ends, as the rules do
|
||||||
|
──────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lm-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.lm-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(233, 233, 237, 0.6);
|
||||||
|
padding: 8px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lm-table td {
|
||||||
|
padding: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.lm-table thead tr {
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
var(--lm-divider) 24px,
|
||||||
|
var(--lm-divider) calc(100% - 24px),
|
||||||
|
transparent
|
||||||
|
)
|
||||||
|
no-repeat bottom / 100% 1px;
|
||||||
|
}
|
||||||
|
.lm-table tbody tr {
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
rgba(233, 233, 237, 0.08) 24px,
|
||||||
|
rgba(233, 233, 237, 0.08) calc(100% - 24px),
|
||||||
|
transparent
|
||||||
|
)
|
||||||
|
no-repeat bottom / 100% 1px;
|
||||||
|
}
|
||||||
|
.lm-table tbody tr:hover {
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(233, 233, 237, 0.04), rgba(233, 233, 237, 0.04)) no-repeat 0 0 / 100% 100%,
|
||||||
|
linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
rgba(233, 233, 237, 0.08) 24px,
|
||||||
|
rgba(233, 233, 237, 0.08) calc(100% - 24px),
|
||||||
|
transparent
|
||||||
|
)
|
||||||
|
no-repeat bottom / 100% 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────────────────────────────────
|
||||||
|
Scrollbars — a thin neutral thumb on no track
|
||||||
|
──────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 8px;
|
||||||
height: 6px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: rgba(15, 23, 42, 0.5);
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: #3f424d;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: #595d6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #3f424d transparent;
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className="dark">
|
<html lang={locale} className="dark">
|
||||||
<body className="bg-slate-950 text-slate-100 min-h-screen font-sans antialiased">
|
<body className="min-h-screen bg-dark-bg font-sans text-slate-100 antialiased">
|
||||||
<I18nProvider locale={locale}>{children}</I18nProvider>
|
<I18nProvider locale={locale}>{children}</I18nProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -80,13 +80,13 @@ export function TotpEnrolment({ secret, uri }: { secret: string; uri: string | n
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs text-slate-400">{t("auth.cantScanQr")}</p>
|
<p className="text-xs text-slate-400">{t("auth.cantScanQr")}</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="flex-1 break-all rounded-lg border border-slate-800 bg-slate-950 p-2.5 font-mono text-xs text-indigo-300">
|
<code className="lm-code min-w-0 flex-1 break-all p-2.5">
|
||||||
{secret}
|
{secret}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void copySecret()}
|
onClick={() => void copySecret()}
|
||||||
className="flex-shrink-0 rounded-lg border border-slate-700 px-3 py-2 text-xs font-medium text-slate-300 transition-colors hover:border-indigo-500/50 hover:text-white"
|
className="flex-none rounded-[10px] border border-white/[0.14] px-3 py-2 text-xs text-slate-200 transition-colors hover:bg-white/[0.07] hover:text-slate-100"
|
||||||
>
|
>
|
||||||
{copied ? t("common.copied") : t("common.copy")}
|
{copied ? t("common.copied") : t("common.copy")}
|
||||||
</button>
|
</button>
|
||||||
@@ -127,12 +127,12 @@ export function RecoveryCodes({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-3.5 text-xs text-emerald-300">
|
<div className="rounded-xl bg-dark-card p-3.5 text-xs text-slate-300 shadow-edge">
|
||||||
<div className="mb-1 font-bold">{t("settings.recoveryCodesTitle")}</div>
|
<div className="mb-1 text-[13.5px] font-medium text-slate-100">{t("settings.recoveryCodesTitle")}</div>
|
||||||
<p className="mb-3">{t("settings.saveRecoveryCodes")}</p>
|
<p className="mb-3 leading-relaxed">{t("settings.saveRecoveryCodes")}</p>
|
||||||
<div className="grid grid-cols-2 gap-1.5 rounded-lg border border-slate-800 bg-slate-950 p-3 text-center font-mono text-[11px] text-white sm:grid-cols-3">
|
<div className="lm-code grid grid-cols-2 gap-1.5 p-3 text-center text-[11px] sm:grid-cols-3">
|
||||||
{codes.map((code) => (
|
{codes.map((code) => (
|
||||||
<span key={code} className="rounded border border-slate-800 p-1">
|
<span key={code} className="rounded bg-white/[0.05] p-1">
|
||||||
{code}
|
{code}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -143,14 +143,14 @@ export function RecoveryCodes({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void copyAll()}
|
onClick={() => void copyAll()}
|
||||||
className="flex-1 rounded-xl border border-slate-700 py-2.5 text-sm font-medium text-slate-200 transition-colors hover:border-emerald-500/50 hover:text-white"
|
className="flex-1 rounded-[10px] border border-white/[0.14] py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-white/[0.07]"
|
||||||
>
|
>
|
||||||
{copied ? t("common.copied") : t("common.copy")}
|
{copied ? t("common.copied") : t("common.copy")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onContinue}
|
onClick={onContinue}
|
||||||
className="flex-1 rounded-xl bg-emerald-600 py-2.5 text-sm font-medium text-white transition-colors hover:bg-emerald-500"
|
className="flex-1 rounded-[10px] border border-indigo-500 py-2.5 text-sm font-medium text-indigo-500 transition-colors hover:bg-indigo-500/[0.12] active:bg-indigo-500/[0.22]"
|
||||||
>
|
>
|
||||||
{continueLabel}
|
{continueLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,16 +8,26 @@ interface BadgeProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tag: a small tinted label from the ramps.
|
||||||
|
*
|
||||||
|
* Nocturne tags are a deep step of a ramp filled behind a light step of the
|
||||||
|
* same ramp - no border, no blur, and no rounded-full pill, which at this size
|
||||||
|
* read as a status light rather than a label. `success` and `info` are the same
|
||||||
|
* accent because this palette is mono: what separates them is the word inside.
|
||||||
|
* Nothing here is sized by its text, so a translated label wraps instead of
|
||||||
|
* being clipped.
|
||||||
|
*/
|
||||||
export function Badge({ variant = 'neutral', children, className }: BadgeProps) {
|
export function Badge({ variant = 'neutral', children, className }: BadgeProps) {
|
||||||
const baseStyles =
|
const baseStyles =
|
||||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border backdrop-blur-sm';
|
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-0.5 text-[11px] font-medium leading-relaxed tracking-[0.02em]';
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
success: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
success: 'bg-indigo-800 text-indigo-100',
|
||||||
warning: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
info: 'bg-indigo-900 text-indigo-200',
|
||||||
danger: 'bg-rose-500/15 text-rose-400 border-rose-500/30',
|
warning: 'bg-amber-900 text-amber-200',
|
||||||
info: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
|
danger: 'bg-rose-900 text-rose-200',
|
||||||
neutral: 'bg-slate-800/60 text-slate-300 border-slate-700/50',
|
neutral: 'bg-slate-800 text-slate-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nocturne buttons are outlined, not filled.
|
||||||
|
*
|
||||||
|
* The primary action is a 1px accent border on transparent with a tinted hover
|
||||||
|
* and a deeper tint when pressed. A flooded accent was the loudest thing on
|
||||||
|
* every screen and made three buttons in a row compete with the content; an
|
||||||
|
* outline states the same intent at the weight it deserves. Focus is the
|
||||||
|
* system's own 2px accent ring from globals.css rather than a per-variant
|
||||||
|
* offset ring.
|
||||||
|
*/
|
||||||
export function Button({
|
export function Button({
|
||||||
variant = 'primary',
|
variant = 'primary',
|
||||||
size = 'md',
|
size = 'md',
|
||||||
@@ -16,25 +26,25 @@ export function Button({
|
|||||||
...props
|
...props
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
const baseStyles =
|
const baseStyles =
|
||||||
'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98]';
|
'inline-flex items-center justify-center whitespace-nowrap font-medium rounded-[10px] border transition-colors duration-150 disabled:opacity-45 disabled:cursor-not-allowed';
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary:
|
primary:
|
||||||
'bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25 focus:ring-indigo-500',
|
'border-indigo-500 text-indigo-500 bg-transparent hover:bg-indigo-500/[0.12] active:bg-indigo-500/[0.22]',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-slate-800 hover:bg-slate-700 text-slate-100 border border-slate-700/60 focus:ring-slate-500',
|
'border-white/[0.14] text-slate-100 bg-transparent hover:bg-white/[0.07] active:bg-white/[0.14]',
|
||||||
outline:
|
outline:
|
||||||
'border border-slate-700 hover:border-slate-500 text-slate-300 hover:text-white bg-transparent focus:ring-slate-500',
|
'border-white/[0.14] text-slate-300 bg-transparent hover:text-slate-100 hover:bg-white/[0.07] active:bg-white/[0.14]',
|
||||||
danger:
|
danger:
|
||||||
'bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-500/25 focus:ring-rose-500',
|
'border-rose-400 text-rose-400 bg-transparent hover:bg-rose-400/[0.12] active:bg-rose-400/[0.22]',
|
||||||
ghost:
|
ghost:
|
||||||
'text-slate-400 hover:text-white hover:bg-slate-800/60 focus:ring-slate-500',
|
'border-transparent text-indigo-500 bg-transparent hover:bg-indigo-500/10 active:bg-indigo-500/[0.18]',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
sm: 'px-2.5 py-1.5 text-xs gap-1.5',
|
sm: 'px-2.5 py-1.5 text-xs gap-1.5',
|
||||||
md: 'px-4 py-2 text-sm gap-2',
|
md: 'px-3 py-2 text-sm gap-2',
|
||||||
lg: 'px-5 py-2.5 text-base gap-2.5',
|
lg: 'px-4 py-2.5 text-[15px] gap-2.5',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,13 +7,21 @@ interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
hoverable?: boolean;
|
hoverable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A panel: a solid ground with a hairline inset ring.
|
||||||
|
*
|
||||||
|
* The edge is drawn with a ring rather than a border so a card that becomes
|
||||||
|
* active does not move its content by a pixel, and so nesting a tile inside a
|
||||||
|
* panel does not accumulate borders. No blur, and no drop shadow - on a dark
|
||||||
|
* ground elevation is the edge itself.
|
||||||
|
*/
|
||||||
export function Card({ children, className, hoverable = false, ...props }: CardProps) {
|
export function Card({ children, className, hoverable = false, ...props }: CardProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
clsx(
|
clsx(
|
||||||
'glass-card rounded-xl p-5 border border-slate-800/80 shadow-lg',
|
'rounded-2xl bg-dark-panel p-5 shadow-edge',
|
||||||
hoverable && 'glass-card-hover cursor-pointer',
|
hoverable && 'cursor-pointer transition-colors hover:bg-dark-card hover:shadow-edge-accent',
|
||||||
className
|
className
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -25,9 +33,17 @@ export function Card({ children, className, hoverable = false, ...props }: CardP
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CardHeader({ children, className }: { children: React.ReactNode; className?: string }) {
|
export function CardHeader({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||||
return <div className={twMerge(clsx('flex items-center justify-between mb-4', className))}>{children}</div>;
|
return (
|
||||||
|
<div className={twMerge(clsx('mb-4 flex flex-wrap items-center justify-between gap-3', className))}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardTitle({ children, className }: { children: React.ReactNode; className?: string }) {
|
export function CardTitle({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||||
return <h3 className={twMerge(clsx('text-lg font-semibold text-slate-100 tracking-tight', className))}>{children}</h3>;
|
return (
|
||||||
|
<h3 className={twMerge(clsx('text-[17px] font-medium leading-tight text-slate-100', className))}>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -8,13 +8,13 @@ export function LanguageSwitcher({ className = "" }: { className?: string }) {
|
|||||||
const { locale, setLocale, t } = useI18n();
|
const { locale, setLocale, t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={`flex items-center gap-2 text-xs ${className}`}>
|
<label className={`flex min-w-0 flex-1 items-center gap-2 text-xs ${className}`}>
|
||||||
<span className="sr-only">{t("settings.language")}</span>
|
<span className="sr-only">{t("settings.language")}</span>
|
||||||
<select
|
<select
|
||||||
aria-label={t("settings.language")}
|
aria-label={t("settings.language")}
|
||||||
value={locale}
|
value={locale}
|
||||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||||
className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1 text-xs text-slate-200"
|
className="min-h-[32px] w-full rounded-[9px] bg-dark-panel px-2 py-1 text-xs text-slate-100 shadow-edge focus-visible:shadow-edge-accent"
|
||||||
>
|
>
|
||||||
{LOCALES.map((l) => (
|
{LOCALES.map((l) => (
|
||||||
<option key={l} value={l}>
|
<option key={l} value={l}>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { initialsFor } from "./lib/initials.js";
|
||||||
|
|
||||||
|
describe("avatar initials", () => {
|
||||||
|
it("takes one letter from each of the first two words", () => {
|
||||||
|
expect(initialsFor("ana.ribeiro@nortide.com.br")).toBe("AR");
|
||||||
|
expect(initialsFor("marcos-cordeiro@transvale.com.br")).toBe("MC");
|
||||||
|
expect(initialsFor("juliana_serrano@nortide.com.br")).toBe("JS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes two letters from a single-word local part", () => {
|
||||||
|
expect(initialsFor("operacoes@nortide.com.br")).toBe("OP");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The old avatar printed email[0], so every account at one domain collapsed
|
||||||
|
// onto the same letter.
|
||||||
|
it("distinguishes two accounts that share a first letter", () => {
|
||||||
|
expect(initialsFor("ana.ribeiro@x.com")).not.toBe(initialsFor("ana.souza@x.com"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays a fixed-width mark when there is no address yet", () => {
|
||||||
|
expect(initialsFor(undefined)).toBe("?");
|
||||||
|
expect(initialsFor(null)).toBe("?");
|
||||||
|
expect(initialsFor("")).toBe("?");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* The two letters an avatar shows.
|
||||||
|
*
|
||||||
|
* The interface used to print `email[0]`, so every account at a company shared
|
||||||
|
* one letter and the avatars stopped distinguishing anything. Two letters are
|
||||||
|
* taken from the local part's own word boundaries - `ana.ribeiro@x` gives AR,
|
||||||
|
* `operacoes@x` gives OP - which is what the redesign draws.
|
||||||
|
*
|
||||||
|
* A missing address returns "?" rather than an empty circle: the avatar is a
|
||||||
|
* fixed-size element and collapsing it moves the row.
|
||||||
|
*/
|
||||||
|
export function initialsFor(address: string | null | undefined): string {
|
||||||
|
const local = (address ?? "").split("@")[0];
|
||||||
|
const words = local.split(/[._\-+\s]+/).filter(Boolean);
|
||||||
|
|
||||||
|
if (words.length === 0) return "?";
|
||||||
|
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
return (words[0][0] + words[1][0]).toUpperCase();
|
||||||
|
}
|
||||||
@@ -1,4 +1,81 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/**
|
||||||
|
* Nocturne, expressed as Tailwind scales.
|
||||||
|
*
|
||||||
|
* The redesign replaces two visual languages with one: the webmail's indigo and
|
||||||
|
* the console's green both become the single blurple accent, and every grey
|
||||||
|
* comes from one perceptual ramp instead of Tailwind's stock slate. Rather than
|
||||||
|
* rename several thousand utility classes across the screens, the scales those
|
||||||
|
* classes already name are remapped onto the design system's own values - so
|
||||||
|
* `bg-slate-900`, `text-emerald-400` and `border-indigo-500/30` all resolve
|
||||||
|
* into Nocturne, and a colour cannot enter the interface from outside it.
|
||||||
|
*
|
||||||
|
* The values here are the same ones globals.css carries as CSS variables.
|
||||||
|
*
|
||||||
|
* @type {import('tailwindcss').Config}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The neutral ramp, ordered the way Tailwind expects (light to dark) so the
|
||||||
|
// existing `text-slate-400` / `bg-slate-900` idiom keeps meaning what it meant.
|
||||||
|
const neutral = {
|
||||||
|
50: '#f3f5fe',
|
||||||
|
100: '#e9e9ed', // body text
|
||||||
|
200: '#e4e7f5',
|
||||||
|
300: '#cfd3e5',
|
||||||
|
400: '#9397ab', // muted text
|
||||||
|
500: '#75798c',
|
||||||
|
600: '#595d6c',
|
||||||
|
700: '#3f424d', // borders
|
||||||
|
800: '#232532', // card
|
||||||
|
900: '#1b1c28', // panel
|
||||||
|
950: '#161826', // page ground
|
||||||
|
};
|
||||||
|
|
||||||
|
// The one accent, in the product's own blurple (OKLCH hue 289).
|
||||||
|
const accent = {
|
||||||
|
50: '#faf9ff',
|
||||||
|
100: '#f5f4ff',
|
||||||
|
200: '#e7e5fe',
|
||||||
|
300: '#d2cefd',
|
||||||
|
400: '#b5abfc',
|
||||||
|
500: '#9184d9',
|
||||||
|
600: '#796cbf',
|
||||||
|
700: '#5d5294',
|
||||||
|
800: '#423a6a',
|
||||||
|
900: '#2b2741',
|
||||||
|
950: '#211d33',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Warning and danger are the only other hues, and both are desaturated: on this
|
||||||
|
// ground a saturated amber or rose reads as an error in the design, not in the
|
||||||
|
// data.
|
||||||
|
const warn = {
|
||||||
|
50: '#fbf8ee',
|
||||||
|
100: '#f6efd8',
|
||||||
|
200: '#ecdfb2',
|
||||||
|
300: '#dfcb8b',
|
||||||
|
400: '#cbb46a',
|
||||||
|
500: '#b39b52',
|
||||||
|
600: '#8f7b41',
|
||||||
|
700: '#6b5c31',
|
||||||
|
800: '#473d21',
|
||||||
|
900: '#2f2917',
|
||||||
|
950: '#1f1b10',
|
||||||
|
};
|
||||||
|
|
||||||
|
const bad = {
|
||||||
|
50: '#fcf3f4',
|
||||||
|
100: '#f8e3e5',
|
||||||
|
200: '#f0c8cc',
|
||||||
|
300: '#e2a8ae',
|
||||||
|
400: '#d08a92',
|
||||||
|
500: '#b96f78',
|
||||||
|
600: '#985860',
|
||||||
|
700: '#74434a',
|
||||||
|
800: '#4e2e33',
|
||||||
|
900: '#3a2a2d',
|
||||||
|
950: '#241a1c',
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
content: [
|
content: [
|
||||||
@@ -9,23 +86,50 @@ module.exports = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
brand: {
|
slate: neutral,
|
||||||
50: '#eef2ff',
|
gray: neutral,
|
||||||
100: '#e0e7ff',
|
zinc: neutral,
|
||||||
500: '#6366f1',
|
neutral: neutral,
|
||||||
600: '#4f46e5',
|
// Every accent name in the codebase collapses onto the one accent. The
|
||||||
700: '#4338ca',
|
// console's emerald and teal are the point of the exercise: the two
|
||||||
900: '#312e81',
|
// surfaces are one product and now look like it.
|
||||||
},
|
indigo: accent,
|
||||||
|
violet: accent,
|
||||||
|
purple: accent,
|
||||||
|
emerald: accent,
|
||||||
|
teal: accent,
|
||||||
|
cyan: accent,
|
||||||
|
sky: accent,
|
||||||
|
blue: accent,
|
||||||
|
brand: accent,
|
||||||
|
amber: warn,
|
||||||
|
yellow: warn,
|
||||||
|
orange: warn,
|
||||||
|
rose: bad,
|
||||||
|
red: bad,
|
||||||
|
pink: bad,
|
||||||
dark: {
|
dark: {
|
||||||
bg: '#090d16',
|
rail: '#12131f',
|
||||||
panel: '#0f172a',
|
bg: '#161826',
|
||||||
card: '#1e293b',
|
panel: '#1b1c28',
|
||||||
border: 'rgba(255, 255, 255, 0.08)',
|
card: '#232532',
|
||||||
}
|
border: 'rgba(233, 233, 237, 0.09)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||||
|
mono: ['ui-monospace', 'Menlo', 'Consolas', 'monospace'],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
xl: '12px',
|
||||||
|
'2xl': '14px',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
// On a dark ground elevation is an edge plus ambient darkness, never a
|
||||||
|
// stack of coloured glows.
|
||||||
|
edge: 'inset 0 0 0 1px rgba(233, 233, 237, 0.09)',
|
||||||
|
'edge-accent': 'inset 0 0 0 1px rgba(145, 132, 217, 0.4)',
|
||||||
|
lift: '0 0 0 1px #3f424d, 0 16px 40px rgba(0, 0, 0, 0.5)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user