#23: show real data, unify the surfaces and make enrolment scannable
The interface was reporting things that were not true. Several screens were
mockups wired to nothing, and a reader with the console in Portuguese was
served a mixture of two languages.
Fabricated data, removed:
- The outbound queue was two invented jobs held in component state. Retry and
Freeze only edited that local array, so the screen showed the same two rows
on every deployment and reported success for actions that never happened.
"Freeze" named an operation the backend has never implemented. It now reads
/api/v1/admin/queue, which was already serving the real thing, and offers
retry and cancel, which exist.
- Every dashboard KPI fell back to an invented constant (1420 received, 890
sent, 3 queued, 18.4% disk), so an unreachable API produced a dashboard of
plausible traffic for a server that might have delivered nothing. Two of
those fields were never returned by the endpoint at all, so the fake disk
figure was what everyone always saw. DMARC did the same with 1250/98%/97%.
- The domains screen showed thirteen DNS records for example.com, all marked
VERIFIED, and posted {domain_id: "default-domain"} to endpoints expecting a
real id. DKIM rotation sends "domain", not "domain_id", so its 400 was
swallowed and a made-up TXT record displayed as though a key had been
issued: rotation had never once worked from this screen.
- The TLS panel asserted a Let's Encrypt RSA 4096 certificate and verified
DANE from static markup, on a deployment currently serving a self-signed
one. It now reads the cert watcher via /api/v1/admin/tls.
- The readiness scorecard printed "10 / 10 all checks passed" regardless of
what preflight reported, including failures.
- A message trace that found nothing invented a five-step delivery - TLS 1.3
accepted, SPF pass, spam score 0.8 - for a message that does not exist.
- Both sidebars showed a fixed admin@lambdamail.local with a hardcoded
"SUPER_ADMIN (2FA)", claiming a second factor whether or not one existed.
- Settings seeded a "[Boletim]" Sieve rule. Saving posts the whole list, so
the first edit wrote that invented filter into the account's real script.
The reconcile endpoint itself answered {records_verified: 13, status:
"VERIFIED"} for any domain id, including one that does not exist, without
resolving a name. It now reports what it does, which is record the request.
Surfaces:
The root was a chooser listing webmail and the console side by side, which
asked visitors a question they should not have to answer and advertised cookie
names and audiences to anonymous readers. It now redirects into the mail
client. Accounts that may administer the server get a link to the console from
inside the app - a link to /admin/login, so crossing over still costs the
second factor the admin audience requires - and the console gets a plain link
back, since the webmail session was never given up to get there.
Enrolment:
Both enrolment screens printed the base32 secret as text and left the operator
to type 32 characters into their phone. The enrol endpoint already returned an
otpauth:// URI; it simply was not drawn. Added a shared component that renders
it as a QR locally - the seed is already in the page, and sending it to an
image service would put it in another request log for nothing.
The recovery-code step rendered its own "sign in" button while the form's
submit button stayed visible below it: two identical buttons, and no way to
copy the codes it insisted you save. It is now terminal, with copy and
continue.
Language:
translate() had no interpolation, so "Undo Send ({seconds}s remaining)" showed
the placeholder and a second hardcoded countdown was appended after it. It now
substitutes, leaving an unmatched token visible rather than blanking it.
The pt-BR bundle had lost its diacritics - "Destinatario", "Ultimo erro",
"Acoes" - from the ASCII-source rule being applied to the translations, which
are data. Restored, and the three bundles are back in key parity at 270 each,
with roughly 120 strings that were hardcoded in the source now keyed.
Healthchecks:
web had none, despite being the only service users reach directly: a wedged
Next process kept its container running and Traefik kept routing to it. Added,
using node's own fetch since the image ships no curl. rspamd and clamav gained
the timeouts and start_periods whose absence would fail them during startup.
migrate stays without one and says why: it is a one-shot job, and a probe
would report unhealthy exactly when it has succeeded.
Verified: 36 webmail tests and 41 auth tests against a real Postgres, both new
admin/me cases confirmed to fail without the route (404). lang-lint clean,
production build clean, 0 vulnerabilities, compose validates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f7e93275d5
commit
ee240ae227
29 files changed
+2117
-1137
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
|
||||||
|
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>
|
</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
|
||||||
|
that listed SMTP, IMAP, Rspamd and the cert watcher with a hardcoded
|
||||||
|
"Online"/"4/4 active" and a pulsing green dot - nothing was ever polled,
|
||||||
|
so it stayed green through any outage - and a domain panel pinned to
|
||||||
|
"example.com" with "13 RECORDS VERIFIED" and a "last checked: just now"
|
||||||
|
that was a literal string. Container liveness is now covered by the
|
||||||
|
compose healthchecks, and the per-domain DNS state lives on the domains
|
||||||
|
screen, which reads it from the database.
|
||||||
|
*/}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Globe className="w-5 h-5 text-indigo-400" />
|
<Globe className="w-5 h-5 text-indigo-400" />
|
||||||
{t("ui.domainVerification")}
|
{t("ui.domainVerification")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="success">Auto-Reconciled</Badge>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800 space-y-3">
|
{metrics ? (
|
||||||
<div className="flex items-center justify-between">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800">
|
||||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1.5">
|
||||||
<span className="font-bold text-sm text-slate-100">example.com</span>
|
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
{t("ui.domainsVerified")}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="success">13 RECORDS VERIFIED</Badge>
|
<div className="text-2xl font-bold text-white">
|
||||||
</div>
|
{metrics.domains_verified}
|
||||||
<p className="text-xs text-slate-400 leading-relaxed">
|
<span className="text-slate-500 text-base"> / {metrics.domains_total}</span>
|
||||||
Cloudflare DNS automation active. SPF, DKIM (RSA 2048 + Ed25519), DMARC (quarantine), MTA-STS e TLS-RPT totalmente sincronizados e validados.
|
|
||||||
</p>
|
|
||||||
<div className="pt-2 flex items-center justify-between text-xs text-slate-400 border-t border-slate-800 font-mono">
|
|
||||||
<span>Last checked: just now</span>
|
|
||||||
<span className="text-indigo-400 hover:underline cursor-pointer">Verificar Registros →</span>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
|
<Server className="w-3.5 h-3.5 text-indigo-400" />
|
||||||
|
{t("ui.mailboxesActive")}
|
||||||
|
</div>
|
||||||
|
<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 className="p-6 text-center text-xs text-slate-500">{t("ui.noData")}</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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.
|
||||||
setDkimRotationStep("generated");
|
setDkimRecord({
|
||||||
setGeneratedDkimTxt(`v=DKIM1; k=${dkimKeyType.toLowerCase().includes("ed") ? "ed25519" : "rsa"}; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC3...`);
|
name: `${data.selector}._domainkey.${data.domain}`,
|
||||||
};
|
value: data.public_key_record,
|
||||||
|
overlapDays: data.overlap_days,
|
||||||
const handleActivateDkim = () => {
|
|
||||||
setDkimRotationStep("active");
|
|
||||||
};
|
|
||||||
|
|
||||||
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 {
|
} catch (err) {
|
||||||
// fallback
|
setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
|
||||||
}
|
} finally {
|
||||||
}
|
setRotating(false);
|
||||||
if (onboardStep < 4) {
|
|
||||||
setOnboardStep(onboardStep + 1);
|
|
||||||
} else {
|
|
||||||
setOnboardSuccess(true);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ 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 },
|
||||||
|
];
|
||||||
|
|
||||||
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">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("dns")}
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
||||||
activeTab === "dns"
|
activeTab === tab.id
|
||||||
? "bg-emerald-600 text-white shadow-sm"
|
? "bg-emerald-600 text-white shadow-sm"
|
||||||
: "text-slate-400 hover:text-slate-200"
|
: "text-slate-400 hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Globe className="w-3.5 h-3.5" />
|
<Icon className="w-3.5 h-3.5" />
|
||||||
<span>Matriz 13 Registros DNS</span>
|
<span>{tab.label}</span>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("onboarding")}
|
|
||||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
|
||||||
activeTab === "onboarding"
|
|
||||||
? "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>
|
</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 && (
|
||||||
|
<div className="p-4 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-xs">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "domains" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{domains.length === 0 && !loading ? (
|
||||||
|
<Card className="p-8 text-center text-xs text-slate-500">{t("admin.noDomains")}</Card>
|
||||||
|
) : (
|
||||||
|
domains.map((d) => (
|
||||||
|
<Card key={d.id} className="p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-indigo-500/15 border border-indigo-500/30 flex items-center justify-center text-indigo-400 flex-shrink-0">
|
||||||
<Globe className="w-5 h-5" />
|
<Globe className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="text-base font-bold text-white">example.com</div>
|
<div className="text-base font-bold text-white truncate">{d.name}</div>
|
||||||
<div className="text-xs text-slate-400">Primary domain | DMARC: quarantine | MTA-STS: testing</div>
|
<div className="text-xs text-slate-400">
|
||||||
|
{t("admin.mailboxCount", { count: d.mailbox_count })}
|
||||||
|
{d.dmarc_policy ? ` | DMARC: ${d.dmarc_policy}` : ""}
|
||||||
|
{d.mta_sts_mode ? ` | MTA-STS: ${d.mta_sts_mode}` : ""}
|
||||||
|
{d.dane_enabled ? " | DANE" : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="success">13 / 13 VERIFICADOS</Badge>
|
</div>
|
||||||
</Card>
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Badge variant={statusVariant(d.dns_status)}>{d.dns_status}</Badge>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant={selectedId === d.id ? "primary" : "outline"}
|
||||||
size="md"
|
size="sm"
|
||||||
onClick={handleReconcile}
|
onClick={() => setSelectedId(d.id)}
|
||||||
disabled={reconciling}
|
|
||||||
className="self-stretch sm:self-auto"
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 ${reconciling ? "animate-spin" : ""}`} />
|
{selectedId === d.id ? <CheckCircle2 className="w-3.5 h-3.5" /> : null}
|
||||||
<span>{reconciling ? "Sincronizando Cloudflare API..." : "Reconciliar Registros DNS"}</span>
|
<span>{selectedId === d.id ? t("common.status") : t("common.add")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{syncMessage && (
|
|
||||||
<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">
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 flex-shrink-0" />
|
|
||||||
<span>{syncMessage}</span>
|
|
||||||
</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 className="grid grid-cols-4 gap-2 text-center text-xs">
|
|
||||||
{["1. Domain", "2. DNS records", "3. Verification", "4. Aliases"].map((label, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className={`p-2.5 rounded-xl border font-semibold transition-all ${
|
|
||||||
onboardStep === idx + 1
|
|
||||||
? "bg-emerald-600/20 border-emerald-500 text-emerald-300 shadow-sm"
|
|
||||||
: onboardStep > idx + 1
|
|
||||||
? "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>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleNextOnboardStep} className="space-y-4 text-xs">
|
|
||||||
{onboardStep === 1 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-semibold text-slate-300 block mb-1">Domain name</label>
|
<label className="font-semibold text-slate-300 block mb-1">{t("admin.domainName")}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={onboardDomain}
|
value={onboardDomain}
|
||||||
onChange={(e) => setOnboardDomain(e.target.value)}
|
onChange={(e) => setOnboardDomain(e.target.value)}
|
||||||
placeholder="novafirma.com"
|
placeholder="example.org"
|
||||||
required
|
required
|
||||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500"
|
className="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>
|
||||||
<div>
|
<Button type="submit" variant="primary" size="md" disabled={onboarding}>
|
||||||
<label className="font-semibold text-slate-300 block mb-1">E-mail do Administrador Principal</label>
|
<Plus className="w-4 h-4" />
|
||||||
<input
|
<span>{onboarding ? t("common.loading") : t("common.add")}</span>
|
||||||
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>
|
||||||
|
|
||||||
<Button type="submit" variant="primary" size="md">
|
|
||||||
<span>{onboardStep === 4 ? "Concluir Onboarding" : "Next step"}</span>
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</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>Gerar Novo Par de Chaves DKIM</span>
|
<span>{rotating ? t("common.loading") : t("admin.dkimRotate")}</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,57 +1,147 @@
|
|||||||
"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 className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.queueTitle")}</h1>
|
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.queueTitle")}</h1>
|
||||||
<p className="text-xs text-slate-400">Inspect outbound queue, retry retriable jobs, or freeze problematic destinations.</p>
|
<p className="text-xs text-slate-400">{t("admin.queueSubtitle")}</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{jobs.length === 0 && !loading ? (
|
||||||
|
<div className="p-8 text-center text-xs text-slate-500">{t("admin.queueEmpty")}</div>
|
||||||
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse text-xs">
|
<table className="w-full text-left border-collapse text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -62,36 +152,42 @@ export default function AdminQueuePage() {
|
|||||||
<th className="p-3">{t("ui.destination")}</th>
|
<th className="p-3">{t("ui.destination")}</th>
|
||||||
<th className="p-3">{t("ui.attempts")}</th>
|
<th className="p-3">{t("ui.attempts")}</th>
|
||||||
<th className="p-3">{t("ui.lastError")}</th>
|
<th className="p-3">{t("ui.lastError")}</th>
|
||||||
<th className="p-3">Status</th>
|
<th className="p-3">{t("common.status")}</th>
|
||||||
<th className="p-3">{t("ui.actions")}</th>
|
<th className="p-3">{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">
|
||||||
{jobs.map((j) => (
|
{jobs.map((j) => (
|
||||||
<tr key={j.id} className="hover:bg-slate-900/30">
|
<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 font-bold text-slate-200">{j.id.slice(0, 8)}</td>
|
||||||
<td className="p-3 text-slate-300">{j.sender}</td>
|
<td className="p-3 text-slate-300">{j.envelope_from}</td>
|
||||||
<td className="p-3 text-slate-300">{j.recipient}</td>
|
<td className="p-3 text-slate-300">{j.envelope_to}</td>
|
||||||
<td className="p-3 text-emerald-400">{j.destinationDomain}</td>
|
<td className="p-3 text-emerald-400">{j.destination_domain}</td>
|
||||||
<td className="p-3 text-slate-400">{j.attempts}</td>
|
<td className="p-3 text-slate-400">{j.attempt}</td>
|
||||||
<td className="p-3 text-slate-400 truncate max-w-xs">{j.lastError}</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">
|
<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]"}>
|
<span className={`${statusClass(j.status)} px-2 py-0.5 rounded text-[10px]`}>
|
||||||
{j.status}
|
{j.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 flex items-center gap-2">
|
<td className="p-3 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRetry(j.id)}
|
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"
|
className="px-2 py-1 rounded bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 text-[10px] font-bold"
|
||||||
>
|
>
|
||||||
Retry
|
{t("admin.retryJob")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleFreeze(j.destinationDomain)}
|
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"
|
className="px-2 py-1 rounded bg-red-600/20 hover:bg-red-600/30 text-red-300 text-[10px] font-bold"
|
||||||
>
|
>
|
||||||
Freeze
|
{t("admin.cancelJob")}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -99,6 +195,7 @@ export default function AdminQueuePage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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
|
||||||
|
// in the reader's language. It used to send a finished English
|
||||||
|
// sentence ("DNS records for example.com") that appeared verbatim
|
||||||
|
// in an otherwise translated console.
|
||||||
|
data.checks.map(
|
||||||
|
(
|
||||||
|
c: { key?: string; target?: string; name: string; status: string; detail?: string },
|
||||||
|
index: number,
|
||||||
|
) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
name: c.name,
|
name: c.key && c.target ? t(`admin.${c.key}`, { domain: c.target }) : c.name,
|
||||||
category: "Security and DNS",
|
category: t("ui.domainVerification"),
|
||||||
status: c.status === "PASS" ? "PASSED" : c.status === "FAIL" ? "FAILED" : "WARNING",
|
status: c.status === "PASS" ? "PASSED" : c.status === "FAIL" ? "FAILED" : "WARNING",
|
||||||
details: c.detail ?? "",
|
details: c.detail ?? "",
|
||||||
})) as PreflightCheck[]
|
}),
|
||||||
|
) 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>
|
||||||
|
{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">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-3xl font-black text-emerald-400 font-mono">10 / 10</span>
|
<span
|
||||||
<Badge variant="success">TODAS AS CHECAGENS APROVADAS</Badge>
|
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>
|
</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>
|
||||||
|
|
||||||
|
{tlsStatus ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
<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="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-slate-400 font-medium">{t("admin.certificateState")}</div>
|
||||||
<div className="text-emerald-400 font-bold text-sm">Let's Encrypt RSA 4096</div>
|
<div
|
||||||
<div className="text-[10px] text-slate-500 font-mono">Renewal is automatic</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>
|
||||||
|
|
||||||
<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">Status Registros DANE TLSA</div>
|
<div className="text-slate-400 font-medium">{t("admin.mailHost")}</div>
|
||||||
<div className="text-emerald-400 font-bold text-sm">3 1 1 SHA-256 Verificado</div>
|
<div className="text-slate-100 font-bold text-sm break-all">
|
||||||
<div className="text-[10px] text-slate-500 font-mono">Cadeia DNSSEC verificada na porta 25</div>
|
{String(tlsStatus.mail_host ?? "-")}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-500 font-mono">
|
||||||
|
{t("admin.tlsMode")}: {String(tlsStatus.tls_mode ?? "-")}
|
||||||
|
</div>
|
||||||
</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.watcherHealthy")}</div>
|
||||||
<div className="text-emerald-400 font-bold text-sm">mode=enforce</div>
|
<div
|
||||||
<div className="text-[10px] text-slate-500 font-mono">max_age=604800 (7 dias)</div>
|
className={`font-bold text-sm ${
|
||||||
|
tlsStatus.watcher_healthy ? "text-emerald-400" : "text-amber-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tlsStatus.watcher_healthy ? t("ui.healthy") : t("common.unavailable")}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-500 font-mono">
|
||||||
|
{t("admin.lastReload")}:{" "}
|
||||||
|
{tlsStatus.last_reload
|
||||||
|
? new Date(String(tlsStatus.last_reload)).toLocaleString()
|
||||||
|
: t("common.never")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="space-y-3">
|
|
||||||
<h3 className="font-bold text-slate-100">Accepted TLS cipher suites (ports 25 / 587 / 993)</h3>
|
|
||||||
<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 className="text-emerald-400 font-semibold">TLS_AES_256_GCM_SHA384 (TLS 1.3) [PREFERENCIAL]</div>
|
|
||||||
<div>TLS_CHACHA20_POLY1305_SHA256 (TLS 1.3)</div>
|
|
||||||
<div>ECDHE-ECDSA-AES256-GCM-SHA384 (TLS 1.2)</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
{/* 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 ? (
|
||||||
<div className="space-y-3">
|
<RecoveryCodes
|
||||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-300">
|
codes={recoveryCodes}
|
||||||
<div className="mb-1 font-bold">{t("settings.recoveryCodesTitle")}</div>
|
onContinue={() => setRecoveryCodes(null)}
|
||||||
<p className="mb-2">{t("settings.saveRecoveryCodes")}</p>
|
continueLabel={t("auth.signInButton")}
|
||||||
<div className="grid grid-cols-2 gap-1 font-mono text-[11px] text-white">
|
/>
|
||||||
{recoveryCodes.map((code) => (
|
) : (
|
||||||
<span key={code}>{code}</span>
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
))}
|
{enrolSecret ? (
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
</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