调整文档路径
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.vscode
|
||||
.idea
|
||||
@@ -0,0 +1,32 @@
|
||||
# ── Stage 1: Build ──────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 先复制依赖描述文件,利用 Docker 缓存层
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
|
||||
# 复制源码
|
||||
COPY . .
|
||||
|
||||
# 构建生产包(tsc + vite build → dist/)
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: Serve ──────────────────────────────────
|
||||
FROM nginx:alpine
|
||||
|
||||
# 将构建产物复制到 Nginx 默认静态目录
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Nginx 配置模板(支持 envsubst 环境变量替换)
|
||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||
|
||||
# 默认后端 API 地址(运行时可通过 -e BACKEND_URL=... 覆盖)
|
||||
ENV BACKEND_URL=https://ai-workshop.digiwincloud.com.cn/mcp-auth-api
|
||||
|
||||
EXPOSE 8902
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,15 @@
|
||||
# MCP Auth Frontend — Nginx 托管 React 静态文件,/api 反代后端 8003(端口 8902)
|
||||
# 启动:docker compose up -d
|
||||
# 停止:docker compose down
|
||||
# 日志:docker compose logs -f
|
||||
services:
|
||||
mcp-auth-frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: mcp-auth-frontend:latest
|
||||
container_name: mcp-auth-frontend
|
||||
network_mode: host
|
||||
environment:
|
||||
- BACKEND_URL=${BACKEND_URL:-https://mcp-auth-admin.digiwincloud.com.cn/mcp-auth-api}
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MCP Token 管理后台</title>
|
||||
<style>
|
||||
.iam-login-input input::placeholder { color: #858D9A !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,109 @@
|
||||
server {
|
||||
listen 8902;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# gzip 压缩
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
# ── /console 子路径(外部 nginx 不剥离前缀,需在此处理)──
|
||||
|
||||
# 后端 API 反向代理(/console/api/ → 后端 /api/)
|
||||
location /console/api/ {
|
||||
proxy_pass ${BACKEND_URL}/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location /console/health {
|
||||
proxy_pass ${BACKEND_URL}/api/health;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 鼎捷云 IAM 登录服务反向代理(/console/iam-api/ → iam.digiwincloud.com.cn/)
|
||||
location /console/iam-api/ {
|
||||
proxy_pass https://iam.digiwincloud.com.cn/;
|
||||
proxy_set_header Host iam.digiwincloud.com.cn;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_ssl_server_name on;
|
||||
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
}
|
||||
|
||||
# 静态资源长缓存(Vite 构建产物带 hash,/console/assets/ → /assets/)
|
||||
location /console/assets/ {
|
||||
alias /usr/share/nginx/html/assets/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA 路由 fallback:/console 下的非文件请求回退到 index.html
|
||||
location /console/ {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# ── 根路径(直接访问容器时使用)──
|
||||
|
||||
# 后端 API 反向代理
|
||||
location /api/ {
|
||||
proxy_pass ${BACKEND_URL}/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location /health {
|
||||
proxy_pass ${BACKEND_URL}/api/health;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 鼎捷云 IAM 登录服务反向代理
|
||||
location /iam-api/ {
|
||||
proxy_pass https://iam.digiwincloud.com.cn/;
|
||||
proxy_set_header Host iam.digiwincloud.com.cn;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_ssl_server_name on;
|
||||
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
}
|
||||
|
||||
# 静态资源长缓存
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SVG / 图片缓存
|
||||
location ~* \.(svg|png|jpg|jpeg|gif|ico|webp)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# SPA 路由 fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+3128
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "mcp-auth-admin-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.5.0",
|
||||
"antd": "^5.21.0",
|
||||
"axios": "^1.7.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"jsencrypt": "^3.3.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Layout, Menu, theme } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import SSOLogin from './pages/SSOLogin';
|
||||
import Services from './pages/Services';
|
||||
import Stats from './pages/Stats';
|
||||
import Tokens from './pages/Tokens';
|
||||
import { clearSession, getUserInfo } from './services/iamAuth';
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
/** 登录守卫:sessionStorage 无 userToken 则跳登录页(携带来源路径,登录后回跳) */
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation();
|
||||
const token = sessionStorage.getItem('userToken');
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function AppLayout() {
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const {
|
||||
token: { colorBgContainer },
|
||||
} = theme.useToken();
|
||||
|
||||
const userInfo = getUserInfo();
|
||||
const displayName = userInfo?.userName || userInfo?.userId || '';
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider collapsible collapsed={collapsed} onCollapse={setCollapsed}>
|
||||
<div
|
||||
style={{
|
||||
height: 48,
|
||||
margin: 12,
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
lineHeight: '48px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
MCP Auth
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[loc.pathname]}
|
||||
items={[
|
||||
{ key: '/tokens', label: 'Token 管理' },
|
||||
{ key: '/services', label: '服务管理' },
|
||||
{ key: '/stats', label: '统计概览' },
|
||||
]}
|
||||
onClick={({ key }) => nav(key)}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header
|
||||
style={{
|
||||
padding: '0 24px',
|
||||
background: colorBgContainer,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span>MCP Token 鉴权管理后台</span>
|
||||
<span style={{ fontSize: 13, color: '#999' }}>
|
||||
{displayName} ·{' '}
|
||||
<a
|
||||
onClick={() => {
|
||||
clearSession();
|
||||
nav('/login', { replace: true });
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</a>
|
||||
</span>
|
||||
</Header>
|
||||
<Content style={{ margin: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 24,
|
||||
minHeight: 480,
|
||||
background: colorBgContainer,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/tokens" element={<Tokens />} />
|
||||
<Route path="/services" element={<Services />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
<Route path="*" element={<Navigate to="/tokens" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// 路由变化时重渲染,重读 sessionStorage 登录态
|
||||
useLocation();
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/sso-login" element={<SSOLogin />} />
|
||||
<Route path="/*" element={<RequireAuth><AppLayout /></RequireAuth>} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import axios from 'axios';
|
||||
import { APP_TOKEN } from '../services/iamAuth';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${import.meta.env.BASE_URL}api`,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// 请求拦截:自动带 IAM 鉴权头(digi-middleware-auth-user / -app)
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = sessionStorage.getItem('userToken');
|
||||
if (token) {
|
||||
config.headers['digi-middleware-auth-user'] = token;
|
||||
config.headers['digi-middleware-auth-app'] = APP_TOKEN;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截:401 清理会话并跳登录页
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
const path = window.location.pathname;
|
||||
if (path !== `${import.meta.env.BASE_URL}login` && !path.startsWith(`${import.meta.env.BASE_URL}sso-login`)) {
|
||||
sessionStorage.removeItem('userToken');
|
||||
sessionStorage.removeItem('userInfo');
|
||||
window.location.href = `${import.meta.env.BASE_URL}login`;
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,158 @@
|
||||
import api from './client';
|
||||
|
||||
export interface TokenRow {
|
||||
token_id: number;
|
||||
token_prefix: string;
|
||||
token_plain: string | null;
|
||||
client_id: string;
|
||||
service_scope: string;
|
||||
service_url: string | null;
|
||||
status: string;
|
||||
expires_at: string | null;
|
||||
description: string | null;
|
||||
created_at: string | null;
|
||||
created_by: string;
|
||||
revoked_at: string | null;
|
||||
revoke_reason: string | null;
|
||||
last_used_at: string | null;
|
||||
last_used_svc: string | null;
|
||||
}
|
||||
|
||||
export interface TokenCreate {
|
||||
client_id: string;
|
||||
service_scope: string;
|
||||
description?: string;
|
||||
expires_at?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateResult {
|
||||
token: string;
|
||||
token_id: number;
|
||||
token_prefix: string;
|
||||
client_id: string;
|
||||
service_scope: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function listTokens(params?: {
|
||||
client_id?: string;
|
||||
status?: string;
|
||||
service_scope?: string;
|
||||
}) {
|
||||
const { data } = await api.get('/tokens', { params });
|
||||
return data as { total: number; tokens: TokenRow[] };
|
||||
}
|
||||
|
||||
export async function createToken(payload: TokenCreate) {
|
||||
const { data } = await api.post('/tokens', payload);
|
||||
return data as CreateResult;
|
||||
}
|
||||
|
||||
export async function revokeToken(token_id: number, reason?: string) {
|
||||
const { data } = await api.put(`/tokens/${token_id}/revoke`, { reason });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function enableToken(token_id: number) {
|
||||
const { data } = await api.put(`/tokens/${token_id}/enable`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteToken(token_id: number) {
|
||||
const { data } = await api.delete(`/tokens/${token_id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateToken(
|
||||
token_id: number,
|
||||
payload: { description?: string; expires_at?: string | null },
|
||||
) {
|
||||
const { data } = await api.patch(`/tokens/${token_id}`, payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function tokenLogs(token_id: number) {
|
||||
const { data } = await api.get(`/tokens/${token_id}/logs`);
|
||||
return data as {
|
||||
total: number;
|
||||
logs: Array<{
|
||||
log_id: number;
|
||||
event: string;
|
||||
service: string | null;
|
||||
client_ip: string | null;
|
||||
occurred_at: string | null;
|
||||
detail: unknown;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStats() {
|
||||
const { data } = await api.get('/stats');
|
||||
return data as {
|
||||
total: number;
|
||||
active: number;
|
||||
revoked: number;
|
||||
by_scope: Record<string, number>;
|
||||
calls_24h: number;
|
||||
clients_24h: number;
|
||||
recent: Array<{
|
||||
token_prefix: string;
|
||||
client_id: string;
|
||||
service: string | null;
|
||||
last_used_at: string | null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== MCP 服务管理 ====================
|
||||
|
||||
export interface ServiceRow {
|
||||
service_id: number;
|
||||
service_name: string;
|
||||
service_url: string;
|
||||
api_key: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
created_at: string | null;
|
||||
created_by: string;
|
||||
revoked_at: string | null;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export interface ServiceCreate {
|
||||
service_name: string;
|
||||
service_url: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ServiceCreateResult {
|
||||
api_key: string;
|
||||
service_id: number;
|
||||
service_name: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function listServices(params?: { status?: string }) {
|
||||
const { data } = await api.get('/services', { params });
|
||||
return data as { total: number; services: ServiceRow[] };
|
||||
}
|
||||
|
||||
export async function registerService(payload: ServiceCreate) {
|
||||
const { data } = await api.post('/services', payload);
|
||||
return data as ServiceCreateResult;
|
||||
}
|
||||
|
||||
export async function revokeService(service_id: number) {
|
||||
const { data } = await api.patch(`/services/${service_id}/revoke`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function enableService(service_id: number) {
|
||||
const { data } = await api.patch(`/services/${service_id}/enable`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteService(service_id: number) {
|
||||
const { data } = await api.delete(`/services/${service_id}`);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import App from './App';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Form, Input } from 'antd';
|
||||
import { LockOutlined, UserOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { iamLogin, saveSession } from '../../services/iamAuth';
|
||||
|
||||
/**
|
||||
* IAM 登录页(鼎捷云统一身份认证)
|
||||
*
|
||||
* 参考 ai-platform 实现:账号密码经 RSA+AES 加密链路上送 IAM,
|
||||
* 登录成功后 userToken + userInfo 写入 sessionStorage,回跳原页面。
|
||||
*/
|
||||
export default function Login() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
|
||||
const onFinish = async (values: { userId: string; password: string }) => {
|
||||
if (!values.userId.trim() || !values.password) {
|
||||
setError('请输入账号和密码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await iamLogin({ userId: values.userId.trim(), password: values.password });
|
||||
saveSession(result.token, result.userInfo);
|
||||
// 回跳原页面(守卫记录的 from),否则去首页
|
||||
const from = (loc.state as { from?: string })?.from ?? '/';
|
||||
nav(from, { replace: true });
|
||||
} catch (ex) {
|
||||
setError(ex instanceof Error ? ex.message : '登录失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100vw',
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
boxSizing: 'border-box',
|
||||
background: 'linear-gradient(160deg, #0a1628 0%, #0d1b30 50%, #0a1628 100%)',
|
||||
fontFamily: "-apple-system, 'SF Pro Display', 'SF Pro Text', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif",
|
||||
color: '#a8d8ff',
|
||||
}}>
|
||||
{/* 装饰光晕 */}
|
||||
<div style={{
|
||||
position: 'fixed', top: '20%', left: '50%', transform: 'translateX(-50%)',
|
||||
width: 600, height: 600, borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(0,212,255,0.12) 0%, transparent 70%)',
|
||||
filter: 'blur(40px)', pointerEvents: 'none', zIndex: 0,
|
||||
}} />
|
||||
|
||||
<div style={{
|
||||
position: 'relative', zIndex: 1,
|
||||
width: 400, maxWidth: '92vw',
|
||||
padding: '40px 36px 32px',
|
||||
borderRadius: 20,
|
||||
background: 'rgba(10,22,40,0.75)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
border: '1px solid rgba(0,212,255,0.2)',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.5), 0 0 48px rgba(0,212,255,0.08)',
|
||||
}}>
|
||||
{/* 品牌区 */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<h1 style={{
|
||||
fontSize: 26, fontWeight: 800, letterSpacing: '0.04em', margin: 0,
|
||||
color: '#e8f4ff', textShadow: '0 0 16px rgba(0,212,255,0.5)',
|
||||
}}>
|
||||
MCP Token 管理后台
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<Form onFinish={onFinish} size="large" autoComplete="on">
|
||||
<Form.Item
|
||||
name="userId"
|
||||
rules={[{ required: true, message: '请输入账号' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: 'rgba(0,212,255,0.6)' }} />}
|
||||
placeholder="账号(用户ID / 手机号 / 邮箱)"
|
||||
autoComplete="username"
|
||||
className="iam-login-input"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: 'rgba(0,212,255,0.06)',
|
||||
borderColor: 'rgba(0,212,255,0.25)',
|
||||
color: '#e8f4ff',
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: 'rgba(0,212,255,0.6)' }} />}
|
||||
placeholder="密码"
|
||||
autoComplete="current-password"
|
||||
className="iam-login-input"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: 'rgba(0,212,255,0.06)',
|
||||
borderColor: 'rgba(0,212,255,0.25)',
|
||||
color: '#e8f4ff',
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 12px', marginBottom: 16,
|
||||
borderRadius: 8, fontSize: 13,
|
||||
background: 'rgba(239,68,68,0.12)',
|
||||
border: '1px solid rgba(239,68,68,0.4)',
|
||||
color: '#fca5a5',
|
||||
}}>
|
||||
<CloseCircleOutlined style={{ flexShrink: 0 }} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
style={{
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.08em',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(90deg, #00d4ff, #4f9eff)',
|
||||
boxShadow: '0 8px 24px rgba(0,212,255,0.35)',
|
||||
}}
|
||||
>
|
||||
{loading ? '登录中...' : '登 录'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<p style={{ fontSize: 13, marginTop: 24, textAlign: 'center', color: 'rgba(168,216,255,0.6)' }}>
|
||||
鼎捷云统一身份认证 · IAM
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Spin } from 'antd';
|
||||
import { CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { iamSsoLogin, saveSession } from '../../services/iamAuth';
|
||||
|
||||
/**
|
||||
* SSO 登录回调入口
|
||||
*
|
||||
* 流程:
|
||||
* 1. 从 URL 参数获取 userToken
|
||||
* 2. 调用 iamSsoLogin 完成:
|
||||
* - POST /identity/token/refresh/app 刷新应用 token + 用户信息
|
||||
* - POST /identity/login/info 获取登录详情
|
||||
* - POST /tenant?appId=APPID 拉取租户列表,选默认租户
|
||||
* - POST /identity/token/refresh/tenant 切换租户刷新 token
|
||||
* 3. userInfo 写入 sessionStorage,跳转首页
|
||||
*/
|
||||
const SSOLogin: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const userToken = searchParams.get('userToken');
|
||||
if (!userToken) {
|
||||
setError('URL 缺少 userToken 参数');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await iamSsoLogin(userToken);
|
||||
if (cancelled) return;
|
||||
saveSession(result.token, result.userInfo);
|
||||
navigate('/', { replace: true });
|
||||
} catch (ex) {
|
||||
if (cancelled) return;
|
||||
setError(ex instanceof Error ? ex.message : 'SSO 登录失败');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [searchParams, navigate]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#0a1628',
|
||||
color: '#a8d8ff',
|
||||
fontFamily: "-apple-system, 'SF Pro Display', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif",
|
||||
}}>
|
||||
{error ? (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16,
|
||||
padding: '32px 40px',
|
||||
borderRadius: 16,
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
border: '1px solid rgba(239,68,68,0.4)',
|
||||
maxWidth: 460,
|
||||
}}>
|
||||
<CloseCircleOutlined style={{ fontSize: 36, color: '#fca5a5' }} />
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#fca5a5' }}>SSO 登录失败</div>
|
||||
<div style={{ fontSize: 13, color: 'rgba(168,216,255,0.7)', textAlign: 'center', wordBreak: 'break-all' }}>
|
||||
{error}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/login', { replace: true })}
|
||||
style={{
|
||||
marginTop: 8, padding: '8px 20px', borderRadius: 8,
|
||||
border: '1px solid rgba(0,212,255,0.4)', background: 'rgba(0,212,255,0.1)',
|
||||
color: '#00d4ff', fontSize: 13, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
前往登录页
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ fontSize: 14, color: 'rgba(168,216,255,0.7)' }}>正在登录...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SSOLogin;
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
listServices,
|
||||
registerService,
|
||||
revokeService,
|
||||
enableService,
|
||||
deleteService,
|
||||
type ServiceCreateResult,
|
||||
type ServiceRow,
|
||||
} from '../../api';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
active: 'green',
|
||||
revoked: 'red',
|
||||
};
|
||||
|
||||
export default function Services() {
|
||||
const [rows, setRows] = useState<ServiceRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [created, setCreated] = useState<ServiceCreateResult | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listServices();
|
||||
setRows(res.services);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const onRevoke = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '吊销此服务的 API Key?',
|
||||
content: '吊销后该 MCP 服务将无法调用鉴权 API。吊销后可执行删除操作。',
|
||||
okText: '吊销',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await revokeService(id);
|
||||
message.success('已吊销');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.detail || '吊销失败');
|
||||
} finally {
|
||||
load();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onEnable = async (id: number) => {
|
||||
await enableService(id);
|
||||
message.success('已启用');
|
||||
load();
|
||||
};
|
||||
|
||||
const onDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '删除此服务?',
|
||||
content: '删除后记录将永久移除,无法恢复。',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteService(id);
|
||||
message.success('已删除');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.detail || '删除失败');
|
||||
} finally {
|
||||
load();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onCreate = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const res = await registerService({
|
||||
service_name: values.service_name,
|
||||
service_url: values.service_url,
|
||||
description: values.description,
|
||||
});
|
||||
setCreated(res);
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.response?.data?.detail) {
|
||||
message.error(e.response.data.detail);
|
||||
} else if (!e?.errorFields) {
|
||||
message.error('注册失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
message.success('已复制');
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ServiceRow> = [
|
||||
{
|
||||
title: 'API Key',
|
||||
dataIndex: 'api_key',
|
||||
key: 'api_key',
|
||||
width: 400,
|
||||
render: (key: string) => (
|
||||
<Space size="small">
|
||||
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
|
||||
{key}
|
||||
</Typography.Text>
|
||||
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => onCopy(key)} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 150, ellipsis: true },
|
||||
{
|
||||
title: 'MCP服务地址',
|
||||
dataIndex: 'service_url',
|
||||
key: 'service_url',
|
||||
width: 280,
|
||||
ellipsis: true,
|
||||
render: (u: string) => (
|
||||
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
|
||||
{u}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{ title: '说明', dataIndex: 'description', key: 'description', width: 200, ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (s: string) => <Tag color={STATUS_COLOR[s] || 'default'}>{s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 160,
|
||||
render: (t: string) => (t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
},
|
||||
{
|
||||
title: '最近调用',
|
||||
dataIndex: 'last_used_at',
|
||||
key: 'last_used_at',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (t: string) => (t ? dayjs(t).format('MM-DD HH:mm') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_, row: ServiceRow) =>
|
||||
row.status === 'active' ? (
|
||||
<Space size="small">
|
||||
<Button type="link" danger size="small" onClick={() => onRevoke(row.service_id)}>
|
||||
吊销
|
||||
</Button>
|
||||
<Button type="link" size="small" disabled>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
) : row.status === 'revoked' ? (
|
||||
<Space size="small">
|
||||
<Button type="link" size="small" onClick={() => onEnable(row.service_id)}>
|
||||
启用
|
||||
</Button>
|
||||
<Button type="link" danger size="small" onClick={() => onDelete(row.service_id)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
注册服务
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowKey="service_id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 15 }}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1540 }}
|
||||
/>
|
||||
|
||||
{/* 注册服务表单 */}
|
||||
<Modal
|
||||
title="注册 MCP 服务"
|
||||
open={createOpen}
|
||||
onOk={onCreate}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
okText="注册"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="service_name"
|
||||
label="服务标识"
|
||||
rules={[{ required: true, message: '请输入服务标识' }]}
|
||||
>
|
||||
<Input placeholder="如 erp / crm" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="service_url"
|
||||
label="MCP 服务地址"
|
||||
rules={[{ required: true, message: '请输入 MCP 服务地址' }]}
|
||||
>
|
||||
<Input placeholder="如 http://10.100.154.100:8001/mcp" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={2} placeholder="服务用途说明" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 注册结果:API Key */}
|
||||
<Modal
|
||||
title="API Key 已生成"
|
||||
open={created !== null}
|
||||
onCancel={() => setCreated(null)}
|
||||
footer={<Button type="primary" onClick={() => setCreated(null)}>关闭</Button>}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
配置到 MCP 服务的 <code>MCP_AUTH_API_KEY</code> 环境变量:
|
||||
</Typography.Paragraph>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={created?.api_key || ''}
|
||||
readOnly
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => created?.api_key && onCopy(created.api_key)}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Col, Row, Statistic, Table, Tag, Typography } from 'antd';
|
||||
import { getStats } from '../../api';
|
||||
|
||||
export default function Stats() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getStats>> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getStats().then(setData).catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!data) return <Typography>加载中…</Typography>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Token 总数" value={data.total} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="活跃" value={data.active} valueStyle={{ color: '#3f8600' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="已吊销" value={data.revoked} valueStyle={{ color: '#cf1322' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="近 24h 调用" value={data.calls_24h} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="按服务范围分布" style={{ marginBottom: 24 }}>
|
||||
<Row gutter={16}>
|
||||
{Object.entries(data.by_scope).map(([scope, cnt]) => (
|
||||
<Col key={scope} span={8}>
|
||||
<Statistic title={scope} value={cnt} />
|
||||
</Col>
|
||||
))}
|
||||
{Object.keys(data.by_scope).length === 0 && (
|
||||
<Typography.Text type="secondary">暂无数据</Typography.Text>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card title="最近调用">
|
||||
<Table
|
||||
dataSource={data.recent}
|
||||
rowKey={(_, i) => String(i)}
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{ title: 'Token', dataIndex: 'token_prefix', key: 'token_prefix' },
|
||||
{ title: '客户端', dataIndex: 'client_id', key: 'client_id' },
|
||||
{
|
||||
title: '服务',
|
||||
dataIndex: 'service',
|
||||
key: 'service',
|
||||
render: (s: string) => (s ? <Tag color="blue">{s}</Tag> : '-'),
|
||||
},
|
||||
{
|
||||
title: '调用时间',
|
||||
dataIndex: 'last_used_at',
|
||||
key: 'last_used_at',
|
||||
render: (t: string) => (t ? new Date(t).toLocaleString('zh-CN') : '-'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
createToken,
|
||||
listTokens,
|
||||
listServices,
|
||||
revokeToken,
|
||||
enableToken,
|
||||
deleteToken,
|
||||
type CreateResult,
|
||||
type TokenRow,
|
||||
type ServiceRow,
|
||||
} from '../../api';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
active: 'green',
|
||||
revoked: 'red',
|
||||
expired: 'orange',
|
||||
};
|
||||
|
||||
export default function Tokens() {
|
||||
const [rows, setRows] = useState<TokenRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [created, setCreated] = useState<CreateResult | null>(null);
|
||||
const [services, setServices] = useState<ServiceRow[]>([]);
|
||||
const [jsonRow, setJsonRow] = useState<TokenRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const buildRegisterJson = (row: TokenRow): string => {
|
||||
const json = {
|
||||
mcpServers: {
|
||||
[row.service_scope]: {
|
||||
type: 'streamable-http',
|
||||
url: row.service_url || '',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${row.token_plain || ''}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return JSON.stringify(json, null, 2);
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listTokens();
|
||||
setRows(res.tokens);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadServices = async () => {
|
||||
try {
|
||||
const res = await listServices({ status: 'active' });
|
||||
setServices(res.services);
|
||||
} catch {
|
||||
// 服务列表加载失败不影响 token 页面使用
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
loadServices();
|
||||
}, []);
|
||||
|
||||
const onRevoke = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '吊销此 Token?',
|
||||
content: '吊销后该 Token 立即失效。吊销后可执行删除操作。',
|
||||
okText: '吊销',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await revokeToken(id, '管理员手动吊销');
|
||||
message.success('已吊销');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.detail || '吊销失败');
|
||||
} finally {
|
||||
load();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onEnable = async (id: number) => {
|
||||
await enableToken(id);
|
||||
message.success('已启用');
|
||||
load();
|
||||
};
|
||||
|
||||
const onDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '删除此 Token?',
|
||||
content: '删除后记录将永久移除,无法恢复。',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteToken(id);
|
||||
message.success('已删除');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.detail || '删除失败');
|
||||
} finally {
|
||||
load();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onCopy = async (text: string) => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
// 非 HTTPS 环境降级方案:临时 textarea + execCommand
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
if (!ok) throw new Error('copy failed');
|
||||
}
|
||||
message.success('已复制');
|
||||
} catch {
|
||||
message.error('复制失败,请手动选择文本复制');
|
||||
}
|
||||
};
|
||||
|
||||
const onCreate = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const res = await createToken({
|
||||
client_id: values.client_id,
|
||||
service_scope: values.service_scope,
|
||||
description: values.description,
|
||||
expires_at: values.expires_at ? values.expires_at.toISOString() : null,
|
||||
});
|
||||
setCreated(res);
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.response?.data?.detail) {
|
||||
message.error(e.response.data.detail);
|
||||
} else if (e?.errorFields) {
|
||||
// 表单校验错误,antd 自动处理
|
||||
} else {
|
||||
message.error('签发失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<TokenRow> = [
|
||||
{ title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 150, ellipsis: true },
|
||||
{
|
||||
title: '服务标识',
|
||||
dataIndex: 'service_scope',
|
||||
key: 'service_scope',
|
||||
width: 180,
|
||||
render: (s: string) => <Tag color="blue">{s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'MCP服务地址',
|
||||
dataIndex: 'service_url',
|
||||
key: 'service_url',
|
||||
width: 280,
|
||||
ellipsis: true,
|
||||
render: (u: string | null) => (
|
||||
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
|
||||
{u || '-'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Token',
|
||||
dataIndex: 'token_plain',
|
||||
key: 'token',
|
||||
width: 300,
|
||||
render: (plain: string | null, row: TokenRow) => {
|
||||
if (!plain) return row.token_prefix;
|
||||
const masked = plain.slice(0, 12) + '••••••••';
|
||||
return (
|
||||
<Space size="small">
|
||||
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
|
||||
{masked}
|
||||
</Typography.Text>
|
||||
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => onCopy(plain)} />
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (s: string) => <Tag color={STATUS_COLOR[s] || 'default'}>{s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '过期时间',
|
||||
dataIndex: 'expires_at',
|
||||
key: 'expires_at',
|
||||
width: 160,
|
||||
render: (t: string) => (t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '永不过期'),
|
||||
},
|
||||
{ title: '说明', dataIndex: 'description', key: 'description', width: 200, ellipsis: true },
|
||||
{
|
||||
title: '最近调用',
|
||||
dataIndex: 'last_used_at',
|
||||
key: 'last_used_at',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (t: string, row: TokenRow) =>
|
||||
t ? `${dayjs(t).format('MM-DD HH:mm')} (${row.last_used_svc || '-'})` : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 240,
|
||||
fixed: 'right',
|
||||
render: (_, row: TokenRow) =>
|
||||
row.status === 'active' ? (
|
||||
<Space size="small">
|
||||
<Button type="link" danger size="small" onClick={() => onRevoke(row.token_id)}>
|
||||
吊销
|
||||
</Button>
|
||||
<Button type="link" size="small" disabled>
|
||||
删除
|
||||
</Button>
|
||||
<Button type="link" size="small" onClick={() => setJsonRow(row)}>
|
||||
注册JSON
|
||||
</Button>
|
||||
</Space>
|
||||
) : row.status === 'revoked' ? (
|
||||
<Space size="small">
|
||||
<Button type="link" size="small" onClick={() => onEnable(row.token_id)}>
|
||||
启用
|
||||
</Button>
|
||||
<Button type="link" danger size="small" onClick={() => onDelete(row.token_id)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => { loadServices(); setCreateOpen(true); }}>
|
||||
签发 Token
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowKey="token_id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 15 }}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1810 }}
|
||||
/>
|
||||
|
||||
{/* 签发表单 */}
|
||||
<Modal
|
||||
title="签发新 Token"
|
||||
open={createOpen}
|
||||
onOk={onCreate}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
okText="签发"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={{ service_scope: undefined }}>
|
||||
<Form.Item
|
||||
name="client_id"
|
||||
label="客户端标识"
|
||||
rules={[{ required: true, message: '请输入 client_id' }]}
|
||||
>
|
||||
<Input placeholder="如 trae / partner-a" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="service_scope"
|
||||
label="服务范围"
|
||||
rules={[{ required: true, message: '请选择服务' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择 MCP 服务"
|
||||
options={services.map((s) => ({
|
||||
value: s.service_url,
|
||||
label: s.service_name.toUpperCase(),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="expires_at" label="过期时间(留空=永不过期)">
|
||||
<Input type="datetime-local" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={2} placeholder="用途说明" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 签发结果:明文 token 仅此一次 */}
|
||||
<Modal
|
||||
title="Token 已签发"
|
||||
open={created !== null}
|
||||
onCancel={() => setCreated(null)}
|
||||
footer={<Button type="primary" onClick={() => setCreated(null)}>我已保存</Button>}
|
||||
>
|
||||
<Typography.Paragraph type="warning">
|
||||
请立即复制并保存,此 Token 仅显示一次,之后无法再次查看。
|
||||
</Typography.Paragraph>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={created?.token || ''}
|
||||
readOnly
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>
|
||||
{created?.message}
|
||||
</Typography.Paragraph>
|
||||
</Modal>
|
||||
|
||||
{/* 注册 JSON 查看结果 */}
|
||||
<Modal
|
||||
title={`注册 JSON — ${jsonRow?.client_id || ''}`}
|
||||
open={jsonRow !== null}
|
||||
onCancel={() => setJsonRow(null)}
|
||||
footer={
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
if (jsonRow) onCopy(buildRegisterJson(jsonRow));
|
||||
setJsonRow(null);
|
||||
}}
|
||||
>
|
||||
复制并关闭
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={12}
|
||||
value={jsonRow ? buildRegisterJson(jsonRow) : ''}
|
||||
readOnly
|
||||
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { JSEncrypt } from 'jsencrypt';
|
||||
|
||||
/**
|
||||
* 鼎捷云 IAM 登录服务(纯前端实现,参考 ai-platform)
|
||||
*
|
||||
* 完整登录流程:
|
||||
* 1. RSA+AES 加密链路获取 userToken(/api/iam/v2/identity/login)
|
||||
* 2. 拉取用户授权租户列表(POST /api/iam/v2/tenant?appId=APPID),默认选第一个
|
||||
* 3. 切换租户刷新 token(POST /api/iam/v2/identity/token/refresh/tenant)
|
||||
* 4. 将完整用户信息(含 authoredUser)写入 sessionStorage
|
||||
*
|
||||
* 注意:APP_TOKEN / APPID 为 IAM 应用凭证,需替换为本应用在鼎捷云 IAM 注册的配置。
|
||||
*/
|
||||
|
||||
// 代理路径(vite.config.ts 中 /iam-api → https://iam.digiwincloud.com.cn)
|
||||
const IAM_API_BASE = `${import.meta.env.BASE_URL}iam-api/api/iam/v2`;
|
||||
const IAM_IDENTITY_BASE = `${IAM_API_BASE}/identity`;
|
||||
|
||||
// 应用 apptoken(digi-middleware-auth-app)
|
||||
export const APP_TOKEN =
|
||||
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRhdGEtYnVzaW5lc3MtZGVtbyIsInNpZCI6MH0.Spo64LstbWxjYNefVFAbEbgfjzZoQGNcqKSGuYUOCRk';
|
||||
|
||||
// 应用 ID(用于租户列表查询)
|
||||
export const APPID = 'data-business-demo';
|
||||
|
||||
// IAM AES 加密固定 IV(16 字节)
|
||||
const AES_IV = 'ghUb#er57HBh(u%g';
|
||||
|
||||
/** PEM 包装/剥离工具 */
|
||||
function wrapPem(base64Key: string): string {
|
||||
if (base64Key.includes('BEGIN')) return base64Key;
|
||||
const body = base64Key.replace(/-----(BEGIN|END)[^-]+-----/g, '').replace(/\s+/g, '');
|
||||
const lines = body.match(/.{1,64}/g) || [];
|
||||
return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----`;
|
||||
}
|
||||
|
||||
function stripPem(pem: string): string {
|
||||
return pem.replace(/-----(BEGIN|END)[^-]+-----/g, '').replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/** AES-CBC/PKCS7 加密,输出 base64(与 Java AES/CBC/PKCS5Padding 等价) */
|
||||
async function aesEncryptToBase64(plainText: string, aesKey: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const keyData = enc.encode(aesKey);
|
||||
const ivData = enc.encode(AES_IV);
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', keyData, { name: 'AES-CBC' }, false, ['encrypt']);
|
||||
const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: ivData }, cryptoKey, enc.encode(plainText));
|
||||
const bytes = new Uint8Array(cipherBuf);
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
/** 应用层请求头(含 apptoken) */
|
||||
function appHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'digi-middleware-auth-app': APP_TOKEN,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** 用户鉴权请求头(含 apptoken + usertoken) */
|
||||
function userHeaders(userToken: string, extra?: Record<string, string>): Record<string, string> {
|
||||
return appHeaders({
|
||||
'digi-middleware-auth-user': userToken,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
/** 从任意对象中尝试提取 token 字符串 */
|
||||
function pickToken(obj: Record<string, unknown>): string | undefined {
|
||||
if (typeof obj.token === 'string') return obj.token;
|
||||
if (typeof obj.userToken === 'string') return obj.userToken;
|
||||
const data = obj.data as Record<string, unknown> | undefined;
|
||||
if (data && typeof data.token === 'string') return data.token;
|
||||
const result = obj.result as Record<string, unknown> | undefined;
|
||||
if (result && typeof result.token === 'string') return result.token;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface IamLoginParams {
|
||||
userId: string;
|
||||
password: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface IamLoginResult {
|
||||
/** 最终 userToken(经 refresh/tenant 刷新后) */
|
||||
token: string;
|
||||
/** userId */
|
||||
userId: string;
|
||||
/** 完整用户信息(含 login 原始返回 + authoredUser + 默认租户) */
|
||||
userInfo: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* IAM 普通登录(identityType: query)
|
||||
* 完整流程:加密登录 → 拉取租户列表 → 切换默认租户刷新 token
|
||||
*/
|
||||
export async function iamLogin({ userId, password, tenantId }: IamLoginParams): Promise<IamLoginResult> {
|
||||
// 1. 客户端生成 RSA 密钥对(1024)
|
||||
const client = new JSEncrypt({ default_key_size: '1024' });
|
||||
client.getKey();
|
||||
const clientPrivateKeyPem = client.getPrivateKey();
|
||||
const clientPublicKeyB64 = stripPem(client.getPublicKey());
|
||||
|
||||
// 2. 获取服务端公钥
|
||||
const pkRes = await fetch(`${IAM_IDENTITY_BASE}/publickey`, { headers: appHeaders() });
|
||||
if (!pkRes.ok) throw new Error(`获取服务端公钥失败 (HTTP ${pkRes.status})`);
|
||||
const pkJson = await pkRes.json();
|
||||
const serverPublicKey: string = pkJson.publicKey;
|
||||
if (!serverPublicKey) throw new Error('服务端公钥为空');
|
||||
|
||||
// 3. 服务端公钥加密客户端公钥
|
||||
const server = new JSEncrypt();
|
||||
server.setPublicKey(wrapPem(serverPublicKey));
|
||||
const clientEncryptPublicKey = server.encrypt(clientPublicKeyB64);
|
||||
if (!clientEncryptPublicKey) throw new Error('加密客户端公钥失败');
|
||||
|
||||
// 4. 获取加密的 AES 密钥
|
||||
const aesRes = await fetch(`${IAM_IDENTITY_BASE}/aeskey`, {
|
||||
method: 'POST',
|
||||
headers: appHeaders(),
|
||||
body: JSON.stringify({ clientEncryptPublicKey }),
|
||||
});
|
||||
if (!aesRes.ok) throw new Error(`获取 AES 密钥失败 (HTTP ${aesRes.status})`);
|
||||
const aesJson = await aesRes.json();
|
||||
const encryptAesKey: string = aesJson.encryptAesKey;
|
||||
if (!encryptAesKey) throw new Error(`获取 AES 密钥失败: ${JSON.stringify(aesJson)}`);
|
||||
|
||||
// 5. 客户端私钥解密 AES 密钥
|
||||
client.setPrivateKey(clientPrivateKeyPem);
|
||||
const aesKey = client.decrypt(encryptAesKey);
|
||||
if (!aesKey) throw new Error('解密 AES 密钥失败');
|
||||
|
||||
// 6. AES 加密密码
|
||||
const passwordHash = await aesEncryptToBase64(password, aesKey);
|
||||
|
||||
// 7. 登录
|
||||
const loginBody: Record<string, string> = {
|
||||
userId,
|
||||
passwordHash,
|
||||
clientEncryptPublicKey,
|
||||
identityType: 'query',
|
||||
};
|
||||
if (tenantId) loginBody.tenantId = tenantId;
|
||||
|
||||
const loginRes = await fetch(`${IAM_IDENTITY_BASE}/login`, {
|
||||
method: 'POST',
|
||||
headers: appHeaders(),
|
||||
body: JSON.stringify(loginBody),
|
||||
});
|
||||
const loginJson = (await loginRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
|
||||
const initialToken = pickToken(loginJson);
|
||||
if (!loginRes.ok || !initialToken) {
|
||||
const msg = loginJson.message || loginJson.msg || loginJson.error || `HTTP ${loginRes.status}`;
|
||||
throw new Error(`登录失败: ${msg}`);
|
||||
}
|
||||
|
||||
// 8. 拉取用户授权租户列表 + 切换默认租户
|
||||
const tenantCtx = await switchDefaultTenant(initialToken);
|
||||
|
||||
// 9. 组装完整 userInfo
|
||||
const userInfo: Record<string, unknown> = {
|
||||
...(tenantCtx.authoredUser ?? {}),
|
||||
...(loginJson ?? {}),
|
||||
userId,
|
||||
token: tenantCtx.token,
|
||||
isLoggedin: true,
|
||||
...(tenantCtx.currTenantList ? { currTenantList: tenantCtx.currTenantList } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
token: tenantCtx.token,
|
||||
userId,
|
||||
userInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换默认租户(公用流程)
|
||||
*
|
||||
* 1. POST /api/iam/v2/tenant?appId=APPID 拉取租户列表
|
||||
* 2. 优先选 isDefault=true 的租户,否则取第一个
|
||||
* 3. POST /api/iam/v2/identity/token/refresh/tenant body={tenantSid}
|
||||
* 4. 返回刷新后的 token + authoredUser + currTenantList
|
||||
*
|
||||
* 异常不抛出,回退到传入的 userToken
|
||||
*/
|
||||
export async function switchDefaultTenant(
|
||||
userToken: string,
|
||||
): Promise<{
|
||||
token: string;
|
||||
authoredUser?: Record<string, unknown>;
|
||||
currTenantList?: unknown[];
|
||||
}> {
|
||||
let finalToken = userToken;
|
||||
let authoredUser: Record<string, unknown> | undefined;
|
||||
let currTenantList: unknown[] | undefined;
|
||||
|
||||
try {
|
||||
const tenantRes = await fetch(`${IAM_API_BASE}/tenant?appId=${encodeURIComponent(APPID)}`, {
|
||||
method: 'POST',
|
||||
headers: userHeaders(userToken),
|
||||
});
|
||||
if (!tenantRes.ok) throw new Error(`获取租户列表失败 (HTTP ${tenantRes.status})`);
|
||||
const tenantJson = (await tenantRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
|
||||
// 兼容数组 / {data:[]} / {list:[]} / {result:[]}
|
||||
let tenants: unknown[] = [];
|
||||
if (Array.isArray(tenantJson)) {
|
||||
tenants = tenantJson;
|
||||
} else if (Array.isArray(tenantJson.data)) {
|
||||
tenants = tenantJson.data as unknown[];
|
||||
} else if (Array.isArray(tenantJson.list)) {
|
||||
tenants = tenantJson.list as unknown[];
|
||||
} else if (Array.isArray(tenantJson.result)) {
|
||||
tenants = tenantJson.result as unknown[];
|
||||
}
|
||||
|
||||
if (tenants.length > 0) {
|
||||
currTenantList = tenants;
|
||||
// 优先选 isDefault=true 的租户,否则取第一个
|
||||
const defaultTenant = (tenants.find((t) => (t as Record<string, unknown>)?.isDefault === true)
|
||||
?? tenants[0]) as Record<string, unknown>;
|
||||
const tenantSid = (defaultTenant.sid as number | string | undefined)
|
||||
?? (defaultTenant.tenantSid as number | string | undefined)
|
||||
?? (defaultTenant.id as number | string | undefined);
|
||||
|
||||
if (tenantSid !== undefined && tenantSid !== null) {
|
||||
// 切换默认租户,刷新 token
|
||||
const refreshRes = await fetch(`${IAM_IDENTITY_BASE}/token/refresh/tenant`, {
|
||||
method: 'POST',
|
||||
headers: userHeaders(userToken),
|
||||
body: JSON.stringify({ tenantSid }),
|
||||
});
|
||||
if (refreshRes.ok) {
|
||||
const refreshJson = (await refreshRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const refreshedToken = pickToken(refreshJson);
|
||||
if (refreshedToken) finalToken = refreshedToken;
|
||||
// authoredUser 平铺到 userInfo 顶层
|
||||
if (refreshJson.authoredUser && typeof refreshJson.authoredUser === 'object') {
|
||||
authoredUser = refreshJson.authoredUser as Record<string, unknown>;
|
||||
} else if (refreshJson.data && typeof refreshJson.data === 'object'
|
||||
&& (refreshJson.data as Record<string, unknown>).authoredUser) {
|
||||
authoredUser = (refreshJson.data as Record<string, unknown>).authoredUser as Record<string, unknown>;
|
||||
} else {
|
||||
// 整个 refresh 返回作为 authoredUser(兼容字段直接在顶层)
|
||||
authoredUser = refreshJson;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
// 租户切换失败不阻断登录,仍使用原 token
|
||||
console.warn('[IAM] 租户切换流程异常,将使用原 token', ex);
|
||||
}
|
||||
|
||||
return { token: finalToken, authoredUser, currTenantList };
|
||||
}
|
||||
|
||||
/**
|
||||
* SSO 登录(基于外部传入的 userToken)
|
||||
*
|
||||
* 流程:
|
||||
* 1. POST /api/iam/v2/identity/token/refresh/app 刷新应用 token + 用户信息
|
||||
* 2. POST /api/iam/v2/identity/login/info 获取登录详情
|
||||
* 3. 调用 switchDefaultTenant 拉取租户列表 + 切换默认租户
|
||||
* 4. 组装完整 userInfo(authoredUser 平铺到顶层)
|
||||
*/
|
||||
export async function iamSsoLogin(initialUserToken: string): Promise<IamLoginResult> {
|
||||
// 1. token/refresh/app:刷新应用 token
|
||||
const refreshAppRes = await fetch(`${IAM_IDENTITY_BASE}/token/refresh/app`, {
|
||||
method: 'POST',
|
||||
headers: userHeaders(initialUserToken),
|
||||
});
|
||||
if (!refreshAppRes.ok) throw new Error(`SSO token 刷新失败 (HTTP ${refreshAppRes.status})`);
|
||||
const refreshAppJson = (await refreshAppRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const appRefreshedToken = pickToken(refreshAppJson) ?? initialUserToken;
|
||||
|
||||
// 2. login/info:获取登录详情
|
||||
let loginInfoJson: Record<string, unknown> = {};
|
||||
try {
|
||||
const infoRes = await fetch(`${IAM_IDENTITY_BASE}/login/info`, {
|
||||
method: 'POST',
|
||||
headers: userHeaders(appRefreshedToken),
|
||||
});
|
||||
if (infoRes.ok) {
|
||||
loginInfoJson = (await infoRes.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
}
|
||||
} catch (ex) {
|
||||
console.warn('[IAM] login/info 调用异常', ex);
|
||||
}
|
||||
|
||||
// 3. 切换默认租户
|
||||
const tenantCtx = await switchDefaultTenant(appRefreshedToken);
|
||||
|
||||
// 4. 组装完整 userInfo
|
||||
const userId = (loginInfoJson.userId as string)
|
||||
?? (refreshAppJson.userId as string)
|
||||
?? (tenantCtx.authoredUser?.userId as string)
|
||||
?? '';
|
||||
|
||||
const userInfo: Record<string, unknown> = {
|
||||
...(tenantCtx.authoredUser ?? {}),
|
||||
...(refreshAppJson ?? {}),
|
||||
...(loginInfoJson ?? {}),
|
||||
userId,
|
||||
token: tenantCtx.token,
|
||||
isLoggedin: true,
|
||||
...(tenantCtx.currTenantList ? { currTenantList: tenantCtx.currTenantList } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
token: tenantCtx.token,
|
||||
userId,
|
||||
userInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- sessionStorage 会话管理 ---------------- */
|
||||
|
||||
const KEY_USER_TOKEN = 'userToken';
|
||||
const KEY_USER_INFO = 'userInfo';
|
||||
const KEY_APP_TOKEN = 'digi-middleware-auth-app';
|
||||
|
||||
export interface SessionUserInfo {
|
||||
userId: string;
|
||||
userName?: string;
|
||||
token: string;
|
||||
tenantId?: string;
|
||||
tenantName?: string;
|
||||
tenantSid?: number;
|
||||
sid?: number;
|
||||
email?: string;
|
||||
telephone?: string;
|
||||
isLoggedin?: boolean;
|
||||
currTenantList?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 保存登录会话 */
|
||||
export function saveSession(token: string, info: Record<string, unknown>): void {
|
||||
sessionStorage.setItem(KEY_USER_TOKEN, token);
|
||||
sessionStorage.setItem(KEY_USER_INFO, JSON.stringify(info));
|
||||
sessionStorage.setItem(KEY_APP_TOKEN, APP_TOKEN);
|
||||
}
|
||||
|
||||
/** 获取当前 userToken */
|
||||
export function getUserToken(): string | null {
|
||||
return sessionStorage.getItem(KEY_USER_TOKEN);
|
||||
}
|
||||
|
||||
/** 获取当前用户信息 */
|
||||
export function getUserInfo(): SessionUserInfo | null {
|
||||
const raw = sessionStorage.getItem(KEY_USER_INFO);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as SessionUserInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出登录,清空会话 */
|
||||
export function clearSession(): void {
|
||||
sessionStorage.removeItem(KEY_USER_TOKEN);
|
||||
sessionStorage.removeItem(KEY_USER_INFO);
|
||||
sessionStorage.removeItem(KEY_APP_TOKEN);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/index.ts","./src/pages/login/index.tsx","./src/pages/ssologin/index.tsx","./src/pages/services/index.tsx","./src/pages/stats/index.tsx","./src/pages/tokens/index.tsx","./src/services/iamauth.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/console/',
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// 生产部署在 /console 子路径下,dev 代理路径也需带前缀
|
||||
'/console/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/console/, ''),
|
||||
},
|
||||
// 鼎捷云 IAM 登录服务代理(避免 CORS / 网络问题)
|
||||
'/console/iam-api': {
|
||||
target: 'https://iam.digiwincloud.com.cn',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/console\/iam-api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user