Files
sexy/packages/backend/src/graphql/resolvers/auth.ts
Sebastian Krüger e236ced12a refactor: replace all explicit any types with proper TypeScript types
Backend resolvers: typed enrichArticle/enrichVideo/enrichModel with DB
and $inferSelect types, SQL<unknown>[] for conditions arrays, proper
enum casts for status/role fields, $inferInsert for .set() updates,
typed raw SQL result rows in gamification, ReplyLike interface for
ctx.reply in auth. Frontend: typed catch blocks with Error/interface
casts, isActiveLink param, adminGetUser response, tags filter callback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 19:25:04 +01:00

236 lines
6.8 KiB
TypeScript

import { GraphQLError } from "graphql";
import { builder } from "../builder";
import { CurrentUserType } from "../types/index";
import { users } from "../../db/schema/index";
import { eq } from "drizzle-orm";
interface ReplyLike {
header?: (name: string, value: string) => void;
}
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 === "admin" ? "viewer" : user[0].role) as "model" | "viewer",
is_admin: user[0].is_admin,
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=Strict; Max-Age=86400${isProduction ? "; Secure" : ""}`;
(ctx.reply as ReplyLike).header?.("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 isProduction = process.env.NODE_ENV === "production";
const cookieValue = `session_token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0${isProduction ? "; Secure" : ""}`;
(ctx.reply as ReplyLike).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,
});
try {
await sendVerification(args.email, verifyToken);
} catch (e) {
console.warn("Failed to send verification email:", (e as Error).message);
}
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));
try {
await sendPasswordReset(args.email, token);
} catch (e) {
console.warn("Failed to send password reset email:", (e as Error).message);
}
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;
},
}),
);