feat: add environment variable password protection and integrate it into the password gate component.

This commit is contained in:
kuekhaoyang
2025-12-21 21:18:07 +08:00
parent 156b9c56a1
commit 74e96fb1b5
3 changed files with 81 additions and 19 deletions
+21
View File
@@ -109,6 +109,27 @@
- **Service Worker**:离线缓存和智能预加载
- **Server Components**:优化首屏加载性能
- **Client Components**:复杂交互和状态管理
### 🔒 访问控制
- **环境级密码**:通过设置环境变量启用全局访问密码
- **永久授权**:输入一次密码即可永久访问,无需重复验证
- **本地管理**:支持在应用设置中添加多个自定义访问密码
---
## ⚙️ 环境配置
如果需要为你的 KVideo 实例添加访问控制,可以设置以下环境变量:
| 变量名 | 必填 | 默认值 | 说明 |
|------|------|------|------|
| `ACCESS_PASSWORD` | 否 | - | 设置后,所有访问者必须输入此密码才能使用应用。 |
### 如何设置环境变量
- **Vercel**: 在项目设置 -> Environment Variables 中添加 `ACCESS_PASSWORD`
- **Docker**: 使用 `-e ACCESS_PASSWORD=你的密码` 运行容器,或在 `docker-compose.yml` 中配置。
- **本地开发**: 在项目根目录创建 `.env.local` 文件并添加 `ACCESS_PASSWORD=你的密码`
## 🚀 快速部署
+43 -19
View File
@@ -3,12 +3,14 @@
import { useState, useEffect } from 'react';
import { settingsStore } from '@/lib/store/settings-store';
import { Lock } from 'lucide-react';
import { verifyEnvPassword, isEnvPasswordRequired } from '@/lib/actions/auth';
const SESSION_UNLOCKED_KEY = 'kvideo-unlocked';
const ACCESS_GRANTED_KEY = 'kvideo-access-granted';
export function PasswordGate({ children }: { children: React.ReactNode }) {
const [isLocked, setIsLocked] = useState(true);
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [isClient, setIsClient] = useState(false);
@@ -17,38 +19,59 @@ export function PasswordGate({ children }: { children: React.ReactNode }) {
checkLockStatus();
}, []);
const checkLockStatus = () => {
const settings = settingsStore.getSettings();
if (!settings.passwordAccess) {
const checkLockStatus = async () => {
const isAccessGranted = localStorage.getItem(ACCESS_GRANTED_KEY) === 'true';
if (isAccessGranted) {
setIsLocked(false);
setLoading(false);
return;
}
const isUnlocked = sessionStorage.getItem(SESSION_UNLOCKED_KEY) === 'true';
if (isUnlocked) {
const envRequired = await isEnvPasswordRequired();
const settings = settingsStore.getSettings();
if (!envRequired && !settings.passwordAccess) {
setIsLocked(false);
} else {
setIsLocked(true);
}
setLoading(false);
};
const handleUnlock = (e: React.FormEvent) => {
const handleUnlock = async (e: React.FormEvent) => {
e.preventDefault();
const settings = settingsStore.getSettings();
if (settings.accessPasswords.includes(password)) {
sessionStorage.setItem(SESSION_UNLOCKED_KEY, 'true');
setLoading(true);
// Check against ENV password first
const isEnvValid = await verifyEnvPassword(password);
if (isEnvValid) {
localStorage.setItem(ACCESS_GRANTED_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);
setLoading(false);
return;
}
// Check against local settings passwords
const settings = settingsStore.getSettings();
if (settings.accessPasswords.includes(password)) {
localStorage.setItem(ACCESS_GRANTED_KEY, 'true');
setIsLocked(false);
setError(false);
setLoading(false);
return;
}
// Invalid password
setError(true);
setLoading(false);
// 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 (!isClient || loading) return null; // Prevent hydration mismatch and show nothing while checking
if (!isLocked) {
return <>{children}</>;
@@ -94,9 +117,10 @@ export function PasswordGate({ children }: { children: React.ReactNode }) {
<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"
disabled={loading}
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 disabled:opacity-50"
>
访
{loading ? '正在验证...' : '解锁访问'}
</button>
</div>
</form>
+17
View File
@@ -0,0 +1,17 @@
'use server';
/**
* Verifies if the provided password matches the ACCESS_PASSWORD environment variable.
*/
export async function verifyEnvPassword(password: string): Promise<boolean> {
const accessPassword = process.env.ACCESS_PASSWORD;
if (!accessPassword) return false;
return password === accessPassword;
}
/**
* Checks if a password is required by checking the presence of ACCESS_PASSWORD in the environment.
*/
export async function isEnvPasswordRequired(): Promise<boolean> {
return !!process.env.ACCESS_PASSWORD;
}