mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat(qrcode-login): enhance WeChat login flow with expiration handlin… (#2212)
* feat(qrcode-login): enhance WeChat login flow with expiration handling and improved session management * feat(qrcode-login): replace RefreshCw icon with RotateCw for loading state * feat(qrcode-login): adjust session expiration handling and improve error status management
This commit is contained in:
@@ -4,11 +4,16 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, RefreshCw, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
||||
@@ -96,7 +101,7 @@ interface QrCodeLoginDialogProps {
|
||||
onSuccess: (credentials: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
|
||||
type DialogState = 'connecting' | 'waiting' | 'expired' | 'success' | 'error';
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
@@ -115,8 +120,10 @@ export default function QrCodeLoginDialog({
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const baseUrlRef = useRef('');
|
||||
const cleanedRef = useRef(false);
|
||||
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
@@ -140,11 +147,14 @@ export default function QrCodeLoginDialog({
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
if (checkExpiredRef.current) {
|
||||
clearInterval(checkExpiredRef.current);
|
||||
checkExpiredRef.current = null;
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
// Cancel backend session
|
||||
if (sessionIdRef.current) {
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl =
|
||||
@@ -171,6 +181,7 @@ export default function QrCodeLoginDialog({
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
baseUrlRef.current = baseUrl;
|
||||
const cfg = platformConfigRef.current;
|
||||
|
||||
try {
|
||||
@@ -191,8 +202,6 @@ export default function QrCodeLoginDialog({
|
||||
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
||||
sessionIdRef.current = session_id;
|
||||
|
||||
// qr_data_url is a pre-rendered data URL (WeChat);
|
||||
// qr_url is a plain URL string (Feishu) that needs local QR generation.
|
||||
if (qr_data_url) {
|
||||
setQrDataUrl(qr_data_url);
|
||||
} else if (qr_url) {
|
||||
@@ -204,11 +213,9 @@ export default function QrCodeLoginDialog({
|
||||
}
|
||||
setState('waiting');
|
||||
|
||||
// Calculate remaining seconds
|
||||
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
||||
setExpireIn(remaining);
|
||||
|
||||
// Start countdown
|
||||
countdownRef.current = setInterval(() => {
|
||||
setExpireIn((prev) => {
|
||||
if (prev <= 1) {
|
||||
@@ -222,7 +229,35 @@ export default function QrCodeLoginDialog({
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Start polling
|
||||
// When countdown hits 0, stop polling and show expired state
|
||||
checkExpiredRef.current = setInterval(() => {
|
||||
setExpireIn((current) => {
|
||||
if (current <= 0) {
|
||||
if (checkExpiredRef.current) {
|
||||
clearInterval(checkExpiredRef.current);
|
||||
checkExpiredRef.current = null;
|
||||
}
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
if (sessionIdRef.current) {
|
||||
fetch(
|
||||
`${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
keepalive: true,
|
||||
},
|
||||
).catch(() => {});
|
||||
sessionIdRef.current = null;
|
||||
}
|
||||
setState('expired');
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, 500);
|
||||
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(
|
||||
@@ -237,7 +272,7 @@ export default function QrCodeLoginDialog({
|
||||
const { status, error, ...rest } = pollJson.data;
|
||||
|
||||
if (status === 'success') {
|
||||
sessionIdRef.current = null; // backend already cleaned up
|
||||
sessionIdRef.current = null;
|
||||
cleanup();
|
||||
setState('success');
|
||||
setTimeout(() => {
|
||||
@@ -249,9 +284,14 @@ export default function QrCodeLoginDialog({
|
||||
cleanup();
|
||||
setState('error');
|
||||
setErrorMessage(error || tRef.current(cfg.failedKey));
|
||||
} else if (status === 'expired') {
|
||||
sessionIdRef.current = null;
|
||||
cleanup();
|
||||
setExpireIn(0);
|
||||
setState('expired');
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors, will retry next interval
|
||||
// ignore poll errors
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
} catch (err: unknown) {
|
||||
@@ -323,6 +363,31 @@ export default function QrCodeLoginDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR code expired — click overlay to refresh */}
|
||||
{state === 'expired' && qrDataUrl && (
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t(platformConfig.scanQRCodeKey)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="relative border rounded-lg p-2 bg-white cursor-pointer group"
|
||||
onClick={() => startLogin()}
|
||||
>
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="QR Code"
|
||||
className="w-56 h-56 opacity-40"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60 rounded-lg group-hover:bg-white/70 transition-colors">
|
||||
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-black/5 group-hover:bg-black/10 transition-colors">
|
||||
<RotateCw className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{state === 'success' && (
|
||||
<div className="flex flex-col items-center space-y-3 py-8">
|
||||
@@ -350,7 +415,7 @@ export default function QrCodeLoginDialog({
|
||||
</div>
|
||||
|
||||
{state === 'error' && (
|
||||
<DialogFooter>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
@@ -358,7 +423,7 @@ export default function QrCodeLoginDialog({
|
||||
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||
{t(platformConfig.retryKey)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user