mirror of
https://github.com/fosrl/pangolin.git
synced 2025-12-05 19:17:29 -06:00
add support message button in saas
This commit is contained in:
@@ -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!"
|
||||
}
|
||||
|
||||
56
server/emails/templates/SupportEmail.tsx
Normal file
56
server/emails/templates/SupportEmail.tsx
Normal 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;
|
||||
@@ -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
|
||||
);
|
||||
);
|
||||
|
||||
1
server/private/routers/misc/index.ts
Normal file
1
server/private/routers/misc/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./sendSupportEmail";
|
||||
85
server/private/routers/misc/sendSupportEmail.ts
Normal file
85
server/private/routers/misc/sendSupportEmail.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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() ? (
|
||||
|
||||
275
src/components/SidebarSupportButton.tsx
Normal file
275
src/components/SidebarSupportButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user