Merge pull request #194 from leonaii/main

feat(kiro): 添加 AWS SSO 凭据导入和批量导入流式进度功能
This commit is contained in:
何夕2077 2026-01-09 12:47:35 +08:00 committed by GitHub
commit e1d6920137
4 changed files with 1196 additions and 60 deletions

View file

@ -1548,13 +1548,72 @@ async function refreshKiroToken(refreshToken, region = KIRO_REFRESH_CONSTANTS.DE
}
}
/**
* 检查 Kiro 凭据是否已存在基于 refreshToken + provider 组合
* @param {string} refreshToken - 要检查的 refreshToken
* @param {string} provider - 提供商名称 (默认: 'claude-kiro-oauth')
* @returns {Promise<{isDuplicate: boolean, existingPath?: string}>} 检查结果
*/
export async function checkKiroCredentialsDuplicate(refreshToken, provider = 'claude-kiro-oauth') {
const kiroDir = path.join(process.cwd(), 'configs', 'kiro');
try {
// 检查 configs/kiro 目录是否存在
if (!fs.existsSync(kiroDir)) {
return { isDuplicate: false };
}
// 递归扫描所有 JSON 文件
const scanDirectory = async (dirPath) => {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
const result = await scanDirectory(fullPath);
if (result.isDuplicate) {
return result;
}
} else if (entry.isFile() && entry.name.endsWith('.json')) {
try {
const content = await fs.promises.readFile(fullPath, 'utf8');
const credentials = JSON.parse(content);
// 检查 refreshToken 是否匹配
if (credentials.refreshToken && credentials.refreshToken === refreshToken) {
const relativePath = path.relative(process.cwd(), fullPath);
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} Found duplicate refreshToken in: ${relativePath}`);
return {
isDuplicate: true,
existingPath: relativePath
};
}
} catch (parseError) {
// 忽略解析错误的文件
}
}
}
return { isDuplicate: false };
};
return await scanDirectory(kiroDir);
} catch (error) {
console.warn(`${KIRO_OAUTH_CONFIG.logPrefix} Error checking duplicates:`, error.message);
return { isDuplicate: false };
}
}
/**
* 批量导入 Kiro refreshToken 并生成凭据文件
* @param {string[]} refreshTokens - refreshToken 数组
* @param {string} region - AWS 区域 (默认: us-east-1)
* @param {boolean} skipDuplicateCheck - 是否跳过重复检查 (默认: false)
* @returns {Promise<Object>} 批量处理结果
*/
export async function batchImportKiroRefreshTokens(refreshTokens, region = KIRO_REFRESH_CONSTANTS.DEFAULT_REGION) {
export async function batchImportKiroRefreshTokens(refreshTokens, region = KIRO_REFRESH_CONSTANTS.DEFAULT_REGION, skipDuplicateCheck = false) {
const results = {
total: refreshTokens.length,
success: 0,
@ -1575,6 +1634,21 @@ export async function batchImportKiroRefreshTokens(refreshTokens, region = KIRO_
continue;
}
// 检查重复
if (!skipDuplicateCheck) {
const duplicateCheck = await checkKiroCredentialsDuplicate(refreshToken);
if (duplicateCheck.isDuplicate) {
results.details.push({
index: i + 1,
success: false,
error: 'duplicate',
existingPath: duplicateCheck.existingPath
});
results.failed++;
continue;
}
}
try {
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} 正在刷新第 ${i + 1}/${refreshTokens.length} 个 token...`);
@ -1626,4 +1700,268 @@ export async function batchImportKiroRefreshTokens(refreshTokens, region = KIRO_
}
return results;
}
}
/**
* 批量导入 Kiro refreshToken 并生成凭据文件流式版本支持实时进度回调
* @param {string[]} refreshTokens - refreshToken 数组
* @param {string} region - AWS 区域 (默认: us-east-1)
* @param {Function} onProgress - 进度回调函数每处理完一个 token 调用
* @param {boolean} skipDuplicateCheck - 是否跳过重复检查 (默认: false)
* @returns {Promise<Object>} 批量处理结果
*/
export async function batchImportKiroRefreshTokensStream(refreshTokens, region = KIRO_REFRESH_CONSTANTS.DEFAULT_REGION, onProgress = null, skipDuplicateCheck = false) {
const results = {
total: refreshTokens.length,
success: 0,
failed: 0,
details: []
};
for (let i = 0; i < refreshTokens.length; i++) {
const refreshToken = refreshTokens[i].trim();
const progressData = {
index: i + 1,
total: refreshTokens.length,
current: null
};
if (!refreshToken) {
progressData.current = {
index: i + 1,
success: false,
error: 'Empty token'
};
results.details.push(progressData.current);
results.failed++;
// 发送进度更新
if (onProgress) {
onProgress({
...progressData,
successCount: results.success,
failedCount: results.failed
});
}
continue;
}
// 检查重复
if (!skipDuplicateCheck) {
const duplicateCheck = await checkKiroCredentialsDuplicate(refreshToken);
if (duplicateCheck.isDuplicate) {
progressData.current = {
index: i + 1,
success: false,
error: 'duplicate',
existingPath: duplicateCheck.existingPath
};
results.details.push(progressData.current);
results.failed++;
// 发送进度更新
if (onProgress) {
onProgress({
...progressData,
successCount: results.success,
failedCount: results.failed
});
}
continue;
}
}
try {
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} 正在刷新第 ${i + 1}/${refreshTokens.length} 个 token...`);
const tokenData = await refreshKiroToken(refreshToken, region);
// 生成文件路径: configs/kiro/{timestamp}_kiro-auth-token/{timestamp}_kiro-auth-token.json
const timestamp = Date.now();
const folderName = `${timestamp}_kiro-auth-token`;
const targetDir = path.join(process.cwd(), 'configs', 'kiro', folderName);
await fs.promises.mkdir(targetDir, { recursive: true });
const credPath = path.join(targetDir, `${folderName}.json`);
await fs.promises.writeFile(credPath, JSON.stringify(tokenData, null, 2));
const relativePath = path.relative(process.cwd(), credPath);
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} Token ${i + 1} 已保存: ${relativePath}`);
progressData.current = {
index: i + 1,
success: true,
path: relativePath,
expiresAt: tokenData.expiresAt
};
results.details.push(progressData.current);
results.success++;
} catch (error) {
console.error(`${KIRO_OAUTH_CONFIG.logPrefix} Token ${i + 1} 刷新失败:`, error.message);
progressData.current = {
index: i + 1,
success: false,
error: error.message
};
results.details.push(progressData.current);
results.failed++;
}
// 发送进度更新
if (onProgress) {
onProgress({
...progressData,
successCount: results.success,
failedCount: results.failed
});
}
}
// 如果有成功的,广播事件并自动关联
if (results.success > 0) {
broadcastEvent('oauth_batch_success', {
provider: 'claude-kiro-oauth',
count: results.success,
timestamp: new Date().toISOString()
});
// 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
}
return results;
}
/**
* 导入 AWS SSO 凭据用于 Kiro (Builder ID 模式)
* 从用户上传的 AWS SSO cache 文件中导入凭据
* @param {Object} credentials - 合并后的凭据对象需包含 clientId clientSecret
* @param {boolean} skipDuplicateCheck - 是否跳过重复检查 (默认: false)
* @returns {Promise<Object>} 导入结果
*/
export async function importAwsCredentials(credentials, skipDuplicateCheck = false) {
try {
// 验证必需字段 - 需要四个字段都存在
const missingFields = [];
if (!credentials.clientId) missingFields.push('clientId');
if (!credentials.clientSecret) missingFields.push('clientSecret');
if (!credentials.accessToken) missingFields.push('accessToken');
if (!credentials.refreshToken) missingFields.push('refreshToken');
if (missingFields.length > 0) {
return {
success: false,
error: `Missing required fields: ${missingFields.join(', ')}`
};
}
// 检查重复凭据
if (!skipDuplicateCheck) {
const duplicateCheck = await checkKiroCredentialsDuplicate(credentials.refreshToken);
if (duplicateCheck.isDuplicate) {
return {
success: false,
error: 'duplicate',
existingPath: duplicateCheck.existingPath
};
}
}
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} Importing AWS credentials...`);
// 准备凭据数据 - 四个字段都是必需的
const credentialsData = {
clientId: credentials.clientId,
clientSecret: credentials.clientSecret,
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
authMethod: credentials.authMethod || 'builder-id',
region: credentials.region || KIRO_REFRESH_CONSTANTS.DEFAULT_REGION
};
// 可选字段
if (credentials.expiresAt) {
credentialsData.expiresAt = credentials.expiresAt;
}
if (credentials.startUrl) {
credentialsData.startUrl = credentials.startUrl;
}
if (credentials.registrationExpiresAt) {
credentialsData.registrationExpiresAt = credentials.registrationExpiresAt;
}
// 尝试刷新获取最新的 accessToken
try {
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} Attempting to refresh token with provided credentials...`);
const region = credentials.region || KIRO_REFRESH_CONSTANTS.DEFAULT_REGION;
const refreshUrl = KIRO_REFRESH_CONSTANTS.REFRESH_IDC_URL.replace('{{region}}', region);
const refreshResponse = await fetch(refreshUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
refreshToken: credentials.refreshToken,
clientId: credentials.clientId,
clientSecret: credentials.clientSecret,
grantType: 'refresh_token'
})
});
if (refreshResponse.ok) {
const tokenData = await refreshResponse.json();
credentialsData.accessToken = tokenData.accessToken;
credentialsData.refreshToken = tokenData.refreshToken;
const expiresIn = tokenData.expiresIn || 3600;
credentialsData.expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} Token refreshed successfully`);
} else {
console.warn(`${KIRO_OAUTH_CONFIG.logPrefix} Token refresh failed, saving original credentials`);
}
} catch (refreshError) {
console.warn(`${KIRO_OAUTH_CONFIG.logPrefix} Token refresh error:`, refreshError.message);
// 继续保存原始凭据
}
// 生成文件路径: configs/kiro/{timestamp}_kiro-auth-token/{timestamp}_kiro-auth-token.json
const timestamp = Date.now();
const folderName = `${timestamp}_kiro-auth-token`;
const targetDir = path.join(process.cwd(), 'configs', 'kiro', folderName);
await fs.promises.mkdir(targetDir, { recursive: true });
const credPath = path.join(targetDir, `${folderName}.json`);
await fs.promises.writeFile(credPath, JSON.stringify(credentialsData, null, 2));
const relativePath = path.relative(process.cwd(), credPath);
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} AWS credentials saved to: ${relativePath}`);
// 广播事件
broadcastEvent('oauth_success', {
provider: 'claude-kiro-oauth',
relativePath: relativePath,
timestamp: new Date().toISOString()
});
// 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
return {
success: true,
path: relativePath
};
} catch (error) {
console.error(`${KIRO_OAUTH_CONFIG.logPrefix} AWS credentials import failed:`, error);
return {
success: false,
error: error.message
};
}
}

View file

@ -56,7 +56,7 @@ import { getAllProviderModels, getProviderModels } from './provider-models.js';
import { CONFIG } from './config-manager.js';
import { serviceInstances, getServiceAdapter } from './adapter.js';
import { initApiService } from './service-manager.js';
import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, handleQwenOAuth, handleKiroOAuth, handleIFlowOAuth, batchImportKiroRefreshTokens } from './oauth-handlers.js';
import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, handleQwenOAuth, handleKiroOAuth, handleIFlowOAuth, batchImportKiroRefreshTokens, batchImportKiroRefreshTokensStream, importAwsCredentials } from './oauth-handlers.js';
import {
generateUUID,
normalizePath,
@ -2126,8 +2126,8 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
return true;
}
// Batch import Kiro refresh tokens
// 批量导入 Kiro refreshToken
// Batch import Kiro refresh tokens with SSE (real-time progress)
// 批量导入 Kiro refreshToken(带实时进度 SSE
if (method === 'POST' && pathParam === '/api/kiro/batch-import-tokens') {
try {
const body = await getRequestBody(req);
@ -2142,21 +2142,125 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
return true;
}
console.log(`[Kiro Batch Import] Starting batch import of ${refreshTokens.length} tokens...`);
console.log(`[Kiro Batch Import] Starting batch import of ${refreshTokens.length} tokens with SSE...`);
const result = await batchImportKiroRefreshTokens(refreshTokens, region || 'us-east-1');
// 设置 SSE 响应头
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
// 发送 SSE 事件的辅助函数
const sendSSE = (event, data) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// 发送开始事件
sendSSE('start', { total: refreshTokens.length });
// 执行流式批量导入
const result = await batchImportKiroRefreshTokensStream(
refreshTokens,
region || 'us-east-1',
(progress) => {
// 每处理完一个 token 发送进度更新
sendSSE('progress', progress);
}
);
console.log(`[Kiro Batch Import] Completed: ${result.success} success, ${result.failed} failed`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
// 发送完成事件
sendSSE('complete', {
success: true,
...result
}));
total: result.total,
successCount: result.success,
failedCount: result.failed,
details: result.details
});
res.end();
return true;
} catch (error) {
console.error('[Kiro Batch Import] Error:', error);
// 如果已经开始发送 SSE则发送错误事件
if (res.headersSent) {
res.write(`event: error\n`);
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
res.end();
} else {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: error.message
}));
}
return true;
}
}
// Import AWS SSO credentials for Kiro
// 导入 AWS SSO 凭据用于 Kiro
if (method === 'POST' && pathParam === '/api/kiro/import-aws-credentials') {
try {
const body = await getRequestBody(req);
const { credentials } = body;
if (!credentials || typeof credentials !== 'object') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: 'credentials object is required'
}));
return true;
}
// 验证必需字段 - 需要四个字段都存在
const missingFields = [];
if (!credentials.clientId) missingFields.push('clientId');
if (!credentials.clientSecret) missingFields.push('clientSecret');
if (!credentials.accessToken) missingFields.push('accessToken');
if (!credentials.refreshToken) missingFields.push('refreshToken');
if (missingFields.length > 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: `Missing required fields: ${missingFields.join(', ')}`
}));
return true;
}
console.log('[Kiro AWS Import] Starting AWS credentials import...');
const result = await importAwsCredentials(credentials);
if (result.success) {
console.log(`[Kiro AWS Import] Successfully imported credentials to: ${result.path}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
path: result.path,
message: 'AWS credentials imported successfully'
}));
} else {
// 重复凭据返回 409 Conflict其他错误返回 500
const statusCode = result.error === 'duplicate' ? 409 : 500;
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: result.error,
existingPath: result.existingPath || null
}));
}
return true;
} catch (error) {
console.error('[Kiro AWS Import] Error:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,

View file

@ -133,18 +133,49 @@ const translations = {
'oauth.kiro.step3': '授权完成后页面会自动关闭',
'oauth.kiro.step4': '刷新本页面查看凭据文件',
'oauth.kiro.batchImport': '批量导入 refreshToken',
'oauth.kiro.batchImportDesc': '批量导入已有的 refreshToken 生成凭据文件',
'oauth.kiro.batchImportDesc': '批量导入已有的 refreshToken 生成凭据文件,该模式不支持 AWS 账号。',
'oauth.kiro.batchImportInstructions': '请输入 refreshToken每行一个。系统将自动刷新并生成凭据文件。',
'oauth.kiro.awsImport': '导入 AWS 账号',
'oauth.kiro.awsImportDesc': '从 AWS SSO cache 目录导入凭据文件,适用于 AWS Builder ID 模式。',
'oauth.kiro.awsImportInstructions': '请上传 AWS SSO cache 目录中的 JSON 文件,需包含 clientId、clientSecret、accessToken、refreshToken 四个字段。',
'oauth.kiro.awsModeFile': '文件上传',
'oauth.kiro.awsModeJson': 'JSON 粘贴',
'oauth.kiro.awsUploadFiles': '上传凭据文件',
'oauth.kiro.awsDragDrop': '拖拽文件到此处',
'oauth.kiro.awsClickUpload': '或点击选择文件',
'oauth.kiro.awsFileHint': '如果一个文件不包含全部字段,可以多次上传不同的文件进行补全',
'oauth.kiro.awsSelectedFiles': '已选择的文件',
'oauth.kiro.awsClearFiles': '清空全部',
'oauth.kiro.awsFileReplaced': '已替换同名文件: {filename}',
'oauth.kiro.awsJsonInput': '粘贴 JSON 凭据',
'oauth.kiro.awsJsonPlaceholderSimple': '在此粘贴包含 clientId、clientSecret、accessToken、refreshToken 的 JSON...',
'oauth.kiro.awsJsonExample': '查看 JSON 格式示例',
'oauth.kiro.awsJsonHint': '可以直接粘贴合并后的 JSON或从 AWS SSO cache 文件复制内容',
'oauth.kiro.awsJsonParseError': 'JSON 格式错误',
'oauth.kiro.awsParseError': '解析文件 {filename} 失败',
'oauth.kiro.awsValidationSuccess': '验证通过!已找到全部必需字段',
'oauth.kiro.awsValidationFailed': '验证失败!缺少必需字段',
'oauth.kiro.awsMissingFields': '缺少 {count} 个字段',
'oauth.kiro.awsUploadMore': '请上传包含缺失字段的文件,或切换到 JSON 模式手动补全',
'oauth.kiro.awsPreviewJson': '合并后的凭据预览',
'oauth.kiro.awsConfirmImport': '确认导入',
'oauth.kiro.awsNoCredentials': '没有可导入的凭据',
'oauth.kiro.awsImporting': '正在导入...',
'oauth.kiro.awsImportSuccess': 'AWS 凭据导入成功!',
'oauth.kiro.awsImportFailed': 'AWS 凭据导入失败',
'oauth.kiro.refreshTokensLabel': 'RefreshToken 列表',
'oauth.kiro.refreshTokensPlaceholder': '每行输入一个 refreshToken\n例如:\naorAxxxxxxxx\naorAyyyyyyyy\naorAzzzzzzzz',
'oauth.kiro.tokenCount': '待导入数量:',
'oauth.kiro.importing': '正在导入中,请稍候...',
'oauth.kiro.importingProgress': '正在导入 {current}/{total}...',
'oauth.kiro.startImport': '开始导入',
'oauth.kiro.noTokens': '请输入至少一个 refreshToken',
'oauth.kiro.importSuccess': '导入成功!共 {count} 个凭据已生成',
'oauth.kiro.importAllFailed': '导入失败!共 {count} 个 token 刷新失败',
'oauth.kiro.importPartial': '部分成功:{success} 个成功,{failed} 个失败',
'oauth.kiro.importError': '导入出错',
'oauth.kiro.duplicateToken': '重复凭据 - 此 refreshToken 已存在',
'oauth.kiro.duplicateCredentials': '该凭据已存在,请勿重复导入',
'oauth.iflow.step1': '点击下方按钮在浏览器中打开 iFlow 授权页面',
'oauth.iflow.step2': '使用您的 iFlow 账号登录并授权',
'oauth.iflow.step3': '授权完成后,系统会自动获取 API Key',
@ -415,6 +446,8 @@ const translations = {
'common.loading': '加载中...',
'common.upload': '上传',
'common.generate': '生成',
'common.found': '已找到',
'common.missing': '缺失',
'common.search': '搜索',
'common.welcome': '欢迎使用AIClient2API管理控制台',
'common.fileType': '不支持的文件类型,请选择 JSON、TXT、KEY、PEM、P12 或 PFX 文件',
@ -574,18 +607,49 @@ const translations = {
'oauth.kiro.step3': 'The page will close automatically after authorization',
'oauth.kiro.step4': 'Refresh this page to view the credentials file',
'oauth.kiro.batchImport': 'Batch Import refreshToken',
'oauth.kiro.batchImportDesc': 'Batch import existing refreshTokens to generate credential files',
'oauth.kiro.batchImportDesc': 'Batch import existing refresh tokens to generate credential files. This mode does not support AWS accounts.',
'oauth.kiro.batchImportInstructions': 'Enter refreshTokens, one per line. The system will automatically refresh and generate credential files.',
'oauth.kiro.awsImport': 'Import AWS Account',
'oauth.kiro.awsImportDesc': 'Import credential files from AWS SSO cache directory. For AWS Builder ID mode.',
'oauth.kiro.awsImportInstructions': 'Upload JSON files from AWS SSO cache directory. Must contain clientId, clientSecret, accessToken, and refreshToken.',
'oauth.kiro.awsModeFile': 'File Upload',
'oauth.kiro.awsModeJson': 'Paste JSON',
'oauth.kiro.awsUploadFiles': 'Upload Credential Files',
'oauth.kiro.awsDragDrop': 'Drag and drop files here',
'oauth.kiro.awsClickUpload': 'or click to select files',
'oauth.kiro.awsFileHint': 'If one file doesn\'t contain all fields, you can upload multiple files to complete them',
'oauth.kiro.awsSelectedFiles': 'Selected Files',
'oauth.kiro.awsClearFiles': 'Clear All',
'oauth.kiro.awsFileReplaced': 'Replaced file: {filename}',
'oauth.kiro.awsJsonInput': 'Paste JSON Credentials',
'oauth.kiro.awsJsonPlaceholderSimple': 'Paste JSON containing clientId, clientSecret, accessToken, refreshToken here...',
'oauth.kiro.awsJsonExample': 'View JSON format example',
'oauth.kiro.awsJsonHint': 'You can paste merged JSON directly, or copy content from AWS SSO cache files',
'oauth.kiro.awsJsonParseError': 'Invalid JSON format',
'oauth.kiro.awsParseError': 'Failed to parse file {filename}',
'oauth.kiro.awsValidationSuccess': 'Validation passed! All required fields found',
'oauth.kiro.awsValidationFailed': 'Validation failed! Required fields missing',
'oauth.kiro.awsMissingFields': '{count} field(s) missing',
'oauth.kiro.awsUploadMore': 'Please upload files containing the missing fields, or switch to JSON mode to complete manually',
'oauth.kiro.awsPreviewJson': 'Merged Credentials Preview',
'oauth.kiro.awsConfirmImport': 'Confirm Import',
'oauth.kiro.awsNoCredentials': 'No credentials to import',
'oauth.kiro.awsImporting': 'Importing...',
'oauth.kiro.awsImportSuccess': 'AWS credentials imported successfully!',
'oauth.kiro.awsImportFailed': 'AWS credentials import failed',
'oauth.kiro.refreshTokensLabel': 'RefreshToken List',
'oauth.kiro.refreshTokensPlaceholder': 'Enter one refreshToken per line\nExample:\naorAxxxxxxxx\naorAyyyyyyyy\naorAzzzzzzzz',
'oauth.kiro.tokenCount': 'Tokens to import:',
'oauth.kiro.importing': 'Importing, please wait...',
'oauth.kiro.importingProgress': 'Importing {current}/{total}...',
'oauth.kiro.startImport': 'Start Import',
'oauth.kiro.noTokens': 'Please enter at least one refreshToken',
'oauth.kiro.importSuccess': 'Import successful! {count} credentials generated',
'oauth.kiro.importAllFailed': 'Import failed! {count} tokens failed to refresh',
'oauth.kiro.importPartial': 'Partial success: {success} succeeded, {failed} failed',
'oauth.kiro.importError': 'Import error',
'oauth.kiro.duplicateToken': 'Duplicate - this refreshToken already exists',
'oauth.kiro.duplicateCredentials': 'This credential already exists, please do not import duplicates',
'oauth.iflow.step1': 'Click the button below to open the iFlow authorization page',
'oauth.iflow.step2': 'Log in with your iFlow account and authorize',
'oauth.iflow.step3': 'After authorization, the system will automatically fetch the API Key',
@ -857,6 +921,8 @@ const translations = {
'common.loading': 'Loading...',
'common.upload': 'Upload',
'common.generate': 'Generate',
'common.found': 'Found',
'common.missing': 'Missing',
'common.search': 'Search',
'common.welcome': 'Welcome to AIClient2API Management Console!',
'common.fileType': 'Unsupported file type. Please select JSON, TXT, KEY, PEM, P12, or PFX.',

View file

@ -496,6 +496,13 @@ function showKiroAuthMethodSelector(providerType) {
<div style="font-size: 12px; color: #666;" data-i18n="oauth.kiro.batchImportDesc">${t('oauth.kiro.batchImportDesc')}</div>
</div>
</button>
<button class="auth-method-btn" data-method="aws-import" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
<i class="fas fa-cloud-upload-alt" style="font-size: 24px; color: #ff9900;"></i>
<div style="text-align: left;">
<div style="font-weight: 600; color: #333;" data-i18n="oauth.kiro.awsImport">${t('oauth.kiro.awsImport')}</div>
<div style="font-size: 12px; color: #666;" data-i18n="oauth.kiro.awsImportDesc">${t('oauth.kiro.awsImportDesc')}</div>
</div>
</button>
</div>
</div>
<div class="modal-footer">
@ -532,6 +539,8 @@ function showKiroAuthMethodSelector(providerType) {
if (method === 'batch-import') {
showKiroBatchImportModal();
} else if (method === 'aws-import') {
showKiroAwsImportModal();
} else {
await executeGenerateAuthUrl(providerType, { method });
}
@ -629,7 +638,7 @@ function showKiroBatchImportModal() {
});
});
// 提交按钮事件
// 提交按钮事件 - 使用 SSE 流式响应实时显示进度
submitBtn.addEventListener('click', async () => {
const tokens = textarea.value.split('\n').filter(line => line.trim());
@ -644,63 +653,153 @@ function showKiroBatchImportModal() {
cancelBtn.disabled = true;
progressDiv.style.display = 'block';
resultDiv.style.display = 'none';
progressBar.style.width = '0%';
// 创建实时结果显示区域
resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;';
resultDiv.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<i class="fas fa-spinner fa-spin" style="color: #10b981;"></i>
<strong id="batchProgressText">${t('oauth.kiro.importingProgress', { current: 0, total: tokens.length })}</strong>
</div>
<div id="batchResultsList" style="max-height: 200px; overflow-y: auto; font-size: 12px; margin-top: 8px;"></div>
`;
const progressText = resultDiv.querySelector('#batchProgressText');
const resultsList = resultDiv.querySelector('#batchResultsList');
let successCount = 0;
let failedCount = 0;
const details = [];
try {
const response = await window.apiClient.post('/kiro/batch-import-tokens', {
refreshTokens: tokens
// 使用 fetch + SSE 获取流式响应
const response = await fetch('/api/kiro/batch-import-tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshTokens: tokens })
});
progressBar.style.width = '100%';
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (response.success) {
// 显示结果
const isAllSuccess = response.failed === 0;
const isAllFailed = response.success === 0;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
let resultClass, resultIcon, resultMessage;
if (isAllSuccess) {
resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;';
resultIcon = 'fa-check-circle';
resultMessage = t('oauth.kiro.importSuccess', { count: response.success });
} else if (isAllFailed) {
resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
resultIcon = 'fa-times-circle';
resultMessage = t('oauth.kiro.importAllFailed', { count: response.failed });
} else {
resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;';
resultIcon = 'fa-exclamation-triangle';
resultMessage = t('oauth.kiro.importPartial', { success: response.success, failed: response.failed });
}
buffer += decoder.decode(value, { stream: true });
resultDiv.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`;
resultDiv.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<i class="fas ${resultIcon}"></i>
<strong>${resultMessage}</strong>
</div>
${response.details && response.details.length > 0 ? `
<div style="max-height: 150px; overflow-y: auto; font-size: 12px; margin-top: 8px;">
${response.details.map(d => `
<div style="padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);">
Token ${d.index}: ${d.success
? `<span style="color: #166534;">✓ ${d.path}</span>`
: `<span style="color: #991b1b;">✗ ${d.error}</span>`}
</div>
`).join('')}
</div>
` : ''}
`;
// 解析 SSE 事件
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 保留最后一个可能不完整的行
progressDiv.style.display = 'none';
let eventType = '';
let eventData = '';
// 如果有成功的,刷新提供商列表
if (response.success > 0) {
loadProviders();
loadConfigList();
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.substring(7).trim();
} else if (line.startsWith('data: ')) {
eventData = line.substring(6).trim();
if (eventType && eventData) {
try {
const data = JSON.parse(eventData);
if (eventType === 'start') {
// 开始事件
console.log(`[Batch Import] Starting import of ${data.total} tokens`);
} else if (eventType === 'progress') {
// 进度更新
const { index, total, current, successCount: sc, failedCount: fc } = data;
successCount = sc;
failedCount = fc;
details.push(current);
// 更新进度条
const percentage = Math.round((index / total) * 100);
progressBar.style.width = `${percentage}%`;
// 更新进度文本
progressText.textContent = t('oauth.kiro.importingProgress', { current: index, total: total });
// 添加结果项
const resultItem = document.createElement('div');
resultItem.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);';
if (current.success) {
resultItem.innerHTML = `Token ${current.index}: <span style="color: #166534;">✓ ${current.path}</span>`;
} else if (current.error === 'duplicate') {
resultItem.innerHTML = `Token ${current.index}: <span style="color: #d97706;">⚠ ${t('oauth.kiro.duplicateToken')}</span>
${current.existingPath ? `<span style="color: #666; font-size: 11px;">(${current.existingPath})</span>` : ''}`;
} else {
resultItem.innerHTML = `Token ${current.index}: <span style="color: #991b1b;">✗ ${current.error}</span>`;
}
resultsList.appendChild(resultItem);
// 自动滚动到底部
resultsList.scrollTop = resultsList.scrollHeight;
} else if (eventType === 'complete') {
// 完成事件
progressBar.style.width = '100%';
progressDiv.style.display = 'none';
const isAllSuccess = data.failedCount === 0;
const isAllFailed = data.successCount === 0;
let resultClass, resultIcon, resultMessage;
if (isAllSuccess) {
resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;';
resultIcon = 'fa-check-circle';
resultMessage = t('oauth.kiro.importSuccess', { count: data.successCount });
} else if (isAllFailed) {
resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
resultIcon = 'fa-times-circle';
resultMessage = t('oauth.kiro.importAllFailed', { count: data.failedCount });
} else {
resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;';
resultIcon = 'fa-exclamation-triangle';
resultMessage = t('oauth.kiro.importPartial', { success: data.successCount, failed: data.failedCount });
}
// 更新结果区域样式
resultDiv.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`;
// 更新标题
const headerDiv = resultDiv.querySelector('div:first-child');
headerDiv.innerHTML = `<i class="fas ${resultIcon}"></i> <strong>${resultMessage}</strong>`;
// 如果有成功的,刷新提供商列表
if (data.successCount > 0) {
loadProviders();
loadConfigList();
}
} else if (eventType === 'error') {
throw new Error(data.error);
}
} catch (parseError) {
console.warn('Failed to parse SSE data:', parseError);
}
eventType = '';
eventData = '';
}
}
}
}
} catch (error) {
console.error('批量导入失败:', error);
console.error('[Kiro Batch Import] Failed:', error);
progressDiv.style.display = 'none';
resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
resultDiv.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
@ -708,7 +807,6 @@ function showKiroBatchImportModal() {
<strong>${t('oauth.kiro.importError')}: ${error.message}</strong>
</div>
`;
progressDiv.style.display = 'none';
} finally {
// 重新启用按钮
textarea.disabled = false;
@ -718,6 +816,536 @@ function showKiroBatchImportModal() {
});
}
/**
* 显示 Kiro AWS 账号导入模态框
* 支持从 AWS SSO cache 目录导入凭据文件或直接粘贴 JSON
*/
function showKiroAwsImportModal() {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.style.display = 'flex';
modal.innerHTML = `
<div class="modal-content" style="max-width: 700px;">
<div class="modal-header">
<h3><i class="fas fa-cloud-upload-alt" style="color: #ff9900;"></i> <span data-i18n="oauth.kiro.awsImport">${t('oauth.kiro.awsImport')}</span></h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="aws-import-instructions" style="margin-bottom: 16px; padding: 12px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px;">
<p style="margin: 0; font-size: 14px; color: #9a3412;">
<i class="fas fa-info-circle"></i>
<span data-i18n="oauth.kiro.awsImportInstructions">${t('oauth.kiro.awsImportInstructions')}</span>
</p>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #c2410c;">
<i class="fas fa-folder-open"></i>
<code style="background: #fed7aa; padding: 2px 6px; border-radius: 4px;">C:\\Users\\{username}\\.aws\\sso\\cache</code>
</p>
</div>
<!-- 输入模式切换 -->
<div class="input-mode-toggle" style="display: flex; gap: 8px; margin-bottom: 16px;">
<button class="mode-btn active" data-mode="file" style="flex: 1; padding: 10px 16px; border: 2px solid #ff9900; border-radius: 8px; background: #fff7ed; color: #9a3412; font-weight: 600; cursor: pointer; transition: all 0.2s;">
<i class="fas fa-file-upload"></i>
<span data-i18n="oauth.kiro.awsModeFile">${t('oauth.kiro.awsModeFile')}</span>
</button>
<button class="mode-btn" data-mode="json" style="flex: 1; padding: 10px 16px; border: 2px solid #d1d5db; border-radius: 8px; background: white; color: #6b7280; font-weight: 600; cursor: pointer; transition: all 0.2s;">
<i class="fas fa-code"></i>
<span data-i18n="oauth.kiro.awsModeJson">${t('oauth.kiro.awsModeJson')}</span>
</button>
</div>
<!-- 文件上传模式 -->
<div class="file-mode-section" id="fileModeSection">
<div class="form-group" style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #374151;">
<span data-i18n="oauth.kiro.awsUploadFiles">${t('oauth.kiro.awsUploadFiles')}</span>
</label>
<div class="aws-file-upload-area" style="border: 2px dashed #d1d5db; border-radius: 8px; padding: 24px; text-align: center; cursor: pointer; transition: all 0.2s;">
<input type="file" id="awsFilesInput" multiple accept=".json" style="display: none;">
<i class="fas fa-cloud-upload-alt" style="font-size: 36px; color: #9ca3af; margin-bottom: 8px;"></i>
<p style="margin: 0; color: #6b7280;" data-i18n="oauth.kiro.awsDragDrop">${t('oauth.kiro.awsDragDrop')}</p>
<p style="margin: 4px 0 0 0; font-size: 12px; color: #9ca3af;" data-i18n="oauth.kiro.awsClickUpload">${t('oauth.kiro.awsClickUpload')}</p>
</div>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #6b7280;">
<i class="fas fa-lightbulb" style="color: #f59e0b;"></i>
<span data-i18n="oauth.kiro.awsFileHint">${t('oauth.kiro.awsFileHint')}</span>
</p>
</div>
<div class="aws-files-list" id="awsFilesList" style="display: none; margin-bottom: 16px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<label style="font-weight: 600; color: #374151;" data-i18n="oauth.kiro.awsSelectedFiles">${t('oauth.kiro.awsSelectedFiles')}</label>
<button id="clearFilesBtn" style="background: none; border: none; color: #ef4444; cursor: pointer; font-size: 12px; padding: 4px 8px; border-radius: 4px; transition: all 0.2s;">
<i class="fas fa-trash-alt"></i>
<span data-i18n="oauth.kiro.awsClearFiles">${t('oauth.kiro.awsClearFiles')}</span>
</button>
</div>
<div id="awsFilesContainer" style="background: #f9fafb; border-radius: 8px; padding: 12px;"></div>
</div>
</div>
<!-- JSON 输入模式 -->
<div class="json-mode-section" id="jsonModeSection" style="display: none;">
<div class="form-group" style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #374151;">
<span data-i18n="oauth.kiro.awsJsonInput">${t('oauth.kiro.awsJsonInput')}</span>
</label>
<textarea
id="awsJsonInput"
rows="12"
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; font-family: monospace; font-size: 13px; resize: vertical;"
placeholder="${t('oauth.kiro.awsJsonPlaceholderSimple')}"
data-i18n-placeholder="oauth.kiro.awsJsonPlaceholderSimple"
></textarea>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #6b7280;">
<i class="fas fa-lightbulb" style="color: #f59e0b;"></i>
<span data-i18n="oauth.kiro.awsJsonHint">${t('oauth.kiro.awsJsonHint')}</span>
</p>
</div>
<details style="margin-bottom: 16px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px;">
<summary style="padding: 12px; cursor: pointer; font-weight: 600; color: #374151; user-select: none;">
<i class="fas fa-code" style="color: #ff9900; margin-right: 8px;"></i>
<span data-i18n="oauth.kiro.awsJsonExample">${t('oauth.kiro.awsJsonExample')}</span>
</summary>
<pre style="margin: 0; padding: 12px; background: #1f2937; color: #10b981; border-radius: 0 0 8px 8px; font-family: monospace; font-size: 12px; overflow-x: auto; white-space: pre;">{
"clientId": "VYZBSTx3Q7QEq1W3Wn8c5nVzLWVhc3QtMQ",
"clientSecret": "eyJraWQi...OAMc",
"expiresAt": "2026-01-09T04:43:18.079944400+00:00",
"accessToken": "aoaAAAAAGlgghoSqRgQK...2tfhmdNZDA",
"authMethod": "IdC",
"provider": "BuilderId",
"refreshToken": "aorAAAAAGn...uKw+E3",
"region": "us-east-1"
}</pre>
</details>
</div>
<div class="aws-validation-result" id="awsValidationResult" style="display: none; margin-bottom: 16px; padding: 12px; border-radius: 8px;"></div>
<div class="aws-json-preview" id="awsJsonPreview" style="display: none; margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #374151;">
<i class="fas fa-eye"></i>
<span data-i18n="oauth.kiro.awsPreviewJson">${t('oauth.kiro.awsPreviewJson')}</span>
</label>
<pre id="awsJsonContent" style="background: #1f2937; color: #10b981; padding: 16px; border-radius: 8px; font-family: monospace; font-size: 12px; max-height: 200px; overflow: auto; white-space: pre-wrap; word-break: break-all;"></pre>
</div>
</div>
<div class="modal-footer">
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
<button class="btn btn-primary aws-import-submit" id="awsImportSubmit" disabled>
<i class="fas fa-check"></i>
<span data-i18n="oauth.kiro.awsConfirmImport">${t('oauth.kiro.awsConfirmImport')}</span>
</button>
</div>
</div>
`;
document.body.appendChild(modal);
const fileInput = modal.querySelector('#awsFilesInput');
const uploadArea = modal.querySelector('.aws-file-upload-area');
const filesListDiv = modal.querySelector('#awsFilesList');
const filesContainer = modal.querySelector('#awsFilesContainer');
const clearFilesBtn = modal.querySelector('#clearFilesBtn');
const validationResult = modal.querySelector('#awsValidationResult');
const jsonPreview = modal.querySelector('#awsJsonPreview');
const jsonContent = modal.querySelector('#awsJsonContent');
const submitBtn = modal.querySelector('#awsImportSubmit');
const closeBtn = modal.querySelector('.modal-close');
const cancelBtn = modal.querySelector('.modal-cancel');
const modeBtns = modal.querySelectorAll('.mode-btn');
const fileModeSection = modal.querySelector('#fileModeSection');
const jsonModeSection = modal.querySelector('#jsonModeSection');
const jsonInputTextarea = modal.querySelector('#awsJsonInput');
let uploadedFiles = [];
let mergedCredentials = null;
let currentMode = 'file';
// 清空文件按钮事件
clearFilesBtn.addEventListener('click', () => {
uploadedFiles = [];
filesContainer.innerHTML = '';
filesListDiv.style.display = 'none';
validationResult.style.display = 'none';
jsonPreview.style.display = 'none';
submitBtn.disabled = true;
mergedCredentials = null;
// 清空 file input
fileInput.value = '';
});
// 清空按钮 hover 效果
clearFilesBtn.addEventListener('mouseenter', () => {
clearFilesBtn.style.background = '#fef2f2';
});
clearFilesBtn.addEventListener('mouseleave', () => {
clearFilesBtn.style.background = 'none';
});
// 模式切换
modeBtns.forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode;
if (mode === currentMode) return;
currentMode = mode;
// 更新按钮样式
modeBtns.forEach(b => {
if (b.dataset.mode === mode) {
b.style.borderColor = '#ff9900';
b.style.background = '#fff7ed';
b.style.color = '#9a3412';
b.classList.add('active');
} else {
b.style.borderColor = '#d1d5db';
b.style.background = 'white';
b.style.color = '#6b7280';
b.classList.remove('active');
}
});
// 切换显示区域
if (mode === 'file') {
fileModeSection.style.display = 'block';
jsonModeSection.style.display = 'none';
// 重新验证文件模式的内容
validateAndPreview();
} else {
fileModeSection.style.display = 'none';
jsonModeSection.style.display = 'block';
// 验证 JSON 输入
validateJsonInput();
}
});
});
// JSON 输入实时验证
jsonInputTextarea.addEventListener('input', () => {
validateJsonInput();
});
// 验证 JSON 输入
function validateJsonInput() {
const inputValue = jsonInputTextarea.value.trim();
if (!inputValue) {
validationResult.style.display = 'none';
jsonPreview.style.display = 'none';
submitBtn.disabled = true;
mergedCredentials = null;
return;
}
try {
mergedCredentials = JSON.parse(inputValue);
validateAndShowResult();
} catch (error) {
validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
validationResult.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<i class="fas fa-exclamation-triangle"></i>
<strong data-i18n="oauth.kiro.awsJsonParseError">${t('oauth.kiro.awsJsonParseError')}</strong>
</div>
<p style="margin: 8px 0 0 0; font-size: 12px;">${error.message}</p>
`;
jsonPreview.style.display = 'none';
submitBtn.disabled = true;
mergedCredentials = null;
}
}
// 文件上传区域交互
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.style.borderColor = '#ff9900';
uploadArea.style.background = '#fffbeb';
});
uploadArea.addEventListener('dragleave', (e) => {
e.preventDefault();
uploadArea.style.borderColor = '#d1d5db';
uploadArea.style.background = 'transparent';
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.style.borderColor = '#d1d5db';
uploadArea.style.background = 'transparent';
const files = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.json'));
if (files.length > 0) {
processFiles(files);
}
});
fileInput.addEventListener('change', () => {
const files = Array.from(fileInput.files);
if (files.length > 0) {
processFiles(files);
}
});
// 处理上传的文件(支持追加)
async function processFiles(files) {
for (const file of files) {
// 检查是否已存在同名文件
const existingIndex = uploadedFiles.findIndex(f => f.name === file.name);
try {
const content = await readFileAsText(file);
const json = JSON.parse(content);
if (existingIndex >= 0) {
// 替换已存在的同名文件
uploadedFiles[existingIndex] = {
name: file.name,
content: json
};
showToast(t('common.info'), t('oauth.kiro.awsFileReplaced', { filename: file.name }), 'info');
} else {
// 追加新文件
uploadedFiles.push({
name: file.name,
content: json
});
}
} catch (error) {
console.error(`Failed to parse ${file.name}:`, error);
showToast(t('common.error'), t('oauth.kiro.awsParseError', { filename: file.name }), 'error');
}
}
// 重新渲染文件列表
renderFilesList();
filesListDiv.style.display = uploadedFiles.length > 0 ? 'block' : 'none';
// 清空 file input 以便可以再次选择相同文件
fileInput.value = '';
validateAndPreview();
}
// 渲染文件列表
function renderFilesList() {
filesContainer.innerHTML = '';
for (const file of uploadedFiles) {
const fileDiv = document.createElement('div');
fileDiv.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 8px; background: white; border-radius: 4px; margin-bottom: 4px;';
fileDiv.dataset.filename = file.name;
const fields = Object.keys(file.content).slice(0, 5).join(', ');
const moreFields = Object.keys(file.content).length > 5 ? '...' : '';
fileDiv.innerHTML = `
<div style="flex: 1; min-width: 0;">
<i class="fas fa-file-code" style="color: #ff9900; margin-right: 8px;"></i>
<span style="font-weight: 500;">${file.name}</span>
<div style="font-size: 11px; color: #6b7280; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${fields}${moreFields}</div>
</div>
<button class="remove-file-btn" data-filename="${file.name}" style="background: none; border: none; color: #ef4444; cursor: pointer; padding: 4px 8px; margin-left: 8px; flex-shrink: 0;">
<i class="fas fa-times"></i>
</button>
`;
filesContainer.appendChild(fileDiv);
}
// 添加删除文件按钮事件
filesContainer.querySelectorAll('.remove-file-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const filename = e.currentTarget.dataset.filename;
uploadedFiles = uploadedFiles.filter(f => f.name !== filename);
renderFilesList();
filesListDiv.style.display = uploadedFiles.length > 0 ? 'block' : 'none';
validateAndPreview();
});
});
}
// 验证并预览(文件模式)
function validateAndPreview() {
if (currentMode !== 'file') return;
if (uploadedFiles.length === 0) {
validationResult.style.display = 'none';
jsonPreview.style.display = 'none';
submitBtn.disabled = true;
mergedCredentials = null;
return;
}
// 智能合并所有文件的内容
// 如果多个文件都有 expiresAt使用包含 refreshToken 的文件中的 expiresAt
mergedCredentials = {};
let expiresAtFromRefreshTokenFile = null;
for (const file of uploadedFiles) {
// 如果这个文件包含 refreshToken记录它的 expiresAt
if (file.content.refreshToken && file.content.expiresAt) {
expiresAtFromRefreshTokenFile = file.content.expiresAt;
}
Object.assign(mergedCredentials, file.content);
}
// 如果找到了包含 refreshToken 的文件的 expiresAt使用它
if (expiresAtFromRefreshTokenFile) {
mergedCredentials.expiresAt = expiresAtFromRefreshTokenFile;
}
validateAndShowResult();
}
// 验证并显示结果(通用)
function validateAndShowResult() {
if (!mergedCredentials) {
validationResult.style.display = 'none';
jsonPreview.style.display = 'none';
submitBtn.disabled = true;
return;
}
// 检查所有四个必需字段
const hasClientId = !!mergedCredentials.clientId;
const hasClientSecret = !!mergedCredentials.clientSecret;
const hasAccessToken = !!mergedCredentials.accessToken;
const hasRefreshToken = !!mergedCredentials.refreshToken;
// 所有四个字段都必须存在
const isValid = hasClientId && hasClientSecret && hasAccessToken && hasRefreshToken;
// 构建字段状态列表
const fieldsList = [
{ key: 'clientId', has: hasClientId },
{ key: 'clientSecret', has: hasClientSecret },
{ key: 'accessToken', has: hasAccessToken },
{ key: 'refreshToken', has: hasRefreshToken }
];
const fieldsHtml = fieldsList.map(f => `
<li>${f.key}: ${f.has
? `<code style="background: #dcfce7; padding: 1px 4px; border-radius: 2px; color: #166534;">✓ ${t('common.found')}</code>`
: `<code style="background: #fecaca; padding: 1px 4px; border-radius: 2px; color: #991b1b;">✗ ${t('common.missing')}</code>`
}</li>
`).join('');
if (isValid) {
validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;';
validationResult.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<i class="fas fa-check-circle"></i>
<strong data-i18n="oauth.kiro.awsValidationSuccess">${t('oauth.kiro.awsValidationSuccess')}</strong>
</div>
<ul style="margin: 8px 0 0 24px; font-size: 13px; list-style: none; padding: 0;">
${fieldsHtml}
</ul>
`;
submitBtn.disabled = false;
} else {
const missingCount = fieldsList.filter(f => !f.has).length;
validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
validationResult.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<i class="fas fa-exclamation-triangle"></i>
<strong>${t('oauth.kiro.awsValidationFailed')}</strong>
<span style="font-weight: normal; font-size: 12px;">(${t('oauth.kiro.awsMissingFields', { count: missingCount })})</span>
</div>
<ul style="margin: 8px 0 0 24px; font-size: 13px; list-style: none; padding: 0;">
${fieldsHtml}
</ul>
<p style="margin: 12px 0 0 0; font-size: 12px; padding: 8px; background: #fee2e2; border-radius: 4px;">
<i class="fas fa-lightbulb" style="color: #dc2626;"></i>
<span data-i18n="oauth.kiro.awsUploadMore">${t('oauth.kiro.awsUploadMore')}</span>
</p>
`;
submitBtn.disabled = true;
}
// 显示 JSON 预览
jsonPreview.style.display = 'block';
// 隐藏敏感信息的部分内容
const previewData = { ...mergedCredentials };
if (previewData.clientSecret) {
previewData.clientSecret = previewData.clientSecret.substring(0, 8) + '...' + previewData.clientSecret.slice(-4);
}
if (previewData.accessToken) {
previewData.accessToken = previewData.accessToken.substring(0, 20) + '...' + previewData.accessToken.slice(-10);
}
if (previewData.refreshToken) {
previewData.refreshToken = previewData.refreshToken.substring(0, 10) + '...' + previewData.refreshToken.slice(-6);
}
jsonContent.textContent = JSON.stringify(previewData, null, 2);
}
// 读取文件内容
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = (e) => reject(e);
reader.readAsText(file);
});
}
// 关闭按钮事件
[closeBtn, cancelBtn].forEach(btn => {
btn.addEventListener('click', () => {
modal.remove();
});
});
// 提交按钮事件
submitBtn.addEventListener('click', async () => {
if (!mergedCredentials) {
showToast(t('common.warning'), t('oauth.kiro.awsNoCredentials'), 'warning');
return;
}
// 禁用按钮
submitBtn.disabled = true;
submitBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> <span>${t('oauth.kiro.awsImporting')}</span>`;
try {
// 确保 authMethod 为 builder-idAWS 账号模式)
if (!mergedCredentials.authMethod) {
mergedCredentials.authMethod = 'builder-id';
}
const response = await window.apiClient.post('/kiro/import-aws-credentials', {
credentials: mergedCredentials
});
if (response.success) {
showToast(t('common.success'), t('oauth.kiro.awsImportSuccess'), 'success');
modal.remove();
// 刷新提供商列表和配置列表
loadProviders();
loadConfigList();
} else if (response.error === 'duplicate') {
// 显示重复凭据警告
const existingPath = response.existingPath || '';
showToast(t('common.warning'), t('oauth.kiro.duplicateCredentials') + (existingPath ? ` (${existingPath})` : ''), 'warning');
} else {
showToast(t('common.error'), response.error || t('oauth.kiro.awsImportFailed'), 'error');
}
} catch (error) {
console.error('AWS import failed:', error);
showToast(t('common.error'), t('oauth.kiro.awsImportFailed') + ': ' + error.message, 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = `<i class="fas fa-check"></i> <span data-i18n="oauth.kiro.awsConfirmImport">${t('oauth.kiro.awsConfirmImport')}</span>`;
}
});
}
/**
* 执行生成授权链接
* @param {string} providerType - 提供商类型