From 8b4722b1c9a0d5d08482f3636d5ddcb713710a9f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 27 Oct 2025 21:55:34 -0700 Subject: [PATCH] add support message button in saas --- messages/en-US.json | 21 +- server/emails/templates/SupportEmail.tsx | 56 ++++ server/private/routers/external.ts | 47 ++- server/private/routers/misc/index.ts | 1 + .../private/routers/misc/sendSupportEmail.ts | 85 ++++++ src/components/LayoutSidebar.tsx | 6 + src/components/SidebarSupportButton.tsx | 275 ++++++++++++++++++ 7 files changed, 476 insertions(+), 15 deletions(-) create mode 100644 server/emails/templates/SupportEmail.tsx create mode 100644 server/private/routers/misc/index.ts create mode 100644 server/private/routers/misc/sendSupportEmail.ts create mode 100644 src/components/SidebarSupportButton.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 1b27fc03..64cef29d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2041,5 +2041,24 @@ "warning": "Warning", "proxyProtocolWarning": "Your backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.", "restarting": "Restarting...", - "manual": "Manual" + "manual": "Manual", + "messageSupport": "Message Support", + "supportNotAvailableTitle": "Support Not Available", + "supportNotAvailableDescription": "Support is not available right now. You can send an email to support@pangolin.net.", + "supportRequestSentTitle": "Support Request Sent", + "supportRequestSentDescription": "Your message has been sent successfully.", + "supportRequestFailedTitle": "Failed to Send Request", + "supportRequestFailedDescription": "An error occurred while sending your support request.", + "supportSubjectRequired": "Subject is required", + "supportSubjectMaxLength": "Subject must be 255 characters or less", + "supportMessageRequired": "Message is required", + "supportReplyTo": "Reply To", + "supportSubject": "Subject", + "supportSubjectPlaceholder": "Enter subject", + "supportMessage": "Message", + "supportMessagePlaceholder": "Enter your message", + "supportSending": "Sending...", + "supportSend": "Send", + "supportMessageSent": "Message Sent!", + "supportWillContact": "We'll be in touch shortly!" } diff --git a/server/emails/templates/SupportEmail.tsx b/server/emails/templates/SupportEmail.tsx new file mode 100644 index 00000000..5e03d577 --- /dev/null +++ b/server/emails/templates/SupportEmail.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailGreeting, + EmailLetterHead, + EmailText +} from "./components/Email"; + +interface SupportEmailProps { + email: string; + username: string; + subject: string; + body: string; +} + +export const SupportEmail = ({ + username, + email, + body, + subject +}: SupportEmailProps) => { + const previewText = subject; + + return ( + + + {previewText} + + + + + + Hi support, + + + You have received a new support request from{" "} + {username} ({email}). + + + + Subject: {subject} + + + + Message: {body} + + + + + + ); +}; + +export default SupportEmail; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index b569e98f..00ad117f 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -22,6 +22,7 @@ import * as auth from "#private/routers/auth"; import * as license from "#private/routers/license"; import * as generateLicense from "./generatedLicense"; import * as logs from "#private/routers/auditLogs"; +import * as misc from "#private/routers/misc"; import { verifyOrgAccess, @@ -74,7 +75,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createIdp), logActionAudit(ActionsEnum.createIdp), - orgIdp.createOrgOidcIdp, + orgIdp.createOrgOidcIdp ); authenticated.post( @@ -84,7 +85,7 @@ authenticated.post( verifyIdpAccess, verifyUserHasAction(ActionsEnum.updateIdp), logActionAudit(ActionsEnum.updateIdp), - orgIdp.updateOrgOidcIdp, + orgIdp.updateOrgOidcIdp ); authenticated.delete( @@ -94,7 +95,7 @@ authenticated.delete( verifyIdpAccess, verifyUserHasAction(ActionsEnum.deleteIdp), logActionAudit(ActionsEnum.deleteIdp), - orgIdp.deleteOrgIdp, + orgIdp.deleteOrgIdp ); authenticated.get( @@ -132,7 +133,7 @@ authenticated.post( verifyCertificateAccess, verifyUserHasAction(ActionsEnum.restartCertificate), logActionAudit(ActionsEnum.restartCertificate), - certificates.restartCertificate, + certificates.restartCertificate ); if (build === "saas") { @@ -158,7 +159,7 @@ if (build === "saas") { verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), logActionAudit(ActionsEnum.billing), - billing.createCheckoutSession, + billing.createCheckoutSession ); authenticated.post( @@ -166,7 +167,7 @@ if (build === "saas") { verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), logActionAudit(ActionsEnum.billing), - billing.createPortalSession, + billing.createPortalSession ); authenticated.get( @@ -194,6 +195,24 @@ if (build === "saas") { verifyOrgAccess, generateLicense.generateNewLicense ); + + authenticated.post( + "/send-support-request", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 3, + keyGenerator: (req) => + `sendSupportRequest:${req.user?.userId || ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only send 3 support requests every 15 minutes. Please try again later.`; + return next( + createHttpError(HttpCode.TOO_MANY_REQUESTS, message) + ); + }, + store: createStore() + }), + misc.sendSupportEmail + ); } authenticated.get( @@ -214,7 +233,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRemoteExitNode), logActionAudit(ActionsEnum.createRemoteExitNode), - remoteExitNode.createRemoteExitNode, + remoteExitNode.createRemoteExitNode ); authenticated.get( @@ -249,7 +268,7 @@ authenticated.delete( verifyRemoteExitNodeAccess, verifyUserHasAction(ActionsEnum.deleteRemoteExitNode), logActionAudit(ActionsEnum.deleteRemoteExitNode), - remoteExitNode.deleteRemoteExitNode, + remoteExitNode.deleteRemoteExitNode ); authenticated.put( @@ -258,7 +277,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createLoginPage), logActionAudit(ActionsEnum.createLoginPage), - loginPage.createLoginPage, + loginPage.createLoginPage ); authenticated.post( @@ -268,7 +287,7 @@ authenticated.post( verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.updateLoginPage), logActionAudit(ActionsEnum.updateLoginPage), - loginPage.updateLoginPage, + loginPage.updateLoginPage ); authenticated.delete( @@ -278,7 +297,7 @@ authenticated.delete( verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.deleteLoginPage), logActionAudit(ActionsEnum.deleteLoginPage), - loginPage.deleteLoginPage, + loginPage.deleteLoginPage ); authenticated.get( @@ -353,7 +372,7 @@ authenticated.get( verifyValidSubscription, verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), - logs.queryActionAuditLogs + logs.queryActionAuditLogs ); authenticated.get( @@ -372,7 +391,7 @@ authenticated.get( verifyValidSubscription, verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), - logs.queryAccessAuditLogs + logs.queryAccessAuditLogs ); authenticated.get( @@ -383,4 +402,4 @@ authenticated.get( verifyUserHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), logs.exportAccessAuditLogs -); \ No newline at end of file +); diff --git a/server/private/routers/misc/index.ts b/server/private/routers/misc/index.ts new file mode 100644 index 00000000..d8d5f4d3 --- /dev/null +++ b/server/private/routers/misc/index.ts @@ -0,0 +1 @@ +export * from "./sendSupportEmail"; diff --git a/server/private/routers/misc/sendSupportEmail.ts b/server/private/routers/misc/sendSupportEmail.ts new file mode 100644 index 00000000..d76643ca --- /dev/null +++ b/server/private/routers/misc/sendSupportEmail.ts @@ -0,0 +1,85 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib/response"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { sendEmail } from "@server/emails"; +import SupportEmail from "@server/emails/templates/SupportEmail"; +import config from "@server/lib/config"; + +const bodySchema = z + .object({ + body: z.string().min(1), + subject: z.string().min(1).max(255) + }) + .strict(); + +export async function sendSupportEmail( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { body, subject } = parsedBody.data; + const user = req.user!; + + try { + await sendEmail( + SupportEmail({ + username: user.username, + email: user.email || "Unknown", + subject, + body + }), + { + name: req.user?.email || "Support User", + to: "support@pangolin.net", + from: req.user?.email || config.getNoReplyEmail(), + subject: `Support Request: ${subject}` + } + ); + return sendResponse(res, { + data: {}, + success: true, + error: false, + message: "Sent support email successfully", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`) + ); + } + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index fb1902a8..a054e829 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -31,6 +31,7 @@ import { } from "@app/components/ui/tooltip"; import { build } from "@server/build"; import SidebarLicenseButton from "./SidebarLicenseButton"; +import { SidebarSupportButton } from "./SidebarSupportButton"; interface LayoutSidebarProps { orgId?: string; @@ -145,6 +146,11 @@ export function LayoutSidebar({ )} + {build === "saas" && ( +
+ +
+ )} {!isSidebarCollapsed && (
{loadFooterLinks() ? ( diff --git a/src/components/SidebarSupportButton.tsx b/src/components/SidebarSupportButton.tsx new file mode 100644 index 00000000..82818662 --- /dev/null +++ b/src/components/SidebarSupportButton.tsx @@ -0,0 +1,275 @@ +"use client"; + +import React, { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { MessageCircle, CheckCircle2 } from "lucide-react"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; +import { useToast } from "@app/hooks/useToast"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { Textarea } from "@app/components/ui/textarea"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { useTranslations } from "next-intl"; + +type SupportFormValues = { + subject: string; + body: string; +}; + +type SidebarSupportButtonProps = { + isCollapsed: boolean; +}; + +export function SidebarSupportButton({ + isCollapsed +}: SidebarSupportButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const { user } = useUserContext(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { toast } = useToast(); + const t = useTranslations(); + + const form = useForm({ + resolver: zodResolver(z.object({ + subject: z + .string() + .min(1, t("supportSubjectRequired")) + .max(255, t("supportSubjectMaxLength")), + body: z.string().min(1, t("supportMessageRequired")) + })), + defaultValues: { + subject: "", + body: "" + } + }); + + const onSubmit = async (data: SupportFormValues) => { + if (!user?.email) { + toast({ + variant: "destructive", + title: t("supportNotAvailableTitle"), + description: t("supportNotAvailableDescription") + }); + return; + } + + setIsSubmitting(true); + try { + await api.post("/send-support-request", { + subject: data.subject, + body: data.body + }); + + setIsSuccess(true); + + toast({ + title: t("supportRequestSentTitle"), + description: t("supportRequestSentDescription") + }); + + form.reset(); + } catch (error) { + toast({ + variant: "destructive", + title: t("supportRequestFailedTitle"), + description: formatAxiosError( + error, + t("supportRequestFailedDescription") + ) + }); + } finally { + setIsSubmitting(false); + } + }; + + if (!user?.email) { + // Show message that support is not available + return ( + + {isCollapsed ? ( + + + + + + + + +

{t("support", { defaultValue: "Support" })}

+
+
+
+ ) : ( + + + + )} + +

+ {t("supportNotAvailableDescription")} +

+
+
+ ); + } + + return ( + { + setIsOpen(open); + if (!open) { + setIsSuccess(false); + } + }} + > + {isCollapsed ? ( + + + + + + + + +

{t("messageSupport")}

+
+
+
+ ) : ( + + + + )} + + {isSuccess ? ( +
+ +

{t("supportMessageSent")}

+

+ {t("supportWillContact")} +

+
+ ) : ( +
+ + + {t("supportReplyTo")} + + + + + + ( + + {t("supportSubject")} + + + + + + )} + /> + + ( + + {t("supportMessage")} + +