feat: Add password login permission handling
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface Settings {
|
||||
loginEnabled: boolean;
|
||||
password?: string;
|
||||
passwordLoginAllowed?: boolean;
|
||||
defaultAutoPlay: boolean;
|
||||
defaultAutoLoop: boolean;
|
||||
maxConcurrentDownloads: number;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface Settings {
|
||||
loginEnabled: boolean;
|
||||
password?: string;
|
||||
isPasswordSet?: boolean;
|
||||
passwordLoginAllowed?: boolean;
|
||||
defaultAutoPlay: boolean;
|
||||
defaultAutoLoop: boolean;
|
||||
maxConcurrentDownloads: number;
|
||||
|
||||
@@ -52,6 +52,8 @@ export const ar = {
|
||||
videoColumns: "أعمدة الفيديو (الصفحة الرئيسية)",
|
||||
columnsCount: "{count} أعمدة",
|
||||
enableLogin: "تفعيل حماية تسجيل الدخول",
|
||||
allowPasswordLogin: "السماح بتسجيل الدخول بكلمة المرور",
|
||||
allowPasswordLoginHelper: "عند التعطيل، لن يكون تسجيل الدخول بكلمة المرور متاحًا. يجب أن يكون لديك مفتاح وصول واحد على الأقل لتعطيل تسجيل الدخول بكلمة المرور.",
|
||||
password: "كلمة المرور",
|
||||
enterPassword: "أدخل كلمة المرور",
|
||||
togglePasswordVisibility: "تبديل رؤية كلمة المرور",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -54,6 +54,8 @@ export const ja = {
|
||||
videoColumns: "ビデオ列数 (ホームページ)",
|
||||
columnsCount: "{count} 列",
|
||||
enableLogin: "ログイン保護を有効にする",
|
||||
allowPasswordLogin: "パスワードログインを許可",
|
||||
allowPasswordLoginHelper: "無効にすると、パスワードログインは利用できません。パスワードログインを無効にするには、少なくとも1つのパスキーが必要です。",
|
||||
password: "パスワード",
|
||||
enterPassword: "パスワードを入力",
|
||||
togglePasswordVisibility: "パスワードの表示切り替え",
|
||||
|
||||
@@ -54,6 +54,8 @@ export const ko = {
|
||||
videoColumns: "비디오 열 (홈페이지)",
|
||||
columnsCount: "{count} 열",
|
||||
enableLogin: "로그인 보호 활성화",
|
||||
allowPasswordLogin: "비밀번호 로그인 허용",
|
||||
allowPasswordLoginHelper: "비활성화되면 비밀번호 로그인을 사용할 수 없습니다. 비밀번호 로그인을 비활성화하려면 최소한 하나의 패스키가 있어야 합니다.",
|
||||
password: "비밀번호",
|
||||
enterPassword: "비밀번호 입력",
|
||||
togglePasswordVisibility: "비밀번호 표시 전환",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -64,6 +64,8 @@ export const ru = {
|
||||
videoColumns: "Колонки видео (Главная страница)",
|
||||
columnsCount: "{count} колонок",
|
||||
enableLogin: "Включить защиту входа",
|
||||
allowPasswordLogin: "Разрешить вход по паролю",
|
||||
allowPasswordLoginHelper: "При отключении вход по паролю недоступен. Для отключения входа по паролю необходимо иметь хотя бы один ключ доступа.",
|
||||
password: "Пароль",
|
||||
enterPassword: "Введите пароль",
|
||||
togglePasswordVisibility: "Показать/скрыть пароль",
|
||||
|
||||
@@ -52,6 +52,8 @@ export const zh = {
|
||||
videoColumns: "视频列数 (主页)",
|
||||
columnsCount: "{count} 列",
|
||||
enableLogin: "启用登录保护",
|
||||
allowPasswordLogin: "允许密码登录",
|
||||
allowPasswordLoginHelper: "禁用后,密码登录将不可用。要禁用密码登录,您必须至少有一个通行密钥。",
|
||||
password: "密码",
|
||||
enterPassword: "输入密码",
|
||||
togglePasswordVisibility: "切换密码可见性",
|
||||
|
||||
Reference in New Issue
Block a user