import { GraphQLError } from "graphql"; import { builder } from "../builder"; import { CurrentUserType } from "../types/index"; import { users } from "../../db/schema/index"; import { eq } from "drizzle-orm"; import { hash, verify as verifyArgon } from "../../lib/argon"; import { setSession, deleteSession } from "../../lib/auth"; import { sendVerification, sendPasswordReset } from "../../lib/email"; import { slugify } from "../../lib/slugify"; import { nanoid } from "nanoid"; builder.mutationField("login", (t) => t.field({ type: CurrentUserType, args: { email: t.arg.string({ required: true }), password: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { const user = await ctx.db .select() .from(users) .where(eq(users.email, args.email.toLowerCase())) .limit(1); if (!user[0]) throw new GraphQLError("Invalid credentials"); const valid = await verifyArgon(user[0].password_hash, args.password); if (!valid) throw new GraphQLError("Invalid credentials"); const token = nanoid(32); const sessionUser = { id: user[0].id, email: user[0].email, role: user[0].role, first_name: user[0].first_name, last_name: user[0].last_name, artist_name: user[0].artist_name, slug: user[0].slug, avatar: user[0].avatar, }; await setSession(token, sessionUser); // Set session cookie const isProduction = process.env.NODE_ENV === "production"; const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=86400${isProduction ? "; Secure" : ""}`; (ctx.reply as any).header?.("Set-Cookie", cookieValue); // For graphql-yoga response if ((ctx as any).serverResponse) { (ctx as any).serverResponse.setHeader("Set-Cookie", cookieValue); } return user[0]; }, }), ); builder.mutationField("logout", (t) => t.field({ type: "Boolean", resolve: async (_root, _args, ctx) => { const cookieHeader = ctx.request.headers.get("cookie") || ""; const cookies = Object.fromEntries( cookieHeader.split(";").map((c) => { const [k, ...v] = c.trim().split("="); return [k.trim(), v.join("=")]; }), ); const token = cookies["session_token"]; if (token) { await deleteSession(token); } // Clear cookie const cookieValue = "session_token=; HttpOnly; Path=/; Max-Age=0"; (ctx.reply as any).header?.("Set-Cookie", cookieValue); return true; }, }), ); builder.mutationField("register", (t) => t.field({ type: "Boolean", args: { email: t.arg.string({ required: true }), password: t.arg.string({ required: true }), firstName: t.arg.string({ required: true }), lastName: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { const existing = await ctx.db .select({ id: users.id }) .from(users) .where(eq(users.email, args.email.toLowerCase())) .limit(1); if (existing.length > 0) throw new GraphQLError("Email already registered"); const passwordHash = await hash(args.password); const artistName = `${args.firstName} ${args.lastName}`; const baseSlug = slugify(artistName); const verifyToken = nanoid(32); // Ensure unique slug let slug = baseSlug; let attempt = 0; while (true) { const existing = await ctx.db .select({ id: users.id }) .from(users) .where(eq(users.slug, slug)) .limit(1); if (existing.length === 0) break; attempt++; slug = `${baseSlug}-${attempt}`; } await ctx.db.insert(users).values({ email: args.email.toLowerCase(), password_hash: passwordHash, first_name: args.firstName, last_name: args.lastName, artist_name: artistName, slug, role: "viewer", email_verify_token: verifyToken, email_verified: false, }); await sendVerification(args.email, verifyToken); return true; }, }), ); builder.mutationField("verifyEmail", (t) => t.field({ type: "Boolean", args: { token: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { const user = await ctx.db .select() .from(users) .where(eq(users.email_verify_token, args.token)) .limit(1); if (!user[0]) throw new GraphQLError("Invalid verification token"); await ctx.db .update(users) .set({ email_verified: true, email_verify_token: null }) .where(eq(users.id, user[0].id)); return true; }, }), ); builder.mutationField("requestPasswordReset", (t) => t.field({ type: "Boolean", args: { email: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { const user = await ctx.db .select() .from(users) .where(eq(users.email, args.email.toLowerCase())) .limit(1); // Always return true to prevent email enumeration if (!user[0]) return true; const token = nanoid(32); const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour await ctx.db .update(users) .set({ password_reset_token: token, password_reset_expiry: expiry }) .where(eq(users.id, user[0].id)); await sendPasswordReset(args.email, token); return true; }, }), ); builder.mutationField("resetPassword", (t) => t.field({ type: "Boolean", args: { token: t.arg.string({ required: true }), newPassword: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { const user = await ctx.db .select() .from(users) .where(eq(users.password_reset_token, args.token)) .limit(1); if (!user[0]) throw new GraphQLError("Invalid or expired reset token"); if (user[0].password_reset_expiry && user[0].password_reset_expiry < new Date()) { throw new GraphQLError("Reset token expired"); } const passwordHash = await hash(args.newPassword); await ctx.db .update(users) .set({ password_hash: passwordHash, password_reset_token: null, password_reset_expiry: null, }) .where(eq(users.id, user[0].id)); return true; }, }), );