mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
fix: enforce adult profile pin visibility
This commit is contained in:
+21
-2
@@ -1,6 +1,7 @@
|
||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
|
||||
|
||||
// Single axios instance used by every API helper. Adds the JWT to outgoing
|
||||
// requests and routes 401s back to the login page.
|
||||
@@ -31,6 +32,15 @@ api.interceptors.request.use((config) => {
|
||||
config.headers = config.headers ?? {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const activeProfileId = getActivePlayProfileId()
|
||||
if (activeProfileId) {
|
||||
config.headers = config.headers ?? {}
|
||||
config.headers['X-Play-Profile-ID'] = activeProfileId
|
||||
const pinToken = getActivePlayProfilePinToken()
|
||||
if (pinToken) {
|
||||
config.headers['X-Play-Profile-PIN-Token'] = pinToken
|
||||
}
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
@@ -91,16 +101,25 @@ const tokenQuery = () => {
|
||||
return `token=${encodeURIComponent(t)}`
|
||||
}
|
||||
|
||||
const profileQuery = () => {
|
||||
const id = getActivePlayProfileId()
|
||||
if (!id) return ''
|
||||
const pinToken = getActivePlayProfilePinToken()
|
||||
return `&profile_id=${encodeURIComponent(id)}${
|
||||
pinToken ? `&profile_pin_token=${encodeURIComponent(pinToken)}` : ''
|
||||
}`
|
||||
}
|
||||
|
||||
// streamURL returns a direct-play URL for <video src>. The JWT is added as
|
||||
// a query parameter because <video> elements cannot send Authorization
|
||||
// headers.
|
||||
export function streamURL(mediaId: string): string {
|
||||
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}`
|
||||
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
|
||||
}
|
||||
|
||||
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
||||
export function hlsURL(mediaId: string): string {
|
||||
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}`
|
||||
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}`
|
||||
}
|
||||
|
||||
// imageURL converts a remote poster URL into a same-origin proxy URL so it
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface PlayProfileInput {
|
||||
allowed_library_ids: string[]
|
||||
}
|
||||
|
||||
export interface PlayProfilePINVerifyResponse {
|
||||
profile: PlayProfile
|
||||
token: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
// playProfilesAPI wraps /play-profiles. The admin variant adds ?all=true.
|
||||
export const playProfilesAPI = {
|
||||
list: (all = false) =>
|
||||
@@ -30,6 +36,11 @@ export const playProfilesAPI = {
|
||||
update: (id: string, input: PlayProfileInput) =>
|
||||
api.put<PlayProfile>(`/play-profiles/${id}`, input).then((r) => r.data),
|
||||
|
||||
verifyPin: (id: string, pin: string) =>
|
||||
api
|
||||
.post<PlayProfilePINVerifyResponse>(`/play-profiles/${id}/verify-pin`, { pin })
|
||||
.then((r) => r.data),
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/play-profiles/${id}`).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
Activity, Bell, Clock, CloudDownload, Compass, Film,
|
||||
Cast, Globe, HardDrive, Heart, Home, Image, KeySquare,
|
||||
@@ -12,6 +13,10 @@ import clsx from 'clsx'
|
||||
import { AppFooter } from './AppFooter'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePermissionStore } from '../stores/permissions'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { playProfilesAPI } from '../api/play_profiles'
|
||||
import { requestPIN } from './PinDialog'
|
||||
import type { PlayProfile } from '../types'
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
@@ -22,9 +27,12 @@ export function Layout() {
|
||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
||||
const isPermissionLoading = usePermissionStore((s) => s.isLoading)
|
||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||
const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false)
|
||||
const [profiles, setProfiles] = useState<PlayProfile[]>([])
|
||||
const [searchFocused, setSearchFocused] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
@@ -52,8 +60,28 @@ export function Layout() {
|
||||
}
|
||||
}, [fetchPermissions, isPermissionLoading, permissions, user])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setProfiles([])
|
||||
setActiveProfile(null)
|
||||
return
|
||||
}
|
||||
playProfilesAPI
|
||||
.list(false)
|
||||
.then((rows) => {
|
||||
setProfiles(rows)
|
||||
const active = rows.find((p) => p.id === activeProfileId)
|
||||
if (!active) {
|
||||
const defaultProfile = rows.find((p) => p.is_default && !p.require_pin)
|
||||
setActiveProfile(defaultProfile?.id ?? null)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [activeProfileId, setActiveProfile, user])
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const can = (key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true
|
||||
const activeProfile = profiles.find((p) => p.id === activeProfileId) ?? null
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -62,6 +90,29 @@ export function Layout() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleProfileSwitch = async (profile: PlayProfile) => {
|
||||
if (activeProfileId === profile.id) {
|
||||
setIsProfileOpen(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
let pinToken: string | null = null
|
||||
if (profile.require_pin) {
|
||||
const pin = await requestPIN({ profileName: profile.name })
|
||||
if (!pin) return
|
||||
const verified = await playProfilesAPI.verifyPin(profile.id, pin)
|
||||
pinToken = verified.token
|
||||
}
|
||||
setActiveProfile(profile.id, pinToken)
|
||||
setIsProfileOpen(false)
|
||||
toast.success(`已切换到「${profile.name}」`)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'PIN 验证失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const sidebarContent = (
|
||||
<div className="flex h-full flex-col bg-white border-r border-gray-200/80">
|
||||
{/* Brand Logo & Brand Title */}
|
||||
@@ -292,7 +343,9 @@ export function Layout() {
|
||||
</div>
|
||||
<div className="text-left hidden md:block">
|
||||
<p className="text-xs font-bold text-gray-900 leading-none">{user?.username}</p>
|
||||
<p className="text-[9px] text-gray-500 font-bold uppercase tracking-wider mt-0.5 leading-none">{user?.role}</p>
|
||||
<p className="text-[9px] text-gray-500 font-bold uppercase tracking-wider mt-0.5 leading-none">
|
||||
{activeProfile ? `Profile: ${activeProfile.name}` : user?.role}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-gray-500" />
|
||||
</button>
|
||||
@@ -317,14 +370,6 @@ export function Layout() {
|
||||
<UserIcon size={16} />
|
||||
<span>个人基本信息</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/play-profiles"
|
||||
onClick={() => setIsProfileOpen(false)}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-950 transition-colors"
|
||||
>
|
||||
<UserCog size={16} />
|
||||
<span>观影 Profile 切换</span>
|
||||
</Link>
|
||||
{user?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
@@ -336,6 +381,48 @@ export function Layout() {
|
||||
</Link>
|
||||
)}
|
||||
<div className="my-1.5 border-t border-gray-100" />
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-gray-500">
|
||||
当前观影 Profile
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveProfile(null)
|
||||
setIsProfileOpen(false)
|
||||
}}
|
||||
className={clsx(
|
||||
'flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-left text-xs transition-colors',
|
||||
!activeProfileId ? 'bg-gray-950 text-white' : 'text-gray-600 hover:bg-gray-50',
|
||||
)}
|
||||
>
|
||||
<span>账号默认</span>
|
||||
<span>{!activeProfileId ? '使用中' : ''}</span>
|
||||
</button>
|
||||
{profiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
onClick={() => handleProfileSwitch(profile)}
|
||||
className={clsx(
|
||||
'flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-left text-xs transition-colors',
|
||||
activeProfileId === profile.id ? 'bg-gray-950 text-white' : 'text-gray-600 hover:bg-gray-50',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{profile.name}</span>
|
||||
<span className="ml-2 shrink-0">{profile.allow_adult ? '成人' : '安全'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="/play-profiles"
|
||||
onClick={() => setIsProfileOpen(false)}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-950 transition-colors"
|
||||
>
|
||||
<UserCog size={16} />
|
||||
<span>管理观影 Profile</span>
|
||||
</Link>
|
||||
<div className="my-1.5 border-t border-gray-100" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { LockKeyhole } from 'lucide-react'
|
||||
|
||||
type PinOptions = {
|
||||
title?: string
|
||||
message?: string
|
||||
profileName: string
|
||||
}
|
||||
|
||||
export function requestPIN(options: PinOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
const close = (value: string | null) => {
|
||||
root.unmount()
|
||||
host.remove()
|
||||
resolve(value)
|
||||
}
|
||||
root.render(<PinDialog options={options} onClose={close} />)
|
||||
})
|
||||
}
|
||||
|
||||
function PinDialog({
|
||||
options,
|
||||
onClose,
|
||||
}: {
|
||||
options: PinOptions
|
||||
onClose: (value: string | null) => void
|
||||
}) {
|
||||
const [pin, setPin] = useState('')
|
||||
|
||||
const onSubmit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
const trimmed = pin.trim()
|
||||
if (!trimmed) return
|
||||
onClose(trimmed)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={() => onClose(null)}
|
||||
>
|
||||
<form
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onSubmit={onSubmit}
|
||||
className="w-full max-w-sm overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex gap-4 p-5">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-amber-50 text-amber-500">
|
||||
<LockKeyhole size={22} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">
|
||||
{options.title || '需要 PIN 验证'}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-ink-50">
|
||||
{options.message || `切换到「${options.profileName}」前请输入 PIN。`}
|
||||
</p>
|
||||
<input
|
||||
autoFocus
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
minLength={4}
|
||||
maxLength={8}
|
||||
value={pin}
|
||||
onChange={(event) => setPin(event.target.value)}
|
||||
className="mt-4 w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-center text-lg font-bold tracking-[0.35em] text-ink-600 outline-none transition focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/40"
|
||||
placeholder="••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-gray-100 bg-gray-50/80 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose(null)}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!pin.trim()}
|
||||
className="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-brand-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
验证并切换
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import toast from 'react-hot-toast'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { playProfilesAPI, type PlayProfileInput } from '../api/play_profiles'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { requestPIN } from '../components/PinDialog'
|
||||
import type { Library, PlayProfile } from '../types'
|
||||
|
||||
// ProfileManagementPage replicates the Vue ProfileManagementView. It
|
||||
@@ -17,6 +19,8 @@ import type { Library, PlayProfile } from '../types'
|
||||
export function ProfileManagementPage() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
|
||||
const userID = useAuthStore((s) => s.user?.id ?? '')
|
||||
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||
const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile)
|
||||
|
||||
const [profiles, setProfiles] = useState<PlayProfile[]>([])
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
@@ -65,6 +69,28 @@ export function ProfileManagementPage() {
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const selectProfile = async (profile: PlayProfile) => {
|
||||
if (profile.user_id !== userID) {
|
||||
toast.error('只能切换当前账号自己的 Profile')
|
||||
return
|
||||
}
|
||||
try {
|
||||
let pinToken: string | null = null
|
||||
if (profile.require_pin) {
|
||||
const pin = await requestPIN({ profileName: profile.name })
|
||||
if (!pin) return
|
||||
const verified = await playProfilesAPI.verifyPin(profile.id, pin)
|
||||
pinToken = verified.token
|
||||
}
|
||||
setActiveProfile(profile.id, pinToken)
|
||||
toast.success(`已切换到「${profile.name}」`)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'PIN 验证失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -105,6 +131,8 @@ export function ProfileManagementPage() {
|
||||
key={p.id}
|
||||
profile={p}
|
||||
libraries={libraries}
|
||||
active={activeProfileId === p.id || (!activeProfileId && p.is_default)}
|
||||
onSelect={() => selectProfile(p)}
|
||||
onEdit={() => openEdit(p)}
|
||||
onDelete={() => onDelete(p)}
|
||||
/>
|
||||
@@ -132,11 +160,15 @@ export function ProfileManagementPage() {
|
||||
function ProfileCard({
|
||||
profile,
|
||||
libraries,
|
||||
active,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
profile: PlayProfile
|
||||
libraries: Library[]
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
@@ -165,6 +197,11 @@ function ProfileCard({
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{active && (
|
||||
<span className="rounded-lg bg-gray-950 px-2 py-0.5 text-xs text-white">
|
||||
当前使用
|
||||
</span>
|
||||
)}
|
||||
{profile.allow_adult && (
|
||||
<span className="rounded-lg bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
|
||||
成人内容
|
||||
@@ -188,6 +225,12 @@ function ProfileCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
>
|
||||
设为当前
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
|
||||
interface PlayProfileState {
|
||||
activeProfileId: string | null
|
||||
activeProfilePinToken: string | null
|
||||
setActiveProfile: (id: string | null, pinToken?: string | null) => void
|
||||
setActiveProfileId: (id: string | null) => void
|
||||
}
|
||||
|
||||
export const usePlayProfileStore = create<PlayProfileState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
activeProfileId: null,
|
||||
activeProfilePinToken: null,
|
||||
setActiveProfile: (id, pinToken = null) =>
|
||||
set({ activeProfileId: id || null, activeProfilePinToken: id ? pinToken || null : null }),
|
||||
setActiveProfileId: (id) =>
|
||||
set((state) => ({
|
||||
activeProfileId: id || null,
|
||||
activeProfilePinToken: id ? state.activeProfilePinToken : null,
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'mediastationgo-play-profile',
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export function getActivePlayProfileId(): string | null {
|
||||
return usePlayProfileStore.getState().activeProfileId
|
||||
}
|
||||
|
||||
export function getActivePlayProfilePinToken(): string | null {
|
||||
return usePlayProfileStore.getState().activeProfilePinToken
|
||||
}
|
||||
Reference in New Issue
Block a user