diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index f73b1bc..a3f331a 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -19,4 +19,7 @@ export const authAPI = { api .post('/me/password', { old_password: oldPassword, new_password: newPassword }) .then((r) => r.data), + + createTemporaryPassword: () => + api.post<{ code: string; expires_in: number }>('/me/temporary-password').then((r) => r.data), } diff --git a/web/src/components/LayoutUserMenu.tsx b/web/src/components/LayoutUserMenu.tsx index 8a5829b..246a944 100644 --- a/web/src/components/LayoutUserMenu.tsx +++ b/web/src/components/LayoutUserMenu.tsx @@ -2,11 +2,12 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNo import { createPortal } from 'react-dom' import { Link, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' -import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, UserCog } from 'lucide-react' +import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, Tv, UserCog } from 'lucide-react' import clsx from 'clsx' import type { PlayProfile } from '../types' import { LayoutThemeToggle } from './LayoutThemeToggle' +import { TemporaryPasswordDialog } from './TemporaryPasswordDialog' import type { ThemeMode } from './useThemeMode' type MenuPosition = { @@ -52,6 +53,7 @@ export function LayoutUserMenu({ const triggerRef = useRef(null) const onCloseRef = useRef(onClose) const [menuPosition, setMenuPosition] = useState(null) + const [isOtpOpen, setIsOtpOpen] = useState(false) onCloseRef.current = onClose @@ -121,11 +123,22 @@ export function LayoutUserMenu({ className="fixed z-[121] w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl" onPointerDown={(event) => event.stopPropagation()} > - } label="设置" onNavigate={onClose} /> - } label="我的收藏" onNavigate={onClose} /> - } label="播放列表" onNavigate={onClose} /> - } label="观看历史" onNavigate={onClose} /> - } label="DLNA投屏" onNavigate={onClose} /> + } label="设置" onNavigate={onClose} /> + } label="我的收藏" onNavigate={onClose} /> + } label="播放列表" onNavigate={onClose} /> + } label="观看历史" onNavigate={onClose} /> + } label="DLNA投屏" onNavigate={onClose} /> + {themeMode && onThemeChange ? (

@@ -203,6 +216,7 @@ export function LayoutUserMenu({ {menuPortal} + setIsOtpOpen(false)} />

) } diff --git a/web/src/components/TemporaryPasswordDialog.tsx b/web/src/components/TemporaryPasswordDialog.tsx new file mode 100644 index 0000000..c4ee232 --- /dev/null +++ b/web/src/components/TemporaryPasswordDialog.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { Check, Copy, Loader2, RefreshCw, Tv, X } from 'lucide-react' +import toast from 'react-hot-toast' + +import { authAPI } from '../api/auth' +import { useAuthStore } from '../stores/auth' + +export function TemporaryPasswordDialog({ + isOpen, + onClose, +}: { + isOpen: boolean + onClose: () => void +}) { + const user = useAuthStore((s) => s.user) + const [code, setCode] = useState('') + const [expiresIn, setExpiresIn] = useState(0) + const [loading, setLoading] = useState(false) + const [copied, setCopied] = useState(false) + + const fetchCode = async () => { + setLoading(true) + try { + const res = await authAPI.createTemporaryPassword() + setCode(res.code) + setExpiresIn(res.expires_in) + setCopied(false) + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + '获取临时密码失败' + toast.error(msg) + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (isOpen) { + fetchCode() + } else { + setCode('') + setExpiresIn(0) + } + }, [isOpen]) + + useEffect(() => { + if (expiresIn <= 0) return + const timer = setInterval(() => { + setExpiresIn((prev) => { + if (prev <= 1) { + clearInterval(timer) + return 0 + } + return prev - 1 + }) + }, 1000) + return () => clearInterval(timer) + }, [expiresIn]) + + const copyCode = async () => { + if (!code) return + try { + await navigator.clipboard.writeText(code) + setCopied(true) + toast.success('已复制到剪贴板') + setTimeout(() => setCopied(false), 2000) + } catch { + toast.error('复制失败,请手动长按复制') + } + } + + if (!isOpen || typeof document === 'undefined') return null + + return createPortal( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

+ 电视端临时登录码 (OTP) +

+

+ 适用于 Emby / Jellyfin / Infuse 客户端快速免密登录 +

+
+
+ +
+ + {/* Content */} +
+
+
+ 登录账号: + {user?.username} +
+ {expiresIn > 0 ? ( +
+ 有效时间剩余: + + {Math.floor(expiresIn / 60).toString().padStart(2, '0')}:{(expiresIn % 60).toString().padStart(2, '0')} + +
+ ) : null} +
+ +
+ {loading ? ( +
+ +
+ ) : code && expiresIn > 0 ? ( + <> +
+ {code} +
+ + + ) : ( +
+ 登录码已失效或未生成 + +
+ )} +
+ +
+ 使用方法: + 在电视端或外部设备的 Emby 登录界面输入用户名 {user?.username} 和上方 6 位临时码。登录后临时码立即作废(一次性阅后即焚),客户端将自动获取长期持久令牌。 +
+
+ + {/* Footer */} +
+ + +
+
+
, + document.body + ) +} diff --git a/web/src/pages/ProfilePage.tsx b/web/src/pages/ProfilePage.tsx index fa8e88b..5413114 100644 --- a/web/src/pages/ProfilePage.tsx +++ b/web/src/pages/ProfilePage.tsx @@ -1,6 +1,6 @@ -import { FormEvent, useState } from 'react' +import { FormEvent, useEffect, useState } from 'react' import toast from 'react-hot-toast' -import { EyeOff, KeyRound, Loader2, Save } from 'lucide-react' +import { Check, Copy, EyeOff, KeyRound, Loader2, Save, Tv } from 'lucide-react' import { authAPI } from '../api/auth' import { profileAPI } from '../api/profile' @@ -21,6 +21,65 @@ export function ProfilePage() { const [savingProfile, setSavingProfile] = useState(false) const [savingPassword, setSavingPassword] = useState(false) + const [otpCode, setOtpCode] = useState('') + const [otpExpiresIn, setOtpExpiresIn] = useState(0) + const [generatingOtp, setGeneratingOtp] = useState(false) + const [copiedOtp, setCopiedOtp] = useState(false) + + useEffect(() => { + if (otpExpiresIn <= 0) return + const timer = setInterval(() => { + setOtpExpiresIn((prev) => { + if (prev <= 1) { + clearInterval(timer) + return 0 + } + return prev - 1 + }) + }, 1000) + return () => clearInterval(timer) + }, [otpExpiresIn]) + + useEffect(() => { + if (window.location.hash === '#tv-otp') { + const el = document.getElementById('tv-otp') + if (el) { + el.scrollIntoView({ behavior: 'smooth' }) + } + } + }, []) + + const onGenerateOTP = async () => { + if (generatingOtp) return + setGeneratingOtp(true) + try { + const res = await authAPI.createTemporaryPassword() + setOtpCode(res.code) + setOtpExpiresIn(res.expires_in) + setCopiedOtp(false) + toast.success('已生成 6 位临时登录码') + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + '生成临时密码失败' + toast.error(msg) + } finally { + setGeneratingOtp(false) + } + } + + const copyOTP = async () => { + if (!otpCode) return + try { + await navigator.clipboard.writeText(otpCode) + setCopiedOtp(true) + toast.success('已复制到剪贴板') + setTimeout(() => setCopiedOtp(false), 2000) + } catch { + toast.error('复制失败,请手动长按复制') + } + } + const onProfile = async (e: FormEvent) => { e.preventDefault() if (savingProfile) return @@ -171,6 +230,64 @@ export function ProfilePage() { 更新密码 + +
+
+
+

+ + Emby / 电视端临时登录码 (OTP) +

+

+ 适用于电视盒子、Apple TV、车机或朋友设备上的 Emby / Infuse / Jellyfin 客户端快速登录。无需使用遥控器输入长密码。 +

+
+ +
+ + {otpCode && otpExpiresIn > 0 ? ( +
+
+
+ 登录账号: + {user?.username} +
+
+ 有效时间剩余: + + {Math.floor(otpExpiresIn / 60).toString().padStart(2, '0')}:{(otpExpiresIn % 60).toString().padStart(2, '0')} + +
+
+ +
+
+ {otpCode} +
+ +
+ +

+ 在客户端的 Emby / Jellyfin 登录界面输入用户名 {user?.username} 和上方 6 位数字临时密码即可完成登录。登录成功后临时密码立即作废(一次性使用),客户端将自动换取长期持久令牌。 +

+
+ ) : null} +
) }