Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ RESEND_API_KEY=re_xxxxxxxxxxxxx
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_EMAIL_API_TOKEN=
EMAIL_FROM="WATeamInbox <noreply@example.com>"
# Product feedback submitted through POST /api/feedback is delivered here.
FEEDBACK_TO_EMAIL=contact@wateaminbox.com

# App
APP_URL=http://localhost:4444
Expand Down
2 changes: 2 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
46 changes: 25 additions & 21 deletions apps/api/src/lib/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function productionEnv(overrides: Partial<Env> = {}): Env {
MAIL_DRIVER: "resend",
RESEND_API_KEY: "re_live_acme_123456789",
EMAIL_FROM: "WATeamInbox <noreply@acme.test>",
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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 <noreply@example.com>" },
/reserved example domain/,
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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(() =>
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 5 additions & 3 deletions apps/api/src/routes/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -296,6 +297,7 @@ function App() {
<AnalyticsRouteTracker />
<AnalyticsConsent />
<KeyboardShortcutsModal />
<FeedbackWidget />
<Toaster position="top-right" richColors />
</>
);
Expand Down
181 changes: 181 additions & 0 deletions apps/web/src/components/feedback/FeedbackWidget.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLFormElement>) => {
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 (
<>
<div className="fixed right-0 top-1/2 z-40 -translate-y-1/2">
<div className="flex flex-col items-center overflow-hidden rounded-l-lg bg-zinc-900 text-white shadow-lg dark:bg-black">
<button
type="button"
onClick={handleDismiss}
aria-label={t("feedback.dismiss", "Dismiss feedback tab")}
className="grid h-8 w-9 place-items-center text-white/70 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => setOpen(true)}
className="rotate-180 px-2.5 py-5 text-xs font-semibold uppercase tracking-[0.2em] transition-colors [writing-mode:vertical-rl] hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60"
>
{t("feedback.tab", "Feedback")}
</button>
</div>
</div>

<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("feedback.title", "Send feedback")}</DialogTitle>
<DialogDescription>
{t(
"feedback.description",
"Tell us what's working and what we can improve.",
)}
</DialogDescription>
</DialogHeader>

<form onSubmit={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="feedback-message">
{t("feedback.messageLabel", "Message")}
</Label>
<Textarea
id="feedback-message"
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder={t(
"feedback.messagePlaceholder",
"Share your thoughts…",
)}
maxLength={FEEDBACK_MAX_LENGTH}
rows={5}
autoFocus
required
/>
<p className="text-right text-xs tabular-nums text-gray-500 dark:text-dark-text-tertiary">
{message.length}/{FEEDBACK_MAX_LENGTH}
</p>
</div>

<div className="grid gap-2">
<Label htmlFor="feedback-email">
{t("feedback.emailLabel", "Email (optional)")}
</Label>
<Input
id="feedback-email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@example.com"
maxLength={254}
/>
</div>

<DialogFooter>
<Button
type="submit"
disabled={
submitting || message.trim().length < FEEDBACK_MIN_LENGTH
}
>
{submitting
? t("feedback.submitting", "Sending…")
: t("feedback.submit", "Submit feedback")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
1 change: 1 addition & 0 deletions apps/web/src/components/feedback/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FeedbackWidget } from "./FeedbackWidget";
Loading