fix and update
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -703,6 +703,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (shouldFinalizeFedcmLogin(pathname)) {
|
||||
await finalizeFedcmLogin(response.accessToken);
|
||||
} else {
|
||||
void syncFedcmSession(response.accessToken);
|
||||
}
|
||||
},
|
||||
[pathname, router, user?.id, warmUpAndApplySession]
|
||||
|
||||
171
apps/frontend/components/id/device-link-qr-dialog.tsx
Normal file
171
apps/frontend/components/id/device-link-qr-dialog.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Loader2, QrCode } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
approveQrLoginSession,
|
||||
createDeviceLinkQrSession,
|
||||
getApiErrorMessage,
|
||||
pollQrLoginSession,
|
||||
type QrLoginSession
|
||||
} from '@/lib/api';
|
||||
|
||||
interface DeviceLinkQrDialogProps {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
token: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onLinked?: () => void;
|
||||
}
|
||||
|
||||
function formatCountdown(expiresAt: string) {
|
||||
const secondsLeft = Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 1000));
|
||||
const minutes = Math.floor(secondsLeft / 60);
|
||||
const seconds = secondsLeft % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function DeviceLinkQrDialog({ open, userId, token, onOpenChange, onLinked }: DeviceLinkQrDialogProps) {
|
||||
const [session, setSession] = React.useState<QrLoginSession | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [approving, setApproving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
|
||||
const [countdown, setCountdown] = React.useState('5:00');
|
||||
const approveStartedRef = React.useRef(false);
|
||||
const refreshInFlightRef = React.useRef(false);
|
||||
|
||||
const refreshSession = React.useCallback(async () => {
|
||||
if (!userId || !token || refreshInFlightRef.current) return;
|
||||
refreshInFlightRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
approveStartedRef.current = false;
|
||||
try {
|
||||
const created = await createDeviceLinkQrSession(userId, token);
|
||||
setSession(created);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, 'Не удалось создать QR-код') ?? 'Не удалось создать QR-код');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
refreshInFlightRef.current = false;
|
||||
}
|
||||
}, [token, userId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setSession(null);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
approveStartedRef.current = false;
|
||||
return;
|
||||
}
|
||||
void refreshSession();
|
||||
}, [open, refreshSession]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !session?.expiresAt) return;
|
||||
const update = () => setCountdown(formatCountdown(session.expiresAt));
|
||||
update();
|
||||
const timer = window.setInterval(update, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [open, session?.expiresAt]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !session?.sessionId || !token || successMessage) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const current = await pollQrLoginSession(session.sessionId);
|
||||
if (cancelled) return;
|
||||
|
||||
if (current.status === 'EXPIRED') {
|
||||
if (!refreshInFlightRef.current) {
|
||||
await refreshSession();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.claimed && !approveStartedRef.current) {
|
||||
approveStartedRef.current = true;
|
||||
setApproving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await approveQrLoginSession(session.sessionId, token);
|
||||
if (cancelled) return;
|
||||
const label = current.claimedDeviceName?.trim();
|
||||
setSuccessMessage(label ? `Устройство «${label}» подключено` : 'Новое устройство подключено');
|
||||
onLinked?.();
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
approveStartedRef.current = false;
|
||||
setError(getApiErrorMessage(err, 'Не удалось подтвердить подключение') ?? 'Не удалось подтвердить подключение');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setApproving(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(getApiErrorMessage(err, 'Не удалось проверить статус QR-кода') ?? 'Не удалось проверить статус QR-кода');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
const timer = window.setInterval(() => void poll(), 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [onLinked, open, refreshSession, session?.sessionId, successMessage, token]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Подключить новое устройство</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && !session ? (
|
||||
<div className="flex min-h-[280px] flex-col items-center justify-center gap-3 text-[#667085]">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<p className="text-sm">Генерируем QR-код...</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{session?.qrPayload ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex w-fit rounded-[24px] border border-[#eceef4] bg-white p-4 shadow-sm">
|
||||
<QRCodeSVG value={session.qrPayload} size={220} />
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-[#667085]">
|
||||
Отсканируйте QR-код камерой на новом устройстве. Откроется страница входа, и устройство подключится к вашему аккаунту.
|
||||
</p>
|
||||
<p className="text-xs text-[#8f92a0]">
|
||||
Код действует <span className="font-medium text-[#1f2430]">{countdown}</span> и обновится автоматически
|
||||
</p>
|
||||
{approving ? (
|
||||
<p className="flex items-center justify-center gap-2 text-sm text-[#3390ec]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Подключаем устройство...
|
||||
</p>
|
||||
) : null}
|
||||
{successMessage ? <p className="text-sm font-medium text-[#1a7f37]">{successMessage}</p> : null}
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
<Button type="button" variant="outline" className="w-full rounded-xl" disabled={loading} onClick={() => void refreshSession()}>
|
||||
<QrCode className="mr-2 h-4 w-4" />
|
||||
{loading ? 'Обновляем...' : 'Обновить QR-код'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -236,6 +236,8 @@ export interface QrLoginSession {
|
||||
status: 'PENDING' | 'APPROVED' | 'EXPIRED';
|
||||
expiresAt: string;
|
||||
qrPayload?: string;
|
||||
claimed?: boolean;
|
||||
claimedDeviceName?: string;
|
||||
auth?: AuthTokens & { user?: PublicUser };
|
||||
}
|
||||
|
||||
@@ -255,6 +257,23 @@ export async function pollQrLoginSession(sessionId: string) {
|
||||
return apiFetch<QrLoginSession>(`/auth/advanced/qr/session/${sessionId}`);
|
||||
}
|
||||
|
||||
export async function createDeviceLinkQrSession(userId: string, token: string) {
|
||||
return apiFetch<QrLoginSession>(`/security/users/${userId}/device-link/session`, { method: 'POST' }, token);
|
||||
}
|
||||
|
||||
export async function claimQrLoginSession(sessionId: string) {
|
||||
const { buildAuthDevicePayload } = await import('@/lib/device-client');
|
||||
const device = buildAuthDevicePayload();
|
||||
return apiFetch<QrLoginSession>(`/auth/advanced/qr/session/${sessionId}/claim`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(device)
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveQrLoginSession(sessionId: string, token: string) {
|
||||
return apiFetch<QrLoginSession>(`/auth/advanced/qr/session/${sessionId}/approve`, { method: 'POST' }, token);
|
||||
}
|
||||
|
||||
export interface TotpSetupResponse {
|
||||
secret: string;
|
||||
otpauthUrl: string;
|
||||
|
||||
Reference in New Issue
Block a user