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:
Dongchuan Fu
2026-05-21 14:28:02 +08:00
committed by GitHub
parent 6823069103
commit 894709d577
2 changed files with 107 additions and 65 deletions

View File

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