add support message button in saas

This commit is contained in:
miloschwartz
2025-10-27 21:55:34 -07:00
parent 9e5c9d9c34
commit 8b4722b1c9
7 changed files with 476 additions and 15 deletions

View File

@@ -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!"
}

View File

@@ -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 (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailGreeting>Hi support,</EmailGreeting>
<EmailText>
You have received a new support request from{" "}
<strong>{username}</strong> ({email}).
</EmailText>
<EmailText>
<strong>Subject:</strong> {subject}
</EmailText>
<EmailText>
<strong>Message:</strong> {body}
</EmailText>
</EmailContainer>
</Body>
</Tailwind>
</Html>
);
};
export default SupportEmail;

View File

@@ -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
);
);

View File

@@ -0,0 +1 @@
export * from "./sendSupportEmail";

View File

@@ -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<any> {
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")
);
}
}

View File

@@ -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({
<SupporterStatus isCollapsed={isSidebarCollapsed} />
</div>
)}
{build === "saas" && (
<div className="mb-3">
<SidebarSupportButton isCollapsed={isSidebarCollapsed} />
</div>
)}
{!isSidebarCollapsed && (
<div className="space-y-2">
{loadFooterLinks() ? (

View File

@@ -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<SupportFormValues>({
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 (
<Popover open={isOpen} onOpenChange={setIsOpen}>
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
size="icon"
className="w-8 h-8"
variant="ghost"
>
<MessageCircle className="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{t("support", { defaultValue: "Support" })}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<PopoverTrigger asChild>
<Button
variant="ghost"
className="w-full justify-center gap-2"
>
<MessageCircle className="h-4 w-4" />
{t("support", { defaultValue: "Support" })}
</Button>
</PopoverTrigger>
)}
<PopoverContent className="w-80" align="start">
<p className="text-sm text-muted-foreground">
{t("supportNotAvailableDescription")}
</p>
</PopoverContent>
</Popover>
);
}
return (
<Popover
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setIsSuccess(false);
}
}}
>
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
size="icon"
className="w-8 h-8"
variant="outline"
>
<MessageCircle className="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{t("messageSupport")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-center gap-2"
>
<MessageCircle className="h-4 w-4" />
{t("messageSupport")}
</Button>
</PopoverTrigger>
)}
<PopoverContent className="w-96" align="start">
{isSuccess ? (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<CheckCircle2 className="h-16 w-16 text-green-500" />
<h3 className="text-lg font-semibold">{t("supportMessageSent")}</h3>
<p className="text-sm text-muted-foreground text-center">
{t("supportWillContact")}
</p>
</div>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormItem>
<FormLabel>{t("supportReplyTo")}</FormLabel>
<FormControl>
<Input
value={user?.email || ""}
disabled
/>
</FormControl>
</FormItem>
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>{t("supportSubject")}</FormLabel>
<FormControl>
<Input
placeholder={t("supportSubjectPlaceholder")}
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="body"
render={({ field }) => (
<FormItem>
<FormLabel>{t("supportMessage")}</FormLabel>
<FormControl>
<Textarea
placeholder={t("supportMessagePlaceholder")}
rows={5}
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
<Button type="submit" disabled={isSubmitting} loading={isSubmitting}>
{t("supportSend")}
</Button>
</div>
</form>
</Form>
)}
</PopoverContent>
</Popover>
);
}