fix and update
This commit is contained in:
@@ -268,9 +268,7 @@ export default function LoginPage() {
|
||||
|
||||
try {
|
||||
|
||||
const deviceName = navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser';
|
||||
|
||||
const session = await createQrLoginSession(deviceName);
|
||||
const session = await createQrLoginSession();
|
||||
|
||||
setQrSessionId(session.sessionId);
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
import { apiFetch, FamilyGroup, fetchFamilyGroups, getApiErrorMessage } from '@/lib/api';
|
||||
import { apiFetch, FamilyGroup, fetchFamilyGroups, getAccessToken, getApiErrorMessage } from '@/lib/api';
|
||||
import { defaultFamilyGroupName } from '@/lib/family-defaults';
|
||||
|
||||
export default function FamilyPage() {
|
||||
const router = useRouter();
|
||||
const { user, token } = useAuth();
|
||||
const { isReady, isPinLocked } = useRequireAuth();
|
||||
const { user, token, isLoading, isPinLocked } = useAuth();
|
||||
const { isReady } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [groups, setGroups] = useState<FamilyGroup[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
@@ -28,23 +28,25 @@ export default function FamilyPage() {
|
||||
}, [user?.displayName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
fetchFamilyGroups(user.id, token)
|
||||
const accessToken = getAccessToken() ?? token?.trim() ?? null;
|
||||
if (!user || !accessToken || isPinLocked || isLoading) return;
|
||||
fetchFamilyGroups(user.id, accessToken)
|
||||
.then((response) => setGroups(response.groups ?? []))
|
||||
.catch((error) => {
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить семью');
|
||||
if (message) showToast(message);
|
||||
});
|
||||
}, [isPinLocked, showToast, token, user]);
|
||||
}, [isLoading, isPinLocked, showToast, token, user]);
|
||||
|
||||
async function createGroup() {
|
||||
if (!user || !token) return;
|
||||
const accessToken = getAccessToken() ?? token?.trim() ?? null;
|
||||
if (!user || !accessToken) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const group = await apiFetch<FamilyGroup>('/family/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ownerId: user.id, name })
|
||||
}, token);
|
||||
}, accessToken);
|
||||
router.push(`/family/${group.id}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось создать семью');
|
||||
@@ -54,7 +56,7 @@ export default function FamilyPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isReady) {
|
||||
if (!isReady || isLoading) {
|
||||
return (
|
||||
<IdShell active="/family">
|
||||
<div className="py-20 text-center text-[#667085]">Загрузка...</div>
|
||||
|
||||
@@ -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, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
|
||||
import { ActiveDevice, ActiveSession, apiFetch, AUTH_SESSION_KEY, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
|
||||
import { formatUserAgentLabel } from '@/lib/device-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
@@ -67,7 +68,9 @@ export default function SecurityPage() {
|
||||
const { isReady } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [devices, setDevices] = useState<ActiveDevice[]>([]);
|
||||
const [sessions, setSessions] = useState<ActiveSession[]>([]);
|
||||
const [history, setHistory] = useState<SignInEvent[]>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [isSecurityLoading, setIsSecurityLoading] = useState(true);
|
||||
const [isRevoking, setIsRevoking] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||
@@ -92,13 +95,16 @@ export default function SecurityPage() {
|
||||
if (!user || !token) return;
|
||||
setIsSecurityLoading(true);
|
||||
try {
|
||||
const [devicesResponse, historyResponse, totpResponse] = await Promise.all([
|
||||
const [devicesResponse, sessionsResponse, historyResponse, totpResponse] = await Promise.all([
|
||||
apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token),
|
||||
apiFetch<{ sessions: ActiveSession[] }>(`/security/users/${user.id}/sessions`, {}, token),
|
||||
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token),
|
||||
apiFetch<TotpStatusResponse>(`/security/users/${user.id}/totp/status`, {}, token)
|
||||
]);
|
||||
setDevices(devicesResponse.devices ?? []);
|
||||
setSessions(sessionsResponse.sessions ?? []);
|
||||
setHistory(historyResponse.events ?? []);
|
||||
setCurrentSessionId(window.localStorage.getItem(AUTH_SESSION_KEY));
|
||||
setTotpEnabled(Boolean(totpResponse.isEnabled));
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность');
|
||||
@@ -112,6 +118,31 @@ export default function SecurityPage() {
|
||||
void loadSecurity();
|
||||
}, [isReady, loadSecurity, user]);
|
||||
|
||||
async function revokeSession(sessionId: string) {
|
||||
if (!user || !token) return;
|
||||
try {
|
||||
await apiFetch(`/security/users/${user.id}/sessions/${sessionId}/revoke`, { method: 'POST' }, token);
|
||||
setSessions((current) => current.filter((session) => session.id !== sessionId));
|
||||
showToast('Сессия завершена');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось завершить сессию');
|
||||
}
|
||||
}
|
||||
|
||||
function sessionDeviceLabel(session: ActiveSession) {
|
||||
return session.deviceName ?? formatUserAgentLabel(session.userAgent) ?? 'Неизвестное устройство';
|
||||
}
|
||||
|
||||
function sessionMetaLine(session: ActiveSession) {
|
||||
const parts = [session.ipAddress ?? 'IP не сохранён'];
|
||||
const agentLabel = formatUserAgentLabel(session.userAgent);
|
||||
if (agentLabel && agentLabel !== session.deviceName) {
|
||||
parts.push(agentLabel);
|
||||
}
|
||||
parts.push(formatDate(session.updatedAt));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
async function revokeDevice(deviceId: string) {
|
||||
if (!user || !token) return;
|
||||
try {
|
||||
@@ -335,10 +366,53 @@ export default function SecurityPage() {
|
||||
return (
|
||||
<IdShell active="/security">
|
||||
<p className="text-sm text-[#667085]">Безопасность</p>
|
||||
<h1 className="text-2xl font-medium tracking-tight sm:text-4xl">Устройства</h1>
|
||||
<h1 className="text-2xl font-medium tracking-tight sm:text-4xl">Устройства и сессии</h1>
|
||||
<p className="mt-2 max-w-[460px] text-base">На них вы вошли в ID и получаете уведомления безопасности от сервисов</p>
|
||||
|
||||
<div className="mt-8 space-y-2">
|
||||
<h2 className="text-lg font-medium">Активные сессии</h2>
|
||||
{isSecurityLoading ? (
|
||||
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем сессии...</div>
|
||||
) : sessions.length ? (
|
||||
sessions.map((session) => {
|
||||
const Icon = deviceIcons[session.deviceType ?? 'OTHER'] ?? Laptop;
|
||||
const isCurrent = session.id === currentSessionId;
|
||||
return (
|
||||
<div key={session.id} className="flex items-center gap-4 rounded-[20px] bg-[#f4f5f8] px-4 py-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{sessionDeviceLabel(session)}</span>
|
||||
{isCurrent ? (
|
||||
<span className="rounded-full bg-[#e8f4ff] px-2 py-0.5 text-[11px] font-semibold text-[#3390ec]">
|
||||
Текущая
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-xs text-[#667085]">{sessionMetaLine(session)}</span>
|
||||
</div>
|
||||
{!isCurrent ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 text-[#d14343] hover:bg-[#fbeaea]"
|
||||
onClick={() => void revokeSession(session.id)}
|
||||
>
|
||||
Выйти
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Активных сессий пока нет</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-2">
|
||||
<h2 className="text-lg font-medium">Другие устройства</h2>
|
||||
{isSecurityLoading ? (
|
||||
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем устройства...</div>
|
||||
) : devices.length ? (
|
||||
@@ -443,10 +517,17 @@ export default function SecurityPage() {
|
||||
<div className="text-[#667085]">Загружаем историю входов...</div>
|
||||
) : history.length ? (
|
||||
history.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-4 border-b border-[#eceef4] pb-3">
|
||||
<Clock3 className="h-5 w-5" />
|
||||
<span className="flex-1">{item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'}</span>
|
||||
<span className="text-sm text-[#667085]">{formatDate(item.createdAt)}</span>
|
||||
<div key={item.id} className="flex items-start gap-4 border-b border-[#eceef4] pb-3">
|
||||
<Clock3 className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p>{item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'}</p>
|
||||
<p className="mt-1 text-sm text-[#667085]">
|
||||
{[item.deviceName, item.ipAddress ?? 'IP не сохранён', formatUserAgentLabel(item.userAgent)]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-sm text-[#667085]">{formatDate(item.createdAt)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user