mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-14 14:31:00 +00:00
fix(cloud): scope QR login requests to workspace (#2414)
Co-authored-by: Chan <dadachann@users.noreply.github.com>
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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アプリ作成',
|
||||||
|
|||||||
@@ -1717,6 +1717,9 @@ const ruRU = {
|
|||||||
'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен',
|
'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен',
|
||||||
loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически',
|
loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически',
|
||||||
loginFailed: 'Не удалось выполнить вход',
|
loginFailed: 'Не удалось выполнить вход',
|
||||||
|
connecting: 'Подключение к сервису WeChat...',
|
||||||
|
waitingForScan: 'Ожидание сканирования',
|
||||||
|
retry: 'Повторить',
|
||||||
},
|
},
|
||||||
dingtalk: {
|
dingtalk: {
|
||||||
createApp: 'Создать приложение DingTalk в один клик',
|
createApp: 'Создать приложение DingTalk в один клик',
|
||||||
|
|||||||
@@ -1680,6 +1680,9 @@ const thTH = {
|
|||||||
'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ',
|
'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ',
|
||||||
loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว',
|
loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว',
|
||||||
loginFailed: 'เข้าสู่ระบบไม่สำเร็จ',
|
loginFailed: 'เข้าสู่ระบบไม่สำเร็จ',
|
||||||
|
connecting: 'กำลังเชื่อมต่อบริการ WeChat...',
|
||||||
|
waitingForScan: 'กำลังรอการสแกน',
|
||||||
|
retry: 'ลองอีกครั้ง',
|
||||||
},
|
},
|
||||||
dingtalk: {
|
dingtalk: {
|
||||||
createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว',
|
createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -1834,6 +1834,9 @@ const zhHans = {
|
|||||||
scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌',
|
scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌',
|
||||||
loginSuccess: '登录成功!令牌已自动填入',
|
loginSuccess: '登录成功!令牌已自动填入',
|
||||||
loginFailed: '登录失败',
|
loginFailed: '登录失败',
|
||||||
|
connecting: '正在连接微信服务...',
|
||||||
|
waitingForScan: '等待扫码中',
|
||||||
|
retry: '重试',
|
||||||
},
|
},
|
||||||
dingtalk: {
|
dingtalk: {
|
||||||
createApp: '一键创建钉钉应用',
|
createApp: '一键创建钉钉应用',
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user