fix and update

This commit is contained in:
lendry
2026-07-07 09:01:49 +03:00
parent 881e5d764b
commit 29306eb2ec
14 changed files with 551 additions and 60 deletions

View File

@@ -4,11 +4,11 @@
import Link from 'next/link';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { FormEvent, Suspense, useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { ChevronLeft, Download, KeyRound, Mail, Network, Phone, QrCode, ShieldCheck, ShieldQuestion, UserRound } from 'lucide-react';
import { ChevronLeft, Download, KeyRound, Loader2, Mail, Network, Phone, QrCode, ShieldCheck, ShieldQuestion, UserRound } from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
@@ -33,10 +33,9 @@ import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { AuthTokens, LoginMethod, OtpChannel, OtpSendResponse } from '@/lib/api';
import { AUTH_TOKEN_KEY, claimQrLoginSession, createQrLoginSession, fetchFedcmSessionStatus, pollQrLoginSession } from '@/lib/api';
import { createQrLoginSession, fetchFedcmSessionStatus, pollQrLoginSession } from '@/lib/api';
import { isFedcmLoginPopup } from '@/lib/fedcm-login-bridge';
import { isFedcmLoginPopup, finalizeFedcmLogin } from '@/lib/fedcm-login-bridge';
import {
clearOtpResendAvailableAt,
@@ -48,7 +47,7 @@ import {
type Step = 'identify' | 'otp' | 'otpChannel' | 'password' | 'pin' | 'ldap' | 'qr' | 'totp';
type Step = 'identify' | 'otp' | 'otpChannel' | 'password' | 'pin' | 'ldap' | 'qr' | 'qrLink' | 'totp';
@@ -83,8 +82,23 @@ const channelLabel: Record<string, string> = {
export default function LoginPage() {
return (
<Suspense
fallback={
<main className="flex min-h-screen items-center justify-center bg-[#1f2128] text-white">
<Loader2 className="h-6 w-6 animate-spin text-[#b9bdc9]" />
</main>
}
>
<LoginPageContent />
</Suspense>
);
}
function LoginPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const finishLoginRedirect = useCallback(() => {
if (typeof window !== 'undefined') {
@@ -269,9 +283,18 @@ export default function LoginPage() {
void (async () => {
const status = await fetchFedcmSessionStatus(publicApiUrl);
if (cancelled || !status?.active || !status.requiresPin || !status.sessionId) return;
setPendingSessionId(status.sessionId);
setStep('pin');
if (cancelled || !status?.active) return;
if (status.requiresPin && status.sessionId) {
setPendingSessionId(status.sessionId);
setStep('pin');
return;
}
const token = window.localStorage.getItem(AUTH_TOKEN_KEY);
if (token) {
await finalizeFedcmLogin(token);
}
})();
return () => {
@@ -279,6 +302,34 @@ export default function LoginPage() {
};
}, [publicApiUrl]);
useEffect(() => {
const qrLink = searchParams.get('qrLink');
if (!qrLink || qrSessionId) return;
let cancelled = false;
setQrLoading(true);
void (async () => {
try {
const claimed = await claimQrLoginSession(qrLink);
if (cancelled) return;
setQrSessionId(qrLink);
setQrExpiresAt(claimed.expiresAt);
setStep('qrLink');
} catch (error) {
if (!cancelled) {
showToast(error instanceof Error ? error.message : 'Не удалось подключить устройство по QR-коду');
}
} finally {
if (!cancelled) setQrLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [qrSessionId, searchParams, showToast]);
useEffect(() => {
if (step !== 'otp' || !recipient) return;
const saved = readOtpResendAvailableAt(recipient);
@@ -319,7 +370,7 @@ export default function LoginPage() {
useEffect(() => {
if (step !== 'qr' || !qrSessionId) return;
if ((step !== 'qr' && step !== 'qrLink') || !qrSessionId) return;
let cancelled = false;
@@ -335,7 +386,13 @@ export default function LoginPage() {
if (session.status === 'EXPIRED') {
showToast('QR-код истёк, создайте новый');
showToast(step === 'qrLink' ? 'QR-код истёк. Отсканируйте новый код на основном устройстве.' : 'QR-код истёк, создайте новый');
if (step === 'qrLink') {
setStep('identify');
setQrSessionId(null);
return;
}
setStep('identify');
@@ -1383,6 +1440,34 @@ export default function LoginPage() {
{step === 'qrLink' ? (
<div className="text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-white" />
<p className="mt-5 text-sm leading-relaxed text-[#b9bdc9]">
Подтверждаем подключение устройства. Дождитесь автоматического входа в аккаунт.
</p>
{qrExpiresAt ? (
<p className="mt-2 text-xs text-[#8f92a0]">
QR-код действует до {new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date(qrExpiresAt))}
</p>
) : null}
</div>
) : null}
{step === 'pin' && pendingSessionId ? (
<form onSubmit={handlePinSubmit} className="space-y-3">

View File

@@ -12,6 +12,7 @@ import {
Monitor,
type LucideIcon,
Phone,
QrCode,
ShieldCheck,
Smartphone,
Speaker,
@@ -29,9 +30,10 @@ import { OtpInput } from '@/components/ui/otp-input';
import { ActiveDevice, ActiveSession, apiFetch, AUTH_SESSION_KEY, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
import { markPinIdleWatch } from '@/lib/pin-idle-storage';
import { formatUserAgentLabel } from '@/lib/device-client';
import { DeviceLinkQrDialog } from '@/components/id/device-link-qr-dialog';
import { useRequireAuth } from '@/hooks/use-require-auth';
import { cn } from '@/lib/utils';
import { QRCodeSVG } from 'qrcode.react';
import { useRequireAuth } from '@/hooks/use-require-auth';
function formatDate(value: string) {
return new Intl.DateTimeFormat('ru-RU', {
@@ -91,6 +93,7 @@ export default function SecurityPage() {
const [passwordTotpCode, setPasswordTotpCode] = useState('');
const [newPassword, setNewPassword] = useState('');
const [passwordOtpSending, setPasswordOtpSending] = useState(false);
const [deviceLinkOpen, setDeviceLinkOpen] = useState(false);
const hasSmsContact = Boolean(user?.phone || user?.backupPhone);
const hasEmailContact = Boolean(user?.email || user?.backupEmail);
@@ -418,7 +421,20 @@ export default function SecurityPage() {
</div>
<div className="mt-8 space-y-2">
<h2 className="text-lg font-medium">Другие устройства</h2>
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-medium">Другие устройства</h2>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-xl"
disabled={!user || !token || isSecurityLoading}
onClick={() => setDeviceLinkOpen(true)}
>
<QrCode className="mr-2 h-4 w-4" />
Подключить новое
</Button>
</div>
{isSecurityLoading ? (
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем устройства...</div>
) : devices.length ? (
@@ -729,6 +745,16 @@ export default function SecurityPage() {
) : null}
</DialogContent>
</Dialog>
{user && token ? (
<DeviceLinkQrDialog
open={deviceLinkOpen}
userId={user.id}
token={token}
onOpenChange={setDeviceLinkOpen}
onLinked={() => void loadSecurity()}
/>
) : null}
</IdShell>
);
}