feat: Implement environment variable password protection with a new config API route, update access control UI, and add global pointer cursor styles.

This commit is contained in:
kuekhaoyang
2025-12-21 22:04:11 +08:00
parent a7d62fa8a5
commit c2c5cab4e7
7 changed files with 213 additions and 35 deletions
+34 -1
View File
@@ -76,7 +76,7 @@
- **语义化 HTML**:使用语义化标签提升可访问性
- **高对比度**:确保 4.5:1 的文字对比度
## 隐私保护
## 🔐 隐私保护
本应用注重用户隐私:
@@ -84,6 +84,39 @@
- **无服务器数据**:不收集或上传任何用户数据
- **自定义源**:用户可自行配置视频源
## 🔒 密码访问控制
KVideo 支持两种密码保护方式:
### 方式一:本地保存密码
在设置页面中启用密码访问,并添加密码:
- **设备独立**:仅在当前浏览器/设备有效
- **可管理**:可随时添加或删除
- **多密码支持**:可设置多个有效密码
### 方式二:环境变量密码(推荐用于部署)
通过 `ACCESS_PASSWORD` 环境变量设置全局密码:
**Docker 部署:**
```bash
docker run -d -p 3000:3000 -e ACCESS_PASSWORD=your_secret_password --name kvideo kuekhaoyang/kvideo:latest
```
**Vercel 部署:**
在 Vercel 项目设置中添加环境变量:
- 变量名:`ACCESS_PASSWORD`
- 变量值:你的密码
**特点:**
- **全局生效**:所有用户都需要此密码才能访问
- **无法在界面删除**:只能通过修改环境变量更改
- **与本地密码兼容**:两种密码都可以解锁应用
## 🛠 技术栈
### 前端核心
+29
View File
@@ -0,0 +1,29 @@
/**
* Config API Route
* Exposes configuration status (never actual values) to the client
*/
import { NextRequest, NextResponse } from 'next/server';
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
export async function GET() {
return NextResponse.json({
hasEnvPassword: ACCESS_PASSWORD.length > 0,
});
}
export async function POST(request: NextRequest) {
try {
const { password } = await request.json();
if (!ACCESS_PASSWORD) {
return NextResponse.json({ valid: false, message: 'No env password set' });
}
const valid = password === ACCESS_PASSWORD;
return NextResponse.json({ valid });
} catch {
return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 });
}
}
+8
View File
@@ -14,6 +14,7 @@ export function useSettingsPage() {
const [passwordAccess, setPasswordAccess] = useState(false);
const [accessPasswords, setAccessPasswords] = useState<string[]>([]);
const [envPasswordSet, setEnvPasswordSet] = useState(false);
useEffect(() => {
const settings = settingsStore.getSettings();
@@ -21,6 +22,12 @@ export function useSettingsPage() {
setSortBy(settings.sortBy);
setPasswordAccess(settings.passwordAccess);
setAccessPasswords(settings.accessPasswords);
// Fetch env password status
fetch('/api/config')
.then(res => res.json())
.then(data => setEnvPasswordSet(data.hasEnvPassword))
.catch(() => setEnvPasswordSet(false));
}, []);
const handleSourcesChange = (newSources: VideoSource[]) => {
@@ -139,6 +146,7 @@ export function useSettingsPage() {
sortBy,
passwordAccess,
accessPasswords,
envPasswordSet,
isAddModalOpen,
isExportModalOpen,
isImportModalOpen,
+2
View File
@@ -18,6 +18,7 @@ export default function SettingsPage() {
sortBy,
passwordAccess,
accessPasswords,
envPasswordSet,
isAddModalOpen,
isExportModalOpen,
isImportModalOpen,
@@ -53,6 +54,7 @@ export default function SettingsPage() {
<PasswordSettings
enabled={passwordAccess}
passwords={accessPasswords}
envPasswordSet={envPasswordSet}
onToggle={handlePasswordToggle}
onAdd={handleAddPassword}
onRemove={handleRemovePassword}
+13
View File
@@ -7,6 +7,19 @@
-moz-osx-font-smoothing: grayscale;
}
/* Global Pointer Cursor for Interactive Elements */
button,
[role="button"],
[type="button"],
[type="submit"],
[type="reset"],
a[href],
label[for],
select,
.cursor-pointer {
cursor: pointer;
}
html {
scroll-behavior: auto;
}
+67 -11
View File
@@ -11,17 +11,45 @@ export function PasswordGate({ children }: { children: React.ReactNode }) {
const [password, setPassword] = useState('');
const [error, setError] = useState(false);
const [isClient, setIsClient] = useState(false);
const [hasEnvPassword, setHasEnvPassword] = useState(false);
const [isValidating, setIsValidating] = useState(false);
useEffect(() => {
setIsClient(true);
checkEnvPasswordStatus();
checkLockStatus();
}, []);
const checkLockStatus = () => {
const checkEnvPasswordStatus = async () => {
try {
const res = await fetch('/api/config');
const data = await res.json();
setHasEnvPassword(data.hasEnvPassword);
} catch {
// Silently fail - env password not available
}
};
const checkLockStatus = async () => {
const settings = settingsStore.getSettings();
if (!settings.passwordAccess) {
setIsLocked(false);
return;
// Check if env password is set
try {
const res = await fetch('/api/config');
const data = await res.json();
const envPasswordSet = data.hasEnvPassword;
// Lock if either local or env password is enabled
if (!settings.passwordAccess && !envPasswordSet) {
setIsLocked(false);
return;
}
} catch {
// If API fails, just check local settings
if (!settings.passwordAccess) {
setIsLocked(false);
return;
}
}
const isUnlocked = sessionStorage.getItem(SESSION_UNLOCKED_KEY) === 'true';
@@ -32,20 +60,48 @@ export function PasswordGate({ children }: { children: React.ReactNode }) {
}
};
const handleUnlock = (e: React.FormEvent) => {
const handleUnlock = async (e: React.FormEvent) => {
e.preventDefault();
setIsValidating(true);
const settings = settingsStore.getSettings();
// First check local passwords
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);
setIsValidating(false);
return;
}
// Then check env password via API
if (hasEnvPassword) {
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (data.valid) {
sessionStorage.setItem(SESSION_UNLOCKED_KEY, 'true');
setIsLocked(false);
setError(false);
setIsValidating(false);
return;
}
} catch {
// API error, proceed to show error
}
}
// Password didn't match
setError(true);
setIsValidating(false);
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
+60 -23
View File
@@ -2,11 +2,12 @@
import { useState } from 'react';
import { SettingsSection } from './SettingsSection';
import { Trash2, Plus, Eye, EyeOff } from 'lucide-react';
import { Trash2, Plus, Eye, EyeOff, Shield, ShieldCheck } from 'lucide-react';
interface PasswordSettingsProps {
enabled: boolean;
passwords: string[];
envPasswordSet: boolean;
onToggle: (enabled: boolean) => void;
onAdd: (password: string) => void;
onRemove: (password: string) => void;
@@ -15,6 +16,7 @@ interface PasswordSettingsProps {
export function PasswordSettings({
enabled,
passwords,
envPasswordSet,
onToggle,
onAdd,
onRemove,
@@ -37,32 +39,66 @@ export function PasswordSettings({
setError('');
};
// If env password is set, access control is automatically enabled
const isActive = enabled || envPasswordSet;
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>
{/* Toggle - only shown if no env password */}
{!envPasswordSet && (
<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 && (
{/* Env Password Notice */}
{envPasswordSet && (
<div className="flex items-center gap-3 p-4 bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] border border-[var(--accent-color)]/30 rounded-[var(--radius-2xl)]">
<ShieldCheck className="text-[var(--accent-color)] shrink-0" size={24} />
<div>
<p className="text-sm font-medium text-[var(--text-color)]">
</p>
<p className="text-xs text-[var(--text-color-secondary)]">
<code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded">ACCESS_PASSWORD</code>
</p>
</div>
</div>
)}
{isActive && (
<div className="space-y-4 pt-4 border-t border-[var(--glass-border)] animate-in fade-in slide-in-from-top-2">
{/* Local Passwords Section */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-[var(--text-color)]"></h4>
<div className="flex items-center gap-2">
<Shield size={16} className="text-[var(--text-color-secondary)]" />
<h4 className="text-sm font-medium text-[var(--text-color)]"></h4>
</div>
<p className="text-xs text-[var(--text-color-secondary)]">
/
</p>
{passwords.length === 0 && (
{passwords.length === 0 && !envPasswordSet && (
<p className="text-sm text-[var(--text-color-secondary)] italic">
访
访
</p>
)}
{passwords.length === 0 && envPasswordSet && (
<p className="text-sm text-[var(--text-color-secondary)] italic">
</p>
)}
@@ -75,7 +111,7 @@ export function PasswordSettings({
<span className="font-mono">{showPassword ? pwd : '••••••'}</span>
<button
onClick={() => onRemove(pwd)}
className="text-[var(--text-color-secondary)] hover:text-red-500 transition-colors"
className="text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer"
title="删除密码"
>
<Trash2 size={14} />
@@ -95,13 +131,13 @@ export function PasswordSettings({
setNewPassword(e.target.value);
setError('');
}}
placeholder="添加新密码..."
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"
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors cursor-pointer"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
@@ -111,7 +147,7 @@ export function PasswordSettings({
<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"
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 cursor-pointer"
>
<Plus size={20} />
</button>
@@ -122,3 +158,4 @@ export function PasswordSettings({
</SettingsSection>
);
}