Compare commits

..
2 Commits
Author SHA1 Message Date
dongsk 989120cab8 sso-login调整 2026-08-27 16:37:33 +08:00
dongsk 2d19c736a6 登录1 2026-08-27 16:06:23 +08:00
9 changed files with 731 additions and 10 deletions
+7
View File
@@ -14,6 +14,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"jsencrypt": "^3.5.4",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -1633,6 +1634,12 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/jsencrypt": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.5.4.tgz",
"integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==",
"license": "MIT"
},
"node_modules/json2mq": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
+1
View File
@@ -15,6 +15,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"jsencrypt": "^3.5.4",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+2
View File
@@ -1,6 +1,7 @@
import { Routes, Route } from 'react-router-dom';
import HomePage from '@/pages/HomePage';
import SSOLogin from '@/pages/SSOLogin';
import LoginPage from '@/pages/LoginPage';
import AIMCockpit from '@/modules/ai-matching/pages/AIMCockpit';
import CostCockpit from '@/modules/cost-analysis/pages/CostCockpit';
import KnowledgeGraphHome from '@/modules/knowledge-graph/pages/KnowledgeGraphHome';
@@ -15,6 +16,7 @@ export default function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/sso-login" element={<SSOLogin />} />
<Route path="/ai-matching" element={<AIMCockpit />} />
<Route path="/cost-analysis" element={<CostCockpit />} />
+5
View File
@@ -152,6 +152,11 @@
to { opacity: 1; transform: translateY(0); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: 0 0 10px rgba(0,212,255,0.2); }
50% { box-shadow: 0 0 30px rgba(0,212,255,0.5); }
+63 -4
View File
@@ -1,10 +1,11 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { BrainCircuit, Calculator, Network, Boxes, ReceiptText, CalendarClock, Sparkles } from 'lucide-react';
import { BrainCircuit, Calculator, Network, Boxes, ReceiptText, CalendarClock, Sparkles, LogOut } from 'lucide-react';
import { useTheme } from '@/shared/context/ThemeContext';
import StarBackground from '@/shared/components/StarBackground';
import ThemeToggle from '@/shared/components/ThemeToggle';
import { modules } from '@/modules/registry';
import { getUserInfo, clearSession, type SessionUserInfo } from '@/shared/services/iamAuth';
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; color?: string }>> = {
BrainCircuit,
@@ -21,6 +22,15 @@ export default function HomePage() {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [searchParams] = useSearchParams();
const [userInfo, setUserInfo] = useState<SessionUserInfo | null>(() => getUserInfo());
// 登录守卫:未登录跳转 /login
useEffect(() => {
const token = sessionStorage.getItem('userToken');
if (!token) {
navigate('/login', { replace: true });
}
}, [navigate]);
// 首页模块多时需纵向滚动;离开后恢复全局 overflow:hidden(其他模块自管布局)
useEffect(() => {
@@ -36,6 +46,12 @@ export default function HomePage() {
}
}, [searchParams]);
const handleLogout = () => {
clearSession();
setUserInfo(null);
navigate('/login', { replace: true });
};
return (
<div style={{
width: '100vw', minHeight: '100vh',
@@ -112,8 +128,51 @@ export default function HomePage() {
</div>
</div>
{/* 右上角主题切换 */}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center' }}>
{/* 右上角:用户信息 + 主题切换 */}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 16 }}>
{userInfo && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 28, height: 28, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 700,
color: '#04121f',
background: 'linear-gradient(135deg, #00d4ff, #4f9eff)',
}}>
{(userInfo.userName ?? userInfo.userId).charAt(0).toUpperCase()}
</div>
<span style={{
fontSize: 13,
...(isDark ? { color: 'rgba(168,216,255,0.85)' } : { color: '#334155' }),
}}>
{userInfo.userName ?? userInfo.userId}
</span>
<button
onClick={handleLogout}
title="退出登录"
style={{
marginLeft: 4,
display: 'flex', alignItems: 'center', gap: 4,
padding: '5px 12px',
borderRadius: 8,
border: `1px solid ${isDark ? 'rgba(0,212,255,0.3)' : 'rgba(0,0,0,0.12)'}`,
background: isDark ? 'rgba(0,212,255,0.06)' : '#fff',
color: isDark ? 'rgba(168,216,255,0.8)' : '#475569',
fontSize: 12,
cursor: 'pointer',
transition: 'border-color 0.2s, color 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = isDark ? 'rgba(0,212,255,0.3)' : 'rgba(0,0,0,0.12)';
e.currentTarget.style.color = isDark ? 'rgba(168,216,255,0.8)' : '#475569';
}}
>
<LogOut size={13} />
退出
</button>
</div>
)}
<ThemeToggle />
</div>
</div>
+208
View File
@@ -0,0 +1,208 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { User, Lock, LogIn, AlertCircle, Loader2 } from 'lucide-react';
import StarBackground from '@/shared/components/StarBackground';
import { iamLogin, saveSession } from '@/shared/services/iamAuth';
import { useTheme } from '@/shared/context/ThemeContext';
const inputStyle = (isDark: boolean): React.CSSProperties => ({
width: '100%',
boxSizing: 'border-box',
padding: '12px 14px 12px 42px',
borderRadius: 10,
border: `1px solid ${isDark ? 'rgba(0,212,255,0.25)' : 'rgba(0,0,0,0.12)'}`,
background: isDark ? 'rgba(0,212,255,0.06)' : '#fff',
color: isDark ? '#e8f4ff' : '#1e293b',
fontSize: 14,
outline: 'none',
transition: 'border-color 0.2s, box-shadow 0.2s',
});
const iconStyle = (isDark: boolean): React.CSSProperties => ({
position: 'absolute',
left: 14,
top: '50%',
transform: 'translateY(-50%)',
color: isDark ? 'rgba(0,212,255,0.6)' : '#94a3b8',
});
export default function LoginPage() {
const navigate = useNavigate();
const { theme } = useTheme();
const isDark = theme === 'dark';
const [userId, setUserId] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
document.body.style.overflow = 'auto';
return () => { document.body.style.overflow = ''; };
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!userId.trim() || !password) {
setError('请输入账号和密码');
return;
}
setLoading(true);
setError('');
try {
const result = await iamLogin({ userId: userId.trim(), password });
saveSession(result.token, result.userInfo);
navigate('/', { 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',
fontFamily: "-apple-system, 'SF Pro Display', 'SF Pro Text', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif",
color: isDark ? '#a8d8ff' : '#1e3a6e',
}}>
{isDark ? <StarBackground theme="dark" /> : (
<div style={{
position: 'fixed', top: 0, left: 0, width: '100%', height: '100%',
background: 'linear-gradient(160deg, #f8f8fb 0%, #f2f3f7 50%, #eef1f7 100%)',
zIndex: 0,
}} />
)}
<div style={{
position: 'relative', zIndex: 1,
width: 400, maxWidth: '92vw',
padding: '40px 36px 32px',
borderRadius: 20,
background: isDark ? 'rgba(10,22,40,0.75)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(16px)',
border: `1px solid ${isDark ? 'rgba(0,212,255,0.2)' : 'rgba(0,0,0,0.06)'}`,
boxShadow: isDark ? '0 24px 64px rgba(0,0,0,0.5), 0 0 48px rgba(0,212,255,0.08)' : '0 24px 64px rgba(30,58,110,0.12)',
}}>
{/* 品牌区 */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<img
src="/digiwin.svg"
alt="Digiwin"
style={{ height: 36, objectFit: 'contain', filter: isDark ? 'drop-shadow(0 0 8px rgba(0,212,255,0.4))' : 'none' }}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, marginTop: 14 }}>
<h1 style={{
fontSize: 26, fontWeight: 800, letterSpacing: '0.04em', margin: 0,
...(isDark
? { color: '#e8f4ff', textShadow: '0 0 16px rgba(0,212,255,0.5)' }
: { color: '#111827' }),
}}>
EIOSpace
</h1>
</div>
<p style={{ fontSize: 13, marginTop: 6, ...(isDark ? { color: 'rgba(168,216,255,0.6)' } : { color: '#6b7280' }) }}>
企业智能 · 数据驱动 · 产业赋能
</p>
</div>
{/* 登录表单 */}
<form onSubmit={handleLogin}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 6, color: isDark ? 'rgba(168,216,255,0.8)' : '#475569' }}>
账号(用户ID / 手机号 / 邮箱)
</label>
<div style={{ position: 'relative' }}>
<User size={16} style={iconStyle(isDark)} />
<input
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="请输入账号"
autoComplete="username"
style={inputStyle(isDark)}
onFocus={(e) => { e.currentTarget.style.borderColor = '#00d4ff'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = isDark ? 'rgba(0,212,255,0.25)' : 'rgba(0,0,0,0.12)'; }}
/>
</div>
</div>
<div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 6, color: isDark ? 'rgba(168,216,255,0.8)' : '#475569' }}>
密码
</label>
<div style={{ position: 'relative' }}>
<Lock size={16} style={iconStyle(isDark)} />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
autoComplete="current-password"
style={inputStyle(isDark)}
onFocus={(e) => { e.currentTarget.style.borderColor = '#00d4ff'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = isDark ? 'rgba(0,212,255,0.25)' : 'rgba(0,0,0,0.12)'; }}
/>
</div>
</div>
{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',
}}>
<AlertCircle size={15} style={{ flexShrink: 0 }} />
{error}
</div>
)}
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: '13px 0',
borderRadius: 10,
border: 'none',
cursor: loading ? 'wait' : 'pointer',
fontSize: 15,
fontWeight: 600,
letterSpacing: '0.08em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: '#04121f',
background: 'linear-gradient(90deg, #00d4ff, #4f9eff)',
boxShadow: '0 8px 24px rgba(0,212,255,0.35)',
transition: 'transform 0.15s, box-shadow 0.15s, opacity 0.15s',
opacity: loading ? 0.7 : 1,
}}
onMouseEnter={(e) => { if (!loading) e.currentTarget.style.transform = 'translateY(-1px)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'none'; }}
>
{loading ? <Loader2 size={17} style={{ animation: 'spin 1s linear infinite' }} /> : <LogIn size={17} />}
{loading ? '登录中...' : '登 录'}
</button>
</form>
<p style={{
textAlign: 'center', fontSize: 12, marginTop: 24,
...(isDark ? { color: 'rgba(168,216,255,0.45)' } : { color: '#94a3b8' }),
}}>
鼎捷云统一身份认证 · IAM
</p>
</div>
</div>
);
}
+63 -5
View File
@@ -1,21 +1,47 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Spin } from 'antd';
import { AlertCircle } from 'lucide-react';
import { iamSsoLogin, saveSession } from '@/shared/services/iamAuth';
/**
* SSO 登录回调入口
* 接收 URL 参数 userToken,写入 sessionStorage 后跳转首页
*
* 流程:
* 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) {
sessionStorage.setItem('userToken', 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 (
@@ -26,8 +52,40 @@ const SSOLogin: React.FC = () => {
alignItems: 'center',
justifyContent: 'center',
background: '#0a1628',
color: '#a8d8ff',
fontFamily: "-apple-system, 'SF Pro Display', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif",
}}>
<Spin size="large" tip="正在登录..." />
{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,
}}>
<AlertCircle size={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>
);
};
+375
View File
@@ -0,0 +1,375 @@
import { JSEncrypt } from 'jsencrypt';
/**
* 鼎捷云 IAM 登录服务(纯前端实现)
*
* 完整登录流程:
* 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
*/
// 代理路径(vite.config.ts 中 /iam-api → https://iam.digiwincloud.com.cn)
const IAM_API_BASE = '/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:
// - authoredUser 内容平铺到顶层(sid/userName/email/currTenantList 等)
// - token 使用最终刷新后的 token
// - 保留 isLoggedin 标识
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
// 优先级:tenantCtx.authoredUser > refreshAppJson > loginInfoJson
// userId 从 loginInfo/refreshApp 中提取
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);
}
+6
View File
@@ -25,6 +25,12 @@ export default defineConfig({
changeOrigin: true,
rewrite: (p) => p.replace(/^\/bexapi/, '/api'),
},
// 鼎捷云 IAM 登录服务代理(避免 CORS / 网络问题)
'/iam-api': {
target: 'https://iam.digiwincloud.com.cn',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/iam-api/, ''),
},
},
},
})