From e0390cc0bc1dc02ab80105c70c89d7e90e958530 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 24 Jan 2026 16:59:33 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E5=BD=95=E8=AE=BE=E5=A4=87=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/change-password/route.ts | 20 ++ src/components/UserMenu.tsx | 305 ++++++++++++++++++++++++++- src/middleware.ts | 6 + 3 files changed, 328 insertions(+), 3 deletions(-) diff --git a/src/app/api/change-password/route.ts b/src/app/api/change-password/route.ts index c566bb8..95e0dc9 100644 --- a/src/app/api/change-password/route.ts +++ b/src/app/api/change-password/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { db } from '@/lib/db'; +import { getUserDevices, revokeRefreshToken } from '@/lib/refresh-token'; export const runtime = 'nodejs'; @@ -48,6 +49,25 @@ export async function POST(request: NextRequest) { // 修改密码(只更新V2存储) await db.changePasswordV2(username, newPassword); + // 撤销除当前设备外的所有 Refresh Token + try { + const currentTokenId = authInfo.tokenId; + const devices = await getUserDevices(username); + + // 撤销所有非当前设备的 token + for (const device of devices) { + if (device.tokenId !== currentTokenId) { + await revokeRefreshToken(username, device.tokenId); + console.log(`Revoked token ${device.tokenId} for ${username} after password change`); + } + } + + console.log(`Password changed for ${username}, revoked ${devices.length - 1} other devices`); + } catch (error) { + console.error('Failed to revoke other devices after password change:', error); + // 不影响密码修改的成功,只记录错误 + } + return NextResponse.json({ ok: true }); } catch (error) { console.error('修改密码失败:', error); diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index bfcf3c4..e096f48 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -19,13 +19,16 @@ import { LogOut, Mail, MessageSquare, + Monitor, MoveDown, MoveUp, Rss, Settings, Shield, Sliders, + Smartphone, Star, + Tablet, User, X, } from 'lucide-react'; @@ -61,6 +64,7 @@ export const UserMenu: React.FC = () => { const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false); const [isFavoritesPanelOpen, setIsFavoritesPanelOpen] = useState(false); const [isEmailSettingsOpen, setIsEmailSettingsOpen] = useState(false); + const [isDeviceManagementOpen, setIsDeviceManagementOpen] = useState(false); const [authInfo, setAuthInfo] = useState(null); const [storageType, setStorageType] = useState('localstorage'); const [mounted, setMounted] = useState(false); @@ -74,7 +78,7 @@ export const UserMenu: React.FC = () => { // Body 滚动锁定 - 使用 overflow 方式避免布局问题 useEffect(() => { - if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen) { + if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen || isDeviceManagementOpen) { const body = document.body; const html = document.documentElement; @@ -93,7 +97,7 @@ export const UserMenu: React.FC = () => { html.style.overflow = originalHtmlOverflow; }; } - }, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen, isEmailSettingsOpen]); + }, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen, isEmailSettingsOpen, isDeviceManagementOpen]); // 设置相关状态 const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true); @@ -124,6 +128,24 @@ export const UserMenu: React.FC = () => { const [emailSettingsLoading, setEmailSettingsLoading] = useState(false); const [emailSettingsSaving, setEmailSettingsSaving] = useState(false); + // 设备管理状态 + const [devices, setDevices] = useState([]); + const [devicesLoading, setDevicesLoading] = useState(false); + const [revoking, setRevoking] = useState(null); + + // 确认对话框状态 + const [confirmDialog, setConfirmDialog] = useState<{ + isOpen: boolean; + title: string; + message: string; + onConfirm: () => void; + }>({ + isOpen: false, + title: '', + message: '', + onConfirm: () => {}, + }); + // 折叠面板状态 const [isDoubanSectionOpen, setIsDoubanSectionOpen] = useState(true); @@ -500,6 +522,96 @@ export const UserMenu: React.FC = () => { } }; + // 加载设备列表 + const loadDevices = async () => { + setDevicesLoading(true); + try { + const response = await fetch('/api/auth/devices'); + if (response.ok) { + const data = await response.json(); + setDevices(data.devices || []); + } + } catch (error) { + console.error('加载设备列表失败:', error); + } finally { + setDevicesLoading(false); + } + }; + + // 撤销单个设备 + const handleRevokeDevice = async (tokenId: string) => { + setConfirmDialog({ + isOpen: true, + title: '撤销设备登录', + message: '确定要撤销该设备的登录吗?', + onConfirm: async () => { + setConfirmDialog({ ...confirmDialog, isOpen: false }); + setRevoking(tokenId); + try { + const response = await fetch('/api/auth/devices', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tokenId }), + }); + + if (response.ok) { + // 重新加载设备列表 + await loadDevices(); + } else { + alert('撤销失败,请重试'); + } + } catch (error) { + console.error('撤销设备失败:', error); + alert('撤销失败,请重试'); + } finally { + setRevoking(null); + } + }, + }); + }; + + // 撤销所有设备 + const handleRevokeAllDevices = async () => { + setConfirmDialog({ + isOpen: true, + title: '登出所有设备', + message: '确定要登出所有设备吗?这将清除所有设备的登录状态(包括当前设备)。', + onConfirm: async () => { + setConfirmDialog({ ...confirmDialog, isOpen: false }); + try { + const response = await fetch('/api/auth/devices', { + method: 'POST', + }); + + if (response.ok) { + // 登出所有设备后,重定向到首页 + window.location.href = '/'; + } else { + alert('操作失败,请重试'); + } + } catch (error) { + console.error('登出所有设备失败:', error); + alert('操作失败,请重试'); + } + }, + }); + }; + + // 根据设备类型返回对应的图标 + const getDeviceIcon = (deviceInfo: string) => { + const info = deviceInfo.toLowerCase(); + + if (info.includes('mobile') || info.includes('iphone') || info.includes('android')) { + return Smartphone; + } + + if (info.includes('tablet') || info.includes('ipad')) { + return Tablet; + } + + return Monitor; + }; + // 点击外部区域关闭下拉框 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -996,7 +1108,7 @@ export const UserMenu: React.FC = () => {
-
+
当前用户 @@ -1014,6 +1126,22 @@ export const UserMenu: React.FC = () => { > + {/* 设备管理图标按钮 */} + {storageType !== 'localstorage' && ( + + )}
{ ); + // 设备管理面板内容 + const deviceManagementPanel = ( + <> + {/* 背景遮罩 */} +
setIsDeviceManagementOpen(false)} + onTouchMove={(e) => { + e.preventDefault(); + }} + onWheel={(e) => { + e.preventDefault(); + }} + style={{ + touchAction: 'none', + }} + /> + + {/* 设备管理面板 */} +
+
{ + e.stopPropagation(); + }} + style={{ + touchAction: 'auto', + }} + > + {/* 标题栏 */} +
+

+ 设备管理 +

+ +
+ + {/* 设备列表 */} +
+ {devicesLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+ ))} +
+ 加载中... +
+
+ ) : devices.length === 0 ? ( +
+ +

暂无登录设备

+
+ ) : ( +
+ {devices.map((device) => { + const DeviceIcon = getDeviceIcon(device.deviceInfo); + return ( +
+
+
+
+ + + {device.deviceInfo} + + {device.isCurrent && ( + + 当前设备 + + )} +
+
+
登录时间: {new Date(device.createdAt).toLocaleString('zh-CN')}
+
最后活跃: {new Date(device.lastUsed).toLocaleString('zh-CN')}
+
+
+ {!device.isCurrent && ( + + )} +
+
+ ); + })} +
+ )} +
+ + {/* 底部操作 */} +
+ +

+ 登出所有设备后需要重新登录 +

+
+
+
+ + ); + return ( <>
@@ -2586,6 +2840,51 @@ export const UserMenu: React.FC = () => { {isEmailSettingsOpen && mounted && createPortal(emailSettingsPanel, document.body)} + + {/* 使用 Portal 将设备管理面板渲染到 document.body */} + {isDeviceManagementOpen && + mounted && + createPortal(deviceManagementPanel, document.body)} + + {/* 确认对话框 */} + {confirmDialog.isOpen && + mounted && + createPortal( +
+
+ {/* 标题 */} +
+

+ {confirmDialog.title} +

+
+ + {/* 内容 */} +
+

+ {confirmDialog.message} +

+
+ + {/* 按钮 */} +
+ + +
+
+
, + document.body + )} ); }; diff --git a/src/middleware.ts b/src/middleware.ts index 97141fd..328610d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -43,6 +43,12 @@ export async function middleware(request: NextRequest) { return handleAuthFailure(request, pathname); } + // 强制要求新版 Cookie(必须包含 tokenId 和 refreshToken) + if (!authInfo.tokenId || !authInfo.refreshToken || !authInfo.refreshExpires) { + console.log(`Old cookie format detected for ${authInfo.username}, forcing re-login`); + return handleAuthFailure(request, pathname); + } + // 验证 Access Token 时间戳 const ACCESS_TOKEN_AGE = TOKEN_CONFIG.ACCESS_TOKEN_AGE; const now = Date.now();