drop case sensitive approach to the username and email across the app
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { POST } from "@/app/api/auth/register/route";
|
||||
import { db } from "@/lib/db";
|
||||
import { getConfig } from "@/lib/config";
|
||||
@@ -158,14 +159,15 @@ describe("POST /api/auth/register", () => {
|
||||
});
|
||||
|
||||
describe("duplicate checks", () => {
|
||||
it("should return 400 when email already exists", async () => {
|
||||
// Mock: email check finds existing user
|
||||
vi.mocked(db.user.findUnique).mockImplementation(async (args) => {
|
||||
if (args?.where?.email) {
|
||||
return { id: "1", email: "test@example.com" } as never;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
it("should return 409 when email already exists", async () => {
|
||||
// Mock: create throws P2002 unique violation on email
|
||||
vi.mocked(db.user.create).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
meta: { target: ["email"] },
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const request = createRequest({
|
||||
name: "Test User",
|
||||
@@ -177,14 +179,43 @@ describe("POST /api/auth/register", () => {
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.status).toBe(409);
|
||||
expect(data.error).toBe("email_taken");
|
||||
});
|
||||
|
||||
it("should return 400 when username already exists", async () => {
|
||||
// Mock: email check passes, username check (case-insensitive via findFirst) finds existing user
|
||||
vi.mocked(db.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(db.user.findFirst).mockResolvedValue({ id: "1", username: "testuser" } as never);
|
||||
it("should return 409 for case-insensitive email collision", async () => {
|
||||
// Mock: create throws P2002 unique violation on CI email index
|
||||
vi.mocked(db.user.create).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
meta: { target: ["users_email_ci_unique"] },
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const request = createRequest({
|
||||
name: "Test User",
|
||||
username: "testuser",
|
||||
email: "Test@Example.COM",
|
||||
password: "password123",
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(data.error).toBe("email_taken");
|
||||
});
|
||||
|
||||
it("should return 409 when username already exists", async () => {
|
||||
// Mock: create throws P2002 unique violation on username
|
||||
vi.mocked(db.user.create).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
meta: { target: ["username"] },
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const request = createRequest({
|
||||
name: "Test User",
|
||||
@@ -196,15 +227,52 @@ describe("POST /api/auth/register", () => {
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(data.error).toBe("username_taken");
|
||||
});
|
||||
|
||||
it("should return 400 for uppercase username", async () => {
|
||||
const request = createRequest({
|
||||
name: "Test User",
|
||||
username: "TestUser",
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("validation_error");
|
||||
});
|
||||
|
||||
it("should return 409 for case-insensitive username collision", async () => {
|
||||
// Mock: create throws P2002 unique violation on CI username index
|
||||
vi.mocked(db.user.create).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
meta: { target: ["users_username_ci_unique"] },
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const request = createRequest({
|
||||
name: "Test User",
|
||||
username: "testuser",
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(data.error).toBe("username_taken");
|
||||
});
|
||||
});
|
||||
|
||||
describe("successful registration", () => {
|
||||
it("should create user and return user data", async () => {
|
||||
vi.mocked(db.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(db.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(db.user.create).mockResolvedValue({
|
||||
id: "user-123",
|
||||
name: "Test User",
|
||||
@@ -238,9 +306,44 @@ describe("POST /api/auth/register", () => {
|
||||
expect(data.password).toBeUndefined(); // Password should not be returned
|
||||
});
|
||||
|
||||
it("should lowercase and trim email and username before saving", async () => {
|
||||
vi.mocked(db.user.create).mockResolvedValue({
|
||||
id: "user-123",
|
||||
name: "Test User",
|
||||
username: "testuser",
|
||||
email: "test@example.com",
|
||||
password: "hashed_password",
|
||||
emailVerified: null,
|
||||
image: null,
|
||||
role: "USER",
|
||||
bio: null,
|
||||
credits: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const request = createRequest({
|
||||
name: " Test User ",
|
||||
username: " testuser ",
|
||||
email: " Test@Example.COM ",
|
||||
password: "password123",
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
expect(db.user.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
name: "Test User",
|
||||
username: "testuser",
|
||||
email: "test@example.com",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept valid username with underscores", async () => {
|
||||
vi.mocked(db.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(db.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(db.user.create).mockResolvedValue({
|
||||
id: "user-123",
|
||||
name: "Test User",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { GET, PATCH } from "@/app/api/user/profile/route";
|
||||
import { db } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
@@ -161,9 +162,15 @@ describe("PATCH /api/user/profile", () => {
|
||||
expect(data.error).toBe("validation_error");
|
||||
});
|
||||
|
||||
it("should return 400 if username is taken", async () => {
|
||||
it("should return 409 if username is taken", async () => {
|
||||
vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "olduser" } } as never);
|
||||
vi.mocked(db.user.findFirst).mockResolvedValue({ id: "other-user" } as never);
|
||||
vi.mocked(db.user.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
meta: { target: ["username"] },
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const request = new Request("http://localhost:3000/api/user/profile", {
|
||||
method: "PATCH",
|
||||
@@ -173,28 +180,30 @@ describe("PATCH /api/user/profile", () => {
|
||||
const response = await PATCH(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.status).toBe(409);
|
||||
expect(data.error).toBe("username_taken");
|
||||
});
|
||||
|
||||
it("should check username case-insensitively", async () => {
|
||||
it("should return 409 for case-insensitive username collision", async () => {
|
||||
vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "olduser" } } as never);
|
||||
vi.mocked(db.user.findFirst).mockResolvedValue({ id: "other-user" } as never);
|
||||
vi.mocked(db.user.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
meta: { target: ["users_username_ci_unique"] },
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const request = new Request("http://localhost:3000/api/user/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: "Test", username: "TakenUser" }),
|
||||
body: JSON.stringify({ name: "Test", username: "takenuser" }),
|
||||
});
|
||||
|
||||
const response = await PATCH(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.status).toBe(409);
|
||||
expect(data.error).toBe("username_taken");
|
||||
expect(db.user.findFirst).toHaveBeenCalledWith({
|
||||
where: { username: { equals: "TakenUser", mode: "insensitive" } },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow keeping the same username", async () => {
|
||||
@@ -217,13 +226,12 @@ describe("PATCH /api/user/profile", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.name).toBe("Updated Name");
|
||||
// Should NOT check for existing username when keeping the same one
|
||||
expect(db.user.findFirst).not.toHaveBeenCalled();
|
||||
// No pre-check needed — uniqueness enforced at DB level
|
||||
expect(db.user.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should update profile successfully", async () => {
|
||||
vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "olduser" } } as never);
|
||||
vi.mocked(db.user.findFirst).mockResolvedValue(null); // Username not taken
|
||||
vi.mocked(db.user.update).mockResolvedValue({
|
||||
id: "user1",
|
||||
name: "New Name",
|
||||
|
||||
@@ -49,7 +49,7 @@ export default async function OGImage({ params }: { params: Promise<{ username:
|
||||
const username = decodedUsername.slice(1);
|
||||
|
||||
const user = await db.user.findFirst({
|
||||
where: { username: { equals: username, mode: "insensitive" } },
|
||||
where: { username: username.toLowerCase() },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function generateMetadata({ params }: UserProfilePageProps): Promis
|
||||
const username = decodedUsername.slice(1);
|
||||
|
||||
const user = await db.user.findFirst({
|
||||
where: { username: { equals: username, mode: "insensitive" } },
|
||||
where: { username: username.toLowerCase() },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { name: true, username: true },
|
||||
});
|
||||
@@ -72,7 +72,7 @@ export default async function UserProfilePage({ params, searchParams }: UserProf
|
||||
const username = decodedUsername.slice(1);
|
||||
|
||||
const user = await db.user.findFirst({
|
||||
where: { username: { equals: username, mode: "insensitive" } },
|
||||
where: { username: username.toLowerCase() },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -150,7 +150,7 @@ export async function POST(request: NextRequest) {
|
||||
let user = await db.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: { equals: normalizedUsername, mode: "insensitive" } },
|
||||
{ username: normalizedUsername },
|
||||
{ email: pseudoEmail },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2,12 +2,16 @@ import { NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { isUniqueConstraintViolation } from "@/lib/db-errors";
|
||||
import { getConfig } from "@/lib/config";
|
||||
|
||||
// Trim before validation to prevent unicode/whitespace tricks that bypass uniqueness checks (e.g. "admin@x.com\u200B" vs "admin@x.com") on certain DBMS
|
||||
const trimmed = z.preprocess((v) => (typeof v === "string" ? v.trim() : v), z.string());
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
username: z.string().min(1).regex(/^[a-zA-Z0-9_]+$/),
|
||||
email: z.string().email(),
|
||||
name: trimmed.pipe(z.string().min(2)),
|
||||
username: trimmed.pipe(z.string().min(1).max(30).regex(/^[a-z0-9_]+$/)),
|
||||
email: trimmed.pipe(z.string().email()).transform((v) => v.toLowerCase()),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
@@ -34,49 +38,41 @@ export async function POST(request: Request) {
|
||||
|
||||
const { name, username, email, password } = parsed.data;
|
||||
|
||||
// Check if email already exists
|
||||
const existingEmail = await db.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existingEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: "email_taken", message: "Email is already taken" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if username already exists (case-insensitive)
|
||||
const existingUsername = await db.user.findFirst({
|
||||
where: { username: { equals: username, mode: "insensitive" } },
|
||||
});
|
||||
|
||||
if (existingUsername) {
|
||||
return NextResponse.json(
|
||||
{ error: "username_taken", message: "Username is already taken" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Create user
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
name,
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
// Atomic create — DB unique constraints enforce email and CI username uniqueness
|
||||
try {
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
name,
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
});
|
||||
return NextResponse.json({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintViolation(error, "email")) {
|
||||
return NextResponse.json(
|
||||
{ error: "email_taken", message: "Email is already taken" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
if (isUniqueConstraintViolation(error, "username")) {
|
||||
return NextResponse.json(
|
||||
{ error: "username_taken", message: "Username is already taken" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { isUniqueConstraintViolation } from "@/lib/db-errors";
|
||||
|
||||
const customLinkSchema = z.object({
|
||||
type: z.enum(["website", "github", "twitter", "linkedin", "instagram", "youtube", "twitch", "discord", "mastodon", "bluesky", "sponsor"]),
|
||||
@@ -10,13 +11,12 @@ const customLinkSchema = z.object({
|
||||
label: z.string().max(30).optional(),
|
||||
});
|
||||
|
||||
// Trim before validation to prevent unicode/whitespace tricks that bypass uniqueness checks (e.g. "admin\u200B@x.com" vs "admin@x.com")
|
||||
const trimmed = z.preprocess((v) => (typeof v === "string" ? v.trim() : v), z.string());
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
username: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(30)
|
||||
.regex(/^[a-zA-Z0-9_]+$/),
|
||||
name: trimmed.pipe(z.string().min(1).max(100)),
|
||||
username: trimmed.pipe(z.string().min(1).max(30).regex(/^[a-z0-9_]+$/)),
|
||||
avatar: z.string().url().optional().or(z.literal("")),
|
||||
bio: z.string().max(250).optional().or(z.literal("")),
|
||||
customLinks: z.array(customLinkSchema).max(5).optional(),
|
||||
@@ -44,43 +44,38 @@ export async function PATCH(request: NextRequest) {
|
||||
|
||||
const { name, username, avatar, bio, customLinks } = parsed.data;
|
||||
|
||||
// Check if username is taken by another user
|
||||
if (username !== session.user.username) {
|
||||
const existingUser = await db.user.findFirst({
|
||||
where: { username: { equals: username, mode: "insensitive" } },
|
||||
select: { id: true },
|
||||
// Atomic update — DB-level CI unique index prevents collisions
|
||||
try {
|
||||
const user = await db.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: {
|
||||
name,
|
||||
username,
|
||||
avatar: avatar || null,
|
||||
bio: bio || null,
|
||||
customLinks: customLinks && customLinks.length > 0 ? customLinks : Prisma.DbNull,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
avatar: true,
|
||||
bio: true,
|
||||
customLinks: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser && existingUser.id !== session.user.id) {
|
||||
return NextResponse.json(user);
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintViolation(error, "username")) {
|
||||
return NextResponse.json(
|
||||
{ error: "username_taken", message: "This username is already taken" },
|
||||
{ status: 400 }
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Update user
|
||||
const user = await db.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: {
|
||||
name,
|
||||
username,
|
||||
avatar: avatar || null,
|
||||
bio: bio || null,
|
||||
customLinks: customLinks && customLinks.length > 0 ? customLinks : Prisma.DbNull,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
avatar: true,
|
||||
bio: true,
|
||||
customLinks: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(user);
|
||||
} catch (error) {
|
||||
console.error("Update profile error:", error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -22,7 +22,7 @@ import { analyticsAuth } from "@/lib/analytics";
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
username: z.string().min(1, "Username is required").regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"),
|
||||
username: z.string().min(1, "Username is required").max(30, "Username must be at most 30 characters").regex(/^[a-z0-9_]+$/, "Username can only contain lowercase letters, numbers, and underscores"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
confirmPassword: z.string(),
|
||||
|
||||
@@ -39,7 +39,7 @@ const profileSchema = z.object({
|
||||
.string()
|
||||
.min(1, "Username is required")
|
||||
.max(30)
|
||||
.regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"),
|
||||
.regex(/^[a-z0-9_]+$/, "Username can only contain lowercase letters, numbers, and underscores"),
|
||||
avatar: z.string().url().optional().or(z.literal("")),
|
||||
bio: z.string().max(250).optional().or(z.literal("")),
|
||||
customLinks: z.array(customLinkSchema).max(5).optional(),
|
||||
|
||||
+52
-54
@@ -1,6 +1,7 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||
import { db } from "@/lib/db";
|
||||
import { isUniqueConstraintViolation } from "@/lib/db-errors";
|
||||
import { getConfig } from "@/lib/config";
|
||||
import { initializePlugins, getAuthPlugin } from "@/lib/plugins";
|
||||
import type { Adapter, AdapterUser } from "next-auth/adapters";
|
||||
@@ -8,30 +9,22 @@ import type { Adapter, AdapterUser } from "next-auth/adapters";
|
||||
// Initialize plugins before use
|
||||
initializePlugins();
|
||||
|
||||
// Generate a unique username from email or name
|
||||
async function generateUsername(email: string, name?: string | null): Promise<string> {
|
||||
// Generate a candidate username from email or name (no DB check — uniqueness enforced at insert time)
|
||||
function generateBaseUsername(email: string, name?: string | null): string {
|
||||
// Try to use the part before @ in email
|
||||
let baseUsername = email.split("@")[0].toLowerCase().replace(/[^a-z0-9_]/g, "");
|
||||
|
||||
|
||||
// If too short, use name
|
||||
if (baseUsername.length < 3 && name) {
|
||||
baseUsername = name.toLowerCase().replace(/[^a-z0-9_]/g, "").slice(0, 15);
|
||||
}
|
||||
|
||||
|
||||
// Ensure minimum length
|
||||
if (baseUsername.length < 3) {
|
||||
baseUsername = "user";
|
||||
}
|
||||
|
||||
// Check if username exists and append number if needed
|
||||
let username = baseUsername;
|
||||
let counter = 1;
|
||||
while (await db.user.findFirst({ where: { username: { equals: username, mode: "insensitive" } } })) {
|
||||
username = `${baseUsername}${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
return username;
|
||||
|
||||
return baseUsername;
|
||||
}
|
||||
|
||||
// Custom adapter that wraps PrismaAdapter to add username
|
||||
@@ -41,68 +34,73 @@ function CustomPrismaAdapter(): Adapter {
|
||||
return {
|
||||
...prismaAdapter,
|
||||
async createUser(data: AdapterUser & { username?: string; githubUsername?: string }) {
|
||||
// Use GitHub username if provided, otherwise generate one
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let username = (data as any).username;
|
||||
const providedUsername = (data as any).username?.trim().toLowerCase() || null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const githubUsername = (data as any).githubUsername; // Immutable GitHub username
|
||||
|
||||
if (!username) {
|
||||
username = await generateUsername(data.email, data.name);
|
||||
} else {
|
||||
username = username.toLowerCase();
|
||||
|
||||
// Check if there's an unclaimed account with this username
|
||||
const normalizedEmail = data.email.trim().toLowerCase();
|
||||
|
||||
// If a username was provided, try to claim an unclaimed account first
|
||||
if (providedUsername) {
|
||||
const username = providedUsername;
|
||||
const unclaimedEmail = `${username}@unclaimed.prompts.chat`;
|
||||
const unclaimedUser = await db.user.findUnique({
|
||||
where: { email: unclaimedEmail },
|
||||
});
|
||||
|
||||
|
||||
if (unclaimedUser) {
|
||||
// Claim this account - update with real user info
|
||||
const claimedUser = await db.user.update({
|
||||
where: { id: unclaimedUser.id },
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
email: normalizedEmail,
|
||||
avatar: data.image,
|
||||
emailVerified: data.emailVerified,
|
||||
githubUsername: githubUsername || undefined, // Store immutable GitHub username
|
||||
githubUsername: githubUsername || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
...claimedUser,
|
||||
image: claimedUser.avatar,
|
||||
} as AdapterUser;
|
||||
}
|
||||
|
||||
// Ensure GitHub username is unique, append number if taken
|
||||
const baseUsername = username;
|
||||
let finalUsername = baseUsername;
|
||||
let counter = 1;
|
||||
while (await db.user.findFirst({ where: { username: { equals: finalUsername, mode: "insensitive" } } })) {
|
||||
finalUsername = `${baseUsername}${counter}`;
|
||||
counter++;
|
||||
}
|
||||
username = finalUsername;
|
||||
}
|
||||
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
avatar: data.image,
|
||||
emailVerified: data.emailVerified,
|
||||
username,
|
||||
githubUsername: githubUsername || undefined, // Store immutable GitHub username
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...user,
|
||||
image: user.avatar,
|
||||
} as AdapterUser;
|
||||
|
||||
// Atomic create with retry on username collision
|
||||
const baseUsername = providedUsername
|
||||
? providedUsername
|
||||
: generateBaseUsername(normalizedEmail, data.name);
|
||||
|
||||
let username = baseUsername;
|
||||
let counter = 1;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
email: normalizedEmail,
|
||||
avatar: data.image,
|
||||
emailVerified: data.emailVerified,
|
||||
username,
|
||||
githubUsername: githubUsername || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...user,
|
||||
image: user.avatar,
|
||||
} as AdapterUser;
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintViolation(error, "username")) {
|
||||
username = `${baseUsername}${counter}`;
|
||||
counter++;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Check if a Prisma error is a unique constraint violation (P2002)
|
||||
* on a specific field.
|
||||
*
|
||||
* Prisma reports column-level constraints as ["fieldName"] and
|
||||
* raw index constraints as ["table_field_unique"], so we match both.
|
||||
*/
|
||||
export function isUniqueConstraintViolation(
|
||||
error: unknown,
|
||||
field: string,
|
||||
): boolean {
|
||||
if (
|
||||
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
|
||||
error.code !== "P2002"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = error.meta?.target;
|
||||
|
||||
if (Array.isArray(target)) {
|
||||
return target.some(
|
||||
(t: string) => t === field || t.includes(field),
|
||||
);
|
||||
}
|
||||
|
||||
return typeof target === "string" && target.includes(field);
|
||||
}
|
||||
Reference in New Issue
Block a user