fix(cloud): scope QR login requests to workspace (#2414)

Co-authored-by: Chan <dadachann@users.noreply.github.com>
This commit is contained in:
Hyu
2026-08-11 16:57:16 +08:00
committed by GitHub
parent 90f3d880e5
commit 20710df9cb
10 changed files with 134 additions and 10 deletions
@@ -15,6 +15,7 @@ import {
XCircle, XCircle,
} from 'lucide-react'; } from 'lucide-react';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
export type QrLoginPlatform = export type QrLoginPlatform =
| 'feishu' | 'feishu'
@@ -55,12 +56,12 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
}, },
weixin: { weixin: {
titleKey: 'weixin.scanLogin', titleKey: 'weixin.scanLogin',
connectingKey: 'feishu.connecting', connectingKey: 'weixin.connecting',
scanQRCodeKey: 'weixin.scanQRCode', scanQRCodeKey: 'weixin.scanQRCode',
waitingKey: 'feishu.waitingForScan', waitingKey: 'weixin.waitingForScan',
successKey: 'weixin.loginSuccess', successKey: 'weixin.loginSuccess',
failedKey: 'weixin.loginFailed', failedKey: 'weixin.loginFailed',
retryKey: 'feishu.retry', retryKey: 'weixin.retry',
apiBase: '/api/v1/platform/adapters/weixin/login', apiBase: '/api/v1/platform/adapters/weixin/login',
extractSuccess: (data) => ({ extractSuccess: (data) => ({
token: data.token, token: data.token,
@@ -146,6 +147,8 @@ export default function QrCodeLoginDialog({
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null); const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | null>(null); const sessionIdRef = useRef<string | null>(null);
const sessionWorkspaceUuidRef = useRef<string | null>(null);
const sessionApiBaseRef = useRef('');
const baseUrlRef = useRef(''); const baseUrlRef = useRef('');
const cleanedRef = useRef(false); const cleanedRef = useRef(false);
@@ -180,18 +183,23 @@ export default function QrCodeLoginDialog({
} }
if (sessionIdRef.current) { if (sessionIdRef.current) {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const baseUrl = const workspaceUuid = sessionWorkspaceUuidRef.current;
import.meta.env.VITE_API_BASE_URL || window.location.origin;
fetch( fetch(
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`, `${baseUrlRef.current}${sessionApiBaseRef.current}/${sessionIdRef.current}`,
{ {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
},
keepalive: true, keepalive: true,
}, },
).catch(() => {}); ).catch(() => {});
sessionIdRef.current = null; sessionIdRef.current = null;
} }
sessionWorkspaceUuidRef.current = null;
sessionApiBaseRef.current = '';
baseUrlRef.current = '';
}, []); }, []);
const startLogin = useCallback(async () => { const startLogin = useCallback(async () => {
@@ -204,6 +212,7 @@ export default function QrCodeLoginDialog({
setSuccessMeta(''); setSuccessMeta('');
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const workspaceUuid = getActiveWorkspaceUuid();
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
baseUrlRef.current = baseUrl; baseUrlRef.current = baseUrl;
const cfg = platformConfigRef.current; const cfg = platformConfigRef.current;
@@ -214,7 +223,10 @@ export default function QrCodeLoginDialog({
const res = await fetch(`${baseUrl}${cfg.apiBase}`, { const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
},
signal: controller.signal, signal: controller.signal,
}); });
@@ -225,6 +237,8 @@ export default function QrCodeLoginDialog({
const { session_id, qr_data_url, qr_url, expire_at } = json.data; const { session_id, qr_data_url, qr_url, expire_at } = json.data;
sessionIdRef.current = session_id; sessionIdRef.current = session_id;
sessionWorkspaceUuidRef.current = workspaceUuid;
sessionApiBaseRef.current = cfg.apiBase;
if (qr_data_url) { if (qr_data_url) {
setQrDataUrl(qr_data_url); setQrDataUrl(qr_data_url);
@@ -270,11 +284,19 @@ export default function QrCodeLoginDialog({
`${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`, `${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`,
{ {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid
? { 'X-Workspace-Id': workspaceUuid }
: {}),
},
keepalive: true, keepalive: true,
}, },
).catch(() => {}); ).catch(() => {});
sessionIdRef.current = null; sessionIdRef.current = null;
sessionWorkspaceUuidRef.current = null;
sessionApiBaseRef.current = '';
baseUrlRef.current = '';
} }
setState('expired'); setState('expired');
} }
@@ -286,7 +308,12 @@ export default function QrCodeLoginDialog({
try { try {
const pollRes = await fetch( const pollRes = await fetch(
`${baseUrl}${cfg.apiBase}/status/${session_id}`, `${baseUrl}${cfg.apiBase}/status/${session_id}`,
{ headers: { Authorization: `Bearer ${token}` } }, {
headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
},
},
); );
if (!pollRes.ok) return; if (!pollRes.ok) return;
+3
View File
@@ -1921,6 +1921,9 @@ const enUS = {
'Scan the QR code below with WeChat to authorize and automatically fill in the token', 'Scan the QR code below with WeChat to authorize and automatically fill in the token',
loginSuccess: 'Login successful! Token has been filled in', loginSuccess: 'Login successful! Token has been filled in',
loginFailed: 'Login failed', loginFailed: 'Login failed',
connecting: 'Connecting to WeChat service...',
waitingForScan: 'Waiting for scan',
retry: 'Retry',
}, },
dingtalk: { dingtalk: {
createApp: 'One-Click Create DingTalk App', createApp: 'One-Click Create DingTalk App',
+3
View File
@@ -1747,6 +1747,9 @@ const esES = {
loginSuccess: loginSuccess:
'¡Inicio de sesión correcto! El token se ha rellenado automáticamente', '¡Inicio de sesión correcto! El token se ha rellenado automáticamente',
loginFailed: 'Error al iniciar sesión', loginFailed: 'Error al iniciar sesión',
connecting: 'Conectando con el servicio de WeChat...',
waitingForScan: 'Esperando escaneo',
retry: 'Reintentar',
}, },
dingtalk: { dingtalk: {
createApp: 'Crear aplicación de DingTalk con un clic', createApp: 'Crear aplicación de DingTalk con un clic',
+3
View File
@@ -1837,6 +1837,9 @@ const jaJP = {
scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力', scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力',
loginSuccess: 'ログイン成功!トークンが自動入力されました', loginSuccess: 'ログイン成功!トークンが自動入力されました',
loginFailed: 'ログイン失敗', loginFailed: 'ログイン失敗',
connecting: 'WeChatサービスに接続中...',
waitingForScan: 'スキャン待ち',
retry: '再試行',
}, },
dingtalk: { dingtalk: {
createApp: 'ワンクリックでDingTalkアプリ作成', createApp: 'ワンクリックでDingTalkアプリ作成',
+3
View File
@@ -1717,6 +1717,9 @@ const ruRU = {
'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен', 'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен',
loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически', loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически',
loginFailed: 'Не удалось выполнить вход', loginFailed: 'Не удалось выполнить вход',
connecting: 'Подключение к сервису WeChat...',
waitingForScan: 'Ожидание сканирования',
retry: 'Повторить',
}, },
dingtalk: { dingtalk: {
createApp: 'Создать приложение DingTalk в один клик', createApp: 'Создать приложение DingTalk в один клик',
+3
View File
@@ -1680,6 +1680,9 @@ const thTH = {
'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ', 'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ',
loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว', loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว',
loginFailed: 'เข้าสู่ระบบไม่สำเร็จ', loginFailed: 'เข้าสู่ระบบไม่สำเร็จ',
connecting: 'กำลังเชื่อมต่อบริการ WeChat...',
waitingForScan: 'กำลังรอการสแกน',
retry: 'ลองอีกครั้ง',
}, },
dingtalk: { dingtalk: {
createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว', createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว',
+3
View File
@@ -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', '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', loginSuccess: 'Đăng nhập thành công! Token đã được điền tự động',
loginFailed: 'Đăng nhập thất bại', 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: { dingtalk: {
createApp: 'Tạo ứng dụng DingTalk chỉ với một lần nhấp', createApp: 'Tạo ứng dụng DingTalk chỉ với một lần nhấp',
+3
View File
@@ -1834,6 +1834,9 @@ const zhHans = {
scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌', scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌',
loginSuccess: '登录成功!令牌已自动填入', loginSuccess: '登录成功!令牌已自动填入',
loginFailed: '登录失败', loginFailed: '登录失败',
connecting: '正在连接微信服务...',
waitingForScan: '等待扫码中',
retry: '重试',
}, },
dingtalk: { dingtalk: {
createApp: '一键创建钉钉应用', createApp: '一键创建钉钉应用',
+3
View File
@@ -1657,6 +1657,9 @@ const zhHant = {
scanQRCode: '請使用微信掃描以下 QR Code,授權後將自動登入並填寫令牌', scanQRCode: '請使用微信掃描以下 QR Code,授權後將自動登入並填寫令牌',
loginSuccess: '登入成功!令牌已自動填入', loginSuccess: '登入成功!令牌已自動填入',
loginFailed: '登入失敗', loginFailed: '登入失敗',
connecting: '正在連接微信服務...',
waitingForScan: '等待掃碼中',
retry: '重試',
}, },
dingtalk: { dingtalk: {
createApp: '一鍵建立釘釘應用', createApp: '一鍵建立釘釘應用',
@@ -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}`,
);
}
}
});