feat: Add password login permission handling

This commit is contained in:
Peifan Li
2026-01-03 11:05:42 -05:00
parent b6e3072350
commit ce544ff9c2
20 changed files with 267 additions and 82 deletions

View File

@@ -5,18 +5,31 @@ import * as storageService from "./storageService";
import { logger } from "../utils/logger";
import { Settings, defaultSettings } from "../types/settings";
/**
* Check if login is required (loginEnabled is true)
*/
export function isLoginRequired(): boolean {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
return mergedSettings.loginEnabled === true;
}
/**
* Check if password authentication is enabled
*/
export function isPasswordEnabled(): {
enabled: boolean;
waitTime?: number;
loginRequired?: boolean;
} {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
// Return true only if login is enabled AND a password is set
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password;
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
// Return true only if login is enabled AND a password is set AND password login is allowed
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password && passwordLoginAllowed;
// Check for remaining wait time
const remainingWaitTime = loginAttemptService.canAttemptLogin();
@@ -24,6 +37,7 @@ export function isPasswordEnabled(): {
return {
enabled: isEnabled,
waitTime: remainingWaitTime > 0 ? remainingWaitTime : undefined,
loginRequired: mergedSettings.loginEnabled === true,
};
}
@@ -41,6 +55,16 @@ export async function verifyPassword(
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
if (!passwordLoginAllowed) {
return {
success: false,
message: "Password login is not allowed. Please use passkey authentication.",
};
}
if (!mergedSettings.password) {
// If no password set but login enabled, allow access
return { success: true };
@@ -90,6 +114,16 @@ export async function hashPassword(password: string): Promise<string> {
* Returns the new password (should be logged, not sent to frontend)
*/
export async function resetPassword(): Promise<string> {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
if (!passwordLoginAllowed) {
throw new Error("Password reset is not allowed when password login is disabled");
}
// Generate random 8-character password using cryptographically secure random
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@@ -102,8 +136,6 @@ export async function resetPassword(): Promise<string> {
const hashedPassword = await hashPassword(newPassword);
// Update settings with new password
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
mergedSettings.password = hashedPassword;
mergedSettings.loginEnabled = true; // Ensure login is enabled

View File

@@ -137,9 +137,19 @@ export async function prepareSettingsForSave(
const prepared = { ...newSettings };
// Handle password hashing
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = existingSettings.passwordLoginAllowed !== false;
if (prepared.password) {
// If password is provided, hash it
prepared.password = await hashPassword(prepared.password);
// If password login is not allowed, reject password updates
if (!passwordLoginAllowed) {
// Remove password from prepared settings to prevent update
delete prepared.password;
logger.warn("Password update rejected: password login is not allowed");
} else {
// If password is provided and allowed, hash it
prepared.password = await hashPassword(prepared.password);
}
} else {
// If password is empty/not provided, keep existing password
prepared.password = existingSettings.password;

View File

@@ -1,6 +1,7 @@
export interface Settings {
loginEnabled: boolean;
password?: string;
passwordLoginAllowed?: boolean;
defaultAutoPlay: boolean;
defaultAutoLoop: boolean;
maxConcurrentDownloads: number;

View File

@@ -1,13 +1,17 @@
import { Help, Settings, VideoLibrary } from '@mui/icons-material';
import { Help, Logout, Settings, VideoLibrary } from '@mui/icons-material';
import {
alpha,
Divider,
Fade,
Menu,
MenuItem,
useMediaQuery,
useTheme
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useLanguage } from '../../contexts/LanguageContext';
interface ManageMenuProps {
@@ -21,8 +25,33 @@ const ManageMenu: React.FC<ManageMenuProps> = ({
}) => {
const navigate = useNavigate();
const { t } = useLanguage();
const { logout } = useAuth();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const API_URL = import.meta.env.VITE_API_URL;
// Check if login is enabled
const { data: settingsData } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
try {
const response = await axios.get(`${API_URL}/settings`, { timeout: 5000 });
return response.data;
} catch (error) {
return null;
}
},
retry: 1,
retryDelay: 1000,
});
const loginEnabled = settingsData?.loginEnabled || false;
const handleLogout = () => {
onClose();
logout();
navigate('/');
};
return (
<Menu
@@ -68,6 +97,12 @@ const ManageMenu: React.FC<ManageMenuProps> = ({
<MenuItem onClick={() => { onClose(); navigate('/instruction'); }}>
<Help sx={{ mr: 2 }} /> {t('instruction')}
</MenuItem>
{loginEnabled && <Divider />}
{loginEnabled && (
<MenuItem onClick={handleLogout}>
<Logout sx={{ mr: 2 }} /> {t('logout')}
</MenuItem>
)}
</Menu>
);
};

View File

@@ -1,6 +1,9 @@
import { Settings, VideoLibrary } from '@mui/icons-material';
import { Box, Button, Collapse, Stack } from '@mui/material';
import { Link } from 'react-router-dom';
import { Logout, Settings, VideoLibrary } from '@mui/icons-material';
import { Box, Button, Collapse, Divider, Stack } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useLanguage } from '../../contexts/LanguageContext';
import { useVisitorMode } from '../../contexts/VisitorModeContext';
import { Collection, Video } from '../../types';
@@ -45,7 +48,33 @@ const MobileMenu: React.FC<MobileMenuProps> = ({
onTagToggle
}) => {
const { t } = useLanguage();
const { logout } = useAuth();
const { visitorMode } = useVisitorMode();
const navigate = useNavigate();
const API_URL = import.meta.env.VITE_API_URL;
// Check if login is enabled
const { data: settingsData } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
try {
const response = await axios.get(`${API_URL}/settings`, { timeout: 5000 });
return response.data;
} catch (error) {
return null;
}
},
retry: 1,
retryDelay: 1000,
});
const loginEnabled = settingsData?.loginEnabled || false;
const handleLogout = () => {
onClose();
logout();
navigate('/');
};
return (
<Collapse in={open} sx={{ width: '100%' }}>
@@ -91,6 +120,22 @@ const MobileMenu: React.FC<MobileMenuProps> = ({
</Button>
</Box>
{/* Logout Button */}
{loginEnabled && (
<>
<Divider />
<Button
variant="outlined"
color="error"
fullWidth
onClick={handleLogout}
startIcon={<Logout />}
>
{t('logout')}
</Button>
</>
)}
{/* Mobile Navigation Items */}
<Box sx={{ mt: 2 }}>
<Collections

View File

@@ -1,4 +1,4 @@
import { Box, Button, FormControlLabel, Switch, TextField } from '@mui/material';
import { Box, Button, FormControlLabel, Switch, TextField, Typography } from '@mui/material';
import { startRegistration } from '@simplewebauthn/browser';
import { useMutation, useQuery } from '@tanstack/react-query';
import axios from 'axios';
@@ -146,18 +146,36 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
{settings.loginEnabled && (
<Box sx={{ mt: 2, maxWidth: 400 }}>
<TextField
fullWidth
label={t('password')}
type="password"
value={settings.password || ''}
onChange={(e) => onChange('password', e.target.value)}
helperText={
settings.isPasswordSet
? t('passwordHelper')
: t('passwordSetHelper')
<FormControlLabel
control={
<Switch
checked={settings.passwordLoginAllowed !== false}
onChange={(e) => onChange('passwordLoginAllowed', e.target.checked)}
disabled={!settings.loginEnabled || !passkeysExist}
/>
}
label={t('allowPasswordLogin') || 'Allow Password Login'}
/>
<Box sx={{ mt: 1, mb: 2 }}>
<Typography variant="body2" color="text.secondary">
{t('allowPasswordLoginHelper') || 'When disabled, password login is not available. You must have at least one passkey to disable password login.'}
</Typography>
</Box>
{settings.passwordLoginAllowed !== false && (
<TextField
fullWidth
label={t('password')}
type="password"
value={settings.password || ''}
onChange={(e) => onChange('password', e.target.value)}
helperText={
settings.isPasswordSet
? t('passwordHelper')
: t('passwordSetHelper')
}
/>
)}
<Box sx={{ mt: 3 }}>
<Box sx={{ mb: 2 }}>

View File

@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthProvider } from '../../contexts/AuthContext';
import Header from '../Header';
// Mock contexts
@@ -92,11 +93,13 @@ describe('Header', () => {
});
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<BrowserRouter>
<Header {...defaultProps} {...props} />
</BrowserRouter>
</ThemeProvider>
<AuthProvider>
<ThemeProvider theme={theme}>
<BrowserRouter>
<Header {...defaultProps} {...props} />
</BrowserRouter>
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
);
};
@@ -124,14 +127,14 @@ describe('Header', () => {
// and fall back to the default name. We verify the component renders correctly either way.
const logo = screen.getByAltText('MyTube Logo');
expect(logo).toBeInTheDocument();
// Wait for the component to stabilize after async operations
await waitFor(() => {
// The title should be either "TestTube" (if settings succeeds) or "MyTube" (default)
const title = screen.queryByText('TestTube') || screen.queryByText('MyTube');
expect(title).toBeInTheDocument();
}, { timeout: 2000 });
// Logo should always be present
expect(logo).toBeInTheDocument();
});

View File

@@ -28,7 +28,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const response = await axios.get(`${API_URL}/settings`);
const { loginEnabled, isPasswordSet } = response.data;
// Login is required only if enabled AND a password is set
// Login is required if loginEnabled is true (regardless of password or passkey)
if (!loginEnabled || !isPasswordSet) {
setLoginRequired(false);
setIsAuthenticated(true);

View File

@@ -41,7 +41,7 @@ const LoginPage: React.FC = () => {
const { login } = useAuth();
const queryClient = useQueryClient();
// Fetch website name from settings
// Fetch website name and settings from settings
const { data: settingsData } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
@@ -56,6 +56,8 @@ const LoginPage: React.FC = () => {
retryDelay: 1000,
});
const passwordLoginAllowed = settingsData?.passwordLoginAllowed !== false;
// Update website name when settings are loaded
useEffect(() => {
if (settingsData && settingsData.websiteName) {
@@ -99,9 +101,9 @@ const LoginPage: React.FC = () => {
}
}, [statusData]);
// Auto-login if password is not enabled
// Auto-login only if login is not required
useEffect(() => {
if (statusData && statusData.enabled === false) {
if (statusData && statusData.loginRequired === false) {
login();
}
}, [statusData, login]);
@@ -350,62 +352,80 @@ const LoginPage: React.FC = () => {
</Typography>
</Box>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1, width: '100%' }}>
<TextField
margin="normal"
required
fullWidth
name="password"
label={t('password')}
type={showPassword ? 'text' : 'password'}
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
disabled={waitTime > 0 || loginMutation.isPending}
helperText={t('defaultPasswordHint') || "Default password: 123"}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t('togglePasswordVisibility')}
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loginMutation.isPending || waitTime > 0}
>
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : t('signIn')}
</Button>
{passkeysExist && (
{passwordLoginAllowed && (
<>
<Divider sx={{ my: 2 }}>OR</Divider>
<Button
<TextField
margin="normal"
required
fullWidth
variant="outlined"
startIcon={<Fingerprint />}
onClick={handlePasskeyLogin}
sx={{ mb: 2 }}
disabled={passkeyLoginMutation.isPending || waitTime > 0}
name="password"
label={t('password')}
type={showPassword ? 'text' : 'password'}
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
disabled={waitTime > 0 || loginMutation.isPending}
helperText={t('defaultPasswordHint') || "Default password: 123"}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t('togglePasswordVisibility')}
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loginMutation.isPending || waitTime > 0}
>
{passkeyLoginMutation.isPending
? (t('authenticating') || 'Authenticating...')
: (t('loginWithPasskey') || 'Login with Passkey')}
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : t('signIn')}
</Button>
{passkeysExist && (
<>
<Divider sx={{ my: 2 }}>OR</Divider>
<Button
fullWidth
variant="outlined"
startIcon={<Fingerprint />}
onClick={handlePasskeyLogin}
sx={{ mb: 2 }}
disabled={passkeyLoginMutation.isPending || waitTime > 0}
>
{passkeyLoginMutation.isPending
? (t('authenticating') || 'Authenticating...')
: (t('loginWithPasskey') || 'Login with Passkey')}
</Button>
</>
)}
</>
)}
{!passwordLoginAllowed && passkeysExist && (
<Button
fullWidth
variant="contained"
startIcon={<Fingerprint />}
onClick={handlePasskeyLogin}
sx={{ mt: 3, mb: 2 }}
disabled={passkeyLoginMutation.isPending || waitTime > 0}
>
{passkeyLoginMutation.isPending
? (t('authenticating') || 'Authenticating...')
: (t('loginWithPasskey') || 'Login with Passkey')}
</Button>
)}
<Button
fullWidth
variant="outlined"

View File

@@ -59,6 +59,7 @@ export interface Settings {
loginEnabled: boolean;
password?: string;
isPasswordSet?: boolean;
passwordLoginAllowed?: boolean;
defaultAutoPlay: boolean;
defaultAutoLoop: boolean;
maxConcurrentDownloads: number;

View File

@@ -52,6 +52,8 @@ export const ar = {
videoColumns: "أعمدة الفيديو (الصفحة الرئيسية)",
columnsCount: "{count} أعمدة",
enableLogin: "تفعيل حماية تسجيل الدخول",
allowPasswordLogin: "السماح بتسجيل الدخول بكلمة المرور",
allowPasswordLoginHelper: "عند التعطيل، لن يكون تسجيل الدخول بكلمة المرور متاحًا. يجب أن يكون لديك مفتاح وصول واحد على الأقل لتعطيل تسجيل الدخول بكلمة المرور.",
password: "كلمة المرور",
enterPassword: "أدخل كلمة المرور",
togglePasswordVisibility: "تبديل رؤية كلمة المرور",

View File

@@ -51,6 +51,8 @@ export const de = {
videoColumns: "Videospalten (Startseite)",
columnsCount: "{count} Spalten",
enableLogin: "Anmeldeschutz aktivieren",
allowPasswordLogin: "Passwort-Anmeldung zulassen",
allowPasswordLoginHelper: "Wenn deaktiviert, ist die Passwort-Anmeldung nicht verfügbar. Sie müssen mindestens einen Passkey haben, um die Passwort-Anmeldung zu deaktivieren.",
password: "Passwort",
enterPassword: "Passwort eingeben",
togglePasswordVisibility: "Passwort sichtbar machen",

View File

@@ -52,6 +52,8 @@ export const en = {
videoColumns: "Video Columns (Homepage)",
columnsCount: "{count} Columns",
enableLogin: "Enable Login Protection",
allowPasswordLogin: "Allow Password Login",
allowPasswordLoginHelper: "When disabled, password login is not available. You must have at least one passkey to disable password login.",
password: "Password",
enterPassword: "Enter Password",
togglePasswordVisibility: "Toggle password visibility",

View File

@@ -62,6 +62,8 @@ export const es = {
videoColumns: "Columnas de video (Página de inicio)",
columnsCount: "{count} Columnas",
enableLogin: "Habilitar Protección de Inicio de Sesión",
allowPasswordLogin: "Permitir Inicio de Sesión con Contraseña",
allowPasswordLoginHelper: "Cuando está deshabilitado, el inicio de sesión con contraseña no está disponible. Debe tener al menos una clave de acceso para deshabilitar el inicio de sesión con contraseña.",
password: "Contraseña",
enterPassword: "Introducir contraseña",
togglePasswordVisibility: "Alternar visibilidad de contraseña",

View File

@@ -55,6 +55,8 @@ export const fr = {
videoColumns: "Colonnes vidéo (Accueil)",
columnsCount: "{count} Colonnes",
enableLogin: "Activer la protection par connexion",
allowPasswordLogin: "Autoriser la connexion par mot de passe",
allowPasswordLoginHelper: "Lorsqu'elle est désactivée, la connexion par mot de passe n'est pas disponible. Vous devez avoir au moins une clé d'accès pour désactiver la connexion par mot de passe.",
password: "Mot de passe",
enterPassword: "Entrez le mot de passe",
togglePasswordVisibility: "Afficher/Masquer le mot de passe",

View File

@@ -54,6 +54,8 @@ export const ja = {
videoColumns: "ビデオ列数 (ホームページ)",
columnsCount: "{count} 列",
enableLogin: "ログイン保護を有効にする",
allowPasswordLogin: "パスワードログインを許可",
allowPasswordLoginHelper: "無効にすると、パスワードログインは利用できません。パスワードログインを無効にするには、少なくとも1つのパスキーが必要です。",
password: "パスワード",
enterPassword: "パスワードを入力",
togglePasswordVisibility: "パスワードの表示切り替え",

View File

@@ -54,6 +54,8 @@ export const ko = {
videoColumns: "비디오 열 (홈페이지)",
columnsCount: "{count} 열",
enableLogin: "로그인 보호 활성화",
allowPasswordLogin: "비밀번호 로그인 허용",
allowPasswordLoginHelper: "비활성화되면 비밀번호 로그인을 사용할 수 없습니다. 비밀번호 로그인을 비활성화하려면 최소한 하나의 패스키가 있어야 합니다.",
password: "비밀번호",
enterPassword: "비밀번호 입력",
togglePasswordVisibility: "비밀번호 표시 전환",

View File

@@ -55,6 +55,8 @@ export const pt = {
videoColumns: "Colunas de vídeo (Página inicial)",
columnsCount: "{count} Colunas",
enableLogin: "Ativar Proteção de Login",
allowPasswordLogin: "Permitir Login com Senha",
allowPasswordLoginHelper: "Quando desabilitado, o login com senha não está disponível. Você deve ter pelo menos uma chave de acesso para desabilitar o login com senha.",
password: "Senha",
enterPassword: "Digite a senha",
togglePasswordVisibility: "Alternar visibilidade da senha",

View File

@@ -64,6 +64,8 @@ export const ru = {
videoColumns: "Колонки видео (Главная страница)",
columnsCount: "{count} колонок",
enableLogin: "Включить защиту входа",
allowPasswordLogin: "Разрешить вход по паролю",
allowPasswordLoginHelper: "При отключении вход по паролю недоступен. Для отключения входа по паролю необходимо иметь хотя бы один ключ доступа.",
password: "Пароль",
enterPassword: "Введите пароль",
togglePasswordVisibility: "Показать/скрыть пароль",

View File

@@ -52,6 +52,8 @@ export const zh = {
videoColumns: "视频列数 (主页)",
columnsCount: "{count} 列",
enableLogin: "启用登录保护",
allowPasswordLogin: "允许密码登录",
allowPasswordLoginHelper: "禁用后,密码登录将不可用。要禁用密码登录,您必须至少有一个通行密钥。",
password: "密码",
enterPassword: "输入密码",
togglePasswordVisibility: "切换密码可见性",