From f1e7c2d7aaa9ae93052785e377a2cd5fa816c7f8 Mon Sep 17 00:00:00 2001 From: RKWojs Date: Fri, 10 Oct 2025 09:15:29 +0200 Subject: [PATCH] feat: implement password reset functionality with token verification and change password feature --- src/app/api/auth/change-password/route.js | 80 +++++++++++ .../api/auth/password-reset/request/route.js | 70 ++++++++++ .../api/auth/password-reset/reset/route.js | 71 ++++++++++ .../api/auth/password-reset/verify/route.js | 47 +++++++ src/app/settings/page.js | 13 ++ src/components/settings/PasswordReset.js | 129 ++++++++++++++++++ src/lib/auditLogSafe.js | 2 + src/lib/i18n.js | 16 ++- src/lib/init-db.js | 15 ++ 9 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 src/app/api/auth/change-password/route.js create mode 100644 src/app/api/auth/password-reset/request/route.js create mode 100644 src/app/api/auth/password-reset/reset/route.js create mode 100644 src/app/api/auth/password-reset/verify/route.js create mode 100644 src/components/settings/PasswordReset.js diff --git a/src/app/api/auth/change-password/route.js b/src/app/api/auth/change-password/route.js new file mode 100644 index 0000000..8bbf0b6 --- /dev/null +++ b/src/app/api/auth/change-password/route.js @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import bcrypt from "bcryptjs"; +import { z } from "zod"; + +const changePasswordSchema = z.object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: z.string().min(6, "New password must be at least 6 characters"), +}); + +export async function POST(request) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const body = await request.json(); + const { currentPassword, newPassword } = changePasswordSchema.parse(body); + + // Import database here to avoid edge runtime issues + const { default: db } = await import("@/lib/db.js"); + + // Get current user password hash + const user = db + .prepare("SELECT password_hash FROM users WHERE id = ?") + .get(session.user.id); + + if (!user) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); + } + + // Verify current password + const isValidPassword = await bcrypt.compare(currentPassword, user.password_hash); + + if (!isValidPassword) { + return NextResponse.json( + { error: "Current password is incorrect" }, + { status: 400 } + ); + } + + // Hash the new password + const hashedNewPassword = await bcrypt.hash(newPassword, 12); + + // Update password + db.prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + .run(hashedNewPassword, session.user.id); + + // Log audit event + try { + const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import("@/lib/auditLogSafe.js"); + await logAuditEventSafe({ + action: AUDIT_ACTIONS.USER_UPDATE, + userId: session.user.id, + resourceType: RESOURCE_TYPES.USER, + details: { field: "password", username: session.user.username }, + }); + } catch (auditError) { + console.error("Failed to log audit event:", auditError); + } + + return NextResponse.json({ + message: "Password changed successfully", + }); + } catch (error) { + console.error("Change password error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/auth/password-reset/request/route.js b/src/app/api/auth/password-reset/request/route.js new file mode 100644 index 0000000..ca7c550 --- /dev/null +++ b/src/app/api/auth/password-reset/request/route.js @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import crypto from "crypto"; +import { z } from "zod"; + +const requestSchema = z.object({ + username: z.string().min(1, "Username is required"), +}); + +export async function POST(request) { + try { + const body = await request.json(); + const { username } = requestSchema.parse(body); + + // Import database here to avoid edge runtime issues + const { default: db } = await import("@/lib/db.js"); + + // Check if user exists and is active + const user = db + .prepare("SELECT id, username, name FROM users WHERE username = ? AND is_active = 1") + .get(username); + + if (!user) { + // Don't reveal if user exists or not for security + return NextResponse.json( + { message: "If the username exists, a password reset link has been sent." }, + { status: 200 } + ); + } + + // Generate reset token + const token = crypto.randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour + + // Delete any existing tokens for this user + db.prepare("DELETE FROM password_reset_tokens WHERE user_id = ?").run(user.id); + + // Insert new token + db.prepare( + "INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)" + ).run(user.id, token, expiresAt); + + // TODO: Send email with reset link + // For now, return the token for testing purposes + console.log(`Password reset token for ${username}: ${token}`); + + // Log audit event + try { + const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import("@/lib/auditLogSafe.js"); + await logAuditEventSafe({ + action: AUDIT_ACTIONS.PASSWORD_RESET_REQUEST, + userId: user.id, + resourceType: RESOURCE_TYPES.USER, + details: { username: user.username }, + }); + } catch (auditError) { + console.error("Failed to log audit event:", auditError); + } + + return NextResponse.json( + { message: "If the username exists, a password reset link has been sent." }, + { status: 200 } + ); + } catch (error) { + console.error("Password reset request error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/auth/password-reset/reset/route.js b/src/app/api/auth/password-reset/reset/route.js new file mode 100644 index 0000000..b525b2e --- /dev/null +++ b/src/app/api/auth/password-reset/reset/route.js @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { z } from "zod"; + +const resetSchema = z.object({ + token: z.string().min(1, "Token is required"), + password: z.string().min(6, "Password must be at least 6 characters"), +}); + +export async function POST(request) { + try { + const body = await request.json(); + const { token, password } = resetSchema.parse(body); + + // Import database here to avoid edge runtime issues + const { default: db } = await import("@/lib/db.js"); + + // Check if token exists and is valid + const resetToken = db + .prepare( + ` + SELECT prt.*, u.username, u.name + FROM password_reset_tokens prt + JOIN users u ON prt.user_id = u.id + WHERE prt.token = ? AND prt.used = 0 AND prt.expires_at > datetime('now') + ` + ) + .get(token); + + if (!resetToken) { + return NextResponse.json( + { error: "Invalid or expired token" }, + { status: 400 } + ); + } + + // Hash the new password + const hashedPassword = await bcrypt.hash(password, 12); + + // Update user password + db.prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + .run(hashedPassword, resetToken.user_id); + + // Mark token as used + db.prepare("UPDATE password_reset_tokens SET used = 1 WHERE id = ?") + .run(resetToken.id); + + // Log audit event + try { + const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import("@/lib/auditLogSafe.js"); + await logAuditEventSafe({ + action: AUDIT_ACTIONS.PASSWORD_RESET, + userId: resetToken.user_id, + resourceType: RESOURCE_TYPES.USER, + details: { username: resetToken.username }, + }); + } catch (auditError) { + console.error("Failed to log audit event:", auditError); + } + + return NextResponse.json({ + message: "Password has been reset successfully", + }); + } catch (error) { + console.error("Password reset error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/auth/password-reset/verify/route.js b/src/app/api/auth/password-reset/verify/route.js new file mode 100644 index 0000000..ec7f8b5 --- /dev/null +++ b/src/app/api/auth/password-reset/verify/route.js @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const verifySchema = z.object({ + token: z.string().min(1, "Token is required"), +}); + +export async function POST(request) { + try { + const body = await request.json(); + const { token } = verifySchema.parse(body); + + // Import database here to avoid edge runtime issues + const { default: db } = await import("@/lib/db.js"); + + // Check if token exists and is valid + const resetToken = db + .prepare( + ` + SELECT prt.*, u.username, u.name + FROM password_reset_tokens prt + JOIN users u ON prt.user_id = u.id + WHERE prt.token = ? AND prt.used = 0 AND prt.expires_at > datetime('now') + ` + ) + .get(token); + + if (!resetToken) { + return NextResponse.json( + { error: "Invalid or expired token" }, + { status: 400 } + ); + } + + return NextResponse.json({ + valid: true, + username: resetToken.username, + name: resetToken.name, + }); + } catch (error) { + console.error("Token verification error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/settings/page.js b/src/app/settings/page.js index e9c4196..936ba1a 100644 --- a/src/app/settings/page.js +++ b/src/app/settings/page.js @@ -6,6 +6,7 @@ import PageHeader from "@/components/ui/PageHeader"; import { Card, CardHeader, CardContent } from "@/components/ui/Card"; import ThemeToggle from "@/components/ui/ThemeToggle"; import LanguageSwitcher from "@/components/ui/LanguageSwitcher"; +import PasswordReset from "@/components/settings/PasswordReset"; export default function SettingsPage() { const { t } = useTranslation(); @@ -58,6 +59,18 @@ export default function SettingsPage() { + + {/* Password Settings */} + + +

+ {t('settings.password') || 'Password'} +

+
+ + + +
); diff --git a/src/components/settings/PasswordReset.js b/src/components/settings/PasswordReset.js new file mode 100644 index 0000000..9ab25c1 --- /dev/null +++ b/src/components/settings/PasswordReset.js @@ -0,0 +1,129 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation } from "@/lib/i18n"; + +export default function PasswordReset() { + const { t } = useTranslation(); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + setMessage(""); + + if (newPassword !== confirmPassword) { + setError("New passwords do not match"); + return; + } + + if (newPassword.length < 6) { + setError("New password must be at least 6 characters long"); + return; + } + + setIsLoading(true); + + try { + const response = await fetch("/api/auth/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + currentPassword, + newPassword, + }), + }); + + const data = await response.json(); + + if (response.ok) { + setMessage("Password changed successfully"); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } else { + setError(data.error || "Failed to change password"); + } + } catch (err) { + setError("An error occurred while changing password"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

+ {t('settings.passwordDescription') || 'Change your account password'} +

+ +
+
+ + setCurrentPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" + required + /> +
+ +
+ + setNewPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" + required + /> +
+ + {error && ( +
+ {error} +
+ )} + + {message && ( +
+ {message} +
+ )} + + +
+
+ ); +} \ No newline at end of file diff --git a/src/lib/auditLogSafe.js b/src/lib/auditLogSafe.js index 84234e9..1e58c82 100644 --- a/src/lib/auditLogSafe.js +++ b/src/lib/auditLogSafe.js @@ -9,6 +9,8 @@ export const AUDIT_ACTIONS = { LOGIN: "login", LOGOUT: "logout", LOGIN_FAILED: "login_failed", + PASSWORD_RESET_REQUEST: "password_reset_request", + PASSWORD_RESET: "password_reset", // Projects PROJECT_CREATE: "project_create", diff --git a/src/lib/i18n.js b/src/lib/i18n.js index 90c00b9..95e4f66 100644 --- a/src/lib/i18n.js +++ b/src/lib/i18n.js @@ -582,7 +582,13 @@ const translations = { theme: "Motyw", themeDescription: "Wybierz preferowany motyw", language: "Język", - languageDescription: "Wybierz preferowany język" + languageDescription: "Wybierz preferowany język", + password: "Hasło", + passwordDescription: "Zmień hasło do konta", + currentPassword: "Aktualne hasło", + newPassword: "Nowe hasło", + confirmPassword: "Potwierdź nowe hasło", + changePassword: "Zmień hasło" } }, @@ -1090,7 +1096,13 @@ const translations = { theme: "Theme", themeDescription: "Choose your preferred theme", language: "Language", - languageDescription: "Select your preferred language" + languageDescription: "Select your preferred language", + password: "Password", + passwordDescription: "Change your account password", + currentPassword: "Current Password", + newPassword: "New Password", + confirmPassword: "Confirm New Password", + changePassword: "Change Password" } } }; diff --git a/src/lib/init-db.js b/src/lib/init-db.js index 66754fe..1ba1c79 100644 --- a/src/lib/init-db.js +++ b/src/lib/init-db.js @@ -452,5 +452,20 @@ export default function initializeDatabase() { CREATE INDEX IF NOT EXISTS idx_field_history_table_record ON field_change_history(table_name, record_id); CREATE INDEX IF NOT EXISTS idx_field_history_field ON field_change_history(table_name, record_id, field_name); CREATE INDEX IF NOT EXISTS idx_field_history_changed_by ON field_change_history(changed_by); + + -- Password reset tokens table + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + user_id TEXT NOT NULL, + token TEXT UNIQUE NOT NULL, + expires_at TEXT NOT NULL, + used INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + -- Create index for password reset tokens + CREATE INDEX IF NOT EXISTS idx_password_reset_token ON password_reset_tokens(token); + CREATE INDEX IF NOT EXISTS idx_password_reset_user ON password_reset_tokens(user_id); `); }