feat(auth): forgot/reset password pages and wiring
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 1m16s
Deploy to Production / Trigger production update (push) Successful in 8s

- ForgotPasswordPage and ResetPasswordPage in the split-screen auth layout.
- "Forgot password" link on the login page now routes to /forgot-password.
- Public routes /forgot-password and /reset-password; reset page validates
  the token on mount and gates the form, with show/hide password and
  confirmation matching.
- i18n keys (reset.*) for ru/en.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 03:24:19 +05:00
parent 8b93161434
commit 845c8024c4
6 changed files with 348 additions and 6 deletions

View File

@@ -0,0 +1,104 @@
import { useState, type FormEvent } from 'react';
import { Link } from 'react-router';
import { ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { apiClient } from '@/lib/api-client';
export default function ForgotPasswordPage() {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
await apiClient.post('/api/auth/forgot-password', { email });
setSubmitted(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Request failed');
} finally {
setIsLoading(false);
}
};
if (submitted) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">{t('reset.forgot_title', 'Password recovery')}</h1>
<p className="text-sm text-text-secondary">
{t(
'reset.email_sent',
'If an account with that email exists, we have sent a link to reset your password. Check your inbox.'
)}
</p>
<Link
to="/login"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-opacity hover:opacity-80"
>
<ArrowLeft className="h-4 w-4" />
{t('reset.back_to_login', 'Back to sign in')}
</Link>
</div>
);
}
return (
<div className="space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold">{t('reset.forgot_title', 'Password recovery')}</h1>
<p className="text-sm text-text-secondary">
{t('reset.forgot_subtitle', 'Enter your account email and we will send you a reset link.')}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</div>
)}
<div className="space-y-2">
<Label htmlFor="email">{t('reset.email', 'E-mail')}</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError(null);
}}
placeholder={t('reset.email_placeholder', 'Enter your email')}
autoComplete="email"
autoFocus
required
/>
</div>
<div className="flex items-center gap-4 pt-1">
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t('reset.sending', 'Sending...')}
</span>
) : (
t('reset.send_link', 'Send link')
)}
</Button>
<Link
to="/login"
className="text-sm font-medium text-primary transition-opacity hover:opacity-80"
>
{t('reset.back_to_login', 'Back to sign in')}
</Link>
</div>
</form>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router';
import { Link, useNavigate } from 'react-router';
import { Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -91,12 +91,12 @@ export default function LoginPage() {
t('login.sign_in', 'Sign in')
)}
</Button>
<button
type="button"
<Link
to="/forgot-password"
className="text-sm font-medium text-primary transition-opacity hover:opacity-80"
>
{t('login.forgot_password', 'Forgot password')}
</button>
</Link>
</div>
</form>
</div>

View File

@@ -0,0 +1,181 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { Eye, EyeOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { apiClient } from '@/lib/api-client';
type Status = 'validating' | 'invalid' | 'form' | 'success';
export default function ResetPasswordPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = searchParams.get('token') ?? '';
const [status, setStatus] = useState<Status>('validating');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
if (!token) {
setStatus('invalid');
return;
}
apiClient
.get<{ valid: boolean }>(`/api/auth/reset-password/validate?token=${encodeURIComponent(token)}`)
.then((res) => {
if (active) setStatus(res.valid ? 'form' : 'invalid');
})
.catch(() => {
if (active) setStatus('invalid');
});
return () => {
active = false;
};
}, [token]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (password !== confirm) {
setError(t('reset.passwords_mismatch', 'Passwords do not match'));
return;
}
setIsLoading(true);
try {
await apiClient.post('/api/auth/reset-password', { token, new_password: password });
setStatus('success');
setTimeout(() => navigate('/login'), 2500);
} catch (err) {
setError(err instanceof Error ? err.message : 'Reset failed');
} finally {
setIsLoading(false);
}
};
if (status === 'validating') {
return (
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t('reset.checking_link', 'Checking the link...')}
</div>
);
}
if (status === 'invalid') {
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">{t('reset.invalid_title', 'Link is invalid')}</h1>
<p className="text-sm text-text-secondary">
{t('reset.invalid_text', 'This password reset link is invalid or has expired. Please request a new one.')}
</p>
<div className="flex items-center gap-4">
<Link to="/forgot-password">
<Button>{t('reset.request_new', 'Request a new link')}</Button>
</Link>
<Link to="/login" className="text-sm font-medium text-primary transition-opacity hover:opacity-80">
{t('reset.back_to_login', 'Back to sign in')}
</Link>
</div>
</div>
);
}
if (status === 'success') {
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">{t('reset.success_title', 'Password updated')}</h1>
<p className="text-sm text-text-secondary">
{t('reset.success_text', 'Your password has been changed. Redirecting to the sign-in page...')}
</p>
<Link to="/login" className="text-sm font-medium text-primary transition-opacity hover:opacity-80">
{t('reset.go_to_login', 'Go to sign in')}
</Link>
</div>
);
}
return (
<div className="space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold">{t('reset.set_title', 'Set a new password')}</h1>
<p className="text-sm text-text-secondary">
{t('reset.set_subtitle', 'Enter a new password for your account.')}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</div>
)}
<div className="space-y-2">
<Label htmlFor="new-password">{t('reset.new_password', 'New password')}</Label>
<div className="relative">
<Input
id="new-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError(null);
}}
placeholder={t('reset.new_password_placeholder', 'Enter new password')}
autoComplete="new-password"
className="pr-9"
autoFocus
required
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')}
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground transition-colors hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">{t('reset.confirm_password', 'Confirm password')}</Label>
<Input
id="confirm-password"
type={showPassword ? 'text' : 'password'}
value={confirm}
onChange={(e) => {
setConfirm(e.target.value);
setError(null);
}}
placeholder={t('reset.confirm_password_placeholder', 'Repeat new password')}
autoComplete="new-password"
required
/>
</div>
<div className="flex items-center gap-4 pt-1">
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t('reset.saving', 'Saving...')}
</span>
) : (
t('reset.save', 'Save password')
)}
</Button>
<Link to="/login" className="text-sm font-medium text-primary transition-opacity hover:opacity-80">
{t('reset.back_to_login', 'Back to sign in')}
</Link>
</div>
</form>
</div>
);
}

View File

@@ -71,6 +71,32 @@
"hide_password": "Hide password",
"logout": "Logout"
},
"reset": {
"forgot_title": "Password recovery",
"forgot_subtitle": "Enter your account email and we will send you a reset link.",
"email": "E-mail",
"email_placeholder": "Enter your email",
"send_link": "Send link",
"sending": "Sending...",
"email_sent": "If an account with that email exists, we have sent a link to reset your password. Check your inbox.",
"back_to_login": "Back to sign in",
"checking_link": "Checking the link...",
"invalid_title": "Link is invalid",
"invalid_text": "This password reset link is invalid or has expired. Please request a new one.",
"request_new": "Request a new link",
"set_title": "Set a new password",
"set_subtitle": "Enter a new password for your account.",
"new_password": "New password",
"new_password_placeholder": "Enter new password",
"confirm_password": "Confirm password",
"confirm_password_placeholder": "Repeat new password",
"passwords_mismatch": "Passwords do not match",
"save": "Save password",
"saving": "Saving...",
"success_title": "Password updated",
"success_text": "Your password has been changed. Redirecting to the sign-in page...",
"go_to_login": "Go to sign in"
},
"dashboard": {
"title": "Dashboard",
"nodes": "Nodes",

View File

@@ -71,6 +71,32 @@
"hide_password": "Скрыть пароль",
"logout": "Выход"
},
"reset": {
"forgot_title": "Восстановление пароля",
"forgot_subtitle": "Введите email вашего аккаунта — мы отправим ссылку для сброса пароля.",
"email": "E-mail",
"email_placeholder": "Введите email",
"send_link": "Отправить ссылку",
"sending": "Отправляем...",
"email_sent": "Если аккаунт с таким email существует, мы отправили на него ссылку для сброса пароля. Проверьте почту.",
"back_to_login": "Вернуться ко входу",
"checking_link": "Проверяем ссылку...",
"invalid_title": "Ссылка недействительна",
"invalid_text": "Эта ссылка для сброса пароля недействительна или истекла. Запросите новую.",
"request_new": "Запросить новую ссылку",
"set_title": "Новый пароль",
"set_subtitle": "Задайте новый пароль для вашего аккаунта.",
"new_password": "Новый пароль",
"new_password_placeholder": "Введите новый пароль",
"confirm_password": "Подтверждение пароля",
"confirm_password_placeholder": "Повторите новый пароль",
"passwords_mismatch": "Пароли не совпадают",
"save": "Сохранить пароль",
"saving": "Сохраняем...",
"success_title": "Пароль обновлён",
"success_text": "Пароль изменён. Перенаправляем на страницу входа...",
"go_to_login": "Перейти ко входу"
},
"dashboard": {
"title": "Дашборд",
"nodes": "Узлы",

View File

@@ -5,6 +5,8 @@ import AuthLayout from '@/layouts/AuthLayout';
import ConsoleLayout from '@/layouts/ConsoleLayout';
import { ProtectedRoute, PublicRoute } from '@/components/shared/route-guards';
import LoginPage from '@/features/auth/LoginPage';
const ForgotPasswordPage = lazy(() => import('./features/auth/ForgotPasswordPage.tsx'));
const ResetPasswordPage = lazy(() => import('./features/auth/ResetPasswordPage.tsx'));
import DashboardPage from '@/features/dashboard/DashboardPage';
import InstancesPage from '@/features/instances/InstancesPage';
import InstanceDetailPage from '@/features/instances/InstanceDetailPage';
@@ -43,12 +45,15 @@ function SuspenseWrap({ children }: { children: React.ReactNode }) {
export const router = createBrowserRouter([
{
path: '/login',
element: <PublicRoute />,
children: [
{
element: <AuthLayout />,
children: [{ index: true, element: <LoginPage /> }],
children: [
{ path: '/login', element: <LoginPage /> },
{ path: '/forgot-password', element: <SuspenseWrap><ForgotPasswordPage /></SuspenseWrap> },
{ path: '/reset-password', element: <SuspenseWrap><ResetPasswordPage /></SuspenseWrap> },
],
},
],
},