diff --git a/README.md b/README.md index e8b0f33..b04e93e 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,18 @@ docker run -d -p 3000:3000 -e ACCESS_PASSWORD=your_premium_password --name kvide - **全局生效**:所有用户都需要此密码才能访问 - **无法在界面删除**:只能通过修改环境变量更改 - **与本地密码兼容**:两种密码都可以解锁应用 +- **密码持久化**:支持通过 `PERSIST_PASSWORD` 环境变量控制是否在当前设备上记住密码。 + +### 方式三:密码持久化设置(ENV) + +通过 `PERSIST_PASSWORD` 环境变量,你可以控制用户在输入正确密码后,是否需要在下次打开时重新输入。 + +| 变量名 | 选项 | 说明 | 默认值 | +|--------|------|------|--------| +| `PERSIST_PASSWORD` | `true` / `false` | 是否在本地浏览器持久化保存解锁状态。设置为 `true` 时,用户只需输入一次密码,后续访问无需再次输入。 | `true` | + +> [!NOTE] +> 此功能仅在设置了 `ACCESS_PASSWORD` 时才会生效。如果没有设置环境密码,此选项将被忽略。 ## 🎨 站点名称自定义配置 @@ -141,6 +153,7 @@ docker run -d -p 3000:3000 -e ACCESS_PASSWORD=your_premium_password --name kvide | `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `视频聚合平台 - KVideo` | | `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `专属视频聚合播放平台,具备美观的 Liquid Glass UI` | | `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `视频聚合平台` | +| `PERSIST_PASSWORD` | 密码持久化 | `true` | ### 配置示例: diff --git a/app/api/config/route.ts b/app/api/config/route.ts index ffb8b54..0ad281e 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -8,11 +8,13 @@ import { NextRequest, NextResponse } from 'next/server'; export const runtime = 'edge'; const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || ''; +const PERSIST_PASSWORD = process.env.PERSIST_PASSWORD !== 'false'; const SUBSCRIPTION_SOURCES = process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || ''; export async function GET() { return NextResponse.json({ hasEnvPassword: ACCESS_PASSWORD.length > 0, + persistPassword: PERSIST_PASSWORD, subscriptionSources: SUBSCRIPTION_SOURCES, }); } diff --git a/components/PasswordGate.tsx b/components/PasswordGate.tsx index 7e8d28a..7f849fc 100644 --- a/components/PasswordGate.tsx +++ b/components/PasswordGate.tsx @@ -16,6 +16,7 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword } const [error, setError] = useState(false); const [isClient, setIsClient] = useState(false); const [hasEnvPassword, setHasEnvPassword] = useState(initialHasEnvPassword); + const [persistEnabled, setPersistEnabled] = useState(true); const [isValidating, setIsValidating] = useState(false); useEffect(() => { @@ -23,9 +24,18 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword } const init = async () => { const settings = settingsStore.getSettings(); - const isUnlocked = sessionStorage.getItem(SESSION_UNLOCKED_KEY) === 'true'; + + // Check both storage if persistence might be enabled + const getUnlockedState = (canPersist: boolean) => { + const sessionUnlocked = sessionStorage.getItem(SESSION_UNLOCKED_KEY) === 'true'; + if (!canPersist) return sessionUnlocked; + const localUnlocked = localStorage.getItem(SESSION_UNLOCKED_KEY) === 'true'; + return sessionUnlocked || localUnlocked; + }; // 1. Initial local check (fast) + // Default to canPersist = true for first check if not sure + const isUnlocked = getUnlockedState(true); const localLocked = (settings.passwordAccess || initialHasEnvPassword) && !isUnlocked; if (mounted) setIsLocked(localLocked); if (mounted) setIsClient(true); @@ -39,6 +49,7 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword } if (mounted) { setHasEnvPassword(data.hasEnvPassword); + setPersistEnabled(data.persistPassword); // CRITICAL: Sync subscriptions immediately if (data.subscriptionSources) { @@ -47,15 +58,15 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword } } // Re-evaluate lock status with confirmed server state - // We only care about envPassword if we are not unlocked. - // Access control logic: - // Locked IF: (Local setting ON OR Env Password Exists) AND (Not Unlocked) - const confirmLocked = (settings.passwordAccess || data.hasEnvPassword) && !isUnlocked; + // Persistence only works if hasEnvPassword is true + const canPersist = data.hasEnvPassword && data.persistPassword; + const finalUnlocked = getUnlockedState(canPersist); + + const confirmLocked = (settings.passwordAccess || data.hasEnvPassword) && !finalUnlocked; setIsLocked(confirmLocked); } } catch (e) { console.error("PasswordGate init failed:", e); - // Fallback: rely on initial/local state which was already set } }; @@ -68,18 +79,12 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword } // Subscribe to settings changes (real-time updates) useEffect(() => { - // Function to handle updates from the store const handleSettingsUpdate = () => { const settings = settingsStore.getSettings(); - const isUnlocked = sessionStorage.getItem(SESSION_UNLOCKED_KEY) === 'true'; + const canPersist = hasEnvPassword && persistEnabled; + const isUnlocked = (sessionStorage.getItem(SESSION_UNLOCKED_KEY) === 'true') || + (canPersist && localStorage.getItem(SESSION_UNLOCKED_KEY) === 'true'); - // We can't easily check env password synchronously here, but we can check local settings. - // If local setting says lock, and we are not unlocked, we lock. - // If local setting says unlock (and no env password known yet), we unlock. - // To be safe, we might just re-run checkLockStatus() but that's async. - // For immediate UI feedback on "Enable/Disable Password" toggle in settings: - - // If password access is disabled in settings, and we assume no env password for a moment (or rely on previous state): if (!settings.passwordAccess && !hasEnvPassword) { setIsLocked(false); } else if (settings.passwordAccess && !isUnlocked) { @@ -89,7 +94,7 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword } const unsubscribe = settingsStore.subscribe(handleSettingsUpdate); return () => unsubscribe(); - }, [hasEnvPassword]); + }, [hasEnvPassword, persistEnabled]); @@ -98,13 +103,21 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword } setIsValidating(true); const settings = settingsStore.getSettings(); + const canPersist = hasEnvPassword && persistEnabled; - // First check local passwords - if (settings.accessPasswords.includes(password)) { + const setUnlocked = () => { + if (canPersist) { + localStorage.setItem(SESSION_UNLOCKED_KEY, 'true'); + } sessionStorage.setItem(SESSION_UNLOCKED_KEY, 'true'); setIsLocked(false); setError(false); setIsValidating(false); + }; + + // First check local passwords + if (settings.accessPasswords.includes(password)) { + setUnlocked(); return; } @@ -118,14 +131,11 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword } }); const data = await res.json(); if (data.valid) { - sessionStorage.setItem(SESSION_UNLOCKED_KEY, 'true'); - setIsLocked(false); - setError(false); - setIsValidating(false); + setUnlocked(); return; } } catch { - // API error, proceed to show error + // API error } } diff --git a/package-lock.json b/package-lock.json index 62bc253..468d6dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.0.1", + "version": "4.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.0.1", + "version": "4.0.2", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 592076c..f4c0496 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.0.1", + "version": "4.0.2", "private": true, "scripts": { "dev": "next dev",