From 01228aabfe97714a711291d9b29feaf2426aa6a2 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sun, 21 Dec 2025 10:56:54 +0800 Subject: [PATCH] feat: Add password protection for settings and remove Docker publish workflow. --- .github/workflows/docker-publish.yml | 65 ------------ app/layout.tsx | 5 +- app/settings/hooks/useSettingsPage.ts | 68 ++++++++++++- app/settings/page.tsx | 15 +++ components/PasswordGate.tsx | 116 +++++++++++++++++++++ components/settings/PasswordSettings.tsx | 124 +++++++++++++++++++++++ components/settings/SettingsSection.tsx | 28 +++++ lib/store/settings-store.ts | 10 ++ 8 files changed, 363 insertions(+), 68 deletions(-) delete mode 100644 .github/workflows/docker-publish.yml create mode 100644 components/PasswordGate.tsx create mode 100644 components/settings/PasswordSettings.tsx create mode 100644 components/settings/SettingsSection.tsx diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index d18f9d7..0000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Docker Build and Push - -on: - push: - branches: - - main - tags: - - 'v*' - workflow_dispatch: - -jobs: - docker: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Get version from package.json - id: package-version - run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: kuekhaoyang/kvideo - tags: | - type=semver,pattern={{version}},value=v${{ steps.package-version.outputs.version }} - type=semver,pattern={{major}}.{{minor}},value=v${{ steps.package-version.outputs.version }} - type=raw,value=latest - type=raw,value=${{ steps.package-version.outputs.version }} - - - name: Debug - List files - run: | - echo "=== Files in current directory ===" - ls -la - echo "=== Node modules status ===" - if [ -d "node_modules" ]; then echo "node_modules exists"; else echo "node_modules NOT found"; fi - echo "=== Lockfiles ===" - ls -la | grep -E "lock|yarn" || echo "No lockfiles found" - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - build-args: | - BUILDKIT_INLINE_CACHE=1 diff --git a/app/layout.tsx b/app/layout.tsx index 213d87f..95b76d5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { ThemeProvider } from "@/components/ThemeProvider"; import { Analytics } from "@vercel/analytics/react"; import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister"; +import { PasswordGate } from "@/components/PasswordGate"; const geistSans = Geist({ @@ -36,7 +37,9 @@ export default function RootLayout({ suppressHydrationWarning > - {children} + + {children} + diff --git a/app/settings/hooks/useSettingsPage.ts b/app/settings/hooks/useSettingsPage.ts index b94e0f1..333bc47 100644 --- a/app/settings/hooks/useSettingsPage.ts +++ b/app/settings/hooks/useSettingsPage.ts @@ -12,15 +12,27 @@ export function useSettingsPage() { const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false); const [editingSource, setEditingSource] = useState(null); + const [passwordAccess, setPasswordAccess] = useState(false); + const [accessPasswords, setAccessPasswords] = useState([]); + useEffect(() => { const settings = settingsStore.getSettings(); setSources(settings.sources || []); setSortBy(settings.sortBy); + setPasswordAccess(settings.passwordAccess); + setAccessPasswords(settings.accessPasswords); }, []); const handleSourcesChange = (newSources: VideoSource[]) => { setSources(newSources); - settingsStore.saveSettings({ sources: newSources, sortBy, searchHistory: true, watchHistory: true }); + settingsStore.saveSettings({ + sources: newSources, + sortBy, + searchHistory: true, + watchHistory: true, + passwordAccess, + accessPasswords + }); }; const handleAddSource = (source: VideoSource) => { @@ -39,7 +51,52 @@ export function useSettingsPage() { const handleSortChange = (newSort: SortOption) => { setSortBy(newSort); - settingsStore.saveSettings({ sources, sortBy: newSort, searchHistory: true, watchHistory: true }); + settingsStore.saveSettings({ + sources, + sortBy: newSort, + searchHistory: true, + watchHistory: true, + passwordAccess, + accessPasswords + }); + }; + + const handlePasswordToggle = (enabled: boolean) => { + setPasswordAccess(enabled); + settingsStore.saveSettings({ + sources, + sortBy, + searchHistory: true, + watchHistory: true, + passwordAccess: enabled, + accessPasswords + }); + }; + + const handleAddPassword = (password: string) => { + const updated = [...accessPasswords, password]; + setAccessPasswords(updated); + settingsStore.saveSettings({ + sources, + sortBy, + searchHistory: true, + watchHistory: true, + passwordAccess, + accessPasswords: updated + }); + }; + + const handleRemovePassword = (password: string) => { + const updated = accessPasswords.filter(p => p !== password); + setAccessPasswords(updated); + settingsStore.saveSettings({ + sources, + sortBy, + searchHistory: true, + watchHistory: true, + passwordAccess, + accessPasswords: updated + }); }; const handleExport = (includeSearchHistory: boolean, includeWatchHistory: boolean) => { @@ -59,6 +116,8 @@ export function useSettingsPage() { const settings = settingsStore.getSettings(); setSources(settings.sources); setSortBy(settings.sortBy); + setPasswordAccess(settings.passwordAccess); + setAccessPasswords(settings.accessPasswords); } return success; }; @@ -78,6 +137,8 @@ export function useSettingsPage() { return { sources, sortBy, + passwordAccess, + accessPasswords, isAddModalOpen, isExportModalOpen, isImportModalOpen, @@ -92,6 +153,9 @@ export function useSettingsPage() { handleSourcesChange, handleAddSource, handleSortChange, + handlePasswordToggle, + handleAddPassword, + handleRemovePassword, handleExport, handleImport, handleRestoreDefaults, diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 3a4fccb..a1b9360 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -8,6 +8,7 @@ import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; 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 { SettingsHeader } from '@/components/settings/SettingsHeader'; import { useSettingsPage } from './hooks/useSettingsPage'; @@ -15,6 +16,8 @@ export default function SettingsPage() { const { sources, sortBy, + passwordAccess, + accessPasswords, isAddModalOpen, isExportModalOpen, isImportModalOpen, @@ -28,6 +31,9 @@ export default function SettingsPage() { handleSourcesChange, handleAddSource, handleSortChange, + handlePasswordToggle, + handleAddPassword, + handleRemovePassword, handleExport, handleImport, handleRestoreDefaults, @@ -43,6 +49,15 @@ export default function SettingsPage() { {/* Header */} + {/* Password Settings */} + + {/* Source Management */} { + setIsClient(true); + checkLockStatus(); + }, []); + + const checkLockStatus = () => { + const settings = settingsStore.getSettings(); + if (!settings.passwordAccess) { + setIsLocked(false); + return; + } + + const isUnlocked = sessionStorage.getItem(SESSION_UNLOCKED_KEY) === 'true'; + if (isUnlocked) { + setIsLocked(false); + } else { + setIsLocked(true); + } + }; + + const handleUnlock = (e: React.FormEvent) => { + e.preventDefault(); + const settings = settingsStore.getSettings(); + if (settings.accessPasswords.includes(password)) { + sessionStorage.setItem(SESSION_UNLOCKED_KEY, 'true'); + setIsLocked(false); + setError(false); + } else { + setError(true); + // Shake animation trigger + const form = document.getElementById('password-form'); + form?.classList.add('animate-shake'); + setTimeout(() => form?.classList.remove('animate-shake'), 500); + } + }; + + if (!isClient) return null; // Prevent hydration mismatch + + 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/PasswordSettings.tsx b/components/settings/PasswordSettings.tsx new file mode 100644 index 0000000..7db988a --- /dev/null +++ b/components/settings/PasswordSettings.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useState } from 'react'; +import { SettingsSection } from './SettingsSection'; +import { Trash2, Plus, Eye, EyeOff } from 'lucide-react'; + +interface PasswordSettingsProps { + enabled: boolean; + passwords: string[]; + onToggle: (enabled: boolean) => void; + onAdd: (password: string) => void; + onRemove: (password: string) => void; +} + +export function PasswordSettings({ + enabled, + passwords, + onToggle, + onAdd, + onRemove, +}: PasswordSettingsProps) { + 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(''); + }; + + return ( + +
+
+ + +
+ + {enabled && ( +
+
+

已授权密码

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

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

+ )} + +
+ {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/components/settings/SettingsSection.tsx b/components/settings/SettingsSection.tsx new file mode 100644 index 0000000..9547b8b --- /dev/null +++ b/components/settings/SettingsSection.tsx @@ -0,0 +1,28 @@ +interface SettingsSectionProps { + title: string; + description?: string; + children: React.ReactNode; + headerAction?: React.ReactNode; +} + +export function SettingsSection({ + title, + description, + children, + headerAction, +}: SettingsSectionProps) { + return ( +
+
+

{title}

+ {headerAction &&
{headerAction}
} +
+ {description && ( +

+ {description} +

+ )} + {children} +
+ ); +} diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts index a37ec4c..376dd89 100644 --- a/lib/store/settings-store.ts +++ b/lib/store/settings-store.ts @@ -20,6 +20,8 @@ export interface AppSettings { sortBy: SortOption; searchHistory: boolean; watchHistory: boolean; + passwordAccess: boolean; + accessPasswords: string[]; } import { exportSettings, importSettings, SEARCH_HISTORY_KEY, WATCH_HISTORY_KEY } from './settings-helpers'; @@ -36,6 +38,8 @@ export const settingsStore = { sortBy: 'default', searchHistory: true, watchHistory: true, + passwordAccess: false, + accessPasswords: [], }; } @@ -46,6 +50,8 @@ export const settingsStore = { sortBy: 'default', searchHistory: true, watchHistory: true, + passwordAccess: false, + accessPasswords: [], }; } @@ -57,6 +63,8 @@ export const settingsStore = { sortBy: parsed.sortBy || 'default', searchHistory: parsed.searchHistory !== undefined ? parsed.searchHistory : true, watchHistory: parsed.watchHistory !== undefined ? parsed.watchHistory : true, + passwordAccess: parsed.passwordAccess !== undefined ? parsed.passwordAccess : false, + accessPasswords: Array.isArray(parsed.accessPasswords) ? parsed.accessPasswords : [], }; } catch { return { @@ -64,6 +72,8 @@ export const settingsStore = { sortBy: 'default', searchHistory: true, watchHistory: true, + passwordAccess: false, + accessPasswords: [], }; } },