This commit is contained in:
2026-05-19 15:10:42 +03:00
parent 22ceb8e2b4
commit 36196a882f
10 changed files with 234 additions and 1 deletions

View File

@@ -9,6 +9,7 @@ import { RegisterPage } from '@pages/register'
import { ConverterPage } from '@pages/converter' import { ConverterPage } from '@pages/converter'
import { SeedPhrasePage } from '@pages/seed-phrase' import { SeedPhrasePage } from '@pages/seed-phrase'
import { KycPage } from '@pages/kyc' import { KycPage } from '@pages/kyc'
import { RestorePasswordPage } from '@pages/restore-password'
import { ROUTES } from '@shared/config/routes' import { ROUTES } from '@shared/config/routes'
import { ScrollToTop } from './ScrollToTop' import { ScrollToTop } from './ScrollToTop'
import { ProtectedRoute } from './ProtectedRoute' import { ProtectedRoute } from './ProtectedRoute'
@@ -24,6 +25,7 @@ export function RouterProvider() {
<Route element={<GuestRoute />}> <Route element={<GuestRoute />}>
<Route path={ROUTES.LOGIN} element={<LoginPage />} /> <Route path={ROUTES.LOGIN} element={<LoginPage />} />
<Route path={ROUTES.REGISTER} element={<RegisterPage />} /> <Route path={ROUTES.REGISTER} element={<RegisterPage />} />
<Route path={ROUTES.RESTORE_PASSWORD} element={<RestorePasswordPage />} />
</Route> </Route>
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>

View File

@@ -68,3 +68,41 @@ export async function uploadAvatar(payload: UploadAvatarPayload): Promise<MeResp
if (!res.ok) throw data if (!res.ok) throw data
return data return data
} }
export async function passwordResetStart(): Promise<void> {
const csrf = await getCsrfToken()
const res = await fetch(`${USERS_API_URL}/me/settings/password/start`, {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRF-Token': csrf,
},
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw data
}
}
export interface PasswordResetCompletePayload {
code: string
new_password: string
confirm_password: string
}
export async function passwordResetComplete(payload: PasswordResetCompletePayload): Promise<void> {
const csrf = await getCsrfToken()
const res = await fetch(`${USERS_API_URL}/me/settings/password/complete`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
},
body: JSON.stringify(payload),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw data
}
}

View File

@@ -0,0 +1 @@
export { RestorePasswordPage } from './ui/RestorePasswordPage'

View File

@@ -0,0 +1,7 @@
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem 0;
}

View File

@@ -0,0 +1,10 @@
import { RestorePasswordForm } from '@widgets/restore-password-form'
import styles from './RestorePasswordPage.module.css'
export function RestorePasswordPage() {
return (
<div className={styles.page}>
<RestorePasswordForm />
</div>
)
}

View File

@@ -10,4 +10,5 @@ export const ROUTES = {
SEED_PHRASE: '/seed-phrase', SEED_PHRASE: '/seed-phrase',
CONVERTER: '/converter', CONVERTER: '/converter',
KYC: '/kyc', KYC: '/kyc',
RESTORE_PASSWORD: '/restore-password',
} as const } as const

View File

@@ -76,7 +76,7 @@ export function LoginForm() {
</div> </div>
<div className={styles.footer}> <div className={styles.footer}>
<a className={styles.forgot}>Забыли пароль?</a> <a className={styles.forgot} onClick={() => navigate(ROUTES.RESTORE_PASSWORD)}>Забыли пароль?</a>
<div className={styles.divider}><span>или</span></div> <div className={styles.divider}><span>или</span></div>
<Button variant="outline" type="button" onClick={() => navigate(ROUTES.REGISTER)}> <Button variant="outline" type="button" onClick={() => navigate(ROUTES.REGISTER)}>
Создать новый кошелёк Создать новый кошелёк

View File

@@ -0,0 +1 @@
export { RestorePasswordForm } from './ui/RestorePasswordForm'

View File

@@ -0,0 +1,69 @@
.card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 32px;
width: 100%;
max-width: 480px;
}
.logo {
display: flex;
justify-content: center;
margin-bottom: 28px;
}
.logo img {
height: 40px;
}
.title {
text-align: center;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 24px;
line-height: 1.3;
}
.fields {
display: flex;
flex-direction: column;
gap: 20px;
}
.error {
color: #ff5a5a;
font-size: 13px;
margin-top: 12px;
text-align: center;
}
.submitWrapper {
margin-top: 28px;
}
.footer {
margin-top: 16px;
display: flex;
justify-content: center;
}
.back {
font-size: 13px;
color: var(--interactive);
cursor: pointer;
text-decoration: none;
transition: color 0.2s;
}
.back:hover {
color: var(--highlight);
}
@media (max-width: 560px) {
.card {
padding: 32px 20px;
border-radius: 0;
}
}

View File

@@ -0,0 +1,104 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { FormField } from '@shared/ui'
import { PrimaryButton } from '@shared/ui'
import { Notification } from '@shared/ui'
import { ROUTES } from '@shared/config/routes'
import { passwordResetStart, passwordResetComplete } from '@features/auth/api/profileApi'
import logo from '@shared/assets/logo-full-white.png'
import styles from './RestorePasswordForm.module.css'
type NotificationState = { message: string; status: 'success' | 'error' }
export function RestorePasswordForm() {
const [code, setCode] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [notification, setNotification] = useState<NotificationState | null>(null)
const navigate = useNavigate()
useEffect(() => {
passwordResetStart()
.then(() => setNotification({ status: 'success', message: 'На вашу почту отправлено письмо с кодом' }))
.catch(() => setNotification({ status: 'error', message: 'Не удалось отправить письмо. Попробуйте позже.' }))
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (newPassword !== confirmPassword) {
setError('Пароли не совпадают')
return
}
setIsLoading(true)
try {
await passwordResetComplete({ code, new_password: newPassword, confirm_password: confirmPassword })
navigate(ROUTES.LOGIN)
} catch {
setError('Не удалось изменить пароль. Проверьте код и попробуйте снова.')
} finally {
setIsLoading(false)
}
}
return (
<>
<form className={styles.card} onSubmit={handleSubmit}>
<div className={styles.logo}>
<img src={logo} alt="ЭКСА" />
</div>
<h1 className={styles.title}>Восстановление пароля</h1>
<div className={styles.fields}>
<FormField
label="Код с почты"
type="text"
value={code}
onChange={setCode}
placeholder="000 000"
required
/>
<FormField
label="Новый пароль"
type="password"
value={newPassword}
onChange={setNewPassword}
placeholder="••••••••"
required
/>
<FormField
label="Повторить пароль"
type="password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="••••••••"
required
/>
</div>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.submitWrapper}>
<PrimaryButton label={isLoading ? 'Сохранение...' : 'Сохранить пароль'} disabled={isLoading} />
</div>
<div className={styles.footer}>
<a className={styles.back} onClick={() => navigate(ROUTES.LOGIN)}>
Вернуться ко входу
</a>
</div>
</form>
{notification && (
<Notification
status={notification.status}
message={notification.message}
onClose={() => setNotification(null)}
/>
)}
</>
)
}