mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-06-01 03:22:03 +08:00
feat: Implement new authentication and account management system, refactoring password gates and settings components.
This commit is contained in:
107
app/api/auth/route.ts
Normal file
107
app/api/auth/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Auth API Route
|
||||
* Handles authentication with role-based accounts
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
||||
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
|
||||
const ACCOUNTS = process.env.ACCOUNTS || '';
|
||||
const PERSIST_SESSION = process.env.PERSIST_SESSION !== 'false'; // default true
|
||||
const SUBSCRIPTION_SOURCES = process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || '';
|
||||
|
||||
// Backward compat: ACCESS_PASSWORD acts as ADMIN_PASSWORD if ADMIN_PASSWORD is not set
|
||||
const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
|
||||
|
||||
interface AccountEntry {
|
||||
password: string;
|
||||
name: string;
|
||||
role: 'admin' | 'viewer';
|
||||
}
|
||||
|
||||
function parseAccounts(): AccountEntry[] {
|
||||
if (!ACCOUNTS) return [];
|
||||
|
||||
return ACCOUNTS.split(',')
|
||||
.map(entry => entry.trim())
|
||||
.filter(entry => entry.length > 0)
|
||||
.map(entry => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length < 2) return null;
|
||||
const [password, name, role] = parts;
|
||||
return {
|
||||
password: password.trim(),
|
||||
name: name.trim(),
|
||||
role: (role?.trim() === 'admin' ? 'admin' : 'viewer') as 'admin' | 'viewer',
|
||||
};
|
||||
})
|
||||
.filter((a): a is AccountEntry => a !== null && a.password.length > 0 && a.name.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic profileId from password using SHA-256.
|
||||
* Uses a salt to avoid rainbow table attacks.
|
||||
*/
|
||||
async function generateProfileId(password: string): Promise<string> {
|
||||
const salt = 'kvideo-profile-salt-v1';
|
||||
const data = new TextEncoder().encode(password + salt);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hash));
|
||||
// Use first 8 bytes (16 hex chars) for a compact but unique ID
|
||||
return hashArray.slice(0, 8).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const hasAuth = !!(effectiveAdminPassword || ACCOUNTS);
|
||||
|
||||
return NextResponse.json({
|
||||
hasAuth,
|
||||
persistSession: PERSIST_SESSION,
|
||||
subscriptionSources: SUBSCRIPTION_SOURCES,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { password } = await request.json();
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
return NextResponse.json({ valid: false, message: 'Password required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Check admin password
|
||||
if (effectiveAdminPassword && password === effectiveAdminPassword) {
|
||||
const profileId = await generateProfileId(password);
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
name: '管理员',
|
||||
role: 'admin',
|
||||
profileId,
|
||||
persistSession: PERSIST_SESSION,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Check ACCOUNTS entries
|
||||
const accounts = parseAccounts();
|
||||
for (const account of accounts) {
|
||||
if (password === account.password) {
|
||||
const profileId = await generateProfileId(password);
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
profileId,
|
||||
persistSession: PERSIST_SESSION,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No match
|
||||
return NextResponse.json({ valid: false });
|
||||
} catch {
|
||||
return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,17 @@
|
||||
/**
|
||||
* Config API Route
|
||||
* Exposes configuration status (never actual values) to the client
|
||||
* Config API Route (Simplified)
|
||||
* Only returns non-auth configuration now.
|
||||
* Auth has moved to /api/auth.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
|
||||
const SETTINGS_PASSWORD = process.env.SETTINGS_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,
|
||||
hasEnvSettingsPassword: SETTINGS_PASSWORD.length > 0,
|
||||
persistPassword: PERSIST_PASSWORD,
|
||||
subscriptionSources: SUBSCRIPTION_SOURCES,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { password, type } = await request.json();
|
||||
|
||||
if (type === 'settings') {
|
||||
if (!SETTINGS_PASSWORD) {
|
||||
return NextResponse.json({ valid: false, message: 'No env settings password set' });
|
||||
}
|
||||
const valid = password === SETTINGS_PASSWORD;
|
||||
return NextResponse.json({ valid });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider>
|
||||
<PasswordGate hasEnvPassword={!!process.env.ACCESS_PASSWORD}>
|
||||
<PasswordGate hasAuth={!!(process.env.ADMIN_PASSWORD || process.env.ACCOUNTS || process.env.ACCESS_PASSWORD)}>
|
||||
<AdKeywordsWrapper />
|
||||
{children}
|
||||
<BackToTop />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AddSourceModal } from '@/components/settings/AddSourceModal';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { PremiumSourceSettings } from '@/components/settings/PremiumSourceSettings';
|
||||
import { SettingsHeader } from '@/components/settings/SettingsHeader';
|
||||
import { SettingsPasswordGate } from '@/components/SettingsPasswordGate';
|
||||
import { AdminGate } from '@/components/AdminGate';
|
||||
import { usePremiumSettingsPage } from './hooks/usePremiumSettingsPage';
|
||||
import Link from 'next/link';
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function PremiumSettingsPage() {
|
||||
} = usePremiumSettingsPage();
|
||||
|
||||
return (
|
||||
<SettingsPasswordGate>
|
||||
<AdminGate>
|
||||
<div className="min-h-screen bg-black">
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl space-y-8">
|
||||
{/* Custom Header for Secret Settings */}
|
||||
@@ -83,6 +83,6 @@ export default function PremiumSettingsPage() {
|
||||
onCancel={() => setIsRestoreDefaultsDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsPasswordGate>
|
||||
</AdminGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,14 +19,6 @@ export function useSettingsPage() {
|
||||
const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false);
|
||||
const [editingSource, setEditingSource] = useState<VideoSource | null>(null);
|
||||
|
||||
const [passwordAccess, setPasswordAccess] = useState(false);
|
||||
const [accessPasswords, setAccessPasswords] = useState<string[]>([]);
|
||||
const [envPasswordSet, setEnvPasswordSet] = useState(false);
|
||||
|
||||
const [settingsPasswordEnabled, setSettingsPasswordEnabled] = useState(false);
|
||||
const [settingsPasswords, setSettingsPasswords] = useState<string[]>([]);
|
||||
const [envSettingsPasswordSet, setEnvSettingsPasswordSet] = useState(false);
|
||||
|
||||
// Display settings
|
||||
const [realtimeLatency, setRealtimeLatency] = useState(false);
|
||||
const [searchDisplayMode, setSearchDisplayMode] = useState<SearchDisplayMode>('normal');
|
||||
@@ -39,27 +31,11 @@ export function useSettingsPage() {
|
||||
setSources(settings.sources || []);
|
||||
setSubscriptions(settings.subscriptions || []);
|
||||
setSortBy(settings.sortBy);
|
||||
setPasswordAccess(settings.passwordAccess);
|
||||
setAccessPasswords(settings.accessPasswords);
|
||||
setSettingsPasswordEnabled(settings.settingsPasswordEnabled);
|
||||
setSettingsPasswords(settings.settingsPasswords);
|
||||
setRealtimeLatency(settings.realtimeLatency);
|
||||
setSearchDisplayMode(settings.searchDisplayMode);
|
||||
setFullscreenType(settings.fullscreenType);
|
||||
setProxyMode(settings.proxyMode);
|
||||
setRememberScrollPosition(settings.rememberScrollPosition);
|
||||
|
||||
// Fetch env password status
|
||||
fetch('/api/config')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setEnvPasswordSet(data.hasEnvPassword);
|
||||
setEnvSettingsPasswordSet(data.hasEnvSettingsPassword);
|
||||
})
|
||||
.catch(() => {
|
||||
setEnvPasswordSet(false);
|
||||
setEnvSettingsPasswordSet(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSourcesChange = (newSources: VideoSource[]) => {
|
||||
@@ -70,10 +46,6 @@ export function useSettingsPage() {
|
||||
sources: newSources,
|
||||
sortBy,
|
||||
subscriptions,
|
||||
searchHistory: true,
|
||||
watchHistory: true,
|
||||
passwordAccess,
|
||||
accessPasswords
|
||||
});
|
||||
};
|
||||
|
||||
@@ -98,85 +70,6 @@ export function useSettingsPage() {
|
||||
...currentSettings,
|
||||
sources,
|
||||
sortBy: newSort,
|
||||
searchHistory: true,
|
||||
watchHistory: true,
|
||||
passwordAccess,
|
||||
accessPasswords
|
||||
});
|
||||
};
|
||||
|
||||
const handlePasswordToggle = (enabled: boolean) => {
|
||||
setPasswordAccess(enabled);
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
settingsStore.saveSettings({
|
||||
...currentSettings,
|
||||
sources,
|
||||
sortBy,
|
||||
searchHistory: true,
|
||||
watchHistory: true,
|
||||
passwordAccess: enabled,
|
||||
accessPasswords
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddPassword = (password: string) => {
|
||||
const updated = [...accessPasswords, password];
|
||||
setAccessPasswords(updated);
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
settingsStore.saveSettings({
|
||||
...currentSettings,
|
||||
sources,
|
||||
sortBy,
|
||||
searchHistory: true,
|
||||
watchHistory: true,
|
||||
passwordAccess,
|
||||
accessPasswords: updated
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemovePassword = (password: string) => {
|
||||
const updated = accessPasswords.filter(p => p !== password);
|
||||
setAccessPasswords(updated);
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
settingsStore.saveSettings({
|
||||
...currentSettings,
|
||||
sources,
|
||||
sortBy,
|
||||
searchHistory: true,
|
||||
watchHistory: true,
|
||||
passwordAccess,
|
||||
accessPasswords: updated
|
||||
});
|
||||
};
|
||||
|
||||
const handleSettingsPasswordToggle = (enabled: boolean) => {
|
||||
setSettingsPasswordEnabled(enabled);
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
settingsStore.saveSettings({
|
||||
...currentSettings,
|
||||
settingsPasswordEnabled: enabled,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddSettingsPassword = (password: string) => {
|
||||
const updated = [...settingsPasswords, password];
|
||||
setSettingsPasswords(updated);
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
settingsStore.saveSettings({
|
||||
...currentSettings,
|
||||
settingsPasswordEnabled,
|
||||
settingsPasswords: updated,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveSettingsPassword = (password: string) => {
|
||||
const updated = settingsPasswords.filter(p => p !== password);
|
||||
setSettingsPasswords(updated);
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
settingsStore.saveSettings({
|
||||
...currentSettings,
|
||||
settingsPasswordEnabled,
|
||||
settingsPasswords: updated,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -199,8 +92,6 @@ export function useSettingsPage() {
|
||||
setSources(settings.sources);
|
||||
setSortBy(settings.sortBy);
|
||||
setSubscriptions(settings.subscriptions || []);
|
||||
setPasswordAccess(settings.passwordAccess);
|
||||
setAccessPasswords(settings.accessPasswords);
|
||||
|
||||
// Reload to apply changes
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
@@ -371,12 +262,6 @@ export function useSettingsPage() {
|
||||
sources,
|
||||
subscriptions,
|
||||
sortBy,
|
||||
passwordAccess,
|
||||
accessPasswords,
|
||||
envPasswordSet,
|
||||
settingsPasswordEnabled,
|
||||
settingsPasswords,
|
||||
envSettingsPasswordSet,
|
||||
realtimeLatency,
|
||||
searchDisplayMode,
|
||||
isAddModalOpen,
|
||||
@@ -393,18 +278,12 @@ export function useSettingsPage() {
|
||||
handleSourcesChange,
|
||||
handleAddSource,
|
||||
handleSortChange,
|
||||
handlePasswordToggle,
|
||||
handleAddPassword,
|
||||
handleRemovePassword,
|
||||
handleSettingsPasswordToggle,
|
||||
handleAddSettingsPassword,
|
||||
handleRemoveSettingsPassword,
|
||||
handleExport,
|
||||
handleImportFile, // Renamed from handleImport
|
||||
handleImportLink, // New
|
||||
handleAddSubscription, // New
|
||||
handleRemoveSubscription, // New
|
||||
handleRefreshSubscription, // New
|
||||
handleImportFile,
|
||||
handleImportLink,
|
||||
handleAddSubscription,
|
||||
handleRemoveSubscription,
|
||||
handleRefreshSubscription,
|
||||
handleRestoreDefaults,
|
||||
handleResetAll,
|
||||
editingSource,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { AddSourceModal } from '@/components/settings/AddSourceModal';
|
||||
import { ExportModal } from '@/components/settings/ExportModal';
|
||||
import { ImportModal } from '@/components/settings/ImportModal';
|
||||
@@ -8,24 +7,17 @@ import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { SourceSettings } from '@/components/settings/SourceSettings';
|
||||
import { SortSettings } from '@/components/settings/SortSettings';
|
||||
import { DataSettings } from '@/components/settings/DataSettings';
|
||||
import { PasswordSettings } from '@/components/settings/PasswordSettings';
|
||||
import { SettingsPasswordSettings } from '@/components/settings/SettingsPasswordSettings';
|
||||
import { AccountSettings } from '@/components/settings/AccountSettings';
|
||||
import { DisplaySettings } from '@/components/settings/DisplaySettings';
|
||||
import { PlayerSettings } from '@/components/settings/PlayerSettings';
|
||||
import { SettingsHeader } from '@/components/settings/SettingsHeader';
|
||||
import { SettingsPasswordGate } from '@/components/SettingsPasswordGate';
|
||||
import { AdminGate } from '@/components/AdminGate';
|
||||
import { useSettingsPage } from './hooks/useSettingsPage';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const {
|
||||
sources,
|
||||
sortBy,
|
||||
passwordAccess,
|
||||
accessPasswords,
|
||||
envPasswordSet,
|
||||
settingsPasswordEnabled,
|
||||
settingsPasswords,
|
||||
envSettingsPasswordSet,
|
||||
realtimeLatency,
|
||||
searchDisplayMode,
|
||||
fullscreenType,
|
||||
@@ -42,12 +34,6 @@ export default function SettingsPage() {
|
||||
handleSourcesChange,
|
||||
handleAddSource,
|
||||
handleSortChange,
|
||||
handlePasswordToggle,
|
||||
handleAddPassword,
|
||||
handleRemovePassword,
|
||||
handleSettingsPasswordToggle,
|
||||
handleAddSettingsPassword,
|
||||
handleRemoveSettingsPassword,
|
||||
handleExport,
|
||||
handleImportFile,
|
||||
handleImportLink,
|
||||
@@ -70,12 +56,15 @@ export default function SettingsPage() {
|
||||
} = useSettingsPage();
|
||||
|
||||
return (
|
||||
<SettingsPasswordGate>
|
||||
<AdminGate>
|
||||
<div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed">
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl space-y-8">
|
||||
{/* Header */}
|
||||
<SettingsHeader />
|
||||
|
||||
{/* Account Settings */}
|
||||
<AccountSettings />
|
||||
|
||||
{/* Player Settings */}
|
||||
<PlayerSettings
|
||||
fullscreenType={fullscreenType}
|
||||
@@ -84,26 +73,6 @@ export default function SettingsPage() {
|
||||
onProxyModeChange={handleProxyModeChange}
|
||||
/>
|
||||
|
||||
{/* Password Settings */}
|
||||
<PasswordSettings
|
||||
enabled={passwordAccess}
|
||||
passwords={accessPasswords}
|
||||
envPasswordSet={envPasswordSet}
|
||||
onToggle={handlePasswordToggle}
|
||||
onAdd={handleAddPassword}
|
||||
onRemove={handleRemovePassword}
|
||||
/>
|
||||
|
||||
{/* Settings Password Protection */}
|
||||
<SettingsPasswordSettings
|
||||
enabled={settingsPasswordEnabled}
|
||||
passwords={settingsPasswords}
|
||||
envSettingsPasswordSet={envSettingsPasswordSet}
|
||||
onToggle={handleSettingsPasswordToggle}
|
||||
onAdd={handleAddSettingsPassword}
|
||||
onRemove={handleRemoveSettingsPassword}
|
||||
/>
|
||||
|
||||
{/* Display Settings */}
|
||||
<DisplaySettings
|
||||
realtimeLatency={realtimeLatency}
|
||||
@@ -190,7 +159,6 @@ export default function SettingsPage() {
|
||||
dangerous
|
||||
/>
|
||||
</div>
|
||||
</SettingsPasswordGate>
|
||||
</AdminGate>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
47
components/AdminGate.tsx
Normal file
47
components/AdminGate.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
// 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
|
||||
if (data.valid) {
|
||||
setSession({
|
||||
profileId: data.profileId,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
}, data.persistSession ?? persistSession);
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
components/settings/AccountSettings.tsx
Normal file
77
components/settings/AccountSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
59
lib/store/auth-store.ts
Normal file
59
lib/store/auth-store.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Auth Store - Simple module-level session management
|
||||
* NOT Zustand — needs to be synchronous at import time for store key generation
|
||||
*/
|
||||
|
||||
export interface AuthSession {
|
||||
profileId: string;
|
||||
name: string;
|
||||
role: 'admin' | 'viewer';
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'kvideo-session';
|
||||
|
||||
export function getSession(): AuthSession | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
// Check sessionStorage first, then localStorage (for persisted sessions)
|
||||
const raw = sessionStorage.getItem(SESSION_KEY) || localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.profileId && parsed.name && parsed.role) {
|
||||
return parsed as AuthSession;
|
||||
}
|
||||
} catch {
|
||||
// Invalid session data
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setSession(session: AuthSession, persist: boolean): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const data = JSON.stringify(session);
|
||||
sessionStorage.setItem(SESSION_KEY, data);
|
||||
if (persist) {
|
||||
localStorage.setItem(SESSION_KEY, data);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
// Also clear old unlock keys for backward compat cleanup
|
||||
sessionStorage.removeItem('kvideo-unlocked');
|
||||
localStorage.removeItem('kvideo-unlocked');
|
||||
}
|
||||
|
||||
export function isAdmin(): boolean {
|
||||
const session = getSession();
|
||||
if (!session) return true; // No auth configured = full access
|
||||
return session.role === 'admin';
|
||||
}
|
||||
|
||||
export function getProfileId(): string {
|
||||
const session = getSession();
|
||||
return session?.profileId || '';
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { FavoriteItem } from '@/lib/types';
|
||||
import { profiledKey } from '@/lib/utils/profile-storage';
|
||||
|
||||
const MAX_FAVORITES = 100;
|
||||
|
||||
@@ -117,8 +118,8 @@ const createFavoritesStore = (name: string) =>
|
||||
)
|
||||
);
|
||||
|
||||
export const useFavoritesStore = createFavoritesStore('kvideo-favorites-store');
|
||||
export const usePremiumFavoritesStore = createFavoritesStore('kvideo-premium-favorites-store');
|
||||
export const useFavoritesStore = createFavoritesStore(profiledKey('kvideo-favorites-store'));
|
||||
export const usePremiumFavoritesStore = createFavoritesStore(profiledKey('kvideo-premium-favorites-store'));
|
||||
|
||||
/**
|
||||
* Helper hook to get the appropriate favorites store
|
||||
|
||||
@@ -7,6 +7,7 @@ import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { VideoHistoryItem, Episode } from '@/lib/types';
|
||||
import { clearSegmentsForUrl, clearAllCache } from '@/lib/utils/cacheManager';
|
||||
import { profiledKey } from '@/lib/utils/profile-storage';
|
||||
|
||||
const MAX_HISTORY_ITEMS = 50;
|
||||
|
||||
@@ -151,8 +152,8 @@ const createHistoryStore = (name: string) =>
|
||||
)
|
||||
);
|
||||
|
||||
export const useHistoryStore = createHistoryStore('kvideo-history-store');
|
||||
export const usePremiumHistoryStore = createHistoryStore('kvideo-premium-history-store');
|
||||
export const useHistoryStore = createHistoryStore(profiledKey('kvideo-history-store'));
|
||||
export const usePremiumHistoryStore = createHistoryStore(profiledKey('kvideo-premium-history-store'));
|
||||
|
||||
/**
|
||||
* Helper hook to get the appropriate history store
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { profiledKey } from '@/lib/utils/profile-storage';
|
||||
|
||||
const MAX_HISTORY_ITEMS = 20;
|
||||
|
||||
@@ -113,8 +114,8 @@ const createSearchHistoryStore = (name: string) =>
|
||||
)
|
||||
);
|
||||
|
||||
export const useSearchHistoryStore = createSearchHistoryStore('kvideo-search-history');
|
||||
export const usePremiumSearchHistoryStore = createSearchHistoryStore('kvideo-premium-search-history');
|
||||
export const useSearchHistoryStore = createSearchHistoryStore(profiledKey('kvideo-search-history'));
|
||||
export const usePremiumSearchHistoryStore = createSearchHistoryStore(profiledKey('kvideo-premium-search-history'));
|
||||
|
||||
/**
|
||||
* Helper hook to get the appropriate search history store
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AppSettings } from './settings-store';
|
||||
import { profiledKey } from '@/lib/utils/profile-storage';
|
||||
|
||||
export const SEARCH_HISTORY_KEY = 'kvideo-search-history';
|
||||
export const WATCH_HISTORY_KEY = 'kvideo-watch-history';
|
||||
@@ -20,8 +21,8 @@ export function exportSettings(settings: AppSettings, includeHistory: boolean =
|
||||
};
|
||||
|
||||
if (includeHistory && typeof window !== 'undefined') {
|
||||
const searchHistory = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||
const watchHistory = localStorage.getItem(WATCH_HISTORY_KEY);
|
||||
const searchHistory = localStorage.getItem(profiledKey(SEARCH_HISTORY_KEY));
|
||||
const watchHistory = localStorage.getItem(profiledKey(WATCH_HISTORY_KEY));
|
||||
|
||||
if (searchHistory) exportData.searchHistory = JSON.parse(searchHistory);
|
||||
if (watchHistory) exportData.watchHistory = JSON.parse(watchHistory);
|
||||
@@ -49,11 +50,11 @@ export function importSettings(
|
||||
|
||||
// Case 2: History only (can be independent)
|
||||
if (data.searchHistory && typeof window !== 'undefined') {
|
||||
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(data.searchHistory));
|
||||
localStorage.setItem(profiledKey(SEARCH_HISTORY_KEY), JSON.stringify(data.searchHistory));
|
||||
imported = true;
|
||||
}
|
||||
if (data.watchHistory && typeof window !== 'undefined') {
|
||||
localStorage.setItem(WATCH_HISTORY_KEY, JSON.stringify(data.watchHistory));
|
||||
localStorage.setItem(profiledKey(WATCH_HISTORY_KEY), JSON.stringify(data.watchHistory));
|
||||
imported = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,10 +28,6 @@ export interface AppSettings {
|
||||
sortBy: SortOption;
|
||||
searchHistory: boolean;
|
||||
watchHistory: boolean;
|
||||
passwordAccess: boolean;
|
||||
accessPasswords: string[];
|
||||
settingsPasswordEnabled: boolean;
|
||||
settingsPasswords: string[];
|
||||
// Player settings
|
||||
autoNextEpisode: boolean;
|
||||
autoSkipIntro: boolean;
|
||||
@@ -106,10 +102,6 @@ function getDefaultAppSettings(): AppSettings {
|
||||
sortBy: 'default',
|
||||
searchHistory: true,
|
||||
watchHistory: true,
|
||||
passwordAccess: false,
|
||||
accessPasswords: [],
|
||||
settingsPasswordEnabled: false,
|
||||
settingsPasswords: [],
|
||||
autoNextEpisode: true,
|
||||
autoSkipIntro: false,
|
||||
skipIntroSeconds: 0,
|
||||
@@ -186,10 +178,6 @@ export const settingsStore = {
|
||||
sortBy: parsed.sortBy || 'default',
|
||||
searchHistory: parsed.searchHistory !== undefined ? parsed.searchHistory : true,
|
||||
watchHistory: parsed.watchHistory !== undefined ? parsed.watchHistory : true,
|
||||
passwordAccess: parsed.passwordAccess !== undefined ? parsed.passwordAccess : false,
|
||||
accessPasswords: Array.isArray(parsed.accessPasswords) ? parsed.accessPasswords : [],
|
||||
settingsPasswordEnabled: parsed.settingsPasswordEnabled !== undefined ? parsed.settingsPasswordEnabled : false,
|
||||
settingsPasswords: Array.isArray(parsed.settingsPasswords) ? parsed.settingsPasswords : [],
|
||||
autoNextEpisode: parsed.autoNextEpisode !== undefined ? parsed.autoNextEpisode : true,
|
||||
autoSkipIntro: parsed.autoSkipIntro !== undefined ? parsed.autoSkipIntro : false,
|
||||
skipIntroSeconds: typeof parsed.skipIntroSeconds === 'number' ? parsed.skipIntroSeconds : 0,
|
||||
|
||||
10
lib/utils/profile-storage.ts
Normal file
10
lib/utils/profile-storage.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getProfileId } from '@/lib/store/auth-store';
|
||||
|
||||
/**
|
||||
* Returns a profiled localStorage key based on the current user's profileId.
|
||||
* If no session exists, returns the base key unchanged (backward compatible).
|
||||
*/
|
||||
export function profiledKey(baseKey: string): string {
|
||||
const id = getProfileId();
|
||||
return id ? `${baseKey}-p-${id}` : baseKey;
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.1.7",
|
||||
"version": "4.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kvideo",
|
||||
"version": "4.1.7",
|
||||
"version": "4.2.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.1.7",
|
||||
"version": "4.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user