diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 3f4f1f3..58ff2b9 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -40,10 +40,13 @@ import { FolderOpen, Globe, Mail, + Monitor, Palette, Plus, Search, Settings, + Smartphone, + Tablet, Trash2, Tv, UserPlus, @@ -593,11 +596,55 @@ const UserConfig = ({ } | null>(null); const [showDeleteUserModal, setShowDeleteUserModal] = useState(false); const [deletingUser, setDeletingUser] = useState(null); + const [showUserDevicesModal, setShowUserDevicesModal] = useState(false); + const [selectedDeviceUsername, setSelectedDeviceUsername] = useState< + string | null + >(null); + const [userDevices, setUserDevices] = useState< + Array<{ + tokenId: string; + deviceInfo: string; + createdAt: number; + lastUsed: number; + expiresAt: number; + isCurrent?: boolean; + }> + >([]); + const [userDevicesLoading, setUserDevicesLoading] = useState(false); + const [revokingUserDevice, setRevokingUserDevice] = useState( + null + ); const trimmedUserSearch = userSearch.trim(); // 当前登录用户名 const currentUsername = getAuthInfoFromBrowserCookie()?.username || null; + // 查看用户设备弹窗打开时锁定背景滚动,避免滚动穿透 + useEffect(() => { + if (!showUserDevicesModal) return; + + const scrollY = window.scrollY; + const originalStyle = { + position: document.body.style.position, + top: document.body.style.top, + width: document.body.style.width, + overflow: document.body.style.overflow, + }; + + document.body.style.position = 'fixed'; + document.body.style.top = `-${scrollY}px`; + document.body.style.width = '100%'; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.position = originalStyle.position; + document.body.style.top = originalStyle.top; + document.body.style.width = originalStyle.width; + document.body.style.overflow = originalStyle.overflow; + window.scrollTo(0, scrollY); + }; + }, [showUserDevicesModal]); + // 判断是否有旧版用户数据需要迁移 const hasOldUserData = config?.UserConfig?.Users?.filter((u: any) => u.role !== 'owner').length ?? @@ -840,6 +887,82 @@ const UserConfig = ({ setShowDeleteUserModal(true); }; + 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; + }; + + const handleViewUserDevices = async (username: string) => { + setSelectedDeviceUsername(username); + setShowUserDevicesModal(true); + setUserDevices([]); + setUserDevicesLoading(true); + + try { + const params = new URLSearchParams({ username }); + const res = await fetch(`/api/admin/user-devices?${params.toString()}`, { + cache: 'no-store', + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `获取设备失败: ${res.status}`); + } + + const data = await res.json(); + setUserDevices(Array.isArray(data.devices) ? data.devices : []); + } catch (err) { + showError(err instanceof Error ? err.message : '获取设备失败', showAlert); + setShowUserDevicesModal(false); + setSelectedDeviceUsername(null); + } finally { + setUserDevicesLoading(false); + } + }; + + const handleRevokeUserDevice = async (tokenId: string) => { + if (!selectedDeviceUsername) return; + + setRevokingUserDevice(tokenId); + try { + const res = await fetch('/api/admin/user-devices', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: selectedDeviceUsername, + tokenId, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `登出设备失败: ${res.status}`); + } + + setUserDevices((prev) => + prev.filter((device) => device.tokenId !== tokenId) + ); + showSuccess('设备已登出', showAlert); + } catch (err) { + showError(err instanceof Error ? err.message : '登出设备失败', showAlert); + } finally { + setRevokingUserDevice(null); + } + }; + const handleConfigureUserApis = (user: { username: string; role: 'user' | 'admin' | 'owner'; @@ -1645,6 +1768,13 @@ const UserConfig = ({ user.username !== currentUsername && (role === 'owner' || (role === 'admin' && user.role === 'user')); + + // 查看设备权限:站长可查看所有用户,管理员可查看普通用户和自己 + const canViewDevices = + role === 'owner' || + (role === 'admin' && + (user.role === 'user' || + user.username === currentUsername)); return ( + {/* 查看设备按钮 */} + {canViewDevices && ( + + )} {/* 修改密码按钮 */} {canChangePassword && ( + + +
+ {userDevicesLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ 加载中... +
+
+ ) : userDevices.length === 0 ? ( +
+ +

+ 暂无登录设备 +

+
+ ) : ( +
+ {userDevices + .slice() + .sort((a, b) => b.lastUsed - a.lastUsed) + .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' + )} +
+
+ 过期时间:{' '} + {new Date(device.expiresAt).toLocaleString( + 'zh-CN' + )} +
+
+
+ {!device.isCurrent && ( + + )} +
+
+ ); + })} +
+ )} +
+ +
+ +
+
+ , + document.body + )} + {/* 配置用户采集源权限弹窗 */} {showConfigureApisModal && selectedUser && diff --git a/src/app/api/admin/user-devices/route.ts b/src/app/api/admin/user-devices/route.ts new file mode 100644 index 0000000..793394b --- /dev/null +++ b/src/app/api/admin/user-devices/route.ts @@ -0,0 +1,171 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { db, getStorage } from '@/lib/db'; +import { + getUserDevices, + revokeRefreshToken, +} from '@/lib/refresh-token'; + +export const runtime = 'nodejs'; + +async function getOperatorRole(username: string): Promise<'owner' | 'admin' | 'user'> { + if (username === process.env.USERNAME) return 'owner'; + + const operatorInfo = await db.getUserInfoV2(username); + if (operatorInfo) { + if (operatorInfo.banned) return 'user'; + return operatorInfo.role; + } + + const adminConfig = await getConfig(); + const userEntry = adminConfig.UserConfig.Users.find( + (u) => u.username === username + ); + if (userEntry?.banned) return 'user'; + return userEntry?.role || 'user'; +} + +async function getTargetRole(username: string): Promise<'owner' | 'admin' | 'user' | null> { + if (username === process.env.USERNAME) return 'owner'; + + const targetInfo = await db.getUserInfoV2(username); + if (targetInfo) return targetInfo.role; + + const adminConfig = await getConfig(); + const userEntry = adminConfig.UserConfig.Users.find( + (u) => u.username === username + ); + return userEntry?.role || null; +} + +export async function GET(request: NextRequest) { + const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; + if (storageType === 'localstorage') { + return NextResponse.json( + { error: '不支持本地存储进行用户设备查询' }, + { status: 400 } + ); + } + + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo?.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const targetUsername = (searchParams.get('username') || '').trim(); + if (!targetUsername) { + return NextResponse.json({ error: '缺少目标用户名' }, { status: 400 }); + } + + const operatorRole = await getOperatorRole(authInfo.username); + if (operatorRole !== 'owner' && operatorRole !== 'admin') { + return NextResponse.json({ error: '权限不足' }, { status: 401 }); + } + + const targetRole = await getTargetRole(targetUsername); + if (!targetRole) { + return NextResponse.json({ error: '目标用户不存在' }, { status: 404 }); + } + + // 管理员仅可查看普通用户和自己的设备;站长可查看所有用户设备。 + if ( + operatorRole === 'admin' && + targetUsername !== authInfo.username && + targetRole !== 'user' + ) { + return NextResponse.json({ error: '权限不足' }, { status: 401 }); + } + + const devices = await getUserDevices(targetUsername); + const devicesWithCurrent = devices.map((device) => ({ + ...device, + isCurrent: + targetUsername === authInfo.username && device.tokenId === authInfo.tokenId, + })); + + return NextResponse.json( + { devices: devicesWithCurrent }, + { headers: { 'Cache-Control': 'no-store' } } + ); + } catch (error) { + console.error('获取用户设备失败:', error); + return NextResponse.json( + { error: '获取用户设备失败', details: (error as Error).message }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; + if (storageType === 'localstorage') { + return NextResponse.json( + { error: '不支持本地存储进行用户设备登出' }, + { status: 400 } + ); + } + + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo?.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { username: targetUsername, tokenId } = (await request.json()) as { + username?: string; + tokenId?: string; + }; + + if (!targetUsername?.trim() || !tokenId?.trim()) { + return NextResponse.json( + { error: '缺少目标用户名或设备 Token' }, + { status: 400 } + ); + } + + const operatorRole = await getOperatorRole(authInfo.username); + if (operatorRole !== 'owner' && operatorRole !== 'admin') { + return NextResponse.json({ error: '权限不足' }, { status: 401 }); + } + + const targetRole = await getTargetRole(targetUsername); + if (!targetRole) { + return NextResponse.json({ error: '目标用户不存在' }, { status: 404 }); + } + + // 管理员仅可登出普通用户和自己的设备;站长可登出所有用户设备。 + if ( + operatorRole === 'admin' && + targetUsername !== authInfo.username && + targetRole !== 'user' + ) { + return NextResponse.json({ error: '权限不足' }, { status: 401 }); + } + + // 避免通过管理面板误登出当前会话;当前设备可在个人设备管理中处理。 + if (targetUsername === authInfo.username && tokenId === authInfo.tokenId) { + return NextResponse.json( + { error: '不能在此处登出当前设备' }, + { status: 400 } + ); + } + + await revokeRefreshToken(targetUsername, tokenId); + const storage = getStorage(); + await storage.deletePushSubscriptionsByTokenId?.(targetUsername, tokenId); + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('登出用户设备失败:', error); + return NextResponse.json( + { error: '登出用户设备失败', details: (error as Error).message }, + { status: 500 } + ); + } +}