Update domain page
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import { Input } from "@unsend/ui/src/input";
|
||||
import { Label } from "@unsend/ui/src/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@unsend/ui/src/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import React, { useState } from "react";
|
||||
import { Domain } from "@prisma/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
|
||||
export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [domainName, setDomainName] = useState("");
|
||||
const deleteDomainMutation = api.domain.deleteDomain.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function handleSave() {
|
||||
deleteDomainMutation.mutate(
|
||||
{
|
||||
id: domain.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.domain.domains.invalidate();
|
||||
setOpen(false);
|
||||
toast.success(`Domain ${domain.name} deleted`);
|
||||
router.replace("/domains");
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-[200px]">
|
||||
Delete domain
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete domain</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-primary">{domain.name}</span>?
|
||||
You can't reverse this.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Type <span className="text-primary">{domain.name}</span> to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
defaultValue=""
|
||||
className="mt-2"
|
||||
onChange={(e) => setDomainName(e.target.value)}
|
||||
value={domainName}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
deleteDomainMutation.isPending || domainName !== domain.name
|
||||
}
|
||||
>
|
||||
{deleteDomainMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteDomain;
|
@@ -2,6 +2,32 @@
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Domain, DomainStatus } from "@prisma/client";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@unsend/ui/src/breadcrumb";
|
||||
import { DomainStatusBadge } from "../domain-badge";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@unsend/ui/src/table";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import { CheckIcon, ClipboardCopy } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Switch } from "@unsend/ui/src/switch";
|
||||
import DeleteDomain from "./delete-domain";
|
||||
import { DkimStatus } from "@aws-sdk/client-sesv2";
|
||||
import SendTestMail from "./send-test-mail";
|
||||
|
||||
export default function DomainItemPage({
|
||||
params,
|
||||
@@ -17,46 +43,289 @@ export default function DomainItemPage({
|
||||
{domainQuery.isLoading ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="font-bold text-lg">{domainQuery.data?.name}</h1>
|
||||
</div>
|
||||
<span className="text-xs capitalize bg-gray-200 rounded px-2 py-1 w-[80px] text-center">
|
||||
{domainQuery.data?.status.toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8 border rounded p-4">
|
||||
<p className="font-semibold">DNS records</p>
|
||||
<div className="flex flex-col gap-4 mt-8">
|
||||
<div className="flex justify-between">
|
||||
<p>TXT</p>
|
||||
<p>{`unsend._domainkey.${domainQuery.data?.name}`}</p>
|
||||
<p className=" w-[200px] overflow-hidden text-ellipsis">{`p=${domainQuery.data?.publicKey}`}</p>
|
||||
<p className=" capitalize">
|
||||
{domainQuery.data?.dkimStatus?.toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<p>TXT</p>
|
||||
<p>{`send.${domainQuery.data?.name}`}</p>
|
||||
<p className=" w-[200px] overflow-hidden text-ellipsis text-nowrap">{`"v=spf1 include:amazonses.com ~all"`}</p>
|
||||
<p className=" capitalize">
|
||||
{domainQuery.data?.spfDetails?.toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<p>MX</p>
|
||||
<p>{`send.${domainQuery.data?.name}`}</p>
|
||||
<p className=" w-[200px] overflow-hidden text-ellipsis text-nowrap">{`feedback-smtp.${domainQuery.data?.region}.amazonses.com`}</p>
|
||||
<p className=" capitalize">
|
||||
{domainQuery.data?.spfDetails?.toLowerCase()}
|
||||
</p>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* <div className="flex items-center gap-4">
|
||||
<h1 className="font-medium text-2xl">{domainQuery.data?.name}</h1>
|
||||
</div> */}
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/domains" className="text-lg">
|
||||
Domains
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="text-lg" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-lg ">
|
||||
{domainQuery.data?.name}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="">
|
||||
<DomainStatusBadge
|
||||
status={domainQuery.data?.status || DomainStatus.NOT_STARTED}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{domainQuery.data ? (
|
||||
<SendTestMail domain={domainQuery.data} />
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
|
||||
<div className=" border rounded-lg p-4">
|
||||
<p className="font-semibold text-xl">DNS records</p>
|
||||
<Table className="mt-2">
|
||||
<TableHeader className="">
|
||||
<TableRow className="">
|
||||
<TableHead className="rounded-tl-xl">Type</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="">TTL</TableHead>
|
||||
<TableHead className="">Priority</TableHead>
|
||||
<TableHead className="rounded-tr-xl">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="">MX</TableCell>
|
||||
<TableCell>
|
||||
<InputWithCopyButton
|
||||
value={`mail.${domainQuery.data?.subdomain || domainQuery.data?.name}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
<InputWithCopyButton
|
||||
value={`feedback-smtp.${domainQuery.data?.region}.amazonses.com`}
|
||||
className="w-[200px] overflow-hidden text-ellipsis text-nowrap"
|
||||
/>
|
||||
{/* <div className="w-[200px] overflow-hidden text-ellipsis text-nowrap">
|
||||
{`feedback-smtp.${domainQuery.data?.region}.amazonses.com`}
|
||||
</div> */}
|
||||
</TableCell>
|
||||
<TableCell className="">Auto</TableCell>
|
||||
<TableCell className="">10</TableCell>
|
||||
<TableCell className="">
|
||||
<DnsVerificationStatus
|
||||
status={domainQuery.data?.spfDetails ?? "NOT_STARTED"}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="">TXT</TableCell>
|
||||
<TableCell>
|
||||
<InputWithCopyButton
|
||||
value={`unsend._domainkey.${domainQuery.data?.subdomain || domainQuery.data?.name}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
<InputWithCopyButton
|
||||
value={`p=${domainQuery.data?.publicKey}`}
|
||||
className="w-[200px] overflow-hidden text-ellipsis"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="">Auto</TableCell>
|
||||
<TableCell className=""></TableCell>
|
||||
<TableCell className="">
|
||||
<DnsVerificationStatus
|
||||
status={domainQuery.data?.dkimStatus ?? "NOT_STARTED"}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="">TXT</TableCell>
|
||||
<TableCell>
|
||||
<InputWithCopyButton
|
||||
value={`mail.${domainQuery.data?.subdomain || domainQuery.data?.name}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
<InputWithCopyButton
|
||||
value={`v=spf1 include:amazonses.com ~all`}
|
||||
className="w-[200px] overflow-hidden text-ellipsis text-nowrap"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="">Auto</TableCell>
|
||||
<TableCell className=""></TableCell>
|
||||
<TableCell className="">
|
||||
<DnsVerificationStatus
|
||||
status={domainQuery.data?.spfDetails ?? "NOT_STARTED"}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="">TXT</TableCell>
|
||||
<TableCell>
|
||||
<InputWithCopyButton
|
||||
value={`_dmarc.${domainQuery.data?.subdomain || domainQuery.data?.name}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
<InputWithCopyButton
|
||||
value={`v=DMARC1; p=none;`}
|
||||
className="w-[200px] overflow-hidden text-ellipsis text-nowrap"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="">Auto</TableCell>
|
||||
<TableCell className=""></TableCell>
|
||||
<TableCell className="">
|
||||
<DnsVerificationStatus
|
||||
status={
|
||||
domainQuery.data?.dmarcAdded ? "SUCCESS" : "NOT_STARTED"
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{domainQuery.data ? (
|
||||
<DomainSettings domain={domainQuery.data} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const InputWithCopyButton: React.FC<{ value: string; className?: string }> = ({
|
||||
value,
|
||||
className,
|
||||
}) => {
|
||||
const [isCopied, setIsCopied] = React.useState(false);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000); // Reset isCopied to false after 2 seconds
|
||||
} catch (err) {
|
||||
console.error("Failed to copy: ", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex gap-2 items-center group"}>
|
||||
<div className={className}>{value}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:bg-transparent p-0 cursor-pointer text-muted-foreground opacity-0 group-hover:opacity-100"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<ClipboardCopy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
const updateDomain = api.domain.updateDomain.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [clickTracking, setClickTracking] = React.useState(
|
||||
domain.clickTracking
|
||||
);
|
||||
const [openTracking, setOpenTracking] = React.useState(domain.openTracking);
|
||||
|
||||
function handleClickTrackingChange() {
|
||||
setClickTracking(!clickTracking);
|
||||
updateDomain.mutate(
|
||||
{ id: domain.id, clickTracking: !clickTracking },
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.domain.domains.invalidate();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleOpenTrackingChange() {
|
||||
setOpenTracking(!openTracking);
|
||||
updateDomain.mutate(
|
||||
{ id: domain.id, openTracking: !openTracking },
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.domain.domains.invalidate();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg p-4 border flex flex-col gap-6">
|
||||
<p className="font-semibold text-xl">Settings</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold">Click tracking</div>
|
||||
<p className=" text-muted-foreground text-sm">
|
||||
Track any links in your emails content.{" "}
|
||||
</p>
|
||||
<Switch
|
||||
checked={clickTracking}
|
||||
onCheckedChange={handleClickTrackingChange}
|
||||
className="data-[state=checked]:bg-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold">Open tracking</div>
|
||||
<p className=" text-muted-foreground text-sm">
|
||||
Unsend adds a tracking pixel to every email you send. This allows you
|
||||
to see how many people open your emails. This will affect the delivery
|
||||
rate of your emails.
|
||||
</p>
|
||||
<Switch
|
||||
checked={openTracking}
|
||||
onCheckedChange={handleOpenTrackingChange}
|
||||
className="data-[state=checked]:bg-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-semibold text-xl mt-2 text-destructive">Danger</p>
|
||||
|
||||
<p className="text-destructive text-sm font-semibold">
|
||||
Deleting a domain will remove all of its DNS records and stop sending
|
||||
emails.
|
||||
</p>
|
||||
<DeleteDomain domain={domain} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DnsVerificationStatus: React.FC<{ status: string }> = ({ status }) => {
|
||||
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
|
||||
switch (status) {
|
||||
case DomainStatus.NOT_STARTED:
|
||||
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
|
||||
break;
|
||||
case DomainStatus.SUCCESS:
|
||||
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
|
||||
break;
|
||||
case DomainStatus.FAILED:
|
||||
badgeColor = "bg-red-500/10 text-red-600 border-red-500/20";
|
||||
break;
|
||||
case DomainStatus.TEMPORARY_FAILURE:
|
||||
case DomainStatus.PENDING:
|
||||
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
|
||||
break;
|
||||
default:
|
||||
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={` text-center min-w-[70px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
|
||||
>
|
||||
<span className="text-xs">
|
||||
{status.split("_").join(" ").toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import { Input } from "@unsend/ui/src/input";
|
||||
import { Label } from "@unsend/ui/src/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@unsend/ui/src/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import React, { useState } from "react";
|
||||
import { Domain } from "@prisma/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
import { Send, SendHorizonal } from "lucide-react";
|
||||
|
||||
export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [domainName, setDomainName] = useState("");
|
||||
const deleteDomainMutation = api.domain.deleteDomain.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function handleSave() {
|
||||
deleteDomainMutation.mutate(
|
||||
{
|
||||
id: domain.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.domain.domains.invalidate();
|
||||
setOpen(false);
|
||||
toast.success(`Domain ${domain.name} deleted`);
|
||||
router.replace("/domains");
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<SendHorizonal className="h-4 w-4 mr-2" />
|
||||
Send test email
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send test email</DialogTitle>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SendTestMail;
|
34
apps/web/src/app/(dashboard)/domains/domain-badge.tsx
Normal file
34
apps/web/src/app/(dashboard)/domains/domain-badge.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { DomainStatus } from "@prisma/client";
|
||||
|
||||
export const DomainStatusBadge: React.FC<{ status: DomainStatus }> = ({
|
||||
status,
|
||||
}) => {
|
||||
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
|
||||
switch (status) {
|
||||
case DomainStatus.NOT_STARTED:
|
||||
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
|
||||
break;
|
||||
case DomainStatus.SUCCESS:
|
||||
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
|
||||
break;
|
||||
case DomainStatus.FAILED:
|
||||
badgeColor = "bg-red-500/10 text-red-600 border-red-500/20";
|
||||
break;
|
||||
case DomainStatus.TEMPORARY_FAILURE:
|
||||
case DomainStatus.PENDING:
|
||||
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
|
||||
break;
|
||||
default:
|
||||
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={` text-center w-[120px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
|
||||
>
|
||||
<span className="text-xs">
|
||||
{status === "SUCCESS" ? "Verified" : status.toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -6,6 +6,8 @@ import Link from "next/link";
|
||||
import { Switch } from "@unsend/ui/src/switch";
|
||||
import { api } from "~/trpc/react";
|
||||
import React from "react";
|
||||
import { StatusIndicator } from "./status-indicator";
|
||||
import { DomainStatusBadge } from "./domain-badge";
|
||||
|
||||
export default function DomainsList() {
|
||||
const domainsQuery = api.domain.domains.useQuery();
|
||||
@@ -72,6 +74,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
</Link>
|
||||
<DomainStatusBadge status={domain.status} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Created at</p>
|
||||
@@ -112,55 +115,3 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DomainStatusBadge: React.FC<{ status: DomainStatus }> = ({ status }) => {
|
||||
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
|
||||
switch (status) {
|
||||
case DomainStatus.NOT_STARTED:
|
||||
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
|
||||
break;
|
||||
case DomainStatus.SUCCESS:
|
||||
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
|
||||
break;
|
||||
case DomainStatus.FAILED:
|
||||
badgeColor = "bg-red-500/10 text-red-800 border-red-600/10";
|
||||
break;
|
||||
case DomainStatus.TEMPORARY_FAILURE:
|
||||
case DomainStatus.PENDING:
|
||||
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
|
||||
break;
|
||||
default:
|
||||
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border text-center w-[120px] text-sm capitalize rounded-full py-0.5 ${badgeColor}`}
|
||||
>
|
||||
{status === "SUCCESS" ? "Verified" : status.toLowerCase()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusIndicator: React.FC<{ status: DomainStatus }> = ({ status }) => {
|
||||
let badgeColor = "bg-gray-400"; // Default color
|
||||
switch (status) {
|
||||
case DomainStatus.NOT_STARTED:
|
||||
badgeColor = "bg-gray-400";
|
||||
break;
|
||||
case DomainStatus.SUCCESS:
|
||||
badgeColor = "bg-emerald-500";
|
||||
break;
|
||||
case DomainStatus.FAILED:
|
||||
badgeColor = "bg-red-500";
|
||||
break;
|
||||
case DomainStatus.TEMPORARY_FAILURE:
|
||||
case DomainStatus.PENDING:
|
||||
badgeColor = "bg-yellow-500";
|
||||
break;
|
||||
default:
|
||||
badgeColor = "bg-gray-400";
|
||||
}
|
||||
|
||||
return <div className={` w-[1px] ${badgeColor} my-1.5 rounded-full`}></div>;
|
||||
};
|
||||
|
26
apps/web/src/app/(dashboard)/domains/status-indicator.tsx
Normal file
26
apps/web/src/app/(dashboard)/domains/status-indicator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { DomainStatus } from "@prisma/client";
|
||||
|
||||
export const StatusIndicator: React.FC<{ status: DomainStatus }> = ({
|
||||
status,
|
||||
}) => {
|
||||
let badgeColor = "bg-gray-400"; // Default color
|
||||
switch (status) {
|
||||
case DomainStatus.NOT_STARTED:
|
||||
badgeColor = "bg-gray-400";
|
||||
break;
|
||||
case DomainStatus.SUCCESS:
|
||||
badgeColor = "bg-emerald-500";
|
||||
break;
|
||||
case DomainStatus.FAILED:
|
||||
badgeColor = "bg-red-500";
|
||||
break;
|
||||
case DomainStatus.TEMPORARY_FAILURE:
|
||||
case DomainStatus.PENDING:
|
||||
badgeColor = "bg-yellow-500";
|
||||
break;
|
||||
default:
|
||||
badgeColor = "bg-gray-400";
|
||||
}
|
||||
|
||||
return <div className={` w-[1px] ${badgeColor} my-1.5 rounded-full`}></div>;
|
||||
};
|
@@ -10,7 +10,7 @@ export const NavButton: React.FC<{
|
||||
}> = ({ href, children }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = pathname === href;
|
||||
const isActive = pathname?.startsWith(href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
@@ -2,6 +2,7 @@ import "@unsend/ui/styles/globals.css";
|
||||
|
||||
import { Inter } from "next/font/google";
|
||||
import { ThemeProvider } from "@unsend/ui/theme-provider";
|
||||
import { Toaster } from "@unsend/ui/src/toaster";
|
||||
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
|
||||
@@ -25,6 +26,7 @@ export default function RootLayout({
|
||||
<html lang="en">
|
||||
<body className={`font-sans ${inter.variable}`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||
<Toaster />
|
||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
@@ -9,6 +9,7 @@ import {
|
||||
import { db } from "~/server/db";
|
||||
import {
|
||||
createDomain,
|
||||
deleteDomain,
|
||||
getDomain,
|
||||
updateDomain,
|
||||
} from "~/server/service/domain-service";
|
||||
@@ -53,4 +54,11 @@ export const domainRouter = createTRPCRouter({
|
||||
openTracking: input.openTracking,
|
||||
});
|
||||
}),
|
||||
|
||||
deleteDomain: teamProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await deleteDomain(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
@@ -1,15 +1,21 @@
|
||||
import { addDomain, getDomainIdentity } from "~/server/ses";
|
||||
import dns from "dns";
|
||||
import util from "util";
|
||||
import * as tldts from "tldts";
|
||||
import * as ses from "~/server/ses";
|
||||
import { db } from "~/server/db";
|
||||
|
||||
const dnsResolveTxt = util.promisify(dns.resolveTxt);
|
||||
|
||||
export async function createDomain(teamId: number, name: string) {
|
||||
console.log("Creating domain:", name);
|
||||
const publicKey = await addDomain(name);
|
||||
const subdomain = tldts.getSubdomain(name);
|
||||
const publicKey = await ses.addDomain(name);
|
||||
|
||||
const domain = await db.domain.create({
|
||||
data: {
|
||||
name,
|
||||
publicKey,
|
||||
teamId,
|
||||
subdomain,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,7 +34,10 @@ export async function getDomain(id: number) {
|
||||
}
|
||||
|
||||
if (domain.status !== "SUCCESS") {
|
||||
const domainIdentity = await getDomainIdentity(domain.name, domain.region);
|
||||
const domainIdentity = await ses.getDomainIdentity(
|
||||
domain.name,
|
||||
domain.region
|
||||
);
|
||||
|
||||
const dkimStatus = domainIdentity.DkimAttributes?.Status;
|
||||
const spfDetails = domainIdentity.MailFromAttributes?.MailFromDomainStatus;
|
||||
@@ -36,13 +45,17 @@ export async function getDomain(id: number) {
|
||||
const verificationStatus = domainIdentity.VerificationStatus;
|
||||
const lastCheckedTime =
|
||||
domainIdentity.VerificationInfo?.LastCheckedTimestamp;
|
||||
const _dmarcRecord = await getDmarcRecord(domain.name);
|
||||
const dmarcRecord = _dmarcRecord?.[0]?.[0];
|
||||
|
||||
console.log(domainIdentity);
|
||||
console.log(dmarcRecord);
|
||||
|
||||
if (
|
||||
domain.dkimStatus !== dkimStatus ||
|
||||
domain.spfDetails !== spfDetails ||
|
||||
domain.status !== verificationStatus
|
||||
domain.status !== verificationStatus ||
|
||||
domain.dmarcAdded !== (dmarcRecord ? true : false)
|
||||
) {
|
||||
domain = await db.domain.update({
|
||||
where: {
|
||||
@@ -52,16 +65,18 @@ export async function getDomain(id: number) {
|
||||
dkimStatus,
|
||||
spfDetails,
|
||||
status: verificationStatus ?? "NOT_STARTED",
|
||||
dmarcAdded: dmarcRecord ? true : false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...domain,
|
||||
dkimStatus,
|
||||
spfDetails,
|
||||
verificationError,
|
||||
dkimStatus: dkimStatus?.toString() ?? null,
|
||||
spfDetails: spfDetails?.toString() ?? null,
|
||||
verificationError: verificationError?.toString() ?? null,
|
||||
lastCheckedTime,
|
||||
dmarcAdded: dmarcRecord ? true : false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,3 +92,33 @@ export async function updateDomain(
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteDomain(id: number) {
|
||||
const domain = await db.domain.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!domain) {
|
||||
throw new Error("Domain not found");
|
||||
}
|
||||
|
||||
const deleted = await ses.deleteDomain(domain.name, domain.region);
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error("Error in deleting domain");
|
||||
}
|
||||
|
||||
return db.domain.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async function getDmarcRecord(domain: string) {
|
||||
try {
|
||||
const dmarcRecord = await dnsResolveTxt(`_dmarc.${domain}`);
|
||||
return dmarcRecord;
|
||||
} catch (error) {
|
||||
console.error("Error fetching DMARC record:", error);
|
||||
return null; // or handle error as appropriate
|
||||
}
|
||||
}
|
||||
|
@@ -66,7 +66,7 @@ export async function addDomain(domain: string, region = "us-east-1") {
|
||||
|
||||
const emailIdentityCommand = new PutEmailIdentityMailFromAttributesCommand({
|
||||
EmailIdentity: domain,
|
||||
MailFromDomain: `send.${domain}`,
|
||||
MailFromDomain: `mail.${domain}`,
|
||||
});
|
||||
|
||||
const emailIdentityResponse = await sesClient.send(emailIdentityCommand);
|
||||
@@ -75,12 +75,23 @@ export async function addDomain(domain: string, region = "us-east-1") {
|
||||
response.$metadata.httpStatusCode !== 200 ||
|
||||
emailIdentityResponse.$metadata.httpStatusCode !== 200
|
||||
) {
|
||||
throw new Error("Failed to create email identity");
|
||||
console.log(response);
|
||||
console.log(emailIdentityResponse);
|
||||
throw new Error("Failed to create domain identity");
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
export async function deleteDomain(domain: string, region = "us-east-1") {
|
||||
const sesClient = getSesClient(region);
|
||||
const command = new DeleteEmailIdentityCommand({
|
||||
EmailIdentity: domain,
|
||||
});
|
||||
const response = await sesClient.send(command);
|
||||
return response.$metadata.httpStatusCode === 200;
|
||||
}
|
||||
|
||||
export async function getDomainIdentity(domain: string, region = "us-east-1") {
|
||||
const sesClient = getSesClient(region);
|
||||
const command = new GetEmailIdentityCommand({
|
||||
@@ -165,7 +176,6 @@ export async function addWebhookConfiguration(
|
||||
ConfigurationSetName: configName, // required
|
||||
EventDestinationName: "unsend_destination", // required
|
||||
EventDestination: {
|
||||
// EventDestinationDefinition
|
||||
Enabled: true,
|
||||
MatchingEventTypes: eventTypes,
|
||||
SnsDestination: {
|
||||
|
Reference in New Issue
Block a user