enable teams for self-hosted (#137)

* enable teams for self-hosted

* remove console
This commit is contained in:
KM Koushik
2025-03-29 00:56:06 +11:00
committed by GitHub
parent 1b6676c1b1
commit f1186f875c
12 changed files with 214 additions and 98 deletions

View File

@@ -91,12 +91,10 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
Developer settings
</NavButton>
{isCloud() ? (
<NavButton href="/settings">
<Cog className="h-4 w-4" />
Settings
</NavButton>
) : null}
<NavButton href="/settings">
<Cog className="h-4 w-4" />
Settings
</NavButton>
{isSelfHosted() || session?.user.isAdmin ? (
<NavButton href="/admin">

View File

@@ -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({
<div>
<h1 className="font-bold text-lg">Settings</h1>
<div className="flex gap-4 mt-4">
<SettingsNavButton href="/settings">Usage</SettingsNavButton>
{currentIsAdmin ? (
{isCloud() ? (
<SettingsNavButton href="/settings">Usage</SettingsNavButton>
) : null}
{currentIsAdmin && isCloud() ? (
<SettingsNavButton href="/settings/billing">
Billing
</SettingsNavButton>

View File

@@ -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 (
<div>
<div>
<div className="flex justify-end ">
<InviteTeamMember />
</div>
<TeamMembersList />
</div>
</div>
);
}
return (
<div>
<UsagePage />

View File

@@ -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<typeof inviteTeamMemberSchema>;
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
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className=" max-w-lg">
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
</DialogHeader>
@@ -152,6 +184,13 @@ export default function InviteTeamMember() {
</FormItem>
)}
/>
{isSelfHosted() && domains?.length ? (
<div className="text-sm text-muted-foreground">
Will use{" "}
<span className="font-bold">hello@{domains[0]?.name}</span> to
send invitation
</div>
) : null}
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
@@ -161,14 +200,23 @@ export default function InviteTeamMember() {
Cancel
</Button>
<Button
type="submit"
disabled={createInvite.isPending}
showSpinner
isLoading={createInvite.isPending}
className="w-[150px]"
onClick={form.handleSubmit(onCopyLink)}
>
{createInvite.isPending ? "Sending..." : "Send Invitation"}
Copy Invitation
</Button>
{isCloud() || domains?.length ? (
<Button
type="submit"
disabled={createInvite.isPending}
isLoading={createInvite.isPending}
className="w-[150px]"
>
Send Invitation
</Button>
) : null}
</div>
</form>
</Form>

View File

@@ -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<{
<p>Resend invite</p>
</TooltipContent>
</Tooltip>
{isSelfHosted() ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
navigator.clipboard.writeText(
`${location.origin}/join-team?inviteId=${invite.id}`
);
toast.success(`Invite link copied to clipboard`);
}}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy invite link</p>
</TooltipContent>
</Tooltip>
) : null}
</TooltipProvider>
);
};

View File

@@ -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<Invite | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -73,8 +70,11 @@ export default function JoinTeam({
);
};
if (!invites?.length)
return <div className="text-center text-xl">No invites found</div>;
if (!invites?.length) {
return !showCreateTeam ? (
<div className="text-center text-xl">No invites found</div>
) : null;
}
return (
<div>

View File

@@ -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 }) => {

View File

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

View File

@@ -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,

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ export class SesSettingsService {
region = env.AWS_DEFAULT_REGION
): Promise<SesSetting | null> {
await this.checkInitialized();
if (this.cache[region]) {
return this.cache[region] as SesSetting;
}