update
This commit is contained in:
@@ -24,7 +24,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { OtpInput } from '@/components/ui/otp-input';
|
||||
import { ActiveDevice, apiFetch, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
|
||||
import { ActiveDevice, apiFetch, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
|
||||
@@ -56,8 +57,13 @@ const dialogConfig: Record<Exclude<DialogMode, null | 'totpSetup' | 'totpDisable
|
||||
backupPhone: { title: 'Резервный телефон', placeholder: '+79991234567', type: 'tel' }
|
||||
};
|
||||
|
||||
const passwordChangeHint =
|
||||
'Чтобы сменить или удалить пароль, подтвердите личность одним из доступных способов ниже.';
|
||||
|
||||
type PasswordVerifyMethod = 'current' | 'sms' | 'email' | 'totp';
|
||||
|
||||
export default function SecurityPage() {
|
||||
const { user, token } = useAuth();
|
||||
const { user, token, refreshProfile } = useAuth();
|
||||
const { isReady } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [devices, setDevices] = useState<ActiveDevice[]>([]);
|
||||
@@ -70,6 +76,17 @@ export default function SecurityPage() {
|
||||
const [totpEnabled, setTotpEnabled] = useState(false);
|
||||
const [totpSetup, setTotpSetup] = useState<TotpSetupResponse | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [passwordVerifyMethod, setPasswordVerifyMethod] = useState<PasswordVerifyMethod>('current');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [passwordOtpCode, setPasswordOtpCode] = useState('');
|
||||
const [passwordOtpSent, setPasswordOtpSent] = useState(false);
|
||||
const [passwordOtpMasked, setPasswordOtpMasked] = useState('');
|
||||
const [passwordTotpCode, setPasswordTotpCode] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [passwordOtpSending, setPasswordOtpSending] = useState(false);
|
||||
|
||||
const hasSmsContact = Boolean(user?.phone || user?.backupPhone);
|
||||
const hasEmailContact = Boolean(user?.email || user?.backupEmail);
|
||||
|
||||
const loadSecurity = useCallback(async () => {
|
||||
if (!user || !token) return;
|
||||
@@ -178,6 +195,108 @@ export default function SecurityPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildPasswordVerification() {
|
||||
if (passwordVerifyMethod === 'current') {
|
||||
return { currentPassword: currentPassword.trim() };
|
||||
}
|
||||
if (passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email') {
|
||||
return { otpCode: passwordOtpCode.trim(), otpChannel: passwordVerifyMethod };
|
||||
}
|
||||
return { totpCode: passwordTotpCode.trim() };
|
||||
}
|
||||
|
||||
function isPasswordVerificationReady() {
|
||||
if (passwordVerifyMethod === 'current') return currentPassword.trim().length > 0;
|
||||
if (passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email') {
|
||||
return passwordOtpSent && passwordOtpCode.trim().length === 6;
|
||||
}
|
||||
return passwordTotpCode.trim().length === 6;
|
||||
}
|
||||
|
||||
async function sendPasswordVerificationOtp() {
|
||||
if (!user || !token || (passwordVerifyMethod !== 'sms' && passwordVerifyMethod !== 'email')) return;
|
||||
setPasswordOtpSending(true);
|
||||
try {
|
||||
const response = await apiFetch<{ maskedTarget?: string }>(
|
||||
`/profile/users/${user.id}/password/otp`,
|
||||
{ method: 'POST', body: JSON.stringify({ channel: passwordVerifyMethod }) },
|
||||
token
|
||||
);
|
||||
setPasswordOtpSent(true);
|
||||
setPasswordOtpMasked(response.maskedTarget ?? '');
|
||||
setPasswordOtpCode('');
|
||||
showToast(`Код отправлен${response.maskedTarget ? ` на ${response.maskedTarget}` : ''}`);
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось отправить код') ?? 'Ошибка');
|
||||
} finally {
|
||||
setPasswordOtpSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPasswordChange() {
|
||||
if (!user || !token || !newPassword.trim()) {
|
||||
showToast('Укажите новый пароль');
|
||||
return;
|
||||
}
|
||||
if (!isPasswordVerificationReady()) {
|
||||
showToast('Подтвердите личность перед сменой пароля');
|
||||
return;
|
||||
}
|
||||
setIsDialogSaving(true);
|
||||
try {
|
||||
await apiFetch(
|
||||
`/profile/users/${user.id}/password`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ newPassword: newPassword.trim(), ...buildPasswordVerification() })
|
||||
},
|
||||
token
|
||||
);
|
||||
showToast('Пароль изменён');
|
||||
resetPasswordDialog();
|
||||
setDialogMode(null);
|
||||
await refreshProfile();
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось сменить пароль') ?? 'Ошибка');
|
||||
} finally {
|
||||
setIsDialogSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPasswordRemove() {
|
||||
if (!user || !token) return;
|
||||
if (!isPasswordVerificationReady()) {
|
||||
showToast('Подтвердите личность перед удалением пароля');
|
||||
return;
|
||||
}
|
||||
setIsDialogSaving(true);
|
||||
try {
|
||||
await apiFetch(
|
||||
`/profile/users/${user.id}/password`,
|
||||
{ method: 'DELETE', body: JSON.stringify(buildPasswordVerification()) },
|
||||
token
|
||||
);
|
||||
showToast('Пароль удалён');
|
||||
resetPasswordDialog();
|
||||
setDialogMode(null);
|
||||
await refreshProfile();
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось удалить пароль') ?? 'Ошибка');
|
||||
} finally {
|
||||
setIsDialogSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPasswordDialog() {
|
||||
setPasswordVerifyMethod('current');
|
||||
setCurrentPassword('');
|
||||
setPasswordOtpCode('');
|
||||
setPasswordOtpSent(false);
|
||||
setPasswordOtpMasked('');
|
||||
setPasswordTotpCode('');
|
||||
setNewPassword('');
|
||||
}
|
||||
|
||||
async function submitDialog() {
|
||||
if (!user || !token || !dialogMode) return;
|
||||
setIsDialogSaving(true);
|
||||
@@ -188,6 +307,7 @@ export default function SecurityPage() {
|
||||
} else if (dialogMode === 'password') {
|
||||
await apiFetch(`/profile/users/${user.id}/password`, { method: 'POST', body: JSON.stringify({ password: dialogValue }) }, token);
|
||||
showToast('Пароль установлен');
|
||||
await refreshProfile();
|
||||
} else if (dialogMode === 'backupEmail') {
|
||||
await apiFetch(`/profile/users/${user.id}/contacts`, { method: 'PATCH', body: JSON.stringify({ backupEmail: dialogValue.trim() }) }, token);
|
||||
showToast('Резервная почта добавлена');
|
||||
@@ -206,6 +326,9 @@ export default function SecurityPage() {
|
||||
|
||||
function openDialog(mode: Exclude<DialogMode, null>) {
|
||||
setDialogValue('');
|
||||
if (mode === 'password') {
|
||||
resetPasswordDialog();
|
||||
}
|
||||
setDialogMode(mode);
|
||||
}
|
||||
|
||||
@@ -280,7 +403,11 @@ export default function SecurityPage() {
|
||||
<KeyRound className="h-5 w-5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Пароль</p>
|
||||
<p className="text-sm text-[#667085]">{user?.email || user?.phone ? 'Добавить пароль как способ входа' : 'Установить пароль'}</p>
|
||||
<p className="text-sm text-[#667085]">
|
||||
{user?.hasPassword
|
||||
? 'Пароль установлен. Для смены или удаления потребуется подтверждение.'
|
||||
: 'Добавить пароль как способ входа'}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
|
||||
</button>
|
||||
@@ -335,6 +462,7 @@ export default function SecurityPage() {
|
||||
setTotpSetup(null);
|
||||
}
|
||||
setTotpCode('');
|
||||
resetPasswordDialog();
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
@@ -369,21 +497,133 @@ export default function SecurityPage() {
|
||||
{dialogMode && dialogMode !== 'totpSetup' && dialogMode !== 'totpDisable' ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogConfig[dialogMode].title}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{dialogMode === 'password' && user?.hasPassword ? 'Пароль установлен' : dialogConfig[dialogMode].title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4">
|
||||
<ShieldCheck className="h-5 w-5 text-[#667085]" />
|
||||
<Input
|
||||
className="border-0 bg-transparent focus:ring-0"
|
||||
type={dialogConfig[dialogMode].type}
|
||||
placeholder={dialogConfig[dialogMode].placeholder}
|
||||
value={dialogValue}
|
||||
onChange={(event) => setDialogValue(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="mt-5 w-full" onClick={submitDialog} disabled={isDialogSaving || !dialogValue.trim()}>
|
||||
{isDialogSaving ? 'Сохраняем...' : 'Сохранить'}
|
||||
</Button>
|
||||
{dialogMode === 'password' && user?.hasPassword ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm leading-relaxed text-[#667085]">{passwordChangeHint}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ id: 'current' as const, label: 'Текущий пароль' },
|
||||
...(hasSmsContact ? [{ id: 'sms' as const, label: 'SMS' }] : []),
|
||||
...(hasEmailContact ? [{ id: 'email' as const, label: 'Почта' }] : []),
|
||||
...(totpEnabled ? [{ id: 'totp' as const, label: 'Аутентификатор' }] : [])
|
||||
].map((method) => (
|
||||
<button
|
||||
key={method.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-full px-3 py-1.5 text-sm transition',
|
||||
passwordVerifyMethod === method.id
|
||||
? 'bg-[#111827] text-white'
|
||||
: 'bg-[#f4f5f8] text-[#667085] hover:bg-[#eceef4]'
|
||||
)}
|
||||
onClick={() => {
|
||||
setPasswordVerifyMethod(method.id);
|
||||
setPasswordOtpSent(false);
|
||||
setPasswordOtpCode('');
|
||||
setPasswordTotpCode('');
|
||||
setCurrentPassword('');
|
||||
}}
|
||||
>
|
||||
{method.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{passwordVerifyMethod === 'current' ? (
|
||||
<div className="rounded-2xl bg-[#f4f5f8] px-4">
|
||||
<Input
|
||||
className="border-0 bg-transparent focus-visible:ring-0"
|
||||
type="password"
|
||||
placeholder="Текущий пароль"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email' ? (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full rounded-xl"
|
||||
disabled={passwordOtpSending}
|
||||
onClick={() => void sendPasswordVerificationOtp()}
|
||||
>
|
||||
{passwordOtpSending ? 'Отправляем код...' : `Отправить код ${passwordVerifyMethod === 'sms' ? 'в SMS' : 'на почту'}`}
|
||||
</Button>
|
||||
{passwordOtpSent ? (
|
||||
<p className="text-center text-sm text-[#667085]">
|
||||
Код отправлен{passwordOtpMasked ? ` на ${passwordOtpMasked}` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
<OtpInput
|
||||
variant="light"
|
||||
value={passwordOtpCode}
|
||||
onChange={setPasswordOtpCode}
|
||||
disabled={!passwordOtpSent || isDialogSaving}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{passwordVerifyMethod === 'totp' ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-[#667085]">Введите код из приложения-аутентификатора</p>
|
||||
<OtpInput variant="light" value={passwordTotpCode} onChange={setPasswordTotpCode} disabled={isDialogSaving} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-2xl bg-[#f4f5f8] px-4">
|
||||
<Input
|
||||
className="border-0 bg-transparent focus-visible:ring-0"
|
||||
type="password"
|
||||
placeholder="Новый пароль (мин. 8 символов)"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<Button
|
||||
className="w-full rounded-xl"
|
||||
disabled={isDialogSaving || newPassword.trim().length < 8 || !isPasswordVerificationReady()}
|
||||
onClick={() => void submitPasswordChange()}
|
||||
>
|
||||
{isDialogSaving ? 'Сохраняем...' : 'Сохранить новый пароль'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full rounded-xl text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
disabled={isDialogSaving || !isPasswordVerificationReady()}
|
||||
onClick={() => void submitPasswordRemove()}
|
||||
>
|
||||
Удалить пароль
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4">
|
||||
<ShieldCheck className="h-5 w-5 text-[#667085]" />
|
||||
<Input
|
||||
className="border-0 bg-transparent focus:ring-0"
|
||||
type={dialogConfig[dialogMode].type}
|
||||
placeholder={dialogConfig[dialogMode].placeholder}
|
||||
value={dialogValue}
|
||||
onChange={(event) => setDialogValue(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="mt-5 w-full" onClick={submitDialog} disabled={isDialogSaving || !dialogValue.trim()}>
|
||||
{isDialogSaving ? 'Сохраняем...' : 'Сохранить'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user