aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/routers/invites.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/trpc/routers/invites.ts')
-rw-r--r--packages/trpc/routers/invites.ts285
1 files changed, 285 insertions, 0 deletions
diff --git a/packages/trpc/routers/invites.ts b/packages/trpc/routers/invites.ts
new file mode 100644
index 00000000..5f7897c5
--- /dev/null
+++ b/packages/trpc/routers/invites.ts
@@ -0,0 +1,285 @@
+import { randomBytes } from "crypto";
+import { TRPCError } from "@trpc/server";
+import { and, eq, gt } from "drizzle-orm";
+import { z } from "zod";
+
+import { invites, users } from "@karakeep/db/schema";
+
+import { generatePasswordSalt, hashPassword } from "../auth";
+import { sendInviteEmail } from "../email";
+import { adminProcedure, publicProcedure, router } from "../index";
+import { createUserRaw } from "./users";
+
+export const invitesAppRouter = router({
+ create: adminProcedure
+ .input(
+ z.object({
+ email: z.string().email(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const existingUser = await ctx.db.query.users.findFirst({
+ where: eq(users.email, input.email),
+ });
+
+ if (existingUser) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User with this email already exists",
+ });
+ }
+
+ const existingInvite = await ctx.db.query.invites.findFirst({
+ where: and(
+ eq(invites.email, input.email),
+ gt(invites.expiresAt, new Date()),
+ ),
+ });
+
+ if (existingInvite) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "An active invite for this email already exists",
+ });
+ }
+
+ const token = randomBytes(32).toString("hex");
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
+
+ const [invite] = await ctx.db
+ .insert(invites)
+ .values({
+ email: input.email,
+ token,
+ expiresAt,
+ invitedBy: ctx.user.id,
+ })
+ .returning();
+
+ // Send invite email
+ try {
+ await sendInviteEmail(
+ input.email,
+ token,
+ ctx.user.name || "A Karakeep admin",
+ );
+ } catch (error) {
+ console.error("Failed to send invite email:", error);
+ // Don't fail the invite creation if email sending fails
+ }
+
+ return {
+ id: invite.id,
+ email: invite.email,
+ expiresAt: invite.expiresAt,
+ };
+ }),
+
+ list: adminProcedure
+ .output(
+ z.object({
+ invites: z.array(
+ z.object({
+ id: z.string(),
+ email: z.string(),
+ createdAt: z.date(),
+ expiresAt: z.date(),
+ invitedBy: z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ }),
+ }),
+ ),
+ }),
+ )
+ .query(async ({ ctx }) => {
+ const dbInvites = await ctx.db.query.invites.findMany({
+ with: {
+ invitedBy: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ orderBy: (invites, { desc }) => [desc(invites.createdAt)],
+ });
+
+ return {
+ invites: dbInvites,
+ };
+ }),
+
+ get: publicProcedure
+ .input(
+ z.object({
+ token: z.string(),
+ }),
+ )
+ .output(
+ z.object({
+ email: z.string(),
+ expired: z.boolean(),
+ }),
+ )
+ .query(async ({ input, ctx }) => {
+ const invite = await ctx.db.query.invites.findFirst({
+ where: eq(invites.token, input.token),
+ });
+
+ if (!invite) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invite not found or has been used",
+ });
+ }
+
+ const now = new Date();
+ const expired = invite.expiresAt < now;
+
+ if (expired) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invite has expired",
+ });
+ }
+
+ return {
+ email: invite.email,
+ expired: false,
+ };
+ }),
+
+ accept: publicProcedure
+ .input(
+ z.object({
+ token: z.string(),
+ name: z.string().min(1),
+ password: z.string().min(8),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const invite = await ctx.db.query.invites.findFirst({
+ where: eq(invites.token, input.token),
+ });
+
+ if (!invite) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invite not found or has been used",
+ });
+ }
+
+ const now = new Date();
+ if (invite.expiresAt < now) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "This invite has expired",
+ });
+ }
+
+ const existingUser = await ctx.db.query.users.findFirst({
+ where: eq(users.email, invite.email),
+ });
+
+ if (existingUser) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User with this email already exists",
+ });
+ }
+
+ const salt = generatePasswordSalt();
+ const user = await createUserRaw(ctx.db, {
+ name: input.name,
+ email: invite.email,
+ password: await hashPassword(input.password, salt),
+ salt,
+ role: "user",
+ emailVerified: new Date(), // Auto-verify invited users
+ });
+
+ // Delete the invite after successful user creation
+ await ctx.db.delete(invites).where(eq(invites.id, invite.id));
+
+ return {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ };
+ }),
+
+ revoke: adminProcedure
+ .input(
+ z.object({
+ inviteId: z.string(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const invite = await ctx.db.query.invites.findFirst({
+ where: eq(invites.id, input.inviteId),
+ });
+
+ if (!invite) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invite not found",
+ });
+ }
+
+ // Delete the invite to revoke it
+ await ctx.db.delete(invites).where(eq(invites.id, input.inviteId));
+
+ return { success: true };
+ }),
+
+ resend: adminProcedure
+ .input(
+ z.object({
+ inviteId: z.string(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const invite = await ctx.db.query.invites.findFirst({
+ where: eq(invites.id, input.inviteId),
+ });
+
+ if (!invite) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invite not found",
+ });
+ }
+
+ const newToken = randomBytes(32).toString("hex");
+ const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
+
+ await ctx.db
+ .update(invites)
+ .set({
+ token: newToken,
+ expiresAt: newExpiresAt,
+ })
+ .where(eq(invites.id, input.inviteId));
+
+ // Send invite email with new token
+ try {
+ await sendInviteEmail(
+ invite.email,
+ newToken,
+ ctx.user.name || "A Karakeep admin",
+ );
+ } catch (error) {
+ console.error("Failed to send invite email:", error);
+ // Don't fail the resend if email sending fails
+ }
+
+ return {
+ id: invite.id,
+ email: invite.email,
+ expiresAt: newExpiresAt,
+ };
+ }),
+});