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"; "use client";
import { useState } from "react"; import { useActionState } from "react";
import { changePasswordAction } from "./actions";
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)", border: "2px solid var(--border)",
@@ -10,56 +11,17 @@ const inputStyle: React.CSSProperties = {
}; };
export function ChangePasswordForm() { export function ChangePasswordForm() {
const [current, setCurrent] = useState(""); const [state, formAction, isPending] = useActionState(changePasswordAction, null);
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);
}
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form action={formAction} className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}> <label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Текущий пароль Текущий пароль
</label> </label>
<input <input
type="password" type="password"
value={current} name="currentPassword"
onChange={(e) => setCurrent(e.target.value)}
required required
className="w-full px-3 py-2 text-sm bg-transparent" className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle} style={inputStyle}
@@ -74,8 +36,7 @@ export function ChangePasswordForm() {
</label> </label>
<input <input
type="password" type="password"
value={next} name="newPassword"
onChange={(e) => setNext(e.target.value)}
required required
minLength={8} minLength={8}
className="w-full px-3 py-2 text-sm bg-transparent" className="w-full px-3 py-2 text-sm bg-transparent"
@@ -91,8 +52,7 @@ export function ChangePasswordForm() {
</label> </label>
<input <input
type="password" type="password"
value={confirm} name="confirmPassword"
onChange={(e) => setConfirm(e.target.value)}
required required
className="w-full px-3 py-2 text-sm bg-transparent" className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle} style={inputStyle}
@@ -101,19 +61,19 @@ export function ChangePasswordForm() {
placeholder="••••••••" placeholder="••••••••"
/> />
</div> </div>
{error && ( {state?.error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p> <p className="text-sm" style={{ color: "var(--destructive)" }}>{state.error}</p>
)} )}
{success && ( {state?.success && (
<p className="text-sm" style={{ color: "var(--foreground)" }}>Пароль успешно изменён.</p> <p className="text-sm" style={{ color: "var(--foreground)" }}>Пароль успешно изменён.</p>
)} )}
<button <button
type="submit" type="submit"
disabled={loading} disabled={isPending}
className="btn-aubade justify-center" 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> </button>
</form> </form>
); );