sso
This commit is contained in:
17
README.md
17
README.md
@@ -40,7 +40,7 @@ npm start
|
|||||||
|
|
||||||
默认会在浏览器打开:
|
默认会在浏览器打开:
|
||||||
|
|
||||||
- `http://localhost:3003/`
|
- `http://localhost:8081/`
|
||||||
|
|
||||||
如未自动打开,可手动在浏览器输入上述地址访问。
|
如未自动打开,可手动在浏览器输入上述地址访问。
|
||||||
|
|
||||||
@@ -67,6 +67,21 @@ REACT_APP_API_BASE_URL=http://115.190.223.209:5232/api
|
|||||||
|
|
||||||
你可以根据实际后端地址进行修改。
|
你可以根据实际后端地址进行修改。
|
||||||
|
|
||||||
|
## OIDC 单点登录联动
|
||||||
|
|
||||||
|
前端已与后端认证流程联动:
|
||||||
|
|
||||||
|
1. 应用启动时调用 `GET /api/auth/me` 读取当前 Session 用户。
|
||||||
|
2. 若未登录(401),前端自动跳转 `/api/login?next=<当前页面>`。
|
||||||
|
3. 后端回调成功后会带着 `next` 回跳到前端原页面。
|
||||||
|
4. 业务 API 请求若返回 401,也会自动触发登录跳转。
|
||||||
|
|
||||||
|
相关实现文件:
|
||||||
|
|
||||||
|
- `src/services/auth.js`:登录跳转、退出、用户信息获取
|
||||||
|
- `src/services/api.js`:axios 401 拦截并跳转登录
|
||||||
|
- `src/App.jsx`:应用初始化时鉴权和用户显示
|
||||||
|
|
||||||
## 功能说明
|
## 功能说明
|
||||||
|
|
||||||
- 左侧侧边栏
|
- 左侧侧边栏
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "set PORT=8081 && react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject"
|
||||||
|
|||||||
107
src/App.css
107
src/App.css
@@ -128,7 +128,6 @@ body {
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
cursor: pointer;
|
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,33 +139,105 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #ececec;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f7f7f7 100%);
|
||||||
|
color: #4b5563;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-trigger.has-name {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: auto;
|
||||||
|
max-width: 220px;
|
||||||
|
padding: 0 12px 0 8px;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-trigger:hover {
|
||||||
|
border-color: #d8dee8;
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, #77c7ff 0%, #4b9dff 100%);
|
||||||
|
color: #fff;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
max-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
.user-tooltip {
|
.user-tooltip {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 30px;
|
top: calc(100% + 10px);
|
||||||
right: -10px;
|
right: 0;
|
||||||
background: #333;
|
min-width: 120px;
|
||||||
color: #fff;
|
padding: 8px;
|
||||||
padding: 6px 10px;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border: 1px solid #ececec;
|
||||||
font-size: 12px;
|
border-radius: 12px;
|
||||||
white-space: nowrap;
|
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-tooltip::before {
|
.user-tooltip::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -6px;
|
||||||
right: 14px;
|
right: 18px;
|
||||||
width: 8px;
|
width: 12px;
|
||||||
height: 8px;
|
height: 12px;
|
||||||
background: #333;
|
background: #fff;
|
||||||
|
border-top: 1px solid #ececec;
|
||||||
|
border-left: 1px solid #ececec;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-profile:hover .user-tooltip {
|
.user-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item:hover {
|
||||||
|
background: #f5f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile:hover .user-tooltip,
|
||||||
|
.user-profile:focus-within .user-tooltip {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,10 +385,6 @@ body {
|
|||||||
background: #40a9ff;
|
background: #40a9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-container {
|
|
||||||
/* 让外层 page-scroll 负责滚动,这里不再额外横向滚动 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
|
|||||||
67
src/App.jsx
67
src/App.jsx
@@ -1,13 +1,19 @@
|
|||||||
// App.jsx
|
// App.jsx
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
User,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
User,
|
||||||
X
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import FstTagPage from './pages/FstTagPage';
|
import FstTagPage from './pages/FstTagPage';
|
||||||
import VersionListPage from './pages/VersionListPage';
|
import VersionListPage from './pages/VersionListPage';
|
||||||
|
import {
|
||||||
|
getCurrentUser,
|
||||||
|
isAuthCallbackPath,
|
||||||
|
redirectToLogin,
|
||||||
|
relayAuthCallbackToBackend
|
||||||
|
} from './services/auth';
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
// 页面组件注册表
|
// 页面组件注册表
|
||||||
@@ -59,6 +65,45 @@ const App = () => {
|
|||||||
return defaultTabs;
|
return defaultTabs;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [currentUser, setCurrentUser] = useState(null);
|
||||||
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
|
const displayName = currentUser?.name || currentUser?.preferred_username || '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthCallbackPath()) {
|
||||||
|
relayAuthCallbackToBackend();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const loadUser = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getCurrentUser();
|
||||||
|
if (mounted) {
|
||||||
|
setCurrentUser(data.user || null);
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (mounted) {
|
||||||
|
if (error?.status === 401) {
|
||||||
|
// 未登录,立即重定向,不显示 Loading 界面
|
||||||
|
setAuthLoading(false);
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadUser();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 监听 activeTab 变化,同步到 URL
|
// 监听 activeTab 变化,同步到 URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const slug = pageToSlug[activeTab];
|
const slug = pageToSlug[activeTab];
|
||||||
@@ -110,6 +155,10 @@ const App = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return <div className="app-container">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
@@ -162,8 +211,20 @@ const App = () => {
|
|||||||
<header className="top-header">
|
<header className="top-header">
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<div className="user-profile">
|
<div className="user-profile">
|
||||||
<User size={20} />
|
<button
|
||||||
<div className="user-tooltip">用户中心</div>
|
type="button"
|
||||||
|
className={`user-trigger${displayName ? ' has-name' : ''}`}
|
||||||
|
aria-label="用户菜单"
|
||||||
|
>
|
||||||
|
<span className="user-avatar">
|
||||||
|
<User size={16} />
|
||||||
|
</span>
|
||||||
|
{displayName && (
|
||||||
|
<span className="user-name" title={displayName}>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { redirectToLogin, redirectToServerLoginUrl } from './auth';
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
const baseURL = isDev
|
const baseURL = isDev
|
||||||
@@ -8,6 +9,7 @@ const baseURL = isDev
|
|||||||
const instance = axios.create({
|
const instance = axios.create({
|
||||||
baseURL,
|
baseURL,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
@@ -31,6 +33,7 @@ instance.interceptors.response.use(
|
|||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
case 401:
|
case 401:
|
||||||
console.error('未授权,请登录');
|
console.error('未授权,请登录');
|
||||||
|
redirectToServerLoginUrl(error.response?.data?.login_url);
|
||||||
break;
|
break;
|
||||||
case 403:
|
case 403:
|
||||||
console.error('没有权限');
|
console.error('没有权限');
|
||||||
|
|||||||
64
src/services/auth.js
Normal file
64
src/services/auth.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
const authBaseUrl = isDev
|
||||||
|
? (process.env.REACT_APP_AUTH_BASE_URL || 'http://localhost:5232')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const CALLBACK_PATH = '/api/daimler/authorized';
|
||||||
|
|
||||||
|
// Check if current path is the auth callback path (for backward compatibility)
|
||||||
|
export const isAuthCallbackPath = () => {
|
||||||
|
return window.location.pathname === CALLBACK_PATH;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Relay auth callback to backend (used when SSO callbacks to frontend)
|
||||||
|
export const relayAuthCallbackToBackend = () => {
|
||||||
|
const query = window.location.search || '';
|
||||||
|
window.location.replace(`${authBaseUrl}${CALLBACK_PATH}${query}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNextUrl = () => {
|
||||||
|
const raw = window.location.href || `${window.location.origin}/`;
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
const route = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||||
|
|
||||||
|
// Prevent recursive redirects when client accidentally lands on auth routes.
|
||||||
|
if (route.startsWith('/api/login') || route.startsWith('/api/daimler/authorized')) {
|
||||||
|
return `${window.location.origin}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildLoginUrl = () => {
|
||||||
|
const nextUrl = normalizeNextUrl();
|
||||||
|
return `${authBaseUrl}/api/login?next=${encodeURIComponent(nextUrl)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const redirectToLogin = () => {
|
||||||
|
window.location.href = buildLoginUrl();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const redirectToServerLoginUrl = (loginUrl) => {
|
||||||
|
if (!loginUrl) {
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility: backend may still return /api/auth/login.
|
||||||
|
window.location.href = loginUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentUser = async () => {
|
||||||
|
const response = await fetch('/api/auth/me', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error('Unauthorized');
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user