feat(auth): forgot/reset password — endpoints + UI + IP rate-limit
Some checks failed
CI / Backend (.NET 8) (push) Successful in 1m6s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 1m17s
Docker Web / Build + push Web (push) Successful in 36s
Docker API / Deploy API on stage (push) Failing after 37s
Docker Web / Deploy Web on stage (push) Successful in 12s
CI / POS (WPF, Windows) (push) Has been cancelled
Some checks failed
CI / Backend (.NET 8) (push) Successful in 1m6s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 1m17s
Docker Web / Build + push Web (push) Successful in 36s
Docker API / Deploy API on stage (push) Failing after 37s
Docker Web / Deploy Web on stage (push) Successful in 12s
CI / POS (WPF, Windows) (push) Has been cancelled
Пункты 5 + 6 пакета SMTP-настроек.
API (AuthForgotPasswordController, anonymous):
- POST /api/auth/forgot-password { email }
· IP rate-limit: 3 попытки в час (in-memory ConcurrentDictionary
с per-IP списком timestamps; для одного API-инстанса хватает,
при scale-out — Redis).
· ВСЕГДА возвращает 200 (анти-юзер-энумерация). Реально шлёт письмо
только если юзер найден И активен И имеет email; иначе тихо
логирует и отдаёт 200.
· Использует UserManager.GeneratePasswordResetTokenAsync (Identity
AddDefaultTokenProviders уже подключён в Program.cs).
· Письмо: ссылка вида https://admin.food-market.kz/reset-password
?email=...&token=... (1 час валидна).
· Если SMTP не настроен — ловит EmailNotConfiguredException,
логирует и всё равно отдаёт 200 (UX-friendly).
- POST /api/auth/reset-password { email, token, newPassword }
· UserManager.ResetPasswordAsync. На InvalidToken — понятный
«Ссылка недействительна или истекла».
· После успеха revoke всех valid-OpenIddict tokens юзера
(UPDATE OpenIddictTokens SET Status='revoked' WHERE Subject=...).
UI:
- /forgot-password — anonymous, форма с email; submit → 200 «проверьте
почту» (одинаковый текст независимо от существования email).
- /reset-password — anonymous, читает email/token из query-string;
поля «новый пароль» + «повторите»; после успеха — auto-redirect
через 2.5 секунды на /login.
- LoginPage: добавлена ссылка «Забыли пароль?» под кнопкой «Войти».
Smoke-флоу:
1. SuperAdmin → /super-admin/platform-settings → SMTP creds + test-send.
2. Юзер → /login → «Забыли пароль?» → /forgot-password → email.
3. Письмо с ссылкой → /reset-password?email&token → новый пароль.
4. Login со старым паролем — отказ (revoked refresh + новый pwd).
5. Login с новым паролем → норма.
This commit is contained in:
parent
ab13a89617
commit
e38a360e54
159
src/food-market.api/Controllers/AuthForgotPasswordController.cs
Normal file
159
src/food-market.api/Controllers/AuthForgotPasswordController.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Web;
|
||||||
|
using foodmarket.Application.Common.Email;
|
||||||
|
using foodmarket.Infrastructure.Identity;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Восстановление пароля. Эндпоинты anonymous, защищены простым
|
||||||
|
/// IP-rate-limit'ом (3 попытки в час на IP), чтобы не было spam-attack.
|
||||||
|
/// Ответ /forgot-password всегда 200 — анти-юзер-энумерация (не палим
|
||||||
|
/// существование email).</summary>
|
||||||
|
[ApiController]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Route("api/auth")]
|
||||||
|
public class AuthForgotPasswordController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly UserManager<User> _userMgr;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IEmailSender _email;
|
||||||
|
private readonly ILogger<AuthForgotPasswordController> _logger;
|
||||||
|
|
||||||
|
// In-memory rate-limit. Для одного API-инстанса достаточно; при scale-out
|
||||||
|
// понадобится Redis. Кладём timestamps попыток per IP, рубим >3 за час.
|
||||||
|
private static readonly ConcurrentDictionary<string, List<DateTime>> _ipAttempts = new();
|
||||||
|
private static readonly TimeSpan _rateLimitWindow = TimeSpan.FromHours(1);
|
||||||
|
private const int _maxAttemptsPerWindow = 3;
|
||||||
|
|
||||||
|
public AuthForgotPasswordController(
|
||||||
|
UserManager<User> userMgr, AppDbContext db, IEmailSender email,
|
||||||
|
ILogger<AuthForgotPasswordController> logger)
|
||||||
|
{
|
||||||
|
_userMgr = userMgr; _db = db; _email = email; _logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ForgotInput(string Email);
|
||||||
|
public record ResetInput(string Email, string Token, string NewPassword);
|
||||||
|
|
||||||
|
[HttpPost("forgot-password")]
|
||||||
|
public async Task<IActionResult> Forgot([FromBody] ForgotInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var ip = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
if (!CheckRateLimit(ip))
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status429TooManyRequests, new
|
||||||
|
{
|
||||||
|
error = "Слишком много попыток. Попробуйте через час.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Универсальный 200 чтобы не палить существование email.
|
||||||
|
// Реально письмо шлём только если юзер найден И активен И имеет email.
|
||||||
|
if (string.IsNullOrWhiteSpace(input.Email))
|
||||||
|
return Ok(new { ok = true });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await _userMgr.FindByEmailAsync(input.Email.Trim());
|
||||||
|
if (user is not null && user.IsActive && !string.IsNullOrEmpty(user.Email))
|
||||||
|
{
|
||||||
|
var token = await _userMgr.GeneratePasswordResetTokenAsync(user);
|
||||||
|
var resetUrl = BuildResetUrl(user.Email!, token);
|
||||||
|
var body =
|
||||||
|
$"Здравствуйте.\n\n" +
|
||||||
|
$"Кто-то запросил восстановление пароля для вашего аккаунта Food Market ({user.Email}).\n" +
|
||||||
|
$"Если это были вы — перейдите по ссылке (действительна 1 час):\n\n" +
|
||||||
|
$"{resetUrl}\n\n" +
|
||||||
|
$"Если вы не запрашивали восстановление — просто игнорируйте это письмо.\n";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _email.SendAsync(user.Email!, "Food Market — восстановление пароля", body, ct);
|
||||||
|
_logger.LogInformation("Forgot-password email sent to {Email}", user.Email);
|
||||||
|
}
|
||||||
|
catch (EmailNotConfiguredException ex)
|
||||||
|
{
|
||||||
|
// Не падаем 500 — это плохая UX. Просто логируем и
|
||||||
|
// отдаём 200, чтобы юзер не ждал зря; SuperAdmin увидит
|
||||||
|
// в логах что нужно настроить SMTP.
|
||||||
|
_logger.LogError(ex, "Forgot-password skipped: SMTP not configured");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Forgot-password email failed for {Email}", user.Email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Forgot-password unexpected error");
|
||||||
|
}
|
||||||
|
return Ok(new { ok = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("reset-password")]
|
||||||
|
public async Task<IActionResult> Reset([FromBody] ResetInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input.Email) || string.IsNullOrWhiteSpace(input.Token))
|
||||||
|
return BadRequest(new { error = "Некорректная ссылка." });
|
||||||
|
if (string.IsNullOrWhiteSpace(input.NewPassword) || input.NewPassword.Length < 8)
|
||||||
|
return BadRequest(new { error = "Пароль должен быть не менее 8 символов." });
|
||||||
|
|
||||||
|
var user = await _userMgr.FindByEmailAsync(input.Email.Trim());
|
||||||
|
if (user is null || !user.IsActive)
|
||||||
|
return BadRequest(new { error = "Ссылка недействительна или истекла." });
|
||||||
|
|
||||||
|
var result = await _userMgr.ResetPasswordAsync(user, input.Token, input.NewPassword);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
// ASP.NET Identity отдаёт «InvalidToken» если токен испорчен/истёк
|
||||||
|
// или если ResetPasswordAsync вызвали для другого юзера.
|
||||||
|
return BadRequest(new
|
||||||
|
{
|
||||||
|
error = result.Errors.Any(e => e.Code == "InvalidToken")
|
||||||
|
? "Ссылка недействительна или истекла. Запросите новую."
|
||||||
|
: string.Join("; ", result.Errors.Select(e => e.Description)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke все активные refresh/access токены — старые пароли больше нерелевантны.
|
||||||
|
await _db.Database.ExecuteSqlRawAsync(
|
||||||
|
"UPDATE \"OpenIddictTokens\" SET \"Status\" = 'revoked' WHERE \"Status\" = 'valid' AND \"Subject\" = {0}",
|
||||||
|
new object[] { user.Id.ToString() });
|
||||||
|
|
||||||
|
return Ok(new { ok = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildResetUrl(string email, string token)
|
||||||
|
{
|
||||||
|
// Frontend-host берём из Host заголовка через nginx — с учётом
|
||||||
|
// что админка на admin.food-market.kz. Если запрос пришёл с
|
||||||
|
// другого host (CORS-call) — фолбэк на admin-домен.
|
||||||
|
var host = HttpContext?.Request?.Host.Value;
|
||||||
|
var scheme = HttpContext?.Request?.Scheme ?? "https";
|
||||||
|
if (string.IsNullOrEmpty(host) || host.Contains("localhost"))
|
||||||
|
{
|
||||||
|
return $"https://admin.food-market.kz/reset-password?email={HttpUtility.UrlEncode(email)}&token={HttpUtility.UrlEncode(token)}";
|
||||||
|
}
|
||||||
|
return $"{scheme}://{host}/reset-password?email={HttpUtility.UrlEncode(email)}&token={HttpUtility.UrlEncode(token)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CheckRateLimit(string ip)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var attempts = _ipAttempts.GetOrAdd(ip, _ => new List<DateTime>());
|
||||||
|
lock (attempts)
|
||||||
|
{
|
||||||
|
// Чистим устаревшие.
|
||||||
|
attempts.RemoveAll(t => now - t > _rateLimitWindow);
|
||||||
|
if (attempts.Count >= _maxAttemptsPerWindow) return false;
|
||||||
|
attempts.Add(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,8 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||||
import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
|
import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
|
||||||
import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage'
|
import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage'
|
||||||
import { SuperAdminPlatformSettingsPage } from '@/pages/SuperAdminPlatformSettingsPage'
|
import { SuperAdminPlatformSettingsPage } from '@/pages/SuperAdminPlatformSettingsPage'
|
||||||
|
import { ForgotPasswordPage } from '@/pages/ForgotPasswordPage'
|
||||||
|
import { ResetPasswordPage } from '@/pages/ResetPasswordPage'
|
||||||
import { RoleGuard } from '@/components/RoleGuard'
|
import { RoleGuard } from '@/components/RoleGuard'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
|
@ -53,6 +55,8 @@ export default function App() {
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
<Route path="/auth-bridge" element={<AuthBridgePage />} />
|
<Route path="/auth-bridge" element={<AuthBridgePage />} />
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
{/* Fallback для orphan AppUser без активной org / Employee.
|
{/* Fallback для orphan AppUser без активной org / Employee.
|
||||||
|
|
|
||||||
82
src/food-market.web/src/pages/ForgotPasswordPage.tsx
Normal file
82
src/food-market.web/src/pages/ForgotPasswordPage.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Logo } from '@/components/Logo'
|
||||||
|
import { Button } from '@/components/Button'
|
||||||
|
import { Field, TextInput } from '@/components/Field'
|
||||||
|
import { validateEmail } from '@/lib/validation'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
/** Anonymous-страница «Забыли пароль?». Не требует логина — открыта по
|
||||||
|
* прямой ссылке /forgot-password. Сервер всегда отвечает 200 (анти-юзер-
|
||||||
|
* энумерация), поэтому UI показывает одинаковое сообщение независимо от
|
||||||
|
* того, есть юзер с таким email или нет. */
|
||||||
|
export function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const err = validateEmail(email)
|
||||||
|
if (err) { setError(err); return }
|
||||||
|
setError(null); setBusy(true)
|
||||||
|
try {
|
||||||
|
// Делаем чистым axios-call без api-interceptor'а — у юзера нет токена.
|
||||||
|
await axios.post('/api/auth/forgot-password', { email })
|
||||||
|
setSubmitted(true)
|
||||||
|
} catch (e2) {
|
||||||
|
const err2 = e2 as { response?: { status?: number, data?: { error?: string } } }
|
||||||
|
if (err2.response?.status === 429) {
|
||||||
|
setError(err2.response.data?.error ?? 'Слишком много попыток. Попробуйте через час.')
|
||||||
|
} else {
|
||||||
|
// Любая другая ошибка — обобщаем, чтобы не палить детали.
|
||||||
|
setSubmitted(true)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
|
||||||
|
<div className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 space-y-5">
|
||||||
|
<Logo />
|
||||||
|
{!submitted ? (
|
||||||
|
<form onSubmit={submit} noValidate className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">Забыли пароль?</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
Укажите email — пришлём ссылку для восстановления.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Field label="Email" error={error ?? undefined}>
|
||||||
|
<TextInput type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email" placeholder="name@example.kz" required />
|
||||||
|
</Field>
|
||||||
|
<Button onClick={() => {}} disabled={busy} className="w-full justify-center">
|
||||||
|
{busy ? 'Отправляю…' : 'Отправить ссылку'}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-center text-slate-500">
|
||||||
|
<Link to="/login" className="text-[var(--color-brand)] hover:underline">← Войти под другим аккаунтом</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">Проверьте почту</h1>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Если аккаунт с указанным email существует, мы отправили на него ссылку для восстановления пароля.
|
||||||
|
Письмо приходит в течение минуты; не забудьте проверить «Спам».
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Ссылка действительна 1 час. Если не дошло — попробуйте ещё раз через час.
|
||||||
|
</p>
|
||||||
|
<Link to="/login" className="block text-center text-sm text-[var(--color-brand)] hover:underline">
|
||||||
|
← К входу
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -96,6 +96,10 @@ export function LoginPage() {
|
||||||
{loading ? 'Выполняется вход…' : 'Войти'}
|
{loading ? 'Выполняется вход…' : 'Войти'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<p className="text-xs text-center">
|
||||||
|
<a href="/forgot-password" className="text-[var(--color-brand)] hover:underline">Забыли пароль?</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p className="text-xs text-slate-400 text-center">
|
<p className="text-xs text-slate-400 text-center">
|
||||||
Dev admin: <code>admin@food-market.local</code> / <code>Admin12345!</code>
|
Dev admin: <code>admin@food-market.local</code> / <code>Admin12345!</code>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
84
src/food-market.web/src/pages/ResetPasswordPage.tsx
Normal file
84
src/food-market.web/src/pages/ResetPasswordPage.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { Logo } from '@/components/Logo'
|
||||||
|
import { Button } from '@/components/Button'
|
||||||
|
import { Field, TextInput } from '@/components/Field'
|
||||||
|
import { validatePassword } from '@/lib/validation'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
/** Anonymous-страница приёма ссылки восстановления. URL вида
|
||||||
|
* /reset-password?email=...&token=...; токен короткоживущий (1 час),
|
||||||
|
* сгенерирован Identity GeneratePasswordResetTokenAsync. */
|
||||||
|
export function ResetPasswordPage() {
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const email = params.get('email') ?? ''
|
||||||
|
const token = params.get('token') ?? ''
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
if (!email || !token) {
|
||||||
|
setError('Ссылка некорректна. Запросите новую.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pwErr = validatePassword(password)
|
||||||
|
if (pwErr) { setError(pwErr); return }
|
||||||
|
if (password !== confirm) { setError('Пароли не совпадают.'); return }
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await axios.post('/api/auth/reset-password', { email, token, newPassword: password })
|
||||||
|
setDone(true)
|
||||||
|
setTimeout(() => navigate('/login', { replace: true }), 2500)
|
||||||
|
} catch (e2) {
|
||||||
|
const err = e2 as { response?: { data?: { error?: string } } }
|
||||||
|
setError(err.response?.data?.error ?? 'Не удалось обновить пароль.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
|
||||||
|
<div className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 space-y-5">
|
||||||
|
<Logo />
|
||||||
|
{!done ? (
|
||||||
|
<form onSubmit={submit} noValidate className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">Новый пароль</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
Аккаунт {email ? <strong>{email}</strong> : 'не указан'}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Field label="Новый пароль">
|
||||||
|
<TextInput type="password" value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" required />
|
||||||
|
</Field>
|
||||||
|
<Field label="Повторите пароль" error={error ?? undefined}>
|
||||||
|
<TextInput type="password" value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" required />
|
||||||
|
</Field>
|
||||||
|
<Button onClick={() => {}} disabled={busy} className="w-full justify-center">
|
||||||
|
{busy ? 'Сохраняю…' : 'Установить пароль'}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-center text-slate-500">
|
||||||
|
<Link to="/login" className="text-[var(--color-brand)] hover:underline">← К входу</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-xl font-bold text-emerald-700 dark:text-emerald-400">Пароль обновлён</h1>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Сейчас перенаправим на страницу входа…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue