调整文档路径

This commit is contained in:
2026-09-02 17:31:52 +08:00
parent 01b3798e02
commit 674ff09db1
43 changed files with 0 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.git
.gitignore
*.md
.vscode
.idea
+32
View File
@@ -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;"]
+15
View File
@@ -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
+15
View File
@@ -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>
+109
View File
@@ -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;
}
}
+3128
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
+115
View File
@@ -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>
);
}
+35
View File
@@ -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;
+158
View File
@@ -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;
}
+17
View File
@@ -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>,
);
+161
View File
@@ -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>
);
}
+93
View File
@@ -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;
+285
View File
@@ -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>
);
}
+77
View File
@@ -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>
);
}
+382
View File
@@ -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>
);
}
+372
View File
@@ -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);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -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"]
}
+1
View File
@@ -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"}
+27
View File
@@ -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',
},
})