fix and update

This commit is contained in:
lendry
2026-06-29 22:51:25 +03:00
parent 885b07d76b
commit 57cb58347b
30 changed files with 799 additions and 187 deletions

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>
))
) : (