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"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"start": "set PORT=8081 && react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
|
||||
107
src/App.css
107
src/App.css
@@ -128,7 +128,6 @@ body {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -140,33 +139,105 @@ body {
|
||||
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 {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: -10px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
min-width: 120px;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.user-tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: 14px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #333;
|
||||
top: -6px;
|
||||
right: 18px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #ececec;
|
||||
border-left: 1px solid #ececec;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -314,10 +385,6 @@ body {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
/* 让外层 page-scroll 负责滚动,这里不再额外横向滚动 */
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
|
||||
67
src/App.jsx
67
src/App.jsx
@@ -1,13 +1,19 @@
|
||||
// App.jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
User,
|
||||
ChevronDown,
|
||||
User,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import './App.css';
|
||||
import FstTagPage from './pages/FstTagPage';
|
||||
import VersionListPage from './pages/VersionListPage';
|
||||
import {
|
||||
getCurrentUser,
|
||||
isAuthCallbackPath,
|
||||
redirectToLogin,
|
||||
relayAuthCallbackToBackend
|
||||
} from './services/auth';
|
||||
|
||||
const App = () => {
|
||||
// 页面组件注册表
|
||||
@@ -59,6 +65,45 @@ const App = () => {
|
||||
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
|
||||
useEffect(() => {
|
||||
const slug = pageToSlug[activeTab];
|
||||
@@ -110,6 +155,10 @@ const App = () => {
|
||||
});
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
return <div className="app-container">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<aside className="sidebar">
|
||||
@@ -162,8 +211,20 @@ const App = () => {
|
||||
<header className="top-header">
|
||||
<div className="header-actions">
|
||||
<div className="user-profile">
|
||||
<User size={20} />
|
||||
<div className="user-tooltip">用户中心</div>
|
||||
<button
|
||||
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>
|
||||
</header>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { redirectToLogin, redirectToServerLoginUrl } from './auth';
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const baseURL = isDev
|
||||
@@ -8,6 +9,7 @@ const baseURL = isDev
|
||||
const instance = axios.create({
|
||||
baseURL,
|
||||
timeout: 10000,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
@@ -31,6 +33,7 @@ instance.interceptors.response.use(
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
console.error('未授权,请登录');
|
||||
redirectToServerLoginUrl(error.response?.data?.login_url);
|
||||
break;
|
||||
case 403:
|
||||
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