From 98e314e31a2b17bbea101ddc87435fa75d10a9b2 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Mon, 16 Feb 2026 22:54:16 +0800 Subject: [PATCH] feat: Implement settings password protection with a dedicated gate, management UI, and API integration. --- app/api/config/route.ts | 12 +- app/premium/settings/page.tsx | 3 + app/settings/hooks/useSettingsPage.ts | 53 ++++- app/settings/page.tsx | 20 ++ components/SettingsPasswordGate.tsx | 182 ++++++++++++++++++ .../settings/SettingsPasswordSettings.tsx | 156 +++++++++++++++ lib/store/settings-store.ts | 6 + package-lock.json | 4 +- package.json | 2 +- 9 files changed, 432 insertions(+), 6 deletions(-) create mode 100644 components/SettingsPasswordGate.tsx create mode 100644 components/settings/SettingsPasswordSettings.tsx diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 0ad281e..dbbbe21 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -8,12 +8,14 @@ import { NextRequest, NextResponse } from 'next/server'; export const runtime = 'edge'; const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || ''; +const SETTINGS_PASSWORD = process.env.SETTINGS_PASSWORD || ''; const PERSIST_PASSWORD = process.env.PERSIST_PASSWORD !== 'false'; const SUBSCRIPTION_SOURCES = process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || ''; export async function GET() { return NextResponse.json({ hasEnvPassword: ACCESS_PASSWORD.length > 0, + hasEnvSettingsPassword: SETTINGS_PASSWORD.length > 0, persistPassword: PERSIST_PASSWORD, subscriptionSources: SUBSCRIPTION_SOURCES, }); @@ -21,7 +23,15 @@ export async function GET() { export async function POST(request: NextRequest) { try { - const { password } = await request.json(); + const { password, type } = await request.json(); + + if (type === 'settings') { + if (!SETTINGS_PASSWORD) { + return NextResponse.json({ valid: false, message: 'No env settings password set' }); + } + const valid = password === SETTINGS_PASSWORD; + return NextResponse.json({ valid }); + } if (!ACCESS_PASSWORD) { return NextResponse.json({ valid: false, message: 'No env password set' }); diff --git a/app/premium/settings/page.tsx b/app/premium/settings/page.tsx index cedce6b..d2a6139 100644 --- a/app/premium/settings/page.tsx +++ b/app/premium/settings/page.tsx @@ -4,6 +4,7 @@ import { AddSourceModal } from '@/components/settings/AddSourceModal'; import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; import { PremiumSourceSettings } from '@/components/settings/PremiumSourceSettings'; import { SettingsHeader } from '@/components/settings/SettingsHeader'; +import { SettingsPasswordGate } from '@/components/SettingsPasswordGate'; import { usePremiumSettingsPage } from './hooks/usePremiumSettingsPage'; import Link from 'next/link'; @@ -23,6 +24,7 @@ export default function PremiumSettingsPage() { } = usePremiumSettingsPage(); return ( +
{/* Custom Header for Secret Settings */} @@ -81,5 +83,6 @@ export default function PremiumSettingsPage() { onCancel={() => setIsRestoreDefaultsDialogOpen(false)} />
+ ); } diff --git a/app/settings/hooks/useSettingsPage.ts b/app/settings/hooks/useSettingsPage.ts index 6f89af0..a37e9b4 100644 --- a/app/settings/hooks/useSettingsPage.ts +++ b/app/settings/hooks/useSettingsPage.ts @@ -23,6 +23,10 @@ export function useSettingsPage() { const [accessPasswords, setAccessPasswords] = useState([]); const [envPasswordSet, setEnvPasswordSet] = useState(false); + const [settingsPasswordEnabled, setSettingsPasswordEnabled] = useState(false); + const [settingsPasswords, setSettingsPasswords] = useState([]); + const [envSettingsPasswordSet, setEnvSettingsPasswordSet] = useState(false); + // Display settings const [realtimeLatency, setRealtimeLatency] = useState(false); const [searchDisplayMode, setSearchDisplayMode] = useState('normal'); @@ -37,6 +41,8 @@ export function useSettingsPage() { setSortBy(settings.sortBy); setPasswordAccess(settings.passwordAccess); setAccessPasswords(settings.accessPasswords); + setSettingsPasswordEnabled(settings.settingsPasswordEnabled); + setSettingsPasswords(settings.settingsPasswords); setRealtimeLatency(settings.realtimeLatency); setSearchDisplayMode(settings.searchDisplayMode); setFullscreenType(settings.fullscreenType); @@ -46,8 +52,14 @@ export function useSettingsPage() { // Fetch env password status fetch('/api/config') .then(res => res.json()) - .then(data => setEnvPasswordSet(data.hasEnvPassword)) - .catch(() => setEnvPasswordSet(false)); + .then(data => { + setEnvPasswordSet(data.hasEnvPassword); + setEnvSettingsPasswordSet(data.hasEnvSettingsPassword); + }) + .catch(() => { + setEnvPasswordSet(false); + setEnvSettingsPasswordSet(false); + }); }, []); const handleSourcesChange = (newSources: VideoSource[]) => { @@ -137,6 +149,37 @@ export function useSettingsPage() { }); }; + const handleSettingsPasswordToggle = (enabled: boolean) => { + setSettingsPasswordEnabled(enabled); + const currentSettings = settingsStore.getSettings(); + settingsStore.saveSettings({ + ...currentSettings, + settingsPasswordEnabled: enabled, + }); + }; + + const handleAddSettingsPassword = (password: string) => { + const updated = [...settingsPasswords, password]; + setSettingsPasswords(updated); + const currentSettings = settingsStore.getSettings(); + settingsStore.saveSettings({ + ...currentSettings, + settingsPasswordEnabled, + settingsPasswords: updated, + }); + }; + + const handleRemoveSettingsPassword = (password: string) => { + const updated = settingsPasswords.filter(p => p !== password); + setSettingsPasswords(updated); + const currentSettings = settingsStore.getSettings(); + settingsStore.saveSettings({ + ...currentSettings, + settingsPasswordEnabled, + settingsPasswords: updated, + }); + }; + const handleExport = (includeSearchHistory: boolean, includeWatchHistory: boolean) => { const data = settingsStore.exportSettings(includeSearchHistory || includeWatchHistory); const blob = new Blob([data], { type: 'application/json' }); @@ -331,6 +374,9 @@ export function useSettingsPage() { passwordAccess, accessPasswords, envPasswordSet, + settingsPasswordEnabled, + settingsPasswords, + envSettingsPasswordSet, realtimeLatency, searchDisplayMode, isAddModalOpen, @@ -350,6 +396,9 @@ export function useSettingsPage() { handlePasswordToggle, handleAddPassword, handleRemovePassword, + handleSettingsPasswordToggle, + handleAddSettingsPassword, + handleRemoveSettingsPassword, handleExport, handleImportFile, // Renamed from handleImport handleImportLink, // New diff --git a/app/settings/page.tsx b/app/settings/page.tsx index c85b3f4..d37e27d 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -9,9 +9,11 @@ import { SourceSettings } from '@/components/settings/SourceSettings'; import { SortSettings } from '@/components/settings/SortSettings'; import { DataSettings } from '@/components/settings/DataSettings'; import { PasswordSettings } from '@/components/settings/PasswordSettings'; +import { SettingsPasswordSettings } from '@/components/settings/SettingsPasswordSettings'; import { DisplaySettings } from '@/components/settings/DisplaySettings'; import { PlayerSettings } from '@/components/settings/PlayerSettings'; import { SettingsHeader } from '@/components/settings/SettingsHeader'; +import { SettingsPasswordGate } from '@/components/SettingsPasswordGate'; import { useSettingsPage } from './hooks/useSettingsPage'; export default function SettingsPage() { @@ -21,6 +23,9 @@ export default function SettingsPage() { passwordAccess, accessPasswords, envPasswordSet, + settingsPasswordEnabled, + settingsPasswords, + envSettingsPasswordSet, realtimeLatency, searchDisplayMode, fullscreenType, @@ -40,6 +45,9 @@ export default function SettingsPage() { handlePasswordToggle, handleAddPassword, handleRemovePassword, + handleSettingsPasswordToggle, + handleAddSettingsPassword, + handleRemoveSettingsPassword, handleExport, handleImportFile, handleImportLink, @@ -62,6 +70,7 @@ export default function SettingsPage() { } = useSettingsPage(); return ( +
{/* Header */} @@ -85,6 +94,16 @@ export default function SettingsPage() { onRemove={handleRemovePassword} /> + {/* Settings Password Protection */} + + {/* Display Settings */}
+ ); } diff --git a/components/SettingsPasswordGate.tsx b/components/SettingsPasswordGate.tsx new file mode 100644 index 0000000..3011dc0 --- /dev/null +++ b/components/SettingsPasswordGate.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { settingsStore } from '@/lib/store/settings-store'; +import { Lock } from 'lucide-react'; + +const SESSION_KEY = 'kvideo-settings-unlocked'; + +export function SettingsPasswordGate({ children }: { children: React.ReactNode }) { + const [isLocked, setIsLocked] = useState(true); + const [password, setPassword] = useState(''); + const [error, setError] = useState(false); + const [isClient, setIsClient] = useState(false); + const [hasEnvSettingsPassword, setHasEnvSettingsPassword] = useState(false); + const [isValidating, setIsValidating] = useState(false); + + useEffect(() => { + let mounted = true; + + const init = async () => { + const settings = settingsStore.getSettings(); + const sessionUnlocked = sessionStorage.getItem(SESSION_KEY) === 'true'; + + const isProtected = (settings.settingsPasswordEnabled && settings.settingsPasswords.length > 0); + const localLocked = isProtected && !sessionUnlocked; + + if (mounted) { + setIsLocked(localLocked); + setIsClient(true); + } + + // Fetch remote config for env settings password + try { + const res = await fetch('/api/config'); + if (!res.ok) throw new Error('Failed to fetch config'); + const data = await res.json(); + + if (mounted) { + setHasEnvSettingsPassword(data.hasEnvSettingsPassword); + + const isProtectedNow = (settings.settingsPasswordEnabled && settings.settingsPasswords.length > 0) || data.hasEnvSettingsPassword; + setIsLocked(isProtectedNow && !sessionUnlocked); + } + } catch (e) { + console.error('SettingsPasswordGate init failed:', e); + } + }; + + init(); + + return () => { mounted = false; }; + }, []); + + // Subscribe to settings changes (auto-unlock if all passwords removed) + useEffect(() => { + const handleSettingsUpdate = () => { + const settings = settingsStore.getSettings(); + const sessionUnlocked = sessionStorage.getItem(SESSION_KEY) === 'true'; + const isProtected = (settings.settingsPasswordEnabled && settings.settingsPasswords.length > 0) || hasEnvSettingsPassword; + + if (!isProtected) { + setIsLocked(false); + } else if (!sessionUnlocked) { + setIsLocked(true); + } + }; + + const unsubscribe = settingsStore.subscribe(handleSettingsUpdate); + return () => unsubscribe(); + }, [hasEnvSettingsPassword]); + + const handleUnlock = async (e: React.FormEvent) => { + e.preventDefault(); + setIsValidating(true); + + const settings = settingsStore.getSettings(); + + const setUnlocked = () => { + sessionStorage.setItem(SESSION_KEY, 'true'); + setIsLocked(false); + setError(false); + setIsValidating(false); + }; + + // Check local settings passwords first + if (settings.settingsPasswords.includes(password)) { + setUnlocked(); + return; + } + + // Then check env password via API + if (hasEnvSettingsPassword) { + try { + const res = await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password, type: 'settings' }), + }); + const data = await res.json(); + if (data.valid) { + setUnlocked(); + return; + } + } catch { + // API error + } + } + + // Password didn't match + setError(true); + setIsValidating(false); + const form = document.getElementById('settings-password-form'); + form?.classList.add('animate-shake'); + setTimeout(() => form?.classList.remove('animate-shake'), 500); + }; + + if (!isClient) return null; + + if (!isLocked) { + return <>{children}; + } + + return ( +
+
+
+
+ +
+ +
+

设置已锁定

+

请输入设置密码以继续

+
+ +
+
+ { + setPassword(e.target.value); + setError(false); + }} + placeholder="输入密码..." + className={`w-full px-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border ${error ? 'border-red-500' : 'border-[var(--glass-border)]' + } focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]`} + autoFocus + /> + {error && ( +

+ 密码错误 +

+ )} +
+ + +
+
+
+ +
+ ); +} diff --git a/components/settings/SettingsPasswordSettings.tsx b/components/settings/SettingsPasswordSettings.tsx new file mode 100644 index 0000000..9852f5d --- /dev/null +++ b/components/settings/SettingsPasswordSettings.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useState } from 'react'; +import { SettingsSection } from './SettingsSection'; +import { Trash2, Plus, Eye, EyeOff, Shield, ShieldCheck } from 'lucide-react'; +import { Switch } from '@/components/ui/Switch'; + +interface SettingsPasswordSettingsProps { + enabled: boolean; + passwords: string[]; + envSettingsPasswordSet: boolean; + onToggle: (enabled: boolean) => void; + onAdd: (password: string) => void; + onRemove: (password: string) => void; +} + +export function SettingsPasswordSettings({ + enabled, + passwords, + envSettingsPasswordSet, + onToggle, + onAdd, + onRemove, +}: SettingsPasswordSettingsProps) { + const [newPassword, setNewPassword] = useState(''); + const [error, setError] = useState(''); + const [showPassword, setShowPassword] = useState(false); + + const handleAdd = (e: React.FormEvent) => { + e.preventDefault(); + if (!newPassword) return; + + if (passwords.includes(newPassword)) { + setError('密码已存在'); + return; + } + + onAdd(newPassword); + setNewPassword(''); + setError(''); + }; + + const isActive = enabled || envSettingsPasswordSet; + + return ( + +
+ {/* Toggle - only shown if no env password */} + {!envSettingsPasswordSet && ( +
+ + +
+ )} + + {/* Env Password Notice */} + {envSettingsPasswordSet && ( +
+ +
+

+ 环境变量密码已启用 +

+

+ 通过 SETTINGS_PASSWORD 环境变量设置,无法在此删除 +

+
+
+ )} + + {isActive && ( +
+ {/* Local Passwords Section */} +
+
+ +

本地保存密码

+
+

+ 仅在当前浏览器/设备有效,可随时添加或删除 +

+ + {passwords.length === 0 && !envSettingsPasswordSet && ( +

+ 未设置本地密码。在至少添加一个密码之前,任何人都可以访问设置。 +

+ )} + + {passwords.length === 0 && envSettingsPasswordSet && ( +

+ 暂无本地密码。可以添加额外的本地密码作为备用。 +

+ )} + +
+ {passwords.map((pwd, index) => ( +
+ {showPassword ? pwd : '••••••'} + +
+ ))} +
+
+ +
+
+
+ { + setNewPassword(e.target.value); + setError(''); + }} + placeholder="添加新的设置密码..." + className="w-full px-4 py-2 pr-10 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-sm" + /> + +
+ {error &&

{error}

} +
+ +
+
+ )} +
+
+ ); +} diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts index e9cc30f..47b218b 100644 --- a/lib/store/settings-store.ts +++ b/lib/store/settings-store.ts @@ -30,6 +30,8 @@ export interface AppSettings { watchHistory: boolean; passwordAccess: boolean; accessPasswords: string[]; + settingsPasswordEnabled: boolean; + settingsPasswords: string[]; // Player settings autoNextEpisode: boolean; autoSkipIntro: boolean; @@ -106,6 +108,8 @@ function getDefaultAppSettings(): AppSettings { watchHistory: true, passwordAccess: false, accessPasswords: [], + settingsPasswordEnabled: false, + settingsPasswords: [], autoNextEpisode: true, autoSkipIntro: false, skipIntroSeconds: 0, @@ -184,6 +188,8 @@ export const settingsStore = { watchHistory: parsed.watchHistory !== undefined ? parsed.watchHistory : true, passwordAccess: parsed.passwordAccess !== undefined ? parsed.passwordAccess : false, accessPasswords: Array.isArray(parsed.accessPasswords) ? parsed.accessPasswords : [], + settingsPasswordEnabled: parsed.settingsPasswordEnabled !== undefined ? parsed.settingsPasswordEnabled : false, + settingsPasswords: Array.isArray(parsed.settingsPasswords) ? parsed.settingsPasswords : [], autoNextEpisode: parsed.autoNextEpisode !== undefined ? parsed.autoNextEpisode : true, autoSkipIntro: parsed.autoSkipIntro !== undefined ? parsed.autoSkipIntro : false, skipIntroSeconds: typeof parsed.skipIntroSeconds === 'number' ? parsed.skipIntroSeconds : 0, diff --git a/package-lock.json b/package-lock.json index 5102ef9..16d3269 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.1.4", + "version": "4.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.1.4", + "version": "4.1.5", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 68d4fe3..8605bbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.1.4", + "version": "4.1.5", "private": true, "scripts": { "dev": "next dev",