"use server" import { revalidatePath } from "next/cache" import { eq } from "drizzle-orm" import { compare, hash } from "bcryptjs" import { db } from "@/shared/db" import { users, passwordSecurity } from "@/shared/db/schema" import type { ActionState } from "@/shared/types/action-state" import { requireAuth, PermissionDeniedError } from "@/shared/lib/auth-guard" import { validatePassword } from "@/shared/lib/password-policy" import { rateLimit, rateLimitKey, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit" const normalizeBcryptHash = (value: string) => { if (value.startsWith("$2")) return value if (value.startsWith("$")) return `$2b${value}` return `$2b$${value}` } /** * Change the current user's password. Requires only authentication * (no specific permission) since every user can manage their own * credentials. Rate-limited to slow brute-force of the current password. */ export async function changePasswordAction( prevState: ActionState, formData: FormData ): Promise> { try { const ctx = await requireAuth() const userId = ctx.userId const limitKey = rateLimitKey("pwd-change", userId) const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.PASSWORD_CHANGE }) if (!limit.success) { return { success: false, message: "Too many attempts. Please try again later." } } const currentPassword = String(formData.get("currentPassword") ?? "") const newPassword = String(formData.get("newPassword") ?? "") const confirmPassword = String(formData.get("confirmPassword") ?? "") if (!currentPassword || !newPassword || !confirmPassword) { return { success: false, message: "All fields are required" } } if (newPassword !== confirmPassword) { return { success: false, message: "New passwords do not match" } } if (newPassword === currentPassword) { return { success: false, message: "New password must be different from current password" } } const validation = validatePassword(newPassword) if (!validation.valid) { return { success: false, message: validation.errors[0] ?? "Password does not meet requirements" } } const [user] = await db .select({ id: users.id, password: users.password }) .from(users) .where(eq(users.id, userId)) .limit(1) if (!user || !user.password) { return { success: false, message: "User not found or no password set" } } const storedHash = normalizeBcryptHash(user.password) if (!storedHash.startsWith("$2")) { return { success: false, message: "Stored password is invalid" } } const ok = await compare(currentPassword, storedHash) if (!ok) { return { success: false, message: "Current password is incorrect" } } const newHash = await hash(newPassword, 10) const now = new Date() await db .update(users) .set({ password: newHash, updatedAt: now }) .where(eq(users.id, userId)) const [existing] = await db .select({ id: passwordSecurity.id }) .from(passwordSecurity) .where(eq(passwordSecurity.userId, userId)) .limit(1) if (existing) { await db .update(passwordSecurity) .set({ lastPasswordChange: now, passwordChangedAt: now, mustChangePassword: false, updatedAt: now, }) .where(eq(passwordSecurity.userId, userId)) } else { await db.insert(passwordSecurity).values({ userId, lastPasswordChange: now, passwordChangedAt: now, mustChangePassword: false, }) } revalidatePath("/settings") return { success: true, message: "Password changed successfully", data: null } } catch (error) { if (error instanceof PermissionDeniedError) return { success: false, message: error.message } return { success: false, message: "Failed to change password" } } }