feat: Add password protection for settings and remove Docker publish workflow.

This commit is contained in:
kuekhaoyang
2025-12-21 10:56:54 +08:00
parent c7e51ac1d5
commit 01228aabfe
8 changed files with 363 additions and 68 deletions
-65
View File
@@ -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
+4 -1
View File
@@ -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
>
<ThemeProvider>
{children}
<PasswordGate>
{children}
</PasswordGate>
<Analytics />
<ServiceWorkerRegister />
</ThemeProvider>
+66 -2
View File
@@ -12,15 +12,27 @@ export function useSettingsPage() {
const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false);
const [editingSource, setEditingSource] = useState<VideoSource | null>(null);
const [passwordAccess, setPasswordAccess] = useState(false);
const [accessPasswords, setAccessPasswords] = useState<string[]>([]);
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,
+15
View File
@@ -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 */}
<SettingsHeader />
{/* Password Settings */}
<PasswordSettings
enabled={passwordAccess}
passwords={accessPasswords}
onToggle={handlePasswordToggle}
onAdd={handleAddPassword}
onRemove={handleRemovePassword}
/>
{/* Source Management */}
<SourceSettings
sources={sources}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { useState, useEffect } from 'react';
import { settingsStore } from '@/lib/store/settings-store';
import { Lock } from 'lucide-react';
const SESSION_UNLOCKED_KEY = 'kvideo-unlocked';
export function PasswordGate({ children }: { children: React.ReactNode }) {
const [isLocked, setIsLocked] = useState(true);
const [password, setPassword] = useState('');
const [error, setError] = useState(false);
const [isClient, setIsClient] = useState(false);
useEffect(() => {
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 (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-[var(--bg-color)] bg-[image:var(--bg-image)] text-[var(--text-color)]">
<div className="w-full max-w-md p-4">
<form
id="password-form"
onSubmit={handleUnlock}
className="bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] p-8 shadow-[var(--shadow-md)] flex flex-col items-center gap-6 transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1)"
>
<div className="w-16 h-16 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] mb-2 shadow-[var(--shadow-sm)] border border-[var(--glass-border)]">
<Lock size={32} />
</div>
<div className="text-center space-y-2">
<h2 className="text-2xl font-bold">访</h2>
<p className="text-[var(--text-color-secondary)]">访</p>
</div>
<div className="w-full space-y-4">
<div className="space-y-2">
<input
type="password"
value={password}
onChange={(e) => {
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 && (
<p className="text-sm text-red-500 text-center animate-pulse">
</p>
)}
</div>
<button
type="submit"
className="w-full py-3 px-4 bg-[var(--accent-color)] text-white font-bold rounded-[var(--radius-2xl)] hover:translate-y-[-2px] hover:brightness-110 shadow-[var(--shadow-sm)] hover:shadow-[0_4px_8px_var(--shadow-color)] active:translate-y-0 active:scale-[0.98] transition-all duration-200"
>
访
</button>
</div>
</form>
</div>
<style jsx global>{`
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.animate-shake {
animation: shake 0.3s cubic-bezier(.36,.07,.19,.97) both;
}
`}</style>
</div>
);
}
+124
View File
@@ -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 (
<SettingsSection title="访问控制" description="为应用启用密码保护功能。">
<div className="space-y-6">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-[var(--text-color)]">
访
</label>
<label className="switch relative inline-flex items-center cursor-pointer h-[30px] w-[50px] shrink-0">
<input
type="checkbox"
className="sr-only peer"
checked={enabled}
onChange={(e) => onToggle(e.target.checked)}
/>
<div className={`switch-slider w-full h-full rounded-[var(--radius-full)] bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)] peer-checked:bg-[var(--accent-color)] transition-colors duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) before:content-[''] before:absolute before:h-[26px] before:w-[26px] before:left-[2px] before:bottom-[2px] before:bg-white before:rounded-[var(--radius-full)] before:transition-transform before:duration-[0.4s] before:cubic-bezier(0.2,0.8,0.2,1) before:shadow-[0_1px_3px_rgba(0,0,0,0.2)] peer-checked:before:translate-x-[20px]`}></div>
</label>
</div>
{enabled && (
<div className="space-y-4 pt-4 border-t border-[var(--glass-border)] animate-in fade-in slide-in-from-top-2">
<div className="space-y-2">
<h4 className="text-sm font-medium text-[var(--text-color)]"></h4>
{passwords.length === 0 && (
<p className="text-sm text-[var(--text-color-secondary)] italic">
访
</p>
)}
<div className="flex flex-wrap gap-2">
{passwords.map((pwd, index) => (
<div
key={index}
className="flex items-center gap-2 px-3 py-1.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] text-sm transition-all duration-300 hover:scale-105"
>
<span className="font-mono">{showPassword ? pwd : '••••••'}</span>
<button
onClick={() => onRemove(pwd)}
className="text-[var(--text-color-secondary)] hover:text-red-500 transition-colors"
title="删除密码"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
<form onSubmit={handleAdd} className="flex gap-2 items-start">
<div className="flex-1 space-y-1">
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => {
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"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{error && <p className="text-xs text-red-500 pl-2">{error}</p>}
</div>
<button
type="submit"
disabled={!newPassword}
className="p-2 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] hover:translate-y-[-2px] hover:brightness-110 shadow-[var(--shadow-sm)] hover:shadow-[0_4px_8px_var(--shadow-color)] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none transition-all duration-200"
>
<Plus size={20} />
</button>
</form>
</div>
)}
</div>
</SettingsSection>
);
}
+28
View File
@@ -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 (
<div className="bg-[var(--glass-bg)] backdrop-filter backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6 mb-6 transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) hover:translate-y-[-5px] hover:scale-[1.02] hover:shadow-[0_8px_24px_var(--shadow-color)] hover:z-10">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-[var(--text-color)]">{title}</h2>
{headerAction && <div className="flex gap-2">{headerAction}</div>}
</div>
{description && (
<p className="text-sm text-[var(--text-color-secondary)] mb-6 leading-[1.6]">
{description}
</p>
)}
{children}
</div>
);
}
+10
View File
@@ -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: [],
};
}
},