Rewrite password change form to use Server Action

Replaces client-side fetch with a proper Server Action + useActionState.
Uncontrolled inputs fix agent-browser testing and improve reliability.
Server action verifies bcrypt hash directly via Prisma.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 17:41:42 +05:00
parent 799117d287
commit c1ae048c14
2 changed files with 48 additions and 53 deletions
+35
View File
@@ -0,0 +1,35 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export async function changePasswordAction(_prevState: unknown, formData: FormData) {
const current = (formData.get("currentPassword") as string) ?? "";
const next = (formData.get("newPassword") as string) ?? "";
const confirm = (formData.get("confirmPassword") as string) ?? "";
if (!current || !next || !confirm) return { error: "Заполните все поля" };
if (next !== confirm) return { error: "Пароли не совпадают" };
if (next.length < 8) return { error: "Новый пароль должен быть не короче 8 символов" };
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return { error: "Сессия истекла, войдите заново" };
const account = await prisma.account.findFirst({
where: { userId: session.user.id, providerId: "credential" },
});
if (!account?.password) return { error: "Аккаунт не найден" };
const valid = await bcrypt.compare(current, account.password);
if (!valid) return { error: "Неверный текущий пароль" };
const hash = await bcrypt.hash(next, 10);
await prisma.account.update({
where: { id: account.id },
data: { password: hash },
});
return { success: true };
}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useActionState } from "react";
import { changePasswordAction } from "./actions";
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
@@ -10,56 +11,17 @@ const inputStyle: React.CSSProperties = {
};
export function ChangePasswordForm() {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setSuccess(false);
if (next !== confirm) {
setError("Пароли не совпадают");
return;
}
if (next.length < 8) {
setError("Новый пароль должен быть не короче 8 символов");
return;
}
setLoading(true);
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword: current, newPassword: next, revokeOtherSessions: false }),
});
setLoading(false);
if (!res.ok) {
setError("Неверный текущий пароль");
return;
}
setCurrent("");
setNext("");
setConfirm("");
setSuccess(true);
}
const [state, formAction, isPending] = useActionState(changePasswordAction, null);
return (
<form onSubmit={handleSubmit} className="space-y-4">
<form action={formAction} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Текущий пароль
</label>
<input
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
name="currentPassword"
required
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
@@ -74,8 +36,7 @@ export function ChangePasswordForm() {
</label>
<input
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
name="newPassword"
required
minLength={8}
className="w-full px-3 py-2 text-sm bg-transparent"
@@ -91,8 +52,7 @@ export function ChangePasswordForm() {
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
name="confirmPassword"
required
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
@@ -101,19 +61,19 @@ export function ChangePasswordForm() {
placeholder="••••••••"
/>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>
{state?.error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>{state.error}</p>
)}
{success && (
{state?.success && (
<p className="text-sm" style={{ color: "var(--foreground)" }}>Пароль успешно изменён.</p>
)}
<button
type="submit"
disabled={loading}
disabled={isPending}
className="btn-aubade justify-center"
style={loading ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
style={isPending ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
>
{loading ? "Сохранение..." : "Сменить пароль"}
{isPending ? "Сохранение..." : "Сменить пароль"}
</button>
</form>
);