feat(auth): forgot/reset password pages and wiring
- 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:
104
frontend/src/features/auth/ForgotPasswordPage.tsx
Normal file
104
frontend/src/features/auth/ForgotPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
181
frontend/src/features/auth/ResetPasswordPage.tsx
Normal file
181
frontend/src/features/auth/ResetPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Узлы",
|
||||
|
||||
@@ -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> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user