2026-03-04 18:07:18 +01:00
|
|
|
import { GraphQLError } from "graphql";
|
2026-03-04 18:42:58 +01:00
|
|
|
import { builder } from "../builder";
|
|
|
|
|
import { CurrentUserType } from "../types/index";
|
|
|
|
|
import { users } from "../../db/schema/index";
|
2026-03-04 18:07:18 +01:00
|
|
|
import { eq } from "drizzle-orm";
|
2026-03-04 18:42:58 +01:00
|
|
|
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";
|
2026-03-04 18:07:18 +01:00
|
|
|
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;
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|