Files
KVideo/components/PasswordGate.tsx
T

313 lines
10 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { Lock, User } from 'lucide-react';
import { clearSession, getSession, setSession, type AuthSession } from '@/lib/store/auth-store';
import { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync';
import { hasStoredAppSetting, settingsStore } from '@/lib/store/settings-store';
import { useIPTVStore } from '@/lib/store/iptv-store';
type LoginMode = 'none' | 'legacy_password' | 'managed';
function syncIPTVSources(rawValue: string) {
const iptvStore = useIPTVStore.getState();
let entries: { name: string; url: string }[] = [];
try {
const parsed = JSON.parse(rawValue);
if (Array.isArray(parsed)) {
entries = parsed.filter((item: unknown): item is { name: string; url: string } => {
if (!item || typeof item !== 'object') return false;
const candidate = item as { name?: unknown; url?: unknown };
return typeof candidate.url === 'string';
});
}
} catch {
if (rawValue.includes('http')) {
const urls = rawValue.split(',').map((value) => value.trim()).filter((value) => value.startsWith('http'));
entries = urls.map((url, index) => ({
name: urls.length > 1 ? `直播源 ${index + 1}` : '直播源',
url,
}));
}
}
iptvStore.syncBuiltinSources(entries);
}
function syncMergeSources(rawValue: string) {
const enabled = rawValue === 'true' || rawValue === '1';
if (!enabled) return;
const settings = settingsStore.getSettings();
if (settings.searchDisplayMode !== 'grouped') {
settingsStore.saveSettings({
...settings,
searchDisplayMode: 'grouped',
});
}
}
function syncDanmakuApiUrl(rawValue: string) {
if (!rawValue || hasStoredAppSetting('danmakuApiUrl')) return;
const settings = settingsStore.getSettings();
if (settings.danmakuApiUrl !== rawValue) {
settingsStore.saveSettings({
...settings,
danmakuApiUrl: rawValue,
});
}
}
function applyRuntimeConfig(data: {
subscriptionSources?: string;
iptvSources?: string;
mergeSources?: string;
danmakuApiUrl?: string;
}) {
if (data.subscriptionSources) {
settingsStore.syncEnvSubscriptions(data.subscriptionSources);
}
if (data.iptvSources) {
syncIPTVSources(data.iptvSources);
}
if (data.mergeSources) {
syncMergeSources(data.mergeSources);
}
if (data.danmakuApiUrl) {
syncDanmakuApiUrl(data.danmakuApiUrl);
}
}
function toAuthSession(session: {
accountId: string;
profileId: string;
username?: string;
name: string;
role: AuthSession['role'];
customPermissions?: AuthSession['customPermissions'];
mode?: AuthSession['mode'];
}): AuthSession {
return {
accountId: session.accountId,
profileId: session.profileId,
username: session.username,
name: session.name,
role: session.role,
customPermissions: session.customPermissions,
mode: session.mode,
};
}
export function PasswordGate({
children,
hasAuth: initialHasAuth,
}: {
children: React.ReactNode;
hasAuth: boolean;
}) {
useSubscriptionSync();
const [isLocked, setIsLocked] = useState(true);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isClient, setIsClient] = useState(false);
const [persistSession, setPersistSession] = useState(true);
const [isValidating, setIsValidating] = useState(false);
const [loginMode, setLoginMode] = useState<LoginMode>('none');
useEffect(() => {
let mounted = true;
const init = async () => {
const mirroredSession = getSession();
try {
const [configRes, sessionRes] = await Promise.all([
fetch('/api/auth'),
fetch('/api/auth/session'),
]);
if (!configRes.ok) {
throw new Error('Failed to fetch auth config');
}
const config = await configRes.json();
const sessionStatus = sessionRes.ok ? await sessionRes.json() : { authenticated: false, session: null };
if (!mounted) return;
setPersistSession(config.persistSession);
setLoginMode(config.loginMode || 'none');
applyRuntimeConfig(config);
if (sessionStatus.authenticated && sessionStatus.session) {
const session = toAuthSession(sessionStatus.session);
const hasMatchingMirror = mirroredSession &&
mirroredSession.accountId === session.accountId &&
mirroredSession.profileId === session.profileId;
setSession(session, config.persistSession);
if (!hasMatchingMirror) {
window.location.reload();
return;
}
setIsLocked(false);
setIsClient(true);
return;
}
if (mirroredSession) {
clearSession();
window.location.reload();
return;
}
setIsLocked(!!config.hasAuth);
setIsClient(true);
} catch {
if (!mounted) return;
setIsLocked(initialHasAuth && !mirroredSession);
setIsClient(true);
}
};
init();
return () => {
mounted = false;
};
}, [initialHasAuth]);
const handleUnlock = async (event: React.FormEvent) => {
event.preventDefault();
setIsValidating(true);
setError('');
try {
const response = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: loginMode === 'managed' ? username : undefined,
password,
}),
});
const data = await response.json();
if (data.valid && data.session) {
setSession(toAuthSession(data.session), data.persistSession ?? persistSession);
window.location.reload();
return;
}
} catch {
// Ignore network errors and show the same message as invalid credentials.
}
setError(loginMode === 'managed' ? '用户名或密码错误' : '密码错误');
setIsValidating(false);
const form = document.getElementById('password-form');
form?.classList.add('animate-shake');
setTimeout(() => form?.classList.remove('animate-shake'), 500);
};
if (!isClient) return null;
if (!isLocked) {
return <>{children}</>;
}
const showManagedFields = loginMode === 'managed';
return (
<div className="fixed inset-0 z-[9999] 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="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)]">
{showManagedFields ? '请输入用户名和密码以继续' : '请输入访问密码以继续'}
</p>
</div>
<div className="w-full space-y-4">
{showManagedFields && (
<div className="space-y-2">
<div className="relative">
<User size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)]" />
<input
type="text"
value={username}
onChange={(event) => {
setUsername(event.target.value);
setError('');
}}
placeholder="输入用户名..."
className="w-full pl-11 pr-4 py-3 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-[var(--text-color)] placeholder-[var(--text-color-secondary)]"
autoComplete="username"
autoFocus
/>
</div>
</div>
)}
<div className="space-y-2">
<input
type="password"
value={password}
onChange={(event) => {
setPassword(event.target.value);
setError('');
}}
placeholder={showManagedFields ? '输入密码...' : '输入密码...'}
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={!showManagedFields}
autoComplete={showManagedFields ? 'current-password' : 'off'}
/>
{error && (
<p className="text-sm text-red-500 text-center animate-pulse">
{error}
</p>
)}
</div>
<button
type="submit"
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>
</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>
);
}