diff --git a/apps/web/src/app/(dashboard)/dasboard-layout.tsx b/apps/web/src/app/(dashboard)/dasboard-layout.tsx index 3ac066e..ad8271d 100644 --- a/apps/web/src/app/(dashboard)/dasboard-layout.tsx +++ b/apps/web/src/app/(dashboard)/dasboard-layout.tsx @@ -91,12 +91,10 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) { Developer settings - {isCloud() ? ( - - - Settings - - ) : null} + + + Settings + {isSelfHosted() || session?.user.isAdmin ? ( diff --git a/apps/web/src/app/(dashboard)/settings/layout.tsx b/apps/web/src/app/(dashboard)/settings/layout.tsx index 49509cd..aac3fd1 100644 --- a/apps/web/src/app/(dashboard)/settings/layout.tsx +++ b/apps/web/src/app/(dashboard)/settings/layout.tsx @@ -2,6 +2,7 @@ import { useTeam } from "~/providers/team-context"; import { SettingsNavButton } from "../dev-settings/settings-nav-button"; +import { isCloud } from "~/utils/common"; export const dynamic = "force-static"; @@ -16,8 +17,10 @@ export default function ApiKeysPage({

Settings

- Usage - {currentIsAdmin ? ( + {isCloud() ? ( + Usage + ) : null} + {currentIsAdmin && isCloud() ? ( Billing diff --git a/apps/web/src/app/(dashboard)/settings/page.tsx b/apps/web/src/app/(dashboard)/settings/page.tsx index b20513c..eb0cd1d 100644 --- a/apps/web/src/app/(dashboard)/settings/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/page.tsx @@ -1,10 +1,24 @@ "use client"; -import { Button } from "@unsend/ui/src/button"; -import { api } from "~/trpc/react"; +import { isCloud } from "~/utils/common"; import UsagePage from "./usage/usage"; +import InviteTeamMember from "./team/invite-team-member"; +import TeamMembersList from "./team/team-members-list"; export default function SettingsPage() { + if (!isCloud()) { + return ( +
+
+
+ +
+ +
+
+ ); + } + return (
diff --git a/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx b/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx index 42bb6be..3bce5fe 100644 --- a/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx +++ b/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx @@ -33,6 +33,7 @@ import { FormMessage, } from "@unsend/ui/src/form"; import { useTeam } from "~/providers/team-context"; +import { isCloud, isSelfHosted } from "~/utils/common"; const inviteTeamMemberSchema = z.object({ email: z @@ -47,6 +48,7 @@ type FormData = z.infer; export default function InviteTeamMember() { const { currentIsAdmin } = useTeam(); + const { data: domains } = api.domain.domains.useQuery(); const [open, setOpen] = useState(false); @@ -60,23 +62,53 @@ export default function InviteTeamMember() { const utils = api.useUtils(); - const createInvite = api.team.createTeamInvite.useMutation({ - onSuccess: () => { - form.reset(); - setOpen(false); - void utils.team.getTeamInvites.invalidate(); - toast.success("Invitation sent successfully"); - }, - onError: (error) => { - toast.error(error.message || "Failed to send invitation"); - }, - }); + const createInvite = api.team.createTeamInvite.useMutation(); function onSubmit(values: FormData) { - createInvite.mutate({ - email: values.email, - role: values.role, - }); + createInvite.mutate( + { + email: values.email, + role: values.role, + sendEmail: true, + }, + { + onSuccess: () => { + form.reset(); + setOpen(false); + void utils.team.getTeamInvites.invalidate(); + toast.success("Invitation sent successfully"); + }, + onError: (error) => { + console.error(error); + toast.error(error.message || "Failed to send invitation"); + }, + } + ); + } + + async function onCopyLink() { + createInvite.mutate( + { + email: form.getValues("email"), + role: form.getValues("role"), + sendEmail: false, + }, + { + onSuccess: (invite) => { + void utils.team.getTeamInvites.invalidate(); + navigator.clipboard.writeText( + `${location.origin}/join-team?inviteId=${invite.id}` + ); + form.reset(); + setOpen(false); + toast.success("Invitation link copied to clipboard"); + }, + onError: (error) => { + console.error(error); + toast.error(error.message || "Failed to copy invitation link"); + }, + } + ); } if (!currentIsAdmin) { @@ -91,7 +123,7 @@ export default function InviteTeamMember() { Invite Member - + Invite Team Member @@ -152,6 +184,13 @@ export default function InviteTeamMember() { )} /> + {isSelfHosted() && domains?.length ? ( +
+ Will use{" "} + hello@{domains[0]?.name} to + send invitation +
+ ) : null}
+ {isCloud() || domains?.length ? ( + + ) : null}
diff --git a/apps/web/src/app/(dashboard)/settings/team/resend-team-invite.tsx b/apps/web/src/app/(dashboard)/settings/team/resend-team-invite.tsx index 3a87f1e..8bb9e06 100644 --- a/apps/web/src/app/(dashboard)/settings/team/resend-team-invite.tsx +++ b/apps/web/src/app/(dashboard)/settings/team/resend-team-invite.tsx @@ -3,13 +3,14 @@ import { Button } from "@unsend/ui/src/button"; import { api } from "~/trpc/react"; import { toast } from "@unsend/ui/src/toaster"; -import { RotateCw } from "lucide-react"; +import { Copy, RotateCw } from "lucide-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@unsend/ui/src/tooltip"; +import { isSelfHosted } from "~/utils/common"; export const ResendTeamInvite: React.FC<{ invite: { id: string; email: string }; @@ -44,6 +45,28 @@ export const ResendTeamInvite: React.FC<{

Resend invite

+ + {isSelfHosted() ? ( + + + + + +

Copy invite link

+
+
+ ) : null} ); }; diff --git a/apps/web/src/components/team/JoinTeam.tsx b/apps/web/src/components/team/JoinTeam.tsx index 10b0698..b893961 100644 --- a/apps/web/src/components/team/JoinTeam.tsx +++ b/apps/web/src/components/team/JoinTeam.tsx @@ -1,11 +1,9 @@ "use client"; -import { z } from "zod"; - import { Button } from "@unsend/ui/src/button"; import { Spinner } from "@unsend/ui/src/spinner"; import { api } from "~/trpc/react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { toast } from "@unsend/ui/src/toaster"; import { Dialog, @@ -23,19 +21,18 @@ type Invite = NonNullable< RouterOutputs["invitation"]["getUserInvites"] >[number]; -const FormSchema = z.object({ - name: z.string().min(2, { - message: "Team name must be at least 2 characters.", - }), -}); - export default function JoinTeam({ showCreateTeam = false, }: { showCreateTeam?: boolean; }) { + const searchParams = useSearchParams(); + const inviteId = searchParams.get("inviteId"); + const { data: invites, status: invitesStatus } = - api.invitation.getUserInvites.useQuery(); + api.invitation.getUserInvites.useQuery({ + inviteId, + }); const joinTeamMutation = api.invitation.acceptTeamInvite.useMutation(); const [selectedInvite, setSelectedInvite] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); @@ -73,8 +70,11 @@ export default function JoinTeam({ ); }; - if (!invites?.length) - return
No invites found
; + if (!invites?.length) { + return !showCreateTeam ? ( +
No invites found
+ ) : null; + } return (
diff --git a/apps/web/src/server/api/routers/invitiation.ts b/apps/web/src/server/api/routers/invitiation.ts index c010cc9..4f94a16 100644 --- a/apps/web/src/server/api/routers/invitiation.ts +++ b/apps/web/src/server/api/routers/invitiation.ts @@ -5,64 +5,31 @@ import { env } from "~/env"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const invitationRouter = createTRPCRouter({ - createTeam: protectedProcedure - .input(z.object({ name: z.string() })) - .mutation(async ({ ctx, input }) => { - const teams = await ctx.db.team.findMany({ + getUserInvites: protectedProcedure + .input( + z.object({ + inviteId: z.string().optional().nullable(), + }) + ) + .query(async ({ ctx, input }) => { + if (!ctx.session.user.email) { + return []; + } + + const invites = await ctx.db.teamInvite.findMany({ where: { - teamUsers: { - some: { - userId: ctx.session.user.id, - }, - }, + ...(input.inviteId + ? { id: input.inviteId } + : { email: ctx.session.user.email }), + }, + include: { + team: true, }, }); - if (teams.length > 0) { - console.log("User already has a team"); - return; - } - - if (!env.NEXT_PUBLIC_IS_CLOUD) { - const _team = await ctx.db.team.findFirst(); - if (_team) { - throw new TRPCError({ - message: "Can't have multiple teams in self hosted version", - code: "UNAUTHORIZED", - }); - } - } - - return ctx.db.team.create({ - data: { - name: input.name, - teamUsers: { - create: { - userId: ctx.session.user.id, - role: "ADMIN", - }, - }, - }, - }); + return invites; }), - getUserInvites: protectedProcedure.query(async ({ ctx }) => { - if (!ctx.session.user.email) { - return []; - } - - const invites = await ctx.db.teamInvite.findMany({ - where: { - email: ctx.session.user.email, - }, - include: { - team: true, - }, - }); - - return invites; - }), - getInvite: protectedProcedure .input(z.object({ inviteId: z.string() })) .query(async ({ ctx, input }) => { diff --git a/apps/web/src/server/api/routers/team.ts b/apps/web/src/server/api/routers/team.ts index 87855c9..9a43bed 100644 --- a/apps/web/src/server/api/routers/team.ts +++ b/apps/web/src/server/api/routers/team.ts @@ -9,6 +9,7 @@ import { teamAdminProcedure, } from "~/server/api/trpc"; import { sendTeamInviteEmail } from "~/server/mailer"; +import send from "~/server/public-api/api/emails/send-email"; export const teamRouter = createTRPCRouter({ createTeam: protectedProcedure @@ -97,8 +98,21 @@ export const teamRouter = createTRPCRouter({ }), createTeamInvite: teamAdminProcedure - .input(z.object({ email: z.string(), role: z.enum(["MEMBER", "ADMIN"]) })) + .input( + z.object({ + email: z.string(), + role: z.enum(["MEMBER", "ADMIN"]), + sendEmail: z.boolean().default(true), + }) + ) .mutation(async ({ ctx, input }) => { + if (!input.email) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Email is required", + }); + } + const user = await ctx.db.user.findUnique({ where: { email: input.email, @@ -125,7 +139,9 @@ export const teamRouter = createTRPCRouter({ const teamUrl = `${env.NEXTAUTH_URL}/join-team?inviteId=${teamInvite.id}`; - await sendTeamInviteEmail(input.email, teamUrl, ctx.team.name); + if (input.sendEmail) { + await sendTeamInviteEmail(input.email, teamUrl, ctx.team.name); + } return teamInvite; }), diff --git a/apps/web/src/server/mailer.ts b/apps/web/src/server/mailer.ts index 732cd40..266dded 100644 --- a/apps/web/src/server/mailer.ts +++ b/apps/web/src/server/mailer.ts @@ -1,5 +1,9 @@ import { env } from "~/env"; import { Unsend } from "unsend"; +import { isSelfHosted } from "~/utils/common"; +import { db } from "./db"; +import { getDomains } from "./service/domain-service"; +import { sendEmail } from "./service/email-service"; let unsend: Unsend | undefined; @@ -54,7 +58,37 @@ async function sendMail( text: string, html: string ) { - if (env.UNSEND_API_KEY && env.FROM_EMAIL) { + if (isSelfHosted()) { + console.log("Sending email using self hosted"); + /* + Self hosted so checking if we can send using one of the available domain + Assuming self hosted will have only one team + TODO: fix this + */ + const team = await db.team.findFirst({}); + if (!team) { + console.error("No team found"); + return; + } + + const domains = await getDomains(team.id); + + if (domains.length === 0 || !domains[0]) { + console.error("No domains found"); + return; + } + + const domain = domains[0]; + + await sendEmail({ + teamId: team.id, + to: email, + from: `hello@${domain.name}`, + subject, + text, + html, + }); + } else if (env.UNSEND_API_KEY && env.FROM_EMAIL) { const resp = await getClient().emails.send({ to: email, from: env.FROM_EMAIL, diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index a87a74a..4e65640 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -165,6 +165,17 @@ export async function deleteDomain(id: number) { }); } +export async function getDomains(teamId: number) { + return db.domain.findMany({ + where: { + teamId, + }, + orderBy: { + createdAt: "desc", + }, + }); +} + async function getDmarcRecord(domain: string) { try { const dmarcRecord = await dnsResolveTxt(`_dmarc.${domain}`); diff --git a/apps/web/src/server/service/email-queue-service.ts b/apps/web/src/server/service/email-queue-service.ts index 0954ed9..f5fc618 100644 --- a/apps/web/src/server/service/email-queue-service.ts +++ b/apps/web/src/server/service/email-queue-service.ts @@ -201,6 +201,8 @@ async function executeEmail( ? JSON.parse(email.attachments) : []; + console.log(`Domain: ${JSON.stringify(domain)}`); + const configurationSetName = await getConfigurationSetName( domain?.clickTracking ?? false, domain?.openTracking ?? false, @@ -208,7 +210,6 @@ async function executeEmail( ); if (!configurationSetName) { - console.log(`[EmailQueueService]: Configuration set not found, skipping`); return; } diff --git a/apps/web/src/server/service/ses-settings-service.ts b/apps/web/src/server/service/ses-settings-service.ts index d60a555..dfd5d67 100644 --- a/apps/web/src/server/service/ses-settings-service.ts +++ b/apps/web/src/server/service/ses-settings-service.ts @@ -27,6 +27,7 @@ export class SesSettingsService { region = env.AWS_DEFAULT_REGION ): Promise { await this.checkInitialized(); + if (this.cache[region]) { return this.cache[region] as SesSetting; }