more fix and update
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
@@ -24,7 +23,9 @@ import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ActiveDevice, apiFetch, SignInEvent } from '@/lib/api';
|
||||
import { OtpInput } from '@/components/ui/otp-input';
|
||||
import { ActiveDevice, apiFetch, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
|
||||
function formatDate(value: string) {
|
||||
@@ -46,9 +47,9 @@ const deviceIcons: Record<string, LucideIcon> = {
|
||||
OTHER: Laptop
|
||||
};
|
||||
|
||||
type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | null;
|
||||
type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | 'totpSetup' | 'totpDisable' | null;
|
||||
|
||||
const dialogConfig: Record<Exclude<DialogMode, null>, { title: string; placeholder: string; type: string }> = {
|
||||
const dialogConfig: Record<Exclude<DialogMode, null | 'totpSetup' | 'totpDisable'>, { title: string; placeholder: string; type: string }> = {
|
||||
pin: { title: 'Установить PIN-код', placeholder: 'PIN из 4-6 цифр', type: 'password' },
|
||||
password: { title: 'Установить пароль', placeholder: 'Новый пароль (мин. 8 символов)', type: 'password' },
|
||||
backupEmail: { title: 'Резервная почта', placeholder: 'backup@example.com', type: 'email' },
|
||||
@@ -56,8 +57,7 @@ const dialogConfig: Record<Exclude<DialogMode, null>, { title: string; placehold
|
||||
};
|
||||
|
||||
export default function SecurityPage() {
|
||||
const router = useRouter();
|
||||
const { user, token, logout } = useAuth();
|
||||
const { user, token } = useAuth();
|
||||
const { isReady } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [devices, setDevices] = useState<ActiveDevice[]>([]);
|
||||
@@ -67,17 +67,22 @@ export default function SecurityPage() {
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||
const [dialogValue, setDialogValue] = useState('');
|
||||
const [isDialogSaving, setIsDialogSaving] = useState(false);
|
||||
const [totpEnabled, setTotpEnabled] = useState(false);
|
||||
const [totpSetup, setTotpSetup] = useState<TotpSetupResponse | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
|
||||
const loadSecurity = useCallback(async () => {
|
||||
if (!user || !token) return;
|
||||
setIsSecurityLoading(true);
|
||||
try {
|
||||
const [devicesResponse, historyResponse] = await Promise.all([
|
||||
const [devicesResponse, historyResponse, totpResponse] = await Promise.all([
|
||||
apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token),
|
||||
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token)
|
||||
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token),
|
||||
apiFetch<TotpStatusResponse>(`/security/users/${user.id}/totp/status`, {}, token)
|
||||
]);
|
||||
setDevices(devicesResponse.devices ?? []);
|
||||
setHistory(historyResponse.events ?? []);
|
||||
setTotpEnabled(Boolean(totpResponse.isEnabled));
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность');
|
||||
} finally {
|
||||
@@ -106,8 +111,8 @@ export default function SecurityPage() {
|
||||
setIsRevoking(true);
|
||||
try {
|
||||
await apiFetch(`/security/users/${user.id}/revoke-all-sessions`, { method: 'POST' }, token);
|
||||
showToast('Все сессии завершены');
|
||||
logout();
|
||||
showToast('Выход выполнен на всех остальных устройствах');
|
||||
await loadSecurity();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось выйти везде');
|
||||
} finally {
|
||||
@@ -115,6 +120,64 @@ export default function SecurityPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openTotpDialog() {
|
||||
if (!user || !token) return;
|
||||
if (totpEnabled) {
|
||||
setTotpCode('');
|
||||
setDialogMode('totpDisable');
|
||||
return;
|
||||
}
|
||||
if (totpSetup) {
|
||||
setTotpCode('');
|
||||
setDialogMode('totpSetup');
|
||||
return;
|
||||
}
|
||||
setIsDialogSaving(true);
|
||||
try {
|
||||
const setup = await apiFetch<TotpSetupResponse>(`/security/users/${user.id}/totp/setup`, { method: 'POST' }, token);
|
||||
setTotpSetup(setup);
|
||||
setTotpCode('');
|
||||
setDialogMode('totpSetup');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось начать настройку аутентификатора');
|
||||
} finally {
|
||||
setIsDialogSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTotpSetup(code: string) {
|
||||
if (!user || !token) return;
|
||||
setIsDialogSaving(true);
|
||||
try {
|
||||
await apiFetch(`/security/users/${user.id}/totp/enable`, { method: 'POST', body: JSON.stringify({ code }) }, token);
|
||||
setTotpEnabled(true);
|
||||
setDialogMode(null);
|
||||
setTotpSetup(null);
|
||||
setTotpCode('');
|
||||
showToast('Приложение-аутентификатор подключено');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось подтвердить код');
|
||||
} finally {
|
||||
setIsDialogSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTotpDisable(code: string) {
|
||||
if (!user || !token) return;
|
||||
setIsDialogSaving(true);
|
||||
try {
|
||||
await apiFetch(`/security/users/${user.id}/totp/disable`, { method: 'POST', body: JSON.stringify({ code }) }, token);
|
||||
setTotpEnabled(false);
|
||||
setDialogMode(null);
|
||||
setTotpCode('');
|
||||
showToast('Приложение-аутентификатор отключено');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось отключить аутентификатор');
|
||||
} finally {
|
||||
setIsDialogSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDialog() {
|
||||
if (!user || !token || !dialogMode) return;
|
||||
setIsDialogSaving(true);
|
||||
@@ -183,8 +246,25 @@ export default function SecurityPage() {
|
||||
<LogOut className="h-6 w-6" />
|
||||
<span>{isRevoking ? 'Выходим...' : 'Выйти везде'}</span>
|
||||
</button>
|
||||
<p className="text-sm text-[#667085]">Завершит сессии на всех устройствах, кроме текущего. Выход с этого устройства — через меню профиля.</p>
|
||||
</div>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Дополнительная защита</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
<button type="button" onClick={() => void openTotpDialog()} disabled={isDialogSaving} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6] disabled:opacity-60">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Приложение-аутентификатор</p>
|
||||
<p className="text-sm text-[#667085]">
|
||||
{totpEnabled ? 'Google Authenticator и аналоги подключены' : 'Подключите OTP-коды через Google Authenticator'}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Способ входа в профиль</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
@@ -248,9 +328,45 @@ export default function SecurityPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(open) => (open ? null : setDialogMode(null))}>
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDialogMode(null);
|
||||
if (dialogMode !== 'totpSetup' || totpEnabled) {
|
||||
setTotpSetup(null);
|
||||
}
|
||||
setTotpCode('');
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
{dialogMode ? (
|
||||
{dialogMode === 'totpSetup' && totpSetup ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Подключить аутентификатор</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-[#667085]">Отсканируйте QR-код в Google Authenticator или аналоге, затем введите 6-значный код.</p>
|
||||
<div className="flex justify-center rounded-2xl bg-white p-4">
|
||||
<QRCodeSVG value={totpSetup.otpauthUrl} size={180} />
|
||||
</div>
|
||||
<p className="break-all rounded-2xl bg-[#f4f5f8] px-4 py-3 text-sm text-[#667085]">
|
||||
Ключ: <span className="font-mono text-[#111827]">{totpSetup.secret}</span>
|
||||
</p>
|
||||
<OtpInput variant="light" value={totpCode} onChange={setTotpCode} onComplete={(code) => void submitTotpSetup(code)} disabled={isDialogSaving} />
|
||||
{isDialogSaving ? <p className="text-center text-sm text-[#667085]">Проверяем код...</p> : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{dialogMode === 'totpDisable' ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Отключить аутентификатор</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-[#667085]">Введите код из приложения, чтобы отключить двухфакторную аутентификацию.</p>
|
||||
<OtpInput variant="light" value={totpCode} onChange={setTotpCode} onComplete={(code) => void submitTotpDisable(code)} disabled={isDialogSaving} />
|
||||
{isDialogSaving ? <p className="text-center text-sm text-[#667085]">Проверяем код...</p> : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{dialogMode && dialogMode !== 'totpSetup' && dialogMode !== 'totpDisable' ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogConfig[dialogMode].title}</DialogTitle>
|
||||
|
||||
Reference in New Issue
Block a user