feat: implement user registration functionality
- Created registerUser server action with email/password validation - Implemented password hashing with bcrypt (10 rounds) - Added email uniqueness check to prevent duplicate registrations - Created session after successful registration using NextAuth - Added comprehensive validation for email format and password length (min 8 chars) - Added email and password validation utilities to lib/validation.ts - Removed PrismaAdapter from auth.ts (using JWT sessions only) - Added vitest testing framework and configuration - Created comprehensive unit tests for registration functionality - All tests passing (6/6) Requirements covered: 1.1, 1.2, 1.3, 1.4, 1.5
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
- Set up environment variables for auth secrets
|
||||
- _Requirements: 1.1, 2.1_
|
||||
|
||||
- [ ] 2. Update database schema for multi-user support
|
||||
- [x] 2. Update database schema for multi-user support
|
||||
- Add User model with email, passwordHash, and name fields
|
||||
- Add Collaborator model for list sharing
|
||||
- Add Invitation model for sharing invitations
|
||||
@@ -26,7 +26,7 @@
|
||||
- **Property 3: Password hashing on registration**
|
||||
- **Validates: Requirements 1.3**
|
||||
|
||||
- [ ] 3. Implement user registration functionality
|
||||
- [x] 3. Implement user registration functionality
|
||||
- Create registration server action with email/password validation
|
||||
- Implement password hashing with bcrypt
|
||||
- Add email uniqueness check
|
||||
|
||||
2
auth.ts
2
auth.ts
@@ -1,6 +1,5 @@
|
||||
import NextAuth from "next-auth"
|
||||
import Credentials from "next-auth/providers/credentials"
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter"
|
||||
import { prisma } from "@/lib/db"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { z } from "zod"
|
||||
@@ -12,7 +11,6 @@ const loginSchema = z.object({
|
||||
})
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
adapter: PrismaAdapter(prisma),
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
|
||||
127
lib/__tests__/auth-actions.test.ts
Normal file
127
lib/__tests__/auth-actions.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests for user registration functionality
|
||||
* This file tests the core registration logic without session creation
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import { prisma } from '../db'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Mock the auth module to avoid Next.js dependencies in tests
|
||||
vi.mock('@/auth', () => ({
|
||||
signIn: vi.fn(),
|
||||
}))
|
||||
|
||||
// Import after mocking
|
||||
import { registerUser } from '../auth-actions'
|
||||
|
||||
describe('registerUser', () => {
|
||||
// Clean up test users after each test
|
||||
afterEach(async () => {
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
email: {
|
||||
contains: 'test-',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should create a new user with valid credentials', async () => {
|
||||
const email = `test-${Date.now()}@example.com`
|
||||
const password = 'password123'
|
||||
const name = 'Test User'
|
||||
|
||||
const result = await registerUser(email, password, name)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.userId).toBeDefined()
|
||||
expect(typeof result.userId).toBe('string')
|
||||
|
||||
// Verify user was created in database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
})
|
||||
|
||||
expect(user).toBeDefined()
|
||||
expect(user?.email).toBe(email)
|
||||
expect(user?.name).toBe(name)
|
||||
expect(user?.passwordHash).toBeDefined()
|
||||
expect(user?.passwordHash).not.toBe(password) // Password should be hashed
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject registration with duplicate email', async () => {
|
||||
const email = `test-${Date.now()}@example.com`
|
||||
const password = 'password123'
|
||||
|
||||
// Create first user
|
||||
const result1 = await registerUser(email, password)
|
||||
expect(result1.success).toBe(true)
|
||||
|
||||
// Try to create second user with same email
|
||||
const result2 = await registerUser(email, password)
|
||||
expect(result2.success).toBe(false)
|
||||
if (!result2.success) {
|
||||
expect(result2.error).toContain('already registered')
|
||||
}
|
||||
})
|
||||
|
||||
it('should hash the password before storing', async () => {
|
||||
const email = `test-${Date.now()}@example.com`
|
||||
const password = 'password123'
|
||||
|
||||
const result = await registerUser(email, password)
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
})
|
||||
|
||||
expect(user?.passwordHash).toBeDefined()
|
||||
expect(user?.passwordHash).not.toBe(password)
|
||||
|
||||
// Verify the hash is valid
|
||||
const isValid = await bcrypt.compare(password, user!.passwordHash)
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject password shorter than 8 characters', async () => {
|
||||
const email = `test-${Date.now()}@example.com`
|
||||
const password = 'short'
|
||||
|
||||
const result = await registerUser(email, password)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain('at least 8 characters')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject invalid email format', async () => {
|
||||
const email = 'invalid-email'
|
||||
const password = 'password123'
|
||||
|
||||
const result = await registerUser(email, password)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain('Invalid email')
|
||||
}
|
||||
})
|
||||
|
||||
it('should create user without name (optional field)', async () => {
|
||||
const email = `test-${Date.now()}@example.com`
|
||||
const password = 'password123'
|
||||
|
||||
const result = await registerUser(email, password)
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
})
|
||||
|
||||
expect(user).toBeDefined()
|
||||
expect(user?.name).toBeNull()
|
||||
})
|
||||
})
|
||||
83
lib/auth-actions.ts
Normal file
83
lib/auth-actions.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
'use server'
|
||||
|
||||
import { prisma } from './db'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import { signIn } from '@/auth'
|
||||
|
||||
// Validation schemas
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
password: z.string().min(8, { message: 'Password must be at least 8 characters' }),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
|
||||
export type RegisterResult =
|
||||
| { success: true; userId: string }
|
||||
| { success: false; error: string }
|
||||
|
||||
/**
|
||||
* Register a new user with email and password
|
||||
* Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
|
||||
*/
|
||||
export async function registerUser(
|
||||
email: string,
|
||||
password: string,
|
||||
name?: string
|
||||
): Promise<RegisterResult> {
|
||||
try {
|
||||
// Validate input (Requirement 1.5 - password length validation)
|
||||
const validation = registerSchema.safeParse({ email, password, name })
|
||||
|
||||
if (!validation.success) {
|
||||
const errors = validation.error.issues.map((issue) => issue.message).join(', ')
|
||||
return { success: false, error: errors }
|
||||
}
|
||||
|
||||
const { email: validEmail, password: validPassword, name: validName } = validation.data
|
||||
|
||||
// Check if email already exists (Requirement 1.2 - duplicate email rejection)
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: validEmail },
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
return { success: false, error: 'Email already registered' }
|
||||
}
|
||||
|
||||
// Hash password (Requirement 1.3 - password hashing)
|
||||
const passwordHash = await bcrypt.hash(validPassword, 10)
|
||||
|
||||
// Create user (Requirement 1.1 - create user with unique identifier)
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: validEmail,
|
||||
passwordHash,
|
||||
name: validName || null,
|
||||
},
|
||||
})
|
||||
|
||||
// Create session after successful registration (Requirement 1.4)
|
||||
try {
|
||||
await signIn('credentials', {
|
||||
email: validEmail,
|
||||
password: validPassword,
|
||||
redirect: false,
|
||||
})
|
||||
} catch (error) {
|
||||
// If session creation fails, still return success since user was created
|
||||
console.error('Session creation failed after registration:', error)
|
||||
}
|
||||
|
||||
return { success: true, userId: user.id }
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
|
||||
// Handle unique constraint violation (shouldn't happen due to check above, but just in case)
|
||||
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||||
return { success: false, error: 'Email already registered' }
|
||||
}
|
||||
|
||||
return { success: false, error: 'Registration failed. Please try again.' }
|
||||
}
|
||||
}
|
||||
@@ -301,3 +301,46 @@ export function validateItemData(data: {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
export function validateEmail(email: string): ValidationResult {
|
||||
const sanitized = sanitizeString(email)
|
||||
|
||||
if (!sanitized) {
|
||||
return { isValid: false, error: 'Email is required' }
|
||||
}
|
||||
|
||||
// Basic email regex pattern
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
if (!emailRegex.test(sanitized)) {
|
||||
return { isValid: false, error: 'Invalid email format' }
|
||||
}
|
||||
|
||||
if (sanitized.length > 255) {
|
||||
return { isValid: false, error: 'Email must be less than 255 characters' }
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password strength
|
||||
*/
|
||||
export function validatePassword(password: string): ValidationResult {
|
||||
if (!password) {
|
||||
return { isValid: false, error: 'Password is required' }
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return { isValid: false, error: 'Password must be at least 8 characters' }
|
||||
}
|
||||
|
||||
if (password.length > 128) {
|
||||
return { isValid: false, error: 'Password must be less than 128 characters' }
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
1386
package-lock.json
generated
1386
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"icons:generate": "node scripts/generate-icons.js",
|
||||
@@ -29,12 +31,14 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@vitest/ui": "^4.0.14",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.0.4",
|
||||
"postcss": "^8",
|
||||
"prisma": "^5.7.1",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.14"
|
||||
}
|
||||
}
|
||||
|
||||
16
vitest.config.ts
Normal file
16
vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
setupFiles: [],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './'),
|
||||
},
|
||||
conditions: ['node', 'default'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user