#23: merge feature/23-real-data-surface-unification into main
This commit is contained in:
commit
39098fa59f
29 files changed
+2201
-1221
No files matched your search
@@ -54,6 +54,11 @@ services:
|
|||||||
networks: [backend]
|
networks: [backend]
|
||||||
|
|
||||||
# ----------------------------------------------------------- MIGRATE
|
# ----------------------------------------------------------- MIGRATE
|
||||||
|
# The only service with no healthcheck, deliberately: it is a one-shot job
|
||||||
|
# that applies the migrations and exits. Everything that depends on it waits
|
||||||
|
# on service_completed_successfully, which is a stronger guarantee than a
|
||||||
|
# probe - a health check on a container that is meant to stop would report
|
||||||
|
# unhealthy exactly when it has done its job.
|
||||||
migrate:
|
migrate:
|
||||||
image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/migrations:${IMAGE_TAG:-latest}
|
image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/migrations:${IMAGE_TAG:-latest}
|
||||||
restart: "no"
|
restart: "no"
|
||||||
@@ -96,7 +101,12 @@ services:
|
|||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "rspamadm control stat >/dev/null 2>&1 || exit 1"]
|
test: ["CMD-SHELL", "rspamadm control stat >/dev/null 2>&1 || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
# Without an explicit timeout Docker allows 30s, which overlaps the next
|
||||||
|
# probe; and without a start_period the first checks run while the
|
||||||
|
# worker is still loading maps and mark the container unhealthy.
|
||||||
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
mem_limit: 768m
|
mem_limit: 768m
|
||||||
networks: [backend, egress] # egress: RBL/DNSBL lookups and fuzzy storage
|
networks: [backend, egress] # egress: RBL/DNSBL lookups and fuzzy storage
|
||||||
|
|
||||||
@@ -112,6 +122,7 @@ services:
|
|||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "clamdcheck.sh || exit 1"]
|
test: ["CMD-SHELL", "clamdcheck.sh || exit 1"]
|
||||||
interval: 60s
|
interval: 60s
|
||||||
|
timeout: 15s # clamd answers slowly while scanning
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 300s # the initial signature load is slow
|
start_period: 300s # the initial signature load is slow
|
||||||
mem_limit: 2g # ClamAV genuinely needs this
|
mem_limit: 2g # ClamAV genuinely needs this
|
||||||
@@ -174,6 +185,7 @@ services:
|
|||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3002/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://localhost:3002/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
mem_limit: 1g
|
mem_limit: 1g
|
||||||
@@ -312,6 +324,16 @@ services:
|
|||||||
# TLS listener comes up without a certificate (section 8.2, trap 2).
|
# TLS listener comes up without a certificate (section 8.2, trap 2).
|
||||||
- traefik.http.routers.lm-mailhost.rule=Host(`${PRIMARY_MAIL_HOST}`)
|
- traefik.http.routers.lm-mailhost.rule=Host(`${PRIMARY_MAIL_HOST}`)
|
||||||
- traefik.http.routers.lm-mailhost.tls.certresolver=letsencrypt
|
- traefik.http.routers.lm-mailhost.tls.certresolver=letsencrypt
|
||||||
|
# The one service that was never probed, despite being the only one users
|
||||||
|
# reach directly: a wedged Next process kept its container "running" and
|
||||||
|
# Traefik kept routing to it. The image ships no curl or wget, so this uses
|
||||||
|
# node's own fetch, as auth and storage do.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
networks: [backend, proxy]
|
networks: [backend, proxy]
|
||||||
|
|
||||||
|
|||||||
@@ -398,8 +398,14 @@ export async function preflightSummary(): Promise<Record<string, unknown>> {
|
|||||||
const domains = await query<{ name: string; dns_status: string; dane_enabled: boolean }>(
|
const domains = await query<{ name: string; dns_status: string; dane_enabled: boolean }>(
|
||||||
`SELECT name, dns_status, dane_enabled FROM domains ORDER BY name`,
|
`SELECT name, dns_status, dane_enabled FROM domains ORDER BY name`,
|
||||||
);
|
);
|
||||||
|
// Each check carries a key and its subject rather than a finished English
|
||||||
|
// sentence. The console builds the label from these, so a reader with the
|
||||||
|
// interface in Portuguese no longer sees "DNS records for example.com"
|
||||||
|
// spliced into an otherwise translated page.
|
||||||
const checks = domains.map((d) => ({
|
const checks = domains.map((d) => ({
|
||||||
name: `DNS records for ${d.name}`,
|
key: "dnsRecordsFor",
|
||||||
|
target: d.name,
|
||||||
|
name: `DNS records for ${d.name}`, // kept for any client not yet using key/target
|
||||||
status: d.dns_status === "VERIFIED" ? "PASS" : d.dns_status === "PENDING" ? "PENDING" : "FAIL",
|
status: d.dns_status === "VERIFIED" ? "PASS" : d.dns_status === "PENDING" ? "PENDING" : "FAIL",
|
||||||
detail: d.dns_status,
|
detail: d.dns_status,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -287,6 +287,24 @@ describeDb("auth API against a real database", () => {
|
|||||||
const dashboard = await get("/api/v1/admin/dashboard", verified.body.token);
|
const dashboard = await get("/api/v1/admin/dashboard", verified.body.token);
|
||||||
expect(dashboard.status).toBe(200);
|
expect(dashboard.status).toBe(200);
|
||||||
expect(dashboard.body).toHaveProperty("queue_depth");
|
expect(dashboard.body).toHaveProperty("queue_depth");
|
||||||
|
|
||||||
|
// The console's sidebar reads its identity from here. It cannot use
|
||||||
|
// /api/v1/user/me, because extractToken picks the cookie from the URL
|
||||||
|
// prefix and an operator may reach /admin/login without ever having opened
|
||||||
|
// webmail. Until this route existed the sidebar printed a fixed
|
||||||
|
// "admin@lambdamail.local" with a hardcoded "SUPER_ADMIN (2FA)".
|
||||||
|
const identity = await get("/api/v1/admin/me", verified.body.token);
|
||||||
|
expect(identity.status).toBe(200);
|
||||||
|
expect(identity.body.email).toBe(adminEmail());
|
||||||
|
expect(identity.body.role).toBe("SUPER_ADMIN");
|
||||||
|
expect(identity.body.mfa_enrolled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses /api/v1/admin/me to a user-surface token", async () => {
|
||||||
|
const login = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD });
|
||||||
|
const token = (login.body.token ?? login.body.challenge_token) as unknown as string;
|
||||||
|
const res = await get("/api/v1/admin/me", token);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("locks the account after repeated failures", async () => {
|
it("locks the account after repeated failures", async () => {
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
|
|||||||
}
|
}
|
||||||
if (url === "/api/v1/user/password" && method === "PUT") return changePassword(req, res, session!);
|
if (url === "/api/v1/user/password" && method === "PUT") return changePassword(req, res, session!);
|
||||||
|
|
||||||
|
if (url === "/api/v1/admin/me" && method === "GET") return me(res, session!);
|
||||||
if (url === "/api/v1/admin/audit" && method === "GET") return adminAudit(res);
|
if (url === "/api/v1/admin/audit" && method === "GET") return adminAudit(res);
|
||||||
if (url === "/api/v1/admin/mailboxes" && method === "POST") return adminCreateMailbox(req, res, session!);
|
if (url === "/api/v1/admin/mailboxes" && method === "POST") return adminCreateMailbox(req, res, session!);
|
||||||
if (url === "/api/v1/admin/mailboxes/bulk-import" && method === "POST") return adminBulkImportMailboxes(req, res, session!);
|
if (url === "/api/v1/admin/mailboxes/bulk-import" && method === "POST") return adminBulkImportMailboxes(req, res, session!);
|
||||||
@@ -361,6 +362,16 @@ function logout(res: ServerResponse): void {
|
|||||||
|
|
||||||
// -------------------------------------------------------------- user area
|
// -------------------------------------------------------------- user area
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in identity, served on both /user/me and /admin/me.
|
||||||
|
*
|
||||||
|
* Two paths rather than one because extractToken picks the cookie from the URL
|
||||||
|
* prefix: an /api/v1/user/* call reads lm_user_session. The admin console has
|
||||||
|
* only lm_admin_session - an operator can reach /admin/login directly without
|
||||||
|
* ever opening webmail - so it needs a path that reads the admin cookie. Both
|
||||||
|
* end up here, and the session they resolve from decides whose details come
|
||||||
|
* back, so neither can be used to look up somebody else.
|
||||||
|
*/
|
||||||
async function me(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
async function me(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||||
const mailbox = await repo.findMailboxByEmail(session.email);
|
const mailbox = await repo.findMailboxByEmail(session.email);
|
||||||
if (!mailbox) return sendJson(res, 404, { error: "NOT_FOUND", message: "Mailbox not found" });
|
if (!mailbox) return sendJson(res, 404, { error: "NOT_FOUND", message: "Mailbox not found" });
|
||||||
@@ -696,7 +707,17 @@ async function adminReconcileDomain(req: IncomingMessage, res: ServerResponse, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
await repo.recordAudit(session.sub, clientIp(req), "domain.reconcile", "domain", domainId, {});
|
await repo.recordAudit(session.sub, clientIp(req), "domain.reconcile", "domain", domainId, {});
|
||||||
sendJson(res, 200, { ok: true, records_verified: 13, status: "VERIFIED" });
|
|
||||||
|
// Reports what this actually did, which is record the request. It used to
|
||||||
|
// answer {records_verified: 13, status: "VERIFIED"} unconditionally - for any
|
||||||
|
// domain id, including one that does not exist - and the console printed that
|
||||||
|
// as "all 13 DNS records verified". Nothing here resolves a name, so nothing
|
||||||
|
// here may claim a record was checked.
|
||||||
|
sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
status: "REQUESTED",
|
||||||
|
message: "Reconciliation was recorded. DNS records are not verified by this endpoint yet.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+271
-133
@@ -1,148 +1,286 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"admin": {
|
||||||
"appName": "LambdaMail",
|
"addAlias": "Add alias",
|
||||||
"userPortal": "Webmail",
|
"aliasesTitle": "Aliases",
|
||||||
"adminPortal": "Management Console",
|
"auditLog": "Audit log",
|
||||||
"loading": "Loading...",
|
"backToWebmail": "Back to webmail",
|
||||||
"save": "Save Changes",
|
"cancelJob": "Cancel",
|
||||||
"cancel": "Cancel",
|
"certificateState": "Certificate",
|
||||||
"delete": "Delete",
|
"checksPassed": "{passed} of {total} passed",
|
||||||
"confirm": "Confirm",
|
"createAccount": "Create account",
|
||||||
"search": "Search",
|
"createAlias": "Create a routing alias",
|
||||||
"status": "Status",
|
"csvPreview": "Preview",
|
||||||
"actions": "Actions",
|
"dashboardSubtitle": "Live figures read from the database.",
|
||||||
"refresh": "Refresh",
|
"dashboardTitle": "Overview",
|
||||||
"logout": "Log Out"
|
"dkimAlgorithm": "Key type",
|
||||||
|
"dkimOverlap": "Both keys sign outbound mail for {days} days so nothing fails while DNS propagates.",
|
||||||
|
"dkimPublishRecord": "Publish this record in DNS:",
|
||||||
|
"dkimRotate": "Generate a new key",
|
||||||
|
"dkimSelector": "Selector",
|
||||||
|
"dkimTitle": "DKIM key rotation",
|
||||||
|
"dmarcSubtitle": "Breakdown of the aggregate XML reports that have been ingested.",
|
||||||
|
"dmarcTitle": "DMARC reports",
|
||||||
|
"dnsRecordsFor": "DNS records for {domain}",
|
||||||
|
"dnsStatusDrift": "Drift",
|
||||||
|
"dnsStatusPartial": "Partial",
|
||||||
|
"dnsStatusVerified": "Verified",
|
||||||
|
"domainName": "Domain name",
|
||||||
|
"domainsTitle": "Domains",
|
||||||
|
"expiresInDays": "Expires in {days} days",
|
||||||
|
"freezeDomain": "Freeze domain",
|
||||||
|
"greylistScore": "Greylist score",
|
||||||
|
"headerScore": "Header-tagging score",
|
||||||
|
"jobCancelled": "Job {id} was cancelled.",
|
||||||
|
"jobRetried": "Job {id} was queued for another attempt.",
|
||||||
|
"lastChecked": "Last checked: {when}",
|
||||||
|
"lastReload": "Last reload",
|
||||||
|
"mailHost": "Mail host",
|
||||||
|
"mailboxCount": "{count} mailboxes",
|
||||||
|
"mailboxesSubtitle": "Accounts, aliases, CSV import and the second-factor policy.",
|
||||||
|
"mailboxesTitle": "Mailboxes",
|
||||||
|
"mfaAdmins": "Required for admins",
|
||||||
|
"mfaEveryone": "Required for everyone",
|
||||||
|
"mfaOptional": "Optional",
|
||||||
|
"mfaPolicyIntro": "Choose how strictly this domain requires a second factor.",
|
||||||
|
"mfaPolicyTitle": "Second-factor requirement",
|
||||||
|
"noCertificate": "No certificate is loaded for this host.",
|
||||||
|
"noChecks": "No checks reported.",
|
||||||
|
"noDomains": "No domains have been onboarded yet.",
|
||||||
|
"onboardIntro": "Creates the domain and its standard RFC 2142 aliases, pointed at your address.",
|
||||||
|
"onboardTitle": "Add a domain",
|
||||||
|
"openAdmin": "Admin console",
|
||||||
|
"preflightTitle": "Readiness checks",
|
||||||
|
"queueEmpty": "The outbound queue is empty.",
|
||||||
|
"queueSubtitle": "Inspect the outbound queue, retry a job, or cancel one.",
|
||||||
|
"queueTitle": "Outbound queue",
|
||||||
|
"readinessIntro": "Checks the server reports about itself before it carries production mail.",
|
||||||
|
"reconcileDns": "Reconcile DNS",
|
||||||
|
"reconcileNotImplemented": "Per-record DNS verification is not implemented yet: this records the request and re-reads the stored status, it does not query a resolver.",
|
||||||
|
"rejectScore": "Reject score",
|
||||||
|
"retryJob": "Retry",
|
||||||
|
"rspamdIntro": "Scores that trigger greylisting, header tagging and rejection.",
|
||||||
|
"rspamdSaved": "Thresholds saved.",
|
||||||
|
"saveThresholds": "Save thresholds",
|
||||||
|
"securitySubtitle": "Readiness checks, TLS state, spam thresholds and the audit trail.",
|
||||||
|
"securityTitle": "Security",
|
||||||
|
"tabAudit": "Audit trail",
|
||||||
|
"tabReadiness": "Readiness",
|
||||||
|
"tabRspamd": "Spam thresholds",
|
||||||
|
"tabTls": "TLS",
|
||||||
|
"tlsMode": "TLS mode",
|
||||||
|
"tlsTitle": "Certificate and transport",
|
||||||
|
"traceNoResults": "No trace found for that queue ID.",
|
||||||
|
"tracePlaceholder": "Queue ID",
|
||||||
|
"traceTitle": "Trace a message",
|
||||||
|
"unknownSni": "Unknown SNI requests",
|
||||||
|
"watcherHealthy": "Certificate watcher"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"userLoginTitle": "Webmail Sign In",
|
"adminLoginTitle": "Admin console",
|
||||||
"adminLoginTitle": "Admin Console Sign In",
|
"adminStepUpNote": "The admin console asks for your second factor again, even though you are already signed in to webmail.",
|
||||||
"emailLabel": "Email Address",
|
"authFailed": "Authentication failed",
|
||||||
"passwordLabel": "Password",
|
"cantScanQr": "Cannot scan? Enter this key manually:",
|
||||||
"signInButton": "Sign In",
|
"emailLabel": "Email address",
|
||||||
"totpCodeLabel": "6-Digit Authenticator Code",
|
"enrolmentFailed": "Could not start the second-factor setup",
|
||||||
"verifyCodeButton": "Verify Code",
|
"enrolmentIntro": "The admin console requires a second factor. Set one up now to continue.",
|
||||||
|
"genericError": "Something went wrong",
|
||||||
"invalidCredentials": "Invalid email or password",
|
"invalidCredentials": "Invalid email or password",
|
||||||
"mfaRequired": "Two-factor authentication code required"
|
"mfaRequired": "Enter the six-digit code from your authenticator app.",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"qrCodeAlt": "QR code for two-factor setup",
|
||||||
|
"scanQr": "Scan this code with Google Authenticator, Aegis, Authy or 1Password.",
|
||||||
|
"signInButton": "Sign in",
|
||||||
|
"signOutOfAdmin": "Leave admin",
|
||||||
|
"totpCodeLabel": "Authentication code",
|
||||||
|
"userLoginTitle": "Sign in to your mailbox",
|
||||||
|
"verificationFailed": "Verification failed",
|
||||||
|
"verifyCodeButton": "Verify code"
|
||||||
},
|
},
|
||||||
"mail": {
|
"common": {
|
||||||
"inbox": "Inbox",
|
"actions": "Actions",
|
||||||
"sent": "Sent",
|
"add": "Add",
|
||||||
"drafts": "Drafts",
|
"adminPortal": "Admin",
|
||||||
"trash": "Trash",
|
"appName": "LambdaMail",
|
||||||
"junk": "Spam",
|
"back": "Back",
|
||||||
"archive": "Archive",
|
"cancel": "Cancel",
|
||||||
"compose": "Compose Email",
|
"confirm": "Confirm",
|
||||||
"noMessages": "No messages in this folder",
|
"continue": "Continue",
|
||||||
"unreadCount": "{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}",
|
"copied": "Copied",
|
||||||
"loadImages": "Load Remote Images",
|
"copy": "Copy",
|
||||||
"reply": "Reply",
|
"delete": "Delete",
|
||||||
"forward": "Forward",
|
"loading": "Loading...",
|
||||||
"undoSend": "Undo Send ({seconds}s remaining)"
|
"logout": "Sign out",
|
||||||
},
|
"never": "Never",
|
||||||
"settings": {
|
"none": "None",
|
||||||
"title": "Account Settings",
|
"refresh": "Refresh",
|
||||||
"security": "Security & 2FA",
|
"retry": "Retry",
|
||||||
"appPasswords": "App Passwords",
|
"save": "Save",
|
||||||
"sessions": "Active Sessions",
|
"search": "Search",
|
||||||
"mfaStatus": "Two-Factor Authentication Status",
|
"status": "Status",
|
||||||
"mfaEnabled": "2FA is Active",
|
"unavailable": "Unavailable",
|
||||||
"mfaDisabled": "2FA is Disabled",
|
"userPortal": "Webmail"
|
||||||
"enableMfa": "Enable 2FA",
|
|
||||||
"generateAppPassword": "Generate App Password",
|
|
||||||
"recoveryCodesTitle": "Recovery Codes",
|
|
||||||
"saveRecoveryCodes": "Save these recovery codes in a safe place. They will not be shown again.",
|
|
||||||
"language": "Language",
|
|
||||||
"signOut": "Sign out",
|
|
||||||
"langEn": "English",
|
|
||||||
"langPtBR": "Portuguese (Brazil)",
|
|
||||||
"langEs": "Spanish"
|
|
||||||
},
|
|
||||||
"admin": {
|
|
||||||
"dashboardTitle": "System Dashboard",
|
|
||||||
"domainsTitle": "Domains & DNS (13 Records)",
|
|
||||||
"mailboxesTitle": "Mailboxes & Accounts",
|
|
||||||
"aliasesTitle": "Aliases & Routing",
|
|
||||||
"queueTitle": "Outbound Delivery Queue",
|
|
||||||
"dmarcTitle": "DMARC Analytics",
|
|
||||||
"securityTitle": "Security & Audit Log",
|
|
||||||
"preflightTitle": "Preflight Diagnostics",
|
|
||||||
"reconcileDns": "Reconcile DNS Records",
|
|
||||||
"dnsStatusVerified": "VERIFIED",
|
|
||||||
"dnsStatusDrift": "DRIFT DETECTED",
|
|
||||||
"dnsStatusPartial": "PARTIAL",
|
|
||||||
"retryJob": "Retry Delivery",
|
|
||||||
"cancelJob": "Cancel Job",
|
|
||||||
"freezeDomain": "Freeze Destination"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"unauthorized": "Session expired or unauthorized",
|
"forbidden": "You do not have access to this",
|
||||||
"forbidden": "Access forbidden for this account surface",
|
"loadFailed": "Could not load the data",
|
||||||
"serverError": "Internal server error occurred"
|
"serverError": "Something went wrong on the server",
|
||||||
|
"unauthorized": "Please sign in again"
|
||||||
|
},
|
||||||
|
"mail": {
|
||||||
|
"addFiles": "Add files...",
|
||||||
|
"allMessages": "All",
|
||||||
|
"archive": "Archive",
|
||||||
|
"attachments": "Attachments ({count})",
|
||||||
|
"attachmentsFilter": "With attachments",
|
||||||
|
"attachmentsUnsupported": "Attachments are listed by name only; file upload is not wired up yet.",
|
||||||
|
"backToList": "Back to the message list",
|
||||||
|
"bold": "Bold",
|
||||||
|
"bulletList": "Bulleted list",
|
||||||
|
"compose": "Compose",
|
||||||
|
"discard": "Discard",
|
||||||
|
"draftSaved": "Draft saved automatically",
|
||||||
|
"drafts": "Drafts",
|
||||||
|
"forward": "Forward",
|
||||||
|
"hideCcBcc": "- Hide Cc/Bcc",
|
||||||
|
"inbox": "Inbox",
|
||||||
|
"insertLink": "Insert link",
|
||||||
|
"insertUrl": "Enter the URL:",
|
||||||
|
"italic": "Italic",
|
||||||
|
"junk": "Spam",
|
||||||
|
"loadImages": "Load images",
|
||||||
|
"noMessages": "No messages",
|
||||||
|
"numberedList": "Numbered list",
|
||||||
|
"reply": "Reply",
|
||||||
|
"sendMessage": "Send message",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"sent": "Sent",
|
||||||
|
"showCcBcc": "+ Add Cc/Bcc",
|
||||||
|
"strikethrough": "Strikethrough",
|
||||||
|
"trash": "Trash",
|
||||||
|
"underline": "Underline",
|
||||||
|
"undoSend": "Sending in {seconds}s",
|
||||||
|
"undoSendAction": "Undo",
|
||||||
|
"unreadCount": "{count} unread",
|
||||||
|
"unreadFilter": "Unread"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"actionDiscard": "Discard",
|
||||||
|
"actionFlag": "Flag message",
|
||||||
|
"actionMove": "Move to Archive",
|
||||||
|
"addRule": "Add rule",
|
||||||
|
"appPasswordPlaceholder": "Thunderbird / Outlook Mobile",
|
||||||
|
"appPasswords": "App passwords",
|
||||||
|
"autoReplySaveFailed": "Could not save the auto-reply",
|
||||||
|
"enableMfa": "Enable two-factor",
|
||||||
|
"fieldFrom": "From",
|
||||||
|
"fieldHeader": "Header",
|
||||||
|
"fieldSubject": "Subject",
|
||||||
|
"generateAppPassword": "Generate",
|
||||||
|
"langEn": "English",
|
||||||
|
"langEs": "Español",
|
||||||
|
"langPtBR": "Português (Brasil)",
|
||||||
|
"language": "Language",
|
||||||
|
"matchContains": "contains",
|
||||||
|
"matchEquals": "is exactly",
|
||||||
|
"matchRegex": "matches regex",
|
||||||
|
"mfaDisabled": "Disabled",
|
||||||
|
"mfaEnabled": "Enabled",
|
||||||
|
"mfaStatus": "Two-factor status",
|
||||||
|
"recoveryCodesTitle": "Recovery codes",
|
||||||
|
"ruleIf": "IF",
|
||||||
|
"ruleThen": "THEN",
|
||||||
|
"saveAutoReply": "Save auto-reply",
|
||||||
|
"saveRecoveryCodes": "Save these somewhere safe. Each one works once, and they are shown only now.",
|
||||||
|
"security": "Security",
|
||||||
|
"sessions": "Sessions",
|
||||||
|
"settingsSaved": "Settings saved.",
|
||||||
|
"sieveIntro": "Rules that sort incoming mail automatically.",
|
||||||
|
"sieveTitle": "Filters (Sieve)",
|
||||||
|
"signOut": "Sign out",
|
||||||
|
"signatureIntro": "Appended automatically to the messages you compose.",
|
||||||
|
"signaturePlaceholder": "Kind regards,\nYour name\nYour role",
|
||||||
|
"signatureSaveFailed": "Could not save the signature",
|
||||||
|
"signatureSaved": "Signature saved.",
|
||||||
|
"signatureTitle": "Email signature",
|
||||||
|
"sixDigitCode": "Six-digit code",
|
||||||
|
"subtitle": "Two-factor authentication, filters, auto-reply and your signature.",
|
||||||
|
"title": "Settings",
|
||||||
|
"twoFactorIntro": "Protect your account with Google Authenticator, Aegis, Authy or 1Password.",
|
||||||
|
"vacationBodyLabel": "Reply message",
|
||||||
|
"vacationDefaultBody": "Thank you for your message. I am currently away and will reply when I return.",
|
||||||
|
"vacationDefaultSubject": "Out of office",
|
||||||
|
"vacationIntro": "Reply automatically to incoming mail while you are away.",
|
||||||
|
"vacationSubjectLabel": "Reply subject",
|
||||||
|
"vacationTitle": "Out-of-office auto-reply",
|
||||||
|
"valuePlaceholder": "Value to match..."
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"accountStatus": "Account Status",
|
"accountStatus": "Account status",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"active": "ACTIVE",
|
"active": "Active",
|
||||||
"activeSessions": "Active Webmail Sessions",
|
"activeSessions": "Active sessions",
|
||||||
"actor": "Actor / Account",
|
"actor": "Actor",
|
||||||
"actualRecord": "Actual DNS Record (Cloudflare)",
|
"actualRecord": "Actual value",
|
||||||
"allChecksPassed": "ALL CHECKS PASSED",
|
"allChecksPassed": "All checks passed",
|
||||||
"appPasswordGenerated": "App Password Generated:",
|
"appPasswordGenerated": "App password generated",
|
||||||
"appPasswordsSection": "App Passwords (IMAP / SMTP Clients)",
|
"appPasswordsSection": "App passwords",
|
||||||
"attempts": "Attempts",
|
"attempts": "Attempts",
|
||||||
"clientIp": "Client IP",
|
"bounceRate": "Bounce rate",
|
||||||
"copyPasswordNow": "Copy this password now. It will not be shown again.",
|
|
||||||
"coreInfra": "Core Infrastructure Status",
|
|
||||||
"currentSession": "Current Webmail Browser Session",
|
|
||||||
"daneVerified": "DANE / MTA-STS verified",
|
|
||||||
"destination": "Destination",
|
|
||||||
"diskUsed": "Disk Capacity Used",
|
|
||||||
"dkimResult": "DKIM Result",
|
|
||||||
"dkimAlignment": "DKIM Signature Alignment",
|
|
||||||
"dmarcEnforcement": "DMARC Policy Enforcement",
|
|
||||||
"domainVerification": "Domain Verification Status",
|
|
||||||
"emailAddress": "Email Address",
|
|
||||||
"eventAction": "Event Action",
|
|
||||||
"expectedValue": "Expected Specification Value",
|
|
||||||
"healthy": "HEALTHY",
|
|
||||||
"imapEngine": "IMAP Protocol Engine (143/993)",
|
|
||||||
"inboundVolume": "Inbound Volume (24h)",
|
|
||||||
"jobId": "Job ID",
|
|
||||||
"lastError": "Last Error",
|
|
||||||
"liveMetrics": "Live operational metrics for LambdaMail mail node.",
|
|
||||||
"localDisk": "Local disk storage driver",
|
|
||||||
"preflightTitle": "Mail Server Preflight Diagnostics",
|
|
||||||
"messageBody": "Message Body",
|
|
||||||
"messageVolume": "Message Volume",
|
|
||||||
"noAppPasswords": "No app passwords generated yet.",
|
|
||||||
"online": "ONLINE",
|
|
||||||
"outboundDelivered": "Outbound Delivered (24h)",
|
|
||||||
"queueDepth": "Outbound Queue Depth",
|
|
||||||
"postgresRunner": "Postgres job runner active",
|
|
||||||
"provisionMailbox": "Provision New Mailbox Account",
|
|
||||||
"recipient": "Recipient",
|
|
||||||
"recordName": "Record Name",
|
|
||||||
"reportingOrg": "Reporting Organization",
|
|
||||||
"scanners": "Rspamd Anti-Spam Scanner & ClamAV",
|
|
||||||
"sender": "Sender",
|
|
||||||
"smtpListener": "SMTP Protocols Listener (25/587/465)",
|
|
||||||
"sourceIp": "Source IP",
|
|
||||||
"spfRatio": "SPF Alignment Pass Ratio",
|
|
||||||
"spfResult": "SPF Result",
|
|
||||||
"storageQuota": "Storage Quota",
|
|
||||||
"subject": "Subject",
|
|
||||||
"targetObject": "Target Object",
|
|
||||||
"timestamp": "Timestamp",
|
|
||||||
"toRecipients": "To (Recipients)",
|
|
||||||
"totalEvaluated": "Total Evaluated Messages",
|
|
||||||
"twoFactorSection": "Two-Factor Authentication (TOTP RFC 6238)",
|
|
||||||
"watching": "WATCHING",
|
|
||||||
"passwordChanged": "Password changed",
|
|
||||||
"signOutOthers": "Sign out other sessions",
|
|
||||||
"changePassword": "Change password",
|
"changePassword": "Change password",
|
||||||
|
"clientIp": "Client IP",
|
||||||
|
"copyPasswordNow": "Copy it now. It is not shown again.",
|
||||||
|
"coreInfra": "Core services",
|
||||||
"currentPassword": "Current password",
|
"currentPassword": "Current password",
|
||||||
"newPassword": "New password (min. 12 characters)",
|
"currentSession": "This device",
|
||||||
"toggleMenu": "Toggle navigation menu"
|
"daneVerified": "DANE verified",
|
||||||
|
"destination": "Destination",
|
||||||
|
"diskUsed": "Storage used",
|
||||||
|
"dkimAlignment": "DKIM alignment",
|
||||||
|
"dkimResult": "DKIM",
|
||||||
|
"dmarcEnforcement": "DMARC policy",
|
||||||
|
"domainVerification": "Domain verification",
|
||||||
|
"domainsVerified": "Domains verified",
|
||||||
|
"emailAddress": "Email address",
|
||||||
|
"eventAction": "Action",
|
||||||
|
"expectedValue": "Expected value",
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"imapEngine": "IMAP service",
|
||||||
|
"inboundVolume": "Received (24h)",
|
||||||
|
"jobId": "Job",
|
||||||
|
"lastError": "Last error",
|
||||||
|
"liveMetrics": "Live metrics",
|
||||||
|
"localDisk": "Local disk",
|
||||||
|
"mailboxesActive": "Active mailboxes",
|
||||||
|
"messageBody": "Message",
|
||||||
|
"messageVolume": "Messages",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"noAppPasswords": "No app passwords yet.",
|
||||||
|
"noData": "No data yet",
|
||||||
|
"online": "Online",
|
||||||
|
"outboundDelivered": "Sent (24h)",
|
||||||
|
"passwordChanged": "Password changed.",
|
||||||
|
"postgresRunner": "Postgres-backed queue",
|
||||||
|
"preflightTitle": "Readiness",
|
||||||
|
"provisionMailbox": "Create mailbox",
|
||||||
|
"queueDepth": "Queue depth",
|
||||||
|
"recipient": "Recipient",
|
||||||
|
"recordName": "Record",
|
||||||
|
"reportingOrg": "Reporting organisation",
|
||||||
|
"scanners": "Spam and virus scanning",
|
||||||
|
"sender": "Sender",
|
||||||
|
"signOutOthers": "Sign out other devices",
|
||||||
|
"smtpListener": "SMTP service",
|
||||||
|
"sourceIp": "Source IP",
|
||||||
|
"spfRatio": "SPF pass rate",
|
||||||
|
"spfResult": "SPF",
|
||||||
|
"storageQuota": "Storage quota",
|
||||||
|
"subject": "Subject",
|
||||||
|
"targetObject": "Target",
|
||||||
|
"timestamp": "Time",
|
||||||
|
"toRecipients": "To",
|
||||||
|
"toggleMenu": "Toggle menu",
|
||||||
|
"totalEvaluated": "Messages evaluated",
|
||||||
|
"twoFactorSection": "Two-factor authentication",
|
||||||
|
"watching": "Watching"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+265
-127
@@ -1,148 +1,286 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"admin": {
|
||||||
"appName": "LambdaMail",
|
"addAlias": "Añadir apelido",
|
||||||
"userPortal": "Webmail",
|
"aliasesTitle": "Alias",
|
||||||
"adminPortal": "Consola de Gestión",
|
"auditLog": "Registro de auditoría",
|
||||||
"loading": "Cargando...",
|
"backToWebmail": "Volver al webmail",
|
||||||
"save": "Guardar Cambios",
|
"cancelJob": "Cancelar",
|
||||||
"cancel": "Cancelar",
|
"certificateState": "Certificado",
|
||||||
"delete": "Eliminar",
|
"checksPassed": "{passed} de {total} aprobadas",
|
||||||
"confirm": "Confirmar",
|
"createAccount": "Crear cuenta",
|
||||||
"search": "Buscar",
|
"createAlias": "Crear un alias de enrutamiento",
|
||||||
"status": "Estado",
|
"csvPreview": "Vista previa",
|
||||||
"actions": "Acciones",
|
"dashboardSubtitle": "Cifras en vivo leídas de la base de datos.",
|
||||||
"refresh": "Actualizar",
|
"dashboardTitle": "Resumen",
|
||||||
"logout": "Cerrar Sesión"
|
"dkimAlgorithm": "Tipo de clave",
|
||||||
|
"dkimOverlap": "Ambas claves firman el correo saliente durante {days} días para que nada falle mientras el DNS se propaga.",
|
||||||
|
"dkimPublishRecord": "Publique este registro en DNS:",
|
||||||
|
"dkimRotate": "Generar una nueva clave",
|
||||||
|
"dkimSelector": "Selector",
|
||||||
|
"dkimTitle": "Rotación de clave DKIM",
|
||||||
|
"dmarcSubtitle": "Desglose de los informes XML agregados procesados.",
|
||||||
|
"dmarcTitle": "Informes DMARC",
|
||||||
|
"dnsRecordsFor": "Registros DNS de {domain}",
|
||||||
|
"dnsStatusDrift": "Divergente",
|
||||||
|
"dnsStatusPartial": "Parcial",
|
||||||
|
"dnsStatusVerified": "Verificado",
|
||||||
|
"domainName": "Nombre del dominio",
|
||||||
|
"domainsTitle": "Dominios",
|
||||||
|
"expiresInDays": "Caduca en {days} días",
|
||||||
|
"freezeDomain": "Congelar dominio",
|
||||||
|
"greylistScore": "Puntuación de greylist",
|
||||||
|
"headerScore": "Puntuación de etiquetado de cabecera",
|
||||||
|
"jobCancelled": "El trabajo {id} fue cancelado.",
|
||||||
|
"jobRetried": "El trabajo {id} se volvió a poner en cola para otro intento.",
|
||||||
|
"lastChecked": "Última comprobación: {when}",
|
||||||
|
"lastReload": "Última recarga",
|
||||||
|
"mailHost": "Host de correo",
|
||||||
|
"mailboxCount": "{count} buzones",
|
||||||
|
"mailboxesSubtitle": "Cuentas, alias, importación CSV y la política de segundo factor.",
|
||||||
|
"mailboxesTitle": "Buzones",
|
||||||
|
"mfaAdmins": "Obligatorio para administradores",
|
||||||
|
"mfaEveryone": "Obligatorio para todos",
|
||||||
|
"mfaOptional": "Opcional",
|
||||||
|
"mfaPolicyIntro": "Elija con qué rigor este dominio exige un segundo factor.",
|
||||||
|
"mfaPolicyTitle": "Requisito de segundo factor",
|
||||||
|
"noCertificate": "No hay certificado cargado para este host.",
|
||||||
|
"noChecks": "No se informaron verificaciones.",
|
||||||
|
"noDomains": "Aún no se ha dado de alta ningún dominio.",
|
||||||
|
"onboardIntro": "Crea el dominio y sus alias estándar RFC 2142, apuntados a su dirección.",
|
||||||
|
"onboardTitle": "Añadir un dominio",
|
||||||
|
"openAdmin": "Consola de administración",
|
||||||
|
"preflightTitle": "Verificaciones de preparación",
|
||||||
|
"queueEmpty": "La cola de salida está vacía.",
|
||||||
|
"queueSubtitle": "Inspeccione la cola de salida, reintente un trabajo o cancele uno.",
|
||||||
|
"queueTitle": "Cola de salida",
|
||||||
|
"readinessIntro": "Verificaciones que el servidor informa sobre sí mismo antes de transportar correo en producción.",
|
||||||
|
"reconcileDns": "Reconciliar DNS",
|
||||||
|
"reconcileNotImplemented": "La verificación de registros DNS individuales aún no está implementada: esto registra la solicitud y relee el estado almacenado, no consulta un resolutor.",
|
||||||
|
"rejectScore": "Puntuación de rechazo",
|
||||||
|
"retryJob": "Reintentar",
|
||||||
|
"rspamdIntro": "Puntuaciones que activan greylisting, etiquetado de cabecera y rechazo.",
|
||||||
|
"rspamdSaved": "Umbrales guardados.",
|
||||||
|
"saveThresholds": "Guardar umbrales",
|
||||||
|
"securitySubtitle": "Verificaciones de preparación, estado TLS, umbrales de spam y el registro de auditoría.",
|
||||||
|
"securityTitle": "Seguridad",
|
||||||
|
"tabAudit": "Registro de auditoría",
|
||||||
|
"tabReadiness": "Preparación",
|
||||||
|
"tabRspamd": "Umbrales de spam",
|
||||||
|
"tabTls": "TLS",
|
||||||
|
"tlsMode": "Modo TLS",
|
||||||
|
"tlsTitle": "Certificado y transporte",
|
||||||
|
"traceNoResults": "No se encontró rastro para ese ID de cola.",
|
||||||
|
"tracePlaceholder": "ID de la cola",
|
||||||
|
"traceTitle": "Rastrear un mensaje",
|
||||||
|
"unknownSni": "Solicitudes SNI desconocidas",
|
||||||
|
"watcherHealthy": "Monitor de certificado"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"userLoginTitle": "Iniciar Sesión en Webmail",
|
"adminLoginTitle": "Consola de administración",
|
||||||
"adminLoginTitle": "Iniciar Sesión en Consola Admin",
|
"adminStepUpNote": "La consola de administración vuelve a pedir su segundo factor, aunque ya haya iniciado sesión en el webmail.",
|
||||||
"emailLabel": "Dirección de Correo",
|
"authFailed": "Fallo en la autenticación",
|
||||||
|
"cantScanQr": "¿No puede escanear? Introduzca esta clave manualmente:",
|
||||||
|
"emailLabel": "Dirección de correo",
|
||||||
|
"enrolmentFailed": "No se pudo iniciar la configuración del segundo factor",
|
||||||
|
"enrolmentIntro": "La consola de administración requiere un segundo factor. Configure uno ahora para continuar.",
|
||||||
|
"genericError": "Algo salió mal",
|
||||||
|
"invalidCredentials": "Correo o contraseña inválidos",
|
||||||
|
"mfaRequired": "Introduzca el código de seis dígitos de su aplicación de autenticación.",
|
||||||
"passwordLabel": "Contraseña",
|
"passwordLabel": "Contraseña",
|
||||||
"signInButton": "Iniciar Sesión",
|
"qrCodeAlt": "Código QR para configurar la verificación en dos pasos",
|
||||||
"totpCodeLabel": "Código del Autenticador (6 dígitos)",
|
"scanQr": "Escanee este código con Google Authenticator, Aegis, Authy o 1Password.",
|
||||||
"verifyCodeButton": "Verificar Código",
|
"signInButton": "Iniciar sesión",
|
||||||
"invalidCredentials": "Correo o contraseña no válidos",
|
"signOutOfAdmin": "Salir de administración",
|
||||||
"mfaRequired": "Código de autenticación de dos factores requerido"
|
"totpCodeLabel": "Código de autenticación",
|
||||||
|
"userLoginTitle": "Acceda a su buzón",
|
||||||
|
"verificationFailed": "Fallo en la verificación",
|
||||||
|
"verifyCodeButton": "Verificar código"
|
||||||
},
|
},
|
||||||
"mail": {
|
"common": {
|
||||||
"inbox": "Bandeja de Entrada",
|
"actions": "Acciones",
|
||||||
"sent": "Enviados",
|
"add": "Añadir",
|
||||||
"drafts": "Borradores",
|
"adminPortal": "Administración",
|
||||||
"trash": "Papelera",
|
"appName": "LambdaMail",
|
||||||
"junk": "Spam",
|
"back": "Volver",
|
||||||
"archive": "Archivo",
|
"cancel": "Cancelar",
|
||||||
"compose": "Redactar Correo",
|
"confirm": "Confirmar",
|
||||||
"noMessages": "No hay mensajes en esta carpeta",
|
"continue": "Continuar",
|
||||||
"unreadCount": "{count, plural, =0 {Sin mensajes no leídos} one {# mensaje no leído} other {# mensajes no leídos}}",
|
"copied": "Copiado",
|
||||||
"loadImages": "Cargar Imágenes Remotas",
|
"copy": "Copiar",
|
||||||
"reply": "Responder",
|
"delete": "Eliminar",
|
||||||
"forward": "Reenviar",
|
"loading": "Cargando...",
|
||||||
"undoSend": "Deshacer Envío ({seconds}s restantes)"
|
"logout": "Cerrar sesión",
|
||||||
},
|
"never": "Nunca",
|
||||||
"settings": {
|
"none": "Ninguno",
|
||||||
"title": "Configuración de la Cuenta",
|
"refresh": "Actualizar",
|
||||||
"security": "Seguridad y 2FA",
|
"retry": "Reintentar",
|
||||||
"appPasswords": "Contraseñas de Aplicación",
|
"save": "Guardar",
|
||||||
"sessions": "Sesiones Activas",
|
"search": "Buscar",
|
||||||
"mfaStatus": "Estado de Autenticación de Dos Factores",
|
"status": "Estado",
|
||||||
"mfaEnabled": "2FA está Activo",
|
"unavailable": "No disponible",
|
||||||
"mfaDisabled": "2FA está Desactivado",
|
"userPortal": "Webmail"
|
||||||
"enableMfa": "Activar 2FA",
|
|
||||||
"generateAppPassword": "Generar Contraseña de Aplicación",
|
|
||||||
"recoveryCodesTitle": "Códigos de Recuperación",
|
|
||||||
"saveRecoveryCodes": "Guarde estos códigos de recuperación en un lugar seguro. No se volverán a mostrar.",
|
|
||||||
"language": "Idioma",
|
|
||||||
"signOut": "Cerrar sesión",
|
|
||||||
"langEn": "Inglés",
|
|
||||||
"langPtBR": "Portugués (Brasil)",
|
|
||||||
"langEs": "Español"
|
|
||||||
},
|
|
||||||
"admin": {
|
|
||||||
"dashboardTitle": "Panel del Sistema",
|
|
||||||
"domainsTitle": "Dominios y DNS (13 Registros)",
|
|
||||||
"mailboxesTitle": "Buzones y Cuentas",
|
|
||||||
"aliasesTitle": "Alias y Enrutamiento",
|
|
||||||
"queueTitle": "Cola de Entrega de Salida",
|
|
||||||
"dmarcTitle": "Análisis DMARC",
|
|
||||||
"securityTitle": "Seguridad y Registro de Auditoría",
|
|
||||||
"preflightTitle": "Diagnóstico Preflight",
|
|
||||||
"reconcileDns": "Reconciliar Registros DNS",
|
|
||||||
"dnsStatusVerified": "VERIFICADO",
|
|
||||||
"dnsStatusDrift": "DESVÍO DETECTADO",
|
|
||||||
"dnsStatusPartial": "PARCIAL",
|
|
||||||
"retryJob": "Reintentar Entrega",
|
|
||||||
"cancelJob": "Cancelar Trabajo",
|
|
||||||
"freezeDomain": "Congelar Destino"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"unauthorized": "Sesión expirada o no autorizada",
|
"forbidden": "No tiene acceso a esto",
|
||||||
"forbidden": "Acceso prohibido para esta superficie de cuenta",
|
"loadFailed": "No se pudieron cargar los datos",
|
||||||
"serverError": "Ocurrió un error interno en el servidor"
|
"serverError": "Algo salió mal en el servidor",
|
||||||
|
"unauthorized": "Inicie sesión de nuevo"
|
||||||
|
},
|
||||||
|
"mail": {
|
||||||
|
"addFiles": "Añadir archivos...",
|
||||||
|
"allMessages": "Todas",
|
||||||
|
"archive": "Archivo",
|
||||||
|
"attachments": "Adjuntos ({count})",
|
||||||
|
"attachmentsFilter": "Con adjuntos",
|
||||||
|
"attachmentsUnsupported": "Los adjuntos se listan solo por nombre; la subida de archivos aún no está implementada.",
|
||||||
|
"backToList": "Volver a la lista de mensajes",
|
||||||
|
"bold": "Negrita",
|
||||||
|
"bulletList": "Lista con viñetas",
|
||||||
|
"compose": "Redactar",
|
||||||
|
"discard": "Descartar",
|
||||||
|
"draftSaved": "Borrador guardado automáticamente",
|
||||||
|
"drafts": "Borradores",
|
||||||
|
"forward": "Reenviar",
|
||||||
|
"hideCcBcc": "- Ocultar Cc/Cco",
|
||||||
|
"inbox": "Bandeja de entrada",
|
||||||
|
"insertLink": "Insertar enlace",
|
||||||
|
"insertUrl": "Introduzca la URL:",
|
||||||
|
"italic": "Cursiva",
|
||||||
|
"junk": "Spam",
|
||||||
|
"loadImages": "Cargar imágenes",
|
||||||
|
"noMessages": "Sin mensajes",
|
||||||
|
"numberedList": "Lista numerada",
|
||||||
|
"reply": "Responder",
|
||||||
|
"sendMessage": "Enviar mensaje",
|
||||||
|
"sending": "Enviando...",
|
||||||
|
"sent": "Enviados",
|
||||||
|
"showCcBcc": "+ Añadir Cc/Cco",
|
||||||
|
"strikethrough": "Tachado",
|
||||||
|
"trash": "Papelera",
|
||||||
|
"underline": "Subrayado",
|
||||||
|
"undoSend": "Enviando en {seconds}s",
|
||||||
|
"undoSendAction": "Deshacer",
|
||||||
|
"unreadCount": "{count} sin leer",
|
||||||
|
"unreadFilter": "Sin leer"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"actionDiscard": "Descartar",
|
||||||
|
"actionFlag": "Marcar mensaje",
|
||||||
|
"actionMove": "Mover al Archivo",
|
||||||
|
"addRule": "Añadir regla",
|
||||||
|
"appPasswordPlaceholder": "Thunderbird / Outlook Mobile",
|
||||||
|
"appPasswords": "Contraseñas de aplicación",
|
||||||
|
"autoReplySaveFailed": "No se pudo guardar la respuesta automática",
|
||||||
|
"enableMfa": "Activar verificación en dos pasos",
|
||||||
|
"fieldFrom": "De",
|
||||||
|
"fieldHeader": "Cabecera",
|
||||||
|
"fieldSubject": "Asunto",
|
||||||
|
"generateAppPassword": "Generar",
|
||||||
|
"langEn": "English",
|
||||||
|
"langEs": "Español",
|
||||||
|
"langPtBR": "Português (Brasil)",
|
||||||
|
"language": "Idioma",
|
||||||
|
"matchContains": "contiene",
|
||||||
|
"matchEquals": "es exactamente",
|
||||||
|
"matchRegex": "coincide con regex",
|
||||||
|
"mfaDisabled": "Desactivada",
|
||||||
|
"mfaEnabled": "Activada",
|
||||||
|
"mfaStatus": "Estado de la verificación en dos pasos",
|
||||||
|
"recoveryCodesTitle": "Códigos de recuperación",
|
||||||
|
"ruleIf": "SI",
|
||||||
|
"ruleThen": "ENTONCES",
|
||||||
|
"saveAutoReply": "Guardar respuesta automática",
|
||||||
|
"saveRecoveryCodes": "Guárdelos en un lugar seguro. Cada uno funciona una sola vez y solo se muestran ahora.",
|
||||||
|
"security": "Seguridad",
|
||||||
|
"sessions": "Sesiones",
|
||||||
|
"settingsSaved": "Configuración guardada.",
|
||||||
|
"sieveIntro": "Reglas que organizan el correo entrante automáticamente.",
|
||||||
|
"sieveTitle": "Filtros (Sieve)",
|
||||||
|
"signOut": "Cerrar sesión",
|
||||||
|
"signatureIntro": "Se añade automáticamente a los mensajes que redacta.",
|
||||||
|
"signaturePlaceholder": "Saludos cordiales,\nSu nombre\nSu cargo",
|
||||||
|
"signatureSaveFailed": "No se pudo guardar la firma",
|
||||||
|
"signatureSaved": "Firma guardada.",
|
||||||
|
"signatureTitle": "Firma de correo",
|
||||||
|
"sixDigitCode": "Código de seis dígitos",
|
||||||
|
"subtitle": "Verificación en dos pasos, filtros, respuesta automática y su firma.",
|
||||||
|
"title": "Configuración",
|
||||||
|
"twoFactorIntro": "Proteja su cuenta con Google Authenticator, Aegis, Authy o 1Password.",
|
||||||
|
"vacationBodyLabel": "Mensaje de la respuesta",
|
||||||
|
"vacationDefaultBody": "Gracias por su mensaje. Estoy ausente en este momento y responderé a mi regreso.",
|
||||||
|
"vacationDefaultSubject": "Fuera de la oficina",
|
||||||
|
"vacationIntro": "Responde automáticamente al correo entrante mientras esté ausente.",
|
||||||
|
"vacationSubjectLabel": "Asunto de la respuesta",
|
||||||
|
"vacationTitle": "Respuesta automática de ausencia",
|
||||||
|
"valuePlaceholder": "Valor a coincidir..."
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"accountStatus": "Estado de la cuenta",
|
"accountStatus": "Estado de la cuenta",
|
||||||
"actions": "Acciones",
|
"actions": "Acciones",
|
||||||
"active": "ACTIVA",
|
"active": "Activo",
|
||||||
"activeSessions": "Sesiones activas del webmail",
|
"activeSessions": "Sesiones activas",
|
||||||
"actor": "Autor / cuenta",
|
"actor": "Autor",
|
||||||
"actualRecord": "Registro DNS actual (Cloudflare)",
|
"actualRecord": "Valor actual",
|
||||||
"allChecksPassed": "TODAS LAS COMPROBACIONES PASARON",
|
"allChecksPassed": "Todas las verificaciones pasaron",
|
||||||
"appPasswordGenerated": "Contrasena de aplicacion generada:",
|
"appPasswordGenerated": "Contraseña de aplicación generada",
|
||||||
"appPasswordsSection": "Contrasenas de aplicacion (clientes IMAP / SMTP)",
|
"appPasswordsSection": "Contraseñas de aplicación",
|
||||||
"attempts": "Intentos",
|
"attempts": "Intentos",
|
||||||
|
"bounceRate": "Tasa de rebote",
|
||||||
|
"changePassword": "Cambiar contraseña",
|
||||||
"clientIp": "IP del cliente",
|
"clientIp": "IP del cliente",
|
||||||
"copyPasswordNow": "Copie esta contrasena ahora. No se mostrara de nuevo.",
|
"copyPasswordNow": "Cópiela ahora. No se mostrará de nuevo.",
|
||||||
"coreInfra": "Estado de la infraestructura",
|
"coreInfra": "Servicios principales",
|
||||||
"currentSession": "Sesion actual de este navegador",
|
"currentPassword": "Contraseña actual",
|
||||||
"daneVerified": "DANE / MTA-STS verificado",
|
"currentSession": "Este dispositivo",
|
||||||
|
"daneVerified": "DANE verificado",
|
||||||
"destination": "Destino",
|
"destination": "Destino",
|
||||||
"diskUsed": "Uso de disco",
|
"diskUsed": "Almacenamiento usado",
|
||||||
"dkimResult": "Resultado DKIM",
|
"dkimAlignment": "Alineación DKIM",
|
||||||
"dkimAlignment": "Alineacion de la firma DKIM",
|
"dkimResult": "DKIM",
|
||||||
"dmarcEnforcement": "Aplicacion de la politica DMARC",
|
"dmarcEnforcement": "Política DMARC",
|
||||||
"domainVerification": "Estado de la verificacion del dominio",
|
"domainVerification": "Verificación de dominio",
|
||||||
"emailAddress": "Direccion de correo",
|
"domainsVerified": "Dominios verificados",
|
||||||
"eventAction": "Accion registrada",
|
"emailAddress": "Dirección de correo",
|
||||||
"expectedValue": "Valor esperado por la especificacion",
|
"eventAction": "Acción",
|
||||||
"healthy": "SALUDABLE",
|
"expectedValue": "Valor esperado",
|
||||||
"imapEngine": "Servidor IMAP (143/993)",
|
"healthy": "Correcto",
|
||||||
"inboundVolume": "Volumen de entrada (24h)",
|
"imapEngine": "Servicio IMAP",
|
||||||
"jobId": "ID del trabajo",
|
"inboundVolume": "Recibidas (24h)",
|
||||||
"lastError": "Ultimo error",
|
"jobId": "Trabajo",
|
||||||
"liveMetrics": "Metricas operativas del nodo de correo.",
|
"lastError": "Último error",
|
||||||
"localDisk": "Almacenamiento en disco local",
|
"liveMetrics": "Métricas en vivo",
|
||||||
"preflightTitle": "Diagnostico de preflight del servidor",
|
"localDisk": "Disco local",
|
||||||
"messageBody": "Cuerpo del mensaje",
|
"mailboxesActive": "Buzones activos",
|
||||||
"messageVolume": "Volumen de mensajes",
|
"messageBody": "Mensaje",
|
||||||
"noAppPasswords": "Aun no se generaron contrasenas de aplicacion.",
|
"messageVolume": "Mensajes",
|
||||||
"online": "EN LINEA",
|
"newPassword": "Nueva contraseña",
|
||||||
"outboundDelivered": "Entregados en salida (24h)",
|
"noAppPasswords": "Aún no hay contraseñas de aplicación.",
|
||||||
"queueDepth": "Profundidad de la cola",
|
"noData": "Aún sin datos",
|
||||||
"postgresRunner": "Ejecutor de tareas Postgres activo",
|
"online": "En línea",
|
||||||
"provisionMailbox": "Crear nuevo buzon",
|
"outboundDelivered": "Enviadas (24h)",
|
||||||
|
"passwordChanged": "Contraseña cambiada.",
|
||||||
|
"postgresRunner": "Cola basada en Postgres",
|
||||||
|
"preflightTitle": "Preparación",
|
||||||
|
"provisionMailbox": "Crear buzón",
|
||||||
|
"queueDepth": "Tamaño de la cola",
|
||||||
"recipient": "Destinatario",
|
"recipient": "Destinatario",
|
||||||
"recordName": "Nombre del registro",
|
"recordName": "Registro",
|
||||||
"reportingOrg": "Organizacion que informo",
|
"reportingOrg": "Organización informante",
|
||||||
"scanners": "Rspamd (antispam) y ClamAV",
|
"scanners": "Análisis de spam y virus",
|
||||||
"sender": "Remitente",
|
"sender": "Remitente",
|
||||||
"smtpListener": "Servidor SMTP (25/587/465)",
|
"signOutOthers": "Cerrar sesión en otros dispositivos",
|
||||||
|
"smtpListener": "Servicio SMTP",
|
||||||
"sourceIp": "IP de origen",
|
"sourceIp": "IP de origen",
|
||||||
"spfRatio": "Tasa de alineacion SPF",
|
"spfRatio": "Tasa de aprobación SPF",
|
||||||
"spfResult": "Resultado SPF",
|
"spfResult": "SPF",
|
||||||
"storageQuota": "Cuota de almacenamiento",
|
"storageQuota": "Cuota de almacenamiento",
|
||||||
"subject": "Asunto",
|
"subject": "Asunto",
|
||||||
"targetObject": "Objeto destino",
|
"targetObject": "Objetivo",
|
||||||
"timestamp": "Fecha y hora",
|
"timestamp": "Hora",
|
||||||
"toRecipients": "Para (destinatarios)",
|
"toRecipients": "Para",
|
||||||
"totalEvaluated": "Total de mensajes evaluados",
|
"toggleMenu": "Alternar menú",
|
||||||
"twoFactorSection": "Autenticacion en dos pasos (TOTP RFC 6238)",
|
"totalEvaluated": "Mensajes evaluados",
|
||||||
"watching": "OBSERVANDO",
|
"twoFactorSection": "Verificación en dos pasos",
|
||||||
"passwordChanged": "Contrasena cambiada",
|
"watching": "Vigilando"
|
||||||
"signOutOthers": "Cerrar las otras sesiones",
|
|
||||||
"changePassword": "Cambiar contrasena",
|
|
||||||
"currentPassword": "Contrasena actual",
|
|
||||||
"newPassword": "Nueva contrasena (min. 12 caracteres)",
|
|
||||||
"toggleMenu": "Abrir o cerrar el menu"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,148 +1,286 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"admin": {
|
||||||
"appName": "LambdaMail",
|
"addAlias": "Adicionar apelido",
|
||||||
"userPortal": "Webmail",
|
"aliasesTitle": "Apelidos",
|
||||||
"adminPortal": "Console de Gestão",
|
"auditLog": "Trilha de auditoria",
|
||||||
"loading": "Carregando...",
|
"backToWebmail": "Voltar ao webmail",
|
||||||
"save": "Salvar Alterações",
|
"cancelJob": "Cancelar",
|
||||||
"cancel": "Cancelar",
|
"certificateState": "Certificado",
|
||||||
"delete": "Excluir",
|
"checksPassed": "{passed} de {total} aprovadas",
|
||||||
"confirm": "Confirmar",
|
"createAccount": "Criar conta",
|
||||||
"search": "Buscar",
|
"createAlias": "Criar um apelido de roteamento",
|
||||||
"status": "Status",
|
"csvPreview": "Pré-visualizar",
|
||||||
"actions": "Ações",
|
"dashboardSubtitle": "Números ao vivo lidos do banco de dados.",
|
||||||
"refresh": "Atualizar",
|
"dashboardTitle": "Visão geral",
|
||||||
"logout": "Sair"
|
"dkimAlgorithm": "Tipo de chave",
|
||||||
|
"dkimOverlap": "As duas chaves assinam as mensagens enviadas por {days} dias, para nada falhar enquanto o DNS propaga.",
|
||||||
|
"dkimPublishRecord": "Publique este registro no DNS:",
|
||||||
|
"dkimRotate": "Gerar uma nova chave",
|
||||||
|
"dkimSelector": "Seletor",
|
||||||
|
"dkimTitle": "Rotação de chave DKIM",
|
||||||
|
"dmarcSubtitle": "Detalhamento dos relatórios XML agregados que foram processados.",
|
||||||
|
"dmarcTitle": "Relatórios DMARC",
|
||||||
|
"dnsRecordsFor": "Registros DNS de {domain}",
|
||||||
|
"dnsStatusDrift": "Divergente",
|
||||||
|
"dnsStatusPartial": "Parcial",
|
||||||
|
"dnsStatusVerified": "Verificado",
|
||||||
|
"domainName": "Nome do domínio",
|
||||||
|
"domainsTitle": "Domínios",
|
||||||
|
"expiresInDays": "Expira em {days} dias",
|
||||||
|
"freezeDomain": "Congelar domínio",
|
||||||
|
"greylistScore": "Pontuação de greylist",
|
||||||
|
"headerScore": "Pontuação de marcação de cabeçalho",
|
||||||
|
"jobCancelled": "O trabalho {id} foi cancelado.",
|
||||||
|
"jobRetried": "O trabalho {id} foi recolocado na fila para nova tentativa.",
|
||||||
|
"lastChecked": "Última verificação: {when}",
|
||||||
|
"lastReload": "Última recarga",
|
||||||
|
"mailHost": "Host de e-mail",
|
||||||
|
"mailboxCount": "{count} caixas de e-mail",
|
||||||
|
"mailboxesSubtitle": "Contas, apelidos, importação CSV e a política de segundo fator.",
|
||||||
|
"mailboxesTitle": "Caixas de e-mail",
|
||||||
|
"mfaAdmins": "Obrigatório para administradores",
|
||||||
|
"mfaEveryone": "Obrigatório para todos",
|
||||||
|
"mfaOptional": "Opcional",
|
||||||
|
"mfaPolicyIntro": "Escolha com que rigor este domínio exige um segundo fator.",
|
||||||
|
"mfaPolicyTitle": "Exigência de segundo fator",
|
||||||
|
"noCertificate": "Nenhum certificado carregado para este host.",
|
||||||
|
"noChecks": "Nenhuma verificação relatada.",
|
||||||
|
"noDomains": "Nenhum domínio foi cadastrado ainda.",
|
||||||
|
"onboardIntro": "Cria o domínio e os apelidos padrão da RFC 2142, apontados para o seu endereço.",
|
||||||
|
"onboardTitle": "Adicionar um domínio",
|
||||||
|
"openAdmin": "Console de administração",
|
||||||
|
"preflightTitle": "Verificações de prontidão",
|
||||||
|
"queueEmpty": "A fila de saída está vazia.",
|
||||||
|
"queueSubtitle": "Inspecione a fila de saída, tente reenviar um trabalho ou cancele um.",
|
||||||
|
"queueTitle": "Fila de saída",
|
||||||
|
"readinessIntro": "Verificações que o servidor relata sobre si mesmo antes de transportar mensagens em produção.",
|
||||||
|
"reconcileDns": "Reconciliar DNS",
|
||||||
|
"reconcileNotImplemented": "A verificação de registros DNS individuais ainda não foi implementada: isto registra a solicitação e relê o status armazenado, não consulta um resolvedor.",
|
||||||
|
"rejectScore": "Pontuação de rejeição",
|
||||||
|
"retryJob": "Tentar novamente",
|
||||||
|
"rspamdIntro": "Pontuações que acionam greylisting, marcação de cabeçalho e rejeição.",
|
||||||
|
"rspamdSaved": "Limites salvos.",
|
||||||
|
"saveThresholds": "Salvar limites",
|
||||||
|
"securitySubtitle": "Verificações de prontidão, estado do TLS, limites de spam e a trilha de auditoria.",
|
||||||
|
"securityTitle": "Segurança",
|
||||||
|
"tabAudit": "Trilha de auditoria",
|
||||||
|
"tabReadiness": "Prontidão",
|
||||||
|
"tabRspamd": "Limites de spam",
|
||||||
|
"tabTls": "TLS",
|
||||||
|
"tlsMode": "Modo TLS",
|
||||||
|
"tlsTitle": "Certificado e transporte",
|
||||||
|
"traceNoResults": "Nenhum rastro encontrado para esse ID de fila.",
|
||||||
|
"tracePlaceholder": "ID da fila",
|
||||||
|
"traceTitle": "Rastrear uma mensagem",
|
||||||
|
"unknownSni": "Requisições SNI desconhecidas",
|
||||||
|
"watcherHealthy": "Monitor de certificado"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"userLoginTitle": "Entrar no Webmail",
|
"adminLoginTitle": "Console de administração",
|
||||||
"adminLoginTitle": "Entrar no Console Admin",
|
"adminStepUpNote": "O console de administração pede o segundo fator novamente, mesmo você já estando conectado ao webmail.",
|
||||||
"emailLabel": "Endereço de E-mail",
|
"authFailed": "Falha na autenticação",
|
||||||
"passwordLabel": "Senha",
|
"cantScanQr": "Não consegue escanear? Digite esta chave manualmente:",
|
||||||
"signInButton": "Entrar",
|
"emailLabel": "Endereço de e-mail",
|
||||||
"totpCodeLabel": "Código do Autenticador (6 dígitos)",
|
"enrolmentFailed": "Não foi possível iniciar a configuração do segundo fator",
|
||||||
"verifyCodeButton": "Verificar Código",
|
"enrolmentIntro": "O console de administração exige um segundo fator. Configure um agora para continuar.",
|
||||||
|
"genericError": "Algo deu errado",
|
||||||
"invalidCredentials": "E-mail ou senha inválidos",
|
"invalidCredentials": "E-mail ou senha inválidos",
|
||||||
"mfaRequired": "Código de autenticação em dois fatores obrigatório"
|
"mfaRequired": "Digite o código de seis dígitos do seu aplicativo autenticador.",
|
||||||
|
"passwordLabel": "Senha",
|
||||||
|
"qrCodeAlt": "Código QR para configurar a verificação em duas etapas",
|
||||||
|
"scanQr": "Escaneie este código com o Google Authenticator, Aegis, Authy ou 1Password.",
|
||||||
|
"signInButton": "Entrar",
|
||||||
|
"signOutOfAdmin": "Sair da administração",
|
||||||
|
"totpCodeLabel": "Código de autenticação",
|
||||||
|
"userLoginTitle": "Entre na sua caixa de e-mail",
|
||||||
|
"verificationFailed": "Falha na verificação",
|
||||||
|
"verifyCodeButton": "Verificar código"
|
||||||
},
|
},
|
||||||
"mail": {
|
"common": {
|
||||||
"inbox": "Caixa de Entrada",
|
"actions": "Ações",
|
||||||
"sent": "Enviados",
|
"add": "Adicionar",
|
||||||
"drafts": "Rascunhos",
|
"adminPortal": "Administração",
|
||||||
"trash": "Lixeira",
|
"appName": "LambdaMail",
|
||||||
"junk": "Spam",
|
"back": "Voltar",
|
||||||
"archive": "Arquivo",
|
"cancel": "Cancelar",
|
||||||
"compose": "Escrever E-mail",
|
"confirm": "Confirmar",
|
||||||
"noMessages": "Nenhuma mensagem nesta pasta",
|
"continue": "Continuar",
|
||||||
"unreadCount": "{count, plural, =0 {Nenhuma mensagem não lida} one {# mensagem não lida} other {# mensagens não lidas}}",
|
"copied": "Copiado",
|
||||||
"loadImages": "Carregar Imagens Remotas",
|
"copy": "Copiar",
|
||||||
"reply": "Responder",
|
"delete": "Excluir",
|
||||||
"forward": "Encaminhar",
|
"loading": "Carregando...",
|
||||||
"undoSend": "Desfazer Envio ({seconds}s restantes)"
|
"logout": "Sair",
|
||||||
},
|
"never": "Nunca",
|
||||||
"settings": {
|
"none": "Nenhum",
|
||||||
"title": "Configurações da Conta",
|
"refresh": "Atualizar",
|
||||||
"security": "Segurança e 2FA",
|
"retry": "Tentar novamente",
|
||||||
"appPasswords": "Senhas de Aplicativo",
|
"save": "Salvar",
|
||||||
"sessions": "Sessões Ativas",
|
"search": "Buscar",
|
||||||
"mfaStatus": "Status da Autenticação em Dois Fatores",
|
"status": "Status",
|
||||||
"mfaEnabled": "2FA está Ativo",
|
"unavailable": "Indisponível",
|
||||||
"mfaDisabled": "2FA está Desativado",
|
"userPortal": "Webmail"
|
||||||
"enableMfa": "Ativar 2FA",
|
|
||||||
"generateAppPassword": "Gerar Senha de Aplicativo",
|
|
||||||
"recoveryCodesTitle": "Códigos de Recuperação",
|
|
||||||
"saveRecoveryCodes": "Guarde estes códigos de recuperação em local seguro. Eles não serão exibidos novamente.",
|
|
||||||
"language": "Idioma",
|
|
||||||
"signOut": "Sair",
|
|
||||||
"langEn": "Inglês",
|
|
||||||
"langPtBR": "Português (Brasil)",
|
|
||||||
"langEs": "Espanhol"
|
|
||||||
},
|
|
||||||
"admin": {
|
|
||||||
"dashboardTitle": "Painel do Sistema",
|
|
||||||
"domainsTitle": "Domínios e DNS (13 Registros)",
|
|
||||||
"mailboxesTitle": "Caixas Postais e Contas",
|
|
||||||
"aliasesTitle": "Aliases e Roteamento",
|
|
||||||
"queueTitle": "Fila de Entrega de Saída",
|
|
||||||
"dmarcTitle": "Análise DMARC",
|
|
||||||
"securityTitle": "Segurança e Log de Auditoria",
|
|
||||||
"preflightTitle": "Diagnóstico Preflight",
|
|
||||||
"reconcileDns": "Reconciliar Registros DNS",
|
|
||||||
"dnsStatusVerified": "VERIFICADO",
|
|
||||||
"dnsStatusDrift": "DESVIO DETECTADO",
|
|
||||||
"dnsStatusPartial": "PARCIAL",
|
|
||||||
"retryJob": "Tentar Entrega Novamente",
|
|
||||||
"cancelJob": "Cancelar Trabalho",
|
|
||||||
"freezeDomain": "Congelar Destino"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"unauthorized": "Sessão expirada ou não autorizada",
|
"forbidden": "Você não tem acesso a isto",
|
||||||
"forbidden": "Acesso proibido para esta superfície de conta",
|
"loadFailed": "Não foi possível carregar os dados",
|
||||||
"serverError": "Ocorreu um erro interno no servidor"
|
"serverError": "Algo deu errado no servidor",
|
||||||
|
"unauthorized": "Entre novamente"
|
||||||
|
},
|
||||||
|
"mail": {
|
||||||
|
"addFiles": "Adicionar arquivos...",
|
||||||
|
"allMessages": "Todas",
|
||||||
|
"archive": "Arquivo",
|
||||||
|
"attachments": "Anexos ({count})",
|
||||||
|
"attachmentsFilter": "Com anexos",
|
||||||
|
"attachmentsUnsupported": "Anexos são listados apenas pelo nome; o envio de arquivos ainda não está implementado.",
|
||||||
|
"backToList": "Voltar para a lista de mensagens",
|
||||||
|
"bold": "Negrito",
|
||||||
|
"bulletList": "Lista com marcadores",
|
||||||
|
"compose": "Escrever",
|
||||||
|
"discard": "Descartar",
|
||||||
|
"draftSaved": "Rascunho salvo automaticamente",
|
||||||
|
"drafts": "Rascunhos",
|
||||||
|
"forward": "Encaminhar",
|
||||||
|
"hideCcBcc": "- Ocultar Cc/Cco",
|
||||||
|
"inbox": "Caixa de entrada",
|
||||||
|
"insertLink": "Inserir link",
|
||||||
|
"insertUrl": "Digite a URL:",
|
||||||
|
"italic": "Itálico",
|
||||||
|
"junk": "Spam",
|
||||||
|
"loadImages": "Carregar imagens",
|
||||||
|
"noMessages": "Nenhuma mensagem",
|
||||||
|
"numberedList": "Lista numerada",
|
||||||
|
"reply": "Responder",
|
||||||
|
"sendMessage": "Enviar mensagem",
|
||||||
|
"sending": "Enviando...",
|
||||||
|
"sent": "Enviados",
|
||||||
|
"showCcBcc": "+ Adicionar Cc/Cco",
|
||||||
|
"strikethrough": "Tachado",
|
||||||
|
"trash": "Lixeira",
|
||||||
|
"underline": "Sublinhado",
|
||||||
|
"undoSend": "Enviando em {seconds}s",
|
||||||
|
"undoSendAction": "Desfazer",
|
||||||
|
"unreadCount": "{count} não lidas",
|
||||||
|
"unreadFilter": "Não lidas"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"actionDiscard": "Descartar",
|
||||||
|
"actionFlag": "Marcar mensagem",
|
||||||
|
"actionMove": "Mover para o Arquivo",
|
||||||
|
"addRule": "Adicionar regra",
|
||||||
|
"appPasswordPlaceholder": "Thunderbird / Outlook Mobile",
|
||||||
|
"appPasswords": "Senhas de aplicativo",
|
||||||
|
"autoReplySaveFailed": "Não foi possível salvar a resposta automática",
|
||||||
|
"enableMfa": "Ativar verificação em duas etapas",
|
||||||
|
"fieldFrom": "De",
|
||||||
|
"fieldHeader": "Cabeçalho",
|
||||||
|
"fieldSubject": "Assunto",
|
||||||
|
"generateAppPassword": "Gerar",
|
||||||
|
"langEn": "English",
|
||||||
|
"langEs": "Español",
|
||||||
|
"langPtBR": "Português (Brasil)",
|
||||||
|
"language": "Idioma",
|
||||||
|
"matchContains": "contém",
|
||||||
|
"matchEquals": "é exatamente",
|
||||||
|
"matchRegex": "corresponde à regex",
|
||||||
|
"mfaDisabled": "Desativada",
|
||||||
|
"mfaEnabled": "Ativada",
|
||||||
|
"mfaStatus": "Status da verificação em duas etapas",
|
||||||
|
"recoveryCodesTitle": "Códigos de recuperação",
|
||||||
|
"ruleIf": "SE",
|
||||||
|
"ruleThen": "ENTÃO",
|
||||||
|
"saveAutoReply": "Salvar resposta automática",
|
||||||
|
"saveRecoveryCodes": "Guarde-os em local seguro. Cada um funciona uma única vez e eles só aparecem agora.",
|
||||||
|
"security": "Segurança",
|
||||||
|
"sessions": "Sessões",
|
||||||
|
"settingsSaved": "Configurações salvas.",
|
||||||
|
"sieveIntro": "Regras que organizam as mensagens recebidas automaticamente.",
|
||||||
|
"sieveTitle": "Filtros (Sieve)",
|
||||||
|
"signOut": "Sair",
|
||||||
|
"signatureIntro": "Anexada automaticamente às mensagens que você escreve.",
|
||||||
|
"signaturePlaceholder": "Atenciosamente,\nSeu nome\nSeu cargo",
|
||||||
|
"signatureSaveFailed": "Não foi possível salvar a assinatura",
|
||||||
|
"signatureSaved": "Assinatura salva.",
|
||||||
|
"signatureTitle": "Assinatura de e-mail",
|
||||||
|
"sixDigitCode": "Código de seis dígitos",
|
||||||
|
"subtitle": "Verificação em duas etapas, filtros, resposta automática e sua assinatura.",
|
||||||
|
"title": "Configurações",
|
||||||
|
"twoFactorIntro": "Proteja sua conta com o Google Authenticator, Aegis, Authy ou 1Password.",
|
||||||
|
"vacationBodyLabel": "Mensagem da resposta",
|
||||||
|
"vacationDefaultBody": "Obrigado pela sua mensagem. Estou ausente no momento e responderei assim que retornar.",
|
||||||
|
"vacationDefaultSubject": "Fora do escritório",
|
||||||
|
"vacationIntro": "Responde automaticamente às mensagens recebidas enquanto você estiver ausente.",
|
||||||
|
"vacationSubjectLabel": "Assunto da resposta",
|
||||||
|
"vacationTitle": "Resposta automática de ausência",
|
||||||
|
"valuePlaceholder": "Valor a corresponder..."
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"accountStatus": "Situacao da conta",
|
"accountStatus": "Status da conta",
|
||||||
"actions": "Acoes",
|
"actions": "Ações",
|
||||||
"active": "ATIVA",
|
"active": "Ativo",
|
||||||
"activeSessions": "Sessoes ativas do webmail",
|
"activeSessions": "Sessões ativas",
|
||||||
"actor": "Autor / conta",
|
"actor": "Autor",
|
||||||
"actualRecord": "Registro DNS atual (Cloudflare)",
|
"actualRecord": "Valor atual",
|
||||||
"allChecksPassed": "TODAS AS CHECAGENS PASSARAM",
|
"allChecksPassed": "Todas as verificações passaram",
|
||||||
"appPasswordGenerated": "Senha de aplicativo gerada:",
|
"appPasswordGenerated": "Senha de aplicativo gerada",
|
||||||
"appPasswordsSection": "Senhas de aplicativo (clientes IMAP / SMTP)",
|
"appPasswordsSection": "Senhas de aplicativo",
|
||||||
"attempts": "Tentativas",
|
"attempts": "Tentativas",
|
||||||
|
"bounceRate": "Taxa de devolução",
|
||||||
|
"changePassword": "Alterar senha",
|
||||||
"clientIp": "IP do cliente",
|
"clientIp": "IP do cliente",
|
||||||
"copyPasswordNow": "Copie esta senha agora. Ela nao sera exibida novamente.",
|
"copyPasswordNow": "Copie agora. Ela não será exibida novamente.",
|
||||||
"coreInfra": "Estado da infraestrutura",
|
"coreInfra": "Serviços principais",
|
||||||
"currentSession": "Sessao atual deste navegador",
|
"currentPassword": "Senha atual",
|
||||||
"daneVerified": "DANE / MTA-STS verificado",
|
"currentSession": "Este dispositivo",
|
||||||
|
"daneVerified": "DANE verificado",
|
||||||
"destination": "Destino",
|
"destination": "Destino",
|
||||||
"diskUsed": "Uso de disco",
|
"diskUsed": "Armazenamento usado",
|
||||||
"dkimResult": "Resultado DKIM",
|
"dkimAlignment": "Alinhamento DKIM",
|
||||||
"dkimAlignment": "Alinhamento da assinatura DKIM",
|
"dkimResult": "DKIM",
|
||||||
"dmarcEnforcement": "Aplicacao da politica DMARC",
|
"dmarcEnforcement": "Política DMARC",
|
||||||
"domainVerification": "Estado da verificacao do dominio",
|
"domainVerification": "Verificação de domínio",
|
||||||
"emailAddress": "Endereco de e-mail",
|
"domainsVerified": "Domínios verificados",
|
||||||
"eventAction": "Acao registrada",
|
"emailAddress": "Endereço de e-mail",
|
||||||
"expectedValue": "Valor esperado pela especificacao",
|
"eventAction": "Ação",
|
||||||
"healthy": "SAUDAVEL",
|
"expectedValue": "Valor esperado",
|
||||||
"imapEngine": "Servidor IMAP (143/993)",
|
"healthy": "Saudável",
|
||||||
"inboundVolume": "Volume de entrada (24h)",
|
"imapEngine": "Serviço IMAP",
|
||||||
"jobId": "ID do trabalho",
|
"inboundVolume": "Recebidas (24h)",
|
||||||
"lastError": "Ultimo erro",
|
"jobId": "Trabalho",
|
||||||
"liveMetrics": "Metricas operacionais do no de e-mail.",
|
"lastError": "Último erro",
|
||||||
"localDisk": "Armazenamento em disco local",
|
"liveMetrics": "Métricas ao vivo",
|
||||||
"preflightTitle": "Diagnostico de preflight do servidor",
|
"localDisk": "Disco local",
|
||||||
"messageBody": "Corpo da mensagem",
|
"mailboxesActive": "Caixas ativas",
|
||||||
"messageVolume": "Volume de mensagens",
|
"messageBody": "Mensagem",
|
||||||
"noAppPasswords": "Nenhuma senha de aplicativo gerada ainda.",
|
"messageVolume": "Mensagens",
|
||||||
"online": "ONLINE",
|
"newPassword": "Nova senha",
|
||||||
"outboundDelivered": "Entregues na saida (24h)",
|
"noAppPasswords": "Nenhuma senha de aplicativo ainda.",
|
||||||
"queueDepth": "Profundidade da fila",
|
"noData": "Ainda sem dados",
|
||||||
"postgresRunner": "Executor de tarefas Postgres ativo",
|
"online": "Online",
|
||||||
"provisionMailbox": "Criar nova caixa postal",
|
"outboundDelivered": "Enviadas (24h)",
|
||||||
"recipient": "Destinatario",
|
"passwordChanged": "Senha alterada.",
|
||||||
"recordName": "Nome do registro",
|
"postgresRunner": "Fila baseada em Postgres",
|
||||||
"reportingOrg": "Organizacao que reportou",
|
"preflightTitle": "Prontidão",
|
||||||
"scanners": "Rspamd (antispam) e ClamAV",
|
"provisionMailbox": "Criar caixa de e-mail",
|
||||||
|
"queueDepth": "Tamanho da fila",
|
||||||
|
"recipient": "Destinatário",
|
||||||
|
"recordName": "Registro",
|
||||||
|
"reportingOrg": "Organização que reportou",
|
||||||
|
"scanners": "Análise de spam e vírus",
|
||||||
"sender": "Remetente",
|
"sender": "Remetente",
|
||||||
"smtpListener": "Servidor SMTP (25/587/465)",
|
"signOutOthers": "Sair dos outros dispositivos",
|
||||||
|
"smtpListener": "Serviço SMTP",
|
||||||
"sourceIp": "IP de origem",
|
"sourceIp": "IP de origem",
|
||||||
"spfRatio": "Taxa de alinhamento SPF",
|
"spfRatio": "Taxa de aprovação SPF",
|
||||||
"spfResult": "Resultado SPF",
|
"spfResult": "SPF",
|
||||||
"storageQuota": "Cota de armazenamento",
|
"storageQuota": "Cota de armazenamento",
|
||||||
"subject": "Assunto",
|
"subject": "Assunto",
|
||||||
"targetObject": "Objeto alvo",
|
"targetObject": "Alvo",
|
||||||
"timestamp": "Data e hora",
|
"timestamp": "Horário",
|
||||||
"toRecipients": "Para (destinatarios)",
|
"toRecipients": "Para",
|
||||||
"totalEvaluated": "Total de mensagens avaliadas",
|
"toggleMenu": "Alternar menu",
|
||||||
"twoFactorSection": "Autenticacao em dois fatores (TOTP RFC 6238)",
|
"totalEvaluated": "Mensagens avaliadas",
|
||||||
"watching": "OBSERVANDO",
|
"twoFactorSection": "Verificação em duas etapas",
|
||||||
"passwordChanged": "Senha alterada",
|
"watching": "Monitorando"
|
||||||
"signOutOthers": "Encerrar as outras sessoes",
|
|
||||||
"changePassword": "Alterar senha",
|
|
||||||
"currentPassword": "Senha atual",
|
|
||||||
"newPassword": "Nova senha (min. 12 caracteres)",
|
|
||||||
"toggleMenu": "Abrir ou fechar o menu"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+7
@@ -13,6 +13,7 @@
|
|||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
"next": "15.5.21",
|
"next": "15.5.21",
|
||||||
|
"qrcode-generator": "^2.0.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"tailwind-merge": "^3.0.1"
|
"tailwind-merge": "^3.0.1"
|
||||||
@@ -3277,6 +3278,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode-generator": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
"next": "15.5.21",
|
"next": "15.5.21",
|
||||||
|
"qrcode-generator": "^2.0.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"tailwind-merge": "^3.0.1"
|
"tailwind-merge": "^3.0.1"
|
||||||
|
|||||||
@@ -19,16 +19,24 @@ 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";
|
||||||
|
|
||||||
|
/** Exactly the fields /api/v1/admin/dashboard returns - see dashboardStats(). */
|
||||||
interface DashboardMetrics {
|
interface DashboardMetrics {
|
||||||
inbound_24h: number;
|
inbound_24h: number;
|
||||||
outbound_24h: number;
|
outbound_24h: number;
|
||||||
queue_depth: number;
|
queue_depth: number;
|
||||||
bounce_rate: number;
|
bounce_rate: number;
|
||||||
spam_score_avg: number;
|
|
||||||
disk_used_percent: number;
|
|
||||||
domains_verified: number;
|
domains_verified: number;
|
||||||
domains_total: number;
|
domains_total: number;
|
||||||
preflight_status: string;
|
mailboxes_active: number;
|
||||||
|
storage_used_bytes: number;
|
||||||
|
storage_quota_bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||||
|
return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
@@ -39,9 +47,9 @@ export default function AdminDashboardPage() {
|
|||||||
const fetchMetrics = () => {
|
const fetchMetrics = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch("/api/v1/admin/dashboard")
|
fetch("/api/v1/admin/dashboard")
|
||||||
.then((res) => res.json())
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
.then((data) => setMetrics(data))
|
.then((data) => setMetrics(data))
|
||||||
.catch(() => {})
|
.catch(() => setMetrics(null))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,18 +57,31 @@ export default function AdminDashboardPage() {
|
|||||||
fetchMetrics();
|
fetchMetrics();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A missing figure reads as "no data", never as a number.
|
||||||
|
*
|
||||||
|
* Each of these used to fall back to an invented constant - 1420 received,
|
||||||
|
* 890 sent, 3 queued, 18.4% disk - so an unreachable API produced a dashboard
|
||||||
|
* of plausible traffic for a server that might not have delivered a single
|
||||||
|
* message. Two of those fields (disk_used_percent, spam_score_avg) were never
|
||||||
|
* returned by the endpoint at all, so the fake disk figure was what everyone
|
||||||
|
* always saw.
|
||||||
|
*/
|
||||||
|
const shown = (value: number | undefined, format?: (n: number) => string) =>
|
||||||
|
value === undefined || value === null ? t("ui.noData") : format ? format(value) : String(value);
|
||||||
|
|
||||||
const kpis = [
|
const kpis = [
|
||||||
{
|
{
|
||||||
title: t("ui.inboundVolume"),
|
title: t("ui.inboundVolume"),
|
||||||
value: metrics?.inbound_24h ?? 1420,
|
value: shown(metrics?.inbound_24h),
|
||||||
subtext: "100% SPF/DKIM validado",
|
subtext: t("ui.messageVolume"),
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
color: "text-indigo-400",
|
color: "text-indigo-400",
|
||||||
bg: "bg-indigo-500/10",
|
bg: "bg-indigo-500/10",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("ui.outboundDelivered"),
|
title: t("ui.outboundDelivered"),
|
||||||
value: metrics?.outbound_24h ?? 890,
|
value: shown(metrics?.outbound_24h),
|
||||||
subtext: t("ui.daneVerified"),
|
subtext: t("ui.daneVerified"),
|
||||||
icon: Send,
|
icon: Send,
|
||||||
color: "text-emerald-400",
|
color: "text-emerald-400",
|
||||||
@@ -68,7 +89,7 @@ export default function AdminDashboardPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("ui.queueDepth"),
|
title: t("ui.queueDepth"),
|
||||||
value: metrics?.queue_depth ?? 3,
|
value: shown(metrics?.queue_depth),
|
||||||
subtext: t("ui.postgresRunner"),
|
subtext: t("ui.postgresRunner"),
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
color: "text-cyan-400",
|
color: "text-cyan-400",
|
||||||
@@ -76,8 +97,10 @@ export default function AdminDashboardPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("ui.diskUsed"),
|
title: t("ui.diskUsed"),
|
||||||
value: `${metrics?.disk_used_percent ?? 18.4}%`,
|
value: shown(metrics?.storage_used_bytes, formatBytes),
|
||||||
subtext: t("ui.localDisk"),
|
subtext: metrics
|
||||||
|
? `${t("ui.storageQuota")}: ${formatBytes(metrics.storage_quota_bytes)}`
|
||||||
|
: t("ui.localDisk"),
|
||||||
icon: HardDrive,
|
icon: HardDrive,
|
||||||
color: "text-amber-400",
|
color: "text-amber-400",
|
||||||
bg: "bg-amber-500/10",
|
bg: "bg-amber-500/10",
|
||||||
@@ -89,23 +112,26 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Header Bar */}
|
{/* Header Bar */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight flex items-center gap-3">
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
||||||
{t("admin.dashboardTitle")}
|
{t("admin.dashboardTitle")}
|
||||||
<span className="text-xs px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-mono font-normal">
|
|
||||||
v1.2 Live
|
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-400 mt-1">{t("ui.liveMetrics")}</p>
|
<p className="text-sm text-slate-400 mt-1">{t("admin.dashboardSubtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="success" className="font-mono py-1">
|
{/* Reports the count it can actually see. The old badge asserted
|
||||||
<Zap className="w-3.5 h-3.5 mr-1 animate-pulse" />
|
"PREFLIGHT: HEALTHY" from a field the endpoint never sends, so it
|
||||||
PREFLIGHT: {metrics?.preflight_status || "HEALTHY"}
|
said healthy unconditionally. The real checks live on the
|
||||||
</Badge>
|
security screen. */}
|
||||||
|
{metrics && (
|
||||||
|
<Badge variant={metrics.domains_verified === metrics.domains_total ? "success" : "warning"}>
|
||||||
|
<Zap className="w-3.5 h-3.5 mr-1" />
|
||||||
|
{t("ui.domainsVerified")}: {metrics.domains_verified}/{metrics.domains_total}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={fetchMetrics} disabled={loading}>
|
<Button variant="outline" size="sm" onClick={fetchMetrics} disabled={loading}>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||||
<span>Atualizar</span>
|
<span>{t("common.refresh")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,68 +167,58 @@ export default function AdminDashboardPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Security & System Infra */}
|
{/*
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
A single card of figures that are genuinely queried.
|
||||||
{/* Core Infrastructure Health */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Server className="w-5 h-5 text-emerald-400" />
|
|
||||||
{t("ui.coreInfra")}
|
|
||||||
</CardTitle>
|
|
||||||
<Badge variant="info">4/4 Ativos</Badge>
|
|
||||||
</CardHeader>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{[
|
|
||||||
{ name: t("ui.smtpListener"), status: t("ui.online"), port: "Porta 25 / 587" },
|
|
||||||
{ name: t("ui.imapEngine"), status: t("ui.online"), port: "Porta 993 (SSL)" },
|
|
||||||
{ name: t("ui.scanners"), status: t("ui.healthy"), port: "Rspamd Engine" },
|
|
||||||
{ name: "Traefik Acme Watcher", status: t("ui.watching"), port: "fsnotify + poll" },
|
|
||||||
].map((svc, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex items-center justify-between p-3.5 rounded-xl bg-slate-900/80 border border-slate-800/80 hover:border-slate-700/60 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-ping" />
|
|
||||||
<div>
|
|
||||||
<span className="text-xs font-semibold text-slate-200 block">{svc.name}</span>
|
|
||||||
<span className="text-[10px] text-slate-400 font-mono">{svc.port}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="success">{svc.status}</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Domain Reconciliation */}
|
What stood here were two cards of decoration: a "core services" panel
|
||||||
<Card>
|
that listed SMTP, IMAP, Rspamd and the cert watcher with a hardcoded
|
||||||
<CardHeader>
|
"Online"/"4/4 active" and a pulsing green dot - nothing was ever polled,
|
||||||
<CardTitle className="flex items-center gap-2">
|
so it stayed green through any outage - and a domain panel pinned to
|
||||||
<Globe className="w-5 h-5 text-indigo-400" />
|
"example.com" with "13 RECORDS VERIFIED" and a "last checked: just now"
|
||||||
{t("ui.domainVerification")}
|
that was a literal string. Container liveness is now covered by the
|
||||||
</CardTitle>
|
compose healthchecks, and the per-domain DNS state lives on the domains
|
||||||
<Badge variant="success">Auto-Reconciled</Badge>
|
screen, which reads it from the database.
|
||||||
</CardHeader>
|
*/}
|
||||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800 space-y-3">
|
<Card>
|
||||||
<div className="flex items-center justify-between">
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
<Globe className="w-5 h-5 text-indigo-400" />
|
||||||
<span className="font-bold text-sm text-slate-100">example.com</span>
|
{t("ui.domainVerification")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
{metrics ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800">
|
||||||
|
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1.5">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
{t("ui.domainsVerified")}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{metrics.domains_verified}
|
||||||
|
<span className="text-slate-500 text-base"> / {metrics.domains_total}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="success">13 RECORDS VERIFIED</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 leading-relaxed">
|
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800">
|
||||||
Cloudflare DNS automation active. SPF, DKIM (RSA 2048 + Ed25519), DMARC (quarantine), MTA-STS e TLS-RPT totalmente sincronizados e validados.
|
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1.5">
|
||||||
</p>
|
<Server className="w-3.5 h-3.5 text-indigo-400" />
|
||||||
<div className="pt-2 flex items-center justify-between text-xs text-slate-400 border-t border-slate-800 font-mono">
|
{t("ui.mailboxesActive")}
|
||||||
<span>Last checked: just now</span>
|
</div>
|
||||||
<span className="text-indigo-400 hover:underline cursor-pointer">Verificar Registros →</span>
|
<div className="text-2xl font-bold text-white">{metrics.mailboxes_active}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800">
|
||||||
|
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1.5">
|
||||||
|
<Send className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
{t("ui.bounceRate")}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{(metrics.bounce_rate * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
) : (
|
||||||
</div>
|
<div className="p-6 text-center text-xs text-slate-500">{t("ui.noData")}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ export default function AdminDmarcPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/v1/admin/dmarc")
|
fetch("/api/v1/admin/dmarc")
|
||||||
.then((res) => res.json())
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
.then((d) => setData(d))
|
.then((d) => setData(d))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -34,27 +34,27 @@ export default function AdminDmarcPage() {
|
|||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.dmarcTitle")}</h1>
|
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.dmarcTitle")}</h1>
|
||||||
<p className="text-xs text-slate-400">Ingested XML aggregate reports breakdown (dmarc_reports table).</p>
|
<p className="text-xs 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 md:grid-cols-4 gap-6">
|
||||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
<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.totalEvaluated")}</div>
|
<div className="text-xs font-medium text-slate-400 mb-2">{t("ui.totalEvaluated")}</div>
|
||||||
<div className="text-3xl font-extrabold text-white">{data?.total_messages ?? 1250}</div>
|
<div className="text-3xl font-extrabold text-white">{data ? data.total_messages : t("ui.noData")}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
<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.spfRatio")}</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">
|
<div className="text-3xl font-extrabold text-emerald-400">
|
||||||
{data ? Math.round((data.spf_pass_count / data.total_messages) * 100) : 98}%
|
{data && data.total_messages > 0 ? `${Math.round((data.spf_pass_count / data.total_messages) * 100)}%` : t("ui.noData")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
<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-xs font-medium text-slate-400 mb-2">{t("ui.dkimAlignment")}</div>
|
||||||
<div className="text-3xl font-extrabold text-emerald-400">
|
<div className="text-3xl font-extrabold text-emerald-400">
|
||||||
{data ? Math.round((data.dkim_pass_count / data.total_messages) * 100) : 97}%
|
{data && data.total_messages > 0 ? `${Math.round((data.dkim_pass_count / data.total_messages) * 100)}%` : t("ui.noData")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export default function AdminDmarcPage() {
|
|||||||
{/* Sending Sources Table */}
|
{/* Sending Sources Table */}
|
||||||
<div className="glass-panel rounded-2xl border border-slate-800 overflow-hidden">
|
<div className="glass-panel rounded-2xl border border-slate-800 overflow-hidden">
|
||||||
<div className="p-4 border-b border-slate-800 bg-slate-900/60 font-bold text-xs text-slate-300">
|
<div className="p-4 border-b border-slate-800 bg-slate-900/60 font-bold text-xs text-slate-300">
|
||||||
Inbound Aggregate Reporting Sending IP Sources
|
{t("ui.sourceIp")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
|||||||
@@ -1,445 +1,360 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import { Globe, RefreshCw, Plus, KeyRound, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||||
Globe,
|
|
||||||
RefreshCw,
|
|
||||||
Plus,
|
|
||||||
KeyRound,
|
|
||||||
ShieldCheck,
|
|
||||||
CheckCircle2,
|
|
||||||
ArrowRight,
|
|
||||||
Copy,
|
|
||||||
Layers,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
|
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";
|
||||||
|
|
||||||
interface DnsRecord {
|
/**
|
||||||
type: string;
|
* Domains, read from the database.
|
||||||
|
*
|
||||||
|
* The previous version of this screen was a mockup end to end. It rendered a
|
||||||
|
* fixed table of thirteen DNS records for "example.com", every one of them
|
||||||
|
* marked VERIFIED; its onboarding wizard had four steps of which only the first
|
||||||
|
* called anything, step three printing "Cloudflare connection validated, 13
|
||||||
|
* records created and propagated" without a request behind it; and its DKIM
|
||||||
|
* rotation posted {domain_id: "default-domain"} to an endpoint whose field is
|
||||||
|
* "domain", swallowed the resulting 400, and displayed a made-up TXT record as
|
||||||
|
* though a key had been issued. Rotation therefore never once worked from here.
|
||||||
|
*
|
||||||
|
* What is shown now is what the API returns, and the one thing the backend
|
||||||
|
* cannot yet do - verify individual DNS records against a resolver - says so
|
||||||
|
* instead of drawing a table of green ticks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Domain {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
expectedValue: string;
|
dns_status: string;
|
||||||
actualValue: string;
|
dmarc_policy: string | null;
|
||||||
status: "VERIFIED" | "DRIFT" | "PARTIAL";
|
mta_sts_mode: string | null;
|
||||||
|
dane_enabled: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
dns_last_checked_at: string | null;
|
||||||
|
mailbox_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DNS_RECORDS_13: DnsRecord[] = [
|
function statusVariant(status: string): "success" | "warning" | "danger" {
|
||||||
{ type: "MX", name: "@", expectedValue: "10 mail.example.com", actualValue: "10 mail.example.com", status: "VERIFIED" },
|
if (status === "VERIFIED") return "success";
|
||||||
{ type: "TXT", name: "@", expectedValue: "v=spf1 mx ~all", actualValue: "v=spf1 mx ~all", status: "VERIFIED" },
|
if (status === "PENDING" || status === "PARTIAL") return "warning";
|
||||||
{ type: "TXT", name: "default._domainkey", expectedValue: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GN...", actualValue: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GN...", status: "VERIFIED" },
|
return "danger";
|
||||||
{ type: "TXT", name: "ed25519._domainkey", expectedValue: "v=DKIM1; k=ed25519; p=117p2...==", actualValue: "v=DKIM1; k=ed25519; p=117p2...==", status: "VERIFIED" },
|
}
|
||||||
{ type: "TXT", name: "_dmarc", expectedValue: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", actualValue: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", status: "VERIFIED" },
|
|
||||||
{ type: "TXT", name: "_mta-sts", expectedValue: "v=STSv1; id=20260802", actualValue: "v=STSv1; id=20260802", status: "VERIFIED" },
|
|
||||||
{ type: "CNAME", name: "mta-sts", expectedValue: "mail.example.com", actualValue: "mail.example.com", status: "VERIFIED" },
|
|
||||||
{ type: "TXT", name: "_smtp._tlsa", expectedValue: "3 1 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", actualValue: "3 1 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", status: "VERIFIED" },
|
|
||||||
{ type: "TXT", name: "_smtp._tls", expectedValue: "v=TLSRPTv1; rua=mailto:tlsrpt@example.com", actualValue: "v=TLSRPTv1; rua=mailto:tlsrpt@example.com", status: "VERIFIED" },
|
|
||||||
{ type: "SRV", name: "_autodiscover._tcp", expectedValue: "0 0 443 mail.example.com", actualValue: "0 0 443 mail.example.com", status: "VERIFIED" },
|
|
||||||
{ type: "SRV", name: "_submission._tcp", expectedValue: "0 0 587 mail.example.com", actualValue: "0 0 587 mail.example.com", status: "VERIFIED" },
|
|
||||||
{ type: "SRV", name: "_imaps._tcp", expectedValue: "0 0 993 mail.example.com", actualValue: "0 0 993 mail.example.com", status: "VERIFIED" },
|
|
||||||
{ type: "A", name: "mail", expectedValue: "203.0.113.195", actualValue: "203.0.113.195", status: "VERIFIED" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AdminDomainsPage() {
|
export default function AdminDomainsPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<"dns" | "onboarding" | "dkim">("dns");
|
const [activeTab, setActiveTab] = useState<"domains" | "onboarding" | "dkim">("domains");
|
||||||
|
const [domains, setDomains] = useState<Domain[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [reconciling, setReconciling] = useState(false);
|
const [reconciling, setReconciling] = useState(false);
|
||||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Guided Onboarding Wizard state
|
|
||||||
const [onboardStep, setOnboardStep] = useState(1);
|
|
||||||
const [onboardDomain, setOnboardDomain] = useState("");
|
const [onboardDomain, setOnboardDomain] = useState("");
|
||||||
const [onboardAdminEmail, setOnboardAdminEmail] = useState("");
|
const [onboarding, setOnboarding] = useState(false);
|
||||||
const [onboardSuccess, setOnboardSuccess] = useState(false);
|
|
||||||
|
|
||||||
// DKIM Rotation Wizard state
|
const [dkimAlgorithm, setDkimAlgorithm] = useState<"ed25519" | "rsa2048">("ed25519");
|
||||||
const [dkimKeyType, setDkimKeyType] = useState<"Ed25519" | "RSA-2048">("Ed25519");
|
const [dkimSelector, setDkimSelector] = useState("s1");
|
||||||
const [dkimSelector, setDkimSelector] = useState("s202608");
|
const [dkimRecord, setDkimRecord] = useState<{ name: string; value: string; overlapDays: number } | null>(null);
|
||||||
const [dkimRotationStep, setDkimRotationStep] = useState<"idle" | "generated" | "active">("idle");
|
const [rotating, setRotating] = useState(false);
|
||||||
const [generatedDkimTxt, setGeneratedDkimTxt] = useState<string | null>(null);
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/admin/domains");
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data: Domain[] = await res.json();
|
||||||
|
setDomains(data);
|
||||||
|
// Keeps the current pick if it still exists, so a refresh after an action
|
||||||
|
// does not silently retarget the next operation at another domain.
|
||||||
|
setSelectedId((prev) => (data.some((d) => d.id === prev) ? prev : (data[0]?.id ?? "")));
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
setDomains([]);
|
||||||
|
setError(t("errors.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const selected = domains.find((d) => d.id === selectedId) ?? null;
|
||||||
|
|
||||||
const handleReconcile = async () => {
|
const handleReconcile = async () => {
|
||||||
|
if (!selected) return;
|
||||||
setReconciling(true);
|
setReconciling(true);
|
||||||
setSyncMessage(null);
|
setNotice(null);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
// The real domain id, not the literal string "default-domain" that used
|
||||||
|
// to be sent here for every domain on the server.
|
||||||
const res = await fetch("/api/v1/admin/domains/reconcile", {
|
const res = await fetch("/api/v1/admin/domains/reconcile", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ domain_id: "default-domain" }),
|
body: JSON.stringify({ domain_id: selected.id }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
const data = await res.json().catch(() => ({}));
|
||||||
setSyncMessage("Cloudflare sync finished: all 13 DNS records verified.");
|
if (!res.ok) throw new Error(data.message);
|
||||||
} else {
|
setNotice(data.message ?? t("settings.settingsSaved"));
|
||||||
setSyncMessage("DNS reconciliation requested.");
|
await load();
|
||||||
}
|
} catch (err) {
|
||||||
} catch {
|
setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
|
||||||
setSyncMessage("Could not reach the reconciliation endpoint.");
|
|
||||||
} finally {
|
} finally {
|
||||||
setReconciling(false);
|
setReconciling(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartDkimRotation = async () => {
|
const handleOnboard = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!onboardDomain.trim()) return;
|
||||||
|
setOnboarding(true);
|
||||||
|
setNotice(null);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await fetch("/api/v1/admin/dkim/rotate", {
|
const res = await fetch("/api/v1/admin/domains/onboard", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ domain: onboardDomain.trim() }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.message);
|
||||||
|
setOnboardDomain("");
|
||||||
|
setNotice(`${data.name}`);
|
||||||
|
await load();
|
||||||
|
setActiveTab("domains");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
|
||||||
|
} finally {
|
||||||
|
setOnboarding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRotateDkim = async () => {
|
||||||
|
if (!selected) return;
|
||||||
|
setRotating(true);
|
||||||
|
setNotice(null);
|
||||||
|
setError(null);
|
||||||
|
setDkimRecord(null);
|
||||||
|
try {
|
||||||
|
// The endpoint's field is "domain" and it wants the name. Sending the
|
||||||
|
// wrong field is what made every previous rotation fail unnoticed.
|
||||||
|
const res = await fetch("/api/v1/admin/dkim/rotate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
domain_id: "default-domain",
|
domain: selected.name,
|
||||||
selector: dkimSelector,
|
selector: dkimSelector,
|
||||||
algorithm: dkimKeyType.toLowerCase().includes("ed") ? "ed25519" : "rsa2048",
|
algorithm: dkimAlgorithm,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch {
|
const data = await res.json().catch(() => ({}));
|
||||||
// fallback handling
|
if (!res.ok) throw new Error(data.message);
|
||||||
|
// The key that was actually generated and sealed, not a sample string.
|
||||||
|
setDkimRecord({
|
||||||
|
name: `${data.selector}._domainkey.${data.domain}`,
|
||||||
|
value: data.public_key_record,
|
||||||
|
overlapDays: data.overlap_days,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
|
||||||
|
} finally {
|
||||||
|
setRotating(false);
|
||||||
}
|
}
|
||||||
setDkimRotationStep("generated");
|
|
||||||
setGeneratedDkimTxt(`v=DKIM1; k=${dkimKeyType.toLowerCase().includes("ed") ? "ed25519" : "rsa"}; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC3...`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleActivateDkim = () => {
|
const tabs = [
|
||||||
setDkimRotationStep("active");
|
{ id: "domains" as const, label: t("admin.domainsTitle"), icon: Globe },
|
||||||
};
|
{ id: "onboarding" as const, label: t("common.add"), icon: Plus },
|
||||||
|
{ id: "dkim" as const, label: "DKIM", icon: KeyRound },
|
||||||
const handleNextOnboardStep = async (e: React.FormEvent) => {
|
];
|
||||||
e.preventDefault();
|
|
||||||
if (onboardStep === 1 && !onboardDomain) return;
|
|
||||||
if (onboardStep === 1) {
|
|
||||||
try {
|
|
||||||
await fetch("/api/v1/admin/domains/onboard", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ domain: onboardDomain }),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (onboardStep < 4) {
|
|
||||||
setOnboardStep(onboardStep + 1);
|
|
||||||
} else {
|
|
||||||
setOnboardSuccess(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto">
|
<div className="p-6 md:p-8 space-y-6 max-w-7xl mx-auto">
|
||||||
{/* Header & Tabs */}
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight flex items-center gap-3">
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
||||||
{t("admin.domainsTitle")}
|
{t("admin.domainsTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-400 mt-1">
|
|
||||||
DNS reconciliation, guided domain onboarding and signing key rotation DKIM.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Selection 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">
|
<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">
|
||||||
<button
|
{tabs.map((tab) => {
|
||||||
onClick={() => setActiveTab("dns")}
|
const Icon = tab.icon;
|
||||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
return (
|
||||||
activeTab === "dns"
|
<button
|
||||||
? "bg-emerald-600 text-white shadow-sm"
|
key={tab.id}
|
||||||
: "text-slate-400 hover:text-slate-200"
|
type="button"
|
||||||
}`}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
>
|
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
||||||
<Globe className="w-3.5 h-3.5" />
|
activeTab === tab.id
|
||||||
<span>Matriz 13 Registros DNS</span>
|
? "bg-emerald-600 text-white shadow-sm"
|
||||||
</button>
|
: "text-slate-400 hover:text-slate-200"
|
||||||
|
}`}
|
||||||
<button
|
>
|
||||||
onClick={() => setActiveTab("onboarding")}
|
<Icon className="w-3.5 h-3.5" />
|
||||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
<span>{tab.label}</span>
|
||||||
activeTab === "onboarding"
|
</button>
|
||||||
? "bg-emerald-600 text-white shadow-sm"
|
);
|
||||||
: "text-slate-400 hover:text-slate-200"
|
})}
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" />
|
|
||||||
<span>Onboarding Guiado</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("dkim")}
|
|
||||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
|
||||||
activeTab === "dkim"
|
|
||||||
? "bg-emerald-600 text-white shadow-sm"
|
|
||||||
: "text-slate-400 hover:text-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<KeyRound className="w-3.5 h-3.5" />
|
|
||||||
<span>DKIM key rotation</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TAB 1: 13 DNS RECORDS RECONCILIATION */}
|
{notice && (
|
||||||
{activeTab === "dns" && (
|
<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="space-y-6">
|
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<span>{notice}</span>
|
||||||
<Card className="flex-1 p-4 flex items-center justify-between">
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
)}
|
||||||
<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 font-bold">
|
{error && (
|
||||||
<Globe className="w-5 h-5" />
|
<div className="p-4 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-xs">
|
||||||
</div>
|
{error}
|
||||||
<div>
|
</div>
|
||||||
<div className="text-base font-bold text-white">example.com</div>
|
)}
|
||||||
<div className="text-xs text-slate-400">Primary domain | DMARC: quarantine | MTA-STS: testing</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="success">13 / 13 VERIFICADOS</Badge>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button
|
{activeTab === "domains" && (
|
||||||
variant="primary"
|
<div className="space-y-4">
|
||||||
size="md"
|
{domains.length === 0 && !loading ? (
|
||||||
onClick={handleReconcile}
|
<Card className="p-8 text-center text-xs text-slate-500">{t("admin.noDomains")}</Card>
|
||||||
disabled={reconciling}
|
) : (
|
||||||
className="self-stretch sm:self-auto"
|
domains.map((d) => (
|
||||||
>
|
<Card key={d.id} className="p-4">
|
||||||
<RefreshCw className={`w-4 h-4 ${reconciling ? "animate-spin" : ""}`} />
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
<span>{reconciling ? "Sincronizando Cloudflare API..." : "Reconciliar Registros DNS"}</span>
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
</Button>
|
<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">
|
||||||
</div>
|
<Globe className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
{syncMessage && (
|
<div className="min-w-0">
|
||||||
<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="text-base font-bold text-white truncate">{d.name}</div>
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 flex-shrink-0" />
|
<div className="text-xs text-slate-400">
|
||||||
<span>{syncMessage}</span>
|
{t("admin.mailboxCount", { count: d.mailbox_count })}
|
||||||
</div>
|
{d.dmarc_policy ? ` | DMARC: ${d.dmarc_policy}` : ""}
|
||||||
|
{d.mta_sts_mode ? ` | MTA-STS: ${d.mta_sts_mode}` : ""}
|
||||||
|
{d.dane_enabled ? " | DANE" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Badge variant={statusVariant(d.dns_status)}>{d.dns_status}</Badge>
|
||||||
|
<Button
|
||||||
|
variant={selectedId === d.id ? "primary" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedId(d.id)}
|
||||||
|
>
|
||||||
|
{selectedId === d.id ? <CheckCircle2 className="w-3.5 h-3.5" /> : null}
|
||||||
|
<span>{selectedId === d.id ? t("common.status") : t("common.add")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-800 text-[11px] text-slate-500 font-mono">
|
||||||
|
{t("admin.lastChecked", {
|
||||||
|
when: d.dns_last_checked_at
|
||||||
|
? new Date(d.dns_last_checked_at).toLocaleString()
|
||||||
|
: t("common.never"),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="p-0 overflow-hidden">
|
{/* Says what reconciliation currently does. The old copy announced
|
||||||
<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">
|
"Cloudflare sync finished: all 13 DNS records verified" on every
|
||||||
<span>Expected 13 DNS records versus actual state</span>
|
press - including when the request failed - while the endpoint
|
||||||
</div>
|
behind it only writes an audit entry. */}
|
||||||
|
<Card className="space-y-3">
|
||||||
<div className="overflow-x-auto">
|
<div className="flex items-start gap-2 text-xs text-amber-300">
|
||||||
<table className="w-full text-left border-collapse text-xs">
|
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||||
<thead>
|
<p>{t("admin.reconcileNotImplemented")}</p>
|
||||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
|
||||||
<th className="p-3.5">Tipo</th>
|
|
||||||
<th className="p-3.5">{t("ui.recordName")}</th>
|
|
||||||
<th className="p-3.5">{t("ui.expectedValue")}</th>
|
|
||||||
<th className="p-3.5">{t("ui.actualRecord")}</th>
|
|
||||||
<th className="p-3.5">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
|
||||||
{DNS_RECORDS_13.map((rec, idx) => (
|
|
||||||
<tr key={idx} className="hover:bg-slate-900/40 transition-colors">
|
|
||||||
<td className="p-3.5 font-bold text-emerald-400">{rec.type}</td>
|
|
||||||
<td className="p-3.5 text-slate-200">{rec.name}</td>
|
|
||||||
<td className="p-3.5 text-slate-300 truncate max-w-xs">{rec.expectedValue}</td>
|
|
||||||
<td className="p-3.5 text-slate-400 truncate max-w-xs">{rec.actualValue}</td>
|
|
||||||
<td className="p-3.5">
|
|
||||||
<Badge variant="success">{rec.status}</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleReconcile} disabled={reconciling || !selected}>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${reconciling ? "animate-spin" : ""}`} />
|
||||||
|
<span>{t("admin.reconcileDns")}</span>
|
||||||
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAB 2: GUIDED DOMAIN ONBOARDING WIZARD */}
|
|
||||||
{activeTab === "onboarding" && (
|
{activeTab === "onboarding" && (
|
||||||
<Card className="max-w-3xl space-y-6">
|
<Card className="max-w-2xl space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-white mb-1 flex items-center gap-2">
|
<h2 className="text-lg font-bold text-white mb-1">{t("admin.onboardTitle")}</h2>
|
||||||
<Sparkles className="w-5 h-5 text-emerald-400" />
|
<p className="text-xs text-slate-400">{t("admin.onboardIntro")}</p>
|
||||||
Guided domain onboarding
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-slate-400">
|
|
||||||
Step by step provisioning for new domains and their Cloudflare records DNS.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<form onSubmit={handleOnboard} className="space-y-3 text-xs">
|
||||||
{/* Stepper Header */}
|
<div>
|
||||||
<div className="grid grid-cols-4 gap-2 text-center text-xs">
|
<label className="font-semibold text-slate-300 block mb-1">{t("admin.domainName")}</label>
|
||||||
{["1. Domain", "2. DNS records", "3. Verification", "4. Aliases"].map((label, idx) => (
|
<input
|
||||||
<div
|
type="text"
|
||||||
key={idx}
|
value={onboardDomain}
|
||||||
className={`p-2.5 rounded-xl border font-semibold transition-all ${
|
onChange={(e) => setOnboardDomain(e.target.value)}
|
||||||
onboardStep === idx + 1
|
placeholder="example.org"
|
||||||
? "bg-emerald-600/20 border-emerald-500 text-emerald-300 shadow-sm"
|
required
|
||||||
: onboardStep > idx + 1
|
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"
|
||||||
? "bg-slate-900 border-slate-700 text-slate-400"
|
/>
|
||||||
: "bg-slate-950 border-slate-800 text-slate-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{onboardSuccess ? (
|
|
||||||
<div className="p-5 rounded-2xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 space-y-2">
|
|
||||||
<div className="font-bold text-base flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
|
|
||||||
Domain onboarding complete
|
|
||||||
</div>
|
|
||||||
<p className="text-xs leading-relaxed">
|
|
||||||
The domain <strong>{onboardDomain}</strong> was provisioned with the 13 DNS records and the RFC 2142 aliases.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<Button type="submit" variant="primary" size="md" disabled={onboarding}>
|
||||||
<form onSubmit={handleNextOnboardStep} className="space-y-4 text-xs">
|
<Plus className="w-4 h-4" />
|
||||||
{onboardStep === 1 && (
|
<span>{onboarding ? t("common.loading") : t("common.add")}</span>
|
||||||
<div className="space-y-3">
|
</Button>
|
||||||
<div>
|
</form>
|
||||||
<label className="font-semibold text-slate-300 block mb-1">Domain name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={onboardDomain}
|
|
||||||
onChange={(e) => setOnboardDomain(e.target.value)}
|
|
||||||
placeholder="novafirma.com"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="font-semibold text-slate-300 block mb-1">E-mail do Administrador Principal</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={onboardAdminEmail}
|
|
||||||
onChange={(e) => setOnboardAdminEmail(e.target.value)}
|
|
||||||
placeholder="admin@novafirma.com"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onboardStep === 2 && (
|
|
||||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-2">
|
|
||||||
<div className="font-bold text-white">The 13 DNS records generated:</div>
|
|
||||||
<pre className="font-mono text-[11px] text-slate-300 leading-relaxed overflow-x-auto p-3 bg-slate-950 rounded-lg border border-slate-800">
|
|
||||||
MX 10 mail.{onboardDomain}
|
|
||||||
TXT v=spf1 mx ~all
|
|
||||||
TXT default._domainkey IN TXT "v=DKIM1; k=rsa; p=..."
|
|
||||||
TXT ed25519._domainkey IN TXT "v=DKIM1; k=ed25519; p=..."
|
|
||||||
TXT _dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@{onboardDomain}"
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onboardStep === 3 && (
|
|
||||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-2">
|
|
||||||
<div className="font-bold text-white">Verificando Registros via API Cloudflare...</div>
|
|
||||||
<div className="p-3 bg-slate-950 rounded-lg text-emerald-400 font-mono text-xs flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
|
||||||
<span>Cloudflare connection validated. 13 records created and propagated.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onboardStep === 4 && (
|
|
||||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-2">
|
|
||||||
<div className="font-bold text-white">Provisioning the standard aliases (RFC 2142):</div>
|
|
||||||
<ul className="list-disc list-inside text-slate-300 space-y-1 font-mono text-xs">
|
|
||||||
<li>postmaster@{onboardDomain} → {onboardAdminEmail}</li>
|
|
||||||
<li>abuse@{onboardDomain} → {onboardAdminEmail}</li>
|
|
||||||
<li>security@{onboardDomain} → {onboardAdminEmail}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-between pt-4 border-t border-slate-800">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="md"
|
|
||||||
disabled={onboardStep === 1}
|
|
||||||
onClick={() => setOnboardStep(onboardStep - 1)}
|
|
||||||
>
|
|
||||||
Voltar
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" variant="primary" size="md">
|
|
||||||
<span>{onboardStep === 4 ? "Concluir Onboarding" : "Next step"}</span>
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAB 3: GUIDED DKIM KEY ROTATION WIZARD */}
|
|
||||||
{activeTab === "dkim" && (
|
{activeTab === "dkim" && (
|
||||||
<Card className="max-w-3xl space-y-6">
|
<Card className="max-w-3xl space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-white mb-1 flex items-center gap-2">
|
<h2 className="text-lg font-bold text-white mb-1 flex items-center gap-2">
|
||||||
<KeyRound className="w-5 h-5 text-indigo-400" />
|
<KeyRound className="w-5 h-5 text-indigo-400" />
|
||||||
DKIM key rotation
|
{t("admin.dkimTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">
|
||||||
Replace a domain signing key without interrupting deliverability.
|
{selected ? selected.name : t("admin.noDomains")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">Tipo da Chave de Assinatura</label>
|
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.dkimAlgorithm")}</label>
|
||||||
<select
|
<select
|
||||||
value={dkimKeyType}
|
value={dkimAlgorithm}
|
||||||
onChange={(e) => setDkimKeyType(e.target.value as "Ed25519" | "RSA-2048")}
|
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="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"
|
||||||
>
|
>
|
||||||
<option value="Ed25519">Ed25519 (fast and compact)</option>
|
<option value="ed25519">Ed25519</option>
|
||||||
<option value="RSA-2048">RSA-2048 (Compatibilidade Legada)</option>
|
<option value="rsa2048">RSA-2048</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">Nome do Seletor DKIM</label>
|
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.dkimSelector")}</label>
|
||||||
<input
|
<input
|
||||||
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}"
|
||||||
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="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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dkimRotationStep === "idle" && (
|
<Button variant="primary" size="md" onClick={handleRotateDkim} disabled={rotating || !selected}>
|
||||||
<Button variant="primary" size="md" onClick={handleStartDkimRotation}>
|
<KeyRound className="w-4 h-4" />
|
||||||
<KeyRound className="w-4 h-4" />
|
<span>{rotating ? t("common.loading") : t("admin.dkimRotate")}</span>
|
||||||
<span>Gerar Novo Par de Chaves DKIM</span>
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dkimRotationStep === "generated" && generatedDkimTxt && (
|
{dkimRecord && (
|
||||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-4 text-xs">
|
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-3 text-xs">
|
||||||
<div className="font-bold text-white">1. Adicione o Novo Registro de Seletor DKIM no DNS:</div>
|
<div className="font-bold text-white">{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="p-3 bg-slate-950 rounded-lg border border-slate-800 font-mono text-[11px] text-emerald-400 break-all">
|
||||||
{dkimSelector}._domainkey.example.com IN TXT "{generatedDkimTxt}"
|
{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="p-3.5 rounded-xl bg-amber-500/15 border border-amber-500/25 text-amber-300">
|
||||||
<strong>Dual-signature overlap active (7 days)</strong>
|
{t("admin.dkimOverlap", { days: dkimRecord.overlapDays })}
|
||||||
<p className="mt-1">Both keys sign outbound mail so nothing fails while DNS propagates.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="primary" size="md" onClick={handleActivateDkim}>
|
|
||||||
Ativar Nova Chave e Desativar Anterior
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dkimRotationStep === "active" && (
|
|
||||||
<div className="p-4 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 font-bold text-xs flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
|
||||||
<span>DKIM key rotation complete. The selector "{dkimSelector}" is active.</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -16,14 +16,17 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
Lock,
|
Lock,
|
||||||
|
Mail,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
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";
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
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: LayoutDashboard },
|
||||||
@@ -56,14 +59,16 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Security Badge Indicator */}
|
{/* Leaving the console needs no ceremony - the webmail session was
|
||||||
<div className="mb-6 p-2.5 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-xs text-emerald-300 flex items-center gap-2">
|
never given up to get here, so this is a plain link back. Coming the
|
||||||
<Lock className="w-4 h-4 text-emerald-400 flex-shrink-0" />
|
other way is what costs a second factor. */}
|
||||||
<div>
|
<Link
|
||||||
<strong className="block text-[11px] font-semibold text-emerald-300">Isolated Surface</strong>
|
href="/user/mail/inbox"
|
||||||
<span className="text-[10px] text-emerald-400/80 font-mono">aud: lambdamail:admin</span>
|
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"
|
||||||
</div>
|
>
|
||||||
</div>
|
<Mail className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate">{t("admin.backToWebmail")}</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Admin Navigation */}
|
{/* Admin Navigation */}
|
||||||
<nav className="space-y-1 text-sm font-medium">
|
<nav className="space-y-1 text-sm font-medium">
|
||||||
@@ -107,12 +112,17 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<div className="pt-3 border-t border-slate-800/80">
|
<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 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="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">
|
<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">
|
||||||
A
|
{account?.email?.[0] ?? "?"}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<div className="text-xs font-semibold text-slate-200 truncate">admin@lambdamail.local</div>
|
<div className="text-xs font-semibold text-slate-200 truncate">
|
||||||
<div className="text-[10px] text-emerald-400 font-mono">SUPER_ADMIN (2FA)</div>
|
{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>
|
||||||
@@ -129,7 +139,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<div className="w-8 h-8 rounded-lg bg-emerald-600 flex items-center justify-center text-white font-bold">
|
<div className="w-8 h-8 rounded-lg bg-emerald-600 flex items-center justify-center text-white font-bold">
|
||||||
<Sliders className="w-4 h-4" />
|
<Sliders className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-slate-100">LambdaMail Admin</span>
|
<span className="font-bold text-slate-100">{t("common.appName")} {t("common.adminPortal")}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileOpen(!mobileOpen)}
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Unlock,
|
Unlock,
|
||||||
Trash2,
|
Trash2,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldAlert,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Upload,
|
Upload,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -106,7 +105,7 @@ export default function AdminMailboxesPage() {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Could not load the accounts.");
|
setError(t("errors.loadFailed"));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -222,7 +221,7 @@ export default function AdminMailboxesPage() {
|
|||||||
setError(data.message ?? "Falha ao importar CSV");
|
setError(data.message ?? "Falha ao importar CSV");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError("Network error during the bulk import.");
|
setError(t("errors.serverError"));
|
||||||
}
|
}
|
||||||
setTimeout(() => setCsvSuccessMessage(null), 4000);
|
setTimeout(() => setCsvSuccessMessage(null), 4000);
|
||||||
};
|
};
|
||||||
@@ -236,7 +235,7 @@ export default function AdminMailboxesPage() {
|
|||||||
{t("admin.mailboxesTitle")}
|
{t("admin.mailboxesTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-400 mt-1">
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
Accounts, aliases, CSV import and authentication policy.
|
{t("admin.mailboxesSubtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -275,7 +274,7 @@ export default function AdminMailboxesPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileSpreadsheet className="w-3.5 h-3.5" />
|
<FileSpreadsheet className="w-3.5 h-3.5" />
|
||||||
<span>CSV import</span>
|
<span>CSV</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,10 +292,10 @@ export default function AdminMailboxesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-bold text-slate-100 flex items-center gap-2">
|
<h2 className="text-sm font-bold text-slate-100 flex items-center gap-2">
|
||||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||||
Two-factor requirement policy
|
{t("admin.mfaPolicyTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
Choose how strictly this domain requires a second factor.
|
{t("admin.mfaPolicyIntro")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -309,7 +308,7 @@ export default function AdminMailboxesPage() {
|
|||||||
: "border-slate-800 text-slate-400 hover:text-slate-200"
|
: "border-slate-800 text-slate-400 hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Opcional
|
{t("admin.mfaOptional")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMfaPolicy("required_admins")}
|
onClick={() => setMfaPolicy("required_admins")}
|
||||||
@@ -319,7 +318,7 @@ export default function AdminMailboxesPage() {
|
|||||||
: "border-slate-800 text-slate-400 hover:text-slate-200"
|
: "border-slate-800 text-slate-400 hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Required for admins
|
{t("admin.mfaAdmins")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMfaPolicy("required_all")}
|
onClick={() => setMfaPolicy("required_all")}
|
||||||
@@ -329,7 +328,7 @@ export default function AdminMailboxesPage() {
|
|||||||
: "border-slate-800 text-slate-400 hover:text-slate-200"
|
: "border-slate-800 text-slate-400 hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Required for everyone
|
{t("admin.mfaEveryone")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,7 +357,7 @@ export default function AdminMailboxesPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
placeholder="Initial password (at least 12 characters)"
|
placeholder={t("ui.newPassword")}
|
||||||
minLength={12}
|
minLength={12}
|
||||||
required
|
required
|
||||||
className="px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500"
|
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"
|
||||||
@@ -376,11 +375,11 @@ export default function AdminMailboxesPage() {
|
|||||||
type="number"
|
type="number"
|
||||||
value={newQuota}
|
value={newQuota}
|
||||||
onChange={(e) => setNewQuota(parseInt(e.target.value, 10))}
|
onChange={(e) => setNewQuota(parseInt(e.target.value, 10))}
|
||||||
placeholder="Cota (MB)"
|
placeholder={t("ui.storageQuota")}
|
||||||
className="px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-emerald-500"
|
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"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variant="primary" size="md">
|
<Button type="submit" variant="primary" size="md">
|
||||||
Criar Conta
|
{t("admin.createAccount")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -395,11 +394,11 @@ export default function AdminMailboxesPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
||||||
<th className="p-3.5">{t("ui.emailAddress")}</th>
|
<th className="p-3.5">{t("ui.emailAddress")}</th>
|
||||||
<th className="p-3.5">Role</th>
|
|
||||||
<th className="p-3.5">{t("ui.storageQuota")}</th>
|
|
||||||
<th className="p-3.5">Status 2FA</th>
|
|
||||||
<th className="p-3.5">{t("ui.accountStatus")}</th>
|
<th className="p-3.5">{t("ui.accountStatus")}</th>
|
||||||
<th className="p-3.5">Actions</th>
|
<th className="p-3.5">{t("ui.storageQuota")}</th>
|
||||||
|
<th className="p-3.5">{t("ui.twoFactorSection")}</th>
|
||||||
|
<th className="p-3.5">{t("ui.accountStatus")}</th>
|
||||||
|
<th className="p-3.5">{t("ui.actions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
<tbody className="divide-y divide-slate-800/60 font-mono">
|
||||||
@@ -465,7 +464,7 @@ export default function AdminMailboxesPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-sm">
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
<Plus className="w-4 h-4 text-emerald-400" />
|
<Plus className="w-4 h-4 text-emerald-400" />
|
||||||
Criar Alias de Roteamento
|
{t("admin.createAlias")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<form onSubmit={handleCreateAlias} className="flex flex-col sm:flex-row gap-3 text-xs">
|
<form onSubmit={handleCreateAlias} className="flex flex-col sm:flex-row gap-3 text-xs">
|
||||||
@@ -486,7 +485,7 @@ export default function AdminMailboxesPage() {
|
|||||||
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="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"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variant="primary" size="md">
|
<Button type="submit" variant="primary" size="md">
|
||||||
Adicionar Alias
|
{t("admin.addAlias")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -500,10 +499,10 @@ export default function AdminMailboxesPage() {
|
|||||||
<table className="w-full text-left border-collapse text-xs">
|
<table className="w-full text-left border-collapse text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
||||||
<th className="p-3.5">Alias address</th>
|
<th className="p-3.5">{t("ui.emailAddress")}</th>
|
||||||
<th className="p-3.5">Destino Final</th>
|
<th className="p-3.5">{t("ui.destination")}</th>
|
||||||
<th className="p-3.5">Domain</th>
|
<th className="p-3.5">{t("admin.domainsTitle")}</th>
|
||||||
<th className="p-3.5">Actions</th>
|
<th className="p-3.5">{t("ui.actions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
<tbody className="divide-y divide-slate-800/60 font-mono">
|
||||||
@@ -563,7 +562,7 @@ export default function AdminMailboxesPage() {
|
|||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button variant="secondary" size="md" onClick={handleParseCsv}>
|
<Button variant="secondary" size="md" onClick={handleParseCsv}>
|
||||||
Preview CSV
|
{t("admin.csvPreview")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{csvPreview.length > 0 && (
|
{csvPreview.length > 0 && (
|
||||||
|
|||||||
@@ -1,104 +1,201 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outbound queue, read from the database.
|
||||||
|
*
|
||||||
|
* This screen used to be a fixture: two invented jobs ("job-101",
|
||||||
|
* user@example.com -> partner@remotehost.com) held in component state, whose
|
||||||
|
* Retry and Freeze buttons only edited that local array. It showed the same two
|
||||||
|
* rows on every deployment, never displayed a real stuck message, and reported
|
||||||
|
* success for actions that had not happened. /api/v1/admin/queue was already
|
||||||
|
* serving the real thing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Mirrors the outbound_jobs row shape returned by the queue endpoint. */
|
||||||
interface QueueJob {
|
interface QueueJob {
|
||||||
id: string;
|
id: string;
|
||||||
sender: string;
|
envelope_from: string;
|
||||||
recipient: string;
|
envelope_to: string;
|
||||||
destinationDomain: string;
|
destination_domain: string;
|
||||||
attempts: number;
|
status: string;
|
||||||
lastError: string;
|
attempt: number;
|
||||||
status: "QUEUED" | "RETRIABLE" | "FROZEN";
|
next_attempt_at: string | null;
|
||||||
|
last_smtp_code: number | null;
|
||||||
|
last_error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_JOBS: QueueJob[] = [
|
interface QueueSummary {
|
||||||
{ id: "job-101", sender: "user@example.com", recipient: "partner@remotehost.com", destinationDomain: "remotehost.com", attempts: 1, lastError: "Connection timeout to 198.51.100.25:25", status: "RETRIABLE" },
|
by_status: Record<string, number>;
|
||||||
{ id: "job-102", sender: "admin@example.com", recipient: "alerts@monitoring.org", destinationDomain: "monitoring.org", attempts: 0, lastError: "None", status: "QUEUED" },
|
recent: QueueJob[];
|
||||||
];
|
}
|
||||||
|
|
||||||
|
/** Only failure states earn a warning colour; the rest are ordinary progress. */
|
||||||
|
function statusClass(status: string): string {
|
||||||
|
if (status === "BOUNCED" || status === "FROZEN") return "badge-danger";
|
||||||
|
if (status === "DEFERRED") return "badge-warning";
|
||||||
|
return "badge-verified";
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminQueuePage() {
|
export default function AdminQueuePage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [jobs, setJobs] = useState<QueueJob[]>(INITIAL_JOBS);
|
const [summary, setSummary] = useState<QueueSummary | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const handleRetry = (id: string) => {
|
const load = useCallback(async () => {
|
||||||
setJobs(jobs.map((j) => (j.id === id ? { ...j, status: "QUEUED", attempts: j.attempts + 1 } : j)));
|
setLoading(true);
|
||||||
setMessage(`Job ${id} forced for immediate retry attempt.`);
|
try {
|
||||||
|
const res = await fetch("/api/v1/admin/queue");
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
setSummary(await res.json());
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
// Left null so the table renders empty rather than showing stale or
|
||||||
|
// invented rows while the service is unreachable.
|
||||||
|
setSummary(null);
|
||||||
|
setError(t("errors.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry and cancel are the two actions the service actually implements.
|
||||||
|
* The old "Freeze" button named a third that has never existed anywhere in
|
||||||
|
* the backend, so pressing it changed nothing but the colour of a cell.
|
||||||
|
*/
|
||||||
|
const act = async (id: string, action: "retry" | "cancel") => {
|
||||||
|
setMessage(null);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(`/api/v1/admin/queue/${encodeURIComponent(id)}/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(t("errors.serverError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessage(t(action === "retry" ? "admin.jobRetried" : "admin.jobCancelled", { id }));
|
||||||
|
// Re-read rather than patching local state, so what is on screen is what
|
||||||
|
// the queue now holds.
|
||||||
|
await load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFreeze = (domain: string) => {
|
const jobs = summary?.recent ?? [];
|
||||||
setJobs(jobs.map((j) => (j.destinationDomain === domain ? { ...j, status: "FROZEN" } : j)));
|
|
||||||
setMessage(`Delivery destination domain '${domain}' has been frozen.`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-6 md:p-8 space-y-6 max-w-7xl mx-auto">
|
||||||
<div>
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.queueTitle")}</h1>
|
<div>
|
||||||
<p className="text-xs text-slate-400">Inspect outbound queue, retry retriable jobs, or freeze problematic destinations.</p>
|
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.queueTitle")}</h1>
|
||||||
|
<p className="text-xs text-slate-400">{t("admin.queueSubtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Counts per status, straight from the by_status aggregate. */}
|
||||||
|
{summary && Object.keys(summary.by_status).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(summary.by_status).map(([status, count]) => (
|
||||||
|
<span
|
||||||
|
key={status}
|
||||||
|
className="rounded-lg border border-slate-800 bg-slate-900/70 px-3 py-1.5 text-xs text-slate-300"
|
||||||
|
>
|
||||||
|
<span className="font-mono font-bold text-white">{count}</span> {status}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</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="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs">
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Queue Jobs Table */}
|
|
||||||
<div className="glass-panel rounded-2xl border border-slate-800 overflow-hidden">
|
<div className="glass-panel rounded-2xl border border-slate-800 overflow-hidden">
|
||||||
<div className="p-4 border-b border-slate-800 bg-slate-900/60 font-bold text-xs text-slate-300">
|
<div className="p-4 border-b border-slate-800 bg-slate-900/60 font-bold text-xs text-slate-300">
|
||||||
Outbound Jobs ({jobs.length})
|
{t("admin.queueTitle")} ({jobs.length})
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
{jobs.length === 0 && !loading ? (
|
||||||
<table className="w-full text-left border-collapse text-xs">
|
<div className="p-8 text-center text-xs text-slate-500">{t("admin.queueEmpty")}</div>
|
||||||
<thead>
|
) : (
|
||||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
<div className="overflow-x-auto">
|
||||||
<th className="p-3">{t("ui.jobId")}</th>
|
<table className="w-full text-left border-collapse text-xs">
|
||||||
<th className="p-3">{t("ui.sender")}</th>
|
<thead>
|
||||||
<th className="p-3">{t("ui.recipient")}</th>
|
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
||||||
<th className="p-3">{t("ui.destination")}</th>
|
<th className="p-3">{t("ui.jobId")}</th>
|
||||||
<th className="p-3">{t("ui.attempts")}</th>
|
<th className="p-3">{t("ui.sender")}</th>
|
||||||
<th className="p-3">{t("ui.lastError")}</th>
|
<th className="p-3">{t("ui.recipient")}</th>
|
||||||
<th className="p-3">Status</th>
|
<th className="p-3">{t("ui.destination")}</th>
|
||||||
<th className="p-3">{t("ui.actions")}</th>
|
<th className="p-3">{t("ui.attempts")}</th>
|
||||||
</tr>
|
<th className="p-3">{t("ui.lastError")}</th>
|
||||||
</thead>
|
<th className="p-3">{t("common.status")}</th>
|
||||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
<th className="p-3">{t("ui.actions")}</th>
|
||||||
{jobs.map((j) => (
|
|
||||||
<tr key={j.id} className="hover:bg-slate-900/30">
|
|
||||||
<td className="p-3 font-bold text-slate-200">{j.id}</td>
|
|
||||||
<td className="p-3 text-slate-300">{j.sender}</td>
|
|
||||||
<td className="p-3 text-slate-300">{j.recipient}</td>
|
|
||||||
<td className="p-3 text-emerald-400">{j.destinationDomain}</td>
|
|
||||||
<td className="p-3 text-slate-400">{j.attempts}</td>
|
|
||||||
<td className="p-3 text-slate-400 truncate max-w-xs">{j.lastError}</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<span className={j.status === "QUEUED" ? "badge-verified px-2 py-0.5 rounded text-[10px]" : "badge-warning px-2 py-0.5 rounded text-[10px]"}>
|
|
||||||
{j.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="p-3 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleRetry(j.id)}
|
|
||||||
className="px-2 py-1 rounded bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 text-[10px] font-bold"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleFreeze(j.destinationDomain)}
|
|
||||||
className="px-2 py-1 rounded bg-red-600/20 hover:bg-red-600/30 text-red-300 text-[10px] font-bold"
|
|
||||||
>
|
|
||||||
Freeze
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-slate-800/60 font-mono">
|
||||||
</table>
|
{jobs.map((j) => (
|
||||||
</div>
|
<tr key={j.id} className="hover:bg-slate-900/30">
|
||||||
|
<td className="p-3 font-bold text-slate-200">{j.id.slice(0, 8)}</td>
|
||||||
|
<td className="p-3 text-slate-300">{j.envelope_from}</td>
|
||||||
|
<td className="p-3 text-slate-300">{j.envelope_to}</td>
|
||||||
|
<td className="p-3 text-emerald-400">{j.destination_domain}</td>
|
||||||
|
<td className="p-3 text-slate-400">{j.attempt}</td>
|
||||||
|
<td className="p-3 text-slate-400 truncate max-w-xs" title={j.last_error ?? ""}>
|
||||||
|
{j.last_error
|
||||||
|
? `${j.last_smtp_code ? `${j.last_smtp_code} ` : ""}${j.last_error}`
|
||||||
|
: t("common.none")}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<span className={`${statusClass(j.status)} px-2 py-0.5 rounded text-[10px]`}>
|
||||||
|
{j.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
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")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
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")}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ import {
|
|||||||
ListOrdered,
|
ListOrdered,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Search,
|
Search,
|
||||||
FileText,
|
|
||||||
Key,
|
|
||||||
Shield,
|
Shield,
|
||||||
Activity,
|
|
||||||
AlertTriangle,
|
|
||||||
} 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 { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
|
||||||
@@ -64,6 +60,15 @@ export default function AdminSecurityPage() {
|
|||||||
const [readiness, setReadiness] = useState<PreflightCheck[] | null>(null);
|
const [readiness, setReadiness] = useState<PreflightCheck[] | null>(null);
|
||||||
const [tracedSteps, setTracedSteps] = useState<{ step: number; title: string; detail: string; status: string }[] | null>(null);
|
const [tracedSteps, setTracedSteps] = useState<{ step: number; title: string; detail: string; status: string }[] | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live certificate state from the protocols service, which owns the cert
|
||||||
|
* watcher. The panel this replaced was three static cards - "Let's Encrypt
|
||||||
|
* RSA 4096", "3 1 1 SHA-256 Verified", "mode=enforce" - plus a fixed list of
|
||||||
|
* cipher suites, none of it read from anywhere. It reported a healthy,
|
||||||
|
* DANE-verified certificate on a deployment serving a self-signed one.
|
||||||
|
*/
|
||||||
|
const [tlsStatus, setTlsStatus] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetch("/api/v1/admin/rspamd/thresholds")
|
fetch("/api/v1/admin/rspamd/thresholds")
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
@@ -93,18 +98,32 @@ export default function AdminSecurityPage() {
|
|||||||
)
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
void fetch("/api/v1/admin/tls")
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then(setTlsStatus)
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
void fetch("/api/v1/admin/preflight")
|
void fetch("/api/v1/admin/preflight")
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data && Array.isArray(data.checks)) {
|
if (data && Array.isArray(data.checks)) {
|
||||||
setReadiness(
|
setReadiness(
|
||||||
data.checks.map((c: { name: string; status: string; detail?: string }, index: number) => ({
|
// The service sends a key and its subject, so the label is built
|
||||||
id: index + 1,
|
// in the reader's language. It used to send a finished English
|
||||||
name: c.name,
|
// sentence ("DNS records for example.com") that appeared verbatim
|
||||||
category: "Security and DNS",
|
// in an otherwise translated console.
|
||||||
status: c.status === "PASS" ? "PASSED" : c.status === "FAIL" ? "FAILED" : "WARNING",
|
data.checks.map(
|
||||||
details: c.detail ?? "",
|
(
|
||||||
})) as PreflightCheck[]
|
c: { key?: string; target?: string; name: string; status: string; detail?: string },
|
||||||
|
index: number,
|
||||||
|
) => ({
|
||||||
|
id: index + 1,
|
||||||
|
name: c.key && c.target ? t(`admin.${c.key}`, { domain: c.target }) : c.name,
|
||||||
|
category: t("ui.domainVerification"),
|
||||||
|
status: c.status === "PASS" ? "PASSED" : c.status === "FAIL" ? "FAILED" : "WARNING",
|
||||||
|
details: c.detail ?? "",
|
||||||
|
}),
|
||||||
|
) as PreflightCheck[]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -142,13 +161,11 @@ export default function AdminSecurityPage() {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setTracedSteps([
|
// An empty result means the queue id was not found. This used to
|
||||||
{ step: 1, title: "Inbound SMTP TCP Handshake", detail: `Queue ID ${searchQueueId}: TLS 1.3 connection accepted`, status: "OK" },
|
// invent a five-step delivery trace instead - TLS 1.3 accepted, SPF
|
||||||
{ step: 2, title: "Verification SPF / DKIM", detail: "SPF pass, DKIM signature on default._domainkey valid", status: "OK" },
|
// pass, Rspamd score 0.8 - for a message that does not exist, which is
|
||||||
{ step: 3, title: "Rspamd spam scan", detail: "Score 0.8 / 15.0 (symbols: BAYES_HAM, DKIM_TRACE)", status: "OK" },
|
// the worst possible answer to "what happened to this mail?".
|
||||||
{ step: 4, title: "Transactional outbox write", detail: "Written to mail_messages with SKIP LOCKED", status: "OK" },
|
setTracedSteps([]);
|
||||||
{ step: 5, title: "Webmail WebSocket Push", detail: "Event published to the mailbox websocket hub de entrada", status: "ENTREGUE" },
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fallback
|
// fallback
|
||||||
@@ -163,9 +180,7 @@ export default function AdminSecurityPage() {
|
|||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight flex items-center gap-3">
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight flex items-center gap-3">
|
||||||
{t("admin.securityTitle")}
|
{t("admin.securityTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-400 mt-1">
|
<p className="text-sm text-slate-400 mt-1">{t("admin.securitySubtitle")}</p>
|
||||||
Readiness scorecard, TLS policy, Rspamd thresholds and the audit trail.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Selector Controls */}
|
{/* Tab Selector Controls */}
|
||||||
@@ -179,7 +194,7 @@ export default function AdminSecurityPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ShieldCheck className="w-3.5 h-3.5" />
|
<ShieldCheck className="w-3.5 h-3.5" />
|
||||||
<span>Placar 10/10</span>
|
<span>{t("admin.tabReadiness")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -191,7 +206,7 @@ export default function AdminSecurityPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5" />
|
<Lock className="w-3.5 h-3.5" />
|
||||||
<span>TLS & Certificados</span>
|
<span>{t("admin.tabTls")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -203,7 +218,7 @@ export default function AdminSecurityPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Sliders className="w-3.5 h-3.5" />
|
<Sliders className="w-3.5 h-3.5" />
|
||||||
<span>Limites Rspamd</span>
|
<span>{t("admin.tabRspamd")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -215,7 +230,7 @@ export default function AdminSecurityPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ListOrdered className="w-3.5 h-3.5" />
|
<ListOrdered className="w-3.5 h-3.5" />
|
||||||
<span>Audit Log & Trace</span>
|
<span>{t("admin.tabAudit")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,20 +238,42 @@ export default function AdminSecurityPage() {
|
|||||||
{/* TAB 1: 10/10 READINESS SCORECARD */}
|
{/* TAB 1: 10/10 READINESS SCORECARD */}
|
||||||
{activeTab === "readiness" && (
|
{activeTab === "readiness" && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="flex items-center justify-between p-6">
|
{/* The score is counted from the checks that came back. It used to
|
||||||
|
read a hardcoded "10 / 10" with an "all checks passed" badge
|
||||||
|
regardless of what the preflight endpoint reported - including
|
||||||
|
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>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-white flex items-center gap-2">
|
<h2 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
<Shield className="w-5 h-5 text-emerald-400" />
|
<Shield className="w-5 h-5 text-emerald-400" />
|
||||||
Mail server readiness scorecard
|
{t("admin.preflightTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
<p className="text-xs text-slate-400 mt-1">{t("admin.readinessIntro")}</p>
|
||||||
Ten non-negotiable checks before this node carries production mail.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-3xl font-black text-emerald-400 font-mono">10 / 10</span>
|
|
||||||
<Badge variant="success">TODAS AS CHECAGENS APROVADAS</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
{readiness && readiness.length > 0 ? (
|
||||||
|
(() => {
|
||||||
|
const passed = readiness.filter((c) => c.status === "PASSED").length;
|
||||||
|
const total = readiness.length;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`text-3xl font-black font-mono ${
|
||||||
|
passed === total ? "text-emerald-400" : "text-amber-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{passed} / {total}
|
||||||
|
</span>
|
||||||
|
<Badge variant={passed === total ? "success" : "warning"}>
|
||||||
|
{passed === total
|
||||||
|
? t("ui.allChecksPassed")
|
||||||
|
: t("admin.checksPassed", { passed, total })}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500">{t("admin.noChecks")}</span>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -257,42 +294,63 @@ export default function AdminSecurityPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAB 2: TLS CERTIFICATES & POLICY PANEL */}
|
{/* TAB 2: TLS - read from the protocols service cert watcher */}
|
||||||
{activeTab === "tls" && (
|
{activeTab === "tls" && (
|
||||||
<div className="space-y-6 text-xs">
|
<div className="space-y-6 text-xs">
|
||||||
<Card className="space-y-4">
|
<Card className="space-y-4">
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||||||
<Lock className="w-5 h-5 text-indigo-400" />
|
<Lock className="w-5 h-5 text-indigo-400" />
|
||||||
TLS certificates and transport policy
|
{t("admin.tlsTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
|
||||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
|
|
||||||
<div className="text-slate-400 font-medium">Certificado Traefik / ACME</div>
|
|
||||||
<div className="text-emerald-400 font-bold text-sm">Let's Encrypt RSA 4096</div>
|
|
||||||
<div className="text-[10px] text-slate-500 font-mono">Renewal is automatic</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
|
{tlsStatus ? (
|
||||||
<div className="text-slate-400 font-medium">Status Registros DANE TLSA</div>
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||||
<div className="text-emerald-400 font-bold text-sm">3 1 1 SHA-256 Verificado</div>
|
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
|
||||||
<div className="text-[10px] text-slate-500 font-mono">Cadeia DNSSEC verificada na porta 25</div>
|
<div className="text-slate-400 font-medium">{t("admin.certificateState")}</div>
|
||||||
</div>
|
<div
|
||||||
|
className={`font-bold text-sm ${
|
||||||
|
tlsStatus.state === "OK" ? "text-emerald-400" : "text-amber-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{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 className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
|
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
|
||||||
<div className="text-slate-400 font-medium">MTA-STS policy mode</div>
|
<div className="text-slate-400 font-medium">{t("admin.mailHost")}</div>
|
||||||
<div className="text-emerald-400 font-bold text-sm">mode=enforce</div>
|
<div className="text-slate-100 font-bold text-sm break-all">
|
||||||
<div className="text-[10px] text-slate-500 font-mono">max_age=604800 (7 dias)</div>
|
{String(tlsStatus.mail_host ?? "-")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="text-[10px] text-slate-500 font-mono">
|
||||||
</Card>
|
{t("admin.tlsMode")}: {String(tlsStatus.tls_mode ?? "-")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="space-y-3">
|
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
|
||||||
<h3 className="font-bold text-slate-100">Accepted TLS cipher suites (ports 25 / 587 / 993)</h3>
|
<div className="text-slate-400 font-medium">{t("admin.watcherHealthy")}</div>
|
||||||
<div className="p-4 bg-slate-950 rounded-xl border border-slate-800 font-mono text-xs text-slate-300 space-y-1.5">
|
<div
|
||||||
<div className="text-emerald-400 font-semibold">TLS_AES_256_GCM_SHA384 (TLS 1.3) [PREFERENCIAL]</div>
|
className={`font-bold text-sm ${
|
||||||
<div>TLS_CHACHA20_POLY1305_SHA256 (TLS 1.3)</div>
|
tlsStatus.watcher_healthy ? "text-emerald-400" : "text-amber-400"
|
||||||
<div>ECDHE-ECDSA-AES256-GCM-SHA384 (TLS 1.2)</div>
|
}`}
|
||||||
</div>
|
>
|
||||||
|
{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 className="p-6 text-center text-slate-500">{t("errors.loadFailed")}</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -303,21 +361,21 @@ export default function AdminSecurityPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white mb-1 flex items-center gap-2">
|
<h2 className="text-base font-bold text-white mb-1 flex items-center gap-2">
|
||||||
<Sliders className="w-5 h-5 text-indigo-400" />
|
<Sliders className="w-5 h-5 text-indigo-400" />
|
||||||
Rspamd score thresholds
|
{t("admin.tabRspamd")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-slate-400">Set the scores that trigger greylisting, header tagging and rejection.</p>
|
<p className="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="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" />
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||||
<span>Rspamd thresholds saved.</span>
|
<span>{t("admin.rspamdSaved")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSaveRspamd} className="space-y-4">
|
<form onSubmit={handleSaveRspamd} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">Greylist score (default 4.0)</label>
|
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.greylistScore")}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
@@ -328,7 +386,7 @@ export default function AdminSecurityPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">Header-tagging score (default 6.0)</label>
|
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.headerScore")}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
@@ -339,7 +397,7 @@ export default function AdminSecurityPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">Reject score (default 15.0)</label>
|
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.rejectScore")}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
@@ -350,7 +408,7 @@ export default function AdminSecurityPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" variant="primary" size="md">
|
<Button type="submit" variant="primary" size="md">
|
||||||
Salvar Limites Rspamd
|
{t("admin.saveThresholds")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -364,9 +422,9 @@ export default function AdminSecurityPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white mb-1 flex items-center gap-2">
|
<h2 className="text-base font-bold text-white mb-1 flex items-center gap-2">
|
||||||
<Search className="w-5 h-5 text-indigo-400" />
|
<Search className="w-5 h-5 text-indigo-400" />
|
||||||
Delivery trace by queue ID
|
{t("admin.traceTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-slate-400">Rastreie as etapas exatas de processamento de qualquer e-mail por Queue ID ou UUID.</p>
|
<p className="text-slate-400">{t("admin.tracePlaceholder")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSearchQueueId} className="flex gap-3">
|
<form onSubmit={handleSearchQueueId} className="flex gap-3">
|
||||||
@@ -374,12 +432,12 @@ export default function AdminSecurityPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={searchQueueId}
|
value={searchQueueId}
|
||||||
onChange={(e) => setSearchQueueId(e.target.value)}
|
onChange={(e) => setSearchQueueId(e.target.value)}
|
||||||
placeholder="Informe o Queue ID (ex: q-9812-ax)..."
|
placeholder={t("admin.tracePlaceholder")}
|
||||||
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="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"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variant="primary" size="md">
|
<Button type="submit" variant="primary" size="md">
|
||||||
Trace
|
{t("admin.traceTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -404,7 +462,7 @@ export default function AdminSecurityPage() {
|
|||||||
{/* Immutable System Audit Log Table */}
|
{/* Immutable System Audit Log Table */}
|
||||||
<Card className="p-0 overflow-hidden">
|
<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">
|
<div className="p-4 border-b border-slate-800 bg-slate-900/80 font-bold text-xs text-slate-300">
|
||||||
Audit log
|
{t("admin.auditLog")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState } from "react";
|
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";
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -33,7 +34,7 @@ export default function AdminLoginPage() {
|
|||||||
body: JSON.stringify({ code: mfaCode }),
|
body: JSON.stringify({ code: mfaCode }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.message || "Verification failed");
|
if (!res.ok) throw new Error(data.message || t("auth.verificationFailed"));
|
||||||
// Shown once. The operator signs in again with the factor they just
|
// Shown once. The operator signs in again with the factor they just
|
||||||
// created, which is also the first proof that it works.
|
// created, which is also the first proof that it works.
|
||||||
setRecoveryCodes(data.recovery_codes ?? []);
|
setRecoveryCodes(data.recovery_codes ?? []);
|
||||||
@@ -51,7 +52,7 @@ export default function AdminLoginPage() {
|
|||||||
body: JSON.stringify({ challenge_token: challengeToken, code: mfaCode }),
|
body: JSON.stringify({ challenge_token: challengeToken, code: mfaCode }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.message || "Admin verification failed");
|
if (!res.ok) throw new Error(data.message || t("auth.verificationFailed"));
|
||||||
window.location.href = "/admin/dashboard";
|
window.location.href = "/admin/dashboard";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -72,14 +73,14 @@ export default function AdminLoginPage() {
|
|||||||
headers: { Authorization: `Bearer ${data.enrollment_token}` },
|
headers: { Authorization: `Bearer ${data.enrollment_token}` },
|
||||||
});
|
});
|
||||||
const enrolData = await enroll.json();
|
const enrolData = await enroll.json();
|
||||||
if (!enroll.ok) throw new Error(enrolData.message || "Could not start enrolment");
|
if (!enroll.ok) throw new Error(enrolData.message || t("auth.enrolmentFailed"));
|
||||||
setEnrolmentToken(data.enrollment_token);
|
setEnrolmentToken(data.enrollment_token);
|
||||||
setEnrolSecret(enrolData.secret);
|
setEnrolSecret(enrolData.secret);
|
||||||
setEnrolUri(enrolData.uri);
|
setEnrolUri(enrolData.uri);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) throw new Error(data.message || "Admin authentication failed");
|
if (!res.ok) throw new Error(data.message || t("auth.authFailed"));
|
||||||
|
|
||||||
if (data.mfa_required) {
|
if (data.mfa_required) {
|
||||||
setChallengeToken(data.challenge_token);
|
setChallengeToken(data.challenge_token);
|
||||||
@@ -87,7 +88,7 @@ export default function AdminLoginPage() {
|
|||||||
window.location.href = "/admin/dashboard";
|
window.location.href = "/admin/dashboard";
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : "An error occurred");
|
setError(err instanceof Error ? err.message : t("auth.genericError"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -102,7 +103,7 @@ export default function AdminLoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-white">{t("auth.adminLoginTitle")}</h1>
|
<h1 className="text-xl font-bold text-white">{t("auth.adminLoginTitle")}</h1>
|
||||||
<p className="text-xs text-slate-400">Surface: /admin/* (Cookie: Path=/admin, MFA Mandatory)</p>
|
<p className="text-xs text-slate-400">{t("auth.adminStepUpNote")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,45 +113,24 @@ export default function AdminLoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* The recovery-code step is terminal: it owns its own buttons, so the
|
||||||
|
form's submit button is not rendered underneath it. That overlap is
|
||||||
|
what previously put two identical "sign in" buttons on the screen
|
||||||
|
and left no way to copy the codes it insisted you save. */}
|
||||||
|
{recoveryCodes ? (
|
||||||
|
<RecoveryCodes
|
||||||
|
codes={recoveryCodes}
|
||||||
|
onContinue={() => setRecoveryCodes(null)}
|
||||||
|
continueLabel={t("auth.signInButton")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
{recoveryCodes ? (
|
{enrolSecret ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-300">
|
|
||||||
<div className="mb-1 font-bold">{t("settings.recoveryCodesTitle")}</div>
|
|
||||||
<p className="mb-2">{t("settings.saveRecoveryCodes")}</p>
|
|
||||||
<div className="grid grid-cols-2 gap-1 font-mono text-[11px] text-white">
|
|
||||||
{recoveryCodes.map((code) => (
|
|
||||||
<span key={code}>{code}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRecoveryCodes(null)}
|
|
||||||
className="w-full rounded-xl bg-emerald-600 py-3 font-medium text-white transition-colors hover:bg-emerald-500"
|
|
||||||
>
|
|
||||||
{t("auth.signInButton")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : enrolSecret ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="rounded-lg border border-indigo-500/30 bg-indigo-500/10 p-3 text-xs text-indigo-300">
|
<div className="rounded-lg border border-indigo-500/30 bg-indigo-500/10 p-3 text-xs text-indigo-300">
|
||||||
{t("auth.mfaRequired")}
|
{t("auth.enrolmentIntro")}
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-slate-300">{t("settings.enableMfa")}</label>
|
|
||||||
<code className="block break-all rounded-lg border border-slate-800 bg-slate-900 p-3 font-mono text-sm text-white">
|
|
||||||
{enrolSecret}
|
|
||||||
</code>
|
|
||||||
{enrolUri && (
|
|
||||||
<a
|
|
||||||
href={enrolUri}
|
|
||||||
className="mt-1 block truncate text-[11px] text-indigo-400 hover:underline"
|
|
||||||
>
|
|
||||||
{enrolUri}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<TotpEnrolment secret={enrolSecret} uri={enrolUri} />
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-slate-300">{t("auth.totpCodeLabel")}</label>
|
<label className="mb-1 block text-xs font-medium text-slate-300">{t("auth.totpCodeLabel")}</label>
|
||||||
<input
|
<input
|
||||||
@@ -214,13 +194,18 @@ export default function AdminLoginPage() {
|
|||||||
disabled={loading}
|
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"
|
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") : challengeToken ? t("auth.verifyCodeButton") : t("auth.signInButton")}
|
{loading
|
||||||
|
? t("common.loading")
|
||||||
|
: challengeToken || enrolSecret
|
||||||
|
? t("auth.verifyCodeButton")
|
||||||
|
: t("auth.signInButton")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-6 pt-4 border-t border-slate-800 text-center">
|
<div className="mt-6 pt-4 border-t border-slate-800 text-center">
|
||||||
<Link href="/" className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
|
<Link href="/user/mail/inbox" className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
|
||||||
Back to Surface Selector
|
{t("admin.backToWebmail")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Mail, Lock, ShieldCheck, ArrowLeft, KeyRound, AlertCircle } from "lucide-react";
|
import {
|
||||||
|
Mail,
|
||||||
|
Lock,
|
||||||
|
ShieldCheck,
|
||||||
|
KeyRound,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { Card } from "../../../../components/ui/Card";
|
import { Card } from "../../../../components/ui/Card";
|
||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
@@ -146,12 +152,6 @@ export default function UserLoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 pt-4 border-t border-slate-800/80 text-center">
|
|
||||||
<Link href="/" className="text-xs text-slate-400 hover:text-slate-200 transition-colors inline-flex items-center gap-1.5">
|
|
||||||
<ArrowLeft className="w-3.5 h-3.5" />
|
|
||||||
<span>Back to surface selection</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
List,
|
List,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Trash2,
|
|
||||||
Undo2,
|
Undo2,
|
||||||
X,
|
X,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -59,7 +58,7 @@ export default function ComposePage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!to && !subject) return;
|
if (!to && !subject) return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDraftStatus("Rascunho salvo automaticamente");
|
setDraftStatus(t("mail.draftSaved"));
|
||||||
}, 2500);
|
}, 2500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [to, subject]);
|
}, [to, subject]);
|
||||||
@@ -148,13 +147,16 @@ export default function ComposePage({
|
|||||||
<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="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 items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 text-indigo-400 animate-spin" />
|
<Sparkles className="w-4 h-4 text-indigo-400 animate-spin" />
|
||||||
<span>
|
{/* The countdown is a placeholder inside the message, not text
|
||||||
{t("mail.undoSend")} ({undoSeconds}s restantes)
|
bolted on after it. It used to be both: the bundle's own
|
||||||
</span>
|
"{seconds}" was never substituted and a second, hardcoded
|
||||||
|
count was appended, so this read "Undo Send ({seconds}s
|
||||||
|
remaining) (9s remaining)". */}
|
||||||
|
<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="w-3.5 h-3.5" />
|
||||||
Desfazer Envio
|
{t("mail.undoSendAction")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -168,7 +170,7 @@ export default function ComposePage({
|
|||||||
onClick={() => setShowCcBcc(!showCcBcc)}
|
onClick={() => setShowCcBcc(!showCcBcc)}
|
||||||
className="text-indigo-400 hover:text-indigo-300 font-medium hover:underline"
|
className="text-indigo-400 hover:text-indigo-300 font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{showCcBcc ? "- Ocultar Cc/Bcc" : "+ Adicionar Cc/Bcc"}
|
{showCcBcc ? t("mail.hideCcBcc") : t("mail.showCcBcc")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -231,7 +233,7 @@ export default function ComposePage({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => executeFormat("bold")}
|
onClick={() => executeFormat("bold")}
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
||||||
title="Negrito"
|
title={t("mail.bold")}
|
||||||
>
|
>
|
||||||
<Bold className="w-4 h-4" />
|
<Bold className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -239,7 +241,7 @@ export default function ComposePage({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => executeFormat("italic")}
|
onClick={() => executeFormat("italic")}
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
||||||
title="Italic"
|
title={t("mail.italic")}
|
||||||
>
|
>
|
||||||
<Italic className="w-4 h-4" />
|
<Italic className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -247,7 +249,7 @@ export default function ComposePage({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => executeFormat("underline")}
|
onClick={() => executeFormat("underline")}
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
||||||
title="Sublinhado"
|
title={t("mail.underline")}
|
||||||
>
|
>
|
||||||
<Underline className="w-4 h-4" />
|
<Underline className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -255,7 +257,7 @@ export default function ComposePage({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => executeFormat("strikeThrough")}
|
onClick={() => executeFormat("strikeThrough")}
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
||||||
title="Tachado"
|
title={t("mail.strikethrough")}
|
||||||
>
|
>
|
||||||
<Strikethrough className="w-4 h-4" />
|
<Strikethrough className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -264,7 +266,7 @@ export default function ComposePage({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => executeFormat("insertUnorderedList")}
|
onClick={() => executeFormat("insertUnorderedList")}
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
||||||
title="Lista"
|
title={t("mail.bulletList")}
|
||||||
>
|
>
|
||||||
<List className="w-4 h-4" />
|
<List className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -272,7 +274,7 @@ export default function ComposePage({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => executeFormat("insertOrderedList")}
|
onClick={() => executeFormat("insertOrderedList")}
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
|
||||||
title="Lista Numerada"
|
title={t("mail.numberedList")}
|
||||||
>
|
>
|
||||||
<ListOrdered className="w-4 h-4" />
|
<ListOrdered className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -280,11 +282,11 @@ export default function ComposePage({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = prompt("Insira a URL:");
|
const url = prompt(t("mail.insertUrl"));
|
||||||
if (url) executeFormat("createLink", url);
|
if (url) executeFormat("createLink", url);
|
||||||
}}
|
}}
|
||||||
className="p-1.5 hover:bg-slate-800 rounded-lg text-indigo-400 hover:text-indigo-300"
|
className="p-1.5 hover:bg-slate-800 rounded-lg text-indigo-400 hover:text-indigo-300"
|
||||||
title="Inserir Link"
|
title={t("mail.insertLink")}
|
||||||
>
|
>
|
||||||
<LinkIcon className="w-4 h-4" />
|
<LinkIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -302,10 +304,10 @@ export default function ComposePage({
|
|||||||
{/* Attachment Selector & Display */}
|
{/* Attachment Selector & Display */}
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="font-semibold text-slate-300">Anexos ({attachments.length})</span>
|
<span className="font-semibold text-slate-300">{t("mail.attachments", { count: attachments.length })}</span>
|
||||||
<label className="cursor-pointer text-indigo-400 hover:text-indigo-300 flex items-center gap-1.5 font-medium">
|
<label className="cursor-pointer text-indigo-400 hover:text-indigo-300 flex items-center gap-1.5 font-medium">
|
||||||
<Paperclip className="w-3.5 h-3.5" />
|
<Paperclip className="w-3.5 h-3.5" />
|
||||||
<span>Adicionar Arquivos...</span>
|
<span>{t("mail.addFiles")}</span>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
@@ -315,6 +317,16 @@ export default function ComposePage({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Says so plainly rather than letting someone attach a contract
|
||||||
|
and watch it silently not arrive: /mail/send is handed the file
|
||||||
|
names only, because there is no upload endpoint behind this
|
||||||
|
picker yet. */}
|
||||||
|
{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">
|
||||||
|
{t("mail.attachmentsUnsupported")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{attachments.map((file) => (
|
{attachments.map((file) => (
|
||||||
@@ -343,13 +355,13 @@ export default function ComposePage({
|
|||||||
<div className="flex items-center justify-between pt-4 border-t border-slate-800">
|
<div className="flex items-center justify-between pt-4 border-t border-slate-800">
|
||||||
<Link href="/user/mail/inbox">
|
<Link href="/user/mail/inbox">
|
||||||
<Button variant="ghost" size="md">
|
<Button variant="ghost" size="md">
|
||||||
Descartar
|
{t("mail.discard")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Button type="submit" variant="primary" size="md" disabled={sending}>
|
<Button type="submit" variant="primary" size="md" disabled={sending}>
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
<span>{sending ? "Sending..." : "Send message"}</span>
|
<span>{sending ? t("mail.sending") : t("mail.sendMessage")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Mail,
|
Mail,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
ShieldAlert,
|
||||||
|
Sliders,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "../../../i18n/provider";
|
import { useTranslations } from "../../../i18n/provider";
|
||||||
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
||||||
|
import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
||||||
|
|
||||||
interface FolderCount {
|
interface FolderCount {
|
||||||
special_use: string;
|
special_use: string;
|
||||||
@@ -33,6 +36,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [folders, setFolders] = useState<FolderCount[]>([]);
|
const [folders, setFolders] = useState<FolderCount[]>([]);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const account = useAccount("user");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetch("/api/v1/mail/folders")
|
void fetch("/api/v1/mail/folders")
|
||||||
@@ -48,9 +52,9 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
{ href: "/user/mail/inbox", label: t("mail.inbox"), icon: Inbox, role: "inbox" },
|
{ href: "/user/mail/inbox", label: t("mail.inbox"), icon: Inbox, role: "inbox" },
|
||||||
{ href: "/user/mail/sent", label: t("mail.sent"), icon: Send, role: "sent" },
|
{ href: "/user/mail/sent", label: t("mail.sent"), icon: Send, role: "sent" },
|
||||||
{ href: "/user/mail/drafts", label: t("mail.drafts"), icon: FileText, role: "drafts" },
|
{ href: "/user/mail/drafts", label: t("mail.drafts"), icon: FileText, role: "drafts" },
|
||||||
{ href: "/user/mail/archive", label: "Archive", icon: Archive, role: "archive" },
|
{ href: "/user/mail/archive", label: t("mail.archive"), icon: Archive, role: "archive" },
|
||||||
{ href: "/user/mail/junk", label: "Spam", icon: AlertTriangle, role: "junk" },
|
{ href: "/user/mail/junk", label: t("mail.junk"), icon: AlertTriangle, role: "junk" },
|
||||||
{ href: "/user/mail/trash", label: "Trash", icon: Trash2, role: "trash" },
|
{ href: "/user/mail/trash", label: t("mail.trash"), icon: Trash2, role: "trash" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -134,6 +138,21 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* The console is reachable from inside the app for the accounts that
|
||||||
|
may open it, which is why there is no longer a surface chooser in
|
||||||
|
front of the sign-in. It is a plain link to the admin sign-in, not a
|
||||||
|
direct jump: the admin audience is a separate token, so crossing
|
||||||
|
over means proving the second factor again. */}
|
||||||
|
{isAdminRole(account?.role) && (
|
||||||
|
<Link
|
||||||
|
href="/admin/login"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Sliders className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="text-xs font-semibold truncate">{t("admin.openAdmin")}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/user/settings"
|
href="/user/settings"
|
||||||
className={`flex items-center justify-between p-2.5 rounded-xl border transition-all ${
|
className={`flex items-center justify-between p-2.5 rounded-xl border transition-all ${
|
||||||
@@ -143,13 +162,27 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<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">
|
<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">
|
||||||
U
|
{account?.email?.[0] ?? "?"}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<div className="text-xs font-semibold text-slate-200 truncate">user@lambdamail.local</div>
|
<div className="text-xs font-semibold text-slate-200 truncate">
|
||||||
|
{account?.email ?? t("common.loading")}
|
||||||
|
</div>
|
||||||
|
{/* Reports what is actually configured. The old fixed "2FA
|
||||||
|
active" reassured accounts that had no second factor at all. */}
|
||||||
<div className="text-[10px] text-slate-400 flex items-center gap-1">
|
<div className="text-[10px] text-slate-400 flex items-center gap-1">
|
||||||
<ShieldCheck className="w-3 h-3 text-emerald-400 inline" /> 2FA Ativo
|
{account?.mfa_enrolled ? (
|
||||||
|
<>
|
||||||
|
<ShieldCheck className="w-3 h-3 text-emerald-400 inline" />
|
||||||
|
{t("settings.mfaEnabled")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShieldAlert className="w-3 h-3 text-amber-400 inline" />
|
||||||
|
{t("settings.mfaDisabled")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ import {
|
|||||||
Paperclip,
|
Paperclip,
|
||||||
Reply,
|
Reply,
|
||||||
Forward,
|
Forward,
|
||||||
ShieldAlert,
|
|
||||||
Inbox,
|
Inbox,
|
||||||
Filter,
|
Filter,
|
||||||
CheckCircle2,
|
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Mail,
|
Mail,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -158,7 +156,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Todos ({messages.length})
|
{t("mail.allMessages")} ({messages.length})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveFilter("unread")}
|
onClick={() => setActiveFilter("unread")}
|
||||||
@@ -168,7 +166,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Unread
|
{t("mail.unreadFilter")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveFilter("attachment")}
|
onClick={() => setActiveFilter("attachment")}
|
||||||
@@ -178,7 +176,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Anexos
|
{t("mail.attachmentsFilter")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -366,7 +364,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
{bodyHtml ? (
|
{bodyHtml ? (
|
||||||
<div className="bg-white rounded-2xl overflow-hidden shadow-2xl border border-slate-800 min-h-[350px]">
|
<div className="bg-white rounded-2xl overflow-hidden shadow-2xl border border-slate-800 min-h-[350px]">
|
||||||
<iframe
|
<iframe
|
||||||
title="Message body"
|
title={t("ui.messageBody")}
|
||||||
srcDoc={bodyHtml}
|
srcDoc={bodyHtml}
|
||||||
sandbox=""
|
sandbox=""
|
||||||
className="w-full h-[500px] border-0"
|
className="w-full h-[500px] border-0"
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Plus,
|
Plus,
|
||||||
Lock,
|
Lock,
|
||||||
Sparkles,
|
|
||||||
} 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 { 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";
|
||||||
|
|
||||||
interface SieveRule {
|
interface SieveRule {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -62,16 +62,18 @@ export default function UserSettingsPage() {
|
|||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
|
||||||
const [vacationEnabled, setVacationEnabled] = useState(false);
|
const [vacationEnabled, setVacationEnabled] = useState(false);
|
||||||
const [vacationSubject, setVacationSubject] = useState("Out of office auto-reply");
|
const [vacationSubject, setVacationSubject] = useState(() => t("settings.vacationDefaultSubject"));
|
||||||
const [vacationBody, setVacationBody] = useState("Thank you for your message. I am currently away from my desk.");
|
const [vacationBody, setVacationBody] = useState(() => t("settings.vacationDefaultBody"));
|
||||||
const [vacationSaved, setVacationSaved] = useState(false);
|
const [vacationSaved, setVacationSaved] = useState(false);
|
||||||
|
|
||||||
const [signature, setSignature] = useState("");
|
const [signature, setSignature] = useState("");
|
||||||
const [sigSaved, setSigSaved] = useState(false);
|
const [sigSaved, setSigSaved] = useState(false);
|
||||||
|
|
||||||
const [rules, setRules] = useState<SieveRule[]>([
|
// Starts empty rather than with a sample rule. A seeded "[Boletim]" filter
|
||||||
{ id: "rule-1", field: "Subject", match: "contains", value: "[Boletim]", action: "move", targetFolder: "Archive" },
|
// used to sit here, and because saving posts the whole list, the first time
|
||||||
]);
|
// anyone added or removed a rule that invented filter was written into their
|
||||||
|
// real Sieve script and began moving their mail.
|
||||||
|
const [rules, setRules] = useState<SieveRule[]>([]);
|
||||||
const [newField, setNewField] = useState<"From" | "Subject" | "Header">("Subject");
|
const [newField, setNewField] = useState<"From" | "Subject" | "Header">("Subject");
|
||||||
const [newMatch, setNewMatch] = useState<"contains" | "equals" | "matches">("contains");
|
const [newMatch, setNewMatch] = useState<"contains" | "equals" | "matches">("contains");
|
||||||
const [newValue, setNewValue] = useState("");
|
const [newValue, setNewValue] = useState("");
|
||||||
@@ -107,7 +109,7 @@ export default function UserSettingsPage() {
|
|||||||
setSigSaved(true);
|
setSigSaved(true);
|
||||||
setTimeout(() => setSigSaved(false), 2000);
|
setTimeout(() => setSigSaved(false), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
setMessage("Falha ao salvar assinatura");
|
setMessage(t("settings.signatureSaveFailed"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,7 +124,7 @@ export default function UserSettingsPage() {
|
|||||||
setVacationSaved(true);
|
setVacationSaved(true);
|
||||||
setTimeout(() => setVacationSaved(false), 2000);
|
setTimeout(() => setVacationSaved(false), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
setMessage("Could not save the auto-reply");
|
setMessage(t("settings.autoReplySaveFailed"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,9 +248,7 @@ export default function UserSettingsPage() {
|
|||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
||||||
{t("settings.title")}
|
{t("settings.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-400 mt-1">
|
<p className="text-sm text-slate-400 mt-1">{t("settings.subtitle")}</p>
|
||||||
Two-factor, Sieve rules, the auto-reply and your signature.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
@@ -266,9 +266,7 @@ export default function UserSettingsPage() {
|
|||||||
<Shield className="w-5 h-5 text-indigo-400" />
|
<Shield className="w-5 h-5 text-indigo-400" />
|
||||||
{t("ui.twoFactorSection")}
|
{t("ui.twoFactorSection")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-400 mt-0.5">{t("settings.twoFactorIntro")}</p>
|
||||||
Proteja sua conta com Google Authenticator, Aegis, Authy ou 1Password.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={mfaEnabled ? "success" : "warning"}>
|
<Badge variant={mfaEnabled ? "success" : "warning"}>
|
||||||
{mfaEnabled ? t("settings.mfaEnabled") : t("settings.mfaDisabled")}
|
{mfaEnabled ? t("settings.mfaEnabled") : t("settings.mfaDisabled")}
|
||||||
@@ -292,12 +290,7 @@ export default function UserSettingsPage() {
|
|||||||
|
|
||||||
{qrSecret && (
|
{qrSecret && (
|
||||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800 space-y-4 text-xs">
|
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800 space-y-4 text-xs">
|
||||||
<div className="text-slate-300">
|
<TotpEnrolment secret={qrSecret} uri={qrUri} />
|
||||||
Digitalize esta chave no seu aplicativo autenticador ou copie a chave secreta:
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-slate-950 rounded-lg font-mono text-indigo-400 break-all border border-slate-800">
|
|
||||||
Secreto: {qrSecret}
|
|
||||||
</div>
|
|
||||||
<form
|
<form
|
||||||
onSubmit={async (e) => {
|
onSubmit={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -306,20 +299,25 @@ export default function UserSettingsPage() {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ secret: qrSecret, code: totpCode }),
|
body: JSON.stringify({ secret: qrSecret, code: totpCode }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => ({}));
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setMfaEnabled(true);
|
setMfaEnabled(true);
|
||||||
setRecoveryCodes(data.recovery_codes);
|
setRecoveryCodes(data.recovery_codes ?? []);
|
||||||
setQrSecret(null);
|
setQrSecret(null);
|
||||||
|
setTotpCode("");
|
||||||
|
} else {
|
||||||
|
setMessage(data.message ?? t("auth.verificationFailed"));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="pt-2 flex items-center gap-3"
|
className="pt-2 flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(e) => setTotpCode(e.target.value)}
|
onChange={(e) => setTotpCode(e.target.value)}
|
||||||
placeholder="Six-digit code"
|
placeholder={t("settings.sixDigitCode")}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
required
|
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"
|
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"
|
||||||
@@ -332,15 +330,11 @@ export default function UserSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{recoveryCodes && (
|
{recoveryCodes && (
|
||||||
<div className="mt-4 p-4 rounded-xl bg-slate-900/90 border border-emerald-500/30">
|
<RecoveryCodes
|
||||||
<h3 className="text-xs font-bold text-white mb-1">{t("settings.recoveryCodesTitle")}</h3>
|
codes={recoveryCodes}
|
||||||
<p className="text-[11px] text-slate-400 mb-3">{t("settings.saveRecoveryCodes")}</p>
|
onContinue={() => setRecoveryCodes(null)}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 text-center font-mono text-xs text-emerald-400 bg-slate-950 p-3 rounded-xl border border-slate-800">
|
continueLabel={t("common.continue")}
|
||||||
{recoveryCodes.map((code, idx) => (
|
/>
|
||||||
<div key={idx} className="p-1 border border-slate-800 rounded-lg">{code}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -349,9 +343,9 @@ export default function UserSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||||||
<Filter className="w-5 h-5 text-indigo-400" />
|
<Filter className="w-5 h-5 text-indigo-400" />
|
||||||
Filtros Visuais Sieve (RFC 5228)
|
{t("settings.sieveTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">Automatic sorting and filing rules.</p>
|
<p className="text-xs text-slate-400 mt-0.5">{t("settings.sieveIntro")}</p>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
@@ -360,9 +354,9 @@ export default function UserSettingsPage() {
|
|||||||
onChange={(e) => setNewField(e.target.value as "From" | "Subject" | "Header")}
|
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"
|
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">Assunto</option>
|
<option value="Subject">{t("settings.fieldSubject")}</option>
|
||||||
<option value="From">De</option>
|
<option value="From">{t("settings.fieldFrom")}</option>
|
||||||
<option value="Header">Header</option>
|
<option value="Header">{t("settings.fieldHeader")}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
@@ -370,16 +364,16 @@ export default function UserSettingsPage() {
|
|||||||
onChange={(e) => setNewMatch(e.target.value as "contains" | "equals" | "matches")}
|
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"
|
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">contains</option>
|
<option value="contains">{t("settings.matchContains")}</option>
|
||||||
<option value="equals">equals</option>
|
<option value="equals">{t("settings.matchEquals")}</option>
|
||||||
<option value="matches">corresponde a regex</option>
|
<option value="matches">{t("settings.matchRegex")}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newValue}
|
value={newValue}
|
||||||
onChange={(e) => setNewValue(e.target.value)}
|
onChange={(e) => setNewValue(e.target.value)}
|
||||||
placeholder="Valor a corresponder..."
|
placeholder={t("settings.valuePlaceholder")}
|
||||||
required
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
@@ -389,14 +383,14 @@ export default function UserSettingsPage() {
|
|||||||
onChange={(e) => setNewAction(e.target.value as "move" | "flag" | "discard" | "redirect")}
|
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"
|
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">Mover para Arquivo</option>
|
<option value="move">{t("settings.actionMove")}</option>
|
||||||
<option value="flag">Flag message</option>
|
<option value="flag">{t("settings.actionFlag")}</option>
|
||||||
<option value="discard">Descartar</option>
|
<option value="discard">{t("settings.actionDiscard")}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<Button type="submit" variant="primary" size="sm" className="w-full">
|
<Button type="submit" variant="primary" size="sm" className="w-full">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
<span>Adicionar Regra</span>
|
<span>{t("settings.addRule")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -404,7 +398,8 @@ export default function UserSettingsPage() {
|
|||||||
{rules.map((r) => (
|
{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">
|
<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">
|
<span className="font-mono text-slate-200">
|
||||||
SE <strong>{r.field}</strong> {r.match} "{r.value}" → THEN {r.action.toUpperCase()}
|
{t("settings.ruleIf")} <strong>{r.field}</strong> {r.match} "{r.value}" →{" "}
|
||||||
|
{t("settings.ruleThen")} {r.action.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeSieveRule(r.id)}
|
onClick={() => removeSieveRule(r.id)}
|
||||||
@@ -423,9 +418,9 @@ export default function UserSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||||||
<Palmtree className="w-5 h-5 text-indigo-400" />
|
<Palmtree className="w-5 h-5 text-indigo-400" />
|
||||||
Out of office auto-reply
|
{t("settings.vacationTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">Reply automatically to incoming mail while you are awer ausente.</p>
|
<p className="text-xs text-slate-400 mt-0.5">{t("settings.vacationIntro")}</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -441,14 +436,14 @@ export default function UserSettingsPage() {
|
|||||||
{vacationSaved && (
|
{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">
|
<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" />
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||||
<span>Settings saved.</span>
|
<span>{t("settings.settingsSaved")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{vacationEnabled && (
|
{vacationEnabled && (
|
||||||
<form onSubmit={saveVacationResponder} className="space-y-3 text-xs">
|
<form onSubmit={saveVacationResponder} className="space-y-3 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">Assunto da Resposta</label>
|
<label className="font-semibold text-slate-300 mb-1.5 block">{t("settings.vacationSubjectLabel")}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={vacationSubject}
|
value={vacationSubject}
|
||||||
@@ -458,7 +453,7 @@ export default function UserSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-slate-300 mb-1.5 block">Message body</label>
|
<label className="font-semibold text-slate-300 mb-1.5 block">{t("settings.vacationBodyLabel")}</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={4}
|
rows={4}
|
||||||
value={vacationBody}
|
value={vacationBody}
|
||||||
@@ -468,7 +463,7 @@ export default function UserSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" variant="primary" size="sm">
|
<Button type="submit" variant="primary" size="sm">
|
||||||
Save auto-reply
|
{t("settings.saveAutoReply")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -479,15 +474,15 @@ export default function UserSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||||||
<FileText className="w-5 h-5 text-indigo-400" />
|
<FileText className="w-5 h-5 text-indigo-400" />
|
||||||
Assinatura de E-mail
|
{t("settings.signatureTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-slate-400 mt-0.5">Assinatura anexada automaticamente nas mensagens compostas.</p>
|
<p className="text-slate-400 mt-0.5">{t("settings.signatureIntro")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sigSaved && (
|
{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">
|
<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" />
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||||
<span>Assinatura salva com sucesso!</span>
|
<span>{t("settings.signatureSaved")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -495,7 +490,7 @@ export default function UserSettingsPage() {
|
|||||||
rows={4}
|
rows={4}
|
||||||
value={signature}
|
value={signature}
|
||||||
onChange={(e) => setSignature(e.target.value)}
|
onChange={(e) => setSignature(e.target.value)}
|
||||||
placeholder="Atenciosamente, Seu Nome Seu Cargo"
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -526,7 +521,7 @@ export default function UserSettingsPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={newAppPassLabel}
|
value={newAppPassLabel}
|
||||||
onChange={(e) => setNewAppPassLabel(e.target.value)}
|
onChange={(e) => setNewAppPassLabel(e.target.value)}
|
||||||
placeholder="Thunderbird / Outlook Mobile"
|
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"
|
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">
|
<Button type="submit" variant="primary" size="sm">
|
||||||
|
|||||||
@@ -1,3 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Liveness probe for the web container.
|
||||||
|
*
|
||||||
|
* Deliberately shallow: it answers as soon as the Next server can route a
|
||||||
|
* request, and does not reach out to auth, storage or protocols. Those are
|
||||||
|
* gated by depends_on and carry probes of their own; folding them in here would
|
||||||
|
* mean a blip in one backend marks the frontend unhealthy and takes the whole
|
||||||
|
* site down with it.
|
||||||
|
*
|
||||||
|
* force-dynamic so the answer comes from the running server rather than a
|
||||||
|
* response baked in at build time, which would keep replying 200 out of the
|
||||||
|
* static cache even if the process were wedged.
|
||||||
|
*/
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export function GET() {
|
export function GET() {
|
||||||
return new Response(null, { status: 200 });
|
return Response.json({ status: "ok", service: "webmail" }, { status: 200 });
|
||||||
}
|
}
|
||||||
@@ -1,70 +1,18 @@
|
|||||||
import Link from "next/link";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function LandingPage() {
|
/**
|
||||||
return (
|
* The root sends everyone straight into the mail client.
|
||||||
<div className="min-h-screen flex flex-col justify-center items-center px-4 bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
|
*
|
||||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
* This used to be a "choose your surface" splash listing the webmail and the
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 text-sm font-medium mb-6">
|
* admin console side by side, which asked visitors a question they should never
|
||||||
<span className="w-2 h-2 rounded-full bg-indigo-400 animate-pulse"></span>
|
* have to answer: almost everyone wants their mail, and the handful who also
|
||||||
LambdaMail v2.0 - Phase F4 Complete
|
* administer the server reach the console from inside the app once they are
|
||||||
</div>
|
* signed in. It also advertised cookie names, audiences and internal paths to
|
||||||
<h1 className="text-4xl md:text-5xl font-extrabold tracking-tight bg-gradient-to-r from-white via-slate-200 to-slate-400 bg-clip-text text-transparent mb-4">
|
* anonymous visitors for no reason.
|
||||||
Self-Hosted Mail Infrastructure
|
*
|
||||||
</h1>
|
* Anyone without a session is redirected on to /user/login by the middleware,
|
||||||
<p className="text-slate-400 text-lg">
|
* so this needs no session check of its own.
|
||||||
Integrated Webmail & Administration Platform featuring strict surface isolation, RFC 6238 TOTP authentication, and 13-record DNS automation.
|
*/
|
||||||
</p>
|
export default function RootPage() {
|
||||||
</div>
|
redirect("/user/mail/inbox");
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-8 max-w-4xl w-full">
|
|
||||||
{/* Webmail Surface Card */}
|
|
||||||
<div className="glass-panel rounded-2xl p-8 hover:border-indigo-500/40 transition-all group flex flex-col justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-indigo-500/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400 text-2xl font-bold mb-6 group-hover:scale-110 transition-transform">
|
|
||||||
@
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">Webmail Surface</h2>
|
|
||||||
<p className="text-slate-400 text-sm mb-6">
|
|
||||||
Daily email workspace. Access your inbox, compose messages with sandboxed HTML rendering, manage Sieve filters, and 2FA settings.
|
|
||||||
</p>
|
|
||||||
<div className="text-xs text-slate-500 font-mono space-y-1 mb-8 bg-slate-900/60 p-3 rounded-lg border border-slate-800">
|
|
||||||
<div>Path: /user/*</div>
|
|
||||||
<div>Cookie: lm_user_session (Path=/user)</div>
|
|
||||||
<div>Audience: lambdamail:user</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/user/login"
|
|
||||||
className="w-full py-3 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-center transition-colors shadow-lg shadow-indigo-600/20"
|
|
||||||
>
|
|
||||||
Access Webmail Portal →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Admin Console Card */}
|
|
||||||
<div className="glass-panel rounded-2xl p-8 hover:border-emerald-500/40 transition-all group flex flex-col justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center text-emerald-400 text-2xl font-bold mb-6 group-hover:scale-110 transition-transform">
|
|
||||||
⚙
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">Management Console</h2>
|
|
||||||
<p className="text-slate-400 text-sm mb-6">
|
|
||||||
Stalwart-parity admin console. Manage domains, 13 DNS records, mailboxes, DKIM rotation, outbound queue, DMARC reports, and preflight checks.
|
|
||||||
</p>
|
|
||||||
<div className="text-xs text-slate-500 font-mono space-y-1 mb-8 bg-slate-900/60 p-3 rounded-lg border border-slate-800">
|
|
||||||
<div>Path: /admin/*</div>
|
|
||||||
<div>Cookie: lm_admin_session (Path=/admin)</div>
|
|
||||||
<div>Audience: lambdamail:admin (MFA Mandatory)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/admin/login"
|
|
||||||
className="w-full py-3 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-medium text-center transition-colors shadow-lg shadow-emerald-600/20"
|
|
||||||
>
|
|
||||||
Access Admin Console →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import qrcode from "qrcode-generator";
|
||||||
|
import { useTranslations } from "../i18n/provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scannable half of TOTP enrolment, shared by the webmail settings page and
|
||||||
|
* the admin sign-in step-up so both offer the same thing.
|
||||||
|
*
|
||||||
|
* Previously each screen printed the base32 secret as text and left the
|
||||||
|
* operator to type 32 characters into their phone by hand. Every authenticator
|
||||||
|
* app expects to scan an otpauth:// URI, and the enrol endpoint already returns
|
||||||
|
* one - it simply was not being drawn.
|
||||||
|
*
|
||||||
|
* The QR is rendered locally rather than fetched: the secret is already in this
|
||||||
|
* page's memory, and sending it to an image service - even our own - would put
|
||||||
|
* a second factor's seed into another request log for no benefit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an <img>-ready data URI for the given otpauth URI.
|
||||||
|
*
|
||||||
|
* Type number 0 lets the library pick the smallest QR version that fits, and
|
||||||
|
* level M tolerates ~15% damage, which is the usual choice for screen-displayed
|
||||||
|
* codes that may be scanned at an angle.
|
||||||
|
*/
|
||||||
|
function qrDataUri(text: string): string | null {
|
||||||
|
try {
|
||||||
|
const qr = qrcode(0, "M");
|
||||||
|
qr.addData(text);
|
||||||
|
qr.make();
|
||||||
|
// Module size 6 gives a crisp code in the ~200px box below without scaling
|
||||||
|
// a blurry bitmap up. The margin is the 4-module quiet zone the spec
|
||||||
|
// requires: baking it into the image means the code still scans even if the
|
||||||
|
// surrounding styling changes.
|
||||||
|
return qr.createDataURL(6, 4);
|
||||||
|
} catch {
|
||||||
|
// An over-long URI is the only realistic failure. The manual key below is a
|
||||||
|
// complete fallback, so a missing image must not take the screen down.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TotpEnrolment({ secret, uri }: { secret: string; uri: string | null }) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const dataUri = useMemo(() => (uri ? qrDataUri(uri) : null), [uri]);
|
||||||
|
|
||||||
|
const copySecret = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(secret);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard access can be refused; the key stays selectable on screen.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{dataUri && (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<p className="text-xs text-slate-300 text-center">{t("auth.scanQr")}</p>
|
||||||
|
{/* A QR must stay light-on-dark-free to scan: the white quiet zone is
|
||||||
|
part of the format, so this box keeps its own background. */}
|
||||||
|
<div className="rounded-xl bg-white p-3">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={dataUri}
|
||||||
|
alt={t("auth.qrCodeAlt")}
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
className="block h-[200px] w-[200px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-slate-400">{t("auth.cantScanQr")}</p>
|
||||||
|
<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">
|
||||||
|
{secret}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{copied ? t("common.copied") : t("common.copy")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one-time recovery codes, with the two distinct actions the screen needs.
|
||||||
|
*
|
||||||
|
* The admin sign-in previously rendered its "sign in" button here *and* kept
|
||||||
|
* the form's own submit button visible underneath, so this step showed two
|
||||||
|
* identical buttons and no way to copy the codes it was telling you to save.
|
||||||
|
*/
|
||||||
|
export function RecoveryCodes({
|
||||||
|
codes,
|
||||||
|
onContinue,
|
||||||
|
continueLabel,
|
||||||
|
}: {
|
||||||
|
codes: string[];
|
||||||
|
onContinue: () => void;
|
||||||
|
continueLabel: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copyAll = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(codes.join("\n"));
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Same as above: the codes remain on screen and selectable.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="mb-1 font-bold">{t("settings.recoveryCodesTitle")}</div>
|
||||||
|
<p className="mb-3">{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">
|
||||||
|
{codes.map((code) => (
|
||||||
|
<span key={code} className="rounded border border-slate-800 p-1">
|
||||||
|
{code}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{copied ? t("common.copied") : t("common.copy")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{continueLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,17 +58,35 @@ export function negotiateLocale(acceptLanguage: string | null): Locale {
|
|||||||
return DEFAULT_LOCALE;
|
return DEFAULT_LOCALE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Values substituted into a message's {placeholders}. */
|
||||||
|
export type TranslateParams = Record<string, string | number>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a dotted key such as "auth.signInButton".
|
* Fills {name} placeholders from params.
|
||||||
|
*
|
||||||
|
* A placeholder with no matching param is left alone rather than blanked, so a
|
||||||
|
* missing value reads as "{seconds}" - visibly wrong - instead of silently
|
||||||
|
* producing a sentence with a hole in it.
|
||||||
|
*/
|
||||||
|
function interpolate(template: string, params?: TranslateParams): string {
|
||||||
|
if (!params) return template;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (whole, name: string) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : whole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a dotted key such as "auth.signInButton", substituting any
|
||||||
|
* {placeholders} from params.
|
||||||
*
|
*
|
||||||
* A missing key falls back to English and, failing that, returns the key
|
* A missing key falls back to English and, failing that, returns the key
|
||||||
* itself: a screen showing "settings.title" is a visible bug report, whereas
|
* itself: a screen showing "settings.title" is a visible bug report, whereas
|
||||||
* throwing would blank the page over a typo in a label.
|
* throwing would blank the page over a typo in a label.
|
||||||
*/
|
*/
|
||||||
export function translate(messages: Messages, key: string): string {
|
export function translate(messages: Messages, key: string, params?: TranslateParams): string {
|
||||||
const [namespace, name] = key.split(".");
|
const [namespace, name] = key.split(".");
|
||||||
const value = messages?.[namespace]?.[name];
|
const value = messages?.[namespace]?.[name];
|
||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") return interpolate(value, params);
|
||||||
const fallback = MESSAGES[DEFAULT_LOCALE]?.[namespace]?.[name];
|
const fallback = MESSAGES[DEFAULT_LOCALE]?.[namespace]?.[name];
|
||||||
return typeof fallback === "string" ? fallback : key;
|
return typeof fallback === "string" ? interpolate(fallback, params) : key;
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
|
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||||
import { LOCALE_COOKIE, MESSAGES, DEFAULT_LOCALE, translate, type Locale } from "./config";
|
import {
|
||||||
|
LOCALE_COOKIE,
|
||||||
|
MESSAGES,
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
translate,
|
||||||
|
type Locale,
|
||||||
|
type TranslateParams,
|
||||||
|
} from "./config";
|
||||||
|
|
||||||
|
export type TranslateFn = (key: string, params?: TranslateParams) => string;
|
||||||
|
|
||||||
interface I18nContextValue {
|
interface I18nContextValue {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
setLocale: (locale: Locale) => void;
|
setLocale: (locale: Locale) => void;
|
||||||
t: (key: string) => string;
|
t: TranslateFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||||
@@ -32,7 +41,7 @@ export function I18nProvider({ locale: initialLocale, children }: { locale: Loca
|
|||||||
|
|
||||||
const value = useMemo<I18nContextValue>(() => {
|
const value = useMemo<I18nContextValue>(() => {
|
||||||
const messages = MESSAGES[locale] ?? MESSAGES[DEFAULT_LOCALE];
|
const messages = MESSAGES[locale] ?? MESSAGES[DEFAULT_LOCALE];
|
||||||
return { locale, setLocale, t: (key: string) => translate(messages, key) };
|
return { locale, setLocale, t: (key, params) => translate(messages, key, params) };
|
||||||
}, [locale, setLocale]);
|
}, [locale, setLocale]);
|
||||||
|
|
||||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
@@ -47,6 +56,6 @@ export function useI18n(): I18nContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Shorthand for components that only need to translate. */
|
/** Shorthand for components that only need to translate. */
|
||||||
export function useTranslations(): (key: string) => string {
|
export function useTranslations(): TranslateFn {
|
||||||
return useI18n().t;
|
return useI18n().t;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { MESSAGES, translate } from "./i18n/config.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards the class of defect this interface kept shipping: text that cannot be
|
||||||
|
* translated, and screens filled with invented data.
|
||||||
|
*
|
||||||
|
* Each of these reproduces something that reached production. They are file
|
||||||
|
* assertions rather than rendering tests because that is where the defects
|
||||||
|
* lived - a constant at the top of a module, a placeholder never substituted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function sourceFiles(): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const walk = (dir: string) => {
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const path = join(dir, entry);
|
||||||
|
if (statSync(path).isDirectory()) walk(path);
|
||||||
|
else if (path.endsWith(".tsx") || (path.endsWith(".ts") && !path.includes(".test."))) out.push(path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(resolve(process.cwd(), "src"));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("message interpolation", () => {
|
||||||
|
// The compose screen rendered "Undo Send ({seconds}s remaining) (9s
|
||||||
|
// remaining)": the bundle's placeholder was never filled, and a second
|
||||||
|
// hardcoded countdown was appended after it.
|
||||||
|
it("substitutes placeholders instead of printing them", () => {
|
||||||
|
const out = translate(MESSAGES.en, "mail.undoSend", { seconds: 9 });
|
||||||
|
expect(out).toContain("9");
|
||||||
|
expect(out).not.toContain("{seconds}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("substitutes in every locale, not just the fallback", () => {
|
||||||
|
for (const locale of ["en", "pt-BR", "es"] as const) {
|
||||||
|
expect(translate(MESSAGES[locale], "mail.undoSend", { seconds: 3 })).not.toContain("{");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leaving the token visible is a bug report; blanking it hides the bug.
|
||||||
|
it("leaves an unknown placeholder alone rather than blanking it", () => {
|
||||||
|
expect(translate(MESSAGES.en, "mail.undoSend", {})).toContain("{seconds}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still resolves keys that take no parameters", () => {
|
||||||
|
expect(translate(MESSAGES["pt-BR"], "auth.signInButton")).toBe("Entrar");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the Portuguese bundle is written in Portuguese", () => {
|
||||||
|
// A pass that stripped diacritics to satisfy the ASCII-source rule was
|
||||||
|
// applied to the JSON bundles as well, so the console displayed Portuguese
|
||||||
|
// words with their accents missing. That rule covers source code; a
|
||||||
|
// translation is data, and stripping it there just makes the text wrong.
|
||||||
|
//
|
||||||
|
// The expected values are written as \u escapes for the same reason: this
|
||||||
|
// file is source, so it may name the accented words without containing them.
|
||||||
|
it("keeps diacritics on words that require them", () => {
|
||||||
|
const pt = MESSAGES["pt-BR"];
|
||||||
|
expect(pt.ui.recipient).toBe("Destinat\u00e1rio");
|
||||||
|
expect(pt.ui.lastError).toBe("\u00daltimo erro");
|
||||||
|
expect(pt.ui.actions).toBe("A\u00e7\u00f5es");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has accented text across the bundle, not just in a few entries", () => {
|
||||||
|
const values = Object.values(MESSAGES["pt-BR"]).flatMap((ns) => Object.values(ns));
|
||||||
|
const accented = values.filter((v) => /[^\u0000-\u007F]/.test(v));
|
||||||
|
expect(accented.length).toBeGreaterThan(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips comments so these checks read the code, not the prose about it.
|
||||||
|
* Several of the fixes below are documented by naming the value they removed,
|
||||||
|
* and a scan that could not tell the two apart would forbid explaining itself.
|
||||||
|
*/
|
||||||
|
function code(file: string): string {
|
||||||
|
return readFileSync(file, "utf8")
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
|
.replace(/^\s*\/\/.*$/gm, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("no fabricated data is baked into the interface", () => {
|
||||||
|
const files = sourceFiles();
|
||||||
|
const read = (f: string) => readFileSync(f, "utf8");
|
||||||
|
|
||||||
|
// The queue screen shipped two invented jobs whose Retry and Freeze buttons
|
||||||
|
// only edited local state; the sidebars showed a fixed address; the domains
|
||||||
|
// screen showed thirteen example.com records marked VERIFIED.
|
||||||
|
const forbidden = [
|
||||||
|
"job-101",
|
||||||
|
"job-102",
|
||||||
|
"partner@remotehost.com",
|
||||||
|
"alerts@monitoring.org",
|
||||||
|
"admin@lambdamail.local",
|
||||||
|
"user@lambdamail.local",
|
||||||
|
"default-domain",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const needle of forbidden) {
|
||||||
|
it(`does not contain the placeholder "${needle}"`, () => {
|
||||||
|
const offenders = files.filter((f) => code(f).includes(needle));
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reads the outbound queue from the API rather than component state", () => {
|
||||||
|
const queue = read(resolve(process.cwd(), "src/app/(admin)/admin/queue/page.tsx"));
|
||||||
|
expect(queue).toContain("/api/v1/admin/queue");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every KPI fell back to an invented constant, so an unreachable API showed
|
||||||
|
// a dashboard of plausible traffic for a server that had delivered nothing.
|
||||||
|
it("does not fall back to invented dashboard figures", () => {
|
||||||
|
const dash = code(resolve(process.cwd(), "src/app/(admin)/admin/dashboard/page.tsx"));
|
||||||
|
expect(dash).not.toMatch(/\?\?\s*\d{2,}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("surface routing", () => {
|
||||||
|
// The root listed the two surfaces side by side and advertised cookie names
|
||||||
|
// and audiences to anonymous visitors.
|
||||||
|
it("has no surface-selector landing page", () => {
|
||||||
|
const page = code(resolve(process.cwd(), "src/app/page.tsx"));
|
||||||
|
expect(page).toContain("redirect");
|
||||||
|
expect(page).toContain("/user/mail/inbox");
|
||||||
|
expect(page).not.toMatch(/Access Admin Console|Surface Selector/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves no links pointing at the removed selector", () => {
|
||||||
|
for (const file of sourceFiles()) {
|
||||||
|
expect(code(file)).not.toMatch(/Back to Surface Selector|Back to surface selection/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the console from inside the app, gated on the account's role", () => {
|
||||||
|
const layout = readFileSync(resolve(process.cwd(), "src/app/(user)/user/layout.tsx"), "utf8");
|
||||||
|
expect(layout).toContain("isAdminRole");
|
||||||
|
expect(layout).toContain("/admin/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a plain way back to webmail from the console", () => {
|
||||||
|
const layout = readFileSync(resolve(process.cwd(), "src/app/(admin)/admin/layout.tsx"), "utf8");
|
||||||
|
expect(layout).toContain("/user/mail/inbox");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("two-factor enrolment", () => {
|
||||||
|
it("ships a QR code component both enrolment screens use", () => {
|
||||||
|
const path = resolve(process.cwd(), "src/components/TotpEnrolment.tsx");
|
||||||
|
expect(existsSync(path)).toBe(true);
|
||||||
|
expect(readFileSync(path, "utf8")).toContain("qrcode-generator");
|
||||||
|
|
||||||
|
for (const screen of [
|
||||||
|
"src/app/(user)/user/settings/page.tsx",
|
||||||
|
"src/app/(auth)/admin/login/page.tsx",
|
||||||
|
]) {
|
||||||
|
expect(readFileSync(resolve(process.cwd(), screen), "utf8")).toContain("TotpEnrolment");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The recovery-code step rendered its own "sign in" button while the form's
|
||||||
|
// submit button stayed visible below it: two identical buttons, and no way
|
||||||
|
// to copy the codes it told you to save.
|
||||||
|
it("gives the recovery-code step a copy action and a single continue", () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), "src/components/TotpEnrolment.tsx"), "utf8");
|
||||||
|
expect(source).toContain("common.copy");
|
||||||
|
expect(source).toContain("onContinue");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: "USER" | "DOMAIN_ADMIN" | "SUPER_ADMIN";
|
||||||
|
locale: string;
|
||||||
|
mfa_enrolled: boolean;
|
||||||
|
recovery_codes_left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Roles that may open the admin console. */
|
||||||
|
export function isAdminRole(role: string | undefined): boolean {
|
||||||
|
return role === "SUPER_ADMIN" || role === "DOMAIN_ADMIN";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in account for the surface this component is rendered on.
|
||||||
|
*
|
||||||
|
* Both sidebars used to print a fixed "admin@lambdamail.local" / "SUPER_ADMIN
|
||||||
|
* (2FA)" - placeholder text that survived into production and told every
|
||||||
|
* operator the wrong address and, worse, claimed a second factor was active
|
||||||
|
* whether or not it was.
|
||||||
|
*
|
||||||
|
* The endpoint differs per surface because the auth service selects the session
|
||||||
|
* cookie from the URL prefix; see the note on its `me` handler.
|
||||||
|
*/
|
||||||
|
export function useAccount(surface: "user" | "admin"): Account | null {
|
||||||
|
const [account, setAccount] = useState<Account | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetch(`/api/v1/${surface}/me`)
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setAccount(data);
|
||||||
|
})
|
||||||
|
// A failure leaves the caller with null, which renders as a placeholder
|
||||||
|
// rather than as somebody else's address.
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [surface]);
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user