Merge pull request #342 from APIParkLab/feature/1.9-OAuth

feat: Add Feishu OAuth login & consumer-grade MCP
This commit is contained in:
ningyv
2025-07-17 11:30:03 +08:00
committed by GitHub
19 changed files with 737 additions and 226 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -137,7 +137,7 @@ function BasicLayout({ project = 'core' }: { project: string }) {
const items: MenuProps['items'] = useMemo(
() =>
[
userInfo?.type !== 'guest' && {
!['guest', 'third-user'].includes(userInfo?.type as string) && {
key: '2',
label: (
<Button
@@ -119,6 +119,11 @@ export const PERMISSION_DEFINITION = [
anyOf: [{ backend: ['system.organization.role.manager_team_role'] }]
}
},
'system.organization.auth.view': {
granted: {
anyOf: [{ backend: ['system.settings.login.manager', 'system.settings.login.view'] }]
}
},
'system.api_market.service_classification.view': {
granted: {
anyOf: [{ backend: ['system.settings.general.view'] }]
@@ -634,6 +639,15 @@ export const PERMISSION_DEFINITION = [
]
}
},
'team.consumer.mcp.view': {
granted: {
anyOf: [
{
backend: ['team.consumer.mcp.manager', 'team.consumer.mcp.view']
}
]
}
},
'team.application.authorization.add': {
granted: {
anyOf: [{ backend: ['system.workspace.application.manager_all', 'team.consumer.authorization.manager'] }]
@@ -207,8 +207,15 @@ const mockData = [
name: '角色',
key: 'role',
path: '/role',
icon: 'ic:baseline-verified-user',
icon: 'ph:user-circle-gear-fill',
access: 'system.organization.role.view'
},
{
name: '鉴权',
key: 'auth',
path: '/auth',
icon: 'ic:baseline-verified-user',
access: 'system.organization.auth.view'
}
]
},
@@ -60,6 +60,16 @@ const mockData = {
}
]
},
{
driver: 'apipark.builtIn.component',
name: 'auth',
router: [
{
path: 'auth',
type: 'normal'
}
]
},
{
driver: 'apipark.builtIn.component',
name: 'cluster',
@@ -461,6 +461,17 @@
"待审核": "K35612f29",
"已审核": "K47eaafde",
"发布申请": "K56b4254f",
"鉴权": "Kb35e6a18",
"系统用户账号登录授权配置": "K679bd7e4",
"授权类型": "K9e7bb257",
"请选择授权类型": "Kc499fc1d",
"APP ID": "Kee9f8f26",
"请输入APP ID": "K9e4c19bb",
"APP ID 参数位于飞书开发人员控制台中的应用程序凭证和基础信息页面上": "K45c99f97",
"APP Secret": "K90f7c3b4",
"请输入APP Secret": "Kdc53d96f",
"APP Secret 参数位于飞书开发人员控制台中的应用程序凭证和基础信息页面上": "K56e77c4",
"启用授权": "K50693bd8",
"API 调用地址": "Kea2f9279",
"API base URL 一般设置为API 网关的外部网络访问地址,或者是API网关绑定的域名。": "K7fc496a1",
"OpenAPI & MCP 调用地址": "Ka7ca8fde",
@@ -553,6 +564,7 @@
"请输入密码": "K25c895d5",
"密码": "K551b0348",
"登录": "Kd2c1a316",
"飞书授权登录": "K682b11cb",
"访客模式": "K192b3e38",
"您可通过访客模式查看所有页面和功能,但是无法编辑数据。访客模式仅用于了解产品功能,您可以在正式产品中关闭该功能。": "K91aa4801",
"Version (0)-(1)": "K480045ce",
@@ -972,5 +972,17 @@
"K6c267c7b": "Avg Requests per Subscriber",
"K133d4291": "Avg Traffic per Subscriber",
"K37c5f1d0": "Token",
"Kb98264d4": "Avg Token per Subscriber"
"Kb98264d4": "Avg Token per Subscriber",
"Kb35e6a18": "Auth",
"K679bd7e4": "System User Account Login Authorization Settings",
"K9e7bb257": "Authorization Type",
"Kc499fc1d": "Please select an authorization type",
"Kee9f8f26": "APP ID",
"K9e4c19bb": "Please enter APP ID",
"K90f7c3b4": "APP Secret",
"Kdc53d96f": "Please enter APP Secret",
"K50693bd8": "Enable Authorization",
"K682b11cb": "Feishu Authorization Login",
"K45c99f97": "The APP ID parameter can be found on the App Credentials and Basic Information page in the Feishu Developer Console",
"K56e77c4": "The APP Secret parameter can be found on the App Credentials and Basic Information page in the Feishu Developer Console"
}
@@ -994,5 +994,17 @@
"K6c267c7b": "消費者あたりの平均リクエスト数",
"K133d4291": "消費者あたりの平均ネットワークトラフィック",
"K37c5f1d0": "トークン消費量",
"Kb98264d4": "消費者あたりの平均トークン消費量"
"Kb98264d4": "消費者あたりの平均トークン消費量",
"Kb35e6a18": "認証",
"K679bd7e4": "システムユーザーアカウントログイン認可設定",
"K9e7bb257": "認可タイプ",
"Kc499fc1d": "認可タイプを選択してください",
"Kee9f8f26": "APP ID",
"K9e4c19bb": "APP ID を入力してください",
"K90f7c3b4": "APP Secret",
"Kdc53d96f": "APP Secret を入力してください",
"K50693bd8": "認可を有効化",
"K682b11cb": "Feishu 認証ログイン",
"K45c99f97": "APP ID パラメータは Feishu 開発者コンソールのアプリ認証情報と基本情報ページにあります",
"K56e77c4": "APP Secret パラメータは Feishu 開発者コンソールのアプリ認証情報と基本情報ページにあります"
}
@@ -923,5 +923,17 @@
"K6c267c7b": "平均每消费者的请求次数",
"K133d4291": "平均每消费者的网络流量",
"K37c5f1d0": "Token 消耗",
"Kb98264d4": "平均每消费者的 Token 消耗"
"Kb98264d4": "平均每消费者的 Token 消耗",
"Kb35e6a18": "鉴权",
"K679bd7e4": "系统用户账号登录授权配置",
"K9e7bb257": "授权类型",
"Kc499fc1d": "请选择授权类型",
"Kee9f8f26": "APP ID",
"K9e4c19bb": "请输入APP ID",
"K90f7c3b4": "APP Secret",
"Kdc53d96f": "请输入APP Secret",
"K50693bd8": "启用授权",
"K682b11cb": "飞书授权登录",
"K45c99f97": "APP ID 参数位于飞书开发人员控制台中的应用程序凭证和基础信息页面上",
"K56e77c4": "APP Secret 参数位于飞书开发人员控制台中的应用程序凭证和基础信息页面上"
}
@@ -994,5 +994,17 @@
"K6c267c7b": "平均每位使用者的請求次數",
"K133d4291": "平均每位使用者的網路流量",
"K37c5f1d0": "Token 消耗",
"Kb98264d4": "平均每位使用者的 Token 消耗"
"Kb98264d4": "平均每位使用者的 Token 消耗",
"Kb35e6a18": "鑑權",
"K679bd7e4": "系統用戶帳號登入授權配置",
"K9e7bb257": "授權類型",
"Kc499fc1d": "請選擇授權類型",
"Kee9f8f26": "APP ID",
"K9e4c19bb": "請輸入 APP ID",
"K90f7c3b4": "APP Secret",
"Kdc53d96f": "請輸入 APP Secret",
"K50693bd8": "啟用授權",
"K682b11cb": "飛書授權登入",
"K45c99f97": "APP ID 參數位於飛書開發人員控制台中的應用程式憑證與基礎資訊頁面",
"K56e77c4": "APP Secret 參數位於飛書開發人員控制台中的應用程式憑證與基礎資訊頁面"
}
@@ -618,6 +618,16 @@ export const routerMap: Map<string, RouterMapConfig> = new Map([
/* webpackChunkName: "[request]" */ '@market/pages/serviceHub/management/ManagementAppSetting.tsx'
)
)
},
{
path: 'mcp',
key: 'consumerMcp',
lazy: lazy(
() =>
import(
/* webpackChunkName: "[request]" */ '@market/pages/serviceHub/management/mcpContent.tsx'
)
)
}
]
},
@@ -693,6 +703,14 @@ export const routerMap: Map<string, RouterMapConfig> = new Map([
]
}
],
[
'auth',
{
type: 'module',
lazy: lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/auth/Auth.tsx')),
key: 'auth'
}
],
[
'analytics',
{
@@ -19,6 +19,7 @@ export type MemberTableListItem = {
enable:boolean
departmentId:string
roles:EntityItem[]
form: string
};
export type AddToDepartmentProps = {
@@ -40,7 +41,7 @@ export type MemberDropdownModalFieldType = {
export type MemberDropdownModalProps = {
type:'addDep'|'addChild'|'addMember'|'editMember'|'rename'
entity?:(MemberTableListItem & {departmentIds:string[]}) | ({id?:string, departmentIds?:string[],name?:string})
entity?:(MemberTableListItem & {departmentIds:string[]}) | ({id?:string, departmentIds?:string[],name?:string,form?:string})
selectedMemberGroupId?:string
}
+358 -207
View File
@@ -1,221 +1,372 @@
import {FC, useCallback, useEffect, useRef, useState} from "react";
import {App, Button, Divider, Form, FormInstance, Input, Spin, Tooltip} from "antd";
import {useGlobalContext} from "@common/contexts/GlobalStateContext.tsx";
import {useFetch} from "@common/hooks/http.ts";
import {BasicResponse, STATUS_CODE} from "@common/const/const.tsx";
import {useNavigate} from "react-router-dom";
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { App, Button, Divider, Form, FormInstance, Input, Spin, Tooltip } from 'antd'
import { useGlobalContext } from '@common/contexts/GlobalStateContext.tsx'
import { useFetch } from '@common/hooks/http.ts'
import { BasicResponse, STATUS_CODE } from '@common/const/const.tsx'
import { useLocation, useNavigate } from 'react-router-dom'
// import {useCrypto} from "../hooks/crypto.ts";
import Logo from '@common/assets/layout-logo.png'
import { $t } from "@common/locales";
import { Icon } from "@iconify/react/dist/iconify.js";
import LanguageSetting from "@common/components/aoplatform/LanguageSetting";
import { LoadingOutlined } from "@ant-design/icons";
import FeishuLogo from '@common/assets/feishu.png'
import { $t } from '@common/locales'
import { Icon } from '@iconify/react/dist/iconify.js'
import LanguageSetting from '@common/components/aoplatform/LanguageSetting'
import { LoadingOutlined } from '@ant-design/icons'
const Login:FC = ()=> {
const {state, dispatch} = useGlobalContext()
const {fetchData} = useFetch()
const { message } = App.useApp()
const navigate = useNavigate();
const formRef = useRef<FormInstance>(null);
const [loading,setLoading] = useState<boolean>()
const [allowGuest, setAllowGuest] = useState<boolean>(false)
const [spinning,setSpinning] = useState<boolean>(false)
const check = useCallback(()=>{
state.isAuthenticated &&setSpinning(true)
fetchData<BasicResponse<{channel:Array<{name:string}>, status:string}>>('account/login',{method:'GET'}).then(response=>{
const {code,data} = response
if(code === STATUS_CODE.SUCCESS && data.status !== 'anonymous'){
dispatch({type:'LOGIN'})
navigate(state.mainPage,{replace:true})
}else{
dispatch({type:'LOGOUT'})
setAllowGuest(data.channel.filter(x=>x.name === 'guest_access').length > 0)
setSpinning(false)
}
})
},[])
const getSystemInfo = useCallback(()=>{
fetchData<BasicResponse<{version:string, buildTime:string}>>('common/version',{method:'GET', eoTransformKeys:['build_time']}).then(response=>{
const {code,data} = response
if(code === STATUS_CODE.SUCCESS){
dispatch({type:'UPDATE_VERSION',version:data.version})
dispatch({type:'UPDATE_DATE',updateDate:data.buildTime})
}
})
},[])
const fetchLogin = async (values:any)=>{
try {
setLoading(true);
const { username, password } = values;
// const encryptedPassword = encryptByEnAES(username, password);
const body = {
name:username,
password: password
// client: 1,
// type: 1,
// app_type: 4,
};
const {code,msg } = await fetchData<BasicResponse<null>>('account/login/username',{method:'POST',eoBody:(body)})
if (code === STATUS_CODE.SUCCESS) {
dispatch({type:'LOGIN'})
// message.success($t(RESPONSE_TIPS.loginSuccess));
const callbackUrl = new URLSearchParams(window.location.search).get('callbackUrl');
if (callbackUrl && callbackUrl !== 'null') {
navigate(callbackUrl);
} else {
navigate(state.mainPage);
}
}else{
dispatch({type:'LOGOUT'})
message.error(msg)
}
} catch (err) {
console.warn(err);
} finally {
setLoading(false)
const Login: FC = () => {
const { state, dispatch } = useGlobalContext()
const { fetchData } = useFetch()
const { message } = App.useApp()
const navigate = useNavigate()
const formRef = useRef<FormInstance>(null)
const [loading, setLoading] = useState<boolean>()
const [allowGuest, setAllowGuest] = useState<boolean>(false)
const [spinning, setSpinning] = useState<boolean>(false)
// 是否允许飞书登录
const [allowFeishuLogin, setAllowFeishuLogin] = useState<boolean>(false)
// 飞书登录app_id
const [feishuAppId, setFeishuAppId] = useState<string>()
// 获取 url 参数
const query = new URLSearchParams(useLocation().search)
/**
* 飞书登录
* @param feishuCode 飞书 code
*/
const feishuLogin = async (feishuCode: string) => {
try {
setLoading(true)
const { code, msg } = await fetchData<BasicResponse<null>>('account/login/feishu', {
method: 'POST',
eoBody: {
code: feishuCode
}
}
const login = async () => {
if (formRef.current) {
const values = await formRef.current.validateFields();
fetchLogin(values);
})
if (code === STATUS_CODE.SUCCESS) {
dispatch({ type: 'LOGIN' })
const callbackUrl = new URLSearchParams(window.location.search).get('callbackUrl')
if (callbackUrl && callbackUrl !== 'null') {
navigate(callbackUrl)
} else {
navigate(state.mainPage)
}
};
const loginAsGuest = ()=>{
fetchLogin({username:'guest',password:'12345678'})
} else {
dispatch({ type: 'LOGOUT' })
message.error(msg)
}
} catch (err) {
console.warn(err)
} finally {
setLoading(false)
}
}
useEffect(() => {
check()
getSystemInfo()
}, []);
const check = useCallback(() => {
state.isAuthenticated && setSpinning(true)
fetchData<BasicResponse<{ channel: Array<{ name: string; config: { [key: string]: any } }>; status: string }>>(
'account/login',
{ method: 'GET' }
).then((response) => {
const { code, data } = response
if (code === STATUS_CODE.SUCCESS && data.status !== 'anonymous') {
dispatch({ type: 'LOGIN' })
navigate(state.mainPage, { replace: true })
} else {
dispatch({ type: 'LOGOUT' })
setAllowGuest(data.channel.filter((x: any) => x.name === 'guest_access').length > 0)
const feishu = data.channel.find((x: any) => x.name === 'feishu')
if (feishu) {
setFeishuAppId(feishu.config.app_id)
setAllowFeishuLogin(true)
}
const code = query.get('code')
if (code) {
feishuLogin(code)
}
setSpinning(false)
}
})
}, [])
return (
spinning?
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} spinning={spinning} className='w-full h-full flex items-center justify-center'></Spin> :
<div className="h-full w-full flex flex-col items-center overflow-auto min-h-[490px] bg-[#0d1117]">
<div id="glow-background" className="background-container">
<svg className="background-pattern" aria-hidden="true">
<defs>
<pattern id="pattern-bg" width="200" height="200" patternUnits="userSpaceOnUse">
<path d="M.5 200V.5H200" fill="none"></path>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#pattern-bg)"></rect>
</svg>
const getSystemInfo = useCallback(() => {
fetchData<BasicResponse<{ version: string; buildTime: string }>>('common/version', {
method: 'GET',
eoTransformKeys: ['build_time']
}).then((response) => {
const { code, data } = response
if (code === STATUS_CODE.SUCCESS) {
dispatch({ type: 'UPDATE_VERSION', version: data.version })
dispatch({ type: 'UPDATE_DATE', updateDate: data.buildTime })
}
})
}, [])
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" viewBox="0 0 800 450" opacity="1">
<defs>
<filter id="bbblurry-filter" x="-100%" y="-100%" width="400%" height="400%" filterUnits="objectBoundingBox" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="99" x="0%" y="0%" width="100%" height="100%" in="SourceGraphic" edgeMode="none" result="blur"></feGaussianBlur>
</filter>
</defs>
<g filter="url(#bbblurry-filter)">
<ellipse rx="80.5" ry="66.5" cx="623.0285107902043" cy="25.708028895006635" fill="hsla(187, 67%, 50%, 1.00)">
<animate attributeName="fill" values="hsla(187, 67%, 50%, 1.00); hsla(340, 85%, 60%, 1.00); hsla(60, 90%, 55%, 1.00); hsla(187, 67%, 50%, 1.00)" dur="6s" repeatCount="indefinite"></animate>
</ellipse>
<ellipse rx="80.5" ry="66.5" cx="446.471435546875" cy="-11.694503784179688" fill="hsla(234, 78%, 61%, 1.00)">
<animate attributeName="fill" values="hsla(234, 78%, 61%, 1.00); hsla(100, 75%, 60%, 1.00); hsla(290, 80%, 70%, 1.00); hsla(234, 78%, 61%, 1.00)" dur="8s" repeatCount="indefinite"></animate>
</ellipse>
<ellipse rx="80.5" ry="66.5" cx="200.54574247724838" cy="-19.02454901710908" fill="hsla(167, 87%, 56%, 1.00)">
<animate attributeName="fill" values="hsla(167, 87%, 56%, 1.00); hsla(10, 90%, 65%, 1.00); hsla(300, 85%, 50%, 1.00); hsla(167, 87%, 56%, 1.00)" dur="10s" repeatCount="indefinite"></animate>
</ellipse>
<ellipse rx="80.5" ry="66.5" cx="340.05827594708103" cy="-9.424536458161867" fill="hsl(25, 100%, 64%)">
<animate attributeName="fill" values="hsl(25, 100%, 64%); hsl(200, 100%, 70%); hsl(50, 95%, 55%); hsl(25, 100%, 64%)" dur="8s" repeatCount="indefinite"></animate>
</ellipse>
</g>
</svg>
const fetchLogin = async (values: any) => {
try {
setLoading(true)
const { username, password } = values
// const encryptedPassword = encryptByEnAES(username, password);
const body = {
name: username,
password: password
// client: 1,
// type: 1,
// app_type: 4,
}
const { code, msg } = await fetchData<BasicResponse<null>>('account/login/username', {
method: 'POST',
eoBody: body
})
if (code === STATUS_CODE.SUCCESS) {
dispatch({ type: 'LOGIN' })
// message.success($t(RESPONSE_TIPS.loginSuccess));
const callbackUrl = new URLSearchParams(window.location.search).get('callbackUrl')
if (callbackUrl && callbackUrl !== 'null') {
navigate(callbackUrl)
} else {
navigate(state.mainPage)
}
} else {
dispatch({ type: 'LOGOUT' })
message.error(msg)
}
} catch (err) {
console.warn(err)
} finally {
setLoading(false)
}
}
const login = async () => {
if (formRef.current) {
const values = await formRef.current.validateFields()
fetchLogin(values)
}
}
const loginAsGuest = () => {
fetchLogin({ username: 'guest', password: '12345678' })
}
// 打开飞书授权页面
const openFeishuLogin = () => {
const href = location.href
const authUrl = `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${feishuAppId}&redirect_uri=${href}`
window.location.href = authUrl
}
useEffect(() => {
check()
getSystemInfo()
}, [])
return spinning ? (
<Spin
indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />}
spinning={spinning}
className="w-full h-full flex items-center justify-center"
></Spin>
) : (
<div className="h-full w-full flex flex-col items-center overflow-auto min-h-[490px] bg-[#0d1117]">
<div id="glow-background" className="background-container">
<svg className="background-pattern" aria-hidden="true">
<defs>
<pattern id="pattern-bg" width="200" height="200" patternUnits="userSpaceOnUse">
<path d="M.5 200V.5H200" fill="none"></path>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#pattern-bg)"></rect>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
version="1.1"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:svgjs="http://svgjs.dev/svgjs"
viewBox="0 0 800 450"
opacity="1"
>
<defs>
<filter
id="bbblurry-filter"
x="-100%"
y="-100%"
width="400%"
height="400%"
filterUnits="objectBoundingBox"
primitiveUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feGaussianBlur
stdDeviation="99"
x="0%"
y="0%"
width="100%"
height="100%"
in="SourceGraphic"
edgeMode="none"
result="blur"
></feGaussianBlur>
</filter>
</defs>
<g filter="url(#bbblurry-filter)">
<ellipse
rx="80.5"
ry="66.5"
cx="623.0285107902043"
cy="25.708028895006635"
fill="hsla(187, 67%, 50%, 1.00)"
>
<animate
attributeName="fill"
values="hsla(187, 67%, 50%, 1.00); hsla(340, 85%, 60%, 1.00); hsla(60, 90%, 55%, 1.00); hsla(187, 67%, 50%, 1.00)"
dur="6s"
repeatCount="indefinite"
></animate>
</ellipse>
<ellipse
rx="80.5"
ry="66.5"
cx="446.471435546875"
cy="-11.694503784179688"
fill="hsla(234, 78%, 61%, 1.00)"
>
<animate
attributeName="fill"
values="hsla(234, 78%, 61%, 1.00); hsla(100, 75%, 60%, 1.00); hsla(290, 80%, 70%, 1.00); hsla(234, 78%, 61%, 1.00)"
dur="8s"
repeatCount="indefinite"
></animate>
</ellipse>
<ellipse
rx="80.5"
ry="66.5"
cx="200.54574247724838"
cy="-19.02454901710908"
fill="hsla(167, 87%, 56%, 1.00)"
>
<animate
attributeName="fill"
values="hsla(167, 87%, 56%, 1.00); hsla(10, 90%, 65%, 1.00); hsla(300, 85%, 50%, 1.00); hsla(167, 87%, 56%, 1.00)"
dur="10s"
repeatCount="indefinite"
></animate>
</ellipse>
<ellipse rx="80.5" ry="66.5" cx="340.05827594708103" cy="-9.424536458161867" fill="hsl(25, 100%, 64%)">
<animate
attributeName="fill"
values="hsl(25, 100%, 64%); hsl(200, 100%, 70%); hsl(50, 95%, 55%); hsl(25, 100%, 64%)"
dur="8s"
repeatCount="indefinite"
></animate>
</ellipse>
</g>
</svg>
</div>
{/* <div className="w-full border-box text-right pr-[40px]"></div> */}
<div className="mx-auto flex-1 flex flex-col items-center justify-center z-[3]">
<div className="mx-auto">
<span className="flex items-center justify-center">
<img className="h-[40px] mr-[8px]" src={Logo} />
</span>
</div>
<section className="block w-[410px] mx-auto mt-[46px] p-[30px] box-border rounded-[10px] shadow-[0_5px_20px_0_rgba(0,0,0,5%)] login-block">
<div className="h-full">
<div className="">
<Form onFinish={login} className="w-[350px]" ref={formRef}>
<Form.Item
className="p-0 bg-transparent rounded border-none"
name="username"
rules={[{ required: true, message: $t('请输入账号'), whitespace: true }]}
>
<Input
className="w-[350px] h-[40px] login-input"
placeholder={$t('账号')}
autoComplete="on"
autoFocus
/>
</Form.Item>
<Form.Item
className="p-0 bg-transparent rounded border-none "
name="password"
rules={[{ required: true, message: $t('请输入密码') }]}
>
<Input.Password
className="w-[350px] h-[40px] login-input"
placeholder={$t('密码')}
autoComplete="off"
/>
</Form.Item>
<Form.Item className="p-0 bg-transparent rounded border-none ">
<Button
loading={loading}
className="h-[40px] mt-mbase w-full inline-flex justify-center items-center"
type="primary"
htmlType="submit"
>
{$t('登录')}
</Button>
</Form.Item>
{allowFeishuLogin && (
<>
<Divider />
<Form.Item className="p-0 bg-transparent rounded border-none mb-0">
<Button
loading={loading}
className="h-[40px] w-full inline-flex justify-center items-center"
type="default"
onClick={openFeishuLogin}
>
<img className="h-[30px]" src={FeishuLogo} />
{$t('飞书授权登录')}
</Button>
</Form.Item>
</>
)}
{allowGuest && (
<>
<Divider />
<Form.Item className="p-0 bg-transparent rounded border-none mb-0">
<Button
loading={loading}
className="h-[40px] w-full inline-flex justify-center items-center"
type="default"
onClick={loginAsGuest}
>
{$t('访客模式')}{' '}
<Tooltip
title={$t(
'您可通过访客模式查看所有页面和功能,但是无法编辑数据。访客模式仅用于了解产品功能,您可以在正式产品中关闭该功能。'
)}
>
<Icon icon="ic:baseline-help" height={18} width={18} />
</Tooltip>
</Button>
</Form.Item>
</>
)}
</Form>
</div>
{/* <div className="w-full border-box text-right pr-[40px]"></div> */}
<div className="mx-auto flex-1 flex flex-col items-center justify-center z-[3]" >
<div className="mx-auto">
<span className="flex items-center justify-center">
<img
className="h-[40px] mr-[8px]"
src={Logo}
/>
</span>
</div>
</div>
</section>
<section className="block w-[410px] mx-auto mt-[46px] p-[30px] box-border rounded-[10px] shadow-[0_5px_20px_0_rgba(0,0,0,5%)] login-block">
<div className="h-full">
<div className="">
<Form onFinish={login} className="w-[350px]"
ref={formRef}>
<Form.Item
className="p-0 bg-transparent rounded border-none"
name="username"
rules={[{ required: true, message: $t('请输入账号') ,whitespace:true }]}
>
<Input
className="w-[350px] h-[40px] login-input"
placeholder={$t("账号")}
autoComplete="on"
autoFocus
/>
</Form.Item>
<Form.Item
className="p-0 bg-transparent rounded border-none "
name="password"
rules={[{ required: true, message: $t('请输入密码') }]}
>
<Input.Password
className="w-[350px] h-[40px] login-input"
placeholder={$t("密码")}
autoComplete="off"
/>
</Form.Item>
<Form.Item
className="p-0 bg-transparent rounded border-none "
>
<Button loading={loading} className="h-[40px] mt-mbase w-full inline-flex justify-center items-center" type="primary" htmlType="submit">
{$t('登录')}
</Button>
</Form.Item>
{
allowGuest && <>
<Divider />
<Form.Item
className="p-0 bg-transparent rounded border-none mb-0"
>
<Button loading={loading} className="h-[40px] w-full inline-flex justify-center items-center" type="default" onClick={loginAsGuest}>
{$t('访客模式')} <Tooltip title={$t('您可通过访客模式查看所有页面和功能,但是无法编辑数据。访客模式仅用于了解产品功能,您可以在正式产品中关闭该功能。')}><Icon icon="ic:baseline-help" height={18} width={18} /></Tooltip>
</Button>
</Form.Item>
</>
}
</Form>
</div>
</div>
</section>
<section className="flex flex-col items-center mt-[46px] text-SECOND_TEXT">
<p className="leading-[28px]">
{$t('Version (0)-(1)',[state?.version,state?.updateDate])}, {$t(state?.powered || '-')}
</p>
<LanguageSetting mode="light"/>
</section>
</div>
</div>
);
<section className="flex flex-col items-center mt-[46px] text-SECOND_TEXT">
<p className="leading-[28px]">
{$t('Version (0)-(1)', [state?.version, state?.updateDate])}, {$t(state?.powered || '-')}
</p>
<LanguageSetting mode="light" />
</section>
</div>
</div>
)
}
export default Login;
export default Login
@@ -0,0 +1,167 @@
import InsidePage from '@common/components/aoplatform/InsidePage'
import WithPermission from '@common/components/aoplatform/WithPermission'
import { BasicResponse, PLACEHOLDER, RESPONSE_TIPS, STATUS_CODE } from '@common/const/const'
import { useGlobalContext } from '@common/contexts/GlobalStateContext'
import { useFetch } from '@common/hooks/http'
import { $t } from '@common/locales'
import { App, Button, Form, Input, Row, Select, Switch } from 'antd'
import { useEffect, useState } from 'react'
type AuthSetting = {
config: {
clientId: string
clientSecret: string
}
enabled: boolean
}
type AuthFieldType = {
authType: string
clientId: string
clientSecret: string
enabled: boolean
}
const Auth = () => {
const { message } = App.useApp()
const [form] = Form.useForm()
const { fetchData } = useFetch()
const [, forceUpdate] = useState<unknown>(null)
const { state } = useGlobalContext()
const [thirdPartyDrivers, setThirdPartyDrivers] = useState<{ label: string; value: string }[]>([])
useEffect(() => {
forceUpdate({})
}, [state.language])
const onFinish = () => {
form.validateFields().then((value) => {
return fetchData<BasicResponse<null>>(`/account/third/${value.authType}`, {
method: 'POST',
eoBody: {
enable: value.enabled,
config: {
client_id: value.clientId,
client_secret: value.clientSecret
}
}
})
.then((response) => {
const { code, msg } = response
if (code === STATUS_CODE.SUCCESS) {
message.success(msg || $t(RESPONSE_TIPS.success))
return Promise.resolve(true)
} else {
message.error(msg || $t(RESPONSE_TIPS.error))
return Promise.reject(msg || $t(RESPONSE_TIPS.error))
}
})
.catch((errorInfo) => {
return Promise.reject(errorInfo)
})
})
}
/**
* 获取第三方授权列表
*/
const getThirdPartyAuthList = () => {
fetchData<
BasicResponse<{
drivers: {
name: string
value: string
}[]
}>
>('/account/third', {
method: 'GET',
}).then((response) => {
const { code, data, msg } = response
if (code === STATUS_CODE.SUCCESS) {
setThirdPartyDrivers(data.drivers.map((item: any) => ({ label: item.name, value: item.value })))
} else {
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
}
/**
* 获取第三方授权配置
*/
const getThirdPartyAuthSetting = () => {
fetchData<BasicResponse<{ info: AuthSetting }>>(`/account/third/${form.getFieldValue('authType')}`, {
method: 'GET',
eoTransformKeys: ['client_id', 'client_secret']
}).then((response) => {
const { code, data, msg } = response
if (code === STATUS_CODE.SUCCESS) {
form.setFieldsValue({
clientId: data.info?.config?.clientId || '',
clientSecret: data.info?.config?.clientSecret || '',
enabled: data.info?.enable || false
})
} else {
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
}
useEffect(() => {
getThirdPartyAuthList()
}, [])
return (
<InsidePage pageTitle={$t('鉴权')} showBorder={false} contentClassName="pr-PAGE_INSIDE_X" scrollPage={false} description={$t("系统用户账号登录授权配置")}>
<WithPermission access="">
<Form
layout="vertical"
labelAlign="left"
scrollToFirstError
form={form}
className={`mx-auto`}
name="authConfig"
onFinish={onFinish}
autoComplete="off"
>
<Form.Item<AuthFieldType>
label={$t('授权类型')}
name="authType"
rules={[{ required: true, message: $t('请选择授权类型') }]}
>
<Select className="w-INPUT_NORMAL" placeholder={$t('请选择授权类型')} onChange={getThirdPartyAuthSetting} options={thirdPartyDrivers} />
</Form.Item>
<Form.Item<AuthFieldType>
label={$t('APP ID')}
name="clientId"
rules={[{ required: true, whitespace: true, message: $t('请输入APP ID') }]}
extra={$t('APP ID 参数位于飞书开发人员控制台中的应用程序凭证和基础信息页面上')}
>
<Input className="w-INPUT_NORMAL" placeholder={$t(PLACEHOLDER.input)} autoComplete="off" />
</Form.Item>
<Form.Item<AuthFieldType>
label={$t('APP Secret')}
name="clientSecret"
rules={[{ required: true, whitespace: true, message: $t('请输入APP Secret') }]}
extra={$t('APP Secret 参数位于飞书开发人员控制台中的应用程序凭证和基础信息页面上')}
>
<Input.Password className="w-INPUT_NORMAL" placeholder={$t(PLACEHOLDER.input)} autoComplete="new-password" />
</Form.Item>
<Form.Item<AuthFieldType> label={$t('启用授权')} name="enabled" valuePropName="checked">
<Switch checkedChildren={$t('启用')} unCheckedChildren={$t('停用')} />
</Form.Item>
<Row className="mb-[10px]">
<WithPermission access="system.devops.system_setting.edit">
<Button type="primary" htmlType="submit">
{$t('保存')}
</Button>
</WithPermission>
</Row>
</Form>
</WithPermission>
</InsidePage>
)
}
export default Auth
@@ -50,17 +50,23 @@ type ServiceApiKeyList = {
expired: number
}>
}
type ConsumerParamsType = {
consumerId: string
teamId: string
}
export interface IntegrationAIContainerRef {
getServiceKeysList: () => void;
}
export interface IntegrationAIContainerProps {
type: 'global' | 'service'
type: 'global' | 'service' | 'consumer'
handleToolsChange: (value: Tool[]) => void
customClassName?: string
service?: ServiceDetailType
serviceId?: string
currentTab?: string
openModal?: (type: 'apply') => void
consumerParams?: ConsumerParamsType
}
export const IntegrationAIContainer = forwardRef<IntegrationAIContainerRef, IntegrationAIContainerProps>(
({
@@ -69,8 +75,9 @@ export const IntegrationAIContainer = forwardRef<IntegrationAIContainerRef, Inte
customClassName,
service,
serviceId,
currentTab,
openModal
currentTab,
openModal,
consumerParams
}: IntegrationAIContainerProps, ref) => {
/** 当前激活的标签 */
const [activeTab, setActiveTab] = useState(type === 'service' ? 'openApi' : 'mcp')
@@ -184,6 +191,34 @@ export const IntegrationAIContainer = forwardRef<IntegrationAIContainerRef, Inte
})
}
/**
* 获取消费者 MCP 配置
* @returns
*/
const getConsumerMcpConfig = () => {
fetchData<BasicResponse<null>>('app/mcp/config', {
method: 'GET',
eoParams: { app: consumerParams?.consumerId, team: consumerParams?.teamId }
})
.then((response) => {
const { code, msg, data } = response
if (code === STATUS_CODE.SUCCESS) {
setTabContent((prevTabContent) => ({
...prevTabContent,
mcp: {
...prevTabContent.mcp,
configContent: data.config || ''
}
}))
} else {
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
.catch((errorInfo) => {
message.error(errorInfo || $t(RESPONSE_TIPS.error))
})
}
/**
* 全局 MCP 跳转
*/
@@ -229,12 +264,12 @@ export const IntegrationAIContainer = forwardRef<IntegrationAIContainerRef, Inte
}))
/**
* 获取服务 API Key 列表
* 获取 API Key 列表
*/
const getServiceKeysList = () => {
const getServiceKeysList = (consumerId?: string) => {
fetchData<BasicResponse<null>>(`my/app/apikeys`, {
method: 'GET',
eoParams: { service: serviceId }
eoParams: consumerId ? { app: consumerId } : { service: serviceId }
})
.then((response) => {
const { code, msg, data } = response
@@ -345,6 +380,10 @@ export const IntegrationAIContainer = forwardRef<IntegrationAIContainerRef, Inte
getGlobalMcpConfig()
setMcpServerUrl('mcp/global/sse')
getGlobalKeysList()
} else if (type === 'consumer'){
getConsumerMcpConfig()
setMcpServerUrl(`mcp/service/${consumerParams?.consumerId}/sse`)
getServiceKeysList(consumerParams?.consumerId)
} else {
service?.basic.enableMcp && setMcpServerUrl(`mcp/service/${serviceId}/sse`)
getServiceKeysList()
@@ -362,6 +401,7 @@ export const IntegrationAIContainer = forwardRef<IntegrationAIContainerRef, Inte
useEffect(() => {
initTabsData()
type === 'global' && getGlobalMcpConfig()
type === 'consumer' && getConsumerMcpConfig()
}, [state.language])
/**
* 切换标签
@@ -408,7 +448,7 @@ export const IntegrationAIContainer = forwardRef<IntegrationAIContainerRef, Inte
}
connectMcpServer()
}
}, [mcpServerUrl, ...(type === 'global' ? [state.language] : [])])
}, [mcpServerUrl, ...(type === 'global' || type === 'consumer' ? [state.language] : [])])
/**
* 获取 MCP tools
*/
@@ -533,7 +573,7 @@ export const IntegrationAIContainer = forwardRef<IntegrationAIContainerRef, Inte
<div className="tab-content font-semibold my-[10px]">API Key</div>
{apiKeyList.length ? (
<>
{type === 'global' ? (
{type === 'global' || type === 'consumer' ? (
<>
<Select
showSearch
@@ -14,6 +14,7 @@ export const MemberDropdownModal = forwardRef<MemberDropdownModalHandle,MemberDr
const {fetchData} = useFetch()
const [departmentList, setDepartmentList] = useState<DepartmentListItem[]>([])
const { state } = useGlobalContext()
const [disableEditMemberData] = useState<boolean>(entity?.form !== 'self-build')
const save:()=>Promise<boolean | string> = ()=>{
let url:string
@@ -182,27 +183,28 @@ export const MemberDropdownModal = forwardRef<MemberDropdownModalHandle,MemberDr
name="name"
rules={[{required: true,whitespace:true }]}
>
<Input className="w-INPUT_NORMAL" disabled={type ==='editMember'} placeholder={$t(PLACEHOLDER.input)}/>
<Input className="w-INPUT_NORMAL" disabled={disableEditMemberData || type ==='editMember'} placeholder={$t(PLACEHOLDER.input)}/>
</Form.Item>
<Form.Item<MemberDropdownModalFieldType>
label={$t("邮箱")}
name="email"
rules={[{required: true,whitespace:true },{type:"email",message: $t(VALIDATE_MESSAGE.email)}]}
>
<Input className="w-INPUT_NORMAL" placeholder={$t(PLACEHOLDER.input)}/>
<Input className="w-INPUT_NORMAL" disabled={disableEditMemberData} placeholder={$t(PLACEHOLDER.input)}/>
</Form.Item>
<Form.Item<MemberDropdownModalFieldType>
label={$t("密码")}
name="password"
rules={[{required: type === 'addMember',whitespace:true }]}
>
<Input className="w-INPUT_NORMAL" placeholder={$t(PLACEHOLDER.input)}/>
<Input disabled={disableEditMemberData} className="w-INPUT_NORMAL" placeholder={$t(PLACEHOLDER.input)}/>
</Form.Item>
<Form.Item<MemberDropdownModalFieldType>
label={$t("部门")}
name="departmentIds"
>
<TreeSelect
disabled={disableEditMemberData}
className="w-INPUT_NORMAL"
fieldNames={{label:'name',value:'id',children:'children'}}
showSearch
@@ -396,7 +396,7 @@ const MemberList = () => {
width: 600,
okText: $t('确认'),
okButtonProps: {
disabled: isActionAllowed(type)
disabled: isActionAllowed(type) || entity?.form !== 'self-build'
},
cancelText: $t('取消'),
closable: true,
@@ -32,6 +32,7 @@ export default function ManagementInsidePage() {
const TENANT_MANAGEMENT_APP_MENU: MenuProps['items'] = useMemo(
() => [
getItem($t('订阅的服务'), 'service', undefined, undefined, undefined, 'team.application.subscription.view'),
getItem($t('MCP 服务'), 'mcp', undefined, undefined, undefined, 'team.consumer.mcp.view'),
getItem($t('访问授权'), 'authorization', undefined, undefined, undefined, 'team.consumer.authorization.view'),
getItem($t('消费者管理'), 'setting', undefined, undefined, undefined, 'team.application.application.view')
],
@@ -0,0 +1,40 @@
import { $t } from '@common/locales'
import { IntegrationAIContainer } from '@core/pages/mcpService/IntegrationAIContainer'
import { Tool } from '@modelcontextprotocol/sdk/types.js'
import { useEffect, useState } from 'react'
import { useGlobalContext } from '@common/contexts/GlobalStateContext'
import McpToolsContainer from '@core/pages/mcpService/McpToolsContainer'
import { useParams } from 'react-router-dom'
import { RouterParams } from '@common/const/type'
const mcpContent = () => {
const [tools, setTools] = useState<Tool[]>([])
const [, forceUpdate] = useState<unknown>(null)
const { teamId, appId } = useParams<RouterParams>()
const { state } = useGlobalContext()
const handleToolsChange = (value: Tool[]) => {
setTools(value)
}
useEffect(() => {
forceUpdate({})
}, [state.language])
return (
<div className=" h-full pt-[32px]">
<div className="flex items-center justify-between w-full ml-[10px] text-[18px] leading-[25px] pb-[16px]">
<span className="font-bold">{$t('MCP 服务')}</span>
</div>
<div className="h-[calc(100%-41px)] flex flex-col ">
<div className="flex mt-[10px] pr-[40px]">
<McpToolsContainer tools={tools} />
<IntegrationAIContainer
consumerParams={{ consumerId: appId!, teamId: teamId! }}
type={'consumer'}
handleToolsChange={handleToolsChange}
></IntegrationAIContainer>
</div>
</div>
</div>
)
}
export default mcpContent