import { randomBytes } from "node:crypto"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { SystemRole } from "@capakraken/db"; import { createTRPCRouter, adminProcedure, publicProcedure } from "../trpc.js"; import { getAppBaseUrl } from "../lib/app-base-url.js"; import { sendEmail } from "../lib/email.js"; import { authRateLimiter } from "../middleware/rate-limit.js"; const INVITE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours function inviteEmailHtml(inviteUrl: string, role: SystemRole): string { return `
You have been invited to join CapaKraken as ${role}.
Click the link below to accept the invitation and set your password:
This link expires in 72 hours and can only be used once.
`; } export const inviteRouter = createTRPCRouter({ /** Admin: create a new invite token and send the invite email. */ createInvite: adminProcedure .input( z.object({ email: z.string().email(), role: z.nativeEnum(SystemRole).default(SystemRole.USER), }), ) .mutation(async ({ ctx, input }) => { // Prevent duplicate pending invites for the same email const existing = await ctx.db.inviteToken.findFirst({ where: { email: input.email, usedAt: null, expiresAt: { gt: new Date() } }, }); if (existing) { throw new TRPCError({ code: "CONFLICT", message: "An active invite already exists for this email address.", }); } const token = randomBytes(32).toString("hex"); const expiresAt = new Date(Date.now() + INVITE_TTL_MS); await ctx.db.inviteToken.create({ data: { email: input.email, role: input.role, token, expiresAt, createdById: ctx.dbUser!.id, }, }); const inviteUrl = `${getAppBaseUrl()}/invite/${token}`; void sendEmail({ to: input.email, subject: "You have been invited to CapaKraken", text: `You have been invited to join CapaKraken as ${input.role}.\n\nAccept your invitation: ${inviteUrl}\n\nThis link expires in 72 hours.`, html: inviteEmailHtml(inviteUrl, input.role), }); return { success: true }; }), /** Admin: list all pending (unused, non-expired) invites. */ listInvites: adminProcedure.query(async ({ ctx }) => { const invites = await ctx.db.inviteToken.findMany({ where: { usedAt: null, expiresAt: { gt: new Date() } }, orderBy: { createdAt: "desc" }, select: { id: true, email: true, role: true, expiresAt: true, createdAt: true }, }); return invites; }), /** Admin: revoke a pending invite. */ revokeInvite: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.db.inviteToken.delete({ where: { id: input.id } }); return { success: true }; }), /** Public: look up invite token metadata (for the accept page). */ getInvite: publicProcedure .input(z.object({ token: z.string() })) .query(async ({ ctx, input }) => { const rl = await authRateLimiter(input.token); if (!rl.allowed) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many attempts. Please wait before trying again.", }); } const invite = await ctx.db.inviteToken.findUnique({ where: { token: input.token }, select: { email: true, role: true, expiresAt: true, usedAt: true }, }); if (!invite) throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found." }); if (invite.usedAt) throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has already been used." }); if (invite.expiresAt < new Date()) throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has expired." }); return { email: invite.email, role: invite.role }; }), /** Public: accept an invite — set password, create account. */ acceptInvite: publicProcedure .input( z.object({ token: z.string(), password: z.string().min(12, "Password must be at least 12 characters.").max(128), }), ) .mutation(async ({ ctx, input }) => { const rl = await authRateLimiter(input.token); if (!rl.allowed) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many attempts. Please wait before trying again.", }); } const invite = await ctx.db.inviteToken.findUnique({ where: { token: input.token }, }); if (!invite) throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found." }); if (invite.usedAt) throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has already been used." }); if (invite.expiresAt < new Date()) throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has expired." }); // Check if user already exists const existing = await ctx.db.user.findUnique({ where: { email: invite.email } }); if (existing) { throw new TRPCError({ code: "CONFLICT", message: "An account with this email already exists.", }); } const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); await ctx.db.user.create({ data: { email: invite.email, name: invite.email.split("@")[0] ?? invite.email, systemRole: invite.role, passwordHash, }, }); await ctx.db.inviteToken.update({ where: { token: input.token }, data: { usedAt: new Date() }, }); return { success: true }; }), });