From 67019666b95bb8c4cd8c070477e482da581ef940 Mon Sep 17 00:00:00 2001 From: jingrow Date: Sun, 4 Jan 2026 04:30:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E4=BA=8E=E4=B8=8EOAuth2=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E7=99=BB=E9=99=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/router/index.ts | 21 +- src/locales/zh-CN.json | 72 +++ src/shared/api/account.ts | 366 ++++++++++++ src/shared/api/oauth2.ts | 297 +++++++++ src/shared/components/ClickToCopyField.vue | 86 +++ src/shared/stores/auth.ts | 166 ++++- src/shared/utils/fetchInterceptor.ts | 56 +- src/shared/utils/oauth2.ts | 173 ++++++ src/views/OAuthCallback.vue | 100 ++++ src/views/auth/Login.vue | 20 +- src/views/settings/DeveloperSettings.vue | 393 ++++++++++++ src/views/settings/ProfileSettings.vue | 665 +++++++++++++++++++++ src/views/settings/Settings.vue | 521 ++-------------- 13 files changed, 2422 insertions(+), 514 deletions(-) create mode 100644 src/shared/api/account.ts create mode 100644 src/shared/api/oauth2.ts create mode 100644 src/shared/components/ClickToCopyField.vue create mode 100644 src/shared/utils/oauth2.ts create mode 100644 src/views/OAuthCallback.vue create mode 100644 src/views/settings/DeveloperSettings.vue create mode 100644 src/views/settings/ProfileSettings.vue diff --git a/src/app/router/index.ts b/src/app/router/index.ts index 102c776..7730d44 100644 --- a/src/app/router/index.ts +++ b/src/app/router/index.ts @@ -16,6 +16,12 @@ const router = createRouter({ component: () => import('../../views/auth/Signup.vue'), meta: { requiresAuth: false } }, + { + path: '/oauth/callback', + name: 'OAuthCallback', + component: () => import('../../views/OAuthCallback.vue'), + meta: { requiresAuth: false } + }, { path: '', name: 'HomePage', @@ -95,7 +101,20 @@ const router = createRouter({ { path: 'settings', name: 'Settings', - component: () => import('../../views/settings/Settings.vue') + component: () => import('../../views/settings/Settings.vue'), + redirect: { name: 'SettingsProfile' }, + children: [ + { + path: 'profile', + name: 'SettingsProfile', + component: () => import('../../views/settings/ProfileSettings.vue') + }, + { + path: 'developer', + name: 'SettingsDeveloper', + component: () => import('../../views/settings/DeveloperSettings.vue') + } + ] }, { path: 'tools', diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 363dd4f..6e163aa 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -21,6 +21,78 @@ "Agent Detail": "智能体详情", "Flow Builder": "流程编排", "Profile": "个人资料", + "Developer": "开发者", + "Edit": "编辑", + "Marketplace Developer": "应用市场开发者", + "Become a Developer": "成为开发者", + "Developers can publish their apps on the marketplace for users to subscribe to, either paid or free.": "开发者可以在应用市场发布自己的应用,供用户付费或免费订阅。", + "Reset Password": "重置密码", + "Change your account login password": "更改您的账户登录密码", + "Disable Account": "禁用账户", + "Disable your account and stop billing": "禁用您的账户并停止计费", + "Disable": "禁用", + "Update Profile Information": "更新个人资料", + "Save Changes": "保存更改", + "Current Password": "当前密码", + "New Password": "新密码", + "After confirming this action:": "确认此操作后:", + "Your account will be disabled": "您的账户将被禁用", + "Your account billing will stop": "您的账户计费将停止", + "You can log in later to re-enable your account. Do you want to continue?": "您可以稍后登录以重新启用您的账户。是否继续?", + "Become a Marketplace Developer?": "成为应用市场开发者?", + "After confirmation, you will be able to publish apps to our marketplace.": "确认后,您将能够在我们的应用市场发布应用。", + "Confirm": "确认", + "Cancel": "取消", + "You can now publish apps to our marketplace": "您现在可以在我们的应用市场发布应用了", + "Failed to mark you as a developer": "标记为开发者失败", + "Your profile has been updated successfully": "您的个人资料已成功更新", + "Failed to update profile": "更新个人资料失败", + "Password reset successfully": "密码重置成功", + "Failed to reset password": "重置密码失败", + "Your account has been disabled successfully": "您的账户已成功禁用", + "Failed to disable account": "禁用账户失败", + "API Access": "API访问", + "API key and API secret can be used to access": "API密钥和API密钥可用于访问", + "Jingrow API": "Jingrow API", + "You don't have an API key yet. Click the button above to create one.": "您还没有API密钥。请点击上方按钮创建一个。", + "Please copy the API key immediately. You will not be able to view it again!": "请立即复制API密钥。您将无法再次查看!", + "Regenerate API Key": "重新生成API密钥", + "Create New API Key": "创建新的API密钥", + "API key regenerated successfully": "API密钥重新生成成功", + "API key created successfully": "API密钥创建成功", + "Failed to create API key": "创建API密钥失败", + "SSH Keys": "SSH密钥", + "Add SSH Key": "添加SSH密钥", + "Add a new SSH key to your account": "向您的账户添加新的SSH密钥", + "SSH key is required": "需要SSH密钥", + "Invalid SSH key format": "SSH密钥格式无效", + "SSH key added successfully": "SSH密钥添加成功", + "Failed to add SSH key": "添加SSH密钥失败", + "SSH key deleted successfully": "SSH密钥删除成功", + "Failed to delete SSH key": "删除SSH密钥失败", + "SSH key updated successfully": "SSH密钥更新成功", + "Failed to set SSH key as default": "设置默认SSH密钥失败", + "Set as Default": "设为默认", + "Default": "默认", + "No SSH keys": "没有SSH密钥", + "Add New SSH Key": "添加新的SSH密钥", + "Starts with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'": "以 'ssh-rsa'、'ecdsa-sha2-nistp256'、'ecdsa-sha2-nistp384'、'ecdsa-sha2-nistp521'、'ssh-ed25519'、'sk-ecdsa-sha2-nistp256@openssh.com' 或 'sk-ssh-ed25519@openssh.com' 开头", + "Copied to clipboard": "已复制到剪贴板", + "Copy": "复制", + "Copied": "已复制", + "Copy failed, please copy manually": "复制失败,请手动复制", + "SSH Fingerprint": "SSH指纹", + "Added Time": "添加时间", + "Actions": "操作", + "Are you sure you want to delete this SSH key?": "您确定要删除此SSH密钥吗?", + "Phone": "手机", + "Email": "邮箱", + "First Name": "名", + "Last Name": "姓", + "Enable Account": "启用账户", + "Enable your account and resume billing": "启用您的账户并恢复计费", + "Your account has been enabled successfully": "您的账户已成功启用", + "Failed to enable account": "启用账户失败", "Logout": "退出登录", "Logged out": "已退出登录", "AI Agent Workflow Platform": "AI Agent 工作流平台", diff --git a/src/shared/api/account.ts b/src/shared/api/account.ts new file mode 100644 index 0000000..ad583e8 --- /dev/null +++ b/src/shared/api/account.ts @@ -0,0 +1,366 @@ +/** + * Account API - 用户账户相关 API + */ +import axios from 'axios' +import { getBearerTokenHeaders } from './oauth2' + +/** + * 获取后端 URL(开发环境使用相对路径通过代理,生产环境使用绝对 URL) + */ +const getBackendUrl = () => { + // 在开发环境中,使用相对路径通过 Vite 代理,避免 CORS 问题 + // 在生产环境中,使用绝对 URL + if (import.meta.env.DEV) { + // 开发环境:返回空字符串,使用相对路径,Vite 代理会转发到后端 + return '' + } else { + // 生产环境:使用绝对 URL + const backendUrl = import.meta.env.VITE_JINGROW_BACKEND_URL || 'https://cloud.jingrow.com' + // 确保 URL 不以 /dashboard 结尾(API 路径应该直接在根路径下) + return backendUrl.replace(/\/dashboard\/?$/, '') + } +} + +/** + * 用户资料接口 + */ +export interface UserProfile { + user: string + username?: string + email?: string + mobile_no?: string + phone?: string + first_name?: string + last_name?: string + user_type?: string + user_image?: string + is_developer?: boolean + api_key?: string +} + +/** + * 获取完整账户信息 + */ +export async function getAccountInfo(): Promise<{ success: boolean; data?: any; message?: string }> { + try { + const response = await axios.get( + `${getBackendUrl()}/api/action/jcloud.api.account.get`, + { + headers: getBearerTokenHeaders() + } + ) + + return { + success: true, + data: response.data.message || response.data + } + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message || '获取账户信息失败' + } + } +} + +/** + * 更新用户资料 + */ +export async function updateProfile(data: { + first_name?: string + last_name?: string + email?: string + username?: string + mobile_no?: string +}): Promise<{ success: boolean; data?: any; message?: string }> { + try { + const response = await axios.post( + `${getBackendUrl()}/api/action/jcloud.api.account.update_profile`, + data, + { + headers: getBearerTokenHeaders() + } + ) + + return { + success: true, + data: response.data.message || response.data, + message: '用户资料更新成功' + } + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message || '更新用户资料失败' + } + } +} + +/** + * 更新用户头像 + */ +export async function updateProfilePicture(file: File): Promise<{ success: boolean; data?: any; message?: string }> { + try { + const formData = new FormData() + formData.append('file', file) + + const response = await axios.post( + `${getBackendUrl()}/api/action/jcloud.api.account.update_profile_picture`, + formData, + { + headers: { + ...getBearerTokenHeaders(), + 'Content-Type': 'multipart/form-data' + } + } + ) + + return { + success: true, + data: response.data.message || response.data, + message: '头像更新成功' + } + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message || '更新头像失败' + } + } +} + +/** + * 创建或重新生成 API Key + */ +export async function createApiSecret(): Promise<{ success: boolean; data?: { api_key: string; api_secret: string }; message?: string }> { + try { + const response = await axios.post( + `${getBackendUrl()}/api/action/jcloud.api.account.create_api_secret`, + {}, + { + headers: getBearerTokenHeaders() + } + ) + + return { + success: true, + data: response.data.message || response.data, + message: 'API Key 创建成功' + } + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message || '创建 API Key 失败' + } + } +} + +/** + * 获取用户 SSH 密钥列表 + */ +export async function getUserSSHKeys(): Promise<{ success: boolean; data?: any[]; message?: string }> { + try { + const response = await axios.get( + `${getBackendUrl()}/api/action/jcloud.api.account.get_user_ssh_keys`, + { + headers: getBearerTokenHeaders() + } + ) + + return { + success: true, + data: response.data.message || response.data + } + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message || '获取 SSH 密钥失败' + } + } +} + +/** + * 添加 SSH 密钥 + */ +export async function addSSHKey(sshKey: string): Promise<{ success: boolean; data?: any; message?: string }> { + try { + const response = await axios.post( + `${getBackendUrl()}/api/action/jcloud.api.client.insert`, + { + pagetype: 'SSH Key', + ssh_key: sshKey + }, + { + headers: getBearerTokenHeaders() + } + ) + + return { + success: true, + data: response.data.message || response.data, + message: 'SSH 密钥添加成功' + } + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message || '添加 SSH 密钥失败' + } + } +} + +/** + * 删除 SSH 密钥 + */ +export async function deleteSSHKey(keyName: string): Promise<{ success: boolean; message?: string }> { + try { + await axios.post( + `${getBackendUrl()}/api/action/jcloud.api.client.delete`, + { + pagetype: 'SSH Key', + name: keyName + }, + { + headers: getBearerTokenHeaders() + } + ) + + return { + success: true, + message: 'SSH 密钥删除成功' + } + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message || '删除 SSH 密钥失败' + } + } +} + +/** + * 设置 SSH 密钥为默认 + */ +export async function setSSHKeyAsDefault(keyName: string): Promise<{ success: boolean; message?: string }> { + try { + await axios.post( + `${getBackendUrl()}/api/action/jcloud.api.account.mark_key_as_default`, + { + name: keyName + }, + { + headers: getBearerTokenHeaders() + } + ) + + return { + success: true, + message: 'SSH 密钥已设置为默认' + } + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message || '设置默认 SSH 密钥失败' + } + } +} + +/** + * 重置密码 + */ +export async function resetPassword(oldPassword: string, newPassword: string): Promise<{ success: boolean; message?: string }> { + try { + const response = await axios.post( + `${getBackendUrl()}/api/action/jcloud.api.account.reset_password`, + { + old_password: oldPassword, + new_password: newPassword + }, + { + headers: getBearerTokenHeaders() + } + ) + + return { + success: true, + message: response.data.message || '密码重置成功' + } + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message || '重置密码失败' + } + } +} + +/** + * 禁用账户 + */ +export async function disableAccount(totpCode?: string): Promise<{ success: boolean; message?: string }> { + try { + const response = await axios.post( + `${getBackendUrl()}/api/action/jcloud.api.account.disable_account`, + { + totp_code: totpCode + }, + { + headers: getBearerTokenHeaders() + } + ) + + return { + success: true, + message: response.data.message || '账户已禁用' + } + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message || '禁用账户失败' + } + } +} + +/** + * 启用账户 + */ +export async function enableAccount(): Promise<{ success: boolean; message?: string }> { + try { + const response = await axios.post( + `${getBackendUrl()}/api/action/jcloud.api.account.enable_account`, + {}, + { + headers: getBearerTokenHeaders() + } + ) + + return { + success: true, + message: response.data.message || '账户已启用' + } + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message || '启用账户失败' + } + } +} + +/** + * 成为开发者 + */ +export async function becomeDeveloper(): Promise<{ success: boolean; message?: string }> { + try { + const response = await axios.post( + `${getBackendUrl()}/api/action/jcloud.api.account.become_developer`, + {}, + { + headers: getBearerTokenHeaders() + } + ) + + return { + success: true, + message: response.data.message || '已成为开发者' + } + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message || '成为开发者失败' + } + } +} diff --git a/src/shared/api/oauth2.ts b/src/shared/api/oauth2.ts new file mode 100644 index 0000000..3d050e8 --- /dev/null +++ b/src/shared/api/oauth2.ts @@ -0,0 +1,297 @@ +/** + * OAuth2 API + */ +import axios from 'axios' +import { + generatePKCE, + storeAccessToken, + storeRefreshToken, + getAccessToken, + getRefreshToken, + clearTokens, + storeCodeVerifier, + getCodeVerifier, + clearCodeVerifier, + type TokenInfo +} from '../utils/oauth2' + +// 从环境变量或配置获取 OAuth2 配置 +const getOAuth2Config = () => { + // 从环境变量或配置中获取 + let backendUrl = import.meta.env.VITE_JINGROW_BACKEND_URL || 'https://cloud.jingrow.com' + + // 确保 URL 不以 /dashboard 结尾(API 路径应该直接在根路径下) + // 如果用户设置了 /dashboard,自动移除 + backendUrl = backendUrl.replace(/\/dashboard\/?$/, '') + + const clientId = import.meta.env.VITE_OAUTH2_CLIENT_ID || 'i679osfadt' + const redirectUri = `${window.location.origin}/oauth/callback` + + return { + backendUrl, + clientId, + redirectUri, + scopes: 'all openid desk:read desk:write' + } +} + +/** + * 获取 API URL(开发环境使用相对路径通过代理,生产环境使用绝对 URL) + */ +const getApiUrl = (apiPath: string): string => { + const config = getOAuth2Config() + // 在开发环境中,使用相对路径通过 Vite 代理,避免 CORS 问题 + // 在生产环境中,使用绝对 URL + if (import.meta.env.DEV) { + // 开发环境:使用相对路径,Vite 代理会转发到后端 + return apiPath + } else { + // 生产环境:使用绝对 URL + return `${config.backendUrl}${apiPath}` + } +} + +/** + * 获取授权 URL + */ +export async function getAuthorizationUrl(state?: string): Promise { + const config = getOAuth2Config() + const { codeVerifier, codeChallenge } = await generatePKCE() + + // 存储 code verifier 用于后续交换 token + storeCodeVerifier(codeVerifier) + + // 调试日志:输出 redirect_uri 以便检查后端配置 + console.log('OAuth2 授权 URL 配置:', { + backendUrl: config.backendUrl, + clientId: config.clientId, + redirectUri: config.redirectUri, + scopes: config.scopes, + currentOrigin: window.location.origin + }) + + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: config.redirectUri, + response_type: 'code', + scope: config.scopes, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + ...(state && { state }) + }) + + // 确保 API 路径正确(不应该包含 /dashboard) + const apiPath = '/api/action/jingrow.integrations.oauth2.authorize' + const authUrl = `${config.backendUrl}${apiPath}?${params.toString()}` + + console.log('OAuth2 授权 URL 配置:', { + backendUrl: config.backendUrl, + apiPath, + fullUrl: authUrl, + redirectUri: config.redirectUri, + clientId: config.clientId + }) + console.log('期望的重定向 URI:', config.redirectUri) + console.log('请确保后端 OAuth Client 的 redirect_uris 包含:', config.redirectUri) + + return authUrl +} + +/** + * 用授权码换取 access token + */ +export async function exchangeCodeForToken(code: string): Promise { + const config = getOAuth2Config() + const codeVerifier = getCodeVerifier() + + if (!codeVerifier) { + throw new Error('Code verifier not found. Please restart the authorization flow.') + } + + // 调试日志:确认使用的 redirect_uri + console.log('OAuth2 Token 交换配置:', { + redirectUri: config.redirectUri, + clientId: config.clientId, + backendUrl: config.backendUrl + }) + + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: config.redirectUri, + client_id: config.clientId, + code_verifier: codeVerifier + }) + + try { + // 使用 getApiUrl 获取正确的 API URL(开发环境通过代理,生产环境使用绝对 URL) + const apiUrl = getApiUrl('/api/action/jingrow.integrations.oauth2.get_token') + const response = await axios.post( + apiUrl, + params.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + } + } + ) + + const tokenInfo: TokenInfo = response.data + + // 存储 token + storeAccessToken(tokenInfo.access_token, tokenInfo.expires_in) + if (tokenInfo.refresh_token) { + storeRefreshToken(tokenInfo.refresh_token) + } + + // 清除 code verifier + clearCodeVerifier() + + return tokenInfo + } catch (error: any) { + clearCodeVerifier() + throw new Error( + error.response?.data?.error_description || + error.response?.data?.error || + error.message || + 'Failed to exchange authorization code for token' + ) + } +} + +/** + * 刷新 access token + */ +export async function refreshAccessToken(): Promise { + const config = getOAuth2Config() + const refreshToken = getRefreshToken() + + if (!refreshToken) { + throw new Error('No refresh token available') + } + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: config.clientId + }) + + try { + // 使用 getApiUrl 获取正确的 API URL(开发环境通过代理,生产环境使用绝对 URL) + const apiUrl = getApiUrl('/api/action/jingrow.integrations.oauth2.get_token') + const response = await axios.post( + apiUrl, + params.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + } + } + ) + + const tokenInfo: TokenInfo = response.data + + // 更新存储的 token + storeAccessToken(tokenInfo.access_token, tokenInfo.expires_in) + if (tokenInfo.refresh_token) { + storeRefreshToken(tokenInfo.refresh_token) + } + + return tokenInfo + } catch (error: any) { + // 刷新失败,清除所有 token + clearTokens() + throw new Error( + error.response?.data?.error_description || + error.response?.data?.error || + error.message || + 'Failed to refresh access token' + ) + } +} + +/** + * 撤销 token + */ +export async function revokeToken(token?: string, tokenTypeHint: 'access_token' | 'refresh_token' = 'access_token'): Promise { + const config = getOAuth2Config() + const tokenToRevoke = token || (tokenTypeHint === 'access_token' ? getAccessToken() : getRefreshToken()) + + if (!tokenToRevoke) { + return + } + + const params = new URLSearchParams({ + token: tokenToRevoke, + token_type_hint: tokenTypeHint + }) + + try { + // 使用 getApiUrl 获取正确的 API URL(开发环境通过代理,生产环境使用绝对 URL) + const apiUrl = getApiUrl('/api/action/jingrow.integrations.oauth2.revoke_token') + await axios.post( + apiUrl, + params.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ) + + // 清除本地存储的 token + clearTokens() + } catch (error: any) { + // 即使撤销失败,也清除本地 token + clearTokens() + console.error('Failed to revoke token:', error) + } +} + +/** + * 获取用户信息(OpenID Connect userinfo) + */ +export async function getUserInfo(): Promise { + const config = getOAuth2Config() + const accessToken = getAccessToken() + + if (!accessToken) { + throw new Error('No access token available') + } + + // 使用 getApiUrl 获取正确的 API URL(开发环境通过代理,生产环境使用绝对 URL) + const apiUrl = getApiUrl('/api/action/jingrow.integrations.oauth2.openid_profile') + const response = await axios.get( + apiUrl, + { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/json' + } + } + ) + + return response.data +} + +/** + * 获取 Bearer token 认证头 + */ +export function getBearerTokenHeaders(): Record { + const accessToken = getAccessToken() + + if (!accessToken) { + return { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + } + + return { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } +} diff --git a/src/shared/components/ClickToCopyField.vue b/src/shared/components/ClickToCopyField.vue new file mode 100644 index 0000000..13496e2 --- /dev/null +++ b/src/shared/components/ClickToCopyField.vue @@ -0,0 +1,86 @@ + + + diff --git a/src/shared/stores/auth.ts b/src/shared/stores/auth.ts index 063c7e4..df81069 100644 --- a/src/shared/stores/auth.ts +++ b/src/shared/stores/auth.ts @@ -2,6 +2,9 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { loginApi, getUserInfoApi, logoutApi, isCookieExpired, getSessionUser } from '../api/auth' import { setInitializingAuth } from '../utils/fetchInterceptor' +import { getAccessToken, isTokenExpired, clearTokens } from '../utils/oauth2' +import { refreshAccessToken } from '../api/oauth2' +import { getAccountInfo } from '../api/account' export interface User { user: string @@ -15,7 +18,10 @@ export const useAuthStore = defineStore('auth', () => { const loading = ref(false) const isAuthenticated = ref(false) - const isLoggedIn = computed(() => isAuthenticated.value && !!user.value) + const isLoggedIn = computed(() => { + const hasToken = !!getAccessToken() + return isAuthenticated.value && !!user.value && hasToken + }) // 判断是否是认证错误 const isAuthError = (error: any): boolean => { @@ -68,11 +74,29 @@ export const useAuthStore = defineStore('auth', () => { user.value = null isAuthenticated.value = false clearUserFromStorage() + // 清除 OAuth2 token + clearTokens() } // 验证并更新用户信息 const validateAndUpdateUser = async (): Promise => { try { + // 优先使用 OAuth2 token 获取账户信息 + const accessToken = getAccessToken() + if (accessToken) { + const accountInfo = await getAccountInfo() + if (accountInfo.success && accountInfo.data) { + const userData = accountInfo.data.user || accountInfo.data + const userInfo: User = { + user: userData.name || userData.user || '', + user_type: userData.user_type || 'System User' + } + setUserState(userInfo) + return true + } + } + + // 如果没有 OAuth2 token,尝试使用 Cookie 认证(向后兼容) const userInfo = await getUserInfoApi() setUserState(userInfo) return true @@ -85,7 +109,91 @@ export const useAuthStore = defineStore('auth', () => { } } - const login = async (username: string, password: string) => { + // OAuth2 登录:重定向到授权页面 + const login = async (state?: string) => { + try { + const { getAuthorizationUrl } = await import('../api/oauth2') + const authUrl = await getAuthorizationUrl(state) + window.location.href = authUrl + } catch (error: any) { + console.error('OAuth2 登录错误:', error) + throw new Error(error.message || '启动 OAuth2 登录失败') + } + } + + // 处理 OAuth2 回调 + const handleOAuthCallback = async (): Promise<{ success: boolean; error?: string }> => { + try { + const { extractAuthorizationCode, extractOAuthError } = await import('../utils/oauth2') + const { exchangeCodeForToken } = await import('../api/oauth2') + + // 调试:输出当前 URL + console.log('OAuth2 回调处理 - 当前 URL:', window.location.href) + console.log('OAuth2 回调处理 - URL 参数:', window.location.search) + + // 检查是否有错误 + const error = extractOAuthError() + if (error) { + console.error('OAuth2 授权错误:', error) + return { + success: false, + error: error.error_description || error.error || '授权失败' + } + } + + // 获取授权码 + const code = extractAuthorizationCode() + console.log('提取的授权码:', code ? '已找到' : '未找到') + + if (!code) { + // 检查是否还在授权页面(可能还没有完成授权) + if (window.location.href.includes('/api/action/jingrow.integrations.oauth2.authorize')) { + console.warn('仍在授权页面,等待用户完成授权...') + return { + success: false, + error: '请先完成授权确认。如果已授权但仍停留在此页面,请检查后端 OAuth Client 的 redirect_uris 配置。' + } + } + + // 检查是否有其他错误信息 + const urlParams = new URLSearchParams(window.location.search) + const allParams = Object.fromEntries(urlParams.entries()) + console.log('URL 中的所有参数:', allParams) + + return { + success: false, + error: `未找到授权码。当前 URL: ${window.location.href}。请确保后端 OAuth Client 的 redirect_uris 包含: http://localhost:3100/oauth/callback` + } + } + + // 用授权码换取 token + console.log('开始用授权码换取 token...') + await exchangeCodeForToken(code) + console.log('Token 交换成功') + + // 验证并获取用户信息 + console.log('开始验证并获取用户信息...') + const success = await validateAndUpdateUser() + if (success) { + console.log('用户信息获取成功') + return { success: true } + } else { + return { + success: false, + error: '获取用户信息失败' + } + } + } catch (error: any) { + console.error('处理 OAuth2 回调错误:', error) + return { + success: false, + error: error.message || '处理授权回调失败' + } + } + } + + // 传统用户名密码登录(向后兼容) + const loginWithPassword = async (username: string, password: string) => { loading.value = true try { const response = await loginApi(username, password) @@ -106,6 +214,16 @@ export const useAuthStore = defineStore('auth', () => { const logout = async () => { try { + // 尝试撤销 OAuth2 token + try { + const { revokeToken } = await import('../api/oauth2') + await revokeToken() + } catch (error) { + // OAuth2 撤销失败,继续执行登出 + console.warn('撤销 OAuth2 token 失败:', error) + } + + // 尝试使用 Cookie 登出(向后兼容) await logoutApi() } catch (error) { console.error('登出错误:', error) @@ -118,20 +236,44 @@ export const useAuthStore = defineStore('auth', () => { setInitializingAuth(true) try { - // 检查cookie状态 - const userId = getSessionUser() - const hasSessionCookie = !isCookieExpired() - const hasCookie = userId || hasSessionCookie - - // 如果有cookie,尝试验证并获取用户信息 - if (hasCookie) { + // 优先检查 OAuth2 access token + const accessToken = getAccessToken() + + if (accessToken) { + // 检查 token 是否过期,如果过期则尝试刷新 + if (isTokenExpired()) { + try { + await refreshAccessToken() + } catch (error) { + console.error('刷新 token 失败:', error) + clearUserState() + setInitializingAuth(false) + return + } + } + + // 使用 OAuth2 token 验证并获取用户信息 const success = await validateAndUpdateUser() if (success) { + setInitializingAuth(false) return } } - // 如果cookie验证失败或没有cookie,尝试从localStorage恢复 + // 如果没有 OAuth2 token,尝试使用 Cookie 认证(向后兼容) + const userId = getSessionUser() + const hasSessionCookie = !isCookieExpired() + const hasCookie = userId || hasSessionCookie + + if (hasCookie) { + const success = await validateAndUpdateUser() + if (success) { + setInitializingAuth(false) + return + } + } + + // 如果都没有,尝试从localStorage恢复 const storedUser = loadUserFromStorage() if (storedUser) { user.value = storedUser @@ -141,7 +283,7 @@ export const useAuthStore = defineStore('auth', () => { // 静默失败,保持localStorage中的状态 }) } else { - // 既没有cookie也没有localStorage,清除认证状态 + // 既没有token也没有cookie也没有localStorage,清除认证状态 if (isAuthenticated.value) { clearUserState() } @@ -171,6 +313,8 @@ export const useAuthStore = defineStore('auth', () => { isAuthenticated, isLoggedIn, login, + loginWithPassword, + handleOAuthCallback, logout, initAuth, updateUserInfo diff --git a/src/shared/utils/fetchInterceptor.ts b/src/shared/utils/fetchInterceptor.ts index 5479f32..2aa0535 100644 --- a/src/shared/utils/fetchInterceptor.ts +++ b/src/shared/utils/fetchInterceptor.ts @@ -9,9 +9,59 @@ export function setInitializingAuth(value: boolean) { isInitializingAuth = value } -// 包装fetch函数,添加401/403错误处理 +// 包装fetch函数,添加 Bearer token 和 401/403 错误处理 window.fetch = async function(...args: Parameters): Promise { - const response = await originalFetch(...args) + // 添加 Bearer token 到请求头 + const [url, options = {}] = args + const headers = new Headers(options.headers) + + // 如果是 API 请求且没有 Authorization 头,尝试添加 Bearer token + // 支持相对路径 (/api/) 和完整 URL (https://.../api/) + const isApiRequest = typeof url === 'string' && ( + url.startsWith('/api/') || + url.includes('/api/action/') + ) + + if (isApiRequest && !headers.has('Authorization')) { + try { + const { getAccessToken, isTokenExpired, refreshAccessToken } = await import('../api/oauth2') + const accessToken = getAccessToken() + + if (accessToken) { + // 检查 token 是否过期,如果过期则尝试刷新 + if (isTokenExpired()) { + try { + await refreshAccessToken() + const newToken = getAccessToken() + if (newToken) { + headers.set('Authorization', `Bearer ${newToken}`) + } + } catch (error) { + // 刷新失败,继续使用旧 token(让后端返回 401) + headers.set('Authorization', `Bearer ${accessToken}`) + } + } else { + headers.set('Authorization', `Bearer ${accessToken}`) + } + } + + // 确保 Content-Type 和 Accept 头存在 + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + if (!headers.has('Accept')) { + headers.set('Accept', 'application/json') + } + } catch (error) { + // 导入失败,忽略(可能是首次加载) + } + } + + // 使用修改后的选项调用原始 fetch + const response = await originalFetch(url, { + ...options, + headers + }) // 检查响应状态码(仅在401/403时处理) if (response.status === 401 || response.status === 403) { @@ -28,7 +78,7 @@ window.fetch = async function(...args: Parameters): Promise { + if (typeof window !== 'undefined' && window.crypto) { + return window.crypto + } + if (typeof globalThis !== 'undefined' && globalThis.crypto) { + return globalThis.crypto + } + if (typeof crypto !== 'undefined') { + return crypto + } + throw new Error('crypto is not available in this environment') +} + +// 生成随机字符串 +function generateRandomString(length: number): string { + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' + const crypto = getCrypto() + const values = crypto.getRandomValues(new Uint8Array(length)) + return values.reduce((acc, x) => acc + possible[x % possible.length], '') +} + +// Base64 URL 编码 +function base64URLEncode(str: string): string { + return btoa(str) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +// SHA-256 哈希 +async function sha256(plain: string): Promise { + const crypto = getCrypto() + + // 检查 crypto.subtle 是否可用(需要 HTTPS 或 localhost) + if (!crypto.subtle) { + throw new Error('crypto.subtle is not available. Please use HTTPS or localhost.') + } + + const encoder = new TextEncoder() + const data = encoder.encode(plain) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return String.fromCharCode(...hashArray) +} + +/** + * 生成 PKCE code verifier 和 code challenge + */ +export async function generatePKCE(): Promise<{ codeVerifier: string; codeChallenge: string }> { + const codeVerifier = generateRandomString(128) + const codeChallenge = base64URLEncode(await sha256(codeVerifier)) + return { codeVerifier, codeChallenge } +} + +/** + * Token 存储键 + */ +const ACCESS_TOKEN_KEY = 'oauth2_access_token' +const REFRESH_TOKEN_KEY = 'oauth2_refresh_token' +const TOKEN_EXPIRES_AT_KEY = 'oauth2_token_expires_at' +const CODE_VERIFIER_KEY = 'oauth2_code_verifier' + +/** + * Token 信息接口 + */ +export interface TokenInfo { + access_token: string + refresh_token?: string + expires_in?: number + token_type?: string + scope?: string +} + +/** + * 存储 access token + */ +export function storeAccessToken(token: string, expiresIn?: number): void { + sessionStorage.setItem(ACCESS_TOKEN_KEY, token) + if (expiresIn) { + const expiresAt = Date.now() + expiresIn * 1000 + sessionStorage.setItem(TOKEN_EXPIRES_AT_KEY, expiresAt.toString()) + } +} + +/** + * 获取 access token + */ +export function getAccessToken(): string | null { + return sessionStorage.getItem(ACCESS_TOKEN_KEY) +} + +/** + * 存储 refresh token + */ +export function storeRefreshToken(token: string): void { + localStorage.setItem(REFRESH_TOKEN_KEY, token) +} + +/** + * 获取 refresh token + */ +export function getRefreshToken(): string | null { + return localStorage.getItem(REFRESH_TOKEN_KEY) +} + +/** + * 检查 token 是否过期 + */ +export function isTokenExpired(): boolean { + const expiresAt = sessionStorage.getItem(TOKEN_EXPIRES_AT_KEY) + if (!expiresAt) return false // 如果没有过期时间,假设未过期 + return Date.now() >= parseInt(expiresAt, 10) +} + +/** + * 清除所有 token + */ +export function clearTokens(): void { + sessionStorage.removeItem(ACCESS_TOKEN_KEY) + sessionStorage.removeItem(TOKEN_EXPIRES_AT_KEY) + localStorage.removeItem(REFRESH_TOKEN_KEY) + sessionStorage.removeItem(CODE_VERIFIER_KEY) +} + +/** + * 存储 code verifier(用于 PKCE) + */ +export function storeCodeVerifier(verifier: string): void { + sessionStorage.setItem(CODE_VERIFIER_KEY, verifier) +} + +/** + * 获取 code verifier + */ +export function getCodeVerifier(): string | null { + return sessionStorage.getItem(CODE_VERIFIER_KEY) +} + +/** + * 清除 code verifier + */ +export function clearCodeVerifier(): void { + sessionStorage.removeItem(CODE_VERIFIER_KEY) +} + +/** + * 从 URL 中提取授权码 + */ +export function extractAuthorizationCode(): string | null { + const params = new URLSearchParams(window.location.search) + return params.get('code') +} + +/** + * 从 URL 中提取错误信息 + */ +export function extractOAuthError(): { error: string; error_description?: string } | null { + const params = new URLSearchParams(window.location.search) + const error = params.get('error') + if (error) { + return { + error, + error_description: params.get('error_description') || undefined + } + } + return null +} diff --git a/src/views/OAuthCallback.vue b/src/views/OAuthCallback.vue new file mode 100644 index 0000000..a8038e1 --- /dev/null +++ b/src/views/OAuthCallback.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/src/views/auth/Login.vue b/src/views/auth/Login.vue index 2adac8b..4ce73b8 100644 --- a/src/views/auth/Login.vue +++ b/src/views/auth/Login.vue @@ -101,22 +101,12 @@ const rules = { const handleLogin = async () => { try { - await formRef.value?.validate() - loading.value = true - - const result = await authStore.login(formData.username, formData.password) - - if (result.success) { - message.success(t('Login successful')) - router.push('/') - } else { - message.error(result.error || t('Login failed')) - } - } catch (error) { + // 使用 OAuth2 登录(会重定向到授权页面) + await authStore.login() + // login 方法会重定向到授权页面,这里不会继续执行 + } catch (error: any) { console.error('Login error:', error) - message.error(t('Login failed, please check username and password')) - } finally { - loading.value = false + message.error(error.message || t('Login failed, please check username and password')) } } diff --git a/src/views/settings/DeveloperSettings.vue b/src/views/settings/DeveloperSettings.vue new file mode 100644 index 0000000..136a2c3 --- /dev/null +++ b/src/views/settings/DeveloperSettings.vue @@ -0,0 +1,393 @@ + + + + + diff --git a/src/views/settings/ProfileSettings.vue b/src/views/settings/ProfileSettings.vue new file mode 100644 index 0000000..3c9623e --- /dev/null +++ b/src/views/settings/ProfileSettings.vue @@ -0,0 +1,665 @@ + + + + + diff --git a/src/views/settings/Settings.vue b/src/views/settings/Settings.vue index 2ffaa40..942a4aa 100644 --- a/src/views/settings/Settings.vue +++ b/src/views/settings/Settings.vue @@ -4,413 +4,53 @@

{{ t('Settings') }}

- - - - - - - - - - - - - - - - - {{ timezoneError }} - - - - - - - - - - - - - {{ t('Only system administrators can view and edit environment configuration') }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + +