enable teams for self-hosted (#137)
* enable teams for self-hosted * remove console
This commit is contained in:
@@ -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">
|
||||
|
@@ -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>
|
||||
|
@@ -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 />
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
@@ -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>
|
||||
|
@@ -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 }) => {
|
||||
|
@@ -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;
|
||||
}),
|
||||
|
@@ -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,
|
||||
|
@@ -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}`);
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
|
Reference in New Issue
Block a user