diff --git a/openspec/changes/user-accounts/tasks.md b/openspec/changes/user-accounts/tasks.md index 224e67f..b334433 100644 --- a/openspec/changes/user-accounts/tasks.md +++ b/openspec/changes/user-accounts/tasks.md @@ -32,7 +32,7 @@ ## 6. User Settings API - [x] 6.1 `[haiku]` Create `PUT /api/auth/profile` endpoint: update user display name -- [ ] 6.2 `[sonnet]` Create `PUT /api/auth/password` endpoint: verify current password, hash new password, update; reject for OAuth users +- [x] 6.2 `[sonnet]` Create `PUT /api/auth/password` endpoint: verify current password, hash new password, update; reject for OAuth users - [ ] 6.3 `[sonnet]` Create `DELETE /api/auth/account` endpoint: delete all user data (cascade) and user record ## 7. Update Existing API Routes diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts new file mode 100644 index 0000000..bc8f267 --- /dev/null +++ b/src/app/api/auth/password/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server'; +import bcryptjs from 'bcryptjs'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { users } from '@/lib/db/schema'; +import { getAuthUser } from '@/lib/auth'; + +export async function PUT(request: NextRequest) { + // Get authenticated user + const user = await getAuthUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Fetch full user record from DB + const [dbUser] = await db + .select({ + id: users.id, + password_hash: users.password_hash, + provider: users.provider, + }) + .from(users) + .where(eq(users.id, user.id)) + .limit(1); + + if (!dbUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Reject OAuth users (no password_hash) + if (!dbUser.password_hash) { + return NextResponse.json( + { error: 'Password change is not available for OAuth accounts' }, + { status: 400 } + ); + } + + // Parse request body + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const { currentPassword, newPassword } = body as Record; + + // Validate currentPassword + if (!currentPassword || typeof currentPassword !== 'string' || currentPassword === '') { + return NextResponse.json({ error: 'Current password is required' }, { status: 400 }); + } + + // Validate newPassword + if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) { + return NextResponse.json( + { error: 'New password must be at least 8 characters' }, + { status: 400 } + ); + } + + // Verify current password + const isValid = await bcryptjs.compare(currentPassword, dbUser.password_hash); + if (!isValid) { + return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 }); + } + + // Hash new password and update in DB + const newPasswordHash = await bcryptjs.hash(newPassword, 12); + await db + .update(users) + .set({ + password_hash: newPasswordHash, + updated_at: new Date(), + }) + .where(eq(users.id, dbUser.id)); + + return NextResponse.json({ message: 'Password updated successfully' }, { status: 200 }); +}