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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user