Merge pull request #194 from leonaii/main
feat(kiro): 添加 AWS SSO 凭据导入和批量导入流式进度功能
This commit is contained in:
commit
e1d6920137
4 changed files with 1196 additions and 60 deletions
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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">×</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-id(AWS 账号模式)
|
||||
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 - 提供商类型
|
||||
|
|
|
|||
Loading…
Reference in a new issue