diff --git a/.env.example b/.env.example index 2eb48e98..b19a8e33 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,8 @@ RESEND_API_KEY=re_xxxxxxxxxxxxx CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_EMAIL_API_TOKEN= EMAIL_FROM="WATeamInbox " +# Product feedback submitted through POST /api/feedback is delivered here. +FEEDBACK_TO_EMAIL=contact@wateaminbox.com # App APP_URL=http://localhost:4444 diff --git a/.env.production.example b/.env.production.example index b78a748b..6ed57a3d 100644 --- a/.env.production.example +++ b/.env.production.example @@ -37,6 +37,8 @@ S3_SIGNED_URL_TTL_SECONDS=300 # Mail provider: resend or cloudflare. Set the matching key file below; the # other provider's variables stay unset and need no placeholder secret. MAIL_DRIVER=resend +# Product feedback submitted through POST /api/feedback is delivered here. +FEEDBACK_TO_EMAIL=contact@wateaminbox.com # Cloudflare Email Service only (MAIL_DRIVER=cloudflare): the 32-character # account ID that owns the onboarded sender domain. Not a secret; the token is. CLOUDFLARE_ACCOUNT_ID= diff --git a/apps/api/src/lib/env.test.ts b/apps/api/src/lib/env.test.ts index efdb8492..81c453cd 100644 --- a/apps/api/src/lib/env.test.ts +++ b/apps/api/src/lib/env.test.ts @@ -16,6 +16,7 @@ function productionEnv(overrides: Partial = {}): Env { MAIL_DRIVER: "resend", RESEND_API_KEY: "re_live_acme_123456789", EMAIL_FROM: "WATeamInbox ", + FEEDBACK_TO_EMAIL: "feedback@acme.test", APP_URL: "https://inbox.acme.test", CORS_ORIGINS: "https://inbox.acme.test,https://admin.acme.test", CENTRIFUGO_API_URL: "https://realtime.acme.test/api", @@ -76,6 +77,7 @@ describe("production environment validation", () => { "MEILISEARCH_API_KEY", "RESEND_API_KEY", "EMAIL_FROM", + "FEEDBACK_TO_EMAIL", "JWT_SECRET", "CENTRIFUGO_API_KEY", "CORS_ORIGINS", @@ -148,6 +150,10 @@ describe("production environment validation", () => { expectInvalid({ MAIL_DRIVER: "smtp" }, /MAIL_DRIVER must be one of/); expectInvalid({ RESEND_API_KEY: "re_xxxxxxxxxxxxx" }, /placeholder value/); expectInvalid({ EMAIL_FROM: "not-an-email" }, /valid email address/); + expectInvalid( + { FEEDBACK_TO_EMAIL: "not-an-email" }, + /FEEDBACK_TO_EMAIL must contain a valid email address/, + ); expectInvalid( { EMAIL_FROM: "WATeamInbox " }, /reserved example domain/, @@ -183,15 +189,15 @@ describe("production environment validation", () => { ).not.toThrow(); }); - test.each([ - "CLOUDFLARE_ACCOUNT_ID", - "CLOUDFLARE_EMAIL_API_TOKEN", - ] as const)("requires Cloudflare setting %s", (key) => { - expectInvalid( - { ...cloudflare, [key]: "" }, - /Missing required production environment variables/, - ); - }); + test.each(["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_EMAIL_API_TOKEN"] as const)( + "requires Cloudflare setting %s", + (key) => { + expectInvalid( + { ...cloudflare, [key]: "" }, + /Missing required production environment variables/, + ); + }, + ); test("rejects an account ID that is not a Cloudflare account ID", () => { for (const accountId of [ @@ -396,18 +402,16 @@ describe("signing secrets are validated in every environment", () => { expect(() => validateSigningSecrets()).not.toThrow(); }); - test.each([ - "development", - "test", - "staging", - "", - ])("a missing JWT_SECRET fails closed when NODE_ENV is %p", (nodeEnv) => { - expect(() => - validateSigningSecrets( - nonProductionEnv({ NODE_ENV: nodeEnv, JWT_SECRET: "" }), - ), - ).toThrow(/JWT_SECRET is required and must not be blank/); - }); + test.each(["development", "test", "staging", ""])( + "a missing JWT_SECRET fails closed when NODE_ENV is %p", + (nodeEnv) => { + expect(() => + validateSigningSecrets( + nonProductionEnv({ NODE_ENV: nodeEnv, JWT_SECRET: "" }), + ), + ).toThrow(/JWT_SECRET is required and must not be blank/); + }, + ); test("a whitespace-only JWT_SECRET is treated as missing", () => { expect(() => diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 90e7b1ea..8c4bf54f 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -89,6 +89,7 @@ export const env = { CLOUDFLARE_ACCOUNT_ID: getEnv("CLOUDFLARE_ACCOUNT_ID", ""), CLOUDFLARE_EMAIL_API_TOKEN: getEnv("CLOUDFLARE_EMAIL_API_TOKEN", ""), EMAIL_FROM: getEnv("EMAIL_FROM", "noreply@example.com"), + FEEDBACK_TO_EMAIL: getEnv("FEEDBACK_TO_EMAIL", "contact@wateaminbox.com"), // App APP_URL: getEnv("APP_URL", "http://localhost:4444"), @@ -456,6 +457,7 @@ export function validateProductionEnv(config: Env = env): void { MEILISEARCH_API_KEY: config.MEILISEARCH_API_KEY, ...requiredMailCredentials(config), EMAIL_FROM: config.EMAIL_FROM, + FEEDBACK_TO_EMAIL: config.FEEDBACK_TO_EMAIL, APP_URL: config.APP_URL, CORS_ORIGINS: config.CORS_ORIGINS, }; @@ -593,6 +595,14 @@ export function validateProductionEnv(config: Env = env): void { ); } + if ( + !/^[^<>\s@]+@[^<>\s@]+\.[^<>\s@]+$/.test(config.FEEDBACK_TO_EMAIL.trim()) + ) { + throw new Error( + "FEEDBACK_TO_EMAIL must contain a valid email address in production", + ); + } + if (config.JWT_SECRET.length < 32) { throw new Error( "JWT_SECRET must contain at least 32 characters in production", diff --git a/apps/api/src/routes/feedback.ts b/apps/api/src/routes/feedback.ts index 4a95964d..20c657ef 100644 --- a/apps/api/src/routes/feedback.ts +++ b/apps/api/src/routes/feedback.ts @@ -2,12 +2,14 @@ * Feedback Routes * * Public endpoint for users to submit product feedback. - * Sends feedback to contact@wateaminbox.com via email. + * Sends feedback to the configured feedback recipient via email. */ -import { Hono } from "hono"; + import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; import { z } from "zod"; import { sendEmail } from "../lib/email.js"; +import { env } from "../lib/env.js"; import { serverError } from "../lib/errors.js"; import { escapeHtml } from "../lib/security.js"; @@ -34,7 +36,7 @@ feedbackRoutes.post("/", zValidator("json", feedbackSchema), async (c) => { const safeMessage = escapeHtml(body.message); const result = await sendEmail({ - to: "contact@wateaminbox.com", + to: env.FEEDBACK_TO_EMAIL, subject: `WATeamInbox Feedback${body.email ? ` from ${body.email}` : ""}`, html: `
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9e3df255..c75c0a18 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -10,6 +10,7 @@ import { ProtectedRoute, WorkspaceRouteGuard, } from "./components/auth"; +import { FeedbackWidget } from "./components/feedback"; import { ProtectedAppLayout } from "./components/layout/ProtectedAppLayout"; import { KeyboardShortcutsModal } from "./components/settings"; import { PageSkeleton } from "./components/ui"; @@ -296,6 +297,7 @@ function App() { + ); diff --git a/apps/web/src/components/feedback/FeedbackWidget.tsx b/apps/web/src/components/feedback/FeedbackWidget.tsx new file mode 100644 index 00000000..4f30aa35 --- /dev/null +++ b/apps/web/src/components/feedback/FeedbackWidget.tsx @@ -0,0 +1,181 @@ +import { X } from "lucide-react"; +import { useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { submitFeedback } from "@/lib/api/feedback"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +const FEEDBACK_DISMISSED_KEY = "wateaminbox-feedback-dismissed"; +const FEEDBACK_MIN_LENGTH = 10; +const FEEDBACK_MAX_LENGTH = 5000; + +function readDismissed(): boolean { + try { + return localStorage.getItem(FEEDBACK_DISMISSED_KEY) === "1"; + } catch { + return false; + } +} + +/** + * Floating feedback widget. + * + * Renders a vertical "Feedback" tab docked to the right edge of the viewport. + * Clicking the tab opens a dialog whose form posts to the public `/api/feedback` + * endpoint. The × button dismisses the tab permanently for this browser. + */ +export function FeedbackWidget() { + const { t } = useTranslation(); + const [dismissed, setDismissed] = useState(readDismissed); + const [open, setOpen] = useState(false); + const [message, setMessage] = useState(""); + const [email, setEmail] = useState(""); + const [submitting, setSubmitting] = useState(false); + + if (dismissed) { + return null; + } + + const handleDismiss = () => { + setDismissed(true); + try { + localStorage.setItem(FEEDBACK_DISMISSED_KEY, "1"); + } catch { + // Ignore storage failures; the tab will simply return on next reload. + } + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + if (message.trim().length < FEEDBACK_MIN_LENGTH) { + toast.error( + t("feedback.minLength", "Feedback must be at least 10 characters."), + ); + return; + } + + setSubmitting(true); + try { + await submitFeedback({ + message: message.trim(), + email: email.trim() || undefined, + }); + toast.success(t("feedback.success", "Thank you for your feedback!")); + setOpen(false); + setMessage(""); + setEmail(""); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : t( + "feedback.error", + "Failed to submit feedback. Please try again later.", + ), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + <> +
+
+ + +
+
+ + + + + {t("feedback.title", "Send feedback")} + + {t( + "feedback.description", + "Tell us what's working and what we can improve.", + )} + + + +
+
+ +