172 lines
6.6 KiB
TypeScript
172 lines
6.6 KiB
TypeScript
'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>
|
||
);
|
||
}
|