mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 08:13:43 +08:00
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:
@@ -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`
|
||||
- 变量值:你的密码
|
||||
|
||||
**特点:**
|
||||
- **全局生效**:所有用户都需要此密码才能访问
|
||||
- **无法在界面删除**:只能通过修改环境变量更改
|
||||
- **与本地密码兼容**:两种密码都可以解锁应用
|
||||
|
||||
## 🛠 技术栈
|
||||
|
||||
### 前端核心
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user