feat: Implement new authentication and account management system, refactoring password gates and settings components.

This commit is contained in:
kuekhaoyang
2026-02-17 00:09:56 +08:00
parent ba615e8965
commit 0753492dfd
22 changed files with 420 additions and 820 deletions
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { isAdmin, getSession } from '@/lib/store/auth-store';
import { ShieldAlert, User } from 'lucide-react';
function ViewerNotice() {
const session = getSession();
return (
<div className="min-h-screen 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">
<div 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">
<div className="w-16 h-16 rounded-[var(--radius-full)] bg-amber-500/10 flex items-center justify-center text-amber-500 mb-2 shadow-[var(--shadow-sm)] border border-[var(--glass-border)]">
<ShieldAlert 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>
{session && (
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] text-sm">
<User size={16} className="text-[var(--text-color-secondary)]" />
<span>{session.name}</span>
<span className="px-2 py-0.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] text-xs text-[var(--text-color-secondary)]">
</span>
</div>
)}
<a
href={window?.location?.pathname?.includes('/premium') ? '/premium' : '/'}
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 text-center"
>
</a>
</div>
</div>
</div>
);
}
export function AdminGate({ children, fallback }: { children: React.ReactNode; fallback?: React.ReactNode }) {
if (!isAdmin()) return fallback || <ViewerNotice />;
return <>{children}</>;
}
+44 -98
View File
@@ -1,13 +1,12 @@
'use client';
import { useState, useEffect } from 'react';
import { settingsStore } from '@/lib/store/settings-store';
import { getSession, setSession } from '@/lib/store/auth-store';
import { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync';
import { settingsStore } from '@/lib/store/settings-store';
import { Lock } from 'lucide-react';
const SESSION_UNLOCKED_KEY = 'kvideo-unlocked';
export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword }: { children: React.ReactNode, hasEnvPassword: boolean }) {
export function PasswordGate({ children, hasAuth: initialHasAuth }: { children: React.ReactNode, hasAuth: boolean }) {
// Enable background subscription syncing globally
useSubscriptionSync();
@@ -15,58 +14,43 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword }
const [password, setPassword] = useState('');
const [error, setError] = useState(false);
const [isClient, setIsClient] = useState(false);
const [hasEnvPassword, setHasEnvPassword] = useState(initialHasEnvPassword);
const [persistEnabled, setPersistEnabled] = useState(true);
const [hasAuth, setHasAuth] = useState(initialHasAuth);
const [persistSession, setPersistSession] = useState(true);
const [isValidating, setIsValidating] = useState(false);
useEffect(() => {
let mounted = true;
const init = async () => {
const settings = settingsStore.getSettings();
// Check if already has a valid session
const session = getSession();
const isAuthenticated = !!session;
// 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;
};
// Initial fast check
const localLocked = initialHasAuth && !isAuthenticated;
if (mounted) {
setIsLocked(localLocked);
setIsClient(true);
}
// 1. Initial local check (fast)
// Determine if the app SHOULD be protected
const isProtected = (settings.passwordAccess && settings.accessPasswords.length > 0) || initialHasEnvPassword;
// Default to canPersist = true for first check if not sure
const isUnlocked = getUnlockedState(true);
const localLocked = isProtected && !isUnlocked;
if (mounted) setIsLocked(localLocked);
if (mounted) setIsClient(true);
// 2. Fetch remote config & sync
// Fetch remote config & sync
try {
const res = await fetch('/api/config');
if (!res.ok) throw new Error('Failed to fetch config');
const res = await fetch('/api/auth');
if (!res.ok) throw new Error('Failed to fetch auth config');
const data = await res.json();
if (mounted) {
setHasEnvPassword(data.hasEnvPassword);
setPersistEnabled(data.persistPassword);
setHasAuth(data.hasAuth);
setPersistSession(data.persistSession);
// CRITICAL: Sync subscriptions immediately
// Sync subscriptions
if (data.subscriptionSources) {
console.log('Syncing env subscriptions:', data.subscriptionSources);
settingsStore.syncEnvSubscriptions(data.subscriptionSources);
}
// Re-evaluate lock status with confirmed server state
// Persistence only works if hasEnvPassword is true
const canPersist = data.hasEnvPassword && data.persistPassword;
const finalUnlocked = getUnlockedState(canPersist);
const isProtectedNow = (settings.passwordAccess && settings.accessPasswords.length > 0) || data.hasEnvPassword;
const confirmLocked = isProtectedNow && !finalUnlocked;
const confirmLocked = data.hasAuth && !isAuthenticated;
setIsLocked(confirmLocked);
}
} catch (e) {
@@ -76,73 +60,34 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword }
init();
return () => {
mounted = false;
};
}, [initialHasEnvPassword]);
// Subscribe to settings changes (real-time updates)
useEffect(() => {
const handleSettingsUpdate = () => {
const settings = settingsStore.getSettings();
const canPersist = hasEnvPassword && persistEnabled;
const isUnlocked = (sessionStorage.getItem(SESSION_UNLOCKED_KEY) === 'true') ||
(canPersist && localStorage.getItem(SESSION_UNLOCKED_KEY) === 'true');
const isProtected = (settings.passwordAccess && settings.accessPasswords.length > 0) || hasEnvPassword;
if (!isProtected) {
setIsLocked(false);
} else if (!isUnlocked) {
setIsLocked(true);
}
};
const unsubscribe = settingsStore.subscribe(handleSettingsUpdate);
return () => unsubscribe();
}, [hasEnvPassword, persistEnabled]);
return () => { mounted = false; };
}, [initialHasAuth]);
const handleUnlock = async (e: React.FormEvent) => {
e.preventDefault();
setIsValidating(true);
const settings = settingsStore.getSettings();
const canPersist = hasEnvPassword && persistEnabled;
try {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.json();
const setUnlocked = () => {
if (canPersist) {
localStorage.setItem(SESSION_UNLOCKED_KEY, 'true');
}
sessionStorage.setItem(SESSION_UNLOCKED_KEY, 'true');
setIsLocked(false);
setError(false);
setIsValidating(false);
};
if (data.valid) {
setSession({
profileId: data.profileId,
name: data.name,
role: data.role,
}, data.persistSession ?? persistSession);
// First check local passwords
if (settings.accessPasswords.includes(password)) {
setUnlocked();
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) {
setUnlocked();
return;
}
} catch {
// API error
// Reload to re-initialize stores with profiled keys
window.location.reload();
return;
}
} catch {
// API error
}
// Password didn't match
@@ -199,9 +144,10 @@ export function PasswordGate({ children, hasEnvPassword: initialHasEnvPassword }
<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={isValidating}
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 disabled:cursor-not-allowed"
>
访
{isValidating ? '验证中...' : '登录'}
</button>
</div>
</form>
-182
View File
@@ -1,182 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { settingsStore } from '@/lib/store/settings-store';
import { Lock } from 'lucide-react';
const SESSION_KEY = 'kvideo-settings-unlocked';
export function SettingsPasswordGate({ children }: { children: React.ReactNode }) {
const [isLocked, setIsLocked] = useState(true);
const [password, setPassword] = useState('');
const [error, setError] = useState(false);
const [isClient, setIsClient] = useState(false);
const [hasEnvSettingsPassword, setHasEnvSettingsPassword] = useState(false);
const [isValidating, setIsValidating] = useState(false);
useEffect(() => {
let mounted = true;
const init = async () => {
const settings = settingsStore.getSettings();
const sessionUnlocked = sessionStorage.getItem(SESSION_KEY) === 'true';
const isProtected = (settings.settingsPasswordEnabled && settings.settingsPasswords.length > 0);
const localLocked = isProtected && !sessionUnlocked;
if (mounted) {
setIsLocked(localLocked);
setIsClient(true);
}
// Fetch remote config for env settings password
try {
const res = await fetch('/api/config');
if (!res.ok) throw new Error('Failed to fetch config');
const data = await res.json();
if (mounted) {
setHasEnvSettingsPassword(data.hasEnvSettingsPassword);
const isProtectedNow = (settings.settingsPasswordEnabled && settings.settingsPasswords.length > 0) || data.hasEnvSettingsPassword;
setIsLocked(isProtectedNow && !sessionUnlocked);
}
} catch (e) {
console.error('SettingsPasswordGate init failed:', e);
}
};
init();
return () => { mounted = false; };
}, []);
// Subscribe to settings changes (auto-unlock if all passwords removed)
useEffect(() => {
const handleSettingsUpdate = () => {
const settings = settingsStore.getSettings();
const sessionUnlocked = sessionStorage.getItem(SESSION_KEY) === 'true';
const isProtected = (settings.settingsPasswordEnabled && settings.settingsPasswords.length > 0) || hasEnvSettingsPassword;
if (!isProtected) {
setIsLocked(false);
} else if (!sessionUnlocked) {
setIsLocked(true);
}
};
const unsubscribe = settingsStore.subscribe(handleSettingsUpdate);
return () => unsubscribe();
}, [hasEnvSettingsPassword]);
const handleUnlock = async (e: React.FormEvent) => {
e.preventDefault();
setIsValidating(true);
const settings = settingsStore.getSettings();
const setUnlocked = () => {
sessionStorage.setItem(SESSION_KEY, 'true');
setIsLocked(false);
setError(false);
setIsValidating(false);
};
// Check local settings passwords first
if (settings.settingsPasswords.includes(password)) {
setUnlocked();
return;
}
// Then check env password via API
if (hasEnvSettingsPassword) {
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password, type: 'settings' }),
});
const data = await res.json();
if (data.valid) {
setUnlocked();
return;
}
} catch {
// API error
}
}
// Password didn't match
setError(true);
setIsValidating(false);
const form = document.getElementById('settings-password-form');
form?.classList.add('animate-shake');
setTimeout(() => form?.classList.remove('animate-shake'), 500);
};
if (!isClient) return null;
if (!isLocked) {
return <>{children}</>;
}
return (
<div className="min-h-screen 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="settings-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>
);
}
+39
View File
@@ -1,8 +1,13 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { Icons } from '@/components/ui/Icon';
import { siteConfig } from '@/lib/config/site-config';
import { getSession, clearSession, type AuthSession } from '@/lib/store/auth-store';
import { LogOut } from 'lucide-react';
interface NavbarProps {
onReset: () => void;
@@ -11,6 +16,16 @@ interface NavbarProps {
export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
const settingsHref = isPremiumMode ? '/premium/settings' : '/settings';
const [session, setSessionState] = useState<AuthSession | null>(null);
useEffect(() => {
setSessionState(getSession());
}, []);
const handleLogout = () => {
clearSession();
window.location.reload();
};
return (
<nav className="sticky top-0 z-[2000] pt-4 pb-2" style={{
@@ -43,6 +58,30 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
</Link>
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
{/* User Info */}
{session && (
<div className="hidden sm:flex items-center gap-2">
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] text-xs">
<div className="w-5 h-5 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] font-bold text-[10px] border border-[var(--glass-border)]">
{session.name.charAt(0)}
</div>
<span className="text-[var(--text-color)] max-w-[60px] truncate">{session.name}</span>
{session.role === 'admin' && (
<span className="px-1 py-0.5 bg-[var(--accent-color)]/10 text-[var(--accent-color)] rounded text-[10px] font-medium">
</span>
)}
</div>
<button
onClick={handleLogout}
className="w-8 h-8 sm:w-8 sm:h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color-secondary)] hover:text-red-500 hover:border-red-500/30 transition-all duration-200 cursor-pointer"
aria-label="退出登录"
title="退出登录"
>
<LogOut size={14} />
</button>
</div>
)}
<a
href="https://github.com/KuekHaoYang/KVideo"
target="_blank"
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { useState, useEffect } from 'react';
import { getSession, clearSession } from '@/lib/store/auth-store';
import { SettingsSection } from './SettingsSection';
import { LogOut, Shield, Info } from 'lucide-react';
export function AccountSettings() {
const [session, setSessionState] = useState<ReturnType<typeof getSession>>(null);
const [hasAuth, setHasAuth] = useState(false);
useEffect(() => {
setSessionState(getSession());
fetch('/api/auth')
.then(res => res.json())
.then(data => setHasAuth(data.hasAuth))
.catch(() => {});
}, []);
const handleLogout = () => {
clearSession();
window.location.reload();
};
if (!hasAuth && !session) return null;
return (
<SettingsSection title="账户管理" description="查看当前登录用户信息和账户配置。">
<div className="space-y-6">
{/* Current User Info */}
{session && (
<div className="flex items-center justify-between p-4 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] font-bold text-lg border border-[var(--glass-border)]">
{session.name.charAt(0)}
</div>
<div>
<p className="text-sm font-medium text-[var(--text-color)]">{session.name}</p>
<div className="flex items-center gap-1.5">
<Shield size={12} className={session.role === 'admin' ? 'text-[var(--accent-color)]' : 'text-[var(--text-color-secondary)]'} />
<span className="text-xs text-[var(--text-color-secondary)]">
{session.role === 'admin' ? '管理员' : '观众'}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] text-[var(--text-color-secondary)] hover:text-red-500 hover:border-red-500/30 transition-all duration-200 cursor-pointer"
>
<LogOut size={14} />
退
</button>
</div>
</div>
)}
{/* Config Notice */}
<div className="flex items-start gap-3 p-4 bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]">
<Info className="text-[var(--text-color-secondary)] shrink-0 mt-0.5" size={16} />
<div className="space-y-1">
<p className="text-xs text-[var(--text-color-secondary)]">
</p>
<div className="text-xs text-[var(--text-color-secondary)] space-y-0.5">
<p><code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ADMIN_PASSWORD</code> </p>
<p><code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ACCOUNTS</code> 密码:名称[:]</p>
</div>
</div>
</div>
</div>
</SettingsSection>
);
}
-158
View File
@@ -1,158 +0,0 @@
'use client';
import { useState } from 'react';
import { SettingsSection } from './SettingsSection';
import { Trash2, Plus, Eye, EyeOff, Shield, ShieldCheck } from 'lucide-react';
import { Switch } from '@/components/ui/Switch';
interface PasswordSettingsProps {
enabled: boolean;
passwords: string[];
envPasswordSet: boolean;
onToggle: (enabled: boolean) => void;
onAdd: (password: string) => void;
onRemove: (password: string) => void;
}
export function PasswordSettings({
enabled,
passwords,
envPasswordSet,
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('');
};
// If env password is set, access control is automatically enabled
const isActive = enabled || envPasswordSet;
return (
<SettingsSection title="访问控制" description="为应用启用密码保护功能。">
<div className="space-y-6">
{/* 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>
<Switch
checked={enabled}
onChange={onToggle}
ariaLabel="启用密码访问开关"
/>
</div>
)}
{/* 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">
<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 && !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>
)}
<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 cursor-pointer"
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 cursor-pointer"
>
{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 cursor-pointer"
>
<Plus size={20} />
</button>
</form>
</div>
)}
</div>
</SettingsSection>
);
}
@@ -1,156 +0,0 @@
'use client';
import { useState } from 'react';
import { SettingsSection } from './SettingsSection';
import { Trash2, Plus, Eye, EyeOff, Shield, ShieldCheck } from 'lucide-react';
import { Switch } from '@/components/ui/Switch';
interface SettingsPasswordSettingsProps {
enabled: boolean;
passwords: string[];
envSettingsPasswordSet: boolean;
onToggle: (enabled: boolean) => void;
onAdd: (password: string) => void;
onRemove: (password: string) => void;
}
export function SettingsPasswordSettings({
enabled,
passwords,
envSettingsPasswordSet,
onToggle,
onAdd,
onRemove,
}: SettingsPasswordSettingsProps) {
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('');
};
const isActive = enabled || envSettingsPasswordSet;
return (
<SettingsSection title="设置页密码保护" description="为设置页面单独设置密码保护,防止他人修改应用配置。">
<div className="space-y-6">
{/* Toggle - only shown if no env password */}
{!envSettingsPasswordSet && (
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-[var(--text-color)]">
</label>
<Switch
checked={enabled}
onChange={onToggle}
ariaLabel="启用设置页密码开关"
/>
</div>
)}
{/* Env Password Notice */}
{envSettingsPasswordSet && (
<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">SETTINGS_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">
<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 && !envSettingsPasswordSet && (
<p className="text-sm text-[var(--text-color-secondary)] italic">
访
</p>
)}
{passwords.length === 0 && envSettingsPasswordSet && (
<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 cursor-pointer"
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 cursor-pointer"
>
{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 cursor-pointer"
>
<Plus size={20} />
</button>
</form>
</div>
)}
</div>
</SettingsSection>
);
}