From 20710df9cb96b8c447d862d2177ba395db1dff9b Mon Sep 17 00:00:00 2001 From: Hyu Date: Tue, 11 Aug 2026 16:57:16 +0800 Subject: [PATCH] fix(cloud): scope QR login requests to workspace (#2414) Co-authored-by: Chan --- .../qrcode-login/QrCodeLoginDialog.tsx | 47 +++++++++--- web/src/i18n/locales/en-US.ts | 3 + web/src/i18n/locales/es-ES.ts | 3 + web/src/i18n/locales/ja-JP.ts | 3 + web/src/i18n/locales/ru-RU.ts | 3 + web/src/i18n/locales/th-TH.ts | 3 + web/src/i18n/locales/vi-VN.ts | 3 + web/src/i18n/locales/zh-Hans.ts | 3 + web/src/i18n/locales/zh-Hant.ts | 3 + .../unit/qr-code-login-workspace.test.mjs | 73 +++++++++++++++++++ 10 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 web/tests/unit/qr-code-login-workspace.test.mjs diff --git a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx index 5865dbb4a..439b99f9d 100644 --- a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx +++ b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx @@ -15,6 +15,7 @@ import { XCircle, } from 'lucide-react'; import QRCode from 'qrcode'; +import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext'; export type QrLoginPlatform = | 'feishu' @@ -55,12 +56,12 @@ const PLATFORM_CONFIGS: Record = { }, weixin: { titleKey: 'weixin.scanLogin', - connectingKey: 'feishu.connecting', + connectingKey: 'weixin.connecting', scanQRCodeKey: 'weixin.scanQRCode', - waitingKey: 'feishu.waitingForScan', + waitingKey: 'weixin.waitingForScan', successKey: 'weixin.loginSuccess', failedKey: 'weixin.loginFailed', - retryKey: 'feishu.retry', + retryKey: 'weixin.retry', apiBase: '/api/v1/platform/adapters/weixin/login', extractSuccess: (data) => ({ token: data.token, @@ -146,6 +147,8 @@ export default function QrCodeLoginDialog({ const checkExpiredRef = useRef | null>(null); const abortRef = useRef(null); const sessionIdRef = useRef(null); + const sessionWorkspaceUuidRef = useRef(null); + const sessionApiBaseRef = useRef(''); const baseUrlRef = useRef(''); const cleanedRef = useRef(false); @@ -180,18 +183,23 @@ export default function QrCodeLoginDialog({ } if (sessionIdRef.current) { const token = localStorage.getItem('token'); - const baseUrl = - import.meta.env.VITE_API_BASE_URL || window.location.origin; + const workspaceUuid = sessionWorkspaceUuidRef.current; fetch( - `${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`, + `${baseUrlRef.current}${sessionApiBaseRef.current}/${sessionIdRef.current}`, { method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, + headers: { + Authorization: `Bearer ${token}`, + ...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}), + }, keepalive: true, }, ).catch(() => {}); sessionIdRef.current = null; } + sessionWorkspaceUuidRef.current = null; + sessionApiBaseRef.current = ''; + baseUrlRef.current = ''; }, []); const startLogin = useCallback(async () => { @@ -204,6 +212,7 @@ export default function QrCodeLoginDialog({ setSuccessMeta(''); const token = localStorage.getItem('token'); + const workspaceUuid = getActiveWorkspaceUuid(); const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; baseUrlRef.current = baseUrl; const cfg = platformConfigRef.current; @@ -214,7 +223,10 @@ export default function QrCodeLoginDialog({ const res = await fetch(`${baseUrl}${cfg.apiBase}`, { method: 'POST', - headers: { Authorization: `Bearer ${token}` }, + headers: { + Authorization: `Bearer ${token}`, + ...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}), + }, signal: controller.signal, }); @@ -225,6 +237,8 @@ export default function QrCodeLoginDialog({ const { session_id, qr_data_url, qr_url, expire_at } = json.data; sessionIdRef.current = session_id; + sessionWorkspaceUuidRef.current = workspaceUuid; + sessionApiBaseRef.current = cfg.apiBase; if (qr_data_url) { setQrDataUrl(qr_data_url); @@ -270,11 +284,19 @@ export default function QrCodeLoginDialog({ `${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`, { method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, + headers: { + Authorization: `Bearer ${token}`, + ...(workspaceUuid + ? { 'X-Workspace-Id': workspaceUuid } + : {}), + }, keepalive: true, }, ).catch(() => {}); sessionIdRef.current = null; + sessionWorkspaceUuidRef.current = null; + sessionApiBaseRef.current = ''; + baseUrlRef.current = ''; } setState('expired'); } @@ -286,7 +308,12 @@ export default function QrCodeLoginDialog({ try { const pollRes = await fetch( `${baseUrl}${cfg.apiBase}/status/${session_id}`, - { headers: { Authorization: `Bearer ${token}` } }, + { + headers: { + Authorization: `Bearer ${token}`, + ...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}), + }, + }, ); if (!pollRes.ok) return; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 6603cf560..c60ad295f 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1921,6 +1921,9 @@ const enUS = { 'Scan the QR code below with WeChat to authorize and automatically fill in the token', loginSuccess: 'Login successful! Token has been filled in', loginFailed: 'Login failed', + connecting: 'Connecting to WeChat service...', + waitingForScan: 'Waiting for scan', + retry: 'Retry', }, dingtalk: { createApp: 'One-Click Create DingTalk App', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 1efa34173..43fc33a9b 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1747,6 +1747,9 @@ const esES = { loginSuccess: '¡Inicio de sesión correcto! El token se ha rellenado automáticamente', loginFailed: 'Error al iniciar sesión', + connecting: 'Conectando con el servicio de WeChat...', + waitingForScan: 'Esperando escaneo', + retry: 'Reintentar', }, dingtalk: { createApp: 'Crear aplicación de DingTalk con un clic', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 7285086c4..026ddd3c4 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1837,6 +1837,9 @@ const jaJP = { scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力', loginSuccess: 'ログイン成功!トークンが自動入力されました', loginFailed: 'ログイン失敗', + connecting: 'WeChatサービスに接続中...', + waitingForScan: 'スキャン待ち', + retry: '再試行', }, dingtalk: { createApp: 'ワンクリックでDingTalkアプリ作成', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index b167c81a1..975360fb8 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1717,6 +1717,9 @@ const ruRU = { 'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен', loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически', loginFailed: 'Не удалось выполнить вход', + connecting: 'Подключение к сервису WeChat...', + waitingForScan: 'Ожидание сканирования', + retry: 'Повторить', }, dingtalk: { createApp: 'Создать приложение DingTalk в один клик', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index c1afcb48a..d19e2fef1 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1680,6 +1680,9 @@ const thTH = { 'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ', loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว', loginFailed: 'เข้าสู่ระบบไม่สำเร็จ', + connecting: 'กำลังเชื่อมต่อบริการ WeChat...', + waitingForScan: 'กำลังรอการสแกน', + retry: 'ลองอีกครั้ง', }, dingtalk: { createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 1a4af1319..02bab4c7a 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1708,6 +1708,9 @@ const viVN = { 'Quét mã QR bên dưới bằng WeChat để ủy quyền và tự động điền token', loginSuccess: 'Đăng nhập thành công! Token đã được điền tự động', loginFailed: 'Đăng nhập thất bại', + connecting: 'Đang kết nối tới dịch vụ WeChat...', + waitingForScan: 'Đang chờ quét mã', + retry: 'Thử lại', }, dingtalk: { createApp: 'Tạo ứng dụng DingTalk chỉ với một lần nhấp', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index bbece7f35..3aba0fcfa 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1834,6 +1834,9 @@ const zhHans = { scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌', loginSuccess: '登录成功!令牌已自动填入', loginFailed: '登录失败', + connecting: '正在连接微信服务...', + waitingForScan: '等待扫码中', + retry: '重试', }, dingtalk: { createApp: '一键创建钉钉应用', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 707907a32..91ecdde61 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1657,6 +1657,9 @@ const zhHant = { scanQRCode: '請使用微信掃描以下 QR Code,授權後將自動登入並填寫令牌', loginSuccess: '登入成功!令牌已自動填入', loginFailed: '登入失敗', + connecting: '正在連接微信服務...', + waitingForScan: '等待掃碼中', + retry: '重試', }, dingtalk: { createApp: '一鍵建立釘釘應用', diff --git a/web/tests/unit/qr-code-login-workspace.test.mjs b/web/tests/unit/qr-code-login-workspace.test.mjs new file mode 100644 index 000000000..5e7819373 --- /dev/null +++ b/web/tests/unit/qr-code-login-workspace.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const dialogPath = path.join( + root, + 'src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx', +); +const localeDir = path.join(root, 'src/i18n/locales'); + +const dialogSource = fs.readFileSync(dialogPath, 'utf8'); + +test('QR credential exchanges preserve the active Workspace scope', () => { + assert.match(dialogSource, /getActiveWorkspaceUuid/); + assert.match( + dialogSource, + /sessionWorkspaceUuidRef\.current = workspaceUuid/, + ); + assert.match( + dialogSource, + /const workspaceUuid = sessionWorkspaceUuidRef\.current/, + ); + assert.match(dialogSource, /sessionApiBaseRef\.current = cfg\.apiBase/); + assert.match( + dialogSource, + /`\$\{baseUrlRef\.current\}\$\{sessionApiBaseRef\.current\}\/\$\{sessionIdRef\.current\}`/, + ); + assert.match(dialogSource, /'X-Workspace-Id': workspaceUuid/); + + const workspaceHeaderUses = dialogSource.match( + /'X-Workspace-Id': workspaceUuid/g, + ); + assert.equal( + workspaceHeaderUses?.length, + 4, + 'start, poll, expiry cleanup, and dialog cleanup must all retain Workspace scope', + ); +}); + +test('WeChat QR login never reuses Feishu progress copy', () => { + const weixinConfig = dialogSource.match( + /weixin:\s*\{[\s\S]*?apiBase:\s*'\/api\/v1\/platform\/adapters\/weixin\/login'/, + )?.[0]; + assert.ok(weixinConfig, 'WeChat platform config is missing'); + assert.match(weixinConfig, /connectingKey:\s*'weixin\.connecting'/); + assert.match(weixinConfig, /waitingKey:\s*'weixin\.waitingForScan'/); + assert.match(weixinConfig, /retryKey:\s*'weixin\.retry'/); + assert.doesNotMatch(weixinConfig, /feishu\./); + + for (const locale of [ + 'en-US.ts', + 'es-ES.ts', + 'ja-JP.ts', + 'ru-RU.ts', + 'th-TH.ts', + 'vi-VN.ts', + 'zh-Hans.ts', + 'zh-Hant.ts', + ]) { + const source = fs.readFileSync(path.join(localeDir, locale), 'utf8'); + const block = source.match(/weixin:\s*\{[\s\S]*?\n\s*\},/)?.[0]; + assert.ok(block, `${locale} is missing the WeChat locale block`); + for (const key of ['connecting', 'waitingForScan', 'retry']) { + assert.match( + block, + new RegExp(`\\b${key}:`), + `${locale} is missing weixin.${key}`, + ); + } + } +});